@purista/harness 1.2.1 → 1.2.2

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/dist/agents/index.d.ts +1 -0
  2. package/dist/agents/index.js +276 -141
  3. package/dist/errors/catalog.d.ts +4 -3
  4. package/dist/harness/defineHarness.d.ts +26 -2
  5. package/dist/harness/defineHarness.js +51 -2
  6. package/dist/index.d.ts +1 -1
  7. package/dist/memory/sandbox/index.js +7 -1
  8. package/dist/models/registry.js +45 -3
  9. package/dist/ports/base-model-provider.js +2 -0
  10. package/dist/ports/capabilities.d.ts +2 -0
  11. package/dist/ports/harness-context.d.ts +1 -0
  12. package/dist/ports/model-provider.d.ts +4 -0
  13. package/dist/ports/state.d.ts +6 -0
  14. package/dist/runtime/abort.d.ts +5 -0
  15. package/dist/runtime/abort.js +33 -0
  16. package/dist/runtime/durable.d.ts +2 -0
  17. package/dist/runtime/durable.js +6 -2
  18. package/dist/runtime/sessionDurable.d.ts +49 -0
  19. package/dist/runtime/sessionDurable.js +135 -0
  20. package/dist/runtime/steps.d.ts +19 -1
  21. package/dist/runtime/steps.js +21 -3
  22. package/dist/sandbox/index.d.ts +34 -0
  23. package/dist/sandbox/index.js +40 -3
  24. package/dist/sessions/index.d.ts +15 -2
  25. package/dist/sessions/index.js +212 -99
  26. package/dist/skills/index.js +19 -6
  27. package/dist/state/in-memory.d.ts +1 -0
  28. package/dist/state/in-memory.js +15 -0
  29. package/dist/telemetry/shim.js +9 -4
  30. package/dist/testing/durableWorkspaceStoreContract.d.ts +1 -1
  31. package/dist/testing/durableWorkspaceStoreContract.js +64 -28
  32. package/dist/tools/index.d.ts +2 -0
  33. package/dist/tools/index.js +15 -1
  34. package/dist/tools/mcp/runner.js +11 -6
  35. package/dist/tools/mcp/stdio.js +170 -1
  36. package/dist/ulid/index.d.ts +6 -1
  37. package/dist/ulid/index.js +31 -13
  38. package/dist/version.d.ts +2 -0
  39. package/dist/version.js +2 -0
  40. package/dist/workflows/index.js +7 -1
  41. package/dist/workspace/in-memory.d.ts +9 -10
  42. package/dist/workspace/in-memory.js +191 -48
  43. package/package.json +1 -1
  44. package/dist/harness/errors.d.ts +0 -62
  45. package/dist/harness/errors.js +0 -67
@@ -4,12 +4,13 @@ import { sandboxMemory } from '../memory/sandbox/index.js';
4
4
  import { validateMemoryAdapter } from '../ports/memory.js';
5
5
  import { validateDurableWorkspaceStore } from '../ports/workspace.js';
6
6
  import { InMemoryStateStore } from '../state/in-memory.js';
7
- import { HarnessConfigError } from '../errors/catalog.js';
7
+ import { HarnessConfigError, SkillManifestError } from '../errors/catalog.js';
8
+ import { BUILTIN_TOOL_NAMES } from '../tools/index.js';
8
9
  import { autoDetectSandbox } from '../sandbox/index.js';
9
10
  import { createSessionHarness } from '../sessions/index.js';
10
11
  import { hasAdapterCapabilities, missingCapabilities, uniqueCapabilities } from '../ports/capabilities.js';
11
12
  /** Stable harness version string for diagnostics and generated documentation. */
