mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-11 17:32:22 -10:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c36321c2f | ||
|
|
107b948263 | ||
|
|
6b1e454a42 |
No files matched your search
+1
-3
@@ -152,9 +152,7 @@ OpenRig. Two repairs aren't in this release: see Known limits.
|
||||
and the stop wasn't exercised. For an adopted session, such as a seat joined
|
||||
with `rig attach --self`, `rig seat stop` still suggests `rig down`, which
|
||||
leaves that terminal running too. Release it with `rig unclaim <session>`
|
||||
(that leaves its process running), then end that process. If it runs in tmux,
|
||||
also end the underlying tmux session, including any remaining shell; closing
|
||||
only its viewing window leaves that session running. ([#2962](https://github.com/mvschwarz/openrig/pull/2962))
|
||||
(that leaves its process running), then close it in its own terminal. ([#2962](https://github.com/mvschwarz/openrig/pull/2962))
|
||||
- **Codex working counter:** a capture that doesn't name its pane keeps the old
|
||||
comparison, a restart that keeps both the pane and its process id isn't seen,
|
||||
and a leftover live frame can count once until it expires. ([#2980](https://github.com/mvschwarz/openrig/pull/2980))
|
||||
|
||||
@@ -140,11 +140,7 @@ that gateway side too. For the pane side, read:
|
||||
|
||||
- `lastNudgeWireResult`: the wake send's result as stored (for example
|
||||
`verified`, `delivered-ack-pending`, `indeterminate:…`, `retained:…`,
|
||||
`failed:…`). A claim never rewrites it; a later wake replaces it. Each stored
|
||||
form reads as one delivery fact (`readWakeOutcome`, `connection-contract.ts`).
|
||||
A result no reader recognises reads `unknown`, never delivered: the wake ladder
|
||||
treats it as an unconfirmed wake, which escalates past the window without a
|
||||
re-send.
|
||||
`failed:…`). A claim never rewrites it; a later wake replaces it.
|
||||
- `lastNudgeResult`: the result as presented. It is the stored result, except
|
||||
that a claimed row whose stored result is `delivered-ack-pending` or
|
||||
`indeterminate:…` reads `claimed` (`presentedNudgeResult`). Unclaiming shows
|
||||
|
||||
@@ -405,6 +405,11 @@ Each startup file and action specifies when it applies:
|
||||
- a launch replaces it, as launches always did, and names it (`guidance_block_refreshed_unrecorded`);
|
||||
- an exact resume or a handover leaves it, and names it as possibly stale (`guidance_block_may_be_stale`).
|
||||
|
||||
**A file keeps one copy of each block.**
|
||||
- **Touched markers:** a marker an editor or formatter rewrapped, with spaces or line breaks inside the comment, still reads as its block, and the next write puts it back in OpenRig's form.
|
||||
- **Collected copies:** they're reduced to the first. Beside a copy you edited, OpenRig's own unedited copies and exact repeats are removed, and every different edited copy stays (`guidance_block_copies_removed`).
|
||||
- **A marker that lost its pair:** OpenRig can't tell where that block starts or ends, so it adds no copy beside it and names it (`guidance_block_markers_damaged`). Put back the missing marker or remove the partial block, and OpenRig's next write keeps one block. `rig package install` leaves such a file as it is, names it in its result (`notes`), and installs the rest. A marker without its pair beside a complete block is named too (`guidance_block_marker_unpaired`), and the block is written.
|
||||
|
||||
An exact resume doesn't type anything into the resumed conversation. So there, the restore result names the block (`guidanceNotes`), but the seat isn't told.
|
||||
|
||||
Use this to avoid re-sending context that a fresh conversation doesn't need twice. For example, a one-time project briefing might only apply on `fresh_start`, while identity grounding should apply on both, so a seat that comes back fresh gets it again.
|
||||
|
||||
@@ -13,9 +13,7 @@ provider usage stop updating, and Claude resume-token capture at adoption or
|
||||
handover loses its current session evidence. An earlier sidecar can remain and
|
||||
contain an older session ID; without one, context and capture report
|
||||
`missing_sidecar`, and provider usage reports `no_statusline_cache_yet` unless an
|
||||
earlier cache remains. A cache that exists but cannot be read reports
|
||||
`claude_statusline_cache_unreadable`; a readable cache that carried no rate
|
||||
limits reports `claude_statusline_cache_had_no_windows`.
|
||||
earlier cache remains.
|
||||
|
||||
## Feed both commands the same input
|
||||
|
||||
|
||||
@@ -137,6 +137,7 @@ export function packageCommand(depsOverride?: StatusDeps): Command {
|
||||
conflicts?: Array<{ existingPath: string; reason: string }>;
|
||||
verification?: { passed: boolean };
|
||||
policyRejected?: Array<{ entry: { exportType: string; exportName: string }; reason: string }>;
|
||||
notes?: string[];
|
||||
error?: string;
|
||||
errors?: string[];
|
||||
code?: string;
|
||||
@@ -204,6 +205,11 @@ export function packageCommand(depsOverride?: StatusDeps): Command {
|
||||
console.log(` ${r.entry.exportType.padEnd(12)} ${r.entry.exportName.padEnd(20)} ${r.reason}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (res.data.notes && res.data.notes.length > 0) {
|
||||
console.log("Left as it is:");
|
||||
for (const note of res.data.notes) console.log(` ${note}`);
|
||||
}
|
||||
});
|
||||
|
||||
// rig package rollback <installId>
|
||||
|
||||
@@ -1493,10 +1493,9 @@ Examples:
|
||||
.addHelpText("after", `
|
||||
Siblings are untouched. A dead seat is refused (use rig seat clean). An adopted
|
||||
session is refused: to end only it, run rig unclaim <session> (it releases the
|
||||
session and leaves its process running), then end that process and the
|
||||
underlying tmux session, including any remaining shell. Closing only its
|
||||
viewing window leaves the tmux session running. rig down <rig> leaves a
|
||||
session OpenRig didn't start running.
|
||||
session and leaves its process running), then end that process in its own
|
||||
terminal. rig down <rig> ends every seat in its rig, so it is the route only
|
||||
for a one-seat rig.
|
||||
A seat handed over (fresh, fork or rebuild) before OpenRig 0.7.9 still reads
|
||||
adopted and is refused too, until its next launched handover or managed launch;
|
||||
the refusal says so. Stopping ends that agent's session and any work in progress:
|
||||
|
||||
@@ -207,6 +207,20 @@ function createMockDaemon() {
|
||||
}));
|
||||
return;
|
||||
}
|
||||
if (sourceRef.includes("skipped")) {
|
||||
res.writeHead(201, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({
|
||||
installId: "inst-skip-1",
|
||||
packageId: "pkg-1",
|
||||
packageName: "guidance-pkg",
|
||||
applied: [],
|
||||
deferred: [],
|
||||
conflicts: [],
|
||||
verification: { passed: true },
|
||||
notes: ["guidance_block_markers_damaged: /repo/CLAUDE.md: guidance-pkg has a BEGIN or END marker without its pair"],
|
||||
}));
|
||||
return;
|
||||
}
|
||||
if (sourceRef.includes("merge")) {
|
||||
res.writeHead(201, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({
|
||||
@@ -383,6 +397,14 @@ describe("rig package", () => {
|
||||
expect(output).toContain("inst-merge-1");
|
||||
});
|
||||
|
||||
it("install with a guidance file left as it is: names the file and the reason, exit 0", async () => {
|
||||
const { logs, exitCode } = await captureLogs(() => makeProgram().parseAsync(["node", "rig", "package", "install", "/skipped/path", "--target", "/repo", "--allow-merge"]));
|
||||
const output = logs.join("\n");
|
||||
expect(output).toContain("Left as it is:");
|
||||
expect(output).toContain(" guidance_block_markers_damaged: /repo/CLAUDE.md: guidance-pkg has a BEGIN or END marker without its pair");
|
||||
expect(exitCode).toBeUndefined();
|
||||
});
|
||||
|
||||
// Test 8: install conflicts (409) → exitCode 1
|
||||
it("install conflicts: prints conflicts, exitCode 1", async () => {
|
||||
const { logs, exitCode } = await captureLogs(() => makeProgram().parseAsync(["node", "rig", "package", "install", "/conflict/path", "--target", "/repo"]));
|
||||
|
||||
@@ -9,7 +9,6 @@ import { queueInboxCheckinsSchema } from "./migrations/158_queue_inbox_checkins.
|
||||
import { queueItemsHandedOffFromIndexSchema } from "./migrations/159_queue_items_handed_off_from_index.js";
|
||||
import { networkBindingsSchema } from "./migrations/160_network_bindings.js";
|
||||
import { networkReceiptsSchema } from "./migrations/161_network_receipts.js";
|
||||
import { sessionAddressReadIndexesSchema } from "./migrations/162_session_address_read_indexes.js";
|
||||
import { activityGrantsSchema } from "./migrations/143_activity_grants.js";
|
||||
import { environmentBindingsSchema } from "./migrations/144_environment_bindings.js";
|
||||
import { nodePermissionsToSchema } from "./migrations/145_node_permissions_to.js";
|
||||
@@ -326,5 +325,4 @@ export const ALL_MIGRATIONS: Migration[] = [
|
||||
queueItemsHandedOffFromIndexSchema,
|
||||
networkBindingsSchema,
|
||||
networkReceiptsSchema,
|
||||
sessionAddressReadIndexesSchema,
|
||||
];
|
||||
@@ -1,13 +0,0 @@
|
||||
import type { Migration } from "../migrate.js";
|
||||
|
||||
/** Address reads seek a current binding; inventory recency is by id, not wall clock.
|
||||
* Keep the older created_at index for readers that explicitly use that order. */
|
||||
export const sessionAddressReadIndexesSchema: Migration = {
|
||||
name: "162_session_address_read_indexes.sql",
|
||||
sql: `
|
||||
CREATE INDEX IF NOT EXISTS idx_bindings_tmux_session ON bindings(tmux_session)
|
||||
WHERE tmux_session IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_node_latest_id ON sessions(node_id, id DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_name_latest_id ON sessions(session_name, id DESC);
|
||||
`,
|
||||
};
|
||||
@@ -643,6 +643,9 @@ export class BootstrapOrchestrator {
|
||||
if (!outcome.ok) {
|
||||
errors.push(`Package install failed for '${ref}' (${runtime}): ${outcome.message}`);
|
||||
packageInstallFailed = true;
|
||||
} else {
|
||||
// A file the install left as it is, and why: the rest installed, and the rig still imports.
|
||||
for (const note of outcome.notes ?? []) warnings.push(`Package '${ref}' (${runtime}): ${note}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
// The shared words for what happened to a piece of work and what a member can do. Today each part of OpenRig names
|
||||
// these for itself (the transport, the outbox, the queue's wake record, the hosted ring, the watchdog), and the names
|
||||
// drift: a result one reader doesn't recognise reads as fine to it and as a failure to another. This module is where
|
||||
// the words live, so a consumer asks it instead of matching raw strings.
|
||||
//
|
||||
// It translates what is already stored; it never renames it. Activity stays in activity-taxonomy.ts, which this module
|
||||
// doesn't redefine. An unrecognised word always reads `unknown`, never success and never failure.
|
||||
|
||||
/** The two contracts' identifiers, in the daemon's existing `<name>.v<N>` form (`operation-correlation.v1`). N changes
|
||||
* only when a word's meaning changes incompatibly; new jobs and capabilities are advertised, not versioned. */
|
||||
export const TEAM_OPERATIONS_CONTRACT = "team-operations.v1";
|
||||
export const MEMBER_CONNECTION_CONTRACT = "member-connection.v1";
|
||||
|
||||
/** Whether a member or a daemon supports something. A client reads an absent answer as `unknown`, never as
|
||||
* `not-supported`. */
|
||||
export type Support = "supported" | "not-supported" | "unknown";
|
||||
|
||||
/** Who established a fact: OpenRig observing it directly, OpenRig's own adapter reporting what it read from the
|
||||
* program, or an outside adapter. A member's own claim never makes a fact observed. */
|
||||
export type Reporter = "openrig-observed" | "openrig-adapter" | "outside-adapter";
|
||||
|
||||
/** What a delivery route says happened to one attempt. None of them means the member read the work or did it: pickup
|
||||
* is a claim on the row, and nothing else. */
|
||||
export type DeliveryFact =
|
||||
/** The route took it: text typed and submitted, a trigger answered 2xx. */
|
||||
| "accepted"
|
||||
/** Queued inside the program; no start seen yet. */
|
||||
| "queued-not-started"
|
||||
/** A turn start correlated to this attempt. */
|
||||
| "turn-started"
|
||||
/** Proven that nothing reached the member: no input was written, or the route refused before sending. */
|
||||
| "not-delivered"
|
||||
/** The route reported a failure after the attempt may already have reached the member (an error answer, a dropped
|
||||
* connection): failed, with its effect unknown. */
|
||||
| "failed-effect-unknown"
|
||||
/** Held on purpose by a guard or a setting; nothing was written. */
|
||||
| "held"
|
||||
| "unknown";
|
||||
|
||||
export interface ReportedFact<F extends string> {
|
||||
fact: F;
|
||||
reporter: Reporter;
|
||||
}
|
||||
|
||||
/** The route a wake took. `none` means no route can carry it (a pull-only member, an address nothing holds). */
|
||||
export type WakeRoute = "terminal" | "gateway" | "hosted-trigger" | "none";
|
||||
|
||||
/** One wake attempt's outcome, read from the queue's recorded nudge result. */
|
||||
export interface WakeOutcome extends ReportedFact<DeliveryFact> {
|
||||
/** Null when the recorded text names no route this reader knows. */
|
||||
route: WakeRoute | null;
|
||||
/** A terminal wake whose submission was seen rendered, not only sent. */
|
||||
renderVerified?: true;
|
||||
/** Which guard held it: the seat's hold or interruption level (retrying is held again), or the draft-aware check
|
||||
* (a draft or unreadable input, which can clear on its own). */
|
||||
heldBy?: "typing-guard" | "draft-aware";
|
||||
/** The recorded text, unchanged. */
|
||||
recorded: string;
|
||||
}
|
||||
|
||||
/** Read a queue row's recorded nudge result (`last_nudge_result`) as a delivery fact. Every form a producer writes
|
||||
* today is listed; anything else reads `unknown` with no route, so a consumer can tell "the wake's outcome can't be
|
||||
* read" from every known outcome. Null when no attempt was recorded. */
|
||||
export function readWakeOutcome(recorded: string | null | undefined): WakeOutcome | null {
|
||||
if (recorded === null || recorded === undefined) return null;
|
||||
const at = (fact: DeliveryFact, route: WakeRoute | null, extra: Partial<WakeOutcome> = {}): WakeOutcome =>
|
||||
({ fact, reporter: "openrig-observed", route, recorded, ...extra });
|
||||
if (recorded === "verified") return at("accepted", "terminal", { renderVerified: true });
|
||||
// Sent and submitted without render verification: the route took it, nothing more.
|
||||
if (recorded === "delivered-ack-pending") return at("accepted", "terminal");
|
||||
if (recorded === "retained:typing_guard") return at("held", "terminal", { heldBy: "typing-guard" });
|
||||
if (recorded.startsWith("retained:")) return at("held", "terminal", { heldBy: "draft-aware" });
|
||||
// A hosted member's trigger answered: the ring was taken, not the work.
|
||||
if (recorded.startsWith("wake-accepted:webhook")) return at("accepted", "hosted-trigger");
|
||||
if (recorded.startsWith("indeterminate:webhook")) return at("unknown", "hosted-trigger");
|
||||
// A ring that never left: no URL, no Authorization value, an invalid URL.
|
||||
if (/^failed:webhook \([^)]*\) (isn't set:|not rung:)/.test(recorded)) return at("not-delivered", "hosted-trigger");
|
||||
// Any other webhook failure came after the POST was sent. The trigger's own error answer is its report; a network
|
||||
// error is what OpenRig saw. Either way the member may have the ring.
|
||||
if (/^failed:webhook \([^)]*\) answered /.test(recorded)) {
|
||||
return at("failed-effect-unknown", "hosted-trigger", { reporter: "outside-adapter" });
|
||||
}
|
||||
if (recorded.startsWith("failed:webhook")) return at("failed-effect-unknown", "hosted-trigger");
|
||||
// The gateway's own ledger records whether the person was posted to; this attempt's own outcome is unknown.
|
||||
if (recorded.startsWith("gateway-owned:")) return at("unknown", "gateway");
|
||||
if (recorded.startsWith("unroutable:")) return at("not-delivered", "none");
|
||||
// `indeterminate:` covers input that may have been written (a paste, an unconfirmed Enter) and timeouts.
|
||||
if (recorded.startsWith("indeterminate:")) return at("unknown", "terminal");
|
||||
// `failed:` is written only when no input could have reached the seat.
|
||||
if (recorded.startsWith("failed:")) return at("not-delivered", "terminal");
|
||||
return at("unknown", null);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import type { InstallPlanEntry } from "./install-planner.js";
|
||||
import type { PolicyResult } from "./install-policy.js";
|
||||
import type { RefinedInstallPlan } from "./conflict-detector.js";
|
||||
import { InstallRepository, type JournalEntry } from "./install-repository.js";
|
||||
import { damagedManagedBlockNote, findManagedBlocks, managedBlockBodyMatches, mergeManagedBlockText } from "./managed-blocks.js";
|
||||
|
||||
export interface EngineFsOps {
|
||||
readFile: (filePath: string) => string;
|
||||
@@ -19,6 +20,8 @@ export interface InstallResult {
|
||||
applied: JournalEntry[];
|
||||
deferred: InstallPlanEntry[];
|
||||
conflicts: InstallPlanEntry[];
|
||||
/** Entries left as they were, each with the reason: a guidance file whose block has a marker without its pair. */
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
export interface RollbackResult {
|
||||
@@ -52,10 +55,19 @@ export class InstallEngine {
|
||||
): InstallResult {
|
||||
const install = this.installRepo.createInstall(packageId, targetRoot, "project_shared", bootstrapId);
|
||||
const applied: JournalEntry[] = [];
|
||||
const notes: string[] = [];
|
||||
const backupRoot = path.join(targetRoot, ".rigged-backups", install.id);
|
||||
|
||||
try {
|
||||
for (const entry of policyResult.approved) {
|
||||
// A guidance block with a marker that lost its pair can't be replaced, and another copy beside it is the
|
||||
// duplicate to avoid: that file is left as it is and named, and the rest of the install goes on.
|
||||
const unreadable = entry.exportType === "guidance" ? this.unreadableGuidanceBlock(entry, plan.packageName) : null;
|
||||
if (unreadable) {
|
||||
notes.push(unreadable);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ensure target directory
|
||||
this.fs.mkdirp(path.dirname(entry.targetPath));
|
||||
|
||||
@@ -121,6 +133,7 @@ export class InstallEngine {
|
||||
applied,
|
||||
deferred: plan.deferred,
|
||||
conflicts: plan.conflicts,
|
||||
notes,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -170,6 +183,14 @@ export class InstallEngine {
|
||||
return { restored, deleted };
|
||||
}
|
||||
|
||||
/** The note for a guidance entry whose target holds a marker of the package's block without its pair, and no complete
|
||||
* copy, or null. */
|
||||
private unreadableGuidanceBlock(entry: InstallPlanEntry, packageName: string): string | null {
|
||||
if (!entry.sourcePath || !this.fs.exists(entry.targetPath)) return null;
|
||||
const found = findManagedBlocks(this.fs.readFile(entry.targetPath), packageName);
|
||||
return found.blocks.length === 0 && found.unpaired > 0 ? damagedManagedBlockNote(entry.targetPath, packageName) : null;
|
||||
}
|
||||
|
||||
private applyGuidanceMerge(entry: InstallPlanEntry, packageName: string): void {
|
||||
if (!entry.sourcePath) return;
|
||||
|
||||
@@ -178,34 +199,17 @@ export class InstallEngine {
|
||||
|
||||
if (this.fs.exists(entry.targetPath)) {
|
||||
const existing = this.fs.readFile(entry.targetPath);
|
||||
const startMarker = BLOCK_START(packageName);
|
||||
const endMarker = BLOCK_END(packageName);
|
||||
const legacyStart = `<!-- BEGIN RIGGED MANAGED BLOCK: ${packageName} -->`;
|
||||
const legacyEnd = `<!-- END RIGGED MANAGED BLOCK: ${packageName} -->`;
|
||||
// Prefer the OpenRig form; fall back to legacy markers from
|
||||
// prior installs so re-applying a guidance package over a
|
||||
// pre-rename file replaces the old block instead of appending a
|
||||
// duplicate.
|
||||
let startIdx = existing.indexOf(startMarker);
|
||||
let endIdx = existing.indexOf(endMarker, startIdx + startMarker.length);
|
||||
let matchedEndLength = endMarker.length;
|
||||
if (startIdx === -1 || endIdx === -1) {
|
||||
const legacyStartIdx = existing.indexOf(legacyStart);
|
||||
const legacyEndIdx = existing.indexOf(legacyEnd, legacyStartIdx + legacyStart.length);
|
||||
if (legacyStartIdx !== -1 && legacyEndIdx !== -1) {
|
||||
startIdx = legacyStartIdx;
|
||||
endIdx = legacyEndIdx;
|
||||
matchedEndLength = legacyEnd.length;
|
||||
}
|
||||
}
|
||||
|
||||
if (startIdx !== -1 && endIdx !== -1) {
|
||||
// Update existing block (rewrites legacy markers to OpenRig form).
|
||||
const updated = existing.slice(0, startIdx) + block + existing.slice(endIdx + matchedEndLength);
|
||||
this.fs.writeFile(entry.targetPath, updated);
|
||||
} else {
|
||||
const found = findManagedBlocks(existing, packageName);
|
||||
if (found.blocks.length === 0) {
|
||||
// Insert new block at end
|
||||
this.fs.writeFile(entry.targetPath, existing + "\n\n" + block + "\n");
|
||||
} else {
|
||||
// Update the first copy in place, in the OpenRig form (legacy markers too). A later copy goes when it repeats the
|
||||
// first copy's text or the package's; a copy with other text, which someone wrote, stays.
|
||||
const first = found.blocks[0]!.body;
|
||||
const updated = mergeManagedBlockText(existing, packageName, block, [],
|
||||
(body) => !managedBlockBodyMatches(body, first) && !managedBlockBodyMatches(body, content));
|
||||
if (updated !== null) this.fs.writeFile(entry.targetPath, updated);
|
||||
}
|
||||
} else {
|
||||
// New file
|
||||
|
||||
@@ -45,7 +45,9 @@ export class InstallVerifier {
|
||||
this.fs = fs;
|
||||
}
|
||||
|
||||
verify(installId: string): VerificationResult {
|
||||
/** `skipped`: entries the install deliberately left as they were (its result's notes), so an install whose every entry was
|
||||
* skipped verifies as that, not as an empty journal. */
|
||||
verify(installId: string, opts: { skipped?: number } = {}): VerificationResult {
|
||||
const install = this.installRepo.getInstall(installId);
|
||||
if (!install) {
|
||||
return {
|
||||
@@ -76,6 +78,16 @@ export class InstallVerifier {
|
||||
const journal = this.installRepo.getJournalEntries(installId);
|
||||
const applyEntries = journal.filter((e) => e.action !== "rollback");
|
||||
|
||||
// Every entry left as it was on purpose, each named in the install's notes: nothing to check, and nothing missing.
|
||||
if (applyEntries.length === 0 && (opts.skipped ?? 0) > 0) {
|
||||
return {
|
||||
passed: true,
|
||||
installId,
|
||||
entries: [],
|
||||
statusCheck: { ...statusCheck, name: "entries_skipped", passed: true, expected: "applied entries, or skips named in notes", actual: `0 applied, ${opts.skipped} skipped` },
|
||||
};
|
||||
}
|
||||
|
||||
// Empty journal for applied install is a verification failure
|
||||
if (applyEntries.length === 0) {
|
||||
return {
|
||||
|
||||
@@ -2,7 +2,8 @@ import { createHash } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import nodePath from "node:path";
|
||||
import type Database from "better-sqlite3";
|
||||
import { frameManagedBlockBody, managedBlockStanding, mergeManagedBlock, type ManagedBlockMergeFsOps, type ManagedBlockStanding } from "./managed-blocks.js";
|
||||
import { damagedManagedBlockNote, frameManagedBlockBody, managedBlockBodyMatches, managedBlockStanding, mergeManagedBlock, pruneManagedBlockCopies,
|
||||
withCanonicalMarkers, type ManagedBlockMergeFsOps, type ManagedBlockStanding } from "./managed-blocks.js";
|
||||
|
||||
/** #2456: OpenRig's record of what it last wrote in each managed guidance block, so a later start, exact resume, handover
|
||||
* or runtime switch can tell a block it wrote (refreshed when its source changes) from a block a person edited (kept). */
|
||||
@@ -92,6 +93,8 @@ export function decideManagedBlock(content: string, blockId: string, source: str
|
||||
const standing = managedBlockStanding(content, blockId, source, recorded, managedBlockBodyHash);
|
||||
const named = opts.sourcePath ?? "its source";
|
||||
if (standing === "absent") return { standing, write: !opts.onlyIfPresent };
|
||||
// A marker without its pair: writing would add a second copy beside the block that can't be read, at every start.
|
||||
if (standing === "damaged") return { standing, write: false, note: damagedManagedBlockNote(targetPath, blockId) };
|
||||
if (unreadable && standing === "unrecorded") {
|
||||
return { standing: "unknown", write: false,
|
||||
note: `guidance_block_record_unreadable: ${targetPath}: ${blockId} wasn't refreshed from ${named} because OpenRig couldn't read its write record` };
|
||||
@@ -103,10 +106,36 @@ export function decideManagedBlock(content: string, blockId: string, source: str
|
||||
return { standing, write: true };
|
||||
}
|
||||
|
||||
/** A block that isn't rewritten (current, edited or unrecorded) can still carry extra copies, or markers an editor
|
||||
* rewrapped: take out the copies that repeat a kept one, and OpenRig's own (its source's text, or what it last wrote)
|
||||
* beside an edited one, so the file keeps one copy of each distinct text, and put the markers in OpenRig's form. Nothing
|
||||
* a person wrote is removed. Best effort: a file that can't be read or written is named in `notes` and left as it is,
|
||||
* and the block's own decision stands. Returns whether the file changed. */
|
||||
export function pruneKeptManagedBlock(files: ManagedBlockMergeFsOps, targetPath: string, blockId: string, source: string,
|
||||
records: ManagedBlockRecords | undefined, notes?: string[]): boolean {
|
||||
let recorded: string | undefined;
|
||||
try { recorded = records?.get(targetPath, blockId); } catch { /* unreadable: only the source's text counts as OpenRig's */ }
|
||||
try {
|
||||
const existing = files.exists(targetPath) ? files.readFile(targetPath) : "";
|
||||
const pruned = pruneManagedBlockCopies(existing, blockId,
|
||||
(body) => managedBlockBodyMatches(body, source) || (recorded !== undefined && managedBlockBodyHash(body) === recorded));
|
||||
const tidy = withCanonicalMarkers(pruned ?? existing, blockId);
|
||||
if (tidy === existing) return false;
|
||||
files.writeFile(targetPath, tidy);
|
||||
if (pruned !== null) {
|
||||
notes?.push(`guidance_block_copies_removed: ${targetPath}: ${blockId} had repeated copies; the repeats were removed and every edited copy was kept`);
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
notes?.push(`guidance_block_copies_not_removed: ${targetPath}: ${blockId}'s repeated copies or markers couldn't be tidied (${(err as Error).message}); the file was left as it is`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** #2456: the one write of a managed guidance block that every adapter's guidance merge goes through. Without `records` it's
|
||||
* the plain merge, as before. With them, `decideManagedBlock` decides; a kept block is named in `warnings` and nothing is
|
||||
* written. A written block is recorded at once, so a later failure in the same start can't lose its record. Returns
|
||||
* whether the block was written. */
|
||||
* the plain merge, as before. With them, `decideManagedBlock` decides; a kept block is named in `warnings`, and only its
|
||||
* repeated copies are taken out (`pruneKeptManagedBlock`). A written block is recorded at once, so a later failure in the
|
||||
* same start can't lose its record. Returns whether the block was written. */
|
||||
export function writeManagedGuidanceBlock(files: ManagedBlockMergeFsOps, targetPath: string, blockId: string, content: string, opts: {
|
||||
records?: ManagedBlockRecords; unrecorded?: "keep" | "refresh"; onlyIfPresent?: boolean; sourcePath?: string;
|
||||
warnings?: string[]; replaceBlockIds?: string[];
|
||||
@@ -115,15 +144,19 @@ export function writeManagedGuidanceBlock(files: ManagedBlockMergeFsOps, targetP
|
||||
// Without records, the plain merge as before; a refresh-only call (`onlyIfPresent`) then writes nothing.
|
||||
if (!opts.records) {
|
||||
if (opts.onlyIfPresent) return false;
|
||||
merge();
|
||||
return true;
|
||||
return merge();
|
||||
}
|
||||
const existing = files.exists(targetPath) ? files.readFile(targetPath) : "";
|
||||
const decision = decideManagedBlock(existing, blockId, content, opts.records, targetPath,
|
||||
{ unrecorded: opts.unrecorded ?? "refresh", onlyIfPresent: opts.onlyIfPresent, sourcePath: opts.sourcePath });
|
||||
if (decision.note) opts.warnings?.push(decision.note);
|
||||
if (!decision.write) return false;
|
||||
merge();
|
||||
if (!decision.write) {
|
||||
if (decision.standing === "edited" || decision.standing === "unrecorded") {
|
||||
pruneKeptManagedBlock(files, targetPath, blockId, content, opts.records, opts.warnings);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (!merge()) return false;
|
||||
recordManagedBlockWrite(opts.records, targetPath, blockId, content, opts.warnings);
|
||||
return true;
|
||||
}
|
||||
@@ -41,15 +41,95 @@ export function managedBlockText(blockId: string, content: string): string {
|
||||
/** What `mergeManagedBlock` puts between a file's existing text and a block it adds at the end. */
|
||||
export const MANAGED_BLOCK_APPEND_SEPARATOR = "\n\n";
|
||||
|
||||
/** A BEGIN or END marker for `blockId`, in the current or the legacy form, as an editor or formatter may leave it: any
|
||||
* run of whitespace, line breaks included, between its words. */
|
||||
function markerSource(kind: "BEGIN" | "END", blockId: string): string {
|
||||
return `<!--\\s*${kind}\\s+(?:OpenRig|RIGGED)\\s+MANAGED\\s+BLOCK:\\s*${escapeRegex(blockId)}\\s*-->`;
|
||||
}
|
||||
|
||||
/** One `blockId` block in a file: where it and its body start and end, and the text between its markers. */
|
||||
export interface FoundManagedBlock { start: number; end: number; bodyStart: number; bodyEnd: number; body: string }
|
||||
|
||||
/** Every `blockId` block in `content`, in order: each BEGIN paired with the next marker when that marker is an END. A
|
||||
* marker left without a pair (an END with no BEGIN before it, or a BEGIN followed by another BEGIN or by nothing) is
|
||||
* counted in `unpaired`: where its block ends, or starts, can't be read. */
|
||||
export function findManagedBlocks(content: string, blockId: string): { blocks: FoundManagedBlock[]; unpaired: number } {
|
||||
const markers = [...content.matchAll(new RegExp(`(${markerSource("BEGIN", blockId)})|${markerSource("END", blockId)}`, "g"))]
|
||||
.map((m) => ({ begin: m[1] !== undefined, index: m.index!, length: m[0].length }));
|
||||
const blocks: FoundManagedBlock[] = [];
|
||||
let unpaired = 0;
|
||||
for (let i = 0; i < markers.length; i++) {
|
||||
const marker = markers[i]!;
|
||||
const next = markers[i + 1];
|
||||
if (marker.begin && next && !next.begin) {
|
||||
const bodyStart = marker.index + marker.length;
|
||||
blocks.push({ start: marker.index, end: next.index + next.length, bodyStart, bodyEnd: next.index, body: content.slice(bodyStart, next.index) });
|
||||
i++;
|
||||
} else {
|
||||
unpaired++;
|
||||
}
|
||||
}
|
||||
return { blocks, unpaired };
|
||||
}
|
||||
|
||||
/** The note for a block OpenRig didn't write because a marker of it has lost its pair. */
|
||||
export function damagedManagedBlockNote(targetPath: string, blockId: string): string {
|
||||
return `guidance_block_markers_damaged: ${targetPath}: ${blockId} has a BEGIN or END marker without its pair, so OpenRig `
|
||||
+ "didn't add another copy; restore the missing marker or remove the partial block, and OpenRig's next write keeps one block";
|
||||
}
|
||||
|
||||
/**
|
||||
* `existing` with `blockId`'s block written as `block`, or null when it can't be written without a second copy: the file
|
||||
* has a marker of that block without its pair, and no complete copy. The first copy is replaced in place and a later copy
|
||||
* is removed with the separator before it, so a file that collected copies keeps one; with `keepLater`, a later copy it
|
||||
* names stays, unless it repeats one already kept. Blocks of `replaceBlockIds` (older ids) are removed. A file with none
|
||||
* of these gets the block at its end.
|
||||
*/
|
||||
export function mergeManagedBlockText(existing: string, blockId: string, block: string, replaceBlockIds: readonly string[] = [],
|
||||
keepLater?: (body: string) => boolean): string | null {
|
||||
const own = findManagedBlocks(existing, blockId);
|
||||
if (own.blocks.length === 0 && own.unpaired > 0) return null;
|
||||
const keptLater: string[] = [];
|
||||
const edits: Array<{ start: number; end: number; text: string }> = [];
|
||||
own.blocks.forEach((found, i) => {
|
||||
if (i === 0) edits.push({ start: found.start, end: found.end, text: block });
|
||||
else if (keepLater?.(found.body) && !keptLater.some((body) => managedBlockBodyMatches(body, found.body))) keptLater.push(found.body);
|
||||
else edits.push({ start: withSeparator(existing, found.start), end: found.end, text: "" });
|
||||
});
|
||||
for (const id of new Set(replaceBlockIds)) {
|
||||
if (id === blockId) continue;
|
||||
for (const found of findManagedBlocks(existing, id).blocks) edits.push({ start: found.start, end: found.end, text: "" });
|
||||
}
|
||||
if (edits.length === 0) return `${existing}${MANAGED_BLOCK_APPEND_SEPARATOR}${block}`;
|
||||
// A block of one id inside another's is left to the outer edit.
|
||||
const ordered: typeof edits = [];
|
||||
for (const edit of edits.sort((a, b) => a.start - b.start)) if (edit.start >= (ordered.at(-1)?.end ?? 0)) ordered.push(edit);
|
||||
let updated = existing;
|
||||
// The block goes in by slicing, never a replacement string, so `$&` and the like in authored content stay as written.
|
||||
for (const edit of ordered.reverse()) updated = `${updated.slice(0, edit.start)}${edit.text}${updated.slice(edit.end)}`;
|
||||
if (own.blocks.length === 0) updated = `${updated.trim()}\n\n${block}`.trim();
|
||||
// End with one newline only when the file doesn't already: re-merging on every launch must not add a line each time
|
||||
// (#2372), and blank lines a person left after the last block stay.
|
||||
return updated.endsWith("\n") ? updated : `${updated}\n`;
|
||||
}
|
||||
|
||||
/** Where a later copy's removal starts: before the newlines that separate it from the text above, so removing the copy
|
||||
* takes the separator the writer put there. */
|
||||
function withSeparator(content: string, start: number): number {
|
||||
let from = start;
|
||||
while (from > 0 && content[from - 1] === "\n") from--;
|
||||
return from;
|
||||
}
|
||||
|
||||
/** Write `blockId`'s block into a guidance file (see `mergeManagedBlockText`). Returns false, writing nothing and naming
|
||||
* the block in `warnings`, when a marker of it has lost its pair. */
|
||||
export function mergeManagedBlock(
|
||||
fs: ManagedBlockMergeFsOps,
|
||||
targetPath: string,
|
||||
blockId: string,
|
||||
content: string,
|
||||
options?: MergeManagedBlockOptions,
|
||||
): void {
|
||||
const begin = MANAGED_BLOCK_START(blockId);
|
||||
const end = MANAGED_BLOCK_END(blockId);
|
||||
): boolean {
|
||||
const block = managedBlockText(blockId, content);
|
||||
|
||||
if (!fs.exists(targetPath)) {
|
||||
@@ -57,49 +137,39 @@ export function mergeManagedBlock(
|
||||
fs.writeFile(targetPath, block);
|
||||
const warnings = excludeNewGeneratedFiles(nodePath.dirname(targetPath), [targetPath]);
|
||||
options?.warnings?.push(...warnings);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
const existing = fs.readFile(targetPath);
|
||||
const allReplaceIds = Array.from(new Set([blockId, ...(options?.replaceBlockIds ?? [])]));
|
||||
const replaceableIds = allReplaceIds.filter((id) => {
|
||||
const candidateBegin = MANAGED_BLOCK_START(id);
|
||||
const candidateEnd = MANAGED_BLOCK_END(id);
|
||||
const legacyBegin = LEGACY_BLOCK_START(id);
|
||||
const legacyEnd = LEGACY_BLOCK_END(id);
|
||||
return (
|
||||
(existing.includes(candidateBegin) && existing.includes(candidateEnd)) ||
|
||||
(existing.includes(legacyBegin) && existing.includes(legacyEnd))
|
||||
);
|
||||
});
|
||||
|
||||
if (replaceableIds.length > 0) {
|
||||
let updated = existing;
|
||||
for (const id of replaceableIds) {
|
||||
const candidateBegin = MANAGED_BLOCK_START(id);
|
||||
const candidateEnd = MANAGED_BLOCK_END(id);
|
||||
const regex = new RegExp(`${escapeRegex(candidateBegin)}[\\s\\S]*?${escapeRegex(candidateEnd)}`, "g");
|
||||
// A replacer function inserts the block literally; a replacement string
|
||||
// would expand `$$`, `$&`, `` $` `` and `$'` in the authored content.
|
||||
const replacement = id === blockId ? block : "";
|
||||
updated = updated.replace(regex, () => replacement);
|
||||
// Legacy marker variant from prior installs — replace with the
|
||||
// OpenRig form (or strip when not the active block id).
|
||||
const legacyBegin = LEGACY_BLOCK_START(id);
|
||||
const legacyEnd = LEGACY_BLOCK_END(id);
|
||||
const legacyRegex = new RegExp(`${escapeRegex(legacyBegin)}[\\s\\S]*?${escapeRegex(legacyEnd)}`, "g");
|
||||
updated = updated.replace(legacyRegex, () => replacement);
|
||||
}
|
||||
if (!updated.includes(begin) || !updated.includes(end)) {
|
||||
updated = `${updated.trim()}\n\n${block}`.trim();
|
||||
}
|
||||
// End with one newline only when the file doesn't already: re-merging on every launch must not add a line each time
|
||||
// (#2372), and blank lines a person left after the last block stay.
|
||||
fs.writeFile(targetPath, updated.endsWith("\n") ? updated : `${updated}\n`);
|
||||
return;
|
||||
const merged = mergeManagedBlockText(existing, blockId, block, options?.replaceBlockIds ?? []);
|
||||
if (merged === null) {
|
||||
options?.warnings?.push(damagedManagedBlockNote(targetPath, blockId));
|
||||
return false;
|
||||
}
|
||||
fs.writeFile(targetPath, merged);
|
||||
if (findManagedBlocks(existing, blockId).unpaired > 0) options?.warnings?.push(unpairedMarkerNote(targetPath, blockId));
|
||||
return true;
|
||||
}
|
||||
|
||||
fs.writeFile(targetPath, `${existing}${MANAGED_BLOCK_APPEND_SEPARATOR}${block}`);
|
||||
/** The note for a marker without its pair left beside a block that was written. */
|
||||
export function unpairedMarkerNote(targetPath: string, blockId: string): string {
|
||||
return `guidance_block_marker_unpaired: ${targetPath}: ${blockId} was written, and a BEGIN or END marker of it without its `
|
||||
+ "pair was left as it is; remove it or restore its pair";
|
||||
}
|
||||
|
||||
/** `content` with each `blockId` block whose markers an editor rewrapped written back in OpenRig's own form, its body
|
||||
* exactly as it is. Markers already in the current or legacy form stay as they are. */
|
||||
export function withCanonicalMarkers(content: string, blockId: string): string {
|
||||
let updated = content;
|
||||
for (const found of findManagedBlocks(content, blockId).blocks.reverse()) {
|
||||
const begin = content.slice(found.start, found.bodyStart);
|
||||
const end = content.slice(found.bodyEnd, found.end);
|
||||
const exact = (begin === MANAGED_BLOCK_START(blockId) && end === MANAGED_BLOCK_END(blockId))
|
||||
|| (begin === LEGACY_BLOCK_START(blockId) && end === LEGACY_BLOCK_END(blockId));
|
||||
if (exact) continue;
|
||||
updated = `${updated.slice(0, found.start)}${MANAGED_BLOCK_START(blockId)}${found.body}${MANAGED_BLOCK_END(blockId)}${updated.slice(found.end)}`;
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** Each `blockId` block in `content` (current markers): the whole block and the text between its markers. */
|
||||
@@ -136,19 +206,47 @@ export function managedBlockOpenings(content: string, blockId: string): number {
|
||||
* - `current`: it holds the source's text;
|
||||
* - `stale`: it holds what OpenRig last wrote, and the source has changed since, so it's OpenRig's to refresh;
|
||||
* - `edited`: it holds something else, which someone wrote by hand;
|
||||
* - `unrecorded`: it differs from the source and OpenRig has no record of writing it, so which of the two is unknown.
|
||||
* - `unrecorded`: it differs from the source and OpenRig has no record of writing it, so which of the two is unknown;
|
||||
* - `damaged`: it holds a marker of the block without its pair, and no complete copy, so the block can't be read.
|
||||
* A block written more than once counts as edited when any copy is. */
|
||||
export type ManagedBlockStanding = "absent" | "current" | "stale" | "edited" | "unrecorded";
|
||||
export type ManagedBlockStanding = "absent" | "current" | "stale" | "edited" | "unrecorded" | "damaged";
|
||||
|
||||
export function managedBlockStanding(content: string, blockId: string, source: string, recordedHash: string | undefined,
|
||||
hash: (body: string) => string): ManagedBlockStanding {
|
||||
const bodies = managedBlockBodies(content, blockId);
|
||||
if (bodies.length === 0) return "absent";
|
||||
const found = findManagedBlocks(content, blockId);
|
||||
const bodies = found.blocks.map((block) => block.body);
|
||||
if (bodies.length === 0) return found.unpaired > 0 ? "damaged" : "absent";
|
||||
if (bodies.every((body) => managedBlockBodyMatches(body, source))) return "current";
|
||||
if (recordedHash === undefined) return "unrecorded";
|
||||
return bodies.every((body) => hash(body) === recordedHash) ? "stale" : "edited";
|
||||
}
|
||||
|
||||
/** `content` with the extra copies of a `blockId` block taken out where no one's text is lost: a later copy whose body
|
||||
* repeats an earlier copy's, and, beside a copy that's kept, a copy that `isOpenRigs` says holds only what OpenRig
|
||||
* wrote. Every other copy stays where it is. Null when there's nothing to take out. */
|
||||
export function pruneManagedBlockCopies(content: string, blockId: string, isOpenRigs: (body: string) => boolean): string | null {
|
||||
const { blocks } = findManagedBlocks(content, blockId);
|
||||
if (blocks.length < 2) return null;
|
||||
const kept: FoundManagedBlock[] = [];
|
||||
const removed: FoundManagedBlock[] = [];
|
||||
for (const block of blocks) {
|
||||
if (kept.some((other) => managedBlockBodyMatches(other.body, block.body))) removed.push(block);
|
||||
else kept.push(block);
|
||||
}
|
||||
const people = kept.filter((block) => !isOpenRigs(block.body));
|
||||
if (people.length > 0) for (const block of kept.filter((b) => isOpenRigs(b.body))) removed.push(block);
|
||||
if (removed.length === 0) return null;
|
||||
let updated = content;
|
||||
for (const block of removed.sort((a, b) => b.start - a.start)) {
|
||||
// A copy goes with the newlines before it; one that starts the file goes with the newlines after it instead.
|
||||
const from = withSeparator(content, block.start);
|
||||
let to = block.end;
|
||||
if (from === 0) while (updated[to] === "\n") to++;
|
||||
updated = `${updated.slice(0, from)}${updated.slice(to)}`;
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** Whether `content` holds a `blockId` block whose body is anything but `written`: someone edited it. */
|
||||
export function hasEditedManagedBlock(content: string, blockId: string, written: string): boolean {
|
||||
return [...content.matchAll(managedBlockPattern(blockId))].some((m) => !managedBlockBodyMatches(m[1]!, written));
|
||||
|
||||
@@ -8,7 +8,7 @@ import { detectConflicts } from "./conflict-detector.js";
|
||||
import { applyPolicy } from "./install-policy.js";
|
||||
|
||||
export type PackageInstallOutcome =
|
||||
| { ok: true; installId: string; packageId: string; applied: number; deferred: number }
|
||||
| { ok: true; installId: string; packageId: string; applied: number; deferred: number; notes?: string[] }
|
||||
| { ok: false; code: "conflict_blocked"; message: string }
|
||||
| { ok: false; code: "policy_rejected"; message: string }
|
||||
| { ok: false; code: "manifest_hash_mismatch"; message: string }
|
||||
@@ -101,7 +101,7 @@ export class PackageInstallService {
|
||||
}
|
||||
|
||||
// Verify
|
||||
const verification = this.installVerifier.verify(result.installId);
|
||||
const verification = this.installVerifier.verify(result.installId, { skipped: result.notes?.length ?? 0 });
|
||||
if (!verification.passed) {
|
||||
return { ok: false, code: "verification_failed", message: "Post-apply verification failed" };
|
||||
}
|
||||
@@ -112,6 +112,7 @@ export class PackageInstallService {
|
||||
packageId: pkg.id,
|
||||
applied: result.applied.length,
|
||||
deferred: result.deferred.length,
|
||||
...(result.notes?.length ? { notes: result.notes } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -61,16 +61,15 @@ export interface ClaudeUsageReaderDeps {
|
||||
|
||||
/**
|
||||
* Read each Claude seat's provider_usage cache → provider_statusline rows via
|
||||
* claudeStatuslineSignals. Explicit unknown rows for absent cache (no_statusline_cache_yet),
|
||||
* an unreadable cache (unreadable_cache), and a valid cache with no windows (empty_reading).
|
||||
* Never throws — a malformed cache becomes an unknown row, not a crash.
|
||||
* claudeStatuslineSignals. Explicit unknown rows for absent cache (no_statusline_cache_yet) and
|
||||
* cache-present-but-no/-malformed windows (empty_reading).
|
||||
* Never throws — a malformed cache is treated as present-with-no-windows, not a crash.
|
||||
*/
|
||||
export function collectClaudeStatuslineSignals(deps: ClaudeUsageReaderDeps): ProviderSignal[] {
|
||||
const now = deps.now();
|
||||
const out: ProviderSignal[] = [];
|
||||
for (const seat of deps.listClaudeSeats()) {
|
||||
let cachePresent = false;
|
||||
let cacheUnreadable = false;
|
||||
let reading: ClaudeStatuslineReading | undefined;
|
||||
let staleAfter: string | undefined;
|
||||
let capturedAsOf: string | undefined;
|
||||
@@ -85,8 +84,7 @@ export function collectClaudeStatuslineSignals(deps: ClaudeUsageReaderDeps): Pro
|
||||
staleAfter = parsed.staleAfter;
|
||||
capturedAsOf = typeof parsed.asOf === "string" ? parsed.asOf : undefined;
|
||||
} catch {
|
||||
// Malformed cache → present-but-unreadable (its own unknown), never a throw.
|
||||
cacheUnreadable = true;
|
||||
// Malformed cache → present-with-no-windows (empty_reading unknown), never a throw.
|
||||
reading = undefined;
|
||||
}
|
||||
}
|
||||
@@ -94,7 +92,6 @@ export function collectClaudeStatuslineSignals(deps: ClaudeUsageReaderDeps): Pro
|
||||
...claudeStatuslineSignals({
|
||||
seatSession: seat.seatSession,
|
||||
cachePresent,
|
||||
cacheUnreadable,
|
||||
reading,
|
||||
// Signal asOf = when the reading was captured (the cache's asOf) when present+valid; else now.
|
||||
asOf: cachePresent && capturedAsOf ? capturedAsOf : now,
|
||||
@@ -112,7 +109,6 @@ export function collectClaudeSignalsFromProviderUsageDirectory(
|
||||
legacyDirectory?: string,
|
||||
): ProviderSignal[] {
|
||||
const seats = new Map<string, ClaudeSeatRef>();
|
||||
const unreadableFiles = new Set<string>();
|
||||
for (const candidateDirectory of [directory, legacyDirectory]) {
|
||||
if (!candidateDirectory) continue;
|
||||
try {
|
||||
@@ -123,16 +119,10 @@ export function collectClaudeSignalsFromProviderUsageDirectory(
|
||||
if (typeof parsed.seatSession === "string") {
|
||||
seats.set(parsed.seatSession, { seatSession: parsed.seatSession });
|
||||
}
|
||||
} catch {
|
||||
// A cache file that exists but cannot be read or parsed still names its seat in the
|
||||
// filename (telemetrySidecarFilename sanitizes the same seat name). Keep it in the
|
||||
// seat list so the reader reports an unreadable cache instead of the file vanishing.
|
||||
unreadableFiles.add(file.slice(0, -".json".length));
|
||||
}
|
||||
} catch { /* malformed cache cannot create a usable cache signal */ }
|
||||
}
|
||||
} catch { /* absent cache directory */ }
|
||||
}
|
||||
for (const seatSession of unreadableFiles) seats.set(seatSession, { seatSession });
|
||||
|
||||
const readFrom = (candidateDirectory: string, seatSession: string): string | null => {
|
||||
try {
|
||||
|
||||
@@ -131,8 +131,6 @@ export interface ClaudeSignalInput {
|
||||
seatSession: string;
|
||||
/** false = the statusline cache has not been written yet (before the first API response). */
|
||||
cachePresent: boolean;
|
||||
/** True when a cache file exists but cannot be read or parsed. Distinct from an empty reading. */
|
||||
cacheUnreadable?: boolean;
|
||||
/** Present only when the cache exists and carried a rate_limits object. */
|
||||
reading?: ClaudeStatuslineReading;
|
||||
asOf: string;
|
||||
@@ -142,7 +140,6 @@ export interface ClaudeSignalInput {
|
||||
export const CLAUDE_UNKNOWN_REASON = {
|
||||
no_statusline_cache_yet: "claude_no_statusline_cache_yet",
|
||||
empty_reading: "claude_statusline_cache_had_no_windows",
|
||||
unreadable_cache: "claude_statusline_cache_unreadable",
|
||||
} as const;
|
||||
|
||||
function claudeUnknownSignal(
|
||||
@@ -168,22 +165,17 @@ function claudeUnknownSignal(
|
||||
/**
|
||||
* Normalize a Claude statusline `rate_limits` reading into `signals[]` rows.
|
||||
* - seat before first response (no cache) → explicit unknown row.
|
||||
* - a cache that exists but cannot be read/parsed → its own unknown reason (broken, not empty).
|
||||
* - seat with subscription windows → one `provider_statusline` row per window (five_hour, and
|
||||
* seven_day normalized to "weekly"); a genuine 0 survives as 0.
|
||||
* - subscription with cache but no windows → explicit unknown row (never fabricated zeros).
|
||||
*/
|
||||
export function claudeStatuslineSignals(input: ClaudeSignalInput): ProviderSignal[] {
|
||||
const { seatSession, cachePresent, cacheUnreadable, reading, asOf, staleAfter } = input;
|
||||
const { seatSession, cachePresent, reading, asOf, staleAfter } = input;
|
||||
|
||||
if (!cachePresent) {
|
||||
return [claudeUnknownSignal(seatSession, asOf, CLAUDE_UNKNOWN_REASON.no_statusline_cache_yet, staleAfter)];
|
||||
}
|
||||
|
||||
if (cacheUnreadable) {
|
||||
return [claudeUnknownSignal(seatSession, asOf, CLAUDE_UNKNOWN_REASON.unreadable_cache, staleAfter)];
|
||||
}
|
||||
|
||||
const windows: Array<["five_hour" | "weekly", ClaudeStatuslineWindow | undefined]> = [
|
||||
["five_hour", reading?.five_hour],
|
||||
["weekly", reading?.seven_day],
|
||||
|
||||
@@ -34,7 +34,6 @@ import { createLinkedSuccessorLookup, deriveCrossHostSuccessorId, hasLinkedSucce
|
||||
import { stalledPickupFinding } from "./queue-pickup.js";
|
||||
import { SettingsStore } from "./user-settings/settings-store.js";
|
||||
import { loadHostRegistry } from "./hosts/hosts-registry-reader.js";
|
||||
import { readWakeOutcome } from "./connection-contract.js";
|
||||
|
||||
export const STUCK_SWEEP_INTERVAL_KEY = "queue.stuck_sweep_interval_seconds";
|
||||
export const DEFAULT_STUCK_SWEEP_INTERVAL_SECONDS = 300;
|
||||
@@ -471,9 +470,8 @@ function deliveryResolutionEvidence(
|
||||
if (row.claimedAt) return `source claimed at ${row.claimedAt}`;
|
||||
if (row.state === "blocked") return "source parked";
|
||||
if (kind === "undelivered-wake") {
|
||||
const wake = readWakeOutcome(row.lastNudgeResult);
|
||||
if (wake?.fact === "accepted" && wake.renderVerified) return "source wake verified";
|
||||
if (wake?.fact === "accepted" && wake.route === "hosted-trigger") return "source wake accepted by its hosted trigger";
|
||||
if (row.lastNudgeResult === "verified") return "source wake verified";
|
||||
if (row.lastNudgeResult?.startsWith("wake-accepted:")) return "source wake accepted by its hosted trigger";
|
||||
}
|
||||
}
|
||||
if (deps.queueRepo.humanNotificationPostedThisEpisode(qitemId)) return "current human ask posted";
|
||||
|
||||
@@ -12,7 +12,6 @@ import type { OperatorDeliveryEngine } from "./gateway/operator-delivery-engine.
|
||||
import { openPromptRefusals, CLOSE_PREFIX, PARKED_OWNER_POLICY_NAME, REFUSED_PREFIX } from "./policies/parked-owner-consumer.js";
|
||||
import { findQueueRecovery, recoveryTag } from "./queue-recovery.js";
|
||||
import { lastMeaningfulTransition, type WaitingView } from "./queue-waiting.js";
|
||||
import { readWakeOutcome } from "./connection-contract.js";
|
||||
// S01 (OPR.0.5.5.1) — WAKE OR ESCALATE ON BATONS. A handoff whose wake fails must never
|
||||
// silently park: today one failed nudge is recorded and nothing follows (the measured
|
||||
// dominant 0.5.3 failure class — a perfect surviving packet, a recipient never woken).
|
||||
@@ -384,44 +383,28 @@ function hasPickupEvidence(db: Database.Database, row: Pick<QueueItem, "qitemId"
|
||||
type WakeMode = "failed" | "unconfirmed" | "held";
|
||||
|
||||
function classifyWakeResult(lastNudgeResult: string | null): WakeMode | null {
|
||||
// A claimed row's unconfirmed result is presented as `claimed` (presentedNudgeResult): the claim is pickup, which
|
||||
// outranks any wake outcome.
|
||||
if (lastNudgeResult === "claimed") return null;
|
||||
const outcome = readWakeOutcome(lastNudgeResult);
|
||||
if (!outcome) return null;
|
||||
switch (outcome.fact) {
|
||||
// Nothing reached the seat, so a retry can't double-deliver. An address no route can carry (`none`) has nothing
|
||||
// to retry; its own notice covers it.
|
||||
case "not-delivered":
|
||||
return outcome.route === "none" ? null : "failed";
|
||||
// A webhook failure after the POST: its effect is unknown, but it keeps the failed path and retry policy it has
|
||||
// always had.
|
||||
case "failed-effect-unknown":
|
||||
return "failed";
|
||||
// #344 follow-up — a typing-guard refusal (retained:) wrote NO pane input, so an
|
||||
// unclaimed baton keeps a retry path instead of stranding behind a terminal
|
||||
// recovery. Admission is scoped to unclaimed batons by unclaimedWakeMode(): the
|
||||
// claimed parked-owner arm's retry readback still selects `failed:%` only, so
|
||||
// admitting retained there would make recovery OWN a row that nothing retries.
|
||||
// Retry behaviour, as it actually runs: the first retry re-probes the guard and
|
||||
// records a fresh attempt; later retries reuse the same delivery id, whose
|
||||
// retained readback returns `retained` again even after the guard has cleared —
|
||||
// so the row re-probes once and then escalates rather than looping probes.
|
||||
// A draft-aware hold can clear on its own (the draft is sent or cleared), so it keeps that retry path. A hold or a
|
||||
// level that holds the wake is the person's choice: retrying is held again, so it reads as held.
|
||||
case "held":
|
||||
return outcome.heldBy === "typing-guard" ? "held" : "failed";
|
||||
// A rendered terminal wake, or a hosted trigger that took the ring (the stuck sweep owns a hosted member's
|
||||
// unclaimed work), never enters. An unverified terminal send rides the confirmation path.
|
||||
case "accepted":
|
||||
return outcome.renderVerified || outcome.route === "hosted-trigger" ? null : "unconfirmed";
|
||||
// Unknown, including a recorded result no reader recognises: it never retries (input may have arrived), and it
|
||||
// escalates past the window when nothing picked the work up, instead of reading as delivered.
|
||||
case "queued-not-started":
|
||||
case "turn-started":
|
||||
case "unknown":
|
||||
return "unconfirmed";
|
||||
}
|
||||
if (!lastNudgeResult) return null;
|
||||
if (lastNudgeResult.startsWith("failed:")) return "failed";
|
||||
// #344 follow-up — a typing-guard refusal (retained:) wrote NO pane input, so an
|
||||
// unclaimed baton keeps a retry path instead of stranding behind a terminal
|
||||
// recovery. Admission is scoped to unclaimed batons by unclaimedWakeMode(): the
|
||||
// claimed parked-owner arm's retry readback still selects `failed:%` only, so
|
||||
// admitting retained there would make recovery OWN a row that nothing retries.
|
||||
// Retry behaviour, as it actually runs: the first retry re-probes the guard and
|
||||
// records a fresh attempt; later retries reuse the same delivery id, whose
|
||||
// retained readback returns `retained` again even after the guard has cleared —
|
||||
// so the row re-probes once and then escalates rather than looping probes.
|
||||
// A draft-aware hold can clear on its own (the draft is sent or cleared), so it keeps that retry path. A hold or a
|
||||
// level that holds the wake is the person's choice: retrying is held again, so it reads as held.
|
||||
if (lastNudgeResult === "retained:typing_guard") return "held";
|
||||
if (lastNudgeResult.startsWith("retained:")) return "failed";
|
||||
if (
|
||||
lastNudgeResult === "delivered-ack-pending" ||
|
||||
lastNudgeResult.startsWith("indeterminate:") ||
|
||||
lastNudgeResult.startsWith("gateway-owned:")
|
||||
)
|
||||
return "unconfirmed";
|
||||
return null; // verified (or unknown vocabulary) — never enters the ladder
|
||||
}
|
||||
|
||||
/** A retained (typing-guard) result is retryable ONLY for an unclaimed baton. The
|
||||
@@ -433,7 +416,7 @@ function classifyWakeResult(lastNudgeResult: string | null): WakeMode | null {
|
||||
function unclaimedWakeMode(row: Pick<QueueItem, "state" | "claimedAt" | "handedOffFrom" | "lastNudgeResult">): WakeMode | null {
|
||||
const mode = classifyWakeResult(row.lastNudgeResult);
|
||||
if (mode === null) return null;
|
||||
const retained = readWakeOutcome(row.lastNudgeResult)?.fact === "held";
|
||||
const retained = row.lastNudgeResult?.startsWith("retained:") ?? false;
|
||||
if (retained && !(row.state === "pending" && !row.claimedAt && row.handedOffFrom)) return null;
|
||||
return mode;
|
||||
}
|
||||
@@ -880,9 +863,7 @@ export async function runWakeLadderTick(deps: WakeLadderDeps): Promise<WakeLadde
|
||||
? `wake failed ${view.attempts} times over ${minutesSince(view.firstMarkerTs ?? row.tsCreated, now)} min`
|
||||
: mode === "held"
|
||||
? `held by ${row.destinationSession}'s typing guard for ${minutesSince(row.tsCreated, now)} min: nothing was typed, and it isn't retried`
|
||||
: readWakeOutcome(row.lastNudgeResult)?.route === null
|
||||
? `wake outcome unknown (unrecognised result "${row.lastNudgeResult!.slice(0, 80)}") with no pickup evidence over ${minutesSince(row.tsCreated, now)} min`
|
||||
: `unconfirmed delivery with no pickup evidence over ${minutesSince(row.tsCreated, now)} min`;
|
||||
: `unconfirmed delivery with no pickup evidence over ${minutesSince(row.tsCreated, now)} min`;
|
||||
if (hasConsumedEscalationHop(deps.db, row.qitemId)) {
|
||||
if (!due || suspended) continue;
|
||||
const consumedReason = `${reason}; escalates_to one-hop delivery already used; existing fallback retained`;
|
||||
|
||||
@@ -17,25 +17,21 @@ import nodePath from "node:path";
|
||||
import { lstatSync } from "node:fs";
|
||||
import type Database from "better-sqlite3";
|
||||
import {
|
||||
MANAGED_BLOCK_START,
|
||||
MANAGED_BLOCK_END,
|
||||
DEFAULT_CLAUDE_MANAGED_BLOCK_FILE,
|
||||
findManagedBlocks,
|
||||
mergeManagedBlock,
|
||||
stripManagedBlocks,
|
||||
type ManagedBlockMergeFsOps,
|
||||
isPerSeatRoleGuidance,
|
||||
isPerSeatRoleStartupFile,
|
||||
} from "./managed-blocks.js";
|
||||
import { decideManagedBlock, recordManagedBlockWrite, type ManagedBlockRecords } from "./managed-block-records.js";
|
||||
import { decideManagedBlock, pruneKeptManagedBlock, recordManagedBlockWrite, type ManagedBlockRecords } from "./managed-block-records.js";
|
||||
import { resolveConcreteHint } from "./runtime-adapter.js";
|
||||
import { reanchorBuiltinStartupFile, reanchorShippedProjectionEntry, withContextEntryFloor } from "./builtin-startup-files.js";
|
||||
import type { NodeStartupSnapshot } from "./types.js";
|
||||
import { SEAT_SKILLS_BOOTSTRAP_BLOCK_ID } from "./seat-skill-storage.js";
|
||||
import { guidanceFileIsLink } from "./footprint-choice.js";
|
||||
|
||||
const LEGACY_BLOCK_START = (id: string) => `<!-- BEGIN RIGGED MANAGED BLOCK: ${id} -->`;
|
||||
const LEGACY_BLOCK_END = (id: string) => `<!-- END RIGGED MANAGED BLOCK: ${id} -->`;
|
||||
|
||||
|
||||
export interface GuidanceItem {
|
||||
blockId: string;
|
||||
@@ -97,15 +93,10 @@ export function recordedGuidanceFile(db: Pick<Database.Database, "prepare">, nod
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
/** Whether guidance text holds this id's block as it is written: a BEGIN marker followed later by its END, in the
|
||||
* current or legacy form. For reporting an installed file; restore's own `blockState` is unchanged. */
|
||||
/** Whether guidance text holds this id's block as it is written: a BEGIN marker followed by its END, in the current or
|
||||
* legacy form, read as the writer reads it (`findManagedBlocks`). For reporting an installed file. */
|
||||
export function hasCompleteGuidanceBlock(content: string, blockId: string): boolean {
|
||||
const ordered = (start: string, end: string): boolean => {
|
||||
const begin = content.indexOf(start);
|
||||
return begin >= 0 && content.indexOf(end, begin + start.length) >= 0;
|
||||
};
|
||||
return ordered(MANAGED_BLOCK_START(blockId), MANAGED_BLOCK_END(blockId))
|
||||
|| ordered(LEGACY_BLOCK_START(blockId), LEGACY_BLOCK_END(blockId));
|
||||
return findManagedBlocks(content, blockId).blocks.length > 0;
|
||||
}
|
||||
|
||||
/** The guidance file a seat's managed blocks live in, and the blocks it should hold on restore. */
|
||||
@@ -199,14 +190,13 @@ export function applySeatSkillsLineRule(guidance: SeatGuidance, answeredYes: boo
|
||||
|
||||
type BlockState = "complete" | "absent" | "incomplete";
|
||||
|
||||
/** A block counts only with both its markers (current or legacy form). A lone marker is incomplete:
|
||||
* writing next to it is unsafe, because cleanup strips from a BEGIN to the next END. */
|
||||
/** A block counts only with both its markers (current or legacy form), read as the writer reads them. A lone marker is
|
||||
* incomplete: writing next to it is unsafe, because cleanup strips from a BEGIN to the next END, and it would add a
|
||||
* second copy beside one that can't be read. */
|
||||
function blockState(content: string, blockId: string): BlockState {
|
||||
const current = [content.includes(MANAGED_BLOCK_START(blockId)), content.includes(MANAGED_BLOCK_END(blockId))];
|
||||
const legacy = [content.includes(LEGACY_BLOCK_START(blockId)), content.includes(LEGACY_BLOCK_END(blockId))];
|
||||
if ((current[0] && current[1]) || (legacy[0] && legacy[1])) return "complete";
|
||||
if (current[0] || current[1] || legacy[0] || legacy[1]) return "incomplete";
|
||||
return "absent";
|
||||
const found = findManagedBlocks(content, blockId);
|
||||
if (found.blocks.length > 0) return "complete";
|
||||
return found.unpaired > 0 ? "incomplete" : "absent";
|
||||
}
|
||||
|
||||
function blockStates(guidance: SeatGuidance, fs: Pick<ManagedBlockMergeFsOps, "exists" | "readFile">): Map<string, BlockState> {
|
||||
@@ -228,11 +218,13 @@ export interface GuidanceRepair {
|
||||
refreshed: string[];
|
||||
/** #2456: blocks left as they are although they differ from their source, each named with the reason
|
||||
* (`guidance_block_edited`, `guidance_block_may_be_stale` or `guidance_block_record_unreadable`). The seat runs them as
|
||||
* they are. A block written whose record wasn't saved is named here too (`guidance_block_record_not_saved`). */
|
||||
* they are. A block written whose record wasn't saved is named here too (`guidance_block_record_not_saved`), and so are
|
||||
* a kept block's removed repeats (`guidance_block_copies_removed`). */
|
||||
kept: string[];
|
||||
}
|
||||
|
||||
const KEPT_GUIDANCE_PREFIXES = ["guidance_block_edited: ", "guidance_block_may_be_stale: ", "guidance_block_record_unreadable: "];
|
||||
const KEPT_GUIDANCE_PREFIXES = ["guidance_block_edited: ", "guidance_block_may_be_stale: ", "guidance_block_record_unreadable: ",
|
||||
"guidance_block_markers_damaged: "];
|
||||
|
||||
/** #2456: the one line a seat is told when a managed guidance block was left as it is (edited by hand, or possibly stale),
|
||||
* naming each one, or undefined when none was. */
|
||||
@@ -261,7 +253,8 @@ export function appendIsCleanupSafe(content: string, blocks: Array<{ blockId: st
|
||||
|
||||
/** Put back the missing managed guidance blocks. With `records` (#2456), a block already there that OpenRig wrote is
|
||||
* refreshed from its changed source, and one edited since, or with no record, is kept and named (`decideManagedBlock`).
|
||||
* Without them a block already there is left as it is, as before. Any other text in the file is left exactly as it is,
|
||||
* Without them a block already there keeps its text. Either way a block left in place loses its exact repeats, and
|
||||
* OpenRig's own copies beside an edited one, and rewrapped markers go back to OpenRig's form (`pruneKeptManagedBlock`). Any other text in the file is left exactly as it is,
|
||||
* and nothing is written unless cleanup would later remove exactly what was written. Never throws: a failure becomes a
|
||||
* reported gap. */
|
||||
export function restoreMissingGuidance(guidance: SeatGuidance, fs: ManagedBlockMergeFsOps, records?: ManagedBlockRecords): GuidanceRepair {
|
||||
@@ -279,17 +272,27 @@ export function restoreMissingGuidance(guidance: SeatGuidance, fs: ManagedBlockM
|
||||
for (const item of guidance.items) {
|
||||
const state = states.get(item.blockId);
|
||||
if (state === "complete") {
|
||||
if (!records || item.unresolved) continue;
|
||||
if (item.unresolved) continue;
|
||||
let source: string;
|
||||
try {
|
||||
if (!fs.exists(item.sourcePath)) continue;
|
||||
source = fs.readFile(item.sourcePath);
|
||||
} catch { continue; }
|
||||
// Without records the block's text stays as it is; only its exact repeats and rewrapped markers are tidied.
|
||||
if (!records) {
|
||||
pruneKeptManagedBlock(fs, guidance.targetPath, item.blockId, source, undefined, repair.kept);
|
||||
continue;
|
||||
}
|
||||
// #2456: the same decision every adapter's guidance write makes; an exact resume and a handover keep an unrecorded block.
|
||||
const decision = decideManagedBlock(existing ?? "", item.blockId, source, records, guidance.targetPath, { unrecorded: "keep", sourcePath: item.sourcePath });
|
||||
if (decision.note) repair.kept.push(decision.note);
|
||||
else if (decision.standing === "current") recordManagedBlockWrite(records, guidance.targetPath, item.blockId, source, repair.kept);
|
||||
else if (decision.write) toWrite.push({ blockId: item.blockId, content: source, refresh: true });
|
||||
if (decision.write && decision.standing !== "current") {
|
||||
toWrite.push({ blockId: item.blockId, content: source, refresh: true });
|
||||
continue;
|
||||
}
|
||||
if (decision.standing === "current") recordManagedBlockWrite(records, guidance.targetPath, item.blockId, source, repair.kept);
|
||||
// A block left as it is (current, edited or unrecorded) keeps one copy of each distinct text, in OpenRig's markers.
|
||||
pruneKeptManagedBlock(fs, guidance.targetPath, item.blockId, source, records, repair.kept);
|
||||
continue;
|
||||
}
|
||||
if (state === "incomplete") {
|
||||
@@ -313,6 +316,9 @@ export function restoreMissingGuidance(guidance: SeatGuidance, fs: ManagedBlockM
|
||||
if (toWrite.length === 0) return repair;
|
||||
// A file that doesn't exist yet holds no text to lose. An existing one is written only if the
|
||||
// next rig down would remove exactly what is added.
|
||||
// Read again: removing a kept block's repeats above may have changed the file.
|
||||
try { existing = fs.exists(guidance.targetPath) ? fs.readFile(guidance.targetPath) : null; }
|
||||
catch (err) { repair.gaps.push(`could not read ${guidance.targetPath}: ${(err as Error).message}`); return repair; }
|
||||
if (existing !== null && !appendIsCleanupSafe(existing, toWrite)) {
|
||||
for (const block of toWrite) {
|
||||
repair.gaps.push(`${block.blockId}: not written, because ${guidance.targetPath} has a managed-block marker that the next rig down would strip together with other text, so adding blocks there could cost that text`);
|
||||
@@ -321,7 +327,7 @@ export function restoreMissingGuidance(guidance: SeatGuidance, fs: ManagedBlockM
|
||||
}
|
||||
for (const block of toWrite) {
|
||||
try {
|
||||
mergeManagedBlock(fs, guidance.targetPath, block.blockId, block.content);
|
||||
if (!mergeManagedBlock(fs, guidance.targetPath, block.blockId, block.content, { warnings: repair.gaps })) continue;
|
||||
if (records) recordManagedBlockWrite(records, guidance.targetPath, block.blockId, block.content, repair.kept);
|
||||
(block.refresh ? repair.refreshed : repair.restored).push(block.blockId);
|
||||
} catch (err) {
|
||||
|
||||
@@ -73,8 +73,7 @@ const SEAT_LOOKUP_GUIDANCE = "List seats with: rig ps --nodes";
|
||||
|
||||
/**
|
||||
* The supported route for a session registered as adopted (origin=claimed): `rig unclaim` releases
|
||||
* it and leaves its process running, so ending that process and its tmux session ends only this seat. Closing only
|
||||
* a viewing window detaches from the tmux session and leaves it running.
|
||||
* it and leaves its process running, so ending that process in its own terminal ends only this seat.
|
||||
* `rig down` stops only a session carrying the seat's OpenRig marker, which OpenRig sets on the sessions
|
||||
* it starts and an adopted, bound or self-attached session lacks, so it isn't a route here and the
|
||||
* guidance says why. Only the guidance names the route; the refusal stands. A seat handed over before
|
||||
@@ -86,13 +85,9 @@ export function claimedSessionRoutes(rigName: string, sessionName: string, exter
|
||||
const release = `To release it from OpenRig and leave its process running, run rig unclaim ${session}.`;
|
||||
if (externalCli) return `This seat was joined with rig attach as an external CLI, so rig down leaves it running. ${release} End the process in its own terminal.`;
|
||||
return `To end this session, run rig unclaim ${session} (it releases the session from OpenRig and leaves its process running), `
|
||||
+ `then end that process and the underlying tmux session, including any remaining shell, so no session keeps the seat's name. `
|
||||
+ `${VIEWER_LEAVES_IT} ${rigDownLeavesIt(rigName)}`;
|
||||
+ `then end that process in its own terminal. ${rigDownLeavesIt(rigName)}`;
|
||||
}
|
||||
|
||||
/** Closing a viewer detaches; the tmux session, its shell and the seat's name stay. */
|
||||
const VIEWER_LEAVES_IT = "Closing only its viewing window leaves the tmux session running.";
|
||||
|
||||
/** Why `rig down <rig>` isn't the route for an adopted session. */
|
||||
function rigDownLeavesIt(rigName: string): string {
|
||||
return `rig down ${shellQuote(rigName)} leaves a session OpenRig didn't start running.`;
|
||||
@@ -1386,8 +1381,6 @@ export class SeatLifecycleService {
|
||||
ok: false,
|
||||
code: "unmanaged_session_collision",
|
||||
message: `Canonical tmux session "${canonicalSessionName}" exists but is not owned by this seat's current managed rows; refusing to overwrite it.`,
|
||||
guidance: "If you released this session with rig unclaim, end that underlying tmux session, including any remaining shell, "
|
||||
+ "then launch again. Closing only its viewing window doesn't end the session.",
|
||||
};
|
||||
}
|
||||
if (!stop) {
|
||||
@@ -1431,10 +1424,8 @@ export class SeatLifecycleService {
|
||||
return {
|
||||
ok: false, code: "claimed_session",
|
||||
message: `Seat "${seatRef}" has an adopted/operator-owned occupant; fresh launch refuses even with --stop.`,
|
||||
guidance: `Release it with rig unclaim ${shellQuote(claimed.session_name)} (its process keeps running), `
|
||||
+ (externalCli
|
||||
? "end that process and close its terminal, then launch fresh."
|
||||
: `end that process and the underlying tmux session, including any remaining shell, then launch fresh. ${VIEWER_LEAVES_IT} ${rigDownLeavesIt(seat.rigName)}`),
|
||||
guidance: `Release it with rig unclaim ${shellQuote(claimed.session_name)} (its process keeps running), end that process yourself, then launch fresh.`
|
||||
+ (externalCli ? "" : ` ${rigDownLeavesIt(seat.rigName)}`),
|
||||
};
|
||||
}
|
||||
const binding = this.sessionRegistry.getBindingForNode(nodeId);
|
||||
|
||||
@@ -124,25 +124,14 @@ export interface CurrentSessionAddress {
|
||||
* Physical names win over derived names. Archived/historical bindings cannot
|
||||
* advertise a new alias, and two current matches never pick an arbitrary seat. */
|
||||
export function resolveCurrentSessionAddress(db: Database.Database, address: string): CurrentSessionAddress | null {
|
||||
const current = `SELECT r.name, r.archived_at, n.id AS nodeId, n.logical_id AS logicalId,
|
||||
const rows = db.prepare(`SELECT r.*, n.id AS nodeId, n.logical_id AS logicalId,
|
||||
s.id AS sessionId, s.session_name AS sessionName, s.status,
|
||||
replace(n.logical_id, '.', '-') || '@' || r.name AS senderAddress
|
||||
FROM nodes n JOIN rigs r ON r.id=n.rig_id
|
||||
JOIN bindings b ON b.node_id=n.id AND b.tmux_session IS NOT NULL
|
||||
CROSS JOIN sessions s ON s.node_id=n.id AND s.session_name=b.tmux_session
|
||||
AND s.id=(SELECT id FROM sessions WHERE node_id=n.id ORDER BY id DESC LIMIT 1)`;
|
||||
// Inventory passes physical names for every seat. The OR with a derived alias
|
||||
// made each lookup walk all nodes (or all historical sessions). Seek the exact
|
||||
// name first; only an absent active exact match needs the alias walk. Keep this
|
||||
// in one statement so both alternatives observe the same database snapshot.
|
||||
const rows = db.prepare(`WITH exact AS MATERIALIZED (
|
||||
${current} WHERE b.tmux_session=? AND r.archived_at IS NULL
|
||||
AND COALESCE(s.status, '') NOT IN ('superseded', 'detached', 'exited')
|
||||
) SELECT * FROM exact
|
||||
UNION ALL
|
||||
${current}
|
||||
WHERE replace(n.logical_id, '.', '-') || '@' || r.name=?
|
||||
AND NOT EXISTS (SELECT 1 FROM exact)`)
|
||||
JOIN sessions s ON s.node_id=n.id AND s.session_name=b.tmux_session
|
||||
AND s.id=(SELECT id FROM sessions WHERE node_id=n.id ORDER BY id DESC LIMIT 1)
|
||||
WHERE s.session_name=? OR replace(n.logical_id, '.', '-') || '@' || r.name=?`)
|
||||
.all(address, address) as Array<CurrentSessionAddress & { name: string; archived_at?: string | null; status: string }>;
|
||||
const active = rows.filter(row => row.archived_at == null && !['superseded', 'detached', 'exited'].includes(row.status));
|
||||
const exact = active.filter(row => row.sessionName === address);
|
||||
@@ -794,7 +783,7 @@ export class SessionRegistry {
|
||||
`SELECT s.* FROM sessions s
|
||||
JOIN nodes n ON s.node_id = n.id
|
||||
WHERE n.rig_id = ?
|
||||
ORDER BY s.created_at, s.rowid`
|
||||
ORDER BY s.created_at`
|
||||
)
|
||||
.all(rigId) as SessionRow[];
|
||||
|
||||
|
||||
@@ -282,7 +282,6 @@ function findCurrentClaudeQuestion(paneContent: string): string | null {
|
||||
const CLAUDE_STATUS_WARNINGS = [
|
||||
/^✘ Auto-update failed(?:: [^·\r\n]+)? · Run claude doctor$/,
|
||||
/^✘ Auto-update failed · Try claude doctor or (?:npm i -g @anthropic-ai\/claude-code|cd ~\/\.claude\/local && npm update @anthropic-ai\/claude-code)$/,
|
||||
/^✘ Auto-update failed · Try claude doctor or npm i -g @anthropic-ai\/claude-c…$/,
|
||||
/^tmux focus-events off · add 'set -g focus-events on' to ~\/\.tmux\.conf and re…$/,
|
||||
/^You've used (?:\d|[1-9]\d)% of your weekly limit · resets \d{1,2}(?::\d{2})?(?:am|pm) \(UTC\)$/,
|
||||
// #1641: Claude 2.1.293's update banner, whatever installer it names.
|
||||
|
||||
@@ -273,7 +273,7 @@ packagesRoutes.post("/install", async (c) => {
|
||||
}
|
||||
|
||||
// Verify
|
||||
const verification = installVerifier.verify(result.installId);
|
||||
const verification = installVerifier.verify(result.installId, { skipped: result.notes?.length ?? 0 });
|
||||
if (!verification.passed) {
|
||||
eventBus.emit({ type: "package.install_failed", packageName: resolved.manifest.name, code: "verification_failed", message: "Post-apply verification failed" });
|
||||
return c.json({
|
||||
@@ -281,6 +281,7 @@ packagesRoutes.post("/install", async (c) => {
|
||||
code: "verification_failed",
|
||||
installId: result.installId,
|
||||
verification,
|
||||
...(result.notes?.length ? { notes: result.notes } : {}),
|
||||
}, 500);
|
||||
}
|
||||
|
||||
@@ -302,6 +303,7 @@ packagesRoutes.post("/install", async (c) => {
|
||||
conflicts: result.conflicts,
|
||||
verification,
|
||||
...(policyResult.rejected.length > 0 ? { policyRejected: policyResult.rejected } : {}),
|
||||
...(result.notes?.length ? { notes: result.notes } : {}),
|
||||
}, 201);
|
||||
});
|
||||
|
||||
|
||||
@@ -1387,6 +1387,38 @@ exports:
|
||||
expect(result.errors.some((e) => e.includes("skipped"))).toBe(true);
|
||||
});
|
||||
|
||||
it("a package whose only guidance entry is left as it is still imports the rig, with the note in warnings", async () => {
|
||||
writePkg(path.join(tmpDir, "test-pkg"), VALID_MANIFEST_YAML, { "skills/helper/SKILL.md": "# Helper" });
|
||||
const guidanceManifest = `
|
||||
schema_version: 1
|
||||
name: guidance-pkg
|
||||
version: "1.0.0"
|
||||
summary: Guidance only
|
||||
compatibility:
|
||||
runtimes: [claude-code]
|
||||
exports:
|
||||
guidance:
|
||||
- source: guidance/rules.md
|
||||
name: rules
|
||||
kind: claude_md
|
||||
supported_scopes: [project_shared]
|
||||
default_scope: project_shared
|
||||
merge_strategy: managed_block
|
||||
`.trim();
|
||||
writePkg(path.join(tmpDir, "guidance-pkg"), guidanceManifest, { "guidance/rules.md": "Follow these rules." });
|
||||
const damaged = "# Project\n\n<!-- BEGIN OpenRig MANAGED BLOCK: guidance-pkg -->\nhalf a block\n";
|
||||
fs.writeFileSync(path.join(tmpDir, "CLAUDE.md"), damaged);
|
||||
const specPath = writeSpec(SPEC_WITH_PACKAGES_YAML.replace(" - ./test-pkg", " - ./test-pkg\n - ./guidance-pkg"));
|
||||
|
||||
const result = await buildOrchestrator().bootstrap({ mode: "apply", sourceRef: specPath, autoApprove: true });
|
||||
|
||||
expect(fs.readFileSync(path.join(tmpDir, "CLAUDE.md"), "utf-8")).toBe(damaged);
|
||||
expect(result.errors).toEqual([]);
|
||||
expect(result.status).toBe("completed");
|
||||
expect(result.stages.find((s) => s.stage === "import_rig")?.status).not.toBe("skipped");
|
||||
expect(result.warnings).toContainEqual(expect.stringMatching(/^Package '\.\/guidance-pkg' \(claude-code\): guidance_block_markers_damaged: .*CLAUDE\.md: guidance-pkg has a BEGIN or END marker without its pair/));
|
||||
});
|
||||
|
||||
// === P7-T05: Bundle source tests ===
|
||||
|
||||
// T25: Real bundle bootstrap happy path
|
||||
|
||||
@@ -89,23 +89,6 @@ describe("cursor-bound composer input", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["", "person's draft"])("reads the observed clipped auto-update footer with input %j", body => {
|
||||
// A complete synthetic frame; the retained partial capture cannot supply physical cursor rows.
|
||||
const snapshot = claude(body);
|
||||
snapshot.screen = snapshot.screen.replace("? for shortcuts", "⏵⏵ accept edits on (shift+tab to cycle)\n✘ Auto-update failed · Try claude doctor or npm i -g @anthropic-ai/claude-c…");
|
||||
expect(inspectComposerInput(snapshot, "claude-code").state).toBe(body ? "text" : "empty");
|
||||
});
|
||||
|
||||
it("keeps near misses unknown below the footer and warning text inside a draft", () => {
|
||||
const warning = "✘ Auto-update failed · Try claude doctor or npm i -g @anthropic-ai/claude-c…";
|
||||
for (const nearMiss of [warning + " now", warning.replace("doctor", "doctors"), warning.replace("claude-c…", "other…")]) {
|
||||
const snapshot = claude("");
|
||||
snapshot.screen = snapshot.screen.replace("? for shortcuts", `? for shortcuts\n${nearMiss}`);
|
||||
expect(inspectComposerInput(snapshot, "claude-code").state).toBe("unknown");
|
||||
}
|
||||
expect(inspectComposerInput(claude(warning, undefined, 100), "claude-code").state).toBe("text");
|
||||
});
|
||||
|
||||
it("reads Claude's context hint below a named composer without skipping arbitrary rows", () => {
|
||||
const frame = (body: string, styled = false, hint = "~66k uncached · /clear to start fresh"): ComposerSnapshot => ({
|
||||
screen: [...Array<string>(22).fill(""),
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readWakeOutcome } from "../src/domain/connection-contract.js";
|
||||
import { NUDGE_FAIL_PREFIX } from "../src/domain/policies/parked-owner-consumer.js";
|
||||
|
||||
describe("readWakeOutcome: every recorded nudge result reads as a delivery fact", () => {
|
||||
// One row per form a producer writes today (queue-repository, hosted-wake, the destination resolver, the parked-owner
|
||||
// consumer). A new producer form belongs here too, or it reads unknown.
|
||||
const cases: Array<[string, Record<string, unknown>]> = [
|
||||
["verified", { fact: "accepted", route: "terminal", renderVerified: true }],
|
||||
["delivered-ack-pending", { fact: "accepted", route: "terminal" }],
|
||||
["retained:typing_guard", { fact: "held", route: "terminal", heldBy: "typing-guard" }],
|
||||
["retained:draft_aware", { fact: "held", route: "terminal", heldBy: "draft-aware" }],
|
||||
["wake-accepted:webhook (hook) answered 202; pickup is the member's claim on this row", { fact: "accepted", route: "hosted-trigger" }],
|
||||
["indeterminate:webhook (hook) didn't answer within 5000 ms", { fact: "unknown", route: "hosted-trigger" }],
|
||||
// A webhook failure after the POST doesn't prove the member missed the ring; one before it does.
|
||||
["failed:webhook (hook) answered 500", { fact: "failed-effect-unknown", route: "hosted-trigger", reporter: "outside-adapter" }],
|
||||
["failed:webhook (hook) answered 302, a redirect, which isn't followed", { fact: "failed-effect-unknown", route: "hosted-trigger", reporter: "outside-adapter" }],
|
||||
["failed:webhook (hook) couldn't be reached (ECONNRESET)", { fact: "failed-effect-unknown", route: "hosted-trigger" }],
|
||||
["failed:webhook (hook) isn't set: no webhook URL to ring", { fact: "not-delivered", route: "hosted-trigger" }],
|
||||
["failed:webhook (hook) not rung: its Authorization value (auth) isn't set", { fact: "not-delivered", route: "hosted-trigger" }],
|
||||
["failed:webhook (hook) not rung: the URL is invalid (bad scheme)", { fact: "not-delivered", route: "hosted-trigger" }],
|
||||
["gateway-owned: 'slack:U1@external' is a virtual @external destination", { fact: "unknown", route: "gateway" }],
|
||||
["unroutable: 'm@r' is a hosted member with no wake trigger", { fact: "not-delivered", route: "none" }],
|
||||
["indeterminate:wake send timed out", { fact: "unknown", route: "terminal" }],
|
||||
["indeterminate:earlier delivery unconfirmed; retry failed:x", { fact: "unknown", route: "terminal" }],
|
||||
["indeterminate: parked-owner wake delivery — timed out", { fact: "unknown", route: "terminal" }],
|
||||
["failed:tmux session not found", { fact: "not-delivered", route: "terminal" }],
|
||||
["failed:terminal-seat: 'sh@r' is a terminal seat", { fact: "not-delivered", route: "terminal" }],
|
||||
["failed:wake not retained: disk full", { fact: "not-delivered", route: "terminal" }],
|
||||
[`${NUDGE_FAIL_PREFIX} — refused`, { fact: "not-delivered", route: "terminal" }],
|
||||
];
|
||||
|
||||
it.each(cases)("%s", (recorded, expected) => {
|
||||
const outcome = readWakeOutcome(recorded)!;
|
||||
expect(outcome).toMatchObject({ reporter: "openrig-observed", ...expected, recorded });
|
||||
});
|
||||
|
||||
it("only a rendered terminal wake is render-verified; an unverified send is accepted, nothing more", () => {
|
||||
expect(readWakeOutcome("verified")?.renderVerified).toBe(true);
|
||||
expect(readWakeOutcome("delivered-ack-pending")?.renderVerified).toBeUndefined();
|
||||
});
|
||||
|
||||
it("an unrecognised result reads unknown with no route, never success or failure", () => {
|
||||
for (const recorded of ["accepted-by-some-new-route", "claimed", "", "VERIFIED", "ok"]) {
|
||||
expect(readWakeOutcome(recorded)).toEqual({ fact: "unknown", reporter: "openrig-observed", route: null, recorded });
|
||||
}
|
||||
});
|
||||
|
||||
it("no recorded attempt reads as no outcome", () => {
|
||||
expect(readWakeOutcome(null)).toBeNull();
|
||||
expect(readWakeOutcome(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,6 @@ import Database from "better-sqlite3";
|
||||
import { createDb } from "../src/db/connection.js";
|
||||
import { migrate } from "../src/db/migrate.js";
|
||||
import { coreSchema } from "../src/db/migrations/001_core_schema.js";
|
||||
import { rigArchiveSchema } from "../src/db/migrations/042_rig_archive.js";
|
||||
import { bindingsSessionsSchema } from "../src/db/migrations/002_bindings_sessions.js";
|
||||
import { eventsSchema } from "../src/db/migrations/003_events.js";
|
||||
import { queueItemSummarySchema } from "../src/db/migrations/044_queue_item_summary.js";
|
||||
@@ -107,7 +106,7 @@ describe("the projector's execution queue", () => {
|
||||
const NOW = new Date("2026-10-25T12:00:00.000Z");
|
||||
function fixture(file?: string) {
|
||||
const db = track(file ? createDb(file) : createDb());
|
||||
migrate(db, [coreSchema, bindingsSessionsSchema, rigArchiveSchema, eventsSchema, queueItemSummarySchema, queueItemsSchema, queueTransitionsSchema, queueTransitionWakesSchema, viewsCustomSchema]);
|
||||
migrate(db, [coreSchema, bindingsSessionsSchema, eventsSchema, queueItemSummarySchema, queueItemsSchema, queueTransitionsSchema, queueTransitionWakesSchema, viewsCustomSchema]);
|
||||
return db;
|
||||
}
|
||||
const insertRow = (db: Database.Database, id: string, state: string, tags: string[], body: string, claimedAt: string | null = "2026-10-25T11:00:00.000Z") =>
|
||||
|
||||
@@ -19,7 +19,6 @@ import type Database from "better-sqlite3";
|
||||
import { createDb } from "../src/db/connection.js";
|
||||
import { migrate } from "../src/db/migrate.js";
|
||||
import { coreSchema } from "../src/db/migrations/001_core_schema.js";
|
||||
import { rigArchiveSchema } from "../src/db/migrations/042_rig_archive.js";
|
||||
import { bindingsSessionsSchema } from "../src/db/migrations/002_bindings_sessions.js";
|
||||
import { eventsSchema } from "../src/db/migrations/003_events.js";
|
||||
import { queueItemSummarySchema } from "../src/db/migrations/044_queue_item_summary.js";
|
||||
@@ -168,7 +167,7 @@ describe("execution view — S27 (OPR.0.5.6.27)", () => {
|
||||
db = createDb();
|
||||
migrate(db, [
|
||||
coreSchema,
|
||||
bindingsSessionsSchema, rigArchiveSchema,
|
||||
bindingsSessionsSchema,
|
||||
eventsSchema,
|
||||
queueItemsSchema,
|
||||
queueItemSummarySchema,
|
||||
|
||||
@@ -108,7 +108,6 @@ import { humanQuestionsSchema } from "../../src/db/migrations/091_human_question
|
||||
import { nodeEffortSchema } from "../../src/db/migrations/092_node_effort.js";
|
||||
import { rigPolicySchema } from "../../src/db/migrations/041_rig_policy.js";
|
||||
import { rigArchiveSchema } from "../../src/db/migrations/042_rig_archive.js";
|
||||
import { sessionAddressReadIndexesSchema } from "../../src/db/migrations/162_session_address_read_indexes.js";
|
||||
import { resumeProvenanceSchema } from "../../src/db/migrations/043_resume_provenance.js";
|
||||
import { resumeVerificationSchema } from "../../src/db/migrations/045_resume_verification.js";
|
||||
import { seatIdentityVerdictsSchema } from "../../src/db/migrations/046_seat_identity_verdicts.js";
|
||||
@@ -169,7 +168,7 @@ import fs from "node:fs";
|
||||
|
||||
/** Seam B R6: the canonical full-fixture migration list, exported so file-backed
|
||||
* DB-reopen tests migrate IDENTICALLY to createFullTestDb. */
|
||||
export const migrationsForFullTestDb = [coreSchema, bindingsSessionsSchema, eventsSchema, snapshotsSchema, checkpointsSchema, resumeMetadataSchema, nodeSpecFieldsSchema, packagesSchema, installJournalSchema, journalSeqSchema, bootstrapSchema, discoverySchema, discoveryFkFix, agentspecRebootSchema, startupContextSchema, chatMessagesSchema, podNamespaceSchema, contextUsageSchema, externalCliAttachmentSchema, rigServicesSchema, seatHandoverObservabilitySchema, nodeCodexConfigProfileSchema, nodePermissionPolicySchema, rigPermissionPolicySchema, nodePolicyProvenanceSchema, rigPolicyProvenanceSchema, streamItemsSchema, queueItemsSchema, queueTransitionsSchema, rigPolicySchema, rigArchiveSchema, resumeProvenanceSchema, resumeVerificationSchema, seatIdentityVerdictsSchema, selfHostIdentitySchema, occupantTenuresSchema, daemonLifecycleSchema, watchdogJobsSchema, occupantGenerationStampsSchema, projectionManifestSchema, watchdogTargetGenerationSchema, appliedLaunchObservationsSchema, appliedLaunchObservationInvalidationsSchema, threadSeatMapSchema, queueTransitionWakesSchema, nodeSessionSourceSchema, scopedOperatingPostureSchema, humanNotificationIntentSchema, reviewReadIndexesSchema, inventoryEventIndexesSchema, rigClaudeManagedBlockFileSchema, nodePermissionSelectionsSchema, humanReplyToSchema, humanQuestionsSchema, nodeEffortSchema, rigNonInterruptiveSchema, rigInstallRootSchema, threadPartMapSchema, queueTransitionsQitemIdOrderSchema, queueItemsRecoveryTagsSchema, resumeRotationSchema, sessionStopIntentSchema, nodeEffortChoiceSchema, modePostureCoexistSchema, seatEnvSchema, appliedLaunchDaemonOptOutSchema, sessionCommunityHarnessSchema, threadSeatMapChannelKeySchema, queueItemWakeInputBoxSchema, providerAccountReadingsSchema, humanQuietWindowsSchema, seatIdentityPanePidSchema, seatIdentityProcessEvidenceSchema, rigStartupLayersSchema, humanOperatingModesSchema, queueDelegatedDecisionSchema, nodeAdvisorSchema, operationCorrelationSchema, terminalPolicySchema, seatRuntimeSwitchesSchema, claudeTokenReadingsSchema, nodeLaunchRecipeSchema, nodeClaudeTokenRefSchema, nodeClaudePermissionsSchema, nodeCodexPermissionsSchema, escalationTargetRefsSchema, sessionAgentRunnerSchema, nodeAccountHomeSchema, nodeClaudeMcpSchema, activityGrantsSchema, environmentBindingsSchema, nodePermissionsToSchema, nodeStartupGuidanceFileSchema, managedBlockWritesSchema, streamIntakeCursorSchema, nodeLaunchWrapperSchema, piAccountSwitchesSchema, eventsRetentionMarksSchema, queueErasureSchema, queueErasureBatchesSchema, queueInboxCheckinsSchema, queueItemsHandedOffFromIndexSchema, networkBindingsSchema, networkReceiptsSchema, sessionAddressReadIndexesSchema];
|
||||
export const migrationsForFullTestDb = [coreSchema, bindingsSessionsSchema, eventsSchema, snapshotsSchema, checkpointsSchema, resumeMetadataSchema, nodeSpecFieldsSchema, packagesSchema, installJournalSchema, journalSeqSchema, bootstrapSchema, discoverySchema, discoveryFkFix, agentspecRebootSchema, startupContextSchema, chatMessagesSchema, podNamespaceSchema, contextUsageSchema, externalCliAttachmentSchema, rigServicesSchema, seatHandoverObservabilitySchema, nodeCodexConfigProfileSchema, nodePermissionPolicySchema, rigPermissionPolicySchema, nodePolicyProvenanceSchema, rigPolicyProvenanceSchema, streamItemsSchema, queueItemsSchema, queueTransitionsSchema, rigPolicySchema, rigArchiveSchema, resumeProvenanceSchema, resumeVerificationSchema, seatIdentityVerdictsSchema, selfHostIdentitySchema, occupantTenuresSchema, daemonLifecycleSchema, watchdogJobsSchema, occupantGenerationStampsSchema, projectionManifestSchema, watchdogTargetGenerationSchema, appliedLaunchObservationsSchema, appliedLaunchObservationInvalidationsSchema, threadSeatMapSchema, queueTransitionWakesSchema, nodeSessionSourceSchema, scopedOperatingPostureSchema, humanNotificationIntentSchema, reviewReadIndexesSchema, inventoryEventIndexesSchema, rigClaudeManagedBlockFileSchema, nodePermissionSelectionsSchema, humanReplyToSchema, humanQuestionsSchema, nodeEffortSchema, rigNonInterruptiveSchema, rigInstallRootSchema, threadPartMapSchema, queueTransitionsQitemIdOrderSchema, queueItemsRecoveryTagsSchema, resumeRotationSchema, sessionStopIntentSchema, nodeEffortChoiceSchema, modePostureCoexistSchema, seatEnvSchema, appliedLaunchDaemonOptOutSchema, sessionCommunityHarnessSchema, threadSeatMapChannelKeySchema, queueItemWakeInputBoxSchema, providerAccountReadingsSchema, humanQuietWindowsSchema, seatIdentityPanePidSchema, seatIdentityProcessEvidenceSchema, rigStartupLayersSchema, humanOperatingModesSchema, queueDelegatedDecisionSchema, nodeAdvisorSchema, operationCorrelationSchema, terminalPolicySchema, seatRuntimeSwitchesSchema, claudeTokenReadingsSchema, nodeLaunchRecipeSchema, nodeClaudeTokenRefSchema, nodeClaudePermissionsSchema, nodeCodexPermissionsSchema, escalationTargetRefsSchema, sessionAgentRunnerSchema, nodeAccountHomeSchema, nodeClaudeMcpSchema, activityGrantsSchema, environmentBindingsSchema, nodePermissionsToSchema, nodeStartupGuidanceFileSchema, managedBlockWritesSchema, streamIntakeCursorSchema, nodeLaunchWrapperSchema, piAccountSwitchesSchema, eventsRetentionMarksSchema, queueErasureSchema, queueErasureBatchesSchema, queueInboxCheckinsSchema, queueItemsHandedOffFromIndexSchema, networkBindingsSchema, networkReceiptsSchema];
|
||||
|
||||
/**
|
||||
* P24 — the DECLARED exclusions for {@link migrationsForFullTestDb}. That list is deliberately a
|
||||
|
||||
@@ -234,6 +234,48 @@ describe("InstallEngine", () => {
|
||||
expect(fs.readFileSync(targetPath, "utf8")).toBe(`${prefix}<!-- BEGIN OpenRig MANAGED BLOCK: test-pkg -->\nUpdated guidance.\n<!-- END OpenRig MANAGED BLOCK: test-pkg -->\n# Footer\n`);
|
||||
});
|
||||
|
||||
it("keeps one copy of its block: the first is updated in place and the repeats go", () => {
|
||||
const pkg = seedPackage();
|
||||
const sourcePath = writeSource("guidance/AGENTS.md", "Updated guidance.");
|
||||
const targetPath = path.join(repoRoot, "AGENTS.md");
|
||||
const old = "<!-- BEGIN OpenRig MANAGED BLOCK: test-pkg -->\nOld content.\n<!-- END OpenRig MANAGED BLOCK: test-pkg -->";
|
||||
fs.writeFileSync(targetPath, `# Header\n\n${old}\n\n${old}\n# Footer\n`);
|
||||
const entry = makeEntry({ exportType: "guidance", classification: "managed_merge", targetPath, sourcePath });
|
||||
new InstallEngine(installRepo, realFs(tmpDir)).apply(makePolicy([entry]), makePlan([entry]), pkg.id, repoRoot);
|
||||
expect(fs.readFileSync(targetPath, "utf8")).toBe(
|
||||
"# Header\n\n<!-- BEGIN OpenRig MANAGED BLOCK: test-pkg -->\nUpdated guidance.\n<!-- END OpenRig MANAGED BLOCK: test-pkg -->\n# Footer\n");
|
||||
});
|
||||
|
||||
it("keeps a later copy someone edited, and every different edit, with the text between", () => {
|
||||
const pkg = seedPackage();
|
||||
const sourcePath = writeSource("guidance/AGENTS.md", "Updated guidance.");
|
||||
const targetPath = path.join(repoRoot, "AGENTS.md");
|
||||
const copy = (body: string) => `<!-- BEGIN OpenRig MANAGED BLOCK: test-pkg -->\n${body}\n<!-- END OpenRig MANAGED BLOCK: test-pkg -->`;
|
||||
fs.writeFileSync(targetPath, `# Header\n\n${copy("Old content.")}\n\nmine\n\n${copy("Old content, with my rule.")}\n\n${copy("Another edit.")}\n`);
|
||||
const entry = makeEntry({ exportType: "guidance", classification: "managed_merge", targetPath, sourcePath });
|
||||
new InstallEngine(installRepo, realFs(tmpDir)).apply(makePolicy([entry]), makePlan([entry]), pkg.id, repoRoot);
|
||||
expect(fs.readFileSync(targetPath, "utf8")).toBe(
|
||||
`# Header\n\n${copy("Updated guidance.")}\n\nmine\n\n${copy("Old content, with my rule.")}\n\n${copy("Another edit.")}\n`);
|
||||
});
|
||||
|
||||
it("installs the rest when a guidance block lost its END: that file is left as it is and named, with no copy added", () => {
|
||||
const pkg = seedPackage();
|
||||
const skillSource = writeSource("skills/foo/SKILL.md", "# Foo Skill");
|
||||
const skillTarget = path.join(repoRoot, ".agents/skills/foo/SKILL.md");
|
||||
const guidanceSource = writeSource("guidance/AGENTS.md", "Updated guidance.");
|
||||
const guidanceTarget = path.join(repoRoot, "AGENTS.md");
|
||||
const text = "# Header\n<!-- BEGIN OpenRig MANAGED BLOCK: test-pkg -->\nOld content.\n# Footer\n";
|
||||
fs.writeFileSync(guidanceTarget, text);
|
||||
const skill = makeEntry({ exportName: "foo", targetPath: skillTarget, sourcePath: skillSource });
|
||||
const guidance = makeEntry({ exportType: "guidance", classification: "managed_merge", targetPath: guidanceTarget, sourcePath: guidanceSource });
|
||||
const result = new InstallEngine(installRepo, realFs(tmpDir)).apply(makePolicy([skill, guidance]), makePlan([skill, guidance]), pkg.id, repoRoot);
|
||||
expect(fs.readFileSync(guidanceTarget, "utf8")).toBe(text);
|
||||
expect(fs.readFileSync(skillTarget, "utf8")).toBe("# Foo Skill");
|
||||
expect(result.applied.map((e) => e.targetPath)).toEqual([skillTarget]);
|
||||
expect(result.notes).toEqual([expect.stringMatching(/^guidance_block_markers_damaged: .*AGENTS\.md: test-pkg has a BEGIN or END marker without its pair/)]);
|
||||
expect(installRepo.getInstall(result.installId)?.status).toBe("applied");
|
||||
});
|
||||
|
||||
// Test 7: Backup created before overwrite
|
||||
it("backup created before overwrite", () => {
|
||||
const pkg = seedPackage();
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
// A guidance file keeps exactly one copy of each OpenRig-managed block, however often OpenRig writes it: after an editor
|
||||
// or formatter touched its markers, after copies collected, and when a marker lost its pair. A person's edit is kept.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
managedBlockStanding,
|
||||
managedBlockText,
|
||||
mergeManagedBlock,
|
||||
type ManagedBlockMergeFsOps,
|
||||
} from "../src/domain/managed-blocks.js";
|
||||
import { managedBlockBodyHash, writeManagedGuidanceBlock, type ManagedBlockRecords } from "../src/domain/managed-block-records.js";
|
||||
import { restoreMissingGuidance } from "../src/domain/restore-guidance.js";
|
||||
|
||||
const FILE = "/repo/AGENTS.md";
|
||||
const SOURCE = "/specs/culture.md";
|
||||
|
||||
/** An in-memory guidance file and source, so nothing here touches a real instruction file. */
|
||||
function memory(files: Record<string, string>): ManagedBlockMergeFsOps & { files: Record<string, string> } {
|
||||
return {
|
||||
files,
|
||||
exists: (p) => p in files,
|
||||
readFile: (p) => files[p]!,
|
||||
writeFile: (p, content) => { files[p] = content; },
|
||||
};
|
||||
}
|
||||
|
||||
function recordsOf(entries: Record<string, string> = {}): ManagedBlockRecords & { entries: Record<string, string> } {
|
||||
return {
|
||||
entries,
|
||||
get: (file, id) => entries[`${file}#${id}`],
|
||||
set: (file, id, body) => { entries[`${file}#${id}`] = managedBlockBodyHash(body); },
|
||||
};
|
||||
}
|
||||
|
||||
const count = (text: string, id = "CULTURE.md") => text.split(`<!-- BEGIN OpenRig MANAGED BLOCK: ${id} -->`).length - 1;
|
||||
|
||||
describe("the writer keeps one copy", () => {
|
||||
it("reads markers a formatter rewrapped as the block, and replaces it in place at every launch", () => {
|
||||
const touched = "# Notes\n\n<!--\n BEGIN OpenRig MANAGED BLOCK: CULTURE.md\n-->\nold culture\n<!-- END OpenRig MANAGED BLOCK: CULTURE.md -->\n\nafter\n";
|
||||
const fs = memory({ [FILE]: touched });
|
||||
const sizes: number[] = [];
|
||||
const written: unknown[] = [];
|
||||
for (let launch = 0; launch < 4; launch++) {
|
||||
written.push(mergeManagedBlock(fs, FILE, "CULTURE.md", "new culture"));
|
||||
sizes.push(fs.files[FILE]!.length);
|
||||
}
|
||||
expect(fs.files[FILE]).toBe(`# Notes\n\n${managedBlockText("CULTURE.md", "new culture")}\n\nafter\n`);
|
||||
expect(new Set(sizes).size).toBe(1);
|
||||
expect(written).toEqual([true, true, true, true]);
|
||||
});
|
||||
|
||||
it("collapses copies that collected to the first, and keeps a person's text before, between and after them", () => {
|
||||
const block = managedBlockText("CULTURE.md", "culture");
|
||||
const fs = memory({ [FILE]: `# Notes\n\n${block}\n\nperson text\n\n${block}\n\n${block}\n\nthe end\n` });
|
||||
mergeManagedBlock(fs, FILE, "CULTURE.md", "culture");
|
||||
expect(fs.files[FILE]).toBe(`# Notes\n\n${block}\n\nperson text\n\nthe end\n`);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a BEGIN whose END was lost", "# Notes\n\n<!-- BEGIN OpenRig MANAGED BLOCK: CULTURE.md -->\nculture\n<!-- END OpenRig MANAGED BLOCK -->\n"],
|
||||
["an END whose BEGIN was lost", "# Notes\n\n<!-- BEGIN MANAGED BLOCK: CULTURE.md -->\nculture\n<!-- END OpenRig MANAGED BLOCK: CULTURE.md -->\n"],
|
||||
])("adds no copy beside %s, at any launch, and names it", (_name, text) => {
|
||||
const fs = memory({ [FILE]: text });
|
||||
const warnings: string[] = [];
|
||||
const written: unknown[] = [];
|
||||
for (let launch = 0; launch < 3; launch++) written.push(mergeManagedBlock(fs, FILE, "CULTURE.md", "culture", { warnings }));
|
||||
expect(fs.files[FILE]).toBe(text);
|
||||
expect(warnings).toHaveLength(3);
|
||||
expect(written).toEqual([false, false, false]);
|
||||
expect(warnings[0]).toMatch(/^guidance_block_markers_damaged: \/repo\/AGENTS\.md: CULTURE\.md has a BEGIN or END marker without its pair/);
|
||||
});
|
||||
|
||||
it("still replaces a complete copy when a lost marker sits beside it, leaves that marker, and names it", () => {
|
||||
const lone = "<!-- BEGIN OpenRig MANAGED BLOCK: CULTURE.md -->\nstray\n";
|
||||
const fs = memory({ [FILE]: `${lone}\n${managedBlockText("CULTURE.md", "old")}\n` });
|
||||
const warnings: string[] = [];
|
||||
const written = mergeManagedBlock(fs, FILE, "CULTURE.md", "new", { warnings });
|
||||
expect(fs.files[FILE]).toBe(`${lone}\n${managedBlockText("CULTURE.md", "new")}\n`);
|
||||
expect(written).toBe(true);
|
||||
expect(warnings).toEqual([expect.stringMatching(/^guidance_block_marker_unpaired: \/repo\/AGENTS\.md: CULTURE\.md was written/)]);
|
||||
});
|
||||
|
||||
it("appends to a file with no marker at all, as before", () => {
|
||||
const fs = memory({ [FILE]: "# Notes\n" });
|
||||
mergeManagedBlock(fs, FILE, "CULTURE.md", "culture");
|
||||
expect(fs.files[FILE]).toBe(`# Notes\n\n\n${managedBlockText("CULTURE.md", "culture")}`);
|
||||
});
|
||||
|
||||
it("reads a lost marker as damaged, not absent", () => {
|
||||
expect(managedBlockStanding("<!-- BEGIN OpenRig MANAGED BLOCK: CULTURE.md -->\nculture\n", "CULTURE.md", "culture", undefined, managedBlockBodyHash))
|
||||
.toBe("damaged");
|
||||
expect(managedBlockStanding("# no block\n", "CULTURE.md", "culture", undefined, managedBlockBodyHash)).toBe("absent");
|
||||
});
|
||||
});
|
||||
|
||||
describe("a launch's guidance write keeps a person's edit and drops only repeats", () => {
|
||||
const launch = (fs: ManagedBlockMergeFsOps, records: ManagedBlockRecords, source: string, warnings: string[]) =>
|
||||
writeManagedGuidanceBlock(fs, FILE, "CULTURE.md", source, { records, sourcePath: SOURCE, warnings });
|
||||
|
||||
it("refreshes OpenRig's own copies to one", () => {
|
||||
const old = managedBlockText("CULTURE.md", "old culture");
|
||||
const fs = memory({ [FILE]: `# Notes\n\n${old}\n\n${old}\n` });
|
||||
const records = recordsOf({ [`${FILE}#CULTURE.md`]: managedBlockBodyHash("old culture") });
|
||||
expect(launch(fs, records, "new culture", [])).toBe(true);
|
||||
expect(fs.files[FILE]).toBe(`# Notes\n\n${managedBlockText("CULTURE.md", "new culture")}\n`);
|
||||
});
|
||||
|
||||
it("keeps copies a formatter changed as edits, and leaves one of them", () => {
|
||||
const formatted = managedBlockText("CULTURE.md", "- culture, reformatted");
|
||||
const fs = memory({ [FILE]: `# Notes\n\n${formatted}\n\n${formatted}\n\n${formatted}\n` });
|
||||
const records = recordsOf({ [`${FILE}#CULTURE.md`]: managedBlockBodyHash("* culture") });
|
||||
const warnings: string[] = [];
|
||||
expect(launch(fs, records, "* culture", warnings)).toBe(false);
|
||||
expect(fs.files[FILE]).toBe(`# Notes\n\n${formatted}\n`);
|
||||
expect(warnings.map((w) => w.split(":")[0])).toEqual(["guidance_block_edited", "guidance_block_copies_removed"]);
|
||||
});
|
||||
|
||||
it("keeps a person's edited copy and drops OpenRig's unedited copy beside it", () => {
|
||||
const edited = managedBlockText("CULTURE.md", "culture, with my change");
|
||||
const fs = memory({ [FILE]: `${managedBlockText("CULTURE.md", "culture")}\n\n${edited}\n` });
|
||||
const records = recordsOf({ [`${FILE}#CULTURE.md`]: managedBlockBodyHash("culture") });
|
||||
expect(launch(fs, records, "culture", [])).toBe(false);
|
||||
expect(fs.files[FILE]).toBe(`${edited}\n`);
|
||||
});
|
||||
|
||||
it("keeps two different edited copies: it can't tell which one is meant", () => {
|
||||
const text = `${managedBlockText("CULTURE.md", "edit one")}\n\n${managedBlockText("CULTURE.md", "edit two")}\n`;
|
||||
const fs = memory({ [FILE]: text });
|
||||
expect(launch(fs, recordsOf({ [`${FILE}#CULTURE.md`]: managedBlockBodyHash("culture") }), "culture", [])).toBe(false);
|
||||
expect(fs.files[FILE]).toBe(text);
|
||||
});
|
||||
|
||||
it("records nothing and adds no copy beside a lost marker", () => {
|
||||
const text = "<!-- BEGIN OpenRig MANAGED BLOCK: CULTURE.md -->\nculture\n";
|
||||
const fs = memory({ [FILE]: text });
|
||||
const records = recordsOf();
|
||||
const warnings: string[] = [];
|
||||
const written: unknown[] = [];
|
||||
for (let i = 0; i < 3; i++) written.push(launch(fs, records, "culture", warnings));
|
||||
expect(fs.files[FILE]).toBe(text);
|
||||
expect(written).toEqual([false, false, false]);
|
||||
expect(records.entries).toEqual({});
|
||||
expect(warnings.every((w) => w.startsWith("guidance_block_markers_damaged: "))).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps going when the file can't be written: the edited block is kept as decided, and the tidy is named", () => {
|
||||
const formatted = managedBlockText("CULTURE.md", "- culture, reformatted");
|
||||
const text = `${formatted}\n\n${formatted}\n`;
|
||||
const fs = memory({ [FILE]: text });
|
||||
fs.writeFile = () => { throw new Error("EACCES: permission denied"); };
|
||||
const warnings: string[] = [];
|
||||
expect(launch(fs, recordsOf({ [`${FILE}#CULTURE.md`]: managedBlockBodyHash("* culture") }), "* culture", warnings)).toBe(false);
|
||||
expect(fs.files[FILE]).toBe(text);
|
||||
expect(warnings.map((w) => w.split(":")[0])).toEqual(["guidance_block_edited", "guidance_block_copies_not_removed"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("restore keeps one copy", () => {
|
||||
it("removes repeats of a current block, and a second restore changes nothing", () => {
|
||||
const current = managedBlockText("CULTURE.md", "* culture");
|
||||
const fs = memory({ [FILE]: `# Notes\n\n${current}\n\nmine\n\n${current}\n`, [SOURCE]: "* culture" });
|
||||
const records = recordsOf({ [`${FILE}#CULTURE.md`]: managedBlockBodyHash("* culture") });
|
||||
const guidance = { targetPath: FILE, items: [{ blockId: "CULTURE.md", sourcePath: SOURCE }] };
|
||||
restoreMissingGuidance(guidance, fs, records);
|
||||
const once = fs.files[FILE];
|
||||
restoreMissingGuidance(guidance, fs, records);
|
||||
expect(once).toBe(`# Notes\n\n${current}\n\nmine\n`);
|
||||
expect(fs.files[FILE]).toBe(once);
|
||||
});
|
||||
|
||||
it("without write records keeps the block's text, and still removes its exact repeats and puts rewrapped markers back", () => {
|
||||
const rewrapped = "<!--\n BEGIN OpenRig MANAGED BLOCK: CULTURE.md -->\nmy edit\n<!-- END OpenRig MANAGED BLOCK: CULTURE.md -->";
|
||||
const fs = memory({ [FILE]: `${rewrapped}\n\n${managedBlockText("CULTURE.md", "my edit")}\n`, [SOURCE]: "* culture" });
|
||||
restoreMissingGuidance({ targetPath: FILE, items: [{ blockId: "CULTURE.md", sourcePath: SOURCE }] }, fs);
|
||||
expect(fs.files[FILE]).toBe(`${managedBlockText("CULTURE.md", "my edit")}\n`);
|
||||
});
|
||||
|
||||
it("removes a kept block's repeats, and puts back nothing beside a lost marker", () => {
|
||||
const formatted = managedBlockText("CULTURE.md", "- culture, reformatted");
|
||||
const fs = memory({
|
||||
[FILE]: `# Notes\n\n${formatted}\n\n${formatted}\n\n<!-- BEGIN OpenRig MANAGED BLOCK: ROLE.md -->\nrole\n`,
|
||||
[SOURCE]: "* culture",
|
||||
"/specs/role.md": "role",
|
||||
});
|
||||
const records = recordsOf({ [`${FILE}#CULTURE.md`]: managedBlockBodyHash("* culture") });
|
||||
const repair = restoreMissingGuidance({ targetPath: FILE, items: [
|
||||
{ blockId: "CULTURE.md", sourcePath: SOURCE },
|
||||
{ blockId: "ROLE.md", sourcePath: "/specs/role.md" },
|
||||
] }, fs, records);
|
||||
expect(fs.files[FILE]).toBe(`# Notes\n\n${formatted}\n\n<!-- BEGIN OpenRig MANAGED BLOCK: ROLE.md -->\nrole\n`);
|
||||
expect(count(fs.files[FILE]!)).toBe(1);
|
||||
expect(repair.restored).toEqual([]);
|
||||
expect(repair.gaps.join("\n")).toContain("ROLE.md: /repo/AGENTS.md holds only part of this block");
|
||||
expect(repair.kept.map((k) => k.split(":")[0])).toEqual(["guidance_block_edited", "guidance_block_copies_removed"]);
|
||||
});
|
||||
});
|
||||
@@ -16,7 +16,7 @@ import { NetworkBindingStore, networkCredentialFile } from "../src/domain/networ
|
||||
import { Hono } from "hono";
|
||||
import { connectMcpHub, HubError, type HubConnect, type HubItem, type HubSession } from "../src/domain/network/hub-client.js";
|
||||
import { networkRoutes } from "../src/routes/network.js";
|
||||
import { NetworkConnectorService, networkClosedElsewhere, networkLocalItemId, runNetworkBindingPass, type NetworkConnectorDeps } from "../src/domain/network/network-connector.js";
|
||||
import { NetworkConnectorService, networkLocalItemId, runNetworkBindingPass, type NetworkConnectorDeps } from "../src/domain/network/network-connector.js";
|
||||
|
||||
const PARTICIPANT = "alex@team";
|
||||
|
||||
@@ -424,16 +424,6 @@ describe("network connector", () => {
|
||||
expect(repo.getById(local!.qitemId)!.resolution).toBe("Here it is.");
|
||||
});
|
||||
|
||||
it("closed-elsewhere conflicts are listed by when they settled, and ones that settled in the same millisecond by local item", () => {
|
||||
const add = db.prepare(`INSERT INTO network_receipts (local_qitem_id, binding_id, hub_qitem_id, created_at, settled_at, outcome)
|
||||
VALUES (?, 'netb-order', ?, '2026-11-11T19:00:00.000Z', ?, 'closed_on_hub')`);
|
||||
// The latest has the first id, and the two that tie were recorded, and name hub items, out of local-item order.
|
||||
add.run("qitem-net-a", "hub-1", "2026-11-11T19:00:00.001Z");
|
||||
add.run("qitem-net-c", "hub-2", "2026-11-11T19:00:00.000Z");
|
||||
add.run("qitem-net-b", "hub-3", "2026-11-11T19:00:00.000Z");
|
||||
expect(networkClosedElsewhere(db, "netb-order").map((c) => c.localItem)).toEqual(["qitem-net-b", "qitem-net-c", "qitem-net-a"]);
|
||||
});
|
||||
|
||||
it("a hub item already failed elsewhere gets no write and isn't our result, and a close that loses a race is read back as closed elsewhere", async () => {
|
||||
const b = bind();
|
||||
hub.add("hub-f1");
|
||||
@@ -461,12 +451,10 @@ describe("network connector", () => {
|
||||
// A later pass's ordinary contact doesn't clear it, and it's read from the receipts, so a restart keeps it.
|
||||
expect((await pass(b.id)).error).toBeUndefined();
|
||||
const listed = await (await app.request("/api/network/bindings")).json() as { bindings: Array<{ id: string; closedElsewhere: unknown[] }> };
|
||||
// Listed in the order they settled, which can fall in one millisecond or two, so compared by local item.
|
||||
const byLocal = (x: { localItem: string }, y: { localItem: string }) => x.localItem.localeCompare(y.localItem);
|
||||
expect([...listed.bindings.find((x) => x.id === b.id)!.closedElsewhere as Array<{ localItem: string }>].sort(byLocal)).toEqual([
|
||||
expect(listed.bindings.find((x) => x.id === b.id)!.closedElsewhere).toEqual([
|
||||
{ hubItem: "hub-f1", localItem: f1!.qitemId, note: `The hub item was closed elsewhere (failed) before this result reached it. The result stays on ${f1!.qitemId}.` },
|
||||
{ hubItem: "hub-f2", localItem: f2!.qitemId, note: `The hub item was closed elsewhere (done, no-follow-on) before this result reached it. The result stays on ${f2!.qitemId}.` },
|
||||
].sort(byLocal));
|
||||
].sort((x, y) => x.localItem.localeCompare(y.localItem)));
|
||||
});
|
||||
|
||||
it("a close without a resolution sends only the closer's own closing note, never another note on the item", async () => {
|
||||
|
||||
@@ -322,6 +322,32 @@ roles:
|
||||
expect(claudeMd).toContain("# Existing content");
|
||||
});
|
||||
|
||||
it("POST /api/packages/install whose only guidance entry is left as it is → 201 with the note, verified", async () => {
|
||||
writePkg(pkgDir, GUIDANCE_MANIFEST_YAML, {
|
||||
"guidance/rules.md": "Follow these rules.",
|
||||
});
|
||||
const damaged = "# Existing content\n\n<!-- BEGIN OpenRig MANAGED BLOCK: guidance-pkg -->\nhalf a block\n";
|
||||
fs.writeFileSync(path.join(targetDir, "CLAUDE.md"), damaged);
|
||||
|
||||
const res = await app.request("/api/packages/install", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
sourceRef: pkgDir,
|
||||
targetRoot: targetDir,
|
||||
runtime: "claude-code",
|
||||
allowMerge: true,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(fs.readFileSync(path.join(targetDir, "CLAUDE.md"), "utf-8")).toBe(damaged);
|
||||
expect(res.status).toBe(201);
|
||||
const body = await res.json();
|
||||
expect(body.applied).toEqual([]);
|
||||
expect(body.notes).toEqual([expect.stringMatching(/^guidance_block_markers_damaged: .*CLAUDE\.md: guidance-pkg has a BEGIN or END marker without its pair/)]);
|
||||
expect(body.verification.passed).toBe(true);
|
||||
});
|
||||
|
||||
// --- Test 8: POST /install mixed policy: skills approved, guidance rejected ---
|
||||
it("POST /api/packages/install mixed policy → 201 with applied + policyRejected", async () => {
|
||||
writePkg(pkgDir, MIXED_MANIFEST_YAML, {
|
||||
|
||||
@@ -112,40 +112,6 @@ describe("Slice-04 C3 — seat-keyed Claude provider_usage discovery (production
|
||||
}
|
||||
});
|
||||
|
||||
it("a live seat whose only cache is malformed reports unreadable_cache through real getReadModel", async () => {
|
||||
const root = fs.mkdtempSync(nodePath.join(os.tmpdir(), "provider-c3-unreadable-"));
|
||||
try {
|
||||
const usageDir = nodePath.join(root, "provider-usage");
|
||||
fs.mkdirSync(usageDir, { recursive: true });
|
||||
const seatSession = "dev-impl@test-rig";
|
||||
fs.writeFileSync(nodePath.join(usageDir, `${seatSession}.json`), "{ not json");
|
||||
|
||||
const db = createFullTestDb();
|
||||
seedClaudeSeat(db, seatSession);
|
||||
const svc = new ProviderServiceImpl({
|
||||
db,
|
||||
listRigs: () => [{ id: "rig-1" }],
|
||||
env: emptyCodexHomeEnv(),
|
||||
now: () => ASOF,
|
||||
collectClaudeSignals: () => collectClaudeSignalsFromProviderUsageDirectory(usageDir, () => ASOF),
|
||||
});
|
||||
const model = await svc.getReadModel();
|
||||
const claude = model.signals.filter((signal) => signal.provider === "claude");
|
||||
|
||||
expect(claude).toHaveLength(1);
|
||||
expect(claude[0]).toMatchObject({
|
||||
seatSession,
|
||||
sourceClass: "unknown",
|
||||
authority: "unknown",
|
||||
unknownReason: "claude_statusline_cache_unreadable",
|
||||
automationUse: "do_not_automate",
|
||||
});
|
||||
expect(claude[0]!.usedPercent).toBeUndefined();
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("an exited latest Claude session plus matching stale cache emits zero Claude signals", async () => {
|
||||
const root = fs.mkdtempSync(nodePath.join(os.tmpdir(), "provider-c3-exited-"));
|
||||
try {
|
||||
|
||||
@@ -76,20 +76,12 @@ describe("collectClaudeStatuslineSignals — reader → provider_statusline / ex
|
||||
expect(sigs[0]!.unknownReason).toBe(CLAUDE_UNKNOWN_REASON.empty_reading);
|
||||
});
|
||||
|
||||
it("malformed cache JSON → unknown(unreadable_cache), never throws", () => {
|
||||
it("malformed cache JSON → unknown(empty_reading), never throws", () => {
|
||||
const sigs = collectClaudeStatuslineSignals(readerDeps({
|
||||
listClaudeSeats: () => [{ seatSession: "dev-impl@rig" }],
|
||||
readCacheRaw: () => "{ not json",
|
||||
}));
|
||||
expect(sigs[0]!.sourceClass).toBe("unknown");
|
||||
expect(sigs[0]!.unknownReason).toBe(CLAUDE_UNKNOWN_REASON.unreadable_cache);
|
||||
});
|
||||
|
||||
it("a valid cache with no windows keeps empty_reading (distinct from an unreadable cache)", () => {
|
||||
const sigs = collectClaudeStatuslineSignals(readerDeps({
|
||||
listClaudeSeats: () => [{ seatSession: "dev-impl@rig" }],
|
||||
readCacheRaw: () => JSON.stringify({ seatSession: "dev-impl@rig", asOf: CACHE_ASOF }),
|
||||
}));
|
||||
expect(sigs[0]!.unknownReason).toBe(CLAUDE_UNKNOWN_REASON.empty_reading);
|
||||
});
|
||||
});
|
||||
@@ -119,33 +111,14 @@ describe("0.5.8 provider-usage compatibility bridge", () => {
|
||||
const malformedCanonical = collectClaudeSignalsFromProviderUsageDirectory(canonical, () => ASOF, legacy);
|
||||
expect(malformedCanonical.find((signal) => signal.seatSession === "legacy@rig")?.usedPercent).toBeUndefined();
|
||||
expect(malformedCanonical.find((signal) => signal.seatSession === "legacy@rig")?.unknownReason)
|
||||
.toBe(CLAUDE_UNKNOWN_REASON.unreadable_cache);
|
||||
.toBe(CLAUDE_UNKNOWN_REASON.empty_reading);
|
||||
|
||||
fs.rmSync(path.join(canonical, "[email protected]"));
|
||||
fs.mkdirSync(path.join(canonical, "[email protected]"));
|
||||
const wrongTypeCanonical = collectClaudeSignalsFromProviderUsageDirectory(canonical, () => ASOF, legacy);
|
||||
expect(wrongTypeCanonical.find((signal) => signal.seatSession === "legacy@rig")?.usedPercent).toBeUndefined();
|
||||
expect(wrongTypeCanonical.find((signal) => signal.seatSession === "legacy@rig")?.unknownReason)
|
||||
.toBe(CLAUDE_UNKNOWN_REASON.unreadable_cache);
|
||||
.toBe(CLAUDE_UNKNOWN_REASON.empty_reading);
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("collectClaudeSignalsFromProviderUsageDirectory — a malformed-only cache stays visible", () => {
|
||||
it("reports unreadable_cache for a seat known only from a malformed file's name", () => {
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), "provider-unreadable-"));
|
||||
const directory = path.join(home, "state", "provider-usage");
|
||||
fs.mkdirSync(directory, { recursive: true });
|
||||
fs.writeFileSync(path.join(directory, "[email protected]"), "{ not json");
|
||||
try {
|
||||
const signals = collectClaudeSignalsFromProviderUsageDirectory(directory, () => ASOF);
|
||||
expect(signals).toHaveLength(1);
|
||||
expect(signals[0]!.seatSession).toBe("broken@rig");
|
||||
expect(signals[0]!.sourceClass).toBe("unknown");
|
||||
expect(signals[0]!.unknownReason).toBe(CLAUDE_UNKNOWN_REASON.unreadable_cache);
|
||||
expect(signals[0]!.usedPercent).toBeUndefined();
|
||||
} finally {
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { CLAUDE_UNKNOWN_REASON, claudeStatuslineSignals } from "../src/domain/provider/provider-signals.js";
|
||||
import { claudeStatuslineSignals } from "../src/domain/provider/provider-signals.js";
|
||||
|
||||
// Slice-04 (OPR.0.5.0.4) — Claude statusline lane normalization (§2 Claude lane + the
|
||||
// 2026-07-31 RESEARCH verdict). Proof item 6: the statusline sidecar's rate_limits object
|
||||
@@ -68,21 +68,5 @@ describe("claudeStatuslineSignals — Claude lane honesty", () => {
|
||||
expect(sigs[0].sourceClass).toBe("unknown");
|
||||
expect(sigs[0].usedPercent).toBeUndefined();
|
||||
expect(sigs[0].automationUse).toBe("do_not_automate");
|
||||
expect(sigs[0].unknownReason).toBe(CLAUDE_UNKNOWN_REASON.empty_reading);
|
||||
});
|
||||
|
||||
it("an unreadable cache gets its own reason, not the empty-reading one", () => {
|
||||
const sigs = claudeStatuslineSignals({
|
||||
seatSession: "dev-impl@rig",
|
||||
cachePresent: true,
|
||||
cacheUnreadable: true,
|
||||
asOf: ASOF,
|
||||
});
|
||||
expect(sigs).toHaveLength(1);
|
||||
expect(sigs[0].sourceClass).toBe("unknown");
|
||||
expect(sigs[0].unknownReason).toBe(CLAUDE_UNKNOWN_REASON.unreadable_cache);
|
||||
expect(sigs[0].unknownReason).not.toBe(CLAUDE_UNKNOWN_REASON.empty_reading);
|
||||
expect(sigs[0].usedPercent).toBeUndefined();
|
||||
expect(sigs[0].automationUse).toBe("do_not_automate");
|
||||
});
|
||||
});
|
||||
@@ -275,56 +275,6 @@ describe("S01 wake-or-escalate — retry ladder, named rungs, derived suspension
|
||||
expect(calls.some((c) => c.target === "worker@r")).toBe(false);
|
||||
});
|
||||
|
||||
it("UNKNOWN READS UNKNOWN: a baton whose recorded wake result no reader recognises escalates past the window, never re-sent, and the notice says the outcome is unknown", async () => {
|
||||
const baton = await mkBaton();
|
||||
setNudgeResult(baton.qitemId, "accepted-by-some-new-route", 45);
|
||||
ageCreated(baton.qitemId, 45);
|
||||
await tick();
|
||||
expect(markersOf(baton.qitemId, LADDER_ATTEMPT_PREFIX)).toHaveLength(0);
|
||||
const rungs = markersOf(baton.qitemId, LADDER_RUNG_PREFIX);
|
||||
expect(rungs).toHaveLength(1);
|
||||
expect(rungs[0]).toMatch(/wake outcome unknown \(unrecognised result "accepted-by-some-new-route"\)/);
|
||||
expect(calls.some((c) => c.target === "worker@r")).toBe(false);
|
||||
});
|
||||
|
||||
it("UNKNOWN READS UNKNOWN (negative controls): inside the window, or once claimed, an unrecognised result gets nothing", async () => {
|
||||
const fresh = await mkBaton();
|
||||
setNudgeResult(fresh.qitemId, "accepted-by-some-new-route", 5);
|
||||
const claimed = await mkBaton("other@r");
|
||||
setNudgeResult(claimed.qitemId, "accepted-by-some-new-route", 45);
|
||||
ageCreated(claimed.qitemId, 45);
|
||||
repo.claim({ qitemId: claimed.qitemId, destinationSession: "other@r" });
|
||||
await tick();
|
||||
expect(calls).toHaveLength(0);
|
||||
expect(markersOf(fresh.qitemId, LADDER_RUNG_PREFIX)).toHaveLength(0);
|
||||
expect(markersOf(claimed.qitemId, LADDER_RUNG_PREFIX)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("KNOWN OUTCOMES KEEP THEIR PATH: a verified wake, a hosted trigger's accepted ring and an unroutable address never enter the ladder", async () => {
|
||||
const batons = [];
|
||||
for (const [idx, result] of ["verified", "wake-accepted:webhook (hook) answered 202; pickup is the member's claim on this row",
|
||||
"unroutable: 'w2@r' has no terminal transport on this daemon"].entries()) {
|
||||
const baton = await mkBaton(`w${idx}@r`);
|
||||
setNudgeResult(baton.qitemId, result, 45);
|
||||
ageCreated(baton.qitemId, 45);
|
||||
batons.push(baton);
|
||||
}
|
||||
await tick();
|
||||
expect(calls).toHaveLength(0);
|
||||
for (const baton of batons) {
|
||||
expect(markersOf(baton.qitemId, LADDER_ATTEMPT_PREFIX)).toHaveLength(0);
|
||||
expect(markersOf(baton.qitemId, LADDER_RUNG_PREFIX)).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("A WEBHOOK FAILURE AFTER THE POST KEEPS THE FAILED PATH: its effect is unknown, but it is retried as it always was", async () => {
|
||||
const baton = await mkBaton();
|
||||
setNudgeResult(baton.qitemId, "failed:webhook (hook) answered 500", 10);
|
||||
await tick();
|
||||
expect(markersOf(baton.qitemId, LADDER_ATTEMPT_PREFIX)).toHaveLength(1);
|
||||
expect(calls).toEqual([{ qitemId: baton.qitemId, target: "worker@r" }]);
|
||||
});
|
||||
|
||||
it("DELIVERY MACHINERY IS NOT PICKUP: a relay-owner-notification-transport-failed note on an unconfirmed or held baton does not count as pickup evidence", async () => {
|
||||
const mod = await ladderMod();
|
||||
for (const [idx, result] of ["gateway-owned: virtual destination", "retained:typing_guard"].entries()) {
|
||||
|
||||
@@ -13,7 +13,6 @@ import type Database from "better-sqlite3";
|
||||
import { createDb } from "../src/db/connection.js";
|
||||
import { migrate } from "../src/db/migrate.js";
|
||||
import { coreSchema } from "../src/db/migrations/001_core_schema.js";
|
||||
import { rigArchiveSchema } from "../src/db/migrations/042_rig_archive.js";
|
||||
import { bindingsSessionsSchema } from "../src/db/migrations/002_bindings_sessions.js";
|
||||
import { eventsSchema } from "../src/db/migrations/003_events.js";
|
||||
import { streamItemsSchema } from "../src/db/migrations/023_stream_items.js";
|
||||
@@ -71,7 +70,7 @@ describe("GET /api/review/*", () => {
|
||||
db = createDb(":memory:");
|
||||
migrate(db, [
|
||||
coreSchema,
|
||||
bindingsSessionsSchema, rigArchiveSchema,
|
||||
bindingsSessionsSchema,
|
||||
eventsSchema,
|
||||
streamItemsSchema,
|
||||
queueItemsSchema,
|
||||
@@ -336,7 +335,7 @@ describe("GET /api/review/rig (OPR.0.4.4.22)", () => {
|
||||
db = createDb(":memory:");
|
||||
migrate(db, [
|
||||
coreSchema,
|
||||
bindingsSessionsSchema, rigArchiveSchema,
|
||||
bindingsSessionsSchema,
|
||||
eventsSchema,
|
||||
streamItemsSchema,
|
||||
queueItemsSchema,
|
||||
@@ -669,7 +668,7 @@ describe("qitem-ccf87c0d — composeMission MEMBERSHIP-scan load contract", () =
|
||||
it("one composeMission over 40 indexed slices executes <= 4 membership queue scans AND composes all 40 ledger entries", () => {
|
||||
const db = createDb();
|
||||
migrate(db, [
|
||||
coreSchema, bindingsSessionsSchema, rigArchiveSchema, eventsSchema, streamItemsSchema,
|
||||
coreSchema, bindingsSessionsSchema, eventsSchema, streamItemsSchema,
|
||||
queueItemsSchema, queueTransitionsSchema, missionControlActionsSchema,
|
||||
queueItemSummarySchema,
|
||||
]);
|
||||
|
||||
@@ -499,8 +499,7 @@ describe("SeatLifecycleService.launchFresh", () => {
|
||||
code: "claimed_session",
|
||||
message: `Seat "${seat.sessionName}" has an adopted/operator-owned occupant; fresh launch refuses even with --stop.`,
|
||||
// B5: rig unclaim releases it (the process keeps running); rig down leaves a session OpenRig didn't start.
|
||||
guidance: "Release it with rig unclaim 'dev-impl@fresh-rig' (its process keeps running), end that process and the underlying tmux session, "
|
||||
+ "including any remaining shell, then launch fresh. Closing only its viewing window leaves the tmux session running. "
|
||||
guidance: "Release it with rig unclaim 'dev-impl@fresh-rig' (its process keeps running), end that process yourself, then launch fresh. "
|
||||
+ "rig down 'fresh-rig' leaves a session OpenRig didn't start running.",
|
||||
};
|
||||
const before = db.serialize();
|
||||
@@ -687,11 +686,6 @@ describe("SeatLifecycleService.launchFresh", () => {
|
||||
alive.add(seat.sessionName);
|
||||
const result = await service.launchFresh({ seatRef: "dev.impl", fresh: true, stop: true, reason: "x" });
|
||||
expect(result).toMatchObject({ ok: false, code: "unmanaged_session_collision" });
|
||||
// A session someone released with rig unclaim and left open is the usual cause: say how to clear it.
|
||||
expect(result).toMatchObject({
|
||||
guidance: "If you released this session with rig unclaim, end that underlying tmux session, including any remaining shell, "
|
||||
+ "then launch again. Closing only its viewing window doesn't end the session.",
|
||||
});
|
||||
expect(killed).toEqual([]);
|
||||
expect(alive.has(seat.sessionName)).toBe(true);
|
||||
});
|
||||
|
||||
@@ -447,7 +447,7 @@ describe("SeatLifecycleService", () => {
|
||||
// the refusal names the supported route, rig unclaim, and says rig down doesn't stop a session OpenRig didn't start,
|
||||
// even in a one-seat rig.
|
||||
expect(claimedResult.guidance).toBe("To end this session, run rig unclaim 'dev-adopted@s5-rig' (it releases the session from OpenRig "
|
||||
+ "and leaves its process running), then end that process and the underlying tmux session, including any remaining shell, so no session keeps the seat's name. Closing only its viewing window leaves the tmux session running. rig down 's5-rig' leaves a session OpenRig didn't start running.");
|
||||
+ "and leaves its process running), then end that process in its own terminal. rig down 's5-rig' leaves a session OpenRig didn't start running.");
|
||||
|
||||
const probed = seatFixture("s5-rig", "dev.flaky");
|
||||
tmux.failProbeFor(probed.sessionName);
|
||||
@@ -471,7 +471,7 @@ describe("SeatLifecycleService", () => {
|
||||
const shared = await service.stopSeat({ seatRef: claimed.sessionName, reason: "x" });
|
||||
if (shared.ok) throw new Error("expected refusal");
|
||||
expect(shared.guidance).toBe("To end this session, run rig unclaim 'dev-adopted@adopted-rig' (it releases the session from OpenRig "
|
||||
+ "and leaves its process running), then end that process and the underlying tmux session, including any remaining shell, so no session keeps the seat's name. Closing only its viewing window leaves the tmux session running. rig down 'adopted-rig' leaves a session OpenRig didn't start running.");
|
||||
+ "and leaves its process running), then end that process in its own terminal. rig down 'adopted-rig' leaves a session OpenRig didn't start running.");
|
||||
|
||||
sessionRegistry.updateBinding(claimed.node.id, { attachmentType: "external_cli", externalSessionName: claimed.sessionName });
|
||||
const external = await service.stopSeat({ seatRef: claimed.sessionName, reason: "x" });
|
||||
@@ -485,7 +485,7 @@ describe("SeatLifecycleService", () => {
|
||||
|
||||
it("claimedSessionRoutes routes a tmux session through rig unclaim, never rig down", () => {
|
||||
expect(claimedSessionRoutes("r", "a@r")).toBe("To end this session, run rig unclaim 'a@r' (it releases the session from OpenRig "
|
||||
+ "and leaves its process running), then end that process and the underlying tmux session, including any remaining shell, so no session keeps the seat's name. Closing only its viewing window leaves the tmux session running. rig down 'r' leaves a session OpenRig didn't start running.");
|
||||
+ "and leaves its process running), then end that process in its own terminal. rig down 'r' leaves a session OpenRig didn't start running.");
|
||||
});
|
||||
|
||||
// ---- P5 / P6 — clean ----
|
||||
|
||||
@@ -636,10 +636,10 @@ describe("rig seat restart --keep-conversation: gates before any signal", () =>
|
||||
w.db.prepare("UPDATE sessions SET origin = 'claimed' WHERE id = ?").run(w.session.id);
|
||||
const claimed = await w.service.restartInPlace({ seatRef: SEAT, plan: true });
|
||||
expect(claimed).toMatchObject({ ok: false, code: "claimed_session" });
|
||||
// B5: the refusal's route is rig unclaim, then ending the process and its tmux session; rig down leaves a session
|
||||
// B5: the refusal's route is rig unclaim, then ending the process in its own terminal; rig down leaves a session
|
||||
// OpenRig didn't start running, so it isn't offered as the way to end this one.
|
||||
const guidance = (claimed as { guidance?: string }).guidance ?? "";
|
||||
expect(guidance).toMatch(/^To end this session, run rig unclaim '[^']+' \(it releases the session from OpenRig and leaves its process running\), then end that process and the underlying tmux session, including any remaining shell, so no session keeps the seat's name\. Closing only its viewing window leaves the tmux session running\. /);
|
||||
expect(guidance).toMatch(/^To end this session, run rig unclaim '[^']+' \(it releases the session from OpenRig and leaves its process running\), then end that process in its own terminal\. /);
|
||||
expect(guidance).toMatch(/rig down '[^']+' leaves a session OpenRig didn't start running\.$/);
|
||||
expect(guidance).not.toMatch(/run rig down/);
|
||||
});
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type Database from "better-sqlite3";
|
||||
import { createDb } from "../src/db/connection.js";
|
||||
import { migrate } from "../src/db/migrate.js";
|
||||
import { ALL_MIGRATIONS } from "../src/db/all-migrations.js";
|
||||
import { resolveCurrentSessionAddress, SessionRegistry } from "../src/domain/session-registry.js";
|
||||
import { getNodeInventory } from "../src/domain/node-inventory.js";
|
||||
|
||||
const databases: Database.Database[] = [];
|
||||
afterEach(() => { for (const db of databases.splice(0)) db.close(); });
|
||||
|
||||
function fixture(indexed = true) {
|
||||
const db = createDb(); databases.push(db);
|
||||
migrate(db, ALL_MIGRATIONS.filter(m => indexed || m.name !== "162_session_address_read_indexes.sql"));
|
||||
db.exec("INSERT INTO rigs(id,name) VALUES('r','team')");
|
||||
const node = db.prepare("INSERT INTO nodes(id,rig_id,logical_id,runtime) VALUES(?,'r',?,'claude-code')");
|
||||
const session = db.prepare("INSERT INTO sessions(id,node_id,session_name,status,created_at) VALUES(?,?,?,'running',?)");
|
||||
const binding = db.prepare("INSERT INTO bindings(id,node_id,tmux_session,attachment_type) VALUES(?,?,?,'tmux')");
|
||||
for (let n = 0; n < 4; n++) {
|
||||
node.run(`n${n}`, `dev.worker${n}`);
|
||||
for (let i = 0; i < 20; i++) session.run(`${n}-${String(i).padStart(3, '0')}`, `n${n}`, `native-${n}@team`, "2099-01-01");
|
||||
// Session id defines recency even when the wall clock moves backwards.
|
||||
session.run(`${n}-999`, `n${n}`, `native-${n}@team`, "2026-01-01");
|
||||
binding.run(`b${n}`, `n${n}`, `native-${n}@team`);
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
describe("current session address read cost", () => {
|
||||
it("seeks a physical name and the latest registration instead of scanning session history", () => {
|
||||
const db = fixture(), sql: string[] = [], prepare = db.prepare;
|
||||
db.prepare = function (text: string) { sql.push(text); return prepare.call(this, text); } as typeof db.prepare;
|
||||
try {
|
||||
expect(resolveCurrentSessionAddress(db, "native-0@team")?.nodeId).toBe("n0");
|
||||
expect(getNodeInventory(db, "r").map(n => n.canonicalSessionName)).toContain("native-0@team");
|
||||
} finally { db.prepare = prepare; }
|
||||
const address = sql.find(s => s.includes("AS senderAddress"))!;
|
||||
const plan = db.prepare(`EXPLAIN QUERY PLAN ${address}`).all("native-0@team", "native-0@team") as { detail: string }[];
|
||||
expect(plan.map(p => p.detail).join("\n")).toMatch(/SEARCH b USING INDEX idx_bindings_tmux_session/);
|
||||
expect(plan.map(p => p.detail).join("\n")).not.toMatch(/SCAN s\b/);
|
||||
const latest = db.prepare("EXPLAIN QUERY PLAN SELECT id FROM sessions WHERE node_id=? ORDER BY id DESC LIMIT 1").all("n0") as { detail: string }[];
|
||||
expect(latest.map(p => p.detail).join("\n")).not.toContain("TEMP B-TREE");
|
||||
});
|
||||
|
||||
it("keeps exact-name priority, derived aliases, and fresh binding changes", () => {
|
||||
const db = fixture();
|
||||
expect(resolveCurrentSessionAddress(db, "dev-worker0@team")?.sessionName).toBe("native-0@team");
|
||||
// A physical spelling takes priority over another seat's derived spelling.
|
||||
db.prepare("UPDATE sessions SET session_name='dev-worker0@team' WHERE id='1-999'").run();
|
||||
db.prepare("UPDATE bindings SET tmux_session='dev-worker0@team' WHERE node_id='n1'").run();
|
||||
expect(resolveCurrentSessionAddress(db, "dev-worker0@team")?.nodeId).toBe("n1");
|
||||
db.prepare("UPDATE sessions SET status='exited' WHERE id='1-999'").run();
|
||||
// That other seat's saved spelling still reserves the name.
|
||||
expect(resolveCurrentSessionAddress(db, "dev-worker0@team")).toBeNull();
|
||||
db.prepare("UPDATE rigs SET archived_at='2026-01-02' WHERE id='r'").run();
|
||||
expect(resolveCurrentSessionAddress(db, "native-0@team")).toBeNull();
|
||||
});
|
||||
|
||||
it("seeks the latest named registration when the generation reader resolves a session", () => {
|
||||
const db = fixture(), sql: string[] = [], prepare = db.prepare;
|
||||
db.prepare = function (text: string) { sql.push(text); return prepare.call(this, text); } as typeof db.prepare;
|
||||
try { new SessionRegistry(db).currentOccupantGenerationForSession("native-0@team"); }
|
||||
finally { db.prepare = prepare; }
|
||||
const read = sql.find(s => s.includes("SELECT node_id FROM sessions WHERE session_name"))!;
|
||||
expect(read).toBeDefined();
|
||||
const plan = db.prepare(`EXPLAIN QUERY PLAN ${read}`).all("native-0@team") as { detail: string }[];
|
||||
expect(plan.map(p => p.detail).join("\n")).toContain("SEARCH sessions USING INDEX idx_sessions_name_latest_id");
|
||||
expect(plan.map(p => p.detail).join("\n")).not.toContain("TEMP B-TREE");
|
||||
});
|
||||
|
||||
it("upgrades populated databases without changing registrations or inventory", () => {
|
||||
const db = fixture(false);
|
||||
const before = { inventory: getNodeInventory(db, "r"),
|
||||
physical: resolveCurrentSessionAddress(db, "native-0@team"),
|
||||
alias: resolveCurrentSessionAddress(db, "dev-worker0@team"),
|
||||
registrations: db.prepare("SELECT * FROM sessions ORDER BY id").all() };
|
||||
migrate(db, ALL_MIGRATIONS);
|
||||
migrate(db, ALL_MIGRATIONS);
|
||||
expect({ inventory: getNodeInventory(db, "r"),
|
||||
physical: resolveCurrentSessionAddress(db, "native-0@team"),
|
||||
alias: resolveCurrentSessionAddress(db, "dev-worker0@team"),
|
||||
registrations: db.prepare("SELECT * FROM sessions ORDER BY id").all() }).toEqual(before);
|
||||
expect(db.pragma("integrity_check", { simple: true })).toBe("ok");
|
||||
expect(db.pragma("foreign_key_check")).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -370,23 +370,6 @@ describe("SessionRegistry", () => {
|
||||
expect(names).toContain("r01-dev1-qa");
|
||||
});
|
||||
|
||||
it("getSessionsForRig preserves insertion order for timestamp ties with the session indexes installed", () => {
|
||||
const indexes = db.prepare("PRAGMA index_list(sessions)").all() as { name: string }[];
|
||||
expect(indexes.map((index) => index.name)).toContain("idx_sessions_node_latest_id");
|
||||
const insert = db.prepare("INSERT INTO sessions(id, node_id, session_name, created_at) VALUES (?, ?, ?, ?)");
|
||||
// Non-monotonic ids distinguish insertion order from either id ordering.
|
||||
insert.run("m-first", "node-1", "first@rig", "2026-01-02");
|
||||
insert.run("z-second", "node-1", "second@rig", "2026-01-02");
|
||||
insert.run("a-third", "node-1", "third@rig", "2026-01-02");
|
||||
insert.run("n-fourth", "node-2", "fourth@rig", "2026-01-02");
|
||||
// Timestamp remains the primary order even when this row was inserted last.
|
||||
insert.run("earlier", "node-2", "earlier@rig", "2026-01-01");
|
||||
|
||||
expect(registry.getSessionsForRig("rig-1").map((session) => session.id)).toEqual([
|
||||
"earlier", "m-first", "z-second", "a-third", "n-fourth",
|
||||
]);
|
||||
});
|
||||
|
||||
it("getBindingForNode returns null when unbound", () => {
|
||||
const binding = registry.getBindingForNode("node-1");
|
||||
expect(binding).toBeNull();
|
||||
|
||||
@@ -7,7 +7,6 @@ import type Database from "better-sqlite3";
|
||||
import { createDb } from "../src/db/connection.js";
|
||||
import { migrate } from "../src/db/migrate.js";
|
||||
import { coreSchema } from "../src/db/migrations/001_core_schema.js";
|
||||
import { rigArchiveSchema } from "../src/db/migrations/042_rig_archive.js";
|
||||
import { outboxEntriesSchema } from "../src/db/migrations/027_outbox_entries.js";
|
||||
import { bindingsSessionsSchema } from "../src/db/migrations/002_bindings_sessions.js";
|
||||
import { eventsSchema } from "../src/db/migrations/003_events.js";
|
||||
@@ -134,7 +133,7 @@ describe("workflow route (WF3 FR-4 — close+recreate+rebind)", () => {
|
||||
db = createDb();
|
||||
migrate(db, [
|
||||
outboxEntriesSchema,
|
||||
coreSchema, bindingsSessionsSchema, rigArchiveSchema, eventsSchema,
|
||||
coreSchema, bindingsSessionsSchema, eventsSchema,
|
||||
queueItemsSchema, queueTransitionsSchema,
|
||||
queueItemSummarySchema, queueItemEvidenceRefSchema,
|
||||
workflowSpecsSchema, workflowInstancesSchema, workflowStepTrailsSchema,
|
||||
|
||||
@@ -7,7 +7,6 @@ import type Database from "better-sqlite3";
|
||||
import { createDb } from "../src/db/connection.js";
|
||||
import { migrate } from "../src/db/migrate.js";
|
||||
import { coreSchema } from "../src/db/migrations/001_core_schema.js";
|
||||
import { rigArchiveSchema } from "../src/db/migrations/042_rig_archive.js";
|
||||
import { outboxEntriesSchema } from "../src/db/migrations/027_outbox_entries.js";
|
||||
import { eventsSchema } from "../src/db/migrations/003_events.js";
|
||||
import { queueItemsSchema } from "../src/db/migrations/024_queue_items.js";
|
||||
@@ -583,7 +582,7 @@ describe("workflow routes — WF-2 structured error boundaries", () => {
|
||||
migrate(db, [
|
||||
outboxEntriesSchema,
|
||||
coreSchema,
|
||||
bindingsSessionsSchema, rigArchiveSchema,
|
||||
bindingsSessionsSchema,
|
||||
eventsSchema,
|
||||
queueItemsSchema,
|
||||
queueTransitionsSchema,
|
||||
|
||||
@@ -13,7 +13,6 @@ import type Database from "better-sqlite3";
|
||||
import { createDb } from "../src/db/connection.js";
|
||||
import { migrate } from "../src/db/migrate.js";
|
||||
import { coreSchema } from "../src/db/migrations/001_core_schema.js";
|
||||
import { rigArchiveSchema } from "../src/db/migrations/042_rig_archive.js";
|
||||
import { outboxEntriesSchema } from "../src/db/migrations/027_outbox_entries.js";
|
||||
import { bindingsSessionsSchema } from "../src/db/migrations/002_bindings_sessions.js";
|
||||
import { eventsSchema } from "../src/db/migrations/003_events.js";
|
||||
@@ -189,7 +188,7 @@ describe("OPR.0.4.6.WF2 — spec language", () => {
|
||||
migrate(db, [
|
||||
outboxEntriesSchema,
|
||||
coreSchema,
|
||||
bindingsSessionsSchema, rigArchiveSchema,
|
||||
bindingsSessionsSchema,
|
||||
eventsSchema,
|
||||
queueItemsSchema,
|
||||
queueTransitionsSchema,
|
||||
|
||||
@@ -287,10 +287,8 @@ function toAgentRow(node: NodeInventoryRead): AgentRow {
|
||||
: node.activityState?.display === "idle"
|
||||
? "idle"
|
||||
: (node.terminalActive === true || (node.terminalActive == null && node.agentActivity?.state === "running") ? "active"
|
||||
// The daemon's activity word decides when it's served (as in `rig ps`): it said unknown, so a quiet
|
||||
// screen stays unknown. A community harness gives no idle signal either. Only an older daemon that
|
||||
// serves no activity word keeps the screen fallback.
|
||||
: node.communityHarness || node.activityState ? "unknown" : "idle"))
|
||||
// A community harness gives no idle signal: a quiet screen is unknown, not idle.
|
||||
: node.communityHarness ? "unknown" : "idle"))
|
||||
: (node.sessionStatus ?? "unknown"),
|
||||
live: node.lifecycleState === "running",
|
||||
canRun: node.lifecycleState !== "running"
|
||||
|
||||
+30
-49
@@ -48,8 +48,6 @@ interface ContentLine {
|
||||
zones?: Array<{ start: number; end: number; action: Action }>;
|
||||
/** slice-17: token segments for canvas-rendered rows (graph view) */
|
||||
segs?: Array<{ text: string; token?: import("./theme.js").Token; bold?: boolean; bg?: import("./theme.js").Token }>;
|
||||
/** Row of a labelled group. The first visible row of each group shows `text` when set, so a scrolled group keeps its label. */
|
||||
sticky?: { group: string; text?: string };
|
||||
}
|
||||
|
||||
function pad(text: string | number | null | undefined, width: number): string {
|
||||
@@ -96,24 +94,17 @@ function agentColumns(contentWidth: number): AgentColumn[] {
|
||||
];
|
||||
}
|
||||
|
||||
/** RIG fits the longest rig name shown, between the tier's old width and its cap; NOW or WORK gives up the difference. */
|
||||
function instanceAgentColumns(contentWidth: number, longestRigName = 0): AgentColumn[] {
|
||||
if (contentWidth >= 110) {
|
||||
const rig = Math.min(Math.max(longestRigName, 13), 24);
|
||||
return [
|
||||
["rig", "RIG", rig, "left"], ["pod", "POD", 8, "left"], ["seat", "SEAT", 14, "left"],
|
||||
["runtime", "RT", 3, "left"], ["context", "CTX", 7, "right"], ["status", "STATE", 10, "left"],
|
||||
["queue", "Q", 2, "right"], ["work", "WORK", 12, "left"], ["now", "NOW", Math.max(18, contentWidth - 78 - (rig - 13)), "left"],
|
||||
];
|
||||
}
|
||||
if (contentWidth >= 78) {
|
||||
const rig = Math.min(Math.max(longestRigName, 10), 16);
|
||||
return [
|
||||
["rig", "RIG", rig, "left"], ["pod", "POD", 6, "left"], ["seat", "SEAT", 12, "left"],
|
||||
["runtime", "RT", 3, "left"], ["context", "CTX", 5, "right"], ["status", "STATE", 9, "left"],
|
||||
["queue", "Q", 2, "right"], ["work", "WORK", Math.max(8, contentWidth - 54 - (rig - 10)), "left"],
|
||||
];
|
||||
}
|
||||
function instanceAgentColumns(contentWidth: number): AgentColumn[] {
|
||||
if (contentWidth >= 110) return [
|
||||
["rig", "RIG", 13, "left"], ["pod", "POD", 8, "left"], ["seat", "SEAT", 14, "left"],
|
||||
["runtime", "RT", 3, "left"], ["context", "CTX", 7, "right"], ["status", "STATE", 10, "left"],
|
||||
["queue", "Q", 2, "right"], ["work", "WORK", 12, "left"], ["now", "NOW", Math.max(18, contentWidth - 78), "left"],
|
||||
];
|
||||
if (contentWidth >= 78) return [
|
||||
["rig", "RIG", 10, "left"], ["pod", "POD", 6, "left"], ["seat", "SEAT", 12, "left"],
|
||||
["runtime", "RT", 3, "left"], ["context", "CTX", 5, "right"], ["status", "STATE", 9, "left"],
|
||||
["queue", "Q", 2, "right"], ["work", "WORK", Math.max(8, contentWidth - 54), "left"],
|
||||
];
|
||||
const fixed = 8 + 5 + 11 + 2 + 5 + 8 + 2 + 7;
|
||||
return [
|
||||
["rig", "RIG", 8, "left"], ["pod", "POD", 5, "left"], ["seat", "SEAT", 11, "left"],
|
||||
@@ -718,7 +709,7 @@ function noRigsText(snap: FleetSnapshot, host: FleetSnapshot["hosts"][number], l
|
||||
: ` (no rigs served by ${host.name})`;
|
||||
}
|
||||
|
||||
/** Rig boundaries take a solid rule and pod boundaries a dotted one; RIG shows on a rig's first visible row so scrolled rows keep it. */
|
||||
/** Rig boundaries take a solid rule and pod boundaries a dotted one; RIG repeats on every row so scrolled rows keep it. */
|
||||
function instanceContentLines(
|
||||
state: ViewState,
|
||||
snap: FleetSnapshot,
|
||||
@@ -810,12 +801,7 @@ function instanceContentLines(
|
||||
lines.push(healthSummaryLine(snap, scope, contentWidth));
|
||||
lines.push(...queueSampleLines(snap, contentWidth));
|
||||
lines.push({ text: state.filter ? `/ filter instance rows: ${state.filter} · / replace · esc clear` : "/ filter instance rows…" });
|
||||
const shownRigs = host.rigs.map((rig) => ({
|
||||
rig,
|
||||
agents: rig.pods.flatMap((pod) => pod.agents.map((agent) => ({ pod: pod.name, agent })))
|
||||
.filter(({ pod, agent }) => !state.filter || rig.name.includes(state.filter) || pod.includes(state.filter) || agent.name.includes(state.filter)),
|
||||
})).filter(({ rig, agents }) => !state.filter || agents.length > 0 || rig.name.includes(state.filter));
|
||||
const columns = instanceAgentColumns(contentWidth, Math.max(0, ...shownRigs.map(({ rig }) => rig.name.length)));
|
||||
const columns = instanceAgentColumns(contentWidth);
|
||||
lines.push({ text: tableRow(columns, { rig: "RIG", pod: "POD", seat: "SEAT", runtime: "RT", context: "CTX", status: "STATE", queue: "Q", work: "WORK", now: "NOW" }) });
|
||||
lines.push({ text: "━".repeat(columnsWidth(columns)) });
|
||||
let seatCount = 0;
|
||||
@@ -824,7 +810,10 @@ function instanceContentLines(
|
||||
let attentionUnknown = 0;
|
||||
let openCount = 0;
|
||||
let firstRig = true;
|
||||
for (const { rig, agents } of shownRigs) {
|
||||
for (const rig of host.rigs) {
|
||||
const agents = rig.pods.flatMap((pod) => pod.agents.map((agent) => ({ pod: pod.name, agent })))
|
||||
.filter(({ pod, agent }) => !state.filter || rig.name.includes(state.filter) || pod.includes(state.filter) || agent.name.includes(state.filter));
|
||||
if (state.filter && agents.length === 0 && !rig.name.includes(state.filter)) continue;
|
||||
if (!firstRig) lines.push({ text: "─".repeat(columnsWidth(columns)) });
|
||||
firstRig = false;
|
||||
const rigAction: Action = { type: "drill", resource: "rig", name: rig.name, target: { host: host.name } };
|
||||
@@ -836,7 +825,7 @@ function instanceContentLines(
|
||||
continue;
|
||||
}
|
||||
let previousPod: string | null = null;
|
||||
for (const [index, { pod, agent }] of agents.entries()) {
|
||||
for (const { pod, agent } of agents) {
|
||||
const firstInPod = pod !== previousPod;
|
||||
if (firstInPod && previousPod != null) lines.push({ text: "┈".repeat(columnsWidth(columns)) });
|
||||
previousPod = pod;
|
||||
@@ -847,21 +836,18 @@ function instanceContentLines(
|
||||
if (agent.needsAttention === true) attentionCount += 1;
|
||||
else if (agent.needsAttention == null) attentionUnknown += 1;
|
||||
openCount += queue.count;
|
||||
const cells = {
|
||||
rig: rig.name,
|
||||
pod: firstInPod ? pod : "",
|
||||
seat: `${stateCell.mark} ${seatName(pod, agent.name)}`,
|
||||
runtime: runtimeShort(agent.runtime),
|
||||
context: contextCompact(agent.context, contentWidth < 110),
|
||||
status: stateCell.word,
|
||||
queue: queueCount(snap, queue.count),
|
||||
work: queue.work,
|
||||
now: queue.now,
|
||||
};
|
||||
const named = tableRow(columns, cells);
|
||||
lines.push({
|
||||
text: index === 0 ? named : tableRow(columns, { ...cells, rig: "" }),
|
||||
sticky: { group: `rig:${rig.name}`, ...(index === 0 ? {} : { text: named }) },
|
||||
text: tableRow(columns, {
|
||||
rig: rig.name,
|
||||
pod: firstInPod ? pod : "",
|
||||
seat: `${stateCell.mark} ${seatName(pod, agent.name)}`,
|
||||
runtime: runtimeShort(agent.runtime),
|
||||
context: contextCompact(agent.context, contentWidth < 110),
|
||||
status: stateCell.word,
|
||||
queue: queueCount(snap, queue.count),
|
||||
work: queue.work,
|
||||
now: queue.now,
|
||||
}),
|
||||
action: { type: "drill", resource: "agent", name: agent.name, target: { host: host.name, rig: rig.name, pod } },
|
||||
zones: [{ start: 0, end: columns[0]![2], action: rigAction }],
|
||||
});
|
||||
@@ -1958,12 +1944,7 @@ function renderBody(state: ViewState, snap: FleetSnapshot, options: RenderOption
|
||||
const contentRows = content.length > bodyRows ? Math.max(bodyRows - 1, 0) : bodyRows;
|
||||
const maxContentOffset = Math.max(content.length - contentRows, 0);
|
||||
const contentStart = Math.min(state.contentOffset, maxContentOffset);
|
||||
const labelledGroups = new Set<string>();
|
||||
const visibleContent = content.slice(contentStart, contentStart + contentRows).map((line) => {
|
||||
if (!line.sticky || labelledGroups.has(line.sticky.group)) return line;
|
||||
labelledGroups.add(line.sticky.group);
|
||||
return line.sticky.text == null ? line : { ...line, text: line.sticky.text };
|
||||
});
|
||||
const visibleContent = content.slice(contentStart, contentStart + contentRows);
|
||||
if (content.length > bodyRows) {
|
||||
const scrollText = `scroll ↑/↓ · ${contentStart + 1}-${contentStart + visibleContent.length} of ${content.length}`;
|
||||
const up = scrollText.indexOf("↑");
|
||||
|
||||
@@ -710,7 +710,7 @@ describe("snapshot hydration over the §4.A reads (Phase 2)", () => {
|
||||
expect(output.lines.find((line) => /\bmismatch\s/.test(line))).not.toContain("run ▸");
|
||||
expect(output.lines.find((line) => /\bmissing\s/.test(line))).not.toContain("run ▸");
|
||||
});
|
||||
it("shows a quiet seat the daemon reads as unknown as unknown, never idle, for a community harness and a built-in runtime alike", async () => {
|
||||
it("shows a quiet community harness as unknown, never idle, and leaves built-in rows as they were", async () => {
|
||||
const base = (FIXTURES["/api/rigs/01JRIG/nodes"] as Array<Record<string, unknown>>)[0]!;
|
||||
const quiet = { startupStatus: "ready", sessionStatus: "running", terminalActive: false, agentActivity: { state: "unknown" },
|
||||
activityState: { activity: "unknown", display: "unknown", needsInput: { count: 0, reason: null }, decidedBy: null } };
|
||||
@@ -727,28 +727,8 @@ describe("snapshot hydration over the §4.A reads (Phase 2)", () => {
|
||||
"dev.app-quiet": "unknown",
|
||||
"dev.app-moving": "active",
|
||||
"dev.app-prompt": "needs_input",
|
||||
"dev.pi-quiet": "unknown",
|
||||
"dev.claude-quiet": "unknown",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the daemon's idle and working words, and the screen fallback only for a daemon that serves no activity word", async () => {
|
||||
const base = (FIXTURES["/api/rigs/01JRIG/nodes"] as Array<Record<string, unknown>>)[0]!;
|
||||
const running = { startupStatus: "ready", sessionStatus: "running", runtime: "claude-code", agentActivity: { state: "unknown" } };
|
||||
const served = (display: string) => ({ activity: display === "idle" ? "idle-at-prompt" : display, display, needsInput: { count: 0, reason: null }, decidedBy: null });
|
||||
const nodes = [
|
||||
{ ...base, ...running, logicalId: "dev.served-idle", terminalActive: false, activityState: served("idle") },
|
||||
{ ...base, ...running, logicalId: "dev.served-working", terminalActive: false, activityState: served("working") },
|
||||
{ ...base, ...running, logicalId: "dev.unknown-moving", terminalActive: true, activityState: served("unknown") },
|
||||
{ ...base, ...running, logicalId: "dev.legacy-quiet", terminalActive: false, activityState: undefined },
|
||||
];
|
||||
const snap = await hydrateSnapshot(fixtureClient({}, { "/api/rigs/01JRIG/nodes": nodes }));
|
||||
const statuses = Object.fromEntries(myrig(snap).pods[0]!.agents.map((agent) => [agent.name, agent.status]));
|
||||
expect(statuses).toEqual({
|
||||
"dev.served-idle": "idle",
|
||||
"dev.served-working": "active",
|
||||
"dev.unknown-moving": "active",
|
||||
"dev.legacy-quiet": "idle",
|
||||
"dev.pi-quiet": "idle",
|
||||
"dev.claude-quiet": "idle",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import { decodeInput, resolveKeyAction, sgrClick } from "../src/input.js";
|
||||
import { renderScreen } from "../src/render.js";
|
||||
import { computeExplorerRows, createViewState } from "../src/state.js";
|
||||
import { createStyle, stripAnsi } from "../src/theme.js";
|
||||
import { explorerWidth } from "../src/visual-layout.js";
|
||||
import { stylizeLines } from "../src/stylize.js";
|
||||
import type { AgentRow, FleetSnapshot } from "../src/types.js";
|
||||
|
||||
@@ -119,20 +118,16 @@ describe("S12 instance mission control", () => {
|
||||
let scrolledSeatRows = 0;
|
||||
for (let offset = 0; offset <= screen.contentMaxOffset; offset += 1) {
|
||||
screen = renderScreen(view.get(), snap, { cols, rows, nowMs: 0, colorMode: "none" });
|
||||
const namedRigs = new Set<string>();
|
||||
for (const target of screen.contentTargets) {
|
||||
if (target.action.type !== "drill" || target.action.resource !== "agent") continue;
|
||||
reached.add(target.action.name);
|
||||
const rig = target.action.target?.rig;
|
||||
const content = stripAnsi(screen.lines[target.y - 1] ?? "").slice(screen.explorerWidth + 2);
|
||||
const firstVisible = !!rig && !namedRigs.has(rig);
|
||||
if (rig) namedRigs.add(rig);
|
||||
if (!rig || !/\b(working|needs you|idle|detached|failed|unknown|blocked)\b/.test(content)) continue;
|
||||
const headingVisible = screen.lines.some((line) => stripAnsi(line).slice(screen.explorerWidth + 2).includes(`RIG ${rig} ·`));
|
||||
if (headingVisible) continue;
|
||||
scrolledSeatRows += 1;
|
||||
if (firstVisible) expect(content.startsWith(rig), `${cols}: first visible seat row of a rig retains rig identity`).toBe(true);
|
||||
else expect(content.startsWith(" ".repeat(rig.length)), `${cols}: later seat rows of a rig leave RIG blank`).toBe(true);
|
||||
expect(content.trimStart().startsWith(rig), `${cols}: scrolled seat row retains rig identity`).toBe(true);
|
||||
}
|
||||
view.dispatch({ type: "content-scroll", delta: 1 });
|
||||
}
|
||||
@@ -145,7 +140,7 @@ describe("S12 instance mission control", () => {
|
||||
expect(whole).toMatch(/empty\s+—\s+\(no seats\).*stopped/);
|
||||
expect(whole).toMatch(/dev\s+[\s\S]*┈{8,}[\s\S]*qa\s+/);
|
||||
expect((whole.match(/┈{8,}/g) ?? [])).toHaveLength(1);
|
||||
expect(whole).toMatch(/\s+qa\s+[^\n]*\n[^\n]*─{8,}[^\n]*\n[^\n]*docs\s+write/);
|
||||
expect(whole).toMatch(/build\s+qa\s+[^\n]*\n[^\n]*─{8,}[^\n]*\n[^\n]*docs\s+write/);
|
||||
expect(whole).toMatch(/docs\s+write\s+[^\n]*\n[^\n]*─{8,}[^\n]*\n[^\n]*empty\s+—/);
|
||||
expect(whole).not.toMatch(/\+\d+|more hidden/i);
|
||||
expect(reached).toEqual(expectedAgents);
|
||||
@@ -324,138 +319,3 @@ describe("S12 view-aware hydration", () => {
|
||||
expect(snap.hosts[0]).toMatchObject({ id: "local", name: "local" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("instance table RIG column (#2848)", () => {
|
||||
const LONG = "RIG-session-review";
|
||||
const OVER_CAP = "rig-name-thirty-characters-xx";
|
||||
|
||||
function colsFor(contentWidth: number): number {
|
||||
for (let cols = contentWidth; cols < contentWidth + 80; cols += 1) {
|
||||
if (cols - explorerWidth(cols) - 2 === contentWidth) return cols;
|
||||
}
|
||||
throw new Error(`no terminal width gives content width ${contentWidth}`);
|
||||
}
|
||||
|
||||
function snapshotWithRigs(names: string[]): FleetSnapshot {
|
||||
const snap = multiRigSnapshot();
|
||||
snap.hosts[0]!.rigs.push(...names.map((name) => ({
|
||||
id: `r-${name}`,
|
||||
name,
|
||||
lifecycleState: "running",
|
||||
pods: [{ name: "ops", agents: [agent("ops.worker", `ops-worker@${name}`, "idle", 10)] }],
|
||||
})));
|
||||
return snap;
|
||||
}
|
||||
|
||||
function table(snap: FleetSnapshot, contentWidth: number, filter = "") {
|
||||
const view = openInstance(snap);
|
||||
if (filter) view.dispatch({ type: "filter", text: filter });
|
||||
const cols = colsFor(contentWidth);
|
||||
const screen = renderScreen(view.get(), snap, { cols, rows: 160, nowMs: 0, colorMode: "none" });
|
||||
const content = screen.lines.map((line) => stripAnsi(line).slice(screen.explorerWidth + 2));
|
||||
const header = content.find((line) => /^RIG\s+POD\s+SEAT/.test(line));
|
||||
expect(header, `${contentWidth}: table header`).toBeDefined();
|
||||
const heavyRule = content.find((line) => /^━{20,}/.test(line))!.match(/^━+/)![0].length;
|
||||
const rigWidth = header!.indexOf("POD") - 1;
|
||||
return { screen, content, header: header!, rigWidth, rowWidth: heavyRule };
|
||||
}
|
||||
|
||||
it.each([
|
||||
// [content width, expected RIG width, flexible column, expected flexible width]
|
||||
[77, 8, "WORK", 77 - (8 + 5 + 11 + 2 + 5 + 8 + 2 + 7)],
|
||||
[78, 16, "WORK", 78 - 54 - (16 - 10)],
|
||||
[109, 16, "WORK", 109 - 54 - (16 - 10)],
|
||||
[110, LONG.length, "NOW", 110 - 78 - (LONG.length - 13)],
|
||||
[190, LONG.length, "NOW", 190 - 78 - (LONG.length - 13)],
|
||||
] as const)("fits RIG to the longest shown name at content width %i", (contentWidth, rigWidth, flexible, flexibleWidth) => {
|
||||
const { header, rigWidth: actual, rowWidth } = table(snapshotWithRigs([LONG]), contentWidth);
|
||||
expect(actual).toBe(rigWidth);
|
||||
expect(rowWidth).toBeLessThanOrEqual(contentWidth);
|
||||
expect(header.trimEnd().endsWith(flexible)).toBe(true);
|
||||
expect(rowWidth - header.lastIndexOf(flexible)).toBe(flexibleWidth);
|
||||
});
|
||||
|
||||
it("keeps the old RIG width as the minimum when every name is short", () => {
|
||||
expect(table(multiRigSnapshot(), 160).rigWidth).toBe(13);
|
||||
expect(table(multiRigSnapshot(), 90).rigWidth).toBe(10);
|
||||
});
|
||||
|
||||
it.each([[110, 24], [160, 24], [78, 16], [100, 16]])("caps RIG and truncates a longer name at content width %i", (contentWidth, cap) => {
|
||||
const { content, rigWidth, rowWidth } = table(snapshotWithRigs([OVER_CAP]), contentWidth);
|
||||
expect(rigWidth).toBe(cap);
|
||||
expect(rowWidth).toBeLessThanOrEqual(contentWidth);
|
||||
expect(content.some((line) => line.startsWith(`${OVER_CAP.slice(0, cap - 1)}…`))).toBe(true);
|
||||
for (const line of content) expect(line.trimEnd().length).toBeLessThanOrEqual(contentWidth);
|
||||
});
|
||||
|
||||
function seatRows(screen: ReturnType<typeof renderScreen>) {
|
||||
return screen.contentTargets
|
||||
.filter((target) => target.action.type === "drill" && target.action.resource === "agent")
|
||||
.map((target) => ({
|
||||
y: target.y,
|
||||
rig: target.action.type === "drill" ? target.action.target?.rig ?? "" : "",
|
||||
name: target.action.type === "drill" ? target.action.name : "",
|
||||
text: stripAnsi(screen.lines[target.y - 1] ?? "").slice(screen.explorerWidth + 2),
|
||||
}));
|
||||
}
|
||||
|
||||
it("names every rig on its single row when each rig has one seat", () => {
|
||||
const names = Array.from({ length: 8 }, (_, index) => `solo-${index}`);
|
||||
const { screen } = table(snapshotWithRigs(names), 160);
|
||||
const rows = seatRows(screen).filter((row) => names.includes(row.rig));
|
||||
expect(rows.map((row) => row.rig)).toEqual(names);
|
||||
for (const row of rows) expect(row.text.startsWith(`${row.rig} `)).toBe(true);
|
||||
});
|
||||
|
||||
it("names a rig on its first row only, across pod dividers, while POD still leads each pod", () => {
|
||||
const { screen, content, rigWidth } = table(multiRigSnapshot(), 160);
|
||||
const build = seatRows(screen).filter((row) => row.rig === "build");
|
||||
expect(build.map((row) => row.name)).toEqual(["dev.driver", "dev.guard", "qa.checker"]);
|
||||
expect(build[0]!.text).toMatch(/^build\s+dev\s/);
|
||||
expect(build[1]!.text.slice(0, rigWidth + 4).trim()).toBe("");
|
||||
expect(build[2]!.text).toMatch(new RegExp(`^ {${rigWidth}} qa\\s`));
|
||||
expect(content[build[2]!.y - 2]).toMatch(/^┈{8,}/);
|
||||
expect(content.filter((line) => line.startsWith("build "))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("drills into the row's rig from a blank RIG cell", () => {
|
||||
const { screen, rigWidth } = table(multiRigSnapshot(), 160);
|
||||
const guard = seatRows(screen).find((row) => row.name === "dev.guard")!;
|
||||
expect(guard.text.slice(0, rigWidth).trim()).toBe("");
|
||||
for (const x of [screen.explorerWidth + 3, screen.explorerWidth + 2 + rigWidth]) {
|
||||
const hit = screen.hitMap.find((target) => target.y === guard.y && x >= target.x1 && x <= target.x2);
|
||||
expect(hit?.action).toEqual({ type: "drill", resource: "rig", name: "build", target: { host: "mm2-openrig1" } });
|
||||
}
|
||||
const pastRig = screen.explorerWidth + 3 + rigWidth;
|
||||
const seatHit = screen.hitMap.find((target) => target.y === guard.y && pastRig >= target.x1 && pastRig <= target.x2);
|
||||
expect(seatHit?.action).toMatchObject({ type: "drill", resource: "agent", name: "dev.guard" });
|
||||
});
|
||||
|
||||
it("names a scrolled rig on its top visible row and leaves the rows under it blank", () => {
|
||||
const snap = multiRigSnapshot();
|
||||
snap.hosts[0]!.rigs[0]!.pods[0]!.agents.push(...Array.from({ length: 36 }, (_, index) =>
|
||||
agent(`dev.extra-${index}`, `dev-extra-${index}@build`, "active", 20 + index),
|
||||
));
|
||||
const view = openInstance(snap);
|
||||
let screen = renderScreen(view.get(), snap, { cols: 160, rows: 30, nowMs: 0, colorMode: "none" });
|
||||
view.dispatch({ type: "layout", contentMaxOffset: screen.contentMaxOffset, contentTargetCount: screen.contentTargets.length });
|
||||
let checked = false;
|
||||
for (let offset = 0; offset <= screen.contentMaxOffset && !checked; offset += 1) {
|
||||
screen = renderScreen(view.get(), snap, { cols: 160, rows: 30, nowMs: 0, colorMode: "none" });
|
||||
const build = seatRows(screen).filter((row) => row.rig === "build");
|
||||
if (build.length >= 3 && build[0]!.name !== "dev.driver") {
|
||||
expect(build[0]!.text.startsWith("build ")).toBe(true);
|
||||
for (const row of build.slice(1)) expect(row.text.startsWith(" ")).toBe(true);
|
||||
checked = true;
|
||||
}
|
||||
view.dispatch({ type: "content-scroll", delta: 1 });
|
||||
}
|
||||
expect(checked).toBe(true);
|
||||
});
|
||||
|
||||
it("sizes RIG from the rigs the filter leaves on screen", () => {
|
||||
const snap = snapshotWithRigs([LONG, "short-rig"]);
|
||||
expect(table(snap, 160).rigWidth).toBe(LONG.length);
|
||||
expect(table(snap, 160, "short").rigWidth).toBe(13);
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,6 @@ import fs from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { test } from "node:test";
|
||||
import { packEntry } from "./npm-pack-json.mjs";
|
||||
|
||||
const script = fs.readFileSync(new URL("./build-package.sh", import.meta.url), "utf8");
|
||||
const assetsBranch = script.match(/^if \[ -d "\$DAEMON_DIR\/assets" \]; then\n[\s\S]*?^fi$/m)?.[0];
|
||||
@@ -130,7 +129,7 @@ test("offline npm pack carries staged asset bytes and executability, with npm's
|
||||
assertStaged(f);
|
||||
const staged = snapshot(f.staged);
|
||||
fs.writeFileSync(join(f.cli, "package.json"), JSON.stringify({ name: "openrig-assets-fixture", version: "1.0.0", private: true, files: ["daemon"] }));
|
||||
const packed = packEntry(execFileSync("bash", ["-c", 'umask 0022; exec npm pack --offline --ignore-scripts --json --pack-destination "$1"', "asset-pack", f.root], { cwd: f.cli, encoding: "utf8", timeout: 30_000, stdio: ["ignore", "pipe", "pipe"] }));
|
||||
const packed = JSON.parse(execFileSync("bash", ["-c", 'umask 0022; exec npm pack --offline --ignore-scripts --json --pack-destination "$1"', "asset-pack", f.root], { cwd: f.cli, encoding: "utf8", timeout: 30_000, stdio: ["ignore", "pipe", "pipe"] }))[0];
|
||||
const files = new Map(packed.files.map(file => [file.path, file]));
|
||||
for (const [name, bytes] of Object.entries(contents)) {
|
||||
const entry = `daemon/assets/${name}`;
|
||||
|
||||
@@ -2,14 +2,14 @@ import { execSync } from "node:child_process";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { packEntry } from "./npm-pack-json.mjs";
|
||||
|
||||
test("npm pack of @openrig/cli includes scripts/check-abi.mjs in tarball", () => {
|
||||
const output = execSync("npm pack --dry-run --json 2>/dev/null", {
|
||||
cwd: "packages/cli",
|
||||
encoding: "utf-8",
|
||||
});
|
||||
const files = packEntry(output).files?.map((f) => f.path) ?? [];
|
||||
const entries = JSON.parse(output);
|
||||
const files = entries[0]?.files?.map((f) => f.path) ?? [];
|
||||
|
||||
assert.ok(
|
||||
files.some((f) => f.includes("scripts/check-abi.mjs")),
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
scanInternalLeaks,
|
||||
} from "./internal-leak-scanner.mjs";
|
||||
import { LEAK_GUARD_RULES_ENV, leakGuardRulesPath } from "./leak-guard-rules.mjs";
|
||||
import { packEntries } from "./npm-pack-json.mjs";
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const FIX = "Genericize the content, cite its public home, or re-home it to the internal pack root.";
|
||||
@@ -156,22 +155,13 @@ function derivePackageArtifacts(packageRoot) {
|
||||
if (packed.status !== 0) {
|
||||
throw new Error(`npm artifact enumeration failed:\n${packed.stderr || packed.stdout || `exit ${packed.status}`}`);
|
||||
}
|
||||
let entries = [];
|
||||
let parsed;
|
||||
try {
|
||||
// One parse, in the shared reader: a JSON string (even one holding a serialized list) is not a shape npm prints.
|
||||
entries = packEntries(packed.stdout);
|
||||
parsed = JSON.parse(packed.stdout);
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError) throw new Error(`npm artifact enumeration did not return JSON: ${error.message}`);
|
||||
// not a shape npm prints: reported below as no files array
|
||||
throw new Error(`npm artifact enumeration did not return JSON: ${error.message}`);
|
||||
}
|
||||
// One package is packed here; scanning only the first of several would issue a receipt for part of the artifact.
|
||||
if (entries.length > 1) {
|
||||
throw new Error(
|
||||
`npm artifact enumeration listed ${entries.length} packages where one was packed; `
|
||||
+ "run the gate for one package (check --package-root and any npm workspaces setting)",
|
||||
);
|
||||
}
|
||||
const rawFiles = entries[0]?.files;
|
||||
const rawFiles = parsed?.[0]?.files;
|
||||
if (!Array.isArray(rawFiles)) throw new Error("npm artifact enumeration returned no files array");
|
||||
const files = rawFiles.map((entry) => entry?.path).filter((path) => typeof path === "string");
|
||||
if (files.length !== rawFiles.length || files.length === 0) {
|
||||
|
||||
@@ -5,7 +5,6 @@ import { execFileSync, spawnSync } from "node:child_process";
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
chmodSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
@@ -406,44 +405,6 @@ test("exact-line allowances reach content and derived scans and are recorded in
|
||||
});
|
||||
});
|
||||
|
||||
test("npm output that is a JSON string is refused, even when the string holds a serialized list", () => {
|
||||
withFixture(({ root, repo, rules, review, receipt }) => {
|
||||
write(join(repo, "public/guide.md"), "Generic product guidance.\n");
|
||||
commitAll(repo, "string output fixture");
|
||||
writeFileSync(review, JSON.stringify({ surfaces: [] }));
|
||||
const bin = join(root, "bin");
|
||||
const serialized = JSON.stringify([{ files: [{ path: "package.json" }, { path: "public/guide.md" }] }]);
|
||||
write(join(bin, "npm"), `#!${process.execPath}\nprocess.stdout.write(${JSON.stringify(JSON.stringify(serialized))});\n`);
|
||||
chmodSync(join(bin, "npm"), 0o755);
|
||||
|
||||
const result = runGate({ repo, rules, review, receipt, cutSha: gitHead(repo),
|
||||
env: { ...process.env, PATH: `${bin}:${process.env.PATH}` } });
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /npm artifact enumeration returned no files array/);
|
||||
});
|
||||
});
|
||||
|
||||
test("npm output that lists more than one package is refused before any scan or receipt", () => {
|
||||
const first = { name: "first", files: [{ path: "package.json" }, { path: "public/guide.md" }] };
|
||||
const second = { name: "second", files: [{ path: "package.json" }, { path: "public/other.md" }] };
|
||||
for (const printed of [[first, second], { first, second }]) {
|
||||
withFixture(({ root, repo, rules, review, receipt }) => {
|
||||
write(join(repo, "public/guide.md"), "Generic product guidance.\n");
|
||||
commitAll(repo, "two package output fixture");
|
||||
writeFileSync(review, JSON.stringify({ surfaces: [] }));
|
||||
const bin = join(root, "bin");
|
||||
write(join(bin, "npm"), `#!${process.execPath}\nprocess.stdout.write(${JSON.stringify(JSON.stringify(printed))});\n`);
|
||||
chmodSync(join(bin, "npm"), 0o755);
|
||||
|
||||
const result = runGate({ repo, rules, review, receipt, cutSha: gitHead(repo),
|
||||
env: { ...process.env, PATH: `${bin}:${process.env.PATH}` } });
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /npm artifact enumeration listed 2 packages where one was packed; run the gate for one package \(check --package-root and any npm workspaces setting\)/);
|
||||
assert.equal(existsSync(receipt), false);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("the release ceremony names the gate and its durable receipt", () => {
|
||||
const pkg = JSON.parse(readFileSync("package.json", "utf8"));
|
||||
assert.equal(pkg.scripts["gate:substance"], "node scripts/check-substance-gate.mjs");
|
||||
@@ -459,7 +420,7 @@ function writeGateHarness(root, source, invocationPath) {
|
||||
mkdirSync(root, { recursive: true });
|
||||
const gate = join(root, "check-substance-gate.mjs");
|
||||
writeFileSync(gate, source);
|
||||
for (const module of ["internal-leak-scanner.mjs", "leak-guard-rules.mjs", "npm-pack-json.mjs"]) {
|
||||
for (const module of ["internal-leak-scanner.mjs", "leak-guard-rules.mjs"]) {
|
||||
writeFileSync(join(root, module), readFileSync(resolve("scripts", module)));
|
||||
}
|
||||
writeFileSync(join(root, "check-internal-leak-guard.mjs"), [
|
||||
@@ -513,7 +474,7 @@ function withFixture(run) {
|
||||
}
|
||||
}
|
||||
|
||||
function runGate({ repo, rules, review, receipt, cutSha, gate = GATE, env = process.env }) {
|
||||
function runGate({ repo, rules, review, receipt, cutSha, gate = GATE }) {
|
||||
return spawnSync(process.execPath, [
|
||||
gate,
|
||||
"--repo", repo,
|
||||
@@ -524,7 +485,7 @@ function runGate({ repo, rules, review, receipt, cutSha, gate = GATE, env = proc
|
||||
"--receipt", receipt,
|
||||
"--judge", "dev-qa@fixture",
|
||||
"--cut-sha", cutSha,
|
||||
], { encoding: "utf8", env });
|
||||
], { encoding: "utf8" });
|
||||
}
|
||||
|
||||
function write(path, content) {
|
||||
|
||||
@@ -4,7 +4,6 @@ import { tmpdir } from "node:os";
|
||||
import { delimiter, join } from "node:path";
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { packEntry } from "./npm-pack-json.mjs";
|
||||
|
||||
test("the CLI build carries the unchanged repository LICENSE and README into npm pack", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "cli-license-"));
|
||||
@@ -27,14 +26,14 @@ test("the CLI build carries the unchanged repository LICENSE and README into npm
|
||||
execFileSync("npm", ["run", "build"], { cwd: cli, env, stdio: "pipe" });
|
||||
const source = readFileSync(join(root, "LICENSE"));
|
||||
assert.deepEqual(readFileSync(join(cli, "LICENSE")), source, "build must stage the repository license verbatim");
|
||||
const packed = packEntry(execFileSync("npm", ["pack", "--ignore-scripts", "--json"], { cwd: cli, env, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }));
|
||||
assert.ok(packed.files.some((file) => file.path === "LICENSE"), "npm archive must include LICENSE");
|
||||
const bytes = execFileSync("tar", ["-xOf", join(cli, packed.filename), "package/LICENSE"]);
|
||||
const packed = JSON.parse(execFileSync("npm", ["pack", "--ignore-scripts", "--json"], { cwd: cli, env, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }));
|
||||
assert.ok(packed[0].files.some((file) => file.path === "LICENSE"), "npm archive must include LICENSE");
|
||||
const bytes = execFileSync("tar", ["-xOf", join(cli, packed[0].filename), "package/LICENSE"]);
|
||||
assert.deepEqual(bytes, source, "packed license must equal the existing root bytes");
|
||||
// npm shows the package-root README on the registry page; without it the page reads "No README data found".
|
||||
const readme = readFileSync(join(root, "README.md"));
|
||||
assert.ok(packed.files.some((file) => file.path === "README.md"), "npm archive must include README.md");
|
||||
const readmeBytes = execFileSync("tar", ["-xOf", join(cli, packed.filename), "package/README.md"]);
|
||||
assert.ok(packed[0].files.some((file) => file.path === "README.md"), "npm archive must include README.md");
|
||||
const readmeBytes = execFileSync("tar", ["-xOf", join(cli, packed[0].filename), "package/README.md"]);
|
||||
assert.deepEqual(readmeBytes, readme, "packed README must equal the existing root bytes");
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
// `npm pack --json` prints an array of entries up to npm 11 and, from npm 12, an object keyed by package name
|
||||
// ("the --json output of `npm pack` and `npm publish` have changed",
|
||||
// https://github.com/npm/cli/releases/tag/v12.0.0). Every reader of that output goes through here, so both shapes
|
||||
// give the same entries.
|
||||
|
||||
/** The entries `npm pack --json` printed, in order, from its output text or its parsed value. */
|
||||
export function packEntries(output) {
|
||||
const parsed = typeof output === "string" ? JSON.parse(output) : output;
|
||||
const entries = Array.isArray(parsed)
|
||||
? parsed
|
||||
: parsed !== null && typeof parsed === "object" ? Object.values(parsed) : null;
|
||||
if (!entries || !entries.every((entry) => entry !== null && typeof entry === "object" && !Array.isArray(entry))) {
|
||||
throw new Error("npm pack --json printed neither an array of entries nor an object of per-package entries");
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/** The one entry of a `npm pack --json` run that packed one package; any other count throws. */
|
||||
export function packEntry(output) {
|
||||
const entries = packEntries(output);
|
||||
if (entries.length !== 1) {
|
||||
throw new Error(`npm pack --json printed ${entries.length} entries where one package was packed`);
|
||||
}
|
||||
return entries[0];
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { packEntries, packEntry } from "./npm-pack-json.mjs";
|
||||
|
||||
const entry = { id: "@openrig/[email protected]", name: "@openrig/cli", version: "1.2.3", filename: "openrig-cli-1.2.3.tgz",
|
||||
files: [{ path: "LICENSE" }, { path: "package.json" }] };
|
||||
|
||||
test("npm 10 and 11: an array of entries", () => {
|
||||
assert.deepEqual(packEntries(JSON.stringify([entry])), [entry]);
|
||||
assert.deepEqual(packEntry(JSON.stringify([entry])), entry);
|
||||
});
|
||||
|
||||
test("npm 12: an object of entries keyed by package name", () => {
|
||||
assert.deepEqual(packEntries(JSON.stringify({ "@openrig/cli": entry })), [entry]);
|
||||
assert.deepEqual(packEntry(JSON.stringify({ "@openrig/cli": entry })), entry);
|
||||
});
|
||||
|
||||
test("both shapes keep every entry in order, and a parsed value reads like its text", () => {
|
||||
const other = { ...entry, name: "@openrig/acp", filename: "openrig-acp-1.2.3.tgz" };
|
||||
assert.deepEqual(packEntries([entry, other]), [entry, other]);
|
||||
assert.deepEqual(packEntries({ "@openrig/cli": entry, "@openrig/acp": other }), [entry, other]);
|
||||
});
|
||||
|
||||
test("packEntry refuses any count but one", () => {
|
||||
assert.throws(() => packEntry("[]"), /0 entries/);
|
||||
assert.throws(() => packEntry("{}"), /0 entries/);
|
||||
assert.throws(() => packEntry(JSON.stringify([entry, entry])), /2 entries/);
|
||||
});
|
||||
|
||||
test("output that is neither shape throws", () => {
|
||||
for (const output of ["null", "\"text\"", "3", JSON.stringify(entry), JSON.stringify([[entry]]), JSON.stringify({ a: null })]) {
|
||||
assert.throws(() => packEntries(output), /neither an array of entries nor an object of per-package entries/, output);
|
||||
}
|
||||
assert.throws(() => packEntries("not json"), SyntaxError);
|
||||
});
|
||||
@@ -8,7 +8,6 @@ import { createHash } from 'node:crypto';
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { packEntry } from './npm-pack-json.mjs';
|
||||
|
||||
const LISTING = ['packages/acp/registry/agent.json', 'packages/acp/registry/icon.svg'];
|
||||
const readJson = (root, rel) => JSON.parse(readFileSync(join(root, rel), 'utf8'));
|
||||
@@ -36,8 +35,8 @@ export function packRelease(root, dest) {
|
||||
mkdirSync(dest, { recursive: true });
|
||||
// The CLI is packed as the release producer has packed it; the launcher isn't a workspace, so it's packed by path.
|
||||
// --ignore-scripts skips pack's prepack and postpack, but npm 10 still runs a `prepare`; neither package has one.
|
||||
const pack = target => packEntry(execFileSync('npm', ['pack', ...target, '--ignore-scripts', '--json', '--pack-destination', dest],
|
||||
{ cwd: root, encoding: 'utf8' }));
|
||||
const pack = target => JSON.parse(execFileSync('npm', ['pack', ...target, '--ignore-scripts', '--json', '--pack-destination', dest],
|
||||
{ cwd: root, encoding: 'utf8' }))[0];
|
||||
const artifacts = [pack(['--workspace', 'packages/cli']), pack(['./packages/acp'])].map(packed => {
|
||||
const path = join(dest, packed.filename);
|
||||
return { name: packed.name, version: packed.version, path, size: packed.size, sha256: sha256(path), integrity: packed.integrity };
|
||||
|
||||
@@ -4,7 +4,6 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { execFileSync, spawn, spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { packEntry } from "./npm-pack-json.mjs";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const cli = path.join(root, "packages", "cli");
|
||||
@@ -48,8 +47,8 @@ async function assertDelayedSplits(entry, pkgDir) {
|
||||
|
||||
try {
|
||||
execFileSync("npm", ["run", "build:package"], { cwd: root, stdio: "inherit" });
|
||||
const packed = packEntry(execFileSync("npm", ["pack", "--ignore-scripts", "--json", "--pack-destination", temp], { cwd: cli, encoding: "utf8" }));
|
||||
const tarball = path.join(temp, packed.filename);
|
||||
const packed = JSON.parse(execFileSync("npm", ["pack", "--ignore-scripts", "--json", "--pack-destination", temp], { cwd: cli, encoding: "utf8" }));
|
||||
const tarball = path.join(temp, packed[0].filename);
|
||||
execFileSync("tar", ["-xzf", tarball, "-C", temp]);
|
||||
|
||||
const pkgDir = path.join(temp, "package");
|
||||
@@ -69,7 +68,7 @@ try {
|
||||
});
|
||||
assert.equal(launched.status, 0, launched.stderr || `packed TUI exited ${launched.status}`);
|
||||
await assertDelayedSplits(entry, pkgDir);
|
||||
console.log(`packed TUI launch PASS: ${packed.filename}`);
|
||||
console.log(`packed TUI launch PASS: ${packed[0].filename}`);
|
||||
} finally {
|
||||
fs.rmSync(temp, { recursive: true, force: true });
|
||||
}
|
||||
Reference in new issue
Block a user