mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-11 17:32:22 -10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32ffc41937 |
No files matched your search
+1
-3
@@ -152,9 +152,7 @@ OpenRig. Two repairs aren't in this release: see Known limits.
|
||||
and the stop wasn't exercised. For an adopted session, such as a seat joined
|
||||
with `rig attach --self`, `rig seat stop` still suggests `rig down`, which
|
||||
leaves that terminal running too. Release it with `rig unclaim <session>`
|
||||
(that leaves its process running), then end that process. If it runs in tmux,
|
||||
also end the underlying tmux session, including any remaining shell; closing
|
||||
only its viewing window leaves that session running. ([#2962](https://github.com/mvschwarz/openrig/pull/2962))
|
||||
(that leaves its process running), then close it in its own terminal. ([#2962](https://github.com/mvschwarz/openrig/pull/2962))
|
||||
- **Codex working counter:** a capture that doesn't name its pane keeps the old
|
||||
comparison, a restart that keeps both the pane and its process id isn't seen,
|
||||
and a leftover live frame can count once until it expires. ([#2980](https://github.com/mvschwarz/openrig/pull/2980))
|
||||
|
||||
@@ -140,11 +140,7 @@ that gateway side too. For the pane side, read:
|
||||
|
||||
- `lastNudgeWireResult`: the wake send's result as stored (for example
|
||||
`verified`, `delivered-ack-pending`, `indeterminate:…`, `retained:…`,
|
||||
`failed:…`). A claim never rewrites it; a later wake replaces it. Each stored
|
||||
form reads as one delivery fact (`readWakeOutcome`, `connection-contract.ts`).
|
||||
A result no reader recognises reads `unknown`, never delivered: the wake ladder
|
||||
treats it as an unconfirmed wake, which escalates past the window without a
|
||||
re-send.
|
||||
`failed:…`). A claim never rewrites it; a later wake replaces it.
|
||||
- `lastNudgeResult`: the result as presented. It is the stored result, except
|
||||
that a claimed row whose stored result is `delivered-ack-pending` or
|
||||
`indeterminate:…` reads `claimed` (`presentedNudgeResult`). Unclaiming shows
|
||||
|
||||
@@ -13,9 +13,7 @@ provider usage stop updating, and Claude resume-token capture at adoption or
|
||||
handover loses its current session evidence. An earlier sidecar can remain and
|
||||
contain an older session ID; without one, context and capture report
|
||||
`missing_sidecar`, and provider usage reports `no_statusline_cache_yet` unless an
|
||||
earlier cache remains. A cache that exists but cannot be read reports
|
||||
`claude_statusline_cache_unreadable`; a readable cache that carried no rate
|
||||
limits reports `claude_statusline_cache_had_no_windows`.
|
||||
earlier cache remains.
|
||||
|
||||
## Feed both commands the same input
|
||||
|
||||
|
||||
@@ -1493,10 +1493,9 @@ Examples:
|
||||
.addHelpText("after", `
|
||||
Siblings are untouched. A dead seat is refused (use rig seat clean). An adopted
|
||||
session is refused: to end only it, run rig unclaim <session> (it releases the
|
||||
session and leaves its process running), then end that process and the
|
||||
underlying tmux session, including any remaining shell. Closing only its
|
||||
viewing window leaves the tmux session running. rig down <rig> leaves a
|
||||
session OpenRig didn't start running.
|
||||
session and leaves its process running), then end that process in its own
|
||||
terminal. rig down <rig> ends every seat in its rig, so it is the route only
|
||||
for a one-seat rig.
|
||||
A seat handed over (fresh, fork or rebuild) before OpenRig 0.7.9 still reads
|
||||
adopted and is refused too, until its next launched handover or managed launch;
|
||||
the refusal says so. Stopping ends that agent's session and any work in progress:
|
||||
|
||||
@@ -9,7 +9,6 @@ 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";
|
||||
@@ -326,5 +325,4 @@ export const ALL_MIGRATIONS: Migration[] = [
|
||||
queueItemsHandedOffFromIndexSchema,
|
||||
networkBindingsSchema,
|
||||
networkReceiptsSchema,
|
||||
sessionAddressReadIndexesSchema,
|
||||
];
|
||||
@@ -1,13 +0,0 @@
|
||||
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);
|
||||
`,
|
||||
};
|
||||
@@ -1,92 +0,0 @@
|
||||
// The shared words for what happened to a piece of work and what a member can do. Today each part of OpenRig names
|
||||
// these for itself (the transport, the outbox, the queue's wake record, the hosted ring, the watchdog), and the names
|
||||
// drift: a result one reader doesn't recognise reads as fine to it and as a failure to another. This module is where
|
||||
// the words live, so a consumer asks it instead of matching raw strings.
|
||||
//
|
||||
// It translates what is already stored; it never renames it. Activity stays in activity-taxonomy.ts, which this module
|
||||
// doesn't redefine. An unrecognised word always reads `unknown`, never success and never failure.
|
||||
|
||||
/** The two contracts' identifiers, in the daemon's existing `<name>.v<N>` form (`operation-correlation.v1`). N changes
|
||||
* only when a word's meaning changes incompatibly; new jobs and capabilities are advertised, not versioned. */
|
||||
export const TEAM_OPERATIONS_CONTRACT = "team-operations.v1";
|
||||
export const MEMBER_CONNECTION_CONTRACT = "member-connection.v1";
|
||||
|
||||
/** Whether a member or a daemon supports something. A client reads an absent answer as `unknown`, never as
|
||||
* `not-supported`. */
|
||||
export type Support = "supported" | "not-supported" | "unknown";
|
||||
|
||||
/** Who established a fact: OpenRig observing it directly, OpenRig's own adapter reporting what it read from the
|
||||
* program, or an outside adapter. A member's own claim never makes a fact observed. */
|
||||
export type Reporter = "openrig-observed" | "openrig-adapter" | "outside-adapter";
|
||||
|
||||
/** What a delivery route says happened to one attempt. None of them means the member read the work or did it: pickup
|
||||
* is a claim on the row, and nothing else. */
|
||||
export type DeliveryFact =
|
||||
/** The route took it: text typed and submitted, a trigger answered 2xx. */
|
||||
| "accepted"
|
||||
/** Queued inside the program; no start seen yet. */
|
||||
| "queued-not-started"
|
||||
/** A turn start correlated to this attempt. */
|
||||
| "turn-started"
|
||||
/** Proven that nothing reached the member: no input was written, or the route refused before sending. */
|
||||
| "not-delivered"
|
||||
/** The route reported a failure after the attempt may already have reached the member (an error answer, a dropped
|
||||
* connection): failed, with its effect unknown. */
|
||||
| "failed-effect-unknown"
|
||||
/** Held on purpose by a guard or a setting; nothing was written. */
|
||||
| "held"
|
||||
| "unknown";
|
||||
|
||||
export interface ReportedFact<F extends string> {
|
||||
fact: F;
|
||||
reporter: Reporter;
|
||||
}
|
||||
|
||||
/** The route a wake took. `none` means no route can carry it (a pull-only member, an address nothing holds). */
|
||||
export type WakeRoute = "terminal" | "gateway" | "hosted-trigger" | "none";
|
||||
|
||||
/** One wake attempt's outcome, read from the queue's recorded nudge result. */
|
||||
export interface WakeOutcome extends ReportedFact<DeliveryFact> {
|
||||
/** Null when the recorded text names no route this reader knows. */
|
||||
route: WakeRoute | null;
|
||||
/** A terminal wake whose submission was seen rendered, not only sent. */
|
||||
renderVerified?: true;
|
||||
/** Which guard held it: the seat's hold or interruption level (retrying is held again), or the draft-aware check
|
||||
* (a draft or unreadable input, which can clear on its own). */
|
||||
heldBy?: "typing-guard" | "draft-aware";
|
||||
/** The recorded text, unchanged. */
|
||||
recorded: string;
|
||||
}
|
||||
|
||||
/** Read a queue row's recorded nudge result (`last_nudge_result`) as a delivery fact. Every form a producer writes
|
||||
* today is listed; anything else reads `unknown` with no route, so a consumer can tell "the wake's outcome can't be
|
||||
* read" from every known outcome. Null when no attempt was recorded. */
|
||||
export function readWakeOutcome(recorded: string | null | undefined): WakeOutcome | null {
|
||||
if (recorded === null || recorded === undefined) return null;
|
||||
const at = (fact: DeliveryFact, route: WakeRoute | null, extra: Partial<WakeOutcome> = {}): WakeOutcome =>
|
||||
({ fact, reporter: "openrig-observed", route, recorded, ...extra });
|
||||
if (recorded === "verified") return at("accepted", "terminal", { renderVerified: true });
|
||||
// Sent and submitted without render verification: the route took it, nothing more.
|
||||
if (recorded === "delivered-ack-pending") return at("accepted", "terminal");
|
||||
if (recorded === "retained:typing_guard") return at("held", "terminal", { heldBy: "typing-guard" });
|
||||
if (recorded.startsWith("retained:")) return at("held", "terminal", { heldBy: "draft-aware" });
|
||||
// A hosted member's trigger answered: the ring was taken, not the work.
|
||||
if (recorded.startsWith("wake-accepted:webhook")) return at("accepted", "hosted-trigger");
|
||||
if (recorded.startsWith("indeterminate:webhook")) return at("unknown", "hosted-trigger");
|
||||
// A ring that never left: no URL, no Authorization value, an invalid URL.
|
||||
if (/^failed:webhook \([^)]*\) (isn't set:|not rung:)/.test(recorded)) return at("not-delivered", "hosted-trigger");
|
||||
// Any other webhook failure came after the POST was sent. The trigger's own error answer is its report; a network
|
||||
// error is what OpenRig saw. Either way the member may have the ring.
|
||||
if (/^failed:webhook \([^)]*\) answered /.test(recorded)) {
|
||||
return at("failed-effect-unknown", "hosted-trigger", { reporter: "outside-adapter" });
|
||||
}
|
||||
if (recorded.startsWith("failed:webhook")) return at("failed-effect-unknown", "hosted-trigger");
|
||||
// The gateway's own ledger records whether the person was posted to; this attempt's own outcome is unknown.
|
||||
if (recorded.startsWith("gateway-owned:")) return at("unknown", "gateway");
|
||||
if (recorded.startsWith("unroutable:")) return at("not-delivered", "none");
|
||||
// `indeterminate:` covers input that may have been written (a paste, an unconfirmed Enter) and timeouts.
|
||||
if (recorded.startsWith("indeterminate:")) return at("unknown", "terminal");
|
||||
// `failed:` is written only when no input could have reached the seat.
|
||||
if (recorded.startsWith("failed:")) return at("not-delivered", "terminal");
|
||||
return at("unknown", null);
|
||||
}
|
||||
@@ -61,16 +61,15 @@ export interface ClaudeUsageReaderDeps {
|
||||
|
||||
/**
|
||||
* Read each Claude seat's provider_usage cache → provider_statusline rows via
|
||||
* claudeStatuslineSignals. Explicit unknown rows for absent cache (no_statusline_cache_yet),
|
||||
* an unreadable cache (unreadable_cache), and a valid cache with no windows (empty_reading).
|
||||
* Never throws — a malformed cache becomes an unknown row, not a crash.
|
||||
* claudeStatuslineSignals. Explicit unknown rows for absent cache (no_statusline_cache_yet) and
|
||||
* cache-present-but-no/-malformed windows (empty_reading).
|
||||
* Never throws — a malformed cache is treated as present-with-no-windows, not a crash.
|
||||
*/
|
||||
export function collectClaudeStatuslineSignals(deps: ClaudeUsageReaderDeps): ProviderSignal[] {
|
||||
const now = deps.now();
|
||||
const out: ProviderSignal[] = [];
|
||||
for (const seat of deps.listClaudeSeats()) {
|
||||
let cachePresent = false;
|
||||
let cacheUnreadable = false;
|
||||
let reading: ClaudeStatuslineReading | undefined;
|
||||
let staleAfter: string | undefined;
|
||||
let capturedAsOf: string | undefined;
|
||||
@@ -85,8 +84,7 @@ export function collectClaudeStatuslineSignals(deps: ClaudeUsageReaderDeps): Pro
|
||||
staleAfter = parsed.staleAfter;
|
||||
capturedAsOf = typeof parsed.asOf === "string" ? parsed.asOf : undefined;
|
||||
} catch {
|
||||
// Malformed cache → present-but-unreadable (its own unknown), never a throw.
|
||||
cacheUnreadable = true;
|
||||
// Malformed cache → present-with-no-windows (empty_reading unknown), never a throw.
|
||||
reading = undefined;
|
||||
}
|
||||
}
|
||||
@@ -94,7 +92,6 @@ export function collectClaudeStatuslineSignals(deps: ClaudeUsageReaderDeps): Pro
|
||||
...claudeStatuslineSignals({
|
||||
seatSession: seat.seatSession,
|
||||
cachePresent,
|
||||
cacheUnreadable,
|
||||
reading,
|
||||
// Signal asOf = when the reading was captured (the cache's asOf) when present+valid; else now.
|
||||
asOf: cachePresent && capturedAsOf ? capturedAsOf : now,
|
||||
@@ -112,7 +109,6 @@ export function collectClaudeSignalsFromProviderUsageDirectory(
|
||||
legacyDirectory?: string,
|
||||
): ProviderSignal[] {
|
||||
const seats = new Map<string, ClaudeSeatRef>();
|
||||
const unreadableFiles = new Set<string>();
|
||||
for (const candidateDirectory of [directory, legacyDirectory]) {
|
||||
if (!candidateDirectory) continue;
|
||||
try {
|
||||
@@ -123,16 +119,10 @@ export function collectClaudeSignalsFromProviderUsageDirectory(
|
||||
if (typeof parsed.seatSession === "string") {
|
||||
seats.set(parsed.seatSession, { seatSession: parsed.seatSession });
|
||||
}
|
||||
} catch {
|
||||
// A cache file that exists but cannot be read or parsed still names its seat in the
|
||||
// filename (telemetrySidecarFilename sanitizes the same seat name). Keep it in the
|
||||
// seat list so the reader reports an unreadable cache instead of the file vanishing.
|
||||
unreadableFiles.add(file.slice(0, -".json".length));
|
||||
}
|
||||
} catch { /* malformed cache cannot create a usable cache signal */ }
|
||||
}
|
||||
} catch { /* absent cache directory */ }
|
||||
}
|
||||
for (const seatSession of unreadableFiles) seats.set(seatSession, { seatSession });
|
||||
|
||||
const readFrom = (candidateDirectory: string, seatSession: string): string | null => {
|
||||
try {
|
||||
|
||||
@@ -131,8 +131,6 @@ export interface ClaudeSignalInput {
|
||||
seatSession: string;
|
||||
/** false = the statusline cache has not been written yet (before the first API response). */
|
||||
cachePresent: boolean;
|
||||
/** True when a cache file exists but cannot be read or parsed. Distinct from an empty reading. */
|
||||
cacheUnreadable?: boolean;
|
||||
/** Present only when the cache exists and carried a rate_limits object. */
|
||||
reading?: ClaudeStatuslineReading;
|
||||
asOf: string;
|
||||
@@ -142,7 +140,6 @@ export interface ClaudeSignalInput {
|
||||
export const CLAUDE_UNKNOWN_REASON = {
|
||||
no_statusline_cache_yet: "claude_no_statusline_cache_yet",
|
||||
empty_reading: "claude_statusline_cache_had_no_windows",
|
||||
unreadable_cache: "claude_statusline_cache_unreadable",
|
||||
} as const;
|
||||
|
||||
function claudeUnknownSignal(
|
||||
@@ -168,22 +165,17 @@ function claudeUnknownSignal(
|
||||
/**
|
||||
* Normalize a Claude statusline `rate_limits` reading into `signals[]` rows.
|
||||
* - seat before first response (no cache) → explicit unknown row.
|
||||
* - a cache that exists but cannot be read/parsed → its own unknown reason (broken, not empty).
|
||||
* - seat with subscription windows → one `provider_statusline` row per window (five_hour, and
|
||||
* seven_day normalized to "weekly"); a genuine 0 survives as 0.
|
||||
* - subscription with cache but no windows → explicit unknown row (never fabricated zeros).
|
||||
*/
|
||||
export function claudeStatuslineSignals(input: ClaudeSignalInput): ProviderSignal[] {
|
||||
const { seatSession, cachePresent, cacheUnreadable, reading, asOf, staleAfter } = input;
|
||||
const { seatSession, cachePresent, reading, asOf, staleAfter } = input;
|
||||
|
||||
if (!cachePresent) {
|
||||
return [claudeUnknownSignal(seatSession, asOf, CLAUDE_UNKNOWN_REASON.no_statusline_cache_yet, staleAfter)];
|
||||
}
|
||||
|
||||
if (cacheUnreadable) {
|
||||
return [claudeUnknownSignal(seatSession, asOf, CLAUDE_UNKNOWN_REASON.unreadable_cache, staleAfter)];
|
||||
}
|
||||
|
||||
const windows: Array<["five_hour" | "weekly", ClaudeStatuslineWindow | undefined]> = [
|
||||
["five_hour", reading?.five_hour],
|
||||
["weekly", reading?.seven_day],
|
||||
|
||||
@@ -34,7 +34,6 @@ import { createLinkedSuccessorLookup, deriveCrossHostSuccessorId, hasLinkedSucce
|
||||
import { stalledPickupFinding } from "./queue-pickup.js";
|
||||
import { SettingsStore } from "./user-settings/settings-store.js";
|
||||
import { loadHostRegistry } from "./hosts/hosts-registry-reader.js";
|
||||
import { readWakeOutcome } from "./connection-contract.js";
|
||||
|
||||
export const STUCK_SWEEP_INTERVAL_KEY = "queue.stuck_sweep_interval_seconds";
|
||||
export const DEFAULT_STUCK_SWEEP_INTERVAL_SECONDS = 300;
|
||||
@@ -471,9 +470,8 @@ function deliveryResolutionEvidence(
|
||||
if (row.claimedAt) return `source claimed at ${row.claimedAt}`;
|
||||
if (row.state === "blocked") return "source parked";
|
||||
if (kind === "undelivered-wake") {
|
||||
const wake = readWakeOutcome(row.lastNudgeResult);
|
||||
if (wake?.fact === "accepted" && wake.renderVerified) return "source wake verified";
|
||||
if (wake?.fact === "accepted" && wake.route === "hosted-trigger") return "source wake accepted by its hosted trigger";
|
||||
if (row.lastNudgeResult === "verified") return "source wake verified";
|
||||
if (row.lastNudgeResult?.startsWith("wake-accepted:")) return "source wake accepted by its hosted trigger";
|
||||
}
|
||||
}
|
||||
if (deps.queueRepo.humanNotificationPostedThisEpisode(qitemId)) return "current human ask posted";
|
||||
|
||||
@@ -12,7 +12,6 @@ import type { OperatorDeliveryEngine } from "./gateway/operator-delivery-engine.
|
||||
import { openPromptRefusals, CLOSE_PREFIX, PARKED_OWNER_POLICY_NAME, REFUSED_PREFIX } from "./policies/parked-owner-consumer.js";
|
||||
import { findQueueRecovery, recoveryTag } from "./queue-recovery.js";
|
||||
import { lastMeaningfulTransition, type WaitingView } from "./queue-waiting.js";
|
||||
import { readWakeOutcome } from "./connection-contract.js";
|
||||
// S01 (OPR.0.5.5.1) — WAKE OR ESCALATE ON BATONS. A handoff whose wake fails must never
|
||||
// silently park: today one failed nudge is recorded and nothing follows (the measured
|
||||
// dominant 0.5.3 failure class — a perfect surviving packet, a recipient never woken).
|
||||
@@ -384,44 +383,28 @@ function hasPickupEvidence(db: Database.Database, row: Pick<QueueItem, "qitemId"
|
||||
type WakeMode = "failed" | "unconfirmed" | "held";
|
||||
|
||||
function classifyWakeResult(lastNudgeResult: string | null): WakeMode | null {
|
||||
// A claimed row's unconfirmed result is presented as `claimed` (presentedNudgeResult): the claim is pickup, which
|
||||
// outranks any wake outcome.
|
||||
if (lastNudgeResult === "claimed") return null;
|
||||
const outcome = readWakeOutcome(lastNudgeResult);
|
||||
if (!outcome) return null;
|
||||
switch (outcome.fact) {
|
||||
// Nothing reached the seat, so a retry can't double-deliver. An address no route can carry (`none`) has nothing
|
||||
// to retry; its own notice covers it.
|
||||
case "not-delivered":
|
||||
return outcome.route === "none" ? null : "failed";
|
||||
// A webhook failure after the POST: its effect is unknown, but it keeps the failed path and retry policy it has
|
||||
// always had.
|
||||
case "failed-effect-unknown":
|
||||
return "failed";
|
||||
// #344 follow-up — a typing-guard refusal (retained:) wrote NO pane input, so an
|
||||
// unclaimed baton keeps a retry path instead of stranding behind a terminal
|
||||
// recovery. Admission is scoped to unclaimed batons by unclaimedWakeMode(): the
|
||||
// claimed parked-owner arm's retry readback still selects `failed:%` only, so
|
||||
// admitting retained there would make recovery OWN a row that nothing retries.
|
||||
// Retry behaviour, as it actually runs: the first retry re-probes the guard and
|
||||
// records a fresh attempt; later retries reuse the same delivery id, whose
|
||||
// retained readback returns `retained` again even after the guard has cleared —
|
||||
// so the row re-probes once and then escalates rather than looping probes.
|
||||
// A draft-aware hold can clear on its own (the draft is sent or cleared), so it keeps that retry path. A hold or a
|
||||
// level that holds the wake is the person's choice: retrying is held again, so it reads as held.
|
||||
case "held":
|
||||
return outcome.heldBy === "typing-guard" ? "held" : "failed";
|
||||
// A rendered terminal wake, or a hosted trigger that took the ring (the stuck sweep owns a hosted member's
|
||||
// unclaimed work), never enters. An unverified terminal send rides the confirmation path.
|
||||
case "accepted":
|
||||
return outcome.renderVerified || outcome.route === "hosted-trigger" ? null : "unconfirmed";
|
||||
// Unknown, including a recorded result no reader recognises: it never retries (input may have arrived), and it
|
||||
// escalates past the window when nothing picked the work up, instead of reading as delivered.
|
||||
case "queued-not-started":
|
||||
case "turn-started":
|
||||
case "unknown":
|
||||
return "unconfirmed";
|
||||
}
|
||||
if (!lastNudgeResult) return null;
|
||||
if (lastNudgeResult.startsWith("failed:")) return "failed";
|
||||
// #344 follow-up — a typing-guard refusal (retained:) wrote NO pane input, so an
|
||||
// unclaimed baton keeps a retry path instead of stranding behind a terminal
|
||||
// recovery. Admission is scoped to unclaimed batons by unclaimedWakeMode(): the
|
||||
// claimed parked-owner arm's retry readback still selects `failed:%` only, so
|
||||
// admitting retained there would make recovery OWN a row that nothing retries.
|
||||
// Retry behaviour, as it actually runs: the first retry re-probes the guard and
|
||||
// records a fresh attempt; later retries reuse the same delivery id, whose
|
||||
// retained readback returns `retained` again even after the guard has cleared —
|
||||
// so the row re-probes once and then escalates rather than looping probes.
|
||||
// A draft-aware hold can clear on its own (the draft is sent or cleared), so it keeps that retry path. A hold or a
|
||||
// level that holds the wake is the person's choice: retrying is held again, so it reads as held.
|
||||
if (lastNudgeResult === "retained:typing_guard") return "held";
|
||||
if (lastNudgeResult.startsWith("retained:")) return "failed";
|
||||
if (
|
||||
lastNudgeResult === "delivered-ack-pending" ||
|
||||
lastNudgeResult.startsWith("indeterminate:") ||
|
||||
lastNudgeResult.startsWith("gateway-owned:")
|
||||
)
|
||||
return "unconfirmed";
|
||||
return null; // verified (or unknown vocabulary) — never enters the ladder
|
||||
}
|
||||
|
||||
/** A retained (typing-guard) result is retryable ONLY for an unclaimed baton. The
|
||||
@@ -433,7 +416,7 @@ function classifyWakeResult(lastNudgeResult: string | null): WakeMode | null {
|
||||
function unclaimedWakeMode(row: Pick<QueueItem, "state" | "claimedAt" | "handedOffFrom" | "lastNudgeResult">): WakeMode | null {
|
||||
const mode = classifyWakeResult(row.lastNudgeResult);
|
||||
if (mode === null) return null;
|
||||
const retained = readWakeOutcome(row.lastNudgeResult)?.fact === "held";
|
||||
const retained = row.lastNudgeResult?.startsWith("retained:") ?? false;
|
||||
if (retained && !(row.state === "pending" && !row.claimedAt && row.handedOffFrom)) return null;
|
||||
return mode;
|
||||
}
|
||||
@@ -880,9 +863,7 @@ export async function runWakeLadderTick(deps: WakeLadderDeps): Promise<WakeLadde
|
||||
? `wake failed ${view.attempts} times over ${minutesSince(view.firstMarkerTs ?? row.tsCreated, now)} min`
|
||||
: mode === "held"
|
||||
? `held by ${row.destinationSession}'s typing guard for ${minutesSince(row.tsCreated, now)} min: nothing was typed, and it isn't retried`
|
||||
: readWakeOutcome(row.lastNudgeResult)?.route === null
|
||||
? `wake outcome unknown (unrecognised result "${row.lastNudgeResult!.slice(0, 80)}") with no pickup evidence over ${minutesSince(row.tsCreated, now)} min`
|
||||
: `unconfirmed delivery with no pickup evidence over ${minutesSince(row.tsCreated, now)} min`;
|
||||
: `unconfirmed delivery with no pickup evidence over ${minutesSince(row.tsCreated, now)} min`;
|
||||
if (hasConsumedEscalationHop(deps.db, row.qitemId)) {
|
||||
if (!due || suspended) continue;
|
||||
const consumedReason = `${reason}; escalates_to one-hop delivery already used; existing fallback retained`;
|
||||
|
||||
@@ -73,8 +73,7 @@ const SEAT_LOOKUP_GUIDANCE = "List seats with: rig ps --nodes";
|
||||
|
||||
/**
|
||||
* The supported route for a session registered as adopted (origin=claimed): `rig unclaim` releases
|
||||
* it and leaves its process running, so ending that process and its tmux session ends only this seat. Closing only
|
||||
* a viewing window detaches from the tmux session and leaves it running.
|
||||
* it and leaves its process running, so ending that process in its own terminal ends only this seat.
|
||||
* `rig down` stops only a session carrying the seat's OpenRig marker, which OpenRig sets on the sessions
|
||||
* it starts and an adopted, bound or self-attached session lacks, so it isn't a route here and the
|
||||
* guidance says why. Only the guidance names the route; the refusal stands. A seat handed over before
|
||||
@@ -86,13 +85,9 @@ export function claimedSessionRoutes(rigName: string, sessionName: string, exter
|
||||
const release = `To release it from OpenRig and leave its process running, run rig unclaim ${session}.`;
|
||||
if (externalCli) return `This seat was joined with rig attach as an external CLI, so rig down leaves it running. ${release} End the process in its own terminal.`;
|
||||
return `To end this session, run rig unclaim ${session} (it releases the session from OpenRig and leaves its process running), `
|
||||
+ `then end that process and the underlying tmux session, including any remaining shell, so no session keeps the seat's name. `
|
||||
+ `${VIEWER_LEAVES_IT} ${rigDownLeavesIt(rigName)}`;
|
||||
+ `then end that process in its own terminal. ${rigDownLeavesIt(rigName)}`;
|
||||
}
|
||||
|
||||
/** Closing a viewer detaches; the tmux session, its shell and the seat's name stay. */
|
||||
const VIEWER_LEAVES_IT = "Closing only its viewing window leaves the tmux session running.";
|
||||
|
||||
/** Why `rig down <rig>` isn't the route for an adopted session. */
|
||||
function rigDownLeavesIt(rigName: string): string {
|
||||
return `rig down ${shellQuote(rigName)} leaves a session OpenRig didn't start running.`;
|
||||
@@ -1386,8 +1381,6 @@ export class SeatLifecycleService {
|
||||
ok: false,
|
||||
code: "unmanaged_session_collision",
|
||||
message: `Canonical tmux session "${canonicalSessionName}" exists but is not owned by this seat's current managed rows; refusing to overwrite it.`,
|
||||
guidance: "If you released this session with rig unclaim, end that underlying tmux session, including any remaining shell, "
|
||||
+ "then launch again. Closing only its viewing window doesn't end the session.",
|
||||
};
|
||||
}
|
||||
if (!stop) {
|
||||
@@ -1431,10 +1424,8 @@ export class SeatLifecycleService {
|
||||
return {
|
||||
ok: false, code: "claimed_session",
|
||||
message: `Seat "${seatRef}" has an adopted/operator-owned occupant; fresh launch refuses even with --stop.`,
|
||||
guidance: `Release it with rig unclaim ${shellQuote(claimed.session_name)} (its process keeps running), `
|
||||
+ (externalCli
|
||||
? "end that process and close its terminal, then launch fresh."
|
||||
: `end that process and the underlying tmux session, including any remaining shell, then launch fresh. ${VIEWER_LEAVES_IT} ${rigDownLeavesIt(seat.rigName)}`),
|
||||
guidance: `Release it with rig unclaim ${shellQuote(claimed.session_name)} (its process keeps running), end that process yourself, then launch fresh.`
|
||||
+ (externalCli ? "" : ` ${rigDownLeavesIt(seat.rigName)}`),
|
||||
};
|
||||
}
|
||||
const binding = this.sessionRegistry.getBindingForNode(nodeId);
|
||||
|
||||
@@ -124,25 +124,14 @@ 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 current = `SELECT r.name, r.archived_at, n.id AS nodeId, n.logical_id AS logicalId,
|
||||
const rows = db.prepare(`SELECT r.*, 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
|
||||
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)`)
|
||||
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=?`)
|
||||
.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);
|
||||
@@ -794,7 +783,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, s.rowid`
|
||||
ORDER BY s.created_at`
|
||||
)
|
||||
.all(rigId) as SessionRow[];
|
||||
|
||||
|
||||
@@ -282,7 +282,6 @@ function findCurrentClaudeQuestion(paneContent: string): string | null {
|
||||
const CLAUDE_STATUS_WARNINGS = [
|
||||
/^✘ Auto-update failed(?:: [^·\r\n]+)? · Run claude doctor$/,
|
||||
/^✘ Auto-update failed · Try claude doctor or (?:npm i -g @anthropic-ai\/claude-code|cd ~\/\.claude\/local && npm update @anthropic-ai\/claude-code)$/,
|
||||
/^✘ Auto-update failed · Try claude doctor or npm i -g @anthropic-ai\/claude-c…$/,
|
||||
/^tmux focus-events off · add 'set -g focus-events on' to ~\/\.tmux\.conf and re…$/,
|
||||
/^You've used (?:\d|[1-9]\d)% of your weekly limit · resets \d{1,2}(?::\d{2})?(?:am|pm) \(UTC\)$/,
|
||||
// #1641: Claude 2.1.293's update banner, whatever installer it names.
|
||||
|
||||
@@ -89,23 +89,6 @@ describe("cursor-bound composer input", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["", "person's draft"])("reads the observed clipped auto-update footer with input %j", body => {
|
||||
// A complete synthetic frame; the retained partial capture cannot supply physical cursor rows.
|
||||
const snapshot = claude(body);
|
||||
snapshot.screen = snapshot.screen.replace("? for shortcuts", "⏵⏵ accept edits on (shift+tab to cycle)\n✘ Auto-update failed · Try claude doctor or npm i -g @anthropic-ai/claude-c…");
|
||||
expect(inspectComposerInput(snapshot, "claude-code").state).toBe(body ? "text" : "empty");
|
||||
});
|
||||
|
||||
it("keeps near misses unknown below the footer and warning text inside a draft", () => {
|
||||
const warning = "✘ Auto-update failed · Try claude doctor or npm i -g @anthropic-ai/claude-c…";
|
||||
for (const nearMiss of [warning + " now", warning.replace("doctor", "doctors"), warning.replace("claude-c…", "other…")]) {
|
||||
const snapshot = claude("");
|
||||
snapshot.screen = snapshot.screen.replace("? for shortcuts", `? for shortcuts\n${nearMiss}`);
|
||||
expect(inspectComposerInput(snapshot, "claude-code").state).toBe("unknown");
|
||||
}
|
||||
expect(inspectComposerInput(claude(warning, undefined, 100), "claude-code").state).toBe("text");
|
||||
});
|
||||
|
||||
it("reads Claude's context hint below a named composer without skipping arbitrary rows", () => {
|
||||
const frame = (body: string, styled = false, hint = "~66k uncached · /clear to start fresh"): ComposerSnapshot => ({
|
||||
screen: [...Array<string>(22).fill(""),
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readWakeOutcome } from "../src/domain/connection-contract.js";
|
||||
import { NUDGE_FAIL_PREFIX } from "../src/domain/policies/parked-owner-consumer.js";
|
||||
|
||||
describe("readWakeOutcome: every recorded nudge result reads as a delivery fact", () => {
|
||||
// One row per form a producer writes today (queue-repository, hosted-wake, the destination resolver, the parked-owner
|
||||
// consumer). A new producer form belongs here too, or it reads unknown.
|
||||
const cases: Array<[string, Record<string, unknown>]> = [
|
||||
["verified", { fact: "accepted", route: "terminal", renderVerified: true }],
|
||||
["delivered-ack-pending", { fact: "accepted", route: "terminal" }],
|
||||
["retained:typing_guard", { fact: "held", route: "terminal", heldBy: "typing-guard" }],
|
||||
["retained:draft_aware", { fact: "held", route: "terminal", heldBy: "draft-aware" }],
|
||||
["wake-accepted:webhook (hook) answered 202; pickup is the member's claim on this row", { fact: "accepted", route: "hosted-trigger" }],
|
||||
["indeterminate:webhook (hook) didn't answer within 5000 ms", { fact: "unknown", route: "hosted-trigger" }],
|
||||
// A webhook failure after the POST doesn't prove the member missed the ring; one before it does.
|
||||
["failed:webhook (hook) answered 500", { fact: "failed-effect-unknown", route: "hosted-trigger", reporter: "outside-adapter" }],
|
||||
["failed:webhook (hook) answered 302, a redirect, which isn't followed", { fact: "failed-effect-unknown", route: "hosted-trigger", reporter: "outside-adapter" }],
|
||||
["failed:webhook (hook) couldn't be reached (ECONNRESET)", { fact: "failed-effect-unknown", route: "hosted-trigger" }],
|
||||
["failed:webhook (hook) isn't set: no webhook URL to ring", { fact: "not-delivered", route: "hosted-trigger" }],
|
||||
["failed:webhook (hook) not rung: its Authorization value (auth) isn't set", { fact: "not-delivered", route: "hosted-trigger" }],
|
||||
["failed:webhook (hook) not rung: the URL is invalid (bad scheme)", { fact: "not-delivered", route: "hosted-trigger" }],
|
||||
["gateway-owned: 'slack:U1@external' is a virtual @external destination", { fact: "unknown", route: "gateway" }],
|
||||
["unroutable: 'm@r' is a hosted member with no wake trigger", { fact: "not-delivered", route: "none" }],
|
||||
["indeterminate:wake send timed out", { fact: "unknown", route: "terminal" }],
|
||||
["indeterminate:earlier delivery unconfirmed; retry failed:x", { fact: "unknown", route: "terminal" }],
|
||||
["indeterminate: parked-owner wake delivery — timed out", { fact: "unknown", route: "terminal" }],
|
||||
["failed:tmux session not found", { fact: "not-delivered", route: "terminal" }],
|
||||
["failed:terminal-seat: 'sh@r' is a terminal seat", { fact: "not-delivered", route: "terminal" }],
|
||||
["failed:wake not retained: disk full", { fact: "not-delivered", route: "terminal" }],
|
||||
[`${NUDGE_FAIL_PREFIX} — refused`, { fact: "not-delivered", route: "terminal" }],
|
||||
];
|
||||
|
||||
it.each(cases)("%s", (recorded, expected) => {
|
||||
const outcome = readWakeOutcome(recorded)!;
|
||||
expect(outcome).toMatchObject({ reporter: "openrig-observed", ...expected, recorded });
|
||||
});
|
||||
|
||||
it("only a rendered terminal wake is render-verified; an unverified send is accepted, nothing more", () => {
|
||||
expect(readWakeOutcome("verified")?.renderVerified).toBe(true);
|
||||
expect(readWakeOutcome("delivered-ack-pending")?.renderVerified).toBeUndefined();
|
||||
});
|
||||
|
||||
it("an unrecognised result reads unknown with no route, never success or failure", () => {
|
||||
for (const recorded of ["accepted-by-some-new-route", "claimed", "", "VERIFIED", "ok"]) {
|
||||
expect(readWakeOutcome(recorded)).toEqual({ fact: "unknown", reporter: "openrig-observed", route: null, recorded });
|
||||
}
|
||||
});
|
||||
|
||||
it("no recorded attempt reads as no outcome", () => {
|
||||
expect(readWakeOutcome(null)).toBeNull();
|
||||
expect(readWakeOutcome(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,6 @@ 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";
|
||||
@@ -107,7 +106,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, rigArchiveSchema, eventsSchema, queueItemSummarySchema, queueItemsSchema, queueTransitionsSchema, queueTransitionWakesSchema, viewsCustomSchema]);
|
||||
migrate(db, [coreSchema, bindingsSessionsSchema, 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") =>
|
||||
|
||||
@@ -19,7 +19,6 @@ 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";
|
||||
@@ -168,7 +167,7 @@ describe("execution view — S27 (OPR.0.5.6.27)", () => {
|
||||
db = createDb();
|
||||
migrate(db, [
|
||||
coreSchema,
|
||||
bindingsSessionsSchema, rigArchiveSchema,
|
||||
bindingsSessionsSchema,
|
||||
eventsSchema,
|
||||
queueItemsSchema,
|
||||
queueItemSummarySchema,
|
||||
|
||||
@@ -108,7 +108,6 @@ 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";
|
||||
@@ -169,7 +168,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, sessionAddressReadIndexesSchema];
|
||||
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];
|
||||
|
||||
/**
|
||||
* P24 — the DECLARED exclusions for {@link migrationsForFullTestDb}. That list is deliberately a
|
||||
|
||||
@@ -16,7 +16,7 @@ import { NetworkBindingStore, networkCredentialFile } from "../src/domain/networ
|
||||
import { Hono } from "hono";
|
||||
import { connectMcpHub, HubError, type HubConnect, type HubItem, type HubSession } from "../src/domain/network/hub-client.js";
|
||||
import { networkRoutes } from "../src/routes/network.js";
|
||||
import { NetworkConnectorService, networkClosedElsewhere, networkLocalItemId, runNetworkBindingPass, type NetworkConnectorDeps } from "../src/domain/network/network-connector.js";
|
||||
import { NetworkConnectorService, networkLocalItemId, runNetworkBindingPass, type NetworkConnectorDeps } from "../src/domain/network/network-connector.js";
|
||||
|
||||
const PARTICIPANT = "alex@team";
|
||||
|
||||
@@ -424,16 +424,6 @@ describe("network connector", () => {
|
||||
expect(repo.getById(local!.qitemId)!.resolution).toBe("Here it is.");
|
||||
});
|
||||
|
||||
it("closed-elsewhere conflicts are listed by when they settled, and ones that settled in the same millisecond by local item", () => {
|
||||
const add = db.prepare(`INSERT INTO network_receipts (local_qitem_id, binding_id, hub_qitem_id, created_at, settled_at, outcome)
|
||||
VALUES (?, 'netb-order', ?, '2026-11-11T19:00:00.000Z', ?, 'closed_on_hub')`);
|
||||
// The latest has the first id, and the two that tie were recorded, and name hub items, out of local-item order.
|
||||
add.run("qitem-net-a", "hub-1", "2026-11-11T19:00:00.001Z");
|
||||
add.run("qitem-net-c", "hub-2", "2026-11-11T19:00:00.000Z");
|
||||
add.run("qitem-net-b", "hub-3", "2026-11-11T19:00:00.000Z");
|
||||
expect(networkClosedElsewhere(db, "netb-order").map((c) => c.localItem)).toEqual(["qitem-net-b", "qitem-net-c", "qitem-net-a"]);
|
||||
});
|
||||
|
||||
it("a hub item already failed elsewhere gets no write and isn't our result, and a close that loses a race is read back as closed elsewhere", async () => {
|
||||
const b = bind();
|
||||
hub.add("hub-f1");
|
||||
@@ -461,12 +451,10 @@ describe("network connector", () => {
|
||||
// A later pass's ordinary contact doesn't clear it, and it's read from the receipts, so a restart keeps it.
|
||||
expect((await pass(b.id)).error).toBeUndefined();
|
||||
const listed = await (await app.request("/api/network/bindings")).json() as { bindings: Array<{ id: string; closedElsewhere: unknown[] }> };
|
||||
// Listed in the order they settled, which can fall in one millisecond or two, so compared by local item.
|
||||
const byLocal = (x: { localItem: string }, y: { localItem: string }) => x.localItem.localeCompare(y.localItem);
|
||||
expect([...listed.bindings.find((x) => x.id === b.id)!.closedElsewhere as Array<{ localItem: string }>].sort(byLocal)).toEqual([
|
||||
expect(listed.bindings.find((x) => x.id === b.id)!.closedElsewhere).toEqual([
|
||||
{ hubItem: "hub-f1", localItem: f1!.qitemId, note: `The hub item was closed elsewhere (failed) before this result reached it. The result stays on ${f1!.qitemId}.` },
|
||||
{ hubItem: "hub-f2", localItem: f2!.qitemId, note: `The hub item was closed elsewhere (done, no-follow-on) before this result reached it. The result stays on ${f2!.qitemId}.` },
|
||||
].sort(byLocal));
|
||||
].sort((x, y) => x.localItem.localeCompare(y.localItem)));
|
||||
});
|
||||
|
||||
it("a close without a resolution sends only the closer's own closing note, never another note on the item", async () => {
|
||||
|
||||
@@ -112,40 +112,6 @@ describe("Slice-04 C3 — seat-keyed Claude provider_usage discovery (production
|
||||
}
|
||||
});
|
||||
|
||||
it("a live seat whose only cache is malformed reports unreadable_cache through real getReadModel", async () => {
|
||||
const root = fs.mkdtempSync(nodePath.join(os.tmpdir(), "provider-c3-unreadable-"));
|
||||
try {
|
||||
const usageDir = nodePath.join(root, "provider-usage");
|
||||
fs.mkdirSync(usageDir, { recursive: true });
|
||||
const seatSession = "dev-impl@test-rig";
|
||||
fs.writeFileSync(nodePath.join(usageDir, `${seatSession}.json`), "{ not json");
|
||||
|
||||
const db = createFullTestDb();
|
||||
seedClaudeSeat(db, seatSession);
|
||||
const svc = new ProviderServiceImpl({
|
||||
db,
|
||||
listRigs: () => [{ id: "rig-1" }],
|
||||
env: emptyCodexHomeEnv(),
|
||||
now: () => ASOF,
|
||||
collectClaudeSignals: () => collectClaudeSignalsFromProviderUsageDirectory(usageDir, () => ASOF),
|
||||
});
|
||||
const model = await svc.getReadModel();
|
||||
const claude = model.signals.filter((signal) => signal.provider === "claude");
|
||||
|
||||
expect(claude).toHaveLength(1);
|
||||
expect(claude[0]).toMatchObject({
|
||||
seatSession,
|
||||
sourceClass: "unknown",
|
||||
authority: "unknown",
|
||||
unknownReason: "claude_statusline_cache_unreadable",
|
||||
automationUse: "do_not_automate",
|
||||
});
|
||||
expect(claude[0]!.usedPercent).toBeUndefined();
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("an exited latest Claude session plus matching stale cache emits zero Claude signals", async () => {
|
||||
const root = fs.mkdtempSync(nodePath.join(os.tmpdir(), "provider-c3-exited-"));
|
||||
try {
|
||||
|
||||
@@ -76,20 +76,12 @@ describe("collectClaudeStatuslineSignals — reader → provider_statusline / ex
|
||||
expect(sigs[0]!.unknownReason).toBe(CLAUDE_UNKNOWN_REASON.empty_reading);
|
||||
});
|
||||
|
||||
it("malformed cache JSON → unknown(unreadable_cache), never throws", () => {
|
||||
it("malformed cache JSON → unknown(empty_reading), never throws", () => {
|
||||
const sigs = collectClaudeStatuslineSignals(readerDeps({
|
||||
listClaudeSeats: () => [{ seatSession: "dev-impl@rig" }],
|
||||
readCacheRaw: () => "{ not json",
|
||||
}));
|
||||
expect(sigs[0]!.sourceClass).toBe("unknown");
|
||||
expect(sigs[0]!.unknownReason).toBe(CLAUDE_UNKNOWN_REASON.unreadable_cache);
|
||||
});
|
||||
|
||||
it("a valid cache with no windows keeps empty_reading (distinct from an unreadable cache)", () => {
|
||||
const sigs = collectClaudeStatuslineSignals(readerDeps({
|
||||
listClaudeSeats: () => [{ seatSession: "dev-impl@rig" }],
|
||||
readCacheRaw: () => JSON.stringify({ seatSession: "dev-impl@rig", asOf: CACHE_ASOF }),
|
||||
}));
|
||||
expect(sigs[0]!.unknownReason).toBe(CLAUDE_UNKNOWN_REASON.empty_reading);
|
||||
});
|
||||
});
|
||||
@@ -119,33 +111,14 @@ describe("0.5.8 provider-usage compatibility bridge", () => {
|
||||
const malformedCanonical = collectClaudeSignalsFromProviderUsageDirectory(canonical, () => ASOF, legacy);
|
||||
expect(malformedCanonical.find((signal) => signal.seatSession === "legacy@rig")?.usedPercent).toBeUndefined();
|
||||
expect(malformedCanonical.find((signal) => signal.seatSession === "legacy@rig")?.unknownReason)
|
||||
.toBe(CLAUDE_UNKNOWN_REASON.unreadable_cache);
|
||||
.toBe(CLAUDE_UNKNOWN_REASON.empty_reading);
|
||||
|
||||
fs.rmSync(path.join(canonical, "[email protected]"));
|
||||
fs.mkdirSync(path.join(canonical, "[email protected]"));
|
||||
const wrongTypeCanonical = collectClaudeSignalsFromProviderUsageDirectory(canonical, () => ASOF, legacy);
|
||||
expect(wrongTypeCanonical.find((signal) => signal.seatSession === "legacy@rig")?.usedPercent).toBeUndefined();
|
||||
expect(wrongTypeCanonical.find((signal) => signal.seatSession === "legacy@rig")?.unknownReason)
|
||||
.toBe(CLAUDE_UNKNOWN_REASON.unreadable_cache);
|
||||
.toBe(CLAUDE_UNKNOWN_REASON.empty_reading);
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("collectClaudeSignalsFromProviderUsageDirectory — a malformed-only cache stays visible", () => {
|
||||
it("reports unreadable_cache for a seat known only from a malformed file's name", () => {
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), "provider-unreadable-"));
|
||||
const directory = path.join(home, "state", "provider-usage");
|
||||
fs.mkdirSync(directory, { recursive: true });
|
||||
fs.writeFileSync(path.join(directory, "[email protected]"), "{ not json");
|
||||
try {
|
||||
const signals = collectClaudeSignalsFromProviderUsageDirectory(directory, () => ASOF);
|
||||
expect(signals).toHaveLength(1);
|
||||
expect(signals[0]!.seatSession).toBe("broken@rig");
|
||||
expect(signals[0]!.sourceClass).toBe("unknown");
|
||||
expect(signals[0]!.unknownReason).toBe(CLAUDE_UNKNOWN_REASON.unreadable_cache);
|
||||
expect(signals[0]!.usedPercent).toBeUndefined();
|
||||
} finally {
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { CLAUDE_UNKNOWN_REASON, claudeStatuslineSignals } from "../src/domain/provider/provider-signals.js";
|
||||
import { claudeStatuslineSignals } from "../src/domain/provider/provider-signals.js";
|
||||
|
||||
// Slice-04 (OPR.0.5.0.4) — Claude statusline lane normalization (§2 Claude lane + the
|
||||
// 2026-07-31 RESEARCH verdict). Proof item 6: the statusline sidecar's rate_limits object
|
||||
@@ -68,21 +68,5 @@ describe("claudeStatuslineSignals — Claude lane honesty", () => {
|
||||
expect(sigs[0].sourceClass).toBe("unknown");
|
||||
expect(sigs[0].usedPercent).toBeUndefined();
|
||||
expect(sigs[0].automationUse).toBe("do_not_automate");
|
||||
expect(sigs[0].unknownReason).toBe(CLAUDE_UNKNOWN_REASON.empty_reading);
|
||||
});
|
||||
|
||||
it("an unreadable cache gets its own reason, not the empty-reading one", () => {
|
||||
const sigs = claudeStatuslineSignals({
|
||||
seatSession: "dev-impl@rig",
|
||||
cachePresent: true,
|
||||
cacheUnreadable: true,
|
||||
asOf: ASOF,
|
||||
});
|
||||
expect(sigs).toHaveLength(1);
|
||||
expect(sigs[0].sourceClass).toBe("unknown");
|
||||
expect(sigs[0].unknownReason).toBe(CLAUDE_UNKNOWN_REASON.unreadable_cache);
|
||||
expect(sigs[0].unknownReason).not.toBe(CLAUDE_UNKNOWN_REASON.empty_reading);
|
||||
expect(sigs[0].usedPercent).toBeUndefined();
|
||||
expect(sigs[0].automationUse).toBe("do_not_automate");
|
||||
});
|
||||
});
|
||||
@@ -275,56 +275,6 @@ describe("S01 wake-or-escalate — retry ladder, named rungs, derived suspension
|
||||
expect(calls.some((c) => c.target === "worker@r")).toBe(false);
|
||||
});
|
||||
|
||||
it("UNKNOWN READS UNKNOWN: a baton whose recorded wake result no reader recognises escalates past the window, never re-sent, and the notice says the outcome is unknown", async () => {
|
||||
const baton = await mkBaton();
|
||||
setNudgeResult(baton.qitemId, "accepted-by-some-new-route", 45);
|
||||
ageCreated(baton.qitemId, 45);
|
||||
await tick();
|
||||
expect(markersOf(baton.qitemId, LADDER_ATTEMPT_PREFIX)).toHaveLength(0);
|
||||
const rungs = markersOf(baton.qitemId, LADDER_RUNG_PREFIX);
|
||||
expect(rungs).toHaveLength(1);
|
||||
expect(rungs[0]).toMatch(/wake outcome unknown \(unrecognised result "accepted-by-some-new-route"\)/);
|
||||
expect(calls.some((c) => c.target === "worker@r")).toBe(false);
|
||||
});
|
||||
|
||||
it("UNKNOWN READS UNKNOWN (negative controls): inside the window, or once claimed, an unrecognised result gets nothing", async () => {
|
||||
const fresh = await mkBaton();
|
||||
setNudgeResult(fresh.qitemId, "accepted-by-some-new-route", 5);
|
||||
const claimed = await mkBaton("other@r");
|
||||
setNudgeResult(claimed.qitemId, "accepted-by-some-new-route", 45);
|
||||
ageCreated(claimed.qitemId, 45);
|
||||
repo.claim({ qitemId: claimed.qitemId, destinationSession: "other@r" });
|
||||
await tick();
|
||||
expect(calls).toHaveLength(0);
|
||||
expect(markersOf(fresh.qitemId, LADDER_RUNG_PREFIX)).toHaveLength(0);
|
||||
expect(markersOf(claimed.qitemId, LADDER_RUNG_PREFIX)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("KNOWN OUTCOMES KEEP THEIR PATH: a verified wake, a hosted trigger's accepted ring and an unroutable address never enter the ladder", async () => {
|
||||
const batons = [];
|
||||
for (const [idx, result] of ["verified", "wake-accepted:webhook (hook) answered 202; pickup is the member's claim on this row",
|
||||
"unroutable: 'w2@r' has no terminal transport on this daemon"].entries()) {
|
||||
const baton = await mkBaton(`w${idx}@r`);
|
||||
setNudgeResult(baton.qitemId, result, 45);
|
||||
ageCreated(baton.qitemId, 45);
|
||||
batons.push(baton);
|
||||
}
|
||||
await tick();
|
||||
expect(calls).toHaveLength(0);
|
||||
for (const baton of batons) {
|
||||
expect(markersOf(baton.qitemId, LADDER_ATTEMPT_PREFIX)).toHaveLength(0);
|
||||
expect(markersOf(baton.qitemId, LADDER_RUNG_PREFIX)).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("A WEBHOOK FAILURE AFTER THE POST KEEPS THE FAILED PATH: its effect is unknown, but it is retried as it always was", async () => {
|
||||
const baton = await mkBaton();
|
||||
setNudgeResult(baton.qitemId, "failed:webhook (hook) answered 500", 10);
|
||||
await tick();
|
||||
expect(markersOf(baton.qitemId, LADDER_ATTEMPT_PREFIX)).toHaveLength(1);
|
||||
expect(calls).toEqual([{ qitemId: baton.qitemId, target: "worker@r" }]);
|
||||
});
|
||||
|
||||
it("DELIVERY MACHINERY IS NOT PICKUP: a relay-owner-notification-transport-failed note on an unconfirmed or held baton does not count as pickup evidence", async () => {
|
||||
const mod = await ladderMod();
|
||||
for (const [idx, result] of ["gateway-owned: virtual destination", "retained:typing_guard"].entries()) {
|
||||
|
||||
@@ -13,7 +13,6 @@ 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";
|
||||
@@ -71,7 +70,7 @@ describe("GET /api/review/*", () => {
|
||||
db = createDb(":memory:");
|
||||
migrate(db, [
|
||||
coreSchema,
|
||||
bindingsSessionsSchema, rigArchiveSchema,
|
||||
bindingsSessionsSchema,
|
||||
eventsSchema,
|
||||
streamItemsSchema,
|
||||
queueItemsSchema,
|
||||
@@ -336,7 +335,7 @@ describe("GET /api/review/rig (OPR.0.4.4.22)", () => {
|
||||
db = createDb(":memory:");
|
||||
migrate(db, [
|
||||
coreSchema,
|
||||
bindingsSessionsSchema, rigArchiveSchema,
|
||||
bindingsSessionsSchema,
|
||||
eventsSchema,
|
||||
streamItemsSchema,
|
||||
queueItemsSchema,
|
||||
@@ -669,7 +668,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, rigArchiveSchema, eventsSchema, streamItemsSchema,
|
||||
coreSchema, bindingsSessionsSchema, eventsSchema, streamItemsSchema,
|
||||
queueItemsSchema, queueTransitionsSchema, missionControlActionsSchema,
|
||||
queueItemSummarySchema,
|
||||
]);
|
||||
|
||||
@@ -499,8 +499,7 @@ describe("SeatLifecycleService.launchFresh", () => {
|
||||
code: "claimed_session",
|
||||
message: `Seat "${seat.sessionName}" has an adopted/operator-owned occupant; fresh launch refuses even with --stop.`,
|
||||
// B5: rig unclaim releases it (the process keeps running); rig down leaves a session OpenRig didn't start.
|
||||
guidance: "Release it with rig unclaim 'dev-impl@fresh-rig' (its process keeps running), end that process and the underlying tmux session, "
|
||||
+ "including any remaining shell, then launch fresh. Closing only its viewing window leaves the tmux session running. "
|
||||
guidance: "Release it with rig unclaim 'dev-impl@fresh-rig' (its process keeps running), end that process yourself, then launch fresh. "
|
||||
+ "rig down 'fresh-rig' leaves a session OpenRig didn't start running.",
|
||||
};
|
||||
const before = db.serialize();
|
||||
@@ -687,11 +686,6 @@ describe("SeatLifecycleService.launchFresh", () => {
|
||||
alive.add(seat.sessionName);
|
||||
const result = await service.launchFresh({ seatRef: "dev.impl", fresh: true, stop: true, reason: "x" });
|
||||
expect(result).toMatchObject({ ok: false, code: "unmanaged_session_collision" });
|
||||
// A session someone released with rig unclaim and left open is the usual cause: say how to clear it.
|
||||
expect(result).toMatchObject({
|
||||
guidance: "If you released this session with rig unclaim, end that underlying tmux session, including any remaining shell, "
|
||||
+ "then launch again. Closing only its viewing window doesn't end the session.",
|
||||
});
|
||||
expect(killed).toEqual([]);
|
||||
expect(alive.has(seat.sessionName)).toBe(true);
|
||||
});
|
||||
|
||||
@@ -447,7 +447,7 @@ describe("SeatLifecycleService", () => {
|
||||
// the refusal names the supported route, rig unclaim, and says rig down doesn't stop a session OpenRig didn't start,
|
||||
// even in a one-seat rig.
|
||||
expect(claimedResult.guidance).toBe("To end this session, run rig unclaim 'dev-adopted@s5-rig' (it releases the session from OpenRig "
|
||||
+ "and leaves its process running), then end that process and the underlying tmux session, including any remaining shell, so no session keeps the seat's name. Closing only its viewing window leaves the tmux session running. rig down 's5-rig' leaves a session OpenRig didn't start running.");
|
||||
+ "and leaves its process running), then end that process in its own terminal. rig down 's5-rig' leaves a session OpenRig didn't start running.");
|
||||
|
||||
const probed = seatFixture("s5-rig", "dev.flaky");
|
||||
tmux.failProbeFor(probed.sessionName);
|
||||
@@ -471,7 +471,7 @@ describe("SeatLifecycleService", () => {
|
||||
const shared = await service.stopSeat({ seatRef: claimed.sessionName, reason: "x" });
|
||||
if (shared.ok) throw new Error("expected refusal");
|
||||
expect(shared.guidance).toBe("To end this session, run rig unclaim 'dev-adopted@adopted-rig' (it releases the session from OpenRig "
|
||||
+ "and leaves its process running), then end that process and the underlying tmux session, including any remaining shell, so no session keeps the seat's name. Closing only its viewing window leaves the tmux session running. rig down 'adopted-rig' leaves a session OpenRig didn't start running.");
|
||||
+ "and leaves its process running), then end that process in its own terminal. rig down 'adopted-rig' leaves a session OpenRig didn't start running.");
|
||||
|
||||
sessionRegistry.updateBinding(claimed.node.id, { attachmentType: "external_cli", externalSessionName: claimed.sessionName });
|
||||
const external = await service.stopSeat({ seatRef: claimed.sessionName, reason: "x" });
|
||||
@@ -485,7 +485,7 @@ describe("SeatLifecycleService", () => {
|
||||
|
||||
it("claimedSessionRoutes routes a tmux session through rig unclaim, never rig down", () => {
|
||||
expect(claimedSessionRoutes("r", "a@r")).toBe("To end this session, run rig unclaim 'a@r' (it releases the session from OpenRig "
|
||||
+ "and leaves its process running), then end that process and the underlying tmux session, including any remaining shell, so no session keeps the seat's name. Closing only its viewing window leaves the tmux session running. rig down 'r' leaves a session OpenRig didn't start running.");
|
||||
+ "and leaves its process running), then end that process in its own terminal. rig down 'r' leaves a session OpenRig didn't start running.");
|
||||
});
|
||||
|
||||
// ---- P5 / P6 — clean ----
|
||||
|
||||
@@ -636,10 +636,10 @@ describe("rig seat restart --keep-conversation: gates before any signal", () =>
|
||||
w.db.prepare("UPDATE sessions SET origin = 'claimed' WHERE id = ?").run(w.session.id);
|
||||
const claimed = await w.service.restartInPlace({ seatRef: SEAT, plan: true });
|
||||
expect(claimed).toMatchObject({ ok: false, code: "claimed_session" });
|
||||
// B5: the refusal's route is rig unclaim, then ending the process and its tmux session; rig down leaves a session
|
||||
// B5: the refusal's route is rig unclaim, then ending the process in its own terminal; rig down leaves a session
|
||||
// OpenRig didn't start running, so it isn't offered as the way to end this one.
|
||||
const guidance = (claimed as { guidance?: string }).guidance ?? "";
|
||||
expect(guidance).toMatch(/^To end this session, run rig unclaim '[^']+' \(it releases the session from OpenRig and leaves its process running\), then end that process and the underlying tmux session, including any remaining shell, so no session keeps the seat's name\. Closing only its viewing window leaves the tmux session running\. /);
|
||||
expect(guidance).toMatch(/^To end this session, run rig unclaim '[^']+' \(it releases the session from OpenRig and leaves its process running\), then end that process in its own terminal\. /);
|
||||
expect(guidance).toMatch(/rig down '[^']+' leaves a session OpenRig didn't start running\.$/);
|
||||
expect(guidance).not.toMatch(/run rig down/);
|
||||
});
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
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,23 +370,6 @@ 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();
|
||||
|
||||
@@ -7,7 +7,6 @@ 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";
|
||||
@@ -134,7 +133,7 @@ describe("workflow route (WF3 FR-4 — close+recreate+rebind)", () => {
|
||||
db = createDb();
|
||||
migrate(db, [
|
||||
outboxEntriesSchema,
|
||||
coreSchema, bindingsSessionsSchema, rigArchiveSchema, eventsSchema,
|
||||
coreSchema, bindingsSessionsSchema, eventsSchema,
|
||||
queueItemsSchema, queueTransitionsSchema,
|
||||
queueItemSummarySchema, queueItemEvidenceRefSchema,
|
||||
workflowSpecsSchema, workflowInstancesSchema, workflowStepTrailsSchema,
|
||||
|
||||
@@ -7,7 +7,6 @@ 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";
|
||||
@@ -583,7 +582,7 @@ describe("workflow routes — WF-2 structured error boundaries", () => {
|
||||
migrate(db, [
|
||||
outboxEntriesSchema,
|
||||
coreSchema,
|
||||
bindingsSessionsSchema, rigArchiveSchema,
|
||||
bindingsSessionsSchema,
|
||||
eventsSchema,
|
||||
queueItemsSchema,
|
||||
queueTransitionsSchema,
|
||||
|
||||
@@ -13,7 +13,6 @@ 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";
|
||||
@@ -189,7 +188,7 @@ describe("OPR.0.4.6.WF2 — spec language", () => {
|
||||
migrate(db, [
|
||||
outboxEntriesSchema,
|
||||
coreSchema,
|
||||
bindingsSessionsSchema, rigArchiveSchema,
|
||||
bindingsSessionsSchema,
|
||||
eventsSchema,
|
||||
queueItemsSchema,
|
||||
queueTransitionsSchema,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { AttentionRead, AttentionItem } from "@openrig/daemon/attention";
|
||||
import type { Action, FleetSnapshot, ViewState } from "../types.js";
|
||||
import { listItem, wrapDetailLines, type ContentLine } from "../detail.js";
|
||||
import { displayTime } from "../time.js";
|
||||
|
||||
/** The bounded, confirmed-delivery or bridge-acceptance projection served by S02; no client classification. */
|
||||
export interface DeliveredHumanUpdates {
|
||||
@@ -11,6 +12,8 @@ export interface DeliveredHumanUpdates {
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
const BRIDGE_ACCEPTED_SCOPE = " · accepted by bridge";
|
||||
|
||||
function isBridgeAcceptedUpdate(q: DeliveredHumanUpdates["items"][number]): boolean {
|
||||
return q.deliveryOutcome === "accepted" || q.deliveryReceipt.startsWith("relay-owner-notification-accepted");
|
||||
}
|
||||
@@ -38,13 +41,13 @@ export function composeHumanUpdates(attention: AttentionRead | null, updates: De
|
||||
const baseScope = projects.length === 1 ? `project ${projects[0]} (queue tag)` : "instance · project unknown";
|
||||
const item: AttentionItem = { id: `human-update:${q.qitemId}`, kind: "update", summary: q.summary || q.body.trim().split(/\r?\n/).find(Boolean) || (accepted ? "Accepted by bridge" : "Delivered update"),
|
||||
recipient: q.destinationSession, urgency: "update", unblocks: null, at: q.deliveredAt,
|
||||
scope: accepted ? `${baseScope} · accepted by bridge` : baseScope, project: null,
|
||||
scope: accepted ? `${baseScope}${BRIDGE_ACCEPTED_SCOPE}` : baseScope, project: null,
|
||||
source: `/api/queue/${encodeURIComponent(q.qitemId)}` };
|
||||
if (!read.items.some(current => current.id === `queue-update:${q.qitemId}`)) read.items.push(item);
|
||||
if (wanted === item.id && read.detail?.item.id !== item.id) {
|
||||
read.detailError = null;
|
||||
read.detail = { item, lines: [q.body, ...(q.humanDetail ? ["Supplemental detail:", q.humanDetail] : []),
|
||||
`From: ${q.sourceSession}`, accepted ? `Accepted by bridge: ${q.deliveredAt}` : `Delivered: ${q.deliveredAt}`, `Delivery receipt: ${q.deliveryReceipt}`, `Queue: ${q.qitemId}`, `Evidence: ${q.evidenceRef ?? "none recorded"}`],
|
||||
`From: ${q.sourceSession}`, `Delivery receipt: ${q.deliveryReceipt}`, `Queue: ${q.qitemId}`, `Evidence: ${q.evidenceRef ?? "none recorded"}`],
|
||||
files: q.evidenceRef?.startsWith("/") ? [{ label: "Update evidence", path: q.evidenceRef }] : [] };
|
||||
}
|
||||
}
|
||||
@@ -71,7 +74,10 @@ export function attentionLines(state: ViewState, snap: FleetSnapshot, width: num
|
||||
if (d.item.recipient) lines.push({ text: `To: ${d.item.recipient}` });
|
||||
if (d.item.id.startsWith("human-update:") || d.item.id.startsWith("queue-update:")) lines.push({ text: "No action needed" });
|
||||
if (d.item.unblocks) lines.push({ text: `Unblocks: ${d.item.unblocks}` });
|
||||
lines.push({ text: `Scope: ${d.item.scope}` }, { text: `Observed: ${d.item.at ?? "unknown"}` });
|
||||
// Times are drawn in the person's zone (ui.timezone), as the header's are; a delivered update's time is when it was
|
||||
// delivered or accepted by the bridge.
|
||||
const when = !d.item.id.startsWith("human-update:") ? "Observed" : d.item.scope.endsWith(BRIDGE_ACCEPTED_SCOPE) ? "Accepted by bridge" : "Delivered";
|
||||
lines.push({ text: `Scope: ${d.item.scope}` }, { text: `${when}: ${displayTime(d.item.at, state.timeZone)}` });
|
||||
lines.push(...questionLines(d.item).map(text => ({ text })));
|
||||
lines.push(...d.lines.map(text => ({ text })));
|
||||
for (const f of d.files) lines.push(listItem(f.label, { type: "attention-source", path: f.path }));
|
||||
@@ -124,10 +130,10 @@ export function attentionLines(state: ViewState, snap: FleetSnapshot, width: num
|
||||
if (i.humanQuestions?.length) lines.push({ text: ` Questions: ${i.humanQuestions.length}, ${unansweredCount(i)} unanswered` });
|
||||
if (i.id.startsWith("human-update:") || i.id.startsWith("queue-update:")) lines.push({ text: " No action needed" });
|
||||
if (i.unblocks) lines.push({ text: ` Unblocks: ${i.unblocks}` });
|
||||
lines.push({ text: ` ${i.scope} · ${i.at ?? "time unknown"}` });
|
||||
lines.push({ text: ` ${i.scope} · ${displayTime(i.at, state.timeZone)}` });
|
||||
}
|
||||
}
|
||||
lines.push({ text: "" }, { text: `Read at ${read.readAt}` }, ...read.sources.map(s => ({ text: `${s.source}: ${s.state} · ${s.detail}` })));
|
||||
lines.push({ text: "" }, { text: `Read at ${displayTime(read.readAt, state.timeZone)}` }, ...read.sources.map(s => ({ text: `${s.source}: ${s.state} · ${s.detail}` })));
|
||||
}
|
||||
lines.push({ text: "" }, { text: "Viewing is not approval. Each request's detail says whether it went to Slack and where to answer it." });
|
||||
return wrapDetailLines(lines, width);
|
||||
|
||||
@@ -287,10 +287,8 @@ function toAgentRow(node: NodeInventoryRead): AgentRow {
|
||||
: node.activityState?.display === "idle"
|
||||
? "idle"
|
||||
: (node.terminalActive === true || (node.terminalActive == null && node.agentActivity?.state === "running") ? "active"
|
||||
// The daemon's activity word decides when it's served (as in `rig ps`): it said unknown, so a quiet
|
||||
// screen stays unknown. A community harness gives no idle signal either. Only an older daemon that
|
||||
// serves no activity word keeps the screen fallback.
|
||||
: node.communityHarness || node.activityState ? "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"
|
||||
|
||||
+30
-49
@@ -48,8 +48,6 @@ 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 {
|
||||
@@ -96,24 +94,17 @@ function agentColumns(contentWidth: number): AgentColumn[] {
|
||||
];
|
||||
}
|
||||
|
||||
/** 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"],
|
||||
];
|
||||
}
|
||||
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"],
|
||||
];
|
||||
const fixed = 8 + 5 + 11 + 2 + 5 + 8 + 2 + 7;
|
||||
return [
|
||||
["rig", "RIG", 8, "left"], ["pod", "POD", 5, "left"], ["seat", "SEAT", 11, "left"],
|
||||
@@ -718,7 +709,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 shows on a rig's first visible row so scrolled rows keep it. */
|
||||
/** Rig boundaries take a solid rule and pod boundaries a dotted one; RIG repeats on every row so scrolled rows keep it. */
|
||||
function instanceContentLines(
|
||||
state: ViewState,
|
||||
snap: FleetSnapshot,
|
||||
@@ -810,12 +801,7 @@ 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 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)));
|
||||
const columns = instanceAgentColumns(contentWidth);
|
||||
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;
|
||||
@@ -824,7 +810,10 @@ function instanceContentLines(
|
||||
let attentionUnknown = 0;
|
||||
let openCount = 0;
|
||||
let firstRig = true;
|
||||
for (const { rig, agents } of shownRigs) {
|
||||
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;
|
||||
if (!firstRig) lines.push({ text: "─".repeat(columnsWidth(columns)) });
|
||||
firstRig = false;
|
||||
const rigAction: Action = { type: "drill", resource: "rig", name: rig.name, target: { host: host.name } };
|
||||
@@ -836,7 +825,7 @@ function instanceContentLines(
|
||||
continue;
|
||||
}
|
||||
let previousPod: string | null = null;
|
||||
for (const [index, { pod, agent }] of agents.entries()) {
|
||||
for (const { pod, agent } of agents) {
|
||||
const firstInPod = pod !== previousPod;
|
||||
if (firstInPod && previousPod != null) lines.push({ text: "┈".repeat(columnsWidth(columns)) });
|
||||
previousPod = pod;
|
||||
@@ -847,21 +836,18 @@ 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: index === 0 ? named : tableRow(columns, { ...cells, rig: "" }),
|
||||
sticky: { group: `rig:${rig.name}`, ...(index === 0 ? {} : { text: named }) },
|
||||
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,
|
||||
}),
|
||||
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 }],
|
||||
});
|
||||
@@ -1958,12 +1944,7 @@ 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 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 };
|
||||
});
|
||||
const visibleContent = content.slice(contentStart, contentStart + contentRows);
|
||||
if (content.length > bodyRows) {
|
||||
const scrollText = `scroll ↑/↓ · ${contentStart + 1}-${contentStart + visibleContent.length} of ${content.length}`;
|
||||
const up = scrollText.indexOf("↑");
|
||||
|
||||
@@ -50,6 +50,24 @@ it("renders an unsent local FYI without inventing an approval or a delivery rece
|
||||
expect(text).not.toContain("Delivery receipt:");
|
||||
});
|
||||
|
||||
it("draws Feed times in the person's zone, as the header does, and redraws them when the zone changes", () => {
|
||||
const item = current(), attention = read([item]);
|
||||
attention.detail = { item, lines: ["Current queue body"], files: [] };
|
||||
const merged = composeHumanUpdates(attention, delivered("other"), item.id)!;
|
||||
const draw = (timeZone: string, attentionOpen: string | null) => attentionLines(
|
||||
{ ...createViewState({ instanceId: "owned-fixture", timeZone }).get(), section: "needs" as const, attentionOpen },
|
||||
{ ...emptySnapshot(), attentionRead: merged }, 140).map(l => l.text).join("\n");
|
||||
const london = draw("Europe/London", null);
|
||||
expect(london).toContain("instance · 2026-10-01 13:00:00 GMT+1");
|
||||
expect(london).toContain("instance · project unknown · 2026-10-08 13:00:00 GMT+1");
|
||||
expect(london).toContain("Read at 2026-10-09 13:00:00 GMT+1");
|
||||
expect(london).not.toMatch(/\d{4}-\d{2}-\d{2}T\d{2}:/);
|
||||
// The same read, drawn after the zone setting arrives: nothing is re-read, and nothing keeps the earlier zone.
|
||||
const angeles = draw("America/Los_Angeles", item.id);
|
||||
expect(angeles).toContain("Observed: 2026-10-01 05:00:00 PDT");
|
||||
expect(angeles).not.toMatch(/\d{4}-\d{2}-\d{2}T\d{2}:|GMT\+1/);
|
||||
});
|
||||
|
||||
it("does not retain delivered history beyond a successful empty receipt window when only current updates fail", () => {
|
||||
const prior = composeHumanUpdates(read([]), delivered())!;
|
||||
const failed = read([]);
|
||||
@@ -155,8 +173,9 @@ it("labels relay bridge acceptance as accepted by the bridge rather than Deliver
|
||||
],
|
||||
};
|
||||
const openedBridge = composeHumanUpdates(read([]), bridgeUpdates, "human-update:bridge-fyi")!;
|
||||
const bridgeDetailLines = openedBridge.detail?.lines.join("\n") ?? "";
|
||||
expect(bridgeDetailLines).toContain("Accepted by bridge: 2026-10-08T14:00:00Z");
|
||||
const detailState = (id: string) => ({ ...createViewState({ instanceId: "owned-fixture", timeZone: "UTC" }).get(), section: "needs" as const, attentionOpen: id });
|
||||
const bridgeDetailLines = attentionLines(detailState("human-update:bridge-fyi"), { ...emptySnapshot(), attentionRead: openedBridge }, 140).map(l => l.text).join("\n");
|
||||
expect(bridgeDetailLines).toContain("Accepted by bridge: 2026-10-08 14:00:00 UTC");
|
||||
expect(bridgeDetailLines).not.toContain("Delivered:");
|
||||
expect(openedBridge.sources.find(s => s.source === "delivered updates")?.detail).toContain("accepted by the bridge");
|
||||
expect(openedBridge.sources.find(s => s.source === "delivered updates")?.detail).not.toContain("confirmed delivered updates");
|
||||
@@ -168,8 +187,8 @@ it("labels relay bridge acceptance as accepted by the bridge rather than Deliver
|
||||
|
||||
// Slack delivered wording remains unchanged
|
||||
const openedSlack = composeHumanUpdates(read([]), delivered("slack-fyi"), "human-update:slack-fyi")!;
|
||||
const slackDetailLines = openedSlack.detail?.lines.join("\n") ?? "";
|
||||
expect(slackDetailLines).toContain("Delivered: 2026-10-08T12:00:00Z");
|
||||
const slackDetailLines = attentionLines(detailState("human-update:slack-fyi"), { ...emptySnapshot(), attentionRead: openedSlack }, 140).map(l => l.text).join("\n");
|
||||
expect(slackDetailLines).toContain("Delivered: 2026-10-08 12:00:00 UTC");
|
||||
expect(slackDetailLines).not.toContain("Accepted by bridge:");
|
||||
expect(openedSlack.sources.find(s => s.source === "delivered updates")?.detail).toBe(
|
||||
"Latest 20 confirmed delivered updates; retained receipt window.",
|
||||
|
||||
@@ -710,7 +710,7 @@ 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 seat the daemon reads as unknown as unknown, never idle, for a community harness and a built-in runtime alike", 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 } };
|
||||
@@ -727,28 +727,8 @@ describe("snapshot hydration over the §4.A reads (Phase 2)", () => {
|
||||
"dev.app-quiet": "unknown",
|
||||
"dev.app-moving": "active",
|
||||
"dev.app-prompt": "needs_input",
|
||||
"dev.pi-quiet": "unknown",
|
||||
"dev.claude-quiet": "unknown",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the daemon's idle and working words, and the screen fallback only for a daemon that serves no activity word", async () => {
|
||||
const base = (FIXTURES["/api/rigs/01JRIG/nodes"] as Array<Record<string, unknown>>)[0]!;
|
||||
const running = { startupStatus: "ready", sessionStatus: "running", runtime: "claude-code", agentActivity: { state: "unknown" } };
|
||||
const served = (display: string) => ({ activity: display === "idle" ? "idle-at-prompt" : display, display, needsInput: { count: 0, reason: null }, decidedBy: null });
|
||||
const nodes = [
|
||||
{ ...base, ...running, logicalId: "dev.served-idle", terminalActive: false, activityState: served("idle") },
|
||||
{ ...base, ...running, logicalId: "dev.served-working", terminalActive: false, activityState: served("working") },
|
||||
{ ...base, ...running, logicalId: "dev.unknown-moving", terminalActive: true, activityState: served("unknown") },
|
||||
{ ...base, ...running, logicalId: "dev.legacy-quiet", terminalActive: false, activityState: undefined },
|
||||
];
|
||||
const snap = await hydrateSnapshot(fixtureClient({}, { "/api/rigs/01JRIG/nodes": nodes }));
|
||||
const statuses = Object.fromEntries(myrig(snap).pods[0]!.agents.map((agent) => [agent.name, agent.status]));
|
||||
expect(statuses).toEqual({
|
||||
"dev.served-idle": "idle",
|
||||
"dev.served-working": "active",
|
||||
"dev.unknown-moving": "active",
|
||||
"dev.legacy-quiet": "idle",
|
||||
"dev.pi-quiet": "idle",
|
||||
"dev.claude-quiet": "idle",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ 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";
|
||||
|
||||
@@ -119,20 +118,16 @@ 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;
|
||||
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);
|
||||
expect(content.trimStart().startsWith(rig), `${cols}: scrolled seat row retains rig identity`).toBe(true);
|
||||
}
|
||||
view.dispatch({ type: "content-scroll", delta: 1 });
|
||||
}
|
||||
@@ -145,7 +140,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(/\s+qa\s+[^\n]*\n[^\n]*─{8,}[^\n]*\n[^\n]*docs\s+write/);
|
||||
expect(whole).toMatch(/build\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);
|
||||
@@ -324,138 +319,3 @@ 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);
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,6 @@ import fs from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { test } from "node:test";
|
||||
import { packEntry } from "./npm-pack-json.mjs";
|
||||
|
||||
const script = fs.readFileSync(new URL("./build-package.sh", import.meta.url), "utf8");
|
||||
const assetsBranch = script.match(/^if \[ -d "\$DAEMON_DIR\/assets" \]; then\n[\s\S]*?^fi$/m)?.[0];
|
||||
@@ -130,7 +129,7 @@ test("offline npm pack carries staged asset bytes and executability, with npm's
|
||||
assertStaged(f);
|
||||
const staged = snapshot(f.staged);
|
||||
fs.writeFileSync(join(f.cli, "package.json"), JSON.stringify({ name: "openrig-assets-fixture", version: "1.0.0", private: true, files: ["daemon"] }));
|
||||
const packed = packEntry(execFileSync("bash", ["-c", 'umask 0022; exec npm pack --offline --ignore-scripts --json --pack-destination "$1"', "asset-pack", f.root], { cwd: f.cli, encoding: "utf8", timeout: 30_000, stdio: ["ignore", "pipe", "pipe"] }));
|
||||
const packed = JSON.parse(execFileSync("bash", ["-c", 'umask 0022; exec npm pack --offline --ignore-scripts --json --pack-destination "$1"', "asset-pack", f.root], { cwd: f.cli, encoding: "utf8", timeout: 30_000, stdio: ["ignore", "pipe", "pipe"] }))[0];
|
||||
const files = new Map(packed.files.map(file => [file.path, file]));
|
||||
for (const [name, bytes] of Object.entries(contents)) {
|
||||
const entry = `daemon/assets/${name}`;
|
||||
|
||||
@@ -2,14 +2,14 @@ import { execSync } from "node:child_process";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { packEntry } from "./npm-pack-json.mjs";
|
||||
|
||||
test("npm pack of @openrig/cli includes scripts/check-abi.mjs in tarball", () => {
|
||||
const output = execSync("npm pack --dry-run --json 2>/dev/null", {
|
||||
cwd: "packages/cli",
|
||||
encoding: "utf-8",
|
||||
});
|
||||
const files = packEntry(output).files?.map((f) => f.path) ?? [];
|
||||
const entries = JSON.parse(output);
|
||||
const files = entries[0]?.files?.map((f) => f.path) ?? [];
|
||||
|
||||
assert.ok(
|
||||
files.some((f) => f.includes("scripts/check-abi.mjs")),
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
scanInternalLeaks,
|
||||
} from "./internal-leak-scanner.mjs";
|
||||
import { LEAK_GUARD_RULES_ENV, leakGuardRulesPath } from "./leak-guard-rules.mjs";
|
||||
import { packEntries } from "./npm-pack-json.mjs";
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const FIX = "Genericize the content, cite its public home, or re-home it to the internal pack root.";
|
||||
@@ -156,22 +155,13 @@ function derivePackageArtifacts(packageRoot) {
|
||||
if (packed.status !== 0) {
|
||||
throw new Error(`npm artifact enumeration failed:\n${packed.stderr || packed.stdout || `exit ${packed.status}`}`);
|
||||
}
|
||||
let entries = [];
|
||||
let parsed;
|
||||
try {
|
||||
// One parse, in the shared reader: a JSON string (even one holding a serialized list) is not a shape npm prints.
|
||||
entries = packEntries(packed.stdout);
|
||||
parsed = JSON.parse(packed.stdout);
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError) throw new Error(`npm artifact enumeration did not return JSON: ${error.message}`);
|
||||
// not a shape npm prints: reported below as no files array
|
||||
throw new Error(`npm artifact enumeration did not return JSON: ${error.message}`);
|
||||
}
|
||||
// One package is packed here; scanning only the first of several would issue a receipt for part of the artifact.
|
||||
if (entries.length > 1) {
|
||||
throw new Error(
|
||||
`npm artifact enumeration listed ${entries.length} packages where one was packed; `
|
||||
+ "run the gate for one package (check --package-root and any npm workspaces setting)",
|
||||
);
|
||||
}
|
||||
const rawFiles = entries[0]?.files;
|
||||
const rawFiles = parsed?.[0]?.files;
|
||||
if (!Array.isArray(rawFiles)) throw new Error("npm artifact enumeration returned no files array");
|
||||
const files = rawFiles.map((entry) => entry?.path).filter((path) => typeof path === "string");
|
||||
if (files.length !== rawFiles.length || files.length === 0) {
|
||||
|
||||
@@ -5,7 +5,6 @@ import { execFileSync, spawnSync } from "node:child_process";
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
chmodSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
@@ -406,44 +405,6 @@ test("exact-line allowances reach content and derived scans and are recorded in
|
||||
});
|
||||
});
|
||||
|
||||
test("npm output that is a JSON string is refused, even when the string holds a serialized list", () => {
|
||||
withFixture(({ root, repo, rules, review, receipt }) => {
|
||||
write(join(repo, "public/guide.md"), "Generic product guidance.\n");
|
||||
commitAll(repo, "string output fixture");
|
||||
writeFileSync(review, JSON.stringify({ surfaces: [] }));
|
||||
const bin = join(root, "bin");
|
||||
const serialized = JSON.stringify([{ files: [{ path: "package.json" }, { path: "public/guide.md" }] }]);
|
||||
write(join(bin, "npm"), `#!${process.execPath}\nprocess.stdout.write(${JSON.stringify(JSON.stringify(serialized))});\n`);
|
||||
chmodSync(join(bin, "npm"), 0o755);
|
||||
|
||||
const result = runGate({ repo, rules, review, receipt, cutSha: gitHead(repo),
|
||||
env: { ...process.env, PATH: `${bin}:${process.env.PATH}` } });
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /npm artifact enumeration returned no files array/);
|
||||
});
|
||||
});
|
||||
|
||||
test("npm output that lists more than one package is refused before any scan or receipt", () => {
|
||||
const first = { name: "first", files: [{ path: "package.json" }, { path: "public/guide.md" }] };
|
||||
const second = { name: "second", files: [{ path: "package.json" }, { path: "public/other.md" }] };
|
||||
for (const printed of [[first, second], { first, second }]) {
|
||||
withFixture(({ root, repo, rules, review, receipt }) => {
|
||||
write(join(repo, "public/guide.md"), "Generic product guidance.\n");
|
||||
commitAll(repo, "two package output fixture");
|
||||
writeFileSync(review, JSON.stringify({ surfaces: [] }));
|
||||
const bin = join(root, "bin");
|
||||
write(join(bin, "npm"), `#!${process.execPath}\nprocess.stdout.write(${JSON.stringify(JSON.stringify(printed))});\n`);
|
||||
chmodSync(join(bin, "npm"), 0o755);
|
||||
|
||||
const result = runGate({ repo, rules, review, receipt, cutSha: gitHead(repo),
|
||||
env: { ...process.env, PATH: `${bin}:${process.env.PATH}` } });
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /npm artifact enumeration listed 2 packages where one was packed; run the gate for one package \(check --package-root and any npm workspaces setting\)/);
|
||||
assert.equal(existsSync(receipt), false);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("the release ceremony names the gate and its durable receipt", () => {
|
||||
const pkg = JSON.parse(readFileSync("package.json", "utf8"));
|
||||
assert.equal(pkg.scripts["gate:substance"], "node scripts/check-substance-gate.mjs");
|
||||
@@ -459,7 +420,7 @@ function writeGateHarness(root, source, invocationPath) {
|
||||
mkdirSync(root, { recursive: true });
|
||||
const gate = join(root, "check-substance-gate.mjs");
|
||||
writeFileSync(gate, source);
|
||||
for (const module of ["internal-leak-scanner.mjs", "leak-guard-rules.mjs", "npm-pack-json.mjs"]) {
|
||||
for (const module of ["internal-leak-scanner.mjs", "leak-guard-rules.mjs"]) {
|
||||
writeFileSync(join(root, module), readFileSync(resolve("scripts", module)));
|
||||
}
|
||||
writeFileSync(join(root, "check-internal-leak-guard.mjs"), [
|
||||
@@ -513,7 +474,7 @@ function withFixture(run) {
|
||||
}
|
||||
}
|
||||
|
||||
function runGate({ repo, rules, review, receipt, cutSha, gate = GATE, env = process.env }) {
|
||||
function runGate({ repo, rules, review, receipt, cutSha, gate = GATE }) {
|
||||
return spawnSync(process.execPath, [
|
||||
gate,
|
||||
"--repo", repo,
|
||||
@@ -524,7 +485,7 @@ function runGate({ repo, rules, review, receipt, cutSha, gate = GATE, env = proc
|
||||
"--receipt", receipt,
|
||||
"--judge", "dev-qa@fixture",
|
||||
"--cut-sha", cutSha,
|
||||
], { encoding: "utf8", env });
|
||||
], { encoding: "utf8" });
|
||||
}
|
||||
|
||||
function write(path, content) {
|
||||
|
||||
@@ -4,7 +4,6 @@ import { tmpdir } from "node:os";
|
||||
import { delimiter, join } from "node:path";
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { packEntry } from "./npm-pack-json.mjs";
|
||||
|
||||
test("the CLI build carries the unchanged repository LICENSE and README into npm pack", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "cli-license-"));
|
||||
@@ -27,14 +26,14 @@ test("the CLI build carries the unchanged repository LICENSE and README into npm
|
||||
execFileSync("npm", ["run", "build"], { cwd: cli, env, stdio: "pipe" });
|
||||
const source = readFileSync(join(root, "LICENSE"));
|
||||
assert.deepEqual(readFileSync(join(cli, "LICENSE")), source, "build must stage the repository license verbatim");
|
||||
const packed = packEntry(execFileSync("npm", ["pack", "--ignore-scripts", "--json"], { cwd: cli, env, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }));
|
||||
assert.ok(packed.files.some((file) => file.path === "LICENSE"), "npm archive must include LICENSE");
|
||||
const bytes = execFileSync("tar", ["-xOf", join(cli, packed.filename), "package/LICENSE"]);
|
||||
const packed = JSON.parse(execFileSync("npm", ["pack", "--ignore-scripts", "--json"], { cwd: cli, env, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }));
|
||||
assert.ok(packed[0].files.some((file) => file.path === "LICENSE"), "npm archive must include LICENSE");
|
||||
const bytes = execFileSync("tar", ["-xOf", join(cli, packed[0].filename), "package/LICENSE"]);
|
||||
assert.deepEqual(bytes, source, "packed license must equal the existing root bytes");
|
||||
// npm shows the package-root README on the registry page; without it the page reads "No README data found".
|
||||
const readme = readFileSync(join(root, "README.md"));
|
||||
assert.ok(packed.files.some((file) => file.path === "README.md"), "npm archive must include README.md");
|
||||
const readmeBytes = execFileSync("tar", ["-xOf", join(cli, packed.filename), "package/README.md"]);
|
||||
assert.ok(packed[0].files.some((file) => file.path === "README.md"), "npm archive must include README.md");
|
||||
const readmeBytes = execFileSync("tar", ["-xOf", join(cli, packed[0].filename), "package/README.md"]);
|
||||
assert.deepEqual(readmeBytes, readme, "packed README must equal the existing root bytes");
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
// `npm pack --json` prints an array of entries up to npm 11 and, from npm 12, an object keyed by package name
|
||||
// ("the --json output of `npm pack` and `npm publish` have changed",
|
||||
// https://github.com/npm/cli/releases/tag/v12.0.0). Every reader of that output goes through here, so both shapes
|
||||
// give the same entries.
|
||||
|
||||
/** The entries `npm pack --json` printed, in order, from its output text or its parsed value. */
|
||||
export function packEntries(output) {
|
||||
const parsed = typeof output === "string" ? JSON.parse(output) : output;
|
||||
const entries = Array.isArray(parsed)
|
||||
? parsed
|
||||
: parsed !== null && typeof parsed === "object" ? Object.values(parsed) : null;
|
||||
if (!entries || !entries.every((entry) => entry !== null && typeof entry === "object" && !Array.isArray(entry))) {
|
||||
throw new Error("npm pack --json printed neither an array of entries nor an object of per-package entries");
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/** The one entry of a `npm pack --json` run that packed one package; any other count throws. */
|
||||
export function packEntry(output) {
|
||||
const entries = packEntries(output);
|
||||
if (entries.length !== 1) {
|
||||
throw new Error(`npm pack --json printed ${entries.length} entries where one package was packed`);
|
||||
}
|
||||
return entries[0];
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { packEntries, packEntry } from "./npm-pack-json.mjs";
|
||||
|
||||
const entry = { id: "@openrig/[email protected]", name: "@openrig/cli", version: "1.2.3", filename: "openrig-cli-1.2.3.tgz",
|
||||
files: [{ path: "LICENSE" }, { path: "package.json" }] };
|
||||
|
||||
test("npm 10 and 11: an array of entries", () => {
|
||||
assert.deepEqual(packEntries(JSON.stringify([entry])), [entry]);
|
||||
assert.deepEqual(packEntry(JSON.stringify([entry])), entry);
|
||||
});
|
||||
|
||||
test("npm 12: an object of entries keyed by package name", () => {
|
||||
assert.deepEqual(packEntries(JSON.stringify({ "@openrig/cli": entry })), [entry]);
|
||||
assert.deepEqual(packEntry(JSON.stringify({ "@openrig/cli": entry })), entry);
|
||||
});
|
||||
|
||||
test("both shapes keep every entry in order, and a parsed value reads like its text", () => {
|
||||
const other = { ...entry, name: "@openrig/acp", filename: "openrig-acp-1.2.3.tgz" };
|
||||
assert.deepEqual(packEntries([entry, other]), [entry, other]);
|
||||
assert.deepEqual(packEntries({ "@openrig/cli": entry, "@openrig/acp": other }), [entry, other]);
|
||||
});
|
||||
|
||||
test("packEntry refuses any count but one", () => {
|
||||
assert.throws(() => packEntry("[]"), /0 entries/);
|
||||
assert.throws(() => packEntry("{}"), /0 entries/);
|
||||
assert.throws(() => packEntry(JSON.stringify([entry, entry])), /2 entries/);
|
||||
});
|
||||
|
||||
test("output that is neither shape throws", () => {
|
||||
for (const output of ["null", "\"text\"", "3", JSON.stringify(entry), JSON.stringify([[entry]]), JSON.stringify({ a: null })]) {
|
||||
assert.throws(() => packEntries(output), /neither an array of entries nor an object of per-package entries/, output);
|
||||
}
|
||||
assert.throws(() => packEntries("not json"), SyntaxError);
|
||||
});
|
||||
@@ -8,7 +8,6 @@ import { createHash } from 'node:crypto';
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { packEntry } from './npm-pack-json.mjs';
|
||||
|
||||
const LISTING = ['packages/acp/registry/agent.json', 'packages/acp/registry/icon.svg'];
|
||||
const readJson = (root, rel) => JSON.parse(readFileSync(join(root, rel), 'utf8'));
|
||||
@@ -36,8 +35,8 @@ export function packRelease(root, dest) {
|
||||
mkdirSync(dest, { recursive: true });
|
||||
// The CLI is packed as the release producer has packed it; the launcher isn't a workspace, so it's packed by path.
|
||||
// --ignore-scripts skips pack's prepack and postpack, but npm 10 still runs a `prepare`; neither package has one.
|
||||
const pack = target => packEntry(execFileSync('npm', ['pack', ...target, '--ignore-scripts', '--json', '--pack-destination', dest],
|
||||
{ cwd: root, encoding: 'utf8' }));
|
||||
const pack = target => JSON.parse(execFileSync('npm', ['pack', ...target, '--ignore-scripts', '--json', '--pack-destination', dest],
|
||||
{ cwd: root, encoding: 'utf8' }))[0];
|
||||
const artifacts = [pack(['--workspace', 'packages/cli']), pack(['./packages/acp'])].map(packed => {
|
||||
const path = join(dest, packed.filename);
|
||||
return { name: packed.name, version: packed.version, path, size: packed.size, sha256: sha256(path), integrity: packed.integrity };
|
||||
|
||||
@@ -4,7 +4,6 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { execFileSync, spawn, spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { packEntry } from "./npm-pack-json.mjs";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const cli = path.join(root, "packages", "cli");
|
||||
@@ -48,8 +47,8 @@ async function assertDelayedSplits(entry, pkgDir) {
|
||||
|
||||
try {
|
||||
execFileSync("npm", ["run", "build:package"], { cwd: root, stdio: "inherit" });
|
||||
const packed = packEntry(execFileSync("npm", ["pack", "--ignore-scripts", "--json", "--pack-destination", temp], { cwd: cli, encoding: "utf8" }));
|
||||
const tarball = path.join(temp, packed.filename);
|
||||
const packed = JSON.parse(execFileSync("npm", ["pack", "--ignore-scripts", "--json", "--pack-destination", temp], { cwd: cli, encoding: "utf8" }));
|
||||
const tarball = path.join(temp, packed[0].filename);
|
||||
execFileSync("tar", ["-xzf", tarball, "-C", temp]);
|
||||
|
||||
const pkgDir = path.join(temp, "package");
|
||||
@@ -69,7 +68,7 @@ try {
|
||||
});
|
||||
assert.equal(launched.status, 0, launched.stderr || `packed TUI exited ${launched.status}`);
|
||||
await assertDelayedSplits(entry, pkgDir);
|
||||
console.log(`packed TUI launch PASS: ${packed.filename}`);
|
||||
console.log(`packed TUI launch PASS: ${packed[0].filename}`);
|
||||
} finally {
|
||||
fs.rmSync(temp, { recursive: true, force: true });
|
||||
}
|
||||
Reference in new issue
Block a user