12
- export const HARNESS_VERSION = '0.0.0';
13
+ export { HARNESS_VERSION } from '../version.js';
13
14
  class Builder {
14
15
  options;
15
16
  configured;
@@ -53,6 +54,9 @@ class Builder {
53
54
  if (defaults.historyWindow !== undefined && defaults.historyWindow < 0) {
54
55
  throw new HarnessConfigError('historyWindow must be >= 0', { reason: 'invalid_defaults', path: 'defaults.historyWindow' });
55
56
  }
57
+ if (defaults.maxParallelToolCalls !== undefined && (!Number.isInteger(defaults.maxParallelToolCalls) || defaults.maxParallelToolCalls < 1)) {
58
+ throw new HarnessConfigError('maxParallelToolCalls must be a positive integer', { reason: 'invalid_defaults', path: 'defaults.maxParallelToolCalls' });
59
+ }
56
60
  return this.clone({ defaults });
57
61
  }
58
62
  models(models) {
@@ -62,6 +66,11 @@ class Builder {
62
66
  return this.clone({ models });
63
67
  }
64
68
  tools(tools) {
69
+ for (const id of Object.keys(tools)) {
70
+ if (!/^[a-z][a-z0-9_]*$/.test(id) || id.length > 64) {
71
+ throw new HarnessConfigError('Invalid tool id. Tool ids must match /^[a-z][a-z0-9_]*$/ and be at most 64 characters.', { reason: 'invalid_tool_id', path: `tools.${id}`, id });
72
+ }
73
+ }
65
74
  return this.clone({ tools });
66
75
  }
67
76
  skills(skills) {
@@ -85,6 +94,7 @@ class Builder {
85
94
  if (!models || Object.keys(models).length === 0) {
86
95
  throw new HarnessConfigError('At least one model alias is required.', { reason: 'missing_models', path: 'models' });
87
96
  }
97
+ this.validateToolSkillNamespace();
88
98
  const sandbox = this.configured.sandbox ?? autoDetectSandbox();
89
99
  const memory = this.configured.memory ?? sandboxMemory();
90
100
  validateMemoryAdapter(memory);
@@ -106,12 +116,15 @@ class Builder {
106
116
  state: this.configured.state ?? new InMemoryStateStore(),
107
117
  sandbox,
108
118
  memory,
119
+ ...(this.configured.runtime ? { runtime: this.configured.runtime } : {}),
120
+ ...(this.configured.workspaceStore ? { workspaceStore: this.configured.workspaceStore } : {}),
109
121
  defaults: {
110
122
  agentMaxIterations: this.configured.defaults?.agentMaxIterations ?? 16,
111
123
  runTimeoutMs: this.configured.defaults?.runTimeoutMs ?? 600_000,
112
124
  toolTimeoutMs: this.configured.defaults?.toolTimeoutMs ?? 120_000,
113
125
  skillTimeoutMs: this.configured.defaults?.skillTimeoutMs ?? 60_000,
114
126
  modelTimeoutMs: this.configured.defaults?.modelTimeoutMs ?? 300_000,
127
+ maxParallelToolCalls: this.configured.defaults?.maxParallelToolCalls ?? 8,
115
128
  ...(this.configured.defaults?.historyWindow !== undefined ? { historyWindow: this.configured.defaults.historyWindow } : {})
116
129
  },
117
130
  models,
@@ -126,6 +139,42 @@ class Builder {
126
139
  clone(patch) {
127
140
  return new Builder(this.options, { ...this.configured, ...patch });
128
141
  }
142
+ /**
143
+ * Tool ids, skill ids, and built-in tool names share one model-facing
144
+ * namespace (spec 08 §6). A custom tool id must not collide with a built-in
145
+ * tool name or a skill id, and a skill id must not collide with a built-in
146
+ * tool name.
147
+ */
148
+ validateToolSkillNamespace() {
149
+ const toolIds = Object.keys(this.configured.tools ?? {});
150
+ const skillIds = new Set(Object.keys(this.configured.skills ?? {}));
151
+ const builtinNames = new Set(BUILTIN_TOOL_NAMES);
152
+ for (const id of toolIds) {
153
+ if (builtinNames.has(id)) {
154
+ throw new SkillManifestError(`Custom tool id "${id}" collides with a built-in tool name.`, {
155
+ reason: 'reserved_name',
156
+ skill_id: id,
157
+ source: 'tool'
158
+ });
159
+ }
160
+ if (skillIds.has(id)) {
161
+ throw new SkillManifestError(`Custom tool id "${id}" collides with a skill id.`, {
162
+ reason: 'reserved_name',
163
+ skill_id: id,
164
+ source: 'tool'
165
+ });
166
+ }
167
+ }
168
+ for (const id of skillIds) {
169
+ if (builtinNames.has(id)) {
170
+ throw new SkillManifestError(`Skill id "${id}" collides with a built-in tool name.`, {
171
+ reason: 'reserved_name',
172
+ skill_id: id,
173
+ source: 'skill'
174
+ });
175
+ }
176
+ }
177
+ }
129
178
  validateAgentSkillReferences(agents) {
130
179
  const configuredSkills = new Set(Object.keys(this.configured.skills ?? {}));
131
180
  for (const [agentId, agent] of Object.entries(agents)) {
package/dist/index.d.ts CHANGED
@@ -4,7 +4,7 @@ export * from './telemetry/index.js';
4
4
  export * from './ulid/index.js';
5
5
  export * from './ports/index.js';
6
6
  export { createDurableWorkflowContext, DurableStepError, DurableRunLeaseError, DurableTerminalRunError, inMemoryDurableRuntime, isTerminalRunStatus } from './runtime/index.js';
7
- export type { DurableActiveRunStatus, DurableWorkflowContext, DurableRunLease, DurableRunStart, DurableRunStatus, DurableRuntime, DurableTerminalRunStatus, FinishRunPatch, InMemoryDurableRuntimeOptions, RunCheckpoint } from './runtime/index.js';
7
+ export type { DurableActiveRunStatus, DurableWorkflowContext, DurableWorkflowContextOptions, DurableStepCommit, DurableRunLease, DurableRunStart, DurableRunStatus, DurableRuntime, DurableTerminalRunStatus, FinishRunPatch, InMemoryDurableRuntimeOptions, RunCheckpoint } from './runtime/index.js';
8
8
  export * from './state/in-memory.js';
9
9
  export * from './models/json.js';
10
10
  export type { SessionRecord, Message, RunRecord, PersistedRunEvent, RunStatus } from './models/state.js';
@@ -45,7 +45,13 @@ class SandboxMemoryAdapter {
45
45
  const path = `${root}/${key}.json`;
46
46
  if (!(await sandbox.exists(path)))
47
47
  return undefined;
48
- return JSON.parse(await sandbox.readText(path));
48
+ const raw = await sandbox.readText(path);
49
+ try {
50
+ return JSON.parse(raw);
51
+ }
52
+ catch (error) {
53
+ throw new StateError('Stored memory value is not valid JSON.', { op: 'memory.get', reason: 'corrupt_value' }, error);
54
+ }
49
55
  },
50
56
  set: async (key, value, op) => {
51
57
  op.signal.throwIfAborted();
@@ -1,4 +1,4 @@
1
- import { ModelCapabilityError } from '../errors/index.js';
1
+ import { ModelCapabilityError, ModelError } from '../errors/index.js';
2
2
  import { ATTR_GEN_AI_REQUEST_MODEL, ATTR_GEN_AI_RESPONSE_FINISH_REASONS, ATTR_GEN_AI_SYSTEM, ATTR_GEN_AI_TOKEN_TYPE, ATTR_GEN_AI_USAGE_INPUT_TOKENS, ATTR_GEN_AI_USAGE_OUTPUT_TOKENS, GEN_AI_TOKEN_TYPE_VALUE_INPUT, GEN_AI_TOKEN_TYPE_VALUE_OUTPUT } from '@opentelemetry/semantic-conventions/incubating';
3
3
  /**
4
4
  * Creates per-alias model handles that enforce capability gates before provider invocation.
@@ -92,7 +92,7 @@ function createHandle(aliasKey, alias, options) {
92
92
  signal,
93
93
  traceparent: req.traceparent ?? options.telemetry?.currentTraceparent()
94
94
  };
95
- return withModelSpan(options, aliasKey, alias, 'embeddings', ctx, () => alias.provider.embed(fullReq));
95
+ return withModelSpan(options, aliasKey, alias, 'embeddings', ctx, () => alias.provider.embed(fullReq)).then((response) => validateEmbeddingResponse(aliasKey, alias, fullReq, response));
96
96
  },
97
97
  rerank(req, signal, ctx) {
98
98
  ensureCapabilities(aliasKey, alias, 'rerank', req);
@@ -107,10 +107,51 @@ function createHandle(aliasKey, alias, options) {
107
107
  signal,
108
108
  traceparent: req.traceparent ?? options.telemetry?.currentTraceparent()
109
109
  };
110
- return withModelSpan(options, aliasKey, alias, 'rerank', ctx, () => alias.provider.rerank(fullReq));
110
+ return withModelSpan(options, aliasKey, alias, 'rerank', ctx, () => alias.provider.rerank(fullReq)).then((response) => validateRerankResponse(aliasKey, alias, fullReq, response));
111
111
  }
112
112
  };
113
113
  }
114
+ /**
115
+ * Provider-neutral guard: the number of embeddings must match the number of
116
+ * inputs, and indices must cover every input exactly once. Protects callers
117
+ * that associate vectors with inputs by position.
118
+ */
119
+ function validateEmbeddingResponse(aliasKey, alias, req, response) {
120
+ const expected = Array.isArray(req.input) ? req.input.length : 1;
121
+ const indices = new Set(response.embeddings.map((item) => item.index));
122
+ const validIndices = response.embeddings.every((item) => Number.isInteger(item.index) && item.index >= 0 && item.index < expected);
123
+ if (response.embeddings.length !== expected || indices.size !== expected || !validIndices) {
124
+ throw new ModelError('Embedding response does not match the request input count.', {
125
+ provider: alias.provider.id,
126
+ model: alias.model,
127
+ method: 'embed',
128
+ reason: 'embedding_count_mismatch',
129
+ providerBody: { expected, received: response.embeddings.length, alias: aliasKey }
130
+ });
131
+ }
132
+ return response;
133
+ }
134
+ /**
135
+ * Provider-neutral guard: every rerank result must reference a distinct, valid
136
+ * document index, and the count must not exceed the requested document count
137
+ * (or `topN` when supplied).
138
+ */
139
+ function validateRerankResponse(aliasKey, alias, req, response) {
140
+ const documentCount = req.documents.length;
141
+ const limit = req.topN !== undefined ? Math.min(req.topN, documentCount) : documentCount;
142
+ const indices = new Set(response.results.map((item) => item.index));
143
+ const validIndices = response.results.every((item) => Number.isInteger(item.index) && item.index >= 0 && item.index < documentCount);
144
+ if (response.results.length > limit || indices.size !== response.results.length || !validIndices) {
145
+ throw new ModelError('Rerank response does not map back to the request documents.', {
146
+ provider: alias.provider.id,
147
+ model: alias.model,
148
+ method: 'rerank',
149
+ reason: 'rerank_result_mismatch',
150
+ providerBody: { documentCount, limit, received: response.results.length, alias: aliasKey }
151
+ });
152
+ }
153
+ return response;
154
+ }
114
155
  function withModelStreamSpan(options, aliasKey, alias, method, ctx, fn) {
115
156
  if (!options.telemetry)
116
157
  return fn();
@@ -306,6 +347,7 @@ function mergeDefaults(alias, call) {
306
347
  || merged.maxTokens !== undefined
307
348
  || merged.topP !== undefined
308
349
  || merged.stopSequences !== undefined
350
+ || merged.parallelToolCalls !== undefined
309
351
  || Object.keys(merged.providerOptions ?? {}).length > 0;
310
352
  return hasTopLevel ? merged : undefined;
311
353
  }
@@ -161,6 +161,8 @@ export class BaseModelProvider {
161
161
  const controller = new AbortController();
162
162
  const relay = () => controller.abort(req.signal.reason);
163
163
  req.signal.addEventListener('abort', relay, { once: true });
164
+ if (req.signal.aborted)
165
+ relay();
164
166
  let rejectTimeout;
165
167
  const timeoutPromise = new Promise((_, reject) => { rejectTimeout = reject; });
166
168
  const timeout = setTimeout(() => {
@@ -17,6 +17,8 @@ export type AdapterCapability =
17
17
  | 'sandbox.resume'
18
18
  /** Sandbox can snapshot and release active compute. */
19
19
  | 'sandbox.hibernate'
20
+ /** Sandbox can host a long-lived process with streaming stdin/stdout. */
21
+ | 'sandbox.spawn'
20
22
  /** Runtime can commit stable checkpoints. */
21
23
  | 'runtime.checkpoint'
22
24
  /** Runtime can retry durable boundaries. */
@@ -14,6 +14,7 @@ export interface HarnessAdapterContext {
14
14
  toolTimeoutMs: number;
15
15
  skillTimeoutMs: number;
16
16
  modelTimeoutMs: number;
17
+ maxParallelToolCalls: number;
17
18
  historyWindow?: number;
18
19
  };
19
20
  }
@@ -29,6 +29,8 @@ export interface ModelDefaults {
29
29
  maxTokens?: number;
30
30
  topP?: number;
31
31
  stopSequences?: string[];
32
+ /** Whether providers should allow the model to emit multiple independent tool calls in one turn. */
33
+ parallelToolCalls?: boolean;
32
34
  providerOptions?: Record<string, unknown>;
33
35
  }
34
36
  /** Per-call generation overrides. */
@@ -37,6 +39,8 @@ export interface ModelCallOptions {
37
39
  maxTokens?: number;
38
40
  topP?: number;
39
41
  stopSequences?: string[];
42
+ /** Overrides whether providers should allow multiple tool calls in one model turn. */
43
+ parallelToolCalls?: boolean;
40
44
  providerOptions?: Record<string, unknown>;
41
45
  }
42
46
  /** Tool call envelope emitted by model adapters. */
@@ -20,6 +20,12 @@ export interface StateStore {
20
20
  before?: string;
21
21
  }): Promise<Message[]>;
22
22
  clearMessages(sessionId: string): Promise<void>;
23
+ /**
24
+ * Atomically replace all messages for a session under one lock (clear +
25
+ * append). Adapters that implement this provide the spec-mandated atomic
26
+ * `replaceHistory`; the session layer falls back to clear+append when absent.
27
+ */
28
+ replaceMessages?(sessionId: string, messages: Message[]): Promise<void>;
23
29
  createRun(record: RunRecord): Promise<void>;
24
30
  finishRun(runId: string, patch: FinishRunPatch): Promise<void>;
25
31
  getRun(runId: string): Promise<RunRecord | undefined>;
@@ -0,0 +1,5 @@
1
+ import { OperationCancelledError, OperationTimeoutError } from '../errors/index.js';
2
+ type AbortScope = 'run' | 'model' | 'tool' | 'workflow' | 'agent' | 'sandbox' | 'memory' | 'workspace';
3
+ export declare function abortError(signal: AbortSignal, scope: AbortScope, message: string): OperationCancelledError | OperationTimeoutError;
4
+ export declare function withAbortSignal<T>(signal: AbortSignal, scope: AbortScope, message: string, fn: () => Promise<T>): Promise<T>;
5
+ export {};
@@ -0,0 +1,33 @@
1
+ import { OperationCancelledError, OperationTimeoutError } from '../errors/index.js';
2
+ export function abortError(signal, scope, message) {
3
+ if (signal.reason instanceof OperationTimeoutError)
4
+ return signal.reason;
5
+ if (signal.reason instanceof OperationCancelledError)
6
+ return signal.reason;
7
+ return new OperationCancelledError(message, { scope }, signal.reason);
8
+ }
9
+ export async function withAbortSignal(signal, scope, message, fn) {
10
+ if (signal.aborted)
11
+ throw abortError(signal, scope, message);
12
+ let abortListener;
13
+ const abortPromise = new Promise((_, reject) => {
14
+ abortListener = () => reject(abortError(signal, scope, message));
15
+ signal.addEventListener('abort', abortListener, { once: true });
16
+ if (signal.aborted)
17
+ abortListener();
18
+ });
19
+ try {
20
+ return await Promise.race([fn(), abortPromise]);
21
+ }
22
+ catch (error) {
23
+ if (error instanceof OperationCancelledError || error instanceof OperationTimeoutError)
24
+ throw error;
25
+ if (signal.aborted)
26
+ throw abortError(signal, scope, message);
27
+ throw error;
28
+ }
29
+ finally {
30
+ if (abortListener)
31
+ signal.removeEventListener('abort', abortListener);
32
+ }
33
+ }
@@ -45,6 +45,8 @@ export interface DurableRunLease {
45
45
  };
46
46
  /** Last committed checkpoint, if any. */
47
47
  readonly checkpoint?: RunCheckpoint;
48
+ /** All committed checkpoints for this run, in commit order, for step replay. */
49
+ readonly checkpoints?: readonly RunCheckpoint[];
48
50
  /** Releases this in-memory lease without making the run terminal. */
49
51
  release(): Promise<void>;
50
52
  }
@@ -61,7 +61,8 @@ class InMemoryDurableRuntime {
61
61
  const state = current ?? {
62
62
  start: record,
63
63
  status: 'running',
64
- attempt: Math.max(1, record.attempt ?? 1)
64
+ attempt: Math.max(1, record.attempt ?? 1),
65
+ checkpoints: new Map()
65
66
  };
66
67
  if (current) {
67
68
  state.attempt += 1;
@@ -95,7 +96,9 @@ class InMemoryDurableRuntime {
95
96
  throw new DurableTerminalRunError(checkpoint.runId, state.status);
96
97
  }
97
98
  const committedAt = checkpoint.committedAt ?? new Date().toISOString();
98
- state.checkpoint = { ...checkpoint, committedAt };
99
+ const stored = { ...checkpoint, committedAt };
100
+ state.checkpoint = stored;
101
+ state.checkpoints.set(stored.stepId, stored);
99
102
  this.checkpointCommitCount += 1;
100
103
  if (this.options.failAfterCheckpoint === this.checkpointCommitCount) {
101
104
  this.releaseLease(lease);
@@ -148,6 +151,7 @@ class InMemoryDurableRuntime {
148
151
  attempt: state.attempt
149
152
  },
150
153
  ...(state.checkpoint ? { checkpoint: state.checkpoint } : {}),
154
+ checkpoints: [...state.checkpoints.values()].sort((a, b) => a.sequence - b.sequence),
151
155
  release: async () => {
152
156
  await this.withSessionLock(lease.sessionId, async () => {
153
157
  this.releaseLease(lease);
@@ -0,0 +1,49 @@
1
+ import type { Logger } from '../logger/index.js';
2
+ import type { JsonValue } from '../models/json.js';
3
+ import type { DurableWorkspaceStore } from '../ports/workspace.js';
4
+ import type { DurableRuntime } from './durable.js';
5
+ import { type DurableWorkflowContext } from './steps.js';
6
+ /** Run-id format accepted for durable invocations. */
7
+ export declare const DURABLE_RUN_ID_PATTERN: RegExp;
8
+ /** Caller-supplied durable invocation options (mirror of `InvokeOptions.durable`). */
9
+ export interface DurableInvokeOptions {
10
+ runId: string;
11
+ workerId?: string;
12
+ stepId?: string;
13
+ attempt?: number;
14
+ }
15
+ /** Durable binding driving one workflow run's lease and workspace lifecycle. */
16
+ export interface DurableWorkflowBinding {
17
+ readonly runId: string;
18
+ readonly attempt: number;
19
+ readonly resumed: boolean;
20
+ readonly step: DurableWorkflowContext['step'];
21
+ /** Marks the run successfully terminal and, when policy permits, cleans up the workspace. */
22
+ finishSuccess(output: JsonValue): Promise<void>;
23
+ /** Marks the run cancelled-terminal and aborts the workspace (blocks resume). */
24
+ finishCancelled(error: unknown): Promise<void>;
25
+ /**
26
+ * Releases the lease without making the run terminal when it was not settled,
27
+ * leaving a failed run resumable by a later retry with the same run id.
28
+ */
29
+ dispose(): Promise<void>;
30
+ }
31
+ /** Narrows a configured runtime adapter to an executable durable runtime. */
32
+ export declare function isExecutableDurableRuntime(runtime: unknown): runtime is DurableRuntime;
33
+ /**
34
+ * Acquires a durable runtime lease for a workflow run and, when a workspace
35
+ * store is configured, starts or resumes the durable workspace and links each
36
+ * new step checkpoint to a workspace checkpoint (spec 21 §16.1).
37
+ */
38
+ export declare function beginDurableWorkflow(args: {
39
+ runtime: DurableRuntime;
40
+ workspaceStore?: DurableWorkspaceStore;
41
+ durable: DurableInvokeOptions;
42
+ defaultWorkerId: string;
43
+ sessionId: string;
44
+ workflowId: string;
45
+ input: JsonValue;
46
+ signal: AbortSignal;
47
+ logger: Logger;
48
+ harnessName: string;
49
+ }): Promise<DurableWorkflowBinding>;
@@ -0,0 +1,135 @@
1
+ import { serializeError } from '../errors/index.js';
2
+ import { createDurableWorkflowContext } from './steps.js';
3
+ /** Run-id format accepted for durable invocations. */
4
+ export const DURABLE_RUN_ID_PATTERN = /^[A-Za-z0-9_.:-]{1,200}$/;
5
+ /** Narrows a configured runtime adapter to an executable durable runtime. */
6
+ export function isExecutableDurableRuntime(runtime) {
7
+ if (!runtime || typeof runtime !== 'object')
8
+ return false;
9
+ const candidate = runtime;
10
+ return typeof candidate.startRun === 'function'
11
+ && typeof candidate.commitCheckpoint === 'function'
12
+ && typeof candidate.finishRun === 'function'
13
+ && typeof candidate.withSessionLock === 'function';
14
+ }
15
+ /**
16
+ * Acquires a durable runtime lease for a workflow run and, when a workspace
17
+ * store is configured, starts or resumes the durable workspace and links each
18
+ * new step checkpoint to a workspace checkpoint (spec 21 §16.1).
19
+ */
20
+ export async function beginDurableWorkflow(args) {
21
+ const { runtime, workspaceStore, durable, sessionId, workflowId, input, signal, logger, harnessName } = args;
22
+ const workerId = durable.workerId ?? args.defaultWorkerId;
23
+ const lease = await runtime.startRun({
24
+ runId: durable.runId,
25
+ sessionId,
26
+ workerId,
27
+ stepId: durable.stepId ?? workflowId,
28
+ input,
29
+ ...(durable.attempt !== undefined ? { attempt: durable.attempt } : {})
30
+ });
31
+ let handle;
32
+ if (workspaceStore) {
33
+ const priorReplay = lease.checkpoint?.replay;
34
+ if (lease.resumed && priorReplay?.workspaceRef) {
35
+ handle = await workspaceStore.resumeWorkspace({
36
+ workspaceRef: priorReplay.workspaceRef,
37
+ ...(priorReplay.checkpointRef ? { checkpointRef: priorReplay.checkpointRef } : {}),
38
+ runId: lease.runId,
39
+ sessionId,
40
+ attempt: lease.attempt,
41
+ idempotencyKey: `${lease.runId}:${lease.attempt}:resume`,
42
+ signal
43
+ });
44
+ }
45
+ else {
46
+ handle = await workspaceStore.startWorkspace({
47
+ runId: lease.runId,
48
+ sessionId,
49
+ workflowId,
50
+ workerId,
51
+ attempt: lease.attempt,
52
+ idempotencyKey: `${lease.runId}:start`,
53
+ signal
54
+ });
55
+ }
56
+ }
57
+ const activeHandle = handle;
58
+ const onStepCommit = workspaceStore && activeHandle
59
+ ? async (commit) => {
60
+ const checkpoint = await workspaceStore.pauseWorkspace({
61
+ handle: activeHandle,
62
+ stepId: commit.stepId,
63
+ sequence: commit.sequence,
64
+ attempt: commit.attempt,
65
+ reason: 'step_completed',
66
+ idempotencyKey: `${lease.runId}:${commit.attempt}:pause:${commit.stepId}`,
67
+ signal
68
+ });
69
+ return {
70
+ runId: lease.runId,
71
+ sessionId,
72
+ workerId,
73
+ leaseId: lease.leaseId,
74
+ stepId: commit.stepId,
75
+ sequence: commit.sequence,
76
+ attempt: commit.attempt,
77
+ checkpointRef: checkpoint.checkpointRef,
78
+ workspaceRef: checkpoint.workspaceRef,
79
+ ...(checkpoint.snapshotRef ? { snapshotRef: checkpoint.snapshotRef } : {}),
80
+ schemaVersion: 1,
81
+ committedAt: checkpoint.committedAt,
82
+ ...(checkpoint.expiresAt ? { expiresAt: checkpoint.expiresAt } : {})
83
+ };
84
+ }
85
+ : undefined;
86
+ const ctx = createDurableWorkflowContext(runtime, lease, onStepCommit ? { onStepCommit } : {});
87
+ const autoCleanup = workspaceStore?.info.policy.retention?.cleanupMode === 'adapter_automatic';
88
+ let settled = false;
89
+ return {
90
+ runId: lease.runId,
91
+ attempt: lease.attempt,
92
+ resumed: lease.resumed,
93
+ step: ctx.step,
94
+ async finishSuccess(output) {
95
+ await runtime.finishRun(lease.runId, { status: 'succeeded', output });
96
+ settled = true;
97
+ if (workspaceStore && activeHandle && autoCleanup) {
98
+ await workspaceStore.cleanupWorkspace({
99
+ workspaceRef: activeHandle.workspaceRef,
100
+ reason: 'terminal_success',
101
+ idempotencyKey: `${lease.runId}:cleanup`
102
+ });
103
+ }
104
+ },
105
+ async finishCancelled(error) {
106
+ await runtime.finishRun(lease.runId, { status: 'cancelled', error: serializeError(error) });
107
+ settled = true;
108
+ if (workspaceStore && activeHandle) {
109
+ await workspaceStore.abortWorkspace({
110
+ workspaceRef: activeHandle.workspaceRef,
111
+ runId: lease.runId,
112
+ sessionId,
113
+ reason: 'cancelled',
114
+ idempotencyKey: `${lease.runId}:abort`
115
+ });
116
+ }
117
+ },
118
+ async dispose() {
119
+ if (settled)
120
+ return;
121
+ try {
122
+ await lease.release();
123
+ }
124
+ catch (error) {
125
+ logger.warn('Failed to release durable lease for retry.', {
126
+ harness: harnessName,
127
+ session_id: sessionId,
128
+ run_id: lease.runId,
129
+ workflow_id: workflowId,
130
+ error: serializeError(error)
131
+ });
132
+ }
133
+ }
134
+ };
135
+ }
@@ -1,5 +1,23 @@
1
1
  import type { JsonValue } from '../models/json.js';
2
+ import type { DurableReplayCheckpoint } from '../ports/workspace.js';
2
3
  import type { DurableRunLease, DurableRuntime } from './durable.js';
4
+ /** Metadata describing a new step checkpoint about to be committed. */
5
+ export interface DurableStepCommit {
6
+ readonly stepId: string;
7
+ readonly sequence: number;
8
+ readonly attempt: number;
9
+ readonly output: JsonValue;
10
+ }
11
+ /** Optional hooks for binding durable steps to a durable workspace store. */
12
+ export interface DurableWorkflowContextOptions {
13
+ /**
14
+ * Invoked before each NEW step checkpoint is committed (never on replay). The
15
+ * returned record is stored on the runtime checkpoint's `replay` field so a
16
+ * later resume can locate the durable workspace checkpoint. This enforces the
17
+ * "workspace state first, runtime checkpoint second" ordering (spec 21 §10).
18
+ */
19
+ readonly onStepCommit?: (commit: DurableStepCommit) => Promise<DurableReplayCheckpoint | undefined>;
20
+ }
3
21
  /** Durable workflow context that exposes explicit checkpoint boundaries. */
4
22
  export interface DurableWorkflowContext {
5
23
  /** Current durable run lease. */
@@ -19,4 +37,4 @@ export declare class DurableStepError extends Error {
19
37
  constructor(message: string);
20
38
  }
21
39
  /** Creates a durable workflow context bound to an acquired runtime lease. */
22
- export declare function createDurableWorkflowContext(runtime: DurableRuntime, lease: DurableRunLease): DurableWorkflowContext;
40
+ export declare function createDurableWorkflowContext(runtime: DurableRuntime, lease: DurableRunLease, options?: DurableWorkflowContextOptions): DurableWorkflowContext;
@@ -7,8 +7,15 @@ export class DurableStepError extends Error {
7
7
  }
8
8
  }
9
9
  /** Creates a durable workflow context bound to an acquired runtime lease. */
10
- export function createDurableWorkflowContext(runtime, lease) {
10
+ export function createDurableWorkflowContext(runtime, lease, options = {}) {
11
11
  const completed = new Set();
12
+ // Committed step outputs from prior attempts, keyed by stepId. On resume,
13
+ // these steps replay their stored output instead of re-running side effects.
14
+ const replay = new Map();
15
+ for (const checkpoint of lease.checkpoints ?? []) {
16
+ replay.set(checkpoint.stepId, checkpoint.output);
17
+ }
18
+ let sequence = (lease.checkpoints ?? []).reduce((max, checkpoint) => Math.max(max, checkpoint.sequence), 0);
12
19
  return {
13
20
  lease,
14
21
  async step(stepId, fn) {
@@ -17,9 +24,19 @@ export function createDurableWorkflowContext(runtime, lease) {
17
24
  throw new DurableStepError(`Duplicate durable step id "${stepId}".`);
18
25
  }
19
26
  completed.add(stepId);
27
+ // Durable replay: a step committed on a prior attempt returns its stored
28
+ // output without re-executing `fn()` or re-committing a checkpoint.
29
+ if (replay.has(stepId)) {
30
+ return replay.get(stepId);
31
+ }
20
32
  const output = await fn();
21
33
  assertJsonSerializable(output, stepId);
22
- const sequence = (lease.checkpoint?.sequence ?? 0) + completed.size;
34
+ sequence += 1;
35
+ // Workspace state is written before the runtime checkpoint (spec 21 §10),
36
+ // and the returned reference is linked on the runtime checkpoint.
37
+ const replayCheckpoint = options.onStepCommit
38
+ ? await options.onStepCommit({ stepId, sequence, attempt: lease.attempt, output })
39
+ : undefined;
23
40
  const checkpoint = {
24
41
  runId: lease.runId,
25
42
  sessionId: lease.sessionId,
@@ -29,7 +46,8 @@ export function createDurableWorkflowContext(runtime, lease) {
29
46
  input: lease.start.input,
30
47
  attempt: lease.attempt,
31
48
  sequence,
32
- output
49
+ output,
50
+ ...(replayCheckpoint ? { replay: replayCheckpoint } : {})
33
51
  };
34
52
  await runtime.commitCheckpoint(checkpoint);
35
53
  return output;
@@ -22,6 +22,40 @@ export interface ExecCapableSandboxSession extends SandboxSessionBase {
22
22
  readonly executor: 'available';
23
23
  exec(command: string, opts?: ExecOptions): Promise<ExecResult>;
24
24
  }
25
+ /** Options for spawning a long-lived process inside the sandbox. */
26
+ export interface SpawnOptions {
27
+ /** Command arguments. */
28
+ args?: readonly string[];
29
+ /** Working directory inside the sandbox. */
30
+ cwd?: string;
31
+ /** Extra environment variables. */
32
+ env?: Record<string, string>;
33
+ /** Cancellation signal; aborting terminates the process. */
34
+ signal?: AbortSignal;
35
+ }
36
+ /** A long-lived process owned by a sandbox session with streaming stdio. */
37
+ export interface SandboxProcess {
38
+ /** Writes a chunk to the process stdin. */
39
+ writeStdin(chunk: string): Promise<void>;
40
+ /** Decoded stdout chunks. Completes when the process exits. */
41
+ readonly stdout: AsyncIterable<string>;
42
+ /** Decoded stderr chunks. Completes when the process exits. */
43
+ readonly stderr: AsyncIterable<string>;
44
+ /** Resolves with the exit code when the process terminates. Never rejects. */
45
+ readonly exit: Promise<{
46
+ exitCode: number;
47
+ signal?: string;
48
+ }>;
49
+ /** Terminates the process. Idempotent. */
50
+ kill(signal?: 'SIGTERM' | 'SIGKILL'): Promise<void>;
51
+ }
52
+ /** Sandbox session that can host long-lived processes (`sandbox.spawn`). */
53
+ export interface SpawnCapableSandboxSession extends SandboxSessionBase {
54
+ readonly executor: 'available';
55
+ spawn(command: string, opts?: SpawnOptions): Promise<SandboxProcess>;
56
+ }
57
+ /** Returns true when a sandbox session can spawn long-lived processes. */
58
+ export declare function isSpawnCapableSession(session: SandboxSessionBase): session is SpawnCapableSandboxSession;
25
59
  export type SandboxSession = SandboxSessionBase & {
26
60
  exec(command: string, opts?: ExecOptions): Promise<ExecResult>;
27
61
  };