@melra/runtime-core 0.3.0-alpha.11 → 0.3.0-alpha.13

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 (45) hide show
  1. package/README.md +3 -2
  2. package/dist/task-controller.d.ts +81 -3
  3. package/dist/task-controller.d.ts.map +1 -1
  4. package/dist/task-controller.js +305 -52
  5. package/dist/task-controller.js.map +1 -1
  6. package/dist/workflow-controller.d.ts +19 -1
  7. package/dist/workflow-controller.d.ts.map +1 -1
  8. package/dist/workflow-controller.js +369 -92
  9. package/dist/workflow-controller.js.map +1 -1
  10. package/dist/workflow-graph.d.ts.map +1 -1
  11. package/dist/workflow-graph.js +25 -1
  12. package/dist/workflow-graph.js.map +1 -1
  13. package/package.json +8 -8
  14. package/dist/circuit-breaker.test.d.ts +0 -2
  15. package/dist/circuit-breaker.test.d.ts.map +0 -1
  16. package/dist/circuit-breaker.test.js +0 -51
  17. package/dist/circuit-breaker.test.js.map +0 -1
  18. package/dist/payload-cipher.test.d.ts +0 -2
  19. package/dist/payload-cipher.test.d.ts.map +0 -1
  20. package/dist/payload-cipher.test.js +0 -33
  21. package/dist/payload-cipher.test.js.map +0 -1
  22. package/dist/retry-guard.test.d.ts +0 -2
  23. package/dist/retry-guard.test.d.ts.map +0 -1
  24. package/dist/retry-guard.test.js +0 -45
  25. package/dist/retry-guard.test.js.map +0 -1
  26. package/dist/task-controller.test.d.ts +0 -2
  27. package/dist/task-controller.test.d.ts.map +0 -1
  28. package/dist/task-controller.test.js +0 -667
  29. package/dist/task-controller.test.js.map +0 -1
  30. package/dist/verification-gate.test.d.ts +0 -2
  31. package/dist/verification-gate.test.d.ts.map +0 -1
  32. package/dist/verification-gate.test.js +0 -42
  33. package/dist/verification-gate.test.js.map +0 -1
  34. package/dist/workflow-controller.test.d.ts +0 -2
  35. package/dist/workflow-controller.test.d.ts.map +0 -1
  36. package/dist/workflow-controller.test.js +0 -911
  37. package/dist/workflow-controller.test.js.map +0 -1
  38. package/dist/workflow-events.test.d.ts +0 -2
  39. package/dist/workflow-events.test.d.ts.map +0 -1
  40. package/dist/workflow-events.test.js +0 -113
  41. package/dist/workflow-events.test.js.map +0 -1
  42. package/dist/workflow-graph.test.d.ts +0 -2
  43. package/dist/workflow-graph.test.d.ts.map +0 -1
  44. package/dist/workflow-graph.test.js +0 -170
  45. package/dist/workflow-graph.test.js.map +0 -1
package/README.md CHANGED
@@ -10,8 +10,9 @@ npm install @melra/runtime-core
10
10
 
11
11
  `TaskController` owns the pipeline. `plan()` classifies the operation, evaluates
12
12
  policy, persists a `TaskRecord`, and returns any approval challenge — it never
