taskflow-mcp-core 0.3.0-beta.1.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.
Files changed (185) hide show
  1. package/CONTROL_GUIDE.md +113 -0
  2. package/README.md +34 -25
  3. package/dist/control/admission-service.d.ts +15 -0
  4. package/dist/control/admission-service.d.ts.map +1 -0
  5. package/dist/control/admission-service.js +64 -0
  6. package/dist/control/admission-service.js.map +1 -0
  7. package/dist/control/approval-edits.d.ts +12 -0
  8. package/dist/control/approval-edits.d.ts.map +1 -0
  9. package/dist/control/approval-edits.js +66 -0
  10. package/dist/control/approval-edits.js.map +1 -0
  11. package/dist/control/approval-service.d.ts +173 -0
  12. package/dist/control/approval-service.d.ts.map +1 -0
  13. package/dist/control/approval-service.js +342 -0
  14. package/dist/control/approval-service.js.map +1 -0
  15. package/dist/control/authorization.d.ts +94 -0
  16. package/dist/control/authorization.d.ts.map +1 -0
  17. package/dist/control/authorization.js +302 -0
  18. package/dist/control/authorization.js.map +1 -0
  19. package/dist/control/control-cli.d.ts +3 -0
  20. package/dist/control/control-cli.d.ts.map +1 -0
  21. package/dist/control/control-cli.js +279 -0
  22. package/dist/control/control-cli.js.map +1 -0
  23. package/dist/control/control-console.d.ts +17 -0
  24. package/dist/control/control-console.d.ts.map +1 -0
  25. package/dist/control/control-console.js +67 -0
  26. package/dist/control/control-console.js.map +1 -0
  27. package/dist/control/control-host.d.ts +98 -0
  28. package/dist/control/control-host.d.ts.map +1 -0
  29. package/dist/control/control-host.js +459 -0
  30. package/dist/control/control-host.js.map +1 -0
  31. package/dist/control/control-mcp.d.ts +11 -0
  32. package/dist/control/control-mcp.d.ts.map +1 -0
  33. package/dist/control/control-mcp.js +78 -0
  34. package/dist/control/control-mcp.js.map +1 -0
  35. package/dist/control/errors.d.ts +44 -0
  36. package/dist/control/errors.d.ts.map +1 -0
  37. package/dist/control/errors.js +93 -0
  38. package/dist/control/errors.js.map +1 -0
  39. package/dist/control/hello.d.ts +39 -0
  40. package/dist/control/hello.d.ts.map +1 -0
  41. package/dist/control/hello.js +80 -0
  42. package/dist/control/hello.js.map +1 -0
  43. package/dist/control/host-runtime.d.ts +32 -0
  44. package/dist/control/host-runtime.d.ts.map +1 -0
  45. package/dist/control/host-runtime.js +542 -0
  46. package/dist/control/host-runtime.js.map +1 -0
  47. package/dist/control/index.d.ts +32 -0
  48. package/dist/control/index.d.ts.map +1 -0
  49. package/dist/control/index.js +32 -0
  50. package/dist/control/index.js.map +1 -0
  51. package/dist/control/legacy-conflict.d.ts +19 -0
  52. package/dist/control/legacy-conflict.d.ts.map +1 -0
  53. package/dist/control/legacy-conflict.js +176 -0
  54. package/dist/control/legacy-conflict.js.map +1 -0
  55. package/dist/control/local-bootstrap.d.ts +21 -0
  56. package/dist/control/local-bootstrap.d.ts.map +1 -0
  57. package/dist/control/local-bootstrap.js +224 -0
  58. package/dist/control/local-bootstrap.js.map +1 -0
  59. package/dist/control/modes.d.ts +60 -0
  60. package/dist/control/modes.d.ts.map +1 -0
  61. package/dist/control/modes.js +86 -0
  62. package/dist/control/modes.js.map +1 -0
  63. package/dist/control/project-admin-cli.d.ts +3 -0
  64. package/dist/control/project-admin-cli.d.ts.map +1 -0
  65. package/dist/control/project-admin-cli.js +155 -0
  66. package/dist/control/project-admin-cli.js.map +1 -0
  67. package/dist/control/project-registry.d.ts +20 -0
  68. package/dist/control/project-registry.d.ts.map +1 -0
  69. package/dist/control/project-registry.js +181 -0
  70. package/dist/control/project-registry.js.map +1 -0
  71. package/dist/control/runtime-checkpoint.d.ts +11 -0
  72. package/dist/control/runtime-checkpoint.d.ts.map +1 -0
  73. package/dist/control/runtime-checkpoint.js +39 -0
  74. package/dist/control/runtime-checkpoint.js.map +1 -0
  75. package/dist/control/runtime-provider.d.ts +100 -0
  76. package/dist/control/runtime-provider.d.ts.map +1 -0
  77. package/dist/control/runtime-provider.js +258 -0
  78. package/dist/control/runtime-provider.js.map +1 -0
  79. package/dist/control/schema/approval.d.ts +108 -0
  80. package/dist/control/schema/approval.d.ts.map +1 -0
  81. package/dist/control/schema/approval.js +65 -0
  82. package/dist/control/schema/approval.js.map +1 -0
  83. package/dist/control/schema/commands.d.ts +663 -0
  84. package/dist/control/schema/commands.d.ts.map +1 -0
  85. package/dist/control/schema/commands.js +147 -0
  86. package/dist/control/schema/commands.js.map +1 -0
  87. package/dist/control/schema/common.d.ts +24 -0
  88. package/dist/control/schema/common.d.ts.map +1 -0
  89. package/dist/control/schema/common.js +38 -0
  90. package/dist/control/schema/common.js.map +1 -0
  91. package/dist/control/schema/coordinator.d.ts +98 -0
  92. package/dist/control/schema/coordinator.d.ts.map +1 -0
  93. package/dist/control/schema/coordinator.js +87 -0
  94. package/dist/control/schema/coordinator.js.map +1 -0
  95. package/dist/control/schema/evidence.d.ts +140 -0
  96. package/dist/control/schema/evidence.d.ts.map +1 -0
  97. package/dist/control/schema/evidence.js +64 -0
  98. package/dist/control/schema/evidence.js.map +1 -0
  99. package/dist/control/schema/header.d.ts +79 -0
  100. package/dist/control/schema/header.d.ts.map +1 -0
  101. package/dist/control/schema/header.js +66 -0
  102. package/dist/control/schema/header.js.map +1 -0
  103. package/dist/control/schema/index.d.ts +31 -0
  104. package/dist/control/schema/index.d.ts.map +1 -0
  105. package/dist/control/schema/index.js +31 -0
  106. package/dist/control/schema/index.js.map +1 -0
  107. package/dist/control/schema/plan.d.ts +291 -0
  108. package/dist/control/schema/plan.d.ts.map +1 -0
  109. package/dist/control/schema/plan.js +80 -0
  110. package/dist/control/schema/plan.js.map +1 -0
  111. package/dist/control/schema/policy.d.ts +96 -0
  112. package/dist/control/schema/policy.d.ts.map +1 -0
  113. package/dist/control/schema/policy.js +73 -0
  114. package/dist/control/schema/policy.js.map +1 -0
  115. package/dist/control/schema/run.d.ts +61 -0
  116. package/dist/control/schema/run.d.ts.map +1 -0
  117. package/dist/control/schema/run.js +56 -0
  118. package/dist/control/schema/run.js.map +1 -0
  119. package/dist/control/schema/te-mirrors.d.ts +145 -0
  120. package/dist/control/schema/te-mirrors.d.ts.map +1 -0
  121. package/dist/control/schema/te-mirrors.js +145 -0
  122. package/dist/control/schema/te-mirrors.js.map +1 -0
  123. package/dist/control/schema/transport.d.ts +479 -0
  124. package/dist/control/schema/transport.d.ts.map +1 -0
  125. package/dist/control/schema/transport.js +181 -0
  126. package/dist/control/schema/transport.js.map +1 -0
  127. package/dist/control/singleton.d.ts +135 -0
  128. package/dist/control/singleton.d.ts.map +1 -0
  129. package/dist/control/singleton.js +607 -0
  130. package/dist/control/singleton.js.map +1 -0
  131. package/dist/control/store/coordinator-store.d.ts +143 -0
  132. package/dist/control/store/coordinator-store.d.ts.map +1 -0
  133. package/dist/control/store/coordinator-store.js +620 -0
  134. package/dist/control/store/coordinator-store.js.map +1 -0
  135. package/dist/control/store/evidence-adapter.d.ts +10 -0
  136. package/dist/control/store/evidence-adapter.d.ts.map +1 -0
  137. package/dist/control/store/evidence-adapter.js +114 -0
  138. package/dist/control/store/evidence-adapter.js.map +1 -0
  139. package/dist/control/store/evidence-explanation.d.ts +55 -0
  140. package/dist/control/store/evidence-explanation.d.ts.map +1 -0
  141. package/dist/control/store/evidence-explanation.js +50 -0
  142. package/dist/control/store/evidence-explanation.js.map +1 -0
  143. package/dist/control/store/evidence-store.d.ts +143 -0
  144. package/dist/control/store/evidence-store.d.ts.map +1 -0
  145. package/dist/control/store/evidence-store.js +437 -0
  146. package/dist/control/store/evidence-store.js.map +1 -0
  147. package/dist/control/store/index.d.ts +3 -0
  148. package/dist/control/store/index.d.ts.map +1 -0
  149. package/dist/control/store/index.js +2 -0
  150. package/dist/control/store/index.js.map +1 -0
  151. package/dist/control/store/journal.d.ts +270 -0
  152. package/dist/control/store/journal.d.ts.map +1 -0
  153. package/dist/control/store/journal.js +50 -0
  154. package/dist/control/store/journal.js.map +1 -0
  155. package/dist/control/store/project-lifecycle.d.ts +49 -0
  156. package/dist/control/store/project-lifecycle.d.ts.map +1 -0
  157. package/dist/control/store/project-lifecycle.js +288 -0
  158. package/dist/control/store/project-lifecycle.js.map +1 -0
  159. package/dist/control/store/store.d.ts +93 -0
  160. package/dist/control/store/store.d.ts.map +1 -0
  161. package/dist/control/store/store.js +873 -0
  162. package/dist/control/store/store.js.map +1 -0
  163. package/dist/control/te-provider.d.ts +86 -0
  164. package/dist/control/te-provider.d.ts.map +1 -0
  165. package/dist/control/te-provider.js +131 -0
  166. package/dist/control/te-provider.js.map +1 -0
  167. package/dist/control/uds.d.ts +114 -0
  168. package/dist/control/uds.d.ts.map +1 -0
  169. package/dist/control/uds.js +456 -0
  170. package/dist/control/uds.js.map +1 -0
  171. package/dist/control/web-console/view.d.ts +5 -0
  172. package/dist/control/web-console/view.d.ts.map +1 -0
  173. package/dist/control/web-console/view.js +122 -0
  174. package/dist/control/web-console/view.js.map +1 -0
  175. package/dist/control/web-console.d.ts +50 -0
  176. package/dist/control/web-console.d.ts.map +1 -0
  177. package/dist/control/web-console.js +317 -0
  178. package/dist/control/web-console.js.map +1 -0
  179. package/dist/mcp/jsonrpc.d.ts.map +1 -1
  180. package/dist/mcp/jsonrpc.js +12 -3
  181. package/dist/mcp/jsonrpc.js.map +1 -1
  182. package/dist/mcp/server.d.ts.map +1 -1
  183. package/dist/mcp/server.js +26 -17
  184. package/dist/mcp/server.js.map +1 -1
  185. package/package.json +13 -5
