mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-11 17:32:22 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
529b24b0a8 | ||
|
|
492170b1b5 | ||
|
|
bf31a9229d | ||
|
|
9e58308c0c | ||
|
|
8df4672263 | ||
|
|
eb6a218010 | ||
|
|
559025c090 | ||
|
|
37e0715c58 |
No files matched your search
+3
-1
@@ -152,7 +152,9 @@ 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 close it in its own terminal. ([#2962](https://github.com/mvschwarz/openrig/pull/2962))
|
||||
(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))
|
||||
- **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,7 +140,11 @@ 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.
|
||||
`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.
|
||||
- `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,7 +13,9 @@ 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.
|
||||
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`.
|
||||
|
||||
## Feed both commands the same input
|
||||
|
||||
|
||||
@@ -342,9 +342,16 @@ startup reconcile leaves when every kernel terminal is gone, for example after a
|
||||
reboot. It does not detect a reboot as such. It uses the same restore as
|
||||
`rig up kernel --existing`, and `rig status` reports it as `Kernel: booting`
|
||||
until the seats are ready. A kernel stopped with `rig down kernel`, including
|
||||
one whose seats were already detached, stays down on later starts. So do a kernel
|
||||
with only some seats stopped and seats you unclaimed. Start one again with
|
||||
`rig up kernel --existing`.
|
||||
one whose seats were already detached, stays down on later starts; start it again
|
||||
with `rig up kernel --existing`. A seat you stopped or unclaimed also stays down.
|
||||
While the kernel's other seats are running, `rig up kernel --existing` refuses
|
||||
(it restores only a kernel with no live session), so start one stopped seat with
|
||||
`rig launch kernel <logical id>` (`--plan` first). It restores that seat from the
|
||||
kernel's latest restore-usable snapshot and resumes its saved conversation; a seat
|
||||
it can't resume is reported as waiting for a decision, not as started.
|
||||
`rig seat launch <seat> --fresh --reason "…"` instead starts a new conversation
|
||||
with no continuity source: use it only when you want a fresh occupant, since the
|
||||
seat's earlier conversation isn't carried.
|
||||
When the tmux server is gone, an explicit `rig down` of any rig can start an
|
||||
empty server and probe again. It records the stop and clears eligible bindings
|
||||
only after confirming the sessions are absent. This can leave an empty tmux
|
||||
|
||||
@@ -9,6 +9,7 @@ import { queueInboxCheckinsSchema } from "./migrations/158_queue_inbox_checkins.
|
||||
import { queueItemsHandedOffFromIndexSchema } from "./migrations/159_queue_items_handed_off_from_index.js";
|
||||
import { networkBindingsSchema } from "./migrations/160_network_bindings.js";
|
||||
import { networkReceiptsSchema } from "./migrations/161_network_receipts.js";
|
||||
import { sessionAddressReadIndexesSchema } from "./migrations/162_session_address_read_indexes.js";
|
||||
import { activityGrantsSchema } from "./migrations/143_activity_grants.js";
|
||||
import { environmentBindingsSchema } from "./migrations/144_environment_bindings.js";
|
||||
import { nodePermissionsToSchema } from "./migrations/145_node_permissions_to.js";
|
||||
@@ -325,4 +326,5 @@ export const ALL_MIGRATIONS: Migration[] = [
|
||||
queueItemsHandedOffFromIndexSchema,
|
||||
networkBindingsSchema,
|
||||
networkReceiptsSchema,
|
||||
sessionAddressReadIndexesSchema,
|
||||
];
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { Migration } from "../migrate.js";
|
||||
|
||||
/** Address reads seek a current binding; inventory recency is by id, not wall clock.
|
||||
* Keep the older created_at index for readers that explicitly use that order. */
|
||||
export const sessionAddressReadIndexesSchema: Migration = {
|
||||
name: "162_session_address_read_indexes.sql",
|
||||
sql: `
|
||||
CREATE INDEX IF NOT EXISTS idx_bindings_tmux_session ON bindings(tmux_session)
|
||||
WHERE tmux_session IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_node_latest_id ON sessions(node_id, id DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_name_latest_id ON sessions(session_name, id DESC);
|
||||
`,
|
||||
};
|
||||
@@ -0,0 +1,92 @@
|
||||
// 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,15 +61,16 @@ 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) and
|
||||
* cache-present-but-no/-malformed windows (empty_reading).
|
||||
* Never throws — a malformed cache is treated as present-with-no-windows, not a crash.
|
||||
* 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.
|
||||
*/
|
||||
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;
|
||||
@@ -84,7 +85,8 @@ export function collectClaudeStatuslineSignals(deps: ClaudeUsageReaderDeps): Pro
|
||||
staleAfter = parsed.staleAfter;
|
||||
capturedAsOf = typeof parsed.asOf === "string" ? parsed.asOf : undefined;
|
||||
} catch {
|
||||
// Malformed cache → present-with-no-windows (empty_reading unknown), never a throw.
|
||||
// Malformed cache → present-but-unreadable (its own unknown), never a throw.
|
||||
cacheUnreadable = true;
|
||||
reading = undefined;
|
||||
}
|
||||
}
|
||||
@@ -92,6 +94,7 @@ 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,
|
||||
@@ -109,6 +112,7 @@ 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 {
|
||||
@@ -119,10 +123,16 @@ export function collectClaudeSignalsFromProviderUsageDirectory(
|
||||
if (typeof parsed.seatSession === "string") {
|
||||
seats.set(parsed.seatSession, { seatSession: parsed.seatSession });
|
||||
}
|
||||
} catch { /* malformed cache cannot create a usable cache signal */ }
|
||||
} 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 { /* absent cache directory */ }
|
||||
}
|
||||
for (const seatSession of unreadableFiles) seats.set(seatSession, { seatSession });
|
||||
|
||||
const readFrom = (candidateDirectory: string, seatSession: string): string | null => {
|
||||
try {
|
||||
|
||||
@@ -131,6 +131,8 @@ 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;
|
||||
@@ -140,6 +142,7 @@ 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(
|
||||
@@ -165,17 +168,22 @@ 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, reading, asOf, staleAfter } = input;
|
||||
const { seatSession, cachePresent, cacheUnreadable, 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,6 +34,7 @@ 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;
|
||||
@@ -470,8 +471,9 @@ function deliveryResolutionEvidence(
|
||||
if (row.claimedAt) return `source claimed at ${row.claimedAt}`;
|
||||
if (row.state === "blocked") return "source parked";
|
||||
if (kind === "undelivered-wake") {
|
||||
if (row.lastNudgeResult === "verified") return "source wake verified";
|
||||
if (row.lastNudgeResult?.startsWith("wake-accepted:")) return "source wake accepted by its hosted trigger";
|
||||
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 (deps.queueRepo.humanNotificationPostedThisEpisode(qitemId)) return "current human ask posted";
|
||||
|
||||
@@ -12,6 +12,7 @@ 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).
|
||||
@@ -383,28 +384,44 @@ function hasPickupEvidence(db: Database.Database, row: Pick<QueueItem, "qitemId"
|
||||
type WakeMode = "failed" | "unconfirmed" | "held";
|
||||
|
||||
function classifyWakeResult(lastNudgeResult: string | null): WakeMode | null {
|
||||
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 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";
|
||||
}
|
||||
}
|
||||
|
||||
/** A retained (typing-guard) result is retryable ONLY for an unclaimed baton. The
|
||||
@@ -416,7 +433,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 = row.lastNudgeResult?.startsWith("retained:") ?? false;
|
||||
const retained = readWakeOutcome(row.lastNudgeResult)?.fact === "held";
|
||||
if (retained && !(row.state === "pending" && !row.claimedAt && row.handedOffFrom)) return null;
|
||||
return mode;
|
||||
}
|
||||
@@ -863,7 +880,9 @@ 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`
|
||||
: `unconfirmed delivery with no pickup evidence over ${minutesSince(row.tsCreated, now)} min`;
|
||||
: 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`;
|
||||
if (hasConsumedEscalationHop(deps.db, row.qitemId)) {
|
||||
if (!due || suspended) continue;
|
||||
const consumedReason = `${reason}; escalates_to one-hop delivery already used; existing fallback retained`;
|
||||
|
||||
@@ -124,14 +124,25 @@ export interface CurrentSessionAddress {
|
||||
* Physical names win over derived names. Archived/historical bindings cannot
|
||||
* advertise a new alias, and two current matches never pick an arbitrary seat. */
|
||||
export function resolveCurrentSessionAddress(db: Database.Database, address: string): CurrentSessionAddress | null {
|
||||
const rows = db.prepare(`SELECT r.*, n.id AS nodeId, n.logical_id AS logicalId,
|
||||
const current = `SELECT r.name, r.archived_at, n.id AS nodeId, n.logical_id AS logicalId,
|
||||
s.id AS sessionId, s.session_name AS sessionName, s.status,
|
||||
replace(n.logical_id, '.', '-') || '@' || r.name AS senderAddress
|
||||
FROM nodes n JOIN rigs r ON r.id=n.rig_id
|
||||
JOIN bindings b ON b.node_id=n.id AND b.tmux_session IS NOT NULL
|
||||
JOIN sessions s ON s.node_id=n.id AND s.session_name=b.tmux_session
|
||||
AND s.id=(SELECT id FROM sessions WHERE node_id=n.id ORDER BY id DESC LIMIT 1)
|
||||
WHERE s.session_name=? OR replace(n.logical_id, '.', '-') || '@' || r.name=?`)
|
||||
CROSS JOIN sessions s ON s.node_id=n.id AND s.session_name=b.tmux_session
|
||||
AND s.id=(SELECT id FROM sessions WHERE node_id=n.id ORDER BY id DESC LIMIT 1)`;
|
||||
// Inventory passes physical names for every seat. The OR with a derived alias
|
||||
// made each lookup walk all nodes (or all historical sessions). Seek the exact
|
||||
// name first; only an absent active exact match needs the alias walk. Keep this
|
||||
// in one statement so both alternatives observe the same database snapshot.
|
||||
const rows = db.prepare(`WITH exact AS MATERIALIZED (
|
||||
${current} WHERE b.tmux_session=? AND r.archived_at IS NULL
|
||||
AND COALESCE(s.status, '') NOT IN ('superseded', 'detached', 'exited')
|
||||
) SELECT * FROM exact
|
||||
UNION ALL
|
||||
${current}
|
||||
WHERE replace(n.logical_id, '.', '-') || '@' || r.name=?
|
||||
AND NOT EXISTS (SELECT 1 FROM exact)`)
|
||||
.all(address, address) as Array<CurrentSessionAddress & { name: string; archived_at?: string | null; status: string }>;
|
||||
const active = rows.filter(row => row.archived_at == null && !['superseded', 'detached', 'exited'].includes(row.status));
|
||||
const exact = active.filter(row => row.sessionName === address);
|
||||
@@ -783,7 +794,7 @@ export class SessionRegistry {
|
||||
`SELECT s.* FROM sessions s
|
||||
JOIN nodes n ON s.node_id = n.id
|
||||
WHERE n.rig_id = ?
|
||||
ORDER BY s.created_at`
|
||||
ORDER BY s.created_at, s.rowid`
|
||||
)
|
||||
.all(rigId) as SessionRow[];
|
||||
|
||||
|
||||
@@ -282,6 +282,7 @@ 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,6 +89,23 @@ 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(""),
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
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,6 +6,7 @@ import Database from "better-sqlite3";
|
||||
import { createDb } from "../src/db/connection.js";
|
||||
import { migrate } from "../src/db/migrate.js";
|
||||
import { coreSchema } from "../src/db/migrations/001_core_schema.js";
|
||||
import { rigArchiveSchema } from "../src/db/migrations/042_rig_archive.js";
|
||||
import { bindingsSessionsSchema } from "../src/db/migrations/002_bindings_sessions.js";
|
||||
import { eventsSchema } from "../src/db/migrations/003_events.js";
|
||||
import { queueItemSummarySchema } from "../src/db/migrations/044_queue_item_summary.js";
|
||||
@@ -106,7 +107,7 @@ describe("the projector's execution queue", () => {
|
||||
const NOW = new Date("2026-10-25T12:00:00.000Z");
|
||||
function fixture(file?: string) {
|
||||
const db = track(file ? createDb(file) : createDb());
|
||||
migrate(db, [coreSchema, bindingsSessionsSchema, eventsSchema, queueItemSummarySchema, queueItemsSchema, queueTransitionsSchema, queueTransitionWakesSchema, viewsCustomSchema]);
|
||||
migrate(db, [coreSchema, bindingsSessionsSchema, rigArchiveSchema, eventsSchema, queueItemSummarySchema, queueItemsSchema, queueTransitionsSchema, queueTransitionWakesSchema, viewsCustomSchema]);
|
||||
return db;
|
||||
}
|
||||
const insertRow = (db: Database.Database, id: string, state: string, tags: string[], body: string, claimedAt: string | null = "2026-10-25T11:00:00.000Z") =>
|
||||
|
||||
@@ -19,6 +19,7 @@ import type Database from "better-sqlite3";
|
||||
import { createDb } from "../src/db/connection.js";
|
||||
import { migrate } from "../src/db/migrate.js";
|
||||
import { coreSchema } from "../src/db/migrations/001_core_schema.js";
|
||||
import { rigArchiveSchema } from "../src/db/migrations/042_rig_archive.js";
|
||||
import { bindingsSessionsSchema } from "../src/db/migrations/002_bindings_sessions.js";
|
||||
import { eventsSchema } from "../src/db/migrations/003_events.js";
|
||||
import { queueItemSummarySchema } from "../src/db/migrations/044_queue_item_summary.js";
|
||||
@@ -167,7 +168,7 @@ describe("execution view — S27 (OPR.0.5.6.27)", () => {
|
||||
db = createDb();
|
||||
migrate(db, [
|
||||
coreSchema,
|
||||
bindingsSessionsSchema,
|
||||
bindingsSessionsSchema, rigArchiveSchema,
|
||||
eventsSchema,
|
||||
queueItemsSchema,
|
||||
queueItemSummarySchema,
|
||||
|
||||
@@ -108,6 +108,7 @@ import { humanQuestionsSchema } from "../../src/db/migrations/091_human_question
|
||||
import { nodeEffortSchema } from "../../src/db/migrations/092_node_effort.js";
|
||||
import { rigPolicySchema } from "../../src/db/migrations/041_rig_policy.js";
|
||||
import { rigArchiveSchema } from "../../src/db/migrations/042_rig_archive.js";
|
||||
import { sessionAddressReadIndexesSchema } from "../../src/db/migrations/162_session_address_read_indexes.js";
|
||||
import { resumeProvenanceSchema } from "../../src/db/migrations/043_resume_provenance.js";
|
||||
import { resumeVerificationSchema } from "../../src/db/migrations/045_resume_verification.js";
|
||||
import { seatIdentityVerdictsSchema } from "../../src/db/migrations/046_seat_identity_verdicts.js";
|
||||
@@ -168,7 +169,7 @@ import fs from "node:fs";
|
||||
|
||||
/** Seam B R6: the canonical full-fixture migration list, exported so file-backed
|
||||
* DB-reopen tests migrate IDENTICALLY to createFullTestDb. */
|
||||
export const migrationsForFullTestDb = [coreSchema, bindingsSessionsSchema, eventsSchema, snapshotsSchema, checkpointsSchema, resumeMetadataSchema, nodeSpecFieldsSchema, packagesSchema, installJournalSchema, journalSeqSchema, bootstrapSchema, discoverySchema, discoveryFkFix, agentspecRebootSchema, startupContextSchema, chatMessagesSchema, podNamespaceSchema, contextUsageSchema, externalCliAttachmentSchema, rigServicesSchema, seatHandoverObservabilitySchema, nodeCodexConfigProfileSchema, nodePermissionPolicySchema, rigPermissionPolicySchema, nodePolicyProvenanceSchema, rigPolicyProvenanceSchema, streamItemsSchema, queueItemsSchema, queueTransitionsSchema, rigPolicySchema, rigArchiveSchema, resumeProvenanceSchema, resumeVerificationSchema, seatIdentityVerdictsSchema, selfHostIdentitySchema, occupantTenuresSchema, daemonLifecycleSchema, watchdogJobsSchema, occupantGenerationStampsSchema, projectionManifestSchema, watchdogTargetGenerationSchema, appliedLaunchObservationsSchema, appliedLaunchObservationInvalidationsSchema, threadSeatMapSchema, queueTransitionWakesSchema, nodeSessionSourceSchema, scopedOperatingPostureSchema, humanNotificationIntentSchema, reviewReadIndexesSchema, inventoryEventIndexesSchema, rigClaudeManagedBlockFileSchema, nodePermissionSelectionsSchema, humanReplyToSchema, humanQuestionsSchema, nodeEffortSchema, rigNonInterruptiveSchema, rigInstallRootSchema, threadPartMapSchema, queueTransitionsQitemIdOrderSchema, queueItemsRecoveryTagsSchema, resumeRotationSchema, sessionStopIntentSchema, nodeEffortChoiceSchema, modePostureCoexistSchema, seatEnvSchema, appliedLaunchDaemonOptOutSchema, sessionCommunityHarnessSchema, threadSeatMapChannelKeySchema, queueItemWakeInputBoxSchema, providerAccountReadingsSchema, humanQuietWindowsSchema, seatIdentityPanePidSchema, seatIdentityProcessEvidenceSchema, rigStartupLayersSchema, humanOperatingModesSchema, queueDelegatedDecisionSchema, nodeAdvisorSchema, operationCorrelationSchema, terminalPolicySchema, seatRuntimeSwitchesSchema, claudeTokenReadingsSchema, nodeLaunchRecipeSchema, nodeClaudeTokenRefSchema, nodeClaudePermissionsSchema, nodeCodexPermissionsSchema, escalationTargetRefsSchema, sessionAgentRunnerSchema, nodeAccountHomeSchema, nodeClaudeMcpSchema, activityGrantsSchema, environmentBindingsSchema, nodePermissionsToSchema, nodeStartupGuidanceFileSchema, managedBlockWritesSchema, streamIntakeCursorSchema, nodeLaunchWrapperSchema, piAccountSwitchesSchema, eventsRetentionMarksSchema, queueErasureSchema, queueErasureBatchesSchema, queueInboxCheckinsSchema, queueItemsHandedOffFromIndexSchema, networkBindingsSchema, networkReceiptsSchema];
|
||||
export const migrationsForFullTestDb = [coreSchema, bindingsSessionsSchema, eventsSchema, snapshotsSchema, checkpointsSchema, resumeMetadataSchema, nodeSpecFieldsSchema, packagesSchema, installJournalSchema, journalSeqSchema, bootstrapSchema, discoverySchema, discoveryFkFix, agentspecRebootSchema, startupContextSchema, chatMessagesSchema, podNamespaceSchema, contextUsageSchema, externalCliAttachmentSchema, rigServicesSchema, seatHandoverObservabilitySchema, nodeCodexConfigProfileSchema, nodePermissionPolicySchema, rigPermissionPolicySchema, nodePolicyProvenanceSchema, rigPolicyProvenanceSchema, streamItemsSchema, queueItemsSchema, queueTransitionsSchema, rigPolicySchema, rigArchiveSchema, resumeProvenanceSchema, resumeVerificationSchema, seatIdentityVerdictsSchema, selfHostIdentitySchema, occupantTenuresSchema, daemonLifecycleSchema, watchdogJobsSchema, occupantGenerationStampsSchema, projectionManifestSchema, watchdogTargetGenerationSchema, appliedLaunchObservationsSchema, appliedLaunchObservationInvalidationsSchema, threadSeatMapSchema, queueTransitionWakesSchema, nodeSessionSourceSchema, scopedOperatingPostureSchema, humanNotificationIntentSchema, reviewReadIndexesSchema, inventoryEventIndexesSchema, rigClaudeManagedBlockFileSchema, nodePermissionSelectionsSchema, humanReplyToSchema, humanQuestionsSchema, nodeEffortSchema, rigNonInterruptiveSchema, rigInstallRootSchema, threadPartMapSchema, queueTransitionsQitemIdOrderSchema, queueItemsRecoveryTagsSchema, resumeRotationSchema, sessionStopIntentSchema, nodeEffortChoiceSchema, modePostureCoexistSchema, seatEnvSchema, appliedLaunchDaemonOptOutSchema, sessionCommunityHarnessSchema, threadSeatMapChannelKeySchema, queueItemWakeInputBoxSchema, providerAccountReadingsSchema, humanQuietWindowsSchema, seatIdentityPanePidSchema, seatIdentityProcessEvidenceSchema, rigStartupLayersSchema, humanOperatingModesSchema, queueDelegatedDecisionSchema, nodeAdvisorSchema, operationCorrelationSchema, terminalPolicySchema, seatRuntimeSwitchesSchema, claudeTokenReadingsSchema, nodeLaunchRecipeSchema, nodeClaudeTokenRefSchema, nodeClaudePermissionsSchema, nodeCodexPermissionsSchema, escalationTargetRefsSchema, sessionAgentRunnerSchema, nodeAccountHomeSchema, nodeClaudeMcpSchema, activityGrantsSchema, environmentBindingsSchema, nodePermissionsToSchema, nodeStartupGuidanceFileSchema, managedBlockWritesSchema, streamIntakeCursorSchema, nodeLaunchWrapperSchema, piAccountSwitchesSchema, eventsRetentionMarksSchema, queueErasureSchema, queueErasureBatchesSchema, queueInboxCheckinsSchema, queueItemsHandedOffFromIndexSchema, networkBindingsSchema, networkReceiptsSchema, sessionAddressReadIndexesSchema];
|
||||
|
||||
/**
|
||||
* P24 — the DECLARED exclusions for {@link migrationsForFullTestDb}. That list is deliberately a
|
||||
|
||||
@@ -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, networkLocalItemId, runNetworkBindingPass, type NetworkConnectorDeps } from "../src/domain/network/network-connector.js";
|
||||
import { NetworkConnectorService, networkClosedElsewhere, networkLocalItemId, runNetworkBindingPass, type NetworkConnectorDeps } from "../src/domain/network/network-connector.js";
|
||||
|
||||
const PARTICIPANT = "alex@team";
|
||||
|
||||
@@ -424,6 +424,16 @@ 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");
|
||||
@@ -451,10 +461,12 @@ 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[] }> };
|
||||
expect(listed.bindings.find((x) => x.id === b.id)!.closedElsewhere).toEqual([
|
||||
// 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([
|
||||
{ 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((x, y) => x.localItem.localeCompare(y.localItem)));
|
||||
].sort(byLocal));
|
||||
});
|
||||
|
||||
it("a close without a resolution sends only the closer's own closing note, never another note on the item", async () => {
|
||||
|
||||
@@ -112,6 +112,40 @@ 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,12 +76,20 @@ describe("collectClaudeStatuslineSignals — reader → provider_statusline / ex
|
||||
expect(sigs[0]!.unknownReason).toBe(CLAUDE_UNKNOWN_REASON.empty_reading);
|
||||
});
|
||||
|
||||
it("malformed cache JSON → unknown(empty_reading), never throws", () => {
|
||||
it("malformed cache JSON → unknown(unreadable_cache), 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);
|
||||
});
|
||||
});
|
||||
@@ -111,14 +119,33 @@ 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.empty_reading);
|
||||
.toBe(CLAUDE_UNKNOWN_REASON.unreadable_cache);
|
||||
|
||||
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.empty_reading);
|
||||
.toBe(CLAUDE_UNKNOWN_REASON.unreadable_cache);
|
||||
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 { claudeStatuslineSignals } from "../src/domain/provider/provider-signals.js";
|
||||
import { CLAUDE_UNKNOWN_REASON, 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,5 +68,21 @@ 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,6 +275,56 @@ 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,6 +13,7 @@ import type Database from "better-sqlite3";
|
||||
import { createDb } from "../src/db/connection.js";
|
||||
import { migrate } from "../src/db/migrate.js";
|
||||
import { coreSchema } from "../src/db/migrations/001_core_schema.js";
|
||||
import { rigArchiveSchema } from "../src/db/migrations/042_rig_archive.js";
|
||||
import { bindingsSessionsSchema } from "../src/db/migrations/002_bindings_sessions.js";
|
||||
import { eventsSchema } from "../src/db/migrations/003_events.js";
|
||||
import { streamItemsSchema } from "../src/db/migrations/023_stream_items.js";
|
||||
@@ -70,7 +71,7 @@ describe("GET /api/review/*", () => {
|
||||
db = createDb(":memory:");
|
||||
migrate(db, [
|
||||
coreSchema,
|
||||
bindingsSessionsSchema,
|
||||
bindingsSessionsSchema, rigArchiveSchema,
|
||||
eventsSchema,
|
||||
streamItemsSchema,
|
||||
queueItemsSchema,
|
||||
@@ -335,7 +336,7 @@ describe("GET /api/review/rig (OPR.0.4.4.22)", () => {
|
||||
db = createDb(":memory:");
|
||||
migrate(db, [
|
||||
coreSchema,
|
||||
bindingsSessionsSchema,
|
||||
bindingsSessionsSchema, rigArchiveSchema,
|
||||
eventsSchema,
|
||||
streamItemsSchema,
|
||||
queueItemsSchema,
|
||||
@@ -668,7 +669,7 @@ describe("qitem-ccf87c0d — composeMission MEMBERSHIP-scan load contract", () =
|
||||
it("one composeMission over 40 indexed slices executes <= 4 membership queue scans AND composes all 40 ledger entries", () => {
|
||||
const db = createDb();
|
||||
migrate(db, [
|
||||
coreSchema, bindingsSessionsSchema, eventsSchema, streamItemsSchema,
|
||||
coreSchema, bindingsSessionsSchema, rigArchiveSchema, eventsSchema, streamItemsSchema,
|
||||
queueItemsSchema, queueTransitionsSchema, missionControlActionsSchema,
|
||||
queueItemSummarySchema,
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type Database from "better-sqlite3";
|
||||
import { createDb } from "../src/db/connection.js";
|
||||
import { migrate } from "../src/db/migrate.js";
|
||||
import { ALL_MIGRATIONS } from "../src/db/all-migrations.js";
|
||||
import { resolveCurrentSessionAddress, SessionRegistry } from "../src/domain/session-registry.js";
|
||||
import { getNodeInventory } from "../src/domain/node-inventory.js";
|
||||
|
||||
const databases: Database.Database[] = [];
|
||||
afterEach(() => { for (const db of databases.splice(0)) db.close(); });
|
||||
|
||||
function fixture(indexed = true) {
|
||||
const db = createDb(); databases.push(db);
|
||||
migrate(db, ALL_MIGRATIONS.filter(m => indexed || m.name !== "162_session_address_read_indexes.sql"));
|
||||
db.exec("INSERT INTO rigs(id,name) VALUES('r','team')");
|
||||
const node = db.prepare("INSERT INTO nodes(id,rig_id,logical_id,runtime) VALUES(?,'r',?,'claude-code')");
|
||||
const session = db.prepare("INSERT INTO sessions(id,node_id,session_name,status,created_at) VALUES(?,?,?,'running',?)");
|
||||
const binding = db.prepare("INSERT INTO bindings(id,node_id,tmux_session,attachment_type) VALUES(?,?,?,'tmux')");
|
||||
for (let n = 0; n < 4; n++) {
|
||||
node.run(`n${n}`, `dev.worker${n}`);
|
||||
for (let i = 0; i < 20; i++) session.run(`${n}-${String(i).padStart(3, '0')}`, `n${n}`, `native-${n}@team`, "2099-01-01");
|
||||
// Session id defines recency even when the wall clock moves backwards.
|
||||
session.run(`${n}-999`, `n${n}`, `native-${n}@team`, "2026-01-01");
|
||||
binding.run(`b${n}`, `n${n}`, `native-${n}@team`);
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
describe("current session address read cost", () => {
|
||||
it("seeks a physical name and the latest registration instead of scanning session history", () => {
|
||||
const db = fixture(), sql: string[] = [], prepare = db.prepare;
|
||||
db.prepare = function (text: string) { sql.push(text); return prepare.call(this, text); } as typeof db.prepare;
|
||||
try {
|
||||
expect(resolveCurrentSessionAddress(db, "native-0@team")?.nodeId).toBe("n0");
|
||||
expect(getNodeInventory(db, "r").map(n => n.canonicalSessionName)).toContain("native-0@team");
|
||||
} finally { db.prepare = prepare; }
|
||||
const address = sql.find(s => s.includes("AS senderAddress"))!;
|
||||
const plan = db.prepare(`EXPLAIN QUERY PLAN ${address}`).all("native-0@team", "native-0@team") as { detail: string }[];
|
||||
expect(plan.map(p => p.detail).join("\n")).toMatch(/SEARCH b USING INDEX idx_bindings_tmux_session/);
|
||||
expect(plan.map(p => p.detail).join("\n")).not.toMatch(/SCAN s\b/);
|
||||
const latest = db.prepare("EXPLAIN QUERY PLAN SELECT id FROM sessions WHERE node_id=? ORDER BY id DESC LIMIT 1").all("n0") as { detail: string }[];
|
||||
expect(latest.map(p => p.detail).join("\n")).not.toContain("TEMP B-TREE");
|
||||
});
|
||||
|
||||
it("keeps exact-name priority, derived aliases, and fresh binding changes", () => {
|
||||
const db = fixture();
|
||||
expect(resolveCurrentSessionAddress(db, "dev-worker0@team")?.sessionName).toBe("native-0@team");
|
||||
// A physical spelling takes priority over another seat's derived spelling.
|
||||
db.prepare("UPDATE sessions SET session_name='dev-worker0@team' WHERE id='1-999'").run();
|
||||
db.prepare("UPDATE bindings SET tmux_session='dev-worker0@team' WHERE node_id='n1'").run();
|
||||
expect(resolveCurrentSessionAddress(db, "dev-worker0@team")?.nodeId).toBe("n1");
|
||||
db.prepare("UPDATE sessions SET status='exited' WHERE id='1-999'").run();
|
||||
// That other seat's saved spelling still reserves the name.
|
||||
expect(resolveCurrentSessionAddress(db, "dev-worker0@team")).toBeNull();
|
||||
db.prepare("UPDATE rigs SET archived_at='2026-01-02' WHERE id='r'").run();
|
||||
expect(resolveCurrentSessionAddress(db, "native-0@team")).toBeNull();
|
||||
});
|
||||
|
||||
it("seeks the latest named registration when the generation reader resolves a session", () => {
|
||||
const db = fixture(), sql: string[] = [], prepare = db.prepare;
|
||||
db.prepare = function (text: string) { sql.push(text); return prepare.call(this, text); } as typeof db.prepare;
|
||||
try { new SessionRegistry(db).currentOccupantGenerationForSession("native-0@team"); }
|
||||
finally { db.prepare = prepare; }
|
||||
const read = sql.find(s => s.includes("SELECT node_id FROM sessions WHERE session_name"))!;
|
||||
expect(read).toBeDefined();
|
||||
const plan = db.prepare(`EXPLAIN QUERY PLAN ${read}`).all("native-0@team") as { detail: string }[];
|
||||
expect(plan.map(p => p.detail).join("\n")).toContain("SEARCH sessions USING INDEX idx_sessions_name_latest_id");
|
||||
expect(plan.map(p => p.detail).join("\n")).not.toContain("TEMP B-TREE");
|
||||
});
|
||||
|
||||
it("upgrades populated databases without changing registrations or inventory", () => {
|
||||
const db = fixture(false);
|
||||
const before = { inventory: getNodeInventory(db, "r"),
|
||||
physical: resolveCurrentSessionAddress(db, "native-0@team"),
|
||||
alias: resolveCurrentSessionAddress(db, "dev-worker0@team"),
|
||||
registrations: db.prepare("SELECT * FROM sessions ORDER BY id").all() };
|
||||
migrate(db, ALL_MIGRATIONS);
|
||||
migrate(db, ALL_MIGRATIONS);
|
||||
expect({ inventory: getNodeInventory(db, "r"),
|
||||
physical: resolveCurrentSessionAddress(db, "native-0@team"),
|
||||
alias: resolveCurrentSessionAddress(db, "dev-worker0@team"),
|
||||
registrations: db.prepare("SELECT * FROM sessions ORDER BY id").all() }).toEqual(before);
|
||||
expect(db.pragma("integrity_check", { simple: true })).toBe("ok");
|
||||
expect(db.pragma("foreign_key_check")).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -370,6 +370,23 @@ describe("SessionRegistry", () => {
|
||||
expect(names).toContain("r01-dev1-qa");
|
||||
});
|
||||
|
||||
it("getSessionsForRig preserves insertion order for timestamp ties with the session indexes installed", () => {
|
||||
const indexes = db.prepare("PRAGMA index_list(sessions)").all() as { name: string }[];
|
||||
expect(indexes.map((index) => index.name)).toContain("idx_sessions_node_latest_id");
|
||||
const insert = db.prepare("INSERT INTO sessions(id, node_id, session_name, created_at) VALUES (?, ?, ?, ?)");
|
||||
// Non-monotonic ids distinguish insertion order from either id ordering.
|
||||
insert.run("m-first", "node-1", "first@rig", "2026-01-02");
|
||||
insert.run("z-second", "node-1", "second@rig", "2026-01-02");
|
||||
insert.run("a-third", "node-1", "third@rig", "2026-01-02");
|
||||
insert.run("n-fourth", "node-2", "fourth@rig", "2026-01-02");
|
||||
// Timestamp remains the primary order even when this row was inserted last.
|
||||
insert.run("earlier", "node-2", "earlier@rig", "2026-01-01");
|
||||
|
||||
expect(registry.getSessionsForRig("rig-1").map((session) => session.id)).toEqual([
|
||||
"earlier", "m-first", "z-second", "a-third", "n-fourth",
|
||||
]);
|
||||
});
|
||||
|
||||
it("getBindingForNode returns null when unbound", () => {
|
||||
const binding = registry.getBindingForNode("node-1");
|
||||
expect(binding).toBeNull();
|
||||
|
||||
@@ -7,6 +7,7 @@ import type Database from "better-sqlite3";
|
||||
import { createDb } from "../src/db/connection.js";
|
||||
import { migrate } from "../src/db/migrate.js";
|
||||
import { coreSchema } from "../src/db/migrations/001_core_schema.js";
|
||||
import { rigArchiveSchema } from "../src/db/migrations/042_rig_archive.js";
|
||||
import { outboxEntriesSchema } from "../src/db/migrations/027_outbox_entries.js";
|
||||
import { bindingsSessionsSchema } from "../src/db/migrations/002_bindings_sessions.js";
|
||||
import { eventsSchema } from "../src/db/migrations/003_events.js";
|
||||
@@ -133,7 +134,7 @@ describe("workflow route (WF3 FR-4 — close+recreate+rebind)", () => {
|
||||
db = createDb();
|
||||
migrate(db, [
|
||||
outboxEntriesSchema,
|
||||
coreSchema, bindingsSessionsSchema, eventsSchema,
|
||||
coreSchema, bindingsSessionsSchema, rigArchiveSchema, eventsSchema,
|
||||
queueItemsSchema, queueTransitionsSchema,
|
||||
queueItemSummarySchema, queueItemEvidenceRefSchema,
|
||||
workflowSpecsSchema, workflowInstancesSchema, workflowStepTrailsSchema,
|
||||
|
||||
@@ -7,6 +7,7 @@ import type Database from "better-sqlite3";
|
||||
import { createDb } from "../src/db/connection.js";
|
||||
import { migrate } from "../src/db/migrate.js";
|
||||
import { coreSchema } from "../src/db/migrations/001_core_schema.js";
|
||||
import { rigArchiveSchema } from "../src/db/migrations/042_rig_archive.js";
|
||||
import { outboxEntriesSchema } from "../src/db/migrations/027_outbox_entries.js";
|
||||
import { eventsSchema } from "../src/db/migrations/003_events.js";
|
||||
import { queueItemsSchema } from "../src/db/migrations/024_queue_items.js";
|
||||
@@ -582,7 +583,7 @@ describe("workflow routes — WF-2 structured error boundaries", () => {
|
||||
migrate(db, [
|
||||
outboxEntriesSchema,
|
||||
coreSchema,
|
||||
bindingsSessionsSchema,
|
||||
bindingsSessionsSchema, rigArchiveSchema,
|
||||
eventsSchema,
|
||||
queueItemsSchema,
|
||||
queueTransitionsSchema,
|
||||
|
||||
@@ -13,6 +13,7 @@ import type Database from "better-sqlite3";
|
||||
import { createDb } from "../src/db/connection.js";
|
||||
import { migrate } from "../src/db/migrate.js";
|
||||
import { coreSchema } from "../src/db/migrations/001_core_schema.js";
|
||||
import { rigArchiveSchema } from "../src/db/migrations/042_rig_archive.js";
|
||||
import { outboxEntriesSchema } from "../src/db/migrations/027_outbox_entries.js";
|
||||
import { bindingsSessionsSchema } from "../src/db/migrations/002_bindings_sessions.js";
|
||||
import { eventsSchema } from "../src/db/migrations/003_events.js";
|
||||
@@ -188,7 +189,7 @@ describe("OPR.0.4.6.WF2 — spec language", () => {
|
||||
migrate(db, [
|
||||
outboxEntriesSchema,
|
||||
coreSchema,
|
||||
bindingsSessionsSchema,
|
||||
bindingsSessionsSchema, rigArchiveSchema,
|
||||
eventsSchema,
|
||||
queueItemsSchema,
|
||||
queueTransitionsSchema,
|
||||
|
||||
+49
-30
@@ -48,6 +48,8 @@ interface ContentLine {
|
||||
zones?: Array<{ start: number; end: number; action: Action }>;
|
||||
/** slice-17: token segments for canvas-rendered rows (graph view) */
|
||||
segs?: Array<{ text: string; token?: import("./theme.js").Token; bold?: boolean; bg?: import("./theme.js").Token }>;
|
||||
/** Row of a labelled group. The first visible row of each group shows `text` when set, so a scrolled group keeps its label. */
|
||||
sticky?: { group: string; text?: string };
|
||||
}
|
||||
|
||||
function pad(text: string | number | null | undefined, width: number): string {
|
||||
@@ -94,17 +96,24 @@ function agentColumns(contentWidth: number): AgentColumn[] {
|
||||
];
|
||||
}
|
||||
|
||||
function instanceAgentColumns(contentWidth: number): AgentColumn[] {
|
||||
if (contentWidth >= 110) return [
|
||||
["rig", "RIG", 13, "left"], ["pod", "POD", 8, "left"], ["seat", "SEAT", 14, "left"],
|
||||
["runtime", "RT", 3, "left"], ["context", "CTX", 7, "right"], ["status", "STATE", 10, "left"],
|
||||
["queue", "Q", 2, "right"], ["work", "WORK", 12, "left"], ["now", "NOW", Math.max(18, contentWidth - 78), "left"],
|
||||
];
|
||||
if (contentWidth >= 78) return [
|
||||
["rig", "RIG", 10, "left"], ["pod", "POD", 6, "left"], ["seat", "SEAT", 12, "left"],
|
||||
["runtime", "RT", 3, "left"], ["context", "CTX", 5, "right"], ["status", "STATE", 9, "left"],
|
||||
["queue", "Q", 2, "right"], ["work", "WORK", Math.max(8, contentWidth - 54), "left"],
|
||||
];
|
||||
/** RIG fits the longest rig name shown, between the tier's old width and its cap; NOW or WORK gives up the difference. */
|
||||
function instanceAgentColumns(contentWidth: number, longestRigName = 0): AgentColumn[] {
|
||||
if (contentWidth >= 110) {
|
||||
const rig = Math.min(Math.max(longestRigName, 13), 24);
|
||||
return [
|
||||
["rig", "RIG", rig, "left"], ["pod", "POD", 8, "left"], ["seat", "SEAT", 14, "left"],
|
||||
["runtime", "RT", 3, "left"], ["context", "CTX", 7, "right"], ["status", "STATE", 10, "left"],
|
||||
["queue", "Q", 2, "right"], ["work", "WORK", 12, "left"], ["now", "NOW", Math.max(18, contentWidth - 78 - (rig - 13)), "left"],
|
||||
];
|
||||
}
|
||||
if (contentWidth >= 78) {
|
||||
const rig = Math.min(Math.max(longestRigName, 10), 16);
|
||||
return [
|
||||
["rig", "RIG", rig, "left"], ["pod", "POD", 6, "left"], ["seat", "SEAT", 12, "left"],
|
||||
["runtime", "RT", 3, "left"], ["context", "CTX", 5, "right"], ["status", "STATE", 9, "left"],
|
||||
["queue", "Q", 2, "right"], ["work", "WORK", Math.max(8, contentWidth - 54 - (rig - 10)), "left"],
|
||||
];
|
||||
}
|
||||
const fixed = 8 + 5 + 11 + 2 + 5 + 8 + 2 + 7;
|
||||
return [
|
||||
["rig", "RIG", 8, "left"], ["pod", "POD", 5, "left"], ["seat", "SEAT", 11, "left"],
|
||||
@@ -709,7 +718,7 @@ function noRigsText(snap: FleetSnapshot, host: FleetSnapshot["hosts"][number], l
|
||||
: ` (no rigs served by ${host.name})`;
|
||||
}
|
||||
|
||||
/** Rig boundaries take a solid rule and pod boundaries a dotted one; RIG repeats on every row so scrolled rows keep it. */
|
||||
/** Rig boundaries take a solid rule and pod boundaries a dotted one; RIG shows on a rig's first visible row so scrolled rows keep it. */
|
||||
function instanceContentLines(
|
||||
state: ViewState,
|
||||
snap: FleetSnapshot,
|
||||
@@ -801,7 +810,12 @@ function instanceContentLines(
|
||||
lines.push(healthSummaryLine(snap, scope, contentWidth));
|
||||
lines.push(...queueSampleLines(snap, contentWidth));
|
||||
lines.push({ text: state.filter ? `/ filter instance rows: ${state.filter} · / replace · esc clear` : "/ filter instance rows…" });
|
||||
const columns = instanceAgentColumns(contentWidth);
|
||||
const shownRigs = host.rigs.map((rig) => ({
|
||||
rig,
|
||||
agents: rig.pods.flatMap((pod) => pod.agents.map((agent) => ({ pod: pod.name, agent })))
|
||||
.filter(({ pod, agent }) => !state.filter || rig.name.includes(state.filter) || pod.includes(state.filter) || agent.name.includes(state.filter)),
|
||||
})).filter(({ rig, agents }) => !state.filter || agents.length > 0 || rig.name.includes(state.filter));
|
||||
const columns = instanceAgentColumns(contentWidth, Math.max(0, ...shownRigs.map(({ rig }) => rig.name.length)));
|
||||
lines.push({ text: tableRow(columns, { rig: "RIG", pod: "POD", seat: "SEAT", runtime: "RT", context: "CTX", status: "STATE", queue: "Q", work: "WORK", now: "NOW" }) });
|
||||
lines.push({ text: "━".repeat(columnsWidth(columns)) });
|
||||
let seatCount = 0;
|
||||
@@ -810,10 +824,7 @@ function instanceContentLines(
|
||||
let attentionUnknown = 0;
|
||||
let openCount = 0;
|
||||
let firstRig = true;
|
||||
for (const rig of host.rigs) {
|
||||
const agents = rig.pods.flatMap((pod) => pod.agents.map((agent) => ({ pod: pod.name, agent })))
|
||||
.filter(({ pod, agent }) => !state.filter || rig.name.includes(state.filter) || pod.includes(state.filter) || agent.name.includes(state.filter));
|
||||
if (state.filter && agents.length === 0 && !rig.name.includes(state.filter)) continue;
|
||||
for (const { rig, agents } of shownRigs) {
|
||||
if (!firstRig) lines.push({ text: "─".repeat(columnsWidth(columns)) });
|
||||
firstRig = false;
|
||||
const rigAction: Action = { type: "drill", resource: "rig", name: rig.name, target: { host: host.name } };
|
||||
@@ -825,7 +836,7 @@ function instanceContentLines(
|
||||
continue;
|
||||
}
|
||||
let previousPod: string | null = null;
|
||||
for (const { pod, agent } of agents) {
|
||||
for (const [index, { pod, agent }] of agents.entries()) {
|
||||
const firstInPod = pod !== previousPod;
|
||||
if (firstInPod && previousPod != null) lines.push({ text: "┈".repeat(columnsWidth(columns)) });
|
||||
previousPod = pod;
|
||||
@@ -836,18 +847,21 @@ function instanceContentLines(
|
||||
if (agent.needsAttention === true) attentionCount += 1;
|
||||
else if (agent.needsAttention == null) attentionUnknown += 1;
|
||||
openCount += queue.count;
|
||||
const cells = {
|
||||
rig: rig.name,
|
||||
pod: firstInPod ? pod : "",
|
||||
seat: `${stateCell.mark} ${seatName(pod, agent.name)}`,
|
||||
runtime: runtimeShort(agent.runtime),
|
||||
context: contextCompact(agent.context, contentWidth < 110),
|
||||
status: stateCell.word,
|
||||
queue: queueCount(snap, queue.count),
|
||||
work: queue.work,
|
||||
now: queue.now,
|
||||
};
|
||||
const named = tableRow(columns, cells);
|
||||
lines.push({
|
||||
text: tableRow(columns, {
|
||||
rig: rig.name,
|
||||
pod: firstInPod ? pod : "",
|
||||
seat: `${stateCell.mark} ${seatName(pod, agent.name)}`,
|
||||
runtime: runtimeShort(agent.runtime),
|
||||
context: contextCompact(agent.context, contentWidth < 110),
|
||||
status: stateCell.word,
|
||||
queue: queueCount(snap, queue.count),
|
||||
work: queue.work,
|
||||
now: queue.now,
|
||||
}),
|
||||
text: index === 0 ? named : tableRow(columns, { ...cells, rig: "" }),
|
||||
sticky: { group: `rig:${rig.name}`, ...(index === 0 ? {} : { text: named }) },
|
||||
action: { type: "drill", resource: "agent", name: agent.name, target: { host: host.name, rig: rig.name, pod } },
|
||||
zones: [{ start: 0, end: columns[0]![2], action: rigAction }],
|
||||
});
|
||||
@@ -1944,7 +1958,12 @@ function renderBody(state: ViewState, snap: FleetSnapshot, options: RenderOption
|
||||
const contentRows = content.length > bodyRows ? Math.max(bodyRows - 1, 0) : bodyRows;
|
||||
const maxContentOffset = Math.max(content.length - contentRows, 0);
|
||||
const contentStart = Math.min(state.contentOffset, maxContentOffset);
|
||||
const visibleContent = content.slice(contentStart, contentStart + contentRows);
|
||||
const labelledGroups = new Set<string>();
|
||||
const visibleContent = content.slice(contentStart, contentStart + contentRows).map((line) => {
|
||||
if (!line.sticky || labelledGroups.has(line.sticky.group)) return line;
|
||||
labelledGroups.add(line.sticky.group);
|
||||
return line.sticky.text == null ? line : { ...line, text: line.sticky.text };
|
||||
});
|
||||
if (content.length > bodyRows) {
|
||||
const scrollText = `scroll ↑/↓ · ${contentStart + 1}-${contentStart + visibleContent.length} of ${content.length}`;
|
||||
const up = scrollText.indexOf("↑");
|
||||
|
||||
@@ -6,6 +6,7 @@ import { decodeInput, resolveKeyAction, sgrClick } from "../src/input.js";
|
||||
import { renderScreen } from "../src/render.js";
|
||||
import { computeExplorerRows, createViewState } from "../src/state.js";
|
||||
import { createStyle, stripAnsi } from "../src/theme.js";
|
||||
import { explorerWidth } from "../src/visual-layout.js";
|
||||
import { stylizeLines } from "../src/stylize.js";
|
||||
import type { AgentRow, FleetSnapshot } from "../src/types.js";
|
||||
|
||||
@@ -118,16 +119,20 @@ describe("S12 instance mission control", () => {
|
||||
let scrolledSeatRows = 0;
|
||||
for (let offset = 0; offset <= screen.contentMaxOffset; offset += 1) {
|
||||
screen = renderScreen(view.get(), snap, { cols, rows, nowMs: 0, colorMode: "none" });
|
||||
const namedRigs = new Set<string>();
|
||||
for (const target of screen.contentTargets) {
|
||||
if (target.action.type !== "drill" || target.action.resource !== "agent") continue;
|
||||
reached.add(target.action.name);
|
||||
const rig = target.action.target?.rig;
|
||||
const content = stripAnsi(screen.lines[target.y - 1] ?? "").slice(screen.explorerWidth + 2);
|
||||
const firstVisible = !!rig && !namedRigs.has(rig);
|
||||
if (rig) namedRigs.add(rig);
|
||||
if (!rig || !/\b(working|needs you|idle|detached|failed|unknown|blocked)\b/.test(content)) continue;
|
||||
const headingVisible = screen.lines.some((line) => stripAnsi(line).slice(screen.explorerWidth + 2).includes(`RIG ${rig} ·`));
|
||||
if (headingVisible) continue;
|
||||
scrolledSeatRows += 1;
|
||||
expect(content.trimStart().startsWith(rig), `${cols}: scrolled seat row retains rig identity`).toBe(true);
|
||||
if (firstVisible) expect(content.startsWith(rig), `${cols}: first visible seat row of a rig retains rig identity`).toBe(true);
|
||||
else expect(content.startsWith(" ".repeat(rig.length)), `${cols}: later seat rows of a rig leave RIG blank`).toBe(true);
|
||||
}
|
||||
view.dispatch({ type: "content-scroll", delta: 1 });
|
||||
}
|
||||
@@ -140,7 +145,7 @@ describe("S12 instance mission control", () => {
|
||||
expect(whole).toMatch(/empty\s+—\s+\(no seats\).*stopped/);
|
||||
expect(whole).toMatch(/dev\s+[\s\S]*┈{8,}[\s\S]*qa\s+/);
|
||||
expect((whole.match(/┈{8,}/g) ?? [])).toHaveLength(1);
|
||||
expect(whole).toMatch(/build\s+qa\s+[^\n]*\n[^\n]*─{8,}[^\n]*\n[^\n]*docs\s+write/);
|
||||
expect(whole).toMatch(/\s+qa\s+[^\n]*\n[^\n]*─{8,}[^\n]*\n[^\n]*docs\s+write/);
|
||||
expect(whole).toMatch(/docs\s+write\s+[^\n]*\n[^\n]*─{8,}[^\n]*\n[^\n]*empty\s+—/);
|
||||
expect(whole).not.toMatch(/\+\d+|more hidden/i);
|
||||
expect(reached).toEqual(expectedAgents);
|
||||
@@ -319,3 +324,138 @@ describe("S12 view-aware hydration", () => {
|
||||
expect(snap.hosts[0]).toMatchObject({ id: "local", name: "local" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("instance table RIG column (#2848)", () => {
|
||||
const LONG = "RIG-session-review";
|
||||
const OVER_CAP = "rig-name-thirty-characters-xx";
|
||||
|
||||
function colsFor(contentWidth: number): number {
|
||||
for (let cols = contentWidth; cols < contentWidth + 80; cols += 1) {
|
||||
if (cols - explorerWidth(cols) - 2 === contentWidth) return cols;
|
||||
}
|
||||
throw new Error(`no terminal width gives content width ${contentWidth}`);
|
||||
}
|
||||
|
||||
function snapshotWithRigs(names: string[]): FleetSnapshot {
|
||||
const snap = multiRigSnapshot();
|
||||
snap.hosts[0]!.rigs.push(...names.map((name) => ({
|
||||
id: `r-${name}`,
|
||||
name,
|
||||
lifecycleState: "running",
|
||||
pods: [{ name: "ops", agents: [agent("ops.worker", `ops-worker@${name}`, "idle", 10)] }],
|
||||
})));
|
||||
return snap;
|
||||
}
|
||||
|
||||
function table(snap: FleetSnapshot, contentWidth: number, filter = "") {
|
||||
const view = openInstance(snap);
|
||||
if (filter) view.dispatch({ type: "filter", text: filter });
|
||||
const cols = colsFor(contentWidth);
|
||||
const screen = renderScreen(view.get(), snap, { cols, rows: 160, nowMs: 0, colorMode: "none" });
|
||||
const content = screen.lines.map((line) => stripAnsi(line).slice(screen.explorerWidth + 2));
|
||||
const header = content.find((line) => /^RIG\s+POD\s+SEAT/.test(line));
|
||||
expect(header, `${contentWidth}: table header`).toBeDefined();
|
||||
const heavyRule = content.find((line) => /^━{20,}/.test(line))!.match(/^━+/)![0].length;
|
||||
const rigWidth = header!.indexOf("POD") - 1;
|
||||
return { screen, content, header: header!, rigWidth, rowWidth: heavyRule };
|
||||
}
|
||||
|
||||
it.each([
|
||||
// [content width, expected RIG width, flexible column, expected flexible width]
|
||||
[77, 8, "WORK", 77 - (8 + 5 + 11 + 2 + 5 + 8 + 2 + 7)],
|
||||
[78, 16, "WORK", 78 - 54 - (16 - 10)],
|
||||
[109, 16, "WORK", 109 - 54 - (16 - 10)],
|
||||
[110, LONG.length, "NOW", 110 - 78 - (LONG.length - 13)],
|
||||
[190, LONG.length, "NOW", 190 - 78 - (LONG.length - 13)],
|
||||
] as const)("fits RIG to the longest shown name at content width %i", (contentWidth, rigWidth, flexible, flexibleWidth) => {
|
||||
const { header, rigWidth: actual, rowWidth } = table(snapshotWithRigs([LONG]), contentWidth);
|
||||
expect(actual).toBe(rigWidth);
|
||||
expect(rowWidth).toBeLessThanOrEqual(contentWidth);
|
||||
expect(header.trimEnd().endsWith(flexible)).toBe(true);
|
||||
expect(rowWidth - header.lastIndexOf(flexible)).toBe(flexibleWidth);
|
||||
});
|
||||
|
||||
it("keeps the old RIG width as the minimum when every name is short", () => {
|
||||
expect(table(multiRigSnapshot(), 160).rigWidth).toBe(13);
|
||||
expect(table(multiRigSnapshot(), 90).rigWidth).toBe(10);
|
||||
});
|
||||
|
||||
it.each([[110, 24], [160, 24], [78, 16], [100, 16]])("caps RIG and truncates a longer name at content width %i", (contentWidth, cap) => {
|
||||
const { content, rigWidth, rowWidth } = table(snapshotWithRigs([OVER_CAP]), contentWidth);
|
||||
expect(rigWidth).toBe(cap);
|
||||
expect(rowWidth).toBeLessThanOrEqual(contentWidth);
|
||||
expect(content.some((line) => line.startsWith(`${OVER_CAP.slice(0, cap - 1)}…`))).toBe(true);
|
||||
for (const line of content) expect(line.trimEnd().length).toBeLessThanOrEqual(contentWidth);
|
||||
});
|
||||
|
||||
function seatRows(screen: ReturnType<typeof renderScreen>) {
|
||||
return screen.contentTargets
|
||||
.filter((target) => target.action.type === "drill" && target.action.resource === "agent")
|
||||
.map((target) => ({
|
||||
y: target.y,
|
||||
rig: target.action.type === "drill" ? target.action.target?.rig ?? "" : "",
|
||||
name: target.action.type === "drill" ? target.action.name : "",
|
||||
text: stripAnsi(screen.lines[target.y - 1] ?? "").slice(screen.explorerWidth + 2),
|
||||
}));
|
||||
}
|
||||
|
||||
it("names every rig on its single row when each rig has one seat", () => {
|
||||
const names = Array.from({ length: 8 }, (_, index) => `solo-${index}`);
|
||||
const { screen } = table(snapshotWithRigs(names), 160);
|
||||
const rows = seatRows(screen).filter((row) => names.includes(row.rig));
|
||||
expect(rows.map((row) => row.rig)).toEqual(names);
|
||||
for (const row of rows) expect(row.text.startsWith(`${row.rig} `)).toBe(true);
|
||||
});
|
||||
|
||||
it("names a rig on its first row only, across pod dividers, while POD still leads each pod", () => {
|
||||
const { screen, content, rigWidth } = table(multiRigSnapshot(), 160);
|
||||
const build = seatRows(screen).filter((row) => row.rig === "build");
|
||||
expect(build.map((row) => row.name)).toEqual(["dev.driver", "dev.guard", "qa.checker"]);
|
||||
expect(build[0]!.text).toMatch(/^build\s+dev\s/);
|
||||
expect(build[1]!.text.slice(0, rigWidth + 4).trim()).toBe("");
|
||||
expect(build[2]!.text).toMatch(new RegExp(`^ {${rigWidth}} qa\\s`));
|
||||
expect(content[build[2]!.y - 2]).toMatch(/^┈{8,}/);
|
||||
expect(content.filter((line) => line.startsWith("build "))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("drills into the row's rig from a blank RIG cell", () => {
|
||||
const { screen, rigWidth } = table(multiRigSnapshot(), 160);
|
||||
const guard = seatRows(screen).find((row) => row.name === "dev.guard")!;
|
||||
expect(guard.text.slice(0, rigWidth).trim()).toBe("");
|
||||
for (const x of [screen.explorerWidth + 3, screen.explorerWidth + 2 + rigWidth]) {
|
||||
const hit = screen.hitMap.find((target) => target.y === guard.y && x >= target.x1 && x <= target.x2);
|
||||
expect(hit?.action).toEqual({ type: "drill", resource: "rig", name: "build", target: { host: "mm2-openrig1" } });
|
||||
}
|
||||
const pastRig = screen.explorerWidth + 3 + rigWidth;
|
||||
const seatHit = screen.hitMap.find((target) => target.y === guard.y && pastRig >= target.x1 && pastRig <= target.x2);
|
||||
expect(seatHit?.action).toMatchObject({ type: "drill", resource: "agent", name: "dev.guard" });
|
||||
});
|
||||
|
||||
it("names a scrolled rig on its top visible row and leaves the rows under it blank", () => {
|
||||
const snap = multiRigSnapshot();
|
||||
snap.hosts[0]!.rigs[0]!.pods[0]!.agents.push(...Array.from({ length: 36 }, (_, index) =>
|
||||
agent(`dev.extra-${index}`, `dev-extra-${index}@build`, "active", 20 + index),
|
||||
));
|
||||
const view = openInstance(snap);
|
||||
let screen = renderScreen(view.get(), snap, { cols: 160, rows: 30, nowMs: 0, colorMode: "none" });
|
||||
view.dispatch({ type: "layout", contentMaxOffset: screen.contentMaxOffset, contentTargetCount: screen.contentTargets.length });
|
||||
let checked = false;
|
||||
for (let offset = 0; offset <= screen.contentMaxOffset && !checked; offset += 1) {
|
||||
screen = renderScreen(view.get(), snap, { cols: 160, rows: 30, nowMs: 0, colorMode: "none" });
|
||||
const build = seatRows(screen).filter((row) => row.rig === "build");
|
||||
if (build.length >= 3 && build[0]!.name !== "dev.driver") {
|
||||
expect(build[0]!.text.startsWith("build ")).toBe(true);
|
||||
for (const row of build.slice(1)) expect(row.text.startsWith(" ")).toBe(true);
|
||||
checked = true;
|
||||
}
|
||||
view.dispatch({ type: "content-scroll", delta: 1 });
|
||||
}
|
||||
expect(checked).toBe(true);
|
||||
});
|
||||
|
||||
it("sizes RIG from the rigs the filter leaves on screen", () => {
|
||||
const snap = snapshotWithRigs([LONG, "short-rig"]);
|
||||
expect(table(snap, 160).rigWidth).toBe(LONG.length);
|
||||
expect(table(snap, 160, "short").rigWidth).toBe(13);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user