@brainervirus/workit-core 1.2.0 → 1.3.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/package.json +1 -1
- package/skills/workit-babysit/SKILL.md +20 -26
- package/skills/workit-behavioral-tdd/SKILL.md +0 -5
- package/skills/workit-blast-radius/SKILL.md +0 -5
- package/skills/workit-challenge/SKILL.md +30 -47
- package/skills/workit-debug/SKILL.md +5 -27
- package/skills/workit-deslop/SKILL.md +0 -5
- package/skills/workit-diagram/SKILL.md +0 -5
- package/skills/workit-green-run/SKILL.md +0 -5
- package/skills/workit-handoff/SKILL.md +2 -8
- package/skills/workit-implement/SKILL.md +14 -20
- package/skills/workit-mockup/SKILL.md +0 -5
- package/skills/workit-plan/SKILL.md +30 -63
- package/skills/workit-review/SKILL.md +0 -5
- package/skills/workit-steer/SKILL.md +27 -27
- package/src/core/authority.ts +31 -8
- package/src/core/auto-approval.ts +44 -32
- package/src/core/branch.ts +293 -42
- package/src/core/config-conversion.ts +4 -4
- package/src/core/config-guard.ts +1 -7
- package/src/core/config.ts +1 -1
- package/src/core/cutover.ts +1207 -140
- package/src/core/doctor.ts +155 -82
- package/src/core/external-action-effects.ts +1102 -209
- package/src/core/external-action.ts +230 -103
- package/src/core/init.ts +25 -115
- package/src/core/legacy-ownership.ts +47 -0
- package/src/core/methods.ts +56 -52
- package/src/core/ports/pr-create.ts +23 -15
- package/src/core/pr-create.ts +374 -84
- package/src/core/registration.ts +0 -15
- package/src/core/repo-tools.ts +0 -1
- package/src/core/route-intent.ts +34 -82
- package/src/core/safe-write.ts +66 -1
- package/src/core/setup-state.ts +7 -0
- package/src/core/setup.ts +175 -82
- package/src/core/task-contract.ts +16 -3
- package/src/core/task-engine.ts +136 -31
- package/src/core/task-evaluation.ts +3 -5
- package/src/core/task-store.ts +159 -1
- package/src/core/tracker-issues.ts +55 -118
- package/src/core/vcs-config.ts +178 -187
- package/src/core/workers.ts +17 -0
- package/src/core/workspaces.ts +548 -46
- package/src/core.ts +1 -2
- package/src/core/ports/vcs-token-create-urls.ts +0 -4
package/src/core/task-engine.ts
CHANGED
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
type Result,
|
|
30
30
|
type Scope,
|
|
31
31
|
type TaskRecord,
|
|
32
|
+
type TaskListItem,
|
|
32
33
|
type TaskSummary,
|
|
33
34
|
type TaskView,
|
|
34
35
|
type Utc,
|
|
@@ -152,6 +153,7 @@ type WorkerAuthority = {
|
|
|
152
153
|
expectedWorkspaceRevision: string;
|
|
153
154
|
state: NativeWorkerObservation["state"];
|
|
154
155
|
session: NativeWorkerObservation["session"];
|
|
156
|
+
report: NativeWorkerObservation["report"] | null;
|
|
155
157
|
provenance: Provenance;
|
|
156
158
|
owner: object;
|
|
157
159
|
store: TaskStore;
|
|
@@ -280,6 +282,7 @@ const verifyNativeWorker = (
|
|
|
280
282
|
expectedWorkspaceRevision: expected.expectedWorkspaceRevision,
|
|
281
283
|
state: expected.state,
|
|
282
284
|
session: expected.session,
|
|
285
|
+
report: expected.report ?? null,
|
|
283
286
|
provenance: result.data,
|
|
284
287
|
...binding,
|
|
285
288
|
});
|
|
@@ -305,7 +308,8 @@ const takeWorker = (
|
|
|
305
308
|
value.expectedRevision !== expected.expectedRevision ||
|
|
306
309
|
value.expectedWorkspaceRevision !== expected.expectedWorkspaceRevision ||
|
|
307
310
|
value.state !== expected.state ||
|
|
308
|
-
!sameValue(value.session, expected.session)
|
|
311
|
+
!sameValue(value.session, expected.session) ||
|
|
312
|
+
!sameValue(value.report, expected.report ?? null)
|
|
309
313
|
)
|
|
310
314
|
return null;
|
|
311
315
|
return value;
|
|
@@ -361,6 +365,8 @@ const applyWorkerLifecycle = (input: ObserveWorkerLifecycleInput): Result<Entry<
|
|
|
361
365
|
return failure("invalid_transition", "worker already has an observed session");
|
|
362
366
|
if (notStarted && !["dispatching", "cancelling"].includes(entry.data.state))
|
|
363
367
|
return failure("invalid_transition", "worker launch was not claimed");
|
|
368
|
+
if (input.report && input.state !== "stopped")
|
|
369
|
+
return failure("invalid_input", "worker reports require a stopped worker");
|
|
364
370
|
if (
|
|
365
371
|
(input.state === "running" ||
|
|
366
372
|
input.state === "cancelling" ||
|
|
@@ -383,6 +389,7 @@ const applyWorkerLifecycle = (input: ObserveWorkerLifecycleInput): Result<Entry<
|
|
|
383
389
|
...entry.data,
|
|
384
390
|
state: terminalCancel ? ("cancelling" as const) : input.state,
|
|
385
391
|
session: input.session,
|
|
392
|
+
report: entry.data.report ?? input.report ?? null,
|
|
386
393
|
},
|
|
387
394
|
} satisfies Entry<Worker>;
|
|
388
395
|
const shouldClear =
|
|
@@ -402,6 +409,7 @@ const applyWorkerLifecycle = (input: ObserveWorkerLifecycleInput): Result<Entry<
|
|
|
402
409
|
if (
|
|
403
410
|
entry.data.state === nextEntry.data.state &&
|
|
404
411
|
sameValue(entry.data.session, input.session) &&
|
|
412
|
+
sameValue(entry.data.report, nextEntry.data.report) &&
|
|
405
413
|
!shouldClear &&
|
|
406
414
|
!shouldUncertain
|
|
407
415
|
)
|
|
@@ -504,6 +512,21 @@ const portableTask = (task: TaskRecord): TaskRecord => {
|
|
|
504
512
|
};
|
|
505
513
|
for (const entry of clone.assessments)
|
|
506
514
|
demote(entry.data.facts, entry.data.signals, entry.data.consequences);
|
|
515
|
+
const sourceDecisions = new Map(task.decisions.map((entry) => [entry.id, entry]));
|
|
516
|
+
for (const entry of clone.decisions) {
|
|
517
|
+
const consumption = sourceDecisions.get(entry.id)?.data.consumption;
|
|
518
|
+
if (!consumption) continue;
|
|
519
|
+
entry.data.consumption = {
|
|
520
|
+
...consumption,
|
|
521
|
+
state: consumption.state === "reserved" ? "uncertain" : consumption.state,
|
|
522
|
+
actionRef: { kind: "record", collection: "decisions", id: entry.id },
|
|
523
|
+
...(consumption.historicalActionId
|
|
524
|
+
? { historicalActionId: consumption.historicalActionId }
|
|
525
|
+
: consumption.actionRef.kind === "host"
|
|
526
|
+
? { historicalActionId: consumption.actionRef.handle }
|
|
527
|
+
: {}),
|
|
528
|
+
};
|
|
529
|
+
}
|
|
507
530
|
// Candidate metadata can contain environment-derived paths and digests. The destination
|
|
508
531
|
// must recapture its own candidate instead of receiving source checkout material.
|
|
509
532
|
clone.candidates = [];
|
|
@@ -641,7 +664,13 @@ const importedTask = (
|
|
|
641
664
|
taskId: "",
|
|
642
665
|
workspaceId: destinationWorkspaceId,
|
|
643
666
|
},
|
|
644
|
-
consumption:
|
|
667
|
+
consumption: data.consumption
|
|
668
|
+
? {
|
|
669
|
+
...data.consumption,
|
|
670
|
+
state: data.consumption.state === "reserved" ? "uncertain" : data.consumption.state,
|
|
671
|
+
actionRef: { kind: "record", collection: "decisions", id: entry.id },
|
|
672
|
+
}
|
|
673
|
+
: null,
|
|
645
674
|
},
|
|
646
675
|
};
|
|
647
676
|
}),
|
|
@@ -858,14 +887,14 @@ export class WorkitCore {
|
|
|
858
887
|
});
|
|
859
888
|
}
|
|
860
889
|
|
|
861
|
-
task(request: unknown): Result<TaskSummary |
|
|
890
|
+
task(request: unknown): Result<TaskSummary | TaskListItem[] | TaskView> {
|
|
862
891
|
const root = this.contextRootError();
|
|
863
892
|
if (!root.ok) return root as Result<never>;
|
|
864
893
|
const parsed = parseOperation("task", request);
|
|
865
894
|
if (!parsed.ok) return parsed as Result<never>;
|
|
866
895
|
const input = parsed.data as any;
|
|
867
896
|
if ((this.context.workerId ?? null) !== null) {
|
|
868
|
-
if (input.action !== "inspect")
|
|
897
|
+
if (input.action !== "inspect" && input.action !== "list")
|
|
869
898
|
return failure("permission_denied", "helpers cannot control task lifecycle or scope");
|
|
870
899
|
}
|
|
871
900
|
switch (input.action) {
|
|
@@ -887,20 +916,67 @@ export class WorkitCore {
|
|
|
887
916
|
case "list": {
|
|
888
917
|
const listed = this.store.listTasks();
|
|
889
918
|
if (!listed.ok) return listed as Result<never>;
|
|
890
|
-
const
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
919
|
+
const status = input.status ?? "open";
|
|
920
|
+
const limit = input.limit ?? 20;
|
|
921
|
+
const query = input.query?.toLowerCase().split(/\s+/);
|
|
922
|
+
const selected = listed.data
|
|
923
|
+
.filter((task) =>
|
|
924
|
+
status === "all"
|
|
925
|
+
? true
|
|
926
|
+
: status === "closed"
|
|
927
|
+
? task.status === "closed"
|
|
928
|
+
: task.status !== "closed",
|
|
929
|
+
)
|
|
930
|
+
.filter(
|
|
931
|
+
(task) =>
|
|
932
|
+
!query ||
|
|
933
|
+
query.every((term: string) =>
|
|
934
|
+
JSON.stringify({
|
|
935
|
+
id: task.id,
|
|
936
|
+
workspaceId: task.workspaceId,
|
|
937
|
+
origin: task.origin,
|
|
938
|
+
createdAt: task.createdAt,
|
|
939
|
+
updatedAt: task.updatedAt,
|
|
940
|
+
source: task.intent.provenance,
|
|
941
|
+
objective: task.intent.data.objective,
|
|
942
|
+
progress: task.progress,
|
|
943
|
+
decisions: task.decisions.map(({ data }) => data),
|
|
944
|
+
})
|
|
945
|
+
.toLowerCase()
|
|
946
|
+
.includes(term),
|
|
947
|
+
),
|
|
948
|
+
)
|
|
949
|
+
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))
|
|
950
|
+
.slice(0, limit);
|
|
951
|
+
if (selected.length === 0) return success(null, null, []);
|
|
952
|
+
const workspace = this.store.readWorkspace();
|
|
953
|
+
if (!workspace.ok) return workspace as Result<never>;
|
|
954
|
+
if (!workspace.data) return failure("not_found", "workspace not found");
|
|
955
|
+
const tasks = selected.map((task): TaskListItem => ({
|
|
956
|
+
id: task.id,
|
|
957
|
+
revision: task.revision,
|
|
958
|
+
workspaceRevision: workspace.data!.revision,
|
|
959
|
+
createdAt: task.createdAt,
|
|
960
|
+
updatedAt: task.updatedAt,
|
|
961
|
+
runtime: task.runtime ?? null,
|
|
962
|
+
objective: task.intent.data.objective,
|
|
963
|
+
status: task.status,
|
|
964
|
+
closure: task.closure,
|
|
965
|
+
progress: task.progress,
|
|
966
|
+
writer: task.status === "closed" ? null : workspace.data!.writer,
|
|
967
|
+
source: {
|
|
968
|
+
host: task.intent.provenance.host,
|
|
969
|
+
kind: task.intent.provenance.kind,
|
|
970
|
+
},
|
|
971
|
+
}));
|
|
972
|
+
return success(null, null, tasks);
|
|
897
973
|
}
|
|
898
974
|
case "inspect": {
|
|
899
975
|
const task = this.store.readTask(input.taskId);
|
|
900
976
|
if (!task.ok) return task as Result<never>;
|
|
901
977
|
const helper = this.helperTaskGuard(task.data, true);
|
|
902
978
|
if (!helper.ok) return helper as Result<never>;
|
|
903
|
-
if (input.view === "summary") return this.summary(task.data);
|
|
979
|
+
if ((input.view ?? "summary") === "summary") return this.summary(task.data);
|
|
904
980
|
return this.view(task.data);
|
|
905
981
|
}
|
|
906
982
|
case "pause":
|
|
@@ -1344,11 +1420,14 @@ export class WorkitCore {
|
|
|
1344
1420
|
const evaluations = evaluateEvidence(task.data, current.data);
|
|
1345
1421
|
const verified = evidence.some((entry) => {
|
|
1346
1422
|
const evaluation = evaluations.find((item) => item.evidenceId === entry.id);
|
|
1347
|
-
return
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1423
|
+
return (
|
|
1424
|
+
entry.recordedAt >= finding.recordedAt &&
|
|
1425
|
+
findingVerificationPasses(finding.data.candidateId, {
|
|
1426
|
+
kind: entry.data.kind,
|
|
1427
|
+
candidateId: entry.data.candidateId,
|
|
1428
|
+
status: evaluation?.status ?? "missing",
|
|
1429
|
+
})
|
|
1430
|
+
);
|
|
1352
1431
|
});
|
|
1353
1432
|
if (!verified)
|
|
1354
1433
|
return failure("permission_denied", "fixed findings require passing verification evidence");
|
|
@@ -1451,7 +1530,18 @@ export class WorkitCore {
|
|
|
1451
1530
|
if (task.data.status !== "active")
|
|
1452
1531
|
return failure("invalid_transition", "paused or closed tasks cannot assign workers");
|
|
1453
1532
|
if (!bindingCovers(task.data.intent.data.scope, input.assignment.scope))
|
|
1454
|
-
return failure("permission_denied", "worker assignment is outside task scope"
|
|
1533
|
+
return failure("permission_denied", "worker assignment is outside task scope", {
|
|
1534
|
+
fields: [
|
|
1535
|
+
{
|
|
1536
|
+
path: "assignment.scope",
|
|
1537
|
+
reason: `paths ${JSON.stringify(input.assignment.scope.paths)} must be contained by lead paths ${JSON.stringify(task.data.intent.data.scope.paths)}`,
|
|
1538
|
+
},
|
|
1539
|
+
{
|
|
1540
|
+
path: "task.intent.scope",
|
|
1541
|
+
reason: `lead paths are ${JSON.stringify(task.data.intent.data.scope.paths)}`,
|
|
1542
|
+
},
|
|
1543
|
+
],
|
|
1544
|
+
});
|
|
1455
1545
|
if (
|
|
1456
1546
|
input.assignment.decisionIds.some(
|
|
1457
1547
|
(id: string) => !task.data.decisions.some((entry) => entry.id === id),
|
|
@@ -1739,7 +1829,15 @@ export class WorkitCore {
|
|
|
1739
1829
|
...item,
|
|
1740
1830
|
recordedAt: mutation.now,
|
|
1741
1831
|
provenance: attested.data,
|
|
1742
|
-
data: {
|
|
1832
|
+
data: {
|
|
1833
|
+
...item.data,
|
|
1834
|
+
state: "dispatching" as const,
|
|
1835
|
+
coordinator: {
|
|
1836
|
+
kind: "host" as const,
|
|
1837
|
+
host: this.context.caller.host,
|
|
1838
|
+
handle: this.context.caller.actor,
|
|
1839
|
+
},
|
|
1840
|
+
},
|
|
1743
1841
|
}
|
|
1744
1842
|
: item,
|
|
1745
1843
|
),
|
|
@@ -1828,6 +1926,7 @@ export class WorkitCore {
|
|
|
1828
1926
|
expectedWorkspaceRevision: input.expectedWorkspaceRevision,
|
|
1829
1927
|
state: "stopped",
|
|
1830
1928
|
session: null,
|
|
1929
|
+
report: null,
|
|
1831
1930
|
provenance: attested.data,
|
|
1832
1931
|
owner: this.authorityOwner,
|
|
1833
1932
|
store: this.store,
|
|
@@ -2205,11 +2304,16 @@ export class WorkitCore {
|
|
|
2205
2304
|
const workspace = this.store.readWorkspace();
|
|
2206
2305
|
if (!workspace.ok) return workspace as Result<never>;
|
|
2207
2306
|
if (!workspace.data) return failure("not_found", "workspace not found");
|
|
2208
|
-
const
|
|
2307
|
+
const historical = task.status === "closed" ? task.candidates.at(-1) : undefined;
|
|
2308
|
+
const current = historical
|
|
2309
|
+
? success(null, null, historical)
|
|
2310
|
+
: captureCandidate(this.store.root, task.intent.data.scope, environment());
|
|
2209
2311
|
if (!current.ok) return current as Result<never>;
|
|
2312
|
+
const evaluationWorkspace =
|
|
2313
|
+
task.status === "closed" ? { ...workspace.data, writer: null } : workspace.data;
|
|
2210
2314
|
const requirements = evaluateRequirements(
|
|
2211
2315
|
task,
|
|
2212
|
-
|
|
2316
|
+
evaluationWorkspace,
|
|
2213
2317
|
this.context.capabilities,
|
|
2214
2318
|
current.data,
|
|
2215
2319
|
this.store.root,
|
|
@@ -2228,7 +2332,7 @@ export class WorkitCore {
|
|
|
2228
2332
|
progress: task.progress,
|
|
2229
2333
|
policy: task.policy,
|
|
2230
2334
|
requirements,
|
|
2231
|
-
writer:
|
|
2335
|
+
writer: evaluationWorkspace.writer,
|
|
2232
2336
|
});
|
|
2233
2337
|
}
|
|
2234
2338
|
|
|
@@ -2236,11 +2340,16 @@ export class WorkitCore {
|
|
|
2236
2340
|
const workspace = this.store.readWorkspace();
|
|
2237
2341
|
if (!workspace.ok) return workspace as Result<never>;
|
|
2238
2342
|
if (!workspace.data) return failure("not_found", "workspace not found");
|
|
2239
|
-
const
|
|
2343
|
+
const historical = task.status === "closed" ? task.candidates.at(-1) : undefined;
|
|
2344
|
+
const current = historical
|
|
2345
|
+
? success(null, null, historical)
|
|
2346
|
+
: captureCandidate(this.store.root, task.intent.data.scope, environment());
|
|
2240
2347
|
if (!current.ok) return current as Result<never>;
|
|
2348
|
+
const evaluationWorkspace =
|
|
2349
|
+
task.status === "closed" ? { ...workspace.data, writer: null } : workspace.data;
|
|
2241
2350
|
const requirements = evaluateRequirements(
|
|
2242
2351
|
task,
|
|
2243
|
-
|
|
2352
|
+
evaluationWorkspace,
|
|
2244
2353
|
this.context.capabilities,
|
|
2245
2354
|
current.data,
|
|
2246
2355
|
this.store.root,
|
|
@@ -2248,7 +2357,7 @@ export class WorkitCore {
|
|
|
2248
2357
|
);
|
|
2249
2358
|
return success(task.revision, workspace.data.revision, {
|
|
2250
2359
|
task,
|
|
2251
|
-
workspace:
|
|
2360
|
+
workspace: evaluationWorkspace,
|
|
2252
2361
|
capabilities: this.context.capabilities,
|
|
2253
2362
|
requirements,
|
|
2254
2363
|
evidence: evaluateEvidence(task, current.data),
|
|
@@ -2357,8 +2466,6 @@ export class WorkitCore {
|
|
|
2357
2466
|
(status === "active" && task.data.status !== "paused")
|
|
2358
2467
|
)
|
|
2359
2468
|
return failure("invalid_transition", `cannot transition ${task.data.status} to ${status}`);
|
|
2360
|
-
if (status === "active" && input.authorityRefs.length === 0)
|
|
2361
|
-
return failure("permission_denied", "resuming a task requires authority references");
|
|
2362
2469
|
const workspace = this.store.readWorkspace();
|
|
2363
2470
|
if (!workspace.ok) return workspace as Result<never>;
|
|
2364
2471
|
if (!workspace.data) return failure("not_found", "workspace not found");
|
|
@@ -2403,7 +2510,7 @@ export class WorkitCore {
|
|
|
2403
2510
|
)
|
|
2404
2511
|
)
|
|
2405
2512
|
return failure("recovery_required", "imported task requires resume reconciliation");
|
|
2406
|
-
if (!validResumeApproval(task.data, workspace.data, input.authorityRefs, this.context))
|
|
2513
|
+
if (!validResumeApproval(task.data, workspace.data, input.authorityRefs ?? [], this.context))
|
|
2407
2514
|
return failure("permission_denied", "imported resume requires native destination approval");
|
|
2408
2515
|
resumeCandidate = current.data.candidate;
|
|
2409
2516
|
}
|
|
@@ -2552,9 +2659,7 @@ export class WorkitCore {
|
|
|
2552
2659
|
success(context.revision, null, {
|
|
2553
2660
|
...value,
|
|
2554
2661
|
status: "closed",
|
|
2555
|
-
candidates:
|
|
2556
|
-
? value.candidates
|
|
2557
|
-
: [...value.candidates, withCandidate],
|
|
2662
|
+
candidates: taskForView.candidates,
|
|
2558
2663
|
closure: {
|
|
2559
2664
|
outcome: closure.data.outcome,
|
|
2560
2665
|
at: context.now,
|
|
@@ -412,17 +412,15 @@ export const resolveBinding = (
|
|
|
412
412
|
};
|
|
413
413
|
};
|
|
414
414
|
|
|
415
|
-
/**
|
|
416
|
-
*
|
|
417
|
-
* bound to the finding's candidate. Used by the auto-reopen scan, the
|
|
418
|
-
* resolve gate, and closure — one predicate, one error code each. */
|
|
415
|
+
/** Evidence evaluation establishes freshness against the current tree;
|
|
416
|
+
* the finding's original candidate identifies where the defect was found. */
|
|
419
417
|
export const findingVerificationPasses = (
|
|
420
418
|
findingCandidateId: string | null | undefined,
|
|
421
419
|
evidence: { kind: string; candidateId: string | null | undefined; status: string },
|
|
422
420
|
): boolean =>
|
|
423
421
|
evidence.status === "passed" &&
|
|
424
422
|
(evidence.kind === "check" || evidence.kind === "review") &&
|
|
425
|
-
((findingCandidateId ?? null) === null || evidence.candidateId
|
|
423
|
+
((findingCandidateId ?? null) === null || evidence.candidateId != null);
|
|
426
424
|
const applicableDecision = (
|
|
427
425
|
task: TaskRecord,
|
|
428
426
|
workspace: WorkspaceRecord,
|
package/src/core/task-store.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
3
|
import * as fs from "node:fs";
|
|
3
4
|
import { hostname } from "node:os";
|
|
4
5
|
import path from "node:path";
|
|
@@ -76,6 +77,7 @@ export type MetadataLock = {
|
|
|
76
77
|
processStart: string | null;
|
|
77
78
|
host: string;
|
|
78
79
|
nonce: string;
|
|
80
|
+
externalAction?: true;
|
|
79
81
|
};
|
|
80
82
|
export type ProcessEvidence = {
|
|
81
83
|
state: "stopped" | "accounted_for";
|
|
@@ -100,6 +102,7 @@ const metadataLockSchema = z
|
|
|
100
102
|
processStart: z.string().nullable(),
|
|
101
103
|
host: z.string().min(1),
|
|
102
104
|
nonce: z.string().min(1),
|
|
105
|
+
externalAction: z.literal(true).optional(),
|
|
103
106
|
})
|
|
104
107
|
.strict();
|
|
105
108
|
export type RecoveryCandidate = {
|
|
@@ -170,7 +173,35 @@ const sameMetadataLock = (left: unknown, right: MetadataLock): boolean => {
|
|
|
170
173
|
const parsed = metadataLockSchema.safeParse(left);
|
|
171
174
|
return parsed.success && canonicalJson(parsed.data) === canonicalJson(right);
|
|
172
175
|
};
|
|
176
|
+
export const sameDirectoryIdentity = (left: string, right: string): boolean => {
|
|
177
|
+
if (!path.isAbsolute(left) || !path.isAbsolute(right)) return false;
|
|
178
|
+
const normalizedLeft = path.resolve(left);
|
|
179
|
+
const normalizedRight = path.resolve(right);
|
|
180
|
+
try {
|
|
181
|
+
const a = fs.statSync(normalizedLeft, { bigint: true });
|
|
182
|
+
const b = fs.statSync(normalizedRight, { bigint: true });
|
|
183
|
+
if (!a.isDirectory() || !b.isDirectory()) return false;
|
|
184
|
+
if (
|
|
185
|
+
process.platform === "win32"
|
|
186
|
+
? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase()
|
|
187
|
+
: normalizedLeft === normalizedRight
|
|
188
|
+
)
|
|
189
|
+
return true;
|
|
190
|
+
const canonicalLeft = fs.realpathSync(normalizedLeft);
|
|
191
|
+
const canonicalRight = fs.realpathSync(normalizedRight);
|
|
192
|
+
if (
|
|
193
|
+
process.platform === "win32"
|
|
194
|
+
? canonicalLeft.toLowerCase() === canonicalRight.toLowerCase()
|
|
195
|
+
: canonicalLeft === canonicalRight
|
|
196
|
+
)
|
|
197
|
+
return true;
|
|
198
|
+
return a.dev === b.dev && a.ino !== 0n && a.ino === b.ino;
|
|
199
|
+
} catch {
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
};
|
|
173
203
|
type LockSnapshot = { raw: string; data: MetadataLock };
|
|
204
|
+
const externalActionLockRoots = new AsyncLocalStorage<ReadonlySet<string>>();
|
|
174
205
|
|
|
175
206
|
export class TaskStore {
|
|
176
207
|
readonly root: string;
|
|
@@ -230,7 +261,11 @@ export class TaskStore {
|
|
|
230
261
|
readWorkspace(): Result<WorkspaceRecord | null> {
|
|
231
262
|
const item = this.readRecord<WorkspaceRecord>(this.workspacePath, workspaceRecordSchema);
|
|
232
263
|
if (!item.exists) return success(null, null, null);
|
|
233
|
-
if (
|
|
264
|
+
if (
|
|
265
|
+
item.result.ok &&
|
|
266
|
+
item.result.data.root !== this.root &&
|
|
267
|
+
!sameDirectoryIdentity(item.result.data.root, this.root)
|
|
268
|
+
)
|
|
234
269
|
return failure("recovery_required", "workspace root binding is invalid", {
|
|
235
270
|
path: this.workspacePath,
|
|
236
271
|
});
|
|
@@ -355,6 +390,37 @@ export class TaskStore {
|
|
|
355
390
|
return this.withLock<TaskRecord>(() => {
|
|
356
391
|
const current = this.readWorkspace();
|
|
357
392
|
if (!current.ok) return current;
|
|
393
|
+
const origin = input.task.origin;
|
|
394
|
+
if (current.data && origin) {
|
|
395
|
+
if (origin.workspaceId === current.data.id) {
|
|
396
|
+
const source = this.readTask(origin.taskId);
|
|
397
|
+
if (source.ok) return success(source.data.revision, current.data.revision, source.data);
|
|
398
|
+
if (source.code !== "not_found") return source as Result<never>;
|
|
399
|
+
return failure("not_found", "source task is not present in this shared workspace");
|
|
400
|
+
}
|
|
401
|
+
const tasks = this.listTasks();
|
|
402
|
+
if (!tasks.ok) return tasks as Result<never>;
|
|
403
|
+
const prior = tasks.data.filter(
|
|
404
|
+
(task) =>
|
|
405
|
+
task.origin?.workspaceId === origin.workspaceId &&
|
|
406
|
+
task.origin?.taskId === origin.taskId,
|
|
407
|
+
);
|
|
408
|
+
if (prior.length > 1)
|
|
409
|
+
return failure(
|
|
410
|
+
"recovery_required",
|
|
411
|
+
`source task has multiple imported mappings: ${prior.map((task) => task.id).join(", ")}`,
|
|
412
|
+
);
|
|
413
|
+
if (prior.length === 1) {
|
|
414
|
+
const existing = prior[0]!;
|
|
415
|
+
if (existing.origin!.exportDigest !== origin.exportDigest)
|
|
416
|
+
return failure(
|
|
417
|
+
"revision_conflict",
|
|
418
|
+
"source task export changed; reconcile the mapped destination task before importing",
|
|
419
|
+
{ taskId: existing.id },
|
|
420
|
+
);
|
|
421
|
+
return success(existing.revision, current.data.revision, existing);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
358
424
|
if (
|
|
359
425
|
current.data
|
|
360
426
|
? input.expectedWorkspaceRevision !== current.data.revision
|
|
@@ -822,6 +888,90 @@ export class TaskStore {
|
|
|
822
888
|
};
|
|
823
889
|
}
|
|
824
890
|
|
|
891
|
+
private externalActionLockOptions(): FileLockSyncAcquireOptions<MetadataLock> {
|
|
892
|
+
const options = this.metadataLockOptions();
|
|
893
|
+
return { ...options, payload: () => ({ ...options.payload(), externalAction: true }) };
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
/** Hold the workspace metadata lock across a managed effect in this checkout. */
|
|
897
|
+
async withExternalActionLock<T>(
|
|
898
|
+
operation: () => Promise<Result<T>>,
|
|
899
|
+
reentrant = false,
|
|
900
|
+
): Promise<Result<T>> {
|
|
901
|
+
const activeRoots = externalActionLockRoots.getStore();
|
|
902
|
+
if (
|
|
903
|
+
activeRoots &&
|
|
904
|
+
[...activeRoots].some((root) => root === this.root || sameDirectoryIdentity(root, this.root))
|
|
905
|
+
)
|
|
906
|
+
return operation();
|
|
907
|
+
try {
|
|
908
|
+
this.initializeMutationStorage();
|
|
909
|
+
} catch (error) {
|
|
910
|
+
return failure("storage_error", `unable to initialize store: ${String(error)}`, {
|
|
911
|
+
path: this.workitDir,
|
|
912
|
+
});
|
|
913
|
+
}
|
|
914
|
+
let handle: FileLockSyncHandle;
|
|
915
|
+
try {
|
|
916
|
+
handle = acquireFileLockSync(this.workspacePath, this.externalActionLockOptions());
|
|
917
|
+
} catch (error) {
|
|
918
|
+
return this.lockFailure(error);
|
|
919
|
+
}
|
|
920
|
+
let result: Result<T>;
|
|
921
|
+
try {
|
|
922
|
+
if (!handle.verifyStillHeld())
|
|
923
|
+
result = failure("recovery_required", "metadata lock was compromised", {
|
|
924
|
+
path: this.lockPath,
|
|
925
|
+
});
|
|
926
|
+
else {
|
|
927
|
+
try {
|
|
928
|
+
const run = () => operation();
|
|
929
|
+
result = await (reentrant
|
|
930
|
+
? externalActionLockRoots.run(new Set([...(activeRoots ?? []), this.root]), run)
|
|
931
|
+
: run());
|
|
932
|
+
} catch {
|
|
933
|
+
result = failure("external_outcome_unknown", "external action outcome is unknown", {
|
|
934
|
+
outcome: "unknown",
|
|
935
|
+
});
|
|
936
|
+
}
|
|
937
|
+
try {
|
|
938
|
+
if (!handle.verifyStillHeld())
|
|
939
|
+
result = failure("external_outcome_unknown", "metadata lock was compromised", {
|
|
940
|
+
outcome: "unknown",
|
|
941
|
+
path: this.lockPath,
|
|
942
|
+
});
|
|
943
|
+
} catch {
|
|
944
|
+
result = failure("external_outcome_unknown", "metadata lock could not be verified", {
|
|
945
|
+
outcome: "unknown",
|
|
946
|
+
path: this.lockPath,
|
|
947
|
+
});
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
} catch {
|
|
951
|
+
result = failure("external_outcome_unknown", "external action lock operation failed", {
|
|
952
|
+
outcome: "unknown",
|
|
953
|
+
path: this.lockPath,
|
|
954
|
+
});
|
|
955
|
+
}
|
|
956
|
+
try {
|
|
957
|
+
handle.release();
|
|
958
|
+
} catch (error) {
|
|
959
|
+
const released = this.lockFailure(error);
|
|
960
|
+
const releaseError = released.ok ? "metadata lock release failed" : released.error;
|
|
961
|
+
return result.ok
|
|
962
|
+
? failure(
|
|
963
|
+
"external_outcome_unknown",
|
|
964
|
+
"external action metadata lock release is uncertain",
|
|
965
|
+
{
|
|
966
|
+
outcome: "unknown",
|
|
967
|
+
path: this.lockPath,
|
|
968
|
+
},
|
|
969
|
+
)
|
|
970
|
+
: failure("recovery_required", `${result.error}; ${releaseError}`, result.details);
|
|
971
|
+
}
|
|
972
|
+
return result;
|
|
973
|
+
}
|
|
974
|
+
|
|
825
975
|
private recoveryLockOptions(
|
|
826
976
|
observed: LockSnapshot | null,
|
|
827
977
|
evidence: ProcessEvidence,
|
|
@@ -912,6 +1062,14 @@ export class TaskStore {
|
|
|
912
1062
|
private lockFailure(error: unknown): Result<never> {
|
|
913
1063
|
const value = error as { code?: unknown; message?: unknown };
|
|
914
1064
|
const code = typeof value?.code === "string" ? value.code : "";
|
|
1065
|
+
if (code === "EEXIST" || code === "file_lock_timeout") {
|
|
1066
|
+
const lock = this.readLockSnapshot();
|
|
1067
|
+
if (lock.ok && lock.data?.data.externalAction)
|
|
1068
|
+
return failure("writer_conflict", "workspace is reserved by a managed external action", {
|
|
1069
|
+
outcome: "not_started",
|
|
1070
|
+
path: this.lockPath,
|
|
1071
|
+
});
|
|
1072
|
+
}
|
|
915
1073
|
const recovery =
|
|
916
1074
|
code === "EEXIST" ||
|
|
917
1075
|
code === "file_lock_timeout" ||
|