intentdna 1.9.0 → 1.9.1
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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +227 -87
- package/dist/cli/commands/run-lifecycle.d.ts +61 -11
- package/dist/cli/commands/run-lifecycle.js +184 -50
- package/dist/cli/commands/run-observer.d.ts +15 -0
- package/dist/cli/commands/run-observer.js +210 -0
- package/dist/cli/commands/run.d.ts +16 -20
- package/dist/cli/commands/run.js +461 -659
- package/dist/cli/commands/sync.js +16 -12
- package/dist/cli/index.js +14 -5
- package/dist/compiler/compile.d.ts +1 -0
- package/dist/compiler/compile.js +1 -1
- package/dist/compiler/controller.d.ts +16 -0
- package/dist/compiler/controller.js +693 -0
- package/dist/compiler/input-resolver.d.ts +2 -0
- package/dist/compiler/input-resolver.js +22 -6
- package/dist/hooks/cli.d.ts +45 -0
- package/dist/hooks/cli.js +263 -31
- package/dist/hooks/host-event.d.ts +57 -0
- package/dist/hooks/host-event.js +187 -0
- package/dist/hooks/index.d.ts +1 -0
- package/dist/hooks/index.js +1 -0
- package/dist/hooks/protocol.d.ts +7 -0
- package/dist/hooks/schema.d.ts +1 -0
- package/dist/hooks/schema.js +89 -1
- package/dist/hooks/state.d.ts +23 -1
- package/dist/hooks/state.js +9 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/mcp/index.js +2 -0
- package/dist/mcp/tools-run.d.ts +4 -0
- package/dist/mcp/tools-run.js +419 -0
- package/dist/mcp/tools-state.d.ts +1 -1
- package/dist/mcp/tools-state.js +13 -241
- package/dist/runtime/artifact-store.d.ts +146 -0
- package/dist/runtime/artifact-store.js +1436 -0
- package/dist/runtime/canonical-attempt-outcome.d.ts +12 -0
- package/dist/runtime/canonical-attempt-outcome.js +195 -0
- package/dist/runtime/canonical-json.d.ts +10 -0
- package/dist/runtime/canonical-json.js +105 -0
- package/dist/runtime/canonical-run-application.d.ts +93 -0
- package/dist/runtime/canonical-run-application.js +229 -0
- package/dist/runtime/canonical-run-service.d.ts +454 -0
- package/dist/runtime/canonical-run-service.js +4760 -0
- package/dist/runtime/canonical-runtime-composition.d.ts +22 -0
- package/dist/runtime/canonical-runtime-composition.js +34 -0
- package/dist/runtime/canonical-runtime-projection.d.ts +3 -0
- package/dist/runtime/canonical-runtime-projection.js +4 -0
- package/dist/runtime/canonical-target-compiler.d.ts +46 -0
- package/dist/runtime/canonical-target-compiler.js +514 -0
- package/dist/runtime/claude-agent-identity.d.ts +2 -0
- package/dist/runtime/claude-agent-identity.js +21 -0
- package/dist/runtime/executable-run-plan.d.ts +328 -0
- package/dist/runtime/executable-run-plan.js +1485 -0
- package/dist/runtime/execution-authority.d.ts +83 -0
- package/dist/runtime/execution-authority.js +98 -0
- package/dist/runtime/handoff-resolver.js +4 -1
- package/dist/runtime/harness-pull-adapter.d.ts +146 -0
- package/dist/runtime/harness-pull-adapter.js +315 -0
- package/dist/runtime/index.d.ts +39 -14
- package/dist/runtime/index.js +29 -7
- package/dist/runtime/local-execution-authority.d.ts +194 -0
- package/dist/runtime/local-execution-authority.js +2877 -0
- package/dist/runtime/local-execution-reconciliation.d.ts +20 -0
- package/dist/runtime/local-execution-reconciliation.js +107 -0
- package/dist/runtime/local-execution-supervisor-script.d.ts +7 -0
- package/dist/runtime/local-execution-supervisor-script.js +793 -0
- package/dist/runtime/local-provider-sandbox.d.ts +42 -0
- package/dist/runtime/local-provider-sandbox.js +154 -0
- package/dist/runtime/local-verifier-execution.d.ts +24 -0
- package/dist/runtime/local-verifier-execution.js +290 -0
- package/dist/runtime/local-windows-execution-supervisor-script.d.ts +6 -0
- package/dist/runtime/local-windows-execution-supervisor-script.js +771 -0
- package/dist/runtime/plan-store.d.ts +30 -0
- package/dist/runtime/plan-store.js +231 -0
- package/dist/runtime/process-tree.d.ts +9 -0
- package/dist/runtime/process-tree.js +97 -24
- package/dist/runtime/push-driver.d.ts +184 -0
- package/dist/runtime/push-driver.js +1002 -0
- package/dist/runtime/result-store.d.ts +34 -3
- package/dist/runtime/result-store.js +1073 -2
- package/dist/runtime/run-binding.d.ts +68 -0
- package/dist/runtime/run-binding.js +278 -0
- package/dist/runtime/run-contracts.d.ts +575 -0
- package/dist/runtime/run-contracts.js +58 -7
- package/dist/runtime/run-controller.d.ts +1 -6
- package/dist/runtime/run-controller.js +0 -41
- package/dist/runtime/run-store.d.ts +85 -7
- package/dist/runtime/run-store.js +2528 -186
- package/dist/runtime/skill-adapter.d.ts +18 -7
- package/dist/runtime/skill-adapter.js +100 -742
- package/dist/runtime/structured-output-validator.d.ts +44 -0
- package/dist/runtime/structured-output-validator.js +171 -0
- package/dist/runtime/verifier-command-binding.d.ts +22 -0
- package/dist/runtime/verifier-command-binding.js +162 -0
- package/dist/runtime/verifier.d.ts +46 -6
- package/dist/runtime/verifier.js +355 -53
- package/dist/runtime/windows-job-keeper.d.ts +47 -0
- package/dist/runtime/windows-job-keeper.js +231 -0
- package/dist/runtime/worker-executor.d.ts +17 -1
- package/dist/runtime/worker-executor.js +26 -5
- package/dist/runtime/workflow-plan-adapter.d.ts +19 -0
- package/dist/runtime/workflow-plan-adapter.js +502 -6
- package/dist/runtime/workflow-runtime-manifest.d.ts +1 -0
- package/dist/runtime/workflow-runtime-manifest.js +5 -0
- package/dist/runtime/workspace-isolation.d.ts +29 -2
- package/dist/runtime/workspace-isolation.js +488 -11
- package/dist/runtime/workspace-observation.d.ts +44 -0
- package/dist/runtime/workspace-observation.js +216 -0
- package/dist/schema/controller-registry.d.ts +0 -7
- package/dist/schema/controller-registry.js +0 -8
- package/dist/schema/types.d.ts +84 -10
- package/dist/schema/types.js +2 -0
- package/dist/schema/validate.js +151 -1
- package/dist/schema/validators/controllers.d.ts +1 -1
- package/dist/schema/validators/controllers.js +126 -41
- package/dist/schema/workflow-authoring-contract.js +2 -0
- package/dist/schema/yaml-parser.js +1 -1
- package/dist/templates/flutter-rewrite-new.dna.yaml +1 -0
- package/dist/templates/flutter-rewrite.dna.yaml +87 -60
- package/dist/templates/safe-refactoring.dna.yaml +1 -0
- package/native/windows-job-keeper/README.md +80 -0
- package/native/windows-job-keeper/bin/aarch64/intentdna-windows-job-keeper.exe +0 -0
- package/native/windows-job-keeper/bin/x86_64/intentdna-windows-job-keeper.exe +0 -0
- package/native/windows-job-keeper/keeper.c +979 -0
- package/native/windows-job-keeper/manifest.json +33 -0
- package/package.json +5 -1
- package/scripts/build-windows-job-keeper.mjs +172 -0
- package/scripts/verify-windows-job-keeper.mjs +184 -0
|
@@ -6,9 +6,12 @@
|
|
|
6
6
|
* Team naming, CLI parsing, generated shell, and automatic rollback are excluded.
|
|
7
7
|
*/
|
|
8
8
|
import { spawnSync } from "node:child_process";
|
|
9
|
-
import { createHash } from "node:crypto";
|
|
9
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
10
10
|
import { existsSync, lstatSync, mkdirSync, realpathSync, } from "node:fs";
|
|
11
|
+
import { link, lstat, mkdir, readFile, rename, rm, stat, unlink, utimes, writeFile, } from "node:fs/promises";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
11
13
|
import { basename, dirname, isAbsolute, join, relative, resolve, } from "node:path";
|
|
14
|
+
import { canonicalizeJson } from "./canonical-json.js";
|
|
12
15
|
export class WorkspaceIsolationError extends Error {
|
|
13
16
|
code;
|
|
14
17
|
constructor(code, message) {
|
|
@@ -35,6 +38,17 @@ function git(cwd, args, allowFailure = false) {
|
|
|
35
38
|
}
|
|
36
39
|
return output;
|
|
37
40
|
}
|
|
41
|
+
function sharedWorkspaceGitState(root) {
|
|
42
|
+
const topLevel = git(root, ["rev-parse", "--show-toplevel"], true);
|
|
43
|
+
const head = git(root, ["rev-parse", "--verify", "HEAD"], true);
|
|
44
|
+
if (topLevel.status !== 0 || head.status !== 0)
|
|
45
|
+
return null;
|
|
46
|
+
const branch = git(root, ["symbolic-ref", "--quiet", "HEAD"], true);
|
|
47
|
+
return {
|
|
48
|
+
head: head.stdout.trim(),
|
|
49
|
+
branch_ref: branch.status === 0 ? branch.stdout.trim() : null,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
38
52
|
function requireText(value, label) {
|
|
39
53
|
const trimmed = value.trim();
|
|
40
54
|
if (!trimmed || trimmed.includes("\0")) {
|
|
@@ -53,6 +67,63 @@ function canonicalExistingDirectory(path, label) {
|
|
|
53
67
|
}
|
|
54
68
|
return realpathSync(requested);
|
|
55
69
|
}
|
|
70
|
+
function isContainedPath(root, candidate) {
|
|
71
|
+
const child = relative(root, candidate);
|
|
72
|
+
return child === "" || (!child.startsWith("..") && !isAbsolute(child));
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Resolve an existing directory below a committed workspace without following
|
|
76
|
+
* symbolic-link components. This is suitable for verifier/process cwd values.
|
|
77
|
+
*/
|
|
78
|
+
export async function canonicalWorkspaceDirectory(workspaceRoot, relativeDirectory, label = "working_directory") {
|
|
79
|
+
return resolveCanonicalWorkspaceDirectory(workspaceRoot, relativeDirectory, label, false);
|
|
80
|
+
}
|
|
81
|
+
async function resolveCanonicalWorkspaceDirectory(workspaceRoot, relativeDirectory, label, createMissing) {
|
|
82
|
+
const root = canonicalExistingDirectory(workspaceRoot, "workspace_root");
|
|
83
|
+
const requested = requireText(relativeDirectory, label);
|
|
84
|
+
if (isAbsolute(requested)) {
|
|
85
|
+
throw new WorkspaceIsolationError("invalid_request", `${label} must be relative`);
|
|
86
|
+
}
|
|
87
|
+
const candidate = resolve(root, requested);
|
|
88
|
+
if (!isContainedPath(root, candidate)) {
|
|
89
|
+
throw new WorkspaceIsolationError("workspace_mismatch", `${label} escapes the committed workspace`);
|
|
90
|
+
}
|
|
91
|
+
const child = relative(root, candidate);
|
|
92
|
+
let current = root;
|
|
93
|
+
for (const segment of child.split(/[\\/]+/).filter(Boolean)) {
|
|
94
|
+
current = join(current, segment);
|
|
95
|
+
let status;
|
|
96
|
+
try {
|
|
97
|
+
status = await lstat(current);
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
if (error.code === "ENOENT" && createMissing) {
|
|
101
|
+
try {
|
|
102
|
+
await mkdir(current);
|
|
103
|
+
}
|
|
104
|
+
catch (mkdirError) {
|
|
105
|
+
if (mkdirError.code !== "EEXIST")
|
|
106
|
+
throw mkdirError;
|
|
107
|
+
}
|
|
108
|
+
status = await lstat(current);
|
|
109
|
+
}
|
|
110
|
+
else if (error.code === "ENOENT") {
|
|
111
|
+
throw new WorkspaceIsolationError("invalid_request", `${label} does not exist: ${candidate}`);
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
throw error;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (status.isSymbolicLink() || !status.isDirectory()) {
|
|
118
|
+
throw new WorkspaceIsolationError("workspace_mismatch", `${label} must contain only real directories: ${current}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
const canonical = realpathSync(candidate);
|
|
122
|
+
if (!isContainedPath(root, canonical)) {
|
|
123
|
+
throw new WorkspaceIsolationError("workspace_mismatch", `${label} resolves outside the committed workspace`);
|
|
124
|
+
}
|
|
125
|
+
return canonical;
|
|
126
|
+
}
|
|
56
127
|
function repositoryRoot(cwd) {
|
|
57
128
|
const result = git(cwd, ["rev-parse", "--show-toplevel"], true);
|
|
58
129
|
if (result.status !== 0 || !result.stdout.trim()) {
|
|
@@ -187,8 +258,9 @@ function expectedBranchRef(plan) {
|
|
|
187
258
|
}
|
|
188
259
|
function assertMatchingWorktree(plan, entry) {
|
|
189
260
|
if (entry.branch_ref !== expectedBranchRef(plan)
|
|
261
|
+
|| entry.head !== plan.base_commit
|
|
190
262
|
|| !sameGitRepository(plan.repository_root, plan.working_directory)) {
|
|
191
|
-
throw new WorkspaceIsolationError("
|
|
263
|
+
throw new WorkspaceIsolationError("workspace_conflict", `existing worktree is not owned by attempt ${plan.attempt_id}: ${plan.working_directory}`);
|
|
192
264
|
}
|
|
193
265
|
}
|
|
194
266
|
export function planAttemptWorkspace(request) {
|
|
@@ -244,6 +316,9 @@ export function allocateAttemptWorkspace(plan) {
|
|
|
244
316
|
if (existing) {
|
|
245
317
|
assertMatchingWorktree(plan, existing);
|
|
246
318
|
const state = workspaceState(plan.working_directory);
|
|
319
|
+
if (state.dirty || state.conflicted) {
|
|
320
|
+
throw new WorkspaceIsolationError("workspace_conflict", `existing pre-start worktree is ${state.conflicted ? "conflicted" : "dirty"}: ${plan.working_directory}`);
|
|
321
|
+
}
|
|
247
322
|
return {
|
|
248
323
|
...plan,
|
|
249
324
|
created: false,
|
|
@@ -261,16 +336,22 @@ export function allocateAttemptWorkspace(plan) {
|
|
|
261
336
|
}
|
|
262
337
|
const branchExists = git(plan.repository_root, ["show-ref", "--verify", "--quiet", expectedBranchRef(plan)], true).status === 0;
|
|
263
338
|
if (branchExists) {
|
|
264
|
-
|
|
339
|
+
const tip = resolveCommit(plan.repository_root, plan.branch_name);
|
|
340
|
+
if (tip !== plan.base_commit) {
|
|
341
|
+
throw new WorkspaceIsolationError("branch_conflict", `attempt branch exists at unexpected commit: ${plan.branch_name}`);
|
|
342
|
+
}
|
|
343
|
+
git(plan.repository_root, ["worktree", "add", plan.working_directory, plan.branch_name]);
|
|
344
|
+
}
|
|
345
|
+
else {
|
|
346
|
+
git(plan.repository_root, [
|
|
347
|
+
"worktree",
|
|
348
|
+
"add",
|
|
349
|
+
"-b",
|
|
350
|
+
plan.branch_name,
|
|
351
|
+
plan.working_directory,
|
|
352
|
+
plan.base_commit,
|
|
353
|
+
]);
|
|
265
354
|
}
|
|
266
|
-
git(plan.repository_root, [
|
|
267
|
-
"worktree",
|
|
268
|
-
"add",
|
|
269
|
-
"-b",
|
|
270
|
-
plan.branch_name,
|
|
271
|
-
plan.working_directory,
|
|
272
|
-
plan.base_commit,
|
|
273
|
-
]);
|
|
274
355
|
return {
|
|
275
356
|
...plan,
|
|
276
357
|
created: true,
|
|
@@ -279,6 +360,402 @@ export function allocateAttemptWorkspace(plan) {
|
|
|
279
360
|
conflicted_at_allocation: false,
|
|
280
361
|
};
|
|
281
362
|
}
|
|
363
|
+
function canonicalWorkspacePlan(payload) {
|
|
364
|
+
return {
|
|
365
|
+
workspace_plan_id: `sha256:${createHash("sha256").update(canonicalizeJson(payload)).digest("hex")}`,
|
|
366
|
+
payload,
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
function authorityKey(kind, path) {
|
|
370
|
+
return `${kind}:${createHash("sha256").update(realpathSync(path)).digest("hex")}`;
|
|
371
|
+
}
|
|
372
|
+
function committedSharedWorkspaceRoot(allocation) {
|
|
373
|
+
let candidate = resolve(allocation.working_directory);
|
|
374
|
+
for (;;) {
|
|
375
|
+
const lexicalKey = `none:${createHash("sha256").update(candidate).digest("hex")}`;
|
|
376
|
+
if (existsSync(candidate)
|
|
377
|
+
&& (lexicalKey === allocation.authority_key || authorityKey("none", candidate) === allocation.authority_key)) {
|
|
378
|
+
return realpathSync(candidate);
|
|
379
|
+
}
|
|
380
|
+
const parent = dirname(candidate);
|
|
381
|
+
if (parent === candidate)
|
|
382
|
+
break;
|
|
383
|
+
candidate = parent;
|
|
384
|
+
}
|
|
385
|
+
throw new WorkspaceIsolationError("workspace_mismatch", "shared working_directory is no longer contained by its committed workspace root");
|
|
386
|
+
}
|
|
387
|
+
async function allocationFromPlan(plan) {
|
|
388
|
+
if (plan.payload.kind === "none") {
|
|
389
|
+
const root = committedSharedWorkspaceRoot(plan.payload);
|
|
390
|
+
const workingDirectory = await resolveCanonicalWorkspaceDirectory(root, relative(root, realpathSync(resolve(plan.payload.working_directory))) || ".", "working_directory", false);
|
|
391
|
+
const gitState = sharedWorkspaceGitState(root);
|
|
392
|
+
return {
|
|
393
|
+
...plan.payload,
|
|
394
|
+
working_directory: workingDirectory,
|
|
395
|
+
workspace_plan_id: plan.workspace_plan_id,
|
|
396
|
+
observed_head: gitState?.head ?? null,
|
|
397
|
+
observed_branch_ref: gitState?.branch_ref ?? null,
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
const legacy = {
|
|
401
|
+
isolation: "worktree",
|
|
402
|
+
run_id: "workspace-authority",
|
|
403
|
+
step_id: "workspace-authority",
|
|
404
|
+
attempt_id: plan.payload.workspace_id,
|
|
405
|
+
repository_root: plan.payload.repository_root,
|
|
406
|
+
worktree_root: dirname(plan.payload.worktree_root),
|
|
407
|
+
working_directory: plan.payload.worktree_root,
|
|
408
|
+
branch_name: plan.payload.branch_name,
|
|
409
|
+
base_ref: plan.payload.base_ref,
|
|
410
|
+
base_commit: plan.payload.base_commit,
|
|
411
|
+
ownership_key: plan.payload.ownership_key,
|
|
412
|
+
};
|
|
413
|
+
allocateAttemptWorkspace(legacy);
|
|
414
|
+
const relativeWorkingDirectory = relative(plan.payload.worktree_root, plan.payload.working_directory);
|
|
415
|
+
if (!isContainedPath(resolve(plan.payload.worktree_root), resolve(plan.payload.working_directory))) {
|
|
416
|
+
throw new WorkspaceIsolationError("workspace_mismatch", "working_directory escapes the committed worktree");
|
|
417
|
+
}
|
|
418
|
+
const workingDirectory = await resolveCanonicalWorkspaceDirectory(plan.payload.worktree_root, relativeWorkingDirectory || ".", "working_directory", true);
|
|
419
|
+
const inspection = inspectAttemptWorkspace({
|
|
420
|
+
...legacy,
|
|
421
|
+
created: false,
|
|
422
|
+
reused: true,
|
|
423
|
+
dirty_at_allocation: false,
|
|
424
|
+
conflicted_at_allocation: false,
|
|
425
|
+
});
|
|
426
|
+
if (!inspection.owned_by_attempt
|
|
427
|
+
|| inspection.head !== plan.payload.base_commit
|
|
428
|
+
|| inspection.branch_ref !== `refs/heads/${plan.payload.branch_name}`
|
|
429
|
+
|| inspection.dirty
|
|
430
|
+
|| inspection.conflicted) {
|
|
431
|
+
throw new WorkspaceIsolationError("workspace_conflict", "ensured worktree does not match the committed plan");
|
|
432
|
+
}
|
|
433
|
+
return {
|
|
434
|
+
...plan.payload,
|
|
435
|
+
working_directory: workingDirectory,
|
|
436
|
+
workspace_plan_id: plan.workspace_plan_id,
|
|
437
|
+
observed_head: inspection.head,
|
|
438
|
+
observed_branch_ref: inspection.branch_ref,
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
function positiveInteger(value, label) {
|
|
442
|
+
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
443
|
+
throw new WorkspaceIsolationError("invalid_request", `${label} must be a positive integer`);
|
|
444
|
+
}
|
|
445
|
+
return value;
|
|
446
|
+
}
|
|
447
|
+
function processIsAlive(pid) {
|
|
448
|
+
try {
|
|
449
|
+
process.kill(pid, 0);
|
|
450
|
+
return true;
|
|
451
|
+
}
|
|
452
|
+
catch (error) {
|
|
453
|
+
return error.code === "EPERM";
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
function parseLockOwner(text) {
|
|
457
|
+
try {
|
|
458
|
+
const value = JSON.parse(text);
|
|
459
|
+
return typeof value.owner === "string"
|
|
460
|
+
&& typeof value.generation === "string"
|
|
461
|
+
&& Number.isSafeInteger(value.pid)
|
|
462
|
+
&& (value.pid ?? 0) > 0
|
|
463
|
+
&& typeof value.acquired_at === "number"
|
|
464
|
+
? value
|
|
465
|
+
: null;
|
|
466
|
+
}
|
|
467
|
+
catch {
|
|
468
|
+
return null;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
export class LocalAttemptWorkspaceAuthority {
|
|
472
|
+
lockRoot;
|
|
473
|
+
lockWaitMs;
|
|
474
|
+
lockStaleMs;
|
|
475
|
+
lockRetryMs;
|
|
476
|
+
lockHeartbeatMs;
|
|
477
|
+
constructor(options = {}) {
|
|
478
|
+
this.lockRoot = resolve(options.lock_root ?? join(tmpdir(), "intentdna-workspace-authority-locks"));
|
|
479
|
+
this.lockWaitMs = positiveInteger(options.lock_wait_ms ?? 10_000, "lock_wait_ms");
|
|
480
|
+
this.lockStaleMs = positiveInteger(options.lock_stale_ms ?? 60_000, "lock_stale_ms");
|
|
481
|
+
this.lockRetryMs = positiveInteger(options.lock_retry_ms ?? 10, "lock_retry_ms");
|
|
482
|
+
this.lockHeartbeatMs = positiveInteger(options.lock_heartbeat_ms ?? 5_000, "lock_heartbeat_ms");
|
|
483
|
+
if (this.lockHeartbeatMs >= this.lockStaleMs) {
|
|
484
|
+
throw new WorkspaceIsolationError("invalid_request", "lock_heartbeat_ms must be less than lock_stale_ms");
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
async plan(request) {
|
|
488
|
+
const node = request.plan.plan.nodes.find((candidate) => candidate.node_id === request.activation.step_id);
|
|
489
|
+
if (!node || node.kind !== "worker") {
|
|
490
|
+
throw new WorkspaceIsolationError("invalid_request", "workspace planning requires a worker node");
|
|
491
|
+
}
|
|
492
|
+
const repositoryDirectory = request.run.run_binding.binding.workspace_root;
|
|
493
|
+
const legacy = planAttemptWorkspace({
|
|
494
|
+
isolation: node.workspace.isolation,
|
|
495
|
+
repository_directory: repositoryDirectory,
|
|
496
|
+
run_id: request.run.run_id,
|
|
497
|
+
step_id: request.activation.step_id,
|
|
498
|
+
attempt_id: request.attempt.attempt_id,
|
|
499
|
+
base_ref: "HEAD",
|
|
500
|
+
});
|
|
501
|
+
if (legacy.isolation === "none") {
|
|
502
|
+
const root = realpathSync(legacy.working_directory);
|
|
503
|
+
const workingDirectory = await resolveCanonicalWorkspaceDirectory(root, node.workspace.relative_working_directory, "relative_working_directory", true);
|
|
504
|
+
return canonicalWorkspacePlan({
|
|
505
|
+
kind: "none",
|
|
506
|
+
workspace_id: request.attempt.attempt_id,
|
|
507
|
+
working_directory: workingDirectory,
|
|
508
|
+
authority_key: authorityKey("none", root),
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
const workingDirectory = resolve(legacy.working_directory, node.workspace.relative_working_directory);
|
|
512
|
+
return canonicalWorkspacePlan({
|
|
513
|
+
kind: "worktree",
|
|
514
|
+
workspace_id: request.attempt.attempt_id,
|
|
515
|
+
working_directory: workingDirectory,
|
|
516
|
+
repository_root: legacy.repository_root,
|
|
517
|
+
worktree_root: legacy.working_directory,
|
|
518
|
+
branch_name: legacy.branch_name,
|
|
519
|
+
base_ref: legacy.base_ref,
|
|
520
|
+
base_commit: legacy.base_commit,
|
|
521
|
+
ownership_key: legacy.ownership_key,
|
|
522
|
+
authority_key: `worktree:${legacy.ownership_key}`,
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
async ensureWithExclusiveAuthority(plan, operation) {
|
|
526
|
+
return this.withLock(plan.payload.authority_key, async () => operation(await allocationFromPlan(plan)));
|
|
527
|
+
}
|
|
528
|
+
async withExclusiveMutationAuthority(request, operation) {
|
|
529
|
+
return this.withLock(request.allocation.authority_key, async () => {
|
|
530
|
+
await this.assertCommittedAllocation(request);
|
|
531
|
+
const fencingEpoch = await this.nextFencingEpoch(request.allocation.authority_key);
|
|
532
|
+
return operation({
|
|
533
|
+
workspace_id: request.allocation.workspace_id,
|
|
534
|
+
workspace_allocation_digest: `sha256:${createHash("sha256")
|
|
535
|
+
.update(canonicalizeJson(request.allocation), "utf8")
|
|
536
|
+
.digest("hex")}`,
|
|
537
|
+
authority_instance_id: `workspace-authority:${randomUUID()}`,
|
|
538
|
+
fencing_epoch: fencingEpoch,
|
|
539
|
+
});
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
async nextFencingEpoch(key) {
|
|
543
|
+
const digest = createHash("sha256").update(key).digest("hex");
|
|
544
|
+
const epochPath = join(this.lockRoot, `${digest}.epoch`);
|
|
545
|
+
let current = 0;
|
|
546
|
+
try {
|
|
547
|
+
const text = await readFile(epochPath, "utf8");
|
|
548
|
+
current = Number(text);
|
|
549
|
+
if (!Number.isSafeInteger(current) || current < 0) {
|
|
550
|
+
throw new WorkspaceIsolationError("workspace_conflict", `invalid workspace fencing epoch for ${key}`);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
catch (error) {
|
|
554
|
+
if (error.code !== "ENOENT")
|
|
555
|
+
throw error;
|
|
556
|
+
}
|
|
557
|
+
const next = current + 1;
|
|
558
|
+
if (!Number.isSafeInteger(next)) {
|
|
559
|
+
throw new WorkspaceIsolationError("workspace_conflict", `workspace fencing epoch exhausted for ${key}`);
|
|
560
|
+
}
|
|
561
|
+
await writeFile(epochPath, String(next), "utf8");
|
|
562
|
+
return next;
|
|
563
|
+
}
|
|
564
|
+
async assertCommittedAllocation(request) {
|
|
565
|
+
const allocation = request.allocation;
|
|
566
|
+
if (allocation.kind === "none") {
|
|
567
|
+
const root = committedSharedWorkspaceRoot(allocation);
|
|
568
|
+
await resolveCanonicalWorkspaceDirectory(root, relative(root, realpathSync(resolve(allocation.working_directory))) || ".", "working_directory", false);
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
const inspection = inspectAttemptWorkspace({
|
|
572
|
+
isolation: "worktree",
|
|
573
|
+
run_id: "workspace-authority",
|
|
574
|
+
step_id: "workspace-authority",
|
|
575
|
+
attempt_id: allocation.workspace_id,
|
|
576
|
+
repository_root: allocation.repository_root,
|
|
577
|
+
worktree_root: dirname(allocation.worktree_root),
|
|
578
|
+
working_directory: allocation.worktree_root,
|
|
579
|
+
branch_name: allocation.branch_name,
|
|
580
|
+
base_ref: allocation.base_ref,
|
|
581
|
+
base_commit: allocation.base_commit,
|
|
582
|
+
ownership_key: allocation.ownership_key,
|
|
583
|
+
created: false,
|
|
584
|
+
reused: true,
|
|
585
|
+
dirty_at_allocation: false,
|
|
586
|
+
conflicted_at_allocation: false,
|
|
587
|
+
});
|
|
588
|
+
if (!inspection.owned_by_attempt || inspection.branch_ref !== allocation.observed_branch_ref) {
|
|
589
|
+
throw new WorkspaceIsolationError("workspace_mismatch", "committed worktree identity changed");
|
|
590
|
+
}
|
|
591
|
+
if (request.require_clean_worktree && (inspection.dirty || inspection.conflicted)) {
|
|
592
|
+
throw new WorkspaceIsolationError("workspace_conflict", "committed worktree is not clean");
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
async withLock(key, operation) {
|
|
596
|
+
await mkdir(this.lockRoot, { recursive: true });
|
|
597
|
+
const digest = createHash("sha256").update(key).digest("hex");
|
|
598
|
+
const lockDirectory = join(this.lockRoot, `${digest}.lock`);
|
|
599
|
+
const owner = randomUUID();
|
|
600
|
+
const generation = randomUUID();
|
|
601
|
+
const ownerState = {
|
|
602
|
+
owner,
|
|
603
|
+
generation,
|
|
604
|
+
pid: process.pid,
|
|
605
|
+
acquired_at: Date.now(),
|
|
606
|
+
};
|
|
607
|
+
const ownerPath = join(lockDirectory, "owner.json");
|
|
608
|
+
const leasePath = join(lockDirectory, `lease-${generation}`);
|
|
609
|
+
const transitionPath = join(this.lockRoot, `${digest}.transition-${generation}`);
|
|
610
|
+
const deadline = Date.now() + this.lockWaitMs;
|
|
611
|
+
for (;;) {
|
|
612
|
+
try {
|
|
613
|
+
await mkdir(lockDirectory);
|
|
614
|
+
await writeFile(ownerPath, JSON.stringify(ownerState), { encoding: "utf8", flag: "wx" });
|
|
615
|
+
await writeFile(leasePath, "", { encoding: "utf8", flag: "wx" });
|
|
616
|
+
break;
|
|
617
|
+
}
|
|
618
|
+
catch (error) {
|
|
619
|
+
const code = error.code;
|
|
620
|
+
if (code !== "EEXIST") {
|
|
621
|
+
await rm(lockDirectory, { recursive: true, force: true });
|
|
622
|
+
throw error;
|
|
623
|
+
}
|
|
624
|
+
try {
|
|
625
|
+
const state = parseLockOwner(await readFile(ownerPath, "utf8"));
|
|
626
|
+
const currentLease = state
|
|
627
|
+
? await stat(join(lockDirectory, `lease-${state.generation}`)).catch(() => null)
|
|
628
|
+
: null;
|
|
629
|
+
const heartbeatAt = currentLease?.mtimeMs ?? state?.acquired_at;
|
|
630
|
+
if (state
|
|
631
|
+
&& heartbeatAt !== undefined
|
|
632
|
+
&& Date.now() - heartbeatAt > this.lockStaleMs
|
|
633
|
+
&& !processIsAlive(state.pid)) {
|
|
634
|
+
const observedTransition = join(this.lockRoot, `${digest}.transition-${state.generation}`);
|
|
635
|
+
try {
|
|
636
|
+
await link(ownerPath, observedTransition);
|
|
637
|
+
}
|
|
638
|
+
catch (transitionError) {
|
|
639
|
+
if (transitionError.code === "EEXIST") {
|
|
640
|
+
await new Promise((resolvePromise) => setTimeout(resolvePromise, this.lockRetryMs));
|
|
641
|
+
continue;
|
|
642
|
+
}
|
|
643
|
+
throw transitionError;
|
|
644
|
+
}
|
|
645
|
+
const quarantine = `${lockDirectory}.stale-${randomUUID()}`;
|
|
646
|
+
try {
|
|
647
|
+
const claimed = parseLockOwner(await readFile(observedTransition, "utf8"));
|
|
648
|
+
if (claimed?.owner !== state.owner || claimed.generation !== state.generation) {
|
|
649
|
+
throw new WorkspaceIsolationError("workspace_conflict", `workspace authority changed during stale-lock takeover ${key}`);
|
|
650
|
+
}
|
|
651
|
+
const claimedLease = await stat(join(lockDirectory, `lease-${state.generation}`)).catch(() => null);
|
|
652
|
+
if (claimedLease
|
|
653
|
+
&& Date.now() - claimedLease.mtimeMs <= this.lockStaleMs) {
|
|
654
|
+
continue;
|
|
655
|
+
}
|
|
656
|
+
await rename(lockDirectory, quarantine);
|
|
657
|
+
const moved = parseLockOwner(await readFile(join(quarantine, "owner.json"), "utf8").catch(() => ""));
|
|
658
|
+
if (moved?.owner !== state.owner || moved.generation !== state.generation) {
|
|
659
|
+
throw new WorkspaceIsolationError("workspace_conflict", `workspace authority changed during stale-lock takeover ${key}`);
|
|
660
|
+
}
|
|
661
|
+
await rm(quarantine, { recursive: true, force: true });
|
|
662
|
+
}
|
|
663
|
+
finally {
|
|
664
|
+
await unlink(observedTransition).catch(() => undefined);
|
|
665
|
+
}
|
|
666
|
+
continue;
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
catch (readError) {
|
|
670
|
+
if (readError.code === "ENOENT") {
|
|
671
|
+
// Another holder is publishing or replacing the lock. Retry; never
|
|
672
|
+
// delete a directory whose owner generation was not observed.
|
|
673
|
+
}
|
|
674
|
+
else if (readError instanceof WorkspaceIsolationError) {
|
|
675
|
+
throw readError;
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
if (Date.now() >= deadline) {
|
|
679
|
+
throw new WorkspaceIsolationError("workspace_conflict", `timed out acquiring workspace authority ${key}`);
|
|
680
|
+
}
|
|
681
|
+
await new Promise((resolvePromise) => setTimeout(resolvePromise, this.lockRetryMs));
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
const heartbeat = setInterval(() => {
|
|
685
|
+
void (async () => {
|
|
686
|
+
try {
|
|
687
|
+
await link(ownerPath, transitionPath);
|
|
688
|
+
}
|
|
689
|
+
catch (error) {
|
|
690
|
+
if (["EEXIST", "ENOENT"].includes(error.code ?? ""))
|
|
691
|
+
return;
|
|
692
|
+
throw error;
|
|
693
|
+
}
|
|
694
|
+
try {
|
|
695
|
+
const claimed = parseLockOwner(await readFile(transitionPath, "utf8"));
|
|
696
|
+
const current = parseLockOwner(await readFile(ownerPath, "utf8"));
|
|
697
|
+
if (claimed?.owner === owner
|
|
698
|
+
&& claimed.generation === generation
|
|
699
|
+
&& current?.owner === owner
|
|
700
|
+
&& current.generation === generation) {
|
|
701
|
+
const now = new Date();
|
|
702
|
+
await utimes(leasePath, now, now);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
finally {
|
|
706
|
+
await unlink(transitionPath).catch(() => undefined);
|
|
707
|
+
}
|
|
708
|
+
})().catch(() => undefined);
|
|
709
|
+
}, this.lockHeartbeatMs);
|
|
710
|
+
heartbeat.unref();
|
|
711
|
+
try {
|
|
712
|
+
return await operation();
|
|
713
|
+
}
|
|
714
|
+
finally {
|
|
715
|
+
clearInterval(heartbeat);
|
|
716
|
+
try {
|
|
717
|
+
for (;;) {
|
|
718
|
+
const state = parseLockOwner(await readFile(ownerPath, "utf8"));
|
|
719
|
+
if (state?.owner !== owner || state.generation !== generation)
|
|
720
|
+
break;
|
|
721
|
+
try {
|
|
722
|
+
await link(ownerPath, transitionPath);
|
|
723
|
+
}
|
|
724
|
+
catch (error) {
|
|
725
|
+
if (error.code === "EEXIST") {
|
|
726
|
+
await new Promise((resolvePromise) => setTimeout(resolvePromise, this.lockRetryMs));
|
|
727
|
+
continue;
|
|
728
|
+
}
|
|
729
|
+
if (error.code === "ENOENT") {
|
|
730
|
+
break;
|
|
731
|
+
}
|
|
732
|
+
else {
|
|
733
|
+
throw error;
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
try {
|
|
737
|
+
const claimed = parseLockOwner(await readFile(transitionPath, "utf8"));
|
|
738
|
+
const current = parseLockOwner(await readFile(ownerPath, "utf8"));
|
|
739
|
+
if (claimed?.owner === owner
|
|
740
|
+
&& claimed.generation === generation
|
|
741
|
+
&& current?.owner === owner
|
|
742
|
+
&& current.generation === generation) {
|
|
743
|
+
await rm(lockDirectory, { recursive: true, force: true });
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
finally {
|
|
747
|
+
await unlink(transitionPath).catch(() => undefined);
|
|
748
|
+
}
|
|
749
|
+
break;
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
catch (error) {
|
|
753
|
+
if (error.code !== "ENOENT")
|
|
754
|
+
throw error;
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
}
|
|
282
759
|
export function inspectAttemptWorkspace(workspace) {
|
|
283
760
|
if (workspace.isolation === "none") {
|
|
284
761
|
return {
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export type WorkspaceObservationErrorCode = "source_drift" | "verification_failed" | "observation_limit_exceeded";
|
|
2
|
+
export declare class WorkspaceObservationError extends Error {
|
|
3
|
+
readonly code: WorkspaceObservationErrorCode;
|
|
4
|
+
constructor(code: WorkspaceObservationErrorCode, message: string, options?: ErrorOptions);
|
|
5
|
+
}
|
|
6
|
+
export interface WorkspaceObservationLimits {
|
|
7
|
+
readonly max_entries: number;
|
|
8
|
+
readonly max_bytes: number;
|
|
9
|
+
readonly max_depth: number;
|
|
10
|
+
readonly max_duration_ms: number;
|
|
11
|
+
}
|
|
12
|
+
export interface WorkspaceObservationHooks {
|
|
13
|
+
after_directory_read?(relativePath: string): Promise<void> | void;
|
|
14
|
+
}
|
|
15
|
+
export interface ObserveWorkspaceRequest {
|
|
16
|
+
readonly workspace_root: string;
|
|
17
|
+
readonly limits?: Partial<WorkspaceObservationLimits>;
|
|
18
|
+
readonly hooks?: WorkspaceObservationHooks;
|
|
19
|
+
readonly now_ms?: () => number;
|
|
20
|
+
}
|
|
21
|
+
export interface GitWorkspaceSnapshot {
|
|
22
|
+
readonly repository_identity_digest: `sha256:${string}`;
|
|
23
|
+
readonly head: string;
|
|
24
|
+
readonly symbolic_branch: string | null;
|
|
25
|
+
readonly porcelain_v2_digest: `sha256:${string}`;
|
|
26
|
+
readonly local_config_digest: `sha256:${string}`;
|
|
27
|
+
}
|
|
28
|
+
export interface WorkspaceObservation {
|
|
29
|
+
readonly schema_version: "intentdna.workspace_observation.v2";
|
|
30
|
+
readonly observed_digest: `sha256:${string}`;
|
|
31
|
+
readonly entry_count: number;
|
|
32
|
+
readonly byte_count: number;
|
|
33
|
+
readonly excluded_relative_paths: readonly [".dna/runtime", ".dna/worktrees/runtime"];
|
|
34
|
+
readonly git: GitWorkspaceSnapshot | null;
|
|
35
|
+
}
|
|
36
|
+
export declare const DEFAULT_WORKSPACE_OBSERVATION_LIMITS: WorkspaceObservationLimits;
|
|
37
|
+
export declare const WORKSPACE_OBSERVATION_EXCLUDED_PATHS: readonly [".dna/runtime", ".dna/worktrees/runtime"];
|
|
38
|
+
export declare function workspaceObservationPolicy(limits?: Partial<WorkspaceObservationLimits>): {
|
|
39
|
+
readonly schema_version: "intentdna.workspace_observation_policy.v1";
|
|
40
|
+
readonly limits: WorkspaceObservationLimits;
|
|
41
|
+
readonly excluded_relative_paths: typeof WORKSPACE_OBSERVATION_EXCLUDED_PATHS;
|
|
42
|
+
readonly git_observation: "head_branch_status_local_config";
|
|
43
|
+
};
|
|
44
|
+
export declare function observeWorkspace(request: ObserveWorkspaceRequest): Promise<WorkspaceObservation>;
|