@staix/agent-hub 0.25.0 → 0.26.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 +10 -5
- package/docs/agent-notes/README.md +12 -0
- package/docs/agent-notes/adapters.md +1 -1
- package/docs/agent-notes/daemon.md +6 -4
- package/docs/agent-notes/tasks.md +5 -4
- package/docs/agent-notes/tests.md +1 -0
- package/docs/dogfood.md +4 -0
- package/docs/operations.md +131 -14
- package/docs/quickstart.md +14 -4
- package/docs/security.md +51 -8
- package/docs/specs/2026-11-01-research-campaigns-design.md +9 -6
- package/docs/verification/2026-11-01-0.26.0.md +54 -0
- package/docs/verified.json +28 -16
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +182 -95
- package/src/adapters/channel-peer.ts +4 -2
- package/src/adapters/definitions/builtin-metadata.ts +6 -6
- package/src/adapters/definitions/qwen.ts +2 -0
- package/src/adapters/registry-types.ts +2 -0
- package/src/adapters/registry.ts +1 -0
- package/src/cli/conductor-recovery.ts +3 -2
- package/src/cli/help.ts +7 -7
- package/src/cli/main.ts +62 -23
- package/src/cli/recovery-package.ts +94 -4
- package/src/cli/reset.ts +14 -4
- package/src/cli/runtime-version-warning.ts +28 -0
- package/src/cli/start-agents-run.ts +125 -0
- package/src/cli/task-output.ts +5 -0
- package/src/cli/upgrade-interactive.ts +4 -4
- package/src/cli/upgrade-runtime.ts +105 -57
- package/src/cli/upgrade.ts +90 -28
- package/src/hub/autonomy-defaults.ts +32 -7
- package/src/hub/board.ts +129 -13
- package/src/hub/budget.ts +3 -4
- package/src/hub/child-process.ts +4 -3
- package/src/hub/conductor.ts +3 -4
- package/src/hub/config-apply.ts +13 -2
- package/src/hub/config-edit-host.ts +1 -0
- package/src/hub/config-tier.ts +3 -0
- package/src/hub/config-trust.ts +10 -2
- package/src/hub/control-client.ts +30 -10
- package/src/hub/daemon.ts +82 -17
- package/src/hub/db-schema.ts +143 -0
- package/src/hub/delivery-journal.ts +3 -4
- package/src/hub/execution-budget.ts +3 -4
- package/src/hub/folder-trigger.ts +3 -4
- package/src/hub/invocations.ts +3 -4
- package/src/hub/lifecycle.ts +87 -29
- package/src/hub/manager.ts +49 -33
- package/src/hub/orca-env.ts +19 -0
- package/src/hub/project-runtime-state.ts +139 -0
- package/src/hub/project-runtime.ts +62 -0
- package/src/hub/protocol-window.ts +19 -0
- package/src/hub/recovery-store.ts +102 -12
- package/src/hub/registry.ts +5 -0
- package/src/hub/research-experiments.ts +33 -4
- package/src/hub/research-runner.ts +172 -0
- package/src/hub/restart.ts +9 -0
- package/src/hub/runtime-capture-prune.ts +415 -0
- package/src/hub/runtime-prune-health.ts +112 -0
- package/src/hub/schedules.ts +3 -4
- package/src/hub/sdlc-merge.ts +7 -0
- package/src/hub/sdlc-queue.ts +13 -1
- package/src/hub/settings.ts +4 -0
- package/src/hub/snapshots.ts +3 -4
- package/src/hub/start-agents.ts +44 -0
- package/src/hub/task-approval.ts +23 -0
- package/src/hub/tasks.ts +133 -34
- package/src/hub/usage-rollup.ts +3 -4
- package/src/pi/tool-receipts.ts +3 -4
package/src/cli/upgrade.ts
CHANGED
|
@@ -9,8 +9,8 @@ import { databaseBusySite, isDatabaseBusy, RecoveryDatabaseBusy } from "../hub/r
|
|
|
9
9
|
import { Database } from "bun:sqlite";
|
|
10
10
|
import { createHash, randomUUID } from "node:crypto";
|
|
11
11
|
import { existsSync, readFileSync } from "node:fs";
|
|
12
|
-
import { join } from "node:path";
|
|
13
|
-
import { REGISTRY_DB_BUSY_TIMEOUT_MS, PROJECT_COLUMNS, projectFromRow, type Project } from "../hub/registry.ts";
|
|
12
|
+
import { isAbsolute, join } from "node:path";
|
|
13
|
+
import { Registry, REGISTRY_DB_BUSY_TIMEOUT_MS, PROJECT_COLUMNS, projectFromRow, type Project } from "../hub/registry.ts";
|
|
14
14
|
import { hubHome } from "../hub/project.ts";
|
|
15
15
|
import { readRecoveryWaits } from "./recovery-waits.ts";
|
|
16
16
|
import { acquireRecoveryLock, claimRunner, coordinatorCurrent, operationIdPattern, readOperation, recoveryCommand, recoveryLock, releaseRecoveryLock, writeOperation } from "../hub/recovery-store.ts";
|
|
@@ -27,6 +27,7 @@ const projectDefinition = (peer: string, root?: string) => {
|
|
|
27
27
|
|
|
28
28
|
export interface RecoveryPeer {
|
|
29
29
|
id: string;
|
|
30
|
+
controlProtocol?: number;
|
|
30
31
|
/** Read-only source observation; never consumed as restoration arguments. */
|
|
31
32
|
observation?: { kind: string; attached: boolean; sessionDigest?: string };
|
|
32
33
|
/** Captured stable module/instance identity; never a portable current-owner proof. */
|
|
@@ -42,6 +43,8 @@ export interface Inspection {
|
|
|
42
43
|
instanceId?: string;
|
|
43
44
|
version?: string;
|
|
44
45
|
protocol?: number;
|
|
46
|
+
protocolMin?: number;
|
|
47
|
+
protocolMax?: number;
|
|
45
48
|
peers: RecoveryPeer[];
|
|
46
49
|
/** `waiting` (#272): what the running hub says keeps it from being quiet (busy peers, pending approvals); display only. */
|
|
47
50
|
recovery?: { operationId?: string; phase?: string; ready?: boolean; waiting?: string[] };
|
|
@@ -65,6 +68,8 @@ export interface PlannedProject {
|
|
|
65
68
|
}
|
|
66
69
|
export interface UpgradePlan {
|
|
67
70
|
schema: 1;
|
|
71
|
+
/** Present for new CLI operations; absent only on retained legacy machine-wide plans. */
|
|
72
|
+
scope?: { projectRoot: string };
|
|
68
73
|
kind: "restart" | "upgrade";
|
|
69
74
|
version: string;
|
|
70
75
|
integrity?: string;
|
|
@@ -122,7 +127,7 @@ export interface RecoveryOperation {
|
|
|
122
127
|
* #227: 2 marks a stop-and-archive recorded on an operation whose coordinator predates #215. Every older runner and
|
|
123
128
|
* abort refuses a schema other than 1 before it takes the lock or claims the runner, so none can advance it.
|
|
124
129
|
*/
|
|
125
|
-
schema: 1 | 2;
|
|
130
|
+
schema: 1 | 2 | 3;
|
|
126
131
|
id: string;
|
|
127
132
|
plan: UpgradePlan;
|
|
128
133
|
createdAt: number;
|
|
@@ -417,20 +422,47 @@ export function planFingerprint(plan: Omit<UpgradePlan, "fingerprint">): string
|
|
|
417
422
|
return createHash("sha256").update(JSON.stringify(stable)).digest("hex");
|
|
418
423
|
}
|
|
419
424
|
|
|
425
|
+
/** A scoped receipt cannot expand into a second project, even when read by a later coordinator. */
|
|
426
|
+
export function recoveryScope(plan: Pick<UpgradePlan, "scope" | "projects">): string | undefined {
|
|
427
|
+
if (plan.scope === undefined) return undefined;
|
|
428
|
+
const scope = plan.scope;
|
|
429
|
+
if (!scope || typeof scope !== "object" || Array.isArray(scope) || Object.keys(scope).length !== 1 || !Object.hasOwn(scope, "projectRoot")
|
|
430
|
+
|| typeof scope.projectRoot !== "string" || !isAbsolute(scope.projectRoot) || !Array.isArray(plan.projects)
|
|
431
|
+
|| plan.projects.length !== 1 || plan.projects[0]?.project.root !== scope.projectRoot)
|
|
432
|
+
throw new Error("reviewed project recovery scope is invalid");
|
|
433
|
+
return scope.projectRoot;
|
|
434
|
+
}
|
|
435
|
+
function sealScopedPlan(op: RecoveryOperation): void {
|
|
436
|
+
if (op.schema !== 3) return;
|
|
437
|
+
const seen = new WeakSet<object>();
|
|
438
|
+
const freeze = (value: unknown): void => {
|
|
439
|
+
if (!value || typeof value !== "object" || seen.has(value)) return;
|
|
440
|
+
seen.add(value);
|
|
441
|
+
for (const item of Object.values(value)) freeze(item);
|
|
442
|
+
Object.freeze(value);
|
|
443
|
+
};
|
|
444
|
+
freeze(op.plan);
|
|
445
|
+
Object.defineProperty(op, "plan", { value: op.plan, enumerable: true, writable: false, configurable: false });
|
|
446
|
+
}
|
|
447
|
+
|
|
420
448
|
export function createOperation(plan: UpgradePlan, sourceRoot: string, home = hubHome(), delegation?: RecoveryDelegation, operationId: string = randomUUID()): RecoveryOperation {
|
|
421
449
|
if (!operationIdPattern.test(operationId)) throw new Error("invalid recovery operation ID");
|
|
422
|
-
if (plan.blockers.length || plan.projects.some((p) => p.blockers.length)) throw new Error("upgrade plan has blockers; no runtimes were changed");
|
|
423
450
|
if (!plan.projects.length) throw new Error("no running projects to recover");
|
|
451
|
+
if (plan.blockers.length || plan.projects.some((p) => p.blockers.length)) throw new Error("upgrade plan has blockers; no runtimes were changed");
|
|
424
452
|
const { fingerprint, ...body } = plan;
|
|
425
453
|
if (planFingerprint(body) !== fingerprint) throw new Error("upgrade plan fingerprint is invalid");
|
|
454
|
+
const scope = recoveryScope(plan);
|
|
426
455
|
const id = operationId;
|
|
427
456
|
const op: RecoveryOperation = {
|
|
428
|
-
schema: 1, id, plan, sourceRoot, createdAt: Date.now(), updatedAt: Date.now(), phase: "pending", step: "stage",
|
|
457
|
+
schema: scope === undefined ? 1 : 3, id, plan, sourceRoot, createdAt: Date.now(), updatedAt: Date.now(), phase: "pending", step: "stage",
|
|
429
458
|
projects: plan.projects.map((p) => ({ id: p.project.id, phase: "pending", terminals: {} })),
|
|
430
459
|
};
|
|
431
460
|
if (delegation) appendRecoveryDelegation(op, delegation, op.createdAt);
|
|
432
|
-
|
|
433
|
-
|
|
461
|
+
sealScopedPlan(op);
|
|
462
|
+
// Publish the nonterminal receipt and its hold atomically with capture pruning's inventory.
|
|
463
|
+
const registry = new Registry(join(home, "registry.db"));
|
|
464
|
+
try { registry.runtimeCaptureLocked(() => { writeOperation(id, op, home); acquireRecoveryLock(id, home, scope); }); }
|
|
465
|
+
finally { registry.close(); }
|
|
434
466
|
return op;
|
|
435
467
|
}
|
|
436
468
|
|
|
@@ -488,7 +520,9 @@ function validateReceipt(id: string, op: RecoveryOperation, historical = false):
|
|
|
488
520
|
// Cleanup validates owned historical facts; this mode never authorizes restore, fresh or reattach.
|
|
489
521
|
const options = historical ? { authority: "historical" as const } : {};
|
|
490
522
|
const decode = (value: unknown) => decodeRestoreReceipt(value, options);
|
|
491
|
-
|
|
523
|
+
const scope = recoveryScope(op.plan);
|
|
524
|
+
if (!(scope === undefined ? op.schema === 1 || (op.schema === 2 && op.disposition) : op.schema === 3) || op.id !== id)
|
|
525
|
+
throw new Error("unsupported operation receipt");
|
|
492
526
|
validateRecoveryDelegations(op);
|
|
493
527
|
if (op.targetFeatures !== undefined && (!Array.isArray(op.targetFeatures) || !op.targetFeatures.every(feature => typeof feature === "string" && feature.length > 0) || !op.targetRoot || !op.targetDigest)) throw new Error("invalid target features");
|
|
494
528
|
const { fingerprint, ...reviewed } = op.plan;
|
|
@@ -584,13 +618,22 @@ async function authorizeRecoveryDriver(driver: RecoveryDriver, op: RecoveryOpera
|
|
|
584
618
|
await driver.authorize(op, phase);
|
|
585
619
|
}
|
|
586
620
|
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
621
|
+
function scopedRecoveryCurrent(operation: () => RecoveryOperation, scope: string | undefined, id: string, home: string): (() => void) | undefined {
|
|
622
|
+
if (scope === undefined) return undefined;
|
|
623
|
+
return () => {
|
|
624
|
+
if (recoveryScope(operation().plan) !== scope || recoveryLock(home, scope) !== id) throw new Error("project recovery scope or hold changed");
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/** Closed effect ports only. Scoped operations also fence their project hold; inspection stays read-only. */
|
|
629
|
+
export function guardedRecoveryDriver(driver: RecoveryDriver, operation: () => RecoveryOperation, current?: () => void): RecoveryDriver {
|
|
630
|
+
if (driver.conductorPeer === undefined && !current) return driver;
|
|
590
631
|
const guard = <A extends unknown[], R>(action: (...args: A) => Promise<R>) => async (...args: A): Promise<R> => {
|
|
632
|
+
current?.();
|
|
591
633
|
await authorizeRecoveryDriver(driver, operation(), "effect");
|
|
634
|
+
current?.();
|
|
592
635
|
try { return await action(...args); }
|
|
593
|
-
finally { await authorizeRecoveryDriver(driver, operation(), "effect"); }
|
|
636
|
+
finally { await authorizeRecoveryDriver(driver, operation(), "effect"); current?.(); }
|
|
594
637
|
};
|
|
595
638
|
return {
|
|
596
639
|
...driver,
|
|
@@ -619,14 +662,19 @@ export async function runRecovery(id: string, driver: RecoveryDriver, home = hub
|
|
|
619
662
|
// mutable phase. A concurrent runner may have completed the operation meanwhile.
|
|
620
663
|
let op = readOperation<RecoveryOperation>(id, home);
|
|
621
664
|
validateReceipt(id, op);
|
|
665
|
+
const scope = recoveryScope(op.plan);
|
|
666
|
+
sealScopedPlan(op);
|
|
622
667
|
if (driver.conductorPeer !== undefined) await authorizeRecoveryDriver(driver, op, "run");
|
|
623
|
-
|
|
668
|
+
const scopeCurrent = scopedRecoveryCurrent(() => op, scope, id, home);
|
|
669
|
+
acquireRecoveryLock(id, home, scope);
|
|
624
670
|
const releaseRunner = claimRunner(id, home);
|
|
625
671
|
try {
|
|
626
672
|
op = readOperation<RecoveryOperation>(id, home);
|
|
673
|
+
if (recoveryScope(op.plan) !== scope) throw new Error("project recovery scope changed while claiming its runner");
|
|
674
|
+
sealScopedPlan(op);
|
|
627
675
|
validateReceipt(id, op);
|
|
628
676
|
if (op.phase === "completed" || op.phase === "cancelled") {
|
|
629
|
-
if (recoveryLock(home) === id) releaseRecoveryLock(id, home);
|
|
677
|
+
if (recoveryLock(home, scope) === id) releaseRecoveryLock(id, home, scope);
|
|
630
678
|
releaseRunner();
|
|
631
679
|
return op;
|
|
632
680
|
}
|
|
@@ -636,7 +684,7 @@ export async function runRecovery(id: string, driver: RecoveryDriver, home = hub
|
|
|
636
684
|
writeOperation(id, op, home);
|
|
637
685
|
}
|
|
638
686
|
} catch (error) { releaseRunner(); throw error; }
|
|
639
|
-
driver = guardedRecoveryDriver(driver, () => op);
|
|
687
|
+
driver = guardedRecoveryDriver(driver, () => op, scopeCurrent);
|
|
640
688
|
let targetFeatures: readonly string[] = [];
|
|
641
689
|
const save = () => { op.updatedAt = driver.now(); writeOperation(id, op, home); };
|
|
642
690
|
const step = (value: string) => { op.step = value; save(); };
|
|
@@ -843,8 +891,8 @@ export async function runRecovery(id: string, driver: RecoveryDriver, home = hub
|
|
|
843
891
|
progress.phase = "peers-restored"; save();
|
|
844
892
|
}
|
|
845
893
|
}
|
|
846
|
-
//
|
|
847
|
-
if (op.plan.kind === "upgrade" && !op.pluginInstalled) {
|
|
894
|
+
// Legacy receipts keep their shared installation steps; project upgrades leave global tools alone.
|
|
895
|
+
if (scope === undefined && op.plan.kind === "upgrade" && !op.pluginInstalled) {
|
|
848
896
|
step("install-plugin"); await driver.installPlugin(op); op.pluginInstalled = true; save();
|
|
849
897
|
}
|
|
850
898
|
for (let i = 0; i < op.projects.length; i++) {
|
|
@@ -866,10 +914,10 @@ export async function runRecovery(id: string, driver: RecoveryDriver, home = hub
|
|
|
866
914
|
progress.phase = "verified"; save();
|
|
867
915
|
}
|
|
868
916
|
if (driver.refreshManager) { step("refresh-manager"); await driver.refreshManager(op); }
|
|
869
|
-
if (op.plan.kind === "upgrade" && !op.globalInstalled) {
|
|
917
|
+
if (scope === undefined && op.plan.kind === "upgrade" && !op.globalInstalled) {
|
|
870
918
|
step("install-global"); await driver.installGlobal(op); op.globalInstalled = true; save();
|
|
871
919
|
}
|
|
872
|
-
releaseRecoveryLock(id, home); op.phase = "completed"; step("completed");
|
|
920
|
+
releaseRecoveryLock(id, home, scope); op.phase = "completed"; step("completed");
|
|
873
921
|
} catch (error) {
|
|
874
922
|
// #215: every runner error ends with the choices status shows, read from every open source, never only this one.
|
|
875
923
|
const presentationError = error instanceof RecoveryPresentationError ? error
|
|
@@ -936,12 +984,18 @@ export async function reattachRecovery(id: string, peer: string, driver: Recover
|
|
|
936
984
|
return { progress, planned, marker, native };
|
|
937
985
|
};
|
|
938
986
|
selection(); // Finished or unrelated operations must not acquire a new lock merely to refuse.
|
|
939
|
-
|
|
987
|
+
const scope = recoveryScope(op.plan);
|
|
988
|
+
sealScopedPlan(op);
|
|
989
|
+
const scopeCurrent = scopedRecoveryCurrent(() => op, scope, id, home);
|
|
990
|
+
acquireRecoveryLock(id, home, scope);
|
|
940
991
|
const releaseRunner = claimRunner(id, home);
|
|
992
|
+
driver = guardedRecoveryDriver(driver, () => op, scopeCurrent);
|
|
941
993
|
let attempted = false;
|
|
942
994
|
const save = () => { op.updatedAt = driver.now(); writeOperation(id, op, home); };
|
|
943
995
|
try {
|
|
944
996
|
op = readOperation<RecoveryOperation>(id, home);
|
|
997
|
+
if (recoveryScope(op.plan) !== scope) throw new Error("project recovery scope changed while claiming its runner");
|
|
998
|
+
sealScopedPlan(op);
|
|
945
999
|
validateReceipt(id, op);
|
|
946
1000
|
const { progress, planned, marker, native } = selection();
|
|
947
1001
|
const expected = structuredClone(marker.binding);
|
|
@@ -985,16 +1039,21 @@ export async function reattachRecovery(id: string, peer: string, driver: Recover
|
|
|
985
1039
|
export async function abortRecovery(id: string, driver: RecoveryDriver, home = hubHome()): Promise<void> {
|
|
986
1040
|
let op = readOperation<RecoveryOperation>(id, home);
|
|
987
1041
|
validateReceipt(id, op, true);
|
|
1042
|
+
const scope = recoveryScope(op.plan);
|
|
1043
|
+
sealScopedPlan(op);
|
|
988
1044
|
if (driver.conductorPeer !== undefined) await authorizeRecoveryDriver(driver, op, "abort");
|
|
989
|
-
|
|
1045
|
+
const scopeCurrent = scopedRecoveryCurrent(() => op, scope, id, home);
|
|
1046
|
+
acquireRecoveryLock(id, home, scope);
|
|
990
1047
|
const releaseRunner = claimRunner(id, home);
|
|
991
1048
|
try {
|
|
992
1049
|
// Re-read after the exclusive runner claim so cancellation cannot act on a
|
|
993
1050
|
// stale pending/prepared receipt after another runner advanced it.
|
|
994
1051
|
op = readOperation<RecoveryOperation>(id, home);
|
|
1052
|
+
if (recoveryScope(op.plan) !== scope) throw new Error("project recovery scope changed while claiming its runner");
|
|
1053
|
+
sealScopedPlan(op);
|
|
995
1054
|
validateReceipt(id, op, true);
|
|
996
1055
|
if (op.phase === "completed" || op.phase === "cancelled") {
|
|
997
|
-
if (recoveryLock(home) === id) releaseRecoveryLock(id, home);
|
|
1056
|
+
if (recoveryLock(home, scope) === id) releaseRecoveryLock(id, home, scope);
|
|
998
1057
|
return;
|
|
999
1058
|
}
|
|
1000
1059
|
if (driver.conductorPeer !== undefined) await authorizeRecoveryDriver(driver, op, "abort");
|
|
@@ -1002,7 +1061,7 @@ export async function abortRecovery(id: string, driver: RecoveryDriver, home = h
|
|
|
1002
1061
|
appendRecoveryDelegation(op, { peer: (driver.conductorPeer ?? driver.liveTestPeer)!, command: "recovery abort" }, driver.now());
|
|
1003
1062
|
writeOperation(id, op, home);
|
|
1004
1063
|
}
|
|
1005
|
-
driver = guardedRecoveryDriver(driver, () => op);
|
|
1064
|
+
driver = guardedRecoveryDriver(driver, () => op, scopeCurrent);
|
|
1006
1065
|
if (abandoned(op)) writeOperation(id, op, home);
|
|
1007
1066
|
const { fingerprint, ...body } = op.plan;
|
|
1008
1067
|
if (fingerprint !== planFingerprint(body)) throw new Error("operation plan changed");
|
|
@@ -1017,7 +1076,7 @@ export async function abortRecovery(id: string, driver: RecoveryDriver, home = h
|
|
|
1017
1076
|
}
|
|
1018
1077
|
delete op.error; delete op.failure; delete op.blockedReattachment; delete op.presentationCause;
|
|
1019
1078
|
op.phase = "cancelled"; op.step = "cancelled"; op.updatedAt = driver.now();
|
|
1020
|
-
writeOperation(id, op, home); releaseRecoveryLock(id, home);
|
|
1079
|
+
writeOperation(id, op, home); releaseRecoveryLock(id, home, scope);
|
|
1021
1080
|
} catch (error) {
|
|
1022
1081
|
if (isDatabaseBusy(error)) throw new RecoveryDatabaseBusy();
|
|
1023
1082
|
throw error;
|
|
@@ -1037,15 +1096,18 @@ export async function disposeRecovery(id: string, choice: { fresh: string } | {
|
|
|
1037
1096
|
if (driver.liveTestPeer && "fresh" in choice && repairedFreshSession(op, choice.fresh) !== false) throw new Error(LIVE_TEST_PERSON_REFUSAL);
|
|
1038
1097
|
// A finished operation owns no lock: never take one only to refuse.
|
|
1039
1098
|
if (op.phase === "completed" || op.phase === "cancelled") throw new Error(`operation is already ${op.phase}`);
|
|
1040
|
-
|
|
1099
|
+
const scope = recoveryScope(op.plan);
|
|
1100
|
+
sealScopedPlan(op);
|
|
1101
|
+
const scopeCurrent = scopedRecoveryCurrent(() => op, scope, id, home);
|
|
1102
|
+
acquireRecoveryLock(id, home, scope);
|
|
1041
1103
|
const releaseRunner = claimRunner(id, home);
|
|
1042
1104
|
try {
|
|
1043
|
-
try { op = readOperation<RecoveryOperation>(id, home); validateReceipt(id, op, "stop" in choice); }
|
|
1105
|
+
try { op = readOperation<RecoveryOperation>(id, home); validateReceipt(id, op, "stop" in choice); if (recoveryScope(op.plan) !== scope) throw new Error("project recovery scope changed while claiming its runner"); sealScopedPlan(op); }
|
|
1044
1106
|
catch (error) { if (driver.liveTestPeer && "fresh" in choice) throw new Error(LIVE_TEST_PERSON_REFUSAL); throw error; }
|
|
1045
1107
|
if (driver.liveTestPeer && "fresh" in choice && repairedFreshSession(op, choice.fresh) !== false) throw new Error(LIVE_TEST_PERSON_REFUSAL);
|
|
1046
1108
|
if (op.phase === "completed" || op.phase === "cancelled") {
|
|
1047
1109
|
// Another runner finished it while this one waited: leave no lock behind, as resume and abort do.
|
|
1048
|
-
if (recoveryLock(home) === id) releaseRecoveryLock(id, home);
|
|
1110
|
+
if (recoveryLock(home, scope) === id) releaseRecoveryLock(id, home, scope);
|
|
1049
1111
|
throw new Error(`operation is already ${op.phase}`);
|
|
1050
1112
|
}
|
|
1051
1113
|
if (driver.liveTestPeer) {
|
|
@@ -1155,7 +1217,7 @@ export async function disposeRecovery(id: string, choice: { fresh: string } | {
|
|
|
1155
1217
|
}
|
|
1156
1218
|
delete op.error; delete op.failure;
|
|
1157
1219
|
op.phase = "cancelled"; op.step = "disposed: stop-and-archive (abandoned, not completed)"; op.updatedAt = driver.now();
|
|
1158
|
-
writeOperation(id, op, home); releaseRecoveryLock(id, home);
|
|
1220
|
+
writeOperation(id, op, home); releaseRecoveryLock(id, home, scope);
|
|
1159
1221
|
return op;
|
|
1160
1222
|
} finally { releaseRunner(); }
|
|
1161
1223
|
}
|
|
@@ -3,8 +3,10 @@ import { closeSync, constants, fstatSync, fsyncSync, lstatSync, mkdirSync, openS
|
|
|
3
3
|
import { isAbsolute, join, relative } from "node:path";
|
|
4
4
|
import { canonicalPath } from "./project.ts";
|
|
5
5
|
import { insideRoot } from "./path-policy.ts";
|
|
6
|
+
import { checkStartAgents } from "./start-agents.ts";
|
|
6
7
|
|
|
7
|
-
|
|
8
|
+
/** `startAgents` is the machine default of `start.agents` (#608), present only when `ahub setup` chose one. */
|
|
9
|
+
export interface AutonomyDefaultsRead { enabled: boolean; fingerprint: string; notice?: string; startAgents?: string[] }
|
|
8
10
|
const MAX_BYTES = 4096;
|
|
9
11
|
const same = (a: Stats, b: Stats) => a.dev === b.dev && a.ino === b.ino && a.size === b.size && a.mtimeMs === b.mtimeMs && a.ctimeMs === b.ctimeMs;
|
|
10
12
|
const refusal = () => new Error("machine autonomy defaults are unsafe or changed; nothing was written");
|
|
@@ -30,26 +32,49 @@ function capture(home: string, roots: readonly string[]): { stat?: Stats; text?:
|
|
|
30
32
|
return { stat: after, text, fingerprint: createHash("sha256").update(`${after.dev}:${after.ino}:${after.ctimeMs}:`).update(text).digest("hex") };
|
|
31
33
|
} finally { closeSync(fd); }
|
|
32
34
|
}
|
|
35
|
+
interface DefaultsDocument { autonomy?: boolean; startAgents?: string[] }
|
|
36
|
+
/** The file's content, or the refusal. `{schema:1}` alone and any key but schema, autonomy and start are refused. */
|
|
37
|
+
function parseDefaults(text: string): DefaultsDocument {
|
|
38
|
+
const raw: unknown = JSON.parse(text);
|
|
39
|
+
const document = raw && typeof raw === "object" && !Array.isArray(raw) ? raw as Record<string, unknown> : undefined;
|
|
40
|
+
if (!document || Object.keys(document).some(key => !["schema", "autonomy", "start"].includes(key)) || document.schema !== 1) throw refusal();
|
|
41
|
+
if (document.autonomy !== undefined && typeof document.autonomy !== "boolean") throw refusal();
|
|
42
|
+
let startAgents: string[] | undefined;
|
|
43
|
+
if (document.start !== undefined) {
|
|
44
|
+
const start = document.start;
|
|
45
|
+
if (!start || typeof start !== "object" || Array.isArray(start) || Object.keys(start).some(key => key !== "agents")) throw refusal();
|
|
46
|
+
try { startAgents = checkStartAgents((start as Record<string, unknown>).agents); } catch { throw refusal(); }
|
|
47
|
+
}
|
|
48
|
+
if (document.autonomy === undefined && startAgents === undefined) throw refusal();
|
|
49
|
+
return { ...(document.autonomy === undefined ? {} : { autonomy: document.autonomy }), ...(startAgents ? { startAgents } : {}) };
|
|
50
|
+
}
|
|
33
51
|
/** Missing or untrusted machine files never change the shipped ask default. */
|
|
34
52
|
export function readAutonomyDefaults(home: string, roots: readonly string[] = []): AutonomyDefaultsRead {
|
|
35
53
|
try {
|
|
36
54
|
const held = capture(home, roots);
|
|
37
55
|
if (held.text === undefined) return { enabled: false, fingerprint: held.fingerprint };
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
if (!document || Object.keys(document).some(key => !["schema", "autonomy"].includes(key)) || document.schema !== 1 || typeof document.autonomy !== "boolean") throw refusal();
|
|
41
|
-
return { enabled: document.autonomy, fingerprint: held.fingerprint };
|
|
56
|
+
const document = parseDefaults(held.text);
|
|
57
|
+
return { enabled: document.autonomy === true, fingerprint: held.fingerprint, ...(document.startAgents ? { startAgents: document.startAgents } : {}) };
|
|
42
58
|
} catch { return { enabled: false, fingerprint: "untrusted", notice: "Machine autonomy defaults ignored: defaults.json must be an owner-only regular file with a valid schema, outside project roots." }; }
|
|
43
59
|
}
|
|
44
|
-
/** Person CLI only. Exact prior evidence is rechecked after confirmation and before replacement. */
|
|
60
|
+
/** Person CLI only. Exact prior evidence is rechecked after confirmation and before replacement. The other half of the file (the start set) is kept. */
|
|
45
61
|
export function writeAutonomyDefaults(home: string, enabled: boolean, expected: string, roots: readonly string[] = []): void {
|
|
62
|
+
writeDefaults(home, expected, roots, kept => ({ schema: 1, autonomy: enabled, ...(kept.startAgents ? { start: { agents: kept.startAgents } } : {}) }));
|
|
63
|
+
}
|
|
64
|
+
/** Person CLI only (`ahub setup`): the machine default of `start.agents`. The autonomy half of the file is kept, and absent stays absent. */
|
|
65
|
+
export function writeMachineStartAgents(home: string, agents: readonly string[], expected: string, roots: readonly string[] = []): void {
|
|
66
|
+
const checked = checkStartAgents([...agents]);
|
|
67
|
+
writeDefaults(home, expected, roots, kept => ({ schema: 1, ...(kept.autonomy === undefined ? {} : { autonomy: kept.autonomy }), start: { agents: checked } }));
|
|
68
|
+
}
|
|
69
|
+
function writeDefaults(home: string, expected: string, roots: readonly string[], build: (kept: DefaultsDocument) => Record<string, unknown>): void {
|
|
46
70
|
const before = capture(home, roots);
|
|
47
71
|
if (before.fingerprint !== expected) throw refusal();
|
|
72
|
+
const kept = before.text === undefined ? {} : parseDefaults(before.text);
|
|
48
73
|
const parent = location(home, roots, true)!;
|
|
49
74
|
const file = join(home, "defaults.json"), temporary = join(home, `.defaults.${randomUUID()}.tmp`);
|
|
50
75
|
try {
|
|
51
76
|
const fd = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
52
|
-
try { writeFileSync(fd, `${JSON.stringify(
|
|
77
|
+
try { writeFileSync(fd, `${JSON.stringify(build(kept), null, 2)}\n`); fsyncSync(fd); }
|
|
53
78
|
finally { closeSync(fd); }
|
|
54
79
|
const current = lstatSync(home);
|
|
55
80
|
if (current.dev !== parent.dev || current.ino !== parent.ino || capture(home, roots).fingerprint !== expected) throw refusal();
|
package/src/hub/board.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { parseAttemptReceipt, parseResearchRunners, verifyAttemptReceipt, type AttemptReceiptV1 } from "./research-runner.ts";
|
|
1
2
|
import { canonicalJson } from "./packs.ts";
|
|
2
|
-
import { parseComparisonPlan, comparisonPlanHash, evaluatorIdentityHash, parseExperimentProtocol, protocolHash, parseExperimentAttempt, parseExperimentEvent, parseExperimentHostEvidence, experimentStatus, projectExperimentWithObservations,
|
|
3
|
+
import { parseComparisonPlan, comparisonPlanHash, evaluatorIdentityHash, parseExperimentProtocol, protocolHash, parseExperimentAttempt, parseExperimentEvent, parseExperimentHostEvidence, experimentStatus, projectExperimentWithObservations, declaredExperimentEvents,
|
|
3
4
|
type ComparisonPlanV1, type ExperimentProtocolRecord, type ExperimentAttemptRecord, type ExperimentEventRecord, type ExperimentWriteFence, type DeclaredExperimentEventInput, type ExperimentHostEvidence, type ExperimentStatus } from "./research-experiments.ts";
|
|
4
5
|
import type { InvocationRow } from "./invocations.ts";
|
|
5
6
|
import { HOST_CODE_SYMBOLS, parseTaskCodeSymbols, type TaskCodeSymbols } from "./task-code-symbols.ts";
|
|
@@ -8,11 +9,11 @@ import { planFingerprint } from "./plan-conformance.ts";
|
|
|
8
9
|
import type { PlannerReason } from "./routing.ts";
|
|
9
10
|
import type { ReviewerPoolReason } from "./reviewer-pool.ts";
|
|
10
11
|
import type { GitHubSource } from "./github-import.ts";
|
|
11
|
-
import { Database } from "
|
|
12
|
+
import type { Database } from "./db-schema.ts";
|
|
13
|
+
import { openHubDatabase, advanceHubDbSchema } from "./db-schema.ts";
|
|
12
14
|
import { planningPatch, type PlanningFields, type Release, type ReleaseState } from "./planning.ts";
|
|
13
15
|
import { HUB, USER, type PeerId } from "./envelope.ts";
|
|
14
16
|
import type { SweepRecord } from "./task-sweep.ts";
|
|
15
|
-
import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
|
|
16
17
|
import type { SdlcReceipt } from "./sdlc.ts";
|
|
17
18
|
import { parseReviewFindings, type ReviewFindingSet } from "./review-findings.ts";
|
|
18
19
|
import { parseDeliverableRecords, type DeliverableRecord } from "./deliverables.ts";
|
|
@@ -107,6 +108,12 @@ const EXPERIMENT_PLAN_SELECT = "SELECT rowid AS row_id,hash,project,campaign_id,
|
|
|
107
108
|
interface ExperimentProtocolRow { hash: string; project: string; campaign_id: string; campaign_revision: number; task_id: number; body: string | null; by_peer: string; at: number; private: number }
|
|
108
109
|
interface ExperimentAttemptRow { row_id?: number; attempt_id: string; project: string; campaign_id: string; campaign_revision: number; protocol_hash: string; task_id: number; idempotency_key: string; body: string | null; body_sha256: string; by_peer: string; at: number; private: number }
|
|
109
110
|
interface ExperimentEventRow { attempt_id: string; project: string; seq: number; idempotency_key: string; kind: string; body: string | null; body_sha256: string; by_peer: string; at: number; private: number }
|
|
111
|
+
export interface RunnerReceiptRecord {
|
|
112
|
+
receipt: AttemptReceiptV1; signature: string; publicKey: string; keyHash: string; bodyHash: string;
|
|
113
|
+
seq: number; by: PeerId; at: number; private: boolean;
|
|
114
|
+
}
|
|
115
|
+
interface RunnerReceiptRow { receipt_id: string; project: string; attempt_id: string; event_seq: number; body: string | null; body_sha256: string; by_peer: string; at: number; private: number }
|
|
116
|
+
const RUNNER_RECEIPT_SELECT = "SELECT receipt_id,project,attempt_id,event_seq,CASE WHEN length(CAST(body AS BLOB))<=131072 THEN body END AS body,body_sha256,by_peer,at,private FROM research_runner_receipts";
|
|
110
117
|
interface ExperimentObservationRow { attempt_id: string; event_seq: number; body: string | null; body_sha256: string }
|
|
111
118
|
const EXPERIMENT_PROTOCOL_SELECT = "SELECT hash,project,campaign_id,campaign_revision,task_id,CASE WHEN length(CAST(body AS BLOB))<=65536 THEN body END AS body,by_peer,at,private FROM research_protocols";
|
|
112
119
|
const EXPERIMENT_ATTEMPT_SELECT = "SELECT rowid AS row_id,attempt_id,project,campaign_id,campaign_revision,protocol_hash,task_id,idempotency_key,CASE WHEN length(CAST(body AS BLOB))<=65536 THEN body END AS body,body_sha256,by_peer,at,private FROM research_attempts";
|
|
@@ -212,6 +219,8 @@ export interface HistoryEntry {
|
|
|
212
219
|
evidenceOnly?: true;
|
|
213
220
|
/** Structured review report bound to the reviewed artifact, stored in the existing history JSON. */
|
|
214
221
|
reviewFindings?: ReviewFindingSet;
|
|
222
|
+
/** Immutable artifact head of this verdict, including when lifecycle gates are off. */
|
|
223
|
+
reviewedHead?: string;
|
|
215
224
|
/**
|
|
216
225
|
* #509a: the files a `done`, a lifecycle record or an approval bound, with the content hashes the hub computed. An approval carries the hashes
|
|
217
226
|
* it was given at review time, so a later edit reads as "deliverable changed since review". Full paths live here; every view goes through
|
|
@@ -403,8 +412,7 @@ export class Board {
|
|
|
403
412
|
}
|
|
404
413
|
|
|
405
414
|
constructor(path: string) {
|
|
406
|
-
this.db =
|
|
407
|
-
this.db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
|
|
415
|
+
this.db = openHubDatabase(path, { create: true });
|
|
408
416
|
this.db.run("PRAGMA journal_mode = WAL");
|
|
409
417
|
this.db.run(`CREATE TABLE IF NOT EXISTS tasks (
|
|
410
418
|
id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, detail TEXT NOT NULL DEFAULT '', class TEXT NOT NULL,
|
|
@@ -505,6 +513,10 @@ export class Board {
|
|
|
505
513
|
this.db.run(`CREATE TABLE IF NOT EXISTS research_attempt_observations (
|
|
506
514
|
attempt_id TEXT NOT NULL, event_seq INTEGER NOT NULL, body TEXT NOT NULL, body_sha256 TEXT NOT NULL,
|
|
507
515
|
PRIMARY KEY(attempt_id,event_seq))`);
|
|
516
|
+
this.db.run(`CREATE TABLE IF NOT EXISTS research_runner_receipts (
|
|
517
|
+
receipt_id TEXT PRIMARY KEY, project TEXT NOT NULL, attempt_id TEXT NOT NULL, event_seq INTEGER NOT NULL,
|
|
518
|
+
body TEXT NOT NULL, body_sha256 TEXT NOT NULL, by_peer TEXT NOT NULL, at INTEGER NOT NULL, private INTEGER NOT NULL,
|
|
519
|
+
UNIQUE(attempt_id,event_seq))`);
|
|
508
520
|
this.db.run(`CREATE TABLE IF NOT EXISTS research_plans (
|
|
509
521
|
hash TEXT PRIMARY KEY, project TEXT NOT NULL, campaign_id TEXT NOT NULL, campaign_revision INTEGER NOT NULL,
|
|
510
522
|
task_id INTEGER NOT NULL, body TEXT NOT NULL, by_peer TEXT NOT NULL, at INTEGER NOT NULL, private INTEGER NOT NULL)`);
|
|
@@ -867,7 +879,7 @@ export class Board {
|
|
|
867
879
|
}
|
|
868
880
|
|
|
869
881
|
hasResearchState(): boolean {
|
|
870
|
-
return (this.db.query("SELECT EXISTS(SELECT 1 FROM research_campaigns) OR EXISTS(SELECT 1 FROM research_units) OR EXISTS(SELECT 1 FROM research_protocols) OR EXISTS(SELECT 1 FROM research_attempts) OR EXISTS(SELECT 1 FROM research_attempt_events) OR EXISTS(SELECT 1 FROM research_attempt_observations) AS present").get() as { present: number }).present === 1;
|
|
882
|
+
return (this.db.query("SELECT EXISTS(SELECT 1 FROM research_campaigns) OR EXISTS(SELECT 1 FROM research_units) OR EXISTS(SELECT 1 FROM research_protocols) OR EXISTS(SELECT 1 FROM research_attempts) OR EXISTS(SELECT 1 FROM research_attempt_events) OR EXISTS(SELECT 1 FROM research_attempt_observations) OR EXISTS(SELECT 1 FROM research_runner_receipts) AS present").get() as { present: number }).present === 1;
|
|
871
883
|
}
|
|
872
884
|
|
|
873
885
|
/** A consistent, streaming digest includes metadata and body hashes, not merely row counts. */
|
|
@@ -1104,6 +1116,15 @@ export class Board {
|
|
|
1104
1116
|
if (typeof row.body !== "string" || !researchInteger(row.seq) || !researchInteger(row.at, 0) || !researchText(row.by_peer) || ![0, 1].includes(row.private) || !researchText(row.idempotency_key, 64)) throw experimentFailure();
|
|
1105
1117
|
const attempt = this.experimentAttempt(row.attempt_id), protocol = attempt && this.experimentProtocol(attempt.attempt.protocolHash);
|
|
1106
1118
|
if (!attempt || !protocol || row.project !== attempt.project) throw experimentFailure();
|
|
1119
|
+
if (row.kind === "attestation") {
|
|
1120
|
+
const raw = JSON.parse(row.body), body = raw?.body, receipt = typeof body?.receiptId === "string" && this.runnerReceipt(body.receiptId);
|
|
1121
|
+
if (!receipt || raw.kind !== "attestation" || Object.keys(raw).length !== 2 || Object.keys(body).length !== 1 || receipt.receipt.attemptId !== row.attempt_id || receipt.seq !== row.seq || receipt.at !== row.at
|
|
1122
|
+
|| receipt.by !== row.by_peer || row.body_sha256 !== experimentHash({ kind: "attestation", body }) || row.idempotency_key !== `rr-${receipt.bodyHash.slice(0, 61)}`
|
|
1123
|
+
|| this.db.query("SELECT 1 FROM research_attempt_observations WHERE attempt_id=? AND event_seq=?").get(row.attempt_id, row.seq)) throw experimentFailure();
|
|
1124
|
+
return { attemptId: row.attempt_id, idempotencyKey: row.idempotency_key,
|
|
1125
|
+
event: { kind: "attestation", body: { receiptId: receipt.receipt.receiptId }, seq: row.seq, at: row.at, provenance: "runner-attested" },
|
|
1126
|
+
bodyHash: row.body_sha256, by: row.by_peer, private: row.private === 1 || attempt.private || receipt.private, observations: [] };
|
|
1127
|
+
}
|
|
1107
1128
|
const input = parseExperimentEvent(JSON.parse(row.body), protocol.protocol);
|
|
1108
1129
|
if (input.kind !== row.kind || experimentHash(input) !== row.body_sha256) throw experimentFailure();
|
|
1109
1130
|
const event = { ...input, seq: row.seq, at: row.at, provenance: "declared" as const };
|
|
@@ -1147,6 +1168,87 @@ export class Board {
|
|
|
1147
1168
|
});
|
|
1148
1169
|
}
|
|
1149
1170
|
|
|
1171
|
+
private runnerReceiptFromRow(row: RunnerReceiptRow): RunnerReceiptRecord {
|
|
1172
|
+
try {
|
|
1173
|
+
if (typeof row.body !== "string" || !researchInteger(row.event_seq) || !researchInteger(row.at, 0) || !researchText(row.by_peer) || ![0, 1].includes(row.private)) throw experimentFailure();
|
|
1174
|
+
const raw = JSON.parse(row.body);
|
|
1175
|
+
if (!raw || Object.keys(raw).sort().join(",") !== "publicKey,receipt,signature") throw experimentFailure();
|
|
1176
|
+
const receipt = parseAttemptReceipt(raw.receipt);
|
|
1177
|
+
const runner = parseResearchRunners({ [receipt.runner]: { publicKey: raw.publicKey, projects: [receipt.projectId], revoked: false } })[receipt.runner]!;
|
|
1178
|
+
if (typeof raw.signature !== "string" || raw.signature.length !== 88 || Buffer.from(raw.signature, "base64").length !== 64
|
|
1179
|
+
|| Buffer.from(raw.signature, "base64").toString("base64") !== raw.signature) throw experimentFailure();
|
|
1180
|
+
const body = { receipt, signature: raw.signature as string, publicKey: runner.publicKey };
|
|
1181
|
+
const attempt = this.experimentAttempt(receipt.attemptId), protocol = attempt && this.experimentProtocol(attempt.attempt.protocolHash);
|
|
1182
|
+
if (!attempt || !protocol || row.receipt_id !== receipt.receiptId || row.attempt_id !== receipt.attemptId || row.project !== receipt.projectId || row.project !== attempt.project
|
|
1183
|
+
|| receipt.protocolHash !== attempt.attempt.protocolHash || experimentHash(body) !== row.body_sha256
|
|
1184
|
+
|| receipt.codeCommit !== undefined && "commit" in protocol.protocol.code && receipt.codeCommit !== protocol.protocol.code.commit) throw experimentFailure();
|
|
1185
|
+
const event = this.db.query(`${EXPERIMENT_EVENT_SELECT} WHERE attempt_id=? AND seq=?`).get(row.attempt_id, row.event_seq) as ExperimentEventRow | null;
|
|
1186
|
+
const expectedEvent = { kind: "attestation", body: { receiptId: receipt.receiptId } };
|
|
1187
|
+
if (!event || event.kind !== "attestation" || typeof event.body !== "string" || event.body !== canonicalJson(expectedEvent)
|
|
1188
|
+
|| event.body_sha256 !== experimentHash(expectedEvent) || event.project !== row.project || event.by_peer !== row.by_peer || event.at !== row.at
|
|
1189
|
+
|| event.idempotency_key !== `rr-${row.body_sha256.slice(0, 61)}` || ![0, 1].includes(event.private)
|
|
1190
|
+
|| this.db.query("SELECT 1 FROM research_attempt_observations WHERE attempt_id=? AND event_seq=?").get(row.attempt_id, row.event_seq)) throw experimentFailure();
|
|
1191
|
+
return { ...body, keyHash: createHash("sha256").update(Buffer.from(runner.publicKey, "base64")).digest("hex"), bodyHash: row.body_sha256,
|
|
1192
|
+
seq: row.event_seq, by: row.by_peer, at: row.at, private: row.private === 1 || event.private === 1 || attempt.private };
|
|
1193
|
+
} catch { throw experimentFailure(); }
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
runnerReceipt(id: string): RunnerReceiptRecord | undefined {
|
|
1197
|
+
const row = this.db.query(`${RUNNER_RECEIPT_SELECT} WHERE receipt_id=?`).get(id) as RunnerReceiptRow | null;
|
|
1198
|
+
return row ? this.runnerReceiptFromRow(row) : undefined;
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
runnerReceipts(attemptId: string): RunnerReceiptRecord[] {
|
|
1202
|
+
if (!this.experimentAttempt(attemptId)) throw experimentFailure();
|
|
1203
|
+
// ponytail: at most 64 signed receipts per attempt; paginate only with a reviewed evidence-page contract.
|
|
1204
|
+
const rows = this.db.query(`${RUNNER_RECEIPT_SELECT} WHERE attempt_id=? ORDER BY event_seq LIMIT 65`).all(attemptId) as RunnerReceiptRow[];
|
|
1205
|
+
if (rows.length > 64) throw experimentFailure();
|
|
1206
|
+
return rows.map(row => this.runnerReceiptFromRow(row));
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
appendRunnerReceipt(record: Pick<RunnerReceiptRecord, "receipt" | "signature" | "publicKey" | "by" | "at" | "private">, expectedLastSeq: number, fence: ExperimentWriteFence): RunnerReceiptRecord {
|
|
1210
|
+
const receipt = parseAttemptReceipt(record.receipt), attempt = this.experimentAttempt(receipt.attemptId), protocol = attempt && this.experimentProtocol(attempt.attempt.protocolHash);
|
|
1211
|
+
if (!attempt || !protocol || record.by !== fence.owner && record.by !== USER || !researchInteger(record.at, 0) || typeof record.private !== "boolean" || !researchInteger(expectedLastSeq, 0)) throw experimentFailure();
|
|
1212
|
+
const body = { receipt, signature: record.signature, publicKey: record.publicKey }, bodyHash = experimentHash(body);
|
|
1213
|
+
return this.atomic(() => {
|
|
1214
|
+
const { campaign } = this.experimentFence(fence);
|
|
1215
|
+
if (attempt.project !== fence.project || attempt.attempt.taskId !== fence.taskId || protocol.protocol.campaignId !== campaign.id || protocol.protocol.campaignRevision !== campaign.revision) throw experimentFailure();
|
|
1216
|
+
const prior = this.runnerReceipt(receipt.receiptId);
|
|
1217
|
+
if (prior) {
|
|
1218
|
+
if (prior.bodyHash !== bodyHash || canonicalJson({ receipt: prior.receipt, signature: prior.signature, publicKey: prior.publicKey }) !== canonicalJson(body)) throw new Error("receiptId already records different signed bytes");
|
|
1219
|
+
return prior;
|
|
1220
|
+
}
|
|
1221
|
+
verifyAttemptReceipt(receipt, record.signature, { runners: { [receipt.runner]: { publicKey: record.publicKey, projects: [fence.project], revoked: false } }, projectId: fence.project,
|
|
1222
|
+
attempt: { id: attempt.attemptId, projectId: attempt.project, protocolHash: attempt.attempt.protocolHash, ...("commit" in protocol.protocol.code ? { codeCommit: protocol.protocol.code.commit } : {}) } });
|
|
1223
|
+
if (this.runnerReceipts(attempt.attemptId).length >= 64) throw new Error("an attempt supports at most 64 runner receipts");
|
|
1224
|
+
if (this.experimentLastSeq(attempt.attemptId) !== expectedLastSeq || !researchInteger(expectedLastSeq + 1)) throw new Error("experiment event sequence changed");
|
|
1225
|
+
const seq = expectedLastSeq + 1, event = { kind: "attestation", body: { receiptId: receipt.receiptId } };
|
|
1226
|
+
// The first attestation is incompatible with older event readers, so both writes and the format floor commit together.
|
|
1227
|
+
advanceHubDbSchema(this.db, 2, "0.26.0");
|
|
1228
|
+
this.taskWrite(() => this.db.query("INSERT INTO research_attempt_events(attempt_id,project,seq,idempotency_key,kind,body,body_sha256,by_peer,at,private) VALUES(?,?,?,?,?,?,?,?,?,?)")
|
|
1229
|
+
.run(attempt.attemptId, fence.project, seq, `rr-${bodyHash.slice(0, 61)}`, "attestation", canonicalJson(event), experimentHash(event), record.by, record.at, record.private || attempt.private ? 1 : 0));
|
|
1230
|
+
this.taskWrite(() => this.db.query("INSERT INTO research_runner_receipts(receipt_id,project,attempt_id,event_seq,body,body_sha256,by_peer,at,private) VALUES(?,?,?,?,?,?,?,?,?)")
|
|
1231
|
+
.run(receipt.receiptId, fence.project, attempt.attemptId, seq, canonicalJson(body), bodyHash, record.by, record.at, record.private || attempt.private ? 1 : 0));
|
|
1232
|
+
if (record.private || attempt.private) this.taskWrite(() => this.db.query("UPDATE research_attempts SET private=1 WHERE attempt_id=? AND private=0").run(attempt.attemptId));
|
|
1233
|
+
return this.runnerReceipt(receipt.receiptId)!;
|
|
1234
|
+
});
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
hasRunnerReceipts(): boolean {
|
|
1238
|
+
return (this.db.query("SELECT EXISTS(SELECT 1 FROM research_runner_receipts) OR EXISTS(SELECT 1 FROM research_attempt_events WHERE kind='attestation') AS present").get() as { present: number }).present === 1;
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
runnerReceiptIntegrity(): { count: number; digest: string } {
|
|
1242
|
+
return this.db.transaction(() => {
|
|
1243
|
+
if (this.db.query("SELECT 1 FROM research_attempt_events AS e LEFT JOIN research_runner_receipts AS r ON r.attempt_id=e.attempt_id AND r.event_seq=e.seq WHERE e.kind='attestation' AND r.receipt_id IS NULL LIMIT 1").get()) throw experimentFailure();
|
|
1244
|
+
const digest = createHash("sha256").update("agent-hub.runner-receipts-integrity/v1\n"); let count = 0;
|
|
1245
|
+
for (const row of this.db.query(`${RUNNER_RECEIPT_SELECT} ORDER BY receipt_id`).iterate() as Iterable<RunnerReceiptRow>) {
|
|
1246
|
+
digest.update(canonicalJson(this.runnerReceiptFromRow(row))).update("\n"); count++;
|
|
1247
|
+
}
|
|
1248
|
+
return { count, digest: digest.digest("hex") };
|
|
1249
|
+
}).deferred();
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1150
1252
|
experimentLastSeq(attemptId: string): number {
|
|
1151
1253
|
if (!this.experimentAttempt(attemptId)) throw experimentFailure();
|
|
1152
1254
|
const last = this.db.query("SELECT MAX(seq) AS seq,COUNT(*) AS count FROM research_attempt_events WHERE attempt_id=?").get(attemptId) as { seq: number | null; count: number };
|
|
@@ -1177,7 +1279,7 @@ export class Board {
|
|
|
1177
1279
|
if (endRows.length > 1) throw experimentFailure();
|
|
1178
1280
|
const end = endRows[0], start = !end && this.db.query(`${EXPERIMENT_EVENT_SELECT} WHERE attempt_id=? AND kind='start' ORDER BY seq LIMIT 1`).get(attemptId) as ExperimentEventRow | null;
|
|
1179
1281
|
const events = end ? [this.experimentEventFromRow(end).event] : start ? [this.experimentEventFromRow(start).event] : [];
|
|
1180
|
-
return experimentStatus(events, this.get(attempt.attempt.taskId)?.state);
|
|
1282
|
+
return experimentStatus(declaredExperimentEvents(events), this.get(attempt.attempt.taskId)?.state);
|
|
1181
1283
|
}
|
|
1182
1284
|
|
|
1183
1285
|
// ponytail: exact counts validate all campaign attempts on each show (linear in attempt count). Upgrade to a transactional status projection maintained on event and task-state changes.
|
|
@@ -1194,7 +1296,7 @@ export class Board {
|
|
|
1194
1296
|
}
|
|
1195
1297
|
|
|
1196
1298
|
hasResearchExperimentState(): boolean {
|
|
1197
|
-
return (this.db.query("SELECT EXISTS(SELECT 1 FROM research_protocols) OR EXISTS(SELECT 1 FROM research_attempts) OR EXISTS(SELECT 1 FROM research_attempt_events) OR EXISTS(SELECT 1 FROM research_attempt_observations) AS present").get() as { present: number }).present === 1;
|
|
1299
|
+
return (this.db.query("SELECT EXISTS(SELECT 1 FROM research_protocols) OR EXISTS(SELECT 1 FROM research_attempts) OR EXISTS(SELECT 1 FROM research_attempt_events) OR EXISTS(SELECT 1 FROM research_attempt_observations) OR EXISTS(SELECT 1 FROM research_runner_receipts) AS present").get() as { present: number }).present === 1;
|
|
1198
1300
|
}
|
|
1199
1301
|
|
|
1200
1302
|
researchExperimentsIntegrity(): { protocols: number; attempts: number; events: number; observations: number; digest: string } {
|
|
@@ -1352,19 +1454,26 @@ export class Board {
|
|
|
1352
1454
|
|
|
1353
1455
|
/** The only way a task changes. Validates the move, records who did what, returns the new row. */
|
|
1354
1456
|
/** A `plan` in the patch replaces the old one whole: a new plan is the owner's current intent, not an addition. */
|
|
1355
|
-
update(id: number, by: PeerId, event: string, patch: Partial<Pick<Task, "state" | "owner" | "planner" | "reviewer" | "reserved" | "refs" | "plan" | "rejections" | "signals" | "sdlc" | "role" | "cancelled" | "codeSymbols">>, note?: string, extra: Pick<HistoryEntry, "invocation" | "plannerReason" | "planAuthor" | "selfReview" | "profile" | "sweep" | "reason" | "from" | "capacity" | "conductorManual" | "withheld" | "noWrites" | "reviewCost" | "reviewContext" | "reviewScope" | "evidenceOnly" | "reviewFindings" | "unmetCriteria" | "deliverables" | "checkKey" | "missingKeys" | "claim" | "planObservation" | "planDeparture" | "planDepartureInvalidated" | keyof ReviewerHistory> = {}, expectedFlowRevision?: number, codeSymbolsAuthority?: typeof HOST_CODE_SYMBOLS): Task {
|
|
1457
|
+
update(id: number, by: PeerId, event: string, patch: Partial<Pick<Task, "state" | "owner" | "planner" | "reviewer" | "reserved" | "refs" | "plan" | "rejections" | "signals" | "sdlc" | "role" | "cancelled" | "codeSymbols">>, note?: string, extra: Pick<HistoryEntry, "invocation" | "plannerReason" | "planAuthor" | "selfReview" | "profile" | "sweep" | "reason" | "from" | "capacity" | "conductorManual" | "withheld" | "noWrites" | "reviewCost" | "reviewContext" | "reviewScope" | "evidenceOnly" | "reviewedHead" | "reviewFindings" | "unmetCriteria" | "deliverables" | "checkKey" | "missingKeys" | "claim" | "planObservation" | "planDeparture" | "planDepartureInvalidated" | keyof ReviewerHistory> = {}, expectedFlowRevision?: number, codeSymbolsAuthority?: typeof HOST_CODE_SYMBOLS): Task {
|
|
1356
1458
|
const task = this.get(id);
|
|
1357
1459
|
if (!task) throw new Error(`no task #${id}`);
|
|
1358
1460
|
if (expectedFlowRevision !== undefined && (!Number.isSafeInteger(expectedFlowRevision) || expectedFlowRevision < 0 || task.history.length !== expectedFlowRevision)) throw new Error("task flow revision changed");
|
|
1359
1461
|
const untouchedReturn = event === "backlog returned untouched" && patch.state === "backlog" && patch.owner === null && patch.reviewer === null
|
|
1360
1462
|
&& ["proposed", "in_progress"].includes(task.state) && expectedFlowRevision !== undefined && !!note?.trim() && !taskHasWorkEvidence(task);
|
|
1361
1463
|
// A new delivered head with a configured check is owner work again until that check finishes.
|
|
1362
|
-
const reviewCheck = event === "done (checking)" && task.state
|
|
1363
|
-
&& expectedFlowRevision !== undefined && (by === task.owner || by === "user") && patch.owner === undefined && patch.reviewer === undefined
|
|
1464
|
+
const reviewCheck = task.sdlc?.merge?.state !== "merged" && event === "done (checking)" && ["in_review", "approved"].includes(task.state) && patch.state === "in_progress"
|
|
1465
|
+
&& expectedFlowRevision !== undefined && (by === task.owner || by === "user") && patch.owner === undefined && (patch.reviewer === undefined || task.state === "approved" && !task.reviewer && patch.reviewer === "user")
|
|
1364
1466
|
&& typeof patch.refs?.commit === "string" && /^[a-f0-9]{40}$/.test(patch.refs.commit)
|
|
1365
1467
|
&& patch.refs.commit !== (task.refs.commit ?? task.sdlc?.head ?? task.sdlc?.localHead);
|
|
1468
|
+
const approvalReopen = task.state === "approved" && task.sdlc?.merge?.state !== "merged" && expectedFlowRevision !== undefined
|
|
1469
|
+
&& patch.owner === undefined && (
|
|
1470
|
+
event === "done" && patch.state === "in_review" && (by === task.owner || by === "user")
|
|
1471
|
+
&& typeof patch.refs?.commit === "string" && /^[a-f0-9]{40}$/.test(patch.refs.commit)
|
|
1472
|
+
&& patch.refs.commit !== (task.refs.commit ?? task.sdlc?.head ?? task.sdlc?.localHead)
|
|
1473
|
+
|| event === "changes_requested" && patch.state === "changes_requested" && (by === task.reviewer || by === "user"));
|
|
1474
|
+
if (extra.reviewedHead !== undefined && (!/^[a-f0-9]{40}$/.test(extra.reviewedHead) || !["approved", "changes_requested"].includes(event))) throw new Error("invalid verdict head");
|
|
1366
1475
|
if (task.draft && patch.state && patch.state !== "backlog" && patch.state !== "cancelled") throw new Error(`task #${id} is an intake draft; the person approves it first`);
|
|
1367
|
-
if (patch.state && patch.state !== task.state && !MOVES[task.state].includes(patch.state) && !untouchedReturn && !reviewCheck) {
|
|
1476
|
+
if (patch.state && patch.state !== task.state && !MOVES[task.state].includes(patch.state) && !untouchedReturn && !reviewCheck && !approvalReopen) {
|
|
1368
1477
|
throw new Error(`task #${id} is ${task.state}: cannot move to ${patch.state}`);
|
|
1369
1478
|
}
|
|
1370
1479
|
const planner = Object.hasOwn(patch, "planner") ? plannerValue(patch.planner) : task.planner ?? null;
|
|
@@ -1417,7 +1526,14 @@ export class Board {
|
|
|
1417
1526
|
// Disabling reporting must not let plan or repository refs edits/reverts resurrect prior consent.
|
|
1418
1527
|
const repositoryRefsChanged = patch.refs !== undefined && JSON.stringify([task.refs.repo, task.refs.paths]) !== JSON.stringify([next.refs.repo, next.refs.paths]);
|
|
1419
1528
|
const departureInvalidated = extra.planDepartureInvalidated === true || hasDeparture && (planChanged || repositoryRefsChanged);
|
|
1420
|
-
|
|
1529
|
+
// Older verdict rows kept their bound head only on the receipt. Preserve it atomically before reopening drops that receipt.
|
|
1530
|
+
const priorApproval = task.history.findLastIndex(entry => entry.event === "approved");
|
|
1531
|
+
const priorHead = task.sdlc?.reviewedHead;
|
|
1532
|
+
const backfill = (approvalReopen || reviewCheck && task.state === "approved") && priorApproval >= 0
|
|
1533
|
+
&& priorHead !== undefined && /^[a-f0-9]{40}$/.test(priorHead);
|
|
1534
|
+
const previousHistory = backfill ? task.history.map((entry, index) => index === priorApproval && !entry.reviewedHead
|
|
1535
|
+
? { ...entry, reviewedHead: entry.reviewFindings?.head ?? priorHead } : entry) : task.history;
|
|
1536
|
+
const history = [...previousHistory, { at: Date.now(), by, event, ...(extra.invocation ? { invocation: extra.invocation } : {}), ...plannerHistory, ...authorHistory, ...(extra.selfReview === true ? { selfReview: true as const } : {}), ...(planRevision ? { planRevision } : {}), ...(extra.planObservation ? { planObservation: extra.planObservation } : {}), ...(extra.planDeparture ? { planDeparture: extra.planDeparture } : {}), ...(departureInvalidated ? { planDepartureInvalidated: true as const } : {}), ...reviewMetadata, ...(note ? { note } : {}), ...(patch.cancelled?.supersededBy ? { supersededBy: patch.cancelled.supersededBy } : {}), ...("owner" in patch ? { owner: next.owner } : {}), ...(extra.profile ? { profile: extra.profile } : {}), ...(extra.sweep ? { sweep: extra.sweep } : {}), ...(extra.reason && TASK_MOVE_REASONS.includes(extra.reason) ? { reason: extra.reason } : {}), ...(extra.from ? { from: extra.from } : {}), ...(extra.capacity === true ? { capacity: true as const } : {}), ...(extra.conductorManual === true ? { conductorManual: true as const } : {}), ...(extra.withheld && (note || reviewFindings) ? { withheld: true as const } : {}), ...(reviewFindings ? { reviewFindings } : {}), ...(extra.reviewedHead ? { reviewedHead: extra.reviewedHead } : {}), ...(unmetCriteria?.length ? { unmetCriteria } : {}), ...(deliverables ? { deliverables } : {}), ...(extra.reviewContext === "fresh" || extra.reviewContext === "continuing" ? { reviewContext: extra.reviewContext } : {}), ...(extra.reviewScope === "code" ? { reviewScope: "code" as const } : {}), ...(extra.evidenceOnly === true && event === "changes_requested" ? { evidenceOnly: true as const } : {}), ...(extra.reviewCost ? { reviewCost: extra.reviewCost } : {}), ...(event === "done" && extra.noWrites === true ? { noWrites: true as const } : {}), ...(["check passed", "check failed"].includes(event) && typeof extra.checkKey === "string" && /^[a-z][a-z0-9_]{0,47}$/.test(extra.checkKey) ? { checkKey: extra.checkKey } : {}), ...(["check passed", "check failed"].includes(event) && Number.isSafeInteger(extra.missingKeys) && extra.missingKeys! >= 0 && extra.missingKeys! <= 100_000 ? { missingKeys: extra.missingKeys } : {}), ...(event === "claim verdict" && extra.claim ? { claim: extra.claim } : {}) }];
|
|
1421
1537
|
this.taskWrite(() => {
|
|
1422
1538
|
const result = this.db.query("UPDATE tasks SET state = ?, owner = ?, reviewer = ?, reserved = ?, refs = ?, plan = ?, rejections = ?, signals = ?, history = ?, sdlc = ?, task_role = ?, cancelled = ?, updated = ?, planner = ? WHERE id = ?")
|
|
1423
1539
|
.run(next.state, next.owner, next.reviewer, next.reserved ?? null, JSON.stringify(next.refs), JSON.stringify(next.plan ?? {}), next.rejections, JSON.stringify(next.signals), JSON.stringify(history), next.sdlc ? JSON.stringify(next.sdlc) : null, next.role ?? null, next.cancelled ? JSON.stringify(next.cancelled) : null, Date.now(), planner, id);
|