@dharma-ai-labs/agent-fabric-task-runner 0.1.5 → 0.1.8
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/README.md +16 -0
- package/dist/index.d.ts +81 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +540 -70
- package/dist/index.js.map +1 -1
- package/dist/index.test.js +283 -4
- package/dist/index.test.js.map +1 -1
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
|
-
import { createHash } from 'node:crypto';
|
|
3
|
-
import { mkdir, readFile, rename, rm
|
|
2
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
3
|
+
import { mkdir, open, readFile, rename, rm } from 'node:fs/promises';
|
|
4
4
|
import { isAbsolute, relative, resolve } from 'node:path';
|
|
5
|
-
import { validateTaskEnvelopeContract, verifyCanonicalObject } from '@dharma-ai-labs/agent-fabric-contracts';
|
|
5
|
+
import { buildActionDecisionAcknowledgement, validateActionDecisionAcknowledgementContract, validateTaskEnvelopeContract, verifyActionDecisionReceipt, verifyCanonicalObject, } from '@dharma-ai-labs/agent-fabric-contracts';
|
|
6
6
|
import { assertPathWithinWorkspace, resolveRegisteredCommand } from '@dharma-ai-labs/agent-fabric-policy';
|
|
7
7
|
import { executeProviderTask, } from '@dharma-ai-labs/agent-fabric-provider-adapters';
|
|
8
|
+
export class ActionDecisionDeniedError extends Error {
|
|
9
|
+
receipt;
|
|
10
|
+
constructor(message, receipt) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.receipt = receipt;
|
|
13
|
+
this.name = 'ActionDecisionDeniedError';
|
|
14
|
+
}
|
|
15
|
+
}
|
|
8
16
|
function normalizePolicyPath(value) {
|
|
9
17
|
return value.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/$/, '');
|
|
10
18
|
}
|
|
@@ -132,6 +140,48 @@ export function providerInstructionsForTask(task) {
|
|
|
132
140
|
throw new Error('A2A provider instructions exceed the execution limit.');
|
|
133
141
|
return instructions;
|
|
134
142
|
}
|
|
143
|
+
function receiptForDurableStorage(receipt) {
|
|
144
|
+
return {
|
|
145
|
+
...receipt,
|
|
146
|
+
commandResults: receipt.commandResults.map((result) => ({ ...result, stdout: '', stderr: '' })),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
function assertValidTaskReceipt(value, expectedTaskId) {
|
|
150
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
151
|
+
throw new Error('Task receipt is invalid.');
|
|
152
|
+
const receipt = value;
|
|
153
|
+
if (typeof receipt.taskId !== 'string' || !/^[0-9a-f-]{36}$/i.test(receipt.taskId)
|
|
154
|
+
|| (expectedTaskId && receipt.taskId !== expectedTaskId)
|
|
155
|
+
|| !['completed', 'failed', 'cancelled'].includes(String(receipt.status))
|
|
156
|
+
|| typeof receipt.worktree !== 'string' || !isAbsolute(receipt.worktree)
|
|
157
|
+
|| receipt.branch !== `dharma/task/${receipt.taskId}`
|
|
158
|
+
|| !Array.isArray(receipt.commandResults) || receipt.commandResults.length > 100
|
|
159
|
+
|| !Number.isFinite(Date.parse(String(receipt.startedAt || '')))
|
|
160
|
+
|| !Number.isFinite(Date.parse(String(receipt.completedAt || '')))
|
|
161
|
+
|| Date.parse(String(receipt.completedAt)) < Date.parse(String(receipt.startedAt))) {
|
|
162
|
+
throw new Error('Task receipt is invalid.');
|
|
163
|
+
}
|
|
164
|
+
for (const result of receipt.commandResults) {
|
|
165
|
+
if (!result || typeof result !== 'object' || Array.isArray(result))
|
|
166
|
+
throw new Error('Task receipt command result is invalid.');
|
|
167
|
+
const command = result;
|
|
168
|
+
if (typeof command.commandId !== 'string' || command.commandId.length < 1 || command.commandId.length > 200
|
|
169
|
+
|| !(command.exitCode === null || Number.isInteger(command.exitCode))
|
|
170
|
+
|| !(command.signal === null || typeof command.signal === 'string')
|
|
171
|
+
|| typeof command.timedOut !== 'boolean'
|
|
172
|
+
|| !/^sha256:[a-f0-9]{64}$/.test(String(command.stdoutSha256 || ''))
|
|
173
|
+
|| !/^sha256:[a-f0-9]{64}$/.test(String(command.stderrSha256 || ''))
|
|
174
|
+
|| typeof command.stdout !== 'string' || typeof command.stderr !== 'string') {
|
|
175
|
+
throw new Error('Task receipt command result is invalid.');
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (receipt.actionAcknowledgement !== undefined) {
|
|
179
|
+
const acknowledgement = validateActionDecisionAcknowledgementContract(receipt.actionAcknowledgement);
|
|
180
|
+
if (!acknowledgement.ok || receipt.actionAcknowledgement.taskId !== receipt.taskId) {
|
|
181
|
+
throw new Error('Task receipt action acknowledgement is invalid.');
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
135
185
|
export class FileTaskReceiptStore {
|
|
136
186
|
directory;
|
|
137
187
|
constructor(directory) {
|
|
@@ -139,7 +189,9 @@ export class FileTaskReceiptStore {
|
|
|
139
189
|
}
|
|
140
190
|
async get(taskId) {
|
|
141
191
|
try {
|
|
142
|
-
|
|
192
|
+
const receipt = JSON.parse(await readFile(resolve(this.directory, `${taskId}.json`), 'utf8'));
|
|
193
|
+
assertValidTaskReceipt(receipt, taskId);
|
|
194
|
+
return receipt;
|
|
143
195
|
}
|
|
144
196
|
catch (error) {
|
|
145
197
|
if (error.code === 'ENOENT')
|
|
@@ -148,15 +200,303 @@ export class FileTaskReceiptStore {
|
|
|
148
200
|
}
|
|
149
201
|
}
|
|
150
202
|
async put(receipt) {
|
|
151
|
-
|
|
203
|
+
assertValidTaskReceipt(receipt, receipt.taskId);
|
|
152
204
|
const target = resolve(this.directory, `${receipt.taskId}.json`);
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
205
|
+
await durableJsonWrite(target, receiptForDurableStorage(receipt));
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
async function durableJsonWrite(path, value) {
|
|
209
|
+
const directory = resolve(path, '..');
|
|
210
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
211
|
+
const temp = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
212
|
+
const handle = await open(temp, 'wx', 0o600);
|
|
213
|
+
try {
|
|
214
|
+
await handle.writeFile(`${JSON.stringify(value)}\n`);
|
|
215
|
+
await handle.sync();
|
|
216
|
+
}
|
|
217
|
+
finally {
|
|
218
|
+
await handle.close();
|
|
219
|
+
}
|
|
220
|
+
await rename(temp, path);
|
|
221
|
+
try {
|
|
222
|
+
const directoryHandle = await open(directory, 'r');
|
|
223
|
+
try {
|
|
224
|
+
await directoryHandle.sync();
|
|
225
|
+
}
|
|
226
|
+
finally {
|
|
227
|
+
await directoryHandle.close();
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
// Directory fsync is unavailable on some supported hosts; the file itself
|
|
232
|
+
// has still been flushed before the atomic rename.
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
export class FileActionExecutionJournal {
|
|
236
|
+
directory;
|
|
237
|
+
pid;
|
|
238
|
+
now;
|
|
239
|
+
ownerId = randomUUID();
|
|
240
|
+
constructor(directory, pid = process.pid, now = () => new Date()) {
|
|
241
|
+
this.directory = directory;
|
|
242
|
+
this.pid = pid;
|
|
243
|
+
this.now = now;
|
|
244
|
+
}
|
|
245
|
+
recordPath(taskId) { return resolve(this.directory, `${taskId}.json`); }
|
|
246
|
+
claimPath(taskId) { return resolve(this.directory, `${taskId}.claim.json`); }
|
|
247
|
+
async get(taskId) {
|
|
248
|
+
try {
|
|
249
|
+
const record = JSON.parse(await readFile(this.recordPath(taskId), 'utf8'));
|
|
250
|
+
if (record.schema !== 'dharma.action-execution-journal/v1' || record.taskId !== taskId
|
|
251
|
+
|| !/^[0-9a-f-]{36}$/i.test(record.taskId)
|
|
252
|
+
|| !/^[0-9a-f-]{36}$/i.test(record.decisionId) || !/^[0-9a-f-]{36}$/i.test(record.endpointId)
|
|
253
|
+
|| !/^sha256:[a-f0-9]{64}$/.test(record.actionDigest)
|
|
254
|
+
|| typeof record.externalIdempotencyKey !== 'string' || !record.externalIdempotencyKey
|
|
255
|
+
|| typeof record.worktree !== 'string' || !isAbsolute(record.worktree)
|
|
256
|
+
|| record.branch !== `dharma/task/${record.taskId}`
|
|
257
|
+
|| !['prepared', 'replay_authorization_started', 'replay_authorized', 'executing', 'effect_observed', 'receipt_recorded'].includes(record.state)
|
|
258
|
+
|| !Number.isFinite(Date.parse(record.preparedAt)) || !Number.isFinite(Date.parse(record.updatedAt))
|
|
259
|
+
|| (record.providerResultDigest !== undefined && !/^sha256:[a-f0-9]{64}$/.test(record.providerResultDigest))) {
|
|
260
|
+
throw new Error('Action execution journal record is invalid.');
|
|
261
|
+
}
|
|
262
|
+
if (record.state === 'receipt_recorded' && !record.receipt) {
|
|
263
|
+
throw new Error('Action execution journal is missing its recorded receipt.');
|
|
264
|
+
}
|
|
265
|
+
if (record.receipt) {
|
|
266
|
+
assertValidTaskReceipt(record.receipt, taskId);
|
|
267
|
+
const acknowledgement = record.receipt.actionAcknowledgement;
|
|
268
|
+
if (acknowledgement
|
|
269
|
+
&& (acknowledgement.endpointId !== record.endpointId || acknowledgement.actionDigest !== record.actionDigest)) {
|
|
270
|
+
throw new Error('Action execution journal receipt conflicts with the signed decision.');
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return record;
|
|
274
|
+
}
|
|
275
|
+
catch (error) {
|
|
276
|
+
if (error.code === 'ENOENT')
|
|
277
|
+
return null;
|
|
278
|
+
throw error;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
async getClaim(taskId) {
|
|
282
|
+
try {
|
|
283
|
+
const claim = JSON.parse(await readFile(this.claimPath(taskId), 'utf8'));
|
|
284
|
+
if (claim.schema !== 'dharma.action-execution-claim/v1' || claim.taskId !== taskId
|
|
285
|
+
|| !/^[0-9a-f-]{36}$/i.test(claim.taskId) || !/^[0-9a-f-]{36}$/i.test(claim.decisionId)
|
|
286
|
+
|| !/^sha256:[a-f0-9]{64}$/.test(claim.actionDigest)
|
|
287
|
+
|| typeof claim.ownerId !== 'string' || !Number.isInteger(claim.pid) || claim.pid <= 0
|
|
288
|
+
|| !Number.isFinite(Date.parse(claim.claimedAt)) || !Number.isFinite(Date.parse(claim.expiresAt))
|
|
289
|
+
|| Date.parse(claim.expiresAt) <= Date.parse(claim.claimedAt)) {
|
|
290
|
+
throw new Error('Action execution claim is invalid.');
|
|
291
|
+
}
|
|
292
|
+
return claim;
|
|
293
|
+
}
|
|
294
|
+
catch (error) {
|
|
295
|
+
if (error.code === 'ENOENT')
|
|
296
|
+
return null;
|
|
297
|
+
throw error;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
async prepare(input) {
|
|
301
|
+
await mkdir(this.directory, { recursive: true, mode: 0o700 });
|
|
302
|
+
const now = this.now().toISOString();
|
|
303
|
+
const record = {
|
|
304
|
+
schema: 'dharma.action-execution-journal/v1', ...input,
|
|
305
|
+
state: 'prepared', preparedAt: now, updatedAt: now,
|
|
306
|
+
};
|
|
307
|
+
try {
|
|
308
|
+
const handle = await open(this.recordPath(input.taskId), 'wx', 0o600);
|
|
309
|
+
try {
|
|
310
|
+
await handle.writeFile(`${JSON.stringify(record)}\n`);
|
|
311
|
+
await handle.sync();
|
|
312
|
+
}
|
|
313
|
+
finally {
|
|
314
|
+
await handle.close();
|
|
315
|
+
}
|
|
316
|
+
return { record, created: true };
|
|
317
|
+
}
|
|
318
|
+
catch (error) {
|
|
319
|
+
if (error.code !== 'EEXIST')
|
|
320
|
+
throw error;
|
|
321
|
+
const existing = await this.get(input.taskId);
|
|
322
|
+
if (!existing
|
|
323
|
+
|| existing.decisionId !== input.decisionId
|
|
324
|
+
|| existing.endpointId !== input.endpointId
|
|
325
|
+
|| existing.actionDigest !== input.actionDigest
|
|
326
|
+
|| existing.externalIdempotencyKey !== input.externalIdempotencyKey
|
|
327
|
+
|| existing.worktree !== input.worktree
|
|
328
|
+
|| existing.branch !== input.branch) {
|
|
329
|
+
throw new Error('Action execution journal conflicts with the signed task.');
|
|
330
|
+
}
|
|
331
|
+
return { record: existing, created: false };
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
async transition(taskId, allowed, state, patch = {}) {
|
|
335
|
+
const current = await this.get(taskId);
|
|
336
|
+
if (!current || !allowed.includes(current.state)) {
|
|
337
|
+
throw new Error(`Action execution journal transition to ${state} is invalid.`);
|
|
338
|
+
}
|
|
339
|
+
const next = { ...current, ...patch, state, updatedAt: this.now().toISOString() };
|
|
340
|
+
await durableJsonWrite(this.recordPath(taskId), next);
|
|
341
|
+
return next;
|
|
342
|
+
}
|
|
343
|
+
async claim(record, maximumAgeMs) {
|
|
344
|
+
if (record.state !== 'replay_authorized')
|
|
345
|
+
throw new Error('Action execution is not authorized for claiming.');
|
|
346
|
+
const boundedAgeMs = Math.min(Math.max(maximumAgeMs, 1), 24 * 60 * 60_000);
|
|
347
|
+
const claim = {
|
|
348
|
+
schema: 'dharma.action-execution-claim/v1', taskId: record.taskId,
|
|
349
|
+
decisionId: record.decisionId, actionDigest: record.actionDigest,
|
|
350
|
+
ownerId: this.ownerId, pid: this.pid, claimedAt: this.now().toISOString(),
|
|
351
|
+
expiresAt: new Date(this.now().getTime() + boundedAgeMs).toISOString(),
|
|
352
|
+
};
|
|
353
|
+
const handle = await open(this.claimPath(record.taskId), 'wx', 0o600);
|
|
354
|
+
try {
|
|
355
|
+
await handle.writeFile(`${JSON.stringify(claim)}\n`);
|
|
356
|
+
await handle.sync();
|
|
357
|
+
}
|
|
358
|
+
finally {
|
|
359
|
+
await handle.close();
|
|
360
|
+
}
|
|
361
|
+
await this.transition(record.taskId, ['replay_authorized'], 'executing');
|
|
362
|
+
return claim;
|
|
363
|
+
}
|
|
364
|
+
isClaimOwnerAlive(claim) {
|
|
365
|
+
if (Date.parse(claim.expiresAt) <= this.now().getTime())
|
|
366
|
+
return false;
|
|
367
|
+
if (claim.ownerId === this.ownerId && claim.pid === this.pid)
|
|
368
|
+
return true;
|
|
369
|
+
try {
|
|
370
|
+
process.kill(claim.pid, 0);
|
|
371
|
+
return true;
|
|
372
|
+
}
|
|
373
|
+
catch (error) {
|
|
374
|
+
return error.code === 'EPERM';
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
async recordEffectObserved(taskId, providerResult) {
|
|
378
|
+
await this.transition(taskId, ['executing'], 'effect_observed', {
|
|
379
|
+
providerResultDigest: `sha256:${createHash('sha256').update(JSON.stringify(providerResult)).digest('hex')}`,
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
async recordReceipt(receipt) {
|
|
383
|
+
assertValidTaskReceipt(receipt, receipt.taskId);
|
|
384
|
+
await this.transition(receipt.taskId, [
|
|
385
|
+
'prepared', 'replay_authorization_started', 'replay_authorized', 'executing', 'effect_observed',
|
|
386
|
+
], 'receipt_recorded', { receipt: receiptForDurableStorage(receipt) });
|
|
387
|
+
await rm(this.claimPath(receipt.taskId), { force: true });
|
|
388
|
+
}
|
|
389
|
+
async selfTest() {
|
|
390
|
+
const path = resolve(this.directory, `.self-test-${randomUUID()}.json`);
|
|
391
|
+
try {
|
|
392
|
+
await durableJsonWrite(path, { schema: 'dharma.action-execution-journal-self-test/v1' });
|
|
393
|
+
const value = JSON.parse(await readFile(path, 'utf8'));
|
|
394
|
+
if (value.schema !== 'dharma.action-execution-journal-self-test/v1')
|
|
395
|
+
throw new Error('Journal self-test readback failed.');
|
|
396
|
+
}
|
|
397
|
+
finally {
|
|
398
|
+
await rm(path, { force: true });
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
export class FileActionDecisionReplayGuard {
|
|
403
|
+
directory;
|
|
404
|
+
constructor(directory) {
|
|
405
|
+
this.directory = directory;
|
|
156
406
|
}
|
|
407
|
+
async consume(decisionId, actionDigest) {
|
|
408
|
+
await mkdir(this.directory, { recursive: true, mode: 0o700 });
|
|
409
|
+
const target = resolve(this.directory, `${decisionId}.json`);
|
|
410
|
+
try {
|
|
411
|
+
const handle = await open(target, 'wx', 0o600);
|
|
412
|
+
try {
|
|
413
|
+
await handle.writeFile(`${JSON.stringify({
|
|
414
|
+
decisionId,
|
|
415
|
+
actionDigest,
|
|
416
|
+
consumedAt: new Date().toISOString(),
|
|
417
|
+
})}\n`);
|
|
418
|
+
}
|
|
419
|
+
finally {
|
|
420
|
+
await handle.close();
|
|
421
|
+
}
|
|
422
|
+
return true;
|
|
423
|
+
}
|
|
424
|
+
catch (error) {
|
|
425
|
+
if (error.code === 'EEXIST')
|
|
426
|
+
return false;
|
|
427
|
+
throw error;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
export function canonicalTaskActionForTask(task, actionId) {
|
|
432
|
+
if (!task.target.endpointId)
|
|
433
|
+
throw new Error('Receipt-required task target endpoint is unavailable.');
|
|
434
|
+
return {
|
|
435
|
+
schema: 'dharma.task-action/v1',
|
|
436
|
+
organizationId: task.organizationId,
|
|
437
|
+
actionId,
|
|
438
|
+
taskId: task.taskId,
|
|
439
|
+
targetEndpointId: task.target.endpointId,
|
|
440
|
+
workspaceId: task.workspaceId,
|
|
441
|
+
taskType: task.taskType,
|
|
442
|
+
instructions: task.instructions,
|
|
443
|
+
skillBundle: task.skillBundle,
|
|
444
|
+
requiredSkills: task.requiredSkills,
|
|
445
|
+
authority: task.authority,
|
|
446
|
+
execution: task.execution,
|
|
447
|
+
acceptance: task.acceptance,
|
|
448
|
+
budget: task.budget,
|
|
449
|
+
expiresAt: task.expiresAt,
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
export async function authorizeEmbeddedActionDecision(input) {
|
|
453
|
+
const receipt = verifyEmbeddedActionDecision(input.task, input.receiver);
|
|
454
|
+
const envelope = input.task.actionDecision;
|
|
455
|
+
if (!await input.replayGuard.consume(envelope.id, envelope.actionDigest)) {
|
|
456
|
+
throw new Error('Action-decision receipt replay was rejected.');
|
|
457
|
+
}
|
|
458
|
+
if (receipt.outcome !== 'release') {
|
|
459
|
+
throw new ActionDecisionDeniedError(`Action-decision outcome ${receipt.outcome} denied execution.`, receipt);
|
|
460
|
+
}
|
|
461
|
+
return receipt;
|
|
462
|
+
}
|
|
463
|
+
export function verifyEmbeddedActionDecision(task, receiver) {
|
|
464
|
+
const envelope = task.actionDecision;
|
|
465
|
+
if (!envelope)
|
|
466
|
+
throw new Error('Embedded action-decision receipt is unavailable; execution is denied.');
|
|
467
|
+
const action = canonicalTaskActionForTask(task, envelope.receipt.actionId);
|
|
468
|
+
const verification = verifyActionDecisionReceipt(envelope, action, receiver.resolvePublicKey, receiver.now?.() ?? new Date());
|
|
469
|
+
if (!verification.ok)
|
|
470
|
+
throw new Error(`Action-decision receipt is invalid: ${verification.reason}.`);
|
|
471
|
+
return envelope.receipt;
|
|
472
|
+
}
|
|
473
|
+
function requiresActionDecisionReceipt(task) {
|
|
474
|
+
return task.requiredCapabilities?.includes('action_decision_receipts_v1') ?? false;
|
|
475
|
+
}
|
|
476
|
+
function interruptedActionReceipt(record, now) {
|
|
477
|
+
const commandResults = [{
|
|
478
|
+
commandId: 'runner', exitCode: null, signal: null, timedOut: false,
|
|
479
|
+
stdoutSha256: `sha256:${'0'.repeat(64)}`,
|
|
480
|
+
stderrSha256: `sha256:${createHash('sha256').update('External effect outcome is unknown after receiver restart.').digest('hex')}`,
|
|
481
|
+
stdout: '', stderr: 'External effect outcome is unknown after receiver restart; execution was not repeated.',
|
|
482
|
+
}];
|
|
483
|
+
return {
|
|
484
|
+
taskId: record.taskId, status: 'failed', worktree: record.worktree, branch: record.branch,
|
|
485
|
+
commandResults,
|
|
486
|
+
actionAcknowledgement: buildActionDecisionAcknowledgement({
|
|
487
|
+
taskId: record.taskId, endpointId: record.endpointId, actionDigest: record.actionDigest,
|
|
488
|
+
disposition: 'unknown', externalIdempotencyKey: record.externalIdempotencyKey,
|
|
489
|
+
result: { status: 'failed', recovery: 'abandoned_execution_claim', commandResults },
|
|
490
|
+
}, now),
|
|
491
|
+
startedAt: record.preparedAt, completedAt: now.toISOString(),
|
|
492
|
+
};
|
|
157
493
|
}
|
|
158
494
|
export async function executeTask(input) {
|
|
159
495
|
verifyTaskEnvelope(input.task, input.serverPublicKey);
|
|
496
|
+
const taskExpiresAt = Date.parse(input.task.expiresAt);
|
|
497
|
+
const remainingTaskMs = () => taskExpiresAt - Date.now();
|
|
498
|
+
if (!Number.isFinite(taskExpiresAt) || remainingTaskMs() <= 0)
|
|
499
|
+
throw new Error('Task envelope expired.');
|
|
160
500
|
if (input.task.organizationId !== input.policy.organizationId)
|
|
161
501
|
throw new Error('Task organization does not match policy.');
|
|
162
502
|
const previous = await input.receiptStore.get(input.task.taskId);
|
|
@@ -175,96 +515,226 @@ export async function executeTask(input) {
|
|
|
175
515
|
if (!allowed.has(commandId))
|
|
176
516
|
throw new Error(`Acceptance command is outside task authority: ${commandId}`);
|
|
177
517
|
}
|
|
518
|
+
const receiptRequired = requiresActionDecisionReceipt(input.task);
|
|
519
|
+
const journal = input.actionExecutionJournal
|
|
520
|
+
?? new FileActionExecutionJournal(resolve(input.relayStateDirectory, 'action-execution-journal'));
|
|
178
521
|
const worktreeRoot = resolve(input.relayStateDirectory, 'worktrees');
|
|
179
522
|
const worktree = assertContained(worktreeRoot, resolve(worktreeRoot, input.task.taskId));
|
|
180
523
|
const branch = `dharma/task/${input.task.taskId}`;
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
const startingCommit = (await gitOutput(worktree, ['rev-parse', 'HEAD'])).trim();
|
|
524
|
+
let decisionReceipt = null;
|
|
525
|
+
let journalRecord = null;
|
|
526
|
+
let executionClaimed = false;
|
|
527
|
+
let contained = false;
|
|
186
528
|
const startedAt = new Date().toISOString();
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
allowWrites: input.task.authority.writePaths.length > 0,
|
|
198
|
-
signal: input.signal,
|
|
529
|
+
if (receiptRequired) {
|
|
530
|
+
if (!input.actionDecisions)
|
|
531
|
+
throw new Error('Action-decision public key resolver is unavailable; execution is denied.');
|
|
532
|
+
if (!input.task.target.endpointId)
|
|
533
|
+
throw new Error('Receipt-required task target endpoint is unavailable.');
|
|
534
|
+
decisionReceipt = verifyEmbeddedActionDecision(input.task, input.actionDecisions);
|
|
535
|
+
const prepared = await journal.prepare({
|
|
536
|
+
taskId: input.task.taskId, decisionId: decisionReceipt.decisionId,
|
|
537
|
+
endpointId: input.task.target.endpointId, actionDigest: decisionReceipt.actionDigest,
|
|
538
|
+
externalIdempotencyKey: decisionReceipt.decisionId, worktree, branch,
|
|
199
539
|
});
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
540
|
+
journalRecord = prepared.record;
|
|
541
|
+
if (journalRecord.state === 'receipt_recorded') {
|
|
542
|
+
if (!journalRecord.receipt)
|
|
543
|
+
throw new Error('Action execution journal is missing its recorded receipt.');
|
|
544
|
+
await input.receiptStore.put(journalRecord.receipt);
|
|
545
|
+
return journalRecord.receipt;
|
|
546
|
+
}
|
|
547
|
+
const priorClaim = await journal.getClaim(input.task.taskId);
|
|
548
|
+
if (priorClaim) {
|
|
549
|
+
if (priorClaim.decisionId !== journalRecord.decisionId || priorClaim.actionDigest !== journalRecord.actionDigest) {
|
|
550
|
+
throw new Error('Action execution claim conflicts with the signed decision.');
|
|
551
|
+
}
|
|
552
|
+
if (journal.isClaimOwnerAlive(priorClaim))
|
|
553
|
+
throw new Error('Action execution is already in progress.');
|
|
554
|
+
const recovered = interruptedActionReceipt(journalRecord, input.actionDecisions.now?.() ?? new Date());
|
|
555
|
+
await journal.recordReceipt(recovered);
|
|
556
|
+
await input.receiptStore.put(recovered);
|
|
557
|
+
return recovered;
|
|
558
|
+
}
|
|
559
|
+
if (journalRecord.state === 'replay_authorization_started'
|
|
560
|
+
|| journalRecord.state === 'executing'
|
|
561
|
+
|| journalRecord.state === 'effect_observed') {
|
|
562
|
+
const recovered = interruptedActionReceipt(journalRecord, input.actionDecisions.now?.() ?? new Date());
|
|
563
|
+
await journal.recordReceipt(recovered);
|
|
564
|
+
await input.receiptStore.put(recovered);
|
|
565
|
+
return recovered;
|
|
566
|
+
}
|
|
567
|
+
if (decisionReceipt.outcome !== 'release') {
|
|
568
|
+
contained = true;
|
|
569
|
+
}
|
|
570
|
+
else if (journalRecord.state === 'prepared') {
|
|
571
|
+
if (input.actionDecisions.replayGuard) {
|
|
572
|
+
journalRecord = await journal.transition(input.task.taskId, ['prepared'], 'replay_authorization_started');
|
|
573
|
+
if (!await input.actionDecisions.replayGuard.consume(decisionReceipt.decisionId, decisionReceipt.actionDigest)) {
|
|
574
|
+
throw new Error('Action-decision receipt replay was rejected.');
|
|
575
|
+
}
|
|
576
|
+
journalRecord = await journal.transition(input.task.taskId, ['replay_authorization_started'], 'replay_authorized');
|
|
577
|
+
}
|
|
578
|
+
else {
|
|
579
|
+
journalRecord = await journal.transition(input.task.taskId, ['prepared'], 'replay_authorized');
|
|
224
580
|
}
|
|
225
581
|
}
|
|
226
|
-
|
|
227
|
-
if (
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
582
|
+
if (!contained) {
|
|
583
|
+
if (journalRecord.state !== 'replay_authorized')
|
|
584
|
+
throw new Error('Action execution journal is not ready for execution.');
|
|
585
|
+
try {
|
|
586
|
+
await journal.claim(journalRecord, Math.min((input.task.execution.timeoutSeconds + input.task.execution.leaseSeconds + 300) * 1_000, remainingTaskMs()));
|
|
587
|
+
executionClaimed = true;
|
|
588
|
+
}
|
|
589
|
+
catch (error) {
|
|
590
|
+
if (error.code !== 'EEXIST')
|
|
591
|
+
throw error;
|
|
592
|
+
throw new Error('Action execution is already claimed by another receiver.');
|
|
232
593
|
}
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
const commandResults = [];
|
|
597
|
+
let status = contained ? 'failed' : 'completed';
|
|
598
|
+
if (contained && decisionReceipt) {
|
|
599
|
+
commandResults.push({
|
|
600
|
+
commandId: 'runner', exitCode: null, signal: null, timedOut: false,
|
|
601
|
+
stdoutSha256: `sha256:${'0'.repeat(64)}`,
|
|
602
|
+
stderrSha256: `sha256:${createHash('sha256').update(`Action-decision outcome ${decisionReceipt.outcome} denied execution.`).digest('hex')}`,
|
|
603
|
+
stdout: '', stderr: `Action-decision outcome ${decisionReceipt.outcome} denied execution.`,
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
if (!contained) {
|
|
607
|
+
try {
|
|
608
|
+
await mkdir(worktreeRoot, { recursive: true, mode: 0o700 });
|
|
609
|
+
await rm(worktree, { recursive: true, force: true });
|
|
610
|
+
await git(input.workspace, ['worktree', 'add', '--detach', worktree, 'HEAD']);
|
|
611
|
+
await git(worktree, ['switch', '-c', branch]);
|
|
612
|
+
const startingCommit = (await gitOutput(worktree, ['rev-parse', 'HEAD'])).trim();
|
|
613
|
+
const allowedCommands = input.task.authority.commands.map(({ commandId }) => resolveRegisteredCommand(input.policy, commandId).argv);
|
|
614
|
+
const providerInstructions = providerInstructionsForTask(input.task);
|
|
615
|
+
const providerTimeBudgetSeconds = Math.min(input.task.execution.timeoutSeconds, Math.floor(remainingTaskMs() / 1_000));
|
|
616
|
+
if (providerTimeBudgetSeconds < 1)
|
|
617
|
+
throw new Error('Task expires before provider execution can start.');
|
|
618
|
+
const providerInput = {
|
|
619
|
+
provider: input.task.target.provider,
|
|
620
|
+
workspace: worktree,
|
|
621
|
+
instructions: providerInstructions,
|
|
622
|
+
timeoutSeconds: providerTimeBudgetSeconds,
|
|
623
|
+
allowedCommandArgv: allowedCommands,
|
|
624
|
+
allowWrites: input.task.authority.writePaths.length > 0,
|
|
625
|
+
...(decisionReceipt ? {
|
|
626
|
+
externalIdempotencyKey: decisionReceipt.decisionId,
|
|
627
|
+
actionDigest: decisionReceipt.actionDigest,
|
|
628
|
+
} : {}),
|
|
241
629
|
signal: input.signal,
|
|
630
|
+
};
|
|
631
|
+
const providerResult = await (input.providerExecutor ?? executeProviderTask)(providerInput);
|
|
632
|
+
if (remainingTaskMs() <= 0)
|
|
633
|
+
throw new Error('Task expired during provider execution.');
|
|
634
|
+
if (executionClaimed)
|
|
635
|
+
await journal.recordEffectObserved(input.task.taskId, providerResult);
|
|
636
|
+
commandResults.push({
|
|
637
|
+
commandId: `provider.${input.task.target.provider}`,
|
|
638
|
+
exitCode: providerResult.exitCode,
|
|
639
|
+
signal: providerResult.signal,
|
|
640
|
+
timedOut: providerResult.timedOut,
|
|
641
|
+
stdout: providerResult.stdout,
|
|
642
|
+
stderr: providerResult.stderr,
|
|
643
|
+
stdoutSha256: providerResult.stdoutSha256,
|
|
644
|
+
stderrSha256: providerResult.stderrSha256,
|
|
242
645
|
});
|
|
243
|
-
|
|
244
|
-
if (result.exitCode !== 0 || result.timedOut) {
|
|
646
|
+
if (providerResult.exitCode !== 0 || providerResult.timedOut)
|
|
245
647
|
status = input.signal?.aborted ? 'cancelled' : 'failed';
|
|
246
|
-
|
|
648
|
+
const trackedChanges = await gitOutput(worktree, [
|
|
649
|
+
'diff', '--name-only', '--diff-filter=ACDMRTUXB', startingCommit, '--',
|
|
650
|
+
]);
|
|
651
|
+
const untrackedChanges = await gitOutput(worktree, ['ls-files', '--others', '--exclude-standard']);
|
|
652
|
+
const changedPaths = `${trackedChanges}\n${untrackedChanges}`
|
|
653
|
+
.split(/\r?\n/)
|
|
654
|
+
.map((path) => path.trim())
|
|
655
|
+
.filter(Boolean);
|
|
656
|
+
if (changedPaths.length > 0) {
|
|
657
|
+
const writes = input.task.authority.writePaths;
|
|
658
|
+
if (writes.length === 0 || changedPaths.some((path) => !pathWithinPolicy(path, writes))) {
|
|
659
|
+
throw new Error('Provider changed a path outside the signed task authority.');
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
for (const { commandId } of input.task.acceptance.commands) {
|
|
663
|
+
if (status !== 'completed')
|
|
664
|
+
break;
|
|
665
|
+
if (input.signal?.aborted) {
|
|
666
|
+
status = 'cancelled';
|
|
667
|
+
break;
|
|
668
|
+
}
|
|
669
|
+
const command = resolveRegisteredCommand(input.policy, commandId);
|
|
670
|
+
const cwd = command.workingDirectory
|
|
671
|
+
? assertPathWithinWorkspace(worktree, command.workingDirectory)
|
|
672
|
+
: worktree;
|
|
673
|
+
const [executable, ...argv] = command.argv;
|
|
674
|
+
const acceptanceTimeBudgetMs = remainingTaskMs();
|
|
675
|
+
if (acceptanceTimeBudgetMs <= 0)
|
|
676
|
+
throw new Error('Task expired before acceptance verification.');
|
|
677
|
+
const result = await runProcess(executable, argv, {
|
|
678
|
+
cwd,
|
|
679
|
+
timeoutMs: Math.min(command.timeoutSeconds * 1_000, input.task.execution.timeoutSeconds * 1_000, acceptanceTimeBudgetMs),
|
|
680
|
+
signal: input.signal,
|
|
681
|
+
});
|
|
682
|
+
if (remainingTaskMs() <= 0)
|
|
683
|
+
throw new Error('Task expired during acceptance verification.');
|
|
684
|
+
commandResults.push({ commandId, ...result });
|
|
685
|
+
if (result.exitCode !== 0 || result.timedOut) {
|
|
686
|
+
status = input.signal?.aborted ? 'cancelled' : 'failed';
|
|
687
|
+
break;
|
|
688
|
+
}
|
|
247
689
|
}
|
|
248
690
|
}
|
|
691
|
+
catch (error) {
|
|
692
|
+
status = input.signal?.aborted ? 'cancelled' : 'failed';
|
|
693
|
+
commandResults.push({
|
|
694
|
+
commandId: 'runner', exitCode: null, signal: null, timedOut: false,
|
|
695
|
+
stdoutSha256: `sha256:${'0'.repeat(64)}`,
|
|
696
|
+
stderrSha256: `sha256:${createHash('sha256').update(String(error)).digest('hex')}`,
|
|
697
|
+
stdout: '', stderr: error instanceof Error ? error.message : String(error),
|
|
698
|
+
});
|
|
699
|
+
}
|
|
249
700
|
}
|
|
250
|
-
|
|
251
|
-
|
|
701
|
+
if (receiptRequired && !contained && status === 'completed' && input.task.authority.network !== 'deny') {
|
|
702
|
+
const message = 'Provider completion cannot prove an external network effect without a provider-specific effect receipt.';
|
|
703
|
+
status = 'failed';
|
|
252
704
|
commandResults.push({
|
|
253
705
|
commandId: 'runner', exitCode: null, signal: null, timedOut: false,
|
|
254
706
|
stdoutSha256: `sha256:${'0'.repeat(64)}`,
|
|
255
|
-
stderrSha256: `sha256:${createHash('sha256').update(
|
|
256
|
-
stdout: '', stderr:
|
|
707
|
+
stderrSha256: `sha256:${createHash('sha256').update(message).digest('hex')}`,
|
|
708
|
+
stdout: '', stderr: message,
|
|
257
709
|
});
|
|
258
710
|
}
|
|
711
|
+
const actionAcknowledgement = decisionReceipt && input.task.target.endpointId
|
|
712
|
+
? buildActionDecisionAcknowledgement({
|
|
713
|
+
taskId: input.task.taskId,
|
|
714
|
+
endpointId: input.task.target.endpointId,
|
|
715
|
+
actionDigest: decisionReceipt.actionDigest,
|
|
716
|
+
disposition: contained ? 'contained' : status === 'completed' ? 'executed' : 'unknown',
|
|
717
|
+
externalIdempotencyKey: decisionReceipt.decisionId,
|
|
718
|
+
result: {
|
|
719
|
+
status,
|
|
720
|
+
commandResults: commandResults.map(({ commandId, exitCode, signal, timedOut, stdoutSha256, stderrSha256 }) => ({
|
|
721
|
+
commandId, exitCode, signal, timedOut, stdoutSha256, stderrSha256,
|
|
722
|
+
})),
|
|
723
|
+
},
|
|
724
|
+
}, input.actionDecisions?.now?.() ?? new Date())
|
|
725
|
+
: undefined;
|
|
259
726
|
const receipt = {
|
|
260
727
|
taskId: input.task.taskId,
|
|
261
728
|
status,
|
|
262
729
|
worktree,
|
|
263
730
|
branch,
|
|
264
731
|
commandResults,
|
|
732
|
+
...(actionAcknowledgement ? { actionAcknowledgement } : {}),
|
|
265
733
|
startedAt,
|
|
266
734
|
completedAt: new Date().toISOString(),
|
|
267
735
|
};
|
|
736
|
+
if (receiptRequired)
|
|
737
|
+
await journal.recordReceipt(receipt);
|
|
268
738
|
await input.receiptStore.put(receipt);
|
|
269
739
|
return receipt;
|
|
270
740
|
}
|