@brainervirus/workit-core 1.0.11 → 1.0.12
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/src/core/authority.ts +53 -11
- package/src/core/branch.ts +39 -2
- package/src/core/doctor.ts +25 -12
- package/src/core/external-action-effects.ts +86 -16
- package/src/core/external-action.ts +192 -15
- package/src/core/methods.ts +8 -8
- package/src/core/runtime-identity.ts +33 -0
- package/src/core/task-contract.ts +107 -0
- package/src/core/task-engine.ts +178 -220
- package/src/core/task-evaluation.ts +127 -22
- package/src/core/task-store.ts +4 -6
- package/src/core/workers.ts +11 -1
- package/src/core.ts +12 -0
package/src/core/task-engine.ts
CHANGED
|
@@ -5,8 +5,14 @@ import {
|
|
|
5
5
|
failure,
|
|
6
6
|
decisionDigest,
|
|
7
7
|
exportBundleSchema,
|
|
8
|
+
assessmentSchema,
|
|
9
|
+
decisionSchema,
|
|
10
|
+
evidenceSchema,
|
|
11
|
+
findingSchema,
|
|
12
|
+
intentSchema,
|
|
8
13
|
newId,
|
|
9
14
|
parseOperation,
|
|
15
|
+
rewriteRecordRefs,
|
|
10
16
|
success,
|
|
11
17
|
type Assessment,
|
|
12
18
|
type Capability,
|
|
@@ -59,6 +65,7 @@ import {
|
|
|
59
65
|
import {
|
|
60
66
|
assertProductWriteAllowed,
|
|
61
67
|
isUncertainWorker,
|
|
68
|
+
workerBlocksTransition,
|
|
62
69
|
type CallerContext,
|
|
63
70
|
type NativeWorkerObservation,
|
|
64
71
|
type NativeWorkerVerification,
|
|
@@ -70,9 +77,12 @@ import {
|
|
|
70
77
|
} from "./workers";
|
|
71
78
|
import {
|
|
72
79
|
captureCandidate,
|
|
80
|
+
checkPin,
|
|
73
81
|
evaluateClosure,
|
|
74
82
|
evaluateEvidence,
|
|
75
83
|
evaluateRequirements,
|
|
84
|
+
findingVerificationPasses,
|
|
85
|
+
resolveBinding,
|
|
76
86
|
type CandidateEnvironment,
|
|
77
87
|
} from "./task-evaluation";
|
|
78
88
|
|
|
@@ -342,6 +352,8 @@ const applyWorkerLifecycle = (input: ObserveWorkerLifecycleInput): Result<Entry<
|
|
|
342
352
|
if (task.data.status === "paused" && (input.state === "running" || input.state === "cancelling"))
|
|
343
353
|
return failure("invalid_transition", "paused task cannot run a worker");
|
|
344
354
|
const notStarted = input.dispatch === true;
|
|
355
|
+
if (notStarted && entry.data.state === "stopped" && input.session === null)
|
|
356
|
+
return success(task.data.revision, workspace.data.revision, entry);
|
|
345
357
|
if (notStarted && (input.state !== "stopped" || input.session !== null))
|
|
346
358
|
return failure("invalid_input", "a never-dispatched worker stops with no session");
|
|
347
359
|
if (notStarted && entry.data.session !== null)
|
|
@@ -395,7 +407,7 @@ const applyWorkerLifecycle = (input: ObserveWorkerLifecycleInput): Result<Entry<
|
|
|
395
407
|
return success(task.data.revision, workspace.data.revision, entry);
|
|
396
408
|
const changed = input.store.mutateTaskAndWorkspace({
|
|
397
409
|
taskId: task.data.id,
|
|
398
|
-
|
|
410
|
+
expectedRevision: input.expectedRevision,
|
|
399
411
|
expectedWorkspaceRevision: input.expectedWorkspaceRevision,
|
|
400
412
|
now: input.now,
|
|
401
413
|
workspace: (current, mutation) =>
|
|
@@ -469,96 +481,28 @@ const importedProvenance = (context: OperationContext): Provenance => ({
|
|
|
469
481
|
});
|
|
470
482
|
|
|
471
483
|
const portableTask = (task: TaskRecord): TaskRecord => {
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
const
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
};
|
|
492
|
-
clone.constraints = clone.constraints.flatMap((constraint) => {
|
|
493
|
-
const source = portableRef(constraint.source);
|
|
494
|
-
return source
|
|
495
|
-
? [
|
|
496
|
-
{
|
|
497
|
-
...constraint,
|
|
498
|
-
source,
|
|
499
|
-
requires: constraint.requires.map((requirement) => ({
|
|
500
|
-
...requirement,
|
|
501
|
-
refs: portableRefs(requirement.refs),
|
|
502
|
-
})),
|
|
503
|
-
},
|
|
504
|
-
]
|
|
505
|
-
: [];
|
|
506
|
-
});
|
|
507
|
-
clone.assessments = clone.assessments.map((entry) => ({
|
|
508
|
-
...entry,
|
|
509
|
-
data: {
|
|
510
|
-
...entry.data,
|
|
511
|
-
facts: entry.data.facts.map(portableFact),
|
|
512
|
-
signals: Object.fromEntries(
|
|
513
|
-
Object.entries(entry.data.signals).map(([name, signal]) => [name, portableSignal(signal)]),
|
|
514
|
-
) as typeof entry.data.signals,
|
|
515
|
-
consequences: entry.data.consequences.map((consequence) => ({
|
|
516
|
-
...consequence,
|
|
517
|
-
fact: portableFact(consequence.fact),
|
|
518
|
-
})),
|
|
519
|
-
verification: entry.data.verification.map((verification) => ({
|
|
520
|
-
...verification,
|
|
521
|
-
availableChecks: portableRefs(verification.availableChecks),
|
|
522
|
-
})),
|
|
523
|
-
},
|
|
524
|
-
}));
|
|
525
|
-
clone.progress = {
|
|
526
|
-
...clone.progress,
|
|
527
|
-
blockers: clone.progress.blockers.map((blocker) => ({
|
|
528
|
-
...blocker,
|
|
529
|
-
refs: portableRefs(blocker.refs),
|
|
530
|
-
})),
|
|
484
|
+
// Schema-driven ref stripping: every Ref anywhere in the record is mapped
|
|
485
|
+
// by shape (arrays drop unportable refs, nullable fields null them), so a
|
|
486
|
+
// new ref-bearing field can never silently leak across checkouts.
|
|
487
|
+
const stripped = rewriteRecordRefs(task, taskRecordSchema, portableRef);
|
|
488
|
+
if (typeof stripped !== "object" || stripped === null || Array.isArray(stripped))
|
|
489
|
+
throw new TypeError("portable task rewrite produced a non-record");
|
|
490
|
+
const clone = structuredClone(stripped) as TaskRecord;
|
|
491
|
+
const demote = (
|
|
492
|
+
facts: Assessment["facts"][number][],
|
|
493
|
+
signals: Assessment["signals"],
|
|
494
|
+
consequences: Assessment["consequences"],
|
|
495
|
+
): void => {
|
|
496
|
+
for (const fact of facts) Object.assign(fact, portableFact(fact));
|
|
497
|
+
for (const name of Object.keys(signals) as (keyof Assessment["signals"])[]) {
|
|
498
|
+
const signal = signals[name];
|
|
499
|
+
Object.assign(signal, portableSignal(signal));
|
|
500
|
+
}
|
|
501
|
+
for (const consequence of consequences)
|
|
502
|
+
Object.assign(consequence.fact, portableFact(consequence.fact));
|
|
531
503
|
};
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
data: {
|
|
535
|
-
...entry.data,
|
|
536
|
-
refs: portableRefs(entry.data.refs),
|
|
537
|
-
reviewContext: portableRef(entry.data.reviewContext),
|
|
538
|
-
},
|
|
539
|
-
}));
|
|
540
|
-
clone.decisions = clone.decisions.map((entry) => ({
|
|
541
|
-
...entry,
|
|
542
|
-
data: {
|
|
543
|
-
...entry.data,
|
|
544
|
-
consumption: entry.data.consumption
|
|
545
|
-
? portableRef(entry.data.consumption.actionRef)
|
|
546
|
-
? {
|
|
547
|
-
...entry.data.consumption,
|
|
548
|
-
actionRef: portableRef(entry.data.consumption.actionRef)!,
|
|
549
|
-
}
|
|
550
|
-
: null
|
|
551
|
-
: null,
|
|
552
|
-
binding: {
|
|
553
|
-
...entry.data.binding,
|
|
554
|
-
contentRefs: portableRefs(entry.data.binding.contentRefs),
|
|
555
|
-
},
|
|
556
|
-
},
|
|
557
|
-
}));
|
|
558
|
-
clone.findings = clone.findings.map((entry) => ({
|
|
559
|
-
...entry,
|
|
560
|
-
data: { ...entry.data, refs: portableRefs(entry.data.refs) },
|
|
561
|
-
}));
|
|
504
|
+
for (const entry of clone.assessments)
|
|
505
|
+
demote(entry.data.facts, entry.data.signals, entry.data.consequences);
|
|
562
506
|
// Candidate metadata can contain environment-derived paths and digests. The destination
|
|
563
507
|
// must recapture its own candidate instead of receiving source checkout material.
|
|
564
508
|
clone.candidates = [];
|
|
@@ -591,15 +535,13 @@ const importedTask = (
|
|
|
591
535
|
allocate(entry.id);
|
|
592
536
|
const fresh = (id: string) => allocate(id);
|
|
593
537
|
const provenance = importedProvenance(context);
|
|
538
|
+
const remap = (ref: Ref): Ref | null => mapRef(ref, ids);
|
|
594
539
|
const intent = {
|
|
595
540
|
...source.intent,
|
|
596
541
|
id: fresh(source.intent.id),
|
|
597
542
|
recordedAt: timestamp,
|
|
598
543
|
provenance,
|
|
599
|
-
data:
|
|
600
|
-
...source.intent.data,
|
|
601
|
-
authorityRefs: source.intent.data.authorityRefs.map((ref) => mapRef(ref, ids)),
|
|
602
|
-
},
|
|
544
|
+
data: rewriteRecordRefs(source.intent.data, intentSchema, remap) as typeof source.intent.data,
|
|
603
545
|
};
|
|
604
546
|
const assessments = source.assessments.map((entry) => {
|
|
605
547
|
const data = entry.data;
|
|
@@ -608,30 +550,7 @@ const importedTask = (
|
|
|
608
550
|
id: fresh(entry.id),
|
|
609
551
|
recordedAt: timestamp,
|
|
610
552
|
provenance,
|
|
611
|
-
data:
|
|
612
|
-
...data,
|
|
613
|
-
facts: data.facts.map((fact) => ({
|
|
614
|
-
...fact,
|
|
615
|
-
refs: fact.refs.map((ref) => mapRef(ref, ids)),
|
|
616
|
-
})),
|
|
617
|
-
signals: Object.fromEntries(
|
|
618
|
-
Object.entries(data.signals).map(([name, signal]) => [
|
|
619
|
-
name,
|
|
620
|
-
{ ...signal, refs: signal.refs.map((ref) => mapRef(ref, ids)) },
|
|
621
|
-
]),
|
|
622
|
-
) as typeof data.signals,
|
|
623
|
-
consequences: data.consequences.map((consequence) => ({
|
|
624
|
-
...consequence,
|
|
625
|
-
fact: {
|
|
626
|
-
...consequence.fact,
|
|
627
|
-
refs: consequence.fact.refs.map((ref) => mapRef(ref, ids)),
|
|
628
|
-
},
|
|
629
|
-
})),
|
|
630
|
-
verification: data.verification.map((verification) => ({
|
|
631
|
-
...verification,
|
|
632
|
-
availableChecks: verification.availableChecks.map((ref) => mapRef(ref, ids)),
|
|
633
|
-
})),
|
|
634
|
-
},
|
|
553
|
+
data: rewriteRecordRefs(data, assessmentSchema, remap) as typeof data,
|
|
635
554
|
};
|
|
636
555
|
});
|
|
637
556
|
const evidence = source.evidence.map((entry) => ({
|
|
@@ -639,11 +558,7 @@ const importedTask = (
|
|
|
639
558
|
id: fresh(entry.id),
|
|
640
559
|
recordedAt: timestamp,
|
|
641
560
|
provenance,
|
|
642
|
-
data:
|
|
643
|
-
...entry.data,
|
|
644
|
-
refs: entry.data.refs.map((ref) => mapRef(ref, ids)),
|
|
645
|
-
reviewContext: entry.data.reviewContext ? mapRef(entry.data.reviewContext, ids) : null,
|
|
646
|
-
},
|
|
561
|
+
data: rewriteRecordRefs(entry.data, evidenceSchema, remap) as typeof entry.data,
|
|
647
562
|
}));
|
|
648
563
|
const decisions = source.decisions.map((entry) => ({
|
|
649
564
|
...entry,
|
|
@@ -651,23 +566,25 @@ const importedTask = (
|
|
|
651
566
|
recordedAt: timestamp,
|
|
652
567
|
provenance,
|
|
653
568
|
}));
|
|
654
|
-
const findings = source.findings.map((entry) =>
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
569
|
+
const findings = source.findings.map((entry) => {
|
|
570
|
+
const data = rewriteRecordRefs(entry.data, findingSchema, remap) as typeof entry.data;
|
|
571
|
+
return {
|
|
572
|
+
...entry,
|
|
573
|
+
id: fresh(entry.id),
|
|
574
|
+
recordedAt: timestamp,
|
|
575
|
+
provenance,
|
|
576
|
+
data: {
|
|
577
|
+
...data,
|
|
578
|
+
resolution: data.resolution
|
|
579
|
+
? {
|
|
580
|
+
...data.resolution,
|
|
581
|
+
evidenceIds: data.resolution.evidenceIds.map((id) => ids.get(id) ?? id),
|
|
582
|
+
decisionIds: data.resolution.decisionIds.map((id) => ids.get(id) ?? id),
|
|
583
|
+
}
|
|
584
|
+
: null,
|
|
585
|
+
},
|
|
586
|
+
};
|
|
587
|
+
});
|
|
671
588
|
const workers = source.workers.map((entry) => ({
|
|
672
589
|
...entry,
|
|
673
590
|
id: fresh(entry.id),
|
|
@@ -712,19 +629,21 @@ const importedTask = (
|
|
|
712
629
|
closure: null,
|
|
713
630
|
assessments,
|
|
714
631
|
evidence,
|
|
715
|
-
decisions: decisions.map((entry) =>
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
...entry
|
|
719
|
-
|
|
720
|
-
...
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
632
|
+
decisions: decisions.map((entry) => {
|
|
633
|
+
const data = rewriteRecordRefs(entry.data, decisionSchema, remap) as typeof entry.data;
|
|
634
|
+
return {
|
|
635
|
+
...entry,
|
|
636
|
+
data: {
|
|
637
|
+
...data,
|
|
638
|
+
binding: {
|
|
639
|
+
...data.binding,
|
|
640
|
+
taskId: "",
|
|
641
|
+
workspaceId: destinationWorkspaceId,
|
|
642
|
+
},
|
|
643
|
+
consumption: null,
|
|
724
644
|
},
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
})),
|
|
645
|
+
};
|
|
646
|
+
}),
|
|
728
647
|
...(actionProgress ? { actionProgress } : {}),
|
|
729
648
|
findings,
|
|
730
649
|
workers,
|
|
@@ -829,7 +748,7 @@ export class WorkitCore {
|
|
|
829
748
|
) {
|
|
830
749
|
if (workspace.writer)
|
|
831
750
|
return failure("recovery_required", "writer ownership must be released first");
|
|
832
|
-
if (task.workers.some((entry) =>
|
|
751
|
+
if (task.workers.some((entry) => workerBlocksTransition("close", entry.data.state)))
|
|
833
752
|
return failure("recovery_required", "worker state requires reconciliation");
|
|
834
753
|
return null;
|
|
835
754
|
}
|
|
@@ -1080,30 +999,23 @@ export class WorkitCore {
|
|
|
1080
999
|
environment(),
|
|
1081
1000
|
);
|
|
1082
1001
|
if (!currentCandidate.ok) return currentCandidate as Result<never>;
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
evidence.candidateId ??= currentCandidate.data.id;
|
|
1089
|
-
} else {
|
|
1090
|
-
evidence.beforeCandidateId ??= null;
|
|
1091
|
-
evidence.candidateId ??= null;
|
|
1092
|
-
}
|
|
1002
|
+
// Resolve the binding first (explicit IDs, else the worker pin, else the
|
|
1003
|
+
// current candidate), then validate and pin-check the resolved values —
|
|
1004
|
+
// never the caller's possibly-empty fields. The input itself is untouched.
|
|
1005
|
+
const pin = helper && helper.ok ? helper.data.data.assignment.candidateId : null;
|
|
1006
|
+
const bound = resolveBinding(evidence, pin, currentCandidate.data.id);
|
|
1093
1007
|
if (helper?.ok) {
|
|
1094
1008
|
const assignment = helper.data.data.assignment;
|
|
1095
1009
|
if (
|
|
1096
1010
|
evidence.requirementIds.some((id: string) => !assignment.requirementIds.includes(id)) ||
|
|
1097
|
-
(assignment.candidateId
|
|
1098
|
-
evidence.candidateId !== assignment.candidateId &&
|
|
1099
|
-
evidence.beforeCandidateId !== assignment.candidateId) ||
|
|
1011
|
+
!checkPin(assignment, bound.beforeCandidateId, bound.candidateId) ||
|
|
1100
1012
|
!refsWithinScope(evidence.refs, assignment.scope) ||
|
|
1101
1013
|
!refsWithinScope(evidence.refs, task.data.intent.data.scope)
|
|
1102
1014
|
)
|
|
1103
1015
|
return failure("permission_denied", "evidence is outside the worker assignment");
|
|
1104
1016
|
}
|
|
1105
1017
|
const knownCandidates = new Set(task.data.candidates.map((candidate) => candidate.id));
|
|
1106
|
-
for (const candidateId of [
|
|
1018
|
+
for (const candidateId of [bound.beforeCandidateId, bound.candidateId]) {
|
|
1107
1019
|
if (
|
|
1108
1020
|
candidateId &&
|
|
1109
1021
|
candidateId !== currentCandidate.data.id &&
|
|
@@ -1111,6 +1023,7 @@ export class WorkitCore {
|
|
|
1111
1023
|
)
|
|
1112
1024
|
return failure("invalid_input", "evidence candidate is not a captured candidate");
|
|
1113
1025
|
}
|
|
1026
|
+
const boundEvidence = { ...evidence, ...bound };
|
|
1114
1027
|
const changed = this.store.mutateTask(
|
|
1115
1028
|
task.data.id,
|
|
1116
1029
|
input.expectedRevision,
|
|
@@ -1122,14 +1035,14 @@ export class WorkitCore {
|
|
|
1122
1035
|
id: newId(),
|
|
1123
1036
|
recordedAt: mutation.now,
|
|
1124
1037
|
provenance: provenance(this.context, "agent_reported"),
|
|
1125
|
-
data:
|
|
1038
|
+
data: boundEvidence,
|
|
1126
1039
|
};
|
|
1127
1040
|
return success(mutation.revision, null, {
|
|
1128
1041
|
...current,
|
|
1129
1042
|
candidates,
|
|
1130
1043
|
evidence: [...current.evidence, entry],
|
|
1131
1044
|
findings: current.findings.map((finding) => {
|
|
1132
|
-
if (finding.data.disposition === "open" ||
|
|
1045
|
+
if (finding.data.disposition === "open" || boundEvidence.result === "skipped")
|
|
1133
1046
|
return finding;
|
|
1134
1047
|
if (finding.data.disposition !== "fixed") return finding;
|
|
1135
1048
|
// A fix stands while its verification still passes on the current
|
|
@@ -1142,17 +1055,17 @@ export class WorkitCore {
|
|
|
1142
1055
|
item.status,
|
|
1143
1056
|
]),
|
|
1144
1057
|
);
|
|
1145
|
-
const verified = withEntry.evidence.some(
|
|
1146
|
-
(
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
(
|
|
1150
|
-
|
|
1058
|
+
const verified = withEntry.evidence.some((item) =>
|
|
1059
|
+
findingVerificationPasses(finding.data.candidateId, {
|
|
1060
|
+
kind: item.data.kind,
|
|
1061
|
+
candidateId: item.data.candidateId,
|
|
1062
|
+
status: statuses.get(item.id) ?? "missing",
|
|
1063
|
+
}),
|
|
1151
1064
|
);
|
|
1152
1065
|
const contradicted =
|
|
1153
|
-
|
|
1066
|
+
boundEvidence.result === "failed" &&
|
|
1154
1067
|
finding.data.refs.some((left: Ref) =>
|
|
1155
|
-
|
|
1068
|
+
boundEvidence.refs.some((right: Ref) => sameValue(left, right)),
|
|
1156
1069
|
);
|
|
1157
1070
|
return verified && !contradicted
|
|
1158
1071
|
? finding
|
|
@@ -1346,7 +1259,7 @@ export class WorkitCore {
|
|
|
1346
1259
|
if (
|
|
1347
1260
|
!bindingCovers(assignment.scope, input.scope) ||
|
|
1348
1261
|
!bindingCovers(task.data.intent.data.scope, input.scope) ||
|
|
1349
|
-
(assignment
|
|
1262
|
+
!checkPin(assignment, null, input.candidateId ?? null) ||
|
|
1350
1263
|
!refsWithinScope(input.refs, assignment.scope) ||
|
|
1351
1264
|
!refsWithinScope(input.refs, task.data.intent.data.scope)
|
|
1352
1265
|
)
|
|
@@ -1403,11 +1316,11 @@ export class WorkitCore {
|
|
|
1403
1316
|
const evaluations = evaluateEvidence(task.data, current.data);
|
|
1404
1317
|
const verified = evidence.some((entry) => {
|
|
1405
1318
|
const evaluation = evaluations.find((item) => item.evidenceId === entry.id);
|
|
1406
|
-
return (
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
);
|
|
1319
|
+
return findingVerificationPasses(finding.data.candidateId, {
|
|
1320
|
+
kind: entry.data.kind,
|
|
1321
|
+
candidateId: entry.data.candidateId,
|
|
1322
|
+
status: evaluation?.status ?? "missing",
|
|
1323
|
+
});
|
|
1411
1324
|
});
|
|
1412
1325
|
if (!verified)
|
|
1413
1326
|
return failure("permission_denied", "fixed findings require passing verification evidence");
|
|
@@ -1423,7 +1336,7 @@ export class WorkitCore {
|
|
|
1423
1336
|
: entry.data.candidateId === finding.data.candidateId ||
|
|
1424
1337
|
entry.data.beforeCandidateId === finding.data.candidateId;
|
|
1425
1338
|
const referenceMatches = finding.data.refs.some((left) =>
|
|
1426
|
-
entry.data.refs.some((right) =>
|
|
1339
|
+
entry.data.refs.some((right) => sameValue(left, right)),
|
|
1427
1340
|
);
|
|
1428
1341
|
return candidateMatches && (referenceMatches || entry.data.claim === finding.data.claim);
|
|
1429
1342
|
});
|
|
@@ -1546,7 +1459,7 @@ export class WorkitCore {
|
|
|
1546
1459
|
const workerId = newId();
|
|
1547
1460
|
const changed = this.store.mutateTaskAndWorkspace({
|
|
1548
1461
|
taskId: task.data.id,
|
|
1549
|
-
|
|
1462
|
+
expectedRevision: input.expectedRevision,
|
|
1550
1463
|
expectedWorkspaceRevision: input.expectedWorkspaceRevision,
|
|
1551
1464
|
now: trustedNow(this.context),
|
|
1552
1465
|
workspace: (current, mutation) => success(mutation.revision, mutation.revision, current),
|
|
@@ -1606,7 +1519,7 @@ export class WorkitCore {
|
|
|
1606
1519
|
return failure("permission_denied", "worker report is outside the worker assignment");
|
|
1607
1520
|
const changed = this.store.mutateTaskAndWorkspace({
|
|
1608
1521
|
taskId: task.data.id,
|
|
1609
|
-
|
|
1522
|
+
expectedRevision: input.expectedRevision,
|
|
1610
1523
|
expectedWorkspaceRevision: input.expectedWorkspaceRevision,
|
|
1611
1524
|
now: trustedNow(this.context),
|
|
1612
1525
|
workspace: (current, mutation) => success(mutation.revision, mutation.revision, current),
|
|
@@ -1639,20 +1552,28 @@ export class WorkitCore {
|
|
|
1639
1552
|
return failure("invalid_transition", "closed task cannot cancel workers");
|
|
1640
1553
|
if (entry.data.state === "unknown")
|
|
1641
1554
|
return failure("recovery_required", "worker state requires recovery");
|
|
1642
|
-
if (entry.data.state === "stopped")
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
//
|
|
1649
|
-
|
|
1555
|
+
if (entry.data.state === "stopped") {
|
|
1556
|
+
// Idempotent cancel: repeating a settled stop succeeds with the
|
|
1557
|
+
// current entry instead of failing the retry.
|
|
1558
|
+
const stopped = task.data.workers.find((candidate) => candidate.id === input.workerId)!;
|
|
1559
|
+
return success(task.data.revision, workspace.data.revision, stopped);
|
|
1560
|
+
}
|
|
1561
|
+
// Lead-attested cancel is terminal for every live state: the lead saw
|
|
1562
|
+
// the run end, so no host observation is awaited. A later live-child
|
|
1563
|
+
// sighting against a stopped worker is a new anomaly for the host to
|
|
1564
|
+
// flag, not a reason to strand the worker short of stopped. A writer
|
|
1565
|
+
// held by the cancelled worker dies with it; anything else would brick
|
|
1566
|
+
// the checkout behind a stopped owner.
|
|
1650
1567
|
const changed = this.store.mutateTaskAndWorkspace({
|
|
1651
1568
|
taskId: task.data.id,
|
|
1652
|
-
|
|
1569
|
+
expectedRevision: input.expectedRevision,
|
|
1653
1570
|
expectedWorkspaceRevision: input.expectedWorkspaceRevision,
|
|
1654
1571
|
now: trustedNow(this.context),
|
|
1655
|
-
workspace: (current, mutation) =>
|
|
1572
|
+
workspace: (current, mutation) =>
|
|
1573
|
+
success(mutation.revision, mutation.revision, {
|
|
1574
|
+
...current,
|
|
1575
|
+
writer: current.writer?.owner.workerId === input.workerId ? null : current.writer,
|
|
1576
|
+
}),
|
|
1656
1577
|
task: (current, mutation) =>
|
|
1657
1578
|
success(mutation.revision, null, {
|
|
1658
1579
|
...current,
|
|
@@ -1661,10 +1582,7 @@ export class WorkitCore {
|
|
|
1661
1582
|
? {
|
|
1662
1583
|
...candidate,
|
|
1663
1584
|
recordedAt: mutation.now,
|
|
1664
|
-
data: {
|
|
1665
|
-
...candidate.data,
|
|
1666
|
-
state: settles ? ("stopped" as const) : ("cancelling" as const),
|
|
1667
|
-
},
|
|
1585
|
+
data: { ...candidate.data, state: "stopped" as const },
|
|
1668
1586
|
}
|
|
1669
1587
|
: candidate,
|
|
1670
1588
|
),
|
|
@@ -1776,7 +1694,7 @@ export class WorkitCore {
|
|
|
1776
1694
|
if (!attested.ok) return attested as Result<never>;
|
|
1777
1695
|
const changed = this.store.mutateTaskAndWorkspace({
|
|
1778
1696
|
taskId: task.data.id,
|
|
1779
|
-
|
|
1697
|
+
expectedRevision: input.expectedRevision,
|
|
1780
1698
|
expectedWorkspaceRevision: input.expectedWorkspaceRevision,
|
|
1781
1699
|
now: trustedNow(this.context),
|
|
1782
1700
|
workspace: (current, mutation) => success(mutation.revision, mutation.revision, current),
|
|
@@ -1958,6 +1876,26 @@ export class WorkitCore {
|
|
|
1958
1876
|
if (!sameSession(helper.data.data.session, this.context))
|
|
1959
1877
|
return failure("permission_denied", "worker session does not match the caller");
|
|
1960
1878
|
}
|
|
1879
|
+
// Write-timed requirements gate acquisition, not just close: a spec
|
|
1880
|
+
// gate is enforced where writes begin, never as advisory prose.
|
|
1881
|
+
const gate = this.view(task.data);
|
|
1882
|
+
if (gate.ok) {
|
|
1883
|
+
const writeGates = (task.data.policy?.requirements ?? []).filter(
|
|
1884
|
+
(requirement) =>
|
|
1885
|
+
requirement.before === "write" &&
|
|
1886
|
+
gate.data.requirements.some(
|
|
1887
|
+
(evaluation) =>
|
|
1888
|
+
evaluation.requirementId === requirement.id &&
|
|
1889
|
+
(evaluation.status === "unsatisfied" || evaluation.status === "unavailable"),
|
|
1890
|
+
),
|
|
1891
|
+
);
|
|
1892
|
+
if (writeGates.length)
|
|
1893
|
+
return failure(
|
|
1894
|
+
"requirements_unsatisfied",
|
|
1895
|
+
"writer ownership is gated by unsatisfied requirements; record their evidence or an approved limitation waiver first",
|
|
1896
|
+
{ requirementIds: writeGates.map((requirement) => requirement.id) },
|
|
1897
|
+
);
|
|
1898
|
+
}
|
|
1961
1899
|
const owner = {
|
|
1962
1900
|
taskId: task.data.id,
|
|
1963
1901
|
workerId: helperId,
|
|
@@ -1969,7 +1907,7 @@ export class WorkitCore {
|
|
|
1969
1907
|
};
|
|
1970
1908
|
const changed = this.store.mutateTaskAndWorkspace({
|
|
1971
1909
|
taskId: task.data.id,
|
|
1972
|
-
|
|
1910
|
+
expectedRevision: input.expectedRevision,
|
|
1973
1911
|
expectedWorkspaceRevision: input.expectedWorkspaceRevision,
|
|
1974
1912
|
now: trustedNow(this.context),
|
|
1975
1913
|
workspace: (current, mutation) =>
|
|
@@ -2006,7 +1944,7 @@ export class WorkitCore {
|
|
|
2006
1944
|
}
|
|
2007
1945
|
const changed = this.store.mutateTaskAndWorkspace({
|
|
2008
1946
|
taskId: task.data.id,
|
|
2009
|
-
|
|
1947
|
+
expectedRevision: input.expectedRevision,
|
|
2010
1948
|
expectedWorkspaceRevision: input.expectedWorkspaceRevision,
|
|
2011
1949
|
now: trustedNow(this.context),
|
|
2012
1950
|
workspace: (currentWorkspace, mutation) =>
|
|
@@ -2317,10 +2255,18 @@ export class WorkitCore {
|
|
|
2317
2255
|
if (seen.has(observation.workerId))
|
|
2318
2256
|
return failure("invalid_input", "worker observations must be unique");
|
|
2319
2257
|
seen.add(observation.workerId);
|
|
2258
|
+
// Anchor on fresh reads, not the passed view: a view captured before
|
|
2259
|
+
// recent mutations must not stale-fail observations that match the
|
|
2260
|
+
// current state. Genuinely stale observations still conflict below.
|
|
2261
|
+
const freshTask = this.store.readTask(view.task.id);
|
|
2262
|
+
const freshWorkspace = this.store.readWorkspace();
|
|
2263
|
+
if (!freshTask.ok) return freshTask as Result<never>;
|
|
2264
|
+
if (!freshWorkspace.ok) return freshWorkspace as Result<never>;
|
|
2265
|
+
if (!freshWorkspace.data) return failure("not_found", "workspace not found");
|
|
2320
2266
|
if (
|
|
2321
2267
|
observation.taskId !== view.task.id ||
|
|
2322
|
-
observation.expectedRevision !==
|
|
2323
|
-
observation.expectedWorkspaceRevision !==
|
|
2268
|
+
observation.expectedRevision !== freshTask.data.revision ||
|
|
2269
|
+
observation.expectedWorkspaceRevision !== freshWorkspace.data.revision
|
|
2324
2270
|
)
|
|
2325
2271
|
return failure("revision_conflict", "worker observation is stale");
|
|
2326
2272
|
if (!view.task.workers.some((entry) => entry.id === observation.workerId))
|
|
@@ -2433,11 +2379,27 @@ export class WorkitCore {
|
|
|
2433
2379
|
return failure("permission_denied", "imported resume requires native destination approval");
|
|
2434
2380
|
resumeCandidate = current.data.candidate;
|
|
2435
2381
|
}
|
|
2436
|
-
const blocker =
|
|
2382
|
+
const blocker =
|
|
2383
|
+
status === "paused"
|
|
2384
|
+
? workspace.data.writer
|
|
2385
|
+
? failure("recovery_required", "writer ownership must be released first")
|
|
2386
|
+
: null
|
|
2387
|
+
: this.activeWorkerBlocker(task.data, workspace.data);
|
|
2437
2388
|
if (blocker) return blocker as Result<never>;
|
|
2389
|
+
let pauseCandidate: import("./task-contract").Candidate | null = null;
|
|
2390
|
+
if (status === "paused") {
|
|
2391
|
+
// Freezing records the tree state the task resumes from.
|
|
2392
|
+
const captured = captureCandidate(
|
|
2393
|
+
this.store.root,
|
|
2394
|
+
task.data.intent.data.scope,
|
|
2395
|
+
environment(),
|
|
2396
|
+
);
|
|
2397
|
+
if (!captured.ok) return captured as Result<never>;
|
|
2398
|
+
pauseCandidate = captured.data;
|
|
2399
|
+
}
|
|
2438
2400
|
const changed = this.store.mutateTaskAndWorkspace({
|
|
2439
2401
|
taskId: task.data.id,
|
|
2440
|
-
|
|
2402
|
+
expectedRevision: input.expectedRevision,
|
|
2441
2403
|
expectedWorkspaceRevision: input.expectedWorkspaceRevision,
|
|
2442
2404
|
now: trustedNow(this.context),
|
|
2443
2405
|
workspace: (workspace, context) => success(context.revision, context.revision, workspace),
|
|
@@ -2446,9 +2408,11 @@ export class WorkitCore {
|
|
|
2446
2408
|
...current,
|
|
2447
2409
|
status,
|
|
2448
2410
|
candidates:
|
|
2449
|
-
resumeCandidate &&
|
|
2450
|
-
!current.candidates.some(
|
|
2451
|
-
|
|
2411
|
+
(resumeCandidate ?? pauseCandidate) &&
|
|
2412
|
+
!current.candidates.some(
|
|
2413
|
+
(candidate) => candidate.id === (resumeCandidate ?? pauseCandidate)!.id,
|
|
2414
|
+
)
|
|
2415
|
+
? [...current.candidates, (resumeCandidate ?? pauseCandidate)!]
|
|
2452
2416
|
: current.candidates,
|
|
2453
2417
|
progress: current.progress,
|
|
2454
2418
|
pauseReason: status === "paused" ? (input.reason ?? null) : null,
|
|
@@ -2487,18 +2451,12 @@ export class WorkitCore {
|
|
|
2487
2451
|
const workspace = this.store.readWorkspace();
|
|
2488
2452
|
if (!workspace.ok) return workspace as Result<never>;
|
|
2489
2453
|
if (!workspace.data) return failure("not_found", "workspace not found");
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
task.data.workers.some((entry) => isUncertainWorker(entry.data.state))
|
|
2493
|
-
)
|
|
2494
|
-
return failure(
|
|
2495
|
-
"recovery_required",
|
|
2496
|
-
"scope revision requires worker ownership reconciliation",
|
|
2497
|
-
);
|
|
2454
|
+
const revisionBlocker = this.activeWorkerBlocker(task.data, workspace.data);
|
|
2455
|
+
if (revisionBlocker) return revisionBlocker as Result<never>;
|
|
2498
2456
|
this.fillRevisions(input, task.data, workspace.data);
|
|
2499
2457
|
const changed = this.store.mutateTaskAndWorkspace({
|
|
2500
2458
|
taskId: task.data.id,
|
|
2501
|
-
|
|
2459
|
+
expectedRevision: input.expectedRevision,
|
|
2502
2460
|
expectedWorkspaceRevision: input.expectedWorkspaceRevision,
|
|
2503
2461
|
now: trustedNow(this.context),
|
|
2504
2462
|
workspace: (workspace, context) => success(context.revision, context.revision, workspace),
|
|
@@ -2558,7 +2516,7 @@ export class WorkitCore {
|
|
|
2558
2516
|
if (!closure.ok) return closure as Result<never>;
|
|
2559
2517
|
const changed = this.store.mutateTaskAndWorkspace({
|
|
2560
2518
|
taskId: task.data.id,
|
|
2561
|
-
|
|
2519
|
+
expectedRevision: input.expectedRevision,
|
|
2562
2520
|
expectedWorkspaceRevision: input.expectedWorkspaceRevision,
|
|
2563
2521
|
now: trustedNow(this.context),
|
|
2564
2522
|
workspace: (value, context) => success(context.revision, context.revision, value),
|