@@ -0,0 +1,542 @@
1
+ /** Authenticated mounted-project command service. All lifecycle facts are written
2
+ * through ControlStore; coordinator authority reads those same durable facts. */
3
+ import { randomUUID } from "node:crypto";
4
+ import * as path from "node:path";
5
+ import { getBuildInfo, compileTaskflowToIR, validateTaskflow } from "taskflow-core";
6
+ import { Value } from "typebox/value";
7
+ import { ControlError } from "./errors.js";
8
+ import { LegacyConflictGuard } from "./legacy-conflict.js";
9
+ import { ProjectRegistry } from "./project-registry.js";
10
+ import {} from "./authorization.js";
11
+ import { UserCoordinatorStore } from "./store/coordinator-store.js";
12
+ import { ProjectAdmissionService } from "./admission-service.js";
13
+ import { RuntimeTeExecutionProvider } from "./runtime-provider.js";
14
+ import { commandRequestHash } from "./schema/commands.js";
15
+ import { CONTROL_WIRE_SCHEMA_VERSION, UuidSchema } from "./schema/common.js";
16
+ import { ApprovalService } from "./approval-service.js";
17
+ import { decodeApprovalEdit, validateApprovalOutputEdit, validateApprovalPlanEdit, MAX_APPROVAL_EDIT_BYTES } from "./approval-edits.js";
18
+ import { explainControlEvidence } from "./store/evidence-explanation.js";
19
+ function fail(message) { throw new ControlError("TF_COMMAND_FAILED", message); }
20
+ function object(value) { if (!value || typeof value !== "object" || Array.isArray(value))
21
+ fail("RPC requires an object"); return value; }
22
+ function id(value) { if (!Value.Check(UuidSchema, value))
23
+ fail("UUID required"); return value; }
24
+ const fact = (payload) => ({ eventId: randomUUID(), recordedAt: Date.now(), payload });
25
+ export class HostRuntime {
26
+ options;
27
+ coordinator;
28
+ #evidence = new Map();
29
+ #approvals = new Map();
30
+ #linked = new Map();
31
+ #closed = false;
32
+ #submitting = new Set();
33
+ #refreshes = new Map();
34
+ constructor(options) {
35
+ this.options = options;
36
+ this.coordinator = new UserCoordinatorStore(path.join(options.controlHome, "coordinator"), { initialMaxActiveRuns: options.maxActiveRuns ?? 1,
37
+ epoch: options.epoch(), holderId: options.holderId(), authority: {
38
+ readLease: options.liveLease,
39
+ authorize: async (context, operation, reservation) => {
40
+ const identity = options.authorization.identity(context);
41
+ const mount = options.registry.resolve(reservation?.projectId ?? identity.binding.projectId);
42
+ const kind = operation === "forceRelease" ? "coordinator.forceRelease" : operation === "setMaxActiveRuns" ? "coordinator.setMaxActiveRuns" : "run.submit";
43
+ const decision = await this.#authorize(context, mount, operation === "snapshot" || operation === "readReservation" ? "read" : "submit", kind);
44
+ return { principal: decision.principal, ownerId: decision.principal, operator: operation === "forceRelease" || operation === "setMaxActiveRuns" };
45
+ },
46
+ readAdmission: reservation => this.#admission(reservation).readAdmission(reservation),
47
+ abandonAdmissionIfAbsent: reservation => this.#admission(reservation).abandonAdmissionIfAbsent(reservation),
48
+ readRelease: async (reservation) => {
49
+ const store = options.registry.resolve(reservation.projectId).store;
50
+ const run = store.readRun(reservation.runId);
51
+ if (!run)
52
+ fail("release run is absent");
53
+ const observed = await options.provider.observe(run.runId);
54
+ const quiet = await options.provider.quiescence(run.runId);
55
+ return { reservationId: reservation.reservationId, projectId: run.projectId, projectControlDomainId: run.controlDomainId, runId: run.runId,
56
+ projectAdmitCommitSeq: run.projectAdmitCommitSeq, runVersion: run.runVersion, proofId: observed.evidenceCommit,
57
+ status: run.status, stage: run.stage, requiresReadmission: !!run.requiresReadmission,
58
+ providerNoLiveProcessTree: quiet.quiescent, noAmbiguousJobs: observed.status !== "ambiguous" && quiet.noPendingResourceIntents,
59
+ reconcileTimeoutOnly: false };
60
+ },
61
+ } });
62
+ }
63
+ async initialize() { await this.coordinator.initialize(); }
64
+ close() { this.#closed = true; this.coordinator.close(); }
65
+ #admission(r) { return new ProjectAdmissionService(this.options.registry.resolve(r.projectId).store); }
66
+ async #authorize(context, mount, operation, commandKind) {
67
+ const h = mount.store.header;
68
+ return this.options.authorization.authorize(context, { projectId: h.projectId, controlDomainId: h.controlDomainId, projectRoot: mount.projectRoot, operation, ...(commandKind ? { commandKind } : {}) });
69
+ }
70
+ #mutate(store, run, changes, payloads = []) {
71
+ return store.mutateRun(run.runId, run.runVersion, state => {
72
+ const next = { ...state.run, ...changes, runVersion: state.run.runVersion + 1 };
73
+ return { ...state, run: next, events: [...payloads.map(fact), fact({ kind: "run.snapshot", run: next })] };
74
+ }).run;
75
+ }
76
+ async dispatch(context, method, raw) {
77
+ const body = raw === undefined ? {} : object(structuredClone(raw));
78
+ const identity = this.options.authorization.identity(context);
79
+ const projectId = body.projectId === undefined ? identity.binding.projectId : id(body.projectId);
80
+ const mount = this.options.registry.resolve(projectId);
81
+ if (body.controlDomainId !== undefined && body.controlDomainId !== mount.store.header.controlDomainId)
82
+ throw new ControlError("TF_POLICY_DENIED", "request control domain mismatch");
83
+ await this.#authorize(context, mount, "read");
84
+ switch (method) {
85
+ case "projects.list": return [{ projectId, controlDomainId: mount.store.header.controlDomainId, name: path.basename(mount.projectRoot) }];
86
+ case "control.store.header": return mount.store.header;
87
+ case "control.store.status": return mount.store.snapshot();
88
+ case "coordinator.status": {
89
+ const snapshot = await this.coordinator.snapshot(context);
90
+ await this.#authorize(context, mount, "read");
91
+ return { ...snapshot,
92
+ reservations: snapshot.reservations.filter(row => row.reservation.projectId === projectId && row.reservation.projectControlDomainId === mount.store.header.controlDomainId),
93
+ commands: snapshot.commands.filter(command => command.record.callerPrincipal === identity.principal && (typeof command.result === "number" || command.result.reservation.projectId === projectId && command.result.reservation.projectControlDomainId === mount.store.header.controlDomainId)),
94
+ };
95
+ }
96
+ case "coordinator.setMaxActiveRuns": return this.coordinator.setMaxActiveRuns({ commandId: id(body.commandId), maxActiveRuns: body.maxActiveRuns }, context);
97
+ case "coordinator.forceRelease": return this.coordinator.forceRelease(id(body.reservationId), { commandId: id(body.commandId), riskAcknowledgement: body.riskAcknowledgement, reason: body.reason }, context);
98
+ case "runs.list": return mount.store.listRuns();
99
+ case "commands.submit": return this.#submit(context, mount, body);
100
+ case "runs.get":
101
+ case "runs.status":
102
+ case "runs.wait": {
103
+ const runId = id(body.runId);
104
+ const run = mount.store.readRun(runId);
105
+ if (!run)
106
+ fail("run does not exist in this project");
107
+ if (method === "runs.wait" && !["completed", "failed", "blocked", "cancelled"].includes(run.status))
108
+ await this.options.provider.wait(runId);
109
+ await this.#refresh(context, mount, runId);
110
+ await this.#authorize(context, mount, "read");
111
+ return mount.store.readRun(runId);
112
+ }
113
+ case "runs.cancel": return this.#cancel(context, mount, body);
114
+ case "runs.result": {
115
+ await this.#authorize(context, mount, "replay", "run.submit");
116
+ const runId = id(body.runId);
117
+ if (!mount.store.readRun(runId))
118
+ fail("run is absent");
119
+ const observed = await this.options.provider.observe(runId);
120
+ await this.#authorize(context, mount, "replay", "run.submit");
121
+ return observed;
122
+ }
123
+ case "approvals.stageEdit": {
124
+ const runId = id(body.runId), kind = body.editKind;
125
+ if (!["output", "plan"].includes(String(kind)) || typeof body.content !== "string" || Buffer.byteLength(body.content) > MAX_APPROVAL_EDIT_BYTES)
126
+ fail("stageEdit requires bounded output or plan text");
127
+ await this.#authorize(context, mount, "submit", "approval.decide");
128
+ const run = mount.store.readRun(runId);
129
+ if (!run || run.status !== "paused")
130
+ fail("edit artifact requires a paused approval run");
131
+ const evidence = await this.#evidenceStore(mount);
132
+ const artifact = await evidence.stageArtifact(Buffer.from(body.content, "utf8"), { mediaType: kind === "output" ? "text/plain; charset=utf-8" : "application/json", storageClass: "project", redactionClass: "internal" });
133
+ await evidence.assertDurableReferences([artifact]);
134
+ await this.#authorize(context, mount, "submit", "approval.decide");
135
+ mount.store.mutateRun(runId, run.runVersion, state => {
136
+ if (state.run.status !== "paused")
137
+ fail("approval changed before artifact staging");
138
+ const next = { ...state.run, runVersion: state.run.runVersion + 1 };
139
+ return { ...state, run: next, approvals: state.approvals.map(item => item.status === "pending" ? { ...item, expectedRunVersion: next.runVersion } : item), events: [fact({ kind: "artifact.recorded", runId, commandKind: "approval.decide", artifact }), fact({ kind: "run.snapshot", run: next })] };
140
+ });
141
+ return artifact;
142
+ }
143
+ case "approval.decide":
144
+ case "approvals.decide": {
145
+ const input = { commandId: id(body.commandId), runId: id(body.runId), approvalRequestId: id(body.approvalRequestId), expectedRunVersion: body.expectedRunVersion, decision: body.decision, ...(body.editArtifactRef !== undefined ? { editArtifactRef: body.editArtifactRef } : {}), ...(body.editKind !== undefined ? { editKind: body.editKind } : {}) };
146
+ const needsRecovery = !this.options.provider.hasLiveExecution(input.runId) && mount.store.readRun(input.runId)?.status === "paused";
147
+ if (needsRecovery) {
148
+ const recovered = this.options.provider.recoverableExecution(input.runId);
149
+ this.#linked.set(input.runId, { plan: recovered.plan, flow: recovered.flow });
150
+ }
151
+ const decision = await this.#approvalService(mount).decide(context, input);
152
+ if (needsRecovery)
153
+ await this.#resume(context, mount, input.runId);
154
+ return decision;
155
+ }
156
+ case "runs.resume": return this.#resume(context, mount, id(body.runId));
157
+ case "approvals.expire": return this.#approvalService(mount).expireDue(context);
158
+ case "approvals.readmit": return this.#approvalService(mount).readmit(context, { approvalRequestId: id(body.approvalRequestId), expectedRunVersion: body.expectedRunVersion });
159
+ case "approvals.list": return mount.store.listApprovals();
160
+ case "evidence.why": return explainControlEvidence(mount.store, context, id(body.runId), {
161
+ authorize: async () => this.#authorize(context, mount, "replay", "run.submit"), readDiagnostic: runId => this.options.provider.readDiagnostic(runId)
162
+ }, { effectId: body.effectId, phaseId: body.phaseId, seeds: body.seeds });
163
+ case "receipts.get": {
164
+ const evidence = await this.#evidenceStore(mount);
165
+ const receipt = await evidence.readReceipt(context, id(body.runId));
166
+ return receipt.receipt;
167
+ }
168
+ default: fail(`unsupported authenticated RPC ${method}`);
169
+ }
170
+ }
171
+ async #submit(context, mount, body) {
172
+ if (Object.keys(body).some(key => !["projectId", "controlDomainId", "commandId", "kind", "flow", "args", "approvalMode"].includes(key)))
173
+ fail("submit contains untrusted or unsupported fields");
174
+ if (body.kind !== "run.submit")
175
+ fail("commands.submit requires kind run.submit");
176
+ const commandId = id(body.commandId);
177
+ const auth = await this.#authorize(context, mount, "submit", "run.submit");
178
+ const h = mount.store.header;
179
+ const binding = { commandId, kind: "run.submit", callerPrincipal: auth.principal, requestHash: commandRequestHash(body), projectId: h.projectId, controlDomainId: h.controlDomainId };
180
+ const previous = mount.store.readCommittedCommand(binding);
181
+ if (previous) {
182
+ await this.#authorize(context, mount, "replay", "run.submit");
183
+ return JSON.parse(previous.responseJson);
184
+ }
185
+ new LegacyConflictGuard(mount.projectRoot, mount.store.storePath).assertMayAttempt();
186
+ const validation = validateTaskflow(body.flow);
187
+ if (!validation.ok)
188
+ fail(validation.errors.join("; "));
189
+ let mode = (body.approvalMode ?? "compat-auto-reject");
190
+ if (!["compat-auto-reject", "durable-optional", "durable-required"].includes(mode))
191
+ fail("invalid approval mode");
192
+ if (mode !== "compat-auto-reject" && !this.options.durableApprovalAvailable?.(context)) {
193
+ if (mode === "durable-required")
194
+ throw new ControlError("TF_FEATURE_REQUIRED", "durable-approval was not negotiated by this authenticated client");
195
+ mode = "compat-auto-reject";
196
+ }
197
+ const flow = structuredClone(body.flow);
198
+ const args = body.args === undefined ? {} : object(body.args);
199
+ const compiled = await compileTaskflowToIR(flow);
200
+ const probe = await this.options.provider.probe();
201
+ if (!compiled.hash || compiled.usedFallbackHash)
202
+ throw new ControlError("TF_COMMAND_FAILED", "flow cannot be content-addressed at Link");
203
+ const runId = randomUUID();
204
+ const owner = { runId, phaseId: "control", attemptId: randomUUID(), unitId: "run", ancestry: [] };
205
+ const planBody = { schemaVersion: CONTROL_WIRE_SCHEMA_VERSION, projectId: h.projectId, controlDomainId: h.controlDomainId, planId: randomUUID(),
206
+ bindings: [{ name: "invocation", path: { workspace: "invocation", intent: "existing-directory", access: "read-write" } }],
207
+ spawnTemplate: { allowedAgentClasses: ["configured"], allowedProviderClasses: ["te-resources"], maxToolCallsPerStep: 0, maxEffectsPerNode: 1000, maxChildren: 1000, maxDepth: 32, budgetShare: 1 },
208
+ savedFlowPins: [], grantRefs: [], claims: [{ name: "irHash", value: compiled.hash }], enforcementCapabilities: probe.capabilities,
209
+ dynamicPolicy: { hostCeiling: { maxActiveRuns: this.options.maxActiveRuns ?? 1 }, authorizationContextHash: auth.authorizationContextHash } };
210
+ const plan = { ...planBody, boundPlanHash: `plan:${commandRequestHash(planBody)}` };
211
+ this.#linked.set(runId, { flow, plan });
212
+ const run = { runId, projectId: h.projectId, controlDomainId: h.controlDomainId, status: "running", stage: "linked", slot: "none", needsOperator: false, runVersion: 0,
213
+ boundPlanHash: plan.boundPlanHash, policyHash: commandRequestHash(auth.effectivePolicy), authorityEpoch: this.options.epoch(), owner };
214
+ const response = { runId, projectId: h.projectId, controlDomainId: h.controlDomainId, status: "accepted" };
215
+ let reservation;
216
+ const preparation = await this.options.provider.prepareExecution({ plan, flow, args, projectRoot: mount.projectRoot, principal: auth.principal, owner, controlDomainId: h.controlDomainId,
217
+ requestApproval: request => this.#requestApproval(context, mount, runId, request, mode),
218
+ dispatchFence: activate => { if (!reservation)
219
+ fail("dispatch reservation missing"); return this.#dispatchFence(context, mount, reservation.reservationId, activate); },
220
+ beforeDispatch: async () => {
221
+ if (!reservation)
222
+ fail("dispatch has no admission binding");
223
+ const row = await this.coordinator.readReservation(reservation.reservationId, context);
224
+ if (!row)
225
+ fail("dispatch reservation missing");
226
+ await this.#authorize(context, mount, "submit", "run.submit");
227
+ const lease = this.options.liveLease();
228
+ if (!lease || lease.fencingEpoch !== run.authorityEpoch || lease.expiresAt <= Date.now())
229
+ throw new ControlError("TF_AUTHORITY_REVOKED", "dispatch fencing epoch is no longer live");
230
+ new ProjectAdmissionService(mount.store).assertDispatch(row.reservation);
231
+ } });
232
+ if (preparation.outcome !== "accepted")
233
+ throw new ControlError("TF_PROVIDER_AMBIGUOUS", "TE preparation did not accept");
234
+ await this.#authorize(context, mount, "submit", "run.submit");
235
+ const raced = mount.store.readCommittedCommand(binding);
236
+ if (raced)
237
+ return JSON.parse(raced.responseJson);
238
+ const command = { ...binding, authorizationContextHash: auth.authorizationContextHash, status: "accepted", firstCommitSeq: 1, lastCommitSeq: 1, recordedAt: Date.now() };
239
+ this.#submitting.add(runId);
240
+ mount.store.createRun(run, [fact({ kind: "run.snapshot", run })], { command, responseJson: JSON.stringify(response) });
241
+ try {
242
+ reservation = (await this.coordinator.reserve({ projectId: h.projectId, projectControlDomainId: h.controlDomainId, runId, ttlMs: 30000 }, context)).reservation;
243
+ const proof = new ProjectAdmissionService(mount.store).admit(reservation, mount.store.readRun(runId).runVersion);
244
+ reservation = (await this.coordinator.commit(reservation.reservationId, { projectAdmitCommitSeq: proof.projectAdmitCommitSeq, attemptId: owner.attemptId }, context)).reservation;
245
+ this.#mutate(mount.store, mount.store.readRun(runId), { slot: "committed", stage: "admitted" });
246
+ const result = await this.options.provider.submit({ preparationId: preparation.fulfillment.preparationId, owner, controlDomainId: h.controlDomainId });
247
+ if (result.outcome === "accepted")
248
+ this.#mutate(mount.store, mount.store.readRun(runId), { stage: "executing" }, [{ kind: "dispatch.acknowledged", providerJobHandle: result.providerJobHandle }]);
249
+ else
250
+ this.#mutate(mount.store, mount.store.readRun(runId), { status: "unknown", stage: "reconciling", needsOperator: true }, [{ kind: "dispatch.ambiguous" }]);
251
+ }
252
+ catch {
253
+ // The command is already durable: report its original acceptance, preserve
254
+ // admission/capacity evidence, and expose uncertainty through run state.
255
+ this.#mutate(mount.store, mount.store.readRun(runId), { status: "unknown", stage: "reconciling", needsOperator: true }, [{ kind: "dispatch.ambiguous" }]);
256
+ }
257
+ this.#submitting.delete(runId);
258
+ await this.#authorize(context, mount, "replay", "run.submit");
259
+ return response;
260
+ }
261
+ async #dispatchFence(context, mount, reservationId, activate) {
262
+ return this.coordinator.withDispatch(reservationId, context, async (row) => {
263
+ await this.#authorize(context, mount, "submit", "run.submit");
264
+ return () => {
265
+ const current = this.options.registry.resolve(row.reservation.projectId);
266
+ const run = current.store.readRun(row.reservation.runId), lease = this.options.liveLease();
267
+ if (!run || !lease || lease.fencingEpoch !== this.options.epoch() || run.authorityEpoch !== lease.fencingEpoch || lease.expiresAt <= Date.now())
268
+ throw new ControlError("TF_AUTHORITY_REVOKED", "dispatch authority is no longer live");
269
+ this.options.authorization.identity(context);
270
+ new ProjectAdmissionService(current.store).assertDispatch(row.reservation);
271
+ new LegacyConflictGuard(current.projectRoot, current.store.storePath).assertMayAttempt();
272
+ return activate();
273
+ };
274
+ });
275
+ }
276
+ async #resume(context, mount, runId) {
277
+ await this.#authorize(context, mount, "submit", "run.submit");
278
+ if (this.options.provider.hasLiveExecution(runId))
279
+ return mount.store.readRun(runId);
280
+ new LegacyConflictGuard(mount.projectRoot, mount.store.storePath).assertMayAttempt();
281
+ let saved = this.options.provider.recoverableExecution(runId);
282
+ this.#linked.set(runId, { plan: saved.plan, flow: saved.flow });
283
+ let current = mount.store.readRun(runId);
284
+ if (!current)
285
+ fail("resume run missing");
286
+ const decision = mount.store.listApprovals().filter(a => a.runId === runId && ["approved", "edited", "rejected", "expired", "cancelled"].includes(a.status)).at(-1);
287
+ if (!decision)
288
+ throw new ControlError("TF_RECONCILE_REQUIRED", "pending approval must be decided before continuation");
289
+ // The prior host may have committed readmission before dying at the still
290
+ // waiting checkpoint. That reservation/owner cannot authorize this process.
291
+ // Recovery above proves the old writer dead; only fresh TE quiescence permits
292
+ // parking and normally releasing its capacity before another readmission.
293
+ if (["approved", "edited"].includes(decision.status) && !current.requiresReadmission) {
294
+ if (current.status !== "running" || current.stage !== "admitted" || current.slot !== "committed")
295
+ throw new ControlError("TF_RECONCILE_REQUIRED", "only an interrupted committed readmission may be re-parked");
296
+ const quiet = await this.options.provider.quiescence(runId);
297
+ if (!quiet.quiescent || !quiet.noPendingResourceIntents)
298
+ throw new ControlError("TF_RECONCILE_REQUIRED", "prior readmission is not quiescent");
299
+ await this.#authorize(context, mount, "submit", "run.submit");
300
+ current = this.#mutate(mount.store, current, { status: "paused", stage: "parked", requiresReadmission: true, needsOperator: false });
301
+ await this.#approvalService(mount).park(context, { runId, expectedRunVersion: current.runVersion });
302
+ current = mount.store.readRun(runId);
303
+ }
304
+ if (["approved", "edited"].includes(decision.status) && current.requiresReadmission)
305
+ await this.#approvalService(mount).readmit(context, { approvalRequestId: decision.approvalRequestId, expectedRunVersion: current.runVersion });
306
+ if (decision.status === "edited" && decision.editKind === "plan") {
307
+ const edit = await this.#readApprovalEdit(context, mount, current, decision, { kind: "plan", artifact: decision.editArtifactRef });
308
+ if (edit.kind !== "plan")
309
+ fail("plan edit missing");
310
+ await this.options.provider.applyApprovalPlan(runId, decision.nodeInstanceId, edit.flow);
311
+ saved = this.options.provider.recoverableExecution(runId);
312
+ this.#linked.set(runId, { plan: saved.plan, flow: saved.flow });
313
+ }
314
+ const run = mount.store.readRun(runId);
315
+ const beforeDispatch = async () => {
316
+ await this.#authorize(context, mount, "submit", "run.submit");
317
+ if (!["approved", "edited"].includes(decision.status))
318
+ return; // rejection resumes only the waiting approval; core blocks its dependents
319
+ const row = await this.coordinator.readReservation(run.reservationId, context);
320
+ await this.#authorize(context, mount, "submit", "run.submit");
321
+ if (!row)
322
+ fail("resumed reservation missing");
323
+ new ProjectAdmissionService(mount.store).assertDispatch(row.reservation);
324
+ };
325
+ await this.options.provider.resumeExecution(runId, { ...saved, owner: run.owner, beforeDispatch, dispatchFence: ["approved", "edited"].includes(decision.status) ? activate => this.#dispatchFence(context, mount, run.reservationId, activate) : undefined, requestApproval: request => this.#requestApproval(context, mount, runId, request, "durable-required") });
326
+ return mount.store.readRun(runId);
327
+ }
328
+ async #cancel(context, mount, body) {
329
+ const input = { commandId: id(body.commandId), runId: id(body.runId), expectedRunVersion: body.expectedRunVersion };
330
+ const auth = await this.#authorize(context, mount, "submit", "run.cancel"), h = mount.store.header;
331
+ const binding = { commandId: input.commandId, kind: "run.cancel", requestHash: commandRequestHash(input), callerPrincipal: auth.principal, projectId: h.projectId, controlDomainId: h.controlDomainId };
332
+ const prior = mount.store.readCommittedCommand(binding);
333
+ if (prior)
334
+ return JSON.parse(prior.responseJson);
335
+ const response = { runId: input.runId, status: "cancellation-requested" };
336
+ mount.store.mutateRun(input.runId, input.expectedRunVersion, (state, meta) => {
337
+ if (["completed", "failed", "blocked", "cancelled"].includes(state.run.status))
338
+ throw new ControlError("TF_STALE_VERSION", "terminal run cannot be cancelled");
339
+ const run = { ...state.run, status: "unknown", stage: "reconciling", needsOperator: true, requiresReadmission: false, runVersion: state.run.runVersion + 1 };
340
+ return { ...state, run, approvals: state.approvals.map(a => a.status === "pending" ? { ...a, status: "cancelled", decidedAt: Date.now() } : a), events: [fact({ kind: "run.snapshot", run })], command: { ...binding, authorizationContextHash: auth.authorizationContextHash, status: "accepted", firstCommitSeq: meta.commitSeq, lastCommitSeq: meta.commitSeq, recordedAt: Date.now() }, responseJson: JSON.stringify(response) };
341
+ }, binding);
342
+ await this.options.provider.cancel({ providerJobHandle: input.runId });
343
+ await this.#refresh(context, mount, input.runId);
344
+ await this.#authorize(context, mount, "replay", "run.cancel");
345
+ return response;
346
+ }
347
+ async #readApprovalEdit(context, mount, run, request, edit) {
348
+ if (!Number.isSafeInteger(edit.artifact.size) || edit.artifact.size < 0 || edit.artifact.size > MAX_APPROVAL_EDIT_BYTES)
349
+ fail("approval edit artifact exceeds the supported size");
350
+ const evidence = await this.#evidenceStore(mount);
351
+ const bytes = await evidence.readArtifact(context, { kind: "run", id: run.runId }, edit.artifact);
352
+ const text = decodeApprovalEdit(bytes, edit.artifact, edit.kind);
353
+ const diagnostic = this.options.provider.readDiagnostic(run.runId);
354
+ if (!diagnostic || diagnostic.projectId !== run.projectId || diagnostic.controlDomainId !== run.controlDomainId)
355
+ throw new ControlError("TF_RECONCILE_REQUIRED", "edit checkpoint is unavailable");
356
+ if (edit.kind === "output")
357
+ return { kind: "output", output: validateApprovalOutputEdit(diagnostic.state, request.nodeInstanceId, text) };
358
+ let candidate;
359
+ try {
360
+ candidate = JSON.parse(text);
361
+ }
362
+ catch {
363
+ fail("approval plan artifact is not JSON");
364
+ }
365
+ return { kind: "plan", ...await validateApprovalPlanEdit(diagnostic.state, request.nodeInstanceId, candidate) };
366
+ }
367
+ #approvalService(mount) {
368
+ let service = this.#approvals.get(mount.store.header.projectId);
369
+ if (service)
370
+ return service;
371
+ const authority = {
372
+ authorize: async (context, scope) => this.#authorize(context, mount, scope.operation === "read" ? "read" : "submit", scope.operation === "decide" ? "approval.decide" : scope.operation === "cancel" ? "run.cancel" : "run.submit"),
373
+ validateRequest: async (_context, run, node) => { if (!this.#linked.get(run.runId)?.flow.phases.some(p => p.id === node && p.type === "approval"))
374
+ throw new ControlError("TF_AUTHORITY_REVOKED", "approval node is not in linked flow"); },
375
+ negotiateDurability: async (context) => this.options.durableApprovalAvailable?.(context) === true,
376
+ quiescence: async (_context, run) => { const q = await this.options.provider.quiescence(run.runId); return { proofId: q.proofId, projectId: run.projectId, projectControlDomainId: run.controlDomainId, runId: run.runId, projectAdmitCommitSeq: run.projectAdmitCommitSeq, providerNoLiveProcessTree: q.quiescent, noAmbiguousJobs: q.quiescent, noPendingResourceIntents: q.noPendingResourceIntents, reconcileTimeoutOnly: false }; },
377
+ validateEdit: async (context, run, request, edit) => { await this.#readApprovalEdit(context, mount, run, request, edit); },
378
+ prepareReadmission: async (context, run, request) => {
379
+ new LegacyConflictGuard(mount.projectRoot, mount.store.storePath).assertMayAttempt();
380
+ const linked = this.#linked.get(run.runId);
381
+ if (!linked)
382
+ throw new ControlError("TF_RECONCILE_REQUIRED", "lost live continuation requires operator reconciliation");
383
+ if (request.status === "edited" && request.editKind === "plan") {
384
+ const edit = await this.#readApprovalEdit(context, mount, run, request, { kind: "plan", artifact: request.editArtifactRef });
385
+ if (edit.kind !== "plan")
386
+ fail("plan edit missing");
387
+ linked.flow = edit.flow;
388
+ }
389
+ const auth = await this.#authorize(context, mount, "submit", "run.submit");
390
+ const ir = request.editKind === "output" ? { hash: this.options.provider.readDiagnostic(run.runId)?.state.flowDefHash } : await compileTaskflowToIR(linked.flow);
391
+ if (!ir.hash)
392
+ fail("readmission Link failed");
393
+ const policyHash = commandRequestHash(auth.effectivePolicy);
394
+ const fragmentBody = { schemaVersion: CONTROL_WIRE_SCHEMA_VERSION, projectId: run.projectId, controlDomainId: run.controlDomainId, fragmentId: randomUUID(), parentBoundPlanHash: run.boundPlanHash, ...(run.boundFragmentHash ? { parentBoundFragmentHash: run.boundFragmentHash } : {}), sourceEventId: request.decisionEventId, sourceCommitSeq: request.decisionCommitSeq, fragmentIRHash: ir.hash, fragmentPolicyHash: `policy:${policyHash}`, capabilitySetHash: `caps:${commandRequestHash(linked.plan.enforcementCapabilities)}`, authorityEpoch: this.options.epoch() };
395
+ return { fragment: { ...fragmentBody, boundFragmentHash: `fragment:${commandRequestHash(fragmentBody)}`, executionSemanticHash: ir.hash }, owner: { runId: run.runId, phaseId: request.nodeInstanceId, attemptId: randomUUID(), unitId: randomUUID(), ancestry: [] }, policyHash, authorizationContextHash: auth.authorizationContextHash };
396
+ },
397
+ validateReadmission: async (context, run, intent) => { await this.#authorize(context, mount, "submit", "run.submit"); if (run.authorityEpoch !== this.options.epoch() || run.policyHash !== intent.policyHash)
398
+ throw new ControlError("TF_AUTHORITY_REVOKED", "readmission authority changed"); },
399
+ reserve: async (context, run) => (await this.coordinator.reserve({ projectId: run.projectId, projectControlDomainId: run.controlDomainId, runId: run.runId, ttlMs: 30000 }, context)).reservation,
400
+ commitReservation: async (context, intent) => { await this.coordinator.commit(intent.reservation.reservationId, { projectAdmitCommitSeq: intent.projectAdmitCommitSeq, attemptId: intent.owner.attemptId }, context); },
401
+ normalRelease: async (context, intent) => { await this.coordinator.normalRelease(intent.reservationId, context); },
402
+ };
403
+ service = new ApprovalService(mount.store, authority);
404
+ this.#approvals.set(mount.store.header.projectId, service);
405
+ return service;
406
+ }
407
+ async #requestApproval(context, mount, runId, request, mode) {
408
+ const service = this.#approvalService(mount), run = mount.store.readRun(runId);
409
+ const previous = mount.store.listApprovals().filter(item => item.runId === runId && item.nodeInstanceId === request.phaseId).at(-1);
410
+ const pending = previous ?? await service.request(context, { runId, expectedRunVersion: run.runVersion, nodeInstanceId: request.phaseId, mode, allowedDecisions: ["approve", "reject", "edit"], owner: this.options.authorization.identity(context).principal, deadline: Date.now() + 86400000, timeoutPolicy: "durable-expire" });
411
+ for (;;) {
412
+ if (this.#closed)
413
+ throw new ControlError("TF_RECONCILE_REQUIRED", "control owner stopped during approval");
414
+ const current = mount.store.readApproval(pending.approvalRequestId);
415
+ if (current.status !== "pending") {
416
+ if (!["approved", "edited"].includes(current.status))
417
+ return { decision: "reject", note: current.status };
418
+ const latest = mount.store.readRun(runId);
419
+ if (latest.requiresReadmission && !["released", "none"].includes(latest.slot)) {
420
+ const q = await this.options.provider.quiescence(runId);
421
+ if (!q.quiescent) {
422
+ await new Promise(resolve => setTimeout(resolve, 20));
423
+ continue;
424
+ }
425
+ await service.park(context, { runId, expectedRunVersion: latest.runVersion });
426
+ }
427
+ if (latest.requiresReadmission)
428
+ await service.readmit(context, { approvalRequestId: current.approvalRequestId, expectedRunVersion: mount.store.readRun(runId).runVersion });
429
+ const admitted = mount.store.readRun(runId);
430
+ const row = await this.coordinator.readReservation(admitted.reservationId, context);
431
+ await this.#authorize(context, mount, "submit", "run.submit");
432
+ if (!row)
433
+ fail("readmission reservation missing");
434
+ new ProjectAdmissionService(mount.store).assertDispatch(row.reservation);
435
+ let decision = { decision: "approve" };
436
+ if (current.status === "edited") {
437
+ const edit = await this.#readApprovalEdit(context, mount, admitted, current, { kind: current.editKind, artifact: current.editArtifactRef });
438
+ if (edit.kind === "output")
439
+ decision = { decision: "edit", note: edit.output };
440
+ else {
441
+ await this.options.provider.applyApprovalPlan(runId, current.nodeInstanceId, edit.flow);
442
+ this.#linked.get(runId).flow = edit.flow;
443
+ decision = { decision: "edit", note: "Plan updated" };
444
+ }
445
+ }
446
+ return this.#dispatchFence(context, mount, admitted.reservationId, () => decision);
447
+ }
448
+ const nowRun = mount.store.readRun(runId);
449
+ if (nowRun.status === "paused" && nowRun.stage === "reconciling") {
450
+ const q = await this.options.provider.quiescence(runId);
451
+ if (q.quiescent)
452
+ try {
453
+ await service.park(context, { runId, expectedRunVersion: nowRun.runVersion });
454
+ }
455
+ catch (error) {
456
+ if (!(error instanceof ControlError) || error.code !== "TF_STALE_VERSION")
457
+ throw error;
458
+ }
459
+ }
460
+ if (current.deadline <= Date.now())
461
+ await service.expireDue(context);
462
+ await new Promise(resolve => setTimeout(resolve, 20));
463
+ }
464
+ }
465
+ async #refresh(context, mount, runId) {
466
+ let pending = this.#refreshes.get(runId);
467
+ if (!pending) {
468
+ pending = this.#refreshOnce(context, mount, runId);
469
+ this.#refreshes.set(runId, pending);
470
+ void pending.finally(() => this.#refreshes.delete(runId)).catch(() => { });
471
+ }
472
+ await pending;
473
+ await this.#authorize(context, mount, "read");
474
+ return mount.store.readRun(runId);
475
+ }
476
+ async #refreshOnce(context, mount, runId) {
477
+ let run = mount.store.readRun(runId);
478
+ const terminalEvent = mount.store.readJournal().flatMap(batch => batch.events).find(event => event.streamId === runId && event.payload.kind === "run.terminal");
479
+ let observed;
480
+ try {
481
+ observed = await this.options.provider.observe(runId);
482
+ }
483
+ catch (error) {
484
+ if (this.#submitting.has(runId) || ["completed", "failed", "blocked", "cancelled"].includes(run.status))
485
+ return run;
486
+ if (error instanceof ControlError && error.code === "TF_DURABILITY_FAILED")
487
+ throw error;
488
+ return run.status === "unknown" ? run : this.#mutate(mount.store, run, { status: "unknown", stage: "reconciling", needsOperator: true }, [{ kind: "reconcile.settled", outcome: "exhausted" }]);
489
+ }
490
+ if (observed.projectId !== run.projectId || observed.controlDomainId !== run.controlDomainId)
491
+ throw new ControlError("TF_AUTHORITY_REVOKED", "provider evidence belongs to another project");
492
+ await this.#authorize(context, mount, "read");
493
+ run = mount.store.readRun(runId);
494
+ if (observed.status === "running")
495
+ return run;
496
+ if (observed.status === "ambiguous" || !observed.noPendingResourceIntents) {
497
+ if (run.status !== "unknown")
498
+ run = this.#mutate(mount.store, run, { status: "unknown", stage: "reconciling", needsOperator: true }, [{ kind: "reconcile.settled", outcome: "exhausted" }]);
499
+ return run;
500
+ }
501
+ if (!terminalEvent) {
502
+ const probe = await this.options.provider.probe();
503
+ if (this.options.evidenceFactory && observed.finalOutput !== undefined) {
504
+ const evidence = await this.#evidenceStore(mount);
505
+ const artifact = await evidence.stageArtifact(Buffer.from(observed.finalOutput), { mediaType: "text/plain; charset=utf-8", storageClass: "project", redactionClass: "internal" });
506
+ await evidence.assertDurableReferences([artifact]);
507
+ this.#mutate(mount.store, mount.store.readRun(runId), {}, [{ kind: "artifact.recorded", runId, commandKind: "run.submit", artifact }]);
508
+ }
509
+ mount.store.recordRunEvidence(runId, { buildInfo: getBuildInfo(), assurance: { journalContinuity: true, providerOutcome: observed.status === "completed" ? "completed" : "failed", artifactIntegrity: "unknown", provenance: { confidentiality: "internal", integrity: "project" }, enforcement: { capabilities: probe.capabilities } } });
510
+ run = mount.store.readRun(runId);
511
+ run = this.#mutate(mount.store, run, { status: observed.status, stage: "terminal", needsOperator: false }, [{ kind: "run.terminal", status: observed.status }]);
512
+ }
513
+ if (run.reservationId && run.slot !== "released") {
514
+ await this.coordinator.normalRelease(run.reservationId, context);
515
+ run = this.#mutate(mount.store, mount.store.readRun(runId), { slot: "released" });
516
+ }
517
+ if (this.options.evidenceFactory)
518
+ await (await this.#evidenceStore(mount)).issueFinalReceipt(runId);
519
+ return run;
520
+ }
521
+ async #evidenceStore(mount) { let e = this.#evidence.get(mount.store.header.projectId); if (!e) {
522
+ if (!this.options.evidenceFactory)
523
+ throw new ControlError("TF_FEATURE_REQUIRED", "evidence adapter is not configured");
524
+ e = Promise.resolve(this.options.evidenceFactory(mount, this.verifyTerminal.bind(this)));
525
+ this.#evidence.set(mount.store.header.projectId, e);
526
+ } return e; }
527
+ async verifyTerminal(project, runId, eventId) {
528
+ const store = this.options.registry.resolve(project.projectId).store;
529
+ if (store.header.controlDomainId !== project.controlDomainId)
530
+ throw new ControlError("TF_POLICY_DENIED", "evidence domain mismatch");
531
+ const events = store.readJournal().flatMap(batch => batch.events);
532
+ const event = events.find(event => event.eventId === eventId && event.streamId === runId && event.payload.kind === "run.terminal");
533
+ const observed = await this.options.provider.observe(runId);
534
+ const quiet = await this.options.provider.quiescence(runId);
535
+ if (!quiet.quiescent || !quiet.noPendingResourceIntents)
536
+ throw new ControlError("TF_RECONCILE_REQUIRED", "current TE quiescence is not proven");
537
+ if (!event || event.payload.kind !== "run.terminal" || !["completed", "failed", "blocked", "cancelled"].includes(observed.status) || observed.runId !== runId || observed.projectId !== project.projectId || observed.controlDomainId !== project.controlDomainId || observed.status !== event.payload.status || !observed.noPendingResourceIntents)
538
+ throw new ControlError("TF_RECONCILE_REQUIRED", "terminal event has no matching TE observation");
539
+ return { terminalStatus: event.payload.status, terminalEventId: eventId, evidenceCommit: observed.evidenceCommit, providerOutcome: observed.status === "completed" ? "completed" : "failed" };
540
+ }
541
+ }
542
+ //# sourceMappingURL=host-runtime.js.map