@staix/agent-hub 0.25.0 → 0.27.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 +17 -0
- package/README.md +12 -6
- package/docs/agent-notes/README.md +12 -0
- package/docs/agent-notes/adapters.md +1 -1
- package/docs/agent-notes/daemon.md +7 -5
- package/docs/agent-notes/tasks.md +7 -6
- package/docs/agent-notes/tests.md +1 -0
- package/docs/dogfood.md +7 -1
- package/docs/operations.md +160 -17
- package/docs/profiles.md +1 -0
- package/docs/quickstart.md +18 -4
- package/docs/sdlc.md +3 -3
- package/docs/security.md +52 -9
- package/docs/specs/2026-09-19-agent-hub-design.md +1 -1
- package/docs/specs/2026-11-01-research-campaigns-design.md +21 -7
- package/docs/verification/2026-11-01-0.26.0.md +54 -0
- package/docs/verification/2026-11-01-0.27.0.md +49 -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 +190 -101
- 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 +9 -8
- package/src/cli/identity.ts +5 -5
- package/src/cli/main.ts +68 -24
- 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/study-output.ts +56 -0
- package/src/cli/study.ts +26 -3
- 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/action-policy.ts +5 -3
- 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 +103 -26
- package/src/hub/db-schema.ts +143 -0
- package/src/hub/delivery-journal.ts +3 -4
- package/src/hub/events.ts +2 -1
- package/src/hub/execution-budget.ts +3 -4
- package/src/hub/folder-trigger.ts +3 -4
- package/src/hub/hub-tools.ts +7 -5
- package/src/hub/invocations.ts +3 -4
- package/src/hub/lifecycle.ts +87 -29
- package/src/hub/manager.ts +49 -33
- package/src/hub/no-writes.ts +1 -1
- package/src/hub/orca-env.ts +19 -0
- package/src/hub/project-runtime-state.ts +164 -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/sdlc-release-authorization.ts +11 -5
- package/src/hub/session-identity.ts +1 -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/templates/presets/confirmation-matrix.json +5 -5
- package/templates/release-guard.block.md +2 -1
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import { lstatSync, opendirSync, type Dirent, type Stats } from "node:fs";
|
|
3
|
+
import { isAbsolute, join, normalize } from "node:path";
|
|
4
|
+
import { processLiveness } from "../pi/process-signature.ts";
|
|
5
|
+
import { hubHome } from "./project.ts";
|
|
6
|
+
import { REGISTRY_DB_BUSY_TIMEOUT_MS } from "./registry.ts";
|
|
7
|
+
import { RUNTIME_PRUNE_INTERVAL_MS } from "./runtime-capture-prune.ts";
|
|
8
|
+
|
|
9
|
+
export interface RuntimePruneHealthCheck { ok: boolean | undefined; name: string; detail: string }
|
|
10
|
+
const MAX_LEDGER_BYTES = 1024 * 1024, MAX_OUTCOME_BYTES = 16 * 1024;
|
|
11
|
+
const MAX_SCAN_ENTRIES = 1024, MAX_QUARANTINES = 64, SCAN_MS = 250;
|
|
12
|
+
const VERSION = /^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$/;
|
|
13
|
+
const columns = "slot,id,admitted_at,phase,pid,outcome";
|
|
14
|
+
const issue = (detail: string, ok: boolean | undefined = undefined): RuntimePruneHealthCheck => ({ ok, name: "runtime pruning", detail });
|
|
15
|
+
const info = (file: string): Stats | undefined => {
|
|
16
|
+
try { return lstatSync(file); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; throw error; }
|
|
17
|
+
};
|
|
18
|
+
const safeFile = (file: string, stat: Stats, limit: number): void => {
|
|
19
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || (stat.mode & 0o077) !== 0 || stat.size > limit) throw new Error(`unsafe metadata: ${file}`);
|
|
20
|
+
};
|
|
21
|
+
interface Admission { slot: number; id: string; admitted_at: number; phase: string; pid: number | null; outcome: string | null; signature: string | null; outcome_ok: number; signature_ok: number }
|
|
22
|
+
interface LedgerHealth { checks: RuntimePruneHealthCheck[]; active: boolean | undefined }
|
|
23
|
+
function admissionHealth(file: string): LedgerHealth {
|
|
24
|
+
let db: Database | undefined;
|
|
25
|
+
try {
|
|
26
|
+
const before = info(file); if (!before) return { checks: [], active: false };
|
|
27
|
+
safeFile(file, before, MAX_LEDGER_BYTES);
|
|
28
|
+
for (const suffix of ["-wal", "-shm", "-journal"]) { const sidecar = info(`${file}${suffix}`); if (sidecar) safeFile(`${file}${suffix}`, sidecar, MAX_LEDGER_BYTES); }
|
|
29
|
+
db = new Database(file, { readonly: true, create: false });
|
|
30
|
+
db.run(`PRAGMA busy_timeout = ${REGISTRY_DB_BUSY_TIMEOUT_MS}`);
|
|
31
|
+
const tables = db.query("SELECT name,type FROM sqlite_master WHERE name NOT LIKE 'sqlite_%' LIMIT 2").all() as { name: string; type: string }[];
|
|
32
|
+
const shape = (db.query("PRAGMA table_info(admission)").all() as { name: string }[]).map(column => column.name).join(",");
|
|
33
|
+
if (tables.length !== 1 || tables[0]?.name !== "admission" || tables[0]?.type !== "table" || ![columns, `${columns},signature`].includes(shape)) throw new Error("unknown admission schema");
|
|
34
|
+
const signed = shape.endsWith(",signature");
|
|
35
|
+
const rows = db.query(`SELECT slot,id,admitted_at,phase,pid,CASE WHEN typeof(outcome)='text' AND length(CAST(outcome AS BLOB))<=${MAX_OUTCOME_BYTES} THEN outcome END AS outcome,
|
|
36
|
+
CASE WHEN outcome IS NULL OR (typeof(outcome)='text' AND length(CAST(outcome AS BLOB))<=${MAX_OUTCOME_BYTES}) THEN 1 ELSE 0 END AS outcome_ok,
|
|
37
|
+
${signed ? "CASE WHEN typeof(signature)='text' AND length(CAST(signature AS BLOB))<=1024 THEN signature END" : "NULL"} AS signature,
|
|
38
|
+
${signed ? "CASE WHEN signature IS NULL OR (typeof(signature)='text' AND length(CAST(signature AS BLOB))<=1024) THEN 1 ELSE 0 END" : "1"} AS signature_ok FROM admission LIMIT 2`).all() as Admission[];
|
|
39
|
+
const row = rows[0], after = info(file);
|
|
40
|
+
if (!after || after.dev !== before.dev || after.ino !== before.ino || after.size > MAX_LEDGER_BYTES) throw new Error("admission identity changed");
|
|
41
|
+
if (!rows.length) return { checks: [], active: false };
|
|
42
|
+
if (rows.length !== 1 || row?.slot !== 1 || row.outcome_ok !== 1 || row.signature_ok !== 1 || typeof row.id !== "string" || row.id.length !== 36 || !/^[a-f0-9-]{36}$/.test(row.id)
|
|
43
|
+
|| !Number.isSafeInteger(row.admitted_at) || row.admitted_at < 0 || !["scheduled", "running", "complete"].includes(row.phase)
|
|
44
|
+
|| row.pid !== null && (!Number.isSafeInteger(row.pid) || row.pid < 1)
|
|
45
|
+
|| row.signature !== null && (!row.signature.trim() || Buffer.byteLength(row.signature) > 1024)) throw new Error("invalid admission row");
|
|
46
|
+
if (row.phase === "running") {
|
|
47
|
+
if (row.pid === null) throw new Error("running admission has no pid");
|
|
48
|
+
const liveness = processLiveness(row.pid, row.signature);
|
|
49
|
+
if (row.signature === null) return { active: undefined, checks: [issue(`legacy running admission has no signed worker identity${liveness === "gone" ? "; its PID is gone" : "; ownership is unknown"}; inspect ${file}`, liveness === "gone" ? false : undefined)] };
|
|
50
|
+
if (liveness === "live") return { active: true, checks: [issue(`signed maintenance worker ${row.pid} is running; ledger ${file}`, true)] };
|
|
51
|
+
return { active: liveness === "gone" ? false : undefined, checks: [issue(`maintenance worker is ${liveness === "gone" ? "gone; admission needs recovery" : "uncertain; ownership needs inspection"}; inspect ${file}`, liveness === "gone" ? false : undefined)] };
|
|
52
|
+
}
|
|
53
|
+
if (row.phase === "scheduled") {
|
|
54
|
+
if (row.pid !== null || row.signature !== null || row.outcome !== null) throw new Error("scheduled admission identity is inconsistent");
|
|
55
|
+
const age = Date.now() - row.admitted_at;
|
|
56
|
+
return { active: false, checks: age < 0 ? [issue(`scheduled admission timestamp is in the future; inspect ${file}`)]
|
|
57
|
+
: age >= RUNTIME_PRUNE_INTERVAL_MS ? [issue(`scheduled maintenance has not started within its admission interval; inspect ${file}`, false)] : [] };
|
|
58
|
+
}
|
|
59
|
+
if (typeof row.outcome !== "string") throw new Error("completed admission has no bounded outcome");
|
|
60
|
+
const outcome = JSON.parse(row.outcome) as { removed?: unknown; quarantined?: unknown; deferred?: unknown };
|
|
61
|
+
if (!outcome || typeof outcome !== "object" || Array.isArray(outcome) || Object.keys(outcome).some(key => !["removed", "quarantined", "deferred"].includes(key))
|
|
62
|
+
|| !Number.isSafeInteger(outcome.removed) || (outcome.removed as number) < 0 || !Array.isArray(outcome.quarantined) || outcome.quarantined.length > MAX_QUARANTINES
|
|
63
|
+
|| outcome.quarantined.some(value => typeof value !== "string" || value.length > 4096 || isAbsolute(value) || normalize(value) !== value || value.split(/[\\/]/).some(part => part === ".."))
|
|
64
|
+
|| outcome.deferred !== undefined && (typeof outcome.deferred !== "string" || Buffer.byteLength(outcome.deferred) > 512)) throw new Error("invalid completed outcome");
|
|
65
|
+
if (outcome.deferred !== undefined || outcome.quarantined.length) {
|
|
66
|
+
const detail = typeof outcome.deferred === "string" ? `maintenance deferred: ${outcome.deferred.replace(/[\x00-\x1f\x7f]/g, " ")}; inspect ${file}` : `maintenance retained quarantine paths; inspect ${file}`;
|
|
67
|
+
return { active: false, checks: [issue(detail, false)] };
|
|
68
|
+
}
|
|
69
|
+
return { checks: [], active: false };
|
|
70
|
+
} catch { return { active: undefined, checks: [issue(`runtime maintenance admission is unreadable, unsafe or unknown; inspect ${file}`)] }; }
|
|
71
|
+
finally { try { db?.close(); } catch { /* Inspection never repairs or alters admission state. */ } }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Scan only the two release directory levels, never capture contents or a referenced/symlinked directory. */
|
|
75
|
+
function quarantineHealth(home: string, active: boolean | undefined): RuntimePruneHealthCheck[] {
|
|
76
|
+
const releases = join(home, "releases"), checks: RuntimePruneHealthCheck[] = [];
|
|
77
|
+
let remaining = MAX_SCAN_ENTRIES, found = 0;
|
|
78
|
+
const deadline = performance.now() + SCAN_MS;
|
|
79
|
+
const scan = (directory: string, visit: (entry: Dirent) => void) => {
|
|
80
|
+
const stat = info(directory); if (!stat) return;
|
|
81
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("unsafe release directory");
|
|
82
|
+
const stream = opendirSync(directory);
|
|
83
|
+
try { for (;;) { const entry = stream.readSync(); if (!entry) break; if (--remaining < 0 || performance.now() > deadline) throw new Error("release inventory bound exceeded"); visit(entry); } }
|
|
84
|
+
finally { stream.closeSync(); }
|
|
85
|
+
};
|
|
86
|
+
try {
|
|
87
|
+
scan(releases, version => {
|
|
88
|
+
if (VERSION.exec(version.name)?.[0] !== version.name) return;
|
|
89
|
+
const directory = join(releases, version.name);
|
|
90
|
+
if (!version.isDirectory() || version.isSymbolicLink()) throw new Error(`unsafe version directory: ${directory}`);
|
|
91
|
+
scan(directory, entry => {
|
|
92
|
+
if (!entry.name.startsWith(".runtime-prune-")) return;
|
|
93
|
+
if (++found > MAX_QUARANTINES) throw new Error("quarantine result bound exceeded");
|
|
94
|
+
const path = join(directory, entry.name), stat = info(path); if (!stat) return;
|
|
95
|
+
const unsafe = stat.isSymbolicLink() || !stat.isDirectory();
|
|
96
|
+
checks.push({ ok: undefined, name: "runtime quarantine", detail: `${unsafe ? "unsafe reserved quarantine path" : active === true ? "maintenance quarantine is in use" : "runtime quarantine remains"}; inspect ${path}` });
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
} catch { checks.push(issue(`runtime quarantine inventory is unreadable, unsafe or exceeds its bound; inspect ${releases}`)); }
|
|
100
|
+
return checks;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Read-only doctor diagnostics. An absent ledger and quarantine inventory preserves the previous empty result. */
|
|
104
|
+
export function runtimePruneHealth(home?: string): RuntimePruneHealthCheck[] {
|
|
105
|
+
try {
|
|
106
|
+
const target = home ?? hubHome(), stat = info(target);
|
|
107
|
+
if (!stat) return [];
|
|
108
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) return [issue(`runtime maintenance home is unsafe; inspect ${target}`)];
|
|
109
|
+
const ledger = admissionHealth(join(target, "runtime-prune.db"));
|
|
110
|
+
return [...ledger.checks, ...quarantineHealth(target, ledger.active)];
|
|
111
|
+
} catch { return [issue(`runtime maintenance diagnostics are unavailable; inspect ${home ?? "the agent-hub home"}`)]; }
|
|
112
|
+
}
|
package/src/hub/schedules.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { Database } from "
|
|
2
|
-
import {
|
|
1
|
+
import type { Database } from "./db-schema.ts";
|
|
2
|
+
import { openHubDatabase } from "./db-schema.ts";
|
|
3
3
|
import { corpusPathProblem } from "./corpus-config.ts";
|
|
4
4
|
|
|
5
5
|
// agent-hub #512 (hub task #330): machine-local schedules. Spec: "Schedules and folder triggers (#512)".
|
|
@@ -377,8 +377,7 @@ export interface ScheduleRow {
|
|
|
377
377
|
export class ScheduleStore {
|
|
378
378
|
private readonly db: Database;
|
|
379
379
|
constructor(dbPath: string) {
|
|
380
|
-
this.db =
|
|
381
|
-
this.db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
|
|
380
|
+
this.db = openHubDatabase(dbPath, { create: true });
|
|
382
381
|
this.db.run("CREATE TABLE IF NOT EXISTS schedule_runs (id TEXT PRIMARY KEY, anchor INTEGER NOT NULL, last_run INTEGER, last_task INTEGER, coalesced INTEGER NOT NULL DEFAULT 0, last_error TEXT)");
|
|
383
382
|
// #335: a person's pause. A column on the existing table, so an older database gains it and every older row reads as not paused.
|
|
384
383
|
if (!(this.db.query("PRAGMA table_info(schedule_runs)").all() as { name: string }[]).some(column => column.name === "paused")) this.db.run("ALTER TABLE schedule_runs ADD COLUMN paused INTEGER NOT NULL DEFAULT 0");
|
package/src/hub/sdlc-merge.ts
CHANGED
|
@@ -144,6 +144,12 @@ export function localGates(input: { subject: DeliveryTask; state: string; taskSt
|
|
|
144
144
|
const { subject, config } = input, gates: string[] = [];
|
|
145
145
|
if (subject.private) return ["private task: a person merges"];
|
|
146
146
|
if (input.state !== "approved") gates.push(`task not approved (state ${input.state})`);
|
|
147
|
+
const approved = subject.receipt?.reviewedHead;
|
|
148
|
+
if (subject.latestHead !== undefined && subject.latestHead !== approved) gates.push("latest reported head differs from approved head");
|
|
149
|
+
const binding = subject.approval;
|
|
150
|
+
if (binding && (!binding.current || binding.mismatch || !binding.head || binding.head !== approved
|
|
151
|
+
|| subject.latestHead !== undefined && binding.head !== subject.latestHead
|
|
152
|
+
|| binding.latestHead !== undefined && binding.head !== binding.latestHead)) gates.push("approval is not current at the latest reported head");
|
|
147
153
|
for (const id of subject.after) {
|
|
148
154
|
const state = input.taskState(id);
|
|
149
155
|
if (state !== "approved") gates.push(`dependency task #${id} is not approved (${state ?? "unknown"})`);
|
|
@@ -206,6 +212,7 @@ export function dispatchGates(input: Pick<MergeInput, "subject" | "taskState"> &
|
|
|
206
212
|
if (!now) return moved;
|
|
207
213
|
const receipt = now.subject.receipt;
|
|
208
214
|
if (receipt?.repo !== decision.repo || receipt?.pr !== decision.pr || receipt?.reviewedHead !== decision.head) return moved;
|
|
215
|
+
if (now.subject.latestHead !== input.subject.latestHead || JSON.stringify(now.subject.approval) !== JSON.stringify(input.subject.approval)) return moved;
|
|
209
216
|
if (now.mode !== input.mode) return ["merge mode changed during the merge check: nothing was run"];
|
|
210
217
|
if (JSON.stringify(requiredChecksFor(now.config, decision.repo)) !== JSON.stringify(requiredChecksFor(input.config, decision.repo))) return ["required checks changed during the merge check: nothing was run"];
|
|
211
218
|
if (!now.allowed) return ["authority or hub availability changed during the merge check: nothing was run"];
|
package/src/hub/sdlc-queue.ts
CHANGED
|
@@ -4,7 +4,14 @@ import { evalGapBlockers, evalGateView, readEvalFor, type EvalGate } from "./eva
|
|
|
4
4
|
import { findingsForHead, findingHead, findingTally, type ReviewFindingSet, type ReviewFindingTally } from "./review-findings.ts";
|
|
5
5
|
import type { CriteriaView } from "./workflow.ts";
|
|
6
6
|
import { parsePrEvidence, readSdlcPr, sdlcGh, sdlcStatus, type PrEvidence, type SdlcConfig, type SdlcGh, type SdlcReceipt } from "./sdlc.ts";
|
|
7
|
-
export interface DeliveryTask {
|
|
7
|
+
export interface DeliveryTask {
|
|
8
|
+
/** Latest owner-reported artifact head; omitted by older library callers. */
|
|
9
|
+
latestHead?: string;
|
|
10
|
+
/** Host-derived verdict binding, never inferred from a mutable lifecycle receipt. */
|
|
11
|
+
approval?: { head?: string; latestHead?: string; mismatch: boolean; current: boolean };
|
|
12
|
+
plan?: { paths?: string[]; revision: number; fingerprint: string; accepted: { repo: string; pr: number; head: string; revision: number; fingerprint: string; paths: string[] }[] };
|
|
13
|
+
findings?: readonly ReviewFindingSet[]; id: number; after: number[]; private: boolean; receipt?: SdlcReceipt; phase?: CriteriaView;
|
|
14
|
+
}
|
|
8
15
|
export interface MergeQueueRow { evalGate?: EvalGate; testIntegrity?: (TestIntegrity & { task?: number })[]; planConformance?: { task?: number; state: string; outside: number; untouched: number; accepted: boolean }[]; findings?: ReviewFindingTally; pr: number; tasks: number[]; head?: string; private?: true; ready: boolean; blockers: string[]; after: number[]; }
|
|
9
16
|
export interface MergeConflict { left: number; right: number; files: number; order: "dependency" | "person_required"; }
|
|
10
17
|
export interface MergeQueue { repo: string; rows: MergeQueueRow[]; order: number[]; unordered?: number[]; conflicts: MergeConflict[]; complete: boolean; truncated: boolean; }
|
|
@@ -94,6 +101,11 @@ export async function readMergeQueue(repo: string, config: SdlcConfig, tasks: De
|
|
|
94
101
|
const integrity = rawIntegrity ? publicTestIntegrity(rawIntegrity, readableMetadata) : undefined;
|
|
95
102
|
if (integrity) (row.testIntegrity ??= []).push({ task: holder!.id, ...integrity });
|
|
96
103
|
row.blockers.push(...sdlcStatus(config, holder?.receipt, read, holder?.phase).blockers);
|
|
104
|
+
const approved = holder?.receipt?.reviewedHead, binding = holder?.approval;
|
|
105
|
+
if (holder?.latestHead !== undefined && holder.latestHead !== approved) row.blockers.push("latest reported head differs from approved head");
|
|
106
|
+
if (binding && (!binding.current || binding.mismatch || !binding.head || binding.head !== approved
|
|
107
|
+
|| holder?.latestHead !== undefined && binding.head !== holder.latestHead
|
|
108
|
+
|| binding.latestHead !== undefined && binding.head !== binding.latestHead)) row.blockers.push("approval is not current at the latest reported head");
|
|
97
109
|
// #481 S5: the eval check itself is a required check inside sdlcStatus; its recorded pass rate and suite are read once per PR here.
|
|
98
110
|
if (!row.evalGate) {
|
|
99
111
|
const gate = evalGateView(config, read, await readEvalFor(config, read, repo, bounded));
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { releaseTrackedClean } from "./release-worktree.ts";
|
|
2
2
|
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
-
import { USER } from "./envelope.ts";
|
|
3
|
+
import { USER, type PeerId } from "./envelope.ts";
|
|
4
4
|
import { hubGitEnv, hubGitSync } from "./git.ts";
|
|
5
5
|
import { readDocEvidence, type DocEvidence, type ReleaseGit, type ReleaseReadiness } from "./sdlc-release.ts";
|
|
6
6
|
import type { SdlcGh } from "./sdlc.ts";
|
|
@@ -13,13 +13,15 @@ const SHA = /^[0-9a-f]{40}$/;
|
|
|
13
13
|
const VERSION = /^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
14
14
|
export const validReleaseVersion = (value: unknown): value is string => typeof value === "string" && value.length <= 128 && VERSION.test(value);
|
|
15
15
|
export interface ReleaseDocsVerdict { fresh: true; errors: 0; documents: number; digest: string }
|
|
16
|
-
export interface ReleaseAuthorization { schemaVersion: 1; id: string; by:
|
|
16
|
+
export interface ReleaseAuthorization { schemaVersion: 1; id: string; by: PeerId; commit: string; version: string; at: number; docs: ReleaseDocsVerdict }
|
|
17
17
|
export interface ReleaseAuthorizationPorts {
|
|
18
18
|
git?: ReleaseGit;
|
|
19
19
|
docs?: (root: string) => Promise<DocEvidence | undefined>;
|
|
20
20
|
gh?: SdlcGh;
|
|
21
21
|
now?: () => number;
|
|
22
22
|
current?: () => boolean;
|
|
23
|
+
/** Host-only current eligible conductor; absent keeps non-person authorization refused. */
|
|
24
|
+
conductor?: () => PeerId | undefined;
|
|
23
25
|
record: (receipt: ReleaseAuthorization) => boolean;
|
|
24
26
|
}
|
|
25
27
|
export type ReleaseGuardVerdict = { ok: true; receipts: ReleaseAuthorization[] } | { ok: false; error: string };
|
|
@@ -44,6 +46,9 @@ export class ReleaseAuthorizations {
|
|
|
44
46
|
private latest?: Entry;
|
|
45
47
|
constructor(private readonly root: string, private readonly ports: ReleaseAuthorizationPorts) {}
|
|
46
48
|
private current(): void { if (this.ports.current?.() === false) throw new ReleaseAuthorizationError("release authorization unavailable during lifecycle transition"); }
|
|
49
|
+
private actor(by: PeerId): void {
|
|
50
|
+
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
|
+
}
|
|
47
52
|
private git(): ReleaseGit {
|
|
48
53
|
let remaining = 5000;
|
|
49
54
|
return args => {
|
|
@@ -85,16 +90,17 @@ export class ReleaseAuthorizations {
|
|
|
85
90
|
return value;
|
|
86
91
|
}
|
|
87
92
|
async authorize(by: string, version: unknown, expectedCommit: unknown): Promise<ReleaseAuthorization> {
|
|
88
|
-
|
|
93
|
+
this.actor(by);
|
|
89
94
|
if (!validReleaseVersion(version) || typeof expectedCommit !== "string" || !SHA.test(expectedCommit)) throw new ReleaseAuthorizationError("authorization needs an exact version and candidate commit");
|
|
90
95
|
this.current();
|
|
91
96
|
const git = this.git(), commit = this.commit(git, "HEAD"), origin = this.origin(git);
|
|
92
97
|
if (commit !== expectedCommit || this.version(git, commit) !== version) throw new ReleaseAuthorizationError("candidate commit or version differs from the person's request");
|
|
93
98
|
const docs = docsVerdict(await (this.ports.docs ?? readDocEvidence)(this.root), commit, version);
|
|
94
99
|
if (!docs) throw new ReleaseAuthorizationError("release authorization requires fresh committed documentation evidence");
|
|
95
|
-
this.current();
|
|
100
|
+
this.current(); this.actor(by);
|
|
96
101
|
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");
|
|
97
|
-
|
|
102
|
+
this.current(); this.actor(by);
|
|
103
|
+
const receipt: ReleaseAuthorization = { schemaVersion: 1, id: randomUUID(), by, commit, version, at: (this.ports.now ?? Date.now)(), docs };
|
|
98
104
|
if (!this.ports.record(structuredClone(receipt))) throw new ReleaseAuthorizationError("release approval audit could not be recorded");
|
|
99
105
|
const entry = { receipt, origin };
|
|
100
106
|
this.entries.set(`${version}:${commit}`, entry); this.latest = entry;
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
/** The task ops a distinct session may run: reads that need no role. Everything else is refused with `sessionRefusal`. */
|
|
11
11
|
export const SESSION_READ_OPS: ReadonlySet<string> = new Set([
|
|
12
|
-
"hub_task_show", "hub_task_list", "hub_status", "hub_capabilities", "hub_study_show", "hub_backlog_list", "hub_release_list",
|
|
12
|
+
"hub_task_show", "hub_task_list", "hub_status", "hub_capabilities", "hub_study_show", "hub_experiment_show", "hub_backlog_list", "hub_release_list",
|
|
13
13
|
]);
|
|
14
14
|
|
|
15
15
|
/** The control messages a distinct session may send besides task ops. `task` is gated again by `SESSION_READ_OPS`. */
|
package/src/hub/settings.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { CLASSES, type TaskClass } from "./board.ts";
|
|
|
12
12
|
import { configTracked, configRepositoryPresent } from "./config-trust.ts";
|
|
13
13
|
import { PERMISSION_MODES, permissionBoundary } from "./permission-mode.ts";
|
|
14
14
|
import { START_MODES } from "./start-mode.ts";
|
|
15
|
+
import { checkStartAgents } from "./start-agents.ts";
|
|
15
16
|
import { OVERLAY_FILE, overlayToml, parseOverlay, parseRouting, routingText, type Routing, type RoutingOverlay } from "./routing.ts";
|
|
16
17
|
|
|
17
18
|
/**
|
|
@@ -74,6 +75,7 @@ export const SETTINGS: readonly SettingDef[] = [
|
|
|
74
75
|
...ACTION_CLASSES.map((kind): SettingDef => ({ key: `action_policy.budgets.${kind}`, group: "Permissions", label: `${kind}: approved session budget`, type: "integer", min: 0, max: 1000, store: "config", applies: "live", risk: "raises", floor: 0, path: ["action_policy", "budgets", kind] })),
|
|
75
76
|
...SETTING_PEERS.filter(peer => !definitionOf(peer)?.permission.unmanaged).map((peer): SettingDef => ({ key: `permission.${peer}`, group: "Permissions", label: `${peer}: mode now`, type: "enum", values: PERMISSION_MODES, store: "runtime", applies: "live", risk: "raises", floor: "ask", path: [peer] })),
|
|
76
77
|
...SETTING_PEERS.filter(peer => !definitionOf(peer)?.permission.unmanaged).map((peer): SettingDef => ({ key: `permission_modes.${peer}`, group: "Permissions", label: `${peer}: mode at next peer start`, type: "enum", values: PERMISSION_MODES, store: "config", applies: "next use", risk: "raises", floor: "ask", path: ["permission_modes", peer] })),
|
|
78
|
+
{ key: "start.agents", group: "Start", label: "Agents ahub up starts (none for an empty list)", type: "peers", store: "config", applies: "next use", risk: "raises", path: ["start", "agents"] },
|
|
77
79
|
...START_MODE_PEERS.map((peer): SettingDef => ({ key: `peers.${peer}.start_mode`, group: "Start", label: `${peer}: start mode (tui unless set)`, type: "enum", values: START_MODES, store: "config", applies: "next use", risk: "raises", floor: "tui", path: ["peers", peer, "start_mode"] })),
|
|
78
80
|
...ADAPTERS.flatMap(adapter => adapter.configuration?.settings ?? []),
|
|
79
81
|
{ key: "routing.stay_switch", group: "Routing", label: "Stay or switch (hub/auto and stage routes)", type: "enum", values: ["off", "shadow", "enforce"], store: "routing", applies: "live", risk: "raises", path: ["stay_switch"] },
|
|
@@ -203,6 +205,7 @@ export function checkSettingValue(def: SettingDef, raw: unknown, routes: readonl
|
|
|
203
205
|
}
|
|
204
206
|
return peers.length === 1 ? peers[0] as string : [...peers] as string[];
|
|
205
207
|
}
|
|
208
|
+
if (def.key === "start.agents") return checkStartAgents(raw, def.key);
|
|
206
209
|
if (!Array.isArray(raw) || raw.length > PEERS_MAX || raw.some((p) => typeof p !== "string" || !PEER.test(p)) || new Set(raw).size !== raw.length) throw new Error(`${def.key} takes up to ${PEERS_MAX} distinct peer ids`);
|
|
207
210
|
return [...(raw as string[])];
|
|
208
211
|
}
|
|
@@ -217,6 +220,7 @@ export function parseSettingText(def: SettingDef, text: string): unknown {
|
|
|
217
220
|
if (def.type === "number") return text.trim() && Number.isFinite(Number(text)) ? Number(text) : text;
|
|
218
221
|
if (def.type === "integer") return /^(0|[1-9]\d*)$/.test(text) ? Number(text) : text;
|
|
219
222
|
if (def.type === "boolean") return text === "true" ? true : text === "false" ? false : text;
|
|
223
|
+
if (def.key === "start.agents" && text.trim() === "none") return [];
|
|
220
224
|
if (def.type === "peers" || def.type === "roles") return text.split(",").map((p) => p.trim()).filter(Boolean);
|
|
221
225
|
return text;
|
|
222
226
|
}
|
package/src/hub/snapshots.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { Database } from "
|
|
1
|
+
import type { Database } from "./db-schema.ts";
|
|
2
|
+
import { openHubDatabase } from "./db-schema.ts";
|
|
2
3
|
import { closeSync, constants, copyFileSync, existsSync, fstatSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, readdirSync, rmSync, statSync, utimesSync, writeFileSync, type Stats } from "node:fs";
|
|
3
4
|
import { createHash } from "node:crypto";
|
|
4
5
|
import { tmpdir } from "node:os";
|
|
@@ -7,7 +8,6 @@ import { childEnv } from "./child-process.ts";
|
|
|
7
8
|
import type { Touch } from "./conflicts.ts";
|
|
8
9
|
import { hubGitSync } from "./git.ts";
|
|
9
10
|
import { realPath } from "./project.ts";
|
|
10
|
-
import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
13
|
* Per-turn workspace snapshots (issue #33): git tree objects written through a copy of the index, so the user's index
|
|
@@ -657,8 +657,7 @@ export function submodulePaths(repo: Repo): string[] {
|
|
|
657
657
|
export class Turns {
|
|
658
658
|
private readonly db: Database;
|
|
659
659
|
constructor(path: string, readonly?: boolean) {
|
|
660
|
-
this.db =
|
|
661
|
-
this.db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
|
|
660
|
+
this.db = openHubDatabase(path, readonly ? { readonly: true } : { create: true });
|
|
662
661
|
if (readonly) return;
|
|
663
662
|
this.db.run("PRAGMA journal_mode = WAL");
|
|
664
663
|
this.db.run(`CREATE TABLE IF NOT EXISTS turns (id TEXT PRIMARY KEY, peer TEXT NOT NULL, started INTEGER NOT NULL, ended INTEGER,
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { SUPPORTED_PEERS, definitionOf } from "../adapters/registry.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* #608 (agent-hub#1040): the agents `ahub up` asks the hub to start, `start.agents`. Pure and free of file-system
|
|
5
|
+
* imports: the config schema (`config-apply.ts`) reaches the Claude channel bundle. The list is read by the CLI at a
|
|
6
|
+
* person's `ahub up` and by the dashboard manager's start; the daemon never reads it, so a restart, a crash recovery,
|
|
7
|
+
* a reload or an upgrade relaunch starts nothing from it.
|
|
8
|
+
*
|
|
9
|
+
* Which kinds may be listed, which are in the default set and why a kind is refused all come from the kind's own
|
|
10
|
+
* `AdapterDefinition.start.upStart` (#451): nothing here names a peer.
|
|
11
|
+
*/
|
|
12
|
+
const upStart = (id: string) => definitionOf(id)?.start.upStart;
|
|
13
|
+
|
|
14
|
+
/** The kinds in the default set, in the registry's order. */
|
|
15
|
+
export const START_AGENTS_DEFAULT: readonly string[] = Object.freeze(SUPPORTED_PEERS.filter(peer => { const s = upStart(peer); return s?.allowed === true && s.default === true; }));
|
|
16
|
+
export const START_AGENTS_MAX = 8;
|
|
17
|
+
const PEER = /^[a-z][a-z0-9-]{0,31}$/;
|
|
18
|
+
|
|
19
|
+
/** The kinds `ahub up` may start. A declared instance, a channel extra and an unknown id are never in it. */
|
|
20
|
+
export const START_AGENTS_ALLOWED: readonly string[] = Object.freeze(SUPPORTED_PEERS.filter(peer => definitionOf(peer)?.start.conductorStartable === true && upStart(peer)?.allowed === true));
|
|
21
|
+
|
|
22
|
+
/** Why `id` cannot be in the start set, or undefined. The reason never repeats the id unless it is a well-formed peer id. */
|
|
23
|
+
export function startAgentRefusal(id: unknown): string | undefined {
|
|
24
|
+
if (typeof id !== "string" || !PEER.test(id)) return "an entry is not a peer id (lowercase letters, digits and dashes)";
|
|
25
|
+
if (START_AGENTS_ALLOWED.includes(id)) return undefined;
|
|
26
|
+
const own = upStart(id);
|
|
27
|
+
if (own?.allowed === false) return `${id} cannot be started by ahub up: ${own.refusal}`;
|
|
28
|
+
if (SUPPORTED_PEERS.includes(id)) return `${id} cannot be started by ahub up: the hub does not start it; run ahub ${id} in a terminal`;
|
|
29
|
+
return `${id} cannot be started by ahub up: it is a declared instance, a channel extra or unknown, and only ${START_AGENTS_ALLOWED.join(", ")} are started by the hub`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The checked list (a copy), or a thrown reason. An empty list is allowed and starts nothing. */
|
|
33
|
+
export function checkStartAgents(raw: unknown, key = "start.agents"): string[] {
|
|
34
|
+
if (!Array.isArray(raw) || raw.length > START_AGENTS_MAX) throw new Error(`${key} takes a list of up to ${START_AGENTS_MAX} peer ids (an empty list starts none)`);
|
|
35
|
+
for (const id of raw) { const why = startAgentRefusal(id); if (why) throw new Error(`${key}: ${why}`); }
|
|
36
|
+
if (new Set(raw).size !== raw.length) throw new Error(`${key} takes distinct peer ids`);
|
|
37
|
+
return [...raw] as string[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A person's text for the list: commas or spaces between ids, `none` for an empty list. */
|
|
41
|
+
export function parseStartAgents(text: string): string[] {
|
|
42
|
+
const words = text.split(/[\s,]+/).filter(Boolean);
|
|
43
|
+
return checkStartAgents(words.length === 1 && words[0] === "none" ? [] : words);
|
|
44
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Task } from "./board.ts";
|
|
2
|
+
|
|
3
|
+
export interface ApprovalBinding { head?: string; latestHead?: string; mismatch: boolean; current: boolean }
|
|
4
|
+
const fullHead = (value: unknown): string | undefined => typeof value === "string" && /^[a-f0-9]{40}$/.test(value) ? value : undefined;
|
|
5
|
+
|
|
6
|
+
/** The last approval remains historical evidence after a new delivery or a withdrawal. Unknown legacy heads stay unknown. */
|
|
7
|
+
export function approvalBinding(task: Task): ApprovalBinding | undefined {
|
|
8
|
+
const verdict = task.history.findLast(entry => entry.event === "approved");
|
|
9
|
+
if (!verdict) return undefined;
|
|
10
|
+
const head = fullHead(verdict.reviewedHead) ?? fullHead(verdict.reviewFindings?.head)
|
|
11
|
+
?? (task.state === "approved" ? fullHead(task.sdlc?.reviewedHead) : undefined);
|
|
12
|
+
const latestHead = fullHead(task.refs.commit ?? task.sdlc?.head ?? task.sdlc?.localHead);
|
|
13
|
+
const mismatch = !!head && !!latestHead && head !== latestHead;
|
|
14
|
+
return { ...(head ? { head } : {}), ...(latestHead ? { latestHead } : {}), mismatch,
|
|
15
|
+
current: task.state === "approved" && !!head && head === latestHead };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function approvalText(binding: ApprovalBinding | undefined): string {
|
|
19
|
+
if (!binding) return "";
|
|
20
|
+
return `Approval bound to head ${binding.head ?? "unknown"}; latest reported head ${binding.latestHead ?? "unknown"}. ${binding.mismatch
|
|
21
|
+
? "HEAD MISMATCH: earlier approval does not approve the latest reported head."
|
|
22
|
+
: binding.current ? "Approval is current at this head." : "Earlier approval is not current; review is required."}`;
|
|
23
|
+
}
|