@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.
- package/dist/agents/index.d.ts +1 -0
- package/dist/agents/index.js +276 -141
- package/dist/errors/catalog.d.ts +4 -3
- package/dist/harness/defineHarness.d.ts +26 -2
- package/dist/harness/defineHarness.js +51 -2
- package/dist/index.d.ts +1 -1
- package/dist/memory/sandbox/index.js +7 -1
- package/dist/models/registry.js +45 -3
- package/dist/ports/base-model-provider.js +2 -0
- package/dist/ports/capabilities.d.ts +2 -0
- package/dist/ports/harness-context.d.ts +1 -0
- package/dist/ports/model-provider.d.ts +4 -0
- package/dist/ports/state.d.ts +6 -0
- package/dist/runtime/abort.d.ts +5 -0
- package/dist/runtime/abort.js +33 -0
- package/dist/runtime/durable.d.ts +2 -0
- package/dist/runtime/durable.js +6 -2
- package/dist/runtime/sessionDurable.d.ts +49 -0
- package/dist/runtime/sessionDurable.js +135 -0
- package/dist/runtime/steps.d.ts +19 -1
- package/dist/runtime/steps.js +21 -3
- package/dist/sandbox/index.d.ts +34 -0
- package/dist/sandbox/index.js +40 -3
- package/dist/sessions/index.d.ts +15 -2
- package/dist/sessions/index.js +212 -99
- package/dist/skills/index.js +19 -6
- package/dist/state/in-memory.d.ts +1 -0
- package/dist/state/in-memory.js +15 -0
- package/dist/telemetry/shim.js +9 -4
- package/dist/testing/durableWorkspaceStoreContract.d.ts +1 -1
- package/dist/testing/durableWorkspaceStoreContract.js +64 -28
- package/dist/tools/index.d.ts +2 -0
- package/dist/tools/index.js +15 -1
- package/dist/tools/mcp/runner.js +11 -6
- package/dist/tools/mcp/stdio.js +170 -1
- package/dist/ulid/index.d.ts +6 -1
- package/dist/ulid/index.js +31 -13
- package/dist/version.d.ts +2 -0
- package/dist/version.js +2 -0
- package/dist/workflows/index.js +7 -1
- package/dist/workspace/in-memory.d.ts +9 -10
- package/dist/workspace/in-memory.js +191 -48
- package/package.json +1 -1
- package/dist/harness/errors.d.ts +0 -62
- 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
|
|
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
|
-
|
|
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();
|
package/dist/models/registry.js
CHANGED
|
@@ -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. */
|
|
@@ -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. */
|
package/dist/ports/state.d.ts
CHANGED
|
@@ -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
|
}
|
package/dist/runtime/durable.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/dist/runtime/steps.d.ts
CHANGED
|
@@ -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;
|
package/dist/runtime/steps.js
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/dist/sandbox/index.d.ts
CHANGED
|
@@ -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
|
};
|