@brainervirus/workit-core 1.0.11 → 1.1.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/src/core/authority.ts +84 -11
- package/src/core/auto-approval.ts +326 -0
- package/src/core/branch.ts +83 -2
- package/src/core/doctor.ts +25 -12
- package/src/core/external-action-effects.ts +179 -17
- package/src/core/external-action.ts +204 -16
- package/src/core/methods.ts +8 -8
- package/src/core/pr-create.ts +50 -0
- package/src/core/route-intent.ts +7 -1
- package/src/core/runtime-identity.ts +33 -0
- package/src/core/task-contract.ts +121 -0
- package/src/core/task-engine.ts +209 -223
- 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/workspaces.ts +7 -1
- package/src/core.ts +29 -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,
|
|
@@ -50,6 +56,7 @@ import {
|
|
|
50
56
|
type ReconcileActionInput,
|
|
51
57
|
} from "./authority";
|
|
52
58
|
import { diffPolicy, resolvePolicy } from "./policy-resolver";
|
|
59
|
+
import { verifyStandingApproval } from "./auto-approval";
|
|
53
60
|
import { TaskStore, type MetadataLock, type ProcessEvidence } from "./task-store";
|
|
54
61
|
import {
|
|
55
62
|
exportDigest,
|
|
@@ -59,6 +66,7 @@ import {
|
|
|
59
66
|
import {
|
|
60
67
|
assertProductWriteAllowed,
|
|
61
68
|
isUncertainWorker,
|
|
69
|
+
workerBlocksTransition,
|
|
62
70
|
type CallerContext,
|
|
63
71
|
type NativeWorkerObservation,
|
|
64
72
|
type NativeWorkerVerification,
|
|
@@ -70,9 +78,12 @@ import {
|
|
|
70
78
|
} from "./workers";
|
|
71
79
|
import {
|
|
72
80
|
captureCandidate,
|
|
81
|
+
checkPin,
|
|
73
82
|
evaluateClosure,
|
|
74
83
|
evaluateEvidence,
|
|
75
84
|
evaluateRequirements,
|
|
85
|
+
findingVerificationPasses,
|
|
86
|
+
resolveBinding,
|
|
76
87
|
type CandidateEnvironment,
|
|
77
88
|
} from "./task-evaluation";
|
|
78
89
|
|
|
@@ -342,6 +353,8 @@ const applyWorkerLifecycle = (input: ObserveWorkerLifecycleInput): Result<Entry<
|
|
|
342
353
|
if (task.data.status === "paused" && (input.state === "running" || input.state === "cancelling"))
|
|
343
354
|
return failure("invalid_transition", "paused task cannot run a worker");
|
|
344
355
|
const notStarted = input.dispatch === true;
|
|
356
|
+
if (notStarted && entry.data.state === "stopped" && input.session === null)
|
|
357
|
+
return success(task.data.revision, workspace.data.revision, entry);
|
|
345
358
|
if (notStarted && (input.state !== "stopped" || input.session !== null))
|
|
346
359
|
return failure("invalid_input", "a never-dispatched worker stops with no session");
|
|
347
360
|
if (notStarted && entry.data.session !== null)
|
|
@@ -395,7 +408,7 @@ const applyWorkerLifecycle = (input: ObserveWorkerLifecycleInput): Result<Entry<
|
|
|
395
408
|
return success(task.data.revision, workspace.data.revision, entry);
|
|
396
409
|
const changed = input.store.mutateTaskAndWorkspace({
|
|
397
410
|
taskId: task.data.id,
|
|
398
|
-
|
|
411
|
+
expectedRevision: input.expectedRevision,
|
|
399
412
|
expectedWorkspaceRevision: input.expectedWorkspaceRevision,
|
|
400
413
|
now: input.now,
|
|
401
414
|
workspace: (current, mutation) =>
|
|
@@ -469,96 +482,28 @@ const importedProvenance = (context: OperationContext): Provenance => ({
|
|
|
469
482
|
});
|
|
470
483
|
|
|
471
484
|
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
|
-
})),
|
|
485
|
+
// Schema-driven ref stripping: every Ref anywhere in the record is mapped
|
|
486
|
+
// by shape (arrays drop unportable refs, nullable fields null them), so a
|
|
487
|
+
// new ref-bearing field can never silently leak across checkouts.
|
|
488
|
+
const stripped = rewriteRecordRefs(task, taskRecordSchema, portableRef);
|
|
489
|
+
if (typeof stripped !== "object" || stripped === null || Array.isArray(stripped))
|
|
490
|
+
throw new TypeError("portable task rewrite produced a non-record");
|
|
491
|
+
const clone = structuredClone(stripped) as TaskRecord;
|
|
492
|
+
const demote = (
|
|
493
|
+
facts: Assessment["facts"][number][],
|
|
494
|
+
signals: Assessment["signals"],
|
|
495
|
+
consequences: Assessment["consequences"],
|
|
496
|
+
): void => {
|
|
497
|
+
for (const fact of facts) Object.assign(fact, portableFact(fact));
|
|
498
|
+
for (const name of Object.keys(signals) as (keyof Assessment["signals"])[]) {
|
|
499
|
+
const signal = signals[name];
|
|
500
|
+
Object.assign(signal, portableSignal(signal));
|
|
501
|
+
}
|
|
502
|
+
for (const consequence of consequences)
|
|
503
|
+
Object.assign(consequence.fact, portableFact(consequence.fact));
|
|
531
504
|
};
|
|
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
|
-
}));
|
|
505
|
+
for (const entry of clone.assessments)
|
|
506
|
+
demote(entry.data.facts, entry.data.signals, entry.data.consequences);
|
|
562
507
|
// Candidate metadata can contain environment-derived paths and digests. The destination
|
|
563
508
|
// must recapture its own candidate instead of receiving source checkout material.
|
|
564
509
|
clone.candidates = [];
|
|
@@ -591,15 +536,13 @@ const importedTask = (
|
|
|
591
536
|
allocate(entry.id);
|
|
592
537
|
const fresh = (id: string) => allocate(id);
|
|
593
538
|
const provenance = importedProvenance(context);
|
|
539
|
+
const remap = (ref: Ref): Ref | null => mapRef(ref, ids);
|
|
594
540
|
const intent = {
|
|
595
541
|
...source.intent,
|
|
596
542
|
id: fresh(source.intent.id),
|
|
597
543
|
recordedAt: timestamp,
|
|
598
544
|
provenance,
|
|
599
|
-
data:
|
|
600
|
-
...source.intent.data,
|
|
601
|
-
authorityRefs: source.intent.data.authorityRefs.map((ref) => mapRef(ref, ids)),
|
|
602
|
-
},
|
|
545
|
+
data: rewriteRecordRefs(source.intent.data, intentSchema, remap) as typeof source.intent.data,
|
|
603
546
|
};
|
|
604
547
|
const assessments = source.assessments.map((entry) => {
|
|
605
548
|
const data = entry.data;
|
|
@@ -608,30 +551,7 @@ const importedTask = (
|
|
|
608
551
|
id: fresh(entry.id),
|
|
609
552
|
recordedAt: timestamp,
|
|
610
553
|
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
|
-
},
|
|
554
|
+
data: rewriteRecordRefs(data, assessmentSchema, remap) as typeof data,
|
|
635
555
|
};
|
|
636
556
|
});
|
|
637
557
|
const evidence = source.evidence.map((entry) => ({
|
|
@@ -639,11 +559,7 @@ const importedTask = (
|
|
|
639
559
|
id: fresh(entry.id),
|
|
640
560
|
recordedAt: timestamp,
|
|
641
561
|
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
|
-
},
|
|
562
|
+
data: rewriteRecordRefs(entry.data, evidenceSchema, remap) as typeof entry.data,
|
|
647
563
|
}));
|
|
648
564
|
const decisions = source.decisions.map((entry) => ({
|
|
649
565
|
...entry,
|
|
@@ -651,23 +567,25 @@ const importedTask = (
|
|
|
651
567
|
recordedAt: timestamp,
|
|
652
568
|
provenance,
|
|
653
569
|
}));
|
|
654
|
-
const findings = source.findings.map((entry) =>
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
570
|
+
const findings = source.findings.map((entry) => {
|
|
571
|
+
const data = rewriteRecordRefs(entry.data, findingSchema, remap) as typeof entry.data;
|
|
572
|
+
return {
|
|
573
|
+
...entry,
|
|
574
|
+
id: fresh(entry.id),
|
|
575
|
+
recordedAt: timestamp,
|
|
576
|
+
provenance,
|
|
577
|
+
data: {
|
|
578
|
+
...data,
|
|
579
|
+
resolution: data.resolution
|
|
580
|
+
? {
|
|
581
|
+
...data.resolution,
|
|
582
|
+
evidenceIds: data.resolution.evidenceIds.map((id) => ids.get(id) ?? id),
|
|
583
|
+
decisionIds: data.resolution.decisionIds.map((id) => ids.get(id) ?? id),
|
|
584
|
+
}
|
|
585
|
+
: null,
|
|
586
|
+
},
|
|
587
|
+
};
|
|
588
|
+
});
|
|
671
589
|
const workers = source.workers.map((entry) => ({
|
|
672
590
|
...entry,
|
|
673
591
|
id: fresh(entry.id),
|
|
@@ -712,19 +630,21 @@ const importedTask = (
|
|
|
712
630
|
closure: null,
|
|
713
631
|
assessments,
|
|
714
632
|
evidence,
|
|
715
|
-
decisions: decisions.map((entry) =>
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
...entry
|
|
719
|
-
|
|
720
|
-
...
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
633
|
+
decisions: decisions.map((entry) => {
|
|
634
|
+
const data = rewriteRecordRefs(entry.data, decisionSchema, remap) as typeof entry.data;
|
|
635
|
+
return {
|
|
636
|
+
...entry,
|
|
637
|
+
data: {
|
|
638
|
+
...data,
|
|
639
|
+
binding: {
|
|
640
|
+
...data.binding,
|
|
641
|
+
taskId: "",
|
|
642
|
+
workspaceId: destinationWorkspaceId,
|
|
643
|
+
},
|
|
644
|
+
consumption: null,
|
|
724
645
|
},
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
})),
|
|
646
|
+
};
|
|
647
|
+
}),
|
|
728
648
|
...(actionProgress ? { actionProgress } : {}),
|
|
729
649
|
findings,
|
|
730
650
|
workers,
|
|
@@ -829,7 +749,7 @@ export class WorkitCore {
|
|
|
829
749
|
) {
|
|
830
750
|
if (workspace.writer)
|
|
831
751
|
return failure("recovery_required", "writer ownership must be released first");
|
|
832
|
-
if (task.workers.some((entry) =>
|
|
752
|
+
if (task.workers.some((entry) => workerBlocksTransition("close", entry.data.state)))
|
|
833
753
|
return failure("recovery_required", "worker state requires reconciliation");
|
|
834
754
|
return null;
|
|
835
755
|
}
|
|
@@ -1080,30 +1000,23 @@ export class WorkitCore {
|
|
|
1080
1000
|
environment(),
|
|
1081
1001
|
);
|
|
1082
1002
|
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
|
-
}
|
|
1003
|
+
// Resolve the binding first (explicit IDs, else the worker pin, else the
|
|
1004
|
+
// current candidate), then validate and pin-check the resolved values —
|
|
1005
|
+
// never the caller's possibly-empty fields. The input itself is untouched.
|
|
1006
|
+
const pin = helper && helper.ok ? helper.data.data.assignment.candidateId : null;
|
|
1007
|
+
const bound = resolveBinding(evidence, pin, currentCandidate.data.id);
|
|
1093
1008
|
if (helper?.ok) {
|
|
1094
1009
|
const assignment = helper.data.data.assignment;
|
|
1095
1010
|
if (
|
|
1096
1011
|
evidence.requirementIds.some((id: string) => !assignment.requirementIds.includes(id)) ||
|
|
1097
|
-
(assignment.candidateId
|
|
1098
|
-
evidence.candidateId !== assignment.candidateId &&
|
|
1099
|
-
evidence.beforeCandidateId !== assignment.candidateId) ||
|
|
1012
|
+
!checkPin(assignment, bound.beforeCandidateId, bound.candidateId) ||
|
|
1100
1013
|
!refsWithinScope(evidence.refs, assignment.scope) ||
|
|
1101
1014
|
!refsWithinScope(evidence.refs, task.data.intent.data.scope)
|
|
1102
1015
|
)
|
|
1103
1016
|
return failure("permission_denied", "evidence is outside the worker assignment");
|
|
1104
1017
|
}
|
|
1105
1018
|
const knownCandidates = new Set(task.data.candidates.map((candidate) => candidate.id));
|
|
1106
|
-
for (const candidateId of [
|
|
1019
|
+
for (const candidateId of [bound.beforeCandidateId, bound.candidateId]) {
|
|
1107
1020
|
if (
|
|
1108
1021
|
candidateId &&
|
|
1109
1022
|
candidateId !== currentCandidate.data.id &&
|
|
@@ -1111,6 +1024,7 @@ export class WorkitCore {
|
|
|
1111
1024
|
)
|
|
1112
1025
|
return failure("invalid_input", "evidence candidate is not a captured candidate");
|
|
1113
1026
|
}
|
|
1027
|
+
const boundEvidence = { ...evidence, ...bound };
|
|
1114
1028
|
const changed = this.store.mutateTask(
|
|
1115
1029
|
task.data.id,
|
|
1116
1030
|
input.expectedRevision,
|
|
@@ -1122,14 +1036,14 @@ export class WorkitCore {
|
|
|
1122
1036
|
id: newId(),
|
|
1123
1037
|
recordedAt: mutation.now,
|
|
1124
1038
|
provenance: provenance(this.context, "agent_reported"),
|
|
1125
|
-
data:
|
|
1039
|
+
data: boundEvidence,
|
|
1126
1040
|
};
|
|
1127
1041
|
return success(mutation.revision, null, {
|
|
1128
1042
|
...current,
|
|
1129
1043
|
candidates,
|
|
1130
1044
|
evidence: [...current.evidence, entry],
|
|
1131
1045
|
findings: current.findings.map((finding) => {
|
|
1132
|
-
if (finding.data.disposition === "open" ||
|
|
1046
|
+
if (finding.data.disposition === "open" || boundEvidence.result === "skipped")
|
|
1133
1047
|
return finding;
|
|
1134
1048
|
if (finding.data.disposition !== "fixed") return finding;
|
|
1135
1049
|
// A fix stands while its verification still passes on the current
|
|
@@ -1142,17 +1056,17 @@ export class WorkitCore {
|
|
|
1142
1056
|
item.status,
|
|
1143
1057
|
]),
|
|
1144
1058
|
);
|
|
1145
|
-
const verified = withEntry.evidence.some(
|
|
1146
|
-
(
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
(
|
|
1150
|
-
|
|
1059
|
+
const verified = withEntry.evidence.some((item) =>
|
|
1060
|
+
findingVerificationPasses(finding.data.candidateId, {
|
|
1061
|
+
kind: item.data.kind,
|
|
1062
|
+
candidateId: item.data.candidateId,
|
|
1063
|
+
status: statuses.get(item.id) ?? "missing",
|
|
1064
|
+
}),
|
|
1151
1065
|
);
|
|
1152
1066
|
const contradicted =
|
|
1153
|
-
|
|
1067
|
+
boundEvidence.result === "failed" &&
|
|
1154
1068
|
finding.data.refs.some((left: Ref) =>
|
|
1155
|
-
|
|
1069
|
+
boundEvidence.refs.some((right: Ref) => sameValue(left, right)),
|
|
1156
1070
|
);
|
|
1157
1071
|
return verified && !contradicted
|
|
1158
1072
|
? finding
|
|
@@ -1174,10 +1088,21 @@ export class WorkitCore {
|
|
|
1174
1088
|
return this.recordDecision(request, observation, true);
|
|
1175
1089
|
}
|
|
1176
1090
|
|
|
1091
|
+
/**
|
|
1092
|
+
* Record a standing auto-approval: no live question, authorized by the
|
|
1093
|
+
* workspace rule named in binding.standing while it covers the operation.
|
|
1094
|
+
* Lead-only, task-scoped, verified live here — revocation is config
|
|
1095
|
+
* removal, which fails the next reserve closed.
|
|
1096
|
+
*/
|
|
1097
|
+
observeStandingDecision(request: unknown): Result<Entry<Decision>> {
|
|
1098
|
+
return this.recordDecision(request, undefined, true, true);
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1177
1101
|
private recordDecision(
|
|
1178
1102
|
request: unknown,
|
|
1179
1103
|
nativeObservation?: unknown,
|
|
1180
1104
|
nativeRequired = false,
|
|
1105
|
+
standing = false,
|
|
1181
1106
|
): Result<Entry<Decision>> {
|
|
1182
1107
|
const root = this.contextRootError();
|
|
1183
1108
|
if (!root.ok) return root as Result<never>;
|
|
@@ -1197,8 +1122,16 @@ export class WorkitCore {
|
|
|
1197
1122
|
return failure("permission_denied", "stated choices cannot authorize mutating actions");
|
|
1198
1123
|
if (stated && !input.binding?.statedChoice)
|
|
1199
1124
|
return failure("invalid_input", "stated choices require binding.statedChoice");
|
|
1200
|
-
if (nativeRequired && nativeObservation === undefined && !stated)
|
|
1125
|
+
if (nativeRequired && nativeObservation === undefined && !stated && !standing)
|
|
1201
1126
|
return failure("permission_denied", "native decision observation is required");
|
|
1127
|
+
if (
|
|
1128
|
+
standing &&
|
|
1129
|
+
(!input.binding?.standing || input.purpose !== "action" || input.response !== "approved")
|
|
1130
|
+
)
|
|
1131
|
+
return failure(
|
|
1132
|
+
"invalid_input",
|
|
1133
|
+
"standing approvals need an approved action binding with a standing rule",
|
|
1134
|
+
);
|
|
1202
1135
|
const task = this.store.readTask(input.taskId);
|
|
1203
1136
|
if (!task.ok) return task as Result<never>;
|
|
1204
1137
|
if ((this.context.workerId ?? null) !== null)
|
|
@@ -1228,8 +1161,13 @@ export class WorkitCore {
|
|
|
1228
1161
|
consumption: null,
|
|
1229
1162
|
} satisfies Omit<Decision, "digest">;
|
|
1230
1163
|
const data: Decision = { ...base, digest: decisionDigest(base) };
|
|
1164
|
+
const standingApproval =
|
|
1165
|
+
standing && !stated
|
|
1166
|
+
? verifyStandingApproval(this.store.root, task.data, this.context.caller, input.binding)
|
|
1167
|
+
: null;
|
|
1168
|
+
if (standingApproval && !standingApproval.ok) return standingApproval as Result<never>;
|
|
1231
1169
|
const native =
|
|
1232
|
-
nativeRequired && !stated
|
|
1170
|
+
nativeRequired && !stated && !standingApproval
|
|
1233
1171
|
? verifyNativeDecision(
|
|
1234
1172
|
this.context.nativeAuthority,
|
|
1235
1173
|
{
|
|
@@ -1276,7 +1214,10 @@ export class WorkitCore {
|
|
|
1276
1214
|
const entry: Entry<Decision> = {
|
|
1277
1215
|
id: newId(),
|
|
1278
1216
|
recordedAt: mutation.now,
|
|
1279
|
-
provenance:
|
|
1217
|
+
provenance:
|
|
1218
|
+
standingApproval?.ok === true
|
|
1219
|
+
? standingApproval.data
|
|
1220
|
+
: (nativeProvenance ?? provenance(this.context, "agent_reported")),
|
|
1280
1221
|
data,
|
|
1281
1222
|
};
|
|
1282
1223
|
return success(mutation.revision, null, {
|
|
@@ -1346,7 +1287,7 @@ export class WorkitCore {
|
|
|
1346
1287
|
if (
|
|
1347
1288
|
!bindingCovers(assignment.scope, input.scope) ||
|
|
1348
1289
|
!bindingCovers(task.data.intent.data.scope, input.scope) ||
|
|
1349
|
-
(assignment
|
|
1290
|
+
!checkPin(assignment, null, input.candidateId ?? null) ||
|
|
1350
1291
|
!refsWithinScope(input.refs, assignment.scope) ||
|
|
1351
1292
|
!refsWithinScope(input.refs, task.data.intent.data.scope)
|
|
1352
1293
|
)
|
|
@@ -1403,11 +1344,11 @@ export class WorkitCore {
|
|
|
1403
1344
|
const evaluations = evaluateEvidence(task.data, current.data);
|
|
1404
1345
|
const verified = evidence.some((entry) => {
|
|
1405
1346
|
const evaluation = evaluations.find((item) => item.evidenceId === entry.id);
|
|
1406
|
-
return (
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
);
|
|
1347
|
+
return findingVerificationPasses(finding.data.candidateId, {
|
|
1348
|
+
kind: entry.data.kind,
|
|
1349
|
+
candidateId: entry.data.candidateId,
|
|
1350
|
+
status: evaluation?.status ?? "missing",
|
|
1351
|
+
});
|
|
1411
1352
|
});
|
|
1412
1353
|
if (!verified)
|
|
1413
1354
|
return failure("permission_denied", "fixed findings require passing verification evidence");
|
|
@@ -1423,7 +1364,7 @@ export class WorkitCore {
|
|
|
1423
1364
|
: entry.data.candidateId === finding.data.candidateId ||
|
|
1424
1365
|
entry.data.beforeCandidateId === finding.data.candidateId;
|
|
1425
1366
|
const referenceMatches = finding.data.refs.some((left) =>
|
|
1426
|
-
entry.data.refs.some((right) =>
|
|
1367
|
+
entry.data.refs.some((right) => sameValue(left, right)),
|
|
1427
1368
|
);
|
|
1428
1369
|
return candidateMatches && (referenceMatches || entry.data.claim === finding.data.claim);
|
|
1429
1370
|
});
|
|
@@ -1546,7 +1487,7 @@ export class WorkitCore {
|
|
|
1546
1487
|
const workerId = newId();
|
|
1547
1488
|
const changed = this.store.mutateTaskAndWorkspace({
|
|
1548
1489
|
taskId: task.data.id,
|
|
1549
|
-
|
|
1490
|
+
expectedRevision: input.expectedRevision,
|
|
1550
1491
|
expectedWorkspaceRevision: input.expectedWorkspaceRevision,
|
|
1551
1492
|
now: trustedNow(this.context),
|
|
1552
1493
|
workspace: (current, mutation) => success(mutation.revision, mutation.revision, current),
|
|
@@ -1606,7 +1547,7 @@ export class WorkitCore {
|
|
|
1606
1547
|
return failure("permission_denied", "worker report is outside the worker assignment");
|
|
1607
1548
|
const changed = this.store.mutateTaskAndWorkspace({
|
|
1608
1549
|
taskId: task.data.id,
|
|
1609
|
-
|
|
1550
|
+
expectedRevision: input.expectedRevision,
|
|
1610
1551
|
expectedWorkspaceRevision: input.expectedWorkspaceRevision,
|
|
1611
1552
|
now: trustedNow(this.context),
|
|
1612
1553
|
workspace: (current, mutation) => success(mutation.revision, mutation.revision, current),
|
|
@@ -1639,20 +1580,28 @@ export class WorkitCore {
|
|
|
1639
1580
|
return failure("invalid_transition", "closed task cannot cancel workers");
|
|
1640
1581
|
if (entry.data.state === "unknown")
|
|
1641
1582
|
return failure("recovery_required", "worker state requires recovery");
|
|
1642
|
-
if (entry.data.state === "stopped")
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
//
|
|
1649
|
-
|
|
1583
|
+
if (entry.data.state === "stopped") {
|
|
1584
|
+
// Idempotent cancel: repeating a settled stop succeeds with the
|
|
1585
|
+
// current entry instead of failing the retry.
|
|
1586
|
+
const stopped = task.data.workers.find((candidate) => candidate.id === input.workerId)!;
|
|
1587
|
+
return success(task.data.revision, workspace.data.revision, stopped);
|
|
1588
|
+
}
|
|
1589
|
+
// Lead-attested cancel is terminal for every live state: the lead saw
|
|
1590
|
+
// the run end, so no host observation is awaited. A later live-child
|
|
1591
|
+
// sighting against a stopped worker is a new anomaly for the host to
|
|
1592
|
+
// flag, not a reason to strand the worker short of stopped. A writer
|
|
1593
|
+
// held by the cancelled worker dies with it; anything else would brick
|
|
1594
|
+
// the checkout behind a stopped owner.
|
|
1650
1595
|
const changed = this.store.mutateTaskAndWorkspace({
|
|
1651
1596
|
taskId: task.data.id,
|
|
1652
|
-
|
|
1597
|
+
expectedRevision: input.expectedRevision,
|
|
1653
1598
|
expectedWorkspaceRevision: input.expectedWorkspaceRevision,
|
|
1654
1599
|
now: trustedNow(this.context),
|
|
1655
|
-
workspace: (current, mutation) =>
|
|
1600
|
+
workspace: (current, mutation) =>
|
|
1601
|
+
success(mutation.revision, mutation.revision, {
|
|
1602
|
+
...current,
|
|
1603
|
+
writer: current.writer?.owner.workerId === input.workerId ? null : current.writer,
|
|
1604
|
+
}),
|
|
1656
1605
|
task: (current, mutation) =>
|
|
1657
1606
|
success(mutation.revision, null, {
|
|
1658
1607
|
...current,
|
|
@@ -1661,10 +1610,7 @@ export class WorkitCore {
|
|
|
1661
1610
|
? {
|
|
1662
1611
|
...candidate,
|
|
1663
1612
|
recordedAt: mutation.now,
|
|
1664
|
-
data: {
|
|
1665
|
-
...candidate.data,
|
|
1666
|
-
state: settles ? ("stopped" as const) : ("cancelling" as const),
|
|
1667
|
-
},
|
|
1613
|
+
data: { ...candidate.data, state: "stopped" as const },
|
|
1668
1614
|
}
|
|
1669
1615
|
: candidate,
|
|
1670
1616
|
),
|
|
@@ -1776,7 +1722,7 @@ export class WorkitCore {
|
|
|
1776
1722
|
if (!attested.ok) return attested as Result<never>;
|
|
1777
1723
|
const changed = this.store.mutateTaskAndWorkspace({
|
|
1778
1724
|
taskId: task.data.id,
|
|
1779
|
-
|
|
1725
|
+
expectedRevision: input.expectedRevision,
|
|
1780
1726
|
expectedWorkspaceRevision: input.expectedWorkspaceRevision,
|
|
1781
1727
|
now: trustedNow(this.context),
|
|
1782
1728
|
workspace: (current, mutation) => success(mutation.revision, mutation.revision, current),
|
|
@@ -1958,6 +1904,26 @@ export class WorkitCore {
|
|
|
1958
1904
|
if (!sameSession(helper.data.data.session, this.context))
|
|
1959
1905
|
return failure("permission_denied", "worker session does not match the caller");
|
|
1960
1906
|
}
|
|
1907
|
+
// Write-timed requirements gate acquisition, not just close: a spec
|
|
1908
|
+
// gate is enforced where writes begin, never as advisory prose.
|
|
1909
|
+
const gate = this.view(task.data);
|
|
1910
|
+
if (gate.ok) {
|
|
1911
|
+
const writeGates = (task.data.policy?.requirements ?? []).filter(
|
|
1912
|
+
(requirement) =>
|
|
1913
|
+
requirement.before === "write" &&
|
|
1914
|
+
gate.data.requirements.some(
|
|
1915
|
+
(evaluation) =>
|
|
1916
|
+
evaluation.requirementId === requirement.id &&
|
|
1917
|
+
(evaluation.status === "unsatisfied" || evaluation.status === "unavailable"),
|
|
1918
|
+
),
|
|
1919
|
+
);
|
|
1920
|
+
if (writeGates.length)
|
|
1921
|
+
return failure(
|
|
1922
|
+
"requirements_unsatisfied",
|
|
1923
|
+
"writer ownership is gated by unsatisfied requirements; record their evidence or an approved limitation waiver first",
|
|
1924
|
+
{ requirementIds: writeGates.map((requirement) => requirement.id) },
|
|
1925
|
+
);
|
|
1926
|
+
}
|
|
1961
1927
|
const owner = {
|
|
1962
1928
|
taskId: task.data.id,
|
|
1963
1929
|
workerId: helperId,
|
|
@@ -1969,7 +1935,7 @@ export class WorkitCore {
|
|
|
1969
1935
|
};
|
|
1970
1936
|
const changed = this.store.mutateTaskAndWorkspace({
|
|
1971
1937
|
taskId: task.data.id,
|
|
1972
|
-
|
|
1938
|
+
expectedRevision: input.expectedRevision,
|
|
1973
1939
|
expectedWorkspaceRevision: input.expectedWorkspaceRevision,
|
|
1974
1940
|
now: trustedNow(this.context),
|
|
1975
1941
|
workspace: (current, mutation) =>
|
|
@@ -2006,7 +1972,7 @@ export class WorkitCore {
|
|
|
2006
1972
|
}
|
|
2007
1973
|
const changed = this.store.mutateTaskAndWorkspace({
|
|
2008
1974
|
taskId: task.data.id,
|
|
2009
|
-
|
|
1975
|
+
expectedRevision: input.expectedRevision,
|
|
2010
1976
|
expectedWorkspaceRevision: input.expectedWorkspaceRevision,
|
|
2011
1977
|
now: trustedNow(this.context),
|
|
2012
1978
|
workspace: (currentWorkspace, mutation) =>
|
|
@@ -2317,10 +2283,18 @@ export class WorkitCore {
|
|
|
2317
2283
|
if (seen.has(observation.workerId))
|
|
2318
2284
|
return failure("invalid_input", "worker observations must be unique");
|
|
2319
2285
|
seen.add(observation.workerId);
|
|
2286
|
+
// Anchor on fresh reads, not the passed view: a view captured before
|
|
2287
|
+
// recent mutations must not stale-fail observations that match the
|
|
2288
|
+
// current state. Genuinely stale observations still conflict below.
|
|
2289
|
+
const freshTask = this.store.readTask(view.task.id);
|
|
2290
|
+
const freshWorkspace = this.store.readWorkspace();
|
|
2291
|
+
if (!freshTask.ok) return freshTask as Result<never>;
|
|
2292
|
+
if (!freshWorkspace.ok) return freshWorkspace as Result<never>;
|
|
2293
|
+
if (!freshWorkspace.data) return failure("not_found", "workspace not found");
|
|
2320
2294
|
if (
|
|
2321
2295
|
observation.taskId !== view.task.id ||
|
|
2322
|
-
observation.expectedRevision !==
|
|
2323
|
-
observation.expectedWorkspaceRevision !==
|
|
2296
|
+
observation.expectedRevision !== freshTask.data.revision ||
|
|
2297
|
+
observation.expectedWorkspaceRevision !== freshWorkspace.data.revision
|
|
2324
2298
|
)
|
|
2325
2299
|
return failure("revision_conflict", "worker observation is stale");
|
|
2326
2300
|
if (!view.task.workers.some((entry) => entry.id === observation.workerId))
|
|
@@ -2433,11 +2407,27 @@ export class WorkitCore {
|
|
|
2433
2407
|
return failure("permission_denied", "imported resume requires native destination approval");
|
|
2434
2408
|
resumeCandidate = current.data.candidate;
|
|
2435
2409
|
}
|
|
2436
|
-
const blocker =
|
|
2410
|
+
const blocker =
|
|
2411
|
+
status === "paused"
|
|
2412
|
+
? workspace.data.writer
|
|
2413
|
+
? failure("recovery_required", "writer ownership must be released first")
|
|
2414
|
+
: null
|
|
2415
|
+
: this.activeWorkerBlocker(task.data, workspace.data);
|
|
2437
2416
|
if (blocker) return blocker as Result<never>;
|
|
2417
|
+
let pauseCandidate: import("./task-contract").Candidate | null = null;
|
|
2418
|
+
if (status === "paused") {
|
|
2419
|
+
// Freezing records the tree state the task resumes from.
|
|
2420
|
+
const captured = captureCandidate(
|
|
2421
|
+
this.store.root,
|
|
2422
|
+
task.data.intent.data.scope,
|
|
2423
|
+
environment(),
|
|
2424
|
+
);
|
|
2425
|
+
if (!captured.ok) return captured as Result<never>;
|
|
2426
|
+
pauseCandidate = captured.data;
|
|
2427
|
+
}
|
|
2438
2428
|
const changed = this.store.mutateTaskAndWorkspace({
|
|
2439
2429
|
taskId: task.data.id,
|
|
2440
|
-
|
|
2430
|
+
expectedRevision: input.expectedRevision,
|
|
2441
2431
|
expectedWorkspaceRevision: input.expectedWorkspaceRevision,
|
|
2442
2432
|
now: trustedNow(this.context),
|
|
2443
2433
|
workspace: (workspace, context) => success(context.revision, context.revision, workspace),
|
|
@@ -2446,9 +2436,11 @@ export class WorkitCore {
|
|
|
2446
2436
|
...current,
|
|
2447
2437
|
status,
|
|
2448
2438
|
candidates:
|
|
2449
|
-
resumeCandidate &&
|
|
2450
|
-
!current.candidates.some(
|
|
2451
|
-
|
|
2439
|
+
(resumeCandidate ?? pauseCandidate) &&
|
|
2440
|
+
!current.candidates.some(
|
|
2441
|
+
(candidate) => candidate.id === (resumeCandidate ?? pauseCandidate)!.id,
|
|
2442
|
+
)
|
|
2443
|
+
? [...current.candidates, (resumeCandidate ?? pauseCandidate)!]
|
|
2452
2444
|
: current.candidates,
|
|
2453
2445
|
progress: current.progress,
|
|
2454
2446
|
pauseReason: status === "paused" ? (input.reason ?? null) : null,
|
|
@@ -2487,18 +2479,12 @@ export class WorkitCore {
|
|
|
2487
2479
|
const workspace = this.store.readWorkspace();
|
|
2488
2480
|
if (!workspace.ok) return workspace as Result<never>;
|
|
2489
2481
|
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
|
-
);
|
|
2482
|
+
const revisionBlocker = this.activeWorkerBlocker(task.data, workspace.data);
|
|
2483
|
+
if (revisionBlocker) return revisionBlocker as Result<never>;
|
|
2498
2484
|
this.fillRevisions(input, task.data, workspace.data);
|
|
2499
2485
|
const changed = this.store.mutateTaskAndWorkspace({
|
|
2500
2486
|
taskId: task.data.id,
|
|
2501
|
-
|
|
2487
|
+
expectedRevision: input.expectedRevision,
|
|
2502
2488
|
expectedWorkspaceRevision: input.expectedWorkspaceRevision,
|
|
2503
2489
|
now: trustedNow(this.context),
|
|
2504
2490
|
workspace: (workspace, context) => success(context.revision, context.revision, workspace),
|
|
@@ -2558,7 +2544,7 @@ export class WorkitCore {
|
|
|
2558
2544
|
if (!closure.ok) return closure as Result<never>;
|
|
2559
2545
|
const changed = this.store.mutateTaskAndWorkspace({
|
|
2560
2546
|
taskId: task.data.id,
|
|
2561
|
-
|
|
2547
|
+
expectedRevision: input.expectedRevision,
|
|
2562
2548
|
expectedWorkspaceRevision: input.expectedWorkspaceRevision,
|
|
2563
2549
|
now: trustedNow(this.context),
|
|
2564
2550
|
workspace: (value, context) => success(context.revision, context.revision, value),
|