13
- executes. `execute()` re-evaluates policy, validates the approval, runs the
14
- adapter under an `AbortSignal` armed with the duration budget, then verifies.
13
+ executes. `execute()` re-evaluates policy, validates the approval (or, in
14
+ enforced mode, requires an operator's out-of-band decision), runs the adapter
15
+ under an `AbortSignal` armed with the duration budget, then verifies.
15
16
 
16
17
  Verification decides success, not the adapter. A task reaches
17
18
  `verified_success` only when the adapter succeeded *and* every declared evidence
@@ -1,4 +1,4 @@
1
- import type { ApprovalResponse, EffectContract, EvidencePredicate, Operation, PolicyDecision, TaskRecord, TaskRequest } from "@melra/protocol";
1
+ import type { ApprovalDecision, ApprovalResponse, EffectContract, EvidencePredicate, Operation, PolicyDecision, TaskRecord, TaskRequest } from "@melra/protocol";
2
2
  import { type CapabilityUsageReader, type LocalPolicy } from "@melra/policy-core";
3
3
  import { type ActionReceipt, type EvidenceItem, type ExecutionCertificate } from "@melra/receipt-schema";
4
4
  import { SqliteStore } from "@melra/storage-sqlite";
@@ -18,6 +18,12 @@ export interface ExecutionResult {
18
18
  export interface TaskPlanOptions {
19
19
  idempotencyKey?: string;
20
20
  attempt?: number;
21
+ /**
22
+ * Planned again from a workflow definition stored when it was accepted.
23
+ * Checks that only govern new work (an unused browser list) are not applied
24
+ * again, so a run accepted before a check existed does not stop mid-way.
25
+ */
26
+ stored?: boolean;
21
27
  }
22
28
  /**
23
29
  * A grant's durable draw-down, the way policy wants to read it.
@@ -35,6 +41,8 @@ export declare class TaskController {
35
41
  private readonly payloadCipher;
36
42
  private readonly active;
37
43
  private readonly breaker;
44
+ /** Whether approvals come only from an operator, out of band. */
45
+ get enforced(): boolean;
38
46
  constructor(store: SqliteStore, policy: LocalPolicy, executor: OperationExecutor, verifier: Verifier, payloadCipher: PayloadCipher, breaker?: CircuitBreaker);
39
47
  /**
40
48
  * The durable draw-down on each grant, as policy sees it.
@@ -45,10 +53,24 @@ export declare class TaskController {
45
53
  * with a way around it.
46
54
  */
47
55
  private readonly capabilityUsage;
56
+ /**
57
+ * Draws by tasks between their re-check and their record, per grant and
58
+ * task. The durable draw is written only once work is verified, so without
59
+ * this two tasks running at once both saw the grant unspent: two operations
60
+ * against `maxOperations: 1`, a parallel workflow node overspending
61
+ * `dailyMax`.
62
+ *
63
+ * ponytail: held in this process. Two servers sharing one database can
64
+ * still both pass a re-check at the same moment; a durable reservation row
65
+ * would close that if it ever matters.
66
+ */
67
+ private readonly reserved;
48
68
  plan(request: TaskRequest, options?: TaskPlanOptions): TaskRecord & {
49
69
  contract: EffectContract;
50
70
  };
51
- preflight(request: TaskRequest): PolicyDecision;
71
+ preflight(request: TaskRequest, options?: {
72
+ stored?: boolean;
73
+ }): PolicyDecision;
52
74
  status(taskId: string): TaskRecord;
53
75
  execute(taskId: string, approval?: ApprovalResponse): Promise<ExecutionResult>;
54
76
  cancel(taskId: string): TaskRecord;
@@ -83,7 +105,57 @@ export declare class TaskController {
83
105
  * or an effect settled this way would be free to run again and free to spend.
84
106
  */
85
107
  private resolveAsVerified;
86
- recoverInterrupted(): Promise<TaskRecord[]>;
108
+ /**
109
+ * With `failed`, each task that could not be recovered is recorded there by
110
+ * id instead of the first failure being thrown at the end.
111
+ */
112
+ recoverInterrupted(failed?: Map<string, unknown>): Promise<TaskRecord[]>;
113
+ private recoverTask;
114
+ /**
115
+ * What an approver is shown: the effect contract, minus the challenge and
116
+ * the policy verdict, which a recheck at execution rewrites. Its digest is
117
+ * what a decision is bound to, so a request that changed after it was
118
+ * shown cannot run on that decision.
119
+ */
120
+ private approvalRender;
121
+ /** A pending challenge as an operator surface shows it. */
122
+ approvalView(taskId: string): {
123
+ contract: {
124
+ contractVersion: string;
125
+ taskId: string;
126
+ identity: import("@melra/protocol").Identity;
127
+ capability: string;
128
+ operation: Operation;
129
+ effect: import("@melra/protocol").Effect;
130
+ risk: import("@melra/protocol").Risk;
131
+ target: string;
132
+ traits: import("@melra/protocol").CapabilityTrait[];
133
+ executionGuarantee: import("@melra/protocol").ExecutionGuarantee;
134
+ forbiddenEffects: import("@melra/protocol").Effect[];
135
+ postconditions: EvidencePredicate[];
136
+ budget: import("@melra/protocol").TaskBudget;
137
+ idempotencyKey?: string;
138
+ metadata: {
139
+ goal: string;
140
+ };
141
+ };
142
+ renderDigest: string;
143
+ task: TaskRecord;
144
+ };
145
+ /**
146
+ * Records an approver's yes to exactly the render they were shown. Never
147
+ * executes, and never touches the task row: the decision lives in its own
148
+ * insert-once table, and `execute` reads it. `out_of_band` only in enforced
149
+ * mode, where nothing the agent saw could have produced it.
150
+ */
151
+ decide(taskId: string, renderDigest: string, approver: string): ApprovalDecision;
152
+ /** The recorded decision for a pending task, while it can still be used. */
153
+ approvalDecision(task: TaskRecord): ApprovalDecision | undefined;
154
+ /**
155
+ * Made before the challenge expired, and used within a TTL of being made:
156
+ * an answer that came in time is not lost to the time it takes to act on.
157
+ */
158
+ private usable;
87
159
  receipts(input: {
88
160
  taskId?: string;
89
161
  receiptId?: string;
@@ -91,6 +163,12 @@ export declare class TaskController {
91
163
  receipts: ActionReceipt[];
92
164
  certificate?: ExecutionCertificate;
93
165
  };
166
+ /**
167
+ * The mode a task's effect ran under: recorded when it started running, or,
168
+ * for a task that started before that was recorded, the mode on its first
169
+ * receipt.
170
+ */
171
+ private runMode;
94
172
  private createReceipt;
95
173
  private loadRequest;
96
174
  private executeWithRetries;
@@ -1 +1 @@
1
- {"version":3,"file":"task-controller.d.ts","sourceRoot":"","sources":["../src/task-controller.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"task-controller.d.ts","sourceRoot":"","sources":["../src/task-controller.ts"],"names":[],"mappings":"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"}
@@ -2,8 +2,8 @@
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
  import { randomUUID } from "node:crypto";
4
4
  import { allowsRetry, delegationChain, effectContract, executionGuaranteeFor, isOutcomeUnknown, LOCAL_IDENTITY, TaskRequestSchema, } from "@melra/protocol";
5
- import { CAPABILITY_DAY_MS, classifyOperation, defaultEvidenceFor, evaluatePolicy, validateApproval, } from "@melra/policy-core";
6
- import { createCertificate, createReceiptId, evidenceStrength, redactStructuredValue, sha256, } from "@melra/receipt-schema";
5
+ import { approvalTtl, CAPABILITY_DAY_MS, classifyOperation, defaultEvidenceFor, evaluatePolicy, validateApproval, } from "@melra/policy-core";
6
+ import { createCertificate, createReceiptId, evidenceStrength, redactSecrets, redactStructuredValue, sha256, } from "@melra/receipt-schema";
7
7
  import { CircuitBreaker } from "./circuit-breaker.js";
8
8
  function now() {
9
9
  return new Date().toISOString();
@@ -83,6 +83,12 @@ export function capabilityUsageReader(store) {
83
83
  return { operations: drawn.operations, amountToday: drawn.amountInWindow };
84
84
  };
85
85
  }
86
+ /** The one browser action that reads each list. */
87
+ const BROWSER_LIST_ACTIONS = [
88
+ ["values", "select"],
89
+ ["fields", "fill_form"],
90
+ ["filePaths", "upload"],
91
+ ];
86
92
  export class TaskController {
87
93
  store;
88
94
  policy;
@@ -91,6 +97,10 @@ export class TaskController {
91
97
  payloadCipher;
92
98
  active = new Map();
93
99
  breaker;
100
+ /** Whether approvals come only from an operator, out of band. */
101
+ get enforced() {
102
+ return this.policy.mode === "enforced";
103
+ }
94
104
  constructor(store, policy, executor, verifier, payloadCipher, breaker) {
95
105
  this.store = store;
96
106
  this.policy = policy;
@@ -112,7 +122,26 @@ export class TaskController {
112
122
  * preflight — a bound that only one of the three enforced would be a bound
113
123
  * with a way around it.
114
124
  */
115
- capabilityUsage = (grantId) => capabilityUsageReader(this.store)(grantId);
125
+ capabilityUsage = (grantId) => {
126
+ const drawn = capabilityUsageReader(this.store)(grantId);
127
+ const held = [...(this.reserved.get(grantId)?.values() ?? [])];
128
+ return {
129
+ operations: drawn.operations + held.length,
130
+ amountToday: drawn.amountToday + held.reduce((total, amount) => total + amount, 0),
131
+ };
132
+ };
133
+ /**
134
+ * Draws by tasks between their re-check and their record, per grant and
135
+ * task. The durable draw is written only once work is verified, so without
136
+ * this two tasks running at once both saw the grant unspent: two operations
137
+ * against `maxOperations: 1`, a parallel workflow node overspending
138
+ * `dailyMax`.
139
+ *
140
+ * ponytail: held in this process. Two servers sharing one database can
141
+ * still both pass a re-check at the same moment; a durable reservation row
142
+ * would close that if it ever matters.
143
+ */
144
+ reserved = new Map();
116
145
  plan(request, options = {}) {
117
146
  const parsedRequest = withDefaultEvidence(TaskRequestSchema.parse(request));
118
147
  if (options.idempotencyKey !== undefined &&
@@ -123,7 +152,7 @@ export class TaskController {
123
152
  (!Number.isInteger(options.attempt) || options.attempt < 1)) {
124
153
  throw new Error("idempotency_attempt_invalid");
125
154
  }
126
- this.preflight(parsedRequest);
155
+ this.preflight(parsedRequest, options);
127
156
  const id = randomUUID();
128
157
  const policy = evaluatePolicy(id, parsedRequest, this.policy, this.capabilityUsage);
129
158
  const timestamp = now();
@@ -160,13 +189,29 @@ export class TaskController {
160
189
  contract: effectContract(planned, classifyOperation(parsedRequest.operation)),
161
190
  };
162
191
  }
163
- preflight(request) {
192
+ preflight(request, options = {}) {
164
193
  const parsed = withDefaultEvidence(TaskRequestSchema.parse(request));
165
194
  const capabilities = this.executor.capabilities?.();
166
195
  if (capabilities !== undefined &&
167
196
  !capabilities.has(parsed.operation.kind)) {
168
197
  throw new Error(`operation_capability_unavailable:${parsed.operation.kind}`);
169
198
  }
199
+ // A list the browser action never reads would sit in the approval as a
200
+ // decoy: shown to the person approving, doing nothing.
201
+ if (parsed.operation.kind === "browser" && options.stored !== true) {
202
+ for (const [field, action] of BROWSER_LIST_ACTIONS) {
203
+ if (parsed.operation[field] !== undefined && parsed.operation.action !== action) {
204
+ throw new Error(`browser_field_unused:${field}`);
205
+ }
206
+ }
207
+ }
208
+ // Refused before anything is approved: a mutation run against evidence no
209
+ // registered verifier can check would end `partial` after the effect.
210
+ for (const predicate of parsed.requiredEvidence) {
211
+ if (!this.verifier.supports(predicate)) {
212
+ throw new Error(`evidence_verifier_unavailable:${predicate.type === "custom" ? predicate.verifier : predicate.type}`);
213
+ }
214
+ }
170
215
  return evaluatePolicy("00000000-0000-4000-8000-000000000000", parsed, this.policy, this.capabilityUsage).decision;
171
216
  }
172
217
  status(taskId) {
@@ -218,22 +263,71 @@ export class TaskController {
218
263
  task.approval?.actionDigest !== rechecked.challenge.actionDigest) {
219
264
  throw new Error("approval_action_digest_mismatch");
220
265
  }
221
- const approvalResult = validateApproval(task.approval, approval);
222
- if (!approvalResult.ok) {
223
- throw new Error(approvalResult.reason);
266
+ // Enforced mode asserts the agent holds no credential, so anything it
267
+ // sends is refused rather than ignored, and only an operator's
268
+ // out-of-band decision lets the effect run.
269
+ if (this.policy.mode === "enforced" && approval !== undefined) {
270
+ throw new Error("approval_in_band_refused");
271
+ }
272
+ // A decision an operator recorded wins over anything the caller sent.
273
+ const render = this.approvalRender(task, request).renderDigest;
274
+ const decision = this.store.getApprovalDecision(task.approval.approvalId);
275
+ // Waiting is only worth it while an operator could still say yes:
276
+ // `decide` refuses an expired challenge, so past it the task is re-planned.
277
+ if (this.policy.mode === "enforced" && decision?.channel !== "out_of_band") {
278
+ throw new Error(Date.parse(task.approval.expiresAt) <= Date.now() ? "approval_expired" : "approval_awaiting_operator");
279
+ }
280
+ if (decision !== undefined) {
281
+ if (decision.renderDigest !== render)
282
+ throw new Error("approval_render_mismatch");
283
+ if (!this.usable(task, decision))
284
+ throw new Error("approval_expired");
285
+ // Out of band is this kernel's claim to make, not the row's: a
286
+ // developer-mode kernel cannot vouch that nothing the agent touched
287
+ // wrote it, whatever mode the process that recorded it was in.
288
+ task.approvedBy = {
289
+ ...decision,
290
+ channel: decision.channel === "out_of_band" && this.policy.mode === "enforced"
291
+ ? "out_of_band"
292
+ : "in_band",
293
+ };
294
+ }
295
+ else {
296
+ const approvalResult = validateApproval(task.approval, approval);
297
+ if (!approvalResult.ok) {
298
+ throw new Error(approvalResult.reason);
299
+ }
300
+ // The phrase is derivable by anyone who knows the task, so an echo of
301
+ // it says only that something echoed it.
302
+ task.approvedBy = {
303
+ approvalId: task.approval.approvalId,
304
+ taskId: task.id,
305
+ renderDigest: render,
306
+ channel: "in_band",
307
+ approver: "phrase",
308
+ decidedAt: now(),
309
+ };
224
310
  }
225
311
  }
226
312
  task.policyDecision = rechecked.decision;
227
313
  const controller = new AbortController();
228
314
  this.active.set(task.id, controller);
315
+ this.store.inFlight.add(task.id);
229
316
  task.status = "running";
317
+ task.mode = this.policy.mode;
230
318
  task.updatedAt = now();
231
- this.store.saveTask(task);
319
+ // Status and owner land in one write, so no running row is ever unowned.
320
+ this.store.saveTask(task, this.store.ownerId());
232
321
  const startedAt = now();
233
322
  const classified = classifyOperation(request.operation);
234
323
  let timeout;
235
324
  let budgetExhausted = false;
236
325
  let shortCircuited = false;
326
+ if (rechecked.grantId !== undefined) {
327
+ const held = this.reserved.get(rechecked.grantId) ?? new Map();
328
+ held.set(task.id, classified.spend?.amount ?? 0);
329
+ this.reserved.set(rechecked.grantId, held);
330
+ }
237
331
  try {
238
332
  const opened = this.breaker.check(classified.target);
239
333
  if (opened !== undefined) {
@@ -288,7 +382,7 @@ export class TaskController {
288
382
  rechecked.grantId !== undefined) {
289
383
  this.store.recordCapabilityUse(rechecked.grantId, task.id, classified.spend?.amount ?? 0, now());
290
384
  }
291
- const receipt = this.createReceipt(task, request, classified.capability, classified.target, classified.effect, startedAt, actionSucceeded, result, evidence, approval);
385
+ const receipt = this.createReceipt(task, request, classified.capability, classified.target, classified.effect, startedAt, actionSucceeded, result, evidence);
292
386
  this.store.saveReceipt(receipt);
293
387
  task.receiptIds.push(receipt.receiptId);
294
388
  if (task.status !== "cancelled") {
@@ -345,7 +439,7 @@ export class TaskController {
345
439
  : message,
346
440
  },
347
441
  ];
348
- const receipt = this.createReceipt(task, request, classified.capability, classified.target, classified.effect, startedAt, false, {}, evidence, approval, task.error);
442
+ const receipt = this.createReceipt(task, request, classified.capability, classified.target, classified.effect, startedAt, false, {}, evidence, task.error);
349
443
  this.store.saveReceipt(receipt);
350
444
  task.receiptIds.push(receipt.receiptId);
351
445
  this.store.saveTask(task);
@@ -356,6 +450,9 @@ export class TaskController {
356
450
  if (timeout !== undefined)
357
451
  clearTimeout(timeout);
358
452
  this.active.delete(task.id);
453
+ this.store.inFlight.delete(task.id);
454
+ if (rechecked.grantId !== undefined)
455
+ this.reserved.get(rechecked.grantId)?.delete(task.id);
359
456
  }
360
457
  }
361
458
  cancel(taskId) {
@@ -429,7 +526,7 @@ export class TaskController {
429
526
  task.error = "reconciliation_confirms_effect_not_applied";
430
527
  task.updatedAt = now();
431
528
  this.store.saveTask(task);
432
- const receipt = this.createReceipt(task, request, classified.capability, classified.target, classified.effect, task.updatedAt, false, { reconciliation: "provider_state_denies_effect" }, verification.evidence, undefined, task.error);
529
+ const receipt = this.createReceipt(task, request, classified.capability, classified.target, classified.effect, task.updatedAt, false, { reconciliation: "provider_state_denies_effect" }, verification.evidence, task.error);
433
530
  this.store.saveReceipt(receipt);
434
531
  task.receiptIds.push(receipt.receiptId);
435
532
  const certificate = this.createAndSaveCertificate(task, verification.evidence);
@@ -449,15 +546,18 @@ export class TaskController {
449
546
  this.store.commitIdempotency(task.idempotencyKey, task.id, task.attempt ?? 1, now());
450
547
  }
451
548
  // Read for the grant id only. The work is already done, so this is
452
- // accounting rather than permission — a grant that has since run out still
453
- // pays for what it authorised.
454
- const { grantId } = evaluatePolicy(task.id, request, this.policy, this.capabilityUsage);
549
+ // accounting rather than permission: a grant that has since run out still
550
+ // pays for what it authorised. Asked as if nothing were drawn, because
551
+ // policy names no grant at all for one that is now exhausted, and the
552
+ // effect then went uncharged.
553
+ const { grantId } = evaluatePolicy(task.id, request, this.policy, () => ({
554
+ operations: 0,
555
+ amountToday: 0,
556
+ }));
455
557
  if (grantId !== undefined) {
456
558
  this.store.recordCapabilityUse(grantId, task.id, classified.spend?.amount ?? 0, now());
457
559
  }
458
- const receipt = this.createReceipt(task, request, classified.capability, classified.target, classified.effect, task.updatedAt, true, observed, evidence, task.approval === undefined
459
- ? undefined
460
- : { approvalId: task.approval.approvalId, phrase: "recovered" });
560
+ const receipt = this.createReceipt(task, request, classified.capability, classified.target, classified.effect, task.updatedAt, true, observed, evidence);
461
561
  this.store.saveReceipt(receipt);
462
562
  task.receiptIds.push(receipt.receiptId);
463
563
  task.status = "verified_success";
@@ -466,43 +566,156 @@ export class TaskController {
466
566
  const certificate = this.createAndSaveCertificate(task, evidence);
467
567
  return { task, receipt, certificate };
468
568
  }
469
- async recoverInterrupted() {
569
+ /**
570
+ * With `failed`, each task that could not be recovered is recorded there by
571
+ * id instead of the first failure being thrown at the end.
572
+ */
573
+ async recoverInterrupted(failed) {
470
574
  const recovered = [];
471
- for (const task of this.store.listInterruptedTasks()) {
472
- const request = this.loadRequest(task.id);
473
- const classified = classifyOperation(request.operation);
474
- if (task.status === "verifying" &&
475
- classified.effect !== "read" &&
476
- request.requiredEvidence.length > 0 &&
477
- request.requiredEvidence.every((predicate) => ["file_exists", "file_absent", "file_hash"].includes(predicate.type))) {
478
- const verification = await this.verifier.verify(request.requiredEvidence, {});
479
- if (verification.verified) {
480
- this.resolveAsVerified(task, request, classified, verification.evidence, {
481
- recovery: "independent_reobservation",
482
- });
483
- recovered.push(task);
484
- continue;
575
+ const failures = failed ?? new Map();
576
+ for (const listed of this.store.listInterruptedTasks()) {
577
+ // Only tasks whose owning process is gone, and only once: a live peer's
578
+ // work is left to finish. This process's own rows are taken too when
579
+ // nothing here has them in flight, so a task whose final write failed,
580
+ // or whose recovery threw after the claim, is retried by the next pass
581
+ // instead of waiting for a restart.
582
+ const task = this.store.claimInterruptedTask(listed.id);
583
+ if (task === undefined)
584
+ continue;
585
+ this.store.inFlight.add(task.id);
586
+ try {
587
+ recovered.push(await this.recoverTask(task));
588
+ }
589
+ catch (error) {
590
+ // One task that cannot be recovered must not keep the rest waiting;
591
+ // the failure is still reported once every task has had its turn.
592
+ failures.set(task.id, error);
593
+ // Given back rather than kept: held by this live process, no other
594
+ // could take it, so an advance elsewhere could never learn why its
595
+ // run is stuck. Whoever claims it next meets the same failure.
596
+ try {
597
+ this.store.releaseTaskClaim(task.id);
598
+ }
599
+ catch {
600
+ // Still ours, then; this process retakes it on its next pass.
485
601
  }
486
602
  }
487
- const effect = classified.effect;
488
- task.status = effect === "read" ? "planned" : "recovery_required";
489
- task.error =
490
- effect === "read"
491
- ? "interrupted_read_ready_for_retry"
492
- : "interrupted_mutation_requires_reconciliation";
493
- task.updatedAt = now();
494
- this.store.saveTask(task);
495
- // A crash mid-flight is the same unknown a dropped reply is, so a task
496
- // that declared how to settle one gets asked here rather than waiting for
497
- // someone to notice it. Still parked if the provider cannot answer.
498
- if (task.status === "recovery_required" && request.reconciliation.length > 0) {
499
- recovered.push((await this.reconcile(task.id)).task);
500
- continue;
603
+ finally {
604
+ this.store.inFlight.delete(task.id);
501
605
  }
502
- recovered.push(task);
503
606
  }
607
+ if (failed === undefined && failures.size > 0)
608
+ throw failures.values().next().value;
504
609
  return recovered;
505
610
  }
611
+ async recoverTask(task) {
612
+ const request = this.loadRequest(task.id);
613
+ const classified = classifyOperation(request.operation);
614
+ if (task.status === "verifying" &&
615
+ classified.effect !== "read" &&
616
+ request.requiredEvidence.length > 0 &&
617
+ request.requiredEvidence.every((predicate) => ["file_exists", "file_absent", "file_hash"].includes(predicate.type))) {
618
+ // What the adapter reported before the crash, when it got that far.
619
+ // Evidence alone is not success: a build that exited 1 beside a dist/
620
+ // left by an earlier one would otherwise recover as verified.
621
+ const sealed = this.store.getTaskResult(task.id);
622
+ const reported = sealed === undefined
623
+ ? undefined
624
+ : this.payloadCipher.open(sealed, `task:${task.id}:result`);
625
+ const actionSucceeded = reported?.success !== false;
626
+ const verification = await this.verifier.verify(request.requiredEvidence, {});
627
+ if (actionSucceeded && verification.verified) {
628
+ this.resolveAsVerified(task, request, classified, verification.evidence, {
629
+ recovery: "independent_reobservation",
630
+ });
631
+ return task;
632
+ }
633
+ }
634
+ const effect = classified.effect;
635
+ task.status = effect === "read" ? "planned" : "recovery_required";
636
+ task.error =
637
+ effect === "read"
638
+ ? "interrupted_read_ready_for_retry"
639
+ : "interrupted_mutation_requires_reconciliation";
640
+ task.updatedAt = now();
641
+ this.store.saveTask(task);
642
+ // A crash mid-flight is the same unknown a dropped reply is, so a task
643
+ // that declared how to settle one gets asked here rather than waiting for
644
+ // someone to notice it. Still parked if the provider cannot answer.
645
+ if (task.status === "recovery_required" && request.reconciliation.length > 0) {
646
+ return (await this.reconcile(task.id)).task;
647
+ }
648
+ return task;
649
+ }
650
+ /**
651
+ * What an approver is shown: the effect contract, minus the challenge and
652
+ * the policy verdict, which a recheck at execution rewrites. Its digest is
653
+ * what a decision is bound to, so a request that changed after it was
654
+ * shown cannot run on that decision.
655
+ */
656
+ approvalRender(task, request) {
657
+ const { authorization: _authorization, policy: _policy, ...contract } = effectContract({ ...task, request }, classifyOperation(request.operation));
658
+ return { contract, renderDigest: sha256(contract) };
659
+ }
660
+ /** A pending challenge as an operator surface shows it. */
661
+ approvalView(taskId) {
662
+ const task = this.status(taskId);
663
+ if (task.status !== "awaiting_approval" || task.approval === undefined) {
664
+ throw new Error(`task_not_awaiting_approval:${task.status}`);
665
+ }
666
+ return { task, ...this.approvalRender(task, this.loadRequest(taskId)) };
667
+ }
668
+ /**
669
+ * Records an approver's yes to exactly the render they were shown. Never
670
+ * executes, and never touches the task row: the decision lives in its own
671
+ * insert-once table, and `execute` reads it. `out_of_band` only in enforced
672
+ * mode, where nothing the agent saw could have produced it.
673
+ */
674
+ decide(taskId, renderDigest, approver) {
675
+ const { task, renderDigest: current } = this.approvalView(taskId);
676
+ // No phrase means an enforced kernel planned it, and would refuse this
677
+ // in-band decision while the insert-once row kept anyone from correcting it.
678
+ if (this.policy.mode !== "enforced" && task.approval.phrase === undefined) {
679
+ throw new Error("approval_needs_enforced_mode");
680
+ }
681
+ if (Date.parse(task.approval.expiresAt) <= Date.now())
682
+ throw new Error("approval_expired");
683
+ if (renderDigest !== current)
684
+ throw new Error("approval_render_mismatch");
685
+ const decision = {
686
+ approvalId: task.approval.approvalId,
687
+ taskId,
688
+ renderDigest,
689
+ channel: this.policy.mode === "enforced" ? "out_of_band" : "in_band",
690
+ approver,
691
+ decidedAt: now(),
692
+ };
693
+ if (!this.store.recordApprovalDecision(decision))
694
+ throw new Error("approval_already_decided");
695
+ return decision;
696
+ }
697
+ /** The recorded decision for a pending task, while it can still be used. */
698
+ approvalDecision(task) {
699
+ if (task.approval === undefined)
700
+ return undefined;
701
+ const decision = this.store.getApprovalDecision(task.approval.approvalId);
702
+ if (decision === undefined || !this.usable(task, decision))
703
+ return undefined;
704
+ // In enforced mode an in-band row is no decision at all, so a workflow node
705
+ // waits for an operator and is re-planned at expiry instead of failing on
706
+ // every advance.
707
+ return this.policy.mode === "enforced" && decision.channel !== "out_of_band" ? undefined : decision;
708
+ }
709
+ /**
710
+ * Made before the challenge expired, and used within a TTL of being made:
711
+ * an answer that came in time is not lost to the time it takes to act on.
712
+ */
713
+ usable(task, decision) {
714
+ const decidedAt = Date.parse(decision.decidedAt);
715
+ return (task.approval !== undefined &&
716
+ decidedAt <= Date.parse(task.approval.expiresAt) &&
717
+ Date.now() < decidedAt + approvalTtl(this.policy));
718
+ }
506
719
  receipts(input) {
507
720
  if (input.receiptId !== undefined) {
508
721
  const receipt = this.store.getReceipt(input.receiptId);
@@ -523,7 +736,23 @@ export class TaskController {
523
736
  ...(certificate === undefined ? {} : { certificate }),
524
737
  };
525
738
  }
526
- createReceipt(task, request, capability, target, effect, startedAt, success, result, evidence, approval, error) {
739
+ /**
740
+ * The mode a task's effect ran under: recorded when it started running, or,
741
+ * for a task that started before that was recorded, the mode on its first
742
+ * receipt.
743
+ */
744
+ runMode(task) {
745
+ if (task.mode !== undefined)
746
+ return task.mode;
747
+ const [first] = task.receiptIds;
748
+ return first === undefined ? undefined : this.store.getReceipt(first)?.mode;
749
+ }
750
+ createReceipt(task, request, capability, target, effect, startedAt, success, result, evidence, error) {
751
+ // From the task, not the caller: recovery and reconciliation write
752
+ // receipts with no caller in sight. A task running since before
753
+ // `approvedBy` existed still names the challenge it was planned under.
754
+ const approvalId = task.approvedBy?.approvalId ??
755
+ (task.policyDecision.outcome === "confirm" ? task.approval?.approvalId : undefined);
527
756
  const sanitized = redactStructuredValue(result);
528
757
  const adapterRedactions = Array.isArray(result.redactions)
529
758
  ? result.redactions.filter((item) => typeof item === "string")
@@ -539,13 +768,25 @@ export class TaskController {
539
768
  principal: delegationChain(request.identity ?? LOCAL_IDENTITY),
540
769
  effect,
541
770
  executionGuarantee: executionGuaranteeFor(effect, request.reconciliation.length > 0),
542
- mode: this.policy.mode,
771
+ mode: this.runMode(task) ?? this.policy.mode,
543
772
  policyDecision: {
544
773
  outcome: task.policyDecision.outcome,
545
774
  policyVersion: task.policyDecision.policyVersion,
546
- ...(approval === undefined ? {} : { approvalId: approval.approvalId }),
775
+ ...(approvalId === undefined ? {} : { approvalId }),
776
+ ...(task.approvedBy === undefined
777
+ ? {}
778
+ : {
779
+ approval: {
780
+ channel: task.approvedBy.channel,
781
+ renderDigest: task.approvedBy.renderDigest,
782
+ approver: task.approvedBy.approver,
783
+ decidedAt: task.approvedBy.decidedAt,
784
+ },
785
+ }),
547
786
  },
548
- target,
787
+ // The target is built from the request (a URL, a memory key) and stored
788
+ // as it is, so it takes the same secret shapes out as everything else.
789
+ target: redactSecrets(target).value,
549
790
  inputDigest: sha256(request.operation),
550
791
  startedAt,
551
792
  endedAt: now(),
@@ -596,6 +837,16 @@ export class TaskController {
596
837
  : new Error("execution_failed_without_error");
597
838
  }
598
839
  createAndSaveCertificate(task, evidence) {
840
+ const approvalChannel = task.approvedBy?.channel ??
841
+ (task.policyDecision.outcome === "allow"
842
+ ? task.policyDecision.effect === "read"
843
+ ? "not_required"
844
+ : "bypassed"
845
+ : undefined);
846
+ // Sealed, so never guessed: a task that ran says how it ran, one that
847
+ // never did was decided here, and one that ran before the mode was
848
+ // recorded and left no receipt saying so goes without.
849
+ const mode = this.runMode(task) ?? (task.receiptIds.length === 0 ? this.policy.mode : undefined);
599
850
  const certificate = createCertificate({
600
851
  taskId: task.id,
601
852
  goal: task.request.goal,
@@ -604,6 +855,8 @@ export class TaskController {
604
855
  receiptIds: task.receiptIds,
605
856
  evidence,
606
857
  createdAt: now(),
858
+ ...(mode === undefined ? {} : { mode }),
859
+ ...(approvalChannel === undefined ? {} : { approvalChannel }),
607
860
  });
608
861
  task.certificateId = certificate.certificateId;
609
862
  task.updatedAt = now();