@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
package/src/hub/daemon.ts
CHANGED
|
@@ -174,9 +174,11 @@ import { githubOriginRepository, githubRemoteRepository } from "./task-repositor
|
|
|
174
174
|
import { Bus } from "./bus.ts";
|
|
175
175
|
import { attribute, deliveryTask, type TaskAttribution } from "./attribution.ts";
|
|
176
176
|
import { DeliveryJournal } from "./delivery-journal.ts";
|
|
177
|
+
import { assertHubDbCompatible } from "./db-schema.ts";
|
|
177
178
|
import { startDashboard, dashboardView, type DashboardSession } from "./ui.ts";
|
|
178
179
|
import { codeGroup } from "./task-code-group.ts";
|
|
179
|
-
import { ControlClient, DAEMON_FEATURES, PROTOCOL, readControl, stateDirFor } from "./control-client.ts";
|
|
180
|
+
import { ControlClient, DAEMON_FEATURES, PROTOCOL, PROTOCOL_MIN, readControl, stateDirFor } from "./control-client.ts";
|
|
181
|
+
import { negotiateProtocol, protocolWindow } from "./protocol-window.ts";
|
|
180
182
|
import { attachEvent, originOf, type Origin } from "./provenance.ts";
|
|
181
183
|
import { mintSessionId, sessionGate } from "./session-identity.ts";
|
|
182
184
|
import { MAX_HOP, newEnvelope, parseMarker, replyParent, sanitize, USER, type Envelope, type PeerId, type Priority } from "./envelope.ts";
|
|
@@ -185,6 +187,7 @@ import { ClaudeTodoSource, DoingTracker, readAiTitle, type DoingKind, type PeerD
|
|
|
185
187
|
import { MemoryClient } from "../memory/client.ts";
|
|
186
188
|
import { VERSION } from "../version.ts";
|
|
187
189
|
import { appendRecords, taskRecords } from "./research.ts";
|
|
190
|
+
import { parseResearchRunners, type RegisteredRunnerV1 } from "./research-runner.ts";
|
|
188
191
|
import { benchReport, MIN_ATTEMPTS, readRuns, runSummary, type Measures, type RunSummary } from "./bench.ts";
|
|
189
192
|
import { projectChain } from "@staix/agent-mem";
|
|
190
193
|
import { recallFor } from "../memory/recall.ts";
|
|
@@ -199,6 +202,7 @@ import { consumeRecoveryContinuity, readRecoveryContinuity, retireRecoveryContin
|
|
|
199
202
|
import { shellQuote, publishTerminalLaunch, terminalLaunchCandidate, launcherOf } from "../cli/terminal-recovery.ts";
|
|
200
203
|
import { TerminalLaunches, terminalOwnerReads } from "./terminal-launch.ts";
|
|
201
204
|
|
|
205
|
+
import { START_AGENTS_DEFAULT, checkStartAgents } from "./start-agents.ts";
|
|
202
206
|
import { peerStartConfig, PEER_START_RETRY_MS, startModeOf, terminalTemplate, type PeerStartConfig, type StartMode } from "./start-mode.ts";
|
|
203
207
|
import { hasKimiHubMcp } from "../cli/kimi-mcp.ts";
|
|
204
208
|
import type { TerminalOpener } from "./terminal-open.ts";
|
|
@@ -281,13 +285,15 @@ export interface HubConfig {
|
|
|
281
285
|
/** A git tree at each turn boundary for `ahub turns` and `ahub undo`, the last `keep` per peer (issue #33). */
|
|
282
286
|
snapshots: { enabled: boolean; keep: number };
|
|
283
287
|
/** #247: opt-in research records of each approved task in ~/.agenthub/research (ids, counts and tokens only). */
|
|
284
|
-
research: { enabled: boolean };
|
|
288
|
+
research: { enabled: boolean; runners?: Record<string, RegisteredRunnerV1> };
|
|
285
289
|
/** #251: a project kept for benchmarks: `ahub bench run` resets its work tree between attempts. */
|
|
286
290
|
bench: { enabled: boolean };
|
|
287
291
|
/** #269: per peer, how a start comes up when nothing more specific was asked: `tui` (the default) or `headless`. Pi and Codex only. */
|
|
288
292
|
peers: PeerStartConfig;
|
|
289
293
|
/** #269: a machine-local argv that opens a terminal for a hub-made TUI start; `{command}` in one element is the command to run. */
|
|
290
294
|
terminal: { open: string[] };
|
|
295
|
+
/** #608: the peers `ahub up` asks the hub to start, machine-local. Read by the CLI at a person's `ahub up` and by the dashboard manager's start; the daemon itself never starts one from it. */
|
|
296
|
+
start: { agents: string[] };
|
|
291
297
|
/** Per-sender rate limits and repeat suppression for what agents send (issue #38). */
|
|
292
298
|
limits: LimitsConfig;
|
|
293
299
|
/** Reviewer choice from recorded review outcomes, once a reviewer has `min_reviews` of an implementer (issue #35). */
|
|
@@ -353,6 +359,7 @@ export const DEFAULT_CONFIG: HubConfig = {
|
|
|
353
359
|
bench: { enabled: false },
|
|
354
360
|
peers: {},
|
|
355
361
|
terminal: { open: [] },
|
|
362
|
+
start: { agents: [...START_AGENTS_DEFAULT] },
|
|
356
363
|
limits: DEFAULT_LIMITS,
|
|
357
364
|
review: { adaptive: false, min_reviews: 5 },
|
|
358
365
|
recovery: { auto_resume_after_crash: false, reconnect_wait_s: RECOVERY_RECONNECT_WAIT_SETTING.defaultValue, unmanaged_reconnect_wait_s: RECOVERY_UNMANAGED_RECONNECT_WAIT_SETTING.defaultValue, control_timeout_s: RECOVERY_CONTROL_TIMEOUT_SETTING.defaultValue, shutdown_wait_s: RECOVERY_SHUTDOWN_WAIT_SETTING.defaultValue, terminal_idle_wait_s: RECOVERY_TERMINAL_IDLE_WAIT_SETTING.defaultValue },
|
|
@@ -363,14 +370,29 @@ export const DEFAULT_CONFIG: HubConfig = {
|
|
|
363
370
|
export { stateDirFor };
|
|
364
371
|
|
|
365
372
|
/** Ids an external process may not claim: the console user and the adapters the daemon runs itself. */
|
|
366
|
-
const READ_ONLY_HUB_TOOLS = new Set(["hub_capabilities", "hub_inbox", "hub_send", "hub_delivery_done", "hub_review", "hub_task_show", "hub_task_list", "hub_study_show", "hub_memory_search", "hub_memory_show", "hub_memory_graph", "hub_source_read", "hub_status"]);
|
|
373
|
+
const READ_ONLY_HUB_TOOLS = new Set(["hub_capabilities", "hub_inbox", "hub_send", "hub_delivery_done", "hub_review", "hub_task_show", "hub_task_list", "hub_study_show", "hub_experiment_show", "hub_memory_search", "hub_memory_show", "hub_memory_graph", "hub_source_read", "hub_status"]);
|
|
367
374
|
const BASE_RESERVED_IDS = new Set<string>([USER, ...SUPPORTED_PEERS.filter(peer => definitionOf(peer)?.attach?.externalHello !== true), HUB, DIGEST]);
|
|
368
375
|
const PEER_ID = /^[a-z][a-z0-9-]{0,31}$/;
|
|
369
376
|
|
|
370
377
|
/** The shared project config, then the machine's own file, which overrides it block by block (issue #17). */
|
|
371
378
|
const CONFIG_FILES = INSTANCE_CONFIG_FILES;
|
|
372
379
|
export interface ConfigLayer { name: typeof CONFIG_FILES[number]; raw: Record<string, any> }
|
|
373
|
-
const CONFIG_BLOCKS = ["action_policy", "memory", "roster", "roles", "catalog_roles", "pii", "data", "conductor", "budget", "context", "inference", "omniroute", ...NATIVE_CONFIG_BLOCKS, "approvals", "tasks", "task_sweep", "bus", "adapter_timing", "model_relay", "console", "corpus", "checks", "snapshots", "research", "bench", "limits", "review", "recovery", "capabilities", "mlx", "permission_modes", "peers", "terminal", "pet"];
|
|
380
|
+
const CONFIG_BLOCKS = ["action_policy", "memory", "roster", "roles", "catalog_roles", "pii", "data", "conductor", "budget", "context", "inference", "omniroute", ...NATIVE_CONFIG_BLOCKS, "approvals", "tasks", "task_sweep", "bus", "adapter_timing", "model_relay", "console", "corpus", "checks", "snapshots", "research", "bench", "limits", "review", "recovery", "capabilities", "mlx", "permission_modes", "peers", "terminal", "start", "pet"];
|
|
381
|
+
|
|
382
|
+
/** Older targets parse raw keys before placement/trust checks, so even an empty or refused declaration needs the floor. */
|
|
383
|
+
function rawResearchRunnersPresent(cwd: string): boolean {
|
|
384
|
+
const texts = configSourceTexts(cwd);
|
|
385
|
+
let present = false;
|
|
386
|
+
for (const name of CONFIG_FILES) {
|
|
387
|
+
if (texts[name] === null) continue;
|
|
388
|
+
const raw: unknown = JSON.parse(texts[name]!);
|
|
389
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("invalid configuration");
|
|
390
|
+
const research = (raw as Record<string, unknown>).research;
|
|
391
|
+
if (research !== undefined && (!research || typeof research !== "object" || Array.isArray(research))) throw new Error("invalid research configuration");
|
|
392
|
+
present ||= research !== undefined && Object.hasOwn(research, "runners");
|
|
393
|
+
}
|
|
394
|
+
return present;
|
|
395
|
+
}
|
|
374
396
|
|
|
375
397
|
/** Connection-time policy refresh reads role/feed fields only, never launches or machine-local configuration. */
|
|
376
398
|
export function loadConductorPolicy(cwd: string, layers?: readonly ConfigLayer[], scope: Pick<ReturnType<typeof readInstanceConfig>, "instances" | "catalog"> = readInstanceConfig(cwd, layers)): { roles: Record<string, string[]>; conductor: HubConfig["conductor"]; planner?: string | string[]; notices?: string[] } {
|
|
@@ -478,6 +500,8 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
|
|
|
478
500
|
peerStartConfig(file.peers, instances, adapterCatalog);
|
|
479
501
|
if (file.terminal != null && (typeof file.terminal !== "object" || Array.isArray(file.terminal))) throw new Error("terminal must be an object");
|
|
480
502
|
terminalTemplate(file.terminal?.open);
|
|
503
|
+
if (file.start != null && (typeof file.start !== "object" || Array.isArray(file.start) || Object.keys(file.start).some((key: string) => key !== "agents"))) throw new Error("start must be an object with an agents list");
|
|
504
|
+
if (file.start?.agents !== undefined) checkStartAgents(file.start.agents);
|
|
481
505
|
if (file.pet !== undefined) validatePetConfig(file.pet, "config.pet");
|
|
482
506
|
if (file.pet && typeof file.pet === "object" && !Object.keys(file.pet).length) delete file.pet;
|
|
483
507
|
delete file.declaredExternalInstances; // host-derived refusal rows, never a file claim
|
|
@@ -487,8 +511,11 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
|
|
|
487
511
|
if (name !== "config.local.json" || configTracked(cwd, name) !== false) throw new Error("remote checks require untracked config.local.json");
|
|
488
512
|
if (file.checks.remote !== undefined) remoteRunnerConfig(file.checks.remote);
|
|
489
513
|
}
|
|
514
|
+
if (file.research !== undefined && (!file.research || typeof file.research !== "object" || Array.isArray(file.research))) throw new Error("research must be an object");
|
|
515
|
+
const hasRunners = file.research && Object.hasOwn(file.research, "runners"), runnerDeclarations: unknown = hasRunners ? file.research.runners : undefined;
|
|
490
516
|
const why = stripUntrusted(file, DEFAULT_CONFIG, cwd, name);
|
|
491
517
|
if (why) ignored.push(why);
|
|
518
|
+
if (hasRunners) file.research.runners = parseResearchRunners(runnerDeclarations);
|
|
492
519
|
for (const [peer, mode] of Object.entries(permissionDefaults(file.permission_modes, instances, adapterCatalog))) appliedPermissionSources[peer] = { mode, source: `.agenthub/${name}` };
|
|
493
520
|
return [file];
|
|
494
521
|
});
|
|
@@ -575,10 +602,11 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
|
|
|
575
602
|
checks: { ...DEFAULT_CONFIG.checks, ...file.checks, runner: file.checks?.runner === "remote" ? "remote" : file.checks?.runner === "container" ? "container" : undefined,
|
|
576
603
|
...(file.checks?.remote !== undefined || file.checks?.runner === "remote" ? { remote: remoteRunnerConfig(file.checks?.remote) } : {}) },
|
|
577
604
|
snapshots: { ...DEFAULT_CONFIG.snapshots, enabled: true, ...file.snapshots },
|
|
578
|
-
research: { enabled: file.research?.enabled === true }, // opt-in: anything but true is off
|
|
605
|
+
research: { enabled: file.research?.enabled === true, ...(file.research?.runners !== undefined ? { runners: parseResearchRunners(file.research.runners) } : {}) }, // opt-in: anything but true is off
|
|
579
606
|
bench: { enabled: file.bench?.enabled === true }, // opt-in: a run resets this project's work tree
|
|
580
607
|
peers: { ...instanceModes, ...peerStartConfig(file.peers, instances, adapterCatalog) },
|
|
581
608
|
terminal: { open: terminalTemplate(file.terminal?.open) },
|
|
609
|
+
start: { agents: file.start?.agents === undefined ? [...START_AGENTS_DEFAULT] : checkStartAgents(file.start.agents) },
|
|
582
610
|
limits: { ...PROJECT_LIMITS, ...file.limits }, // on with any project config (issue #38)
|
|
583
611
|
review: { ...DEFAULT_CONFIG.review, ...file.review },
|
|
584
612
|
recovery: { ...DEFAULT_CONFIG.recovery, ...file.recovery },
|
|
@@ -698,6 +726,7 @@ export interface DaemonOptions {
|
|
|
698
726
|
|
|
699
727
|
interface Client {
|
|
700
728
|
authed: boolean;
|
|
729
|
+
protocol?: number;
|
|
701
730
|
role?: "peer" | "console" | "tools";
|
|
702
731
|
peer?: PeerId;
|
|
703
732
|
/** agent-hub#1036: this connection is a distinct session; `peer` is the id the daemon minted for it (`<peer>~<origin>`), never a bus peer. */
|
|
@@ -747,6 +776,8 @@ function capturedRecoverySourceDigest(): string | undefined {
|
|
|
747
776
|
}
|
|
748
777
|
|
|
749
778
|
export async function startDaemon(opts: DaemonOptions) {
|
|
779
|
+
// Refuse unknown formats before logs, session cleanup, module factories or any writable store is opened.
|
|
780
|
+
assertHubDbCompatible(join(opts.stateDir, "hub.db"));
|
|
750
781
|
const resolveExecutionCwd = (peer: string, currentDeclarations: InstancesConfig | undefined, currentRoles: readonly string[], catalog: ReturnType<typeof createProjectAdapterCatalog>): string =>
|
|
751
782
|
resolveInstanceExecutionCwd(opts.cwd, peer, currentDeclarations, currentRoles, catalog, undefined, undefined, opts.instanceExecutionGit);
|
|
752
783
|
const projectId = opts.projectId ?? randomUUID();
|
|
@@ -2048,6 +2079,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2048
2079
|
};
|
|
2049
2080
|
const tasks: Tasks = new Tasks({
|
|
2050
2081
|
releaseWindow,
|
|
2082
|
+
researchRunners: () => {
|
|
2083
|
+
if (config.research.runners === undefined) return {};
|
|
2084
|
+
const why = configRefusal(opts.cwd, "config.local.json");
|
|
2085
|
+
if (why || configSourceTexts(opts.cwd)["config.local.json"] === null) throw new Error(`research.runners source trust unavailable${why ? `: ${why}` : ""}`);
|
|
2086
|
+
return parseResearchRunners(config.research.runners);
|
|
2087
|
+
},
|
|
2051
2088
|
capabilities: peerCapabilities, repository: memoryRepo,
|
|
2052
2089
|
contextAdmission: () => ({
|
|
2053
2090
|
policy: { max_used: config.context.max_assignment_used ?? 0, stale_min: config.context.stale_min },
|
|
@@ -3483,6 +3520,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3483
3520
|
}
|
|
3484
3521
|
// Inside a PII turn the worker's words may carry the PII whatever they are attached to: a note would go to
|
|
3485
3522
|
// shared memory and a new task could be routed to a cloud peer without matching any pattern.
|
|
3523
|
+
if (op === "hub_experiment_record" && (piiTurn || turns.get(by)?.private || contextPii(by))) throw new PreEffectToolRefusal(`${op} is not available while working on a PII task: its text must not leave this machine`);
|
|
3486
3524
|
if (piiTurn && (MEMORY_READ_TOOLS.has(op) || op === "hub_memory_correct" || op === "hub_remember" || op === "hub_task_propose" || op === "hub_sdlc_record" || op === "hub_plan_review" || op === "hub_sdlc_costs" || op === "hub_sdlc_metrics" || op.startsWith("hub_release_") || op === "hub_backlog_return_untouched" || op === "hub_backlog_plan" || op === "hub_backlog_undo" || op === "hub_backlog_pull" || op === "hub_backlog_split" || op === "release_new" || op === "hub_study_draft" || op === "hub_study_revise")) throw new PreEffectToolRefusal(`${op} is not available while working on a PII task: its text must not leave this machine`);
|
|
3487
3525
|
// The same words attached to an ordinary task would reach its reviewer, its owner, overlapping owners (a plan, the
|
|
3488
3526
|
// completed-change notice) and shared memory. A turn that holds a PII task acts on ordinary tasks in a turn of its own.
|
|
@@ -3697,6 +3735,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3697
3735
|
return JSON.stringify(await tasks.studyRevise(by, a));
|
|
3698
3736
|
case "hub_study_show":
|
|
3699
3737
|
return JSON.stringify(a.id === undefined ? tasks.studyList(by, a) : tasks.studyShow(by, a));
|
|
3738
|
+
// #602 (issue 932 T3): the read of the experiment records Tasks already keeps; it runs nothing and reads no file, and a private record is blanked inside the view.
|
|
3739
|
+
case "hub_experiment_show":
|
|
3740
|
+
return JSON.stringify(await tasks.experimentShow(by, a));
|
|
3741
|
+
case "hub_experiment_record":
|
|
3742
|
+
return JSON.stringify(await tasks.experimentRecord(by, a));
|
|
3700
3743
|
case "hub_study_handoff":
|
|
3701
3744
|
if (!a || typeof a !== "object" || Array.isArray(a) || Object.keys(a).some(key => key !== "handoff")) throw new PreEffectToolRefusal("study handoff takes only a handoff object");
|
|
3702
3745
|
return JSON.stringify(await tasks.studyHandoff(by, a.handoff));
|
|
@@ -4217,7 +4260,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4217
4260
|
docs: opts.releaseDocEvidence,
|
|
4218
4261
|
gh: (args, timeout) => sdlcGh(args, timeout, opts.cwd),
|
|
4219
4262
|
current: () => !stopping && !recoveryActive() && !bus.isRecoveryHeld,
|
|
4220
|
-
|
|
4263
|
+
conductor: () => {
|
|
4264
|
+
const peer = conductorPeer(config.roles);
|
|
4265
|
+
return peer && !turns.get(peer)?.private && !contextPii(peer) ? peer : undefined;
|
|
4266
|
+
},
|
|
4267
|
+
record: receipt => event({ type: "release_authorization", by: receipt.by, authorization: receipt.id, commit: receipt.commit, version: receipt.version, docsDigest: receipt.docs.digest, documents: receipt.docs.documents }),
|
|
4221
4268
|
});
|
|
4222
4269
|
const terminalReads = terminalOwnerReads();
|
|
4223
4270
|
const terminalLaunches = new TerminalLaunches({
|
|
@@ -4400,6 +4447,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4400
4447
|
const metadata = peer.recoveryMetadata?.() ?? {};
|
|
4401
4448
|
const row: RestartPeerSnapshot = { id, state: peer.state, queueIds: bus.queueIds(id), ...(metadata.launch ? { launch: metadata.launch as Record<string, unknown> } : {}) };
|
|
4402
4449
|
if (typeof metadata.threadId === "string") row.threadId = metadata.threadId;
|
|
4450
|
+
if (typeof metadata.controlProtocol === "number" && Number.isSafeInteger(metadata.controlProtocol) && metadata.controlProtocol > 0) row.controlProtocol = metadata.controlProtocol;
|
|
4403
4451
|
const sessionId = definitionOf(id)?.transport === "claude-channel" ? claudeSession(id).sessionId : metadata.sessionId;
|
|
4404
4452
|
if (typeof sessionId === "string" && sessionId) row.sessionId = sessionId;
|
|
4405
4453
|
return [id, row];
|
|
@@ -4422,7 +4470,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4422
4470
|
const researchDispatch = board.researchDispatchIntegrity();
|
|
4423
4471
|
const researchExperiments = board.researchExperimentsIntegrity();
|
|
4424
4472
|
const researchComparisons = board.researchComparisonIntegrity();
|
|
4425
|
-
|
|
4473
|
+
const researchRunners = board.runnerReceiptIntegrity();
|
|
4474
|
+
return { queues, manualPaused: [...manualPaused].sort(), boardDigest: digest(boardState), budgetDigest: digest(budgetState), ...(research.campaigns || research.units ? { research } : {}), ...(researchDispatch.activations || researchDispatch.handoffs ? { researchDispatch } : {}), ...(researchExperiments.protocols || researchExperiments.attempts || researchExperiments.events || researchExperiments.observations ? { researchExperiments } : {}), ...(researchComparisons.plans || researchComparisons.approvals || researchComparisons.contexts ? { researchComparisons } : {}), ...(researchRunners.count ? { researchRunners } : {}), ...(allExternalInstanceIds().length ? { externalBindingsDigest: digest(allExternalInstanceIds().sort().map(id => [id, (externalRefusedRows.has(id) ? externalBindings.get(id) : retainedExternalBinding(id)) ?? null])) } : {}) };
|
|
4426
4475
|
};
|
|
4427
4476
|
/** The integrity in the shape `expected` was recorded in: the current one unless only an older shape matches. */
|
|
4428
4477
|
const integrityAs = (expected: unknown) => {
|
|
@@ -4512,7 +4561,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4512
4561
|
...(lifecycleConfig.mode !== "off" ? { lifecycleCriteria: true } : {}),
|
|
4513
4562
|
// Every supported peer's membership, for launchers that run outside the console role (#484).
|
|
4514
4563
|
roster: [...KNOWN_PEERS, ...OPTIONAL_PEERS].map(peer => ({ peer, on: onRoster(peer) })),
|
|
4515
|
-
protocol: PROTOCOL,
|
|
4564
|
+
protocol: PROTOCOL_MIN, protocolMin: PROTOCOL_MIN, protocolMax: PROTOCOL,
|
|
4516
4565
|
features: DAEMON_FEATURES,
|
|
4517
4566
|
configuredAdapters: configuredIds,
|
|
4518
4567
|
...(externalAdapters.length ? { externalAdapters } : {}),
|
|
@@ -6708,6 +6757,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6708
6757
|
// Retired dictionaries never enter active publication; hub ids remain opaque keys.
|
|
6709
6758
|
if (prefix === "routing.targets" || prefix === "routing.routes") return [];
|
|
6710
6759
|
if (prefix === "routing.hub_routes") return [prefix];
|
|
6760
|
+
if (prefix === "research.runners") return [prefix];
|
|
6711
6761
|
if (value && typeof value === "object" && !Array.isArray(value)) return Object.entries(value).flatMap(([key, child]) => declaredPaths(child, prefix ? `${prefix}.${key}` : key));
|
|
6712
6762
|
return prefix ? [prefix] : [];
|
|
6713
6763
|
}
|
|
@@ -7586,6 +7636,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
7586
7636
|
// Raw presence matters even for a disabled or ignored shared declaration: an older target parses the unknown key first.
|
|
7587
7637
|
if (namesRunners) return "target lacks research_records; the project's research.runners cannot be loaded by that daemon";
|
|
7588
7638
|
}
|
|
7639
|
+
if (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("research_runners")) {
|
|
7640
|
+
if (board.hasRunnerReceipts()) return "target lacks research_runners; persisted runner attestations require a supporting daemon";
|
|
7641
|
+
try {
|
|
7642
|
+
if (config.research.runners !== undefined || rawResearchRunnersPresent(opts.cwd)) return "target lacks research_runners; the project's research.runners requires a supporting daemon";
|
|
7643
|
+
} catch { return "research_runners compatibility is unknown; configuration could not be read"; }
|
|
7644
|
+
}
|
|
7589
7645
|
if ((!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("research_dispatch")) && board.hasResearchDispatchState())
|
|
7590
7646
|
return "target lacks research_dispatch; persisted campaign stop policies and handoffs require a supporting daemon";
|
|
7591
7647
|
if ((!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("research_dropped_offers")) && board.hasResearchDroppedOffers())
|
|
@@ -7637,8 +7693,16 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
7637
7693
|
} catch { return "calendar_schedules compatibility is unknown; configuration could not be read"; }
|
|
7638
7694
|
if (namesCalendar) return "target lacks calendar_schedules; the project's schedules use cron or tz, which that daemon cannot load";
|
|
7639
7695
|
}
|
|
7696
|
+
if (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("schema_guard"))
|
|
7697
|
+
return "target lacks schema_guard; controlled recovery requires a guard-aware runtime";
|
|
7640
7698
|
return undefined;
|
|
7641
7699
|
};
|
|
7700
|
+
// Preserve lifecycle fences before capability probes; the prepare branch rechecks them after resume capture awaits.
|
|
7701
|
+
if (msg.op === "prepare") {
|
|
7702
|
+
if (rosterChanging) return recoveryError("roster change is in progress; wait for ahub status before preparing recovery");
|
|
7703
|
+
if (requestedPeerStops.size) return recoveryError("peer stop is in progress; wait for ahub status before preparing recovery");
|
|
7704
|
+
if (recoveryCommitted) return recoveryError("recovery commit is already in progress");
|
|
7705
|
+
}
|
|
7642
7706
|
const checkTarget = msg.op === "prepare" || msg.op === "commit" || msg.op === "inspect" && msg.targetFeatures !== undefined;
|
|
7643
7707
|
if (checkTarget) {
|
|
7644
7708
|
const error = incompatibleRecoveryTarget(); if (error) return recoveryError(error);
|
|
@@ -7906,7 +7970,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
7906
7970
|
if (!c.authed) {
|
|
7907
7971
|
if (msg.t !== "hello" || msg.token !== token) return sock.close(4401, "bad token");
|
|
7908
7972
|
if (typeof msg.peer === "string" && msg.peer.includes("@")) return sock.close(4403, "remote identities have no control connection");
|
|
7909
|
-
|
|
7973
|
+
const offered = protocolWindow(msg.v, msg.vMin), selectedProtocol = offered && negotiateProtocol({ v: PROTOCOL, vMin: PROTOCOL_MIN }, offered);
|
|
7974
|
+
if (selectedProtocol === undefined) {
|
|
7910
7975
|
// An outdated plugin would drop every digest without a trace. Refuse it loudly instead.
|
|
7911
7976
|
log(`refused ${msg.role} ${msg.peer ?? ""}: wire version ${msg.v ?? 1}, hub speaks ${PROTOCOL} (claude plugin update agent-hub@agent-hub)`);
|
|
7912
7977
|
return sock.close(4426, `wire version mismatch: hub speaks ${PROTOCOL}; update the agent-hub plugin`);
|
|
@@ -7917,6 +7982,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
7917
7982
|
try { refreshConductorPolicy(); } catch { return sock.close(4403, "invalid conductor role/feed configuration"); }
|
|
7918
7983
|
if (stopping && msg.role !== "console") return sock.close(1013, "hub is stopping");
|
|
7919
7984
|
c.authed = true;
|
|
7985
|
+
c.protocol = selectedProtocol;
|
|
7920
7986
|
c.role = msg.role;
|
|
7921
7987
|
if (c.role === "console") consoles.add(sock);
|
|
7922
7988
|
else if (c.role === "tools" && sessionHello(msg)) {
|
|
@@ -8003,7 +8069,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
8003
8069
|
}
|
|
8004
8070
|
// #604 PR B: one attach event per admitted peer or tools hello, with the same fields an envelope's `origin` carries. A refused hello returned above and writes none.
|
|
8005
8071
|
if (c.role === "peer" || c.role === "tools") event(attachEvent({ role: c.role, peer: c.peer, launchId: c.launchId, ...(c.session ? { session: true } : {}) }));
|
|
8006
|
-
return void reply({ t: "welcome", projectId, instanceId, cwd: opts.cwd, protocol: PROTOCOL, features: DAEMON_FEATURES, ...(c.session ? { session: c.peer } : {}) });
|
|
8072
|
+
return void reply({ t: "welcome", projectId, instanceId, cwd: opts.cwd, protocol: c.protocol, protocolMin: PROTOCOL_MIN, protocolMax: PROTOCOL, features: DAEMON_FEATURES, ...(c.session ? { session: c.peer } : {}) });
|
|
8007
8073
|
}
|
|
8008
8074
|
const committedSourceProof = c.role === "tools" && msg.t === "recovery_authority" && msg.op === "verify" && recoveryCommitted
|
|
8009
8075
|
&& msg.binding?.operationId === recoveryOperationId && msg.binding?.sourceInstanceId === instanceId;
|
|
@@ -8272,11 +8338,15 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
8272
8338
|
const plan = msg.plan as UpgradePlan;
|
|
8273
8339
|
const { fingerprint, ...body } = plan ?? {} as UpgradePlan;
|
|
8274
8340
|
if (!plan || !["restart", "upgrade"].includes(plan.kind) || plan.projects.some(row => row.terminals.length > 0) || planFingerprint(body) !== fingerprint || fingerprint !== raw.planFingerprint || plan.kind !== raw.kind || existsSync(operationPath(raw.operationId))
|
|
8275
|
-
|| raw.sourceInstanceId !== instanceId || recoveryActive() || recoveryLock() || !plan.projects.some(row => row.project.id === projectId && row.project.root === opts.cwd && row.project.stateDir === opts.stateDir && row.source.instanceId === instanceId)) throw new RecoveryAuthorityError();
|
|
8341
|
+
|| raw.sourceInstanceId !== instanceId || recoveryActive() || recoveryLock(undefined, opts.cwd) || !plan.projects.some(row => row.project.id === projectId && row.project.root === opts.cwd && row.project.stateDir === opts.stateDir && row.source.instanceId === instanceId)) throw new RecoveryAuthorityError();
|
|
8342
|
+
const { recoveryScope } = await import("../cli/upgrade.ts"); current();
|
|
8343
|
+
const scope = recoveryScope(plan);
|
|
8344
|
+
if (scope !== undefined && scope !== opts.cwd) throw new RecoveryAuthorityError();
|
|
8276
8345
|
if (plan.kind === "restart" && plan.projects.length !== 1) throw new RecoveryAuthorityError();
|
|
8277
8346
|
if (plan.kind === "upgrade") {
|
|
8278
|
-
//
|
|
8347
|
+
// Scoped upgrades bind the source's one registered project; retained shared upgrades still cover every affected project.
|
|
8279
8348
|
const affected = registeredProjects().filter(project => {
|
|
8349
|
+
if (scope !== undefined) return project.root === scope;
|
|
8280
8350
|
const control = readControl(project.stateDir);
|
|
8281
8351
|
return control ? processLiveness(control.pid, control.pidSignature) !== "gone" : project.pid !== null && processLiveness(project.pid, project.pidSignature) !== "gone";
|
|
8282
8352
|
});
|
|
@@ -8289,23 +8359,26 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
8289
8359
|
if (!Array.isArray(selected.reconnectOnly) || new Set(selected.reconnectOnly).size !== selected.reconnectOnly.length) throw new RecoveryAuthorityError();
|
|
8290
8360
|
for (const id of selected.reconnectOnly) {
|
|
8291
8361
|
const row = selected.source.peers.find(peer => peer.id === id), owner = bus.peers.get(id), definition = definitionOf(id);
|
|
8292
|
-
if (!row || selected.freshStart?.includes(id) || Object.keys(row).some(key => !["id", "state", "observation"].includes(key)) || !row.observation
|
|
8362
|
+
if (!row || selected.freshStart?.includes(id) || Object.keys(row).some(key => !["id", "state", "observation", "controlProtocol"].includes(key)) || !row.observation
|
|
8293
8363
|
|| Object.keys(row.observation).some(key => !["kind", "attached", "sessionDigest"].includes(key))
|
|
8294
8364
|
|| !isWsPeer(owner) || definition?.transport !== "claude-channel" || !owner.attached || owner.managedLaunchId || claudeLaunchProof.verified(id)
|
|
8365
|
+
|| !protocolWindow(row.controlProtocol) || owner.recoveryMetadata().controlProtocol !== row.controlProtocol
|
|
8295
8366
|
|| row.observation.kind !== definition.kind || row.observation.attached !== owner.attached || row.observation.sessionDigest !== recoverySessionDigest(claudeSession(id).sessionId)) throw new RecoveryAuthorityError();
|
|
8296
8367
|
const generation = owner.sessionGeneration;
|
|
8297
8368
|
const launcher = await launcherOf(id, opts.cwd, { stateDir: opts.stateDir, instanceId }); current();
|
|
8298
|
-
if (launcher && launcher.state !== "gone" || bus.peers.get(id) !== owner || owner.sessionGeneration !== generation || !owner.attached || owner.managedLaunchId || claudeLaunchProof.verified(id)
|
|
8369
|
+
if (launcher && launcher.state !== "gone" || bus.peers.get(id) !== owner || owner.sessionGeneration !== generation || !owner.attached || owner.managedLaunchId || claudeLaunchProof.verified(id)
|
|
8370
|
+
|| owner.recoveryMetadata().controlProtocol !== row.controlProtocol) throw new RecoveryAuthorityError();
|
|
8299
8371
|
reconnectCurrent.push(() => bus.peers.get(id) === owner && owner.sessionGeneration === generation && owner.attached && !owner.managedLaunchId && !claudeLaunchProof.verified(id)
|
|
8372
|
+
&& owner.recoveryMetadata().controlProtocol === row.controlProtocol
|
|
8300
8373
|
&& definitionOf(id)?.kind === row.observation!.kind && owner.attached === row.observation!.attached && recoverySessionDigest(claudeSession(id).sessionId) === row.observation!.sessionDigest);
|
|
8301
8374
|
reconnect.set(id, row.observation);
|
|
8302
8375
|
}
|
|
8303
8376
|
}
|
|
8304
8377
|
if (reconnectCurrent.some(check => !check())) throw new RecoveryAuthorityError();
|
|
8305
|
-
const normalize = (row: { id: string; state: string; observation?: { kind: string; attached: boolean; sessionDigest?: string }; threadId?: string; sessionId?: string; sessionFile?: string; args?: Record<string, string>; launch?: Record<string, unknown> }) => {
|
|
8378
|
+
const normalize = (row: { id: string; state: string; controlProtocol?: number; observation?: { kind: string; attached: boolean; sessionDigest?: string }; threadId?: string; sessionId?: string; sessionFile?: string; args?: Record<string, string>; launch?: Record<string, unknown> }) => {
|
|
8306
8379
|
const launch = row.args ?? row.launch ?? {};
|
|
8307
8380
|
const observed = reconnect.get(row.id);
|
|
8308
|
-
return { id: row.id, active: row.state !== "offline", threadId: row.threadId, sessionId: observed ? undefined : row.sessionId,
|
|
8381
|
+
return { id: row.id, active: row.state !== "offline", controlProtocol: row.controlProtocol, threadId: row.threadId, sessionId: observed ? undefined : row.sessionId,
|
|
8309
8382
|
...(observed ? { sessionDigest: row.observation?.sessionDigest ?? recoverySessionDigest(row.sessionId) } : {}),
|
|
8310
8383
|
...(observed ? { kind: row.observation?.kind ?? definitionOf(row.id)?.kind, attached: row.observation?.attached ?? (isWsPeer(bus.peers.get(row.id)) && (bus.peers.get(row.id) as import("../adapters/channel-peer.ts").WsPeer).attached) } : {}),
|
|
8311
8384
|
sessionFile: row.sessionFile ?? (typeof launch.sessionFile === "string" ? launch.sessionFile : undefined),
|
|
@@ -8319,9 +8392,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
8319
8392
|
if (raw.kind === "restart") {
|
|
8320
8393
|
if (!sourcePackageDigest || raw.targetDigest !== sourcePackageDigest) throw new RecoveryAuthorityError();
|
|
8321
8394
|
} else {
|
|
8322
|
-
const staged = join(hubHome(), "releases", plan.version);
|
|
8323
|
-
|
|
8324
|
-
|
|
8395
|
+
const staged = join(scope === undefined ? hubHome() : realPath(hubHome()), "releases", plan.version);
|
|
8396
|
+
const expectedTarget = scope === undefined ? join(staged, "node_modules/@staix/agent-hub") : join(staged, `source-${raw.targetDigest}`);
|
|
8397
|
+
if (raw.targetRoot !== expectedTarget) throw new RecoveryAuthorityError();
|
|
8398
|
+
// A new/expired grant still needs registry proof; verification of an already issued grant below uses only retained target bytes.
|
|
8399
|
+
const verified = JSON.parse(readFileSync(join(scope === undefined ? staged : join(staged, "registry"), "verified.json"), "utf8"));
|
|
8325
8400
|
if (verified.digest !== raw.targetDigest || verified.integrity !== plan.integrity) throw new RecoveryAuthorityError();
|
|
8326
8401
|
}
|
|
8327
8402
|
if (!sourcePackageDigest || plan.sourceDigest !== sourcePackageDigest && (plan.kind !== "upgrade" || plan.sourceDigest !== raw.targetDigest)) throw new RecoveryAuthorityError();
|
|
@@ -8726,6 +8801,15 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
8726
8801
|
);
|
|
8727
8802
|
return;
|
|
8728
8803
|
}
|
|
8804
|
+
if (msg.op === "authorize-release") {
|
|
8805
|
+
const asker = c.role === "console" ? USER : c.role === "tools" && c.peer && conductorPeer(config.roles) === c.peer && !c.toolsOwner && !turns.get(c.peer)?.private && !contextPii(c.peer) ? c.peer : undefined;
|
|
8806
|
+
if (!asker) return void reply({ ok: false, error: "only the console user or the configured conductor may authorize a release" });
|
|
8807
|
+
void releaseAuthorizations.authorize(asker, msg.version, msg.commit).then(
|
|
8808
|
+
authorization => reply({ ok: true, authorization }),
|
|
8809
|
+
error => reply({ ok: false, error: releaseAuthorizationError(error) }),
|
|
8810
|
+
);
|
|
8811
|
+
return;
|
|
8812
|
+
}
|
|
8729
8813
|
if (c.role !== "console" && ["status", "explain", "queue", "release"].includes(msg.op)) {
|
|
8730
8814
|
let current: () => void;
|
|
8731
8815
|
try { current = publicReadCurrent(sock); current(); } catch { return void reply({ ok: false, error: "public lifecycle read unavailable for this caller" }); }
|
|
@@ -8766,13 +8850,6 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
8766
8850
|
return;
|
|
8767
8851
|
}
|
|
8768
8852
|
if (c.role !== "console") return void reply({ ok: false, error: "SDLC readback is console only; task owners record ids through hub_sdlc_record" });
|
|
8769
|
-
if (msg.op === "authorize-release") {
|
|
8770
|
-
void releaseAuthorizations.authorize(USER, msg.version, msg.commit).then(
|
|
8771
|
-
authorization => reply({ ok: true, authorization }),
|
|
8772
|
-
error => reply({ ok: false, error: releaseAuthorizationError(error) }),
|
|
8773
|
-
);
|
|
8774
|
-
return;
|
|
8775
|
-
}
|
|
8776
8853
|
if (msg.op === "queue" || msg.op === "release") {
|
|
8777
8854
|
if (typeof msg.repo !== "string" || !Array.isArray(msg.liveIssues ?? []) || (msg.liveIssues ?? []).length > 32) return void reply({ ok: false, error: "invalid delivery readback arguments" });
|
|
8778
8855
|
const read = msg.op === "queue" ? tasks.deliveryQueue(msg.repo) : tasks.releaseReadiness(msg.repo, msg.liveIssues ?? []);
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { constants, Database } from "bun:sqlite";
|
|
2
|
+
export type { Database } from "bun:sqlite";
|
|
3
|
+
import { closeSync, lstatSync, openSync, readSync, statSync } from "node:fs";
|
|
4
|
+
import { pathToFileURL } from "node:url";
|
|
5
|
+
import { VERSION } from "../version.ts";
|
|
6
|
+
import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
|
|
7
|
+
|
|
8
|
+
/** Bump only for an incompatible hub.db format; compatible releases keep the introducing version. */
|
|
9
|
+
export const HUB_DB_SCHEMA = 2;
|
|
10
|
+
/** New/legacy databases keep format 1 until an incompatible record is committed. */
|
|
11
|
+
export const HUB_DB_BASE_SCHEMA = 1;
|
|
12
|
+
/** schema is the maximum supported format, never a request to migrate an existing compatible database. */
|
|
13
|
+
export interface HubDbSchemaReader { schema: number; version: string }
|
|
14
|
+
export interface HubDatabaseOptions {
|
|
15
|
+
readonly?: boolean;
|
|
16
|
+
create?: boolean;
|
|
17
|
+
reader?: HubDbSchemaReader;
|
|
18
|
+
}
|
|
19
|
+
export class HubDbSchemaError extends Error {
|
|
20
|
+
readonly code = "HUB_DB_SCHEMA";
|
|
21
|
+
constructor(message: string, readonly requiredVersion?: string) { super(message); this.name = "HubDbSchemaError"; }
|
|
22
|
+
}
|
|
23
|
+
interface HubDbSchema { schema: number; requiredVersion: string }
|
|
24
|
+
const VERSION_TEXT = /^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?(?:\+[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?$/;
|
|
25
|
+
const safeVersion = (value: unknown): value is string => typeof value === "string" && value.length <= 64 && VERSION_TEXT.exec(value)?.[0] === value;
|
|
26
|
+
const defaultReader = (): HubDbSchemaReader => ({ schema: HUB_DB_SCHEMA, version: VERSION });
|
|
27
|
+
const invalidSchema = () => new HubDbSchemaError("this project's hub.db schema metadata is invalid; inspect or restore the database before starting agent-hub");
|
|
28
|
+
const busy = (error: unknown): boolean => {
|
|
29
|
+
const value = error as { code?: unknown; message?: unknown } | null;
|
|
30
|
+
return typeof value?.code === "string" && /^SQLITE_(BUSY|LOCKED)(?:_|$)/.test(value.code)
|
|
31
|
+
|| typeof value?.message === "string" && /database (?:is )?(?:locked|busy)|database table is locked/i.test(value.message);
|
|
32
|
+
};
|
|
33
|
+
function readerValue(reader: HubDbSchemaReader): HubDbSchemaReader {
|
|
34
|
+
if (!reader || !Number.isSafeInteger(reader.schema) || reader.schema < 1 || !safeVersion(reader.version)) throw invalidSchema();
|
|
35
|
+
return { schema: reader.schema, version: reader.version };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** No persistent PRAGMA or schema/data write runs while reading the marker. */
|
|
39
|
+
function schemaOn(db: Database): HubDbSchema | undefined {
|
|
40
|
+
try {
|
|
41
|
+
const entries = db.query("SELECT type FROM sqlite_master WHERE name='hub_schema' COLLATE NOCASE LIMIT 2").all() as { type: string }[];
|
|
42
|
+
if (!entries.length) return undefined;
|
|
43
|
+
if (entries.length !== 1 || entries[0]!.type !== "table") throw invalidSchema();
|
|
44
|
+
const rows = db.query("SELECT id,schema,CASE WHEN typeof(required_version)='text' AND length(CAST(required_version AS BLOB))<=64 THEN required_version END AS required_version FROM hub_schema LIMIT 2")
|
|
45
|
+
.all() as { id: number; schema: number; required_version: string | null }[];
|
|
46
|
+
const row = rows[0];
|
|
47
|
+
if (rows.length !== 1 || !row || row.id !== 1 || !Number.isSafeInteger(row.schema) || row.schema < 1 || !safeVersion(row.required_version)) throw invalidSchema();
|
|
48
|
+
return { schema: row.schema, requiredVersion: row.required_version };
|
|
49
|
+
} catch (error) {
|
|
50
|
+
// Native corruption is unreadable storage, not a parsed schema claim; reset may archive it intact.
|
|
51
|
+
const code = (error as { code?: unknown } | null)?.code;
|
|
52
|
+
if (error instanceof HubDbSchemaError || busy(error) || typeof code === "string" && /^(?:SQLITE_NOTADB|SQLITE_CORRUPT)(?:_|$)/.test(code)) throw error;
|
|
53
|
+
throw invalidSchema();
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function checkOn(db: Database, reader: HubDbSchemaReader): HubDbSchema | undefined {
|
|
57
|
+
const stored = schemaOn(db);
|
|
58
|
+
if (stored && stored.schema > reader.schema) throw new HubDbSchemaError(`this project was opened by agent-hub ${stored.requiredVersion}; use ${stored.requiredVersion} or later`, stored.requiredVersion);
|
|
59
|
+
return stored;
|
|
60
|
+
}
|
|
61
|
+
function existingFile(file: string): boolean {
|
|
62
|
+
if (file === ":memory:") return false;
|
|
63
|
+
let info;
|
|
64
|
+
try { info = lstatSync(file); }
|
|
65
|
+
catch (error) { if ((error as { code?: string }).code === "ENOENT") return false; throw error; }
|
|
66
|
+
// A dangling link or non-file is unknown storage, never a missing legacy database.
|
|
67
|
+
if (info.isSymbolicLink()) { try { info = statSync(file); } catch { throw invalidSchema(); } }
|
|
68
|
+
if (!info.isFile()) throw invalidSchema();
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Closed, checkpointed WAL files need a quiet metadata probe on macOS; unknown file/mode state never qualifies. */
|
|
73
|
+
function checkpointedWal(file: string): boolean {
|
|
74
|
+
let fd: number | undefined;
|
|
75
|
+
try {
|
|
76
|
+
fd = openSync(file, "r");
|
|
77
|
+
const header = Buffer.alloc(20);
|
|
78
|
+
return readSync(fd, header, 0, header.length, 0) === header.length && header.toString("ascii", 0, 15) === "SQLite format 3"
|
|
79
|
+
&& header[15] === 0 && header[18] === 2 && header[19] === 2;
|
|
80
|
+
} catch { return false; }
|
|
81
|
+
finally { if (fd !== undefined) closeSync(fd); }
|
|
82
|
+
}
|
|
83
|
+
function noSidecars(file: string): boolean {
|
|
84
|
+
for (const suffix of ["-wal", "-shm", "-journal"]) {
|
|
85
|
+
try { lstatSync(`${file}${suffix}`); return false; }
|
|
86
|
+
catch (error) { if ((error as { code?: string }).code !== "ENOENT") return false; }
|
|
87
|
+
}
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
function probeCompatible(file: string, reader: HubDbSchemaReader, quiet: boolean): void {
|
|
91
|
+
const uri = pathToFileURL(file); uri.searchParams.set("immutable", "1");
|
|
92
|
+
const db = quiet ? new Database(uri.href, constants.SQLITE_OPEN_READONLY | constants.SQLITE_OPEN_URI) : new Database(file, { readonly: true, create: false });
|
|
93
|
+
try {
|
|
94
|
+
db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`); // connection-local, not persisted
|
|
95
|
+
checkOn(db, reader);
|
|
96
|
+
} finally { db.close(); }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Live WAL reads may take SHM read locks. Refusal performs no SQL/schema/data or DB/WAL write; SHM locks are SQLite's ceiling. */
|
|
100
|
+
export function assertHubDbCompatible(file: string, reader: HubDbSchemaReader = defaultReader()): void {
|
|
101
|
+
const supported = readerValue(reader);
|
|
102
|
+
if (!existingFile(file)) return;
|
|
103
|
+
if (checkpointedWal(file) && noSidecars(file)) {
|
|
104
|
+
// This handle is short-lived and never escapes. A live returned handle must continue observing later WAL commits.
|
|
105
|
+
let failure: unknown, failed = false;
|
|
106
|
+
try { probeCompatible(file, supported, true); } catch (error) { failure = error; failed = true; }
|
|
107
|
+
if (!noSidecars(file)) { probeCompatible(file, supported, false); return; }
|
|
108
|
+
if (failed) throw failure;
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
probeCompatible(file, supported, false);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Every hub.db writer enters here before its own persistent PRAGMAs, migrations or maintenance writes. */
|
|
115
|
+
export function openHubDatabase(file: string, options: HubDatabaseOptions = {}): Database {
|
|
116
|
+
const reader = readerValue(options.reader ?? defaultReader());
|
|
117
|
+
// Refuse a future file without opening a writable connection, which can perform SQLite recovery work itself.
|
|
118
|
+
assertHubDbCompatible(file, reader);
|
|
119
|
+
const db = new Database(file, options.readonly ? { readonly: true, create: false } : { readwrite: true, create: options.create ?? true });
|
|
120
|
+
try {
|
|
121
|
+
db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
|
|
122
|
+
if (options.readonly) { checkOn(db, reader); return db; }
|
|
123
|
+
// The second read and legacy baseline stamp share the writer lock, without filesystem work in SQL.
|
|
124
|
+
// Existing compatible markers never advance here; an incompatible migration must advance its marker with its actual DDL/data transaction.
|
|
125
|
+
db.transaction(() => {
|
|
126
|
+
const stored = checkOn(db, reader);
|
|
127
|
+
if (!stored) {
|
|
128
|
+
db.run("CREATE TABLE hub_schema (id INTEGER PRIMARY KEY CHECK(id=1),schema INTEGER NOT NULL,required_version TEXT NOT NULL)");
|
|
129
|
+
db.query("INSERT INTO hub_schema(id,schema,required_version) VALUES(1,?,?)").run(HUB_DB_BASE_SCHEMA, VERSION);
|
|
130
|
+
}
|
|
131
|
+
}).immediate();
|
|
132
|
+
return db;
|
|
133
|
+
} catch (error) { db.close(); throw error; }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The format marker advances only in the same transaction as the incompatible data it protects. */
|
|
137
|
+
export function advanceHubDbSchema(db: Database, schema: number, requiredVersion: string): void {
|
|
138
|
+
if (!db.inTransaction || !Number.isSafeInteger(schema) || schema < HUB_DB_BASE_SCHEMA || schema > HUB_DB_SCHEMA || !safeVersion(requiredVersion))
|
|
139
|
+
throw new HubDbSchemaError("hub.db schema advance requires an active data transaction and a supported format");
|
|
140
|
+
const stored = checkOn(db, defaultReader());
|
|
141
|
+
if (!stored) throw invalidSchema();
|
|
142
|
+
if (stored.schema < schema) db.query("UPDATE hub_schema SET schema=?,required_version=? WHERE id=1").run(schema, requiredVersion);
|
|
143
|
+
}
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { chmodSync, existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
2
2
|
import { dirname } from "node:path";
|
|
3
|
-
import { Database } from "
|
|
3
|
+
import type { Database } from "./db-schema.ts";
|
|
4
|
+
import { openHubDatabase } from "./db-schema.ts";
|
|
4
5
|
import type { BusSnapshot } from "./bus.ts";
|
|
5
6
|
import type { QueuePressure } from "./queue-backpressure.ts";
|
|
6
7
|
import { HUB, KINDS, USER, type Envelope, type Kind } from "./envelope.ts";
|
|
7
8
|
import { heldDeliveryHints } from "./delivery-hints.ts";
|
|
8
9
|
import { processLiveness } from "../pi/process-signature.ts";
|
|
9
|
-
import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
|
|
10
10
|
import { parseRemotePeerId } from "../rooms/application.ts";
|
|
11
11
|
import { validateDeferredHub } from "./deferred-hub.ts";
|
|
12
12
|
|
|
@@ -282,8 +282,7 @@ export class DeliveryJournal {
|
|
|
282
282
|
this.operationId = options.operationId;
|
|
283
283
|
this.terminalHistoryLimit = options.terminalHistoryLimit;
|
|
284
284
|
mkdirSync(dirname(options.file), { recursive: true, mode: 0o700 });
|
|
285
|
-
this.db =
|
|
286
|
-
this.db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
|
|
285
|
+
this.db = openHubDatabase(options.file, { create: true });
|
|
287
286
|
this.harden();
|
|
288
287
|
this.db.run("PRAGMA journal_mode = WAL");
|
|
289
288
|
this.db.run("PRAGMA synchronous = FULL");
|
package/src/hub/events.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { appendFileSync, closeSync, existsSync, fstatSync, openSync, readFileSync, readSync } from "node:fs";
|
|
2
2
|
import type { TaskAttribution } from "./attribution.ts";
|
|
3
|
+
import type { PeerId } from "./envelope.ts";
|
|
3
4
|
import type { SwitchTrace } from "../models/route/stage.ts";
|
|
4
5
|
import type { ScreenRecord } from "./inference.ts";
|
|
5
6
|
|
|
@@ -13,7 +14,7 @@ export const EVENTS_SCHEMA = 1;
|
|
|
13
14
|
* ids, routing and sizes, tasks carry ids and states, so the file can be exported without leaking what agents wrote.
|
|
14
15
|
*/
|
|
15
16
|
export type HubEvent =
|
|
16
|
-
| { type: "release_authorization"; by:
|
|
17
|
+
| { type: "release_authorization"; by: PeerId; authorization: string; commit: string; version: string; docsDigest: string; documents: number }
|
|
17
18
|
| { type: "permission_mode"; peer: string; from: import("./permission-mode.ts").PermissionMode; to: import("./permission-mode.ts").PermissionMode }
|
|
18
19
|
| { type: "role"; peer?: string; task?: number; actor: string; stage: "prepared" | "assigned"; roles: { id: string; layer: "shipped" | "project"; hash: string }[] }
|
|
19
20
|
/** A settings change (#269): the registry key, the bounded registry values before/after and their source; an optional person reason is sanitized and capped at 400 UTF-8 bytes. */
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { definitionOf, type KindMap } from "../adapters/registry.ts";
|
|
2
|
-
import { Database } from "
|
|
3
|
-
import {
|
|
2
|
+
import type { Database } from "./db-schema.ts";
|
|
3
|
+
import { openHubDatabase } from "./db-schema.ts";
|
|
4
4
|
|
|
5
5
|
export type ExecutionUnit = "model_calls" | "tool_calls" | "elapsed_ms" | "tokens";
|
|
6
6
|
export type ExecutionBudgetLimits = Partial<Record<ExecutionUnit, number>>;
|
|
@@ -43,8 +43,7 @@ const validLimits = (raw: ExecutionBudgetLimits): ExecutionBudgetLimits => {
|
|
|
43
43
|
export class ExecutionBudget {
|
|
44
44
|
private readonly db: Database;
|
|
45
45
|
constructor(path: string, private readonly instances?: KindMap) {
|
|
46
|
-
this.db =
|
|
47
|
-
this.db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
|
|
46
|
+
this.db = openHubDatabase(path, { create: true });
|
|
48
47
|
this.db.run("PRAGMA journal_mode = WAL");
|
|
49
48
|
this.db.run(`CREATE TABLE IF NOT EXISTS execution_budgets (
|
|
50
49
|
id TEXT PRIMARY KEY, kind TEXT NOT NULL, task_id INTEGER, peers TEXT NOT NULL,
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { Database } from "
|
|
1
|
+
import type { Database } from "./db-schema.ts";
|
|
2
|
+
import { openHubDatabase } from "./db-schema.ts";
|
|
2
3
|
import { closeSync, constants, fstatSync, lstatSync, openSync, readSync, readdirSync, realpathSync } from "node:fs";
|
|
3
4
|
import { join, resolve, sep } from "node:path";
|
|
4
5
|
import { createHash } from "node:crypto";
|
|
5
6
|
import { isDenied } from "../local/deny.ts";
|
|
6
7
|
import { checkCorpusRoot } from "./corpus.ts";
|
|
7
|
-
import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
|
|
8
8
|
import { realPath } from "./project.ts";
|
|
9
9
|
import { folderGlobMatches, MAX_NEW_PER_POLL, type FolderEntry, type FolderRunResult, type FolderScan, type ScannedFile } from "./schedules.ts";
|
|
10
10
|
import { MAX_SOURCE_BYTES, screenRegistration, type SourceRegistry } from "./sources.ts";
|
|
@@ -169,8 +169,7 @@ export interface FolderSeenRow { trigger: string; real_path: string; size: numbe
|
|
|
169
169
|
export class FolderStore {
|
|
170
170
|
private readonly db: Database;
|
|
171
171
|
constructor(dbPath: string) {
|
|
172
|
-
this.db =
|
|
173
|
-
this.db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
|
|
172
|
+
this.db = openHubDatabase(dbPath, { create: true });
|
|
174
173
|
this.db.run("CREATE TABLE IF NOT EXISTS folder_seen (trigger TEXT NOT NULL, real_path TEXT NOT NULL, size INTEGER NOT NULL, mtime INTEGER NOT NULL, sha256 TEXT NOT NULL, task INTEGER, at INTEGER NOT NULL, PRIMARY KEY (trigger, real_path, sha256))");
|
|
175
174
|
this.db.run("CREATE INDEX IF NOT EXISTS folder_seen_hash ON folder_seen (trigger, sha256)");
|
|
176
175
|
this.db.run("CREATE TABLE IF NOT EXISTS folder_poll (trigger TEXT PRIMARY KEY, started INTEGER NOT NULL, baselined INTEGER NOT NULL DEFAULT 0, last_poll INTEGER, last_error TEXT)");
|