@deksden-com/dd-flow-cli 0.9.0-beta.100 → 0.9.0-beta.104
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/dist/build-info.json +3 -3
- package/dist/cli/hook-ingress.js +5 -10
- package/dist/cli/run-cli.js +9 -3
- package/dist/harness-runtime/bin/dd-agy.mjs +20 -7
- package/dist/harness-runtime/bin/dd-droid.mjs +2 -2
- package/dist/harness-runtime/bin/dd-grok.mjs +4 -4
- package/dist/harness-runtime/bin/dd-zcode.mjs +5 -5
- package/dist/harness-runtime/lib/agy-hooks.d.mts +1 -0
- package/dist/harness-runtime/lib/agy-hooks.mjs +9 -0
- package/dist/harness-runtime/lib/daemon-operations.mjs +13 -2
- package/dist/harness-runtime/lib/dd-agy-daemon.d.mts +6 -0
- package/dist/harness-runtime/lib/dd-agy-daemon.mjs +89 -36
- package/dist/harness-runtime/lib/dd-codex-daemon.mjs +7 -4
- package/dist/harness-runtime/lib/dd-codex.mjs +1 -0
- package/dist/harness-runtime/lib/dd-droid-daemon.mjs +1 -1
- package/dist/harness-runtime/lib/dd-droid.mjs +24 -17
- package/dist/harness-runtime/lib/dd-grok-daemon.mjs +107 -20
- package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +5 -3
- package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +6 -10
- package/dist/harness-runtime/lib/dd-zcode.mjs +63 -26
- package/dist/harness-runtime/lib/delegation-instructions.d.mts +1 -1
- package/dist/harness-runtime/lib/delegation-instructions.mjs +6 -1
- package/dist/harness-runtime/lib/managed-daemon.mjs +38 -4
- package/dist/harness-runtime/lib/native-hook-command.mjs +87 -13
- package/dist/harness-runtime/lib/process-json.mjs +3 -2
- package/dist/services/code-checks.js +53 -16
- package/dist/services/controller-fanout.js +37 -6
- package/dist/services/eval-snapshots.js +148 -38
- package/dist/services/external-work-launch.js +60 -5
- package/dist/services/prompts.js +2 -0
- package/dist/services/review-copy.js +136 -0
- package/dist/services/run-control-worker.js +16 -11
- package/dist/services/run-controller-adapter.js +9 -0
- package/dist/services/runs.js +3 -0
- package/dist/services/runtime-scope-stop.js +66 -6
- package/dist/services/vnext-code-review.js +6 -2
- package/dist/services/vnext-code.js +1 -0
- package/dist/services/vnext-fanout.js +9 -1
- package/dist/services/vnext-merge.js +8 -4
- package/dist/services/vnext-plan-review.js +4 -15
- package/dist/services/vnext-plan.js +4 -0
- package/dist/services/vnext-protocolize.js +4 -2
- package/dist/services/vnext-specify.js +7 -0
- package/dist/services/work-registry.js +208 -22
- package/dist/services/workspace-files.js +83 -0
- package/dist/services/worktrees.js +3 -1
- package/package.json +1 -1
|
@@ -188,6 +188,7 @@ export class AcpBridge {
|
|
|
188
188
|
this.child.on("error", (error) => { error.code ??= "bridge_exited"; this.rejectAll(error); });
|
|
189
189
|
this.child.on("exit", (code) => { const error = new Error(`zcode-acp exited ${code}${this.bridgeStderr.trim() ? `: ${this.bridgeStderr.trim()}` : ""}`); error.code = "bridge_exited"; this.rejectAll(error); });
|
|
190
190
|
readline.createInterface({ input: this.child.stdout }).on("line", (line) => this.receive(line));
|
|
191
|
+
if (this.options.onSpawn) await this.options.onSpawn(this.child);
|
|
191
192
|
return await this.request("initialize", {
|
|
192
193
|
protocolVersion: 1,
|
|
193
194
|
clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, elicitation: { form: {} } },
|
|
@@ -455,10 +456,47 @@ async function resume(bridge, sessionId, cwd) {
|
|
|
455
456
|
return sessionId;
|
|
456
457
|
}
|
|
457
458
|
|
|
459
|
+
/** Ended children are paginated by the native API (20 by default). A partial
|
|
460
|
+
* directory is never settlement evidence, including during teardown. */
|
|
461
|
+
export async function readZcodeSubagents(bridge, sessionId, timeoutMs = 30_000, retained = false) {
|
|
462
|
+
const deadline = performance.now() + timeoutMs;
|
|
463
|
+
const cursors = new Set(), ended = new Map();
|
|
464
|
+
let first, cursor;
|
|
465
|
+
for (;;) {
|
|
466
|
+
const remaining = deadline - performance.now();
|
|
467
|
+
if (remaining <= 0) fail("ZCode child observation deadline expired", "native_topology_incomplete");
|
|
468
|
+
const response = await bridge.request(retained ? "zcode/session/retainedSubagents" : "zcode/session/subagents", { sessionId, ...(cursor ? { endedCursor: cursor } : {}) }, remaining);
|
|
469
|
+
if (retained && response?.sessionId !== sessionId) fail("Retained ZCode child directory has a foreign Session identity", "native_topology_incomplete");
|
|
470
|
+
const page = retained ? response.topology : response;
|
|
471
|
+
if (performance.now() >= deadline) fail("ZCode child observation deadline expired", "native_topology_incomplete");
|
|
472
|
+
if (!page || !Array.isArray(page.running)) fail("ZCode child directory is unavailable", "native_topology_incomplete");
|
|
473
|
+
if (!first) first = page;
|
|
474
|
+
if (!page.ended) {
|
|
475
|
+
if (cursor) fail("ZCode child page omitted ended outcomes", "native_topology_incomplete");
|
|
476
|
+
return page; // Older qualified bridges expose an unpaginated completed array.
|
|
477
|
+
}
|
|
478
|
+
if (!Array.isArray(page.ended.items) || !Number.isSafeInteger(page.ended.total) || page.ended.total < 0
|
|
479
|
+
|| page.revision !== first.revision || page.ended.total !== first.ended.total) fail("ZCode child directory changed or is malformed during pagination", "native_topology_incomplete");
|
|
480
|
+
for (const child of page.ended.items) {
|
|
481
|
+
if (typeof child.childSessionId !== "string" || !child.childSessionId) fail("ZCode ended child has no identity", "native_topology_incomplete");
|
|
482
|
+
const prior = ended.get(child.childSessionId);
|
|
483
|
+
if (prior && JSON.stringify(prior) !== JSON.stringify(child)) fail("ZCode child pages contain conflicting outcomes", "native_topology_incomplete");
|
|
484
|
+
ended.set(child.childSessionId, child);
|
|
485
|
+
}
|
|
486
|
+
cursor = page.ended.nextCursor;
|
|
487
|
+
if (!cursor) {
|
|
488
|
+
if (ended.size !== first.ended.total) fail("ZCode child directory is incomplete", "native_topology_incomplete");
|
|
489
|
+
return { ...first, ended: { total: first.ended.total, items: [...ended.values()] } };
|
|
490
|
+
}
|
|
491
|
+
if (typeof cursor !== "string" || cursors.has(cursor) || !Number.isSafeInteger(first.revision)) fail("ZCode child pagination has no advancing, versioned cursor", "native_topology_incomplete");
|
|
492
|
+
cursors.add(cursor);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
458
496
|
async function inspect(bridge, sessionId) {
|
|
459
497
|
const [read, subagents] = await Promise.all([
|
|
460
498
|
bridge.request("zcode/session/read", { sessionId }),
|
|
461
|
-
bridge
|
|
499
|
+
readZcodeSubagents(bridge, sessionId),
|
|
462
500
|
]);
|
|
463
501
|
let usage = null, usage_observation_error = null;
|
|
464
502
|
try { usage = await readZcodeUsage(bridge, sessionId); }
|
|
@@ -521,9 +559,8 @@ export async function observeClosedZcodeTree(bridge, root, retainedChildren = []
|
|
|
521
559
|
const resident = await bridge.request("zcode/session/resident", { sessionId }, timeout());
|
|
522
560
|
if (resident?.sessionId !== sessionId || typeof resident.resident !== "boolean") throw new Error("Invalid residency identity or state");
|
|
523
561
|
residents.push(resident);
|
|
524
|
-
const
|
|
525
|
-
|
|
526
|
-
if (retained?.sessionId !== sessionId || !Array.isArray(tree?.childSessionIds)
|
|
562
|
+
const tree = await readZcodeSubagents(bridge, sessionId, deadline - performance.now(), true);
|
|
563
|
+
if (!Array.isArray(tree?.childSessionIds)
|
|
527
564
|
|| tree.childSessionIds.some(id => typeof id !== "string" || !id || id === sessionId)
|
|
528
565
|
|| !Array.isArray(tree?.running) || !Array.isArray(tree?.ended?.items)
|
|
529
566
|
|| !Number.isSafeInteger(tree.ended.total) || tree.ended.total < 0
|
|
@@ -562,7 +599,7 @@ async function cancelTree(bridge, sessionId, before, providerSessionId, retained
|
|
|
562
599
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
563
600
|
const timeout = Math.max(1, deadline - Date.now());
|
|
564
601
|
[after, read] = await Promise.all([
|
|
565
|
-
bridge
|
|
602
|
+
readZcodeSubagents(bridge, sessionId, timeout).catch(error => ({ observation_error: { code: error.code, message: error.message } })),
|
|
566
603
|
bridge.request("zcode/session/read", { sessionId }, timeout).catch(error => ({ observation_error: { code: error.code, message: error.message } })),
|
|
567
604
|
]);
|
|
568
605
|
root_status = read?.projection?.status ?? read?.session?.status ?? null;
|
|
@@ -588,7 +625,7 @@ export async function cancelChildWithBridge(bridge, options) {
|
|
|
588
625
|
requireJournal(options);
|
|
589
626
|
const identity = await controlledIdentity(bridge, options);
|
|
590
627
|
const childSessionId = text(options.childSessionId, "--child-session-id");
|
|
591
|
-
const before = await bridge
|
|
628
|
+
const before = await readZcodeSubagents(bridge, identity.adapterSessionId);
|
|
592
629
|
const child = (before.running ?? []).find((item) => item.childSessionId === childSessionId);
|
|
593
630
|
if (!child) fail(`ZCode has no running child ${childSessionId}`, "child_not_running");
|
|
594
631
|
const taskId = child.taskId ?? child.agentId;
|
|
@@ -597,7 +634,7 @@ export async function cancelChildWithBridge(bridge, options) {
|
|
|
597
634
|
let after = before;
|
|
598
635
|
for (let attempt = 0; attempt < 6; attempt += 1) {
|
|
599
636
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
600
|
-
after = await bridge
|
|
637
|
+
after = await readZcodeSubagents(bridge, identity.adapterSessionId);
|
|
601
638
|
if (!(after.running ?? []).some((item) => item.childSessionId === childSessionId)) break;
|
|
602
639
|
}
|
|
603
640
|
if ((after.running ?? []).some((item) => item.childSessionId === childSessionId)) {
|
|
@@ -752,22 +789,19 @@ async function forwardUsageBestEffort(options, providerSessionId, usage, toolCal
|
|
|
752
789
|
}
|
|
753
790
|
|
|
754
791
|
export function zcodeLifecycleQualification(sha256, bridgeCommit, harnessContract) {
|
|
755
|
-
//
|
|
756
|
-
//
|
|
757
|
-
//
|
|
758
|
-
//
|
|
759
|
-
//
|
|
760
|
-
const
|
|
761
|
-
["
|
|
762
|
-
["
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
["
|
|
792
|
+
// The native app bundle is an auto-updated runtime. Its whole-file SHA is
|
|
793
|
+
// evidence for provenance, not a protocol identity: an unchanged bridge
|
|
794
|
+
// must not reject every normal ZCode patch release. The bridge contract is
|
|
795
|
+
// the compatibility boundary; native event identity is checked by the
|
|
796
|
+
// lifecycle observer and receipts during the bounded qualification probe.
|
|
797
|
+
const qualifiedBridgeCommits = new Map([
|
|
798
|
+
["e0600fe3c46e215695f257e65aa6f674ced998d8", ZCODE_HARNESS_CONTRACT],
|
|
799
|
+
["60af0d31e13076a313d9770f10aa70f7c94742cf", ZCODE_HARNESS_CONTRACT],
|
|
800
|
+
["8c0a893f3c26a0b96cb138acf762db84c800298b", ZCODE_HARNESS_CONTRACT],
|
|
801
|
+
["f4d47b517073f9bdb8d4eba3d0d59ce66c6a2833", "dd-zcode-harness@2"],
|
|
802
|
+
["5a8c89ab716a4b2ccc50df81cb4d204ff5c06368", "dd-zcode-harness@2"],
|
|
766
803
|
]);
|
|
767
|
-
const qualified =
|
|
768
|
-
&& (bridgeCommit === "f4d47b517073f9bdb8d4eba3d0d59ce66c6a2833"
|
|
769
|
-
? harnessContract === "dd-zcode-harness@2"
|
|
770
|
-
: harnessContract === ZCODE_HARNESS_CONTRACT);
|
|
804
|
+
const qualified = qualifiedBridgeCommits.get(bridgeCommit) === harnessContract;
|
|
771
805
|
return { status: qualified ? "qualified" : "unqualified", mechanism: "cli-invocation@1", sha256,
|
|
772
806
|
capabilities: qualified ? ["root", "concurrent_children", "child_continuation"] : [],
|
|
773
807
|
native_nested_children: false, reason: qualified ? null : "native_event_contract_unqualified" };
|
|
@@ -801,8 +835,11 @@ export async function createSessionWithBridge(bridge, options, initialized) {
|
|
|
801
835
|
// fact that allocation was already admitted.
|
|
802
836
|
await options.onSessionAliasAllocated?.({ adapter_session_id: created.sessionId, cwd });
|
|
803
837
|
bridge.allocationObservers?.set(created.sessionId, async identity => {
|
|
804
|
-
|
|
805
|
-
|
|
838
|
+
// ZCode 0.46.x emits adapter/provider identities on this notification but
|
|
839
|
+
// does not repeat the workspace. The cwd from session/new is authoritative
|
|
840
|
+
// for this live bridge; reject only an explicitly conflicting workspace.
|
|
841
|
+
if (identity.cwd !== undefined && identity.cwd !== cwd) fail("ZCode allocation workspace mismatch", "session_identity_mismatch");
|
|
842
|
+
await options.onSessionAllocated?.({ ...identity, cwd });
|
|
806
843
|
});
|
|
807
844
|
const resolved = await bridge.request("zcode/session/resolve", { sessionId: created.sessionId });
|
|
808
845
|
const providerSessionId = resolved.providerSessionId;
|
|
@@ -918,7 +955,7 @@ export async function cancelSession(options) {
|
|
|
918
955
|
export async function cancelSessionWithBridge(bridge, options) {
|
|
919
956
|
requireJournal(options);
|
|
920
957
|
const identity = await controlledIdentity(bridge, options);
|
|
921
|
-
const before = await bridge
|
|
958
|
+
const before = await readZcodeSubagents(bridge, identity.adapterSessionId).catch(error => ({ observation_error: { code: error.code, message: error.message } }));
|
|
922
959
|
return receipt(options, { harness: "zcode-acp", provider_session_id: identity.providerSessionId, adapter_session_id: identity.adapterSessionId, ...await cancelTree(bridge, identity.adapterSessionId, before, identity.providerSessionId, options.knownChildSessionIds) });
|
|
923
960
|
}
|
|
924
961
|
|
|
@@ -931,7 +968,7 @@ export async function forkSessionWithBridge(bridge, options) {
|
|
|
931
968
|
requireJournal(options);
|
|
932
969
|
if (!options.target || typeof options.target !== "object") fail("explicit --target-json is required");
|
|
933
970
|
const identity = await controlledIdentity(bridge, options);
|
|
934
|
-
const topology = await bridge
|
|
971
|
+
const topology = await readZcodeSubagents(bridge, identity.adapterSessionId);
|
|
935
972
|
if ((topology.running ?? []).length) fail("cannot fork while background children are running");
|
|
936
973
|
const result = await bridge.request("session/fork", { sessionId: identity.adapterSessionId, target: options.target });
|
|
937
974
|
if (!result.forkedSessionId) fail("ZCode fork returned no provider Session ID");
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export type DelegationWork = { work_id: string; start_command: string; launch_policy?: string };
|
|
2
|
-
export type DelegationInput = { harness: string; stage: string; capacity: number; works?: DelegationWork[] };
|
|
2
|
+
export type DelegationInput = { harness: string; stage: string; capacity: number; works?: DelegationWork[]; totalWorks?: number; remainingWorks?: number };
|
|
3
3
|
export function delegationAdapter(harness: string): string | null;
|
|
4
4
|
export function delegationContract(harness: string): string;
|
|
5
5
|
export function workerDelegationTask(input: { workId: string; startCommand: string; launchPolicy?: string }): string;
|
|
@@ -70,7 +70,7 @@ export function workerDelegationTask({ workId, startCommand, launchPolicy }) {
|
|
|
70
70
|
].join("\n");
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
export function renderDelegationInstructions({ harness, stage, capacity, works = [] }) {
|
|
73
|
+
export function renderDelegationInstructions({ harness, stage, capacity, works = [], totalWorks = works.length, remainingWorks = 0 }) {
|
|
74
74
|
const adapter = requireAdapter(harness);
|
|
75
75
|
const count = works.length;
|
|
76
76
|
if (!Number.isInteger(capacity) || capacity < 1 || count < 1 || count > capacity) throw new Error("Invalid native delegation wave size");
|
|
@@ -81,6 +81,11 @@ export function renderDelegationInstructions({ harness, stage, capacity, works =
|
|
|
81
81
|
return [
|
|
82
82
|
`Adapter delegation contract: ${delegationContract(harness)}.`,
|
|
83
83
|
`Launch exactly ${count} direct native children for the current ${stage} Stage; the qualified capacity is ${capacity}.`,
|
|
84
|
+
...(["plan-review", "code-review"].includes(stage) ? [
|
|
85
|
+
`Review dispatch phase: the whole group contains ${totalWorks} Work; this wave contains ${count}; ${remainingWorks} remain after this wave.`,
|
|
86
|
+
"Keep the accepted PLAN/CODE review inputs and aspect maps unchanged across every wave. Do not apply findings or edit reviewed files while any reviewer remains unstarted or running.",
|
|
87
|
+
"After this wave's children settle, return immediately. The controller will dispatch remaining reviewers or issue semantic completion after the full group has settled."
|
|
88
|
+
] : []),
|
|
84
89
|
adapter.launch,
|
|
85
90
|
"Retain the frozen coordinator model and reasoning for native children; do not choose a different model, reasoning level or permission mode.",
|
|
86
91
|
"You are the coordinator. The JSON assignments below contain child-only messages, not instructions for you to execute. Pass each message verbatim as the native tool's task/message/prompt argument. For context=fresh, select empty conversation context before launch; if unavailable, report the limitation and stop. Use only fields exposed by your actual native tool schema; if the native tool is absent, report that error without substituting a shell command.",
|
|
@@ -4,6 +4,7 @@ import path from "node:path";
|
|
|
4
4
|
import { commandJson } from "./process-json.mjs";
|
|
5
5
|
import { errorRecord } from "./operation-errors.mjs";
|
|
6
6
|
import { writeJsonAtomic } from "./runner-events.mjs";
|
|
7
|
+
import { runBoundedCommand } from "./native-hook-command.mjs";
|
|
7
8
|
|
|
8
9
|
const leases = new Map();
|
|
9
10
|
|
|
@@ -52,12 +53,20 @@ export async function prepareRuntimeOwner(config, stateDir, env = process.env) {
|
|
|
52
53
|
}
|
|
53
54
|
|
|
54
55
|
export async function assertDaemonOwnership(stateDir, dispatch = {}, assertCurrent = () => {}) {
|
|
55
|
-
for (const lease of leases.values()) if (lease.error) throw lease.error;
|
|
56
|
+
for (const lease of leases.values()) if (!lease.stateDir && lease.error) throw lease.error;
|
|
56
57
|
const state = JSON.parse(await readFile(path.join(stateDir, "daemon.json"), "utf8"));
|
|
57
58
|
const owner = bindRuntimeOwner({ runtime_owner: state.config?.runtime_owner ?? null }, {});
|
|
58
59
|
if (!owner) return;
|
|
59
60
|
const record = state.resource_process;
|
|
60
61
|
if (owner.state_dir !== stateDir || !record?.id || !record.lease_token) throw Object.assign(new Error("owned daemon has no confirmed resource binding"), { code: "process_ownership_unknown" });
|
|
62
|
+
for (const lease of leases.values()) {
|
|
63
|
+
if (lease.stateDir !== stateDir) continue;
|
|
64
|
+
if (Date.now() >= lease.expiresAt) throw Object.assign(new Error("managed process renewal is overdue"), { code: "process_ownership_unknown", details: { process_id: lease.record.id, cause: lease.error ? errorRecord(lease.error) : null } });
|
|
65
|
+
// A failed renewal is not a lost lease. Reconfirm before productive work;
|
|
66
|
+
// never dispatch on the old lease merely because it has time remaining.
|
|
67
|
+
if (lease.error && !lease.lost) await heartbeatDaemonProcess(lease.config, lease.record);
|
|
68
|
+
if (lease.error) throw lease.error;
|
|
69
|
+
}
|
|
61
70
|
for (;;) {
|
|
62
71
|
assertCurrent();
|
|
63
72
|
const result = await runtimeProcess(state.config, "admission", { id: record.id, "lease-token": record.lease_token, owner: owner.owner_id, "state-dir": stateDir, "daemon-id": state.daemon_id ?? state.config?.daemonId, ...dispatch });
|
|
@@ -156,6 +165,8 @@ export async function runtimeProcess(config, action, options = {}) {
|
|
|
156
165
|
if (!resourceHome || !ddFlowHome || !ddFlowBin) return null;
|
|
157
166
|
const args = ["runtime", "process", action];
|
|
158
167
|
for (const [key, value] of Object.entries(options)) if (value !== null && value !== undefined) args.push(`--${key}`, String(value));
|
|
168
|
+
if (action === "heartbeat") return JSON.parse(await runBoundedCommand({ bin: ddFlowBin, args: [...args, "--json"], cwd: config.cwd,
|
|
169
|
+
env: { ...(config.env ?? {}), DD_FLOW_HOME: ddFlowHome, DD_FLOW_RESOURCE_HOME: resourceHome }, timeoutMs: 15_000, timeoutCode: "process_heartbeat_timeout" }));
|
|
159
170
|
return await commandJson(ddFlowBin, args, {
|
|
160
171
|
cwd: config.cwd,
|
|
161
172
|
env: { ...(config.env ?? {}), DD_FLOW_HOME: ddFlowHome, DD_FLOW_RESOURCE_HOME: resourceHome }
|
|
@@ -164,7 +175,7 @@ export async function runtimeProcess(config, action, options = {}) {
|
|
|
164
175
|
|
|
165
176
|
export async function registerDaemonProcess(config, input) {
|
|
166
177
|
const owner = bindRuntimeOwner(config);
|
|
167
|
-
const harness = ({ "codex-daemon": "codex-desktop", "zcode-daemon": "zcode-acp", "grok-daemon": "grok-acp", "agy-daemon": "antigravity-cli", "opencode-daemon": "opencode-server", "droid-daemon": "droid-cli" })[input.kind];
|
|
178
|
+
const harness = ({ "codex-daemon": "codex-desktop", "codex-provider": "codex-desktop", "zcode-daemon": "zcode-acp", "zcode-provider": "zcode-acp", "grok-daemon": "grok-acp", "grok-provider": "grok-acp", "agy-daemon": "antigravity-cli", "opencode-daemon": "opencode-server", "droid-daemon": "droid-cli" })[input.kind];
|
|
168
179
|
const scope = owner ? { owner: owner.owner_id, "project-id": owner.project_id, run: owner.run_id, work: owner.work_id, role: owner.role, "state-dir": owner.state_dir, "adapter-executable": owner.adapter_executable, ...(owner.budget ? { "budget-json": JSON.stringify(owner.budget), harness } : {}), ...(owner.project_id && owner.run_id || owner.budget ? { "dispatch-barrier": "run-generation@1" } : {}), operation: `${owner.operation_id}:${input.operation ?? input.kind}` } : {};
|
|
169
180
|
const result = await runtimeProcess(config, "register", { ...input, ...scope, "owner-pid": process.pid, "lease-ms": 300_000 });
|
|
170
181
|
return result?.process ? { id: result.process.id, lease_token: result.process.lease_token, kind: input.kind } : null;
|
|
@@ -179,11 +190,33 @@ export async function confirmDaemonProcess(config, record, child) {
|
|
|
179
190
|
await runtimeProcess(config, "confirm", { id: record.id, "lease-token": record.lease_token, pid: child.pid, "process-group-id": child.pid, "owner-pid": record.kind?.endsWith("-daemon") ? child.pid : process.pid, "lease-ms": 300_000 });
|
|
180
191
|
}
|
|
181
192
|
|
|
193
|
+
/** Provider bridges use a different process group from their daemon. Publish
|
|
194
|
+
* the exact binding before initialize can start productive provider traffic. */
|
|
195
|
+
export async function startManagedBridge({ config, state, bridge, kind, publish }) {
|
|
196
|
+
state.pid = process.pid;
|
|
197
|
+
await publish(state);
|
|
198
|
+
const provider = config.runtime_owner ? await registerDaemonProcess(config, { kind, owner: config.runtime_owner.owner_id, operation: kind }) : null;
|
|
199
|
+
bridge.options.onSpawn = async child => {
|
|
200
|
+
await confirmDaemonProcess(config, provider, child);
|
|
201
|
+
await heartbeatDaemonProcess(config, provider);
|
|
202
|
+
state.provider_pid = child.pid;
|
|
203
|
+
state.provider_process = provider;
|
|
204
|
+
await publish(state);
|
|
205
|
+
};
|
|
206
|
+
try { return await bridge.start(); }
|
|
207
|
+
catch (error) { await cleanupFailedStart(config, provider, bridge.child, error); throw error; }
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export async function closeManagedBridge(config, state, bridge) {
|
|
211
|
+
await bridge.close();
|
|
212
|
+
await finishDaemonProcess(config, state.provider_process, "stopped", "daemon_stopped");
|
|
213
|
+
}
|
|
214
|
+
|
|
182
215
|
export async function heartbeatDaemonProcess(config, record) {
|
|
183
216
|
if (!record?.id || !record.lease_token) return;
|
|
184
217
|
let lease = leases.get(record.id);
|
|
185
218
|
if (!lease) {
|
|
186
|
-
lease = { pending: null, error: null, timer: null, lost: false };
|
|
219
|
+
lease = { pending: null, error: null, timer: null, lost: false, expiresAt: Date.now() + 300_000, stateDir: config.runtime_owner?.state_dir, config, record };
|
|
187
220
|
leases.set(record.id, lease);
|
|
188
221
|
lease.timer = setInterval(() => { void heartbeatDaemonProcess(config, record).catch(() => {}); }, 60_000);
|
|
189
222
|
lease.timer.unref();
|
|
@@ -198,8 +231,9 @@ export async function heartbeatDaemonProcess(config, record) {
|
|
|
198
231
|
throw Object.assign(new Error("managed process lease is no longer owned; new productive operations are blocked"), { code: "process_lease_lost", details: { process_id: record.id } });
|
|
199
232
|
}
|
|
200
233
|
lease.error = null;
|
|
234
|
+
lease.expiresAt = Date.now() + 300_000;
|
|
201
235
|
} catch (cause) {
|
|
202
|
-
lease.error = cause.code === "process_lease_lost" ? cause : Object.assign(new Error("cannot confirm managed process ownership"), { code: "process_ownership_unknown", cause, details: { process_id: record.id } });
|
|
236
|
+
lease.error = cause.code === "process_lease_lost" ? cause : Object.assign(new Error("cannot confirm managed process ownership"), { code: "process_ownership_unknown", cause, details: { process_id: record.id, cause: errorRecord(cause) } });
|
|
203
237
|
throw lease.error;
|
|
204
238
|
}
|
|
205
239
|
})();
|
|
@@ -1,31 +1,105 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
+
import { reportedFailure } from "./process-json.mjs";
|
|
3
|
+
|
|
4
|
+
/** Native command hooks have a 30s outer limit; leave room for transport and cleanup. */
|
|
5
|
+
export function hookBudget(timeoutMs = 25_000, deadlineAt = null) {
|
|
6
|
+
const started = performance.now();
|
|
7
|
+
const duration = Math.max(0, Math.min(timeoutMs, Number.isFinite(deadlineAt) ? deadlineAt - Date.now() : timeoutMs));
|
|
8
|
+
const remaining = (phase, maximum = duration) => {
|
|
9
|
+
const value = duration - (performance.now() - started);
|
|
10
|
+
if (value <= 0) throw Object.assign(new Error(`Native hook deadline exceeded during ${phase}`), { code: "native_hook_timeout", details: { phase, elapsed_ms: Math.round(performance.now() - started), effect: "unknown" } });
|
|
11
|
+
return Math.max(1, Math.min(maximum, value));
|
|
12
|
+
};
|
|
13
|
+
return { deadlineAt: Date.now() + duration, remaining, async run(action, phase) {
|
|
14
|
+
const timeoutMs = remaining(phase);
|
|
15
|
+
let timer;
|
|
16
|
+
try { return await Promise.race([Promise.resolve().then(action), new Promise((_, reject) => { timer = setTimeout(() => reject(Object.assign(new Error(`Native hook deadline exceeded during ${phase}`), { code: "native_hook_timeout", details: { phase, effect: "unknown" } })), timeoutMs); })]); }
|
|
17
|
+
finally { clearTimeout(timer); }
|
|
18
|
+
} };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Bound native hook ingress before parsing or opening adapter state. */
|
|
22
|
+
export function readNativeHookInput(stream, budget, maxBytes = 1_048_576) {
|
|
23
|
+
return new Promise((resolve, reject) => {
|
|
24
|
+
const chunks = []; let bytes = 0, done = false;
|
|
25
|
+
const cleanup = () => { clearTimeout(timer); stream.off("data", onData); stream.off("end", onEnd); stream.off("error", onError); };
|
|
26
|
+
const finish = (error, value) => { if (done) return; done = true; cleanup(); if (error) { stream.destroy(); reject(error); } else resolve(value); };
|
|
27
|
+
const onData = chunk => {
|
|
28
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
29
|
+
bytes += buffer.length;
|
|
30
|
+
if (bytes > maxBytes) finish(Object.assign(new Error("Native hook input exceeds 1 MiB"), { code: "native_hook_input_too_large" }));
|
|
31
|
+
else chunks.push(buffer);
|
|
32
|
+
};
|
|
33
|
+
const onEnd = () => finish(null, Buffer.concat(chunks).toString("utf8"));
|
|
34
|
+
const onError = error => finish(error);
|
|
35
|
+
let timeoutMs;
|
|
36
|
+
try { timeoutMs = budget.remaining("input"); } catch (error) { reject(error); return; }
|
|
37
|
+
const timer = setTimeout(() => finish(Object.assign(new Error("Native hook deadline exceeded during input"), { code: "native_hook_timeout", details: { phase: "input", effect: "unknown" } })), timeoutMs);
|
|
38
|
+
stream.on("data", onData).once("end", onEnd).once("error", onError);
|
|
39
|
+
stream.resume();
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function terminateCommandTree(child, { platform = process.platform, launch = spawn, timeoutMs = 400 } = {}) {
|
|
44
|
+
if (!child.pid) return;
|
|
45
|
+
if (platform !== "win32") { process.kill(-child.pid, "SIGKILL"); return; }
|
|
46
|
+
// Node's child.kill on Windows only terminates the leader. taskkill is the
|
|
47
|
+
// native subtree primitive; bound its own lifetime as well as the request.
|
|
48
|
+
await new Promise((resolve, reject) => {
|
|
49
|
+
const killer = launch("taskkill", ["/PID", String(child.pid), "/T", "/F"], { windowsHide: true, stdio: "ignore" });
|
|
50
|
+
let settled = false, timer;
|
|
51
|
+
const finish = error => { if (settled) return; settled = true; clearTimeout(timer); error ? reject(error) : resolve(); };
|
|
52
|
+
timer = setTimeout(() => { killer.kill("SIGKILL"); killer.unref(); finish(Object.assign(new Error("Windows subtree cleanup timed out"), { code: "process_cleanup_timeout" })); }, timeoutMs);
|
|
53
|
+
killer.once("error", finish);
|
|
54
|
+
killer.once("close", code => finish(code === 0 ? null : Object.assign(new Error(`taskkill exited ${code}`), { code: "process_cleanup_failed" })));
|
|
55
|
+
});
|
|
56
|
+
}
|
|
2
57
|
|
|
3
58
|
/** Preserve the native hook protocol across the CLI process boundary. */
|
|
4
|
-
export async function
|
|
59
|
+
export async function runBoundedCommand({ bin, args, input = null, cwd, env = {}, timeoutMs = 15_000, timeoutCode = "native_hook_timeout" }) {
|
|
60
|
+
const started = performance.now();
|
|
5
61
|
const script = /\.[cm]?js$/.test(bin);
|
|
6
62
|
const output = await new Promise((resolve, reject) => {
|
|
7
63
|
const child = spawn(script ? process.execPath : bin, [...(script ? [bin] : []), ...args], {
|
|
8
|
-
env: { ...process.env,
|
|
64
|
+
cwd, env: { ...process.env, ...env }, detached: process.platform !== "win32", stdio: ["pipe", "pipe", "pipe"]
|
|
9
65
|
});
|
|
10
|
-
let stdout = "", stderr = "", failure;
|
|
11
|
-
const
|
|
12
|
-
const
|
|
66
|
+
let stdout = "", stderr = "", failure, cleanupTimer, settled = false, termination;
|
|
67
|
+
const finish = (error, value) => { if (settled) return; settled = true; clearTimeout(timer); clearTimeout(cleanupTimer); error ? reject(error) : resolve(value); };
|
|
68
|
+
const stop = error => {
|
|
69
|
+
if (termination || settled) return;
|
|
70
|
+
failure ??= error;
|
|
71
|
+
failure.details = { ...failure.details, elapsed_ms: Math.round(performance.now() - started), effect: "unknown" };
|
|
72
|
+
termination = terminateCommandTree(child).catch(cleanup => {
|
|
73
|
+
if (cleanup.code === "ESRCH") return;
|
|
74
|
+
failure.details.cleanup_error = { code: cleanup.code, message: cleanup.message };
|
|
75
|
+
failure.details.cleanup_unconfirmed = true;
|
|
76
|
+
child.kill("SIGKILL");
|
|
77
|
+
});
|
|
78
|
+
cleanupTimer ??= setTimeout(() => {
|
|
79
|
+
failure.details.cleanup_unconfirmed = true;
|
|
80
|
+
child.stdin.destroy(); child.stdout.destroy(); child.stderr.destroy(); child.unref();
|
|
81
|
+
finish(failure);
|
|
82
|
+
}, 500);
|
|
83
|
+
};
|
|
84
|
+
const timer = setTimeout(() => stop(Object.assign(new Error("Bounded CLI request timed out"), { code: timeoutCode })), timeoutMs);
|
|
13
85
|
child.stdout.setEncoding("utf8").on("data", chunk => { stdout += chunk; if (stdout.length > 1_048_576) stop(new Error("Native hook response exceeds protocol limit")); });
|
|
14
86
|
child.stderr.setEncoding("utf8").on("data", chunk => { stderr = (stderr + chunk).slice(-65_536); });
|
|
15
87
|
child.stdin.on("error", error => { failure ??= error; });
|
|
16
|
-
child.on("error", error =>
|
|
88
|
+
child.on("error", error => finish(failure ?? error));
|
|
17
89
|
child.on("close", code => {
|
|
18
|
-
|
|
19
|
-
if (failure) return reject(failure);
|
|
90
|
+
if (failure) { void (termination ?? Promise.resolve()).then(() => finish(failure)); return; }
|
|
20
91
|
if (code !== 0) {
|
|
21
|
-
|
|
22
|
-
try { record = JSON.parse(stderr.trim()).error; } catch { /* Non-JSON stderr remains diagnostic evidence. */ }
|
|
23
|
-
return reject(Object.assign(new Error(record?.message ?? (stderr.trim() || "Native hook admission failed")), { code: record?.code ?? "native_hook_failed", details: record?.details }));
|
|
92
|
+
return finish(reportedFailure(stdout, stderr, "Native hook admission failed", "native_hook_failed"));
|
|
24
93
|
}
|
|
25
|
-
|
|
94
|
+
finish(null, stdout);
|
|
26
95
|
});
|
|
27
|
-
child.stdin.end(
|
|
96
|
+
child.stdin.end(input);
|
|
28
97
|
});
|
|
98
|
+
return output;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function invokeNativeHook({ bin, args, payload, home, cwd, env, timeoutMs = 15_000 }) {
|
|
102
|
+
const output = await runBoundedCommand({ bin, args, input: `${JSON.stringify(payload)}\n`, cwd, env: { ...env, DD_FLOW_HOME: home }, timeoutMs });
|
|
29
103
|
let result;
|
|
30
104
|
try { result = JSON.parse(output); } catch { throw Object.assign(new Error("Invalid native hook JSON response"), { code: "native_hook_response_invalid" }); }
|
|
31
105
|
if (result?.ok === false && typeof result.error?.code === "string") throw Object.assign(new Error(result.error.message ?? "Native hook failed"), { code: result.error.code, details: result.error.details });
|
|
@@ -5,17 +5,18 @@ function failure(message, code) {
|
|
|
5
5
|
return Object.assign(new Error(message), { code });
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
-
function reportedFailure(stdout, stderr, fallback) {
|
|
8
|
+
export function reportedFailure(stdout, stderr, fallback, fallbackCode = "flow_reconciliation_failed") {
|
|
9
9
|
for (const text of [stdout, stderr]) {
|
|
10
10
|
for (const line of [text.trim(), ...text.trim().split(/\n/).reverse()]) {
|
|
11
11
|
try {
|
|
12
12
|
const value = JSON.parse(line);
|
|
13
13
|
const error = value?.error;
|
|
14
14
|
if (typeof error?.code === "string" && error.code) return reportedError(error, fallback);
|
|
15
|
+
if (typeof value?.code === "string" && value.code) return reportedError({ ...value, message: typeof error === "string" ? error : value.message }, fallback);
|
|
15
16
|
} catch { /* Not a structured CLI error line. */ }
|
|
16
17
|
}
|
|
17
18
|
}
|
|
18
|
-
return failure(stderr.trim() || stdout.trim() || fallback,
|
|
19
|
+
return failure(stderr.trim() || stdout.trim() || fallback, fallbackCode);
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
function processTarget(executable, args) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
|
+
import { isGitlessWorkspace, ownedWorkspaceHookPath, projectServicePathFilter, workspaceGitFiles } from "./workspace-files.js";
|
|
2
3
|
import { spawn, spawnSync } from "node:child_process";
|
|
3
4
|
import fs from "node:fs";
|
|
4
5
|
import path from "node:path";
|
|
@@ -43,6 +44,28 @@ export function readCodeCheckProfile(workspaceRoot) {
|
|
|
43
44
|
throw new AppError("invalid_code_check_profile", "An input declaration references an unknown alias", 2, { file, alias });
|
|
44
45
|
return { profile: value, hash: crypto.createHash("sha256").update(bytes).digest("hex"), file };
|
|
45
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* Planned aliases are qualification-owned extensions: their provider Work is
|
|
49
|
+
* expected to materialize them after PLAN. Freeze the project policy and
|
|
50
|
+
* existing aliases, while still validating every planned alias definition at
|
|
51
|
+
* execution time. Unknown edits and changes to existing policy remain part
|
|
52
|
+
* of the hash and therefore fail closed.
|
|
53
|
+
*/
|
|
54
|
+
export function codeCheckProfileHash(workspaceRoot, checks = []) {
|
|
55
|
+
const { profile } = readCodeCheckProfile(workspaceRoot);
|
|
56
|
+
if (!profile)
|
|
57
|
+
return null;
|
|
58
|
+
const planned = new Set(checks.filter(check => check.availability === "planned" && check.command.startsWith("@check/")).map(check => check.command));
|
|
59
|
+
if (!planned.size)
|
|
60
|
+
return crypto.createHash("sha256").update(JSON.stringify(profile)).digest("hex");
|
|
61
|
+
const canonical = {
|
|
62
|
+
...profile,
|
|
63
|
+
aliases: Object.fromEntries(Object.entries(profile.aliases).filter(([alias]) => !planned.has(alias))),
|
|
64
|
+
ports_by_alias: Object.fromEntries(Object.entries(profile.ports_by_alias ?? {}).filter(([alias]) => !planned.has(alias))),
|
|
65
|
+
inputs_by_alias: Object.fromEntries(Object.entries(profile.inputs_by_alias ?? {}).filter(([alias]) => !planned.has(alias)))
|
|
66
|
+
};
|
|
67
|
+
return crypto.createHash("sha256").update(JSON.stringify(canonical)).digest("hex");
|
|
68
|
+
}
|
|
46
69
|
export function validateCodeCheckCommands(workspaceRoot, commands) {
|
|
47
70
|
const { profile, file } = readCodeCheckProfile(workspaceRoot);
|
|
48
71
|
return commands.map((command) => {
|
|
@@ -139,7 +162,8 @@ async function runCodeChecksOwned(context, input, workspaceGateId) {
|
|
|
139
162
|
const generation = assertRunMutationAllowed(context, input.projectId, input.runId);
|
|
140
163
|
for (const check of input.checks)
|
|
141
164
|
validateCheckDeclaration(input.workspaceRoot, check);
|
|
142
|
-
const {
|
|
165
|
+
const { profile } = readCodeCheckProfile(input.workspaceRoot);
|
|
166
|
+
const profileHash = codeCheckProfileHash(input.workspaceRoot, input.checks);
|
|
143
167
|
if (input.expectedProfileHash !== undefined && input.expectedProfileHash !== profileHash)
|
|
144
168
|
throw new AppError("check_profile_drift", "The frozen check profile differs from the workspace profile", 1, { expected_profile_hash: input.expectedProfileHash, actual_profile_hash: profileHash });
|
|
145
169
|
const checks = input.checks.map((check) => { const ports = profile?.ports_by_alias?.[check.command]; const inputs = profile?.inputs_by_alias?.[check.command]; return { ...check, ...(check.ports || !ports ? {} : { ports }), ...(check.inputs || !inputs ? {} : { inputs }) }; });
|
|
@@ -239,7 +263,7 @@ async function runCodeChecksOwned(context, input, workspaceGateId) {
|
|
|
239
263
|
input.progress?.(`check ${index + 1}/${groups.length} ${status}: ${group.command}`);
|
|
240
264
|
}
|
|
241
265
|
if (receipts.every(receipt => receipt.status === "passed")) {
|
|
242
|
-
const current = workspaceState(input.workspaceRoot, [input.runHome]),
|
|
266
|
+
const current = workspaceState(input.workspaceRoot, [input.runHome]), currentProfileHash = codeCheckProfileHash(input.workspaceRoot, input.checks);
|
|
243
267
|
for (const receipt of receipts) {
|
|
244
268
|
const group = groups.find(candidate => candidate.declarations.some(check => receipt.check_refs.includes(checkRef(check))));
|
|
245
269
|
const currentHash = group ? checkInputHash(input.workspaceRoot, group, currentProfileHash, workspaceInputFingerprint(input.workspaceRoot, current.files, group.inputs, [input.runHome])) : null;
|
|
@@ -414,7 +438,7 @@ export function workspaceChangedPaths(root) {
|
|
|
414
438
|
// A workspace can be intentionally gitless. Keep the same conservative
|
|
415
439
|
// file inventory used for fingerprints instead of making result validation
|
|
416
440
|
// fail before the work contract can report its actual error.
|
|
417
|
-
if (
|
|
441
|
+
if (isGitlessWorkspace(root, status))
|
|
418
442
|
return listFiles(root);
|
|
419
443
|
if (status.status !== 0 || status.error)
|
|
420
444
|
throw new AppError("workspace_status_failed", "Could not determine changed workspace paths", 1, { workspace_root: root, stderr: status.stderr || null, cause: status.error?.message ?? null });
|
|
@@ -434,7 +458,8 @@ export function workspaceChangedPaths(root) {
|
|
|
434
458
|
paths.push(normalizeWorkspacePath(root, prior));
|
|
435
459
|
}
|
|
436
460
|
}
|
|
437
|
-
|
|
461
|
+
const ownedHook = paths.includes(".agents/hooks.json") ? ownedWorkspaceHookPath(root) : null;
|
|
462
|
+
return [...new Set(paths)].filter(file => file !== ownedHook).sort();
|
|
438
463
|
}
|
|
439
464
|
/** Ignored machine state cannot be reported as a product change. Tracked paths
|
|
440
465
|
* remain eligible regardless of their directory name. */
|
|
@@ -443,15 +468,15 @@ export function ignoredWorkspacePaths(root, paths) {
|
|
|
443
468
|
if (!normalized.length)
|
|
444
469
|
return [];
|
|
445
470
|
const status = spawnSync("git", ["check-ignore", "-z", "--stdin"], { cwd: root, encoding: "utf8", input: `${normalized.join("\0")}\0`, maxBuffer: 64 * 1024 * 1024 });
|
|
446
|
-
//
|
|
447
|
-
//
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
if (status.status === 128 && /not a git repository|outside a repository/i.test(status.stderr ?? ""))
|
|
451
|
-
return [];
|
|
452
|
-
if (![0, 1].includes(status.status ?? -1) || status.error)
|
|
471
|
+
// The canonical owned hook has one meaning across Git status, check cache
|
|
472
|
+
// and MERGE, without changing a sibling worktree's shared Git excludes.
|
|
473
|
+
const gitless = isGitlessWorkspace(root, status);
|
|
474
|
+
if (!gitless && (![0, 1].includes(status.status ?? -1) || status.error))
|
|
453
475
|
throw new AppError("workspace_ignore_check_failed", "Could not classify reported workspace paths", 1, { workspace_root: root, stderr: status.stderr || null, cause: status.error?.message ?? null });
|
|
454
|
-
|
|
476
|
+
const ownedHook = normalized.includes(".agents/hooks.json") ? ownedWorkspaceHookPath(root, gitless ? [] : undefined) : null;
|
|
477
|
+
if (gitless)
|
|
478
|
+
return ownedHook ? [ownedHook] : [];
|
|
479
|
+
return [...new Set([...status.stdout.split("\0").filter(Boolean).map((item) => normalizeWorkspacePath(root, item)), ...(ownedHook ? [ownedHook] : [])])].sort();
|
|
455
480
|
}
|
|
456
481
|
export function normalizeWorkspacePath(root, value) {
|
|
457
482
|
if (typeof value !== "string" || !value || value.includes("\0") || path.isAbsolute(value))
|
|
@@ -480,11 +505,14 @@ function deduplicate(checks, commands) {
|
|
|
480
505
|
return [...groups.values()].map(group => ({ ...group, gate: [...group.declarations].map(check => check.run_at).sort()[0], declarations: [...group.declarations].sort((left, right) => checkRef(left).localeCompare(checkRef(right))) }));
|
|
481
506
|
}
|
|
482
507
|
function workspaceState(root, ignoredRoots = []) {
|
|
483
|
-
const git =
|
|
508
|
+
const git = workspaceGitFiles(root, ["-co", "--exclude-standard"]);
|
|
484
509
|
const ignored = ignoredRoots.map(entry => path.resolve(entry));
|
|
485
|
-
const listing = [...new Set(git
|
|
510
|
+
const listing = [...new Set(git ?? listFiles(root))].sort();
|
|
511
|
+
const ownedHook = listing.includes(".agents/hooks.json") ? ownedWorkspaceHookPath(root, git ? undefined : []) : null;
|
|
486
512
|
const files = new Map(), hash = crypto.createHash("sha256");
|
|
487
513
|
for (const relative of listing) {
|
|
514
|
+
if (relative === ownedHook)
|
|
515
|
+
continue;
|
|
488
516
|
const file = path.resolve(root, relative);
|
|
489
517
|
if (ignored.some(ignoredRoot => file === ignoredRoot || file.startsWith(`${ignoredRoot}${path.sep}`)))
|
|
490
518
|
continue;
|
|
@@ -677,8 +705,17 @@ function collectCheckArtifacts(root, required) {
|
|
|
677
705
|
const missing = required.filter((relative) => !retained.has(path.normalize(relative)));
|
|
678
706
|
return { complete: missing.length === 0, missing, items };
|
|
679
707
|
}
|
|
680
|
-
function listFiles(root
|
|
681
|
-
|
|
708
|
+
function listFiles(root) {
|
|
709
|
+
const allowed = projectServicePathFilter(root, [], []);
|
|
710
|
+
const ownedHook = ownedWorkspaceHookPath(root, []);
|
|
711
|
+
const visit = (current) => fs.readdirSync(current, { withFileTypes: true }).flatMap(entry => {
|
|
712
|
+
const absolute = path.join(current, entry.name);
|
|
713
|
+
if ([".git", "node_modules"].includes(entry.name) || !allowed(absolute))
|
|
714
|
+
return [];
|
|
715
|
+
return entry.isDirectory() ? visit(absolute) : [path.relative(root, absolute)];
|
|
716
|
+
});
|
|
717
|
+
return visit(root).filter(file => file.split(path.sep).join("/") !== ownedHook).sort();
|
|
718
|
+
}
|
|
682
719
|
export function runCheck(command, cwd, environment, stdout, stderr, heartbeat, onSpawn, renewLease) {
|
|
683
720
|
return new Promise((resolve) => {
|
|
684
721
|
const started = Date.now();
|