Compare commits

..
Author SHA1 Message Date
dev60-review 39a3b6e29b fix(setup): offer to register the person where the setup agent reads it
The rigs skill already tells an agent setting OpenRig up to offer
registration when no person is registered, but the step sits deep in the
handoff section, after the part an agent reads to install, and agents
that did read it went on without making the offer. OpenRig's questions to
a person, such as another machine asking to pair, then had no one to
reach.

`rig daemon start` now ends, when the person registry is readable and
holds nobody, with the offer and the exact command: ask the person, and
only on their yes run `rig gateway human add <id> --display-name "<name>"
--delivery-class B --default`. Nothing is registered by the start itself.
A registered person, or a registry that can't be read, prints nothing new,
and the start's result is unchanged. The skill's "Start OpenRig" step,
which agents read on the install path, now says to make that offer before
going on.
2026-11-12 02:19:04 +00:00
Mike Schwarz 559025c090 perf(daemon): index current-session reads for node inventory (#2997)
Node inventory resolved each seat's current session by scanning its session history, so the read grew with history on a long-lived daemon, one of the parent daemon's node-route stall paths. Migration 162 adds two indexes for the current-session reads, and the session registry's queries now seek on them, resolving equal timestamps by the current database's rowid. Read plans are pinned in a new test. The history-scaling repair is measured; the production pause attribution stays open.
2026-11-11 17:55:10 -08:00
hgreene624 37e0715c58 fix(tui): group instance rows by rig and fit the RIG column (#2857)
The TUI's instance table cut long rig names to a fixed 13 cells and drew no break between rigs, so rigs that share a prefix looked alike and a new rig's first row read as part of the last pod. The table now shows the rig name once per rig group, on its first row, as the POD column does, and keeps it on the top visible row when scrolled. The RIG column is sized to the longest rig name shown, from the old width up to a cap, with NOW or WORK giving up the difference so the row width is unchanged. A blank RIG cell still drills into its rig. For #2848.
2026-11-11 17:36:19 -08:00
25 changed files with 426 additions and 55 deletions

No files matched your search

+19
View File
@@ -162,6 +162,24 @@ export function realDeps(): LifecycleDeps {
};
}
/** What `daemon start` tells the agent setting OpenRig up when the person registry holds nobody: the offer, in the
* output it reads at that moment. Empty when a person is registered, or when the registry can't be read, since an
* unreadable registry isn't evidence that nobody is registered. */
export async function personRegistrationOffer(): Promise<string[]> {
try {
const { participantsView } = await import("@openrig/daemon/gateway-human-registry");
const view = participantsView();
if (!view.ok || view.participants.some((p) => p.kind === "person")) return [];
} catch {
return [];
}
return [
"No person is registered yet, so OpenRig's questions to a person, such as another machine asking to pair, can't reach anyone.",
"If you're setting OpenRig up for someone, offer to register them now. Ask the name to use, and only on their yes run:",
' rig gateway human add <id> --display-name "<name>" --delivery-class B --default',
];
}
export function daemonCommand(depsOverride?: LifecycleDeps): Command {
const getDeps = () => depsOverride ?? realDeps();
const cmd = new Command("daemon").description("Manage the OpenRig daemon");
@@ -301,6 +319,7 @@ export function daemonCommand(depsOverride?: LifecycleDeps): Command {
process.exitCode = 1;
}
}
if (process.exitCode !== 1) for (const line of await personRegistrationOffer()) console.log(line);
} catch (err) {
console.error(err instanceof Error ? err.message : String(err));
process.exitCode = 1;
@@ -0,0 +1,56 @@
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { addHumanFragment } from "@openrig/daemon/gateway-human-registry";
import { startDaemon } from "../src/daemon-lifecycle.js";
import { daemonCommand } from "../src/commands/daemon.js";
vi.mock("../src/daemon-lifecycle.js", async (original) => {
const actual = await original<typeof import("../src/daemon-lifecycle.js")>();
return { ...actual, getDaemonStatus: vi.fn(async () => ({ state: "stopped" })), startDaemon: vi.fn(async () => ({ port: 7433, pid: 4242, host: "127.0.0.1" })) };
});
vi.mock("../src/system-preflight.js", () => ({ SystemPreflight: class { run = async () => ({ ready: true, checks: [] }); }, quietPreflightExec: async () => "" }));
const OFFER = [
"No person is registered yet, so OpenRig's questions to a person, such as another machine asking to pair, can't reach anyone.",
"If you're setting OpenRig up for someone, offer to register them now. Ask the name to use, and only on their yes run:",
' rig gateway human add <id> --display-name "<name>" --delivery-class B --default',
];
let home: string;
let logs: string[];
beforeEach(() => {
home = mkdtempSync(join(tmpdir(), "daemon-start-person-offer-"));
vi.stubEnv("OPENRIG_HOME", home);
logs = [];
vi.spyOn(console, "log").mockImplementation((...args: unknown[]) => { logs.push(args.join(" ")); });
vi.spyOn(console, "error").mockImplementation(() => {});
});
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllEnvs(); rmSync(home, { recursive: true, force: true }); process.exitCode = undefined; });
const start = () => daemonCommand({} as never).parseAsync(["start"], { from: "user" });
it("with nobody registered, daemon start ends with the offer to register the person, and registers no one", async () => {
await start();
expect(startDaemon).toHaveBeenCalled();
expect(logs).toEqual(["Daemon started on port 7433 (pid 4242)", ...OFFER]);
expect(process.exitCode).toBeUndefined();
const { participantsView } = await import("@openrig/daemon/gateway-human-registry");
const view = participantsView(home);
expect(view.ok && view.participants).toEqual([]);
});
it("with a person registered, daemon start makes no offer", async () => {
expect(addHumanFragment({ entityId: "ada", class: "human", displayName: "Ada", address: "ada@external", connectorBindings: [], prefs: { deliveryClass: "B" } }, home).ok).toBe(true);
await start();
expect(logs).toEqual(["Daemon started on port 7433 (pid 4242)"]);
});
it("an unreadable registry is not read as nobody registered: no offer, and the start still succeeds", async () => {
mkdirSync(join(home, "gateway", "humans"), { recursive: true });
writeFileSync(join(home, "gateway", "humans", "ada.yaml"), "entityId: [unclosed\n");
await start();
expect(logs).toEqual(["Daemon started on port 7433 (pid 4242)"]);
expect(process.exitCode).toBeUndefined();
});
@@ -7,7 +7,7 @@
"url": "https://github.com/mvschwarz"
},
"homepage": "https://github.com/mvschwarz/openrig-plugins",
"repository": "https://github.com/mvschwarz/openrig-plugins",
"repository": "github:mvschwarz/openrig-plugins",
"license": "Apache-2.0",
"keywords": ["openrig", "agent", "topology", "rig", "continuity", "coordination", "compaction", "handoff"],
"skills": "./skills",
@@ -7,7 +7,7 @@
"url": "https://github.com/mvschwarz"
},
"homepage": "https://github.com/mvschwarz/openrig-plugins",
"repository": "https://github.com/mvschwarz/openrig-plugins",
"repository": "github:mvschwarz/openrig-plugins",
"license": "Apache-2.0",
"keywords": ["openrig", "agent", "topology", "rig", "continuity", "coordination", "compaction", "handoff"],
"skills": "./skills",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

