mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-11 17:32:22 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6803a891a | ||
|
|
13fe8d5999 |
No files matched your search
+88
-114
@@ -1,31 +1,15 @@
|
||||
name: Tests
|
||||
|
||||
# Builds, type-checks and runs every test suite on a clean GitHub runner, and records the file
|
||||
# list each published package would contain. Each suite is its own job so one failure does not
|
||||
# hide the others. No provider credentials or operator configuration are supplied.
|
||||
# Package suites deny external networking; scenario runtime has no external route.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
on: workflow_dispatch
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: tests-${{ github.event_name }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-and-package:
|
||||
baseline-command-names:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
timeout-minutes: 12
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: edd0a967e67af10b5513439cd14f87eeeabd179f
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
@@ -33,110 +17,100 @@ jobs:
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run build
|
||||
- name: Record the published file list of each package
|
||||
- name: Measure the old command and absent new command at the selected base
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p pack-lists
|
||||
for p in cli daemon tui; do
|
||||
(cd packages/$p && npm pack --dry-run --json --ignore-scripts) \
|
||||
| node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{for(const f of JSON.parse(s)[0].files)console.log(f.path)})' \
|
||||
| sort > pack-lists/$p.txt
|
||||
echo "$p: $(wc -l < pack-lists/$p.txt) files"
|
||||
done
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: pack-lists
|
||||
path: pack-lists/
|
||||
|
||||
typecheck:
|
||||
node packages/cli/dist/bin-wrapper.js --help > /tmp/baseline-help.txt
|
||||
node packages/cli/dist/bin-wrapper.js agent-app list --help > /tmp/baseline-list-help.txt
|
||||
grep -F 'agent-app' /tmp/baseline-help.txt
|
||||
! grep -F 'community-harness' /tmp/baseline-help.txt
|
||||
set +e
|
||||
node packages/cli/dist/bin-wrapper.js community-harness list --help > /tmp/new-before.txt 2>&1
|
||||
result=$?
|
||||
set -e
|
||||
test "$result" != 0
|
||||
cat /tmp/baseline-list-help.txt /tmp/new-before.txt
|
||||
candidate-behavior-and-package:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run lint
|
||||
|
||||
repo-checks:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run test:repo
|
||||
|
||||
package-tests:
|
||||
if: ${{ github.event_name != 'push' }}
|
||||
# macOS, the platform OpenRig runs on: several daemon and cli tests read macOS ps columns,
|
||||
# file-inode behaviour and release tags.
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 45
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
package: [daemon, cli, tui, ui]
|
||||
timeout-minutes: 22
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: 13fe8d599954cb8542295c353f51074477fb07c0
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: brew install tmux
|
||||
- run: sudo apt-get update -qq && sudo apt-get install -y tmux
|
||||
- run: npm ci
|
||||
- name: Build the packages the tests import
|
||||
run: npm run build
|
||||
- name: Run the suite without ambient credentials or external network
|
||||
- name: Build and assemble only this test candidate
|
||||
run: npm run build:package
|
||||
- name: Typecheck all packages
|
||||
run: npm run lint
|
||||
- name: Focused renamed consumers, built-ins and migration preservation
|
||||
run: npx vitest run packages/daemon/test/community-harness-runtime.test.ts packages/daemon/test/restore-orchestrator.test.ts packages/daemon/test/seat-set-cwd.test.ts packages/daemon/test/migration-fixture-parity.test.ts packages/cli/test/lazy-program.test.ts packages/cli/test/ps.test.ts packages/tui/test/hydrate.test.ts
|
||||
- name: Real private daemon, HTTP, CLI, SQLite, tmux launch and delivery
|
||||
run: npx vitest run packages/daemon/test/community-harness-runtime.integration.test.ts
|
||||
- name: Package and source name inventory with enumerated storage exceptions
|
||||
run: |
|
||||
suite_root=$(mktemp -d "$RUNNER_TEMP/opr-test.XXXXXX")
|
||||
suite_root=$(cd "$suite_root" && pwd -P)
|
||||
mkdir -p "$suite_root/home" "$suite_root/bin"
|
||||
# Same system bytes, nonprivileged mode; no system file is changed.
|
||||
/usr/bin/install -m 0755 /bin/ps "$suite_root/bin/ps"
|
||||
# Only this job's temporary sockets (including tmux) are reachable.
|
||||
env -i HOME="$suite_root/home" PATH="$PATH" TMPDIR="$suite_root" TMUX_TMPDIR="$suite_root" \
|
||||
CI=true TERM=xterm-256color \
|
||||
sandbox-exec -D IPC_ROOT="$suite_root" -p '(version 1)(allow default)(deny network-outbound)(allow network-outbound (remote ip "localhost:*"))(allow network-outbound (subpath (param "IPC_ROOT")))' \
|
||||
/bin/bash -s "${{ matrix.package }}" <<'SUITE'
|
||||
set -euo pipefail
|
||||
node scripts/ci-macos-process-probe.mjs
|
||||
# The production readers use bare ps. Select real system bytes only after
|
||||
# self/owned-child observation and network/IPC confinement are proved.
|
||||
export PATH="$TMPDIR/bin:$PATH"
|
||||
test "$(command -v ps)" = "$TMPDIR/bin/ps"
|
||||
exec npm run test -w "packages/$1"
|
||||
SUITE
|
||||
|
||||
installed-scenario:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 35
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- name: Installed package, daemon restart, and seeded durability failure
|
||||
run: bash scripts/run-pr-scenarios.sh
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: installed-scenario-evidence
|
||||
path: dist/pr-scenarios/
|
||||
if-no-files-found: error
|
||||
npm pack --workspace packages/cli --ignore-scripts --pack-destination packages/cli
|
||||
python3 - <<'PYCODE'
|
||||
import pathlib,re,subprocess,json,tarfile
|
||||
pattern=re.compile(r"agent[-_ ]apps?(?:\b|_)|agentApp\w*|AgentApp\w*|AGENT_APP\w*", re.I)
|
||||
root=pathlib.Path('.')
|
||||
source_allow={
|
||||
'packages/daemon/src/db/all-migrations.ts':['import { sessionAgentAppSchema as sessionCommunityHarnessSchema } from "./migrations/114_session_agent_app.js";'],
|
||||
'packages/daemon/test/helpers/test-app.ts':['import { sessionAgentAppSchema as sessionCommunityHarnessSchema } from "../../src/db/migrations/114_session_agent_app.js";'],
|
||||
'packages/daemon/src/domain/session-registry.ts':[
|
||||
'this.db.prepare("UPDATE sessions SET agent_app = ? WHERE id = ?").run(app, sessionId);',
|
||||
'...(row.agent_app ? { communityHarness: row.agent_app } : {}),',
|
||||
'agent_app?: string | null;',
|
||||
'has = (db.prepare("PRAGMA table_info(sessions)").all() as Array<{ name?: string }>).some(row => row.name === "agent_app");',
|
||||
'return has ? `${alias}.agent_app` : "NULL";'],
|
||||
'packages/daemon/test/community-harness-runtime.test.ts':[
|
||||
'CREATE TABLE sessions(id INTEGER, node_id TEXT, session_name TEXT, status TEXT, agent_app TEXT);',
|
||||
'db.prepare("INSERT INTO sessions (id, node_id, session_name, status, startup_status, agent_app) VALUES (\'s1\', \'n1\', \'w@r\', \'running\', \'ready\', ?)\").run(communityHarness);']
|
||||
}
|
||||
# SQL fixture strings above are checked as exact source lines below, using the actual accepted line from source.
|
||||
f='packages/daemon/test/community-harness-runtime.test.ts'
|
||||
source_allow[f]=[line.strip() for line in (root/f).read_text().splitlines() if pattern.search(line) and ('CREATE TABLE sessions(' in line or 'INSERT INTO sessions (' in line)]
|
||||
legacy='packages/daemon/src/db/migrations/114_session_agent_app.ts'
|
||||
assert (root/legacy).read_bytes()==subprocess.check_output(['git','show','edd0a967e67af10b5513439cd14f87eeeabd179f:'+legacy])
|
||||
violations=[];allowed=[]
|
||||
for path in subprocess.check_output(['git','ls-files','-z']).decode().split('\0'):
|
||||
if not path:continue
|
||||
if pattern.search(path) and path!=legacy:violations.append([path,'path'])
|
||||
data=(root/path).read_bytes()
|
||||
try: text=data.decode()
|
||||
except UnicodeDecodeError:continue
|
||||
for n,line in enumerate(text.splitlines(),1):
|
||||
if not pattern.search(line):continue
|
||||
if path==legacy or line.strip() in source_allow.get(path,[]):allowed.append([path,n,line.strip()])
|
||||
else:violations.append([path,n,line])
|
||||
print('SOURCE_NAME_SWEEP='+json.dumps({'allowed':allowed,'violations':violations}))
|
||||
assert not violations
|
||||
package_allow={
|
||||
'package/daemon/dist/db/all-migrations.js':lambda line:'sessionAgentAppSchema as sessionCommunityHarnessSchema' in line and '114_session_agent_app.js' in line,
|
||||
'package/daemon/dist/domain/session-registry.js':lambda line:any(k in line for k in ['UPDATE sessions SET agent_app = ?', 'row.agent_app ? { communityHarness: row.agent_app }', 'row.name === "agent_app"', '`${alias}.agent_app`'])
|
||||
}
|
||||
archive=next(pathlib.Path('packages/cli').glob('*.tgz'))
|
||||
violations=[];allowed=[];names=[]
|
||||
with tarfile.open(archive) as tar:
|
||||
for member in tar.getmembers():
|
||||
names.append(member.name)
|
||||
immutable=member.name.startswith('package/daemon/dist/db/migrations/114_session_agent_app.')
|
||||
if pattern.search(member.name) and not immutable:violations.append([member.name,'path'])
|
||||
if not member.isfile():continue
|
||||
try:text=tar.extractfile(member).read().decode()
|
||||
except UnicodeDecodeError:continue
|
||||
for n,line in enumerate(text.splitlines(),1):
|
||||
if not pattern.search(line):continue
|
||||
if immutable or package_allow.get(member.name,lambda line:False)(line):allowed.append([member.name,n,line.strip()])
|
||||
else:violations.append([member.name,n,line])
|
||||
assert 'package/dist/commands/community-harness.js' in names
|
||||
assert 'package/daemon/dist/domain/community-harness-presets.js' in names
|
||||
assert 'package/daemon/dist/routes/community-harnesses.js' in names
|
||||
print('PACKAGE_NAME_SWEEP='+json.dumps({'members':len(names),'allowed':allowed,'violations':violations}))
|
||||
assert not violations
|
||||
PYCODE
|
||||
@@ -29,7 +29,7 @@ What a seat runs has its own words:
|
||||
| --- | --- | --- |
|
||||
| Runtime | What a seat runs: a harness, a terminal, or the stub (a scripted stand-in used in tests). It's the rig spec's `runtime` field and the TUI's runtime column | `claude-code`, `codex`, `terminal` |
|
||||
| Harness | The agent software around a model | Claude Code, Codex, Pi |
|
||||
| Community harness | A harness an outside contributor maintains. It's installed as a preset under `$OPENRIG_HOME/agent-apps/`, listed by `rig agent-app list`, and OpenRig doesn't test it | A preset's name in a member's `runtime` |
|
||||
| Community harness | A harness an outside contributor maintains. It's installed as a preset under `$OPENRIG_HOME/community-harnesses/`, listed by `rig community-harness list`, and OpenRig doesn't test it | A preset's name in a member's `runtime` |
|
||||
| Terminal | A seat with no agent: a shell for infrastructure processes | `runtime: terminal` |
|
||||
|
||||
## How the software teams grow
|
||||
|
||||
+8
-8
@@ -1,12 +1,12 @@
|
||||
// `rig agent-app list`: the community agent apps installed as presets under $OPENRIG_HOME/agent-apps, each with its
|
||||
// maintainer and declared limits. OpenRig hasn't tested these apps.
|
||||
// `rig community-harness list`: the community harnesses installed as presets under $OPENRIG_HOME/community-harnesses, each with its
|
||||
// maintainer and declared limits. OpenRig hasn't tested these harnesses.
|
||||
import { Command } from "commander";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl, statusGuardMessage } from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
|
||||
interface AgentAppWire {
|
||||
interface CommunityHarnessWire {
|
||||
name: string;
|
||||
usable: boolean;
|
||||
maintainer?: string;
|
||||
@@ -15,8 +15,8 @@ interface AgentAppWire {
|
||||
problem?: string;
|
||||
}
|
||||
|
||||
export function agentAppCommand(depsOverride?: StatusDeps): Command {
|
||||
const cmd = new Command("agent-app").description("Community harnesses installed as presets (not tested by OpenRig)");
|
||||
export function communityHarnessCommand(depsOverride?: StatusDeps): Command {
|
||||
const cmd = new Command("community-harness").description("Community harnesses installed as presets (not tested by OpenRig)");
|
||||
const getDeps = (): StatusDeps => depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
|
||||
|
||||
cmd.command("list")
|
||||
@@ -29,14 +29,14 @@ export function agentAppCommand(depsOverride?: StatusDeps): Command {
|
||||
if (status.state !== "running" || status.healthy === false) {
|
||||
const gm = statusGuardMessage(status); throw new Error(`${gm.fact} ${gm.action}`);
|
||||
}
|
||||
const res = await deps.clientFactory(getDaemonUrl(status)).get<{ dir: string; apps: AgentAppWire[] }>("/api/agent-apps");
|
||||
const res = await deps.clientFactory(getDaemonUrl(status)).get<{ dir: string; harnesses: CommunityHarnessWire[] }>("/api/community-harnesses");
|
||||
if (res.status !== 200 || !res.data) throw new Error(`Daemon returned HTTP ${res.status}`);
|
||||
if (opts.json) { console.log(JSON.stringify(res.data, null, 2)); return; }
|
||||
if (res.data.apps.length === 0) {
|
||||
if (res.data.harnesses.length === 0) {
|
||||
console.log(`No community harness presets installed. Add one as ${res.data.dir}/<name>.yaml.`);
|
||||
return;
|
||||
}
|
||||
for (const app of res.data.apps) {
|
||||
for (const app of res.data.harnesses) {
|
||||
if (!app.usable) { console.log(`${app.name}: can't be used. ${app.problem}`); continue; }
|
||||
const limits = app.limits?.length ? ` Declared limits: ${app.limits.join("; ")}.` : "";
|
||||
console.log(`${app.name}: community harness, maintained by ${app.maintainer}, not tested by OpenRig.${limits}`);
|
||||
@@ -79,8 +79,8 @@ interface NodeEntry {
|
||||
decidedBy: string | null;
|
||||
} | null;
|
||||
identityVerdict?: { reason?: string | null } | null;
|
||||
/** A community agent app's seat: its label, maintainer and declared limits. */
|
||||
agentApp?: { app: string; maintainer: string | null; limits: string[]; identity: string; needsInput: string; presetError?: string };
|
||||
/** A community harness's seat: its label, maintainer and declared limits. */
|
||||
communityHarness?: { harness: string; maintainer: string | null; limits: string[]; identity: string; needsInput: string; presetError?: string };
|
||||
/** Slice 15 — `has-work-to-do` primitive. Derived from queue_items;
|
||||
* NEVER derived from terminalActive. */
|
||||
hasAssignedWork?: boolean;
|
||||
@@ -207,7 +207,7 @@ const ALLOWED_NODE_FIELDS = new Set([
|
||||
"canonicalSessionName",
|
||||
"nodeKind",
|
||||
"runtime",
|
||||
"agentApp",
|
||||
"communityHarness",
|
||||
"model",
|
||||
"modelObservation",
|
||||
"sessionStatus",
|
||||
@@ -623,11 +623,11 @@ function hasCurrentUsageLimit(node: NodeEntry): boolean {
|
||||
&& activity.stale === false && !activity.fallback;
|
||||
}
|
||||
|
||||
/** One line per community agent app seat: what OpenRig knows about it, and what it doesn't. */
|
||||
export function formatAgentAppLine(seat: string, app: NonNullable<NodeEntry["agentApp"]>): string {
|
||||
if (app.presetError) return `Community harness (${seat}): ${app.app}: ${app.presetError}.`;
|
||||
/** One line per community harness seat: what OpenRig knows about it, and what it doesn't. */
|
||||
export function formatCommunityHarnessLine(seat: string, app: NonNullable<NodeEntry["communityHarness"]>): string {
|
||||
if (app.presetError) return `Community harness (${seat}): ${app.harness}: ${app.presetError}.`;
|
||||
const limits = app.limits.length ? `; declared limits: ${app.limits.join("; ")}` : "";
|
||||
return `Community harness (${seat}): ${app.app}, maintained by ${app.maintainer}, not tested by OpenRig. Identity ${app.identity}; needs-input ${app.needsInput}${limits}.`;
|
||||
return `Community harness (${seat}): ${app.harness}, maintained by ${app.maintainer}, not tested by OpenRig. Identity ${app.identity}; needs-input ${app.needsInput}${limits}.`;
|
||||
}
|
||||
|
||||
function missingSessionGuidance(node: NodeEntry): string | null {
|
||||
@@ -675,7 +675,7 @@ export function compactNodeProjection(nodes: NodeEntry[]): Array<Record<string,
|
||||
// runtime is complete fleet-wide; model is a DECLARATION, absent on 13 of
|
||||
// 15 claude-code seats — see formatDeclaredModel for the render rule.
|
||||
runtime: n.runtime ?? null,
|
||||
...(n.agentApp ? { agentApp: n.agentApp } : {}),
|
||||
...(n.communityHarness ? { communityHarness: n.communityHarness } : {}),
|
||||
model: n.model ?? null,
|
||||
// Compact tuple: [literal model, evidence source, native record time].
|
||||
// Full JSON / --fields retain the complete labelled object and occupant generation.
|
||||
@@ -862,7 +862,7 @@ contextUsage.percent, contextUsage.state (with --nodes). Other keys are rejected
|
||||
activeCount, hasWorkCount, attentionCount, status, lifecycleState, uptime,
|
||||
latestSnapshot.
|
||||
--fields accepts (node-level, with --nodes): rigId, rigName, logicalId, podId,
|
||||
podNamespace, hostSelfId, canonicalSessionName, nodeKind, runtime, agentApp, model, sessionStatus,
|
||||
podNamespace, hostSelfId, canonicalSessionName, nodeKind, runtime, communityHarness, model, sessionStatus,
|
||||
intentionallyStopped, startupStatus, restoreOutcome, lifecycleState, tmuxAttachCommand,
|
||||
resumeCommand, latestError, terminalActive, hasAssignedWork,
|
||||
assignedWorkCount, pendingWorkCount, inProgressWorkCount, blockedWorkCount,
|
||||
@@ -1312,7 +1312,7 @@ async function handleNodes(
|
||||
}
|
||||
}
|
||||
for (const n of humanList as NodeEntry[]) {
|
||||
if (n.agentApp) console.log(formatAgentAppLine(n.canonicalSessionName ?? n.logicalId, n.agentApp));
|
||||
if (n.communityHarness) console.log(formatCommunityHarnessLine(n.canonicalSessionName ?? n.logicalId, n.communityHarness));
|
||||
}
|
||||
if (humanTruncated) {
|
||||
const remaining = filtered.length - HUMAN_NODE_BUDGET;
|
||||
@@ -1324,15 +1324,15 @@ async function handleNodes(
|
||||
}
|
||||
}
|
||||
|
||||
function formatActivity(n: Pick<NodeEntry, "agentActivity" | "activityState" | "agentApp">): string {
|
||||
function formatActivity(n: Pick<NodeEntry, "agentActivity" | "activityState" | "communityHarness">): string {
|
||||
// S19 — render from the served taxonomy state when present: needs-input as
|
||||
// count(+reason), otherwise the bridge's display value. No local arbitration.
|
||||
const tax = n.activityState;
|
||||
if (tax) {
|
||||
if (tax.needsInput.count > 0) return `needs-input x${tax.needsInput.count}`;
|
||||
// A community agent app has no needs-input source in that state, so its zero isn't a clearance: a prompt the
|
||||
// A community harness has no needs-input source in that state, so its zero isn't a clearance: a prompt the
|
||||
// screen shows is kept.
|
||||
if (n.agentApp && n.agentActivity?.state === "needs_input") return "needs-input (screen)";
|
||||
if (n.communityHarness && n.agentActivity?.state === "needs_input") return "needs-input (screen)";
|
||||
return tax.display;
|
||||
}
|
||||
// Legacy fallback (a daemon without the S19 enrichment) — retires with the
|
||||
|
||||
@@ -70,7 +70,7 @@ import { contextCommand } from "./commands/context.js";
|
||||
import { pluginCommand } from "./commands/plugin.js";
|
||||
import { skillCommand } from "./commands/skill.js";
|
||||
import { agentImageCommand } from "./commands/agent-image.js";
|
||||
import { agentAppCommand } from "./commands/agent-app.js";
|
||||
import { communityHarnessCommand } from "./commands/community-harness.js";
|
||||
import { forkCommand } from "./commands/fork.js";
|
||||
import { workspaceCommand, type WorkspaceDeps } from "./commands/workspace.js";
|
||||
import { whoamiCommand } from "./commands/whoami.js";
|
||||
@@ -139,7 +139,7 @@ export interface ProgramDeps {
|
||||
pluginDeps?: StatusDeps;
|
||||
skillDeps?: StatusDeps;
|
||||
agentImageDeps?: StatusDeps;
|
||||
agentAppDeps?: StatusDeps;
|
||||
communityHarnessDeps?: StatusDeps;
|
||||
forkDeps?: StatusDeps;
|
||||
workspaceDeps?: WorkspaceDeps;
|
||||
whoamiDeps?: StatusDeps;
|
||||
@@ -221,7 +221,7 @@ export function createProgram(depsOverride?: ProgramDeps): Command {
|
||||
program.addCommand(pluginCommand(depsOverride?.pluginDeps));
|
||||
program.addCommand(skillCommand(depsOverride?.skillDeps));
|
||||
program.addCommand(agentImageCommand(depsOverride?.agentImageDeps));
|
||||
program.addCommand(agentAppCommand(depsOverride?.agentAppDeps));
|
||||
program.addCommand(communityHarnessCommand(depsOverride?.communityHarnessDeps));
|
||||
program.addCommand(forkCommand(depsOverride?.forkDeps));
|
||||
program.addCommand(workspaceCommand(depsOverride?.workspaceDeps));
|
||||
program.addCommand(rigModeCommand(depsOverride?.rigModeDeps));
|
||||
|
||||
@@ -63,7 +63,7 @@ export const COMMAND_LOADERS: ReadonlyMap<string, Load> = new Map<string, Load>(
|
||||
["plugin", async () => (await import("./commands/plugin.js")).pluginCommand()],
|
||||
["skill", async () => (await import("./commands/skill.js")).skillCommand()],
|
||||
["agent-image", async () => (await import("./commands/agent-image.js")).agentImageCommand()],
|
||||
["agent-app", async () => (await import("./commands/agent-app.js")).agentAppCommand()],
|
||||
["community-harness", async () => (await import("./commands/community-harness.js")).communityHarnessCommand()],
|
||||
["fork", async () => (await import("./commands/fork.js")).forkCommand()],
|
||||
["workspace", async () => (await import("./commands/workspace.js")).workspaceCommand()],
|
||||
["mode", async () => (await import("./commands/rig-mode.js")).rigModeCommand()],
|
||||
|
||||
@@ -5,6 +5,18 @@ import { COMMAND_LOADERS, createLazyProgram } from "../src/lazy-program.js";
|
||||
describe("lazy-program", () => {
|
||||
const full = createProgram();
|
||||
|
||||
it("exposes the community-harness command in eager and lazy help without an old-name alias", async () => {
|
||||
const oldName = ["agent", "app"].join("-");
|
||||
expect(COMMAND_LOADERS.has("community-harness")).toBe(true);
|
||||
expect(COMMAND_LOADERS.has(oldName)).toBe(false);
|
||||
expect(full.helpInformation()).toContain("community-harness");
|
||||
expect(full.helpInformation()).not.toContain(oldName);
|
||||
const lazy = await createLazyProgram("community-harness");
|
||||
expect(lazy?.commands[0]?.commands.map(command => command.name())).toEqual(["list"]);
|
||||
expect(lazy?.commands[0]?.helpInformation()).toContain("Community harnesses");
|
||||
expect(full.commands.map(command => command.name()).filter(name => name === "community-harness")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("names every top-level command of createProgram, in its order", () => {
|
||||
expect([...COMMAND_LOADERS.keys()]).toEqual(full.commands.map((c) => c.name()));
|
||||
});
|
||||
|
||||
+25
-25
@@ -13,23 +13,23 @@ import type { ProjectionPlan } from "../domain/projection-planner.js";
|
||||
import type { TmuxAdapter } from "./tmux.js";
|
||||
import type { SeatLaunchEnvironment } from "../domain/seat-launch-environment.js";
|
||||
import { isShellForeground } from "../domain/shell-classifier.js";
|
||||
import { isAgentAppRuntime, loadAgentAppPreset, agentAppsDir } from "../domain/agent-app-presets.js";
|
||||
import { agentAppOf } from "../domain/built-in-runtimes.js";
|
||||
import { isCommunityHarnessRuntime, loadCommunityHarnessPreset, communityHarnessesDir } from "../domain/community-harness-presets.js";
|
||||
import { communityHarnessOf } from "../domain/built-in-runtimes.js";
|
||||
|
||||
interface AgentAppAdapterDeps {
|
||||
interface CommunityHarnessAdapterDeps {
|
||||
tmux: Pick<TmuxAdapter, "hasSession" | "getPaneCommand" | "sendShellCommand">;
|
||||
seatLaunchEnvironment?: Pick<SeatLaunchEnvironment, "command">;
|
||||
presetsDir?: () => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A community agent app, launched from its installed preset (`domain/agent-app-presets.ts`). It types the preset's
|
||||
* A community harness, launched from its installed preset (`domain/community-harness-presets.ts`). It types the preset's
|
||||
* command into the seat's pane and is ready while the pane runs something other than a shell. That's readiness, not
|
||||
* identity: a process alone doesn't prove which session it is. It projects nothing, and refuses resume and fork,
|
||||
* which a launch-only preset doesn't support.
|
||||
*/
|
||||
export class AgentAppRuntimeAdapter implements RuntimeAdapter {
|
||||
constructor(readonly runtime: string, private readonly deps: AgentAppAdapterDeps) {}
|
||||
export class CommunityHarnessRuntimeAdapter implements RuntimeAdapter {
|
||||
constructor(readonly runtime: string, private readonly deps: CommunityHarnessAdapterDeps) {}
|
||||
|
||||
async listInstalled(_binding: NodeBinding): Promise<InstalledResource[]> {
|
||||
return [];
|
||||
@@ -57,7 +57,7 @@ export class AgentAppRuntimeAdapter implements RuntimeAdapter {
|
||||
if (opts.resumeToken) {
|
||||
return { ok: false, error: `Resume is unsupported: community harness "${this.runtime}" is launch-only. No session was started.` };
|
||||
}
|
||||
const found = loadAgentAppPreset(this.runtime, this.deps.presetsDir?.() ?? agentAppsDir());
|
||||
const found = loadCommunityHarnessPreset(this.runtime, this.deps.presetsDir?.() ?? communityHarnessesDir());
|
||||
if (!found.ok) return { ok: false, error: found.message };
|
||||
const session = binding.tmuxSession ?? opts.name;
|
||||
const command = this.deps.seatLaunchEnvironment
|
||||
@@ -75,50 +75,50 @@ export class AgentAppRuntimeAdapter implements RuntimeAdapter {
|
||||
}
|
||||
const command = await this.deps.tmux.getPaneCommand(session);
|
||||
if (!command || isShellForeground(command)) {
|
||||
return { ready: false, reason: `Community harness "${this.runtime}" isn't running: the pane is at a shell.`, code: "app_not_running" };
|
||||
return { ready: false, reason: `Community harness "${this.runtime}" isn't running: the pane is at a shell.`, code: "harness_not_running" };
|
||||
}
|
||||
return { ready: true };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The runtime adapter map, extended with community agent apps: a runtime name that isn't built in resolves to an
|
||||
* AgentAppRuntimeAdapter while its preset is installed. The built-in entries are returned as they are, and the map's
|
||||
* The runtime adapter map, extended with community harnesses: a runtime name that isn't built in resolves to an
|
||||
* CommunityHarnessRuntimeAdapter while its preset is installed. The built-in entries are returned as they are, and the map's
|
||||
* own keys stay the built-ins.
|
||||
*/
|
||||
export function withAgentApps<T extends Record<string, RuntimeAdapter>>(builtIns: T, deps: AgentAppAdapterDeps): T {
|
||||
export function withCommunityHarnesses<T extends Record<string, RuntimeAdapter>>(builtIns: T, deps: CommunityHarnessAdapterDeps): T {
|
||||
const installed = (key: string | symbol): key is string =>
|
||||
typeof key === "string" && !Object.hasOwn(builtIns, key) && isAgentAppRuntime(key)
|
||||
&& loadAgentAppPreset(key, deps.presetsDir?.() ?? agentAppsDir()).ok;
|
||||
typeof key === "string" && !Object.hasOwn(builtIns, key) && isCommunityHarnessRuntime(key)
|
||||
&& loadCommunityHarnessPreset(key, deps.presetsDir?.() ?? communityHarnessesDir()).ok;
|
||||
return new Proxy(builtIns, {
|
||||
get: (target, key, receiver) => installed(key) ? new AgentAppRuntimeAdapter(key, deps) : Reflect.get(target, key, receiver),
|
||||
get: (target, key, receiver) => installed(key) ? new CommunityHarnessRuntimeAdapter(key, deps) : Reflect.get(target, key, receiver),
|
||||
has: (target, key) => Reflect.has(target, key) || installed(key),
|
||||
});
|
||||
}
|
||||
|
||||
/** The hidden key of a relaunch map's agent-app resolver; a symbol, so the map's own keys stay the built-ins. */
|
||||
const AGENT_APP_RESOLVER = Symbol("agent-app-resolver");
|
||||
/** The hidden key of a relaunch map's community-harness resolver; a symbol, so the map's own keys stay the built-ins. */
|
||||
const COMMUNITY_HARNESS_RESOLVER = Symbol("community-harness-resolver");
|
||||
|
||||
/**
|
||||
* The runtime adapter map for relaunches (restore, seat lifecycle): exactly the built-ins, plus a hidden resolver for a
|
||||
* community agent app. Only `seatAdapter`, given a seat's recorded selection, reaches that resolver, so a seat whose
|
||||
* community harness. Only `seatAdapter`, given a seat's recorded selection, reaches that resolver, so a seat whose
|
||||
* runtime merely matches an installed preset's name (a free-text self-attach) gets no adapter, as before.
|
||||
*/
|
||||
export function withAgentAppResolver<T extends Record<string, RuntimeAdapter>>(builtIns: T, deps: AgentAppAdapterDeps): T {
|
||||
Object.defineProperty(builtIns, AGENT_APP_RESOLVER, {
|
||||
export function withCommunityHarnessResolver<T extends Record<string, RuntimeAdapter>>(builtIns: T, deps: CommunityHarnessAdapterDeps): T {
|
||||
Object.defineProperty(builtIns, COMMUNITY_HARNESS_RESOLVER, {
|
||||
enumerable: false,
|
||||
value: (runtime: string) => isAgentAppRuntime(runtime) && loadAgentAppPreset(runtime, deps.presetsDir?.() ?? agentAppsDir()).ok
|
||||
? new AgentAppRuntimeAdapter(runtime, deps) : undefined,
|
||||
value: (runtime: string) => isCommunityHarnessRuntime(runtime) && loadCommunityHarnessPreset(runtime, deps.presetsDir?.() ?? communityHarnessesDir()).ok
|
||||
? new CommunityHarnessRuntimeAdapter(runtime, deps) : undefined,
|
||||
});
|
||||
return builtIns;
|
||||
}
|
||||
|
||||
/** The adapter to relaunch a seat with: its runtime's built-in adapter, or, for a seat launched for a community agent
|
||||
* app (`agentAppOf`), that app's adapter while its preset is installed. */
|
||||
* app (`communityHarnessOf`), that app's adapter while its preset is installed. */
|
||||
export function seatAdapter(map: Record<string, RuntimeAdapter> | undefined, runtime: string | null | undefined,
|
||||
agentApp: string | null | undefined): RuntimeAdapter | undefined {
|
||||
communityHarness: string | null | undefined): RuntimeAdapter | undefined {
|
||||
if (!map || !runtime) return undefined;
|
||||
if (Object.hasOwn(map, runtime)) return map[runtime];
|
||||
const resolve = (map as Record<symbol, ((runtime: string) => RuntimeAdapter | undefined) | undefined>)[AGENT_APP_RESOLVER];
|
||||
return agentAppOf(agentApp, runtime) && resolve ? resolve(runtime) : undefined;
|
||||
const resolve = (map as Record<symbol, ((runtime: string) => RuntimeAdapter | undefined) | undefined>)[COMMUNITY_HARNESS_RESOLVER];
|
||||
return communityHarnessOf(communityHarness, runtime) && resolve ? resolve(runtime) : undefined;
|
||||
}
|
||||
@@ -115,7 +115,7 @@ import { modePostureCoexistSchema } from "./migrations/109_mode_posture_coexist.
|
||||
import { workflowInstanceScopeSchema } from "./migrations/110_workflow_instance_scope.js";
|
||||
import { queueTransitionsTsQitemSchema } from "./migrations/112_queue_transitions_ts_qitem.js";
|
||||
import { appliedLaunchDaemonOptOutSchema } from "./migrations/113_applied_launch_daemon_opt_out.js";
|
||||
import { sessionAgentAppSchema } from "./migrations/114_session_agent_app.js";
|
||||
import { sessionAgentAppSchema as sessionCommunityHarnessSchema } from "./migrations/114_session_agent_app.js";
|
||||
import { threadSeatMapChannelKeySchema } from "./migrations/115_thread_seat_map_channel_key.js";
|
||||
|
||||
/** Ordered migrations; numbers may be reserved by independent changes. */
|
||||
@@ -231,6 +231,6 @@ export const ALL_MIGRATIONS: Migration[] = [
|
||||
seatEnvSchema,
|
||||
queueTransitionsTsQitemSchema,
|
||||
appliedLaunchDaemonOptOutSchema,
|
||||
sessionAgentAppSchema,
|
||||
sessionCommunityHarnessSchema,
|
||||
threadSeatMapChannelKeySchema,
|
||||
];
|
||||
@@ -18,7 +18,7 @@
|
||||
// display from the count+reason fields via `deriveDisplayActivity` — the one bridge —
|
||||
// so no store or transition ever holds "needs-input" as a state. That reconciliation
|
||||
// is documented in the reference doc's addendum-mapping section.
|
||||
import { agentAppOf } from "./built-in-runtimes.js";
|
||||
import { communityHarnessOf } from "./built-in-runtimes.js";
|
||||
|
||||
/** Activity axis — what a present agent is doing. */
|
||||
export type ActivityValue = "working" | "idle-at-prompt" | "unknown";
|
||||
@@ -106,7 +106,7 @@ export interface RungDeclaration {
|
||||
* never inherits its predecessor's rung authority. */
|
||||
export interface AdapterRungInventory {
|
||||
adapterId: string;
|
||||
runtime: "claude-code" | "codex" | "tmux-generic" | "agent-app";
|
||||
runtime: "claude-code" | "codex" | "tmux-generic" | "community-harness";
|
||||
rungs: RungDeclaration[];
|
||||
}
|
||||
|
||||
@@ -195,20 +195,20 @@ export const TMUX_GENERIC_RUNG_INVENTORY: AdapterRungInventory = {
|
||||
rungs: [{ rung: "window-sampling", lifecycleCoverage: "full", initialTrust: "authoritative" }],
|
||||
};
|
||||
|
||||
/** A community agent app declares no activity signal. A changing screen reads working, decided by window
|
||||
/** A community harness declares no activity signal. A changing screen reads working, decided by window
|
||||
* sampling; a quiet screen reads unknown, never idle, since an app can sit quietly at a prompt or an approval. */
|
||||
export const AGENT_APP_RUNG_INVENTORY: AdapterRungInventory = {
|
||||
adapterId: "agent-app",
|
||||
runtime: "agent-app",
|
||||
export const COMMUNITY_HARNESS_RUNG_INVENTORY: AdapterRungInventory = {
|
||||
adapterId: "community-harness",
|
||||
runtime: "community-harness",
|
||||
rungs: [{ rung: "window-sampling", lifecycleCoverage: "partial", initialTrust: "positive-only" }],
|
||||
};
|
||||
|
||||
/** Resolve a runtime string to its rung inventory (the ingest auto-declaration path).
|
||||
* Unknown runtimes get the generic floor — partial-coverage honesty by default. A seat launched for a community
|
||||
* agent app (its session's recorded selection, still the node's runtime) gets that app's inventory. */
|
||||
export function runtimeRungInventory(runtime: string | null, agentApp?: string | null): AdapterRungInventory {
|
||||
* community harness (its session's recorded selection, still the node's runtime) gets that app's inventory. */
|
||||
export function runtimeRungInventory(runtime: string | null, communityHarness?: string | null): AdapterRungInventory {
|
||||
if (runtime === "claude-code") return CLAUDE_ACTIVITY_RUNG_INVENTORY;
|
||||
if (runtime === "codex") return CODEX_ACTIVITY_RUNG_INVENTORY;
|
||||
if (agentAppOf(agentApp, runtime)) return AGENT_APP_RUNG_INVENTORY;
|
||||
if (communityHarnessOf(communityHarness, runtime)) return COMMUNITY_HARNESS_RUNG_INVENTORY;
|
||||
return TMUX_GENERIC_RUNG_INVENTORY;
|
||||
}
|
||||
@@ -1,15 +1,15 @@
|
||||
/** The runtimes OpenRig ships. Any other runtime name names a community agent app (`agent-app-presets.ts`), and a
|
||||
/** The runtimes OpenRig ships. Any other runtime name names a community harness (`community-harness-presets.ts`), and a
|
||||
* preset can't take one of these names. */
|
||||
export const BUILT_IN_RUNTIMES: ReadonlySet<string> = new Set(["claude-code", "codex", "pi", "omp", "terminal", "stub"]);
|
||||
|
||||
/** A runtime name that isn't built in names a community agent app. */
|
||||
export function isAgentAppRuntime(runtime: string | null | undefined): runtime is string {
|
||||
/** A runtime name that isn't built in names a community harness. */
|
||||
export function isCommunityHarnessRuntime(runtime: string | null | undefined): runtime is string {
|
||||
return !!runtime && !BUILT_IN_RUNTIMES.has(runtime);
|
||||
}
|
||||
|
||||
/** The community agent app a seat runs, from positive provenance only: the session was launched for that app
|
||||
* (`sessions.agent_app`), and it's still the node's runtime. A free-text runtime name alone, an older session or a
|
||||
/** The community harness a seat runs, from positive provenance only: the session was launched for that app
|
||||
* (`sessions.community_harness`), and it's still the node's runtime. A free-text runtime name alone, an older session or a
|
||||
* changed runtime gives null, so such a seat is treated as it always was. */
|
||||
export function agentAppOf(agentApp: string | null | undefined, runtime: string | null | undefined): string | null {
|
||||
return agentApp && agentApp === runtime ? agentApp : null;
|
||||
export function communityHarnessOf(communityHarness: string | null | undefined, runtime: string | null | undefined): string | null {
|
||||
return communityHarness && communityHarness === runtime ? communityHarness : null;
|
||||
}
|
||||
+26
-26
@@ -1,21 +1,21 @@
|
||||
// Community agent apps: another agent app runs in a seat from a small data preset the person installs, one YAML file
|
||||
// per app under $OPENRIG_HOME/agent-apps/<name>.yaml. A preset names the app, the command that starts it in the seat's
|
||||
// pane, its maintainer and the limits it declares. OpenRig hasn't tested these apps: it shows them as community apps,
|
||||
// Community harnesses: another community harness runs in a seat from a small data preset the person installs, one YAML file
|
||||
// per harness under $OPENRIG_HOME/community-harnesses/<name>.yaml. A preset names the harness, the command that starts it in the seat's
|
||||
// pane, its maintainer and the limits it declares. OpenRig hasn't tested these harnesses: it shows them as community harnesses,
|
||||
// never confirms their identity, and reads their activity only from what the screen shows. Built-in runtime names are
|
||||
// reserved, so a preset can't replace one.
|
||||
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
||||
import { basename, join } from "node:path";
|
||||
import { parse as parseYaml } from "yaml";
|
||||
import { getDefaultOpenRigPath } from "../openrig-compat.js";
|
||||
import { BUILT_IN_RUNTIMES, isAgentAppRuntime } from "./built-in-runtimes.js";
|
||||
import { BUILT_IN_RUNTIMES, isCommunityHarnessRuntime } from "./built-in-runtimes.js";
|
||||
|
||||
export { BUILT_IN_RUNTIMES, isAgentAppRuntime };
|
||||
export { BUILT_IN_RUNTIMES, isCommunityHarnessRuntime };
|
||||
|
||||
export const AGENT_APP_PRESET_FORMAT = 1;
|
||||
export const COMMUNITY_HARNESS_PRESET_FORMAT = 1;
|
||||
const FIELDS = ["format", "name", "command", "maintainer", "limits"] as const;
|
||||
const NAME = /^[a-z0-9][a-z0-9-]{0,62}$/;
|
||||
|
||||
export interface AgentAppPreset {
|
||||
export interface CommunityHarnessPreset {
|
||||
name: string;
|
||||
command: string;
|
||||
maintainer: string;
|
||||
@@ -23,26 +23,26 @@ export interface AgentAppPreset {
|
||||
path: string;
|
||||
}
|
||||
|
||||
export type AgentAppPresetLookup =
|
||||
| { ok: true; preset: AgentAppPreset }
|
||||
export type CommunityHarnessPresetLookup =
|
||||
| { ok: true; preset: CommunityHarnessPreset }
|
||||
| { ok: false; kind: "missing" | "incompatible"; path: string; message: string };
|
||||
|
||||
export function agentAppsDir(): string {
|
||||
return getDefaultOpenRigPath("agent-apps");
|
||||
export function communityHarnessesDir(): string {
|
||||
return getDefaultOpenRigPath("community-harnesses");
|
||||
}
|
||||
|
||||
function parsePreset(name: string, path: string, text: string): AgentAppPresetLookup {
|
||||
const bad = (reason: string): AgentAppPresetLookup => ({ ok: false, kind: "incompatible", path,
|
||||
function parsePreset(name: string, path: string, text: string): CommunityHarnessPresetLookup {
|
||||
const bad = (reason: string): CommunityHarnessPresetLookup => ({ ok: false, kind: "incompatible", path,
|
||||
message: `community harness "${name}": the preset at ${path} can't be used: ${reason}` });
|
||||
let doc: unknown;
|
||||
try { doc = parseYaml(text); } catch { return bad("it isn't valid YAML"); }
|
||||
if (!doc || typeof doc !== "object" || Array.isArray(doc)) return bad("it isn't a YAML mapping");
|
||||
const raw = doc as Record<string, unknown>;
|
||||
if (raw.format !== AGENT_APP_PRESET_FORMAT) {
|
||||
return bad(`it declares format ${JSON.stringify(raw.format ?? null)}; this OpenRig reads format ${AGENT_APP_PRESET_FORMAT}`);
|
||||
if (raw.format !== COMMUNITY_HARNESS_PRESET_FORMAT) {
|
||||
return bad(`it declares format ${JSON.stringify(raw.format ?? null)}; this OpenRig reads format ${COMMUNITY_HARNESS_PRESET_FORMAT}`);
|
||||
}
|
||||
const unknown = Object.keys(raw).filter(key => !(FIELDS as readonly string[]).includes(key));
|
||||
if (unknown.length) return bad(`unknown field${unknown.length > 1 ? "s" : ""} ${unknown.join(", ")} (format ${AGENT_APP_PRESET_FORMAT} has ${FIELDS.join(", ")})`);
|
||||
if (unknown.length) return bad(`unknown field${unknown.length > 1 ? "s" : ""} ${unknown.join(", ")} (format ${COMMUNITY_HARNESS_PRESET_FORMAT} has ${FIELDS.join(", ")})`);
|
||||
if (raw.name !== name) return bad(`its name is ${JSON.stringify(raw.name ?? null)}, not "${name}" (the file name)`);
|
||||
for (const key of ["command", "maintainer"] as const) {
|
||||
if (typeof raw[key] !== "string" || !(raw[key] as string).trim()) return bad(`${key} must be a non-empty string`);
|
||||
@@ -89,8 +89,8 @@ function shellSyntaxIn(command: string): string | null {
|
||||
return quote ? "an unclosed quote" : null;
|
||||
}
|
||||
|
||||
/** The installed preset for a community agent app, or why there isn't a usable one. */
|
||||
export function loadAgentAppPreset(name: string, dir: string = agentAppsDir()): AgentAppPresetLookup {
|
||||
/** The installed preset for a community harness, or why there isn't a usable one. */
|
||||
export function loadCommunityHarnessPreset(name: string, dir: string = communityHarnessesDir()): CommunityHarnessPresetLookup {
|
||||
const path = join(dir, `${name}.yaml`);
|
||||
if (BUILT_IN_RUNTIMES.has(name)) {
|
||||
return { ok: false, kind: "incompatible", path, message: `"${name}" is a built-in runtime; a community harness preset can't use that name` };
|
||||
@@ -114,16 +114,16 @@ export function loadAgentAppPreset(name: string, dir: string = agentAppsDir()):
|
||||
}
|
||||
|
||||
/** Every preset file in the directory, usable or not, sorted by name. */
|
||||
export function listAgentAppPresets(dir: string = agentAppsDir()): AgentAppPresetLookup[] {
|
||||
export function listCommunityHarnessPresets(dir: string = communityHarnessesDir()): CommunityHarnessPresetLookup[] {
|
||||
if (!existsSync(dir)) return [];
|
||||
let files: string[];
|
||||
try { files = readdirSync(dir).filter(file => file.endsWith(".yaml")).sort(); } catch { return []; }
|
||||
return files.map(file => loadAgentAppPreset(file.slice(0, -".yaml".length), dir));
|
||||
return files.map(file => loadCommunityHarnessPreset(file.slice(0, -".yaml".length), dir));
|
||||
}
|
||||
|
||||
/** What a seat on a community agent app shows about it. */
|
||||
export interface AgentAppLabel {
|
||||
app: string;
|
||||
/** What a seat on a community harness shows about it. */
|
||||
export interface CommunityHarnessLabel {
|
||||
harness: string;
|
||||
community: true;
|
||||
testedByOpenRig: false;
|
||||
maintainer: string | null;
|
||||
@@ -134,10 +134,10 @@ export interface AgentAppLabel {
|
||||
presetError?: string;
|
||||
}
|
||||
|
||||
export function agentAppLabel(runtime: string, dir: string = agentAppsDir()): AgentAppLabel {
|
||||
const found = loadAgentAppPreset(runtime, dir);
|
||||
export function communityHarnessLabel(runtime: string, dir: string = communityHarnessesDir()): CommunityHarnessLabel {
|
||||
const found = loadCommunityHarnessPreset(runtime, dir);
|
||||
return {
|
||||
app: runtime, community: true, testedByOpenRig: false,
|
||||
harness: runtime, community: true, testedByOpenRig: false,
|
||||
maintainer: found.ok ? found.preset.maintainer : null,
|
||||
limits: found.ok ? found.preset.limits : [],
|
||||
identity: "not confirmed", needsInput: "from the screen only: no harness prompt pattern",
|
||||
@@ -21,9 +21,9 @@ import { deriveCanonicalSessionName } from "./session-name.js";
|
||||
import { buildNativeResumeCommand, buildCodexResumeCore } from "./native-resume-probe.js";
|
||||
import { AppliedLaunchObservationStore } from "./applied-launch-observation-store.js";
|
||||
import { tmuxAttachCommand } from "../adapters/tmux-server.js";
|
||||
import { agentAppLabel } from "./agent-app-presets.js";
|
||||
import { agentAppOf } from "./built-in-runtimes.js";
|
||||
import { sessionAgentAppSelect } from "./session-registry.js";
|
||||
import { communityHarnessLabel } from "./community-harness-presets.js";
|
||||
import { communityHarnessOf } from "./built-in-runtimes.js";
|
||||
import { sessionCommunityHarnessSelect } from "./session-registry.js";
|
||||
|
||||
// -- Row types for SQL results --
|
||||
|
||||
@@ -55,7 +55,7 @@ interface InventoryRow {
|
||||
session_name: string | null;
|
||||
session_created_at: string | null;
|
||||
session_status: string | null;
|
||||
session_agent_app?: string | null;
|
||||
session_community_harness?: string | null;
|
||||
intentionally_stopped: number | null;
|
||||
startup_status: string | null;
|
||||
resume_type: string | null;
|
||||
@@ -584,7 +584,7 @@ function runInventoryRowQuery(db: Database.Database, whereClause: string, orderC
|
||||
s.session_name,
|
||||
s.created_at as session_created_at,
|
||||
s.status as session_status,
|
||||
${sessionAgentAppSelect(db)} as session_agent_app,
|
||||
${sessionCommunityHarnessSelect(db)} as session_community_harness,
|
||||
s.intentionally_stopped,
|
||||
s.startup_status,
|
||||
s.resume_type,
|
||||
@@ -697,7 +697,7 @@ function buildInventoryEntry(
|
||||
attachmentType: (row.binding_attachment_type as NodeInventoryEntry["attachmentType"]) ?? null,
|
||||
nodeKind: deriveNodeKind(row.runtime),
|
||||
runtime: row.runtime,
|
||||
...(agentAppOf(row.session_agent_app, row.runtime) ? { agentApp: agentAppLabel(row.runtime!) } : {}),
|
||||
...(communityHarnessOf(row.session_community_harness, row.runtime) ? { communityHarness: communityHarnessLabel(row.runtime!) } : {}),
|
||||
sessionStatus,
|
||||
storedSessionStatus: row.session_status,
|
||||
intentionallyStopped: row.intentionally_stopped === 1
|
||||
@@ -1485,7 +1485,7 @@ export async function attachAgentActivity(
|
||||
now: sampledAt,
|
||||
captureObserver: deps.captureObserver,
|
||||
binding: deps.captureObserver ? deps.observationBinding?.(entry) : undefined,
|
||||
agentApp: entry.agentApp?.app ?? null,
|
||||
communityHarness: entry.communityHarness?.harness ?? null,
|
||||
})),
|
||||
};
|
||||
}));
|
||||
|
||||
@@ -79,8 +79,8 @@ const LAUNCH_DEPENDENCY_KINDS = new Set(["delegates_to", "spawned_by"]);
|
||||
// existing export surface and the execution call sites below are unchanged.
|
||||
import { resolveActiveSnapshotSession, activeOccupantAmbiguityError, absentSeatResumeHistory, absentSeatResumeHistoryError } from "./active-occupant.js";
|
||||
import { effectiveRestorePolicy, type RestoreSeatFacts } from "./restore-policy.js";
|
||||
import { agentAppOf } from "./built-in-runtimes.js";
|
||||
import { seatAdapter } from "../adapters/agent-app-runtime-adapter.js";
|
||||
import { communityHarnessOf } from "./built-in-runtimes.js";
|
||||
import { seatAdapter } from "../adapters/community-harness-runtime-adapter.js";
|
||||
import { pluginPathsOf } from "./claude-core-plugin-env.js";
|
||||
export { resolveActiveSnapshotSession } from "./active-occupant.js";
|
||||
export type { ActiveSnapshotSessionResolution } from "./active-occupant.js";
|
||||
@@ -140,7 +140,7 @@ function snapshotLaunchOutcome(data: SnapshotData, nodeId: string, runtime: stri
|
||||
}
|
||||
const policy = effectiveRestorePolicy(seatFacts(data, nodeId, savedRuntime, runtime), session.restorePolicy);
|
||||
if (policy !== "resume_if_possible") return { kind: "fresh", why: `saves this seat with restore policy '${policy}'` };
|
||||
if (agentAppOf(session.agentApp, runtime)) return { kind: "decision", why: "is a community harness whose preset is launch-only (resume unsupported)" };
|
||||
if (communityHarnessOf(session.communityHarness, runtime)) return { kind: "decision", why: "is a community harness whose preset is launch-only (resume unsupported)" };
|
||||
if (!session.resumeToken) return { kind: "decision", why: "has no resume token for this seat" };
|
||||
if (!session.resumeType || session.resumeType === "none") return { kind: "fresh", why: "records no resume source for this seat" };
|
||||
return { kind: "resume" };
|
||||
@@ -547,7 +547,7 @@ export class RestoreOrchestrator {
|
||||
const runtime = allNodes.find(current => current.id === node.id)?.runtime ?? node.runtime;
|
||||
// Launch needs consistent positive evidence: both samples idle with the same fingerprint, and the
|
||||
// bound pane still in this session (a recycled pane id could name another session's shell).
|
||||
if (pane && (["claude-code", "codex", "pi", "omp"].includes(runtime ?? "") || agentAppOf(session.agentApp, runtime))
|
||||
if (pane && (["claude-code", "codex", "pi", "omp"].includes(runtime ?? "") || communityHarnessOf(session.communityHarness, runtime))
|
||||
&& isShellForeground(await this.tmuxAdapter.getPaneCommand(pane) ?? "")
|
||||
&& (await this.tmuxAdapter.listPanes(session.sessionName)).some(candidate => candidate.id === pane)
|
||||
&& (await observeClaudeDelivery({ target: pane, tmux: this.tmuxAdapter, listProcesses: this.listProcesses }, { unknownKeepsIdle: false })).state === "idle_shell"
|
||||
@@ -920,8 +920,8 @@ export class RestoreOrchestrator {
|
||||
const snapSession = occupantResolution.kind === "resolved" ? occupantResolution.session : null;
|
||||
const policy = effectiveRestorePolicy(seatFacts(data, nodeId, node.runtime, liveNode ? liveNode.runtime : node.runtime), snapSession?.restorePolicy);
|
||||
const resumeSourceRecorded = !!snapSession?.resumeType && snapSession.resumeType !== "none";
|
||||
// An agent app is launch-only: nothing resumes it, so even a saved token (say, from a former runtime) stops here.
|
||||
if (policy === "resume_if_possible" && snapSession && !freshRequested && agentAppOf(snapSession.agentApp, node.runtime)) {
|
||||
// A community harness is launch-only: nothing resumes it, so even a saved token (say, from a former runtime) stops here.
|
||||
if (policy === "resume_if_possible" && snapSession && !freshRequested && communityHarnessOf(snapSession.communityHarness, node.runtime)) {
|
||||
return {
|
||||
nodeId,
|
||||
logicalId: node.logicalId,
|
||||
@@ -1147,10 +1147,10 @@ export class RestoreOrchestrator {
|
||||
}
|
||||
const session = sessionResolution.kind === "resolved" ? sessionResolution.session : null;
|
||||
const checkpoint = data.checkpoints[node.id] ?? null;
|
||||
// Relaunching a seat that was launched for a community agent app keeps that selection on the session registered
|
||||
// Relaunching a seat that was launched for a community harness keeps that selection on the session registered
|
||||
// for it, whether or not the app can start now: never a fallback to a built-in reading.
|
||||
const carriedAgentApp = agentAppOf(session?.agentApp, node.runtime);
|
||||
if (carriedAgentApp && launchResult?.session?.id) this.sessionRegistry.recordAgentAppSelection(launchResult.session.id, carriedAgentApp);
|
||||
const carriedCommunityHarness = communityHarnessOf(session?.communityHarness, node.runtime);
|
||||
if (carriedCommunityHarness && launchResult?.session?.id) this.sessionRegistry.recordCommunityHarnessSelection(launchResult.session.id, carriedCommunityHarness);
|
||||
|
||||
// Check restore policy. OPR.0.3.4.2: a --fresh-listed seat (operation B)
|
||||
// deliberately skips the resume attempt; its launch reports `fresh-primed`.
|
||||
@@ -1291,7 +1291,7 @@ export class RestoreOrchestrator {
|
||||
// (e.g. a node-subset launch that did not thread adapters), we CANNOT resume
|
||||
// and MUST NOT silently fresh-prime — fail closed to awaiting-decision.
|
||||
// Explicit --fresh and non-resume policies stay the only fresh-prime paths.
|
||||
const resumeAdapter = seatAdapter(opts?.adapters, node.runtime, session?.agentApp);
|
||||
const resumeAdapter = seatAdapter(opts?.adapters, node.runtime, session?.communityHarness);
|
||||
if (!resumeAdapter) {
|
||||
await this.rollbackToZeroSession(node.id, sessionName, launchResult?.session.id, priorState);
|
||||
return {
|
||||
@@ -1332,7 +1332,7 @@ export class RestoreOrchestrator {
|
||||
// nothing else is replayed.
|
||||
const replayContained = resumeRequested && !!resumeToken;
|
||||
const startupRuntime = startupCtx?.runtime ?? node.runtime ?? null;
|
||||
const startupAdapter = seatAdapter(opts?.adapters, startupRuntime, session?.agentApp);
|
||||
const startupAdapter = seatAdapter(opts?.adapters, startupRuntime, session?.communityHarness);
|
||||
|
||||
// A new pod-aware agent needs a startup context to run StartupOrchestrator.
|
||||
// Do not report a healthy fresh-primed result when that context or adapter
|
||||
@@ -1354,7 +1354,7 @@ export class RestoreOrchestrator {
|
||||
// Attempt restore-safe startup replay if context available
|
||||
if (data.nodeStartupContext && opts?.adapters && launchResult) {
|
||||
if (startupCtx) {
|
||||
const adapter = seatAdapter(opts.adapters, startupCtx.runtime, session?.agentApp);
|
||||
const adapter = seatAdapter(opts.adapters, startupCtx.runtime, session?.communityHarness);
|
||||
if (adapter) {
|
||||
// Prefilter: check which files/entries still exist
|
||||
const existsFn = opts.fsOps?.exists ?? (() => true);
|
||||
|
||||
@@ -142,7 +142,7 @@ import { resolveAgentRef, type AgentResolverFsOps } from "./agent-resolver.js";
|
||||
import { resolveNodeConfig, type ResolutionContext } from "./profile-resolver.js";
|
||||
import { getOpenRigInstallCwdError, resolveAuthoredCwd, resolveLaunchCwd } from "./cwd-resolution.js";
|
||||
import nodePath from "node:path";
|
||||
import { loadAgentAppPreset } from "./agent-app-presets.js";
|
||||
import { loadCommunityHarnessPreset } from "./community-harness-presets.js";
|
||||
import { BUILT_IN_RUNTIMES } from "./built-in-runtimes.js";
|
||||
import { validateClaudeActivityHookDelivery, CLAUDE_ACTIVITY_HOOKS_RESOURCE_TYPE } from "./claude-activity-hooks.js";
|
||||
import {
|
||||
@@ -395,9 +395,9 @@ export async function preflightValidatedSpec(rigSpec: PodRigSpec, preflightCtx:
|
||||
}
|
||||
}
|
||||
|
||||
// Check runtime: a built-in, or a community agent app whose preset is installed and usable.
|
||||
// Check runtime: a built-in, or a community harness whose preset is installed and usable.
|
||||
if (!SUPPORTED_RUNTIMES.has(member.runtime)) {
|
||||
const app = loadAgentAppPreset(member.runtime);
|
||||
const app = loadCommunityHarnessPreset(member.runtime);
|
||||
if (!app.ok) {
|
||||
errors.push(`${pod.id}.${member.id}: unsupported runtime "${member.runtime}": ${app.kind === "missing" ? "it isn't built in, and " : ""}${app.message}`);
|
||||
}
|
||||
|
||||
@@ -11,8 +11,8 @@ import type {
|
||||
RungHealthEvent,
|
||||
RungTrust,
|
||||
} from "./activity-taxonomy.js";
|
||||
import { AGENT_APP_RUNG_INVENTORY, EVIDENCE_RUNG_RANK, runtimeRungInventory } from "./activity-taxonomy.js";
|
||||
import { sessionAgentAppSelect } from "./session-registry.js";
|
||||
import { COMMUNITY_HARNESS_RUNG_INVENTORY, EVIDENCE_RUNG_RANK, runtimeRungInventory } from "./activity-taxonomy.js";
|
||||
import { sessionCommunityHarnessSelect } from "./session-registry.js";
|
||||
|
||||
/** Default polling cadence: 1Hz. The default silence window is 3s, so
|
||||
* 1Hz polling gives at-most ~1s freshness lag on the cached observation. */
|
||||
@@ -236,14 +236,14 @@ export class SeatActivityService {
|
||||
try {
|
||||
const rows = db.prepare(`
|
||||
SELECT s.session_name as session_name, n.id as node_id, n.runtime as runtime,
|
||||
COALESCE(b.attachment_type, 'tmux') as attachment_type, ${sessionAgentAppSelect(db)} as agent_app
|
||||
COALESCE(b.attachment_type, 'tmux') as attachment_type, ${sessionCommunityHarnessSelect(db)} as community_harness
|
||||
FROM nodes n
|
||||
JOIN sessions s ON s.node_id = n.id
|
||||
AND s.id = (SELECT s2.id FROM sessions s2 WHERE s2.node_id = n.id ORDER BY s2.id DESC LIMIT 1)
|
||||
LEFT JOIN bindings b ON b.node_id = n.id
|
||||
WHERE s.status = 'running'
|
||||
AND s.session_name IS NOT NULL
|
||||
`).all() as Array<{ session_name: string; node_id: string; runtime: string | null; attachment_type: string; agent_app: string | null }>;
|
||||
`).all() as Array<{ session_name: string; node_id: string; runtime: string | null; attachment_type: string; community_harness: string | null }>;
|
||||
const tmuxRows = rows.filter((r) => r.attachment_type === "tmux");
|
||||
const liveTmux = new Set(tmuxRows.map((r) => r.session_name));
|
||||
for (const pane of this.latestByPaneId.keys()) {
|
||||
@@ -265,10 +265,10 @@ export class SeatActivityService {
|
||||
// touching the launch machinery.
|
||||
for (const r of tmuxRows) {
|
||||
const known = this.ladder.get(r.node_id);
|
||||
const inventory = runtimeRungInventory(r.runtime, r.agent_app);
|
||||
const inventory = runtimeRungInventory(r.runtime, r.community_harness);
|
||||
// A replacement occupant can keep the seat's session name; its own recorded app selection (or none) decides.
|
||||
const agentAppChanged = !!known && (known.inventory === AGENT_APP_RUNG_INVENTORY) !== (inventory === AGENT_APP_RUNG_INVENTORY);
|
||||
if (!known || known.inventory === null || known.sessionName !== r.session_name || agentAppChanged) {
|
||||
const communityHarnessChanged = !!known && (known.inventory === COMMUNITY_HARNESS_RUNG_INVENTORY) !== (inventory === COMMUNITY_HARNESS_RUNG_INVENTORY);
|
||||
if (!known || known.inventory === null || known.sessionName !== r.session_name || communityHarnessChanged) {
|
||||
this.declareRungInventory({ seatNodeId: r.node_id, sessionName: r.session_name }, inventory);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,8 +11,8 @@ import { classifyPaneRuntimeMatch } from "./seat-identity-reconciler.js";
|
||||
import { SeatIdentityStore } from "./seat-identity-store.js";
|
||||
import { explainNativeRejection, subtreeNamesClaudeExecutable, observeClaudeDelivery, observeClaudePaneProcess, observeClaudePaneRuntime, observeCodexPaneProcess, verifyClaudePaneProcess, verifyClaudePaneRuntime, verifyCodexPaneProcess, verifyPaneIdleShell, type NativeProcessRow, type NativeProcessLister, type ClaudeResumeRotation, findExactNativeResumeProcess, listNativeProcesses } from "./native-process-lineage.js";
|
||||
import { isShellForeground } from "./shell-classifier.js";
|
||||
import { agentAppOf } from "./built-in-runtimes.js";
|
||||
import { sessionAgentAppSelect } from "./session-registry.js";
|
||||
import { communityHarnessOf } from "./built-in-runtimes.js";
|
||||
import { sessionCommunityHarnessSelect } from "./session-registry.js";
|
||||
|
||||
type PaneIdentityTmux = Pick<TmuxAdapter, "listPanes" | "getPanePid" | "getPaneCommand">;
|
||||
type ProcessRow = NativeProcessRow;
|
||||
@@ -234,11 +234,11 @@ export async function rebindAndVerifyPaneIdentity(input: {
|
||||
const runtimeAmbiguous = input.runtime === "codex" || claudeProcessProof ? lineageMatch === null : runtimeMatch === "match" && (strictNativeLineage
|
||||
? lineageMatch === null
|
||||
: input.runtime === "claude-code" && !normalizedCommand.includes("claude"));
|
||||
// A community agent app's live pane is never proof of which session it runs. Only a session launched for that app
|
||||
// A community harness's live pane is never proof of which session it runs. Only a session launched for that app
|
||||
// counts: the selection recorded on the session being rebound.
|
||||
const selected = (input.db.prepare(`SELECT ${sessionAgentAppSelect(input.db, "sessions")} AS agent_app FROM sessions WHERE node_id = ? AND session_name = ? ORDER BY id DESC LIMIT 1`)
|
||||
.get(input.nodeId, input.sessionName) as { agent_app: string | null } | undefined)?.agent_app;
|
||||
const unconfirmed = pid !== null && runtimeMatch !== "mismatch" && !runtimeAmbiguous && !!agentAppOf(selected, input.runtime);
|
||||
const selected = (input.db.prepare(`SELECT ${sessionCommunityHarnessSelect(input.db, "sessions")} AS community_harness FROM sessions WHERE node_id = ? AND session_name = ? ORDER BY id DESC LIMIT 1`)
|
||||
.get(input.nodeId, input.sessionName) as { community_harness: string | null } | undefined)?.community_harness;
|
||||
const unconfirmed = pid !== null && runtimeMatch !== "mismatch" && !runtimeAmbiguous && !!communityHarnessOf(selected, input.runtime);
|
||||
const verdict: SeatIdentityVerdict = {
|
||||
nodeId: input.nodeId,
|
||||
verdict: pid === null
|
||||
|
||||
@@ -7,8 +7,8 @@ import type { TmuxAdapter } from "../adapters/tmux.js";
|
||||
import type { SeatIdentityVerdict } from "./types.js";
|
||||
import { SeatIdentityStore, SelfHostIdentityStore } from "./seat-identity-store.js";
|
||||
import { RESERVED_HOST_IDS, validateHostRegistry } from "./hosts/hosts-registry-reader.js";
|
||||
import { agentAppOf } from "./built-in-runtimes.js";
|
||||
import { sessionAgentAppSelect } from "./session-registry.js";
|
||||
import { communityHarnessOf } from "./built-in-runtimes.js";
|
||||
import { sessionCommunityHarnessSelect } from "./session-registry.js";
|
||||
|
||||
/** Default identity-reconcile cadence: 5s. Process identity changes rarely
|
||||
* (a seat's pane/process is stable for its whole life), and the check is
|
||||
@@ -81,7 +81,7 @@ interface RunningSeatRow {
|
||||
resume_rotated_from?: string | null;
|
||||
resume_rotated_process?: string | null;
|
||||
resume_launch_process?: string | null;
|
||||
agent_app?: string | null;
|
||||
community_harness?: string | null;
|
||||
}
|
||||
|
||||
type PaneObservation = { pid: number | null; command: string | null };
|
||||
@@ -130,7 +130,7 @@ export class SeatIdentityReconciler {
|
||||
s.resume_rotated_from as resume_rotated_from,
|
||||
s.resume_rotated_process as resume_rotated_process,
|
||||
s.resume_launch_process as resume_launch_process,
|
||||
${sessionAgentAppSelect(this.db)} as agent_app
|
||||
${sessionCommunityHarnessSelect(this.db)} as community_harness
|
||||
FROM nodes n
|
||||
JOIN sessions s ON s.node_id = n.id
|
||||
AND s.id = (SELECT s2.id FROM sessions s2 WHERE s2.node_id = n.id ORDER BY s2.id DESC LIMIT 1)
|
||||
@@ -359,8 +359,8 @@ export class SeatIdentityReconciler {
|
||||
};
|
||||
}
|
||||
|
||||
// A community agent app's process doesn't prove which session it is: the pane is live, its identity unconfirmed.
|
||||
if (agentAppOf(seat.agent_app, seat.runtime)) {
|
||||
// A community harness's process doesn't prove which session it is: the pane is live, its identity unconfirmed.
|
||||
if (communityHarnessOf(seat.community_harness, seat.runtime)) {
|
||||
return {
|
||||
...base,
|
||||
verdict: "unconfirmed",
|
||||
|
||||
@@ -42,8 +42,8 @@ import {
|
||||
CodexInPlaceRestart, type InPlaceCodexLauncher, type InPlaceRestartIo, type InPlaceRestartRefusal,
|
||||
type InPlaceRestartReport, type InPlaceRestartTarget, type InPlaceVerifyResult,
|
||||
} from "./seat-restart-in-place.js";
|
||||
import { AgentAppRuntimeAdapter, seatAdapter } from "../adapters/agent-app-runtime-adapter.js";
|
||||
import { sessionAgentAppSelect } from "./session-registry.js";
|
||||
import { CommunityHarnessRuntimeAdapter, seatAdapter } from "../adapters/community-harness-runtime-adapter.js";
|
||||
import { sessionCommunityHarnessSelect } from "./session-registry.js";
|
||||
|
||||
/**
|
||||
* S5 (OPR.0.5.4.7) — the seat-lifecycle verb surface: set-model, single-seat stop,
|
||||
@@ -241,7 +241,7 @@ interface LatestSessionRow {
|
||||
session_name: string;
|
||||
status: string;
|
||||
origin: string;
|
||||
agent_app?: string | null;
|
||||
community_harness?: string | null;
|
||||
}
|
||||
|
||||
interface PersistedStartupContextRow {
|
||||
@@ -371,16 +371,16 @@ export class SeatLifecycleService {
|
||||
if (!node.runtime || startup.context.runtime !== node.runtime) {
|
||||
return { ok: false, code: "startup_context_runtime_mismatch", message: "The saved startup context belongs to a different runtime." };
|
||||
}
|
||||
// Resolved as launch resolves it, so a recorded community agent app finds its adapter too.
|
||||
const adapter = seatAdapter(this.runtimeAdapters, node.runtime, this.latestSession(node.id)?.agent_app);
|
||||
// Resolved as launch resolves it, so a recorded community harness finds its adapter too.
|
||||
const adapter = seatAdapter(this.runtimeAdapters, node.runtime, this.latestSession(node.id)?.community_harness);
|
||||
if (!adapter) return { ok: false, code: "runtime_adapter_missing", message: "The configured runtime adapter is unavailable." };
|
||||
|
||||
const warnings: string[] = [];
|
||||
// The filesystem startup files the next fresh launch would deliver (never send_text or actions), with legacy auto
|
||||
// hints resolved, for the read-only destination check.
|
||||
const files: ResolvedStartupFile[] = [];
|
||||
// A community agent app takes no startup files at launch (its adapter delivers none), so there's nothing to check.
|
||||
const takesStartupFiles = !(adapter instanceof AgentAppRuntimeAdapter);
|
||||
// A community harness takes no startup files at launch (its adapter delivers none), so there's nothing to check.
|
||||
const takesStartupFiles = !(adapter instanceof CommunityHarnessRuntimeAdapter);
|
||||
for (const file of takesStartupFiles ? startup.context.resolvedStartupFiles : []) {
|
||||
if (!file.appliesOn.includes("fresh_start") || file.deliveryHint === "send_text") continue;
|
||||
try {
|
||||
@@ -873,7 +873,7 @@ export class SeatLifecycleService {
|
||||
}
|
||||
const pane = await observeSolePane(this.tmuxAdapter, binding.tmuxSession);
|
||||
if (!pane.ok || pane.pane !== binding.tmuxPane) return { ok: false as const, code: "binding_changed", message: "The managed terminal binding changed; context was not delivered." };
|
||||
const adapter = seatAdapter(this.runtimeAdapters, node.runtime, session.agent_app);
|
||||
const adapter = seatAdapter(this.runtimeAdapters, node.runtime, session.community_harness);
|
||||
if (!adapter) return { ok: false as const, code: "runtime_adapter_missing", message: "The configured runtime adapter is unavailable." };
|
||||
const ready = await adapter.checkReady({ ...binding, cwd: node.cwd ?? "." });
|
||||
if (!ready.ready) return { ok: false as const, code: "attention_required", message: ready.reason ?? "Resolve the native prerequisite first." };
|
||||
@@ -921,7 +921,7 @@ export class SeatLifecycleService {
|
||||
message: `Persisted startup context runtime '${startup.context.runtime}' does not match current node runtime '${node.runtime ?? "missing"}'.`,
|
||||
};
|
||||
}
|
||||
const adapter = seatAdapter(this.runtimeAdapters, node.runtime, this.latestSession(node.id)?.agent_app);
|
||||
const adapter = seatAdapter(this.runtimeAdapters, node.runtime, this.latestSession(node.id)?.community_harness);
|
||||
if (!adapter) {
|
||||
return {
|
||||
ok: false as const,
|
||||
@@ -1807,7 +1807,7 @@ export class SeatLifecycleService {
|
||||
|
||||
private latestSession(nodeId: string): LatestSessionRow | null {
|
||||
const row = this.db.prepare(
|
||||
`SELECT id, session_name, status, origin, ${sessionAgentAppSelect(this.db, "sessions")} AS agent_app FROM sessions WHERE node_id = ? ORDER BY id DESC LIMIT 1`,
|
||||
`SELECT id, session_name, status, origin, ${sessionCommunityHarnessSelect(this.db, "sessions")} AS community_harness FROM sessions WHERE node_id = ? ORDER BY id DESC LIMIT 1`,
|
||||
).get(nodeId) as LatestSessionRow | undefined;
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type Database from "better-sqlite3";
|
||||
import type { PaneCapture, TmuxAdapter } from "../adapters/tmux.js";
|
||||
import { agentAppPaneActivity, classifyPaneActivity, type PaneActivityClassification } from "./session-transport.js";
|
||||
import { agentAppOf } from "./built-in-runtimes.js";
|
||||
import { communityHarnessPaneActivity, classifyPaneActivity, type PaneActivityClassification } from "./session-transport.js";
|
||||
import { communityHarnessOf } from "./built-in-runtimes.js";
|
||||
import { detectCodexLimitBanner, type CodexLimitBanner } from "./provider/codex-limit-banner.js";
|
||||
import { IDLE_FULL_CAPTURE_MS, windowSnapshot } from "./transcript-rotation.js";
|
||||
import { sessionAgentAppSelect } from "./session-registry.js";
|
||||
import { sessionCommunityHarnessSelect } from "./session-registry.js";
|
||||
|
||||
/** A cached STRUCTURAL pane observation: the classifyPaneActivity verdict plus WHEN the pane was read
|
||||
* as motion. observedAt is a LIVENESS timestamp (last time we saw the pane), NOT a hook-arrival age —
|
||||
@@ -91,7 +91,7 @@ export class SeatStructuralActivityService {
|
||||
* null or failed capture INVALIDATES the prior row (never leaves a stale positive verdict) and
|
||||
* returns null (MUST-FIX 1). `prefetched` (#308) is the sweep's batched capture: a session it holds (null = gone or
|
||||
* empty) is used as is; one it lacks is captured here, per seat. */
|
||||
async pollSeat(sessionName: string, prefetched?: Map<string, PaneCapture> | null, runtime?: string | null, agentApp?: string | null): Promise<StructuralObservation | null> {
|
||||
async pollSeat(sessionName: string, prefetched?: Map<string, PaneCapture> | null, runtime?: string | null, communityHarness?: string | null): Promise<StructuralObservation | null> {
|
||||
let content: string | null;
|
||||
let observedAt: Date | null = null;
|
||||
if (prefetched?.has(sessionName)) {
|
||||
@@ -110,7 +110,7 @@ export class SeatStructuralActivityService {
|
||||
this.latestBySession.delete(sessionName);
|
||||
return null;
|
||||
}
|
||||
const c = agentAppPaneActivity(classifyPaneActivity(content), agentApp);
|
||||
const c = communityHarnessPaneActivity(classifyPaneActivity(content), communityHarness);
|
||||
const at = observedAt ?? this.now();
|
||||
const obs: StructuralObservation = {
|
||||
state: c.state,
|
||||
@@ -141,7 +141,7 @@ export class SeatStructuralActivityService {
|
||||
this.sweeping = true;
|
||||
try {
|
||||
const rows = db.prepare(`
|
||||
SELECT s.session_name as session_name, n.runtime as runtime, ${sessionAgentAppSelect(db)} as agent_app
|
||||
SELECT s.session_name as session_name, n.runtime as runtime, ${sessionCommunityHarnessSelect(db)} as community_harness
|
||||
FROM nodes n
|
||||
JOIN sessions s ON s.node_id = n.id
|
||||
AND s.id = (SELECT s2.id FROM sessions s2 WHERE s2.node_id = n.id ORDER BY s2.id DESC LIMIT 1)
|
||||
@@ -149,10 +149,10 @@ export class SeatStructuralActivityService {
|
||||
WHERE s.status = 'running'
|
||||
AND s.session_name IS NOT NULL
|
||||
AND COALESCE(b.attachment_type, 'tmux') = 'tmux'
|
||||
`).all() as Array<{ session_name: string; runtime: string | null; agent_app: string | null }>;
|
||||
`).all() as Array<{ session_name: string; runtime: string | null; community_harness: string | null }>;
|
||||
const live = new Set(rows.map((r) => r.session_name));
|
||||
const runtimeBySession = new Map(rows.map((r) => [r.session_name, r.runtime] as const));
|
||||
const agentAppBySession = new Map(rows.map((r) => [r.session_name, agentAppOf(r.agent_app, r.runtime)] as const));
|
||||
const communityHarnessBySession = new Map(rows.map((r) => [r.session_name, communityHarnessOf(r.community_harness, r.runtime)] as const));
|
||||
for (const s of Array.from(this.latestBySession.keys())) {
|
||||
if (!live.has(s)) this.latestBySession.delete(s); // release memory + never serve a stale read
|
||||
}
|
||||
@@ -185,7 +185,7 @@ export class SeatStructuralActivityService {
|
||||
: null;
|
||||
await Promise.all(due.map(async (r) => {
|
||||
try {
|
||||
const obs = await this.pollSeat(r.session_name, prefetched, runtimeBySession.get(r.session_name) ?? null, agentAppBySession.get(r.session_name));
|
||||
const obs = await this.pollSeat(r.session_name, prefetched, runtimeBySession.get(r.session_name) ?? null, communityHarnessBySession.get(r.session_name));
|
||||
const seen = activity?.get(r.session_name);
|
||||
if (obs && seen !== undefined) this.capturedActivity.set(r.session_name, { activity: seen.activity, size: seen.size, startSec, fullAt: sweepAt.getTime() });
|
||||
else this.capturedActivity.delete(r.session_name);
|
||||
|
||||
@@ -370,8 +370,8 @@ export class SessionRegistry {
|
||||
// empty-token skip or a lower-rank no-op. Callers that only set-and-forget can
|
||||
// ignore it; the adoption-capture path uses it so its audit event never
|
||||
// falsely claims a captured write when the provenance guard refused it.
|
||||
/** Record that this session was launched for a community agent app, selected through its preset. */
|
||||
recordAgentAppSelection(sessionId: string, app: string): void {
|
||||
/** Record that this session was launched for a community harness, selected through its preset. */
|
||||
recordCommunityHarnessSelection(sessionId: string, app: string): void {
|
||||
this.db.prepare("UPDATE sessions SET agent_app = ? WHERE id = ?").run(app, sessionId);
|
||||
}
|
||||
|
||||
@@ -712,8 +712,8 @@ export class SessionRegistry {
|
||||
origin: (row.origin === "claimed" ? "claimed" : "launched"),
|
||||
startupStatus: (row.startup_status as Session["startupStatus"]) ?? "pending",
|
||||
startupCompletedAt: row.startup_completed_at ?? null,
|
||||
// Present only on a session launched for a community agent app, so other sessions read as before.
|
||||
...(row.agent_app ? { agentApp: row.agent_app } : {}),
|
||||
// Present only on a session launched for a community harness, so other sessions read as before.
|
||||
...(row.agent_app ? { communityHarness: row.agent_app } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -768,16 +768,16 @@ interface BindingRow {
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
const sessionsHaveAgentApp = new WeakMap<Database.Database, boolean>();
|
||||
const sessionsHaveCommunityHarness = new WeakMap<Database.Database, boolean>();
|
||||
|
||||
/** The SQL that reads a session's recorded agent app: `<alias>.agent_app`, or NULL on a schema without that column
|
||||
/** The SQL that reads a session's recorded community harness: the retained storage column, or NULL on a schema without that column
|
||||
* (as other optional columns are read), so a partial schema reads every seat as before. The schema check is made
|
||||
* once per database: the column only appears through a migration, which runs before these reads. */
|
||||
export function sessionAgentAppSelect(db: Database.Database, alias = "s"): string {
|
||||
let has = sessionsHaveAgentApp.get(db);
|
||||
export function sessionCommunityHarnessSelect(db: Database.Database, alias = "s"): string {
|
||||
let has = sessionsHaveCommunityHarness.get(db);
|
||||
if (has === undefined) {
|
||||
has = (db.prepare("PRAGMA table_info(sessions)").all() as Array<{ name?: string }>).some(row => row.name === "agent_app");
|
||||
sessionsHaveAgentApp.set(db, has);
|
||||
sessionsHaveCommunityHarness.set(db, has);
|
||||
}
|
||||
return has ? `${alias}.agent_app` : "NULL";
|
||||
}
|
||||
@@ -476,12 +476,12 @@ function draftComposerLine(lines: string[], verdict: PaneActivityClassification)
|
||||
return lines[last]!.trim();
|
||||
}
|
||||
|
||||
/** A community agent app's screen never proves it's idle: a quiet prompt can be an approval or a pause, so its idle
|
||||
* reading becomes unknown. Positive work and attention are kept. `agentApp` is the seat's recorded selection
|
||||
* (`agentAppOf`); without it the classification is returned as it is. */
|
||||
export function agentAppPaneActivity(classification: PaneActivityClassification, agentApp: string | null | undefined): PaneActivityClassification {
|
||||
return agentApp && classification.state === "agent_idle"
|
||||
? { state: "unknown", reason: "agent_app_no_idle_signal", evidence: classification.evidence }
|
||||
/** A community harness's screen never proves it's idle: a quiet prompt can be an approval or a pause, so its idle
|
||||
* reading becomes unknown. Positive work and attention are kept. `communityHarness` is the seat's recorded selection
|
||||
* (`communityHarnessOf`); without it the classification is returned as it is. */
|
||||
export function communityHarnessPaneActivity(classification: PaneActivityClassification, communityHarness: string | null | undefined): PaneActivityClassification {
|
||||
return communityHarness && classification.state === "agent_idle"
|
||||
? { state: "unknown", reason: "community_harness_no_idle_signal", evidence: classification.evidence }
|
||||
: classification;
|
||||
}
|
||||
|
||||
@@ -643,8 +643,8 @@ export async function probeSessionActivity(input: {
|
||||
timerlessStatusIsLive?: boolean;
|
||||
/** Supplied only by an active draft-aware send; ordinary observations stay unchanged. */
|
||||
composerSnapshot?: () => Promise<ComposerSnapshot | null>;
|
||||
/** The seat's community agent app (`agentAppOf`): its idle-looking screen reads unknown. */
|
||||
agentApp?: string | null;
|
||||
/** The seat's community harness (`communityHarnessOf`): its idle-looking screen reads unknown. */
|
||||
communityHarness?: string | null;
|
||||
}): Promise<AgentActivity> {
|
||||
// Capture routing and observation labels must share the entry context. The
|
||||
// caller may reuse/mutate its input while hasSession is pending.
|
||||
@@ -759,8 +759,8 @@ export async function probeSessionActivity(input: {
|
||||
const paneContent = snapshot?.screen ?? await tmuxAdapter.capturePaneContent(sessionName, 20);
|
||||
const capturedAt = new Date().toISOString();
|
||||
const classifyOptions = { timerlessStatusIsLive: input.timerlessStatusIsLive === true };
|
||||
let classification = agentAppPaneActivity(classifyPaneActivity(paneContent ?? "", { ...classifyOptions,
|
||||
composerSnapshot: snapshot ?? undefined, runtime }), input.agentApp);
|
||||
let classification = communityHarnessPaneActivity(classifyPaneActivity(paneContent ?? "", { ...classifyOptions,
|
||||
composerSnapshot: snapshot ?? undefined, runtime }), input.communityHarness);
|
||||
// Claude renders its prompt suggestion faint, which the plain capture loses. When a Claude pane reads
|
||||
// as a draft, one capture with styling is classified on its own. Newer attention or work there is
|
||||
// kept. A faint suggestion counts only when it's the same composer text this capture read as a draft;
|
||||
|
||||
@@ -24,7 +24,7 @@ import { resolveReadinessTimeoutMs } from "./readiness-timeout.js";
|
||||
import { SettingsStore } from "./user-settings/settings-store.js";
|
||||
import { shellQuote } from "../adapters/shell-quote.js";
|
||||
import { observeClaudeResumeLaunch, type ClaudeLaunchedProcess, type NativeProcessLister } from "./native-process-lineage.js";
|
||||
import { AgentAppRuntimeAdapter } from "../adapters/agent-app-runtime-adapter.js";
|
||||
import { CommunityHarnessRuntimeAdapter } from "../adapters/community-harness-runtime-adapter.js";
|
||||
|
||||
// Expanded startup text can put the current input marker above 50 scrollback lines.
|
||||
const STARTUP_SUBMIT_CAPTURE_LINES = 200;
|
||||
@@ -414,10 +414,10 @@ export class StartupOrchestrator {
|
||||
}
|
||||
|
||||
// 5. Launch harness (unless skipped for legacy nodes)
|
||||
// A community agent app's seat: record the selection on this session before the app starts. It's provenance for
|
||||
// A community harness's seat: record the selection on this session before the app starts. It's provenance for
|
||||
// later reads (identity, activity, labels, restore), not launch success or identity proof.
|
||||
if (input.adapter instanceof AgentAppRuntimeAdapter) {
|
||||
try { this.sessionRegistry.recordAgentAppSelection(input.sessionId, input.adapter.runtime); }
|
||||
if (input.adapter instanceof CommunityHarnessRuntimeAdapter) {
|
||||
try { this.sessionRegistry.recordCommunityHarnessSelection(input.sessionId, input.adapter.runtime); }
|
||||
catch (err) { errors.push(`Couldn't record the community harness selection: ${(err as Error).message}`); return this.fail(input, "failed", errors); }
|
||||
}
|
||||
|
||||
|
||||
@@ -105,8 +105,8 @@ export interface Session {
|
||||
origin: "launched" | "claimed";
|
||||
startupStatus: "pending" | "ready" | "attention_required" | "failed";
|
||||
startupCompletedAt: string | null;
|
||||
/** The community agent app this session was launched for (selected through its preset); absent otherwise. */
|
||||
agentApp?: string;
|
||||
/** The community harness this session was launched for (selected through its preset); absent otherwise. */
|
||||
communityHarness?: string;
|
||||
}
|
||||
|
||||
// -- Event types --
|
||||
@@ -592,7 +592,7 @@ export type NodeLifecycleState = "running" | "detached" | "recoverable" | "atten
|
||||
* projection; `verified`, `binding_absent`, and `tmux_unavailable` (and an
|
||||
* absent verdict) leave it unchanged.
|
||||
*/
|
||||
/** `unconfirmed`: the seat's pane is live, but nothing proves which session it runs (a community agent app). */
|
||||
/** `unconfirmed`: the seat's pane is live, but nothing proves which session it runs (a community harness). */
|
||||
export type SeatIdentityVerdictKind = "verified" | "unconfirmed" | "mismatch" | "pane_missing" | "binding_absent" | "tmux_unavailable";
|
||||
|
||||
export interface SeatIdentityVerdict {
|
||||
@@ -665,8 +665,8 @@ export interface NodeInventoryEntry {
|
||||
attachmentType?: "tmux" | "external_cli" | null;
|
||||
nodeKind: "agent" | "infrastructure";
|
||||
runtime: string | null;
|
||||
/** Present only on a community agent app's seat: its label, maintainer and declared limits. */
|
||||
agentApp?: import("./agent-app-presets.js").AgentAppLabel;
|
||||
/** Present only on a community harness's seat: its label, maintainer and declared limits. */
|
||||
communityHarness?: import("./community-harness-presets.js").CommunityHarnessLabel;
|
||||
/** Effective current status; confirmed session absence projects as detached. */
|
||||
sessionStatus: string | null;
|
||||
/** Persisted session status, retained for history and registry-based rollups. */
|
||||
|
||||
+6
-6
@@ -1,15 +1,15 @@
|
||||
// Community agent apps: the installed presets, usable or not, so a person or an agent can see which apps can run in a
|
||||
// Community harnesses: the installed presets, usable or not, so a person or an agent can see which apps can run in a
|
||||
// seat, who maintains each, and what each declares it can't do.
|
||||
// GET /api/agent-apps — every preset under $OPENRIG_HOME/agent-apps
|
||||
// GET /api/community-harnesses — every preset under $OPENRIG_HOME/community-harnesses
|
||||
import { basename } from "node:path";
|
||||
import { Hono } from "hono";
|
||||
import { agentAppsDir, listAgentAppPresets } from "../domain/agent-app-presets.js";
|
||||
import { communityHarnessesDir, listCommunityHarnessPresets } from "../domain/community-harness-presets.js";
|
||||
|
||||
export function agentAppsRoutes(): Hono {
|
||||
export function communityHarnessesRoutes(): Hono {
|
||||
const app = new Hono();
|
||||
app.get("/", (c) => c.json({
|
||||
dir: agentAppsDir(),
|
||||
apps: listAgentAppPresets().map(found => found.ok
|
||||
dir: communityHarnessesDir(),
|
||||
harnesses: listCommunityHarnessPresets().map(found => found.ok
|
||||
? { name: found.preset.name, usable: true, community: true, testedByOpenRig: false, maintainer: found.preset.maintainer,
|
||||
limits: found.preset.limits, command: found.preset.command, path: found.preset.path }
|
||||
: { name: basename(found.path, ".yaml"), usable: false, path: found.path, problem: found.message }),
|
||||
@@ -84,7 +84,7 @@ import { jsonBodyErrorHandler, trackJsonBodyParseErrors } from "./middleware/jso
|
||||
import { getSelfHostId, getSelfHostIdSource } from "./domain/hosts/fanout-contract.js";
|
||||
import { contextPacksRoutes } from "./routes/context-packs.js";
|
||||
import { agentImagesRoutes } from "./routes/agent-images.js";
|
||||
import { agentAppsRoutes } from "./routes/agent-apps.js";
|
||||
import { communityHarnessesRoutes } from "./routes/community-harnesses.js";
|
||||
import type { SpecReviewService } from "./domain/spec-review-service.js";
|
||||
import type { SpecLibraryService } from "./domain/spec-library-service.js";
|
||||
import type { ChatRepository } from "./domain/chat-repository.js";
|
||||
@@ -777,7 +777,7 @@ export function createApp(deps: AppDeps): Hono {
|
||||
app.route("/api/agent-images", agentImagesRoutes({
|
||||
specRoots: deps.agentImageSpecRoots ?? (() => []),
|
||||
}));
|
||||
app.route("/api/agent-apps", agentAppsRoutes());
|
||||
app.route("/api/community-harnesses", communityHarnessesRoutes());
|
||||
app.route("/api/whoami", whoamiRoutes());
|
||||
// Slice-04 OPR.0.5.0.4: registered so the endpoints exist and 503 honestly; the providerService
|
||||
// is not set in context until the collection/service seam (C) wires it (until then -> 503).
|
||||
|
||||
@@ -178,7 +178,7 @@ import type { NotificationAdapter } from "./domain/mission-control/notification-
|
||||
import { OPENRIG_HOME } from "./openrig-compat.js";
|
||||
import { homeIsolationAdvisory } from "./domain/home-isolation.js";
|
||||
import { materializeBuiltinPolicyReference } from "./domain/builtin-policy-reference.js";
|
||||
import { withAgentAppResolver, withAgentApps } from "./adapters/agent-app-runtime-adapter.js";
|
||||
import { withCommunityHarnessResolver, withCommunityHarnesses } from "./adapters/community-harness-runtime-adapter.js";
|
||||
import { ensureActivityHookToken, writeActivityEndpointFile, deriveActivityUrl, readActivityEndpointFile } from "./domain/activity-endpoint.js";
|
||||
import {
|
||||
getCompatibleOpenRigPath,
|
||||
@@ -1025,7 +1025,7 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
|
||||
db, rigRepo, podRepo,
|
||||
sessionRegistry, eventBus, nodeLauncher, startupOrchestrator,
|
||||
fsOps: { readFile: (p: string) => fs.readFileSync(p, "utf-8"), exists: (p: string) => fs.existsSync(p) },
|
||||
adapters: withAgentApps({ "claude-code": claudeAdapter, "codex": codexAdapter, "pi": piAdapter, "omp": ompAdapter, "stub": stubAdapter, "terminal": new (await import("./adapters/terminal-adapter.js")).TerminalAdapter() }, { tmux: tmuxAdapter, seatLaunchEnvironment }),
|
||||
adapters: withCommunityHarnesses({ "claude-code": claudeAdapter, "codex": codexAdapter, "pi": piAdapter, "omp": ompAdapter, "stub": stubAdapter, "terminal": new (await import("./adapters/terminal-adapter.js")).TerminalAdapter() }, { tmux: tmuxAdapter, seatLaunchEnvironment }),
|
||||
tmuxAdapter,
|
||||
agentImageLibrary,
|
||||
continuityPolicyMaterializer,
|
||||
@@ -1263,7 +1263,7 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
|
||||
// Capture compatibility before the spec-library initializer creates the primary directory.
|
||||
const userSpecLibraryRoot = getDefaultOpenRigPath("specs");
|
||||
const compatibleSpecLibraryRoot = getCompatibleOpenRigPath("specs");
|
||||
const runtimeAdaptersMap = withAgentAppResolver({
|
||||
const runtimeAdaptersMap = withCommunityHarnessResolver({
|
||||
"claude-code": claudeAdapter,
|
||||
"codex": codexAdapter,
|
||||
"pi": piAdapter,
|
||||
|
||||
+57
-43
@@ -6,17 +6,17 @@ import { fileURLToPath } from "node:url";
|
||||
import { prepareHermeticEnv, type HermeticScaffold } from "./helpers/hermetic-env.js";
|
||||
import { runRig, spawnScenarioDaemon, type ScenarioDaemon } from "./helpers/scenario-daemon.js";
|
||||
|
||||
// One community agent app end to end, through the real CLI, daemon, SQLite and a private tmux server: a synthetic
|
||||
// One community harness end to end, through the real CLI, daemon, SQLite and a private tmux server: a synthetic
|
||||
// Node app installed as a preset, then discovery, start, delivery through `rig send`, activity, resume, a missing
|
||||
// preset and a built-in name collision. Nothing here runs a real agent app or touches a real home.
|
||||
// preset and a built-in name collision. Nothing here runs a real community harness or touches a real home.
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const rigBin = resolve(here, "../../cli/dist/bin-wrapper.js");
|
||||
const appScript = resolve(here, "fixtures/agent-app/synth-app.mjs");
|
||||
const appScript = resolve(here, "fixtures/community-harness/synth-harness.mjs");
|
||||
const agents = resolve(here, "../../test-system/scenarios/agents");
|
||||
const session = "work-worker@agent-app-rig";
|
||||
const session = "work-worker@community-harness-rig";
|
||||
const sleep = (ms: number) => new Promise(r => setTimeout(r, ms));
|
||||
|
||||
describe("a community agent app from a data preset", () => {
|
||||
describe("a community harness from a data preset", () => {
|
||||
let scaffold: HermeticScaffold;
|
||||
let daemon: ScenarioDaemon;
|
||||
let rigRoot: string;
|
||||
@@ -29,7 +29,7 @@ describe("a community agent app from a data preset", () => {
|
||||
return JSON.parse(result.stdout);
|
||||
}
|
||||
async function node() {
|
||||
const nodes = await json(["ps", "--nodes", "--rig", "agent-app-rig", "--full", "--json"]);
|
||||
const nodes = await json(["ps", "--nodes", "--rig", "community-harness-rig", "--full", "--json"]);
|
||||
return nodes[0];
|
||||
}
|
||||
const capture = () => execFileSync("tmux", ["-S", scaffold.tmuxSocketPath, "capture-pane", "-p", "-t", `=${session}:`], { env: scaffold.env as NodeJS.ProcessEnv }).toString();
|
||||
@@ -39,7 +39,7 @@ describe("a community agent app from a data preset", () => {
|
||||
while (!ok(value) && Date.now() < end) { await sleep(500); value = await read(); }
|
||||
return value;
|
||||
}
|
||||
const presetDir = () => join(scaffold.env.OPENRIG_HOME!, "agent-apps");
|
||||
const presetDir = () => join(scaffold.env.OPENRIG_HOME!, "community-harnesses");
|
||||
const writePreset = (file: string, body: string) => { mkdirSync(presetDir(), { recursive: true }); writeFileSync(join(presetDir(), file), body); };
|
||||
const specFor = (name: string, runtime: string) => {
|
||||
const path = join(rigRoot, `${name}.yaml`);
|
||||
@@ -52,8 +52,8 @@ describe("a community agent app from a data preset", () => {
|
||||
rigRoot = join(scaffold.root, "rig");
|
||||
mkdirSync(rigRoot);
|
||||
cpSync(agents, join(rigRoot, "agents"), { recursive: true });
|
||||
writePreset("synth-app.yaml", [
|
||||
"format: 1", "name: synth-app", `command: node ${appScript}`, "maintainer: Example Maintainer",
|
||||
writePreset("synth-harness.yaml", [
|
||||
"format: 1", "name: synth-harness", `command: node ${appScript}`, "maintainer: Example Maintainer",
|
||||
"limits:", " - resume unsupported", " - fork unsupported", " - no approval-prompt detection", "",
|
||||
].join("\n"));
|
||||
writePreset("codex.yaml", ["format: 1", "name: codex", "command: node -e 0", "maintainer: someone", ""].join("\n"));
|
||||
@@ -61,29 +61,43 @@ describe("a community agent app from a data preset", () => {
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
try { if (daemon) for (const r of ["agent-app-rig", "free-rig"]) await rig(["down", r, "--json", "--force"]); }
|
||||
try { if (daemon) for (const r of ["community-harness-rig", "free-rig"]) await rig(["down", r, "--json", "--force"]); }
|
||||
finally { await daemon?.stop(); }
|
||||
}, 60_000);
|
||||
|
||||
it("serves the renamed route and JSON keys without exposing the removed public route", async () => {
|
||||
const response = await fetch(`${daemon.baseUrl}/api/community-harnesses`);
|
||||
expect(response.status).toBe(200);
|
||||
const data = await response.json();
|
||||
expect(data.dir).toBe(presetDir());
|
||||
expect(data.harnesses.map((preset: { name: string }) => preset.name)).toContain("synth-harness");
|
||||
expect(data).not.toHaveProperty("apps");
|
||||
const oldRoute = ["agent", "apps"].join("-");
|
||||
expect((await fetch(`${daemon.baseUrl}/api/${oldRoute}`)).status).toBe(404);
|
||||
const help = await rig(["community-harness", "list", "--help"]);
|
||||
expect(help.code).toBe(0);
|
||||
expect(help.stdout).toContain("community harness presets");
|
||||
});
|
||||
|
||||
it("is discoverable with its maintainer and limits, and a preset can't take a built-in name", async () => {
|
||||
const listed = await json(["agent-app", "list", "--json"]);
|
||||
const byName = Object.fromEntries(listed.apps.map((app: { name: string }) => [app.name, app]));
|
||||
expect(byName["synth-app"]).toMatchObject({ usable: true, community: true, testedByOpenRig: false, maintainer: "Example Maintainer",
|
||||
const listed = await json(["community-harness", "list", "--json"]);
|
||||
const byName = Object.fromEntries(listed.harnesses.map((app: { name: string }) => [app.name, app]));
|
||||
expect(byName["synth-harness"]).toMatchObject({ usable: true, community: true, testedByOpenRig: false, maintainer: "Example Maintainer",
|
||||
limits: ["resume unsupported", "fork unsupported", "no approval-prompt detection"] });
|
||||
expect(byName["codex"]).toMatchObject({ usable: false, problem: '"codex" is a built-in runtime; a community harness preset can\'t use that name' });
|
||||
});
|
||||
|
||||
it("starts from the preset, labelled as a community app with identity not confirmed", async () => {
|
||||
const up = await rig(["up", specFor("agent-app-rig", "synth-app"), "--cwd", rigRoot, "--json", "--yes"], 120_000);
|
||||
const up = await rig(["up", specFor("community-harness-rig", "synth-harness"), "--cwd", rigRoot, "--json", "--yes"], 120_000);
|
||||
expect(up.code, JSON.stringify(up)).toBe(0);
|
||||
rigId = JSON.parse(up.stdout).rigId;
|
||||
expect(await until(async () => capture(), text => text.includes("synth-app 1.0 started"))).toContain("app> ");
|
||||
expect(await until(async () => capture(), text => text.includes("synth-harness 1.0 started"))).toContain("app> ");
|
||||
const seat = await node();
|
||||
expect(seat).toMatchObject({ runtime: "synth-app", sessionStatus: "running",
|
||||
agentApp: { app: "synth-app", maintainer: "Example Maintainer", identity: "not confirmed", needsInput: "from the screen only: no harness prompt pattern" } });
|
||||
const human = await rig(["ps", "--nodes", "--rig", "agent-app-rig"]);
|
||||
expect(human.stdout).toContain(`Community harness (${session}): synth-app, maintained by Example Maintainer, not tested by OpenRig. Identity not confirmed`);
|
||||
const verdict = await until(() => json(["ps", "--nodes", "--rig", "agent-app-rig", "--full", "--json"]).then(n => n[0]?.identityVerdict),
|
||||
expect(seat).toMatchObject({ runtime: "synth-harness", sessionStatus: "running",
|
||||
communityHarness: { harness: "synth-harness", maintainer: "Example Maintainer", identity: "not confirmed", needsInput: "from the screen only: no harness prompt pattern" } });
|
||||
const human = await rig(["ps", "--nodes", "--rig", "community-harness-rig"]);
|
||||
expect(human.stdout).toContain(`Community harness (${session}): synth-harness, maintained by Example Maintainer, not tested by OpenRig. Identity not confirmed`);
|
||||
const verdict = await until(() => json(["ps", "--nodes", "--rig", "community-harness-rig", "--full", "--json"]).then(n => n[0]?.identityVerdict),
|
||||
v => v?.verdict === "unconfirmed", 20_000);
|
||||
expect(verdict).toMatchObject({ verdict: "unconfirmed", reason: "process_identity_unconfirmed" });
|
||||
}, 150_000);
|
||||
@@ -117,7 +131,7 @@ describe("a community agent app from a data preset", () => {
|
||||
// rig ps keeps the prompt the screen shows: the app's default-zero needs-input isn't a clearance.
|
||||
// The ACTIVITY cell is clipped to its width, as for a built-in's "needs-input x1".
|
||||
const row = (out: string) => out.split("\n").find(line => line.includes(session)) ?? "";
|
||||
const shown = await until(() => rig(["ps", "--nodes", "--rig", "agent-app-rig"]).then(r => row(r.stdout)), line => line.includes("needs-input"), 20_000);
|
||||
const shown = await until(() => rig(["ps", "--nodes", "--rig", "community-harness-rig"]).then(r => row(r.stdout)), line => line.includes("needs-input"), 20_000);
|
||||
expect(shown).toContain("needs-input");
|
||||
// The person answers it at the pane.
|
||||
execFileSync("tmux", ["-S", scaffold.tmuxSocketPath, "send-keys", "-t", `=${session}:`, "y", "Enter"], { env: scaffold.env as NodeJS.ProcessEnv });
|
||||
@@ -154,12 +168,12 @@ describe("a community agent app from a data preset", () => {
|
||||
const launched = await rig(["launch", rigId, "work.worker", "--json"], 60_000);
|
||||
expect(launched.code).not.toBe(0);
|
||||
expect(JSON.parse(launched.stdout)).toMatchObject({ status: "awaiting-decision",
|
||||
error: `Resume is unsupported for community harness "synth-app": its preset is launch-only. No session was started, and the earlier conversation is left as it is. Re-run with --fresh work.worker only if you want a new conversation.` });
|
||||
error: `Resume is unsupported for community harness "synth-harness": its preset is launch-only. No session was started, and the earlier conversation is left as it is. Re-run with --fresh work.worker only if you want a new conversation.` });
|
||||
expect(() => capture()).toThrow();
|
||||
// A new conversation stays the person's deliberate choice.
|
||||
const fresh = await rig(["up", "agent-app-rig", "--existing", "--fresh", "work.worker", "--json"], 120_000);
|
||||
const fresh = await rig(["up", "community-harness-rig", "--existing", "--fresh", "work.worker", "--json"], 120_000);
|
||||
expect(JSON.parse(fresh.stdout).nodes, fresh.stdout).toEqual([expect.objectContaining({ logicalId: "work.worker", status: "fresh-primed" })]);
|
||||
expect(await until(async () => capture(), text => text.includes("synth-app 1.0 started"))).toContain("app> ");
|
||||
expect(await until(async () => capture(), text => text.includes("synth-harness 1.0 started"))).toContain("app> ");
|
||||
await rig(["send", session, "quit", "--json"]);
|
||||
await until(async () => capture(), text => text.includes("bye"));
|
||||
execFileSync("tmux", ["-S", scaffold.tmuxSocketPath, "kill-session", "-t", `=${session}`], { env: scaffold.env as NodeJS.ProcessEnv });
|
||||
@@ -175,16 +189,16 @@ describe("a community agent app from a data preset", () => {
|
||||
expect(all.map((n: { rigName: string }) => n.rigName)).not.toContain("missing-app-rig");
|
||||
}, 90_000);
|
||||
|
||||
it("keeps an app's seat an agent app after its preset is removed, across a daemon restart and a snapshot", async () => {
|
||||
it("keeps an app's seat a community harness after its preset is removed, across a daemon restart and a snapshot", async () => {
|
||||
// A fresh start while the preset is installed: this occupant was launched for the app.
|
||||
const fresh = await rig(["up", "agent-app-rig", "--existing", "--fresh", "work.worker", "--json"], 120_000);
|
||||
const fresh = await rig(["up", "community-harness-rig", "--existing", "--fresh", "work.worker", "--json"], 120_000);
|
||||
expect(JSON.parse(fresh.stdout).nodes, fresh.stdout).toEqual([expect.objectContaining({ logicalId: "work.worker", status: "fresh-primed" })]);
|
||||
await until(async () => capture(), text => text.includes("synth-app 1.0 started"));
|
||||
rmSync(join(presetDir(), "synth-app.yaml"));
|
||||
await until(async () => capture(), text => text.includes("synth-harness 1.0 started"));
|
||||
rmSync(join(presetDir(), "synth-harness.yaml"));
|
||||
await daemon.restart();
|
||||
const seat = await until(node, n => n.identityVerdict?.verdict === "unconfirmed", 30_000);
|
||||
expect(seat).toMatchObject({ runtime: "synth-app", identityVerdict: { verdict: "unconfirmed", reason: "process_identity_unconfirmed" } });
|
||||
expect(seat.agentApp.presetError).toContain(`no community harness preset named "synth-app" is installed`);
|
||||
expect(seat).toMatchObject({ runtime: "synth-harness", identityVerdict: { verdict: "unconfirmed", reason: "process_identity_unconfirmed" } });
|
||||
expect(seat.communityHarness.presetError).toContain(`no community harness preset named "synth-harness" is installed`);
|
||||
await sleep(8_000);
|
||||
expect((await node()).activityState).toMatchObject({ activity: "unknown", decidedBy: null });
|
||||
// Stopped, then a snapshot: still launch-only, so nothing resumes and nothing starts in its place.
|
||||
@@ -196,13 +210,13 @@ describe("a community agent app from a data preset", () => {
|
||||
const launched = await rig(["launch", rigId, "work.worker", "--json"], 60_000);
|
||||
expect(launched.code).not.toBe(0);
|
||||
expect(JSON.parse(launched.stdout)).toMatchObject({ status: "awaiting-decision",
|
||||
error: expect.stringContaining(`Resume is unsupported for community harness "synth-app"`) });
|
||||
error: expect.stringContaining(`Resume is unsupported for community harness "synth-harness"`) });
|
||||
expect(() => capture()).toThrow();
|
||||
const again = await rig(["up", "agent-app-rig", "--existing", "--fresh", "work.worker", "--json"], 120_000);
|
||||
expect(again.stdout + again.stderr).toContain("Harness not started: no synth-app runtime adapter for work.worker.");
|
||||
expect(capture()).not.toContain("synth-app 1.0 started");
|
||||
expect((await node()).agentApp.presetError).toContain(`no community harness preset named "synth-app" is installed`);
|
||||
expect(readFileSync(join(rigRoot, "agent-app-rig.yaml"), "utf-8")).toContain("runtime: synth-app");
|
||||
const again = await rig(["up", "community-harness-rig", "--existing", "--fresh", "work.worker", "--json"], 120_000);
|
||||
expect(again.stdout + again.stderr).toContain("Harness not started: no synth-harness runtime adapter for work.worker.");
|
||||
expect(capture()).not.toContain("synth-harness 1.0 started");
|
||||
expect((await node()).communityHarness.presetError).toContain(`no community harness preset named "synth-harness" is installed`);
|
||||
expect(readFileSync(join(rigRoot, "community-harness-rig.yaml"), "utf-8")).toContain("runtime: synth-harness");
|
||||
}, 300_000);
|
||||
|
||||
/** Self-attach from a new pane on the private tmux server, the way a person runs `rig attach --self`. */
|
||||
@@ -221,17 +235,17 @@ describe("a community agent app from a data preset", () => {
|
||||
const up = await rig(["up", specFor("free-rig", "stub"), "--cwd", rigRoot, "--json", "--yes"], 120_000);
|
||||
expect(up.code, JSON.stringify(up)).toBe(0);
|
||||
const freeRigId = JSON.parse(up.stdout).rigId;
|
||||
const attached = await attachSelf("free-seat", ["--rig", freeRigId, "--pod", "work", "--member", "free", "--runtime", "free-app", "--cwd", rigRoot]);
|
||||
const attached = await attachSelf("free-seat", ["--rig", freeRigId, "--pod", "work", "--member", "free", "--runtime", "free-harness", "--cwd", rigRoot]);
|
||||
expect(attached, JSON.stringify(attached)).toMatchObject({ ok: true });
|
||||
const freeSeat = async () => (await json(["ps", "--nodes", "--rig", "free-rig", "--full", "--json"])).find((n: { logicalId: string }) => n.logicalId === "work.free");
|
||||
const check = async () => {
|
||||
const seat = await until(freeSeat, n => n?.identityVerdict?.verdict === "verified" && n?.activityState?.activity === "idle-at-prompt", 30_000);
|
||||
expect(seat).toMatchObject({ runtime: "free-app", identityVerdict: { verdict: "verified", reason: null },
|
||||
expect(seat).toMatchObject({ runtime: "free-harness", identityVerdict: { verdict: "verified", reason: null },
|
||||
activityState: { activity: "idle-at-prompt", decidedBy: "window-sampling" } });
|
||||
expect(seat).not.toHaveProperty("agentApp");
|
||||
expect(seat).not.toHaveProperty("communityHarness");
|
||||
};
|
||||
await check();
|
||||
writePreset("free-app.yaml", ["format: 1", "name: free-app", `command: node ${appScript}`, "maintainer: Example Maintainer", ""].join("\n"));
|
||||
writePreset("free-harness.yaml", ["format: 1", "name: free-harness", `command: node ${appScript}`, "maintainer: Example Maintainer", ""].join("\n"));
|
||||
await sleep(4_000);
|
||||
await check();
|
||||
// A relaunch doesn't start the same-named app in its place.
|
||||
@@ -250,12 +264,12 @@ describe("a community agent app from a data preset", () => {
|
||||
await rig(["down", "free-rig", "--json", "--force"]);
|
||||
}, 300_000);
|
||||
|
||||
it("doesn't pass the agent-app classification to a different occupant of the same seat", async () => {
|
||||
expect((await rig(["down", "agent-app-rig", "--json", "--force"])).code).toBe(0);
|
||||
it("doesn't pass the community-harness classification to a different occupant of the same seat", async () => {
|
||||
expect((await rig(["down", "community-harness-rig", "--json", "--force"])).code).toBe(0);
|
||||
const attached = await attachSelf("other-occupant", ["--rig", rigId, "--node", "work.worker"]);
|
||||
expect(attached, JSON.stringify(attached)).toMatchObject({ ok: true });
|
||||
const seat = await until(node, n => n.identityVerdict?.verdict === "verified", 30_000);
|
||||
expect(seat.identityVerdict).toMatchObject({ verdict: "verified", reason: null });
|
||||
expect(seat).not.toHaveProperty("agentApp");
|
||||
expect(seat).not.toHaveProperty("communityHarness");
|
||||
}, 120_000);
|
||||
});
|
||||
+75
-54
@@ -2,11 +2,12 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { listAgentAppPresets, loadAgentAppPreset } from "../src/domain/agent-app-presets.js";
|
||||
import { AgentAppRuntimeAdapter, seatAdapter, withAgentAppResolver, withAgentApps } from "../src/adapters/agent-app-runtime-adapter.js";
|
||||
import { listCommunityHarnessPresets, loadCommunityHarnessPreset } from "../src/domain/community-harness-presets.js";
|
||||
import { CommunityHarnessRuntimeAdapter, seatAdapter, withCommunityHarnessResolver, withCommunityHarnesses } from "../src/adapters/community-harness-runtime-adapter.js";
|
||||
import { migrate } from "../src/db/migrate.js";
|
||||
import { SessionRegistry } from "../src/domain/session-registry.js";
|
||||
import {
|
||||
AGENT_APP_RUNG_INVENTORY,
|
||||
COMMUNITY_HARNESS_RUNG_INVENTORY,
|
||||
CLAUDE_ACTIVITY_RUNG_INVENTORY,
|
||||
CODEX_ACTIVITY_RUNG_INVENTORY,
|
||||
TMUX_GENERIC_RUNG_INVENTORY,
|
||||
@@ -15,8 +16,8 @@ import {
|
||||
import { SeatActivityService } from "../src/domain/seat-activity-service.js";
|
||||
import { SeatIdentityReconciler } from "../src/domain/seat-identity-reconciler.js";
|
||||
import { SeatIdentityStore } from "../src/domain/seat-identity-store.js";
|
||||
import { createFullTestDb } from "./helpers/test-app.js";
|
||||
import { agentAppPaneActivity, classifyPaneActivity } from "../src/domain/session-transport.js";
|
||||
import { createFullTestDb, migrationsForFullTestDb } from "./helpers/test-app.js";
|
||||
import { communityHarnessPaneActivity, classifyPaneActivity } from "../src/domain/session-transport.js";
|
||||
import type { RuntimeAdapter, NodeBinding } from "../src/domain/runtime-adapter.js";
|
||||
import { TmuxAdapter } from "../src/adapters/tmux.js";
|
||||
import Database from "better-sqlite3";
|
||||
@@ -31,18 +32,18 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
function presets(files: Record<string, string>): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), "agent-apps-"));
|
||||
const dir = mkdtempSync(join(tmpdir(), "community-harnesses-"));
|
||||
dirs.push(dir);
|
||||
for (const [name, body] of Object.entries(files)) writeFileSync(join(dir, name), body);
|
||||
return dir;
|
||||
}
|
||||
const GOOD = "format: 1\nname: synth\ncommand: synth-app --serve\nmaintainer: Example Maintainer\nlimits: [resume unsupported]\n";
|
||||
const GOOD = "format: 1\nname: synth\ncommand: synth-harness --serve\nmaintainer: Example Maintainer\nlimits: [resume unsupported]\n";
|
||||
|
||||
describe("agent app presets", () => {
|
||||
describe("community harness presets", () => {
|
||||
it("loads a preset with the fields this format reads", () => {
|
||||
const dir = presets({ "synth.yaml": GOOD });
|
||||
expect(loadAgentAppPreset("synth", dir)).toEqual({ ok: true, preset: {
|
||||
name: "synth", command: "synth-app --serve", maintainer: "Example Maintainer", limits: ["resume unsupported"], path: join(dir, "synth.yaml") } });
|
||||
expect(loadCommunityHarnessPreset("synth", dir)).toEqual({ ok: true, preset: {
|
||||
name: "synth", command: "synth-harness --serve", maintainer: "Example Maintainer", limits: ["resume unsupported"], path: join(dir, "synth.yaml") } });
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -50,24 +51,24 @@ describe("agent app presets", () => {
|
||||
["another format", { "synth.yaml": GOOD.replace("format: 1", "format: 2") }, "incompatible", "it declares format 2; this OpenRig reads format 1"],
|
||||
["a field this format doesn't read", { "synth.yaml": `${GOOD}resume: --resume {token}\n` }, "incompatible", "unknown field resume"],
|
||||
["a name that isn't the file's", { "synth.yaml": GOOD.replace("name: synth", "name: other") }, "incompatible", `its name is "other", not "synth"`],
|
||||
["no command", { "synth.yaml": GOOD.replace("command: synth-app --serve\n", "") }, "incompatible", "command must be a non-empty string"],
|
||||
["no command", { "synth.yaml": GOOD.replace("command: synth-harness --serve\n", "") }, "incompatible", "command must be a non-empty string"],
|
||||
["YAML that doesn't parse", { "synth.yaml": "format: [1" }, "incompatible", "it isn't valid YAML"],
|
||||
])("says why it can't use %s", (_label, files, kind, text) => {
|
||||
const found = loadAgentAppPreset("synth", presets(files as Record<string, string>));
|
||||
const found = loadCommunityHarnessPreset("synth", presets(files as Record<string, string>));
|
||||
expect(found).toMatchObject({ ok: false, kind });
|
||||
expect(!found.ok && found.message).toContain(text);
|
||||
});
|
||||
|
||||
it("calls a preset file with a name it can't take incompatible, and never looks outside the directory", () => {
|
||||
const dir = presets({ "My_App.yaml": GOOD.replace("name: synth", "name: My_App") });
|
||||
expect(loadAgentAppPreset("My_App", dir)).toMatchObject({ ok: false, kind: "incompatible",
|
||||
expect(loadCommunityHarnessPreset("My_App", dir)).toMatchObject({ ok: false, kind: "incompatible",
|
||||
message: expect.stringContaining("its name must be lowercase letters, digits and hyphens") });
|
||||
expect(listAgentAppPresets(dir)).toEqual([expect.objectContaining({ ok: false, kind: "incompatible" })]);
|
||||
expect(listCommunityHarnessPresets(dir)).toEqual([expect.objectContaining({ ok: false, kind: "incompatible" })]);
|
||||
// A file one level up, in a directory this test owns, is never reached by "../outside".
|
||||
const parent = presets({ "outside.yaml": GOOD.replace("name: synth", "name: outside") });
|
||||
const inner = join(parent, "agent-apps");
|
||||
const inner = join(parent, "community-harnesses");
|
||||
mkdirSync(inner);
|
||||
expect(loadAgentAppPreset("../outside", inner)).toMatchObject({ ok: false, kind: "missing" });
|
||||
expect(loadCommunityHarnessPreset("../outside", inner)).toMatchObject({ ok: false, kind: "missing" });
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -82,55 +83,55 @@ describe("agent app presets", () => {
|
||||
["! (history expansion)", "node app.mjs --last !!"],
|
||||
["! (history expansion)", 'node app.mjs --label "hi!"'],
|
||||
])("keeps shell syntax (%s) out of the launch line typed into the pane's shell", (shown, command) => {
|
||||
const found = loadAgentAppPreset("synth", presets({ "synth.yaml": GOOD.replace("command: synth-app --serve", `command: ${command}`) }));
|
||||
const found = loadCommunityHarnessPreset("synth", presets({ "synth.yaml": GOOD.replace("command: synth-harness --serve", `command: ${command}`) }));
|
||||
expect(found).toMatchObject({ ok: false, kind: "incompatible" });
|
||||
expect(!found.ok && found.message).toContain(`command must be a single program and its arguments; shell syntax such as ${shown} isn't supported: put it in a script and name the script`);
|
||||
});
|
||||
|
||||
it("accepts a program whose arguments are quoted, operators inside single quotes included", () => {
|
||||
const command = `node "/srv/my apps/app.mjs" --label 'a|b && c!' --name "two words" --path 'C:\\apps\\x'`;
|
||||
const found = loadAgentAppPreset("synth", presets({ "synth.yaml": GOOD.replace("command: synth-app --serve", `command: ${command}`) }));
|
||||
const found = loadCommunityHarnessPreset("synth", presets({ "synth.yaml": GOOD.replace("command: synth-harness --serve", `command: ${command}`) }));
|
||||
expect(found).toMatchObject({ ok: true, preset: { command } });
|
||||
});
|
||||
|
||||
it("reserves every built-in runtime name", () => {
|
||||
for (const name of ["claude-code", "codex", "pi", "omp", "terminal", "stub"]) {
|
||||
const dir = presets({ [`${name}.yaml`]: GOOD.replace("name: synth", `name: ${name}`) });
|
||||
expect(loadAgentAppPreset(name, dir)).toMatchObject({ ok: false, kind: "incompatible",
|
||||
expect(loadCommunityHarnessPreset(name, dir)).toMatchObject({ ok: false, kind: "incompatible",
|
||||
message: `"${name}" is a built-in runtime; a community harness preset can't use that name` });
|
||||
}
|
||||
});
|
||||
|
||||
it("lists every preset file, usable or not", () => {
|
||||
const dir = presets({ "synth.yaml": GOOD, "codex.yaml": GOOD.replace("name: synth", "name: codex"), "notes.txt": "x" });
|
||||
expect(listAgentAppPresets(dir).map(found => found.ok)).toEqual([false, true]);
|
||||
expect(listCommunityHarnessPresets(dir).map(found => found.ok)).toEqual([false, true]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the agent app adapter", () => {
|
||||
describe("the community harness adapter", () => {
|
||||
function adapter(dir: string) {
|
||||
const tmux = {
|
||||
hasSession: vi.fn(async () => true),
|
||||
getPaneCommand: vi.fn(async () => "node"),
|
||||
sendShellCommand: vi.fn(async () => ({ ok: true as const })),
|
||||
};
|
||||
return { tmux, app: new AgentAppRuntimeAdapter("synth", { tmux, presetsDir: () => dir }) };
|
||||
return { tmux, app: new CommunityHarnessRuntimeAdapter("synth", { tmux, presetsDir: () => dir }) };
|
||||
}
|
||||
const binding = { tmuxSession: "w@r" } as NodeBinding;
|
||||
|
||||
it("types the preset's command into the seat's pane", async () => {
|
||||
const { tmux, app } = adapter(presets({ "synth.yaml": GOOD }));
|
||||
expect(await app.launchHarness(binding, { name: "w@r" })).toEqual({ ok: true });
|
||||
expect(tmux.sendShellCommand).toHaveBeenCalledWith("w@r", "synth-app --serve", undefined, { stageIfLong: true, execInScript: true });
|
||||
expect(tmux.sendShellCommand).toHaveBeenCalledWith("w@r", "synth-harness --serve", undefined, { stageIfLong: true, execInScript: true });
|
||||
});
|
||||
|
||||
it("types the command through the seat's launch environment, as a real daemon does", async () => {
|
||||
const { tmux } = adapter(presets({ "synth.yaml": GOOD }));
|
||||
const command = vi.fn(async (_session: string, cmd: string, target?: { runtime?: string }) => `/usr/bin/env OPENRIG_RUNTIME=${target?.runtime} ${cmd}`);
|
||||
const app = new AgentAppRuntimeAdapter("synth", { tmux, seatLaunchEnvironment: { command }, presetsDir: () => presets({ "synth.yaml": GOOD }) });
|
||||
const app = new CommunityHarnessRuntimeAdapter("synth", { tmux, seatLaunchEnvironment: { command }, presetsDir: () => presets({ "synth.yaml": GOOD }) });
|
||||
expect(await app.launchHarness(binding, { name: "w@r" })).toEqual({ ok: true });
|
||||
expect(command).toHaveBeenCalledWith("w@r", "synth-app --serve", { runtime: "synth" });
|
||||
expect(tmux.sendShellCommand).toHaveBeenCalledWith("w@r", "/usr/bin/env OPENRIG_RUNTIME=synth synth-app --serve", undefined, { stageIfLong: true, execInScript: true });
|
||||
expect(command).toHaveBeenCalledWith("w@r", "synth-harness --serve", { runtime: "synth" });
|
||||
expect(tmux.sendShellCommand).toHaveBeenCalledWith("w@r", "/usr/bin/env OPENRIG_RUNTIME=synth synth-harness --serve", undefined, { stageIfLong: true, execInScript: true });
|
||||
});
|
||||
|
||||
it("refuses resume and fork without starting anything", async () => {
|
||||
@@ -146,7 +147,7 @@ describe("the agent app adapter", () => {
|
||||
const { tmux, app } = adapter(presets({ "synth.yaml": GOOD }));
|
||||
expect(await app.checkReady(binding)).toEqual({ ready: true });
|
||||
tmux.getPaneCommand.mockResolvedValue("bash");
|
||||
expect(await app.checkReady(binding)).toMatchObject({ ready: false, code: "app_not_running" });
|
||||
expect(await app.checkReady(binding)).toMatchObject({ ready: false, code: "harness_not_running" });
|
||||
});
|
||||
|
||||
it("places nothing and says so", async () => {
|
||||
@@ -161,7 +162,7 @@ describe("relaunching a seat", () => {
|
||||
const builtIn = (runtime: string) => ({ runtime }) as RuntimeAdapter;
|
||||
const tmux = {} as Pick<TmuxAdapter, "hasSession" | "getPaneCommand" | "sendShellCommand">;
|
||||
function map(dir: string) {
|
||||
return withAgentAppResolver({ codex: builtIn("codex"), terminal: builtIn("terminal") } as Record<string, RuntimeAdapter>, { tmux, presetsDir: () => dir });
|
||||
return withCommunityHarnessResolver({ codex: builtIn("codex"), terminal: builtIn("terminal") } as Record<string, RuntimeAdapter>, { tmux, presetsDir: () => dir });
|
||||
}
|
||||
|
||||
it("keeps the map's keys the built-ins, and returns them as they are", () => {
|
||||
@@ -171,35 +172,55 @@ describe("relaunching a seat", () => {
|
||||
expect(m["synth"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("resolves an agent app only for a seat launched for it", () => {
|
||||
it("resolves a community harness only for a seat launched for it", () => {
|
||||
const m = map(presets({ "synth.yaml": GOOD }));
|
||||
expect(seatAdapter(m, "synth", "synth")).toBeInstanceOf(AgentAppRuntimeAdapter);
|
||||
expect(seatAdapter(m, "synth", "synth")).toBeInstanceOf(CommunityHarnessRuntimeAdapter);
|
||||
// A free-text runtime that only matches a preset's name gets no adapter, as before the preset existed.
|
||||
expect(seatAdapter(m, "synth", null)).toBeUndefined();
|
||||
expect(seatAdapter(m, "synth", "other-app")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("gives an agent app's seat no adapter once its preset is gone", () => {
|
||||
it("gives a community harness's seat no adapter once its preset is gone", () => {
|
||||
expect(seatAdapter(map(presets({})), "synth", "synth")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("the agent app selection recorded on a session", () => {
|
||||
describe("the community harness selection recorded on a session", () => {
|
||||
it("keeps schema 114 applied once and maps its existing selection through the renamed field", () => {
|
||||
const db = createFullTestDb(); dbs.push(db);
|
||||
const migration = migrationsForFullTestDb.find(item => item.name.startsWith("114_"))!;
|
||||
expect(migration).toBeDefined();
|
||||
const ledgerBefore = db.prepare("SELECT name, applied_at FROM schema_migrations WHERE name = ?").get(migration.name);
|
||||
expect(ledgerBefore).toBeDefined();
|
||||
const registry = new SessionRegistry(db);
|
||||
db.prepare("INSERT INTO rigs (id, name) VALUES ('rig-1', 'r')").run();
|
||||
db.prepare("INSERT INTO nodes (id, rig_id, logical_id, runtime, cwd) VALUES ('n1', 'rig-1', 'work.worker', 'synth', '/tmp')").run();
|
||||
const session = registry.registerSession("n1", "work-worker@r");
|
||||
registry.recordCommunityHarnessSelection(session.id, "synth");
|
||||
const before = db.prepare("SELECT * FROM sessions WHERE id = ?").get(session.id);
|
||||
// Calling migrate on a database that has 114 must skip it, not repeat its ALTER or rewrite data.
|
||||
migrate(db, migrationsForFullTestDb);
|
||||
expect(db.prepare("SELECT name, applied_at FROM schema_migrations WHERE name = ?").get(migration.name)).toEqual(ledgerBefore);
|
||||
expect(db.prepare("SELECT * FROM sessions WHERE id = ?").get(session.id)).toEqual(before);
|
||||
expect(registry.getSessionsForRig("rig-1")[0]).toHaveProperty("communityHarness", "synth");
|
||||
expect(Object.keys(registry.getSessionsForRig("rig-1")[0]!)).not.toContain(["agent", "App"].join(""));
|
||||
});
|
||||
|
||||
it("is read back only for the session it was recorded on", () => {
|
||||
const db = createFullTestDb(); dbs.push(db);
|
||||
const registry = new SessionRegistry(db);
|
||||
db.prepare("INSERT INTO rigs (id, name) VALUES ('rig-1', 'r')").run();
|
||||
db.prepare("INSERT INTO nodes (id, rig_id, logical_id, runtime, cwd) VALUES ('n1', 'rig-1', 'work.worker', 'synth', '/tmp')").run();
|
||||
const launched = registry.registerSession("n1", "work-worker@r");
|
||||
registry.recordAgentAppSelection(launched.id, "synth");
|
||||
registry.recordCommunityHarnessSelection(launched.id, "synth");
|
||||
const claimed = registry.registerClaimedSession("n1", "work-worker@r");
|
||||
const sessions = registry.getSessionsForRig("rig-1");
|
||||
expect(sessions.find(s => s.id === launched.id)?.agentApp).toBe("synth");
|
||||
expect(sessions.find(s => s.id === claimed.id)).not.toHaveProperty("agentApp");
|
||||
expect(sessions.find(s => s.id === launched.id)?.communityHarness).toBe("synth");
|
||||
expect(sessions.find(s => s.id === claimed.id)).not.toHaveProperty("communityHarness");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the runtime adapter map with agent apps", () => {
|
||||
describe("the runtime adapter map with community harnesses", () => {
|
||||
const builtIn = (runtime: string) => ({ runtime }) as RuntimeAdapter;
|
||||
const builtIns = () => ({ "claude-code": builtIn("claude-code"), codex: builtIn("codex"), pi: builtIn("pi"), omp: builtIn("omp"), stub: builtIn("stub"), terminal: builtIn("terminal") });
|
||||
const tmux = {} as Pick<TmuxAdapter, "hasSession" | "getPaneCommand" | "sendShellCommand">;
|
||||
@@ -207,15 +228,15 @@ describe("the runtime adapter map with agent apps", () => {
|
||||
it("returns each built-in adapter as it is, even when a preset file takes its name", () => {
|
||||
const original = builtIns();
|
||||
const dir = presets({ "codex.yaml": GOOD.replace("name: synth", "name: codex"), "synth.yaml": GOOD });
|
||||
const map = withAgentApps(original, { tmux, presetsDir: () => dir });
|
||||
const map = withCommunityHarnesses(original, { tmux, presetsDir: () => dir });
|
||||
for (const [name, adapter] of Object.entries(original)) expect(map[name as keyof typeof original]).toBe(adapter);
|
||||
expect(Object.keys(map)).toEqual(Object.keys(original));
|
||||
});
|
||||
|
||||
it("resolves an installed app, and nothing for one that isn't installed", () => {
|
||||
const dir = presets({ "synth.yaml": GOOD });
|
||||
const map: Record<string, RuntimeAdapter> = withAgentApps(builtIns(), { tmux, presetsDir: () => dir });
|
||||
expect(map["synth"]).toBeInstanceOf(AgentAppRuntimeAdapter);
|
||||
const map: Record<string, RuntimeAdapter> = withCommunityHarnesses(builtIns(), { tmux, presetsDir: () => dir });
|
||||
expect(map["synth"]).toBeInstanceOf(CommunityHarnessRuntimeAdapter);
|
||||
expect(map["synth"]!.runtime).toBe("synth");
|
||||
expect("synth" in map).toBe(true);
|
||||
expect(map["no-such-app"]).toBeUndefined();
|
||||
@@ -223,18 +244,18 @@ describe("the runtime adapter map with agent apps", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("activity for an agent app", () => {
|
||||
it("gives built-ins the inventories they had, and agent apps their own", () => {
|
||||
describe("activity for a community harness", () => {
|
||||
it("gives built-ins the inventories they had, and community harnesses their own", () => {
|
||||
expect(runtimeRungInventory("claude-code")).toBe(CLAUDE_ACTIVITY_RUNG_INVENTORY);
|
||||
expect(runtimeRungInventory("codex")).toBe(CODEX_ACTIVITY_RUNG_INVENTORY);
|
||||
for (const runtime of ["pi", "omp", "terminal", "stub", null]) expect(runtimeRungInventory(runtime)).toBe(TMUX_GENERIC_RUNG_INVENTORY);
|
||||
// Only a seat launched for the app gets its inventory; a free-text runtime keeps the generic floor, as before.
|
||||
expect(runtimeRungInventory("synth", "synth")).toBe(AGENT_APP_RUNG_INVENTORY);
|
||||
expect(runtimeRungInventory("synth", "synth")).toBe(COMMUNITY_HARNESS_RUNG_INVENTORY);
|
||||
expect(runtimeRungInventory("synth")).toBe(TMUX_GENERIC_RUNG_INVENTORY);
|
||||
expect(runtimeRungInventory("synth", "other-app")).toBe(TMUX_GENERIC_RUNG_INVENTORY);
|
||||
});
|
||||
|
||||
function seat(inventory: typeof AGENT_APP_RUNG_INVENTORY) {
|
||||
function seat(inventory: typeof COMMUNITY_HARNESS_RUNG_INVENTORY) {
|
||||
const svc = new SeatActivityService({ tmux: {} as never, defaultWindowSeconds: 3 });
|
||||
svc.declareRungInventory({ seatNodeId: "n1", sessionName: "w@r" }, inventory);
|
||||
let seq = 0;
|
||||
@@ -244,7 +265,7 @@ describe("activity for an agent app", () => {
|
||||
}
|
||||
|
||||
it("reads a quiet screen as unknown and a changing one as working, decided by window sampling", () => {
|
||||
const s = seat(AGENT_APP_RUNG_INVENTORY);
|
||||
const s = seat(COMMUNITY_HARNESS_RUNG_INVENTORY);
|
||||
expect(s.state()).toMatchObject({ activity: "unknown", decidedBy: null });
|
||||
s.sample("idle-at-prompt");
|
||||
expect(s.state()).toMatchObject({ activity: "unknown", decidedBy: null, needsInputEvidence: null });
|
||||
@@ -261,23 +282,23 @@ describe("activity for an agent app", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("the pane reader for an agent app", () => {
|
||||
it("turns an idle-looking screen into unknown for an agent app, and keeps work, attention and built-ins as they were", () => {
|
||||
describe("the pane reader for a community harness", () => {
|
||||
it("turns an idle-looking screen into unknown for a community harness, and keeps work, attention and built-ins as they were", () => {
|
||||
const idle = classifyPaneActivity("some output\n❯ ");
|
||||
expect(idle.state).toBe("agent_idle");
|
||||
expect(agentAppPaneActivity(idle, "synth")).toEqual({ state: "unknown", reason: "agent_app_no_idle_signal", evidence: idle.evidence });
|
||||
for (const agentApp of [null, undefined, ""]) expect(agentAppPaneActivity(idle, agentApp)).toBe(idle);
|
||||
expect(communityHarnessPaneActivity(idle, "synth")).toEqual({ state: "unknown", reason: "community_harness_no_idle_signal", evidence: idle.evidence });
|
||||
for (const communityHarness of [null, undefined, ""]) expect(communityHarnessPaneActivity(idle, communityHarness)).toBe(idle);
|
||||
const prompt = classifyPaneActivity("Do you want to proceed? (y/n)");
|
||||
expect(prompt.state).toBe("attention");
|
||||
expect(agentAppPaneActivity(prompt, "synth")).toBe(prompt);
|
||||
expect(communityHarnessPaneActivity(prompt, "synth")).toBe(prompt);
|
||||
const busy = classifyPaneActivity("⠋ Working (esc to interrupt)");
|
||||
expect(agentAppPaneActivity(busy, "synth")).toBe(busy);
|
||||
expect(communityHarnessPaneActivity(busy, "synth")).toBe(busy);
|
||||
});
|
||||
});
|
||||
|
||||
describe("activity after the seat's occupant is replaced under the same session name", () => {
|
||||
it("follows the new occupant's own selection: a free-text occupant's quiet screen reads idle, as on main", async () => {
|
||||
const scratch = mkdtempSync(join(tmpdir(), "agent-app-swap-"));
|
||||
const scratch = mkdtempSync(join(tmpdir(), "community-harness-swap-"));
|
||||
dirs.push(scratch);
|
||||
const socket = join(scratch, "tmux.sock");
|
||||
const tmux = async (...args: string[]) => (await promisify(execFile)("tmux", ["-S", socket, ...args])).stdout;
|
||||
@@ -306,12 +327,12 @@ describe("activity after the seat's occupant is replaced under the same session
|
||||
});
|
||||
});
|
||||
|
||||
describe("identity for an agent app", () => {
|
||||
function reconcile(runtime: string, command: string, agentApp: string | null = null) {
|
||||
describe("identity for a community harness", () => {
|
||||
function reconcile(runtime: string, command: string, communityHarness: string | null = null) {
|
||||
const db = createFullTestDb(); dbs.push(db);
|
||||
db.prepare("INSERT INTO rigs (id, name) VALUES ('rig-1', 'r')").run();
|
||||
db.prepare("INSERT INTO nodes (id, rig_id, logical_id, runtime, cwd) VALUES ('n1', 'rig-1', 'work.worker', ?, '/tmp')").run(runtime);
|
||||
db.prepare("INSERT INTO sessions (id, node_id, session_name, status, startup_status, agent_app) VALUES ('s1', 'n1', 'w@r', 'running', 'ready', ?)").run(agentApp);
|
||||
db.prepare("INSERT INTO sessions (id, node_id, session_name, status, startup_status, agent_app) VALUES ('s1', 'n1', 'w@r', 'running', 'ready', ?)").run(communityHarness);
|
||||
db.prepare("INSERT INTO bindings (id, node_id, attachment_type, tmux_session, tmux_pane) VALUES ('b1', 'n1', 'tmux', 'w@r', '%1')").run();
|
||||
const tmux = { listSessions: vi.fn(async () => [{ name: "w@r" }] as never), getPanePid: vi.fn(async () => 10), getPaneCommand: vi.fn(async () => command) };
|
||||
return { rec: new SeatIdentityReconciler({ db, tmux, now: () => new Date("2026-10-17T12:00:00Z") }), verdict: () => new SeatIdentityStore(db).getForNode("n1") };
|
||||
+2
-2
@@ -1,10 +1,10 @@
|
||||
// A synthetic community agent app for the agent-app runtime tests. It runs in the seat's pane as a Node process and
|
||||
// A synthetic community harness for the community-harness runtime tests. It runs in the seat's pane as a Node process and
|
||||
// reads one line at a time, skipping the envelope lines `rig send` adds: "busy" prints changing output for a few seconds, "ask" shows an approval prompt, "quit"
|
||||
// exits back to the shell, and anything else is echoed back.
|
||||
import { createInterface } from "node:readline";
|
||||
|
||||
const write = (text) => process.stdout.write(text);
|
||||
write("synth-app 1.0 started\napp> ");
|
||||
write("synth-harness 1.0 started\napp> ");
|
||||
const rl = createInterface({ input: process.stdin });
|
||||
let asking = false;
|
||||
// `rig send` wraps a message in a short envelope (sender lines, a separator, a reply hint). Those lines aren't input.
|
||||
@@ -7,7 +7,7 @@ import { sessionStopIntentSchema } from "../../src/db/migrations/103_session_sto
|
||||
import { nodeEffortChoiceSchema } from "../../src/db/migrations/104_node_effort_choice.js";
|
||||
import { seatEnvSchema } from "../../src/db/migrations/111_seat_env.js";
|
||||
import { appliedLaunchDaemonOptOutSchema } from "../../src/db/migrations/113_applied_launch_daemon_opt_out.js";
|
||||
import { sessionAgentAppSchema } from "../../src/db/migrations/114_session_agent_app.js";
|
||||
import { sessionAgentAppSchema as sessionCommunityHarnessSchema } from "../../src/db/migrations/114_session_agent_app.js";
|
||||
import { threadSeatMapChannelKeySchema } from "../../src/db/migrations/115_thread_seat_map_channel_key.js";
|
||||
import { modePostureCoexistSchema } from "../../src/db/migrations/109_mode_posture_coexist.js";
|
||||
import { rigNonInterruptiveSchema } from "../../src/db/migrations/095_rig_non_interruptive.js";
|
||||
@@ -132,7 +132,7 @@ import fs from "node:fs";
|
||||
|
||||
/** Seam B R6: the canonical full-fixture migration list, exported so file-backed
|
||||
* DB-reopen tests migrate IDENTICALLY to createFullTestDb. */
|
||||
export const migrationsForFullTestDb = [coreSchema, bindingsSessionsSchema, eventsSchema, snapshotsSchema, checkpointsSchema, resumeMetadataSchema, nodeSpecFieldsSchema, packagesSchema, installJournalSchema, journalSeqSchema, bootstrapSchema, discoverySchema, discoveryFkFix, agentspecRebootSchema, startupContextSchema, chatMessagesSchema, podNamespaceSchema, contextUsageSchema, externalCliAttachmentSchema, rigServicesSchema, seatHandoverObservabilitySchema, nodeCodexConfigProfileSchema, nodePermissionPolicySchema, rigPermissionPolicySchema, nodePolicyProvenanceSchema, rigPolicyProvenanceSchema, streamItemsSchema, queueItemsSchema, queueTransitionsSchema, rigPolicySchema, rigArchiveSchema, resumeProvenanceSchema, resumeVerificationSchema, seatIdentityVerdictsSchema, selfHostIdentitySchema, occupantTenuresSchema, daemonLifecycleSchema, watchdogJobsSchema, occupantGenerationStampsSchema, projectionManifestSchema, watchdogTargetGenerationSchema, appliedLaunchObservationsSchema, appliedLaunchObservationInvalidationsSchema, threadSeatMapSchema, queueTransitionWakesSchema, nodeSessionSourceSchema, scopedOperatingPostureSchema, humanNotificationIntentSchema, reviewReadIndexesSchema, inventoryEventIndexesSchema, rigClaudeManagedBlockFileSchema, nodePermissionSelectionsSchema, humanReplyToSchema, humanQuestionsSchema, nodeEffortSchema, rigNonInterruptiveSchema, rigInstallRootSchema, threadPartMapSchema, queueTransitionsQitemIdOrderSchema, queueItemsRecoveryTagsSchema, resumeRotationSchema, sessionStopIntentSchema, nodeEffortChoiceSchema, modePostureCoexistSchema, seatEnvSchema, appliedLaunchDaemonOptOutSchema, sessionAgentAppSchema, threadSeatMapChannelKeySchema];
|
||||
export const migrationsForFullTestDb = [coreSchema, bindingsSessionsSchema, eventsSchema, snapshotsSchema, checkpointsSchema, resumeMetadataSchema, nodeSpecFieldsSchema, packagesSchema, installJournalSchema, journalSeqSchema, bootstrapSchema, discoverySchema, discoveryFkFix, agentspecRebootSchema, startupContextSchema, chatMessagesSchema, podNamespaceSchema, contextUsageSchema, externalCliAttachmentSchema, rigServicesSchema, seatHandoverObservabilitySchema, nodeCodexConfigProfileSchema, nodePermissionPolicySchema, rigPermissionPolicySchema, nodePolicyProvenanceSchema, rigPolicyProvenanceSchema, streamItemsSchema, queueItemsSchema, queueTransitionsSchema, rigPolicySchema, rigArchiveSchema, resumeProvenanceSchema, resumeVerificationSchema, seatIdentityVerdictsSchema, selfHostIdentitySchema, occupantTenuresSchema, daemonLifecycleSchema, watchdogJobsSchema, occupantGenerationStampsSchema, projectionManifestSchema, watchdogTargetGenerationSchema, appliedLaunchObservationsSchema, appliedLaunchObservationInvalidationsSchema, threadSeatMapSchema, queueTransitionWakesSchema, nodeSessionSourceSchema, scopedOperatingPostureSchema, humanNotificationIntentSchema, reviewReadIndexesSchema, inventoryEventIndexesSchema, rigClaudeManagedBlockFileSchema, nodePermissionSelectionsSchema, humanReplyToSchema, humanQuestionsSchema, nodeEffortSchema, rigNonInterruptiveSchema, rigInstallRootSchema, threadPartMapSchema, queueTransitionsQitemIdOrderSchema, queueItemsRecoveryTagsSchema, resumeRotationSchema, sessionStopIntentSchema, nodeEffortChoiceSchema, modePostureCoexistSchema, seatEnvSchema, appliedLaunchDaemonOptOutSchema, sessionCommunityHarnessSchema, threadSeatMapChannelKeySchema];
|
||||
|
||||
/**
|
||||
* P24 — the DECLARED exclusions for {@link migrationsForFullTestDb}. That list is deliberately a
|
||||
|
||||
@@ -2647,23 +2647,23 @@ describe("RestoreOrchestrator", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("an agent app's seat stops at resume-unsupported even with a saved token, launching nothing", async () => {
|
||||
it("a community harness's seat stops at resume-unsupported even with a saved token, launching nothing", async () => {
|
||||
const rig = rigRepo.createRig("test-rig");
|
||||
db.prepare("INSERT INTO pods (id, rig_id, label) VALUES (?, ?, ?)").run("pod-agent-app", rig.id, "Dev");
|
||||
const node = rigRepo.addNode(rig.id, "dev.impl", { runtime: "synth-app", podId: "pod-agent-app" });
|
||||
db.prepare("INSERT INTO pods (id, rig_id, label) VALUES (?, ?, ?)").run("pod-community-harness", rig.id, "Dev");
|
||||
const node = rigRepo.addNode(rig.id, "dev.impl", { runtime: "synth-harness", podId: "pod-community-harness" });
|
||||
const session = sessionRegistry.registerSession(node.id, "dev-impl@test-rig");
|
||||
sessionRegistry.updateStatus(session.id, "running");
|
||||
// A token left by a former runtime: nothing resumes an agent app with it.
|
||||
// A token left by a former runtime: nothing resumes a community harness with it.
|
||||
sessionRegistry.updateResumeToken(session.id, "codex_id", "former-runtime-token");
|
||||
sessionRegistry.recordAgentAppSelection(session.id, "synth-app");
|
||||
db.prepare("INSERT INTO node_startup_context (node_id, projection_entries_json, resolved_files_json, startup_actions_json, runtime) VALUES (?, ?, ?, ?, ?)").run(node.id, "[]", "[]", "[]", "synth-app");
|
||||
sessionRegistry.recordCommunityHarnessSelection(session.id, "synth-harness");
|
||||
db.prepare("INSERT INTO node_startup_context (node_id, projection_entries_json, resolved_files_json, startup_actions_json, runtime) VALUES (?, ?, ?, ?, ?)").run(node.id, "[]", "[]", "[]", "synth-harness");
|
||||
const snap = snapshotCapture.captureSnapshot(rig.id, "test");
|
||||
sessionRegistry.updateStatus(session.id, "exited");
|
||||
db.prepare("DELETE FROM bindings WHERE node_id = ?").run(node.id);
|
||||
|
||||
const launchHarness = vi.fn(async () => ({ ok: true as const }));
|
||||
const mockAdapter = {
|
||||
runtime: "synth-app",
|
||||
runtime: "synth-harness",
|
||||
listInstalled: vi.fn(async () => []),
|
||||
project: vi.fn(async () => ({ projected: [], skipped: [], failed: [] })),
|
||||
deliverStartup: vi.fn(async () => ({ delivered: 0, failed: [] })),
|
||||
@@ -2672,25 +2672,25 @@ describe("RestoreOrchestrator", () => {
|
||||
};
|
||||
|
||||
const orch = createOrchestrator();
|
||||
const result = await orch.restore(snap.id, { adapters: { "synth-app": mockAdapter } });
|
||||
const result = await orch.restore(snap.id, { adapters: { "synth-harness": mockAdapter } });
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
const nodeResult = result.result.nodes.find((n) => n.nodeId === node.id);
|
||||
expect(nodeResult!.status).toBe("awaiting-decision");
|
||||
expect(nodeResult!.error).toBe(`Resume is unsupported for community harness "synth-app": its preset is launch-only. No session was started, and the earlier conversation is left as it is. Re-run with --fresh dev.impl only if you want a new conversation.`);
|
||||
expect(nodeResult!.error).toBe(`Resume is unsupported for community harness "synth-harness": its preset is launch-only. No session was started, and the earlier conversation is left as it is. Re-run with --fresh dev.impl only if you want a new conversation.`);
|
||||
expect(launchHarness).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it("a free-text runtime seat that wasn't launched for an agent app keeps the existing restore result", async () => {
|
||||
it("a free-text runtime seat that wasn't launched for a community harness keeps the existing restore result", async () => {
|
||||
const rig = rigRepo.createRig("test-rig");
|
||||
db.prepare("INSERT INTO pods (id, rig_id, label) VALUES (?, ?, ?)").run("pod-free-text", rig.id, "Dev");
|
||||
const node = rigRepo.addNode(rig.id, "dev.impl", { runtime: "synth-app", podId: "pod-free-text" });
|
||||
const node = rigRepo.addNode(rig.id, "dev.impl", { runtime: "synth-harness", podId: "pod-free-text" });
|
||||
const session = sessionRegistry.registerSession(node.id, "dev-impl@test-rig");
|
||||
sessionRegistry.updateStatus(session.id, "running");
|
||||
sessionRegistry.updateResumeToken(session.id, "codex_id", "some-token");
|
||||
db.prepare("INSERT INTO node_startup_context (node_id, projection_entries_json, resolved_files_json, startup_actions_json, runtime) VALUES (?, ?, ?, ?, ?)").run(node.id, "[]", "[]", "[]", "synth-app");
|
||||
db.prepare("INSERT INTO node_startup_context (node_id, projection_entries_json, resolved_files_json, startup_actions_json, runtime) VALUES (?, ?, ?, ?, ?)").run(node.id, "[]", "[]", "[]", "synth-harness");
|
||||
const snap = snapshotCapture.captureSnapshot(rig.id, "test");
|
||||
sessionRegistry.updateStatus(session.id, "exited");
|
||||
db.prepare("DELETE FROM bindings WHERE node_id = ?").run(node.id);
|
||||
|
||||
@@ -19,7 +19,7 @@ import { CodexRuntimeAdapter } from "../src/adapters/codex-runtime-adapter.js";
|
||||
import { PiRuntimeAdapter } from "../src/adapters/pi-runtime-adapter.js";
|
||||
import { OmpRuntimeAdapter } from "../src/adapters/omp-runtime-adapter.js";
|
||||
import { StubRuntimeAdapter } from "../src/adapters/stub-runtime-adapter.js";
|
||||
import { withAgentAppResolver } from "../src/adapters/agent-app-runtime-adapter.js";
|
||||
import { withCommunityHarnessResolver } from "../src/adapters/community-harness-runtime-adapter.js";
|
||||
import { TerminalAdapter } from "../src/adapters/terminal-adapter.js";
|
||||
import { piSeatPaths } from "../src/adapters/pi-runner-protocol.js";
|
||||
import type { TmuxAdapter } from "../src/adapters/tmux.js";
|
||||
@@ -345,14 +345,14 @@ describe("changing an existing seat's launch directory", () => {
|
||||
expect(cwdEvents()).toEqual([]);
|
||||
});
|
||||
|
||||
it("resolves a community agent app's adapter the way launch does, and checks none of the startup files it never takes", async () => {
|
||||
it("resolves a community harness's adapter the way launch does, and checks none of the startup files it never takes", async () => {
|
||||
const seat = seed();
|
||||
const presets = path.join(root, "agent-apps");
|
||||
const presets = path.join(root, "community-harnesses");
|
||||
fs.mkdirSync(presets);
|
||||
fs.writeFileSync(path.join(presets, "synth.yaml"), "format: 1\nname: synth\ncommand: synth-app --serve\nmaintainer: Example Maintainer\nlimits: [resume unsupported]\n");
|
||||
fs.writeFileSync(path.join(presets, "synth.yaml"), "format: 1\nname: synth\ncommand: synth-harness --serve\nmaintainer: Example Maintainer\nlimits: [resume unsupported]\n");
|
||||
db.prepare("UPDATE nodes SET runtime = 'synth' WHERE id = ?").run(seat.node.id);
|
||||
db.prepare("UPDATE node_startup_context SET runtime = 'synth' WHERE node_id = ?").run(seat.node.id);
|
||||
// A required startup skill the app would never take (an agent app takes no startup files).
|
||||
// A required startup skill the app would never take (a community harness takes no startup files).
|
||||
const skill = path.join(root, "app-skill", "review", "SKILL.md");
|
||||
fs.mkdirSync(path.dirname(skill), { recursive: true });
|
||||
fs.writeFileSync(skill, "# Review\n");
|
||||
@@ -360,9 +360,9 @@ describe("changing an existing seat's launch directory", () => {
|
||||
...seat.files, { path: "SKILL.md", absolutePath: skill, ownerRoot: path.dirname(skill), deliveryHint: "skill_install", required: true, appliesOn: ["fresh_start"] },
|
||||
]), seat.node.id);
|
||||
const sessionId = (db.prepare("SELECT id FROM sessions WHERE node_id = ? ORDER BY id DESC LIMIT 1").get(seat.node.id) as { id: string }).id;
|
||||
registry.recordAgentAppSelection(sessionId, "synth");
|
||||
registry.recordCommunityHarnessSelection(sessionId, "synth");
|
||||
service = new SeatLifecycleService({ db, rigRepo, sessionRegistry: registry, eventBus, tmuxAdapter: tmux,
|
||||
runtimeAdapters: withAgentAppResolver({ "claude-code": adapter } as Record<string, RuntimeAdapter>, { tmux, presetsDir: () => presets }) });
|
||||
runtimeAdapters: withCommunityHarnessResolver({ "claude-code": adapter } as Record<string, RuntimeAdapter>, { tmux, presetsDir: () => presets }) });
|
||||
const result = await service.setCwd({ seatRef: seat.sessionName, cwd: newCwd, reason: "isolate workspace" });
|
||||
// No refusal code, named first so a failure shows which one.
|
||||
expect((result as { code?: string }).code).toBeUndefined();
|
||||
|
||||
@@ -76,8 +76,8 @@ interface NodeInventoryRead {
|
||||
decidedBy?: string | null;
|
||||
} | null;
|
||||
identityVerdict?: { verdict?: string; reason?: string | null } | null;
|
||||
/** Present only on a community agent app's seat. */
|
||||
agentApp?: { app: string } | null;
|
||||
/** Present only on a community harness's seat. */
|
||||
communityHarness?: { harness: string } | null;
|
||||
canonicalSessionName: string | null;
|
||||
tmuxAttachCommand?: string | null;
|
||||
cwd?: string | null;
|
||||
@@ -268,8 +268,8 @@ function toAgentRow(node: NodeInventoryRead): AgentRow {
|
||||
: node.activityState?.display === "idle"
|
||||
? "idle"
|
||||
: (node.terminalActive === true || (node.terminalActive == null && node.agentActivity?.state === "running") ? "active"
|
||||
// A community agent app gives no idle signal: a quiet screen is unknown, not idle.
|
||||
: node.agentApp ? "unknown" : "idle"))
|
||||
// A community harness gives no idle signal: a quiet screen is unknown, not idle.
|
||||
: node.communityHarness ? "unknown" : "idle"))
|
||||
: (node.sessionStatus ?? "unknown"),
|
||||
live: node.lifecycleState === "running",
|
||||
canRun: node.lifecycleState !== "running"
|
||||
|
||||
@@ -446,14 +446,14 @@ describe("snapshot hydration over the §4.A reads (Phase 2)", () => {
|
||||
expect(output.lines.find((line) => /\bmismatch\s/.test(line))).not.toContain("run ▸");
|
||||
expect(output.lines.find((line) => /\bmissing\s/.test(line))).not.toContain("run ▸");
|
||||
});
|
||||
it("shows a quiet community agent app as unknown, never idle, and leaves built-in rows as they were", async () => {
|
||||
it("shows a quiet community harness as unknown, never idle, and leaves built-in rows as they were", async () => {
|
||||
const base = (FIXTURES["/api/rigs/01JRIG/nodes"] as Array<Record<string, unknown>>)[0]!;
|
||||
const quiet = { startupStatus: "ready", sessionStatus: "running", terminalActive: false, agentActivity: { state: "unknown" },
|
||||
activityState: { activity: "unknown", display: "unknown", needsInput: { count: 0, reason: null }, decidedBy: null } };
|
||||
const nodes = [
|
||||
{ ...base, ...quiet, logicalId: "dev.app-quiet", runtime: "synth-app", agentApp: { app: "synth-app" } },
|
||||
{ ...base, ...quiet, logicalId: "dev.app-moving", runtime: "synth-app", agentApp: { app: "synth-app" }, terminalActive: true },
|
||||
{ ...base, ...quiet, logicalId: "dev.app-prompt", runtime: "synth-app", agentApp: { app: "synth-app" }, agentActivity: { state: "needs_input" } },
|
||||
{ ...base, ...quiet, logicalId: "dev.app-quiet", runtime: "synth-harness", communityHarness: { harness: "synth-harness" } },
|
||||
{ ...base, ...quiet, logicalId: "dev.app-moving", runtime: "synth-harness", communityHarness: { harness: "synth-harness" }, terminalActive: true },
|
||||
{ ...base, ...quiet, logicalId: "dev.app-prompt", runtime: "synth-harness", communityHarness: { harness: "synth-harness" }, agentActivity: { state: "needs_input" } },
|
||||
{ ...base, ...quiet, logicalId: "dev.pi-quiet", runtime: "pi" },
|
||||
{ ...base, ...quiet, logicalId: "dev.claude-quiet", runtime: "claude-code" },
|
||||
];
|
||||
|
||||
Reference in new issue
Block a user