@staix/agent-hub 0.24.0 → 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +23 -0
- package/README.md +35 -8
- package/docs/agent-notes/README.md +12 -0
- package/docs/agent-notes/adapters.md +2 -1
- package/docs/agent-notes/bus.md +1 -0
- package/docs/agent-notes/daemon.md +18 -3
- package/docs/agent-notes/tasks.md +12 -4
- package/docs/agent-notes/tests.md +3 -1
- package/docs/dogfood.md +16 -2
- package/docs/memory.md +1 -1
- package/docs/operations.md +264 -14
- package/docs/profiles.md +2 -0
- package/docs/quickstart.md +23 -6
- package/docs/security.md +198 -3
- package/docs/specs/2026-09-19-agent-hub-design.md +25 -1
- package/docs/specs/2026-09-19-multi-project-design.md +12 -1
- package/docs/specs/2026-11-01-research-campaigns-design.md +354 -0
- package/docs/verification/2026-11-01-0.25.0.md +91 -0
- package/docs/verification/2026-11-01-0.26.0.md +54 -0
- package/docs/verified.json +45 -17
- package/package.json +4 -3
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +5294 -4797
- package/src/adapters/channel-peer.ts +4 -2
- package/src/adapters/claude-channel.ts +29 -6
- package/src/adapters/definitions/builtin-metadata.ts +6 -6
- package/src/adapters/definitions/native-cli-doctor.ts +30 -0
- package/src/adapters/definitions/qwen.ts +2 -0
- package/src/adapters/jcode.ts +3 -0
- package/src/adapters/registry-types.ts +2 -0
- package/src/adapters/registry.ts +2 -1
- package/src/cli/acon-register.ts +29 -0
- package/src/cli/conductor-recovery.ts +3 -2
- package/src/cli/handoff-args.ts +27 -0
- package/src/cli/handoff-output.ts +25 -0
- package/src/cli/help.ts +11 -8
- package/src/cli/identity.ts +7 -1
- package/src/cli/main.ts +138 -30
- package/src/cli/output.ts +8 -5
- 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/schedule-output.ts +8 -4
- package/src/cli/start-agents-run.ts +125 -0
- package/src/cli/study-output.ts +71 -0
- package/src/cli/study.ts +58 -0
- package/src/cli/task-output.ts +6 -0
- package/src/cli/upgrade-interactive.ts +4 -4
- package/src/cli/upgrade-runtime.ts +105 -57
- package/src/cli/upgrade.ts +90 -28
- package/src/hub/autonomy-defaults.ts +32 -7
- package/src/hub/board.ts +993 -8
- package/src/hub/budget.ts +3 -4
- package/src/hub/channel-project.ts +61 -0
- 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 +39 -10
- package/src/hub/daemon.ts +326 -25
- package/src/hub/db-schema.ts +143 -0
- package/src/hub/delivery-journal.ts +3 -4
- package/src/hub/envelope.ts +2 -0
- package/src/hub/events.ts +4 -1
- package/src/hub/execution-budget.ts +15 -4
- package/src/hub/folder-trigger.ts +3 -4
- package/src/hub/handoff.ts +126 -0
- package/src/hub/hub-tools.ts +12 -2
- package/src/hub/invocations.ts +3 -4
- package/src/hub/lifecycle.ts +87 -29
- package/src/hub/manager.ts +110 -87
- package/src/hub/no-writes.ts +1 -1
- package/src/hub/orca-env.ts +19 -0
- package/src/hub/path-policy.ts +12 -8
- package/src/hub/project-runtime-state.ts +139 -0
- package/src/hub/project-runtime.ts +62 -0
- package/src/hub/project.ts +14 -5
- package/src/hub/protocol-window.ts +19 -0
- package/src/hub/provenance.ts +30 -0
- package/src/hub/recovery-store.ts +102 -12
- package/src/hub/registry.ts +5 -0
- package/src/hub/research-campaign.ts +265 -0
- package/src/hub/research-experiments.ts +474 -0
- package/src/hub/research-runner.ts +172 -0
- package/src/hub/restart.ts +35 -0
- package/src/hub/runtime-capture-prune.ts +415 -0
- package/src/hub/runtime-prune-health.ts +112 -0
- package/src/hub/schedules.ts +213 -13
- package/src/hub/sdlc-merge.ts +7 -0
- package/src/hub/sdlc-queue.ts +13 -1
- package/src/hub/session-identity.ts +49 -0
- 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 +1005 -34
- package/src/hub/ui.ts +15 -6
- package/src/hub/usage-rollup.ts +3 -4
- package/src/memory/capture.ts +2 -2
- package/src/pi/tool-receipts.ts +3 -4
- package/src/ui/index.html +5 -3
- package/src/ui/office-space.ts +118 -0
- package/src/ui/plugin.ts +293 -0
- package/templates/AGENTS.block.md +1 -0
- package/templates/profiles/general.json +1 -1
- package/templates/profiles/research.json +4 -4
- package/templates/profiles/writing.json +1 -1
- package/templates/roles/research/analyst.md +1 -0
- package/templates/roles/research/fact-checker.md +1 -0
- package/templates/roles/research/research-lead.md +1 -0
- package/templates/roles/research/researcher.md +1 -0
- package/templates/roles/research/statistician.md +1 -0
package/src/hub/lifecycle.ts
CHANGED
|
@@ -1,15 +1,23 @@
|
|
|
1
|
+
import { resolveProjectRuntime, recordProjectRuntime, confirmProjectRuntime, readProjectRuntimeForCaller, projectRuntimeRefusal, type ProjectRuntimeSelection } from "./project-runtime.ts";
|
|
2
|
+
import { scheduleRuntimeCapturePrune } from "./runtime-capture-prune.ts";
|
|
3
|
+
import { createProjectRuntimeAccess, type ProjectRuntimeAccess } from "./project-runtime-state.ts";
|
|
1
4
|
import { isDatabaseBusyReply, RecoveryDatabaseBusy } from "./recovery-error.ts";
|
|
2
5
|
import { spawn } from "node:child_process";
|
|
3
6
|
import { randomUUID } from "node:crypto";
|
|
4
7
|
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
5
|
-
import { join } from "node:path";
|
|
8
|
+
import { join, resolve } from "node:path";
|
|
6
9
|
import { createServer, type Server } from "node:net";
|
|
7
|
-
import { ControlClient, PROTOCOL, readControl } from "./control-client.ts";
|
|
10
|
+
import { ControlClient, PROTOCOL, PROTOCOL_MIN, controlProtocolWindow, readControl } from "./control-client.ts";
|
|
11
|
+
import { negotiateProtocol, protocolWindow } from "./protocol-window.ts";
|
|
12
|
+
import { assertHubDbCompatible } from "./db-schema.ts";
|
|
13
|
+
import { verifyPackage, verifyRecoveryTarget } from "../cli/recovery-package.ts";
|
|
14
|
+
import { VERSION } from "../version.ts";
|
|
8
15
|
import { startDaemon } from "./daemon.ts";
|
|
9
16
|
import { terminalOpener } from "./terminal-open.ts";
|
|
10
17
|
import { Registry, type Project } from "./registry.ts";
|
|
11
18
|
import { CODEX_APP, CODEX_PROXY, CONTROL } from "./ports.ts";
|
|
12
|
-
import { assertLifecycleAvailable, recoveryLock } from "./recovery-store.ts";
|
|
19
|
+
import { assertLifecycleAvailable, readOperation, recoveryLock } from "./recovery-store.ts";
|
|
20
|
+
import { realPath } from "./project.ts";
|
|
13
21
|
import { processLiveness, processSignature } from "../pi/process-signature.ts";
|
|
14
22
|
|
|
15
23
|
export type ProjectInspection = {
|
|
@@ -25,7 +33,9 @@ export async function inspectProject(project: Project, timeoutMs = 3000, readerP
|
|
|
25
33
|
const control = readControl(project.stateDir);
|
|
26
34
|
if (control) {
|
|
27
35
|
if (processLiveness(control.pid, control.pidSignature) === "gone") return { state: "stopped" };
|
|
28
|
-
|
|
36
|
+
const advertised = controlProtocolWindow(control);
|
|
37
|
+
if ((control.protocol !== undefined || control.protocolMin !== undefined || control.protocolMax !== undefined)
|
|
38
|
+
&& (!advertised || negotiateProtocol(protocolWindow(PROTOCOL, PROTOCOL_MIN)!, advertised) === undefined)) {
|
|
29
39
|
return { state: "incompatible", error: `hub protocol ${control.protocol}; stop with its matching CLI before upgrading` };
|
|
30
40
|
}
|
|
31
41
|
if ((control.projectId && control.projectId !== project.id) || control.cwd !== project.root) {
|
|
@@ -87,9 +97,24 @@ async function available(base: number): Promise<boolean> {
|
|
|
87
97
|
/** The signals the project daemon stops on (see `runProjectDaemon`). */
|
|
88
98
|
export const STOP_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP", "SIGQUIT"] as const;
|
|
89
99
|
|
|
100
|
+
function startupRuntime(project: Project, env: NodeJS.ProcessEnv, registry: Registry, access: ProjectRuntimeAccess) {
|
|
101
|
+
const sourceRoot = resolve(import.meta.dir, "../.."), id = env.AGENTHUB_RECOVERY_OPERATION;
|
|
102
|
+
if (id && recoveryLock(undefined, project.root) === id) {
|
|
103
|
+
const op = readOperation<{ targetRoot?: string; targetDigest?: string; plan: { kind: "restart" | "upgrade"; version: string; projects: { project: Project }[] } }>(id);
|
|
104
|
+
if (!op.targetRoot || !op.targetDigest || op.plan.version !== VERSION || realPath(op.targetRoot) !== realPath(sourceRoot)
|
|
105
|
+
|| !op.plan.projects.some(row => row.project.id === project.id && row.project.root === project.root && row.project.stateDir === project.stateDir))
|
|
106
|
+
throw new Error("recovery startup does not match its retained target runtime");
|
|
107
|
+
// Authority binds this exact target path. Do not re-stage it at a different, byte-identical path.
|
|
108
|
+
return { root: sourceRoot, digest: op.targetDigest, version: VERSION, kind: op.plan.kind, check: () => verifyRecoveryTarget(op.plan.kind, sourceRoot, VERSION) };
|
|
109
|
+
}
|
|
110
|
+
const retained = resolveProjectRuntime(project, VERSION, sourceRoot, registry, access);
|
|
111
|
+
return { ...retained, check: () => verifyRecoveryTarget(retained.kind, retained.root, retained.version) };
|
|
112
|
+
}
|
|
113
|
+
|
|
90
114
|
/** Called only in the detached daemon process. A competing starter exits without touching state. */
|
|
91
115
|
export async function runProjectDaemon(project: Project, unattended = false): Promise<void> {
|
|
92
|
-
assertLifecycleAvailable();
|
|
116
|
+
assertLifecycleAvailable(project.root);
|
|
117
|
+
assertHubDbCompatible(join(project.stateDir, "hub.db"));
|
|
93
118
|
const registry = new Registry();
|
|
94
119
|
const instanceId = randomUUID();
|
|
95
120
|
let claimed = false;
|
|
@@ -108,11 +133,19 @@ export async function runProjectDaemon(project: Project, unattended = false): Pr
|
|
|
108
133
|
forcedExit.unref();
|
|
109
134
|
};
|
|
110
135
|
try {
|
|
136
|
+
const runtimeAccess = createProjectRuntimeAccess(project);
|
|
111
137
|
const before = await inspectProject(registry.get(project.id) ?? project);
|
|
112
138
|
if (before.state === "running" || before.state === "starting") return;
|
|
113
139
|
if (before.state !== "stopped") throw new Error(before.error ?? before.state);
|
|
114
140
|
claimed = registry.claim(project.id, instanceId, process.pid, processSignature(process.pid));
|
|
115
141
|
if (!claimed) return;
|
|
142
|
+
registry.runtimeCaptureLocked(() => {
|
|
143
|
+
const runtime = startupRuntime(project, process.env, registry, runtimeAccess);
|
|
144
|
+
// Direct internal daemon entry must not bypass the selected runtime. Normal up already spawned its verified capture.
|
|
145
|
+
if (realPath(resolve(import.meta.dir, "../..")) !== runtime.root || verifyPackage(resolve(import.meta.dir, "../.."), VERSION) !== runtime.digest)
|
|
146
|
+
throw projectRuntimeRefusal(runtime.version, "start with ahub up so the recorded runtime is used");
|
|
147
|
+
recordProjectRuntime(project, runtime, instanceId, registry, runtimeAccess);
|
|
148
|
+
});
|
|
116
149
|
let base = registry.get(project.id)!.basePort;
|
|
117
150
|
for (let attempt = 0; attempt < 100; attempt++) {
|
|
118
151
|
if (await available(base)) {
|
|
@@ -145,44 +178,69 @@ export async function runProjectDaemon(project: Project, unattended = false): Pr
|
|
|
145
178
|
|
|
146
179
|
export async function startProject(project: Project, options: { unattended?: boolean; env?: NodeJS.ProcessEnv; liveTest?: boolean; current?: () => void } = {}): Promise<any> {
|
|
147
180
|
options.current?.();
|
|
148
|
-
assertLifecycleAvailable();
|
|
181
|
+
assertLifecycleAvailable(project.root);
|
|
149
182
|
const registry = new Registry();
|
|
150
183
|
try {
|
|
151
184
|
const current = registry.get(project.id);
|
|
152
185
|
if (!current || current.root !== project.root || current.stateDir !== project.stateDir) throw new Error("project registration changed; refresh before starting");
|
|
186
|
+
const runtimeAccess = createProjectRuntimeAccess(current);
|
|
187
|
+
const admitRunningRuntime = () => {
|
|
188
|
+
const fresh = registry.get(project.id);
|
|
189
|
+
if (!fresh || fresh.root !== project.root || fresh.stateDir !== project.stateDir) throw new Error("project registration changed; refresh before starting");
|
|
190
|
+
readProjectRuntimeForCaller(fresh, VERSION, runtimeAccess);
|
|
191
|
+
};
|
|
153
192
|
const before = await inspectProject(current);
|
|
154
193
|
options.current?.();
|
|
155
|
-
if (before.state === "running") return before.status;
|
|
194
|
+
if (before.state === "running") { admitRunningRuntime(); return before.status; }
|
|
156
195
|
if (before.state !== "stopped" && before.state !== "starting") throw new Error(before.error ?? before.state);
|
|
157
196
|
if (!existsSync(join(project.root, ".agenthub", "config.json"))) throw new Error("project is not initialized; run ahub init there first");
|
|
197
|
+
let launchedRuntime: ProjectRuntimeSelection | undefined;
|
|
158
198
|
if (before.state === "stopped") {
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
// operation ID must not make a later ordinary startup load a retired snapshot.
|
|
169
|
-
if (launchEnv.AGENTHUB_RECOVERY_OPERATION !== recoveryLock()) delete launchEnv.AGENTHUB_RECOVERY_OPERATION;
|
|
199
|
+
const launchEnv = { ...(options.env ?? process.env) };
|
|
200
|
+
// Expired operation ids must not make an ordinary startup load a retired snapshot.
|
|
201
|
+
if (launchEnv.AGENTHUB_RECOVERY_OPERATION !== recoveryLock(undefined, project.root)) delete launchEnv.AGENTHUB_RECOVERY_OPERATION;
|
|
202
|
+
registry.runtimeCaptureLocked(() => {
|
|
203
|
+
const claim = registry.get(project.id);
|
|
204
|
+
if (!claim || claim.root !== project.root || claim.stateDir !== project.stateDir) throw new Error("project registration changed; refresh before starting");
|
|
205
|
+
// A competing starter owns readiness now. Never replace its selection or create a second native start.
|
|
206
|
+
if (claim.instanceId && processLiveness(claim.pid, claim.pidSignature) !== "gone") return;
|
|
207
|
+
const runtime = startupRuntime(project, launchEnv, registry, runtimeAccess);
|
|
170
208
|
options.current?.();
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
209
|
+
assertLifecycleAvailable(project.root);
|
|
210
|
+
if (runtime.check() !== runtime.digest) throw new Error("staged startup runtime changed");
|
|
211
|
+
// The selected runtime's own preflight decides schema compatibility; an older invoking CLI cannot preflight for it.
|
|
212
|
+
recordProjectRuntime(project, runtime, null, registry, runtimeAccess);
|
|
213
|
+
launchedRuntime = runtime;
|
|
214
|
+
mkdirSync(project.stateDir, { recursive: true });
|
|
215
|
+
const marker = join(project.stateDir, "project.json");
|
|
216
|
+
const temporary = `${marker}.${randomUUID()}.tmp`;
|
|
217
|
+
writeFileSync(temporary, JSON.stringify({ root: project.root, projectId: project.id }), { mode: 0o600 });
|
|
218
|
+
renameSync(temporary, marker);
|
|
219
|
+
const log = openSync(join(project.stateDir, "hub.log"), "a");
|
|
220
|
+
try {
|
|
221
|
+
options.current?.();
|
|
222
|
+
const child = spawn(process.execPath, [join(runtime.root, "src/cli/main.ts"), "--project", project.root, ...(options.liveTest ? ["--live-test"] : []), "daemon",
|
|
223
|
+
...(options.unattended ? ["--unattended"] : [])], {
|
|
224
|
+
cwd: project.root, detached: true, stdio: ["ignore", log, log],
|
|
225
|
+
env: { ...launchEnv, AGENTHUB_STATE_DIR: project.stateDir, AGENTHUB_PROJECT_DIR: project.root,
|
|
226
|
+
AGENTHUB_UNATTENDED: options.unattended ? "1" : "0" },
|
|
227
|
+
});
|
|
228
|
+
child.on("error", () => {}); // bounded readiness below reports failure with the project's log path
|
|
229
|
+
child.unref();
|
|
230
|
+
} finally { closeSync(log); }
|
|
231
|
+
});
|
|
180
232
|
}
|
|
181
233
|
const deadline = Date.now() + 12_000;
|
|
182
234
|
while (Date.now() < deadline) {
|
|
183
235
|
const observed = await inspectProject(registry.get(project.id) ?? project);
|
|
184
236
|
options.current?.();
|
|
185
|
-
if (observed.state === "running")
|
|
237
|
+
if (observed.state === "running") {
|
|
238
|
+
admitRunningRuntime();
|
|
239
|
+
if (launchedRuntime) confirmProjectRuntime(project, launchedRuntime, observed.status, registry, runtimeAccess);
|
|
240
|
+
// Unknown references defer maintenance; cache cleanup never changes a confirmed startup outcome.
|
|
241
|
+
try { scheduleRuntimeCapturePrune(); } catch { /* retain captures on unreadable inventory or cleanup failure */ }
|
|
242
|
+
return observed.status;
|
|
243
|
+
}
|
|
186
244
|
if (observed.state === "incompatible" || observed.state === "missing") throw new Error(observed.error ?? observed.state);
|
|
187
245
|
await Bun.sleep(100);
|
|
188
246
|
}
|
|
@@ -200,7 +258,7 @@ function claimHeld(id: string, instanceId: string): boolean {
|
|
|
200
258
|
}
|
|
201
259
|
|
|
202
260
|
export async function stopProject(project: Project, expectedInstance?: string): Promise<void> {
|
|
203
|
-
assertLifecycleAvailable();
|
|
261
|
+
assertLifecycleAvailable(project.root);
|
|
204
262
|
const inspection = await inspectProject(project);
|
|
205
263
|
if (inspection.state === "stopped") return;
|
|
206
264
|
if (inspection.state !== "running" && inspection.state !== "stopping") throw new Error(inspection.error ?? "hub ownership is not verified; not stopping it");
|
package/src/hub/manager.ts
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
|
+
import { assertExecutableRuntimeRoot } from "./project-runtime-state.ts";
|
|
1
2
|
import { BACKLOG_SPLIT_SCREEN_TIMEOUT_MS } from "./planning.ts";
|
|
2
3
|
import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
|
|
3
4
|
import { Database } from "bun:sqlite";
|
|
4
5
|
import { randomBytes, randomUUID } from "node:crypto";
|
|
5
6
|
import { chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
6
|
-
import { join } from "node:path";
|
|
7
|
+
import { join, resolve } from "node:path";
|
|
7
8
|
import { spawn } from "node:child_process";
|
|
8
|
-
import { ControlClient, PROTOCOL, RECOVERY_SOURCE_PROTOCOLS } from "./control-client.ts";
|
|
9
|
+
import { ControlClient, PROTOCOL, PROTOCOL_MIN, RECOVERY_SOURCE_PROTOCOLS, controlProtocolWindow } from "./control-client.ts";
|
|
9
10
|
import { Registry, type Project } from "./registry.ts";
|
|
10
|
-
import { hubHome } from "./project.ts";
|
|
11
|
+
import { hubHome, realPath } from "./project.ts";
|
|
11
12
|
import { startDashboard, dashboardView } from "./ui.ts";
|
|
12
13
|
import { projectChain } from "@staix/agent-mem";
|
|
13
14
|
import type { ProjectInspection } from "./lifecycle.ts";
|
|
@@ -22,10 +23,12 @@ export type Lifecycle = {
|
|
|
22
23
|
stopProject(project: Project, expectedInstance?: string): Promise<void>;
|
|
23
24
|
};
|
|
24
25
|
export type ManagerOptions = { registry?: Registry; lifecycle?: Lifecycle; home?: string; cli?: string; orphanWatchMs?: number; liveTest?: boolean;
|
|
26
|
+
/** #608: runs after the dashboard's "start" has the hub ready, as the person's `ahub up` would, never for a live-test session. Its returned lines and any failure go to manager.log, one line each. Never part of a dashboard request. */
|
|
27
|
+
afterStart?: (project: Project) => Promise<string[]>;
|
|
25
28
|
/** Test clock seam: maps nominal forwarding deadlines, never comes from dashboard input. */
|
|
26
29
|
forwardTimeoutMs?: (action: string, nominalMs: number) => number };
|
|
27
30
|
type ManagerHandle = { stop(): Promise<void>; stopped: Promise<void> };
|
|
28
|
-
type Manifest = { port: number; protocol: number; instanceId: string; pid: number; pidSignature?: string };
|
|
31
|
+
type Manifest = { port: number; protocol: number; protocolMin?: number; protocolMax?: number; instanceId: string; pid: number; pidSignature?: string; runtimeRoot?: string };
|
|
29
32
|
const active = new Map<string, { handle: ManagerHandle; issue(liveTest?: boolean): string }>();
|
|
30
33
|
const CONTROL_TIMEOUT = 3000;
|
|
31
34
|
// A current CLI may authenticate the previous protocol-9 manager while
|
|
@@ -79,6 +82,77 @@ function removeManifest(home: string, instanceId: string) {
|
|
|
79
82
|
rmSync(files(home).token, { force: true });
|
|
80
83
|
}
|
|
81
84
|
|
|
85
|
+
/** The manager's project listing and forwarding (#935): shared by `ahub ui --all` and the acon plugin, never widened for either. */
|
|
86
|
+
export function projectConsole(options: { registry: Registry; lifecycle: Lifecycle; home: string; stopping?: () => boolean; forwardTimeoutMs?: ManagerOptions["forwardTimeoutMs"]; afterStart?: ManagerOptions["afterStart"] }) {
|
|
87
|
+
const { registry, lifecycle, home } = options;
|
|
88
|
+
const aliases = new Map<string, { at: number; names: string[] }>();
|
|
89
|
+
const list = async () => {
|
|
90
|
+
const projects = registry.list();
|
|
91
|
+
for (const p of projects) if (!aliases.has(p.id) || Date.now() - aliases.get(p.id)!.at > 60_000) {
|
|
92
|
+
aliases.set(p.id, { at: Date.now(), names: existsSync(p.root) ? projectChain(p.root) : [] });
|
|
93
|
+
}
|
|
94
|
+
const rows = await Promise.all(projects.map(async (project) => {
|
|
95
|
+
let result: ProjectInspection;
|
|
96
|
+
try { result = await bounded(lifecycle.inspectProject(project), 7000); }
|
|
97
|
+
catch (error) { result = { state: "unavailable", error: safeError(error) }; }
|
|
98
|
+
const names = aliases.get(project.id)!.names;
|
|
99
|
+
const shared = projects.some((other) => other.id !== project.id && aliases.get(other.id)!.names.some((name) => names.includes(name)));
|
|
100
|
+
return { id: project.id, root: project.root, state: result.state,
|
|
101
|
+
instanceId: result.state === "running" ? result.status?.instanceId ?? null : null,
|
|
102
|
+
peers: result.status?.peers ?? {}, tasks: result.status?.tasks ?? {},
|
|
103
|
+
...(shared ? { warning: "Native memory aliases are shared with another registered project." } : {}),
|
|
104
|
+
...(result.error ? { error: result.error } : {}) };
|
|
105
|
+
}));
|
|
106
|
+
return { ok: true, mode: "all", projects: rows };
|
|
107
|
+
};
|
|
108
|
+
const act = async (input: Record<string, unknown>, session?: { liveTest?: true }) => {
|
|
109
|
+
if (options.stopping?.()) return { ok: false, error: "manager is stopping" };
|
|
110
|
+
const project = typeof input.projectId === "string" ? registry.get(input.projectId) : undefined;
|
|
111
|
+
if (!project) return { ok: false, error: "unknown project" };
|
|
112
|
+
let client: ControlClient | undefined;
|
|
113
|
+
try {
|
|
114
|
+
if (input.action === "start") {
|
|
115
|
+
const status = await bounded(lifecycle.startProject(project, { unattended: false, env: { ...scrubEnv(process.env), AGENTHUB_HOME: home } }), 20_000);
|
|
116
|
+
// The agents start in the background: the dashboard's answer is the hub's readiness, and a terminal that is slow to open must not hold it.
|
|
117
|
+
// A live-test dashboard session is an agent borrowing the person's console: it never opens the person's default agents. The session, not this process, carries that flag.
|
|
118
|
+
if (options.afterStart && session?.liveTest !== true) {
|
|
119
|
+
const stamp = (line: string) => `${new Date().toISOString()} default agents for ${project.id}: ${line.replace(/[\x00-\x1f\x7f]+/g, " ").slice(0, 600)}`;
|
|
120
|
+
void options.afterStart(project).then(
|
|
121
|
+
lines => { for (const line of lines) console.error(stamp(line)); },
|
|
122
|
+
error => { console.error(stamp(`not started: ${safeError(error)}`)); });
|
|
123
|
+
}
|
|
124
|
+
return { ok: true, projectId: project.id, instanceId: status.instanceId };
|
|
125
|
+
}
|
|
126
|
+
if (typeof input.instanceId !== "string" || !input.instanceId) return { ok: false, error: "expected daemon instance is required" };
|
|
127
|
+
if (input.action === "stop") {
|
|
128
|
+
await bounded(lifecycle.stopProject(project, input.instanceId), 20_000);
|
|
129
|
+
return { ok: true };
|
|
130
|
+
}
|
|
131
|
+
// The settings actions (#269) reach the project hub as an ordinary session's: this dashboard opens no settings session.
|
|
132
|
+
if (!["snapshot", "send", "pause", "resume", "permit", "propose", "assign", "backlog_pull", "backlog_return", "planning", "planning_undo", "planner_pull", "backlog_split", "release_new", "release_change", "setting", "setting_undo", "setting_preview", "setting_reload", "config_edit_preview", "config_edit_apply", "start_peer", "sdlc_queue", "sdlc_release"].includes(String(input.action))) return { ok: false, error: "invalid dashboard action" };
|
|
133
|
+
client = await ControlClient.connect(project.stateDir, { role: "console", projectId: project.id, projectRoot: project.root, instanceId: input.instanceId });
|
|
134
|
+
const deliveryRead = input.action === "sdlc_queue" || input.action === "sdlc_release";
|
|
135
|
+
let nominalMs = 10_000;
|
|
136
|
+
if (input.action === "start_peer") nominalMs = MAX_COMMAND_MS + 5_000;
|
|
137
|
+
else if (input.action === "backlog_split") nominalMs = BACKLOG_SPLIT_SCREEN_TIMEOUT_MS + HUB_DB_BUSY_TIMEOUT_MS + 5_000;
|
|
138
|
+
else if (deliveryRead) nominalMs = 30_000;
|
|
139
|
+
const timeoutMs = options.forwardTimeoutMs?.(String(input.action), nominalMs) ?? nominalMs;
|
|
140
|
+
const result = await client.request(input.action === "snapshot"
|
|
141
|
+
? { t: "ui_snapshot", after: input.after ?? 0, ...(input.memory !== undefined ? { memory: input.memory } : {}), ...(dashboardView(input.view) ? { view: input.view } : {}) }
|
|
142
|
+
: { t: "ui_action", action: input, instanceId: input.instanceId }, timeoutMs);
|
|
143
|
+
// ControlClient resolves its deadline as this exact sentinel; a hub refusal stays a known failure.
|
|
144
|
+
if (["start_peer", "backlog_split"].includes(String(input.action)) && result.ok === false && result.error === `no answer from the hub within ${Math.round(timeoutMs / 1000)} s`) {
|
|
145
|
+
const text = input.action === "backlog_split" ? "Split result is unconfirmed. Reload the task before retrying; the hub may still commit its children." : "Start result is unconfirmed. Check the Peers panel before retrying; the hub may still be starting the peer.";
|
|
146
|
+
return { ok: false, unconfirmed: true, text, error: text };
|
|
147
|
+
}
|
|
148
|
+
if (deliveryRead) { const { rid: _transportId, ...report } = result; return report; }
|
|
149
|
+
return result;
|
|
150
|
+
} catch (error) { return { ok: false, error: safeError(error) }; }
|
|
151
|
+
finally { client?.close(); }
|
|
152
|
+
};
|
|
153
|
+
return { list, act };
|
|
154
|
+
}
|
|
155
|
+
|
|
82
156
|
export async function startManager(options: ManagerOptions = {}): Promise<ManagerHandle> {
|
|
83
157
|
const home = options.home ?? hubHome();
|
|
84
158
|
const existing = active.get(home);
|
|
@@ -118,87 +192,36 @@ export async function startManager(options: ManagerOptions = {}): Promise<Manage
|
|
|
118
192
|
cleanup(); resolveStopped();
|
|
119
193
|
};
|
|
120
194
|
try {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
}
|
|
152
|
-
if (typeof input.instanceId !== "string" || !input.instanceId) return { ok: false, error: "expected daemon instance is required" };
|
|
153
|
-
if (input.action === "stop") {
|
|
154
|
-
await bounded(lifecycle.stopProject(project, input.instanceId), 20_000);
|
|
155
|
-
return { ok: true };
|
|
156
|
-
}
|
|
157
|
-
// The settings actions (#269) reach the project hub as an ordinary session's: this dashboard opens no settings session.
|
|
158
|
-
if (!["snapshot", "send", "pause", "resume", "permit", "propose", "assign", "backlog_pull", "backlog_return", "planning", "planning_undo", "planner_pull", "backlog_split", "release_new", "release_change", "setting", "setting_undo", "setting_preview", "setting_reload", "config_edit_preview", "config_edit_apply", "start_peer", "sdlc_queue", "sdlc_release"].includes(String(input.action))) return { ok: false, error: "invalid dashboard action" };
|
|
159
|
-
client = await ControlClient.connect(project.stateDir, { role: "console", projectId: project.id, projectRoot: project.root, instanceId: input.instanceId });
|
|
160
|
-
const deliveryRead = input.action === "sdlc_queue" || input.action === "sdlc_release";
|
|
161
|
-
let nominalMs = 10_000;
|
|
162
|
-
if (input.action === "start_peer") nominalMs = MAX_COMMAND_MS + 5_000;
|
|
163
|
-
else if (input.action === "backlog_split") nominalMs = BACKLOG_SPLIT_SCREEN_TIMEOUT_MS + HUB_DB_BUSY_TIMEOUT_MS + 5_000;
|
|
164
|
-
else if (deliveryRead) nominalMs = 30_000;
|
|
165
|
-
const timeoutMs = options.forwardTimeoutMs?.(String(input.action), nominalMs) ?? nominalMs;
|
|
166
|
-
const result = await client.request(input.action === "snapshot"
|
|
167
|
-
? { t: "ui_snapshot", after: input.after ?? 0, ...(input.memory !== undefined ? { memory: input.memory } : {}), ...(dashboardView(input.view) ? { view: input.view } : {}) }
|
|
168
|
-
: { t: "ui_action", action: input, instanceId: input.instanceId }, timeoutMs);
|
|
169
|
-
// ControlClient resolves its deadline as this exact sentinel; a hub refusal stays a known failure.
|
|
170
|
-
if (["start_peer", "backlog_split"].includes(String(input.action)) && result.ok === false && result.error === `no answer from the hub within ${Math.round(timeoutMs / 1000)} s`) {
|
|
171
|
-
const text = input.action === "backlog_split" ? "Split result is unconfirmed. Reload the task before retrying; the hub may still commit its children." : "Start result is unconfirmed. Check the Peers panel before retrying; the hub may still be starting the peer.";
|
|
172
|
-
return { ok: false, unconfirmed: true, text, error: text };
|
|
173
|
-
}
|
|
174
|
-
if (deliveryRead) { const { rid: _transportId, ...report } = result; return report; }
|
|
175
|
-
return result;
|
|
176
|
-
} catch (error) { return { ok: false, error: safeError(error) }; }
|
|
177
|
-
finally { client?.close(); }
|
|
178
|
-
};
|
|
179
|
-
dashboard = startDashboard({ projects: list, snapshot: (after, input = {}) => act({ ...input, action: "snapshot", after }), action: act });
|
|
180
|
-
server = Bun.serve({ hostname: "127.0.0.1", port: 0, maxRequestBodySize: 1024,
|
|
181
|
-
async fetch(req) {
|
|
182
|
-
if (req.headers.has("origin")) return new Response("forbidden", { status: 403 });
|
|
183
|
-
if (req.headers.get("authorization") !== `Bearer ${token}`) return new Response("unauthorized", { status: 401 });
|
|
184
|
-
if (req.method !== "POST") return new Response("POST required", { status: 405 });
|
|
185
|
-
const expected = req.headers.get("x-agenthub-instance");
|
|
186
|
-
if (expected && expected !== instanceId) return Response.json({ ok: false, error: "manager instance changed" }, { status: 409 });
|
|
187
|
-
if (stopping) return Response.json({ ok: false, error: "manager is stopping" }, { status: 409 });
|
|
188
|
-
const path = new URL(req.url).pathname;
|
|
189
|
-
if (path === "/open") {
|
|
190
|
-
const input = await req.json().catch(() => ({})) as { liveTest?: boolean };
|
|
191
|
-
return Response.json({ ok: true, instanceId, protocol: PROTOCOL, url: dashboard!.issue(false, input.liveTest === true), ...(input.liveTest === true ? { liveTest: true } : {}) });
|
|
192
|
-
}
|
|
193
|
-
if (path === "/stop") { setTimeout(() => void stop(), 0); return Response.json({ ok: true, instanceId }); }
|
|
194
|
-
return new Response("not found", { status: 404 });
|
|
195
|
-
},
|
|
196
|
-
});
|
|
197
|
-
writeFileSync(f.token, token, { mode: 0o600 });
|
|
198
|
-
chmodSync(f.token, 0o600);
|
|
199
|
-
const temp = `${f.status}.${instanceId}.tmp`;
|
|
200
|
-
writeFileSync(temp, JSON.stringify({ port: server.port, protocol: PROTOCOL, instanceId, pid: process.pid, ...(pidSignature ? { pidSignature } : {}) }));
|
|
201
|
-
renameSync(temp, f.status);
|
|
195
|
+
const publicationRegistry = typeof registry.runtimeCaptureLocked === "function" && typeof registry.path === "string"
|
|
196
|
+
&& resolve(registry.path) === resolve(join(home, "registry.db")) ? registry : new Registry(join(home, "registry.db"));
|
|
197
|
+
try { publicationRegistry.runtimeCaptureLocked(() => {
|
|
198
|
+
assertExecutableRuntimeRoot(realPath(resolve(import.meta.dir, "../..")));
|
|
199
|
+
owner = claimOwner(f.owner, instanceId, pidSignature);
|
|
200
|
+
const { list, act } = projectConsole({ registry, lifecycle, home, stopping: () => stopping, forwardTimeoutMs: options.forwardTimeoutMs, ...(options.afterStart && !options.liveTest ? { afterStart: options.afterStart } : {}) });
|
|
201
|
+
dashboard = startDashboard({ projects: list, snapshot: (after, input = {}) => act({ ...input, action: "snapshot", after }), action: act });
|
|
202
|
+
server = Bun.serve({ hostname: "127.0.0.1", port: 0, maxRequestBodySize: 1024,
|
|
203
|
+
async fetch(req) {
|
|
204
|
+
if (req.headers.has("origin")) return new Response("forbidden", { status: 403 });
|
|
205
|
+
if (req.headers.get("authorization") !== `Bearer ${token}`) return new Response("unauthorized", { status: 401 });
|
|
206
|
+
if (req.method !== "POST") return new Response("POST required", { status: 405 });
|
|
207
|
+
const expected = req.headers.get("x-agenthub-instance");
|
|
208
|
+
if (expected && expected !== instanceId) return Response.json({ ok: false, error: "manager instance changed" }, { status: 409 });
|
|
209
|
+
if (stopping) return Response.json({ ok: false, error: "manager is stopping" }, { status: 409 });
|
|
210
|
+
const path = new URL(req.url).pathname;
|
|
211
|
+
if (path === "/open") {
|
|
212
|
+
const input = await req.json().catch(() => ({})) as { liveTest?: boolean };
|
|
213
|
+
return Response.json({ ok: true, instanceId, protocol: PROTOCOL_MIN, protocolMin: PROTOCOL_MIN, protocolMax: PROTOCOL, url: dashboard!.issue(false, input.liveTest === true), ...(input.liveTest === true ? { liveTest: true } : {}) });
|
|
214
|
+
}
|
|
215
|
+
if (path === "/stop") { setTimeout(() => void stop(), 0); return Response.json({ ok: true, instanceId }); }
|
|
216
|
+
return new Response("not found", { status: 404 });
|
|
217
|
+
},
|
|
218
|
+
});
|
|
219
|
+
writeFileSync(f.token, token, { mode: 0o600 });
|
|
220
|
+
chmodSync(f.token, 0o600);
|
|
221
|
+
const temp = `${f.status}.${instanceId}.tmp`;
|
|
222
|
+
writeFileSync(temp, JSON.stringify({ port: server.port, protocol: PROTOCOL_MIN, protocolMin: PROTOCOL_MIN, protocolMax: PROTOCOL, instanceId, pid: process.pid, ...(pidSignature ? { pidSignature } : {}), runtimeRoot: realPath(resolve(import.meta.dir, "../..")) }), { mode: 0o600 });
|
|
223
|
+
renameSync(temp, f.status);
|
|
224
|
+
}); } finally { if (publicationRegistry !== registry) publicationRegistry.close(); }
|
|
202
225
|
// The manager outlives whoever opened it; a deleted AGENTHUB_HOME (a test suite's temp
|
|
203
226
|
// home, issue #56) must not leave it serving a registry that no longer exists.
|
|
204
227
|
orphanWatch = setInterval(() => {
|
|
@@ -215,7 +238,7 @@ export async function startManager(options: ManagerOptions = {}): Promise<Manage
|
|
|
215
238
|
async function managerRequest(home: string, path: "/open" | "/stop", liveTest = false): Promise<{ status: Manifest; body: any }> {
|
|
216
239
|
const status = readManifest(home);
|
|
217
240
|
if (!status) throw new Error("manager manifest is unavailable");
|
|
218
|
-
if (!SUPPORTED_MANAGER_PROTOCOLS.has(status.protocol)) throw new Error("manager protocol is incompatible; use its matching CLI to stop it");
|
|
241
|
+
if (!SUPPORTED_MANAGER_PROTOCOLS.has(status.protocol) || !controlProtocolWindow(status)) throw new Error("manager protocol is incompatible; use its matching CLI to stop it");
|
|
219
242
|
const token = readFileSync(files(home).token, "utf8").trim();
|
|
220
243
|
if (!token) throw new Error("manager token is unavailable");
|
|
221
244
|
const response = await fetch(`http://127.0.0.1:${status.port}${path}`, {
|
|
@@ -268,7 +291,7 @@ export async function openManager(options: ManagerOptions = {}): Promise<string>
|
|
|
268
291
|
export async function refreshManager(options: ManagerOptions = {}): Promise<string> {
|
|
269
292
|
const home = options.home ?? hubHome();
|
|
270
293
|
const manifest = readManifest(home);
|
|
271
|
-
if (manifest && manifest
|
|
294
|
+
if (manifest && (controlProtocolWindow(manifest)?.v ?? Infinity) < PROTOCOL && RECOVERY_SOURCE_PROTOCOLS.includes(manifest.protocol as (typeof RECOVERY_SOURCE_PROTOCOLS)[number])) {
|
|
272
295
|
await managerRequest(home, "/stop");
|
|
273
296
|
const deadline = Date.now() + CONTROL_TIMEOUT;
|
|
274
297
|
while (Date.now() < deadline && readManifest(home)) await Bun.sleep(25);
|
package/src/hub/no-writes.ts
CHANGED
|
@@ -37,7 +37,7 @@ export class NoWriteTurns {
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
|
|
40
|
-
const OWNER_TOOL_EFFECTS = new Set(["hub_task_propose", "hub_task_decline", "hub_task_done", "hub_task_cancel", "hub_task_assign", "hub_task_escalate", "hub_review", "hub_checkpoint", "hub_remember", "hub_memory_correct", "hub_check_start", "hub_check_cancel", "hub_sdlc_record", "hub_plan_review", "hub_backlog_plan", "hub_backlog_pull", "hub_backlog_undo", "hub_backlog_split", "hub_backlog_return_untouched", "hub_release_new", "hub_release_configure", "hub_release_assign", "hub_release_cut"]);
|
|
40
|
+
const OWNER_TOOL_EFFECTS = new Set(["hub_task_propose", "hub_task_decline", "hub_task_done", "hub_task_cancel", "hub_task_assign", "hub_task_escalate", "hub_review", "hub_checkpoint", "hub_remember", "hub_memory_correct", "hub_check_start", "hub_check_cancel", "hub_sdlc_record", "hub_plan_review", "hub_backlog_plan", "hub_backlog_pull", "hub_backlog_undo", "hub_backlog_split", "hub_backlog_return_untouched", "hub_release_new", "hub_release_configure", "hub_release_assign", "hub_release_cut", "hub_study_handoff"]);
|
|
41
41
|
/** A read does not prove progress; failed/pending mutations are handled conservatively by the host. */
|
|
42
42
|
export function ownerToolEffect(op: string, input: Record<string, unknown> | undefined): boolean {
|
|
43
43
|
return OWNER_TOOL_EFFECTS.has(op) || op === "hub_task_accept" && !!input?.plan && typeof input.plan === "object" && Object.keys(input.plan).length > 0;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #613 (agent-hub#1048): the one source of truth for the Orca terminal environment. No imports, so every layer can read it
|
|
3
|
+
* (the launcher, the hub's terminal opener, the test preload and the gate's comment).
|
|
4
|
+
*
|
|
5
|
+
* The Orca launcher decides whether to open a terminal in the person's running Orca from these four names, so a process
|
|
6
|
+
* that inherits them reaches that Orca. A hub or a test run started from an Orca pane inherits them, so tests and the gate
|
|
7
|
+
* start with the whole `ORCA_` prefix removed and a test that wants one sets it itself.
|
|
8
|
+
*/
|
|
9
|
+
export const ORCA_LAUNCHER_ENV: readonly string[] = Object.freeze(["ORCA_CLI_COMMAND", "ORCA_DEV_REPO_ROOT", "ORCA_TERMINAL_HANDLE", "ORCA_WORKTREE_ID"]);
|
|
10
|
+
|
|
11
|
+
/** Every variable an Orca terminal exports: the launcher's four and the rest of the pane's environment. */
|
|
12
|
+
export const isOrcaEnvName = (name: string): boolean => name.startsWith("ORCA_");
|
|
13
|
+
|
|
14
|
+
/** A copy of `env` with every `ORCA_` variable removed. */
|
|
15
|
+
export function withoutOrcaEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
|
16
|
+
const out: NodeJS.ProcessEnv = { ...env };
|
|
17
|
+
for (const name of Object.keys(out)) if (isOrcaEnvName(name)) delete out[name];
|
|
18
|
+
return out;
|
|
19
|
+
}
|
package/src/hub/path-policy.ts
CHANGED
|
@@ -32,7 +32,11 @@ export function policyTargets(cwd: string, paths: string[]): string[] {
|
|
|
32
32
|
const root = realPath(cwd);
|
|
33
33
|
return [...new Set(paths.flatMap(path => { const lexical = resolve(root, path); return [lexical, policyRealPath(lexical)]; }))];
|
|
34
34
|
}
|
|
35
|
-
type DiscoveryBudget = { entries: number };
|
|
35
|
+
type DiscoveryBudget = { entries: number; failClosedOnDiscoveryLimit?: boolean };
|
|
36
|
+
function discoveryLimit(budget: DiscoveryBudget): false {
|
|
37
|
+
if (budget.failClosedOnDiscoveryLimit) throw new Error("policy alias discovery incomplete");
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
36
40
|
/** A dangling direct child names a future lexical target; an ELOOP child opens no file. */
|
|
37
41
|
function directAliasTarget(path: string): string | undefined {
|
|
38
42
|
let target = path;
|
|
@@ -55,16 +59,16 @@ function directAliasTarget(path: string): string | undefined {
|
|
|
55
59
|
}
|
|
56
60
|
return undefined;
|
|
57
61
|
}
|
|
58
|
-
/** Only
|
|
62
|
+
/** Only completed bounded scans contribute aliases; opt-in strict callers treat an incomplete scan as protected. */
|
|
59
63
|
function aliasScope(dir: string, lexical: string, actual: string, includeParents: boolean, budget: DiscoveryBudget): boolean {
|
|
60
64
|
// Readability must not depend on reaching readSync after an exhausted-budget return.
|
|
61
65
|
accessSync(dir, constants.R_OK);
|
|
62
66
|
const handle = opendirSync(dir);
|
|
63
67
|
let matched = false;
|
|
64
68
|
try {
|
|
65
|
-
if (budget.entries >= 4096) return
|
|
69
|
+
if (budget.entries >= 4096) return discoveryLimit(budget);
|
|
66
70
|
for (let entry = handle.readSync(); entry; entry = handle.readSync()) {
|
|
67
|
-
if (budget.entries >= 4096) return
|
|
71
|
+
if (budget.entries >= 4096) return discoveryLimit(budget);
|
|
68
72
|
budget.entries++;
|
|
69
73
|
if (!entry.isSymbolicLink()) continue;
|
|
70
74
|
const target = directAliasTarget(join(dir, entry.name));
|
|
@@ -138,13 +142,13 @@ function globScope(root: string, lexical: string, actual: string, path: string,
|
|
|
138
142
|
if (!lowerBeneath(real, root)) throw new Error("policy glob discovery outside project");
|
|
139
143
|
const info = statSync(dir);
|
|
140
144
|
if (!info.isDirectory()) return false;
|
|
141
|
-
if (aliasDiscovery && budget.entries >= 4096) return
|
|
145
|
+
if (aliasDiscovery && budget.entries >= 4096) return discoveryLimit(budget);
|
|
142
146
|
if (depth > 32 || ancestors.has(real) || budget.entries > 4096) throw new Error("policy glob discovery unavailable");
|
|
143
147
|
const chain = new Set(ancestors); chain.add(real);
|
|
144
148
|
const handle = opendirSync(dir);
|
|
145
149
|
try {
|
|
146
150
|
for (let entry = handle.readSync(); entry; entry = handle.readSync()) {
|
|
147
|
-
if (aliasDiscovery && budget.entries >= 4096) return
|
|
151
|
+
if (aliasDiscovery && budget.entries >= 4096) return discoveryLimit(budget);
|
|
148
152
|
if (++budget.entries > 4096) throw new Error("policy glob discovery unavailable");
|
|
149
153
|
const next = advance(pattern, states, entry.name);
|
|
150
154
|
if (next.size && visit(join(dir, entry.name), next, depth + 1, chain)) return true;
|
|
@@ -163,13 +167,13 @@ export function matchesPolicyLexically(cwd: string, path: string, paths: string[
|
|
|
163
167
|
}
|
|
164
168
|
|
|
165
169
|
/** Compare declared spelling, real targets and bounded aliases; legacy discovery errors fail closed. */
|
|
166
|
-
export function matchesPolicy(cwd: string, path: string, paths: string[], includeParents = false): boolean {
|
|
170
|
+
export function matchesPolicy(cwd: string, path: string, paths: string[], includeParents = false, options: { failClosedOnDiscoveryLimit?: boolean } = {}): boolean {
|
|
167
171
|
if (!paths.length) return false;
|
|
168
172
|
try {
|
|
169
173
|
if (path.length > 4096) return true;
|
|
170
174
|
const root = realPath(cwd), lexical = resolve(root, path), actual = policyRealPath(lexical);
|
|
171
175
|
includeParents &&= !existing(lexical) || statSync(lexical).isDirectory();
|
|
172
|
-
const budget = { entries: 0 }, roots = new Set<string>(), exact: string[] = [];
|
|
176
|
+
const budget: DiscoveryBudget = { entries: 0, failClosedOnDiscoveryLimit: options.failClosedOnDiscoveryLimit }, roots = new Set<string>(), exact: string[] = [];
|
|
173
177
|
if (paths.some(entry => {
|
|
174
178
|
if (/[?*]/.test(entry)) return globScope(root, lexical, actual, entry, includeParents, budget, roots);
|
|
175
179
|
const targets = policyTargets(root, [entry]);
|