@@ -7,7 +7,7 @@
"url": "https://github.com/mvschwarz"
},
"homepage": "https://github.com/mvschwarz/openrig",
"repository": "https://github.com/mvschwarz/openrig",
"repository": "github:mvschwarz/openrig",
"license": "Apache-2.0",
"keywords": ["openrig", "agents", "team", "delegation", "mcp"],
"skills": "./skills",
@@ -7,7 +7,7 @@
"url": "https://github.com/mvschwarz"
},
"homepage": "https://github.com/mvschwarz/openrig",
"repository": "https://github.com/mvschwarz/openrig",
"repository": "github:mvschwarz/openrig",
"license": "Apache-2.0",
"keywords": [
"openrig",
@@ -8,7 +8,7 @@
"url": "https://github.com/mvschwarz"
},
"homepage": "https://github.com/mvschwarz/openrig",
"repository": "https://github.com/mvschwarz/openrig",
"repository": "github:mvschwarz/openrig",
"license": "Apache-2.0",
"keywords": ["openrig", "agents", "team", "delegation", "mcp"]
}
+2 -1
View File
@@ -72,7 +72,8 @@ person needs in their project or home folder and name that path.
- **Start OpenRig:** `rig daemon status`, and `rig daemon start` if it isn't running. `rig preflight` and `rig doctor`
don't start it. Starting it also starts OpenRig's own team, the kernel, whose operator sets up the person's team.
Keep it.
Keep it. If its output says no person is registered, make the offer it names before you go on, and register only on
the person's yes ("Who answers OpenRig's questions" below).
- **If the person uses Codex:** now that OpenRig is running, make the one-time offer under "Codex: one offer, once,
after OpenRig starts" at the end of this step.
- **Show them OpenRig, the handoff:** once OpenRig is running, open the OpenRig TUI and operator for the person. Say
+2
View File
@@ -9,6 +9,7 @@ import { queueInboxCheckinsSchema } from "./migrations/158_queue_inbox_checkins.
import { queueItemsHandedOffFromIndexSchema } from "./migrations/159_queue_items_handed_off_from_index.js";
import { networkBindingsSchema } from "./migrations/160_network_bindings.js";
import { networkReceiptsSchema } from "./migrations/161_network_receipts.js";
import { sessionAddressReadIndexesSchema } from "./migrations/162_session_address_read_indexes.js";
import { activityGrantsSchema } from "./migrations/143_activity_grants.js";
import { environmentBindingsSchema } from "./migrations/144_environment_bindings.js";
import { nodePermissionsToSchema } from "./migrations/145_node_permissions_to.js";
@@ -325,4 +326,5 @@ export const ALL_MIGRATIONS: Migration[] = [
queueItemsHandedOffFromIndexSchema,
networkBindingsSchema,
networkReceiptsSchema,
sessionAddressReadIndexesSchema,
];
@@ -0,0 +1,13 @@
import type { Migration } from "../migrate.js";
/** Address reads seek a current binding; inventory recency is by id, not wall clock.
* Keep the older created_at index for readers that explicitly use that order. */
export const sessionAddressReadIndexesSchema: Migration = {
name: "162_session_address_read_indexes.sql",
sql: `
CREATE INDEX IF NOT EXISTS idx_bindings_tmux_session ON bindings(tmux_session)
WHERE tmux_session IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_sessions_node_latest_id ON sessions(node_id, id DESC);
CREATE INDEX IF NOT EXISTS idx_sessions_name_latest_id ON sessions(session_name, id DESC);
`,
};
+16 -5
View File
@@ -124,14 +124,25 @@ export interface CurrentSessionAddress {
* Physical names win over derived names. Archived/historical bindings cannot
* advertise a new alias, and two current matches never pick an arbitrary seat. */
export function resolveCurrentSessionAddress(db: Database.Database, address: string): CurrentSessionAddress | null {
const rows = db.prepare(`SELECT r.*, n.id AS nodeId, n.logical_id AS logicalId,
const current = `SELECT r.name, r.archived_at, n.id AS nodeId, n.logical_id AS logicalId,
s.id AS sessionId, s.session_name AS sessionName, s.status,
replace(n.logical_id, '.', '-') || '@' || r.name AS senderAddress
FROM nodes n JOIN rigs r ON r.id=n.rig_id
JOIN bindings b ON b.node_id=n.id AND b.tmux_session IS NOT NULL
JOIN sessions s ON s.node_id=n.id AND s.session_name=b.tmux_session
AND s.id=(SELECT id FROM sessions WHERE node_id=n.id ORDER BY id DESC LIMIT 1)
WHERE s.session_name=? OR replace(n.logical_id, '.', '-') || '@' || r.name=?`)
CROSS JOIN sessions s ON s.node_id=n.id AND s.session_name=b.tmux_session
AND s.id=(SELECT id FROM sessions WHERE node_id=n.id ORDER BY id DESC LIMIT 1)`;
// Inventory passes physical names for every seat. The OR with a derived alias
// made each lookup walk all nodes (or all historical sessions). Seek the exact
// name first; only an absent active exact match needs the alias walk. Keep this
// in one statement so both alternatives observe the same database snapshot.
const rows = db.prepare(`WITH exact AS MATERIALIZED (
${current} WHERE b.tmux_session=? AND r.archived_at IS NULL
AND COALESCE(s.status, '') NOT IN ('superseded', 'detached', 'exited')
) SELECT * FROM exact
UNION ALL
${current}
WHERE replace(n.logical_id, '.', '-') || '@' || r.name=?
AND NOT EXISTS (SELECT 1 FROM exact)`)
.all(address, address) as Array<CurrentSessionAddress & { name: string; archived_at?: string | null; status: string }>;
const active = rows.filter(row => row.archived_at == null && !['superseded', 'detached', 'exited'].includes(row.status));
const exact = active.filter(row => row.sessionName === address);
@@ -783,7 +794,7 @@ export class SessionRegistry {
`SELECT s.* FROM sessions s
JOIN nodes n ON s.node_id = n.id
WHERE n.rig_id = ?
ORDER BY s.created_at`
ORDER BY s.created_at, s.rowid`
)
.all(rigId) as SessionRow[];
@@ -6,6 +6,7 @@ import Database from "better-sqlite3";
import { createDb } from "../src/db/connection.js";
import { migrate } from "../src/db/migrate.js";
import { coreSchema } from "../src/db/migrations/001_core_schema.js";
import { rigArchiveSchema } from "../src/db/migrations/042_rig_archive.js";
import { bindingsSessionsSchema } from "../src/db/migrations/002_bindings_sessions.js";
import { eventsSchema } from "../src/db/migrations/003_events.js";
import { queueItemSummarySchema } from "../src/db/migrations/044_queue_item_summary.js";
@@ -106,7 +107,7 @@ describe("the projector's execution queue", () => {
const NOW = new Date("2026-10-25T12:00:00.000Z");
function fixture(file?: string) {
const db = track(file ? createDb(file) : createDb());
migrate(db, [coreSchema, bindingsSessionsSchema, eventsSchema, queueItemSummarySchema, queueItemsSchema, queueTransitionsSchema, queueTransitionWakesSchema, viewsCustomSchema]);
migrate(db, [coreSchema, bindingsSessionsSchema, rigArchiveSchema, eventsSchema, queueItemSummarySchema, queueItemsSchema, queueTransitionsSchema, queueTransitionWakesSchema, viewsCustomSchema]);
return db;
}
const insertRow = (db: Database.Database, id: string, state: string, tags: string[], body: string, claimedAt: string | null = "2026-10-25T11:00:00.000Z") =>
+2 -1
View File
@@ -19,6 +19,7 @@ import type Database from "better-sqlite3";
import { createDb } from "../src/db/connection.js";
import { migrate } from "../src/db/migrate.js";
import { coreSchema } from "../src/db/migrations/001_core_schema.js";
import { rigArchiveSchema } from "../src/db/migrations/042_rig_archive.js";
import { bindingsSessionsSchema } from "../src/db/migrations/002_bindings_sessions.js";
import { eventsSchema } from "../src/db/migrations/003_events.js";
import { queueItemSummarySchema } from "../src/db/migrations/044_queue_item_summary.js";
@@ -167,7 +168,7 @@ describe("execution view — S27 (OPR.0.5.6.27)", () => {
db = createDb();
migrate(db, [
coreSchema,
bindingsSessionsSchema,
bindingsSessionsSchema, rigArchiveSchema,
eventsSchema,
queueItemsSchema,
queueItemSummarySchema,
+2 -1
View File
@@ -108,6 +108,7 @@ import { humanQuestionsSchema } from "../../src/db/migrations/091_human_question
import { nodeEffortSchema } from "../../src/db/migrations/092_node_effort.js";
import { rigPolicySchema } from "../../src/db/migrations/041_rig_policy.js";
import { rigArchiveSchema } from "../../src/db/migrations/042_rig_archive.js";
import { sessionAddressReadIndexesSchema } from "../../src/db/migrations/162_session_address_read_indexes.js";
import { resumeProvenanceSchema } from "../../src/db/migrations/043_resume_provenance.js";
import { resumeVerificationSchema } from "../../src/db/migrations/045_resume_verification.js";
import { seatIdentityVerdictsSchema } from "../../src/db/migrations/046_seat_identity_verdicts.js";
@@ -168,7 +169,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, sessionCommunityHarnessSchema, threadSeatMapChannelKeySchema, queueItemWakeInputBoxSchema, providerAccountReadingsSchema, humanQuietWindowsSchema, seatIdentityPanePidSchema, seatIdentityProcessEvidenceSchema, rigStartupLayersSchema, humanOperatingModesSchema, queueDelegatedDecisionSchema, nodeAdvisorSchema, operationCorrelationSchema, terminalPolicySchema, seatRuntimeSwitchesSchema, claudeTokenReadingsSchema, nodeLaunchRecipeSchema, nodeClaudeTokenRefSchema, nodeClaudePermissionsSchema, nodeCodexPermissionsSchema, escalationTargetRefsSchema, sessionAgentRunnerSchema, nodeAccountHomeSchema, nodeClaudeMcpSchema, activityGrantsSchema, environmentBindingsSchema, nodePermissionsToSchema, nodeStartupGuidanceFileSchema, managedBlockWritesSchema, streamIntakeCursorSchema, nodeLaunchWrapperSchema, piAccountSwitchesSchema, eventsRetentionMarksSchema, queueErasureSchema, queueErasureBatchesSchema, queueInboxCheckinsSchema, queueItemsHandedOffFromIndexSchema, networkBindingsSchema, networkReceiptsSchema];
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, queueItemWakeInputBoxSchema, providerAccountReadingsSchema, humanQuietWindowsSchema, seatIdentityPanePidSchema, seatIdentityProcessEvidenceSchema, rigStartupLayersSchema, humanOperatingModesSchema, queueDelegatedDecisionSchema, nodeAdvisorSchema, operationCorrelationSchema, terminalPolicySchema, seatRuntimeSwitchesSchema, claudeTokenReadingsSchema, nodeLaunchRecipeSchema, nodeClaudeTokenRefSchema, nodeClaudePermissionsSchema, nodeCodexPermissionsSchema, escalationTargetRefsSchema, sessionAgentRunnerSchema, nodeAccountHomeSchema, nodeClaudeMcpSchema, activityGrantsSchema, environmentBindingsSchema, nodePermissionsToSchema, nodeStartupGuidanceFileSchema, managedBlockWritesSchema, streamIntakeCursorSchema, nodeLaunchWrapperSchema, piAccountSwitchesSchema, eventsRetentionMarksSchema, queueErasureSchema, queueErasureBatchesSchema, queueInboxCheckinsSchema, queueItemsHandedOffFromIndexSchema, networkBindingsSchema, networkReceiptsSchema, sessionAddressReadIndexesSchema];
/**
* P24 — the DECLARED exclusions for {@link migrationsForFullTestDb}. That list is deliberately a
@@ -90,8 +90,7 @@ describe("openrig-core plugin — manifest shape (HG-2.2)", () => {
// Hook + skills wiring
expect(manifest["skills"]).toBe("./skills");
expect(manifest["hooks"]).toBe("./hooks/claude.json");
// An https URL: Claude's plugin directory drops a `github:` shorthand from its listing.
expect(manifest["repository"]).toBe("https://github.com/mvschwarz/openrig-plugins");
expect(manifest["repository"]).toMatch(/github:mvschwarz\/openrig-plugins/);
expect(manifest["license"]).toBeDefined();
});
+4 -3
View File
@@ -13,6 +13,7 @@ import type Database from "better-sqlite3";
import { createDb } from "../src/db/connection.js";
import { migrate } from "../src/db/migrate.js";
import { coreSchema } from "../src/db/migrations/001_core_schema.js";
import { rigArchiveSchema } from "../src/db/migrations/042_rig_archive.js";
import { bindingsSessionsSchema } from "../src/db/migrations/002_bindings_sessions.js";
import { eventsSchema } from "../src/db/migrations/003_events.js";
import { streamItemsSchema } from "../src/db/migrations/023_stream_items.js";
@@ -70,7 +71,7 @@ describe("GET /api/review/*", () => {
db = createDb(":memory:");
migrate(db, [
coreSchema,
bindingsSessionsSchema,
bindingsSessionsSchema, rigArchiveSchema,
eventsSchema,
streamItemsSchema,
queueItemsSchema,
@@ -335,7 +336,7 @@ describe("GET /api/review/rig (OPR.0.4.4.22)", () => {
db = createDb(":memory:");
migrate(db, [
coreSchema,
bindingsSessionsSchema,
bindingsSessionsSchema, rigArchiveSchema,
eventsSchema,
streamItemsSchema,
queueItemsSchema,
@@ -668,7 +669,7 @@ describe("qitem-ccf87c0d — composeMission MEMBERSHIP-scan load contract", () =
it("one composeMission over 40 indexed slices executes <= 4 membership queue scans AND composes all 40 ledger entries", () => {
const db = createDb();
migrate(db, [
coreSchema, bindingsSessionsSchema, eventsSchema, streamItemsSchema,
coreSchema, bindingsSessionsSchema, rigArchiveSchema, eventsSchema, streamItemsSchema,
queueItemsSchema, queueTransitionsSchema, missionControlActionsSchema,
queueItemSummarySchema,
]);
@@ -0,0 +1,86 @@
import { afterEach, describe, expect, it } from "vitest";
import type Database from "better-sqlite3";
import { createDb } from "../src/db/connection.js";
import { migrate } from "../src/db/migrate.js";
import { ALL_MIGRATIONS } from "../src/db/all-migrations.js";
import { resolveCurrentSessionAddress, SessionRegistry } from "../src/domain/session-registry.js";
import { getNodeInventory } from "../src/domain/node-inventory.js";
const databases: Database.Database[] = [];
afterEach(() => { for (const db of databases.splice(0)) db.close(); });
function fixture(indexed = true) {
const db = createDb(); databases.push(db);
migrate(db, ALL_MIGRATIONS.filter(m => indexed || m.name !== "162_session_address_read_indexes.sql"));
db.exec("INSERT INTO rigs(id,name) VALUES('r','team')");
const node = db.prepare("INSERT INTO nodes(id,rig_id,logical_id,runtime) VALUES(?,'r',?,'claude-code')");
const session = db.prepare("INSERT INTO sessions(id,node_id,session_name,status,created_at) VALUES(?,?,?,'running',?)");
const binding = db.prepare("INSERT INTO bindings(id,node_id,tmux_session,attachment_type) VALUES(?,?,?,'tmux')");
for (let n = 0; n < 4; n++) {
node.run(`n${n}`, `dev.worker${n}`);
for (let i = 0; i < 20; i++) session.run(`${n}-${String(i).padStart(3, '0')}`, `n${n}`, `native-${n}@team`, "2099-01-01");
// Session id defines recency even when the wall clock moves backwards.
session.run(`${n}-999`, `n${n}`, `native-${n}@team`, "2026-01-01");
binding.run(`b${n}`, `n${n}`, `native-${n}@team`);
}
return db;
}
describe("current session address read cost", () => {
it("seeks a physical name and the latest registration instead of scanning session history", () => {
const db = fixture(), sql: string[] = [], prepare = db.prepare;
db.prepare = function (text: string) { sql.push(text); return prepare.call(this, text); } as typeof db.prepare;
try {
expect(resolveCurrentSessionAddress(db, "native-0@team")?.nodeId).toBe("n0");
expect(getNodeInventory(db, "r").map(n => n.canonicalSessionName)).toContain("native-0@team");
} finally { db.prepare = prepare; }
const address = sql.find(s => s.includes("AS senderAddress"))!;
const plan = db.prepare(`EXPLAIN QUERY PLAN ${address}`).all("native-0@team", "native-0@team") as { detail: string }[];
expect(plan.map(p => p.detail).join("\n")).toMatch(/SEARCH b USING INDEX idx_bindings_tmux_session/);
expect(plan.map(p => p.detail).join("\n")).not.toMatch(/SCAN s\b/);
const latest = db.prepare("EXPLAIN QUERY PLAN SELECT id FROM sessions WHERE node_id=? ORDER BY id DESC LIMIT 1").all("n0") as { detail: string }[];
expect(latest.map(p => p.detail).join("\n")).not.toContain("TEMP B-TREE");
});
it("keeps exact-name priority, derived aliases, and fresh binding changes", () => {
const db = fixture();
expect(resolveCurrentSessionAddress(db, "dev-worker0@team")?.sessionName).toBe("native-0@team");
// A physical spelling takes priority over another seat's derived spelling.
db.prepare("UPDATE sessions SET session_name='dev-worker0@team' WHERE id='1-999'").run();
db.prepare("UPDATE bindings SET tmux_session='dev-worker0@team' WHERE node_id='n1'").run();
expect(resolveCurrentSessionAddress(db, "dev-worker0@team")?.nodeId).toBe("n1");
db.prepare("UPDATE sessions SET status='exited' WHERE id='1-999'").run();
// That other seat's saved spelling still reserves the name.
expect(resolveCurrentSessionAddress(db, "dev-worker0@team")).toBeNull();
db.prepare("UPDATE rigs SET archived_at='2026-01-02' WHERE id='r'").run();
expect(resolveCurrentSessionAddress(db, "native-0@team")).toBeNull();
});
it("seeks the latest named registration when the generation reader resolves a session", () => {
const db = fixture(), sql: string[] = [], prepare = db.prepare;
db.prepare = function (text: string) { sql.push(text); return prepare.call(this, text); } as typeof db.prepare;
try { new SessionRegistry(db).currentOccupantGenerationForSession("native-0@team"); }
finally { db.prepare = prepare; }
const read = sql.find(s => s.includes("SELECT node_id FROM sessions WHERE session_name"))!;
expect(read).toBeDefined();
const plan = db.prepare(`EXPLAIN QUERY PLAN ${read}`).all("native-0@team") as { detail: string }[];
expect(plan.map(p => p.detail).join("\n")).toContain("SEARCH sessions USING INDEX idx_sessions_name_latest_id");
expect(plan.map(p => p.detail).join("\n")).not.toContain("TEMP B-TREE");
});
it("upgrades populated databases without changing registrations or inventory", () => {
const db = fixture(false);
const before = { inventory: getNodeInventory(db, "r"),
physical: resolveCurrentSessionAddress(db, "native-0@team"),
alias: resolveCurrentSessionAddress(db, "dev-worker0@team"),
registrations: db.prepare("SELECT * FROM sessions ORDER BY id").all() };
migrate(db, ALL_MIGRATIONS);
migrate(db, ALL_MIGRATIONS);
expect({ inventory: getNodeInventory(db, "r"),
physical: resolveCurrentSessionAddress(db, "native-0@team"),
alias: resolveCurrentSessionAddress(db, "dev-worker0@team"),
registrations: db.prepare("SELECT * FROM sessions ORDER BY id").all() }).toEqual(before);
expect(db.pragma("integrity_check", { simple: true })).toBe("ok");
expect(db.pragma("foreign_key_check")).toEqual([]);
});
});
@@ -370,6 +370,23 @@ describe("SessionRegistry", () => {
expect(names).toContain("r01-dev1-qa");
});
it("getSessionsForRig preserves insertion order for timestamp ties with the session indexes installed", () => {
const indexes = db.prepare("PRAGMA index_list(sessions)").all() as { name: string }[];
expect(indexes.map((index) => index.name)).toContain("idx_sessions_node_latest_id");
const insert = db.prepare("INSERT INTO sessions(id, node_id, session_name, created_at) VALUES (?, ?, ?, ?)");
// Non-monotonic ids distinguish insertion order from either id ordering.
insert.run("m-first", "node-1", "first@rig", "2026-01-02");
insert.run("z-second", "node-1", "second@rig", "2026-01-02");
insert.run("a-third", "node-1", "third@rig", "2026-01-02");
insert.run("n-fourth", "node-2", "fourth@rig", "2026-01-02");
// Timestamp remains the primary order even when this row was inserted last.
insert.run("earlier", "node-2", "earlier@rig", "2026-01-01");
expect(registry.getSessionsForRig("rig-1").map((session) => session.id)).toEqual([
"earlier", "m-first", "z-second", "a-third", "n-fourth",
]);
});
it("getBindingForNode returns null when unbound", () => {
const binding = registry.getBindingForNode("node-1");
expect(binding).toBeNull();
+2 -1
View File
@@ -7,6 +7,7 @@ import type Database from "better-sqlite3";
import { createDb } from "../src/db/connection.js";
import { migrate } from "../src/db/migrate.js";
import { coreSchema } from "../src/db/migrations/001_core_schema.js";
import { rigArchiveSchema } from "../src/db/migrations/042_rig_archive.js";
import { outboxEntriesSchema } from "../src/db/migrations/027_outbox_entries.js";
import { bindingsSessionsSchema } from "../src/db/migrations/002_bindings_sessions.js";
import { eventsSchema } from "../src/db/migrations/003_events.js";
@@ -133,7 +134,7 @@ describe("workflow route (WF3 FR-4 — close+recreate+rebind)", () => {
db = createDb();
migrate(db, [
outboxEntriesSchema,
coreSchema, bindingsSessionsSchema, eventsSchema,
coreSchema, bindingsSessionsSchema, rigArchiveSchema, eventsSchema,
queueItemsSchema, queueTransitionsSchema,
queueItemSummarySchema, queueItemEvidenceRefSchema,
workflowSpecsSchema, workflowInstancesSchema, workflowStepTrailsSchema,
+2 -1
View File
@@ -7,6 +7,7 @@ import type Database from "better-sqlite3";
import { createDb } from "../src/db/connection.js";
import { migrate } from "../src/db/migrate.js";
import { coreSchema } from "../src/db/migrations/001_core_schema.js";
import { rigArchiveSchema } from "../src/db/migrations/042_rig_archive.js";
import { outboxEntriesSchema } from "../src/db/migrations/027_outbox_entries.js";
import { eventsSchema } from "../src/db/migrations/003_events.js";
import { queueItemsSchema } from "../src/db/migrations/024_queue_items.js";
@@ -582,7 +583,7 @@ describe("workflow routes — WF-2 structured error boundaries", () => {
migrate(db, [
outboxEntriesSchema,
coreSchema,
bindingsSessionsSchema,
bindingsSessionsSchema, rigArchiveSchema,
eventsSchema,
queueItemsSchema,
queueTransitionsSchema,
@@ -13,6 +13,7 @@ import type Database from "better-sqlite3";
import { createDb } from "../src/db/connection.js";
import { migrate } from "../src/db/migrate.js";
import { coreSchema } from "../src/db/migrations/001_core_schema.js";
import { rigArchiveSchema } from "../src/db/migrations/042_rig_archive.js";
import { outboxEntriesSchema } from "../src/db/migrations/027_outbox_entries.js";
import { bindingsSessionsSchema } from "../src/db/migrations/002_bindings_sessions.js";
import { eventsSchema } from "../src/db/migrations/003_events.js";
@@ -188,7 +189,7 @@ describe("OPR.0.4.6.WF2 — spec language", () => {
migrate(db, [
outboxEntriesSchema,
coreSchema,
bindingsSessionsSchema,
bindingsSessionsSchema, rigArchiveSchema,
eventsSchema,
queueItemsSchema,
queueTransitionsSchema,
+49 -30
View File
@@ -48,6 +48,8 @@ interface ContentLine {
zones?: Array<{ start: number; end: number; action: Action }>;
/** slice-17: token segments for canvas-rendered rows (graph view) */
segs?: Array<{ text: string; token?: import("./theme.js").Token; bold?: boolean; bg?: import("./theme.js").Token }>;
/** Row of a labelled group. The first visible row of each group shows `text` when set, so a scrolled group keeps its label. */
sticky?: { group: string; text?: string };
}
function pad(text: string | number | null | undefined, width: number): string {
@@ -94,17 +96,24 @@ function agentColumns(contentWidth: number): AgentColumn[] {
];
}
function instanceAgentColumns(contentWidth: number): AgentColumn[] {
if (contentWidth >= 110) return [
["rig", "RIG", 13, "left"], ["pod", "POD", 8, "left"], ["seat", "SEAT", 14, "left"],
["runtime", "RT", 3, "left"], ["context", "CTX", 7, "right"], ["status", "STATE", 10, "left"],
["queue", "Q", 2, "right"], ["work", "WORK", 12, "left"], ["now", "NOW", Math.max(18, contentWidth - 78), "left"],
];
if (contentWidth >= 78) return [
["rig", "RIG", 10, "left"], ["pod", "POD", 6, "left"], ["seat", "SEAT", 12, "left"],
["runtime", "RT", 3, "left"], ["context", "CTX", 5, "right"], ["status", "STATE", 9, "left"],
["queue", "Q", 2, "right"], ["work", "WORK", Math.max(8, contentWidth - 54), "left"],
];
/** RIG fits the longest rig name shown, between the tier's old width and its cap; NOW or WORK gives up the difference. */
function instanceAgentColumns(contentWidth: number, longestRigName = 0): AgentColumn[] {
if (contentWidth >= 110) {
const rig = Math.min(Math.max(longestRigName, 13), 24);
return [
["rig", "RIG", rig, "left"], ["pod", "POD", 8, "left"], ["seat", "SEAT", 14, "left"],
["runtime", "RT", 3, "left"], ["context", "CTX", 7, "right"], ["status", "STATE", 10, "left"],
["queue", "Q", 2, "right"], ["work", "WORK", 12, "left"], ["now", "NOW", Math.max(18, contentWidth - 78 - (rig - 13)), "left"],
];
}
if (contentWidth >= 78) {
const rig = Math.min(Math.max(longestRigName, 10), 16);
return [
["rig", "RIG", rig, "left"], ["pod", "POD", 6, "left"], ["seat", "SEAT", 12, "left"],
["runtime", "RT", 3, "left"], ["context", "CTX", 5, "right"], ["status", "STATE", 9, "left"],
["queue", "Q", 2, "right"], ["work", "WORK", Math.max(8, contentWidth - 54 - (rig - 10)), "left"],
];
}
const fixed = 8 + 5 + 11 + 2 + 5 + 8 + 2 + 7;
return [
["rig", "RIG", 8, "left"], ["pod", "POD", 5, "left"], ["seat", "SEAT", 11, "left"],
@@ -709,7 +718,7 @@ function noRigsText(snap: FleetSnapshot, host: FleetSnapshot["hosts"][number], l
: ` (no rigs served by ${host.name})`;
}
/** Rig boundaries take a solid rule and pod boundaries a dotted one; RIG repeats on every row so scrolled rows keep it. */
/** Rig boundaries take a solid rule and pod boundaries a dotted one; RIG shows on a rig's first visible row so scrolled rows keep it. */
function instanceContentLines(
state: ViewState,
snap: FleetSnapshot,
@@ -801,7 +810,12 @@ function instanceContentLines(
lines.push(healthSummaryLine(snap, scope, contentWidth));
lines.push(...queueSampleLines(snap, contentWidth));
lines.push({ text: state.filter ? `/ filter instance rows: ${state.filter} · / replace · esc clear` : "/ filter instance rows…" });
const columns = instanceAgentColumns(contentWidth);
const shownRigs = host.rigs.map((rig) => ({
rig,
agents: rig.pods.flatMap((pod) => pod.agents.map((agent) => ({ pod: pod.name, agent })))
.filter(({ pod, agent }) => !state.filter || rig.name.includes(state.filter) || pod.includes(state.filter) || agent.name.includes(state.filter)),
})).filter(({ rig, agents }) => !state.filter || agents.length > 0 || rig.name.includes(state.filter));
const columns = instanceAgentColumns(contentWidth, Math.max(0, ...shownRigs.map(({ rig }) => rig.name.length)));
lines.push({ text: tableRow(columns, { rig: "RIG", pod: "POD", seat: "SEAT", runtime: "RT", context: "CTX", status: "STATE", queue: "Q", work: "WORK", now: "NOW" }) });
lines.push({ text: "━".repeat(columnsWidth(columns)) });
let seatCount = 0;
@@ -810,10 +824,7 @@ function instanceContentLines(
let attentionUnknown = 0;
let openCount = 0;
let firstRig = true;
for (const rig of host.rigs) {
const agents = rig.pods.flatMap((pod) => pod.agents.map((agent) => ({ pod: pod.name, agent })))
.filter(({ pod, agent }) => !state.filter || rig.name.includes(state.filter) || pod.includes(state.filter) || agent.name.includes(state.filter));
if (state.filter && agents.length === 0 && !rig.name.includes(state.filter)) continue;
for (const { rig, agents } of shownRigs) {
if (!firstRig) lines.push({ text: "─".repeat(columnsWidth(columns)) });
firstRig = false;
const rigAction: Action = { type: "drill", resource: "rig", name: rig.name, target: { host: host.name } };
@@ -825,7 +836,7 @@ function instanceContentLines(
continue;
}
let previousPod: string | null = null;
for (const { pod, agent } of agents) {
for (const [index, { pod, agent }] of agents.entries()) {
const firstInPod = pod !== previousPod;
if (firstInPod && previousPod != null) lines.push({ text: "┈".repeat(columnsWidth(columns)) });
previousPod = pod;
@@ -836,18 +847,21 @@ function instanceContentLines(
if (agent.needsAttention === true) attentionCount += 1;
else if (agent.needsAttention == null) attentionUnknown += 1;
openCount += queue.count;
const cells = {
rig: rig.name,
pod: firstInPod ? pod : "",
seat: `${stateCell.mark} ${seatName(pod, agent.name)}`,
runtime: runtimeShort(agent.runtime),
context: contextCompact(agent.context, contentWidth < 110),
status: stateCell.word,
queue: queueCount(snap, queue.count),
work: queue.work,
now: queue.now,
};
const named = tableRow(columns, cells);
lines.push({
text: tableRow(columns, {
rig: rig.name,
pod: firstInPod ? pod : "",
seat: `${stateCell.mark} ${seatName(pod, agent.name)}`,
runtime: runtimeShort(agent.runtime),
context: contextCompact(agent.context, contentWidth < 110),
status: stateCell.word,
queue: queueCount(snap, queue.count),
work: queue.work,
now: queue.now,
}),
text: index === 0 ? named : tableRow(columns, { ...cells, rig: "" }),
sticky: { group: `rig:${rig.name}`, ...(index === 0 ? {} : { text: named }) },
action: { type: "drill", resource: "agent", name: agent.name, target: { host: host.name, rig: rig.name, pod } },
zones: [{ start: 0, end: columns[0]![2], action: rigAction }],
});
@@ -1944,7 +1958,12 @@ function renderBody(state: ViewState, snap: FleetSnapshot, options: RenderOption
const contentRows = content.length > bodyRows ? Math.max(bodyRows - 1, 0) : bodyRows;
const maxContentOffset = Math.max(content.length - contentRows, 0);
const contentStart = Math.min(state.contentOffset, maxContentOffset);
const visibleContent = content.slice(contentStart, contentStart + contentRows);
const labelledGroups = new Set<string>();
const visibleContent = content.slice(contentStart, contentStart + contentRows).map((line) => {
if (!line.sticky || labelledGroups.has(line.sticky.group)) return line;
labelledGroups.add(line.sticky.group);
return line.sticky.text == null ? line : { ...line, text: line.sticky.text };
});
if (content.length > bodyRows) {
const scrollText = `scroll ↑/↓ · ${contentStart + 1}-${contentStart + visibleContent.length} of ${content.length}`;
const up = scrollText.indexOf("↑");
@@ -6,6 +6,7 @@ import { decodeInput, resolveKeyAction, sgrClick } from "../src/input.js";
import { renderScreen } from "../src/render.js";
import { computeExplorerRows, createViewState } from "../src/state.js";
import { createStyle, stripAnsi } from "../src/theme.js";
import { explorerWidth } from "../src/visual-layout.js";
import { stylizeLines } from "../src/stylize.js";
import type { AgentRow, FleetSnapshot } from "../src/types.js";
@@ -118,16 +119,20 @@ describe("S12 instance mission control", () => {
let scrolledSeatRows = 0;
for (let offset = 0; offset <= screen.contentMaxOffset; offset += 1) {
screen = renderScreen(view.get(), snap, { cols, rows, nowMs: 0, colorMode: "none" });
const namedRigs = new Set<string>();
for (const target of screen.contentTargets) {
if (target.action.type !== "drill" || target.action.resource !== "agent") continue;
reached.add(target.action.name);
const rig = target.action.target?.rig;
const content = stripAnsi(screen.lines[target.y - 1] ?? "").slice(screen.explorerWidth + 2);
const firstVisible = !!rig && !namedRigs.has(rig);
if (rig) namedRigs.add(rig);
if (!rig || !/\b(working|needs you|idle|detached|failed|unknown|blocked)\b/.test(content)) continue;
const headingVisible = screen.lines.some((line) => stripAnsi(line).slice(screen.explorerWidth + 2).includes(`RIG ${rig} ·`));
if (headingVisible) continue;
scrolledSeatRows += 1;
expect(content.trimStart().startsWith(rig), `${cols}: scrolled seat row retains rig identity`).toBe(true);
if (firstVisible) expect(content.startsWith(rig), `${cols}: first visible seat row of a rig retains rig identity`).toBe(true);
else expect(content.startsWith(" ".repeat(rig.length)), `${cols}: later seat rows of a rig leave RIG blank`).toBe(true);
}
view.dispatch({ type: "content-scroll", delta: 1 });
}
@@ -140,7 +145,7 @@ describe("S12 instance mission control", () => {
expect(whole).toMatch(/empty\s+—\s+\(no seats\).*stopped/);
expect(whole).toMatch(/dev\s+[\s\S]*┈{8,}[\s\S]*qa\s+/);
expect((whole.match(/┈{8,}/g) ?? [])).toHaveLength(1);
expect(whole).toMatch(/build\s+qa\s+[^\n]*\n[^\n]*─{8,}[^\n]*\n[^\n]*docs\s+write/);
expect(whole).toMatch(/\s+qa\s+[^\n]*\n[^\n]*─{8,}[^\n]*\n[^\n]*docs\s+write/);
expect(whole).toMatch(/docs\s+write\s+[^\n]*\n[^\n]*─{8,}[^\n]*\n[^\n]*empty\s+—/);
expect(whole).not.toMatch(/\+\d+|more hidden/i);
expect(reached).toEqual(expectedAgents);
@@ -319,3 +324,138 @@ describe("S12 view-aware hydration", () => {
expect(snap.hosts[0]).toMatchObject({ id: "local", name: "local" });
});
});
describe("instance table RIG column (#2848)", () => {
const LONG = "RIG-session-review";
const OVER_CAP = "rig-name-thirty-characters-xx";
function colsFor(contentWidth: number): number {
for (let cols = contentWidth; cols < contentWidth + 80; cols += 1) {
if (cols - explorerWidth(cols) - 2 === contentWidth) return cols;
}
throw new Error(`no terminal width gives content width ${contentWidth}`);
}
function snapshotWithRigs(names: string[]): FleetSnapshot {
const snap = multiRigSnapshot();
snap.hosts[0]!.rigs.push(...names.map((name) => ({
id: `r-${name}`,
name,
lifecycleState: "running",
pods: [{ name: "ops", agents: [agent("ops.worker", `ops-worker@${name}`, "idle", 10)] }],
})));
return snap;
}
function table(snap: FleetSnapshot, contentWidth: number, filter = "") {
const view = openInstance(snap);
if (filter) view.dispatch({ type: "filter", text: filter });
const cols = colsFor(contentWidth);
const screen = renderScreen(view.get(), snap, { cols, rows: 160, nowMs: 0, colorMode: "none" });
const content = screen.lines.map((line) => stripAnsi(line).slice(screen.explorerWidth + 2));
const header = content.find((line) => /^RIG\s+POD\s+SEAT/.test(line));
expect(header, `${contentWidth}: table header`).toBeDefined();
const heavyRule = content.find((line) => /^━{20,}/.test(line))!.match(/^━+/)![0].length;
const rigWidth = header!.indexOf("POD") - 1;
return { screen, content, header: header!, rigWidth, rowWidth: heavyRule };
}
it.each([
// [content width, expected RIG width, flexible column, expected flexible width]
[77, 8, "WORK", 77 - (8 + 5 + 11 + 2 + 5 + 8 + 2 + 7)],
[78, 16, "WORK", 78 - 54 - (16 - 10)],
[109, 16, "WORK", 109 - 54 - (16 - 10)],
[110, LONG.length, "NOW", 110 - 78 - (LONG.length - 13)],
[190, LONG.length, "NOW", 190 - 78 - (LONG.length - 13)],
] as const)("fits RIG to the longest shown name at content width %i", (contentWidth, rigWidth, flexible, flexibleWidth) => {
const { header, rigWidth: actual, rowWidth } = table(snapshotWithRigs([LONG]), contentWidth);
expect(actual).toBe(rigWidth);
expect(rowWidth).toBeLessThanOrEqual(contentWidth);
expect(header.trimEnd().endsWith(flexible)).toBe(true);
expect(rowWidth - header.lastIndexOf(flexible)).toBe(flexibleWidth);
});
it("keeps the old RIG width as the minimum when every name is short", () => {
expect(table(multiRigSnapshot(), 160).rigWidth).toBe(13);
expect(table(multiRigSnapshot(), 90).rigWidth).toBe(10);
});
it.each([[110, 24], [160, 24], [78, 16], [100, 16]])("caps RIG and truncates a longer name at content width %i", (contentWidth, cap) => {
const { content, rigWidth, rowWidth } = table(snapshotWithRigs([OVER_CAP]), contentWidth);
expect(rigWidth).toBe(cap);
expect(rowWidth).toBeLessThanOrEqual(contentWidth);
expect(content.some((line) => line.startsWith(`${OVER_CAP.slice(0, cap - 1)}…`))).toBe(true);
for (const line of content) expect(line.trimEnd().length).toBeLessThanOrEqual(contentWidth);
});
function seatRows(screen: ReturnType<typeof renderScreen>) {
return screen.contentTargets
.filter((target) => target.action.type === "drill" && target.action.resource === "agent")
.map((target) => ({
y: target.y,
rig: target.action.type === "drill" ? target.action.target?.rig ?? "" : "",
name: target.action.type === "drill" ? target.action.name : "",
text: stripAnsi(screen.lines[target.y - 1] ?? "").slice(screen.explorerWidth + 2),
}));
}
it("names every rig on its single row when each rig has one seat", () => {
const names = Array.from({ length: 8 }, (_, index) => `solo-${index}`);
const { screen } = table(snapshotWithRigs(names), 160);
const rows = seatRows(screen).filter((row) => names.includes(row.rig));
expect(rows.map((row) => row.rig)).toEqual(names);
for (const row of rows) expect(row.text.startsWith(`${row.rig} `)).toBe(true);
});
it("names a rig on its first row only, across pod dividers, while POD still leads each pod", () => {
const { screen, content, rigWidth } = table(multiRigSnapshot(), 160);
const build = seatRows(screen).filter((row) => row.rig === "build");
expect(build.map((row) => row.name)).toEqual(["dev.driver", "dev.guard", "qa.checker"]);
expect(build[0]!.text).toMatch(/^build\s+dev\s/);
expect(build[1]!.text.slice(0, rigWidth + 4).trim()).toBe("");
expect(build[2]!.text).toMatch(new RegExp(`^ {${rigWidth}} qa\\s`));
expect(content[build[2]!.y - 2]).toMatch(/^┈{8,}/);
expect(content.filter((line) => line.startsWith("build "))).toHaveLength(1);
});
it("drills into the row's rig from a blank RIG cell", () => {
const { screen, rigWidth } = table(multiRigSnapshot(), 160);
const guard = seatRows(screen).find((row) => row.name === "dev.guard")!;
expect(guard.text.slice(0, rigWidth).trim()).toBe("");
for (const x of [screen.explorerWidth + 3, screen.explorerWidth + 2 + rigWidth]) {
const hit = screen.hitMap.find((target) => target.y === guard.y && x >= target.x1 && x <= target.x2);
expect(hit?.action).toEqual({ type: "drill", resource: "rig", name: "build", target: { host: "mm2-openrig1" } });
}
const pastRig = screen.explorerWidth + 3 + rigWidth;
const seatHit = screen.hitMap.find((target) => target.y === guard.y && pastRig >= target.x1 && pastRig <= target.x2);
expect(seatHit?.action).toMatchObject({ type: "drill", resource: "agent", name: "dev.guard" });
});
it("names a scrolled rig on its top visible row and leaves the rows under it blank", () => {
const snap = multiRigSnapshot();
snap.hosts[0]!.rigs[0]!.pods[0]!.agents.push(...Array.from({ length: 36 }, (_, index) =>
agent(`dev.extra-${index}`, `dev-extra-${index}@build`, "active", 20 + index),
));
const view = openInstance(snap);
let screen = renderScreen(view.get(), snap, { cols: 160, rows: 30, nowMs: 0, colorMode: "none" });
view.dispatch({ type: "layout", contentMaxOffset: screen.contentMaxOffset, contentTargetCount: screen.contentTargets.length });
let checked = false;
for (let offset = 0; offset <= screen.contentMaxOffset && !checked; offset += 1) {
screen = renderScreen(view.get(), snap, { cols: 160, rows: 30, nowMs: 0, colorMode: "none" });
const build = seatRows(screen).filter((row) => row.rig === "build");
if (build.length >= 3 && build[0]!.name !== "dev.driver") {
expect(build[0]!.text.startsWith("build ")).toBe(true);
for (const row of build.slice(1)) expect(row.text.startsWith(" ")).toBe(true);
checked = true;
}
view.dispatch({ type: "content-scroll", delta: 1 });
}
expect(checked).toBe(true);
});
it("sizes RIG from the rigs the filter leaves on screen", () => {
const snap = snapshotWithRigs([LONG, "short-rig"]);
expect(table(snap, 160).rigWidth).toBe(LONG.length);
expect(table(snap, 160, "short").rigWidth).toBe(13);
});
});
+2 -1
View File
@@ -72,7 +72,8 @@ person needs in their project or home folder and name that path.
- **Start OpenRig:** `rig daemon status`, and `rig daemon start` if it isn't running. `rig preflight` and `rig doctor`
don't start it. Starting it also starts OpenRig's own team, the kernel, whose operator sets up the person's team.
Keep it.
Keep it. If its output says no person is registered, make the offer it names before you go on, and register only on
the person's yes ("Who answers OpenRig's questions" below).
- **If the person uses Codex:** now that OpenRig is running, make the one-time offer under "Codex: one offer, once,
after OpenRig starts" at the end of this step.
- **Show them OpenRig, the handoff:** once OpenRig is running, open the OpenRig TUI and operator for the person. Say