@workit-dev/core 0.0.1 → 1.0.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/LICENSE +21 -0
- package/README.md +22 -43
- package/package.json +56 -6
- package/schemas/config.json +74 -0
- package/schemas/vcs.json +79 -0
- package/schemas/workspaces.json +1029 -0
- package/schemas/youtrack.json +84 -0
- package/scripts/analyze-release-scope.ts +215 -0
- package/scripts/bundled-deps.json +131 -0
- package/scripts/fixtures/sample-plan.md +15 -0
- package/scripts/fixtures/sample-sdd/progress.md +2 -0
- package/scripts/install-codex-plugin.sh +28 -0
- package/scripts/install-cursor-plugin.sh +146 -0
- package/scripts/install-opencode-plugin.sh +79 -0
- package/scripts/install-pi-package.sh +56 -0
- package/scripts/lib/config-dir.sh +28 -0
- package/scripts/publish-changed-packages.ts +395 -0
- package/scripts/rewrite-workspace-deps.ts +53 -0
- package/scripts/run-cursor-mcp.sh +6 -0
- package/scripts/sync-release-manifests.ts +82 -0
- package/scripts/sync-runtime.sh +142 -0
- package/scripts/validate-cursor-marketplace.ts +171 -0
- package/scripts/verify-bundle-sources.ts +134 -0
- package/scripts/write-schemas.ts +16 -0
- package/skills/workit-architecture/SKILL.md +72 -0
- package/skills/workit-architecture/agents/openai.yaml +3 -0
- package/skills/workit-architecture/references/instruction-files.md +52 -0
- package/skills/workit-architecture/references/vocabulary.md +53 -0
- package/skills/workit-bdd/SKILL.md +53 -0
- package/skills/workit-continue/SKILL.md +54 -0
- package/skills/workit-debug/SKILL.md +45 -0
- package/skills/workit-deslop/SKILL.md +58 -0
- package/skills/workit-fanout/SKILL.md +71 -0
- package/skills/workit-fanout/references/brief.md +141 -0
- package/skills/workit-fanout/references/orchestration.md +169 -0
- package/skills/workit-implement/SKILL.md +71 -0
- package/skills/workit-retro/SKILL.md +72 -0
- package/skills/workit-retro/agents/openai.yaml +3 -0
- package/skills/workit-retro/references/sources.md +35 -0
- package/skills/workit-retro/references/steering.md +39 -0
- package/skills/workit-review/SKILL.md +66 -0
- package/skills/workit-review/references/impact.md +24 -0
- package/skills/workit-shape/SKILL.md +71 -0
- package/skills/workit-shape/references/diagrams.md +17 -0
- package/skills/workit-shape/references/knowledge.md +61 -0
- package/skills/workit-shape/references/mockups.md +15 -0
- package/skills/workit-shape/references/slicing.md +44 -0
- package/skills/workit-ship/SKILL.md +71 -0
- package/skills/workit-ship/references/changelog.md +44 -0
- package/skills/workit-ship/references/pr-body.md +32 -0
- package/skills/workit-ship/references/release.md +39 -0
- package/skills/workit-test-audit/SKILL.md +54 -0
- package/skills/workit-verify-app/SKILL.md +68 -0
- package/skills/workit-verify-app/references/template.md +51 -0
- package/src/autonomy.ts +910 -0
- package/src/check-config.ts +613 -0
- package/src/checks.ts +534 -0
- package/src/ci-waiter.ts +159 -0
- package/src/core/boundary.ts +55 -0
- package/src/core/branch-policy.ts +112 -0
- package/src/core/branch.ts +1192 -0
- package/src/core/changelog-gate.ts +349 -0
- package/src/core/changelog-merge.ts +214 -0
- package/src/core/changelog.ts +392 -0
- package/src/core/cleanup.ts +638 -0
- package/src/core/cli-locate.ts +173 -0
- package/src/core/commit-flavors.ts +65 -0
- package/src/core/config-schemas.ts +653 -0
- package/src/core/config.ts +351 -0
- package/src/core/context-read.ts +144 -0
- package/src/core/docs-gate.ts +69 -0
- package/src/core/finding-evidence.ts +89 -0
- package/src/core/finding-queue.ts +36 -0
- package/src/core/git.ts +73 -0
- package/src/core/gitignore.ts +52 -0
- package/src/core/heavy-eta.ts +170 -0
- package/src/core/heavy-lease.ts +290 -0
- package/src/core/heavy-rules.ts +373 -0
- package/src/core/heavy-scope.ts +131 -0
- package/src/core/heavy-words.ts +83 -0
- package/src/core/heavy.ts +535 -0
- package/src/core/hygiene.ts +149 -0
- package/src/core/init.ts +218 -0
- package/src/core/live-session.ts +333 -0
- package/src/core/logger.ts +310 -0
- package/src/core/methods.ts +185 -0
- package/src/core/model-fallback.ts +208 -0
- package/src/core/node-resolve.mjs +249 -0
- package/src/core/operation-input.ts +632 -0
- package/src/core/package-root.ts +28 -0
- package/src/core/policy/attributes.ts +97 -0
- package/src/core/policy/bookkeeping.ts +119 -0
- package/src/core/policy/carry.ts +122 -0
- package/src/core/policy/check-evidence.ts +115 -0
- package/src/core/policy/current-judgment.ts +20 -0
- package/src/core/policy/derive.ts +387 -0
- package/src/core/policy/judgment.ts +246 -0
- package/src/core/policy/lane.ts +562 -0
- package/src/core/policy/pr-lane.ts +32 -0
- package/src/core/policy/substance.ts +365 -0
- package/src/core/policy-resolver.ts +94 -0
- package/src/core/pr-create.ts +448 -0
- package/src/core/pr-template.ts +162 -0
- package/src/core/release-notes.ts +197 -0
- package/src/core/release-tracks.ts +779 -0
- package/src/core/remotes.ts +436 -0
- package/src/core/repo-context.ts +542 -0
- package/src/core/repo-paths.ts +29 -0
- package/src/core/resources.ts +1299 -0
- package/src/core/role-models.ts +603 -0
- package/src/core/route-intent.ts +305 -0
- package/src/core/rules.ts +65 -0
- package/src/core/runtime-identity.ts +63 -0
- package/src/core/safe-write.ts +87 -0
- package/src/core/scope-glob.ts +56 -0
- package/src/core/scripts.ts +6 -0
- package/src/core/skill-manifests.ts +173 -0
- package/src/core/store-lock.ts +374 -0
- package/src/core/support-matrix.ts +24 -0
- package/src/core/task-context.ts +247 -0
- package/src/core/task-contract.ts +1279 -0
- package/src/core/task-delivery.ts +109 -0
- package/src/core/task-engine.ts +2248 -0
- package/src/core/task-evaluation.ts +1147 -0
- package/src/core/task-store.ts +2649 -0
- package/src/core/task-view.ts +180 -0
- package/src/core/templates.ts +22 -0
- package/src/core/tracker-issues.ts +174 -0
- package/src/core/vcs-config.ts +540 -0
- package/src/core/workers.ts +62 -0
- package/src/core/workit-paths.mjs +242 -0
- package/src/core/workspace-branch-policy.ts +123 -0
- package/src/core/workspaces.ts +1745 -0
- package/src/core/youtrack-backend.ts +288 -0
- package/src/core/youtrack-tools.ts +260 -0
- package/src/core/youtrack-writes.ts +73 -0
- package/src/core/youtrack-ytrack.ts +217 -0
- package/src/core/youtrack.ts +454 -0
- package/src/core/ytrack-skill.ts +297 -0
- package/src/core.ts +171 -0
- package/src/deslop/lex.ts +271 -0
- package/src/deslop/scan.ts +232 -0
- package/src/duration.ts +27 -0
- package/src/fanout-brief.ts +486 -0
- package/src/fanout-check.ts +455 -0
- package/src/fanout-landed.ts +370 -0
- package/src/fanout-stack.ts +108 -0
- package/src/fanout-status.ts +362 -0
- package/src/fanout-worktree.ts +893 -0
- package/src/fanout.ts +1473 -0
- package/src/forge/exec.ts +385 -0
- package/src/forge/github.ts +831 -0
- package/src/forge/gitlab.ts +906 -0
- package/src/forge/issue-forge.ts +209 -0
- package/src/forge/issue-link.ts +468 -0
- package/src/forge/issue-ops.ts +314 -0
- package/src/forge/merge-gate-base-red.ts +72 -0
- package/src/forge/pr-body.ts +100 -0
- package/src/forge/pr-edit.ts +292 -0
- package/src/forge/pr-ops.ts +1109 -0
- package/src/forge/pr-status.ts +10 -0
- package/src/forge/redact.ts +195 -0
- package/src/forge/report.ts +994 -0
- package/src/forge/reruns.ts +132 -0
- package/src/forge/resolve.ts +509 -0
- package/src/forge/types.ts +242 -0
- package/src/forge/verify.ts +398 -0
- package/src/git/default-base.ts +130 -0
- package/src/git/env-redirect.ts +28 -0
- package/src/git/facts.ts +122 -0
- package/src/git/ops.ts +1823 -0
- package/src/git/refs-read.ts +332 -0
- package/src/git/release.ts +1020 -0
- package/src/git/rev.ts +1175 -0
- package/src/git/session-hook.ts +597 -0
- package/src/git/stage.ts +63 -0
- package/src/hooks/agent-model.ts +149 -0
- package/src/hooks/budget-signal.ts +284 -0
- package/src/hooks/context.ts +461 -0
- package/src/hooks/cwd-normalize.ts +79 -0
- package/src/hooks/deadline.ts +228 -0
- package/src/hooks/delivery-verbs.ts +182 -0
- package/src/hooks/descriptor.ts +124 -0
- package/src/hooks/handle.ts +433 -0
- package/src/hooks/heavy-gate.ts +99 -0
- package/src/hooks/hook-runs.ts +130 -0
- package/src/hooks/hosts/claude-code.ts +380 -0
- package/src/hooks/hosts/codex.ts +372 -0
- package/src/hooks/hosts/cursor.ts +419 -0
- package/src/hooks/hosts/fields.ts +98 -0
- package/src/hooks/hosts/opencode.ts +79 -0
- package/src/hooks/hosts/pi.ts +78 -0
- package/src/hooks/index.ts +60 -0
- package/src/hooks/model-limit.ts +378 -0
- package/src/hooks/model-switch.ts +35 -0
- package/src/hooks/policy.ts +29 -0
- package/src/hooks/process.ts +48 -0
- package/src/hooks/protocol.ts +135 -0
- package/src/hooks/raw-git.ts +2289 -0
- package/src/hooks/repeat-failure.ts +115 -0
- package/src/hooks/run.ts +54 -0
- package/src/hooks/session-marker.ts +151 -0
- package/src/hooks/shell-words.ts +532 -0
- package/src/hooks/skill-nudge.ts +295 -0
- package/src/hooks/spawn-gate.ts +72 -0
- package/src/hooks/standing-signal.ts +99 -0
- package/src/hooks/standing.ts +225 -0
- package/src/hooks/steering.ts +74 -0
- package/src/hooks/stop.ts +408 -0
- package/src/hooks/worktree-confine.ts +415 -0
- package/src/hooks/write-gate.ts +1174 -0
- package/src/host-session.ts +459 -0
- package/src/knowledge.ts +368 -0
- package/src/ledger.ts +2913 -0
- package/src/stack.ts +2570 -0
- package/src/store/event-log.ts +217 -0
- package/src/store/notes.ts +29 -0
- package/src/store/patch.ts +135 -0
- package/src/store/paths.ts +368 -0
- package/src/store/reduce.ts +203 -0
- package/src/test-audit-mocks.ts +309 -0
- package/src/verdict-cover.ts +151 -0
- package/src/worktree-deps.ts +142 -0
- package/templates/headers.md +3 -0
- package/templates/hygiene/.editorconfig +8 -0
- package/templates/hygiene/.gitattributes +3 -0
- package/templates/hygiene/CHANGELOG.md +14 -0
- package/templates/hygiene/CONTRIBUTING.md +3 -0
- package/templates/hygiene/LICENSE +21 -0
- package/templates/hygiene/README.md +3 -0
- package/templates/issue-update.md +5 -0
- package/templates/plan-template.md +26 -0
- package/templates/spec-template.md +64 -0
- package/templates/workit-contract.md +13 -0
|
@@ -0,0 +1,2248 @@
|
|
|
1
|
+
import { realpathSync } from "node:fs";
|
|
2
|
+
import {
|
|
3
|
+
CHECK_OBSERVATION_PATH,
|
|
4
|
+
POLICY_VERSION,
|
|
5
|
+
canonicalJson,
|
|
6
|
+
checkObservationSchema,
|
|
7
|
+
failure,
|
|
8
|
+
decisionDigest,
|
|
9
|
+
exportBundleSchema,
|
|
10
|
+
decisionSchema,
|
|
11
|
+
evidenceSchema,
|
|
12
|
+
findingSchema,
|
|
13
|
+
intentSchema,
|
|
14
|
+
judgmentSchema,
|
|
15
|
+
legacyAssessmentSchema,
|
|
16
|
+
newId,
|
|
17
|
+
parseOperation,
|
|
18
|
+
resolveIdRef,
|
|
19
|
+
rewriteRecordRefs,
|
|
20
|
+
success,
|
|
21
|
+
type Candidate,
|
|
22
|
+
type Caller,
|
|
23
|
+
type CheckObservation,
|
|
24
|
+
type Decision,
|
|
25
|
+
type Entry,
|
|
26
|
+
type Evidence,
|
|
27
|
+
type ExportBundle,
|
|
28
|
+
type Finding,
|
|
29
|
+
type Judgment,
|
|
30
|
+
type Policy,
|
|
31
|
+
provenanceSchema,
|
|
32
|
+
type Ref,
|
|
33
|
+
type Result,
|
|
34
|
+
type Scope,
|
|
35
|
+
type TaskRecord,
|
|
36
|
+
type TaskBrief,
|
|
37
|
+
type TaskListItem,
|
|
38
|
+
type TaskSummary,
|
|
39
|
+
type TaskView,
|
|
40
|
+
type Utc,
|
|
41
|
+
type Provenance,
|
|
42
|
+
type Worker,
|
|
43
|
+
type OperationFamily,
|
|
44
|
+
type WorkspaceRecord,
|
|
45
|
+
taskRecordSchema,
|
|
46
|
+
scopeCovers as bindingCovers,
|
|
47
|
+
} from "./task-contract";
|
|
48
|
+
import { diffPolicy, resolvePolicy } from "./policy-resolver";
|
|
49
|
+
import { normalizeOperationInput } from "./operation-input";
|
|
50
|
+
import { latestJudgment } from "./policy/derive";
|
|
51
|
+
import {
|
|
52
|
+
givenReason,
|
|
53
|
+
judgeInputSummary,
|
|
54
|
+
liftedBlockers,
|
|
55
|
+
normalizeJudgment,
|
|
56
|
+
ownLiftedBlockers,
|
|
57
|
+
} from "./policy/judgment";
|
|
58
|
+
import { MAX_LINE_BYTES, appendObserved, observedRowBytes } from "../ledger";
|
|
59
|
+
|
|
60
|
+
/** Refs kept in a `policy.judged` row (the task store keeps all of them). */
|
|
61
|
+
const MAX_JUDGED_REFS = 5;
|
|
62
|
+
import { currentBranch, headSha } from "../git/rev";
|
|
63
|
+
import { resolveAutonomy, type VerificationMode } from "../autonomy";
|
|
64
|
+
|
|
65
|
+
import { TaskStore } from "./task-store";
|
|
66
|
+
import {
|
|
67
|
+
compactContext,
|
|
68
|
+
contextRootError,
|
|
69
|
+
environment,
|
|
70
|
+
helperEntry,
|
|
71
|
+
helperTaskGuard,
|
|
72
|
+
sameSession,
|
|
73
|
+
taskView,
|
|
74
|
+
type OperationContext,
|
|
75
|
+
} from "./task-view";
|
|
76
|
+
import { defaultLockTimeout } from "./store-lock";
|
|
77
|
+
import {
|
|
78
|
+
exportDigest,
|
|
79
|
+
reconcileResume as reconcileResumeContext,
|
|
80
|
+
type ResumeReconciliation,
|
|
81
|
+
} from "./task-context";
|
|
82
|
+
import {
|
|
83
|
+
isUncertainWorker,
|
|
84
|
+
workerBlocksTransition,
|
|
85
|
+
type NativeWorkerObservation,
|
|
86
|
+
type NativeWorkerVerification,
|
|
87
|
+
type NativeWorkerVerifier,
|
|
88
|
+
} from "./workers";
|
|
89
|
+
import {
|
|
90
|
+
boundTaskSummary,
|
|
91
|
+
captureCandidate,
|
|
92
|
+
checkPin,
|
|
93
|
+
treeFreshness,
|
|
94
|
+
type Freshness,
|
|
95
|
+
evaluateClosure,
|
|
96
|
+
evaluateEvidence,
|
|
97
|
+
evaluateRequirements,
|
|
98
|
+
findingVerificationPasses,
|
|
99
|
+
resolveBinding,
|
|
100
|
+
verifyDecisionContentAtRoot,
|
|
101
|
+
} from "./task-evaluation";
|
|
102
|
+
|
|
103
|
+
/** The user-config verification mode for `root`; `self` when it cannot be read. */
|
|
104
|
+
const workspaceVerification = (root: string): VerificationMode => {
|
|
105
|
+
try {
|
|
106
|
+
return resolveAutonomy(root).verification;
|
|
107
|
+
} catch {
|
|
108
|
+
return "self";
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
export type { OperationContext } from "./task-view";
|
|
113
|
+
|
|
114
|
+
export type ObserveCheckRequest = {
|
|
115
|
+
taskId: string;
|
|
116
|
+
/** Omit to retry over concurrent writers (S2b); a value is strict compare-and-swap. */
|
|
117
|
+
expectedRevision?: string;
|
|
118
|
+
observation: CheckObservation;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const provenance = (
|
|
122
|
+
context: OperationContext,
|
|
123
|
+
kind: "host_observed" | "agent_reported" = context.provenanceKind ?? "host_observed",
|
|
124
|
+
) => ({
|
|
125
|
+
kind,
|
|
126
|
+
host: context.caller.host,
|
|
127
|
+
session: { kind: "host" as const, host: context.caller.host, handle: context.caller.actor },
|
|
128
|
+
workerId: context.workerId ?? null,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
type WorkerBinding = {
|
|
132
|
+
owner: object;
|
|
133
|
+
store: TaskStore;
|
|
134
|
+
root: string;
|
|
135
|
+
caller: Caller;
|
|
136
|
+
};
|
|
137
|
+
type WorkerAuthority = {
|
|
138
|
+
taskId: string;
|
|
139
|
+
workspaceId: string;
|
|
140
|
+
workerId: string;
|
|
141
|
+
expectedRevision: string;
|
|
142
|
+
expectedWorkspaceRevision: string;
|
|
143
|
+
state: NativeWorkerObservation["state"];
|
|
144
|
+
session: NativeWorkerObservation["session"];
|
|
145
|
+
report: NativeWorkerObservation["report"] | null;
|
|
146
|
+
provenance: Provenance;
|
|
147
|
+
owner: object;
|
|
148
|
+
store: TaskStore;
|
|
149
|
+
root: string;
|
|
150
|
+
caller: Caller;
|
|
151
|
+
};
|
|
152
|
+
type VerifiedWorker = object;
|
|
153
|
+
const verifiedWorkers = new WeakMap<object, WorkerAuthority>();
|
|
154
|
+
|
|
155
|
+
const sameValue = (left: unknown, right: unknown): boolean => {
|
|
156
|
+
try {
|
|
157
|
+
return canonicalJson(left) === canonicalJson(right);
|
|
158
|
+
} catch {
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const validNativeProvenance = (
|
|
164
|
+
value: unknown,
|
|
165
|
+
caller: Caller,
|
|
166
|
+
expected: Pick<NativeWorkerObservation, "workerId" | "session">,
|
|
167
|
+
): value is Provenance => {
|
|
168
|
+
if (!provenanceSchema.safeParse(value).success) return false;
|
|
169
|
+
const observed = value as Provenance;
|
|
170
|
+
return (
|
|
171
|
+
observed.kind === "host_observed" &&
|
|
172
|
+
observed.host === caller.host &&
|
|
173
|
+
observed.workerId === expected.workerId &&
|
|
174
|
+
sameValue(observed.session, expected.session)
|
|
175
|
+
);
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const verifyNativeWorker = (
|
|
179
|
+
verifier: NativeWorkerVerifier | undefined,
|
|
180
|
+
input: NativeWorkerVerification,
|
|
181
|
+
binding: WorkerBinding,
|
|
182
|
+
): Result<VerifiedWorker> => {
|
|
183
|
+
if (!verifier) return failure("permission_denied", "native worker verifier is unavailable");
|
|
184
|
+
let result: Result<Provenance>;
|
|
185
|
+
try {
|
|
186
|
+
result = verifier.verifyWorker(input);
|
|
187
|
+
} catch (error) {
|
|
188
|
+
return failure("permission_denied", `native worker observation failed: ${String(error)}`);
|
|
189
|
+
}
|
|
190
|
+
const expected = input.expected;
|
|
191
|
+
if (!result.ok || !validNativeProvenance(result.data, input.caller, expected))
|
|
192
|
+
return failure("permission_denied", "native worker observation was not attested");
|
|
193
|
+
const token = {};
|
|
194
|
+
verifiedWorkers.set(token, {
|
|
195
|
+
taskId: expected.taskId,
|
|
196
|
+
workspaceId: expected.workspaceId,
|
|
197
|
+
workerId: expected.workerId,
|
|
198
|
+
expectedRevision: expected.expectedRevision,
|
|
199
|
+
expectedWorkspaceRevision: expected.expectedWorkspaceRevision,
|
|
200
|
+
state: expected.state,
|
|
201
|
+
session: expected.session,
|
|
202
|
+
report: expected.report ?? null,
|
|
203
|
+
provenance: result.data,
|
|
204
|
+
...binding,
|
|
205
|
+
});
|
|
206
|
+
return success(null, null, token);
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const takeWorker = (
|
|
210
|
+
token: VerifiedWorker,
|
|
211
|
+
binding: WorkerBinding,
|
|
212
|
+
expected: NativeWorkerObservation,
|
|
213
|
+
): WorkerAuthority | null => {
|
|
214
|
+
const value = verifiedWorkers.get(token);
|
|
215
|
+
verifiedWorkers.delete(token);
|
|
216
|
+
if (!value) return null;
|
|
217
|
+
if (
|
|
218
|
+
value.owner !== binding.owner ||
|
|
219
|
+
value.store !== binding.store ||
|
|
220
|
+
value.root !== binding.root ||
|
|
221
|
+
value.root !== value.store.root ||
|
|
222
|
+
!sameValue(value.caller, binding.caller) ||
|
|
223
|
+
value.taskId !== expected.taskId ||
|
|
224
|
+
value.workerId !== expected.workerId ||
|
|
225
|
+
value.expectedRevision !== expected.expectedRevision ||
|
|
226
|
+
value.expectedWorkspaceRevision !== expected.expectedWorkspaceRevision ||
|
|
227
|
+
value.state !== expected.state ||
|
|
228
|
+
!sameValue(value.session, expected.session) ||
|
|
229
|
+
!sameValue(value.report, expected.report ?? null)
|
|
230
|
+
)
|
|
231
|
+
return null;
|
|
232
|
+
return value;
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
type ObserveWorkerLifecycleInput = NativeWorkerObservation & {
|
|
236
|
+
store: TaskStore;
|
|
237
|
+
authority: VerifiedWorker;
|
|
238
|
+
authorityOwner: object;
|
|
239
|
+
caller: Caller;
|
|
240
|
+
now?: Utc;
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
const applyWorkerLifecycle = (input: ObserveWorkerLifecycleInput): Result<Entry<Worker>> => {
|
|
244
|
+
if (!input.store || !input.authority || !input.authorityOwner)
|
|
245
|
+
return failure("invalid_input", "native worker observation preconditions are required");
|
|
246
|
+
const authority = takeWorker(
|
|
247
|
+
input.authority,
|
|
248
|
+
{
|
|
249
|
+
owner: input.authorityOwner,
|
|
250
|
+
store: input.store,
|
|
251
|
+
root: input.store.root,
|
|
252
|
+
caller: input.caller,
|
|
253
|
+
},
|
|
254
|
+
input,
|
|
255
|
+
);
|
|
256
|
+
if (!authority) return failure("permission_denied", "native worker observation is not verified");
|
|
257
|
+
|
|
258
|
+
const task = input.store.readTask(input.taskId);
|
|
259
|
+
if (!task.ok) return task;
|
|
260
|
+
const workspace = input.store.readWorkspace();
|
|
261
|
+
if (!workspace.ok) return workspace;
|
|
262
|
+
if (!workspace.data) return failure("not_found", "workspace not found");
|
|
263
|
+
if (
|
|
264
|
+
task.data.workspaceId !== authority.workspaceId ||
|
|
265
|
+
workspace.data.id !== authority.workspaceId
|
|
266
|
+
)
|
|
267
|
+
return failure("recovery_required", "worker workspace binding is invalid");
|
|
268
|
+
const entry = task.data.workers.find((candidate) => candidate.id === input.workerId);
|
|
269
|
+
if (!entry) return failure("not_found", "worker not found");
|
|
270
|
+
if (task.data.status === "closed")
|
|
271
|
+
return failure("invalid_transition", "closed task cannot observe workers");
|
|
272
|
+
if (task.data.status === "paused" && (input.state === "running" || input.state === "cancelling"))
|
|
273
|
+
return failure("invalid_transition", "paused task cannot run a worker");
|
|
274
|
+
// Compare-and-swap first: a stale observation (another process launched
|
|
275
|
+
// and observed this worker meanwhile) must not pass as a no-op below.
|
|
276
|
+
if (input.expectedRevision !== task.data.revision)
|
|
277
|
+
return failure("revision_conflict", "worker observation is stale; re-read the task", {
|
|
278
|
+
expectedRevision: input.expectedRevision,
|
|
279
|
+
actualRevision: task.data.revision,
|
|
280
|
+
});
|
|
281
|
+
if (input.report && input.state !== "stopped")
|
|
282
|
+
return failure("invalid_input", "worker reports require a stopped worker");
|
|
283
|
+
if (
|
|
284
|
+
(input.state === "running" || input.state === "cancelling" || input.state === "stopped") &&
|
|
285
|
+
!input.session
|
|
286
|
+
)
|
|
287
|
+
return failure("invalid_input", "running worker observations require a session");
|
|
288
|
+
if (entry.data.session && !sameValue(entry.data.session, input.session))
|
|
289
|
+
return failure("permission_denied", "worker session does not match its assignment");
|
|
290
|
+
// `unknown` from `assigned` records a launch whose outcome was never
|
|
291
|
+
// observed (its launcher died); the lead must cancel before a relaunch.
|
|
292
|
+
if (
|
|
293
|
+
entry.data.state === "assigned" &&
|
|
294
|
+
input.state !== "running" &&
|
|
295
|
+
input.state !== "stopped" &&
|
|
296
|
+
input.state !== "unknown"
|
|
297
|
+
)
|
|
298
|
+
return failure("invalid_transition", "worker has not started");
|
|
299
|
+
if (input.state === "running" && entry.data.state === "stopped")
|
|
300
|
+
return failure("invalid_transition", "stopped worker cannot run again");
|
|
301
|
+
const terminalCancel = entry.data.state === "cancelling" && input.state === "running";
|
|
302
|
+
// The first observation replaces the assigning provenance with the child's,
|
|
303
|
+
// so the assigning coordinator session is kept on the worker: a restarted
|
|
304
|
+
// host still attributes the running child to its coordinator.
|
|
305
|
+
const coordinator =
|
|
306
|
+
entry.data.coordinator ??
|
|
307
|
+
(entry.data.state === "assigned" && entry.provenance.session?.kind === "host"
|
|
308
|
+
? entry.provenance.session
|
|
309
|
+
: undefined);
|
|
310
|
+
const nextEntry = {
|
|
311
|
+
...entry,
|
|
312
|
+
recordedAt: input.now ?? entry.recordedAt,
|
|
313
|
+
provenance: authority.provenance,
|
|
314
|
+
data: {
|
|
315
|
+
...entry.data,
|
|
316
|
+
...(coordinator ? { coordinator } : {}),
|
|
317
|
+
state: terminalCancel ? ("cancelling" as const) : input.state,
|
|
318
|
+
session: input.session,
|
|
319
|
+
report: entry.data.report ?? input.report ?? null,
|
|
320
|
+
},
|
|
321
|
+
} satisfies Entry<Worker>;
|
|
322
|
+
// A no-op observation (same state, same session)
|
|
323
|
+
// must not rewrite the entry: hosts observe on every session event, and a
|
|
324
|
+
// revision bump per event livelocks worker sessions — each call would
|
|
325
|
+
// invalidate the revision the previous call returned.
|
|
326
|
+
if (
|
|
327
|
+
entry.data.state === nextEntry.data.state &&
|
|
328
|
+
sameValue(entry.data.session, input.session) &&
|
|
329
|
+
sameValue(entry.data.report, nextEntry.data.report)
|
|
330
|
+
)
|
|
331
|
+
return success(task.data.revision, workspace.data.revision, entry);
|
|
332
|
+
const changed = input.store.mutateTaskAndWorkspace({
|
|
333
|
+
taskId: task.data.id,
|
|
334
|
+
expectedRevision: input.expectedRevision,
|
|
335
|
+
expectedWorkspaceRevision: input.expectedWorkspaceRevision,
|
|
336
|
+
now: input.now,
|
|
337
|
+
workspace: (current, mutation) => success(mutation.revision, mutation.revision, current),
|
|
338
|
+
task: (current, mutation) =>
|
|
339
|
+
success(mutation.revision, null, {
|
|
340
|
+
...current,
|
|
341
|
+
workers: current.workers.map((candidate) =>
|
|
342
|
+
candidate.id === nextEntry.id ? { ...nextEntry, recordedAt: mutation.now } : candidate,
|
|
343
|
+
),
|
|
344
|
+
}),
|
|
345
|
+
});
|
|
346
|
+
if (!changed.ok) return changed;
|
|
347
|
+
const updated = changed.data.task.workers.find((candidate) => candidate.id === input.workerId);
|
|
348
|
+
return updated
|
|
349
|
+
? success(changed.data.task.revision, changed.data.workspace.revision, updated)
|
|
350
|
+
: failure("recovery_required", "worker disappeared during lifecycle observation");
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
const refsWithinScope = (refs: Ref[], scope: Scope): boolean =>
|
|
354
|
+
refs.every(
|
|
355
|
+
(ref) =>
|
|
356
|
+
ref.kind !== "file" ||
|
|
357
|
+
bindingCovers(scope, { description: "", paths: [ref.path], exclusions: [] }),
|
|
358
|
+
);
|
|
359
|
+
|
|
360
|
+
/** Actions that take a taskId; without one they apply to the implicit task. */
|
|
361
|
+
const TASK_SCOPED_ACTIONS: ReadonlySet<string> = new Set([
|
|
362
|
+
"task.inspect",
|
|
363
|
+
"task.revise",
|
|
364
|
+
"task.progress",
|
|
365
|
+
"task.pause",
|
|
366
|
+
"task.resume",
|
|
367
|
+
"task.close",
|
|
368
|
+
"policy.assess",
|
|
369
|
+
"policy.preview",
|
|
370
|
+
"policy.explain",
|
|
371
|
+
"evidence.record",
|
|
372
|
+
"finding.record",
|
|
373
|
+
"finding.resolve",
|
|
374
|
+
"decision.record",
|
|
375
|
+
"decision.revoke",
|
|
376
|
+
"worker.assign",
|
|
377
|
+
"worker.report",
|
|
378
|
+
"worker.cancel",
|
|
379
|
+
"state.export",
|
|
380
|
+
]);
|
|
381
|
+
/** Worker actions that name their worker: they resolve to that worker's task. */
|
|
382
|
+
const ONLY_OWN_REPORT = "workers can submit only their own report";
|
|
383
|
+
const NO_HELPER_CONTROL = "helpers cannot cancel or control workers";
|
|
384
|
+
const WORKER_ADDRESSED_ACTIONS: ReadonlySet<string> = new Set(["worker.cancel", "worker.report"]);
|
|
385
|
+
/**
|
|
386
|
+
* The exact CLI command that runs worker `request` (cancel or report, with a
|
|
387
|
+
* valid workerId) on `taskId`, so a retry never depends on the branch.
|
|
388
|
+
*/
|
|
389
|
+
const workerCommand = (taskId: string, request: unknown): string => {
|
|
390
|
+
const { action, workerId, reason, report } = request as Record<string, unknown>;
|
|
391
|
+
const rest = JSON.stringify({ reason, report });
|
|
392
|
+
const payload = rest === "{}" ? "" : ` --payload '${rest.replace(/'/gu, "'\\''")}'`;
|
|
393
|
+
return `workit worker ${String(action)} ${String(workerId)} --task ${taskId}${payload}`;
|
|
394
|
+
};
|
|
395
|
+
/** Recording actions that create the implicit task on first use. */
|
|
396
|
+
const CREATING_ACTIONS: ReadonlySet<string> = new Set([
|
|
397
|
+
"task.revise",
|
|
398
|
+
"task.progress",
|
|
399
|
+
"policy.assess",
|
|
400
|
+
"evidence.record",
|
|
401
|
+
"finding.record",
|
|
402
|
+
"decision.record",
|
|
403
|
+
"worker.assign",
|
|
404
|
+
]);
|
|
405
|
+
/** Commit attempts for a call whose revisions the engine filled (see retryFilledRevisions). */
|
|
406
|
+
const REVISION_RETRY_ATTEMPTS = 8;
|
|
407
|
+
const retryPause = new Int32Array(new SharedArrayBuffer(4));
|
|
408
|
+
/** Which revisions the caller left for the engine to fill from its own read. */
|
|
409
|
+
type FilledRevisions = { task: boolean; workspace: boolean };
|
|
410
|
+
const filledRevisions = (request: unknown): FilledRevisions => {
|
|
411
|
+
const value = (typeof request === "object" && request !== null ? request : {}) as {
|
|
412
|
+
expectedRevision?: unknown;
|
|
413
|
+
expectedWorkspaceRevision?: unknown;
|
|
414
|
+
};
|
|
415
|
+
return {
|
|
416
|
+
task: value.expectedRevision === undefined,
|
|
417
|
+
workspace: value.expectedWorkspaceRevision === undefined,
|
|
418
|
+
};
|
|
419
|
+
};
|
|
420
|
+
/**
|
|
421
|
+
* A store compare-and-swap rejection (it carries the actual revision) on a
|
|
422
|
+
* revision the engine filled. A conflict on a caller-supplied revision, or a
|
|
423
|
+
* semantic conflict such as a changed import source, is never retried.
|
|
424
|
+
*/
|
|
425
|
+
const filledConflict = (result: Result<unknown>, filled: FilledRevisions): boolean =>
|
|
426
|
+
!result.ok &&
|
|
427
|
+
result.code === "revision_conflict" &&
|
|
428
|
+
(("actualRevision" in result.details && filled.task) ||
|
|
429
|
+
("actualWorkspaceRevision" in result.details && filled.workspace));
|
|
430
|
+
/**
|
|
431
|
+
* Re-run `run` while it loses a compare-and-swap race on an engine-filled
|
|
432
|
+
* revision. Each attempt commits at most once and a CAS rejection commits
|
|
433
|
+
* nothing, so a success is applied exactly once. Attempts stop at
|
|
434
|
+
* REVISION_RETRY_ATTEMPTS or once the lock budget has elapsed — so blocking
|
|
435
|
+
* stays within about twice that budget — and end as retryable busy.
|
|
436
|
+
*/
|
|
437
|
+
const retryFilledRevisions = <T>(filled: FilledRevisions, run: () => Result<T>): Result<T> => {
|
|
438
|
+
if (!filled.task && !filled.workspace) return run();
|
|
439
|
+
let result = run();
|
|
440
|
+
// The deadline starts after the first attempt, so a slow first lock wait
|
|
441
|
+
// still leaves at least one retry.
|
|
442
|
+
const deadline = Date.now() + defaultLockTimeout();
|
|
443
|
+
for (let attempt = 1; filledConflict(result, filled); attempt += 1) {
|
|
444
|
+
if (attempt >= REVISION_RETRY_ATTEMPTS || Date.now() >= deadline)
|
|
445
|
+
return failure("busy", "records kept changing under concurrent writers; retry the call");
|
|
446
|
+
// Jittered backoff de-synchronizes writers that lost the same race.
|
|
447
|
+
Atomics.wait(retryPause, 0, 0, Math.floor(Math.random() * 4 * attempt) + 1);
|
|
448
|
+
result = run();
|
|
449
|
+
}
|
|
450
|
+
return result;
|
|
451
|
+
};
|
|
452
|
+
|
|
453
|
+
const trustedNow = (context: OperationContext): Utc =>
|
|
454
|
+
typeof context.now === "function" ? context.now() : context.now;
|
|
455
|
+
|
|
456
|
+
const mapRef = (ref: Ref, ids: Map<string, string>): Ref => {
|
|
457
|
+
if (ref.kind !== "record") return ref;
|
|
458
|
+
return { ...ref, id: ids.get(ref.id) ?? ref.id };
|
|
459
|
+
};
|
|
460
|
+
|
|
461
|
+
const portableRef = (ref: Ref | null): Ref | null =>
|
|
462
|
+
ref && (ref.kind === "file" || ref.kind === "record") ? ref : null;
|
|
463
|
+
const importedProvenance = (context: OperationContext): Provenance => ({
|
|
464
|
+
kind: "imported",
|
|
465
|
+
host: context.caller.host,
|
|
466
|
+
session: null,
|
|
467
|
+
workerId: null,
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
const portableTask = (task: TaskRecord): TaskRecord => {
|
|
471
|
+
// Schema-driven ref stripping: every Ref anywhere in the record is mapped
|
|
472
|
+
// by shape (arrays drop unportable refs, nullable fields null them), so a
|
|
473
|
+
// new ref-bearing field can never silently leak across checkouts.
|
|
474
|
+
const stripped = rewriteRecordRefs(task, taskRecordSchema, portableRef);
|
|
475
|
+
if (typeof stripped !== "object" || stripped === null || Array.isArray(stripped))
|
|
476
|
+
throw new TypeError("portable task rewrite produced a non-record");
|
|
477
|
+
const clone = structuredClone(stripped) as TaskRecord;
|
|
478
|
+
// Candidate metadata can contain environment-derived paths and digests. The destination
|
|
479
|
+
// must recapture its own candidate instead of receiving source checkout material.
|
|
480
|
+
clone.candidates = [];
|
|
481
|
+
return clone;
|
|
482
|
+
};
|
|
483
|
+
|
|
484
|
+
const importedTask = (
|
|
485
|
+
source: TaskRecord,
|
|
486
|
+
bundle: ExportBundle,
|
|
487
|
+
destinationWorkspaceId: string,
|
|
488
|
+
context: OperationContext,
|
|
489
|
+
timestamp: Utc,
|
|
490
|
+
): TaskRecord => {
|
|
491
|
+
const ids = new Map<string, string>();
|
|
492
|
+
const allocate = (id: string) => {
|
|
493
|
+
const existing = ids.get(id);
|
|
494
|
+
if (existing) return existing;
|
|
495
|
+
const value = newId();
|
|
496
|
+
ids.set(id, value);
|
|
497
|
+
return value;
|
|
498
|
+
};
|
|
499
|
+
for (const entry of [
|
|
500
|
+
source.intent,
|
|
501
|
+
...source.assessments,
|
|
502
|
+
...(source.judgments ?? []),
|
|
503
|
+
...source.evidence,
|
|
504
|
+
...source.decisions,
|
|
505
|
+
...source.findings,
|
|
506
|
+
...source.workers,
|
|
507
|
+
])
|
|
508
|
+
allocate(entry.id);
|
|
509
|
+
const fresh = (id: string) => allocate(id);
|
|
510
|
+
const imported = importedProvenance(context);
|
|
511
|
+
const remap = (ref: Ref): Ref | null => mapRef(ref, ids);
|
|
512
|
+
const intent = {
|
|
513
|
+
...source.intent,
|
|
514
|
+
id: fresh(source.intent.id),
|
|
515
|
+
recordedAt: timestamp,
|
|
516
|
+
provenance: imported,
|
|
517
|
+
data: rewriteRecordRefs(source.intent.data, intentSchema, remap) as typeof source.intent.data,
|
|
518
|
+
};
|
|
519
|
+
const assessments = source.assessments.map((entry) => {
|
|
520
|
+
const data = entry.data;
|
|
521
|
+
return {
|
|
522
|
+
...entry,
|
|
523
|
+
id: fresh(entry.id),
|
|
524
|
+
recordedAt: timestamp,
|
|
525
|
+
provenance: imported,
|
|
526
|
+
data: rewriteRecordRefs(data, legacyAssessmentSchema, remap) as typeof data,
|
|
527
|
+
};
|
|
528
|
+
});
|
|
529
|
+
const judgments = source.judgments?.map((entry) => ({
|
|
530
|
+
...entry,
|
|
531
|
+
id: fresh(entry.id),
|
|
532
|
+
recordedAt: timestamp,
|
|
533
|
+
provenance: imported,
|
|
534
|
+
data: rewriteRecordRefs(entry.data, judgmentSchema, remap) as typeof entry.data,
|
|
535
|
+
}));
|
|
536
|
+
const evidence = source.evidence.map((entry) => ({
|
|
537
|
+
...entry,
|
|
538
|
+
id: fresh(entry.id),
|
|
539
|
+
recordedAt: timestamp,
|
|
540
|
+
provenance: imported,
|
|
541
|
+
data: rewriteRecordRefs(entry.data, evidenceSchema, remap) as typeof entry.data,
|
|
542
|
+
}));
|
|
543
|
+
const decisions = source.decisions.map((entry) => ({
|
|
544
|
+
...entry,
|
|
545
|
+
id: fresh(entry.id),
|
|
546
|
+
recordedAt: timestamp,
|
|
547
|
+
provenance: imported,
|
|
548
|
+
}));
|
|
549
|
+
const findings = source.findings.map((entry) => {
|
|
550
|
+
const data = rewriteRecordRefs(entry.data, findingSchema, remap) as typeof entry.data;
|
|
551
|
+
return {
|
|
552
|
+
...entry,
|
|
553
|
+
id: fresh(entry.id),
|
|
554
|
+
recordedAt: timestamp,
|
|
555
|
+
provenance: imported,
|
|
556
|
+
data: {
|
|
557
|
+
...data,
|
|
558
|
+
resolution: data.resolution
|
|
559
|
+
? {
|
|
560
|
+
...data.resolution,
|
|
561
|
+
evidenceIds: data.resolution.evidenceIds.map((id) => ids.get(id) ?? id),
|
|
562
|
+
decisionIds: data.resolution.decisionIds.map((id) => ids.get(id) ?? id),
|
|
563
|
+
}
|
|
564
|
+
: null,
|
|
565
|
+
},
|
|
566
|
+
};
|
|
567
|
+
});
|
|
568
|
+
const workers = source.workers.map((entry) => ({
|
|
569
|
+
...entry,
|
|
570
|
+
id: fresh(entry.id),
|
|
571
|
+
recordedAt: timestamp,
|
|
572
|
+
provenance: imported,
|
|
573
|
+
data: {
|
|
574
|
+
...entry.data,
|
|
575
|
+
state: "stopped" as const,
|
|
576
|
+
session: null,
|
|
577
|
+
assignment: {
|
|
578
|
+
...entry.data.assignment,
|
|
579
|
+
decisionIds: entry.data.assignment.decisionIds.map((id) => ids.get(id) ?? id),
|
|
580
|
+
},
|
|
581
|
+
report: entry.data.report
|
|
582
|
+
? {
|
|
583
|
+
...entry.data.report,
|
|
584
|
+
evidenceIds: entry.data.report.evidenceIds.map((id) => ids.get(id) ?? id),
|
|
585
|
+
findingIds: entry.data.report.findingIds.map((id) => ids.get(id) ?? id),
|
|
586
|
+
}
|
|
587
|
+
: null,
|
|
588
|
+
},
|
|
589
|
+
}));
|
|
590
|
+
const task: TaskRecord = {
|
|
591
|
+
...source,
|
|
592
|
+
id: newId(),
|
|
593
|
+
workspaceId: destinationWorkspaceId,
|
|
594
|
+
revision: newId(),
|
|
595
|
+
createdAt: timestamp,
|
|
596
|
+
updatedAt: timestamp,
|
|
597
|
+
origin: {
|
|
598
|
+
workspaceId: bundle.sourceWorkspaceId,
|
|
599
|
+
taskId: bundle.task.id,
|
|
600
|
+
exportDigest: bundle.digest,
|
|
601
|
+
},
|
|
602
|
+
intent,
|
|
603
|
+
constraints: context.constraints,
|
|
604
|
+
status: "paused",
|
|
605
|
+
closure: null,
|
|
606
|
+
assessments,
|
|
607
|
+
...(judgments ? { judgments } : {}),
|
|
608
|
+
evidence,
|
|
609
|
+
decisions: decisions.map((entry) => {
|
|
610
|
+
const data = rewriteRecordRefs(entry.data, decisionSchema, remap) as typeof entry.data;
|
|
611
|
+
return {
|
|
612
|
+
...entry,
|
|
613
|
+
data: {
|
|
614
|
+
...data,
|
|
615
|
+
binding: {
|
|
616
|
+
...data.binding,
|
|
617
|
+
taskId: "",
|
|
618
|
+
workspaceId: destinationWorkspaceId,
|
|
619
|
+
},
|
|
620
|
+
},
|
|
621
|
+
};
|
|
622
|
+
}),
|
|
623
|
+
findings,
|
|
624
|
+
workers,
|
|
625
|
+
candidates: [],
|
|
626
|
+
policy: null,
|
|
627
|
+
progress: {
|
|
628
|
+
...source.progress,
|
|
629
|
+
blockers: source.progress.blockers.map((blocker) => ({
|
|
630
|
+
...blocker,
|
|
631
|
+
refs: blocker.refs.map((ref) => mapRef(ref, ids)),
|
|
632
|
+
})),
|
|
633
|
+
},
|
|
634
|
+
};
|
|
635
|
+
task.decisions = task.decisions.map((entry) => ({
|
|
636
|
+
...entry,
|
|
637
|
+
data: {
|
|
638
|
+
...entry.data,
|
|
639
|
+
binding: { ...entry.data.binding, taskId: task.id },
|
|
640
|
+
digest: decisionDigest(entry.data),
|
|
641
|
+
},
|
|
642
|
+
}));
|
|
643
|
+
return task;
|
|
644
|
+
};
|
|
645
|
+
|
|
646
|
+
export class WorkitCore {
|
|
647
|
+
private readonly authorityOwner = {};
|
|
648
|
+
|
|
649
|
+
constructor(
|
|
650
|
+
private readonly store: TaskStore,
|
|
651
|
+
private readonly context: OperationContext,
|
|
652
|
+
) {}
|
|
653
|
+
|
|
654
|
+
/** Optional host actions are coordinator-owned; supervised worker contexts cannot invoke them. */
|
|
655
|
+
public isDelegatedCaller(): boolean {
|
|
656
|
+
return this.context.workerId !== null && this.context.workerId !== undefined;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
private contextRootError(): Result<null> {
|
|
660
|
+
return contextRootError(this.store, this.context);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* Omitted revisions default to the records read for this call; explicit values
|
|
665
|
+
* stay CAS-enforced downstream. Call after loading task/workspace records.
|
|
666
|
+
*/
|
|
667
|
+
private fillRevisions(
|
|
668
|
+
input: { expectedRevision?: string; expectedWorkspaceRevision?: string | null },
|
|
669
|
+
task?: { revision: string },
|
|
670
|
+
workspace?: { revision: string } | null,
|
|
671
|
+
): void {
|
|
672
|
+
if (input.expectedRevision === undefined && task) input.expectedRevision = task.revision;
|
|
673
|
+
if (input.expectedWorkspaceRevision === undefined)
|
|
674
|
+
input.expectedWorkspaceRevision = workspace ? workspace.revision : null;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* A revision the caller omitted is not a compare-and-swap request: when
|
|
679
|
+
* another writer commits between this call's read and its locked commit,
|
|
680
|
+
* re-run the whole operation — fresh read, policy, requirement and
|
|
681
|
+
* candidate checks, then commit — so a re-check that now fails returns that
|
|
682
|
+
* failure. Caller-supplied revisions stay strict CAS.
|
|
683
|
+
*/
|
|
684
|
+
private retryOmittedRevisions<T>(request: unknown, run: () => Result<T>): Result<T> {
|
|
685
|
+
return retryFilledRevisions(filledRevisions(request), run);
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* A task-scoped request without a `taskId` applies to the implicit task of
|
|
690
|
+
* this checkout's branch (D3). Recording actions create that task on first
|
|
691
|
+
* use; reads and lifecycle actions only look it up. Helpers never create.
|
|
692
|
+
*/
|
|
693
|
+
private implicitTask(family: OperationFamily, raw: unknown): Result<unknown> {
|
|
694
|
+
// Flat tool inputs become the canonical request first (S17).
|
|
695
|
+
const request = normalizeOperationInput(family, raw, this.context.caller.host);
|
|
696
|
+
if (typeof request !== "object" || request === null || Array.isArray(request))
|
|
697
|
+
return success(null, null, request);
|
|
698
|
+
const value = request as { action?: unknown; taskId?: unknown };
|
|
699
|
+
if (value.taskId !== undefined || typeof value.action !== "string")
|
|
700
|
+
return success(null, null, request);
|
|
701
|
+
const action = `${family}.${value.action}`;
|
|
702
|
+
if (!TASK_SCOPED_ACTIONS.has(action)) return success(null, null, request);
|
|
703
|
+
const root = this.contextRootError();
|
|
704
|
+
if (!root.ok) return root;
|
|
705
|
+
// A worker belongs to the task it was assigned on, whatever branch is
|
|
706
|
+
// checked out now (#264): worker.cancel/report follow the named worker,
|
|
707
|
+
// a helper's own calls follow its worker id.
|
|
708
|
+
const addressed = WORKER_ADDRESSED_ACTIONS.has(action);
|
|
709
|
+
// A missing or malformed id is invalid_input (from taskForWorker).
|
|
710
|
+
const workerId: unknown = addressed
|
|
711
|
+
? (value as { workerId?: unknown }).workerId
|
|
712
|
+
: (this.context.workerId ?? null);
|
|
713
|
+
if (workerId !== null) {
|
|
714
|
+
const owner = this.store.taskForWorker(workerId);
|
|
715
|
+
if (!owner.ok) return owner;
|
|
716
|
+
if (owner.data) return success(null, null, { ...request, taskId: owner.data });
|
|
717
|
+
if (addressed)
|
|
718
|
+
return failure(
|
|
719
|
+
"not_found",
|
|
720
|
+
`worker ${workerId as string} is on no open task; find its task with \`workit task status --all\`, then run \`${workerCommand("<task-id>", request)}\``,
|
|
721
|
+
);
|
|
722
|
+
}
|
|
723
|
+
const create = CREATING_ACTIONS.has(action) && (this.context.workerId ?? null) === null;
|
|
724
|
+
const found = this.store.implicitTask({
|
|
725
|
+
provenance: provenance(this.context),
|
|
726
|
+
create,
|
|
727
|
+
now: trustedNow(this.context),
|
|
728
|
+
});
|
|
729
|
+
if (!found.ok) return found;
|
|
730
|
+
if (!found.data)
|
|
731
|
+
return failure(
|
|
732
|
+
"not_found",
|
|
733
|
+
'no task for this branch yet; pass taskId, or record something first (`workit task start "<objective>"`, a note, a check) to create it',
|
|
734
|
+
);
|
|
735
|
+
return success(null, null, { ...request, taskId: found.data.task.id });
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
private helperEntry(
|
|
739
|
+
task: TaskRecord,
|
|
740
|
+
requireSession = false,
|
|
741
|
+
requireActive = false,
|
|
742
|
+
): Result<Entry<Worker>> {
|
|
743
|
+
return helperEntry(task, this.context, requireSession, requireActive);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
private helperTaskGuard(task: TaskRecord, allowRead = false): Result<null> {
|
|
747
|
+
return helperTaskGuard(task, this.context, allowRead);
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
private activeWorkerBlocker(task: TaskRecord) {
|
|
751
|
+
if (task.workers.some((entry) => workerBlocksTransition("close", entry.data.state)))
|
|
752
|
+
return failure("recovery_required", "worker state requires reconciliation");
|
|
753
|
+
return null;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
state(request: unknown): Result<ExportBundle | TaskSummary | TaskRecord | WorkspaceRecord> {
|
|
757
|
+
const resolved = this.implicitTask("state", request);
|
|
758
|
+
if (!resolved.ok) return resolved;
|
|
759
|
+
const value = resolved.data;
|
|
760
|
+
return (value as { action?: unknown } | null)?.action === "import"
|
|
761
|
+
? this.retryOmittedRevisions(value, () => this.stateOnce(value))
|
|
762
|
+
: this.stateOnce(value);
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
private stateOnce(
|
|
766
|
+
request: unknown,
|
|
767
|
+
): Result<ExportBundle | TaskSummary | TaskRecord | WorkspaceRecord> {
|
|
768
|
+
const root = this.contextRootError();
|
|
769
|
+
if (!root.ok) return root;
|
|
770
|
+
if ((this.context.workerId ?? null) !== null)
|
|
771
|
+
return failure("permission_denied", "helpers cannot control task state");
|
|
772
|
+
const parsed = parseOperation("state", request);
|
|
773
|
+
if (!parsed.ok) return parsed;
|
|
774
|
+
const input = parsed.data;
|
|
775
|
+
if (input.action === "export") {
|
|
776
|
+
const task = this.store.readTask(input.taskId);
|
|
777
|
+
if (!task.ok) return task;
|
|
778
|
+
const workspace = this.store.readWorkspace();
|
|
779
|
+
if (!workspace.ok) return workspace;
|
|
780
|
+
if (!workspace.data) return failure("not_found", "workspace not found");
|
|
781
|
+
const portable = taskRecordSchema.safeParse(portableTask(task.data));
|
|
782
|
+
if (!portable.success)
|
|
783
|
+
return failure("storage_error", "portable task is invalid after sanitization");
|
|
784
|
+
const bundle = {
|
|
785
|
+
schemaVersion: 1 as const,
|
|
786
|
+
exportedAt: trustedNow(this.context),
|
|
787
|
+
sourceWorkspaceId: workspace.data.id,
|
|
788
|
+
task: portable.data,
|
|
789
|
+
};
|
|
790
|
+
const checked = exportBundleSchema.safeParse({
|
|
791
|
+
...bundle,
|
|
792
|
+
digest: exportDigest(bundle),
|
|
793
|
+
});
|
|
794
|
+
if (!checked.success) return failure("storage_error", "portable export bundle is invalid");
|
|
795
|
+
return success(task.data.revision, workspace.data.revision, checked.data);
|
|
796
|
+
}
|
|
797
|
+
if (input.action === "import") {
|
|
798
|
+
const checked = exportBundleSchema.safeParse(input.bundle);
|
|
799
|
+
if (!checked.success) return failure("invalid_input", "export bundle is invalid");
|
|
800
|
+
const bundle = checked.data;
|
|
801
|
+
if (
|
|
802
|
+
bundle.sourceWorkspaceId !== bundle.task.workspaceId ||
|
|
803
|
+
exportDigest({
|
|
804
|
+
schemaVersion: bundle.schemaVersion,
|
|
805
|
+
exportedAt: bundle.exportedAt,
|
|
806
|
+
sourceWorkspaceId: bundle.sourceWorkspaceId,
|
|
807
|
+
task: bundle.task,
|
|
808
|
+
}) !== bundle.digest
|
|
809
|
+
)
|
|
810
|
+
return failure("invalid_input", "export bundle digest is invalid");
|
|
811
|
+
const workspace = this.store.readWorkspace();
|
|
812
|
+
if (!workspace.ok) return workspace;
|
|
813
|
+
const destinationWorkspaceId = workspace.data?.id ?? newId();
|
|
814
|
+
this.fillRevisions(input, undefined, workspace.data ?? null);
|
|
815
|
+
const timestamp = trustedNow(this.context);
|
|
816
|
+
const task = importedTask(
|
|
817
|
+
bundle.task,
|
|
818
|
+
bundle,
|
|
819
|
+
destinationWorkspaceId,
|
|
820
|
+
this.context,
|
|
821
|
+
timestamp,
|
|
822
|
+
);
|
|
823
|
+
const imported = this.store.importTask({
|
|
824
|
+
task,
|
|
825
|
+
expectedWorkspaceRevision: input.expectedWorkspaceRevision,
|
|
826
|
+
workspaceId: destinationWorkspaceId,
|
|
827
|
+
now: timestamp,
|
|
828
|
+
});
|
|
829
|
+
if (!imported.ok) return imported;
|
|
830
|
+
return this.summary(imported.data);
|
|
831
|
+
}
|
|
832
|
+
return failure("invalid_input", "unknown state action");
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
task(request: unknown): Result<TaskSummary | TaskBrief | TaskListItem[] | TaskView> {
|
|
836
|
+
const resolved = this.implicitTask("task", request);
|
|
837
|
+
if (!resolved.ok) return resolved;
|
|
838
|
+
const value = resolved.data;
|
|
839
|
+
return this.retryOmittedRevisions(value, () => this.taskOnce(value));
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
private taskOnce(request: unknown): Result<TaskSummary | TaskBrief | TaskListItem[] | TaskView> {
|
|
843
|
+
const root = this.contextRootError();
|
|
844
|
+
if (!root.ok) return root;
|
|
845
|
+
const parsed = parseOperation("task", request);
|
|
846
|
+
if (!parsed.ok) return parsed;
|
|
847
|
+
const input = parsed.data;
|
|
848
|
+
if (this.context.readOnlyChild) {
|
|
849
|
+
if (input.action !== "inspect")
|
|
850
|
+
return failure("permission_denied", "child sessions can only inspect the task summary");
|
|
851
|
+
const task = this.store.readTask(input.taskId);
|
|
852
|
+
if (!task.ok) return task;
|
|
853
|
+
const summary = this.summary(task.data);
|
|
854
|
+
return summary.ok ? { ...summary, data: boundTaskSummary(summary.data) } : summary;
|
|
855
|
+
}
|
|
856
|
+
if ((this.context.workerId ?? null) !== null) {
|
|
857
|
+
if (input.action !== "inspect" && input.action !== "list")
|
|
858
|
+
return failure("permission_denied", "helpers cannot control task lifecycle or scope");
|
|
859
|
+
}
|
|
860
|
+
switch (input.action) {
|
|
861
|
+
case "start": {
|
|
862
|
+
if (input.expectedWorkspaceRevision === undefined) {
|
|
863
|
+
const workspace = this.store.readWorkspace();
|
|
864
|
+
if (!workspace.ok) return workspace;
|
|
865
|
+
input.expectedWorkspaceRevision = workspace.data?.revision ?? null;
|
|
866
|
+
}
|
|
867
|
+
const created = this.store.create({
|
|
868
|
+
intent: input.intent,
|
|
869
|
+
provenance: provenance(this.context),
|
|
870
|
+
expectedWorkspaceRevision: input.expectedWorkspaceRevision,
|
|
871
|
+
now: trustedNow(this.context),
|
|
872
|
+
});
|
|
873
|
+
if (!created.ok) return created;
|
|
874
|
+
return this.summary(created.data);
|
|
875
|
+
}
|
|
876
|
+
case "list": {
|
|
877
|
+
const listed = this.store.listTasks();
|
|
878
|
+
if (!listed.ok) return listed;
|
|
879
|
+
const status = input.status ?? "open";
|
|
880
|
+
const limit = input.limit ?? 20;
|
|
881
|
+
const query = input.query?.toLowerCase().split(/\s+/);
|
|
882
|
+
const selected = listed.data
|
|
883
|
+
.filter((task) =>
|
|
884
|
+
status === "all"
|
|
885
|
+
? true
|
|
886
|
+
: status === "closed"
|
|
887
|
+
? task.status === "closed"
|
|
888
|
+
: task.status !== "closed",
|
|
889
|
+
)
|
|
890
|
+
.filter(
|
|
891
|
+
(task) =>
|
|
892
|
+
!query ||
|
|
893
|
+
query.every((term: string) =>
|
|
894
|
+
JSON.stringify({
|
|
895
|
+
id: task.id,
|
|
896
|
+
workspaceId: task.workspaceId,
|
|
897
|
+
origin: task.origin,
|
|
898
|
+
createdAt: task.createdAt,
|
|
899
|
+
updatedAt: task.updatedAt,
|
|
900
|
+
source: task.intent.provenance,
|
|
901
|
+
objective: task.intent.data.objective,
|
|
902
|
+
progress: task.progress,
|
|
903
|
+
decisions: task.decisions.map(({ data }) => data),
|
|
904
|
+
})
|
|
905
|
+
.toLowerCase()
|
|
906
|
+
.includes(term),
|
|
907
|
+
),
|
|
908
|
+
)
|
|
909
|
+
.toSorted((left, right) => right.updatedAt.localeCompare(left.updatedAt))
|
|
910
|
+
.slice(0, limit);
|
|
911
|
+
if (selected.length === 0) return success(null, null, []);
|
|
912
|
+
const workspace = this.store.readWorkspace();
|
|
913
|
+
if (!workspace.ok) return workspace;
|
|
914
|
+
if (!workspace.data) return failure("not_found", "workspace not found");
|
|
915
|
+
const tasks = selected.map((task): TaskListItem => ({
|
|
916
|
+
id: task.id,
|
|
917
|
+
revision: task.revision,
|
|
918
|
+
workspaceRevision: workspace.data!.revision,
|
|
919
|
+
createdAt: task.createdAt,
|
|
920
|
+
updatedAt: task.updatedAt,
|
|
921
|
+
runtime: task.runtime ?? null,
|
|
922
|
+
objective: task.intent.data.objective,
|
|
923
|
+
status: task.status,
|
|
924
|
+
closure: task.closure,
|
|
925
|
+
progress: task.progress,
|
|
926
|
+
source: {
|
|
927
|
+
host: task.intent.provenance.host,
|
|
928
|
+
kind: task.intent.provenance.kind,
|
|
929
|
+
},
|
|
930
|
+
}));
|
|
931
|
+
return success(null, null, tasks);
|
|
932
|
+
}
|
|
933
|
+
case "inspect": {
|
|
934
|
+
const task = this.store.readTask(input.taskId);
|
|
935
|
+
if (!task.ok) return task;
|
|
936
|
+
const helper = this.helperTaskGuard(task.data, true);
|
|
937
|
+
if (!helper.ok) return helper;
|
|
938
|
+
if ((input.view ?? "summary") === "summary") return this.summary(task.data);
|
|
939
|
+
return this.view(task.data);
|
|
940
|
+
}
|
|
941
|
+
case "pause":
|
|
942
|
+
return this.transition(input, "paused");
|
|
943
|
+
case "resume":
|
|
944
|
+
return this.transition(input, "active");
|
|
945
|
+
case "progress":
|
|
946
|
+
return this.progress(input);
|
|
947
|
+
case "revise":
|
|
948
|
+
return this.revise(input);
|
|
949
|
+
case "close":
|
|
950
|
+
return this.close(input);
|
|
951
|
+
default:
|
|
952
|
+
return failure("invalid_transition", `task action ${input.action} is not implemented`);
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
policy(request: unknown): Result<Policy | null> {
|
|
957
|
+
const resolved = this.implicitTask("policy", request);
|
|
958
|
+
if (!resolved.ok) return resolved;
|
|
959
|
+
const value = resolved.data;
|
|
960
|
+
return this.retryOmittedRevisions(value, () => this.policyOnce(value));
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
private policyOnce(request: unknown): Result<Policy | null> {
|
|
964
|
+
const root = this.contextRootError();
|
|
965
|
+
if (!root.ok) return root;
|
|
966
|
+
const parsed = parseOperation("policy", request);
|
|
967
|
+
if (!parsed.ok) return parsed;
|
|
968
|
+
const input = parsed.data;
|
|
969
|
+
const task = this.store.readTask(input.taskId);
|
|
970
|
+
if (!task.ok) return task;
|
|
971
|
+
if (input.action === "explain") return success(task.data.revision, null, task.data.policy);
|
|
972
|
+
if ((this.context.workerId ?? null) !== null)
|
|
973
|
+
return failure("permission_denied", "helpers cannot change task requirements");
|
|
974
|
+
this.fillRevisions(input, task.data);
|
|
975
|
+
const previous = latestJudgment(task.data);
|
|
976
|
+
const judged = normalizeJudgment(input.judgment, previous);
|
|
977
|
+
if (!judged.ok) return judged;
|
|
978
|
+
const judgment = judged.data.judgment;
|
|
979
|
+
const resolved = this.resolve(task.data, judgment);
|
|
980
|
+
if (!resolved.ok) return resolved;
|
|
981
|
+
if (input.action === "preview") return success(null, null, resolved.data);
|
|
982
|
+
if (input.action !== "assess")
|
|
983
|
+
return failure("invalid_transition", "unsupported policy action");
|
|
984
|
+
// M6: a session lifting a before-write blocker it judged itself gives a
|
|
985
|
+
// reason; every assess is recorded in the ledger below either way.
|
|
986
|
+
const lifted = liftedBlockers(previous, judgment);
|
|
987
|
+
const why = givenReason(input.judgment);
|
|
988
|
+
const own = ownLiftedBlockers(task.data.judgments ?? [], lifted, this.context.caller.actor);
|
|
989
|
+
if (own.length && !why)
|
|
990
|
+
return failure(
|
|
991
|
+
"invalid_input",
|
|
992
|
+
`this session judged ${own.join(" and ")} true on this task; lifting it needs a reason: re-run with --why "<reason>" (recorded in the ledger)`,
|
|
993
|
+
);
|
|
994
|
+
// The audit row is sized before anything changes, so a blocker is never
|
|
995
|
+
// lifted with a reason too long to record.
|
|
996
|
+
const audit = this.judgedRow(
|
|
997
|
+
task.data.id,
|
|
998
|
+
input.judgment,
|
|
999
|
+
judgment,
|
|
1000
|
+
resolved.data,
|
|
1001
|
+
lifted,
|
|
1002
|
+
why,
|
|
1003
|
+
);
|
|
1004
|
+
const bytes = observedRowBytes(audit);
|
|
1005
|
+
if (bytes > MAX_LINE_BYTES)
|
|
1006
|
+
return failure(
|
|
1007
|
+
"invalid_input",
|
|
1008
|
+
`the judgment is too long to record in the ledger (${bytes} bytes; the limit is ${MAX_LINE_BYTES}): shorten --why and pass details by --ref <path>`,
|
|
1009
|
+
);
|
|
1010
|
+
const changed = this.store.mutateTask(
|
|
1011
|
+
task.data.id,
|
|
1012
|
+
input.expectedRevision,
|
|
1013
|
+
(current, mutation) => {
|
|
1014
|
+
if (current.status === "closed")
|
|
1015
|
+
return failure("invalid_transition", "closed task cannot be assessed");
|
|
1016
|
+
const nextChange = diffPolicy(
|
|
1017
|
+
current.policy,
|
|
1018
|
+
resolved.data,
|
|
1019
|
+
"policy reassessed",
|
|
1020
|
+
mutation.now,
|
|
1021
|
+
);
|
|
1022
|
+
const entry = {
|
|
1023
|
+
id: newId(),
|
|
1024
|
+
recordedAt: mutation.now,
|
|
1025
|
+
provenance: provenance(this.context),
|
|
1026
|
+
data: judgment,
|
|
1027
|
+
};
|
|
1028
|
+
const next = {
|
|
1029
|
+
...current,
|
|
1030
|
+
constraints: this.context.constraints,
|
|
1031
|
+
judgments: [...(current.judgments ?? []), entry],
|
|
1032
|
+
// Older readers would drop judgments on rewrite: they fail closed instead.
|
|
1033
|
+
critical: [...new Set([...(current.critical ?? []), "judgments"])],
|
|
1034
|
+
policy: resolved.data,
|
|
1035
|
+
policyChanges: nextChange
|
|
1036
|
+
? [...current.policyChanges, nextChange]
|
|
1037
|
+
: current.policyChanges,
|
|
1038
|
+
};
|
|
1039
|
+
return success(mutation.revision, null, next);
|
|
1040
|
+
},
|
|
1041
|
+
trustedNow(this.context),
|
|
1042
|
+
);
|
|
1043
|
+
if (!changed.ok) return changed;
|
|
1044
|
+
// Best effort: the assessment already stands in the task store.
|
|
1045
|
+
appendObserved(this.store.root, audit);
|
|
1046
|
+
return success(changed.data.revision, null, changed.data.policy);
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
/**
|
|
1050
|
+
* The ledger row that makes a judge call auditable (M6): who judged, what
|
|
1051
|
+
* was sent (the reason once, in `why`), what it resolved to, the
|
|
1052
|
+
* requirements and the blockers it lifted. Refs are capped to keep the row
|
|
1053
|
+
* within one ledger line.
|
|
1054
|
+
*/
|
|
1055
|
+
private judgedRow(
|
|
1056
|
+
taskId: string,
|
|
1057
|
+
input: unknown,
|
|
1058
|
+
judgment: Judgment,
|
|
1059
|
+
policy: Policy | null,
|
|
1060
|
+
lifted: readonly string[],
|
|
1061
|
+
why: string | null,
|
|
1062
|
+
) {
|
|
1063
|
+
const root = this.store.root;
|
|
1064
|
+
const branch = currentBranch(root);
|
|
1065
|
+
const { note: _note, refs, ...calls } = judgment;
|
|
1066
|
+
return {
|
|
1067
|
+
type: "policy.judged",
|
|
1068
|
+
actor: {
|
|
1069
|
+
host: this.context.caller.host,
|
|
1070
|
+
session: this.context.caller.actor,
|
|
1071
|
+
agentId: this.context.workerId ?? null,
|
|
1072
|
+
},
|
|
1073
|
+
branch,
|
|
1074
|
+
head: branch ? headSha(root) : null,
|
|
1075
|
+
taskId,
|
|
1076
|
+
input: judgeInputSummary(input),
|
|
1077
|
+
judgment: { ...calls, refs: refs.slice(0, MAX_JUDGED_REFS), refCount: refs.length },
|
|
1078
|
+
requirements: (policy?.requirements ?? []).map((requirement) => requirement.ruleId),
|
|
1079
|
+
lifted,
|
|
1080
|
+
why,
|
|
1081
|
+
};
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
evidence(request: unknown): Result<Entry<Evidence>> {
|
|
1085
|
+
const resolved = this.implicitTask("evidence", request);
|
|
1086
|
+
if (!resolved.ok) return resolved;
|
|
1087
|
+
const value = resolved.data;
|
|
1088
|
+
return this.retryOmittedRevisions(value, () => this.evidenceOnce(value));
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
private evidenceOnce(request: unknown): Result<Entry<Evidence>> {
|
|
1092
|
+
const root = this.contextRootError();
|
|
1093
|
+
if (!root.ok) return root;
|
|
1094
|
+
const parsed = parseOperation("evidence", request);
|
|
1095
|
+
if (!parsed.ok) return parsed;
|
|
1096
|
+
const input = parsed.data;
|
|
1097
|
+
const task = this.store.readTask(input.taskId);
|
|
1098
|
+
if (!task.ok) return task;
|
|
1099
|
+
if (task.data.status === "closed")
|
|
1100
|
+
return failure("invalid_transition", "closed task cannot record evidence");
|
|
1101
|
+
this.fillRevisions(input, task.data);
|
|
1102
|
+
const helper =
|
|
1103
|
+
(this.context.workerId ?? null) === null ? null : this.helperEntry(task.data, true, true);
|
|
1104
|
+
if (helper && !helper.ok) return helper;
|
|
1105
|
+
const evidence = input.evidence as Evidence;
|
|
1106
|
+
if (
|
|
1107
|
+
(evidence.result === "missing" || evidence.result === "skipped") &&
|
|
1108
|
+
!evidence.summary.trim()
|
|
1109
|
+
)
|
|
1110
|
+
return failure("invalid_input", "missing or skipped evidence requires a reason");
|
|
1111
|
+
if (evidence.kind === "review") {
|
|
1112
|
+
if (!sameSession(evidence.reviewContext, this.context))
|
|
1113
|
+
return failure("invalid_input", "review context must match the trusted caller session");
|
|
1114
|
+
}
|
|
1115
|
+
const currentCandidate = captureCandidate(
|
|
1116
|
+
this.store.root,
|
|
1117
|
+
task.data.intent.data.scope,
|
|
1118
|
+
environment(),
|
|
1119
|
+
);
|
|
1120
|
+
if (!currentCandidate.ok) return currentCandidate;
|
|
1121
|
+
// Resolve the binding first (explicit IDs, else the worker pin, else the
|
|
1122
|
+
// current candidate), then validate and pin-check the resolved values —
|
|
1123
|
+
// never the caller's possibly-empty fields. The input itself is untouched.
|
|
1124
|
+
const pin = helper && helper.ok ? helper.data.data.assignment.candidateId : null;
|
|
1125
|
+
const bound = resolveBinding(evidence, pin, currentCandidate.data.id);
|
|
1126
|
+
if (helper?.ok) {
|
|
1127
|
+
const assignment = helper.data.data.assignment;
|
|
1128
|
+
if (
|
|
1129
|
+
evidence.requirementIds.some((id: string) => !assignment.requirementIds.includes(id)) ||
|
|
1130
|
+
!checkPin(assignment, bound.beforeCandidateId, bound.candidateId) ||
|
|
1131
|
+
!refsWithinScope(evidence.refs, assignment.scope) ||
|
|
1132
|
+
!refsWithinScope(evidence.refs, task.data.intent.data.scope)
|
|
1133
|
+
)
|
|
1134
|
+
return failure("permission_denied", "evidence is outside the worker assignment");
|
|
1135
|
+
}
|
|
1136
|
+
const knownCandidates = new Set(task.data.candidates.map((candidate) => candidate.id));
|
|
1137
|
+
for (const candidateId of [bound.beforeCandidateId, bound.candidateId]) {
|
|
1138
|
+
if (
|
|
1139
|
+
candidateId &&
|
|
1140
|
+
candidateId !== currentCandidate.data.id &&
|
|
1141
|
+
!knownCandidates.has(candidateId)
|
|
1142
|
+
)
|
|
1143
|
+
return failure("invalid_input", "evidence candidate is not a captured candidate");
|
|
1144
|
+
}
|
|
1145
|
+
const boundEvidence = { ...evidence, ...bound };
|
|
1146
|
+
const changed = this.store.mutateTask(
|
|
1147
|
+
task.data.id,
|
|
1148
|
+
input.expectedRevision,
|
|
1149
|
+
(current, mutation) => {
|
|
1150
|
+
const candidates = current.candidates.some((item) => item.id === currentCandidate.data.id)
|
|
1151
|
+
? current.candidates
|
|
1152
|
+
: [...current.candidates, currentCandidate.data];
|
|
1153
|
+
const entry = {
|
|
1154
|
+
id: newId(),
|
|
1155
|
+
recordedAt: mutation.now,
|
|
1156
|
+
provenance: provenance(this.context, "agent_reported"),
|
|
1157
|
+
data: boundEvidence,
|
|
1158
|
+
};
|
|
1159
|
+
return success(mutation.revision, null, {
|
|
1160
|
+
...current,
|
|
1161
|
+
candidates,
|
|
1162
|
+
evidence: [...current.evidence, entry],
|
|
1163
|
+
findings: current.findings.map((finding) => {
|
|
1164
|
+
if (finding.data.disposition === "open" || boundEvidence.result === "skipped")
|
|
1165
|
+
return finding;
|
|
1166
|
+
if (finding.data.disposition !== "fixed") return finding;
|
|
1167
|
+
// A fix stands while its verification still passes on the current
|
|
1168
|
+
// tree; it reopens only on lapse or direct contradiction. Judged
|
|
1169
|
+
// dispositions never auto-reopen.
|
|
1170
|
+
const withEntry = { ...current, candidates, evidence: [...current.evidence, entry] };
|
|
1171
|
+
const statuses = new Map(
|
|
1172
|
+
evaluateEvidence(
|
|
1173
|
+
withEntry,
|
|
1174
|
+
currentCandidate.data,
|
|
1175
|
+
treeFreshness(this.store.root),
|
|
1176
|
+
).map((item) => [item.evidenceId, item.status]),
|
|
1177
|
+
);
|
|
1178
|
+
const verified = withEntry.evidence.some((item) =>
|
|
1179
|
+
findingVerificationPasses(finding.data.candidateId, {
|
|
1180
|
+
kind: item.data.kind,
|
|
1181
|
+
candidateId: item.data.candidateId,
|
|
1182
|
+
status: statuses.get(item.id) ?? "missing",
|
|
1183
|
+
}),
|
|
1184
|
+
);
|
|
1185
|
+
const contradicted =
|
|
1186
|
+
boundEvidence.result === "failed" &&
|
|
1187
|
+
finding.data.refs.some((left: Ref) =>
|
|
1188
|
+
boundEvidence.refs.some((right: Ref) => sameValue(left, right)),
|
|
1189
|
+
);
|
|
1190
|
+
return verified && !contradicted
|
|
1191
|
+
? finding
|
|
1192
|
+
: { ...finding, data: { ...finding.data, disposition: "open", resolution: null } };
|
|
1193
|
+
}),
|
|
1194
|
+
});
|
|
1195
|
+
},
|
|
1196
|
+
trustedNow(this.context),
|
|
1197
|
+
);
|
|
1198
|
+
if (!changed.ok) return changed;
|
|
1199
|
+
return success(changed.data.revision, null, changed.data.evidence.at(-1)!);
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
/**
|
|
1203
|
+
* Record a check the CLI itself ran and observed (`workit check`, design
|
|
1204
|
+
* §2.1 S9). Host-only, like observeDecision: the caller must be the workit
|
|
1205
|
+
* CLI (never a helper), and agents reach evidence only through
|
|
1206
|
+
* `evidence.record`, which stays agent_reported and cannot carry an
|
|
1207
|
+
* observation. The entry is `host_observed` by `workit_cli`, keyed to the
|
|
1208
|
+
* worktree tree in the observation, and the task lists the observation
|
|
1209
|
+
* path as critical so an older reader fails closed instead of dropping it.
|
|
1210
|
+
* Outside git (no tree key) it is bound to the current candidate instead.
|
|
1211
|
+
*/
|
|
1212
|
+
observeCheck(request: ObserveCheckRequest): Result<Entry<Evidence>> {
|
|
1213
|
+
return this.retryOmittedRevisions(request, () => this.observeCheckOnce(request));
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
private observeCheckOnce(request: ObserveCheckRequest): Result<Entry<Evidence>> {
|
|
1217
|
+
const root = this.contextRootError();
|
|
1218
|
+
if (!root.ok) return root;
|
|
1219
|
+
if (this.context.caller.host !== "workit_cli" || (this.context.workerId ?? null) !== null)
|
|
1220
|
+
return failure("permission_denied", "only the workit CLI records observed checks");
|
|
1221
|
+
const observation = checkObservationSchema.safeParse(request?.observation);
|
|
1222
|
+
if (!observation.success)
|
|
1223
|
+
return failure("invalid_input", "check observation is invalid", {
|
|
1224
|
+
fields: observation.error.issues.map((issue) => ({
|
|
1225
|
+
path: issue.path.join("."),
|
|
1226
|
+
reason: issue.message,
|
|
1227
|
+
})),
|
|
1228
|
+
});
|
|
1229
|
+
const task = this.store.readTask(request.taskId);
|
|
1230
|
+
if (!task.ok) return task;
|
|
1231
|
+
if (task.data.status === "closed")
|
|
1232
|
+
return failure("invalid_transition", "closed task cannot record evidence");
|
|
1233
|
+
const observed: CheckObservation = observation.data;
|
|
1234
|
+
let candidate: Candidate | null = null;
|
|
1235
|
+
if (observed.tree === null) {
|
|
1236
|
+
const captured = captureCandidate(
|
|
1237
|
+
this.store.root,
|
|
1238
|
+
task.data.intent.data.scope,
|
|
1239
|
+
environment(),
|
|
1240
|
+
);
|
|
1241
|
+
if (!captured.ok) return captured;
|
|
1242
|
+
candidate = captured.data;
|
|
1243
|
+
}
|
|
1244
|
+
const label = observed.name ?? observed.argv.join(" ");
|
|
1245
|
+
const data: Evidence = {
|
|
1246
|
+
kind: "check",
|
|
1247
|
+
claim: `workit check ${label}`.slice(0, 500),
|
|
1248
|
+
requirementIds: [],
|
|
1249
|
+
...(candidate ? { beforeCandidateId: candidate.id, candidateId: candidate.id } : {}),
|
|
1250
|
+
result: observed.exitCode === 0 ? "passed" : "failed",
|
|
1251
|
+
summary: `${observed.configured ? "configured" : "ad-hoc"} check exited ${observed.exitCode}${observed.timedOut ? " (timed out)" : ""} in ${observed.durationMs} ms`,
|
|
1252
|
+
refs: [],
|
|
1253
|
+
exitCode: observed.exitCode,
|
|
1254
|
+
reviewContext: null,
|
|
1255
|
+
observation: observed,
|
|
1256
|
+
};
|
|
1257
|
+
const changed = this.store.mutateTask(
|
|
1258
|
+
task.data.id,
|
|
1259
|
+
request.expectedRevision ?? task.data.revision,
|
|
1260
|
+
(current, mutation) => {
|
|
1261
|
+
const candidates =
|
|
1262
|
+
candidate === null || current.candidates.some((item) => item.id === candidate.id)
|
|
1263
|
+
? current.candidates
|
|
1264
|
+
: [...current.candidates, candidate];
|
|
1265
|
+
const critical = [...new Set([...(current.critical ?? []), CHECK_OBSERVATION_PATH])];
|
|
1266
|
+
return success(mutation.revision, null, {
|
|
1267
|
+
...current,
|
|
1268
|
+
critical,
|
|
1269
|
+
candidates,
|
|
1270
|
+
evidence: [
|
|
1271
|
+
...current.evidence,
|
|
1272
|
+
{
|
|
1273
|
+
id: newId(),
|
|
1274
|
+
recordedAt: mutation.now,
|
|
1275
|
+
provenance: provenance(this.context, "host_observed"),
|
|
1276
|
+
data,
|
|
1277
|
+
},
|
|
1278
|
+
],
|
|
1279
|
+
});
|
|
1280
|
+
},
|
|
1281
|
+
trustedNow(this.context),
|
|
1282
|
+
);
|
|
1283
|
+
if (!changed.ok) return changed;
|
|
1284
|
+
return success(changed.data.revision, null, changed.data.evidence.at(-1)!);
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
decision(request: unknown): Result<Entry<Decision>> {
|
|
1288
|
+
const resolved = this.implicitTask("decision", request);
|
|
1289
|
+
return resolved.ok ? this.recordDecision(resolved.data) : resolved;
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
/**
|
|
1293
|
+
* Commit a task mutation whose update re-validates everything it depends on
|
|
1294
|
+
* under the lock: a CAS loss on an engine-filled revision re-reads the
|
|
1295
|
+
* record, repeats `recheck` against it and retries the commit.
|
|
1296
|
+
*/
|
|
1297
|
+
private commitTask(
|
|
1298
|
+
taskId: string,
|
|
1299
|
+
expected: string,
|
|
1300
|
+
filled: boolean,
|
|
1301
|
+
recheck: (fresh: TaskRecord) => Result<unknown>,
|
|
1302
|
+
update: Parameters<TaskStore["mutateTask"]>[2],
|
|
1303
|
+
): Result<TaskRecord> {
|
|
1304
|
+
let revision = expected;
|
|
1305
|
+
let first = true;
|
|
1306
|
+
return retryFilledRevisions({ task: filled, workspace: false }, () => {
|
|
1307
|
+
if (!first) {
|
|
1308
|
+
const fresh = this.store.readTask(taskId);
|
|
1309
|
+
if (!fresh.ok) return fresh;
|
|
1310
|
+
const checked = recheck(fresh.data);
|
|
1311
|
+
if (!checked.ok) return checked;
|
|
1312
|
+
revision = fresh.data.revision;
|
|
1313
|
+
}
|
|
1314
|
+
first = false;
|
|
1315
|
+
return this.store.mutateTask(taskId, revision, update, trustedNow(this.context));
|
|
1316
|
+
});
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
/**
|
|
1320
|
+
* A decision is a durable, agent-asserted record (D2, D18). It satisfies
|
|
1321
|
+
* `decisions` requirements and accepted limitations; it never authorizes an
|
|
1322
|
+
* external effect, so no host attestation is minted or checked.
|
|
1323
|
+
*/
|
|
1324
|
+
private recordDecision(request: unknown): Result<Entry<Decision>> {
|
|
1325
|
+
const root = this.contextRootError();
|
|
1326
|
+
if (!root.ok) return root;
|
|
1327
|
+
const parsed = parseOperation("decision", request);
|
|
1328
|
+
if (!parsed.ok) return parsed;
|
|
1329
|
+
const input = parsed.data;
|
|
1330
|
+
const stated = input.action === "record" && input.response === "stated";
|
|
1331
|
+
if (stated && !input.binding?.statedChoice)
|
|
1332
|
+
return failure("invalid_input", "stated choices require binding.statedChoice");
|
|
1333
|
+
const task = this.store.readTask(input.taskId);
|
|
1334
|
+
if (!task.ok) return task;
|
|
1335
|
+
if ((this.context.workerId ?? null) !== null)
|
|
1336
|
+
return failure("permission_denied", "helpers cannot record or revoke decisions");
|
|
1337
|
+
const filled = input.expectedRevision === undefined;
|
|
1338
|
+
this.fillRevisions(input, task.data);
|
|
1339
|
+
if (input.action === "record") {
|
|
1340
|
+
if (task.data.status === "closed")
|
|
1341
|
+
return failure("invalid_transition", "closed task cannot record a decision");
|
|
1342
|
+
const workspace = this.store.readWorkspace();
|
|
1343
|
+
if (!workspace.ok) return workspace;
|
|
1344
|
+
if (!workspace.data) return failure("not_found", "workspace not found");
|
|
1345
|
+
// Flat inputs omit the binding ids: a decision binds to where it is recorded.
|
|
1346
|
+
const binding = {
|
|
1347
|
+
...input.binding,
|
|
1348
|
+
taskId: input.binding.taskId ?? task.data.id,
|
|
1349
|
+
workspaceId: input.binding.workspaceId ?? workspace.data.id,
|
|
1350
|
+
} as Decision["binding"];
|
|
1351
|
+
if (binding.taskId !== task.data.id || binding.workspaceId !== workspace.data.id)
|
|
1352
|
+
return failure("invalid_input", "decision task or workspace binding is invalid");
|
|
1353
|
+
const content = verifyDecisionContentAtRoot(this.store.root, binding);
|
|
1354
|
+
if (!content.ok) return content;
|
|
1355
|
+
const knownRequirements = new Set(
|
|
1356
|
+
task.data.policy?.requirements.map((item) => item.id) ?? [],
|
|
1357
|
+
);
|
|
1358
|
+
if (input.requirementIds.some((id: string) => !knownRequirements.has(id)))
|
|
1359
|
+
return failure("invalid_input", "decision references an unknown requirement");
|
|
1360
|
+
const base = {
|
|
1361
|
+
purpose: input.purpose,
|
|
1362
|
+
binding,
|
|
1363
|
+
response: input.response,
|
|
1364
|
+
requirementIds: input.requirementIds,
|
|
1365
|
+
revoked: null,
|
|
1366
|
+
} satisfies Omit<Decision, "digest">;
|
|
1367
|
+
const data: Decision = { ...base, digest: decisionDigest(base) };
|
|
1368
|
+
const changed = this.commitTask(
|
|
1369
|
+
task.data.id,
|
|
1370
|
+
input.expectedRevision,
|
|
1371
|
+
filled,
|
|
1372
|
+
(fresh) =>
|
|
1373
|
+
fresh.status === "closed"
|
|
1374
|
+
? failure("invalid_transition", "closed task cannot record a decision")
|
|
1375
|
+
: success(null, null, null),
|
|
1376
|
+
(current, mutation) => {
|
|
1377
|
+
if (current.status === "closed")
|
|
1378
|
+
return failure("invalid_transition", "closed task cannot record a decision");
|
|
1379
|
+
const known = new Set(current.policy?.requirements.map((item) => item.id) ?? []);
|
|
1380
|
+
if (input.requirementIds.some((id: string) => !known.has(id)))
|
|
1381
|
+
return failure("invalid_input", "decision references an unknown requirement");
|
|
1382
|
+
const entry: Entry<Decision> = {
|
|
1383
|
+
id: newId(),
|
|
1384
|
+
recordedAt: mutation.now,
|
|
1385
|
+
provenance: provenance(this.context, "agent_reported"),
|
|
1386
|
+
data,
|
|
1387
|
+
};
|
|
1388
|
+
return success(mutation.revision, null, {
|
|
1389
|
+
...current,
|
|
1390
|
+
decisions: [...current.decisions, entry],
|
|
1391
|
+
});
|
|
1392
|
+
},
|
|
1393
|
+
);
|
|
1394
|
+
if (!changed.ok) return changed;
|
|
1395
|
+
return success(changed.data.revision, null, changed.data.decisions.at(-1)!);
|
|
1396
|
+
}
|
|
1397
|
+
const decision = task.data.decisions.find((entry) => entry.id === input.decisionId);
|
|
1398
|
+
if (!decision) return failure("not_found", "decision not found");
|
|
1399
|
+
if (!input.reason.trim())
|
|
1400
|
+
return failure("invalid_input", "decision revocation requires a reason");
|
|
1401
|
+
if (decision.data.revoked) return failure("invalid_transition", "decision is already revoked");
|
|
1402
|
+
const changed = this.commitTask(
|
|
1403
|
+
task.data.id,
|
|
1404
|
+
input.expectedRevision,
|
|
1405
|
+
filled,
|
|
1406
|
+
() => success(null, null, null),
|
|
1407
|
+
(current, mutation) => {
|
|
1408
|
+
const entry = current.decisions.find((candidate) => candidate.id === input.decisionId);
|
|
1409
|
+
if (!entry) return failure("not_found", "decision not found");
|
|
1410
|
+
if (entry.data.revoked) return failure("invalid_transition", "decision is already revoked");
|
|
1411
|
+
const data = {
|
|
1412
|
+
...entry.data,
|
|
1413
|
+
revoked: { at: mutation.now, reason: input.reason },
|
|
1414
|
+
};
|
|
1415
|
+
const updated = { ...entry, data };
|
|
1416
|
+
return success(mutation.revision, null, {
|
|
1417
|
+
...current,
|
|
1418
|
+
decisions: current.decisions.map((candidate) =>
|
|
1419
|
+
candidate.id === input.decisionId ? updated : candidate,
|
|
1420
|
+
),
|
|
1421
|
+
});
|
|
1422
|
+
},
|
|
1423
|
+
);
|
|
1424
|
+
if (!changed.ok) return changed;
|
|
1425
|
+
const entry = changed.data.decisions.find((candidate) => candidate.id === input.decisionId);
|
|
1426
|
+
return entry
|
|
1427
|
+
? success(changed.data.revision, null, entry)
|
|
1428
|
+
: failure("recovery_required", "decision disappeared");
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
finding(request: unknown): Result<Entry<Finding>> {
|
|
1432
|
+
const resolved = this.implicitTask("finding", request);
|
|
1433
|
+
if (!resolved.ok) return resolved;
|
|
1434
|
+
const value = resolved.data;
|
|
1435
|
+
return this.retryOmittedRevisions(value, () => this.findingOnce(value));
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
private findingOnce(request: unknown): Result<Entry<Finding>> {
|
|
1439
|
+
const root = this.contextRootError();
|
|
1440
|
+
if (!root.ok) return root;
|
|
1441
|
+
const parsed = parseOperation("finding", request);
|
|
1442
|
+
if (!parsed.ok) return parsed;
|
|
1443
|
+
const input = parsed.data;
|
|
1444
|
+
const task = this.store.readTask(input.taskId);
|
|
1445
|
+
if (!task.ok) return task;
|
|
1446
|
+
if (task.data.status === "closed")
|
|
1447
|
+
return failure("invalid_transition", "closed task cannot mutate findings");
|
|
1448
|
+
this.fillRevisions(input, task.data);
|
|
1449
|
+
const helper =
|
|
1450
|
+
(this.context.workerId ?? null) === null ? null : this.helperEntry(task.data, true, true);
|
|
1451
|
+
if (helper && !helper.ok) return helper;
|
|
1452
|
+
if (input.action === "record") {
|
|
1453
|
+
if (helper?.ok) {
|
|
1454
|
+
const assignment = helper.data.data.assignment;
|
|
1455
|
+
if (
|
|
1456
|
+
!bindingCovers(assignment.scope, input.scope) ||
|
|
1457
|
+
!bindingCovers(task.data.intent.data.scope, input.scope) ||
|
|
1458
|
+
!checkPin(assignment, null, input.candidateId ?? null) ||
|
|
1459
|
+
!refsWithinScope(input.refs, assignment.scope) ||
|
|
1460
|
+
!refsWithinScope(input.refs, task.data.intent.data.scope)
|
|
1461
|
+
)
|
|
1462
|
+
return failure("permission_denied", "finding is outside the worker assignment");
|
|
1463
|
+
}
|
|
1464
|
+
if (
|
|
1465
|
+
input.candidateId &&
|
|
1466
|
+
!task.data.candidates.some((candidate) => candidate.id === input.candidateId)
|
|
1467
|
+
)
|
|
1468
|
+
return failure("invalid_input", "finding candidate is not a captured candidate");
|
|
1469
|
+
const changed = this.store.mutateTask(
|
|
1470
|
+
task.data.id,
|
|
1471
|
+
input.expectedRevision,
|
|
1472
|
+
(current, mutation) => {
|
|
1473
|
+
const entry: Entry<Finding> = {
|
|
1474
|
+
id: newId(),
|
|
1475
|
+
recordedAt: mutation.now,
|
|
1476
|
+
provenance: provenance(this.context, "agent_reported"),
|
|
1477
|
+
data: {
|
|
1478
|
+
claim: input.claim,
|
|
1479
|
+
consequence: input.consequence,
|
|
1480
|
+
scope: input.scope,
|
|
1481
|
+
candidateId: input.candidateId ?? null,
|
|
1482
|
+
refs: input.refs,
|
|
1483
|
+
disposition: "open",
|
|
1484
|
+
resolution: null,
|
|
1485
|
+
},
|
|
1486
|
+
};
|
|
1487
|
+
return success(mutation.revision, null, {
|
|
1488
|
+
...current,
|
|
1489
|
+
findings: [...current.findings, entry],
|
|
1490
|
+
});
|
|
1491
|
+
},
|
|
1492
|
+
trustedNow(this.context),
|
|
1493
|
+
);
|
|
1494
|
+
if (!changed.ok) return changed;
|
|
1495
|
+
return success(changed.data.revision, null, changed.data.findings.at(-1)!);
|
|
1496
|
+
}
|
|
1497
|
+
if (helper?.ok) return failure("permission_denied", "helpers cannot resolve findings");
|
|
1498
|
+
const findingRef = resolveIdRef(
|
|
1499
|
+
"finding",
|
|
1500
|
+
input.findingId,
|
|
1501
|
+
task.data.findings.map((entry) => entry.id),
|
|
1502
|
+
);
|
|
1503
|
+
if (!findingRef.ok) return findingRef;
|
|
1504
|
+
input.findingId = findingRef.data;
|
|
1505
|
+
const finding = task.data.findings.find((entry) => entry.id === input.findingId);
|
|
1506
|
+
if (!finding) return failure("not_found", "finding not found");
|
|
1507
|
+
if (!input.reason.trim())
|
|
1508
|
+
return failure("invalid_input", "finding resolution requires a reason");
|
|
1509
|
+
const evidence = task.data.evidence.filter((entry) => input.evidenceIds.includes(entry.id));
|
|
1510
|
+
const decisions = task.data.decisions.filter((entry) => input.decisionIds.includes(entry.id));
|
|
1511
|
+
if (
|
|
1512
|
+
evidence.length !== input.evidenceIds.length ||
|
|
1513
|
+
decisions.length !== input.decisionIds.length
|
|
1514
|
+
)
|
|
1515
|
+
return failure("invalid_input", "finding resolution references an unknown record");
|
|
1516
|
+
if (input.disposition === "fixed") {
|
|
1517
|
+
const current = captureCandidate(this.store.root, task.data.intent.data.scope, environment());
|
|
1518
|
+
if (!current.ok) return current;
|
|
1519
|
+
const evaluations = evaluateEvidence(task.data, current.data, treeFreshness(this.store.root));
|
|
1520
|
+
const verified = evidence.some((entry) => {
|
|
1521
|
+
const evaluation = evaluations.find((item) => item.evidenceId === entry.id);
|
|
1522
|
+
return (
|
|
1523
|
+
entry.recordedAt >= finding.recordedAt &&
|
|
1524
|
+
findingVerificationPasses(finding.data.candidateId, {
|
|
1525
|
+
kind: entry.data.kind,
|
|
1526
|
+
candidateId: entry.data.candidateId,
|
|
1527
|
+
status: evaluation?.status ?? "missing",
|
|
1528
|
+
})
|
|
1529
|
+
);
|
|
1530
|
+
});
|
|
1531
|
+
if (!verified)
|
|
1532
|
+
return failure("permission_denied", "fixed findings require passing verification evidence");
|
|
1533
|
+
}
|
|
1534
|
+
if (input.disposition === "dismissed" && evidence.length === 0)
|
|
1535
|
+
return failure("permission_denied", "dismissal requires supporting evidence");
|
|
1536
|
+
if (input.disposition === "dismissed") {
|
|
1537
|
+
const relevant = evidence.every((entry) => {
|
|
1538
|
+
if (entry.data.result === "missing" || entry.data.result === "skipped") return false;
|
|
1539
|
+
const candidateMatches =
|
|
1540
|
+
finding.data.candidateId === null
|
|
1541
|
+
? entry.data.candidateId === null
|
|
1542
|
+
: entry.data.candidateId === finding.data.candidateId ||
|
|
1543
|
+
entry.data.beforeCandidateId === finding.data.candidateId;
|
|
1544
|
+
const referenceMatches = finding.data.refs.some((left) =>
|
|
1545
|
+
entry.data.refs.some((right) => sameValue(left, right)),
|
|
1546
|
+
);
|
|
1547
|
+
return candidateMatches && (referenceMatches || entry.data.claim === finding.data.claim);
|
|
1548
|
+
});
|
|
1549
|
+
if (!relevant)
|
|
1550
|
+
return failure("permission_denied", "dismissal evidence is unrelated to the finding");
|
|
1551
|
+
}
|
|
1552
|
+
if (input.disposition === "deferred") {
|
|
1553
|
+
const workspace = this.store.readWorkspace();
|
|
1554
|
+
if (!workspace.ok) return workspace;
|
|
1555
|
+
if (!workspace.data) return failure("not_found", "workspace not found");
|
|
1556
|
+
const allowed = decisions.some(
|
|
1557
|
+
(entry) =>
|
|
1558
|
+
entry.provenance.kind !== "imported" &&
|
|
1559
|
+
entry.data.purpose === "limitation" &&
|
|
1560
|
+
entry.data.response === "approved" &&
|
|
1561
|
+
entry.data.revoked === null &&
|
|
1562
|
+
verifyDecisionContentAtRoot(this.store.root, entry.data.binding).ok &&
|
|
1563
|
+
entry.data.binding.taskId === task.data.id &&
|
|
1564
|
+
entry.data.binding.workspaceId === workspace.data!.id &&
|
|
1565
|
+
bindingCovers(entry.data.binding.scope, finding.data.scope) &&
|
|
1566
|
+
entry.data.requirementIds.some((id: string) =>
|
|
1567
|
+
task.data.policy?.requirements.some(
|
|
1568
|
+
(requirement) => requirement.id === id && requirement.acceptanceAllowed,
|
|
1569
|
+
),
|
|
1570
|
+
),
|
|
1571
|
+
);
|
|
1572
|
+
if (!allowed)
|
|
1573
|
+
return failure(
|
|
1574
|
+
"permission_denied",
|
|
1575
|
+
"deferred findings require an approved limitation decision that references an acceptanceAllowed requirement and covers the finding scope",
|
|
1576
|
+
);
|
|
1577
|
+
}
|
|
1578
|
+
const changed = this.store.mutateTask(
|
|
1579
|
+
task.data.id,
|
|
1580
|
+
input.expectedRevision,
|
|
1581
|
+
(current, mutation) => {
|
|
1582
|
+
const existing = current.findings.find((entry) => entry.id === input.findingId);
|
|
1583
|
+
if (!existing) return failure("not_found", "finding not found");
|
|
1584
|
+
const data =
|
|
1585
|
+
input.disposition === "open"
|
|
1586
|
+
? { ...existing.data, disposition: "open" as const, resolution: null }
|
|
1587
|
+
: {
|
|
1588
|
+
...existing.data,
|
|
1589
|
+
disposition: input.disposition,
|
|
1590
|
+
resolution: {
|
|
1591
|
+
reason: input.reason,
|
|
1592
|
+
evidenceIds: input.evidenceIds,
|
|
1593
|
+
decisionIds: input.decisionIds,
|
|
1594
|
+
},
|
|
1595
|
+
};
|
|
1596
|
+
const updated = { ...existing, data };
|
|
1597
|
+
return success(mutation.revision, null, {
|
|
1598
|
+
...current,
|
|
1599
|
+
findings: current.findings.map((entry) =>
|
|
1600
|
+
entry.id === input.findingId ? updated : entry,
|
|
1601
|
+
),
|
|
1602
|
+
});
|
|
1603
|
+
},
|
|
1604
|
+
trustedNow(this.context),
|
|
1605
|
+
);
|
|
1606
|
+
if (!changed.ok) return changed;
|
|
1607
|
+
const entry = changed.data.findings.find((candidate) => candidate.id === input.findingId);
|
|
1608
|
+
return entry
|
|
1609
|
+
? success(changed.data.revision, null, entry)
|
|
1610
|
+
: failure("recovery_required", "finding disappeared");
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
worker(request: unknown): Result<Entry<Worker>> {
|
|
1614
|
+
const resolved = this.implicitTask("worker", request);
|
|
1615
|
+
if (!resolved.ok) return resolved;
|
|
1616
|
+
const value = resolved.data;
|
|
1617
|
+
const result = this.retryOmittedRevisions(value, () => this.workerOnce(value));
|
|
1618
|
+
// A cancel/report whose task was resolved from the worker id names the
|
|
1619
|
+
// exact command for that task (every host and the CLI see the same text).
|
|
1620
|
+
const { action, taskId } = (value ?? {}) as { action?: unknown; taskId?: unknown };
|
|
1621
|
+
return result.ok ||
|
|
1622
|
+
(request as { taskId?: unknown } | null)?.taskId !== undefined ||
|
|
1623
|
+
typeof taskId !== "string" ||
|
|
1624
|
+
!WORKER_ADDRESSED_ACTIONS.has(`worker.${String(action)}`)
|
|
1625
|
+
? result
|
|
1626
|
+
: { ...result, error: `${result.error}; run \`${workerCommand(taskId, value)}\`` };
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
private workerOnce(request: unknown): Result<Entry<Worker>> {
|
|
1630
|
+
const root = this.contextRootError();
|
|
1631
|
+
if (!root.ok) return root;
|
|
1632
|
+
const parsed = parseOperation("worker", request);
|
|
1633
|
+
if (!parsed.ok) return parsed;
|
|
1634
|
+
const input = parsed.data;
|
|
1635
|
+
const task = this.store.readTask(input.taskId);
|
|
1636
|
+
if (!task.ok) return task;
|
|
1637
|
+
const workspace = this.store.readWorkspace();
|
|
1638
|
+
if (!workspace.ok) return workspace;
|
|
1639
|
+
if (!workspace.data) return failure("not_found", "workspace not found");
|
|
1640
|
+
this.fillRevisions(input, task.data, workspace.data);
|
|
1641
|
+
const helperId = this.context.workerId ?? null;
|
|
1642
|
+
if (input.action === "assign") {
|
|
1643
|
+
if (helperId !== null) return failure("permission_denied", "helpers cannot assign workers");
|
|
1644
|
+
if (task.data.status !== "active")
|
|
1645
|
+
return failure("invalid_transition", "paused or closed tasks cannot assign workers");
|
|
1646
|
+
if (!bindingCovers(task.data.intent.data.scope, input.assignment.scope))
|
|
1647
|
+
return failure("permission_denied", "worker assignment is outside task scope", {
|
|
1648
|
+
fields: [
|
|
1649
|
+
{
|
|
1650
|
+
path: "assignment.scope",
|
|
1651
|
+
reason: `paths ${JSON.stringify(input.assignment.scope.paths)} must be contained by lead paths ${JSON.stringify(task.data.intent.data.scope.paths)}`,
|
|
1652
|
+
},
|
|
1653
|
+
{
|
|
1654
|
+
path: "task.intent.scope",
|
|
1655
|
+
reason: `lead paths are ${JSON.stringify(task.data.intent.data.scope.paths)}`,
|
|
1656
|
+
},
|
|
1657
|
+
],
|
|
1658
|
+
});
|
|
1659
|
+
if (
|
|
1660
|
+
input.assignment.decisionIds.some(
|
|
1661
|
+
(id: string) => !task.data.decisions.some((entry) => entry.id === id),
|
|
1662
|
+
)
|
|
1663
|
+
)
|
|
1664
|
+
return failure("invalid_input", "worker assignment references an unknown decision");
|
|
1665
|
+
if (
|
|
1666
|
+
input.assignment.requirementIds.some(
|
|
1667
|
+
(id: string) =>
|
|
1668
|
+
!task.data.policy?.requirements.some((requirement) => requirement.id === id),
|
|
1669
|
+
)
|
|
1670
|
+
)
|
|
1671
|
+
return failure("invalid_input", "worker assignment references an unknown requirement");
|
|
1672
|
+
const reviewRequirements = (task.data.policy?.requirements ?? []).filter(
|
|
1673
|
+
(requirement) => requirement.dimension === "review",
|
|
1674
|
+
);
|
|
1675
|
+
if (
|
|
1676
|
+
input.assignment.role === "reviewer" &&
|
|
1677
|
+
input.assignment.requirementIds.length === 0 &&
|
|
1678
|
+
reviewRequirements.length
|
|
1679
|
+
)
|
|
1680
|
+
return failure(
|
|
1681
|
+
"invalid_input",
|
|
1682
|
+
"reviewer assignment needs the review requirement ids it will evidence",
|
|
1683
|
+
{ requirementIds: reviewRequirements.map((requirement) => requirement.id) },
|
|
1684
|
+
);
|
|
1685
|
+
if (
|
|
1686
|
+
input.assignment.candidateId &&
|
|
1687
|
+
!task.data.candidates.some((candidate) => candidate.id === input.assignment.candidateId)
|
|
1688
|
+
)
|
|
1689
|
+
return failure("invalid_input", "worker assignment references an unknown candidate");
|
|
1690
|
+
input.assignment.candidateId ??= null;
|
|
1691
|
+
const workerId = newId();
|
|
1692
|
+
const changed = this.store.mutateTaskAndWorkspace({
|
|
1693
|
+
taskId: task.data.id,
|
|
1694
|
+
expectedRevision: input.expectedRevision,
|
|
1695
|
+
expectedWorkspaceRevision: input.expectedWorkspaceRevision,
|
|
1696
|
+
now: trustedNow(this.context),
|
|
1697
|
+
workspace: (current, mutation) => success(mutation.revision, mutation.revision, current),
|
|
1698
|
+
task: (current, mutation) => {
|
|
1699
|
+
const entry: Entry<Worker> = {
|
|
1700
|
+
id: workerId,
|
|
1701
|
+
recordedAt: mutation.now,
|
|
1702
|
+
provenance: provenance(this.context, "agent_reported"),
|
|
1703
|
+
data: { assignment: input.assignment, state: "assigned", session: null, report: null },
|
|
1704
|
+
};
|
|
1705
|
+
return success(mutation.revision, null, {
|
|
1706
|
+
...current,
|
|
1707
|
+
workers: [...current.workers, entry],
|
|
1708
|
+
});
|
|
1709
|
+
},
|
|
1710
|
+
});
|
|
1711
|
+
if (!changed.ok) return changed;
|
|
1712
|
+
return success(
|
|
1713
|
+
changed.data.task.revision,
|
|
1714
|
+
changed.data.workspace.revision,
|
|
1715
|
+
changed.data.task.workers.at(-1)!,
|
|
1716
|
+
);
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1719
|
+
// A helper may only address itself: resolving against its own id alone keeps
|
|
1720
|
+
// another worker's id out of any ambiguity error.
|
|
1721
|
+
const workerRef = resolveIdRef(
|
|
1722
|
+
"worker",
|
|
1723
|
+
input.workerId,
|
|
1724
|
+
helperId === null ? task.data.workers.map((candidate) => candidate.id) : [helperId],
|
|
1725
|
+
);
|
|
1726
|
+
if (!workerRef.ok)
|
|
1727
|
+
return helperId === null
|
|
1728
|
+
? workerRef
|
|
1729
|
+
: failure(
|
|
1730
|
+
"permission_denied",
|
|
1731
|
+
input.action === "report" ? ONLY_OWN_REPORT : NO_HELPER_CONTROL,
|
|
1732
|
+
);
|
|
1733
|
+
input.workerId = workerRef.data;
|
|
1734
|
+
const entry = task.data.workers.find((candidate) => candidate.id === input.workerId);
|
|
1735
|
+
if (!entry) return failure("not_found", "worker not found");
|
|
1736
|
+
if (input.action === "report") {
|
|
1737
|
+
if (helperId === null || helperId !== input.workerId)
|
|
1738
|
+
return failure("permission_denied", ONLY_OWN_REPORT);
|
|
1739
|
+
const helper = this.helperEntry(task.data, true, true);
|
|
1740
|
+
if (!helper.ok) return helper;
|
|
1741
|
+
const ownFindings = task.data.findings
|
|
1742
|
+
.filter((candidate) => candidate.provenance.workerId === helperId)
|
|
1743
|
+
.map((candidate) => candidate.id);
|
|
1744
|
+
const reported: string[] = [];
|
|
1745
|
+
for (const findingRef of input.report.findingIds) {
|
|
1746
|
+
const found = resolveIdRef("finding", findingRef, ownFindings);
|
|
1747
|
+
if (found.ok) reported.push(found.data);
|
|
1748
|
+
else if (found.code === "not_found") reported.push(findingRef);
|
|
1749
|
+
else return found;
|
|
1750
|
+
}
|
|
1751
|
+
input.report.findingIds = reported;
|
|
1752
|
+
const evidence = task.data.evidence.filter((candidate) =>
|
|
1753
|
+
input.report.evidenceIds.includes(candidate.id),
|
|
1754
|
+
);
|
|
1755
|
+
const findings = task.data.findings.filter((candidate) =>
|
|
1756
|
+
input.report.findingIds.includes(candidate.id),
|
|
1757
|
+
);
|
|
1758
|
+
if (
|
|
1759
|
+
evidence.length !== input.report.evidenceIds.length ||
|
|
1760
|
+
findings.length !== input.report.findingIds.length
|
|
1761
|
+
)
|
|
1762
|
+
return failure("invalid_input", "worker report references an unknown record");
|
|
1763
|
+
if (
|
|
1764
|
+
evidence.some((candidate) => candidate.provenance.workerId !== helperId) ||
|
|
1765
|
+
findings.some((candidate) => candidate.provenance.workerId !== helperId) ||
|
|
1766
|
+
evidence.some(
|
|
1767
|
+
(candidate) =>
|
|
1768
|
+
!refsWithinScope(candidate.data.refs, helper.data.data.assignment.scope) ||
|
|
1769
|
+
!refsWithinScope(candidate.data.refs, task.data.intent.data.scope),
|
|
1770
|
+
) ||
|
|
1771
|
+
findings.some(
|
|
1772
|
+
(candidate) =>
|
|
1773
|
+
!refsWithinScope(candidate.data.refs, helper.data.data.assignment.scope) ||
|
|
1774
|
+
!refsWithinScope(candidate.data.refs, task.data.intent.data.scope),
|
|
1775
|
+
)
|
|
1776
|
+
)
|
|
1777
|
+
return failure("permission_denied", "worker report is outside the worker assignment");
|
|
1778
|
+
const changed = this.store.mutateTaskAndWorkspace({
|
|
1779
|
+
taskId: task.data.id,
|
|
1780
|
+
expectedRevision: input.expectedRevision,
|
|
1781
|
+
expectedWorkspaceRevision: input.expectedWorkspaceRevision,
|
|
1782
|
+
now: trustedNow(this.context),
|
|
1783
|
+
workspace: (current, mutation) => success(mutation.revision, mutation.revision, current),
|
|
1784
|
+
task: (current, mutation) =>
|
|
1785
|
+
success(mutation.revision, null, {
|
|
1786
|
+
...current,
|
|
1787
|
+
workers: current.workers.map((candidate) =>
|
|
1788
|
+
candidate.id === input.workerId
|
|
1789
|
+
? {
|
|
1790
|
+
...candidate,
|
|
1791
|
+
recordedAt: mutation.now,
|
|
1792
|
+
data: { ...candidate.data, report: input.report },
|
|
1793
|
+
}
|
|
1794
|
+
: candidate,
|
|
1795
|
+
),
|
|
1796
|
+
}),
|
|
1797
|
+
});
|
|
1798
|
+
if (!changed.ok) return changed;
|
|
1799
|
+
const updated = changed.data.task.workers.find(
|
|
1800
|
+
(candidate) => candidate.id === input.workerId,
|
|
1801
|
+
);
|
|
1802
|
+
return updated
|
|
1803
|
+
? success(changed.data.task.revision, changed.data.workspace.revision, updated)
|
|
1804
|
+
: failure("recovery_required", "worker disappeared during report");
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
if (helperId !== null) return failure("permission_denied", NO_HELPER_CONTROL);
|
|
1808
|
+
if (task.data.status === "closed")
|
|
1809
|
+
return failure("invalid_transition", "closed task cannot cancel workers");
|
|
1810
|
+
if (entry.data.state === "stopped") {
|
|
1811
|
+
// Idempotent cancel: repeating a settled stop succeeds with the
|
|
1812
|
+
// current entry instead of failing the retry.
|
|
1813
|
+
const stopped = task.data.workers.find((candidate) => candidate.id === input.workerId)!;
|
|
1814
|
+
return success(task.data.revision, workspace.data.revision, stopped);
|
|
1815
|
+
}
|
|
1816
|
+
// Lead-attested cancel is terminal for every live or unknown state: the lead
|
|
1817
|
+
// saw the run end (or chose to abandon it), so no host observation is awaited. A later live-child
|
|
1818
|
+
// sighting against a stopped worker is a new anomaly for the host to
|
|
1819
|
+
// flag, not a reason to strand the worker short of stopped.
|
|
1820
|
+
const changed = this.store.mutateTaskAndWorkspace({
|
|
1821
|
+
taskId: task.data.id,
|
|
1822
|
+
expectedRevision: input.expectedRevision,
|
|
1823
|
+
expectedWorkspaceRevision: input.expectedWorkspaceRevision,
|
|
1824
|
+
now: trustedNow(this.context),
|
|
1825
|
+
workspace: (current, mutation) => success(mutation.revision, mutation.revision, current),
|
|
1826
|
+
task: (current, mutation) =>
|
|
1827
|
+
success(mutation.revision, null, {
|
|
1828
|
+
...current,
|
|
1829
|
+
workers: current.workers.map((candidate) =>
|
|
1830
|
+
candidate.id === input.workerId
|
|
1831
|
+
? {
|
|
1832
|
+
...candidate,
|
|
1833
|
+
recordedAt: mutation.now,
|
|
1834
|
+
data: { ...candidate.data, state: "stopped" as const },
|
|
1835
|
+
}
|
|
1836
|
+
: candidate,
|
|
1837
|
+
),
|
|
1838
|
+
}),
|
|
1839
|
+
});
|
|
1840
|
+
if (!changed.ok) return changed;
|
|
1841
|
+
return success(
|
|
1842
|
+
changed.data.task.revision,
|
|
1843
|
+
changed.data.workspace.revision,
|
|
1844
|
+
changed.data.task.workers.find((candidate) => candidate.id === input.workerId)!,
|
|
1845
|
+
);
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1848
|
+
observeWorkerLifecycle(input: NativeWorkerObservation): Result<Entry<Worker>> {
|
|
1849
|
+
const root = this.contextRootError();
|
|
1850
|
+
if (!root.ok) return root;
|
|
1851
|
+
if (
|
|
1852
|
+
this.context.workerId !== undefined &&
|
|
1853
|
+
this.context.workerId !== null &&
|
|
1854
|
+
this.context.workerId !== input.workerId
|
|
1855
|
+
)
|
|
1856
|
+
return failure("permission_denied", "worker observation does not match the caller");
|
|
1857
|
+
const task = this.store.readTask(input.taskId);
|
|
1858
|
+
if (!task.ok) return task;
|
|
1859
|
+
const workspace = this.store.readWorkspace();
|
|
1860
|
+
if (!workspace.ok) return workspace;
|
|
1861
|
+
if (!workspace.data) return failure("not_found", "workspace not found");
|
|
1862
|
+
const authority = verifyNativeWorker(
|
|
1863
|
+
this.context.nativeWorker,
|
|
1864
|
+
{
|
|
1865
|
+
observation: input.observation,
|
|
1866
|
+
expected: { ...input, workspaceId: workspace.data.id },
|
|
1867
|
+
caller: this.context.caller,
|
|
1868
|
+
},
|
|
1869
|
+
{
|
|
1870
|
+
owner: this.authorityOwner,
|
|
1871
|
+
store: this.store,
|
|
1872
|
+
root: this.store.root,
|
|
1873
|
+
caller: this.context.caller,
|
|
1874
|
+
},
|
|
1875
|
+
);
|
|
1876
|
+
if (!authority.ok) return authority;
|
|
1877
|
+
return applyWorkerLifecycle({
|
|
1878
|
+
...input,
|
|
1879
|
+
store: this.store,
|
|
1880
|
+
authority: authority.data,
|
|
1881
|
+
authorityOwner: this.authorityOwner,
|
|
1882
|
+
caller: this.context.caller,
|
|
1883
|
+
now: trustedNow(this.context),
|
|
1884
|
+
});
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
private resolve(task: TaskRecord, judgment: Judgment): Result<Policy> {
|
|
1888
|
+
return resolvePolicy({
|
|
1889
|
+
intent: task.intent.data,
|
|
1890
|
+
judgment,
|
|
1891
|
+
constraints: this.context.constraints,
|
|
1892
|
+
verification: workspaceVerification(this.context.root),
|
|
1893
|
+
});
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1896
|
+
private summary(task: TaskRecord): Result<TaskSummary> {
|
|
1897
|
+
const workspace = this.store.readWorkspace();
|
|
1898
|
+
if (!workspace.ok) return workspace;
|
|
1899
|
+
if (!workspace.data) return failure("not_found", "workspace not found");
|
|
1900
|
+
const historical = task.status === "closed" ? task.candidates.at(-1) : undefined;
|
|
1901
|
+
const current = historical
|
|
1902
|
+
? success(null, null, historical)
|
|
1903
|
+
: captureCandidate(this.store.root, task.intent.data.scope, environment());
|
|
1904
|
+
if (!current.ok) return current;
|
|
1905
|
+
const evaluationWorkspace = workspace.data;
|
|
1906
|
+
const tree = treeFreshness(this.store.root);
|
|
1907
|
+
const requirements = evaluateRequirements(
|
|
1908
|
+
task,
|
|
1909
|
+
evaluationWorkspace,
|
|
1910
|
+
this.context.capabilities,
|
|
1911
|
+
current.data,
|
|
1912
|
+
this.store.root,
|
|
1913
|
+
this.context.caller,
|
|
1914
|
+
tree,
|
|
1915
|
+
);
|
|
1916
|
+
return success(task.revision, workspace.data.revision, {
|
|
1917
|
+
id: task.id,
|
|
1918
|
+
revision: task.revision,
|
|
1919
|
+
workspaceRevision: workspace.data.revision,
|
|
1920
|
+
createdAt: task.createdAt,
|
|
1921
|
+
updatedAt: task.updatedAt,
|
|
1922
|
+
runtime: task.runtime ?? null,
|
|
1923
|
+
objective: task.intent.data.objective,
|
|
1924
|
+
status: task.status,
|
|
1925
|
+
closure: task.closure,
|
|
1926
|
+
progress: task.progress,
|
|
1927
|
+
policy: task.policy,
|
|
1928
|
+
requirements,
|
|
1929
|
+
});
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
/** Compact task context for per-turn host injection (task-view.ts). */
|
|
1933
|
+
compactContext(
|
|
1934
|
+
taskId: string | null,
|
|
1935
|
+
freshness?: Freshness,
|
|
1936
|
+
origin: { origin: string; taskId: string } | null = null,
|
|
1937
|
+
): Result<string> {
|
|
1938
|
+
return compactContext(this.store, this.context, taskId, freshness, origin);
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1941
|
+
private view(task: TaskRecord, capture = true, freshness?: Freshness): Result<TaskView> {
|
|
1942
|
+
return taskView(this.store, this.context, task, capture, freshness);
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
reconcileResume(
|
|
1946
|
+
view: TaskView,
|
|
1947
|
+
observations: NativeWorkerObservation[] = [],
|
|
1948
|
+
): Result<ResumeReconciliation> {
|
|
1949
|
+
const root = this.contextRootError();
|
|
1950
|
+
if (!root.ok) return root;
|
|
1951
|
+
if ((this.context.workerId ?? null) !== null)
|
|
1952
|
+
return failure("permission_denied", "helpers cannot reconcile task workers");
|
|
1953
|
+
if (view.task.workspaceId !== view.workspace.id)
|
|
1954
|
+
return failure("invalid_input", "task and workspace bindings are invalid");
|
|
1955
|
+
try {
|
|
1956
|
+
if (realpathSync(view.workspace.root) !== this.store.root)
|
|
1957
|
+
return failure("invalid_input", "task view root does not match the task store");
|
|
1958
|
+
} catch {
|
|
1959
|
+
return failure("invalid_input", "task view root cannot be resolved");
|
|
1960
|
+
}
|
|
1961
|
+
const updates: NativeWorkerObservation[] = [];
|
|
1962
|
+
const seen = new Set<string>();
|
|
1963
|
+
const binding: WorkerBinding = {
|
|
1964
|
+
owner: this.authorityOwner,
|
|
1965
|
+
store: this.store,
|
|
1966
|
+
root: this.store.root,
|
|
1967
|
+
caller: this.context.caller,
|
|
1968
|
+
};
|
|
1969
|
+
for (const observation of observations) {
|
|
1970
|
+
if (seen.has(observation.workerId))
|
|
1971
|
+
return failure("invalid_input", "worker observations must be unique");
|
|
1972
|
+
seen.add(observation.workerId);
|
|
1973
|
+
// Anchor on fresh reads, not the passed view: a view captured before
|
|
1974
|
+
// recent mutations must not stale-fail observations that match the
|
|
1975
|
+
// current state. Genuinely stale observations still conflict below.
|
|
1976
|
+
const freshTask = this.store.readTask(view.task.id);
|
|
1977
|
+
const freshWorkspace = this.store.readWorkspace();
|
|
1978
|
+
if (!freshTask.ok) return freshTask;
|
|
1979
|
+
if (!freshWorkspace.ok) return freshWorkspace;
|
|
1980
|
+
if (!freshWorkspace.data) return failure("not_found", "workspace not found");
|
|
1981
|
+
if (
|
|
1982
|
+
observation.taskId !== view.task.id ||
|
|
1983
|
+
observation.expectedRevision !== freshTask.data.revision ||
|
|
1984
|
+
observation.expectedWorkspaceRevision !== freshWorkspace.data.revision
|
|
1985
|
+
)
|
|
1986
|
+
return failure("revision_conflict", "worker observation is stale");
|
|
1987
|
+
if (!view.task.workers.some((entry) => entry.id === observation.workerId))
|
|
1988
|
+
return failure("permission_denied", "worker observation is not assigned");
|
|
1989
|
+
const verified = verifyNativeWorker(
|
|
1990
|
+
this.context.nativeWorker,
|
|
1991
|
+
{
|
|
1992
|
+
observation: observation.observation,
|
|
1993
|
+
expected: { ...observation, workspaceId: view.workspace.id },
|
|
1994
|
+
caller: this.context.caller,
|
|
1995
|
+
},
|
|
1996
|
+
binding,
|
|
1997
|
+
);
|
|
1998
|
+
if (!verified.ok) return verified;
|
|
1999
|
+
if (!takeWorker(verified.data, binding, observation))
|
|
2000
|
+
return failure("permission_denied", "worker observation authority was not retained");
|
|
2001
|
+
updates.push(observation);
|
|
2002
|
+
}
|
|
2003
|
+
const observedStates = new Map(updates.map((entry) => [entry.workerId, entry.state]));
|
|
2004
|
+
const effectiveView: TaskView = {
|
|
2005
|
+
...view,
|
|
2006
|
+
task: {
|
|
2007
|
+
...view.task,
|
|
2008
|
+
workers: view.task.workers.map((entry) => {
|
|
2009
|
+
const update = updates.find((item) => item.workerId === entry.id);
|
|
2010
|
+
return update
|
|
2011
|
+
? {
|
|
2012
|
+
...entry,
|
|
2013
|
+
data: { ...entry.data, state: update.state, session: update.session },
|
|
2014
|
+
}
|
|
2015
|
+
: entry;
|
|
2016
|
+
}),
|
|
2017
|
+
},
|
|
2018
|
+
};
|
|
2019
|
+
const base = reconcileResumeContext(effectiveView);
|
|
2020
|
+
if (!base.ok) return base;
|
|
2021
|
+
const needsReconciliation = view.task.workers.some((entry) =>
|
|
2022
|
+
isUncertainWorker(observedStates.get(entry.id) ?? entry.data.state),
|
|
2023
|
+
);
|
|
2024
|
+
const blockers = [...base.data.blockers];
|
|
2025
|
+
if (
|
|
2026
|
+
needsReconciliation &&
|
|
2027
|
+
!blockers.some((entry) => entry.reason === "worker state requires reconciliation")
|
|
2028
|
+
)
|
|
2029
|
+
blockers.push({
|
|
2030
|
+
reason: "worker state requires reconciliation",
|
|
2031
|
+
dependentAction: "resume",
|
|
2032
|
+
refs: [],
|
|
2033
|
+
});
|
|
2034
|
+
return success(null, null, { ...base.data, workerUpdates: updates, blockers });
|
|
2035
|
+
}
|
|
2036
|
+
|
|
2037
|
+
private transition(input: any, status: "active" | "paused"): Result<TaskSummary> {
|
|
2038
|
+
const task = this.store.readTask(input.taskId);
|
|
2039
|
+
if (!task.ok) return task;
|
|
2040
|
+
const helper = this.helperTaskGuard(task.data);
|
|
2041
|
+
if (!helper.ok) return helper;
|
|
2042
|
+
if (
|
|
2043
|
+
(status === "paused" && task.data.status !== "active") ||
|
|
2044
|
+
(status === "active" && task.data.status !== "paused")
|
|
2045
|
+
)
|
|
2046
|
+
return failure("invalid_transition", `cannot transition ${task.data.status} to ${status}`);
|
|
2047
|
+
const workspace = this.store.readWorkspace();
|
|
2048
|
+
if (!workspace.ok) return workspace;
|
|
2049
|
+
if (!workspace.data) return failure("not_found", "workspace not found");
|
|
2050
|
+
this.fillRevisions(input, task.data, workspace.data);
|
|
2051
|
+
let resumeCandidate: import("./task-contract").Candidate | null = null;
|
|
2052
|
+
if (status === "active" && task.data.origin) {
|
|
2053
|
+
if (!task.data.policy)
|
|
2054
|
+
return failure("needs_input", "imported task requires a current policy");
|
|
2055
|
+
if (task.data.policy.policyVersion !== POLICY_VERSION)
|
|
2056
|
+
return failure(
|
|
2057
|
+
"needs_input",
|
|
2058
|
+
"stored policy version is unsupported; reassessment is required",
|
|
2059
|
+
);
|
|
2060
|
+
const view = this.view(task.data);
|
|
2061
|
+
if (!view.ok) return view;
|
|
2062
|
+
const current = reconcileResumeContext(view.data);
|
|
2063
|
+
if (!current.ok) return current;
|
|
2064
|
+
const requirements = view.data.requirements;
|
|
2065
|
+
if (
|
|
2066
|
+
requirements.some(
|
|
2067
|
+
(requirement) =>
|
|
2068
|
+
requirement.status === "unsatisfied" || requirement.status === "unavailable",
|
|
2069
|
+
)
|
|
2070
|
+
)
|
|
2071
|
+
return failure("requirements_unsatisfied", "imported task requires current requirements", {
|
|
2072
|
+
requirementIds: requirements
|
|
2073
|
+
.filter(
|
|
2074
|
+
(requirement) =>
|
|
2075
|
+
requirement.status === "unsatisfied" || requirement.status === "unavailable",
|
|
2076
|
+
)
|
|
2077
|
+
.map((requirement) => requirement.requirementId),
|
|
2078
|
+
});
|
|
2079
|
+
if (current.data.reassessmentRequired)
|
|
2080
|
+
return failure("needs_input", "imported task requires destination policy reassessment");
|
|
2081
|
+
if (current.data.staleEvidenceIds.length)
|
|
2082
|
+
return failure("needs_input", "imported task requires fresh destination evidence");
|
|
2083
|
+
if (
|
|
2084
|
+
current.data.blockers.some(
|
|
2085
|
+
(blocker) =>
|
|
2086
|
+
blocker.dependentAction === "resume" ||
|
|
2087
|
+
blocker.reason === "worker state requires reconciliation",
|
|
2088
|
+
)
|
|
2089
|
+
)
|
|
2090
|
+
return failure("recovery_required", "imported task requires resume reconciliation");
|
|
2091
|
+
resumeCandidate = current.data.candidate;
|
|
2092
|
+
}
|
|
2093
|
+
const blocker = status === "paused" ? null : this.activeWorkerBlocker(task.data);
|
|
2094
|
+
if (blocker) return blocker;
|
|
2095
|
+
let pauseCandidate: import("./task-contract").Candidate | null = null;
|
|
2096
|
+
if (status === "paused") {
|
|
2097
|
+
// Freezing records the tree state the task resumes from.
|
|
2098
|
+
const captured = captureCandidate(
|
|
2099
|
+
this.store.root,
|
|
2100
|
+
task.data.intent.data.scope,
|
|
2101
|
+
environment(),
|
|
2102
|
+
);
|
|
2103
|
+
if (!captured.ok) return captured;
|
|
2104
|
+
pauseCandidate = captured.data;
|
|
2105
|
+
}
|
|
2106
|
+
const changed = this.store.mutateTaskAndWorkspace({
|
|
2107
|
+
taskId: task.data.id,
|
|
2108
|
+
expectedRevision: input.expectedRevision,
|
|
2109
|
+
expectedWorkspaceRevision: input.expectedWorkspaceRevision,
|
|
2110
|
+
now: trustedNow(this.context),
|
|
2111
|
+
workspace: (unchanged, context) => success(context.revision, context.revision, unchanged),
|
|
2112
|
+
task: (current, context) =>
|
|
2113
|
+
success(context.revision, null, {
|
|
2114
|
+
...current,
|
|
2115
|
+
status,
|
|
2116
|
+
candidates:
|
|
2117
|
+
(resumeCandidate ?? pauseCandidate) &&
|
|
2118
|
+
!current.candidates.some(
|
|
2119
|
+
(candidate) => candidate.id === (resumeCandidate ?? pauseCandidate)!.id,
|
|
2120
|
+
)
|
|
2121
|
+
? [...current.candidates, (resumeCandidate ?? pauseCandidate)!]
|
|
2122
|
+
: current.candidates,
|
|
2123
|
+
progress: current.progress,
|
|
2124
|
+
pauseReason: status === "paused" ? (input.reason ?? null) : null,
|
|
2125
|
+
}),
|
|
2126
|
+
});
|
|
2127
|
+
if (!changed.ok) return changed;
|
|
2128
|
+
return this.summary(changed.data.task);
|
|
2129
|
+
}
|
|
2130
|
+
|
|
2131
|
+
private progress(input: any): Result<TaskSummary> {
|
|
2132
|
+
const task = this.store.readTask(input.taskId);
|
|
2133
|
+
if (!task.ok) return task;
|
|
2134
|
+
const helper = this.helperTaskGuard(task.data);
|
|
2135
|
+
if (!helper.ok) return helper;
|
|
2136
|
+
if (task.data.status === "closed")
|
|
2137
|
+
return failure("invalid_transition", "closed task cannot update progress");
|
|
2138
|
+
this.fillRevisions(input, task.data);
|
|
2139
|
+
const changed = this.store.mutateTask(
|
|
2140
|
+
task.data.id,
|
|
2141
|
+
input.expectedRevision,
|
|
2142
|
+
(current, context) =>
|
|
2143
|
+
success(context.revision, null, { ...current, progress: input.progress }),
|
|
2144
|
+
trustedNow(this.context),
|
|
2145
|
+
);
|
|
2146
|
+
if (!changed.ok) return changed;
|
|
2147
|
+
return this.summary(changed.data);
|
|
2148
|
+
}
|
|
2149
|
+
|
|
2150
|
+
private revise(input: any): Result<TaskSummary> {
|
|
2151
|
+
const task = this.store.readTask(input.taskId);
|
|
2152
|
+
if (!task.ok) return task;
|
|
2153
|
+
const helper = this.helperTaskGuard(task.data);
|
|
2154
|
+
if (!helper.ok) return helper;
|
|
2155
|
+
if (task.data.status === "closed")
|
|
2156
|
+
return failure("invalid_transition", "closed task cannot be revised");
|
|
2157
|
+
const workspace = this.store.readWorkspace();
|
|
2158
|
+
if (!workspace.ok) return workspace;
|
|
2159
|
+
if (!workspace.data) return failure("not_found", "workspace not found");
|
|
2160
|
+
const revisionBlocker = this.activeWorkerBlocker(task.data);
|
|
2161
|
+
if (revisionBlocker) return revisionBlocker;
|
|
2162
|
+
this.fillRevisions(input, task.data, workspace.data);
|
|
2163
|
+
const changed = this.store.mutateTaskAndWorkspace({
|
|
2164
|
+
taskId: task.data.id,
|
|
2165
|
+
expectedRevision: input.expectedRevision,
|
|
2166
|
+
expectedWorkspaceRevision: input.expectedWorkspaceRevision,
|
|
2167
|
+
now: trustedNow(this.context),
|
|
2168
|
+
workspace: (unchanged, context) => success(context.revision, context.revision, unchanged),
|
|
2169
|
+
task: (current, context) =>
|
|
2170
|
+
success(context.revision, null, {
|
|
2171
|
+
...current,
|
|
2172
|
+
intent: {
|
|
2173
|
+
id: newId(),
|
|
2174
|
+
recordedAt: context.now,
|
|
2175
|
+
provenance: provenance(this.context),
|
|
2176
|
+
data: input.intent,
|
|
2177
|
+
},
|
|
2178
|
+
policy: null,
|
|
2179
|
+
progress: { ...current.progress, summary: `Policy invalidated: ${input.reason}` },
|
|
2180
|
+
}),
|
|
2181
|
+
});
|
|
2182
|
+
if (!changed.ok) return changed;
|
|
2183
|
+
return this.summary(changed.data.task);
|
|
2184
|
+
}
|
|
2185
|
+
|
|
2186
|
+
private close(input: any): Result<TaskSummary> {
|
|
2187
|
+
const task = this.store.readTask(input.taskId);
|
|
2188
|
+
if (!task.ok) return task;
|
|
2189
|
+
const helper = this.helperTaskGuard(task.data);
|
|
2190
|
+
if (!helper.ok) return helper;
|
|
2191
|
+
if (task.data.status !== "active" && task.data.status !== "paused")
|
|
2192
|
+
return failure("invalid_transition", "closed task cannot be reopened or closed again");
|
|
2193
|
+
const workspace = this.store.readWorkspace();
|
|
2194
|
+
if (!workspace.ok) return workspace;
|
|
2195
|
+
if (!workspace.data) return failure("not_found", "workspace not found");
|
|
2196
|
+
const blocker = this.activeWorkerBlocker(task.data);
|
|
2197
|
+
if (blocker) return blocker;
|
|
2198
|
+
this.fillRevisions(input, task.data, workspace.data);
|
|
2199
|
+
const current = captureCandidate(this.store.root, task.data.intent.data.scope, environment());
|
|
2200
|
+
if (!current.ok) return current;
|
|
2201
|
+
const withCandidate = current.data;
|
|
2202
|
+
const taskForView =
|
|
2203
|
+
withCandidate.id === task.data.candidates.at(-1)?.id
|
|
2204
|
+
? task.data
|
|
2205
|
+
: { ...task.data, candidates: [...task.data.candidates, withCandidate] };
|
|
2206
|
+
const tree = treeFreshness(this.store.root);
|
|
2207
|
+
const requirements = evaluateRequirements(
|
|
2208
|
+
taskForView,
|
|
2209
|
+
workspace.data,
|
|
2210
|
+
this.context.capabilities,
|
|
2211
|
+
withCandidate,
|
|
2212
|
+
this.store.root,
|
|
2213
|
+
this.context.caller,
|
|
2214
|
+
tree,
|
|
2215
|
+
);
|
|
2216
|
+
const view = {
|
|
2217
|
+
task: taskForView,
|
|
2218
|
+
workspace: workspace.data,
|
|
2219
|
+
capabilities: this.context.capabilities,
|
|
2220
|
+
requirements,
|
|
2221
|
+
evidence: evaluateEvidence(taskForView, withCandidate, tree),
|
|
2222
|
+
} satisfies TaskView;
|
|
2223
|
+
const closure = evaluateClosure(input.outcome, view);
|
|
2224
|
+
if (!closure.ok) return closure;
|
|
2225
|
+
const changed = this.store.mutateTaskAndWorkspace({
|
|
2226
|
+
taskId: task.data.id,
|
|
2227
|
+
expectedRevision: input.expectedRevision,
|
|
2228
|
+
expectedWorkspaceRevision: input.expectedWorkspaceRevision,
|
|
2229
|
+
now: trustedNow(this.context),
|
|
2230
|
+
workspace: (value, context) => success(context.revision, context.revision, value),
|
|
2231
|
+
task: (value, context) =>
|
|
2232
|
+
success(context.revision, null, {
|
|
2233
|
+
...value,
|
|
2234
|
+
status: "closed",
|
|
2235
|
+
candidates: taskForView.candidates,
|
|
2236
|
+
closure: {
|
|
2237
|
+
outcome: closure.data.outcome,
|
|
2238
|
+
at: context.now,
|
|
2239
|
+
summary: input.summary,
|
|
2240
|
+
decisionIds: closure.data.decisionIds,
|
|
2241
|
+
evidenceIds: closure.data.evidenceIds,
|
|
2242
|
+
},
|
|
2243
|
+
}),
|
|
2244
|
+
});
|
|
2245
|
+
if (!changed.ok) return changed;
|
|
2246
|
+
return this.summary(changed.data.task);
|
|
2247
|
+
}
|
|
2248
|
+
}
|