mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-12 01:41:39 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6f57d8dd7 | ||
|
|
b6b73d7c63 | ||
|
|
020430314d | ||
|
|
8e6c49f782 | ||
|
|
6329634e66 | ||
|
|
6e5bc883ce |
No files matched your search
@@ -405,11 +405,6 @@ 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.
|
||||
|
||||
@@ -151,6 +151,40 @@ An outbox entry's `deliveryState` reports the send attempt; only `delivered` con
|
||||
an open FYI, or a closed ask is not that receipt. The rig-wide reads and their limits are described below; the existing
|
||||
`outbox/list?senderSession=<session>` read remains available.
|
||||
|
||||
### What needs a person, and how their answers link back (`team-operations.v1`)
|
||||
|
||||
`GET /api/team-operations/capabilities` says which team-operations jobs this daemon supports, separately from its
|
||||
build:
|
||||
|
||||
```json
|
||||
{ "contract": "team-operations.v1",
|
||||
"capabilities": { "needs-me.read": "supported", "needs-me.answer": "not-supported", "needs-me.follow": "not-supported" },
|
||||
"build": { "semver": "…", "commit": "…" } }
|
||||
```
|
||||
|
||||
A capability reads `supported`, `not-supported` or `unknown`, and a missing key means `unknown`. A daemon without the
|
||||
route (HTTP 404) predates the contract: keep reading what it already returns. In a source run, `build` is
|
||||
`{ semver: null, commit: null }`.
|
||||
|
||||
With `needs-me.read` supported:
|
||||
|
||||
- **What needs the person:** `GET /api/attention?view=<person-id>` (the same read as the Feed). Each queue item adds
|
||||
`revision` and `about`. `revision` is the request's opaque decision revision: the same value as that row's
|
||||
`decisionRevision`, and what an answer can name as `expectedRevision` so it applies only to the version the person
|
||||
read. `about` lists the requests it concerns by id: a row parked on the person is itself the request, and an ask is
|
||||
about the open rows blocked on it (at most 100; empty when none are recorded).
|
||||
- **A snapshot watermark:** the response's `watermark` is the newest event `seq` read before the snapshot. Follow
|
||||
`GET /api/events` from it with `Last-Event-ID: <watermark>`: every event after the snapshot is replayed, some possibly
|
||||
already reflected in it. Recording one answer of several emits no event yet, so re-read the snapshot (or the row)
|
||||
rather than relying on events alone for partial answers.
|
||||
- **The answers returned to a request:** `GET /api/queue/<qitemId>` adds `answers`, oldest first, each
|
||||
`{ qitemId, by, at, text, episode, recordedIdentity }`. They are found by the answer row's link to the request, not
|
||||
by its text, so don't parse the `In reply to:` line in an answer's body. `text` is the person's answer without that
|
||||
footer. `episode` names the park an answer belongs to, or is null. `recordedIdentity` is the identity provenance
|
||||
recorded when the answer row was created. An answer row's own read has `replyTo`, the request it answers. A partial
|
||||
set of choices stays on the request's `humanAnswers` until the whole set is returned. A runner's permission request
|
||||
or a host pairing request is answered in place, with no answer row.
|
||||
|
||||
## Route details
|
||||
|
||||
- **A rig's queue history:** `GET /api/queue/list?rig=<rig>&updatedSince=<ISO time>&sort=updated` returns the
|
||||
|
||||
@@ -342,21 +342,9 @@ startup reconcile leaves when every kernel terminal is gone, for example after a
|
||||
reboot. It does not detect a reboot as such. It uses the same restore as
|
||||
`rig up kernel --existing`, and `rig status` reports it as `Kernel: booting`
|
||||
until the seats are ready. A kernel stopped with `rig down kernel`, including
|
||||
one whose seats were already detached, stays down on later starts; start it again
|
||||
with `rig up kernel --existing`. A seat you stopped or unclaimed also stays down.
|
||||
While the kernel's other seats are running, `rig up kernel --existing` refuses
|
||||
(it restores only a kernel with no live session), so start one stopped seat with
|
||||
`rig launch kernel <logical id>` (`--plan` first). It restores that seat from the
|
||||
kernel snapshot that restore ranks first: the newest usable automatic one (taken
|
||||
before `rig down`, on the periodic schedule, or at a runtime switch), even when a
|
||||
manual one is newer, and otherwise the newest usable one. `--plan` names the
|
||||
snapshot it picked and any newer usable one, and `--snapshot-id <id>` picks one
|
||||
yourself. Under the default restore policy, which the kernel's seats keep, it
|
||||
resumes the seat's saved conversation; a seat it can't resume is reported as
|
||||
waiting for a decision, not as started.
|
||||
`rig seat launch <seat> --fresh --reason "…"` instead starts a new conversation
|
||||
with no continuity source: use it only when you want a fresh occupant, since the
|
||||
seat's earlier conversation isn't carried.
|
||||
one whose seats were already detached, stays down on later starts. So do a kernel
|
||||
with only some seats stopped and seats you unclaimed. Start one again with
|
||||
`rig up kernel --existing`.
|
||||
When the tmux server is gone, an explicit `rig down` of any rig can start an
|
||||
empty server and probe again. It records the stop and clears eligible bindings
|
||||
only after confirming the sessions are absent. This can leave an empty tmux
|
||||
|
||||
@@ -137,7 +137,6 @@ 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;
|
||||
@@ -205,11 +204,6 @@ 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>
|
||||
|
||||
@@ -305,15 +305,17 @@ function projectForExternal<T>(row: T, view: ExternalView): T {
|
||||
return fields as T;
|
||||
}
|
||||
|
||||
/** A handed-on item as a scoped caller receives it: without the daemon's own successor fields (successors,
|
||||
* successorsTruncated, successorNote), which name rows and remote ids regardless of this caller's scope. Its answer is
|
||||
* show's scope-checked successor (successorOf). */
|
||||
function withoutDaemonSuccessors<T>(row: T): T {
|
||||
/** An item as a scoped caller receives it: without the daemon's own fields that carry other rows regardless of this
|
||||
* caller's scope. Those are the successor fields (successors, successorsTruncated, successorNote), which name rows and
|
||||
* remote ids; a handed-on item's answer is show's scope-checked successor (successorOf). And the answers people returned
|
||||
* to the item (answers), which are other rows, from people, to its asker. */
|
||||
function withoutDaemonLinkedRows<T>(row: T): T {
|
||||
if (!row || typeof row !== "object" || Array.isArray(row)) return row;
|
||||
const fields = { ...(row as Record<string, unknown>) };
|
||||
delete fields.successors;
|
||||
delete fields.successorsTruncated;
|
||||
delete fields.successorNote;
|
||||
delete fields.answers;
|
||||
return fields as T;
|
||||
}
|
||||
|
||||
@@ -1445,7 +1447,7 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
|
||||
const view = externalView(scope);
|
||||
let successor = handedOn ? await successorOf(client, qitemId, row.closureTarget, view ? scope!.item : undefined) : undefined;
|
||||
if (scope && !view && successor && "destinationSession" in successor && !scope.successor(successor.destinationSession)) successor = { unknown: true, code: "out_of_scope" };
|
||||
const shownItem = scope ? withoutDaemonSuccessors(item.data) : item.data;
|
||||
const shownItem = scope ? withoutDaemonLinkedRows(item.data) : item.data;
|
||||
const data = view
|
||||
? { item: projectForExternal(shownItem, view), transitions: Array.isArray(transitions.data) ? transitions.data.map((t) => projectForExternal(t, view)) : transitions.data }
|
||||
: { item: shownItem, transitions: transitions.data };
|
||||
|
||||
@@ -989,6 +989,21 @@ describe("rig mcp serve --http --grant-service on the daemon's routes", () => {
|
||||
expect(back.text).not.toContain("qitem-split-hidden");
|
||||
});
|
||||
|
||||
it("a scoped show of its own item never carries the answers a person returned to it; the owner's read does", async () => {
|
||||
const served = await listen();
|
||||
const asExternal = await connect(served.url);
|
||||
const own = (await asExternal("rig_queue_create", { destination: "lead@team-a", body: "my task" })).structured.qitemId as string;
|
||||
// The item asked of a person, and their answer row back to the caller: another row, from a person, linked to it.
|
||||
db.prepare("UPDATE queue_items SET destination_session = 'human-x@external' WHERE qitem_id = ?").run(own);
|
||||
await repo.createHumanAnswer({ sourceSession: "human-x@external", destinationSession: CALLER, body: "The person's private reasoning",
|
||||
inboundReplyTo: own, nudge: false } as never);
|
||||
expect(await (await fetch(`${daemonUrl}/api/queue/${own}`)).json()).toMatchObject({ answers: [{ by: "human-x@external" }] });
|
||||
const shown = await asExternal("rig_queue_show", { qitemId: own });
|
||||
expect(shown.isError).toBe(false);
|
||||
expect(shown.structured.item).not.toHaveProperty("answers");
|
||||
expect(shown.text).not.toContain("private reasoning");
|
||||
});
|
||||
|
||||
it("an id-reuse conflict never names another item's parties, and still names the external caller's own", async () => {
|
||||
const served = await listen();
|
||||
const asExternal = await connect(served.url);
|
||||
|
||||
@@ -207,20 +207,6 @@ 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({
|
||||
@@ -397,14 +383,6 @@ 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"]));
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"url": "https://github.com/mvschwarz"
|
||||
},
|
||||
"homepage": "https://github.com/mvschwarz/openrig-plugins",
|
||||
"repository": "https://github.com/mvschwarz/openrig-plugins",
|
||||
"repository": "github:mvschwarz/openrig-plugins",
|
||||
"license": "Apache-2.0",
|
||||
"keywords": ["openrig", "agent", "topology", "rig", "continuity", "coordination", "compaction", "handoff"],
|
||||
"skills": "./skills",
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"url": "https://github.com/mvschwarz"
|
||||
},
|
||||
"homepage": "https://github.com/mvschwarz/openrig-plugins",
|
||||
"repository": "https://github.com/mvschwarz/openrig-plugins",
|
||||
"repository": "github:mvschwarz/openrig-plugins",
|
||||
"license": "Apache-2.0",
|
||||
"keywords": ["openrig", "agent", "topology", "rig", "continuity", "coordination", "compaction", "handoff"],
|
||||
"skills": "./skills",
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 53 KiB |
@@ -7,7 +7,7 @@
|
||||
"url": "https://github.com/mvschwarz"
|
||||
},
|
||||
"homepage": "https://github.com/mvschwarz/openrig",
|
||||
"repository": "https://github.com/mvschwarz/openrig",
|
||||
"repository": "github:mvschwarz/openrig",
|
||||
"license": "Apache-2.0",
|
||||
"keywords": ["openrig", "agents", "team", "delegation", "mcp"],
|
||||
"skills": "./skills",
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"url": "https://github.com/mvschwarz"
|
||||
},
|
||||
"homepage": "https://github.com/mvschwarz/openrig",
|
||||
"repository": "https://github.com/mvschwarz/openrig",
|
||||
"repository": "github:mvschwarz/openrig",
|
||||
"license": "Apache-2.0",
|
||||
"keywords": [
|
||||
"openrig",
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"url": "https://github.com/mvschwarz"
|
||||
},
|
||||
"homepage": "https://github.com/mvschwarz/openrig",
|
||||
"repository": "https://github.com/mvschwarz/openrig",
|
||||
"repository": "github:mvschwarz/openrig",
|
||||
"license": "Apache-2.0",
|
||||
"keywords": ["openrig", "agents", "team", "delegation", "mcp"]
|
||||
}
|
||||
@@ -25,6 +25,11 @@ export interface AttentionItem {
|
||||
/** The settings page where the person decides this request directly, without an agent (a pairing request's Approve and
|
||||
* Deny): a TUI settings category, and the request it opens on there. */
|
||||
settingsLink?: { category: string; focus: string; label: string };
|
||||
/** A queue request's decision revision: the same opaque token its single-row read returns and a settling act can name. */
|
||||
revision?: string;
|
||||
/** The requests this is about, by ID: a parked row is itself the request; an ask is about the open rows blocked on it
|
||||
* (at most 100). Empty when no request is recorded as waiting on it. */
|
||||
about?: string[];
|
||||
}
|
||||
export interface AttentionDetail {
|
||||
item: AttentionItem;
|
||||
@@ -38,6 +43,9 @@ export interface AttentionRead {
|
||||
sources: Array<{ source: string; state: "available" | "unavailable" | "partial"; detail: string }>;
|
||||
detail: AttentionDetail | null;
|
||||
detailError: string | null;
|
||||
/** The event log's newest seq, read before this snapshot: following /api/events from it (Last-Event-ID) replays every
|
||||
* event since, possibly some already reflected here, never one missed. Absent without an event log. */
|
||||
watermark?: number;
|
||||
/** Whose queue this Feed shows (`view`), and who acts on it. A view, never an identity: acts on what it shows carry the
|
||||
* primary user's authority, the person at the terminal, whoever's queue it is. `requested` names a view that matched no
|
||||
* registered person. Absent when no person is registered. */
|
||||
|
||||
@@ -10,6 +10,7 @@ import { queueItemsHandedOffFromIndexSchema } from "./migrations/159_queue_items
|
||||
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 { queueItemsReplyToIndexSchema } from "./migrations/164_queue_items_reply_to_index.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";
|
||||
@@ -327,4 +328,5 @@ export const ALL_MIGRATIONS: Migration[] = [
|
||||
networkBindingsSchema,
|
||||
networkReceiptsSchema,
|
||||
sessionAddressReadIndexesSchema,
|
||||
queueItemsReplyToIndexSchema,
|
||||
];
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { Migration } from "../migrate.js";
|
||||
|
||||
/** An ask's answers and its decision revision find rows by their `reply_to`. Without this, each lookup scans the whole
|
||||
* queue, once per request on the attention read. Partial: only replies carry the column. */
|
||||
export const queueItemsReplyToIndexSchema: Migration = {
|
||||
name: "164_queue_items_reply_to_index.sql",
|
||||
sql: `
|
||||
CREATE INDEX IF NOT EXISTS idx_queue_items_reply_to ON queue_items(reply_to) WHERE reply_to IS NOT NULL;
|
||||
`,
|
||||
};
|
||||
@@ -643,9 +643,6 @@ 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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ 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;
|
||||
@@ -20,8 +19,6 @@ 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 {
|
||||
@@ -55,19 +52,10 @@ 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));
|
||||
|
||||
@@ -133,7 +121,6 @@ export class InstallEngine {
|
||||
applied,
|
||||
deferred: plan.deferred,
|
||||
conflicts: plan.conflicts,
|
||||
notes,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -183,14 +170,6 @@ 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;
|
||||
|
||||
@@ -199,17 +178,34 @@ export class InstallEngine {
|
||||
|
||||
if (this.fs.exists(entry.targetPath)) {
|
||||
const existing = this.fs.readFile(entry.targetPath);
|
||||
const found = findManagedBlocks(existing, packageName);
|
||||
if (found.blocks.length === 0) {
|
||||
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 {
|
||||
// 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,9 +45,7 @@ export class InstallVerifier {
|
||||
this.fs = fs;
|
||||
}
|
||||
|
||||
/** `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 {
|
||||
verify(installId: string): VerificationResult {
|
||||
const install = this.installRepo.getInstall(installId);
|
||||
if (!install) {
|
||||
return {
|
||||
@@ -78,16 +76,6 @@ 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,8 +2,7 @@ import { createHash } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import nodePath from "node:path";
|
||||
import type Database from "better-sqlite3";
|
||||
import { damagedManagedBlockNote, frameManagedBlockBody, managedBlockBodyMatches, managedBlockStanding, mergeManagedBlock, pruneManagedBlockCopies,
|
||||
withCanonicalMarkers, type ManagedBlockMergeFsOps, type ManagedBlockStanding } from "./managed-blocks.js";
|
||||
import { frameManagedBlockBody, managedBlockStanding, mergeManagedBlock, 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). */
|
||||
@@ -93,8 +92,6 @@ 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` };
|
||||
@@ -106,36 +103,10 @@ 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 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. */
|
||||
* 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. */
|
||||
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[];
|
||||
@@ -144,19 +115,15 @@ 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;
|
||||
return merge();
|
||||
merge();
|
||||
return true;
|
||||
}
|
||||
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) {
|
||||
if (decision.standing === "edited" || decision.standing === "unrecorded") {
|
||||
pruneKeptManagedBlock(files, targetPath, blockId, content, opts.records, opts.warnings);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (!merge()) return false;
|
||||
if (!decision.write) return false;
|
||||
merge();
|
||||
recordManagedBlockWrite(opts.records, targetPath, blockId, content, opts.warnings);
|
||||
return true;
|
||||
}
|
||||
@@ -41,95 +41,15 @@ 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,
|
||||
): boolean {
|
||||
): void {
|
||||
const begin = MANAGED_BLOCK_START(blockId);
|
||||
const end = MANAGED_BLOCK_END(blockId);
|
||||
const block = managedBlockText(blockId, content);
|
||||
|
||||
if (!fs.exists(targetPath)) {
|
||||
@@ -137,39 +57,49 @@ export function mergeManagedBlock(
|
||||
fs.writeFile(targetPath, block);
|
||||
const warnings = excludeNewGeneratedFiles(nodePath.dirname(targetPath), [targetPath]);
|
||||
options?.warnings?.push(...warnings);
|
||||
return true;
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = fs.readFile(targetPath);
|
||||
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;
|
||||
}
|
||||
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))
|
||||
);
|
||||
});
|
||||
|
||||
/** 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)}`;
|
||||
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;
|
||||
}
|
||||
return updated;
|
||||
|
||||
fs.writeFile(targetPath, `${existing}${MANAGED_BLOCK_APPEND_SEPARATOR}${block}`);
|
||||
}
|
||||
|
||||
/** Each `blockId` block in `content` (current markers): the whole block and the text between its markers. */
|
||||
@@ -206,47 +136,19 @@ 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;
|
||||
* - `damaged`: it holds a marker of the block without its pair, and no complete copy, so the block can't be read.
|
||||
* - `unrecorded`: it differs from the source and OpenRig has no record of writing it, so which of the two is unknown.
|
||||
* A block written more than once counts as edited when any copy is. */
|
||||
export type ManagedBlockStanding = "absent" | "current" | "stale" | "edited" | "unrecorded" | "damaged";
|
||||
export type ManagedBlockStanding = "absent" | "current" | "stale" | "edited" | "unrecorded";
|
||||
|
||||
export function managedBlockStanding(content: string, blockId: string, source: string, recordedHash: string | undefined,
|
||||
hash: (body: string) => string): ManagedBlockStanding {
|
||||
const found = findManagedBlocks(content, blockId);
|
||||
const bodies = found.blocks.map((block) => block.body);
|
||||
if (bodies.length === 0) return found.unpaired > 0 ? "damaged" : "absent";
|
||||
const bodies = managedBlockBodies(content, blockId);
|
||||
if (bodies.length === 0) return "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; notes?: string[] }
|
||||
| { ok: true; installId: string; packageId: string; applied: number; deferred: number }
|
||||
| { 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, { skipped: result.notes?.length ?? 0 });
|
||||
const verification = this.installVerifier.verify(result.installId);
|
||||
if (!verification.passed) {
|
||||
return { ok: false, code: "verification_failed", message: "Post-apply verification failed" };
|
||||
}
|
||||
@@ -112,7 +112,6 @@ export class PackageInstallService {
|
||||
packageId: pkg.id,
|
||||
applied: result.applied.length,
|
||||
deferred: result.deferred.length,
|
||||
...(result.notes?.length ? { notes: result.notes } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -21,17 +21,12 @@
|
||||
// per founder authorization 2026-05-10 (LICENSE only); 404 is the
|
||||
// expected normal-state response until a separate publish authorization.
|
||||
|
||||
import fs from "node:fs";
|
||||
import nodePath from "node:path";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
|
||||
export interface PluginVendorFs {
|
||||
readFile(path: string): string;
|
||||
writeFile(path: string, content: string): void;
|
||||
/** A file's raw bytes. With `writeBytes`, a plugin's files are copied and compared byte for byte, so a binary file
|
||||
* (an icon) arrives intact. Optional: without them files are read and written as text. */
|
||||
readBytes?(path: string): Uint8Array;
|
||||
writeBytes?(path: string, content: Uint8Array): void;
|
||||
/** Copy raw bytes, failing if the recovery path already exists. */
|
||||
copyFileExclusive(source: string, target: string): void;
|
||||
exists(path: string): boolean;
|
||||
@@ -165,7 +160,7 @@ export class PluginVendorService {
|
||||
const destPath = nodePath.join(targetDir, relPath);
|
||||
if (
|
||||
this.fs.exists(destPath) &&
|
||||
hashContent(this.read(destPath)) === hashContent(this.read(srcPath))
|
||||
hashContent(this.fs.readFile(destPath)) === hashContent(this.fs.readFile(srcPath))
|
||||
) {
|
||||
this.preserveMode(srcPath, destPath);
|
||||
}
|
||||
@@ -175,7 +170,7 @@ export class PluginVendorService {
|
||||
}
|
||||
}
|
||||
|
||||
const updates: Array<[string, FileContent]> = this.fs.listFiles(sourceDir).map(rel => [rel, this.read(nodePath.join(sourceDir, rel))]);
|
||||
const updates: Array<[string, string]> = this.fs.listFiles(sourceDir).map(rel => [rel, this.fs.readFile(nodePath.join(sourceDir, rel))]);
|
||||
if (!this.preserveBeforeRefresh(targetDir, updates)) return;
|
||||
this.fs.mkdirp(targetDir);
|
||||
|
||||
@@ -184,34 +179,23 @@ export class PluginVendorService {
|
||||
const destPath = nodePath.join(targetDir, relPath);
|
||||
// Hash-skip: only write if content differs (idempotent re-runs). Reconcile mode even on
|
||||
// skip — a byte-identical copy staged earlier may still carry the wrong (default) mode.
|
||||
if (this.fs.exists(destPath) && hashContent(this.read(destPath)) === hashContent(content)) {
|
||||
if (this.fs.exists(destPath) && hashContent(this.fs.readFile(destPath)) === hashContent(content)) {
|
||||
this.preserveMode(srcPath, destPath);
|
||||
continue;
|
||||
}
|
||||
this.fs.mkdirp(nodePath.dirname(destPath));
|
||||
this.write(destPath, content);
|
||||
this.fs.writeFile(destPath, content);
|
||||
this.preserveMode(srcPath, destPath);
|
||||
}
|
||||
}
|
||||
|
||||
/** A plugin file as the fs reads it exactly: bytes when it can, else text. */
|
||||
private read(path: string): FileContent {
|
||||
return this.fs.readBytes ? this.fs.readBytes(path) : this.fs.readFile(path);
|
||||
}
|
||||
|
||||
private write(path: string, content: FileContent): void {
|
||||
if (typeof content === "string") this.fs.writeFile(path, content);
|
||||
else if (this.fs.writeBytes) this.fs.writeBytes(path, content);
|
||||
else this.fs.writeFile(path, Buffer.from(content).toString("utf-8"));
|
||||
}
|
||||
|
||||
/** Preserve every replaced file before touching a target, including its version authority. */
|
||||
private preserveBeforeRefresh(targetDir: string, updates: Array<[string, FileContent]>): boolean {
|
||||
private preserveBeforeRefresh(targetDir: string, updates: Array<[string, string]>): boolean {
|
||||
const backupDir = nodePath.join(nodePath.dirname(this.userPluginsDir), "backups", "managed-refresh", randomUUID());
|
||||
try {
|
||||
for (const [rel, content] of new Map(updates)) {
|
||||
const dest = nodePath.join(targetDir, rel);
|
||||
if (!this.fs.exists(dest) || hashContent(this.read(dest)) === hashContent(content)) continue;
|
||||
if (!this.fs.exists(dest) || hashContent(this.fs.readFile(dest)) === hashContent(content)) continue;
|
||||
const backup = nodePath.join(backupDir, rel);
|
||||
this.fs.mkdirp(nodePath.dirname(backup));
|
||||
this.fs.copyFileExclusive(dest, backup);
|
||||
@@ -292,7 +276,7 @@ export class PluginVendorService {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const updates: Array<[string, FileContent]> = files.map(rel => [rel, this.read(nodePath.join(sourceDir, rel))]);
|
||||
const updates: Array<[string, string]> = files.map(rel => [rel, this.fs.readFile(nodePath.join(sourceDir, rel))]);
|
||||
if (!this.preserveBeforeRefresh(targetDir, [...updates, [GLOBAL_VENDOR_VERSION, `${sourceVersion}\n`]])) continue;
|
||||
this.fs.mkdirp(targetDir);
|
||||
for (const [relPath, content] of updates) {
|
||||
@@ -300,13 +284,13 @@ export class PluginVendorService {
|
||||
const destPath = nodePath.join(targetDir, relPath);
|
||||
if (
|
||||
this.fs.exists(destPath) &&
|
||||
hashContent(this.read(destPath)) === hashContent(content)
|
||||
hashContent(this.fs.readFile(destPath)) === hashContent(content)
|
||||
) {
|
||||
this.preserveMode(srcPath, destPath);
|
||||
continue;
|
||||
}
|
||||
this.fs.mkdirp(nodePath.dirname(destPath));
|
||||
this.write(destPath, content);
|
||||
this.fs.writeFile(destPath, content);
|
||||
this.preserveMode(srcPath, destPath);
|
||||
}
|
||||
this.fs.writeFile(versionMarker, `${sourceVersion}\n`);
|
||||
@@ -355,37 +339,6 @@ export class PluginVendorService {
|
||||
}
|
||||
}
|
||||
|
||||
/** A plugin file's content: its bytes, or its text from an fs that only reads text. */
|
||||
type FileContent = string | Uint8Array;
|
||||
|
||||
/** The same digest for a text and for its UTF-8 bytes, so a text compare and a byte compare agree. */
|
||||
function hashContent(content: FileContent): string {
|
||||
function hashContent(content: string): string {
|
||||
return createHash("sha256").update(content).digest("hex");
|
||||
}
|
||||
|
||||
/** The daemon's own filesystem for vendoring: files are copied and compared as bytes. */
|
||||
export function nodePluginVendorFs(): PluginVendorFs {
|
||||
return {
|
||||
readFile: (p) => fs.readFileSync(p, "utf-8"),
|
||||
writeFile: (p, c) => fs.writeFileSync(p, c, "utf-8"),
|
||||
readBytes: (p) => fs.readFileSync(p),
|
||||
writeBytes: (p, c) => fs.writeFileSync(p, c),
|
||||
copyFileExclusive: (src, dest) => fs.copyFileSync(src, dest, fs.constants.COPYFILE_EXCL),
|
||||
exists: (p) => fs.existsSync(p),
|
||||
mkdirp: (p) => { fs.mkdirSync(p, { recursive: true }); },
|
||||
listFiles: (dir) => {
|
||||
const r: string[] = [];
|
||||
/** Collect relative file paths recursively. */
|
||||
const w = (d: string, pre: string) => {
|
||||
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
|
||||
if (e.isDirectory()) w(nodePath.join(d, e.name), nodePath.join(pre, e.name));
|
||||
else r.push(pre ? nodePath.join(pre, e.name) : e.name);
|
||||
}
|
||||
};
|
||||
w(dir, "");
|
||||
return r;
|
||||
},
|
||||
statMode: (p) => fs.statSync(p).mode,
|
||||
chmod: (p, m) => fs.chmodSync(p, m),
|
||||
};
|
||||
}
|
||||
@@ -2190,6 +2190,51 @@ export class QueueRepository {
|
||||
.map((row) => ({ qitemId: row.qitem_id, body: row.body ?? "" }));
|
||||
}
|
||||
|
||||
/** Every answer returned to this ask, oldest first, under getHumanAnswer's rule for whoever answered: an ask to a person
|
||||
* (never an update), a row back to the asker linked by reply_to, from someone who may answer it (never a shared
|
||||
* channel). An answer row carries the human-answer tag; the unnamed primary user's own is one. An untagged Slack reply
|
||||
* (from before that tag) counts only when it came before the ask's first tagged answer (in this read's order) and no
|
||||
* later than the ask's first close, even if it was reopened since, and only each person's first: after an answer or a
|
||||
* close, a reply on the thread is correspondence. Each gives who, when,
|
||||
* their words without the footer, the park it answered (null for the ask itself or an older Slack reply), and the
|
||||
* identity recorded when the answer row was created. An ask answered in place (a runner's permission request, a host
|
||||
* pairing request) has no answer row: its answer is on the ask. */
|
||||
answersForAsk(askId: string): Array<{ qitemId: string; by: string; at: string; text: string; episode: string | null;
|
||||
recordedIdentity: { provenance: string | null; principal: string | null } | null }> {
|
||||
const ask = this.getById(askId);
|
||||
if (!this.hasReplyToColumn || !ask || ask.humanIntent === "update" || !this.isPersonAddress(ask.destinationSession)) return [];
|
||||
const candidates = (this.db.prepare(`SELECT qitem_id, source_session, ts_created,
|
||||
EXISTS (SELECT 1 FROM json_each(queue_items.tags) WHERE value = 'human-answer') AS tagged
|
||||
FROM queue_items
|
||||
WHERE reply_to = ? AND destination_session = ?
|
||||
AND (EXISTS (SELECT 1 FROM json_each(queue_items.tags) WHERE value = 'human-answer')
|
||||
OR (qitem_id LIKE 'qitem-slack-inbound-%' AND COALESCE(summary, '') NOT LIKE '[Recovered after gap] %'))
|
||||
ORDER BY ts_created, rowid`).all(askId, ask.sourceSession) as Array<{ qitem_id: string; source_session: string; ts_created: string; tagged: number }>)
|
||||
.filter((row) => !this.isSharedChannel(row.source_session)
|
||||
&& (this.answersAsk(ask, row.source_session) || (row.tagged === 1 && row.source_session === UNATTRIBUTED_ACTOR)));
|
||||
const firstTagged = candidates.findIndex((row) => row.tagged === 1);
|
||||
const history = this.transitionLog.listForQitem(askId);
|
||||
// The ask's first close, whatever its state now: a reopen doesn't make later thread replies its answers.
|
||||
const firstClose = history.find((transition) => !isBlockerLive(transition.state))?.ts;
|
||||
const legacyFirst = new Set<string>();
|
||||
const rows = candidates.filter((row, index) => row.tagged === 1 || ((firstTagged < 0 || index < firstTagged)
|
||||
&& (!firstClose || row.ts_created <= firstClose) && !legacyFirst.has(row.source_session) && !!legacyFirst.add(row.source_session)));
|
||||
if (rows.length === 0) return [];
|
||||
// An answer to a park's question has the ID made from that park's episode, so the episode is read back from the ID.
|
||||
const episodes = new Map(history
|
||||
.filter((transition) => transition.ownerNotificationKind === "human-required")
|
||||
.map((transition) => `${askId}:${transition.transitionId}`)
|
||||
.map((key) => [humanAnswerId(askId, key), key] as const));
|
||||
return rows.flatMap(({ qitem_id }) => {
|
||||
const row = this.getById(qitem_id);
|
||||
if (!row) return [];
|
||||
const created = this.transitionLog.listForQitem(qitem_id)[0];
|
||||
return [{ qitemId: row.qitemId, by: row.sourceSession, at: row.tsCreated, text: humanAnswerText(row.body),
|
||||
episode: episodes.get(row.qitemId) ?? null,
|
||||
recordedIdentity: created ? { provenance: created.identityProvenance, principal: created.identityPrincipal } : null }];
|
||||
});
|
||||
}
|
||||
|
||||
/** This person's answer to the current question of a decision parked on them, if one was returned. */
|
||||
currentEpisodeAnswer(qitemId: string, actorSession: string): QueueItem | null {
|
||||
const episode = this.currentHumanRequiredEpisode(qitemId);
|
||||
|
||||
@@ -17,21 +17,25 @@ 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, pruneKeptManagedBlock, recordManagedBlockWrite, type ManagedBlockRecords } from "./managed-block-records.js";
|
||||
import { decideManagedBlock, 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;
|
||||
@@ -93,10 +97,15 @@ 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 by its END, in the current or
|
||||
* legacy form, read as the writer reads it (`findManagedBlocks`). For reporting an installed file. */
|
||||
/** 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. */
|
||||
export function hasCompleteGuidanceBlock(content: string, blockId: string): boolean {
|
||||
return findManagedBlocks(content, blockId).blocks.length > 0;
|
||||
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));
|
||||
}
|
||||
|
||||
/** The guidance file a seat's managed blocks live in, and the blocks it should hold on restore. */
|
||||
@@ -190,13 +199,14 @@ export function applySeatSkillsLineRule(guidance: SeatGuidance, answeredYes: boo
|
||||
|
||||
type BlockState = "complete" | "absent" | "incomplete";
|
||||
|
||||
/** 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. */
|
||||
/** 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. */
|
||||
function blockState(content: string, blockId: string): BlockState {
|
||||
const found = findManagedBlocks(content, blockId);
|
||||
if (found.blocks.length > 0) return "complete";
|
||||
return found.unpaired > 0 ? "incomplete" : "absent";
|
||||
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";
|
||||
}
|
||||
|
||||
function blockStates(guidance: SeatGuidance, fs: Pick<ManagedBlockMergeFsOps, "exists" | "readFile">): Map<string, BlockState> {
|
||||
@@ -218,13 +228,11 @@ 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`), and so are
|
||||
* a kept block's removed repeats (`guidance_block_copies_removed`). */
|
||||
* they are. A block written whose record wasn't saved is named here too (`guidance_block_record_not_saved`). */
|
||||
kept: string[];
|
||||
}
|
||||
|
||||
const KEPT_GUIDANCE_PREFIXES = ["guidance_block_edited: ", "guidance_block_may_be_stale: ", "guidance_block_record_unreadable: ",
|
||||
"guidance_block_markers_damaged: "];
|
||||
const KEPT_GUIDANCE_PREFIXES = ["guidance_block_edited: ", "guidance_block_may_be_stale: ", "guidance_block_record_unreadable: "];
|
||||
|
||||
/** #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. */
|
||||
@@ -253,8 +261,7 @@ 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 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,
|
||||
* 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,
|
||||
* 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 {
|
||||
@@ -272,27 +279,17 @@ export function restoreMissingGuidance(guidance: SeatGuidance, fs: ManagedBlockM
|
||||
for (const item of guidance.items) {
|
||||
const state = states.get(item.blockId);
|
||||
if (state === "complete") {
|
||||
if (item.unresolved) continue;
|
||||
if (!records || 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);
|
||||
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);
|
||||
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 });
|
||||
continue;
|
||||
}
|
||||
if (state === "incomplete") {
|
||||
@@ -316,9 +313,6 @@ 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`);
|
||||
@@ -327,7 +321,7 @@ export function restoreMissingGuidance(guidance: SeatGuidance, fs: ManagedBlockM
|
||||
}
|
||||
for (const block of toWrite) {
|
||||
try {
|
||||
if (!mergeManagedBlock(fs, guidance.targetPath, block.blockId, block.content, { warnings: repair.gaps })) continue;
|
||||
mergeManagedBlock(fs, guidance.targetPath, block.blockId, block.content);
|
||||
if (records) recordManagedBlockWrite(records, guidance.targetPath, block.blockId, block.content, repair.kept);
|
||||
(block.refresh ? repair.refreshed : repair.restored).push(block.blockId);
|
||||
} catch (err) {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { BUILD_INFO, type BuildInfo } from "../build-info.js";
|
||||
|
||||
/** The team-operations contract an app reads before relying on a job's fields: one contract version with per-job
|
||||
* capabilities, distinct from the build label. A capability is supported, not-supported or unknown; an absent key reads
|
||||
* unknown. */
|
||||
export const TEAM_OPERATIONS_CONTRACT = "team-operations.v1";
|
||||
|
||||
export type CapabilityValue = "supported" | "not-supported" | "unknown";
|
||||
|
||||
/** needs-me: see what needs me, answer as me, follow it through. This build reads it (what needs the person, each
|
||||
* request's revision and what it's about, the answers returned to it, and a snapshot watermark). Answering under this
|
||||
* contract (a stable answer identity) and following a request through to its result aren't in it yet; the existing
|
||||
* answer paths keep working outside it. */
|
||||
export const TEAM_OPERATIONS_CAPABILITIES: Record<string, CapabilityValue> = {
|
||||
"needs-me.read": "supported",
|
||||
"needs-me.answer": "not-supported",
|
||||
"needs-me.follow": "not-supported",
|
||||
};
|
||||
|
||||
export function teamOperationsCapabilities(info: BuildInfo = BUILD_INFO) {
|
||||
return {
|
||||
contract: TEAM_OPERATIONS_CONTRACT,
|
||||
capabilities: TEAM_OPERATIONS_CAPABILITIES,
|
||||
// A source run has null build fields rather than an invented version.
|
||||
build: { semver: info.semver, commit: info.commit },
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import type { AttentionRead, AttentionItem } from "../attention-surface.js";
|
||||
import type { QueueRepository } from "../domain/queue-repository.js";
|
||||
import type { EventBus } from "../domain/event-bus.js";
|
||||
import { isHumanSeatSessionRef } from "../domain/session-name.js";
|
||||
import { unansweredQuestions } from "../domain/human-questions.js";
|
||||
import type { HealthProjectionService } from "../domain/health-detectors.js";
|
||||
@@ -20,6 +21,8 @@ export function attentionRoutes(): Hono {
|
||||
app.get("/", c => {
|
||||
const wanted = c.req.query("item");
|
||||
const result: AttentionRead = { scope: "instance", readAt: new Date().toISOString(), items: [], sources: [], detail: null, detailError: null };
|
||||
const eventBus = c.get("eventBus" as never) as EventBus | undefined;
|
||||
if (eventBus) result.watermark = eventBus.maxSeq();
|
||||
// Whether an item is in the queue this Feed shows; an item opened by id is shown in full whatever the view.
|
||||
let inView = (_recipient: string | null | undefined): boolean => true;
|
||||
const add = (item: AttentionItem, lines: () => string[], files: () => Array<{ label: string; path: string }> = () => []) => {
|
||||
@@ -86,7 +89,8 @@ export function attentionRoutes(): Hono {
|
||||
...(questions ? { humanQuestions: questions, humanAnswers: q.humanAnswers ?? {} } : {}),
|
||||
// A pairing request is also decided directly on this host's settings page, opened on this request (its row).
|
||||
...(q.evidenceRef?.startsWith("pair-request:") ? { settingsLink: { category: "instance", focus: q.qitemId, label: "Open pairing settings (Approve or Deny directly)" } } : {}),
|
||||
summary: q.summary || q.body.trim().split(/\r?\n/).find(Boolean) || "Request summary unavailable", urgency: q.priority, unblocks, at: q.tsUpdated, scope: p ? `project ${p.id}` : "instance · project unknown", project: p ? { id: p.id, root: p.root } : null, source: `/api/queue/${encodeURIComponent(q.qitemId)}` };
|
||||
summary: q.summary || q.body.trim().split(/\r?\n/).find(Boolean) || "Request summary unavailable", urgency: q.priority, unblocks, at: q.tsUpdated, scope: p ? `project ${p.id}` : "instance · project unknown", project: p ? { id: p.id, root: p.root } : null, source: `/api/queue/${encodeURIComponent(q.qitemId)}`,
|
||||
revision: queue.decisionRevision(q), about: toPerson ? deps.slice(0, 100).map(d => d.qitem_id) : [q.qitemId] };
|
||||
const unbound = noConnector(toPerson ? q.destinationSession : q.blockedOn);
|
||||
const notSent = unbound || quiet.has(q.qitemId);
|
||||
const route = unanswered
|
||||
|
||||
@@ -273,7 +273,7 @@ packagesRoutes.post("/install", async (c) => {
|
||||
}
|
||||
|
||||
// Verify
|
||||
const verification = installVerifier.verify(result.installId, { skipped: result.notes?.length ?? 0 });
|
||||
const verification = installVerifier.verify(result.installId);
|
||||
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,7 +281,6 @@ packagesRoutes.post("/install", async (c) => {
|
||||
code: "verification_failed",
|
||||
installId: result.installId,
|
||||
verification,
|
||||
...(result.notes?.length ? { notes: result.notes } : {}),
|
||||
}, 500);
|
||||
}
|
||||
|
||||
@@ -303,7 +302,6 @@ packagesRoutes.post("/install", async (c) => {
|
||||
conflicts: result.conflicts,
|
||||
verification,
|
||||
...(policyResult.rejected.length > 0 ? { policyRejected: policyResult.rejected } : {}),
|
||||
...(result.notes?.length ? { notes: result.notes } : {}),
|
||||
}, 201);
|
||||
});
|
||||
|
||||
|
||||
@@ -1546,6 +1546,8 @@ export function queueRoutes(): Hono {
|
||||
...item,
|
||||
// #2446: what a settling Mission Control act can be guarded with (expectedRevision).
|
||||
decisionRevision: repo.decisionRevision(item),
|
||||
// The answer rows returned to this row, read by their link rather than their text; empty when none came back.
|
||||
answers: repo.answersForAsk(qitemId),
|
||||
// A closed row's outcome (its note and reason) lives only in the transition log; omitted while the row is open.
|
||||
...(closing ? { closingTransition: {
|
||||
transitionId: closing.transitionId,
|
||||
|
||||
@@ -38,6 +38,7 @@ import type { ClaimService } from "./domain/claim-service.js";
|
||||
import type { SelfAttachService } from "./domain/self-attach-service.js";
|
||||
import { rigsRoutes } from "./routes/rigs.js";
|
||||
import { OPERATION_CONTRACT, OPERATION_KINDS } from "./domain/operation-correlation.js";
|
||||
import { teamOperationsCapabilities } from "./domain/team-operations.js";
|
||||
import { sessionsRoutes, nodesRoutes, sessionAdminRoutes } from "./routes/sessions.js";
|
||||
import { adaptersRoutes } from "./routes/adapters.js";
|
||||
import { eventsRoute } from "./routes/events.js";
|
||||
@@ -795,6 +796,7 @@ export function createApp(deps: AppDeps): Hono {
|
||||
|
||||
app.route("/api/rigs", rigsRoutes);
|
||||
app.get("/api/operations/capabilities", (c) => c.json({ contract: OPERATION_CONTRACT, kinds: OPERATION_KINDS }));
|
||||
app.get("/api/team-operations/capabilities", (c) => c.json(teamOperationsCapabilities()));
|
||||
app.route("/api/rigs/:rigId/sessions", sessionsRoutes);
|
||||
// Slice 24 — per-rig CMUX workspace launcher.
|
||||
app.route("/api/rigs/:rigId/cmux", rigCmuxRoutes);
|
||||
|
||||
@@ -1049,9 +1049,30 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
|
||||
// (repo currently empty as of 2026-05-10; vendored
|
||||
// copy is the source of truth at v0).
|
||||
try {
|
||||
const { PluginVendorService, nodePluginVendorFs } = await import("./domain/plugin-vendor-service.js");
|
||||
const { PluginVendorService } = await import("./domain/plugin-vendor-service.js");
|
||||
const vendoredAssetsDir = nodePath.resolve(import.meta.dirname, "../assets/plugins");
|
||||
const userPluginsDir = getDefaultOpenRigPath("plugins");
|
||||
const realFs = {
|
||||
readFile: (p: string) => fs.readFileSync(p, "utf-8"),
|
||||
writeFile: (p: string, c: string) => fs.writeFileSync(p, c, "utf-8"),
|
||||
copyFileExclusive: (src: string, dest: string) => fs.copyFileSync(src, dest, fs.constants.COPYFILE_EXCL),
|
||||
exists: (p: string) => fs.existsSync(p),
|
||||
mkdirp: (p: string) => fs.mkdirSync(p, { recursive: true }),
|
||||
listFiles: (dir: string) => {
|
||||
const r: string[] = [];
|
||||
/** Collect relative file paths recursively. */
|
||||
function w(d: string, pre: string) {
|
||||
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
|
||||
if (e.isDirectory()) w(nodePath.join(d, e.name), nodePath.join(pre, e.name));
|
||||
else r.push(pre ? nodePath.join(pre, e.name) : e.name);
|
||||
}
|
||||
}
|
||||
w(dir, "");
|
||||
return r;
|
||||
},
|
||||
statMode: (p: string) => fs.statSync(p).mode,
|
||||
chmod: (p: string, m: number) => fs.chmodSync(p, m),
|
||||
};
|
||||
const httpClient = async (url: string, opts?: { timeoutMs?: number }) => {
|
||||
const ctrl = new AbortController();
|
||||
const timer = setTimeout(() => ctrl.abort(), opts?.timeoutMs ?? 5000);
|
||||
@@ -1065,7 +1086,7 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
|
||||
const vendorService = new PluginVendorService({
|
||||
vendoredAssetsDir,
|
||||
userPluginsDir,
|
||||
fs: nodePluginVendorFs(),
|
||||
fs: realFs,
|
||||
httpClient,
|
||||
logger: (...args) => console.log("[openrig]", ...args),
|
||||
});
|
||||
|
||||
@@ -1387,38 +1387,6 @@ 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
|
||||
|
||||
@@ -6,6 +6,7 @@ import { queueInboxCheckinsSchema } from "../../src/db/migrations/158_queue_inbo
|
||||
import { queueItemsHandedOffFromIndexSchema } from "../../src/db/migrations/159_queue_items_handed_off_from_index.js";
|
||||
import { networkBindingsSchema } from "../../src/db/migrations/160_network_bindings.js";
|
||||
import { networkReceiptsSchema } from "../../src/db/migrations/161_network_receipts.js";
|
||||
import { queueItemsReplyToIndexSchema } from "../../src/db/migrations/164_queue_items_reply_to_index.js";
|
||||
import { activityGrantsSchema } from "../../src/db/migrations/143_activity_grants.js";
|
||||
import { environmentBindingsSchema } from "../../src/db/migrations/144_environment_bindings.js";
|
||||
import { nodePermissionsToSchema } from "../../src/db/migrations/145_node_permissions_to.js";
|
||||
@@ -169,7 +170,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, sessionAddressReadIndexesSchema, queueItemsReplyToIndexSchema];
|
||||
|
||||
/**
|
||||
* P24 — the DECLARED exclusions for {@link migrationsForFullTestDb}. That list is deliberately a
|
||||
|
||||
@@ -234,48 +234,6 @@ 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();
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
// 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"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
// team-operations.v1, needs-me.read: what needs the person, each request's revision and what it's about, and the answers
|
||||
// returned to it, read by their links rather than by searching answer text. Real SQLite (all migrations), the real queue,
|
||||
// the Mission Control answer path, the queue's single-row read and the attention read. Nothing native.
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import Database from "better-sqlite3";
|
||||
import { Hono } from "hono";
|
||||
import { migrate } from "../src/db/migrate.js";
|
||||
import { ALL_MIGRATIONS } from "../src/db/all-migrations.js";
|
||||
import { EventBus } from "../src/domain/event-bus.js";
|
||||
import { QueueRepository } from "../src/domain/queue-repository.js";
|
||||
import { MissionControlWriteContract } from "../src/domain/mission-control/mission-control-write-contract.js";
|
||||
import { MissionControlActionLog } from "../src/domain/mission-control/mission-control-action-log.js";
|
||||
import { missionControlRoutes } from "../src/routes/mission-control.js";
|
||||
import { queueRoutes } from "../src/routes/queue.js";
|
||||
import { attentionRoutes } from "../src/routes/attention.js";
|
||||
import type { AttentionRead } from "../src/attention-surface.js";
|
||||
import { teamOperationsCapabilities } from "../src/domain/team-operations.js";
|
||||
|
||||
const PERSON = "human-test@external", ASKER = "requester@test-rig";
|
||||
const registry = { ok: true as const, entities: [{ entityId: "human-test", class: "human" as const, displayName: "human-test",
|
||||
address: PERSON, connectorBindings: [], prefs: { deliveryClass: "A" as const } }] };
|
||||
const QUESTIONS = [
|
||||
{ id: "q1", question: "Ship it?", options: [{ id: "yes", label: "Yes" }, { id: "no", label: "No" }] },
|
||||
{ id: "q2", question: "Tell the team?", options: [{ id: "yes", label: "Yes" }, { id: "no", label: "No" }] },
|
||||
];
|
||||
|
||||
const closes: Array<() => void> = [];
|
||||
afterEach(() => { for (const close of closes.splice(0)) close(); });
|
||||
|
||||
function fixture() {
|
||||
const db = new Database(":memory:"); migrate(db, ALL_MIGRATIONS); const bus = new EventBus(db);
|
||||
const repo = new QueueRepository(db, bus, { validateRig: () => true, loadHumanRegistry: () => registry,
|
||||
transport: { send: async () => ({ ok: true, verified: true, sent: true }) } });
|
||||
const contract = new MissionControlWriteContract({ db, eventBus: bus, queueRepo: repo, actionLog: new MissionControlActionLog(db) });
|
||||
const app = new Hono();
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("queueRepo" as never, repo as never); c.set("eventBus" as never, bus as never);
|
||||
c.set("missionControlWriteContract" as never, contract as never);
|
||||
c.set("settingsStore" as never, { resolveOne: () => ({ value: "/nonexistent-needs-me-fixture" }) } as never);
|
||||
await next();
|
||||
});
|
||||
app.route("/api/mission-control", missionControlRoutes());
|
||||
app.route("/api/queue", queueRoutes());
|
||||
app.route("/api/attention", attentionRoutes());
|
||||
closes.push(() => db.close());
|
||||
const ask = async () => (await repo.create({ sourceSession: ASKER, destinationSession: PERSON, body: "Synthetic two-question ask",
|
||||
summary: "Ship?", humanIntent: "decision", humanQuestions: QUESTIONS, nudge: false })).qitemId;
|
||||
const read = async (id: string) => (await app.request(`/api/queue/${id}`)).json() as Promise<Record<string, any>>;
|
||||
const attention = async () => (await app.request("/api/attention")).json() as Promise<AttentionRead>;
|
||||
const act = async (body: Record<string, unknown>) => {
|
||||
const response = await app.request("/api/mission-control/action", { method: "POST",
|
||||
headers: { "Content-Type": "application/json", "X-OpenRig-Session": PERSON }, body: JSON.stringify({ actorSession: PERSON, ...body }) });
|
||||
return { status: response.status, body: await response.json() as Record<string, any> };
|
||||
};
|
||||
return { db, bus, repo, ask, read, attention, act };
|
||||
}
|
||||
|
||||
describe("needs-me.read: answers on the ask, by link", () => {
|
||||
it("an ask lists no answer until its set is returned, then the one answer row, with who, when and the words", async () => {
|
||||
const f = fixture(); const id = await f.ask();
|
||||
expect((await f.read(id)).answers).toEqual([]);
|
||||
expect(await f.act({ verb: "answer", qitemId: id, questionId: "q1", optionId: "yes" })).toMatchObject({ status: 200 });
|
||||
// A partial answer is recorded on the ask (humanAnswers); nothing has been returned to the asker yet.
|
||||
expect(await f.read(id)).toMatchObject({ humanAnswers: { q1: "yes" }, answers: [] });
|
||||
const done = await f.act({ verb: "answer", qitemId: id, questionId: "q2", optionId: "no" });
|
||||
expect(done).toMatchObject({ status: 200 });
|
||||
const { answers } = await f.read(id);
|
||||
expect(answers).toEqual([{ qitemId: done.body.createdQitemId, by: PERSON, at: expect.any(String),
|
||||
text: "Ship it?: Yes\nTell the team?: No", episode: null, recordedIdentity: expect.anything() }]);
|
||||
// The other direction is the answer row's own link.
|
||||
expect(await f.read(answers[0].qitemId)).toMatchObject({ replyTo: id, answers: [] });
|
||||
});
|
||||
|
||||
it("an answer in the person's words reads back as those words, without the footer", async () => {
|
||||
const f = fixture(); const id = await f.ask();
|
||||
expect(await f.act({ verb: "answer", qitemId: id, decision: "Hold until Friday" })).toMatchObject({ status: 200 });
|
||||
expect((await f.read(id)).answers).toMatchObject([{ by: PERSON, text: "Hold until Friday" }]);
|
||||
});
|
||||
|
||||
it("names the park an answer belongs to when the ask is parked on its person", async () => {
|
||||
const f = fixture(); const id = await f.ask();
|
||||
f.repo.update({ qitemId: id, actorSession: ASKER, state: "blocked", blockedOn: PERSON, summary: "Still need your call",
|
||||
evidenceRef: "/fixture/decision", transitionNote: "parked on its person" });
|
||||
const episode = f.repo.currentHumanRequiredEpisode(id)!.key;
|
||||
// As the Slack path records a parked ask's answer: under that park's question.
|
||||
const parked = await f.repo.createHumanAnswer({ sourceSession: PERSON, destinationSession: ASKER, body: "Go ahead\n\n---\nIn reply to: x",
|
||||
tags: ["human-answer"], inboundReplyTo: id, episodeKey: episode, nudge: false });
|
||||
expect((await f.read(id)).answers).toMatchObject([{ qitemId: parked.qitemId, by: PERSON, text: "Go ahead", episode }]);
|
||||
});
|
||||
|
||||
it("counts only real answers: not correspondence, another reply after the answer, a reply to an update, or a row to someone else", async () => {
|
||||
const f = fixture();
|
||||
const legacy = (qitemId: string, replyTo: string, source: string, destination: string, at = new Date().toISOString()) => {
|
||||
f.db.prepare("INSERT INTO queue_items(qitem_id,ts_created,ts_updated,source_session,destination_session,state,priority,body,reply_to,tags) VALUES(?,?,?,?,?,?,?,?,?,?)")
|
||||
.run(qitemId, at, at, source, destination, "pending", "routine", `Words in ${qitemId}`, replyTo, "[]");
|
||||
};
|
||||
// An older Slack reply from before the answer tag: the asked person's first one counts, and a second one doesn't.
|
||||
const old = await f.ask();
|
||||
legacy("qitem-slack-inbound-old-1", old, PERSON, ASKER);
|
||||
legacy("qitem-slack-inbound-old-2", old, PERSON, ASKER);
|
||||
// A reply from someone who can't answer the ask, and one sent to someone other than the asker.
|
||||
legacy("qitem-slack-inbound-agent", old, "owner@test-rig", ASKER);
|
||||
legacy("qitem-slack-inbound-elsewhere", old, PERSON, "owner@test-rig");
|
||||
expect((await f.read(old)).answers).toEqual([expect.objectContaining({ qitemId: "qitem-slack-inbound-old-1", episode: null })]);
|
||||
// Once a tagged answer exists, a later untagged reply on the ask is correspondence, not a second answer.
|
||||
const id = await f.ask();
|
||||
const done = await f.act({ verb: "answer", qitemId: id, decision: "Ship it" });
|
||||
legacy("qitem-slack-inbound-later", id, PERSON, ASKER);
|
||||
expect((await f.read(id)).answers).toEqual([expect.objectContaining({ qitemId: done.body.createdQitemId, text: "Ship it" })]);
|
||||
// Replies posted to an update's thread are never its answers.
|
||||
const update = (await f.repo.create({ sourceSession: ASKER, destinationSession: PERSON, body: "FYI", humanIntent: "update", nudge: false })).qitemId;
|
||||
legacy("qitem-slack-inbound-on-update", update, PERSON, ASKER);
|
||||
expect((await f.read(update)).answers).toEqual([]);
|
||||
// Closed with no answer row (answered in place), then a reply on its thread: correspondence, not its answer.
|
||||
const inPlace = await f.ask();
|
||||
f.repo.update({ qitemId: inPlace, actorSession: PERSON, state: "done", closureReason: "no-follow-on", transitionNote: "decided in place" });
|
||||
legacy("qitem-slack-inbound-after-close", inPlace, PERSON, ASKER, new Date(Date.now() + 60_000).toISOString());
|
||||
expect((await f.read(inPlace)).answers).toEqual([]);
|
||||
// Reopening the ask, or closing it again later, doesn't make that reply its answer.
|
||||
f.repo.update({ qitemId: inPlace, actorSession: ASKER, state: "pending", reopen: true, transitionNote: "reopened to ask again" });
|
||||
expect((await f.read(inPlace)).answers).toEqual([]);
|
||||
f.repo.update({ qitemId: inPlace, actorSession: PERSON, state: "done", closureReason: "no-follow-on", transitionNote: "decided again" });
|
||||
expect((await f.read(inPlace)).answers).toEqual([]);
|
||||
});
|
||||
|
||||
it("at the same time as the first tagged answer, an older reply counts only when it was recorded first", async () => {
|
||||
const f = fixture();
|
||||
const at = new Date(Date.now() - 60_000).toISOString();
|
||||
const insert = (qitemId: string, askId: string, tags: string) => f.db.prepare("INSERT INTO queue_items(qitem_id,ts_created,ts_updated,source_session,destination_session,state,priority,body,reply_to,tags) VALUES(?,?,?,?,?,?,?,?,?,?)")
|
||||
.run(qitemId, at, at, PERSON, ASKER, "pending", "routine", `Words in ${qitemId}`, askId, tags);
|
||||
const legacyFirst = await f.ask();
|
||||
insert("qitem-slack-inbound-tie-first", legacyFirst, "[]");
|
||||
insert("qitem-tagged-tie-second", legacyFirst, '["human-answer"]');
|
||||
expect((await f.read(legacyFirst)).answers.map((a: { qitemId: string }) => a.qitemId)).toEqual(["qitem-slack-inbound-tie-first", "qitem-tagged-tie-second"]);
|
||||
const taggedFirst = await f.ask();
|
||||
insert("qitem-tagged-tie-first", taggedFirst, '["human-answer"]');
|
||||
insert("qitem-slack-inbound-tie-second", taggedFirst, "[]");
|
||||
expect((await f.read(taggedFirst)).answers.map((a: { qitemId: string }) => a.qitemId)).toEqual(["qitem-tagged-tie-first"]);
|
||||
});
|
||||
|
||||
it("keeps a real older Slack answer that came before a re-park's tagged answer, and lists both", async () => {
|
||||
const f = fixture(); const id = await f.ask();
|
||||
const at = new Date(Date.now() - 60_000).toISOString();
|
||||
f.db.prepare("INSERT INTO queue_items(qitem_id,ts_created,ts_updated,source_session,destination_session,state,priority,body,reply_to,tags) VALUES(?,?,?,?,?,?,?,?,?,?)")
|
||||
.run("qitem-slack-inbound-before-tag", at, at, PERSON, ASKER, "pending", "routine", "Yes, ship it", id, "[]");
|
||||
// That answer closed the ask; later it was deliberately reopened onto its person, and answered again under the tag.
|
||||
f.repo.update({ qitemId: id, actorSession: ASKER, state: "done", closureReason: "no-follow-on", transitionNote: "answered in Slack" });
|
||||
f.repo.update({ qitemId: id, actorSession: ASKER, state: "blocked", blockedOn: PERSON, reopen: true, summary: "One more call",
|
||||
evidenceRef: "/fixture/decision", transitionNote: "reopened for a follow-up decision" });
|
||||
const episode = f.repo.currentHumanRequiredEpisode(id)!.key;
|
||||
const parked = await f.repo.createHumanAnswer({ sourceSession: PERSON, destinationSession: ASKER, body: "Also tell the team",
|
||||
tags: ["human-answer"], inboundReplyTo: id, episodeKey: episode, nudge: false });
|
||||
expect((await f.read(id)).answers).toMatchObject([
|
||||
{ qitemId: "qitem-slack-inbound-before-tag", text: "Yes, ship it", episode: null },
|
||||
{ qitemId: parked.qitemId, text: "Also tell the team", episode },
|
||||
]);
|
||||
});
|
||||
|
||||
it("finds an ask's replies through the reply_to index, not a scan of the queue", async () => {
|
||||
const f = fixture(); const id = await f.ask();
|
||||
const replyToQueries = new Set<string>();
|
||||
const prepare = f.db.prepare.bind(f.db);
|
||||
f.db.prepare = ((sql: string) => {
|
||||
if (/reply_to = \?/.test(sql)) replyToQueries.add(sql);
|
||||
return prepare(sql);
|
||||
}) as typeof f.db.prepare;
|
||||
await f.read(id);
|
||||
await f.attention();
|
||||
f.db.prepare = prepare;
|
||||
// The answer lookup and the decision revision's reply lookups, as the reads actually ran them.
|
||||
expect(replyToQueries.size).toBeGreaterThanOrEqual(2);
|
||||
for (const sql of replyToQueries) {
|
||||
const params = (sql.match(/\?/g) ?? []).map(() => "x");
|
||||
const plan = (f.db.prepare(`EXPLAIN QUERY PLAN ${sql}`).all(...params) as Array<{ detail: string }>).map((row) => row.detail).join("\n");
|
||||
expect(plan, sql).toMatch(/SEARCH queue_items USING INDEX idx_queue_items_reply_to/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("needs-me.read: the attention snapshot", () => {
|
||||
it("gives each request its revision and what it's about, and a watermark read before the snapshot", async () => {
|
||||
const f = fixture(); const id = await f.ask();
|
||||
const waiting = await f.repo.create({ sourceSession: ASKER, destinationSession: "owner@test-rig", body: "Waits on the ask", nudge: false });
|
||||
f.repo.claim({ qitemId: waiting.qitemId, destinationSession: "owner@test-rig" });
|
||||
f.repo.update({ qitemId: waiting.qitemId, actorSession: "owner@test-rig", state: "blocked", blockedOn: id });
|
||||
const park = await f.repo.create({ sourceSession: ASKER, destinationSession: "owner@test-rig", body: "Parked on the person", nudge: false });
|
||||
f.repo.claim({ qitemId: park.qitemId, destinationSession: "owner@test-rig" });
|
||||
f.repo.update({ qitemId: park.qitemId, actorSession: "owner@test-rig", state: "blocked", blockedOn: PERSON });
|
||||
const before = f.bus.maxSeq();
|
||||
const snapshot = await f.attention();
|
||||
expect(snapshot.watermark).toBe(before);
|
||||
const item = (qitemId: string) => snapshot.items.find((i) => i.id === `queue:${qitemId}`);
|
||||
expect(item(id)).toMatchObject({ revision: (await f.read(id)).decisionRevision, about: [waiting.qitemId] });
|
||||
expect(item(park.qitemId)).toMatchObject({ revision: (await f.read(park.qitemId)).decisionRevision, about: [park.qitemId] });
|
||||
});
|
||||
});
|
||||
|
||||
describe("team-operations.v1 capabilities", () => {
|
||||
it("names the contract, needs-me's capabilities and the build label separately", () => {
|
||||
expect(teamOperationsCapabilities({ semver: "9.9.9", commit: "abc", dirty: false, builtAt: null })).toEqual({
|
||||
contract: "team-operations.v1",
|
||||
capabilities: { "needs-me.read": "supported", "needs-me.answer": "not-supported", "needs-me.follow": "not-supported" },
|
||||
build: { semver: "9.9.9", commit: "abc" },
|
||||
});
|
||||
expect(teamOperationsCapabilities().build).toEqual({ semver: null, commit: null });
|
||||
});
|
||||
});
|
||||
@@ -90,8 +90,7 @@ describe("openrig-core plugin — manifest shape (HG-2.2)", () => {
|
||||
// Hook + skills wiring
|
||||
expect(manifest["skills"]).toBe("./skills");
|
||||
expect(manifest["hooks"]).toBe("./hooks/claude.json");
|
||||
// An https URL: Claude's plugin directory drops a `github:` shorthand from its listing.
|
||||
expect(manifest["repository"]).toBe("https://github.com/mvschwarz/openrig-plugins");
|
||||
expect(manifest["repository"]).toMatch(/github:mvschwarz\/openrig-plugins/);
|
||||
expect(manifest["license"]).toBeDefined();
|
||||
});
|
||||
|
||||
|
||||
@@ -322,32 +322,6 @@ 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, {
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
// Vendoring copies a bundled plugin into ~/.openrig/plugins byte for byte, binary files included: a plugin's icon.png
|
||||
// must arrive exactly as it ships, and a text file must arrive as it always did.
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import nodePath from "node:path";
|
||||
import { createHash } from "node:crypto";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { nodePluginVendorFs, PluginVendorService } from "../src/domain/plugin-vendor-service.js";
|
||||
|
||||
const dirs: string[] = [];
|
||||
afterEach(() => { for (const dir of dirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); });
|
||||
|
||||
/** A PNG header and bytes that aren't valid UTF-8, so a text round trip would change them. */
|
||||
const ICON = Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), Buffer.from([0xff, 0xfe, 0x80, 0x00, 0xc3, 0x28, 0xa0, 0xa1])]);
|
||||
const sha = (bytes: Buffer) => createHash("sha256").update(bytes).digest("hex");
|
||||
const httpClient = async () => ({ ok: false, status: 404 });
|
||||
|
||||
function scratch(): { assets: string; plugins: string } {
|
||||
const root = fs.mkdtempSync(nodePath.join(os.tmpdir(), "openrig-vendor-binary-"));
|
||||
dirs.push(root);
|
||||
return { assets: nodePath.join(root, "assets"), plugins: nodePath.join(root, "home", "plugins") };
|
||||
}
|
||||
|
||||
function shipPlugin(assets: string, version: string, icon = ICON): void {
|
||||
const plugin = nodePath.join(assets, "openrig");
|
||||
fs.mkdirSync(nodePath.join(plugin, ".claude-plugin"), { recursive: true });
|
||||
fs.mkdirSync(nodePath.join(plugin, "skills", "demo"), { recursive: true });
|
||||
fs.writeFileSync(nodePath.join(plugin, ".claude-plugin", "plugin.json"), JSON.stringify({ name: "openrig", version, description: "Team — work" }));
|
||||
fs.writeFileSync(nodePath.join(plugin, ".claude-plugin", "icon.png"), icon);
|
||||
fs.writeFileSync(nodePath.join(plugin, "skills", "demo", "SKILL.md"), "# Demo — a skill\n");
|
||||
fs.writeFileSync(nodePath.join(plugin, "skills", "demo", "badge.png"), icon);
|
||||
}
|
||||
|
||||
const service = (assets: string, plugins: string) =>
|
||||
new PluginVendorService({ vendoredAssetsDir: assets, userPluginsDir: plugins, fs: nodePluginVendorFs(), httpClient });
|
||||
|
||||
describe("vendoring a plugin with a binary file", () => {
|
||||
it("installs the icon byte for byte, and its text files as before", async () => {
|
||||
const { assets, plugins } = scratch();
|
||||
shipPlugin(assets, "0.1.0");
|
||||
await service(assets, plugins).ensureVendored("openrig");
|
||||
const installed = fs.readFileSync(nodePath.join(plugins, "openrig", ".claude-plugin", "icon.png"));
|
||||
expect(sha(installed)).toBe(sha(ICON));
|
||||
expect(fs.readFileSync(nodePath.join(plugins, "openrig", "skills", "demo", "SKILL.md"), "utf-8")).toBe("# Demo — a skill\n");
|
||||
});
|
||||
|
||||
it("leaves an identical install alone at the same version, and refreshes a newer one exactly, keeping the old bytes", async () => {
|
||||
const { assets, plugins } = scratch();
|
||||
shipPlugin(assets, "0.1.0");
|
||||
await service(assets, plugins).ensureVendored("openrig");
|
||||
await service(assets, plugins).ensureVendored("openrig");
|
||||
const backups = nodePath.join(nodePath.dirname(plugins), "backups");
|
||||
expect(fs.existsSync(backups)).toBe(false);
|
||||
|
||||
const newer = Buffer.concat([ICON, Buffer.from([0xfd, 0xbe])]);
|
||||
fs.rmSync(assets, { recursive: true });
|
||||
shipPlugin(assets, "0.2.0", newer);
|
||||
await service(assets, plugins).ensureVendored("openrig");
|
||||
expect(sha(fs.readFileSync(nodePath.join(plugins, "openrig", ".claude-plugin", "icon.png")))).toBe(sha(newer));
|
||||
const kept = fs.readdirSync(nodePath.join(backups, "managed-refresh"))
|
||||
.map((id) => nodePath.join(backups, "managed-refresh", id, ".claude-plugin", "icon.png"))
|
||||
.filter((path) => fs.existsSync(path));
|
||||
expect(kept.map((path) => sha(fs.readFileSync(path)))).toEqual([sha(ICON)]);
|
||||
});
|
||||
|
||||
it("projects a skill's binary file into a global skill root byte for byte", () => {
|
||||
const { assets, plugins } = scratch();
|
||||
shipPlugin(assets, "0.1.0");
|
||||
const root = nodePath.join(nodePath.dirname(plugins), "global-skills");
|
||||
service(assets, plugins).ensureSkillDirGlobally(nodePath.join(assets, "openrig", "skills", "demo"), "demo", "0.1.0", [root]);
|
||||
expect(sha(fs.readFileSync(nodePath.join(root, "demo", "badge.png")))).toBe(sha(ICON));
|
||||
expect(fs.readFileSync(nodePath.join(root, "demo", "SKILL.md"), "utf-8")).toBe("# Demo — a skill\n");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user