@staix/agent-hub 0.28.0 → 0.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/README.md +2 -2
- package/docs/agent-notes/daemon.md +2 -0
- package/docs/agent-notes/local-worker.md +3 -1
- package/docs/agent-notes/tasks.md +3 -1
- package/docs/agent-notes/tests.md +1 -0
- package/docs/design/task-board.md +1 -1
- package/docs/dogfood.md +6 -3
- package/docs/operations.md +73 -11
- package/docs/quickstart.md +11 -3
- package/docs/sdlc.md +1 -1
- package/docs/security.md +17 -2
- package/docs/verification/2026-11-03-0.29.0.md +59 -0
- package/docs/verification/2026-11-03-control-write-stall-windows.json +241 -0
- package/docs/verification/2026-11-03-control-write-stall-windows.md +20 -0
- package/docs/verification/2026-11-03-live-board-latency.json +556 -0
- package/docs/verification/2026-11-03-live-board-latency.md +64 -0
- package/docs/verified.json +13 -13
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +524 -18
- package/src/cli/help.ts +1 -1
- package/src/cli/output.ts +5 -0
- package/src/cli/recovery-package.ts +9 -0
- package/src/hub/board.ts +3 -2
- package/src/hub/control-client.ts +16 -1
- package/src/hub/control-write-trace.ts +409 -0
- package/src/hub/daemon.ts +171 -47
- package/src/hub/hub-tools.ts +1 -1
- package/src/hub/routing.ts +9 -2
- package/src/hub/runtime-capture-prune.ts +4 -1
- package/src/hub/sdlc-release-authorization.ts +13 -4
- package/src/hub/sdlc-release.ts +23 -10
- package/src/hub/sdlc.ts +15 -2
- package/src/hub/task-worktree.ts +331 -0
- package/src/hub/tasks.ts +162 -17
- package/src/local/deny.ts +49 -0
- package/src/local/tools.ts +4 -2
- package/src/memory/brief.ts +5 -0
- package/src/memory/client.ts +5 -2
package/src/hub/daemon.ts
CHANGED
|
@@ -45,6 +45,7 @@ import { createStalenessProbe } from "./memory-staleness.ts";
|
|
|
45
45
|
import { matchingPlanFails, planGuardNodes, taskMemoryQueryNodes } from "./memory-plan-guard.ts";
|
|
46
46
|
import { planningWipLimits } from "./planning.ts";
|
|
47
47
|
import { ToolWriteRegistry, toolWriteOperation, toolWriteUnknownReply } from "./tool-request.ts";
|
|
48
|
+
import { ControlWriteTelemetry, controlWriteMetadata, controlWriteSpan } from "./control-write-trace.ts";
|
|
48
49
|
import { projectedDiff } from "./projected-diff.ts";
|
|
49
50
|
import { listenWithoutPortCollision } from "./listen.ts";
|
|
50
51
|
import { needs, type NeedsInput } from "../ui/needs.ts";
|
|
@@ -174,6 +175,7 @@ import { autonomyPreset, autonomyEnvironment, planAutonomy, autonomyRemovalKeys,
|
|
|
174
175
|
import { readAutonomyDefaults } from "./autonomy-defaults.ts";
|
|
175
176
|
import { AUTONOMY_UNDO, autonomyOwnershipDigest, autonomyUndoKeys, writeAutonomyPreset, LEARN_CHECKLIST, NEVER_EDITABLE, projectSettings, checkSettingValue, ignoredLine, pendingUndo, pendingUndoIdentity, readOverlay, restoredValue, routingCandidate, storedLocally, sharedValue, settingDef as instanceSettingDef, settingRefusal, settingRows, settingText, undoSetting, valueAt, writeConfigSetting, writeRoutingSetting, type SettingAuthority, type SettingSource, type SettingDef, type SettingRow, type SettingValue, type UndoIdentity } from "./settings.ts";
|
|
176
177
|
import { githubOriginRepository, githubRemoteRepository } from "./task-repository.ts";
|
|
178
|
+
import { TaskWorktrees, originVerdict } from "./task-worktree.ts";
|
|
177
179
|
import { Bus } from "./bus.ts";
|
|
178
180
|
import { attribute, deliveryTask, type TaskAttribution } from "./attribution.ts";
|
|
179
181
|
import { DeliveryJournal } from "./delivery-journal.ts";
|
|
@@ -1208,7 +1210,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1208
1210
|
let parsed: any;
|
|
1209
1211
|
try {
|
|
1210
1212
|
parsed = JSON.parse(String(data));
|
|
1211
|
-
|
|
1213
|
+
onControlMessage(sock, parsed);
|
|
1212
1214
|
} catch (e) {
|
|
1213
1215
|
if (isDatabaseBusy(e) && sock.data.authed && Number.isSafeInteger(parsed?.rid)) {
|
|
1214
1216
|
const busy = new RecoveryDatabaseBusy();
|
|
@@ -1718,10 +1720,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1718
1720
|
for (const t of board.list("in_progress")) if (!tasks.isPii(t)) renewLeases(t);
|
|
1719
1721
|
}
|
|
1720
1722
|
let syncRun: Promise<void> | undefined;
|
|
1723
|
+
let controlWriteTelemetry: ControlWriteTelemetry | undefined;
|
|
1721
1724
|
/** Machine sync, when a person set a remote; one log line per failure, never a blocked turn. Stopping aborts and awaits it. */
|
|
1722
1725
|
function syncNow(): Promise<void> {
|
|
1723
1726
|
if (!memoryStore || syncRun || stopping) return syncRun ?? Promise.resolve();
|
|
1724
|
-
|
|
1727
|
+
const run = async () => {
|
|
1725
1728
|
try {
|
|
1726
1729
|
checkMemory();
|
|
1727
1730
|
const r = await (opts.memorySync ?? syncMemory)(memoryStore!, { signal: syncStop.signal });
|
|
@@ -1729,7 +1732,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1729
1732
|
else if (r.warning) log(`hub memory sync: ${r.warning}`);
|
|
1730
1733
|
} catch { /* A stale binding disables native memory, never the bus. */ }
|
|
1731
1734
|
finally { syncRun = undefined; }
|
|
1732
|
-
}
|
|
1735
|
+
};
|
|
1736
|
+
syncRun = controlWriteTelemetry ? controlWriteTelemetry.observe("memory", () => !stopping, run) : run();
|
|
1733
1737
|
return syncRun;
|
|
1734
1738
|
}
|
|
1735
1739
|
|
|
@@ -2047,6 +2051,13 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2047
2051
|
}).finally(() => { refreshingRunner = undefined; });
|
|
2048
2052
|
return refreshingRunner;
|
|
2049
2053
|
};
|
|
2054
|
+
// #1115: the hub makes, commits and clears the task worktree of a hub-sandboxed peer. The sandbox profile is unchanged; the record file lives in the hub's state dir, which no sandbox root contains.
|
|
2055
|
+
const taskWorktree = new TaskWorktrees({
|
|
2056
|
+
root: opts.cwd, stateFile: join(opts.stateDir, "task-worktrees.json"),
|
|
2057
|
+
applies: peer => definitionOf(peer)?.trust.hubSandboxed === true,
|
|
2058
|
+
deny: () => config.local.deny, protectInstructions: () => config.protect_instruction_files,
|
|
2059
|
+
pushAllowed: urls => originVerdict(shellProjectRepository, urls, url => githubRemoteRepository(url)),
|
|
2060
|
+
});
|
|
2050
2061
|
const peerCapabilities = () => {
|
|
2051
2062
|
// Keep the last confirmed capability during a bounded, single-flight refresh; replace it only on a result.
|
|
2052
2063
|
if (Date.now() - runnerStateAt >= 60_000) void refreshRunner();
|
|
@@ -2054,9 +2065,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2054
2065
|
if (!definitionOf(peer)?.trust.hubSandboxed) return [peer, { tests: true, spec: true, network: true, worktree: true }];
|
|
2055
2066
|
// #1081: Pi and the local worker run their shell under one sandbox network setting (`local.bash_network`): false is no network at all, an egress proxy or "direct" is some.
|
|
2056
2067
|
const network = sandboxNetwork !== false;
|
|
2057
|
-
// A hub sandbox never lets its shell run `git worktree add` (#270: it writes under the git common dir), so
|
|
2058
|
-
|
|
2059
|
-
|
|
2068
|
+
// A hub sandbox never lets its shell run `git worktree add` (#270: it writes under the git common dir), so the hub makes the task worktree for such a peer (#1115): the capability is whether it can here.
|
|
2069
|
+
const hubWorktree = taskWorktree.available();
|
|
2070
|
+
return [peer, { tests: runnerState.available && may(peer, "check_run"), spec: specState.available && may(peer, "spec_read"), network, worktree: hubWorktree.ok,
|
|
2071
|
+
...(hubWorktree.ok ? {} : { worktreeReason: `the hub cannot make a task worktree for it: ${hubWorktree.reason}; ${hubWorktree.next} (#1115)` }),
|
|
2060
2072
|
...(network ? {} : { networkReason: "local.bash_network is off, so its shell has no network (ahub settings local.bash_network)" }),
|
|
2061
2073
|
testsReason: !may(peer, "check_run") ? `capabilities.${peer}` : `runner unavailable: ${runnerState.reason ?? "unknown"}`,
|
|
2062
2074
|
specReason: !may(peer, "spec_read") ? `capabilities.${peer}` : specState.reason ?? "gh unauthenticated" }];
|
|
@@ -2129,7 +2141,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2129
2141
|
if (why || configSourceTexts(opts.cwd)["config.local.json"] === null) throw new Error(`research.runners source trust unavailable${why ? `: ${why}` : ""}`);
|
|
2130
2142
|
return parseResearchRunners(config.research.runners);
|
|
2131
2143
|
},
|
|
2132
|
-
capabilities: peerCapabilities, repository: memoryRepo,
|
|
2144
|
+
capabilities: peerCapabilities, taskWorktree, repository: memoryRepo,
|
|
2133
2145
|
contextAdmission: () => ({
|
|
2134
2146
|
policy: { max_used: config.context.max_assignment_used ?? 0, stale_min: config.context.stale_min },
|
|
2135
2147
|
peers: (config.context.max_assignment_used ?? 0) > 0 ? Object.fromEntries(bus.knownPeers().filter(peer => definitionOf(peer)?.budget?.context).map(peer => [peer,
|
|
@@ -2241,13 +2253,16 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2241
2253
|
read: task => publicCorpus([task.title, ...task.refs.paths ?? [], ...task.plan?.paths ?? [], ...task.plan?.symbols ?? []].join(" "), () => corpusBriefAllowed(task)).map(item => ({ id: item.id, kind: "note", line: `${item.id}: ${item.text.replace(/\s+/g, " ")}` })),
|
|
2242
2254
|
}),
|
|
2243
2255
|
...(memoryStore ? { planFails: (task: import("./board.ts").Task) => {
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2256
|
+
const read = () => {
|
|
2257
|
+
checkMemory();
|
|
2258
|
+
const context = { cwd: opts.cwd, project: memoryProject, repo: memoryRepo, deny: config.local.deny };
|
|
2259
|
+
const nodes = planGuardNodes(task, context);
|
|
2260
|
+
const related = screenMemory(memoryStore.related(memoryProject, nodes, Number.MAX_SAFE_INTEGER));
|
|
2261
|
+
const all = screenMemory(memoryStore.list({ project: memoryProject, kind: "fail", limit: Number.MAX_SAFE_INTEGER }));
|
|
2262
|
+
const rows = [...new Map([...related, ...all].map(row => [row.id, row])).values()];
|
|
2263
|
+
return matchingPlanFails(task, rows, context).slice(0, config.memory.brief_items).map(v => ({ id: `mem:${v.id}`, line: memoryLine(v), kind: v.kind, about: v.about }));
|
|
2264
|
+
};
|
|
2265
|
+
return controlWriteTelemetry ? controlWriteTelemetry.observe("memory", controlWritePrivacyGuard(true, true, undefined, task.id), read) : read();
|
|
2251
2266
|
} } : {}),
|
|
2252
2267
|
...(memoryStore ? { store: { store: memoryStore, project: memoryProject, repo: memoryRepo, deny: config.local.deny, onNote: (id: string) => void suggestEdges(id) } } : {}),
|
|
2253
2268
|
notify,
|
|
@@ -2270,14 +2285,29 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2270
2285
|
log(`note for ${peer} not kept: ${(e as Error).message}`);
|
|
2271
2286
|
}
|
|
2272
2287
|
},
|
|
2273
|
-
triage: {
|
|
2288
|
+
triage: {
|
|
2289
|
+
classify: (title, detail) => {
|
|
2290
|
+
const classify = () => inference?.triage(title, detail) ?? Promise.resolve(undefined);
|
|
2291
|
+
return controlWriteTelemetry ? controlWriteTelemetry.observe("external", controlWritePrivacyGuard(true, true), classify,
|
|
2292
|
+
() => boundedSafe(`${title}\n${detail}`)) : classify();
|
|
2293
|
+
},
|
|
2294
|
+
onCampus: () => controlWriteTelemetry ? controlWriteTelemetry.observe("external", controlWritePrivacyGuard(true, true), () => onCampus()) : onCampus(),
|
|
2295
|
+
},
|
|
2274
2296
|
// #334: the hub model's split, validated against the same `hub_backlog_split` parts schema a planner's call is, and the source registry for `--source`.
|
|
2275
|
-
intake: { split: (system, user, opts) =>
|
|
2297
|
+
intake: { split: (system, user, opts) => {
|
|
2298
|
+
const split = () => inference?.split(system, user, opts) ?? Promise.resolve(undefined);
|
|
2299
|
+
return controlWriteTelemetry ? controlWriteTelemetry.observe("external", controlWritePrivacyGuard(true, true), split,
|
|
2300
|
+
() => boundedSafe(`${system}\n${user}`)) : split();
|
|
2301
|
+
}, sourceKnown: id => sourceRegistry.get(id) !== undefined,
|
|
2276
2302
|
partsSchema: () => profileTaskTools(resolvedProfile, lifecycleConfig.stages, lifecycleConfig.mode !== "off").find(tool => tool.name === "hub_backlog_split")?.inputSchema.properties.parts },
|
|
2277
2303
|
studySource: id => sourceRegistry.get(id),
|
|
2278
2304
|
experimentCheck: run => checkRunner.result(run),
|
|
2279
2305
|
// The PII screen (issue #198): the model on this machine when MLX/Ollama is enabled, else the gateway on campus only.
|
|
2280
|
-
piiScreen:
|
|
2306
|
+
piiScreen: (text) => {
|
|
2307
|
+
const screen = () => (opts.piiScreen ?? ((value: string) => screenPii(value, { ...(config.mlx.enabled === false ? {} : { device: () => ensureMlx(config.mlx) }), onCampus, omni, fixedModel: () => activeRouting().local.fixed_model })))(text);
|
|
2308
|
+
return controlWriteTelemetry ? controlWriteTelemetry.observe("external", controlWritePrivacyGuard(true, true), screen,
|
|
2309
|
+
() => boundedSafe(text)) : screen();
|
|
2310
|
+
},
|
|
2281
2311
|
recordScreen: (record) => event({ type: "pii_screen", ...record }),
|
|
2282
2312
|
quota: (): ReturnType<Budget["headroom"]> => projectBudgetHeadroom(budget.headroom(), accountMembers), // budget is built below; this runs at assignment time
|
|
2283
2313
|
review: config.review,
|
|
@@ -2573,6 +2603,20 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2573
2603
|
};
|
|
2574
2604
|
const activityRings: Record<string, ActivityRing> = Object.fromEntries(SUPPORTED_PEERS.filter(peer => definitionOf(peer)?.capabilities?.watch).map(peer => [peer, new ActivityRing()]));
|
|
2575
2605
|
const contextPii = (peer: PeerId) => !!turns.get(peer)?.private || board.peerTaskSignals(peer).some(task => !isClosedTaskState(task.state) && tasks.isPii(task));
|
|
2606
|
+
// Hoisted for the Tasks ports above; only the returned guard reads fully initialized daemon state.
|
|
2607
|
+
function controlWritePrivacyGuard(admittedAtReceive: boolean, inputPublicAtReceive: boolean, peer?: PeerId, taskId?: number) {
|
|
2608
|
+
return () => {
|
|
2609
|
+
try {
|
|
2610
|
+
if (!admittedAtReceive || !inputPublicAtReceive) return false;
|
|
2611
|
+
if (peer && (turns.get(peer)?.private || contextPii(peer))) return false;
|
|
2612
|
+
if (taskId !== undefined) {
|
|
2613
|
+
const task = board.get(taskId);
|
|
2614
|
+
if (!task || tasks.isPii(task) || !tasks.publicMetadataText(String(task.id))) return false;
|
|
2615
|
+
}
|
|
2616
|
+
return true;
|
|
2617
|
+
} catch { return false; }
|
|
2618
|
+
};
|
|
2619
|
+
}
|
|
2576
2620
|
// #878: what each peer is doing. Daemon memory only: never persisted (status.json leaves it out), logged, captured or relayed.
|
|
2577
2621
|
const doings = new Map<PeerId, DoingTracker>();
|
|
2578
2622
|
const observeDoing = (peer: PeerId, kind: DoingKind, text?: unknown, learnedPrivate = false) => {
|
|
@@ -2815,22 +2859,25 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2815
2859
|
};
|
|
2816
2860
|
const snap = (what: string, capture = config.snapshots.enabled, peer?: string): { tree?: string; head?: string | null; commit?: string | null; reflog?: HeadMoveCursor; ms: number; budget?: SnapshotBudget; repo?: Repo } => {
|
|
2817
2861
|
if (!capture) return { ms: 0 };
|
|
2818
|
-
const
|
|
2819
|
-
|
|
2820
|
-
|
|
2821
|
-
|
|
2822
|
-
|
|
2823
|
-
|
|
2824
|
-
|
|
2825
|
-
|
|
2826
|
-
|
|
2827
|
-
|
|
2828
|
-
|
|
2829
|
-
|
|
2830
|
-
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
|
|
2862
|
+
const captureSnapshot = () => {
|
|
2863
|
+
const t0 = performance.now(), budget = reserveSnapshotBudget();
|
|
2864
|
+
let tree: string | undefined, head: string | null | undefined, commit: string | null | undefined, repo: Repo | undefined, reflog: HeadMoveCursor | undefined;
|
|
2865
|
+
try {
|
|
2866
|
+
repo = peer ? repoFor(peer, budget) : factsRepo;
|
|
2867
|
+
const before = repo && headState(repo, budget);
|
|
2868
|
+
tree = repo && (opts.snapshot ?? snapshot)(repo, budget);
|
|
2869
|
+
const after = repo && headState(repo, budget);
|
|
2870
|
+
head = after?.tree; commit = after?.head;
|
|
2871
|
+
if (!before || !after || before.tree !== after.tree) tree = undefined;
|
|
2872
|
+
if (tree && repo && head !== undefined && after) reflog = headMoveCursor(repo, head, budget, after.head);
|
|
2873
|
+
if (repo && !tree) log(`snapshot failed at ${what}`);
|
|
2874
|
+
} catch (error) {
|
|
2875
|
+
tree = undefined; head = undefined; commit = undefined; reflog = undefined;
|
|
2876
|
+
snapshotFailure(what, error);
|
|
2877
|
+
}
|
|
2878
|
+
return { tree, head, commit, reflog, budget, repo, ms: Math.round(performance.now() - t0) };
|
|
2879
|
+
};
|
|
2880
|
+
return controlWriteTelemetry ? controlWriteTelemetry.observe("snapshot", controlWritePrivacyGuard(true, true, peer as PeerId | undefined), captureSnapshot) : captureSnapshot();
|
|
2834
2881
|
};
|
|
2835
2882
|
function privateSnapshotWindow(): boolean {
|
|
2836
2883
|
return [...turns.values()].some(turn => turn.private) || board.list().some(task => !isClosedTaskState(task.state) && tasks.isPii(task));
|
|
@@ -3302,6 +3349,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3302
3349
|
// commit waits for those in flight, or its integrity digest misses their later writes. Completion checks outlive
|
|
3303
3350
|
// their op, so recoveryReady() also waits for `tasks.checksPending()`: a check the commit's stop kills would write.
|
|
3304
3351
|
let taskOpsInFlight = 0;
|
|
3352
|
+
controlWriteTelemetry = new ControlWriteTelemetry({ emit: line => {
|
|
3353
|
+
try { if (tasks.publicMetadataText(line) && boundedSafe(line)) log(line); } catch { /* diagnostics never fail a write */ }
|
|
3354
|
+
} });
|
|
3355
|
+
startupCleanup.push(() => controlWriteTelemetry?.stop());
|
|
3305
3356
|
const toolWriteRegistry = new ToolWriteRegistry(instanceId, opts.toolWriteReplayMaxEntries === undefined ? {} : { maxEntries: opts.toolWriteReplayMaxEntries });
|
|
3306
3357
|
const toolWriteScope = (c: Client, operation: string): string => {
|
|
3307
3358
|
const actor = c.peer ?? USER, peer = c.peer ? bus.peers.get(c.peer) : undefined;
|
|
@@ -3925,7 +3976,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3925
3976
|
const silent = tasks.silentFor(t.id);
|
|
3926
3977
|
const overlap = a.plan == null && a.refs == null && !silent ? "" : tasks.overlaps(t);
|
|
3927
3978
|
if (silent && factsOn()) factsFor(by).sawPlans(by, tasks.overlapTasks(t)); // the plans in this answer need no fact later
|
|
3928
|
-
return [line(t), specs, overlap, tasks.planGuardText(t), tasks.submoduleNotice(t)].filter(Boolean).join("\n"); // #798 D1
|
|
3979
|
+
return [line(t), specs, overlap, tasks.planGuardText(t), tasks.submoduleNotice(t), tasks.worktreeNote(t, by)].filter(Boolean).join("\n"); // #798 D1
|
|
3929
3980
|
}
|
|
3930
3981
|
case "hub_task_cancel": {
|
|
3931
3982
|
if (!a || typeof a !== "object" || Array.isArray(a) || Object.keys(a).some(key => !["id", "reason", "superseded_by", "revision"].includes(key))
|
|
@@ -3938,7 +3989,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3938
3989
|
case "hub_task_decline":
|
|
3939
3990
|
return line(await tasks.decline(by, a.id, a.reason, a.capacity === true));
|
|
3940
3991
|
case "hub_task_done": {
|
|
3941
|
-
|
|
3992
|
+
// #1115: a hub-sandboxed peer's worktree is staged, committed and pushed by the hub first, so the done carries the real head and branch.
|
|
3993
|
+
const handed = await tasks.worktreeHandoff(by, a.id, a.refs);
|
|
3994
|
+
const t = await tasks.done(by, a.id, a.summary, handed ?? a.refs, a.deliverables, a.spec_ack);
|
|
3942
3995
|
const completion = t.history.findLast(h => h.by === by && (h.event === "done" || h.event === "done (checking)"));
|
|
3943
3996
|
if (!piiTurn && !tasks.isPii(t) && completion && !completion.withheld && typeof a.summary === "string" && completion.note?.startsWith(a.summary)) {
|
|
3944
3997
|
citeMemory(by, a.summary, `summary:${t.id}:${t.history.indexOf(completion)}`, [newEnvelope(by, "", { refs: { task: String(t.id) } })]);
|
|
@@ -4769,6 +4822,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4769
4822
|
isolatedChecks: { pending: checkPending.size, orphans: orphanChecks, unknownReceipts: checkRunner.unknownReceipts ?? [], maintenanceUnavailable: checkRunner.maintenanceUnavailable === true },
|
|
4770
4823
|
taskCapabilities: peerCapabilities(),
|
|
4771
4824
|
tasks: board.counts(),
|
|
4825
|
+
...(includeAttentionItems && controlWriteTelemetry ? { controlWriteTiming: controlWriteTelemetry.status() } : {}),
|
|
4772
4826
|
taskStalls, pendingTaskOffers,
|
|
4773
4827
|
...(bus.storageError ? { deliveryError: bus.storageError } : {}),
|
|
4774
4828
|
...(recoveryOperationId ? { recovery: { operationId: recoveryOperationId, phase: recoveryPhase, ready: recoveryReady() } } : {}),
|
|
@@ -4799,20 +4853,28 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4799
4853
|
// (the status reply is unchanged); readers without it judge the pid alone, as 0.12.20 did.
|
|
4800
4854
|
const pidSignature = processSignature(process.pid);
|
|
4801
4855
|
const writeStatus = () => {
|
|
4802
|
-
|
|
4803
|
-
|
|
4804
|
-
|
|
4805
|
-
|
|
4806
|
-
|
|
4856
|
+
const write = () => {
|
|
4857
|
+
try {
|
|
4858
|
+
const file = join(opts.stateDir, "status.json"); // clients parse this on every connect: replace it atomically
|
|
4859
|
+
writeFileSync(`${file}.${instanceId}.tmp`, `${JSON.stringify({ ...status(), ...(pidSignature ? { pidSignature } : {}) }, null, 2)}\n`);
|
|
4860
|
+
renameSync(`${file}.${instanceId}.tmp`, file);
|
|
4861
|
+
} catch { /* the state dir is gone; the watchdog is stopping the hub (issue #56) */ }
|
|
4862
|
+
};
|
|
4863
|
+
if (controlWriteTelemetry) controlWriteTelemetry.observe("status-hook", () => true, write);
|
|
4864
|
+
else write();
|
|
4807
4865
|
};
|
|
4808
4866
|
const taskChanged = board.onChange;
|
|
4809
4867
|
board.onChange = (task, entry) => {
|
|
4810
|
-
|
|
4811
|
-
|
|
4812
|
-
|
|
4813
|
-
|
|
4814
|
-
|
|
4815
|
-
|
|
4868
|
+
const changed = () => {
|
|
4869
|
+
try { taskChanged?.(task, entry); }
|
|
4870
|
+
finally {
|
|
4871
|
+
try { handoffNotifyOrigin(task, entry); } catch { /* never throw after a board write */ }
|
|
4872
|
+
try { settleSupersededDeliveries(); }
|
|
4873
|
+
finally { writeStatus(); } // committed task transitions refresh token-free attention even without bus activity
|
|
4874
|
+
}
|
|
4875
|
+
};
|
|
4876
|
+
if (controlWriteTelemetry) controlWriteTelemetry.observe("queue-hook", controlWritePrivacyGuard(true, true, undefined, task.id), changed);
|
|
4877
|
+
else changed();
|
|
4816
4878
|
};
|
|
4817
4879
|
|
|
4818
4880
|
// A queue change is not a bus event: without this, status.json reports the queue as it was before the last
|
|
@@ -4847,6 +4909,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4847
4909
|
let reportingQueues = false;
|
|
4848
4910
|
let queuePressureStatusQueued = false;
|
|
4849
4911
|
bus.onQueues = () => {
|
|
4912
|
+
const changed = () => {
|
|
4850
4913
|
if (stopping || settlingSuperseded || reportingQueues) return;
|
|
4851
4914
|
remoteQueueChanged();
|
|
4852
4915
|
reportingQueues = true;
|
|
@@ -4872,6 +4935,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4872
4935
|
});
|
|
4873
4936
|
}
|
|
4874
4937
|
}
|
|
4938
|
+
};
|
|
4939
|
+
if (controlWriteTelemetry) controlWriteTelemetry.observe("queue-hook", () => true, changed);
|
|
4940
|
+
else changed();
|
|
4875
4941
|
};
|
|
4876
4942
|
// Opening the journal/restoring the snapshot preceded callbacks; classify those holds too.
|
|
4877
4943
|
settleSupersededDeliveries();
|
|
@@ -8134,10 +8200,62 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
8134
8200
|
if (!result.ok) throw new Error(String(result.error));
|
|
8135
8201
|
}
|
|
8136
8202
|
|
|
8137
|
-
function
|
|
8203
|
+
function onControlMessage(sock: Sock, msg: any): void {
|
|
8204
|
+
const client = sock.data;
|
|
8205
|
+
const admittedAtReceive = client.authed && !!client.role, peer = client.peer;
|
|
8206
|
+
if (!admittedAtReceive) return onMessage(sock, msg);
|
|
8207
|
+
const metadata = controlWriteMetadata(msg);
|
|
8208
|
+
if (!metadata || !controlWriteTelemetry) return onMessage(sock, msg);
|
|
8209
|
+
const inputPublicAtReceive = (() => {
|
|
8210
|
+
const rawArgs = msg?.args;
|
|
8211
|
+
if (rawArgs === undefined) return true;
|
|
8212
|
+
if (!rawArgs || typeof rawArgs !== "object" || Array.isArray(rawArgs)) return false;
|
|
8213
|
+
try {
|
|
8214
|
+
const idDescriptor = Object.getOwnPropertyDescriptor(rawArgs, "id");
|
|
8215
|
+
if (idDescriptor && (!Object.hasOwn(idDescriptor, "value") || idDescriptor.value !== undefined) && metadata.task === undefined) return false;
|
|
8216
|
+
const stack: { value: unknown; depth: number }[] = [{ value: rawArgs, depth: 0 }];
|
|
8217
|
+
const text: string[] = [];
|
|
8218
|
+
let bytes = 0, nodes = 0;
|
|
8219
|
+
while (stack.length) {
|
|
8220
|
+
const current = stack.pop()!;
|
|
8221
|
+
if (++nodes > 2048 || current.depth > 16) return undefined;
|
|
8222
|
+
if (typeof current.value === "string") {
|
|
8223
|
+
bytes += Buffer.byteLength(current.value);
|
|
8224
|
+
if (bytes > 32 * 1024) return undefined;
|
|
8225
|
+
text.push(current.value);
|
|
8226
|
+
} else if (Array.isArray(current.value)) {
|
|
8227
|
+
if (current.value.length > 256) return undefined;
|
|
8228
|
+
for (const value of current.value) stack.push({ value, depth: current.depth + 1 });
|
|
8229
|
+
} else if (current.value && typeof current.value === "object") {
|
|
8230
|
+
const values = Object.values(current.value);
|
|
8231
|
+
if (values.length > 256) return undefined;
|
|
8232
|
+
for (const value of values) stack.push({ value, depth: current.depth + 1 });
|
|
8233
|
+
}
|
|
8234
|
+
}
|
|
8235
|
+
const joined = text.join("\n");
|
|
8236
|
+
return tasks.publicMetadataText(joined) && boundedSafe(joined);
|
|
8237
|
+
} catch { return undefined; }
|
|
8238
|
+
})();
|
|
8239
|
+
const privacyCurrent = controlWritePrivacyGuard(admittedAtReceive, inputPublicAtReceive === true, peer, metadata.task);
|
|
8240
|
+
let finishControlWrite = () => {};
|
|
8241
|
+
let finished = false;
|
|
8242
|
+
const finish = () => { if (!finished) { finished = true; finishControlWrite(); } };
|
|
8243
|
+
try {
|
|
8244
|
+
void controlWriteTelemetry.run(metadata, privacyCurrent, () => {
|
|
8245
|
+
const completed = new Promise<void>(resolve => { finishControlWrite = resolve; });
|
|
8246
|
+
try {
|
|
8247
|
+
controlWriteSpan("daemon-dispatch", () => onMessage(sock, msg, finish));
|
|
8248
|
+
} catch (error) { finish(); throw error; }
|
|
8249
|
+
return completed;
|
|
8250
|
+
});
|
|
8251
|
+
} catch (error) { finish(); throw error; }
|
|
8252
|
+
}
|
|
8253
|
+
|
|
8254
|
+
function onMessage(sock: Sock, msg: any, finishControlWrite: () => void = () => {}): void {
|
|
8138
8255
|
const c = sock.data;
|
|
8139
8256
|
let refusalRecorded=false;
|
|
8140
8257
|
const reply = (body: Record<string, unknown>) => {
|
|
8258
|
+
const sendReply = () => {
|
|
8141
8259
|
const writeRequestId = typeof msg.requestId === "string" && msg.requestId.length <= 256 && !/[\p{Cc}\p{Cf}]/u.test(msg.requestId)
|
|
8142
8260
|
&& toolWriteOperation(msg) !== undefined && body.requestId === msg.requestId ? msg.requestId : undefined;
|
|
8143
8261
|
let writeRefusal: string | undefined;
|
|
@@ -8157,6 +8275,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
8157
8275
|
body = { ...body, ...toolWriteUnknownReply(msg, writeRequestId, writeRefusal ?? "caller authority changed before the result could be returned"), ...authority };
|
|
8158
8276
|
}
|
|
8159
8277
|
if (sock.readyState === WebSocket.OPEN) sock.send(JSON.stringify({ rid: msg.rid, ...body }));
|
|
8278
|
+
};
|
|
8279
|
+
try {
|
|
8280
|
+
controlWriteSpan("daemon-reply", sendReply);
|
|
8281
|
+
} finally { finishControlWrite(); }
|
|
8160
8282
|
};
|
|
8161
8283
|
const runTaskRequest = (run: () => Promise<string>, extraCurrent: () => void = () => {}, formatError: (error: unknown) => Record<string, unknown> = error => ({ t: "task", ok: false, error: error instanceof Error ? error.message : "task write refused" })): void => {
|
|
8162
8284
|
const operation = toolWriteOperation(msg);
|
|
@@ -9548,6 +9670,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
9548
9670
|
}
|
|
9549
9671
|
async function stopOnce(): Promise<void> {
|
|
9550
9672
|
stopping = true;
|
|
9673
|
+
controlWriteTelemetry?.stop();
|
|
9551
9674
|
roomCommands.stop();
|
|
9552
9675
|
tasks.stopQueuePressureRechecks();
|
|
9553
9676
|
const studiesStopped = tasks.stopStudies();
|
|
@@ -9647,6 +9770,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
9647
9770
|
bus.onQueues(); // initial restored holds are reported only after the listener is bound; writes status.json too
|
|
9648
9771
|
log(`${RUN_START}${process.pid} control=127.0.0.1:${server.port} cwd=${opts.cwd}`);
|
|
9649
9772
|
ready = true;
|
|
9773
|
+
controlWriteTelemetry?.start();
|
|
9650
9774
|
handoffCatchUp();
|
|
9651
9775
|
tasks.scheduleQueuePressureRecheck();
|
|
9652
9776
|
for (const name of opts.configWatch === false ? [] : CONFIG_SOURCE_FILES) {
|
package/src/hub/hub-tools.ts
CHANGED
|
@@ -122,7 +122,7 @@ export const TASK_TOOLS: HubTool[] = [
|
|
|
122
122
|
tool("hub_task_accept", "Take a task that was assigned to you, with your plan for it. Set refs.repo for work in another repository; acceptance may correct only that repository metadata.", { id, plan: acceptPlan, refs: { ...refs, properties: { repo: refs.properties.repo } } }, ["id"]),
|
|
123
123
|
tool("hub_task_cancel", "Cancel proposed/backlog work you proposed as its configured planner, including work an owner accepted and then declined while it has no execution, branch, commit or PR evidence; work with any such evidence is person-only. Keeps history and does not stop a native turn or delete files.", { id, reason: str, superseded_by: str, revision: { type: "integer", minimum: 0 } }, ["id", "reason"]),
|
|
124
124
|
tool("hub_task_decline", "Pass on a task assigned to you; the hub offers it to the next peer. A plain decline is a lasting refusal: you are never offered this task again. Pass capacity: true when you only have no room right now: you are skipped for this move only, and a later reroute or planner pull may offer it to you again.", { id, reason: str, capacity: { type: "boolean" } }, ["id"]),
|
|
125
|
-
tool("hub_task_done", "Mark your task finished. It goes to its reviewer with your summary and refs; no duplicate hub_send chat is needed. If a covering spec is unchanged, update it or provide spec_ack with a public reason for each unchanged spec. When the project configures a check for its class, the hub runs it first and the result comes as a task message: a failed check keeps the task with you.", { id, summary: { type: "string", description: "what changed, why, and the check you ran with its result" }, refs, deliverables: deliverablesSchema, spec_ack: specAck }, ["id", "summary"]),
|
|
125
|
+
tool("hub_task_done", "Mark your task finished. It goes to its reviewer with your summary and refs; no duplicate hub_send chat is needed. When re-filing work after review, set refs.commit to the new full SHA; summary text does not change the review head. If a covering spec is unchanged, update it or provide spec_ack with a public reason for each unchanged spec. When the project configures a check for its class, the hub runs it first and the result comes as a task message: a failed check keeps the task with you.", { id, summary: { type: "string", description: "what changed, why, and the check you ran with its result" }, refs, deliverables: deliverablesSchema, spec_ack: specAck }, ["id", "summary"]),
|
|
126
126
|
tool("hub_sdlc_record", "Record artifact ids or claimed local checks on your task. This grants no authority or external-check evidence. Only its owner/reviewer or the person may record; only its reviewer/person may name a reviewed head or phase checklist. Phase evidence refs are content hashes, not external verification.", { id, receipt: { type: "object", properties: { deliverables: deliverablesSchema, stage: { type: "string", enum: ["spec", "plan", "implement", "review", "verify", "merge", "release"] }, repo: str, issue: id, pr: id, localHead: str, localResult: { type: "string", enum: ["passed", "failed", "unknown"] }, reviewedHead: str, phase: { type: "object", properties: { id: str, head: str, tasks: { type: "array", maxItems: 64, items: { type: "object", properties: { id, after: { type: "array", maxItems: 16, items: id } }, required: ["id", "after"], additionalProperties: false } }, criteria: { type: "array", maxItems: 32, items: { type: "object", properties: { id: str, required: { type: "boolean" }, result: { type: "string", enum: ["verified", "failed", "unverified"] }, refs: { type: "array", maxItems: 8, items: str } }, required: ["id", "required", "result", "refs"], additionalProperties: false } } }, required: ["id", "tasks", "criteria"], additionalProperties: false } }, additionalProperties: false } }, ["id", "receipt"]),
|
|
127
127
|
tool("hub_task_list", "The task board, newest updated first, 50 compact rows (id, state, owner, reviewer, title) per call. The reply is {tasks, total, next?}: pass next as cursor for the following page. Use ids for full rows of specific tasks, or detail: true for full rows of a page. PII tasks show as [pii].", { state: { type: "string", enum: ["proposed", "in_progress", "in_review", "approved", "changes_requested", "cancelled"] }, ready: { type: "boolean", description: "only proposed tasks with nothing left to wait for" }, limit: { type: "integer", minimum: 1, maximum: 200, description: "rows per page, default 50" }, cursor: { type: "string", description: "the next value from the previous reply, unchanged" }, ids: { type: "array", minItems: 1, maxItems: 200, items: { type: "integer", minimum: 1 }, description: "full rows for exactly these task numbers; no paging" }, detail: { type: "boolean", description: "full rows instead of compact rows" } }),
|
|
128
128
|
tool("hub_specs", "Read the project's design-spec checks or find specs covering repository-relative paths. Read-only; at most 20 result items are returned. Does not reveal private task data.", { action: { type: "string", enum: ["check", "covers"] }, paths: { type: "array", maxItems: 32, items: { type: "string", minLength: 1, maxLength: 2000 } } }, ["action"]),
|
package/src/hub/routing.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { join, resolve } from "node:path";
|
|
|
7
7
|
import { CLASSES, type Task, type TaskClass } from "./board.ts";
|
|
8
8
|
import { isGitHubRepository, specRefs } from "./spec-read.ts";
|
|
9
9
|
|
|
10
|
-
export interface PeerCapabilities { tests: boolean; spec: boolean; testsReason?: string; specReason?: string; /** #1081: the peer's shell can reach the network (through the egress proxy or directly). Absent means unknown, never refused. */ network?: boolean; networkReason?: string; /** #1081: the peer can
|
|
10
|
+
export interface PeerCapabilities { tests: boolean; spec: boolean; testsReason?: string; specReason?: string; /** #1081: the peer's shell can reach the network (through the egress proxy or directly). Absent means unknown, never refused. */ network?: boolean; networkReason?: string; /** #1081: the peer can have the task worktree made for it: it makes its own, or (a hub sandbox, #270) the hub makes it (#1115) where it can. Absent means unknown, never refused. */ worktree?: boolean; worktreeReason?: string; }
|
|
11
11
|
import type { PeerId, PeerState } from "./envelope.ts";
|
|
12
12
|
|
|
13
13
|
import { parseHubRoutes, type HubRoute } from "../models/route/config.ts";
|
|
@@ -343,6 +343,12 @@ export function ownerRoleOfClass(cls: TaskClass): "planner" | "implementer" | "r
|
|
|
343
343
|
return cls === "review" ? "reviewer" : cls === "plan" ? "planner" : "implementer";
|
|
344
344
|
}
|
|
345
345
|
|
|
346
|
+
/**
|
|
347
|
+
* #1129: the classes whose owner changes the repository, so a hub-sandboxed owner needs the hub's task worktree and commit (#1115) whether or not the task declares
|
|
348
|
+
* `needs_worktree`. `test`, `summarize`, `triage`, `review` and `plan` read and report: they get none. One definition, so routing and `Tasks` cannot disagree.
|
|
349
|
+
*/
|
|
350
|
+
export const writesRepository = (cls: TaskClass): boolean => cls === "implement" || cls === "bulk_edit";
|
|
351
|
+
|
|
346
352
|
/** Why a planner was chosen: the person named it, rotation reached it, the task's recorded planner is kept, or there is none (PII, no holder). */
|
|
347
353
|
export type PlannerReason = "rotation" | "named" | "kept" | "none";
|
|
348
354
|
|
|
@@ -493,7 +499,8 @@ export function assign(
|
|
|
493
499
|
// #1081: work that declares it needs the network is never offered to a peer whose shell has none. Unknown (no `network` entry) is not a refusal.
|
|
494
500
|
if (task.signals.includes("needs_network") && caps?.network === false) return deny("network", `no network: ${caps.networkReason ?? "its shell cannot reach the network"}`);
|
|
495
501
|
// #1081: work that declares it needs a task worktree is never offered to a peer that cannot create one, so no branch ref is left behind by a refused accept.
|
|
496
|
-
|
|
502
|
+
// #1129: write-class work needs one whether or not it declares it, because a hub-sandboxed owner cannot commit and the hub commits in its worktree. Unknown capability is not a refusal.
|
|
503
|
+
if ((task.signals.includes("needs_worktree") || writesRepository(task.class)) && caps?.worktree === false) return deny("worktree", `no worktree: ${caps.worktreeReason ?? "it cannot create the task worktree"}`);
|
|
497
504
|
}
|
|
498
505
|
if (opts.offRoster?.includes(peer)) return deny("roster", "off roster");
|
|
499
506
|
const named = role === "owner" ? opts.namedOwner ?? reserved : opts.namedReviewer;
|
|
@@ -246,7 +246,10 @@ function knownLayout(root: string, directories: readonly string[], files: readon
|
|
|
246
246
|
}
|
|
247
247
|
}
|
|
248
248
|
function captureLayout(root: string): void {
|
|
249
|
-
knownLayout(root, ["src", "plugins", "templates", ".claude-plugin"], ["package.json", "README.md", "LICENSE", "CHANGELOG.md"], ["node_modules"]);
|
|
249
|
+
knownLayout(root, ["src", "plugins", "templates", ".claude-plugin"], ["package.json", "README.md", "LICENSE", "CHANGELOG.md"], ["node_modules", "scripts"]);
|
|
250
|
+
// #1120: captures made before the release checker was carried have no scripts directory; when present it holds exactly the checker.
|
|
251
|
+
// The name is repeated here on purpose: a pruner copied into a capture runs beside that capture's own verifier and imports nothing but verifyPackage from it. test/runtime-capture-checker.test.ts pins this name to RELEASE_CHECKER.
|
|
252
|
+
if (optionalInfo(join(root, "scripts"))) knownLayout(join(root, "scripts"), [], ["check-docs.mjs"]);
|
|
250
253
|
const modules = join(root, "node_modules");
|
|
251
254
|
if (optionalInfo(modules)) {
|
|
252
255
|
knownLayout(modules, ["@staix"], []); const scope = join(modules, "@staix"); knownLayout(scope, ["agent-mem"], []);
|
|
@@ -2,7 +2,7 @@ import { releaseTrackedClean } from "./release-worktree.ts";
|
|
|
2
2
|
import { createHash, randomUUID } from "node:crypto";
|
|
3
3
|
import { USER, type PeerId } from "./envelope.ts";
|
|
4
4
|
import { hubGitEnv, hubGitSync } from "./git.ts";
|
|
5
|
-
import {
|
|
5
|
+
import { readDocEvidenceDetailed, type DocEvidence, type ReleaseGit, type ReleaseReadiness } from "./sdlc-release.ts";
|
|
6
6
|
import type { SdlcGh } from "./sdlc.ts";
|
|
7
7
|
import type { ReleaseGuardRequest, ReleasePushUpdate } from "../cli/sdlc-release-guard.ts";
|
|
8
8
|
|
|
@@ -17,6 +17,8 @@ export interface ReleaseAuthorization { schemaVersion: 1; id: string; by: PeerId
|
|
|
17
17
|
export interface ReleaseAuthorizationPorts {
|
|
18
18
|
git?: ReleaseGit;
|
|
19
19
|
docs?: (root: string) => Promise<DocEvidence | undefined>;
|
|
20
|
+
/** Test seam: the checker program the default reader spawns (#1120). Never set from configuration. */
|
|
21
|
+
checker?: string;
|
|
20
22
|
gh?: SdlcGh;
|
|
21
23
|
now?: () => number;
|
|
22
24
|
current?: () => boolean;
|
|
@@ -46,6 +48,13 @@ export class ReleaseAuthorizations {
|
|
|
46
48
|
private latest?: Entry;
|
|
47
49
|
constructor(private readonly root: string, private readonly ports: ReleaseAuthorizationPorts) {}
|
|
48
50
|
private current(): void { if (this.ports.current?.() === false) throw new ReleaseAuthorizationError("release authorization unavailable during lifecycle transition"); }
|
|
51
|
+
/** #1120: a checker that is missing or failed is its own refusal; only evidence that is read and not fresh is "stale documentation". An injected reader keeps its undefined meaning (no evidence). */
|
|
52
|
+
private async docs(commit: string, version: string): Promise<ReleaseDocsVerdict | undefined> {
|
|
53
|
+
if (this.ports.docs) return docsVerdict(await this.ports.docs(this.root), commit, version);
|
|
54
|
+
const read = await readDocEvidenceDetailed(this.root, undefined, this.ports.checker);
|
|
55
|
+
if ("unavailable" in read) throw new ReleaseAuthorizationError(`release authorization needs the documentation checker: ${read.unavailable}`);
|
|
56
|
+
return docsVerdict(read.evidence, commit, version);
|
|
57
|
+
}
|
|
49
58
|
private actor(by: PeerId): void {
|
|
50
59
|
if (by !== USER && (typeof by !== "string" || !by.trim() || this.ports.conductor?.() !== by)) throw new ReleaseAuthorizationError("only the person or the configured conductor may authorize a release");
|
|
51
60
|
}
|
|
@@ -95,8 +104,8 @@ export class ReleaseAuthorizations {
|
|
|
95
104
|
this.current();
|
|
96
105
|
const git = this.git(), commit = this.commit(git, "HEAD"), origin = this.origin(git);
|
|
97
106
|
if (commit !== expectedCommit || this.version(git, commit) !== version) throw new ReleaseAuthorizationError("candidate commit or version differs from the person's request");
|
|
98
|
-
const docs =
|
|
99
|
-
if (!docs) throw new ReleaseAuthorizationError("release authorization requires fresh committed documentation evidence");
|
|
107
|
+
const docs = await this.docs(commit, version);
|
|
108
|
+
if (!docs) throw new ReleaseAuthorizationError("release authorization requires fresh committed documentation evidence: run `node scripts/check-docs.mjs --committed` in the candidate checkout, restamp the stale documents in a committed change, then authorize again");
|
|
100
109
|
this.current(); this.actor(by);
|
|
101
110
|
if (this.commit(git, "HEAD") !== commit || this.version(git, commit) !== version || this.origin(git).hash !== origin.hash) throw new ReleaseAuthorizationError("release candidate changed during authorization");
|
|
102
111
|
this.current(); this.actor(by);
|
|
@@ -127,7 +136,7 @@ export class ReleaseAuthorizations {
|
|
|
127
136
|
if (!releaseTrackedClean(this.root, git)) throw new ReleaseAuthorizationError("npm publish requires a clean tracked worktree");
|
|
128
137
|
const commit = this.commit(git, "HEAD"), version = this.version(git, commit);
|
|
129
138
|
const receipt = this.matched(git, version, commit);
|
|
130
|
-
const docs =
|
|
139
|
+
const docs = await this.docs(commit, version);
|
|
131
140
|
this.current();
|
|
132
141
|
if (!docs || docs.digest !== receipt.docs.digest || this.commit(git, "HEAD") !== commit || !releaseTrackedClean(this.root, git)) throw new ReleaseAuthorizationError("publish checkout or documentation evidence changed");
|
|
133
142
|
return { ok: true, receipts: [this.matched(git, version, commit)] };
|
package/src/hub/sdlc-release.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Task } from "./board.ts";
|
|
2
2
|
import type { Release } from "./planning.ts";
|
|
3
3
|
import { spawn } from "node:child_process";
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
4
5
|
import { fileURLToPath } from "node:url";
|
|
5
6
|
import { childEnv, stopOwnedProcess } from "./child-process.ts";
|
|
6
7
|
import { hubGitSync } from "./git.ts";
|
|
@@ -23,7 +24,7 @@ const sdlcGh: SdlcGh = async (...args) => {
|
|
|
23
24
|
export interface DocEvidence { schemaVersion: 1; head?: string; version?: string; documents: { path?: string; state: "fresh" | "stale" | "nonancestor" | "unknown"; verifiedAgainst?: string; commits?: number; dirty?: boolean }[]; errors: number; fresh: boolean; }
|
|
24
25
|
export interface ReleaseReadiness { /** #481 AC12: interval, approval-to-release lead time and hotfix rate over shipped releases, filled by Tasks.releaseReadiness. */ releaseMetrics?: ReturnType<typeof releaseMetrics>; repo: string; head?: string; version?: string; tag?: string; tagCommit?: string; commits: string[]; mergedPrs: { pr: number; commit: string }[]; documents?: DocEvidence; liveIssues: { id: number; state: "open" | "closed" | "unknown" }[]; blockers: string[]; sourceReady: boolean; native: "unverified"; ready: false; }
|
|
25
26
|
export type ReleaseGit = (args: string[]) => { code: number; stdout: string };
|
|
26
|
-
export interface ReleaseReaders { gh?: SdlcGh; git?: ReleaseGit; docs?: (root: string, timeoutMs: number) => Promise<DocEvidence | undefined>; }
|
|
27
|
+
export interface ReleaseReaders { gh?: SdlcGh; git?: ReleaseGit; docs?: (root: string, timeoutMs: number) => Promise<DocEvidence | undefined>; /** Test seam (#1120): the checker program the default docs reader spawns. */ checker?: string; }
|
|
27
28
|
const SHA = /^[0-9a-f]{40}$/;
|
|
28
29
|
const VERSION = /^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
29
30
|
const REPO = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
|
@@ -34,21 +35,31 @@ function tagMatches(target: unknown, commit: string): boolean {
|
|
|
34
35
|
return typeof oid === "string" && SHA.test(oid) && oid === commit;
|
|
35
36
|
}
|
|
36
37
|
/** Package-owned checker only; no caller-supplied program or document text is executed. */
|
|
37
|
-
|
|
38
|
+
/** #1120: why no evidence could be read, apart from evidence that says the documents are stale. */
|
|
39
|
+
export type DocEvidenceRead = { evidence: DocEvidence } | { unavailable: string };
|
|
40
|
+
const CHECKER_PATH = fileURLToPath(new URL("../../scripts/check-docs.mjs", import.meta.url));
|
|
41
|
+
const CHECKER_MISSING = "the documentation checker scripts/check-docs.mjs is missing from the running hub, which predates the fix that carries it into captured runtimes; record consent from a hub started from a checkout or a release that carries the checker (docs/sdlc.md, release preparation)";
|
|
42
|
+
const CHECKER_FAILED = "the documentation checker in the running hub did not finish with readable evidence (it timed out, crashed or printed unreadable output); run `node scripts/check-docs.mjs --json --committed` in the candidate checkout to see why, then retry";
|
|
43
|
+
export async function readDocEvidence(root: string, timeoutMs = 10_000): Promise<DocEvidence | undefined> {
|
|
44
|
+
const read = await readDocEvidenceDetailed(root, timeoutMs);
|
|
45
|
+
return "evidence" in read ? read.evidence : undefined;
|
|
46
|
+
}
|
|
47
|
+
export function readDocEvidenceDetailed(root: string, timeoutMs = 10_000, checker = CHECKER_PATH): Promise<DocEvidenceRead> {
|
|
48
|
+
if (!existsSync(checker)) return Promise.resolve({ unavailable: CHECKER_MISSING });
|
|
38
49
|
return new Promise(resolve => {
|
|
39
|
-
const child = spawn(process.execPath, [
|
|
50
|
+
const child = spawn(process.execPath, [checker, root, "--json", "--committed"], { cwd: root, env: childEnv(), detached: true, stdio: ["ignore", "pipe", "ignore"] });
|
|
40
51
|
let output = "", invalid = false;
|
|
41
52
|
const stop = () => { invalid = true; void stopOwnedProcess(child, { group: true }).catch(() => {}); };
|
|
42
53
|
const timer = setTimeout(stop, Math.max(1, Math.min(10_000, timeoutMs)));
|
|
43
54
|
child.stdout?.setEncoding("utf8"); child.stdout?.on("data", chunk => { output += chunk; if (Buffer.byteLength(output) > 256 * 1024) { output = ""; stop(); } });
|
|
44
|
-
child.once("error", () => { invalid = true; clearTimeout(timer); resolve(
|
|
55
|
+
child.once("error", () => { invalid = true; clearTimeout(timer); resolve({ unavailable: CHECKER_FAILED }); });
|
|
45
56
|
child.once("close", (code, signal) => {
|
|
46
|
-
clearTimeout(timer); if (invalid || signal || (code !== 0 && code !== 1)) return resolve(
|
|
57
|
+
clearTimeout(timer); if (invalid || signal || (code !== 0 && code !== 1)) return resolve({ unavailable: CHECKER_FAILED });
|
|
47
58
|
try {
|
|
48
59
|
const data = JSON.parse(output);
|
|
49
|
-
if (data.schemaVersion !== 1 || !Array.isArray(data.documents) || data.documents.length > 128 || !Number.isSafeInteger(data.errors) || data.errors < 0 || typeof data.fresh !== "boolean" || (data.head !== undefined && !SHA.test(data.head)) || (data.version !== undefined && !VERSION.test(data.version)) || data.documents.some((d: {state?:string}) => !["fresh","stale","nonancestor","unknown"].includes(d.state ?? ""))) return resolve(
|
|
50
|
-
resolve(data);
|
|
51
|
-
} catch { resolve(
|
|
60
|
+
if (data.schemaVersion !== 1 || !Array.isArray(data.documents) || data.documents.length > 128 || !Number.isSafeInteger(data.errors) || data.errors < 0 || typeof data.fresh !== "boolean" || (data.head !== undefined && !SHA.test(data.head)) || (data.version !== undefined && !VERSION.test(data.version)) || data.documents.some((d: {state?:string}) => !["fresh","stale","nonancestor","unknown"].includes(d.state ?? ""))) return resolve({ unavailable: CHECKER_FAILED });
|
|
61
|
+
resolve({ evidence: data });
|
|
62
|
+
} catch { resolve({ unavailable: CHECKER_FAILED }); }
|
|
52
63
|
});
|
|
53
64
|
});
|
|
54
65
|
}
|
|
@@ -101,8 +112,10 @@ export async function readReleaseReadiness(root: string, repo: string, liveIssue
|
|
|
101
112
|
} catch { report.blockers.push("merged PR coverage unknown or truncated"); break; }
|
|
102
113
|
}
|
|
103
114
|
}
|
|
104
|
-
const left = deadline - performance.now(); report.documents = left > 0 ? await (readers.docs ??
|
|
105
|
-
|
|
115
|
+
const left = deadline - performance.now(); report.documents = left > 0 ? await (readers.docs ?? ((dir, ms) => readDocEvidenceDetailed(dir, ms, readers.checker).then(read => "evidence" in read ? read.evidence : undefined)))(root,Math.min(10_000,left)) : undefined;
|
|
116
|
+
const checkerMissing = !readers.docs && !existsSync(readers.checker ?? CHECKER_PATH); // #1120: a hub without the checker says so instead of calling the documents stale
|
|
117
|
+
if (checkerMissing) report.blockers.push("documentation checker unavailable in the running hub: " + CHECKER_MISSING);
|
|
118
|
+
else if (!report.documents || report.documents.head !== head || !report.documents.fresh || report.documents.errors || report.documents.documents.some(d => d.state !== "fresh")) report.blockers.push("documentation freshness or ancestry is unknown or stale");
|
|
106
119
|
if (!liveIssues.length) report.blockers.push("native verification ledger unspecified");
|
|
107
120
|
for (const id of liveIssues) {
|
|
108
121
|
const reply = await gh(["issue","view",String(id),"--repo",repo,"--json","number,state"]); let state: "open"|"closed"|"unknown" = "unknown";
|