mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-09 03:39:51 -10:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6bc3186975 | ||
|
|
00d6d25cce | ||
|
|
10736e1d9c | ||
|
|
1fe9296b48 | ||
|
|
369a129f69 |
No files matched your search
@@ -170,6 +170,16 @@ function atxHeading(line: string): { level: number; title: string } | null {
|
||||
export function parseMarkdownSections(text: string): MarkdownSection[] {
|
||||
const lines = text.split("\n");
|
||||
const { hits: headers } = scanHeaders(lines);
|
||||
// Resolve all section ends in one backwards pass. Copying the remaining
|
||||
// header array for every section makes even sibling-only documents quadratic.
|
||||
const fullEnds: number[] = [];
|
||||
const following: HeaderHit[] = [];
|
||||
for (let idx = headers.length - 1; idx >= 0; idx--) {
|
||||
const header = headers[idx]!;
|
||||
while (following.length && following[following.length - 1]!.level > header.level) following.pop();
|
||||
fullEnds[idx] = following[following.length - 1]?.line ?? lines.length;
|
||||
following.push(header);
|
||||
}
|
||||
const sections: MarkdownSection[] = [];
|
||||
let currentH2: string | null = null;
|
||||
for (let idx = 0; idx < headers.length; idx++) {
|
||||
@@ -186,7 +196,7 @@ export function parseMarkdownSections(text: string): MarkdownSection[] {
|
||||
// instead of being silently promoted to top-level addresses.
|
||||
const headerPath = h.level === 2 ? [slug] : currentH2 !== null ? [currentH2, slug] : [slug];
|
||||
// FULL span: to the next header with level <= this one (same-or-higher).
|
||||
const fullEnd = headers.slice(idx + 1).find((n) => n.level <= h.level)?.line ?? lines.length;
|
||||
const fullEnd = fullEnds[idx]!;
|
||||
// OWN text: to the next header of ANY level.
|
||||
const ownEnd = headers[idx + 1]?.line ?? lines.length;
|
||||
sections.push({
|
||||
|
||||
@@ -128,7 +128,7 @@ function ledger(dir: string, io: ScopeFsDeps): Judgment[] {
|
||||
const expected = `${String(receipts.length + 1).padStart(8, "0")}.md`;
|
||||
if (name !== expected) throw new JudgmentError("journal_conflict", `${home}: expected ${expected}, found ${name}; inspect retained receipts`);
|
||||
const raw = io.readFile(path.join(home, name));
|
||||
const fm = raw && /^---\n([\s\S]*?)\n---(?:\n|$)/.exec(raw);
|
||||
const fm = raw && /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(raw);
|
||||
if (!fm) throw new JudgmentError("journal_invalid", `${home}/${name}: incomplete judgment`);
|
||||
const obj = mapping(YAML.parse(fm[1]!), name), { id, ...payload } = obj;
|
||||
if (id !== hash(payload) || obj.version !== 1 || obj.sequence !== receipts.length + 1 || typeof obj.itemId !== "string" || typeof obj.itemRevision !== "string" || typeof obj.policyRevision !== "string" || typeof obj.actor !== "string" || typeof obj.operationId !== "string" || typeof obj.intent !== "string" || !["accept", "reject", "withdraw"].includes(String(obj.verdict)) || !Array.isArray(obj.evidence)) throw new JudgmentError("journal_invalid", `${home}/${name}: malformed or changed judgment; inspect history`);
|
||||
|
||||
@@ -66,7 +66,7 @@ import { physicalSessionName } from "./session-registry.js";
|
||||
import type { QueueWakeFacts } from "./draft-aware-delivery.js";
|
||||
import { derivePickup, type PickupReceipt } from "./queue-pickup.js";
|
||||
import { withRecoveryIndex } from "./queue-recovery.js";
|
||||
import { lastMeaningfulTransition, readWaitingView, type WaitingView, type WaitingActivityReader } from "./queue-waiting.js";
|
||||
import { lastMeaningfulTransitionMany, meaningfulFor, readWaitingView, WaitingStatements, type WaitingView, type WaitingActivityReader, type WaitingReads } from "./queue-waiting.js";
|
||||
import { isOpenRigSender, OPENRIG_SENDER, wrapPaneEnvelope } from "../lib/pane-envelope.js";
|
||||
import { isBoundTerminalSeat } from "./terminal-seat.js";
|
||||
import { renderQueueHandoffNudge } from "./queue-nudge-text.js";
|
||||
@@ -4432,14 +4432,12 @@ export class QueueRepository {
|
||||
.prepare("SELECT * FROM queue_items WHERE qitem_id = ?")
|
||||
.get(qitemId) as QueueItemRow | undefined;
|
||||
if (!row) return null;
|
||||
const item = this.rowToItem(row);
|
||||
// OPR.0.5.6.14 — the row FACE answers "did it reach them" in one read for
|
||||
// gateway-routed rows; null for pane-bound (absence-governed, no key lies).
|
||||
const ledger = this.deliveryOutcomeFor(item.qitemId);
|
||||
// The same projection as a listing's, for one row: one read of its owner notifications and ledger.
|
||||
const item = this.projectRows([row], true)[0]!;
|
||||
return {
|
||||
...item,
|
||||
deliveryOutcome: ledger?.outcome ?? null,
|
||||
...(ledger && ledger.outcome !== "posted" && ledger.outcome !== "accepted" ? { deliveryFailureDetail: ledger.detail } : {}),
|
||||
...(item.replyTo ? { replyToFallback: this.replyToFallbackFor(item.qitemId) } : {}),
|
||||
};
|
||||
}
|
||||
@@ -4584,18 +4582,25 @@ export class QueueRepository {
|
||||
`SELECT ${columns} FROM queue_items ${where} ORDER BY ${orderBy} LIMIT ?`
|
||||
)
|
||||
.all(...params) as QueueItemRow[];
|
||||
const items = this.projectRows(rows, !opts?.compact);
|
||||
return opts?.compact ? items.map(compactRow) : items;
|
||||
}
|
||||
|
||||
/** The listing projection (list, and show as a listing of one row): each row's item with its delivery outcome, the
|
||||
* owner notifications, ledgers and shared reads read once for all the rows. includeWaiting: full rows (SELECT *). */
|
||||
private projectRows(rows: QueueItemRow[], includeWaiting: boolean): QueueItem[] {
|
||||
const owners = this.transitionLog.ownerNotificationsForQitems(rows.map((row) => row.qitem_id));
|
||||
const ledgers = this.deliveryOutcomesFor(rows, owners, true);
|
||||
const items = rows.map((r) => {
|
||||
const reads = this.listingReads(rows, includeWaiting);
|
||||
return rows.map((r) => {
|
||||
const ledger = ledgers.get(r.qitem_id);
|
||||
const item = this.rowToItem(r, !opts?.compact, { owners: owners.get(r.qitem_id) ?? [], ledger: ledger ?? null });
|
||||
const item = this.rowToItem(r, includeWaiting, { owners: owners.get(r.qitem_id) ?? [], ledger: ledger ?? null, reads });
|
||||
return {
|
||||
...item,
|
||||
deliveryOutcome: ledger?.outcome ?? null,
|
||||
...(ledger && ledger.outcome !== "posted" && ledger.outcome !== "accepted" ? { deliveryFailureDetail: ledger.detail } : {}),
|
||||
};
|
||||
});
|
||||
return opts?.compact ? items.map(compactRow) : items;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -5498,9 +5503,27 @@ export class QueueRepository {
|
||||
try { return this.activityReader?.(session) ?? null; } catch { return null; }
|
||||
}
|
||||
|
||||
/** What a listing reads once for all its rows instead of once per row. includeWaiting: the rows' waiting views read
|
||||
* their qitem blockers' last meaningful transitions too. */
|
||||
private listingReads(rows: QueueItemRow[], includeWaiting: boolean): ListingReads {
|
||||
const ids = rows.map((r) => r.qitem_id);
|
||||
if (includeWaiting) for (const r of rows) if (r.blocked_on?.startsWith("qitem-")) ids.push(r.blocked_on);
|
||||
// Each owner's activity once per listing: a row and its waiting view (and every other row to that owner) read the
|
||||
// same seat state. ownerActivity reads a failure as null, so one session's failure stays that session's.
|
||||
const activities = new Map<string, ReturnType<WaitingActivityReader>>();
|
||||
const activity = (session: string): ReturnType<WaitingActivityReader> => {
|
||||
if (!activities.has(session)) activities.set(session, this.ownerActivity(session));
|
||||
return activities.get(session) ?? null;
|
||||
};
|
||||
return { meaningful: lastMeaningfulTransitionMany(this.db, ids), statements: new WaitingStatements(this.db), activity };
|
||||
}
|
||||
|
||||
/** listed: the row's owner notifications and ledger, when a listing read them for all its rows. */
|
||||
waitingView(qitemId: string, listed?: ListedLedger): WaitingView | null {
|
||||
const view = readWaitingView(this.db, qitemId, this.activityReader, target => this.acceptsDestination(target));
|
||||
// The row in hand, when the listing passed it: its later re-reads below are served by it too.
|
||||
const inHand = listed?.row && listed.row.qitem_id === qitemId ? listed.row : undefined;
|
||||
const reads = listed?.reads && inHand ? { ...listed.reads, row: inHand } : listed?.reads;
|
||||
const view = readWaitingView(this.db, qitemId, listed?.reads?.activity ?? this.activityReader, target => this.acceptsDestination(target), reads);
|
||||
if (view && ["pending", "in-progress"].includes(view.state)) {
|
||||
// #514 — the unclaimed sweep skips a pending ask already posted to its human in the current
|
||||
// episode (same predicate as runStuckSweep), so the view must not promise that sweep.
|
||||
@@ -5508,7 +5531,9 @@ export class QueueRepository {
|
||||
const posted = episodeDelivery !== null;
|
||||
const onSteering = !posted && view.state === "pending" && this.humanAskWithoutAlert(qitemId, listed);
|
||||
const row = view.state === "pending" && !onSteering
|
||||
? this.db.prepare(`SELECT state, ${this.hasHumanIntentColumn ? "human_intent" : "NULL AS human_intent"}, destination_session, last_nudge_result FROM queue_items WHERE qitem_id = ?`).get(qitemId) as { state: QueueItem["state"]; human_intent: QueueItem["humanIntent"]; destination_session: string; last_nudge_result: string | null } | undefined
|
||||
? inHand
|
||||
? { state: inHand.state as QueueItem["state"], human_intent: inHand.human_intent ?? null, destination_session: inHand.destination_session, last_nudge_result: inHand.last_nudge_result }
|
||||
: this.db.prepare(`SELECT state, ${this.hasHumanIntentColumn ? "human_intent" : "NULL AS human_intent"}, destination_session, last_nudge_result FROM queue_items WHERE qitem_id = ?`).get(qitemId) as { state: QueueItem["state"]; human_intent: QueueItem["humanIntent"]; destination_session: string; last_nudge_result: string | null } | undefined
|
||||
: undefined;
|
||||
const humanUpdate = !!row && this.isRegisteredHumanUpdate({ state: row.state, humanIntent: row.human_intent, destinationSession: row.destination_session });
|
||||
const noticeOutcome = !posted && !onSteering ? (listed ? listed.ledger?.outcome : this.deliveryOutcomeFor(qitemId)?.outcome) : undefined;
|
||||
@@ -5535,7 +5560,7 @@ export class QueueRepository {
|
||||
if (recovery && !localOnly) {
|
||||
if (!awaitingHuman && !humanUpdate) view.laterBackstop = { ...view.nextBackstop, note: "Conditional safety net; current delivery/recovery ownership is evaluated first." };
|
||||
view.nextBackstop = recovery;
|
||||
} else if (!awaitingHuman && view.state === "pending" && (this.db.prepare("SELECT last_nudge_result FROM queue_items WHERE qitem_id = ?")
|
||||
} else if (!awaitingHuman && view.state === "pending" && (inHand ?? this.db.prepare("SELECT last_nudge_result FROM queue_items WHERE qitem_id = ?")
|
||||
.get(qitemId) as { last_nudge_result: string | null } | undefined)?.last_nudge_result === "retained:typing_guard") {
|
||||
// Held at the seat on purpose: not stuck, and not picked up. Nothing replays it; reading or delivering it is a
|
||||
// deliberate step.
|
||||
@@ -5582,9 +5607,11 @@ export class QueueRepository {
|
||||
private rowToItem(row: QueueItemRow, includeWaiting = true, listed?: ListedLedger): QueueItem {
|
||||
// S04 — derive the pickup receipt at the ONE shared projection point (list/show/overdue
|
||||
// all flow through here), so the park-vs-strand question is answered by the row face.
|
||||
const meaningful = lastMeaningfulTransition(this.db, row.qitem_id);
|
||||
const waiting = includeWaiting ? this.waitingView(row.qitem_id, listed) : null;
|
||||
const activity = this.ownerActivity(row.destination_session);
|
||||
const meaningful = meaningfulFor(this.db, row.qitem_id, listed?.reads);
|
||||
// A listing's rows are its own SELECT *: the waiting view reads the row in hand instead of reading it again.
|
||||
const waiting = includeWaiting ? this.waitingView(row.qitem_id, listed?.reads ? { ...listed, row } : listed) : null;
|
||||
const listedActivity = listed?.reads?.activity;
|
||||
const activity = listedActivity ? listedActivity(row.destination_session) : this.ownerActivity(row.destination_session);
|
||||
const pickup = derivePickup({
|
||||
state: row.state,
|
||||
lastMeaningfulAt: meaningful?.at,
|
||||
@@ -5650,8 +5677,13 @@ type LedgerItem = Pick<QueueItem, "qitemId" | "state" | "blockedOn" | "destinati
|
||||
|
||||
type DeliveryLedger = { outcome: "posted" | "accepted" | "transport-failed" | "never-posted"; detail: string; episode: { notificationKey: string; startedAt: string } | null };
|
||||
|
||||
/** A listed row's owner notifications (newest first) and its delivery ledger, read once for the whole listing. */
|
||||
type ListedLedger = { owners: QueueTransition[]; ledger: DeliveryLedger | null };
|
||||
/** What a listing reads once for all its rows (see QueueRepository.listingReads): the waiting view's reads, and each
|
||||
* owner's activity, memoized for the listing. */
|
||||
type ListingReads = WaitingReads & { activity?: (session: string) => ReturnType<WaitingActivityReader> };
|
||||
|
||||
/** A listed row's owner notifications (newest first) and its delivery ledger, read once for the whole listing, and the
|
||||
* listing's shared reads. */
|
||||
type ListedLedger = { owners: QueueTransition[]; ledger: DeliveryLedger | null; reads?: ListingReads; row?: QueueItemRow };
|
||||
|
||||
function ledgerItem(row: LedgerRow): LedgerItem {
|
||||
return {
|
||||
|
||||
@@ -27,15 +27,83 @@ export function lastMeaningfulTransition(db: Database.Database, id: string): { i
|
||||
return row ? { id: row.transition_id, at: row.ts } : null;
|
||||
}
|
||||
|
||||
/** lastMeaningfulTransition for many rows in one read: the same LAG window, partitioned by row, and the same filters.
|
||||
* Every requested id is a key of the result; an id without a meaningful transition maps to null. */
|
||||
export function lastMeaningfulTransitionMany(db: Database.Database, ids: readonly string[]): Map<string, { id: number; at: string } | null> {
|
||||
const unique = [...new Set(ids)];
|
||||
const out = new Map<string, { id: number; at: string } | null>(unique.map((id) => [id, null]));
|
||||
if (unique.length === 0) return out;
|
||||
if (!db.prepare("SELECT 1 FROM sqlite_master WHERE name = 'queue_transitions'").get()) return out;
|
||||
const hasWakes = db.prepare("SELECT 1 FROM sqlite_master WHERE name = 'queue_transition_wakes'").get();
|
||||
const rows = db.prepare(`SELECT qitem_id, transition_id, ts FROM (
|
||||
SELECT t.qitem_id, t.transition_id, t.ts, ROW_NUMBER() OVER (PARTITION BY t.qitem_id ORDER BY t.transition_id DESC) AS newest FROM (
|
||||
SELECT *, LAG(state) OVER (PARTITION BY qitem_id ORDER BY transition_id) AS previous_state FROM queue_transitions
|
||||
WHERE qitem_id IN (SELECT value FROM json_each(?))
|
||||
) t WHERE (t.actor_session NOT IN ('watchdog@system', 'wake-ladder@system', 'daemon@kernel', 'daemon@system') OR t.state IS NOT t.previous_state)
|
||||
${hasWakes ? "AND NOT EXISTS (SELECT 1 FROM queue_transition_wakes w WHERE w.transition_id = t.transition_id AND w.phase = 'fired')" : ""}
|
||||
) WHERE newest = 1`).all(JSON.stringify(unique)) as Array<{ qitem_id: string; transition_id: number; ts: string }>;
|
||||
for (const row of rows) out.set(row.qitem_id, { id: row.transition_id, at: row.ts });
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The waiting view's statements, each prepared once, and the schema checks they depend on, each made once. A listing
|
||||
* holds one for all its rows; a single read uses a fresh one. A listing reads synchronously, so neither the statements
|
||||
* nor the schema checks can go stale under it. */
|
||||
export class WaitingStatements {
|
||||
private readonly prepared = new Map<string, Database.Statement>();
|
||||
private readonly tables = new Map<string, boolean>();
|
||||
constructor(private readonly db: Database.Database) {}
|
||||
|
||||
statement(sql: string): Database.Statement {
|
||||
let prepared = this.prepared.get(sql);
|
||||
if (!prepared) {
|
||||
prepared = this.db.prepare(sql);
|
||||
this.prepared.set(sql, prepared);
|
||||
}
|
||||
return prepared;
|
||||
}
|
||||
|
||||
hasTable(name: string): boolean {
|
||||
let known = this.tables.get(name);
|
||||
if (known === undefined) {
|
||||
known = this.statement("SELECT 1 FROM sqlite_master WHERE name = ?").get(name) !== undefined;
|
||||
this.tables.set(name, known);
|
||||
}
|
||||
return known;
|
||||
}
|
||||
}
|
||||
|
||||
/** The queue_items columns a waiting view reads from its own row. */
|
||||
export interface WaitingRow {
|
||||
qitem_id: string; source_session: string; destination_session: string; state: string; blocked_on: string | null; closure_required_at: string | null; tags: string | null;
|
||||
}
|
||||
|
||||
/** What a listing has already read for all its rows, lent to each row's waiting view. */
|
||||
export interface WaitingReads {
|
||||
/** The row in hand (a listing's own SELECT *), read in the same synchronous pass; used only when its id is the view's. */
|
||||
row?: WaitingRow;
|
||||
/** The listing's statements and schema checks, shared by all its rows. */
|
||||
statements?: WaitingStatements;
|
||||
/** lastMeaningfulTransitionMany over the listing's rows and their qitem blockers; an id it lacks is read alone. */
|
||||
meaningful?: ReadonlyMap<string, { id: number; at: string } | null>;
|
||||
}
|
||||
|
||||
/** One row's last meaningful transition: from the listing's batch when it read this id, else read alone. */
|
||||
export function meaningfulFor(db: Database.Database, id: string, reads?: WaitingReads): { id: number; at: string } | null {
|
||||
const known = reads?.meaningful;
|
||||
return known && known.has(id) ? known.get(id) ?? null : lastMeaningfulTransition(db, id);
|
||||
}
|
||||
|
||||
/** Grace starts when a row becomes actionable again, not at its birth before a
|
||||
* long healthy park. Same-state notes and deliveries cannot move this boundary. */
|
||||
export function pendingSince(db: Database.Database, id: string): string | null {
|
||||
if (!db.prepare("SELECT 1 FROM sqlite_master WHERE name = 'queue_transitions'").get()) return (db.prepare("SELECT ts_created FROM queue_items WHERE qitem_id = ?").get(id) as { ts_created: string } | undefined)?.ts_created ?? null;
|
||||
const row = db.prepare(`SELECT ts, previous_state FROM (SELECT ts, state, transition_id,
|
||||
export function pendingSince(db: Database.Database, id: string, statements = new WaitingStatements(db)): string | null {
|
||||
const created = () => (statements.statement("SELECT ts_created FROM queue_items WHERE qitem_id = ?").get(id) as { ts_created: string } | undefined)?.ts_created ?? null;
|
||||
if (!statements.hasTable("queue_transitions")) return created();
|
||||
const row = statements.statement(`SELECT ts, previous_state FROM (SELECT ts, state, transition_id,
|
||||
LAG(state) OVER (ORDER BY transition_id) AS previous_state FROM queue_transitions WHERE qitem_id = ?)
|
||||
WHERE state = 'pending' AND previous_state IS NOT 'pending' ORDER BY transition_id DESC LIMIT 1`)
|
||||
.get(id) as { ts: string; previous_state: string | null } | undefined;
|
||||
if (!row || row.previous_state === null) return (db.prepare("SELECT ts_created FROM queue_items WHERE qitem_id = ?").get(id) as { ts_created: string } | undefined)?.ts_created ?? null;
|
||||
if (!row || row.previous_state === null) return created();
|
||||
return row.ts;
|
||||
}
|
||||
function unclaimedSeconds(): number {
|
||||
@@ -67,13 +135,14 @@ export interface WaitingView {
|
||||
attention?: { scope: string; revision: string; source: "last observed by wait timer" };
|
||||
}
|
||||
|
||||
export function readWaitingView(db: Database.Database, id: string, readActivity?: WaitingActivityReader, acceptsDestination?: (destination: string) => boolean): WaitingView | null {
|
||||
const row = db.prepare(`SELECT qitem_id, source_session, destination_session, state, blocked_on, closure_required_at, tags
|
||||
FROM queue_items WHERE qitem_id = ?`).get(id) as {
|
||||
qitem_id: string; source_session: string; destination_session: string; state: string; blocked_on: string | null; closure_required_at: string | null; tags: string | null;
|
||||
} | undefined;
|
||||
export function readWaitingView(db: Database.Database, id: string, readActivity?: WaitingActivityReader, acceptsDestination?: (destination: string) => boolean,
|
||||
reads?: WaitingReads): WaitingView | null {
|
||||
const statements = reads?.statements ?? new WaitingStatements(db);
|
||||
const held = reads?.row;
|
||||
const row = held && held.qitem_id === id ? held : statements.statement(`SELECT qitem_id, source_session, destination_session, state, blocked_on, closure_required_at, tags
|
||||
FROM queue_items WHERE qitem_id = ?`).get(id) as WaitingRow | undefined;
|
||||
if (!row) return null;
|
||||
const blocker = row.blocked_on?.startsWith("qitem-") ? db.prepare(
|
||||
const blocker = row.blocked_on?.startsWith("qitem-") ? statements.statement(
|
||||
"SELECT destination_session, state FROM queue_items WHERE qitem_id = ?",
|
||||
).get(row.blocked_on) as { destination_session: string; state: string } | undefined : undefined;
|
||||
let recoveryOwner: string | null = null;
|
||||
@@ -82,10 +151,10 @@ export function readWaitingView(db: Database.Database, id: string, readActivity?
|
||||
const subject = row.blocked_on ? blocker?.destination_session ?? row.blocked_on : row.destination_session;
|
||||
let observed: ReturnType<WaitingActivityReader> = null;
|
||||
try { observed = readActivity?.(subject) ?? null; } catch { /* unavailable remains unknown */ }
|
||||
const meaningful = lastMeaningfulTransition(db, row.blocked_on?.startsWith("qitem-") ? row.blocked_on : id);
|
||||
const meaningful = meaningfulFor(db, row.blocked_on?.startsWith("qitem-") ? row.blocked_on : id, reads);
|
||||
const sweepInterval = sweepSeconds();
|
||||
const view: WaitingView = {
|
||||
obligation: id, owner: row.destination_session, state: row.state, actionableSince: row.state === "pending" ? pendingSince(db, id) : null,
|
||||
obligation: id, owner: row.destination_session, state: row.state, actionableSince: row.state === "pending" ? pendingSince(db, id, statements) : null,
|
||||
blocker: row.blocked_on ? { ref: row.blocked_on, owner: blocker?.destination_session ?? null, state: blocker?.state ?? null } : null,
|
||||
lastMeaningfulChange: meaningful,
|
||||
liveness: { subject, activity: observed?.activity ?? "unknown", needsInput: observed?.needsInput ?? { count: 0, reason: null }, confidence: observed && observed.activity !== "unknown" ? "oracle" : "unknown" },
|
||||
@@ -100,8 +169,8 @@ export function readWaitingView(db: Database.Database, id: string, readActivity?
|
||||
view.nextBackstop.mechanism = row.state === "pending" ? "queue-stuck-sweep:unclaimed" : "queue-stuck-sweep:pickup (activity checked at detection)";
|
||||
}
|
||||
if (row.state === "blocked") {
|
||||
const hasTimers = db.prepare("SELECT 1 FROM sqlite_master WHERE name = 'queue_transition_wakes'").get();
|
||||
const timer = hasTimers ? db.prepare(`SELECT j.job_id, j.policy, j.last_evaluation_at, j.registered_at, j.interval_seconds, j.scan_interval_seconds, j.spec_yaml
|
||||
const hasTimers = statements.hasTable("queue_transition_wakes");
|
||||
const timer = hasTimers ? statements.statement(`SELECT j.job_id, j.policy, j.last_evaluation_at, j.registered_at, j.interval_seconds, j.scan_interval_seconds, j.spec_yaml
|
||||
FROM queue_transition_wakes w JOIN watchdog_jobs j ON j.job_id = w.wake_ref
|
||||
WHERE w.qitem_id = ? AND w.phase = 'armed' AND j.state = 'active'
|
||||
ORDER BY w.transition_id DESC LIMIT 1`).get(id) as { job_id: string; policy: WatchdogJob["policy"]; last_evaluation_at: string | null; registered_at: string; interval_seconds: number; scan_interval_seconds: number | null; spec_yaml: string } | undefined : undefined;
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
// {type:"user"|"assistant", message:{role, content}} where content is a STRING or an array of
|
||||
// {type:"text"|"thinking"|"tool_use", text?} blocks (only "text" carries user-visible content).
|
||||
// Codex rollout lines use {payload:{type:"message", role, content}} — handled by the same reader.
|
||||
import { openSync, fstatSync, readSync, closeSync } from "node:fs";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
|
||||
export interface JsonlExchange {
|
||||
role: string;
|
||||
@@ -55,60 +55,34 @@ function toExchange(obj: unknown): JsonlExchange | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Read complete lines newest-first; join bytes before UTF-8 decoding, including long records. */
|
||||
function* readLinesBackwards(path: string): Generator<string> {
|
||||
const fd = openSync(path, "r");
|
||||
try {
|
||||
let position = fstatSync(fd).size;
|
||||
let fragments: Buffer[] = [];
|
||||
while (position > 0) {
|
||||
const chunk = Buffer.allocUnsafe(Math.min(64 * 1024, position));
|
||||
position -= chunk.length;
|
||||
let read = 0;
|
||||
while (read < chunk.length) {
|
||||
const count = readSync(fd, chunk, read, chunk.length - read, position + read);
|
||||
if (count === 0) throw new Error("transcript changed before the recap read completed");
|
||||
read += count;
|
||||
}
|
||||
let end = chunk.length;
|
||||
while (end > 0) {
|
||||
const newline = chunk.lastIndexOf(10, end - 1);
|
||||
if (newline === -1) break;
|
||||
const tail = chunk.subarray(newline + 1, end);
|
||||
yield (fragments.length ? Buffer.concat([tail, ...fragments.reverse()]) : tail).toString("utf8");
|
||||
fragments = [];
|
||||
end = newline;
|
||||
}
|
||||
if (end > 0) fragments.push(chunk.subarray(0, end));
|
||||
}
|
||||
if (fragments.length) yield Buffer.concat(fragments.reverse()).toString("utf8");
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the LAST `n` visible exchanges without reading the whole provider session transcript.
|
||||
* Memory follows the selected exchanges and longest visited line, not the whole file.
|
||||
* Honest-degraded: any read/decoding failure returns []; never return a partial recap.
|
||||
* Unparseable/metadata/text-less lines are skipped, as before.
|
||||
* Parse a provider session JSONL at `path` into the LAST `n` {role, content} exchanges (bounded recap).
|
||||
* Honest-degraded: missing/unreadable file → []; unparseable/metadata/text-less lines are skipped.
|
||||
*/
|
||||
export function parseJsonlExchanges(path: string, n: number): JsonlExchange[] {
|
||||
if (n <= 0) return [];
|
||||
const exchanges: JsonlExchange[] = [];
|
||||
if (n <= 0 || !existsSync(path)) return [];
|
||||
let raw: string;
|
||||
try {
|
||||
for (const line of readLinesBackwards(path)) {
|
||||
let obj: unknown;
|
||||
try {
|
||||
obj = JSON.parse(line);
|
||||
} catch {
|
||||
continue; // corrupt/blank line — skip, never throw
|
||||
}
|
||||
const ex = toExchange(obj);
|
||||
if (ex && exchanges.push(ex) >= n) break;
|
||||
}
|
||||
raw = readFileSync(path, "utf8");
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const exchanges: JsonlExchange[] = [];
|
||||
// Only decode the tail needed for the recap, not every exchange in a long session.
|
||||
let end = raw.length;
|
||||
while (end > 0) {
|
||||
const start = raw.lastIndexOf("\n", end - 1) + 1;
|
||||
const line = raw.slice(start, end);
|
||||
end = start - 1;
|
||||
if (line.trim().length === 0) continue;
|
||||
let obj: unknown;
|
||||
try {
|
||||
obj = JSON.parse(line);
|
||||
} catch {
|
||||
continue; // corrupt line — skip, never throw
|
||||
}
|
||||
const ex = toExchange(obj);
|
||||
if (ex && exchanges.push(ex) >= n) break;
|
||||
}
|
||||
return exchanges.reverse();
|
||||
}
|
||||
@@ -950,7 +950,7 @@ activityRoutes.get("/parked", (c) => {
|
||||
| undefined;
|
||||
const queueRepo = c.get("queueRepo" as never) as
|
||||
| {
|
||||
list: (opts: { destinationSession?: string; state?: string[]; limit?: number }) => Array<{ qitemId: string; state: string; summary?: string | null }>;
|
||||
listOpenObligations: (destinationSession: string, limit: number) => Array<{ qitemId: string; state: "pending" | "in-progress" | "blocked"; summary: string | null }>;
|
||||
getParkWakeStatus: (qitemId: string) => import("../domain/queue-wake-repository.js").ParkWakeStatus | null;
|
||||
parkWakeServedByOwnReminder?: (qitemId: string) => boolean;
|
||||
}
|
||||
@@ -967,10 +967,10 @@ activityRoutes.get("/parked", (c) => {
|
||||
const { diagnoseSeatParked, diagnoseRigParked, PARKED_OBLIGATION_LIMIT } = parkedQuery;
|
||||
const deps = {
|
||||
getSeatState: (id: string) => nodeExecutorConfirmedStopped(rigRepo.db, id) ? null : oracle.getSeatState(id),
|
||||
// The obligation face only: the same rows, order and limit as a full list() of this destination's open states,
|
||||
// without the per-row projections (pickup, waiting view, delivery ledger) the diagnosis never reads.
|
||||
listOpenObligations: (destination: string, limit: number) => ({
|
||||
rows: queueRepo
|
||||
.list({ destinationSession: destination, state: ["pending", "in-progress", "blocked"], limit })
|
||||
.map((r) => ({ qitemId: r.qitemId, state: r.state as "pending" | "in-progress" | "blocked", summary: r.summary ?? null })),
|
||||
rows: queueRepo.listOpenObligations(destination, limit),
|
||||
limit,
|
||||
}),
|
||||
getParkWake: (qitemId: string) => queueRepo.getParkWakeStatus(qitemId),
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
// The parked route reads its obligation face with QueueRepository.listOpenObligations (qitem id, state and summary)
|
||||
// instead of a full list() that derives pickup, waiting view and delivery ledger for every row it never shows.
|
||||
// The response must be the same, byte for byte, as the one the full list() produced. Real queue, rig, session and
|
||||
// identity repositories on a full test DB; the only difference between the two apps is the obligation reader.
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import type Database from "better-sqlite3";
|
||||
import { Hono } from "hono";
|
||||
import { createFullTestDb } from "./helpers/test-app.js";
|
||||
import { activityRoutes } from "../src/routes/activity.js";
|
||||
import { RigRepository } from "../src/domain/rig-repository.js";
|
||||
import { SessionRegistry } from "../src/domain/session-registry.js";
|
||||
import { SeatIdentityStore } from "../src/domain/seat-identity-store.js";
|
||||
import { SeatActivityService } from "../src/domain/seat-activity-service.js";
|
||||
import { EventBus } from "../src/domain/event-bus.js";
|
||||
import { QueueRepository } from "../src/domain/queue-repository.js";
|
||||
import { WatchdogJobsRepository } from "../src/domain/watchdog-jobs-repository.js";
|
||||
import { WatchdogHistoryLog } from "../src/domain/watchdog-history-log.js";
|
||||
import { watchdogHistorySchema } from "../src/db/migrations/032_watchdog_history.js";
|
||||
import { queueItemSummarySchema } from "../src/db/migrations/044_queue_item_summary.js";
|
||||
|
||||
const RIG = "park-rig";
|
||||
const IDLE = "dev-impl@park-rig"; // idle at its prompt: parked whenever it owes work
|
||||
const UNKNOWN = "dev-qa@park-rig"; // no activity evidence: indeterminate
|
||||
const STOPPED = "dev-ops@park-rig"; // idle evidence, but its session is confirmed missing
|
||||
const ALIAS = "dev-old@park-rig"; // an earlier address of IDLE, resolved by resolveSessionAddresses
|
||||
|
||||
describe("GET /api/activity/parked reads the obligation face without the full list()", () => {
|
||||
let db: Database.Database;
|
||||
let queue: QueueRepository;
|
||||
let jobs: WatchdogJobsRepository;
|
||||
let history: WatchdogHistoryLog;
|
||||
let oracle: SeatActivityService;
|
||||
let clock = 0;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createFullTestDb();
|
||||
db.exec(watchdogHistorySchema.sql); // the full test DB doesn't include the engine's audit table
|
||||
db.exec(queueItemSummarySchema.sql); // nor the summary column, which the obligation face carries
|
||||
const rigRepo = new RigRepository(db);
|
||||
const sessions = new SessionRegistry(db);
|
||||
const rig = rigRepo.createRig(RIG);
|
||||
rigRepo.createRig("empty-rig");
|
||||
const now = Date.parse("2026-11-07T09:00:00.000Z");
|
||||
oracle = new SeatActivityService({ tmux: { readPaneLastActivity: async () => null }, defaultWindowSeconds: 3, now: () => new Date(now) });
|
||||
for (const [logicalId, session] of [["dev.impl", IDLE], ["dev.qa", UNKNOWN], ["dev.ops", STOPPED]] as const) {
|
||||
const node = rigRepo.addNode(rig.id, logicalId, { runtime: "claude-code" });
|
||||
const registered = sessions.registerSession(node.id, session);
|
||||
sessions.updateStatus(registered.id, "running");
|
||||
if (session === UNKNOWN) continue;
|
||||
oracle.declareRungInventory({ seatNodeId: node.id, sessionName: session }, {
|
||||
adapterId: "claude-code-adapter", runtime: "claude-code",
|
||||
rungs: [{ rung: "window-sampling", lifecycleCoverage: "full", initialTrust: "authoritative" }],
|
||||
});
|
||||
oracle.reportEvidence({ seatNodeId: node.id, sessionName: session, rung: "window-sampling",
|
||||
sourceId: "tmux:window-activity", seq: 1, observedAt: new Date(now).toISOString(), activity: "idle-at-prompt" });
|
||||
if (session === STOPPED) {
|
||||
sessions.updateBinding(node.id, { tmuxSession: session, tmuxPane: "%7", attachmentType: "tmux" } as never);
|
||||
new SeatIdentityStore(db).upsert({
|
||||
nodeId: node.id, sessionName: session, verdict: "pane_missing", reason: "session_missing", evidenceSource: "tmux_session",
|
||||
evidence: { registeredPane: "%7", observedPid: null, observedCommand: null, matchedLayer: null },
|
||||
observedAt: new Date(Date.now() + 1000).toISOString(),
|
||||
} as never);
|
||||
}
|
||||
}
|
||||
const gen = (session: string) => sessions.currentOccupantGenerationForSession(session);
|
||||
queue = new QueueRepository(db, new EventBus(db), {
|
||||
validateRig: () => true,
|
||||
resolveOccupantGeneration: gen,
|
||||
resolveSessionAddresses: (name: string) => (name === IDLE ? [IDLE, ALIAS] : [name]),
|
||||
} as never);
|
||||
jobs = new WatchdogJobsRepository(db, undefined, gen);
|
||||
queue.attachWatchdogJobsRepository(jobs);
|
||||
history = new WatchdogHistoryLog(db);
|
||||
clock = 0;
|
||||
});
|
||||
afterEach(() => db.close());
|
||||
|
||||
function app(queueRepo: unknown): Hono {
|
||||
const a = new Hono();
|
||||
a.use("*", async (c, next) => {
|
||||
c.set("seatActivityService" as never, oracle as never);
|
||||
c.set("queueRepo" as never, queueRepo as never);
|
||||
c.set("rigRepo" as never, { db } as never);
|
||||
await next();
|
||||
});
|
||||
a.route("/api/activity", activityRoutes);
|
||||
return a;
|
||||
}
|
||||
|
||||
/** The obligation reader the route used before: the full list() of the destination's open states, mapped down. */
|
||||
function fullListFace() {
|
||||
return {
|
||||
listOpenObligations: (destinationSession: string, limit: number) =>
|
||||
queue.list({ destinationSession, state: ["pending", "in-progress", "blocked"], limit })
|
||||
.map((r) => ({ qitemId: r.qitemId, state: r.state, summary: r.summary ?? null })),
|
||||
getParkWakeStatus: (qitemId: string) => queue.getParkWakeStatus(qitemId),
|
||||
parkWakeServedByOwnReminder: (qitemId: string) => queue.parkWakeServedByOwnReminder(qitemId),
|
||||
};
|
||||
}
|
||||
|
||||
/** Both responses, as bytes; they must be equal. Returns the parsed body for the case's own assertions. */
|
||||
async function sameResponse(path: string): Promise<{ status: number; body: any }> {
|
||||
const lean = await app(queue).request(path);
|
||||
const full = await app(fullListFace()).request(path);
|
||||
const leanText = await lean.text();
|
||||
expect(lean.status).toBe(full.status);
|
||||
expect(leanText).toBe(await full.text());
|
||||
return { status: lean.status, body: JSON.parse(leanText) };
|
||||
}
|
||||
|
||||
async function row(destinationSession = IDLE, summary = "owed"): Promise<string> {
|
||||
return (await queue.create({ sourceSession: "orch-lead@park-rig", destinationSession, body: "work", summary, nudge: false } as never)).qitemId;
|
||||
}
|
||||
function reminder(target = IDLE): string {
|
||||
return jobs.register({
|
||||
policy: "periodic-reminder",
|
||||
specYaml: ["policy: periodic-reminder", "target:", ` session: ${JSON.stringify(target)}`, "message: \"check your rows\"", ""].join("\n"),
|
||||
targetSession: target, intervalSeconds: 300, registeredBySession: target,
|
||||
} as never).jobId;
|
||||
}
|
||||
function park(qitemId: string, watchdogId: string) {
|
||||
queue.update({ qitemId, actorSession: IDLE, state: "blocked", blockedOn: "external:ci", transitionNote: "continuation: check CI",
|
||||
wakeWatchdogId: watchdogId } as never);
|
||||
}
|
||||
function fire(jobId: string) {
|
||||
const evaluatedAt = new Date(Date.UTC(2026, 10, 7, 3, 0, clock++)).toISOString();
|
||||
history.record({ jobId, evaluatedAt, outcome: "sent", deliveryStatus: "ok", deliveryTargetSession: IDLE } as never);
|
||||
queue.recordWatchdogWakeAttempt(jobId, "ok");
|
||||
}
|
||||
|
||||
it("0 rows: the rig and the seat read the same, and the board is clean", async () => {
|
||||
const rig = await sameResponse(`/api/activity/parked?rig=${RIG}`);
|
||||
expect(rig.status).toBe(200);
|
||||
expect(rig.body.rig.seats.find((s: { sessionName: string }) => s.sessionName === IDLE).obligations.openCount).toBe(0);
|
||||
const seat = await sameResponse(`/api/activity/parked?seat=${encodeURIComponent(IDLE)}`);
|
||||
expect(seat.body.seat).toMatchObject({ parked: false, confidence: { obligations: "complete" } });
|
||||
});
|
||||
|
||||
it("1 row: the same parked verdict, item and summary", async () => {
|
||||
const q = await row(IDLE, "the one owed row");
|
||||
const { body } = await sameResponse(`/api/activity/parked?seat=${encodeURIComponent(IDLE)}`);
|
||||
expect(body.seat.parked).toBe(true);
|
||||
expect(body.seat.obligations.items).toEqual([{ qitemId: q, state: "pending", summary: "the one owed row" }]);
|
||||
});
|
||||
|
||||
it("more than the limit (500): the same 500 rows, newest first, and truncation-possible; ties on ts_created included", async () => {
|
||||
const insert = db.prepare(`INSERT INTO queue_items (qitem_id, ts_created, ts_updated, source_session, destination_session, state, body, summary)
|
||||
VALUES (?, ?, ?, 'orch-lead@park-rig', ?, ?, 'work', ?)`);
|
||||
const states = ["pending", "in-progress", "blocked"];
|
||||
db.transaction(() => {
|
||||
for (let i = 0; i < 510; i++) {
|
||||
// Two rows per millisecond, so the ORDER BY ts_created DESC window has ties at its edge.
|
||||
const ts = new Date(Date.UTC(2026, 10, 1, 0, 0, 0, Math.floor(i / 2))).toISOString();
|
||||
insert.run(`qitem-bulk-${String(i).padStart(4, "0")}`, ts, ts, i % 7 === 0 ? ALIAS : IDLE, states[i % 3], i % 5 === 0 ? null : `row ${i}`);
|
||||
}
|
||||
})();
|
||||
const { body } = await sameResponse(`/api/activity/parked?seat=${encodeURIComponent(IDLE)}`);
|
||||
expect(body.seat.obligations.openCount + body.seat.obligations.heldCount).toBe(500);
|
||||
expect(body.seat.confidence.obligations).toBe("truncation-possible");
|
||||
expect(body.seat.obligations.complete).toBe(false);
|
||||
});
|
||||
|
||||
it("a held row with a live wake reads healthy on both", async () => {
|
||||
const q = await row();
|
||||
park(q, reminder());
|
||||
const { body } = await sameResponse(`/api/activity/parked?seat=${encodeURIComponent(IDLE)}`);
|
||||
expect(body.seat.obligations.held[0]).toMatchObject({ qitemId: q, healthy: true, wake: { live: true } });
|
||||
});
|
||||
|
||||
it("a held row served by its owner's own reminder reads the same", async () => {
|
||||
const q = await row();
|
||||
const job = reminder();
|
||||
park(q, job);
|
||||
fire(job);
|
||||
const { body } = await sameResponse(`/api/activity/parked?seat=${encodeURIComponent(IDLE)}`);
|
||||
expect(body.seat.obligations.held[0]).toMatchObject({ qitemId: q, healthy: true, servedByOwnReminder: true });
|
||||
});
|
||||
|
||||
it("an indeterminate seat (no activity evidence) reads the same", async () => {
|
||||
await row(UNKNOWN);
|
||||
const { body } = await sameResponse(`/api/activity/parked?rig=${RIG}`);
|
||||
expect(body.rig.seats.find((s: { sessionName: string }) => s.sessionName === UNKNOWN).parked).toBe("indeterminate");
|
||||
});
|
||||
|
||||
it("a seat confirmed stopped reads the same (indeterminate despite its idle evidence)", async () => {
|
||||
await row(STOPPED);
|
||||
const { body } = await sameResponse(`/api/activity/parked?seat=${encodeURIComponent(STOPPED)}`);
|
||||
expect(body.seat.parked).toBe("indeterminate");
|
||||
});
|
||||
|
||||
it("a rig without seats reads the same", async () => {
|
||||
const { status, body } = await sameResponse("/api/activity/parked?rig=empty-rig");
|
||||
expect(status).toBe(200);
|
||||
expect(body.rig).toMatchObject({ parked: false, seats: [] });
|
||||
});
|
||||
|
||||
it("rows addressed to an alias of the seat are found the same way (resolveSessionAddresses)", async () => {
|
||||
const q = await row(ALIAS, "sent to the old address");
|
||||
const { body } = await sameResponse(`/api/activity/parked?seat=${encodeURIComponent(IDLE)}`);
|
||||
expect(body.seat.obligations.items).toEqual([{ qitemId: q, state: "pending", summary: "sent to the old address" }]);
|
||||
});
|
||||
});
|
||||
@@ -51,8 +51,8 @@ describe("Wave-O B2 — parked diagnosis is RIG-SCOPED with the scope named", ()
|
||||
|
||||
// The obligation face: ONLY dev-b@rig-b owes work.
|
||||
const queueRepo = {
|
||||
list: (opts: { destinationSession?: string }) =>
|
||||
opts.destinationSession === "dev-b@rig-b"
|
||||
listOpenObligations: (destinationSession: string) =>
|
||||
destinationSession === "dev-b@rig-b"
|
||||
? [{ qitemId: "qitem-b-1", state: "pending", summary: "owed in rig-b" }]
|
||||
: [],
|
||||
};
|
||||
|
||||
@@ -30,7 +30,7 @@ function makeApp(opts: { withDeps: boolean; rows?: Array<{ qitemId: string; stat
|
||||
app.use("*", async (c, next) => {
|
||||
if (opts.withDeps) {
|
||||
c.set("seatActivityService" as never, svc as never);
|
||||
c.set("queueRepo" as never, { list: () => opts.rows ?? [] } as never);
|
||||
c.set("queueRepo" as never, { listOpenObligations: () => opts.rows ?? [] } as never);
|
||||
c.set("rigRepo" as never, { db: { prepare: (sql: string) => ({
|
||||
get: () => ({ id: "rig-1", name: "v-openrig-build" }),
|
||||
all: () => (sql.includes("SELECT name FROM rigs") ? [] : [{ node_id: SEAT, session_name: SESSION }]),
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { mkdirSync, writeFileSync, readFileSync, existsSync, rmSync, mkdtempSync, copyFileSync, chmodSync, statSync, readdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { execSync, spawn, spawnSync } from "node:child_process";
|
||||
import { execFileSync, spawn, spawnSync } from "node:child_process";
|
||||
import { ClaudeCodeAdapter } from "../src/adapters/claude-code-adapter.js";
|
||||
import { shellQuote } from "../src/adapters/shell-quote.js";
|
||||
import { replaceFile } from "../src/adapters/replace-file.js";
|
||||
@@ -100,7 +100,7 @@ describe("Claude Status Line Collector Script", () => {
|
||||
// T1: Parses valid status line and writes sidecar
|
||||
it("parses valid Claude status line JSON and writes sidecar file", () => {
|
||||
const outputPath = join(tmpDir, "context", "test.json");
|
||||
execSync(`echo '${VALID_STATUS_LINE}' | node ${collectorPath} ${outputPath}`, { encoding: "utf-8" });
|
||||
execFileSync(process.execPath, [collectorPath, outputPath], { input: VALID_STATUS_LINE, encoding: "utf-8" });
|
||||
|
||||
expect(existsSync(outputPath)).toBe(true);
|
||||
const content = JSON.parse(readFileSync(outputPath, "utf-8"));
|
||||
@@ -128,7 +128,7 @@ describe("Claude Status Line Collector Script", () => {
|
||||
// T2: Atomic write (no .tmp left behind on success)
|
||||
it("writes atomically — no .tmp file left behind", () => {
|
||||
const outputPath = join(tmpDir, "context", "atomic.json");
|
||||
execSync(`echo '${VALID_STATUS_LINE}' | node ${collectorPath} ${outputPath}`, { encoding: "utf-8" });
|
||||
execFileSync(process.execPath, [collectorPath, outputPath], { input: VALID_STATUS_LINE, encoding: "utf-8" });
|
||||
|
||||
expect(existsSync(outputPath)).toBe(true);
|
||||
expect(existsSync(outputPath + ".tmp")).toBe(false);
|
||||
@@ -165,7 +165,7 @@ describe("Claude Status Line Collector Script", () => {
|
||||
|
||||
it("writes to a session-specific sidecar when given a context directory", () => {
|
||||
const outputDir = join(tmpDir, "context");
|
||||
execSync(`echo '${VALID_STATUS_LINE}' | node ${collectorPath} ${outputDir}`, { encoding: "utf-8" });
|
||||
execFileSync(process.execPath, [collectorPath, outputDir], { input: VALID_STATUS_LINE, encoding: "utf-8" });
|
||||
|
||||
const sessionSidecar = join(outputDir, "[email protected]");
|
||||
expect(existsSync(sessionSidecar)).toBe(true);
|
||||
@@ -236,7 +236,7 @@ describe("Claude Status Line Collector Script", () => {
|
||||
// T3b: No output path arg — silent exit
|
||||
it("silently exits with no output path argument", () => {
|
||||
// Should not throw
|
||||
execSync(`echo '${VALID_STATUS_LINE}' | node ${collectorPath}`, { encoding: "utf-8" });
|
||||
execFileSync(process.execPath, [collectorPath], { input: VALID_STATUS_LINE, encoding: "utf-8" });
|
||||
});
|
||||
|
||||
it("logs a shape error when a directory target cannot be resolved to a session sidecar", () => {
|
||||
|
||||
@@ -30,7 +30,7 @@ it("keeps a visibly running Codex turn working when the window timestamp is quie
|
||||
const app = new Hono();
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("seatActivityService" as never, oracle as never);
|
||||
c.set("queueRepo" as never, { list: () => [{ qitemId: "work", state: "in-progress", summary: "Work" }] } as never);
|
||||
c.set("queueRepo" as never, { listOpenObligations: () => [{ qitemId: "work", state: "in-progress", summary: "Work" }] } as never);
|
||||
c.set("rigRepo" as never, { db: { prepare: (sql: string) => ({
|
||||
get: () => ({ id: "rig-1", name: "fixture" }),
|
||||
all: () => sql.includes("SELECT name FROM rigs") ? [] : [{ node_id: node, session_name: session }],
|
||||
|
||||
@@ -4,6 +4,7 @@ import { cpSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } f
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { prepareHermeticEnv, type HermeticScaffold } from "./helpers/hermetic-env.js";
|
||||
import { shellQuote } from "../src/adapters/shell-quote.js";
|
||||
import { runRig, spawnScenarioDaemon, type ScenarioDaemon } from "./helpers/scenario-daemon.js";
|
||||
|
||||
// One community harness end to end, through the real CLI, daemon, SQLite and a private tmux server: a synthetic
|
||||
@@ -11,7 +12,7 @@ import { runRig, spawnScenarioDaemon, type ScenarioDaemon } from "./helpers/scen
|
||||
// preset and a built-in name collision. Nothing here runs a real community harness or touches a real home.
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const rigBin = resolve(here, "../../cli/dist/bin-wrapper.js");
|
||||
const appScript = resolve(here, "fixtures/community-harness/synth-harness.mjs");
|
||||
const appFixture = resolve(here, "fixtures/community-harness/synth-harness.mjs");
|
||||
const agents = resolve(here, "../../test-system/scenarios/agents");
|
||||
const session = "work-worker@community-harness-rig";
|
||||
const sleep = (ms: number) => new Promise(r => setTimeout(r, ms));
|
||||
@@ -20,6 +21,7 @@ describe("a community harness from a data preset", () => {
|
||||
let scaffold: HermeticScaffold;
|
||||
let daemon: ScenarioDaemon;
|
||||
let rigRoot: string;
|
||||
let appScript: string;
|
||||
let rigId: string;
|
||||
|
||||
const rig = (args: string[], timeout?: number) => runRig(args, daemon.readEnv, rigBin, timeout);
|
||||
@@ -51,9 +53,11 @@ describe("a community harness from a data preset", () => {
|
||||
scaffold = prepareHermeticEnv({ baseEnv: { PATH: process.env.PATH, TERM: "xterm-256color" } });
|
||||
rigRoot = join(scaffold.root, "rig");
|
||||
mkdirSync(rigRoot);
|
||||
appScript = join(rigRoot, "synth #harness.mjs");
|
||||
cpSync(appFixture, appScript);
|
||||
cpSync(agents, join(rigRoot, "agents"), { recursive: true });
|
||||
writePreset("synth-harness.yaml", [
|
||||
"format: 1", "name: synth-harness", `command: node ${appScript}`, "maintainer: Example Maintainer",
|
||||
"format: 1", "name: synth-harness", `command: ${JSON.stringify(`node ${shellQuote(appScript)}`)}`, "maintainer: Example Maintainer",
|
||||
"limits:", " - resume unsupported", " - fork unsupported", " - no approval-prompt detection", "",
|
||||
].join("\n"));
|
||||
const previousDir = join(scaffold.env.OPENRIG_HOME!, ["agent", "apps"].join("-"));
|
||||
@@ -84,7 +88,7 @@ exec "$@"
|
||||
}
|
||||
|
||||
it("prefixes a runner-less preset with resolved literal wrapper argv", async () => {
|
||||
writePreset("wrapped-plain.yaml", `format: 1\nname: wrapped-plain\ncommand: node ${appScript}\nmaintainer: Example\n`);
|
||||
writePreset("wrapped-plain.yaml", `format: 1\nname: wrapped-plain\ncommand: ${JSON.stringify(`node ${shellQuote(appScript)}`)}\nmaintainer: Example\n`);
|
||||
const wrapper = recordingWrapper("plain");
|
||||
const up = await rig(["up", specFor("wrapped-plain-rig", "wrapped-plain", wrapper.argv), "--cwd", rigRoot, "--json", "--yes"], 120_000);
|
||||
expect(up.code, JSON.stringify(up)).toBe(0);
|
||||
@@ -95,7 +99,7 @@ exec "$@"
|
||||
it("keeps a capable runner outside the wrapper and wraps its real native child", async () => {
|
||||
const runner = resolve(here, "fixtures/runner-conformance/minimal-acp-runner.mjs");
|
||||
const agent = resolve(here, "../../../docs/reference/runner-conformance/fake-acp-agent.mjs");
|
||||
writePreset("wrapped-runner.yaml", `format: 1\nname: wrapped-runner\ncommand: node ${runner} --agent 'node ${agent}'\nmaintainer: Example\nrunner: true\nlaunch_result: true\nlaunch_wrapper: true\n`);
|
||||
writePreset("wrapped-runner.yaml", `format: 1\nname: wrapped-runner\ncommand: ${JSON.stringify(`node ${shellQuote(runner)} --agent ${shellQuote(`node ${shellQuote(agent)}`)}`)}\nmaintainer: Example\nrunner: true\nlaunch_result: true\nlaunch_wrapper: true\n`);
|
||||
const wrapper = recordingWrapper("runner");
|
||||
const up = await rig(["up", specFor("wrapped-runner-rig", "wrapped-runner", wrapper.argv), "--cwd", rigRoot, "--json", "--yes"], 120_000);
|
||||
expect(up.code, JSON.stringify(up)).toBe(0);
|
||||
@@ -112,7 +116,7 @@ exec "$@"
|
||||
const marker = join(rigRoot, "incapable-started");
|
||||
const launcher = join(rigRoot, "incapable.mjs");
|
||||
writeFileSync(launcher, `import { writeFileSync } from 'node:fs'; writeFileSync(${JSON.stringify(marker)}, 'started');`);
|
||||
writePreset("incapable-runner.yaml", `format: 1\nname: incapable-runner\ncommand: node ${launcher}\nmaintainer: Example\nrunner: true\nlaunch_result: true\n${capability}`);
|
||||
writePreset("incapable-runner.yaml", `format: 1\nname: incapable-runner\ncommand: ${JSON.stringify(`node ${shellQuote(launcher)}`)}\nmaintainer: Example\nrunner: true\nlaunch_result: true\n${capability}`);
|
||||
const wrapper = recordingWrapper("incapable");
|
||||
const up = await rig(["up", specFor("incapable-runner-rig", "incapable-runner", wrapper.argv), "--cwd", rigRoot, "--json", "--yes"], 120_000);
|
||||
expect(up.code, JSON.stringify(up)).not.toBe(0);
|
||||
@@ -148,7 +152,7 @@ exec "$@"
|
||||
});
|
||||
|
||||
it("hands the composed first turn to a real launcher without typing it into its pane", async () => {
|
||||
writePreset("synth-first.yaml", `format: 1\nname: synth-first\ncommand: node ${appScript}\nmaintainer: Example\nfirst_input: true\n`);
|
||||
writePreset("synth-first.yaml", `format: 1\nname: synth-first\ncommand: ${JSON.stringify(`node ${shellQuote(appScript)}`)}\nmaintainer: Example\nfirst_input: true\n`);
|
||||
const up = await rig(["up", specFor("first-input-rig", "synth-first"), "--cwd", rigRoot, "--json", "--yes"], 120_000);
|
||||
expect(up.code, JSON.stringify(up)).toBe(0);
|
||||
const inputDir = join(scaffold.env.OPENRIG_HOME!, "state", "first-input", "work-worker%40first-input-rig");
|
||||
@@ -322,7 +326,7 @@ exec "$@"
|
||||
expect(seat).not.toHaveProperty("communityHarness");
|
||||
};
|
||||
await check();
|
||||
writePreset("free-harness.yaml", ["format: 1", "name: free-harness", `command: node ${appScript}`, "maintainer: Example Maintainer", ""].join("\n"));
|
||||
writePreset("free-harness.yaml", ["format: 1", "name: free-harness", `command: ${JSON.stringify(`node ${shellQuote(appScript)}`)}`, "maintainer: Example Maintainer", ""].join("\n"));
|
||||
await sleep(4_000);
|
||||
await check();
|
||||
// A relaunch doesn't start the same-named app in its place.
|
||||
|
||||
@@ -254,7 +254,6 @@ describe("validateMarkdownAddressability — the compose gate", () => {
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("Markdown block boundaries in context addresses", () => {
|
||||
it("keeps inline backtick spans from swallowing later sections", () => {
|
||||
const text = "## Setup\n```literal ` backticks```\n## Deploy\ndeploy instructions\n";
|
||||
@@ -280,3 +279,25 @@ describe("Markdown block boundaries in context addresses", () => {
|
||||
expect(resolveAddress(text.replace("\n#\n", "\n# Named\n"), ["independent"]).text).toContain("independent instructions");
|
||||
});
|
||||
});
|
||||
|
||||
describe("section boundaries in large documents", () => {
|
||||
it("keeps sibling spans independent", () => {
|
||||
const blocks = Array.from({ length: 10000 }, (_, i) => `## Section ${i}\nbody ${i}\n`);
|
||||
const sections = parseMarkdownSections(blocks.join(""));
|
||||
expect(sections).toHaveLength(blocks.length);
|
||||
sections.forEach((section, i) => {
|
||||
const expected = i === blocks.length - 1 ? blocks[i] : blocks[i]!.trimEnd();
|
||||
expect(section.text).toBe(expected);
|
||||
expect(section.ownText).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
it("ends nested spans at the nearest same-or-higher heading, including H1", () => {
|
||||
const text = "## Parent\nintro\n### Child\nchild\n#### Detail\ndetail\n### Sibling\nsibling\n# Boundary\nend";
|
||||
expect(parseMarkdownSections(text).map(({ title, text, ownText }) => ({ title, text, ownText }))).toEqual([
|
||||
{ title: "Parent", text: "## Parent\nintro\n### Child\nchild\n#### Detail\ndetail\n### Sibling\nsibling", ownText: "## Parent\nintro" },
|
||||
{ title: "Child", text: "### Child\nchild\n#### Detail\ndetail", ownText: "### Child\nchild" },
|
||||
{ title: "Sibling", text: "### Sibling\nsibling", ownText: "### Sibling\nsibling" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -81,6 +81,18 @@ describe("attributed proof judgments and derived readiness", () => {
|
||||
expect(files.map(p => fs.readFileSync(p, "utf8"))).toEqual(before);
|
||||
expect(fs.readFileSync(join(f.alpha, "proof/judgments/00000001.md"), "utf8")).toContain(a.judgment.id);
|
||||
});
|
||||
it("retains judgment identity and correction history after CRLF conversion", () => {
|
||||
const f = fixture(), accepted = f.judge();
|
||||
const receipt = join(f.alpha, "proof/judgments/00000001.md");
|
||||
f.write(receipt, fs.readFileSync(receipt, "utf8").replace(/\n/g, "\r\n"));
|
||||
const current = readSliceReadiness(f.alpha);
|
||||
expect(current).toEqual(accepted.readiness);
|
||||
const correction = f.judge(f.input(undefined, "reject"));
|
||||
expect(correction.judgment.previous).toBe(accepted.judgment.id);
|
||||
expect(correction.readiness.items[0]!.state).toBe("rejected");
|
||||
f.write(receipt, fs.readFileSync(receipt, "utf8").replace("verdict: accept", "verdict: reject"));
|
||||
expect(readSliceReadiness(f.alpha).issues.join()).toContain("changed judgment");
|
||||
});
|
||||
it("rejects unauthorized actors, wrong revisions and missing evidence without a receipt", () => {
|
||||
const f = fixture();
|
||||
expect(() => f.judge(f.input(), "other@trial")).toThrow("not a judge");
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
// A listing reads what every row needs once for all its rows instead of once per row: the last meaningful transitions
|
||||
// in one windowed query, and (with it) the waiting view's statements, the owners' activity and the row itself. Each
|
||||
// batched read must answer exactly what the per-row read answers, and a listing must project the same items.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import Database from "better-sqlite3";
|
||||
import { migrate } from "../src/db/migrate.js";
|
||||
import { ALL_MIGRATIONS } from "../src/db/all-migrations.js";
|
||||
import { queueItemsSchema } from "../src/db/migrations/024_queue_items.js";
|
||||
import { queueTransitionsSchema } from "../src/db/migrations/025_queue_transitions.js";
|
||||
import { EventBus } from "../src/domain/event-bus.js";
|
||||
import { QueueRepository } from "../src/domain/queue-repository.js";
|
||||
import { lastMeaningfulTransition, lastMeaningfulTransitionMany } from "../src/domain/queue-waiting.js";
|
||||
|
||||
let db: Database.Database;
|
||||
const at = (s: number) => new Date(Date.UTC(2026, 10, 7, 9, 0, s)).toISOString();
|
||||
|
||||
function item(id: string, o: { state?: string; blockedOn?: string | null } = {}) {
|
||||
db.prepare(`INSERT INTO queue_items(qitem_id, ts_created, ts_updated, source_session, destination_session, state, body, blocked_on)
|
||||
VALUES (?, ?, ?, 'sender@r', 'worker@r', ?, 'body', ?)`).run(id, at(0), at(0), o.state ?? "pending", o.blockedOn ?? null);
|
||||
}
|
||||
function transition(qitemId: string, state: string, actor: string, s: number): number {
|
||||
return Number(db.prepare(`INSERT INTO queue_transitions(qitem_id, ts, state, actor_session) VALUES (?, ?, ?, ?)`)
|
||||
.run(qitemId, at(s), state, actor).lastInsertRowid);
|
||||
}
|
||||
function wake(transitionId: number, qitemId: string, phase: "armed" | "fired") {
|
||||
db.prepare(`INSERT INTO queue_transition_wakes(transition_id, qitem_id, phase, wake_kind, wake_ref) VALUES (?, ?, ?, 'timer', 'job-1')`)
|
||||
.run(transitionId, qitemId, phase);
|
||||
}
|
||||
/** The batched answer and the per-row answer, for the same ids. */
|
||||
function both(ids: string[]) {
|
||||
return {
|
||||
many: Object.fromEntries(lastMeaningfulTransitionMany(db, ids)),
|
||||
perRow: Object.fromEntries(ids.map((id) => [id, lastMeaningfulTransition(db, id)])),
|
||||
};
|
||||
}
|
||||
|
||||
describe("lastMeaningfulTransitionMany answers what lastMeaningfulTransition answers", () => {
|
||||
beforeEach(() => { db = new Database(":memory:"); migrate(db, ALL_MIGRATIONS); });
|
||||
afterEach(() => db.close());
|
||||
|
||||
it("covers a row without transitions, system actors repeating a state, a state change by a system actor, and an author note", () => {
|
||||
item("q-none");
|
||||
item("q-system-only"); // every transition by a system actor, same state: the first one counts
|
||||
transition("q-system-only", "pending", "daemon@system", 1);
|
||||
transition("q-system-only", "pending", "watchdog@system", 2);
|
||||
transition("q-system-only", "pending", "wake-ladder@system", 3);
|
||||
item("q-mixed"); // an author, then system bookkeeping in the same state
|
||||
transition("q-mixed", "pending", "sender@r", 1);
|
||||
transition("q-mixed", "in-progress", "worker@r", 2);
|
||||
transition("q-mixed", "in-progress", "daemon@kernel", 3);
|
||||
transition("q-mixed", "in-progress", "watchdog@system", 4);
|
||||
item("q-system-moves"); // a system actor that changes the state is meaningful
|
||||
transition("q-system-moves", "pending", "sender@r", 1);
|
||||
transition("q-system-moves", "blocked", "worker@r", 2);
|
||||
transition("q-system-moves", "pending", "daemon@system", 3);
|
||||
const { many, perRow } = both(["q-none", "q-system-only", "q-mixed", "q-system-moves", "q-unknown"]);
|
||||
expect(many).toEqual(perRow);
|
||||
expect(many["q-none"]).toBeNull();
|
||||
expect(many["q-unknown"]).toBeNull();
|
||||
expect(many["q-system-only"]).not.toBeNull();
|
||||
});
|
||||
|
||||
it("skips a transition whose wake fired, but not one whose wake is only armed", () => {
|
||||
item("q-fired");
|
||||
transition("q-fired", "pending", "sender@r", 1);
|
||||
const parked = transition("q-fired", "blocked", "worker@r", 2);
|
||||
wake(parked, "q-fired", "armed");
|
||||
const fired = transition("q-fired", "pending", "worker@r", 3);
|
||||
wake(fired, "q-fired", "fired");
|
||||
item("q-armed");
|
||||
const armed = transition("q-armed", "blocked", "worker@r", 1);
|
||||
wake(armed, "q-armed", "armed");
|
||||
const { many, perRow } = both(["q-fired", "q-armed"]);
|
||||
expect(many).toEqual(perRow);
|
||||
expect(many["q-fired"]).toEqual({ id: parked, at: at(2) });
|
||||
});
|
||||
|
||||
it("an empty listing reads nothing and returns an empty map", () => {
|
||||
const spy = vi.spyOn(db, "prepare");
|
||||
expect(lastMeaningfulTransitionMany(db, []).size).toBe(0);
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("an id asked twice (a listed row that is also another row's blocker) maps to its one transition", () => {
|
||||
item("q-blocker");
|
||||
const t = transition("q-blocker", "in-progress", "worker@r", 1);
|
||||
item("q-waiter", { state: "blocked", blockedOn: "q-blocker" });
|
||||
transition("q-waiter", "blocked", "worker@r", 2);
|
||||
const ids = ["q-blocker", "q-waiter", "q-blocker"];
|
||||
const { many, perRow } = both(ids);
|
||||
expect(many).toEqual(perRow);
|
||||
expect(many["q-blocker"]).toEqual({ id: t, at: at(1) });
|
||||
expect(lastMeaningfulTransitionMany(db, ids).size).toBe(2);
|
||||
});
|
||||
|
||||
it("reads in a constant number of statements, whatever the number of rows", () => {
|
||||
for (let i = 0; i < 40; i++) { item(`q-${i}`); transition(`q-${i}`, "pending", "sender@r", i); }
|
||||
const spy = vi.spyOn(db, "prepare");
|
||||
lastMeaningfulTransitionMany(db, Array.from({ length: 40 }, (_, i) => `q-${i}`));
|
||||
expect(spy.mock.calls.length).toBeLessThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("lastMeaningfulTransitionMany on older schemas", () => {
|
||||
afterEach(() => db.close());
|
||||
|
||||
it("a DB without queue_transition_wakes reads every transition as the per-row read does", () => {
|
||||
db = new Database(":memory:");
|
||||
migrate(db, [queueItemsSchema, queueTransitionsSchema]);
|
||||
item("q-a");
|
||||
transition("q-a", "pending", "sender@r", 1);
|
||||
transition("q-a", "pending", "watchdog@system", 2);
|
||||
item("q-b");
|
||||
const { many, perRow } = both(["q-a", "q-b"]);
|
||||
expect(many).toEqual(perRow);
|
||||
expect(many["q-a"]).not.toBeNull();
|
||||
});
|
||||
|
||||
it("a DB without queue_transitions maps every id to null", () => {
|
||||
db = new Database(":memory:");
|
||||
migrate(db, [queueItemsSchema]);
|
||||
item("q-a");
|
||||
expect(Object.fromEntries(lastMeaningfulTransitionMany(db, ["q-a"]))).toEqual({ "q-a": lastMeaningfulTransition(db, "q-a") });
|
||||
expect(lastMeaningfulTransition(db, "q-a")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("a listing projects the same items with its shared reads as with per-row reads", () => {
|
||||
let repo: QueueRepository;
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
vi.setSystemTime(new Date("2026-11-07T09:30:00.000Z"));
|
||||
db = new Database(":memory:");
|
||||
migrate(db, ALL_MIGRATIONS);
|
||||
repo = new QueueRepository(db, new EventBus(db), { validateRig: () => true, loadHumanRegistry: () => ({ ok: true, entities: [] }) } as never);
|
||||
});
|
||||
afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); db.close(); });
|
||||
|
||||
/** The listing as it reads now, and the same listing with its shared reads withheld (every row read alone). */
|
||||
function listedBothWays(opts: Parameters<QueueRepository["list"]>[0]) {
|
||||
const shared = repo.list(opts);
|
||||
const own = repo as unknown as { listingReads: unknown };
|
||||
own.listingReads = () => undefined;
|
||||
try {
|
||||
return { shared, perRow: repo.list(opts) };
|
||||
} finally {
|
||||
delete (own as { listingReads?: unknown }).listingReads;
|
||||
}
|
||||
}
|
||||
|
||||
async function seed() {
|
||||
const make = (destinationSession: string, body: string) =>
|
||||
repo.create({ sourceSession: "orch@r", destinationSession, body, nudge: false } as never).then((q) => q.qitemId);
|
||||
const fresh = await make("worker@r", "fresh");
|
||||
const claimed = await make("worker@r", "claimed");
|
||||
repo.update({ qitemId: claimed, actorSession: "worker@r", state: "in-progress" } as never);
|
||||
const blocker = await make("other@r", "the blocker");
|
||||
const waiter = await make("worker@r", "waits on the blocker");
|
||||
repo.update({ qitemId: waiter, actorSession: "worker@r", state: "blocked", blockedOn: blocker, transitionNote: "continuation: wait" } as never);
|
||||
const timed = await make("worker@r", "parked on a timer");
|
||||
repo.update({ qitemId: timed, actorSession: "worker@r", state: "blocked", blockedOn: "external:ci", transitionNote: "continuation: CI",
|
||||
wakeAfterSeconds: 600 } as never);
|
||||
const done = await make("worker@r", "done");
|
||||
repo.update({ qitemId: done, actorSession: "worker@r", state: "done", closureReason: "no-follow-on" } as never);
|
||||
return { fresh, claimed, blocker, waiter, timed, done };
|
||||
}
|
||||
|
||||
it("full and compact listings, with blockers inside and outside the listing", async () => {
|
||||
await seed();
|
||||
for (const opts of [{}, { compact: true as const }, { destinationSession: "worker@r" }, { destinationSession: "worker@r", compact: true as const },
|
||||
{ state: ["blocked" as const] }, { rig: "r" }]) {
|
||||
const { shared, perRow } = listedBothWays(opts as never);
|
||||
expect(shared).toEqual(perRow);
|
||||
}
|
||||
});
|
||||
|
||||
it("an empty listing", () => {
|
||||
const { shared, perRow } = listedBothWays({});
|
||||
expect(shared).toEqual([]);
|
||||
expect(perRow).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a listing prepares the waiting view's statements once, not once per row", () => {
|
||||
let repo: QueueRepository;
|
||||
beforeEach(() => {
|
||||
db = new Database(":memory:");
|
||||
migrate(db, ALL_MIGRATIONS);
|
||||
repo = new QueueRepository(db, new EventBus(db), { validateRig: () => true, loadHumanRegistry: () => ({ ok: true, entities: [] }) } as never);
|
||||
});
|
||||
afterEach(() => { vi.restoreAllMocks(); db.close(); });
|
||||
|
||||
/** How many times each kind of statement is prepared while `run` executes. */
|
||||
function preparedWhile(run: () => unknown): Record<string, number> {
|
||||
const prepare = db.prepare.bind(db);
|
||||
const kinds: Record<string, (sql: string) => boolean> = {
|
||||
pendingSince: (sql) => sql.includes("SELECT ts, previous_state FROM"),
|
||||
blocker: (sql) => sql.includes("SELECT destination_session, state FROM queue_items WHERE qitem_id = ?"),
|
||||
timer: (sql) => sql.includes("FROM queue_transition_wakes w JOIN watchdog_jobs j"),
|
||||
waitingRow: (sql) => sql.includes("SELECT qitem_id, source_session, destination_session, state, blocked_on, closure_required_at, tags"),
|
||||
schemaCheck: (sql) => /sqlite_master WHERE name = (\?|'queue_transitions'|'queue_transition_wakes')/.test(sql),
|
||||
};
|
||||
const counts = Object.fromEntries(Object.keys(kinds).map((k) => [k, 0]));
|
||||
const spy = vi.spyOn(db, "prepare").mockImplementation(((sql: string) => {
|
||||
for (const [kind, match] of Object.entries(kinds)) if (match(sql)) counts[kind]!++;
|
||||
return prepare(sql);
|
||||
}) as typeof db.prepare);
|
||||
try { run(); } finally { spy.mockRestore(); }
|
||||
return counts;
|
||||
}
|
||||
|
||||
it("30 pending rows, 30 rows blocked on another row and 30 parked on a timer", async () => {
|
||||
const make = (body: string) => repo.create({ sourceSession: "orch@r", destinationSession: "worker@r", body, nudge: false } as never)
|
||||
.then((q) => q.qitemId);
|
||||
for (let i = 0; i < 30; i++) {
|
||||
await make(`pending ${i}`);
|
||||
const blocker = await make(`blocker ${i}`);
|
||||
repo.update({ qitemId: await make(`waits ${i}`), actorSession: "worker@r", state: "blocked", blockedOn: blocker, transitionNote: "continuation: wait" } as never);
|
||||
repo.update({ qitemId: await make(`timed ${i}`), actorSession: "worker@r", state: "blocked", blockedOn: "external:ci", transitionNote: "continuation: CI",
|
||||
wakeAfterSeconds: 600 } as never);
|
||||
}
|
||||
const counts = preparedWhile(() => repo.list({ limit: 500 }));
|
||||
expect(counts.pendingSince).toBeLessThanOrEqual(1);
|
||||
expect(counts.blocker).toBeLessThanOrEqual(1);
|
||||
expect(counts.timer).toBeLessThanOrEqual(1);
|
||||
expect(counts.waitingRow).toBeLessThanOrEqual(1);
|
||||
// lastMeaningfulTransitionMany's two checks and the waiting statements' one per table, whatever the row count.
|
||||
expect(counts.schemaCheck).toBeLessThanOrEqual(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a listing reads each owner's activity once", () => {
|
||||
let repo: QueueRepository;
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
vi.setSystemTime(new Date("2026-11-07T09:30:00.000Z"));
|
||||
db = new Database(":memory:");
|
||||
migrate(db, ALL_MIGRATIONS);
|
||||
repo = new QueueRepository(db, new EventBus(db), { validateRig: () => true, loadHumanRegistry: () => ({ ok: true, entities: [] }) } as never);
|
||||
});
|
||||
afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); db.close(); });
|
||||
|
||||
const idle = { activity: "idle-at-prompt", needsInput: { count: 0, reason: null }, decidedBy: "window-sampling" };
|
||||
const make = (destinationSession: string) =>
|
||||
repo.create({ sourceSession: "orch@r", destinationSession, body: "work", nudge: false } as never).then((q) => q.qitemId);
|
||||
|
||||
it("one read per distinct session, for the pickup receipt and the waiting view alike", async () => {
|
||||
for (let i = 0; i < 10; i++) { await make("a@r"); await make("b@r"); }
|
||||
const reader = vi.fn((session: string) => (session ? idle : null) as never);
|
||||
repo.attachActivityReader(reader);
|
||||
repo.list({});
|
||||
expect(reader.mock.calls.map(([s]) => s).sort()).toEqual(["a@r", "b@r"]);
|
||||
repo.list({ compact: true });
|
||||
expect(reader).toHaveBeenCalledTimes(4); // a new listing reads again
|
||||
});
|
||||
|
||||
it("a reader that throws for one session leaves the other sessions' rows as they were, and that session's rows unknown", async () => {
|
||||
await make("ok@r"); await make("broken@r"); await make("ok@r");
|
||||
repo.attachActivityReader((session) => { if (session === "broken@r") throw new Error("fixture: seat state unreadable"); return idle as never; });
|
||||
const shared = repo.list({});
|
||||
const own = repo as unknown as { listingReads: unknown };
|
||||
own.listingReads = () => undefined;
|
||||
const perRow = repo.list({});
|
||||
delete (own as { listingReads?: unknown }).listingReads;
|
||||
expect(shared).toEqual(perRow);
|
||||
const liveness = (owner: string) => shared.filter((i) => i.destinationSession === owner).map((i) => i.waiting?.liveness.confidence);
|
||||
expect(liveness("ok@r")).toEqual(["oracle", "oracle"]);
|
||||
expect(liveness("broken@r")).toEqual(["unknown"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a listed row's waiting view reads the row in hand instead of reading it again", () => {
|
||||
let repo: QueueRepository;
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
vi.setSystemTime(new Date("2026-11-07T09:30:00.000Z"));
|
||||
db = new Database(":memory:");
|
||||
migrate(db, ALL_MIGRATIONS);
|
||||
repo = new QueueRepository(db, new EventBus(db), { validateRig: () => true, loadHumanRegistry: () => ({ ok: true, entities: [] }) } as never);
|
||||
});
|
||||
afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); db.close(); });
|
||||
|
||||
const make = (destinationSession: string, body = "work") =>
|
||||
repo.create({ sourceSession: "orch@r", destinationSession, body, nudge: false } as never).then((q) => q.qitemId);
|
||||
const rereads = (sql: string) =>
|
||||
sql.includes("SELECT qitem_id, source_session, destination_session, state, blocked_on, closure_required_at, tags")
|
||||
|| /SELECT state, (human_intent|NULL AS human_intent), destination_session, last_nudge_result FROM queue_items WHERE qitem_id = \?/.test(sql)
|
||||
|| sql.includes("SELECT last_nudge_result FROM queue_items WHERE qitem_id = ?");
|
||||
|
||||
it("prepares none of the waiting view's own-row reads, and projects what the per-row read projects", async () => {
|
||||
const plain = await make("worker@r");
|
||||
const held = await make("worker@r");
|
||||
db.prepare("UPDATE queue_items SET last_nudge_result = 'retained:typing_guard' WHERE qitem_id = ?").run(held);
|
||||
const claimed = await make("worker@r");
|
||||
repo.update({ qitemId: claimed, actorSession: "worker@r", state: "in-progress" } as never);
|
||||
const prepare = db.prepare.bind(db);
|
||||
let n = 0;
|
||||
const spy = vi.spyOn(db, "prepare").mockImplementation(((sql: string) => { if (rereads(sql)) n++; return prepare(sql); }) as typeof db.prepare);
|
||||
const shared = repo.list({});
|
||||
spy.mockRestore();
|
||||
expect(n).toBe(0);
|
||||
const own = repo as unknown as { listingReads: unknown };
|
||||
own.listingReads = () => undefined;
|
||||
const perRow = repo.list({});
|
||||
delete (own as { listingReads?: unknown }).listingReads;
|
||||
expect(shared).toEqual(perRow);
|
||||
expect(shared.find((i) => i.qitemId === held)?.waiting?.nextBackstop).toMatchObject({ mechanism: "held by the seat's typing guard", held: true });
|
||||
expect(shared.find((i) => i.qitemId === plain)?.waiting?.state).toBe("pending");
|
||||
});
|
||||
|
||||
it("a single-row read outside a listing still reads its own row", async () => {
|
||||
const q = await make("worker@r");
|
||||
const prepare = db.prepare.bind(db);
|
||||
let n = 0;
|
||||
const spy = vi.spyOn(db, "prepare").mockImplementation(((sql: string) => { if (rereads(sql)) n++; return prepare(sql); }) as typeof db.prepare);
|
||||
expect(repo.waitingView(q)?.state).toBe("pending");
|
||||
spy.mockRestore();
|
||||
expect(n).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("show (getById) is the listing projection of one row", () => {
|
||||
let repo: QueueRepository;
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
vi.setSystemTime(new Date("2026-11-07T09:30:00.000Z"));
|
||||
db = new Database(":memory:");
|
||||
migrate(db, ALL_MIGRATIONS);
|
||||
repo = new QueueRepository(db, new EventBus(db), { validateRig: () => true, loadHumanRegistry: () => ({ ok: true, entities: [] }) } as never);
|
||||
});
|
||||
afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); db.close(); });
|
||||
|
||||
/** getById as it read before: the per-row projection, then the delivery outcome read on its own. */
|
||||
function getByIdPerRow(qitemId: string) {
|
||||
const own = repo as unknown as { rowToItem: (row: unknown) => Record<string, unknown> };
|
||||
const row = db.prepare("SELECT * FROM queue_items WHERE qitem_id = ?").get(qitemId);
|
||||
const item = own.rowToItem.call(repo, row);
|
||||
const ledger = repo.deliveryOutcomeFor(qitemId);
|
||||
return { ...item, deliveryOutcome: ledger?.outcome ?? null, ...(ledger && ledger.outcome !== "posted" && ledger.outcome !== "accepted" ? { deliveryFailureDetail: ledger.detail } : {}) };
|
||||
}
|
||||
|
||||
it("answers what the per-row projection answered: pending, in-progress, blocked, parked, done and handed-off", async () => {
|
||||
const make = (destinationSession: string, body: string) =>
|
||||
repo.create({ sourceSession: "orch@r", destinationSession, body, nudge: false } as never).then((q) => q.qitemId);
|
||||
const fresh = await make("worker@r", "fresh");
|
||||
const claimed = await make("worker@r", "claimed");
|
||||
repo.update({ qitemId: claimed, actorSession: "worker@r", state: "in-progress" } as never);
|
||||
const blocker = await make("other@r", "the blocker");
|
||||
const waiter = await make("worker@r", "waits");
|
||||
repo.update({ qitemId: waiter, actorSession: "worker@r", state: "blocked", blockedOn: blocker, transitionNote: "continuation: wait" } as never);
|
||||
const timed = await make("worker@r", "timer");
|
||||
repo.update({ qitemId: timed, actorSession: "worker@r", state: "blocked", blockedOn: "external:ci", transitionNote: "continuation: CI",
|
||||
wakeAfterSeconds: 600 } as never);
|
||||
const done = await make("worker@r", "done");
|
||||
repo.update({ qitemId: done, actorSession: "worker@r", state: "done", closureReason: "no-follow-on" } as never);
|
||||
const passed = await make("worker@r", "passed on");
|
||||
const { created: successor } = await repo.handoff({ qitemId: passed, fromSession: "worker@r", toSession: "next@r", nudge: false });
|
||||
const ids = [fresh, claimed, blocker, waiter, timed, done, passed, successor.qitemId];
|
||||
// Each state the face can show is here, so the comparison below covers it.
|
||||
expect(ids.map((id) => repo.getById(id)?.state)).toEqual(
|
||||
["pending", "in-progress", "pending", "blocked", "blocked", "done", "handed-off", "pending"]);
|
||||
expect(repo.getById(timed)?.pickup?.state).toBe("parked");
|
||||
for (const id of ids) expect(repo.getById(id)).toEqual(getByIdPerRow(id));
|
||||
expect(repo.getById("qitem-missing")).toBeNull();
|
||||
});
|
||||
|
||||
it("reads the row's last meaningful transition once, not once for the pickup and once for the waiting view", async () => {
|
||||
const q = (await repo.create({ sourceSession: "orch@r", destinationSession: "worker@r", body: "work", nudge: false } as never)).qitemId;
|
||||
const prepare = db.prepare.bind(db);
|
||||
let n = 0;
|
||||
const spy = vi.spyOn(db, "prepare").mockImplementation(((sql: string) => {
|
||||
if (sql.includes("actor_session NOT IN ('watchdog@system'")) n++;
|
||||
return prepare(sql);
|
||||
}) as typeof db.prepare);
|
||||
repo.getById(q);
|
||||
spy.mockRestore();
|
||||
expect(n).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import { execFile, spawnSync } from "node:child_process";
|
||||
import { resolve } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { shellQuote } from "../src/adapters/shell-quote.js";
|
||||
|
||||
// The runner conformance check that ships in docs/reference, run against a test double that keeps the contract, and
|
||||
// against copies of it that each break one promise: the check must pass the first and catch every break.
|
||||
@@ -15,7 +16,7 @@ type Result = { id: string; status: "pass" | "fail" | "skip" | "advisory"; met?:
|
||||
type Report = { results: Result[]; summary: { pass: number; fail: number; skip: number; advisory: number } };
|
||||
|
||||
async function check(args: string[], runnerFlags = "", nodeArgs: string[] = [], timeoutMs = 90_000): Promise<{ code: number; report: Report }> {
|
||||
const runner = `${process.execPath} ${RUNNER} --agent {agent}${runnerFlags}`;
|
||||
const runner = `${shellQuote(process.execPath)} ${shellQuote(RUNNER)} --agent {agent}${runnerFlags}`;
|
||||
try {
|
||||
const { stdout } = await promisify(execFile)(process.execPath, [...nodeArgs, CHECK, "--runner", runner, "--name", "minimal", "--json", ...args], { timeout: timeoutMs });
|
||||
return { code: 0, report: JSON.parse(stdout) };
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
import { mkdtempSync, rmSync, writeFileSync, openSync, writeSync, closeSync, statSync, readSync } from "node:fs";
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { parseJsonlExchanges } from "../src/domain/session-jsonl.js";
|
||||
|
||||
vi.mock("node:fs", async (importOriginal) => {
|
||||
const fs = await importOriginal<typeof import("node:fs")>();
|
||||
return { ...fs, readSync: vi.fn(fs.readSync) };
|
||||
});
|
||||
|
||||
// Seat-handover boot recap: read the PROVIDER session JSONL (claude sidecar
|
||||
// transcript_path / codex rollout_path) into the last-N {role, content} exchanges for the boot recap.
|
||||
// Defensive / honest-degraded: metadata + thinking/tool_use-only lines carry no user text and are
|
||||
@@ -16,7 +11,7 @@ vi.mock("node:fs", async (importOriginal) => {
|
||||
// claude-projects line shape ({type,message:{role,content}}; content string OR [{type,text}] blocks).
|
||||
|
||||
const dirs: string[] = [];
|
||||
afterEach(() => { vi.mocked(readSync).mockReset(); for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }); });
|
||||
afterEach(() => { for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }); });
|
||||
function fixture(lines: unknown[]): string {
|
||||
const d = mkdtempSync(join(tmpdir(), "sj-"));
|
||||
dirs.push(d);
|
||||
@@ -91,67 +86,6 @@ describe("parseJsonlExchanges — claude-projects role/content shape", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("recap reads bounded chunks from the end", () => {
|
||||
it("reads the last six exchanges from a sparse transcript larger than the string limit", () => {
|
||||
const p = fixture([]);
|
||||
const expected = Array.from({ length: 6 }, (_, i) => ({ role: "user", content: String(i) }));
|
||||
const tail = "\n" + expected.map((message) => JSON.stringify({ message })).join("\n")
|
||||
+ '\n{"type":"metadata"}\n{partial';
|
||||
const fd = openSync(p, "w");
|
||||
try {
|
||||
writeSync(fd, tail, 512 * 1024 * 1024 + 4096);
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
expect(statSync(p).size).toBeGreaterThan(512 * 1024 * 1024);
|
||||
expect(parseJsonlExchanges(p, 6)).toEqual(expected);
|
||||
const requestedBytes = vi.mocked(readSync).mock.calls.reduce((sum, call) => sum + Number(call[3] ?? 0), 0);
|
||||
expect(requestedBytes).toBeLessThan(1024 * 1024);
|
||||
});
|
||||
|
||||
it.each(["", "\n", "\r\n"])("keeps UTF-8 and whole records across chunks with ending %j", (ending) => {
|
||||
const p = fixture([]);
|
||||
const long = "🍵é".repeat(40_000);
|
||||
const expected = [{ role: "user", content: "first" }, { role: "assistant", content: long }];
|
||||
writeFileSync(p, [
|
||||
JSON.stringify({ message: expected[0] }),
|
||||
'{"message":{"role":"user",', // Physical JSONL fragments stay corrupt; never join separate lines.
|
||||
'"content":"not an exchange"}}',
|
||||
JSON.stringify({ message: expected[1] }),
|
||||
JSON.stringify({ type: "metadata", padding: "x".repeat(150_000) }),
|
||||
"{partial",
|
||||
].join("\r\n") + ending);
|
||||
expect(parseJsonlExchanges(p, 6)).toEqual(expected);
|
||||
expect(parseJsonlExchanges(p, 1)).toEqual(expected.slice(-1));
|
||||
});
|
||||
|
||||
it("completes a short positional read before decoding", async () => {
|
||||
const fs = await vi.importActual<typeof import("node:fs")>("node:fs");
|
||||
const p = fixture([{ message: { role: "user", content: "🍵" } }]);
|
||||
vi.mocked(readSync).mockImplementationOnce((fd, buffer) => fs.readSync(fd, buffer, 0, 1, 0));
|
||||
expect(parseJsonlExchanges(p, 6)).toEqual([{ role: "user", content: "🍵" }]);
|
||||
expect(readSync).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("returns no partial recap if the file becomes unreadable after a visible exchange", async () => {
|
||||
const fs = await vi.importActual<typeof import("node:fs")>("node:fs");
|
||||
const p = fixture([
|
||||
{ message: { role: "user", content: "first" } },
|
||||
{ type: "metadata", padding: "x".repeat(150_000) },
|
||||
{ message: { role: "assistant", content: "last" } },
|
||||
]);
|
||||
vi.mocked(readSync).mockImplementationOnce(fs.readSync).mockReturnValueOnce(0);
|
||||
expect(parseJsonlExchanges(p, 6)).toEqual([]);
|
||||
expect(readSync).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("reports no exchanges for an unreadable record or metadata-only chunks", () => {
|
||||
const p = fixture([{ type: "metadata", padding: "x".repeat(150_000) }]);
|
||||
expect(parseJsonlExchanges(p, 6)).toEqual([]);
|
||||
expect(parseJsonlExchanges(join(p, ".."), 6)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("recap tail boundaries", () => {
|
||||
it.each(["", "\n", "\r\n"])("keeps chronological order with trailing %j", (ending) => {
|
||||
const p = fixture([]);
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { existsSync, realpathSync } from "node:fs";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
/**
|
||||
* Exact tracked docs paths allowed outside the three durable roots.
|
||||
@@ -57,6 +59,6 @@ export function main() {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
if (process.argv[1] && existsSync(process.argv[1]) && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) {
|
||||
main();
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, writeFileSync } from "node:fs";
|
||||
import { dirname, isAbsolute, relative, resolve } from "node:path";
|
||||
import {
|
||||
buildInternalLeakMessage,
|
||||
@@ -200,7 +201,7 @@ function splitNul(bytes) {
|
||||
.sort();
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${resolve(process.argv[1])}`) {
|
||||
if (process.argv[1] && existsSync(process.argv[1]) && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) {
|
||||
try {
|
||||
process.exitCode = main();
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
chmodSync,
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
realpathSync,
|
||||
rmSync,
|
||||
lstatSync,
|
||||
writeFileSync,
|
||||
@@ -555,7 +557,7 @@ export async function main(argv = process.argv.slice(2), dependencies = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${resolve(process.argv[1])}`) {
|
||||
if (process.argv[1] && existsSync(process.argv[1]) && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) {
|
||||
try {
|
||||
await main();
|
||||
} catch (err) {
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import { copyFileSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const entrypoints = ["check-docs-guard.mjs", "check-internal-leak-guard.mjs", "mirror-skills.mjs"];
|
||||
|
||||
function fixture(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), "openrig scripts # "));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const scripts = join(root, "scripts");
|
||||
mkdirSync(scripts);
|
||||
for (const file of [...entrypoints, "internal-leak-scanner.mjs", "leak-guard-rules.mjs"]) {
|
||||
copyFileSync(new URL(file, import.meta.url), join(scripts, file));
|
||||
}
|
||||
const env = { ...process.env };
|
||||
delete env.OPENRIG_LEAK_GUARD_RULES;
|
||||
return {
|
||||
root,
|
||||
run: (file, ...args) => spawnSync(process.execPath, [join(scripts, file), ...args], {
|
||||
cwd: root, encoding: "utf8", env,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
test("docs guard rejects tracked plans from a checkout path containing spaces and #", (t) => {
|
||||
const { root, run } = fixture(t);
|
||||
mkdirSync(join(root, "docs/plans"), { recursive: true });
|
||||
writeFileSync(join(root, "docs/plans/example.md"), "local plan\n");
|
||||
execFileSync("git", ["init", "-q"], { cwd: root });
|
||||
execFileSync("git", ["add", "."], { cwd: root });
|
||||
const result = run("check-docs-guard.mjs");
|
||||
assert.equal(result.status, 1, result.stderr);
|
||||
assert.match(result.stderr, /docs\/plans\/example\.md/);
|
||||
});
|
||||
|
||||
test("leak guard reports a missing rules file instead of silently succeeding", (t) => {
|
||||
const { root, run } = fixture(t);
|
||||
const result = run("check-internal-leak-guard.mjs", "--repo", root, "--rules", join(root, "missing.json"), "--mode", "full");
|
||||
assert.equal(result.status, 1, result.stderr);
|
||||
assert.match(result.stderr, /Rules file not found/);
|
||||
});
|
||||
|
||||
test("mirror check reports missing generated inputs instead of silently succeeding", (t) => {
|
||||
const { run } = fixture(t);
|
||||
const result = run("mirror-skills.mjs", "--check");
|
||||
assert.equal(result.status, 1, result.stderr);
|
||||
assert.match(result.stderr, /ENOENT.*skill-edge-layout\.generated\.json/);
|
||||
});
|
||||
|
||||
for (const file of entrypoints) {
|
||||
for (const args of [[], ["missing-entrypoint.mjs"]]) {
|
||||
test(`${file} can be imported with ${args.length ? "a nonexistent" : "no"} entrypoint argument`, (t) => {
|
||||
const { root } = fixture(t);
|
||||
const url = pathToFileURL(join(root, "scripts", file)).href;
|
||||
const result = spawnSync(process.execPath, ["--input-type=module", "-e", `await import(${JSON.stringify(url)})`, ...args], {
|
||||
cwd: root, encoding: "utf8",
|
||||
});
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(result.stdout, "");
|
||||
assert.equal(result.stderr, "");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const [file, args, error] of [
|
||||
["check-docs-guard.mjs", [], /not a git repository/],
|
||||
["check-internal-leak-guard.mjs", ["--repo", ".", "--rules", "missing.json", "--mode", "full"], /Rules file not found/],
|
||||
["mirror-skills.mjs", ["--check"], /ENOENT.*skill-edge-layout\.generated\.json/],
|
||||
]) {
|
||||
test(`${file} runs through a preserved symlink`, (t) => {
|
||||
const { root } = fixture(t);
|
||||
const link = join(root, "scripts", "linked.mjs");
|
||||
symlinkSync(file, link);
|
||||
const result = spawnSync(process.execPath, ["--preserve-symlinks-main", link, ...args], {
|
||||
cwd: root, encoding: "utf8",
|
||||
});
|
||||
assert.equal(result.status, 1, result.stderr);
|
||||
assert.match(result.stderr, error);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user