@purista/harness 1.2.1 → 1.2.3

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 (46) 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 +45 -4
  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.d.ts +10 -3
  9. package/dist/models/registry.js +45 -3
  10. package/dist/ports/base-model-provider.js +2 -0
  11. package/dist/ports/capabilities.d.ts +2 -0
  12. package/dist/ports/harness-context.d.ts +1 -0
  13. package/dist/ports/model-provider.d.ts +4 -0
  14. package/dist/ports/state.d.ts +6 -0
  15. package/dist/runtime/abort.d.ts +5 -0
  16. package/dist/runtime/abort.js +33 -0
  17. package/dist/runtime/durable.d.ts +2 -0
  18. package/dist/runtime/durable.js +6 -2
  19. package/dist/runtime/sessionDurable.d.ts +49 -0
  20. package/dist/runtime/sessionDurable.js +135 -0
  21. package/dist/runtime/steps.d.ts +19 -1
  22. package/dist/runtime/steps.js +21 -3
  23. package/dist/sandbox/index.d.ts +34 -0
  24. package/dist/sandbox/index.js +40 -3
  25. package/dist/sessions/index.d.ts +15 -2
  26. package/dist/sessions/index.js +336 -105
  27. package/dist/skills/index.js +19 -6
  28. package/dist/state/in-memory.d.ts +1 -0
  29. package/dist/state/in-memory.js +15 -0
  30. package/dist/telemetry/shim.js +9 -4
  31. package/dist/testing/durableWorkspaceStoreContract.d.ts +1 -1
  32. package/dist/testing/durableWorkspaceStoreContract.js +64 -28
  33. package/dist/tools/index.d.ts +2 -0
  34. package/dist/tools/index.js +15 -1
  35. package/dist/tools/mcp/runner.js +11 -6
  36. package/dist/tools/mcp/stdio.js +170 -1
  37. package/dist/ulid/index.d.ts +6 -1
  38. package/dist/ulid/index.js +31 -13
  39. package/dist/version.d.ts +2 -0
  40. package/dist/version.js +2 -0
  41. package/dist/workflows/index.js +7 -1
  42. package/dist/workspace/in-memory.d.ts +9 -10
  43. package/dist/workspace/in-memory.js +191 -48
  44. package/package.json +1 -1
  45. package/dist/harness/errors.d.ts +0 -62
  46. package/dist/harness/errors.js +0 -67
@@ -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
  };
@@ -2,6 +2,10 @@ import { createRequire } from 'node:module';
2
2
  import path from 'node:path';
3
3
  import { OperationCancelledError, OperationTimeoutError, HarnessConfigError, SandboxError, SandboxNoExecutorError } from '../errors/index.js';
4
4
  const require = createRequire(import.meta.url);
5
+ /** Returns true when a sandbox session can spawn long-lived processes. */
6
+ export function isSpawnCapableSession(session) {
7
+ return typeof session.spawn === 'function';
8
+ }
5
9
  function now() { return new Date().toISOString(); }
6
10
  function normalizePath(input) {
7
11
  if (!input.startsWith('/'))
@@ -63,7 +67,7 @@ class MemorySandboxSession {
63
67
  const relative = root === '/' ? k.slice(1) : k.slice(root.length + 1);
64
68
  if (!opts?.recursive && relative.includes('/'))
65
69
  continue;
66
- if (opts?.glob && !new RegExp(opts.glob.replaceAll('.', '\\.').replaceAll('*', '.*')).test(k))
70
+ if (opts?.glob && !globToRegExp(opts.glob).test(k))
67
71
  continue;
68
72
  out.push({ name: k.split('/').at(-1) ?? '', path: k, kind: v.kind, ...(v.kind === 'file' ? { size: v.data.byteLength } : {}) });
69
73
  }
@@ -155,11 +159,44 @@ export function bashSandbox(opts) {
155
159
  }
156
160
  };
157
161
  }
162
+ /**
163
+ * Translate a glob to a fully-anchored RegExp matched against the absolute
164
+ * path. `*`/`**` match any characters and `?` matches a single character; all
165
+ * other regex metacharacters are escaped to literals so a pattern can never
166
+ * throw a `SyntaxError` or trigger catastrophic backtracking. Anchoring both
167
+ * ends fixes the previous over-match (e.g. `*.ts` no longer matches `a.tsx`).
168
+ */
169
+ function globToRegExp(glob) {
170
+ let out = '^';
171
+ for (let i = 0; i < glob.length; i += 1) {
172
+ const char = glob[i];
173
+ if (char === '*') {
174
+ out += '.*';
175
+ if (glob[i + 1] === '*')
176
+ i += 1;
177
+ }
178
+ else if (char === '?') {
179
+ out += '.';
180
+ }
181
+ else if (/[.+^${}()|[\]\\]/.test(char)) {
182
+ out += `\\${char}`;
183
+ }
184
+ else {
185
+ out += char;
186
+ }
187
+ }
188
+ return new RegExp(`${out}$`);
189
+ }
158
190
  export function autoDetectSandbox() {
159
191
  try {
160
192
  return bashSandbox();
161
193
  }
162
- catch {
163
- return inMemorySandbox();
194
+ catch (error) {
195
+ // Only fall back to the no-executor sandbox when just-bash is absent.
196
+ // A real configuration/init error must surface, not silently downgrade.
197
+ if (error instanceof HarnessConfigError && error.meta?.reason === 'just_bash_not_installed') {
198
+ return inMemorySandbox();
199
+ }
200
+ throw error;
164
201
  }
165
202
  }
@@ -1,7 +1,8 @@
1
1
  import type { Logger } from '../logger/index.js';
2
- import type { Harness, HarnessDefaults, BuilderState, TelemetryOptions } from '../harness/defineHarness.js';
2
+ import type { RunEvent, Harness, HarnessDefaults, BuilderState, TelemetryOptions } from '../harness/defineHarness.js';
3
3
  import type { MemoryAdapter } from '../ports/memory.js';
4
- import type { HarnessInspection } from '../ports/capabilities.js';
4
+ import type { DurableRuntimeAdapter, HarnessInspection } from '../ports/capabilities.js';
5
+ import type { DurableWorkspaceStore } from '../ports/workspace.js';
5
6
  import type { Sandbox } from '../sandbox/index.js';
6
7
  import type { StateStore } from '../ports/state.js';
7
8
  import { type TelemetryShim } from '../telemetry/index.js';
@@ -13,6 +14,8 @@ type HarnessDefinition<S extends BuilderState> = {
13
14
  state: StateStore;
14
15
  sandbox: Sandbox;
15
16
  memory: MemoryAdapter;
17
+ runtime?: DurableRuntimeAdapter;
18
+ workspaceStore?: DurableWorkspaceStore;
16
19
  defaults: HarnessDefaults;
17
20
  models: NonNullable<S['models']>;
18
21
  tools: NonNullable<S['tools']>;
@@ -21,5 +24,15 @@ type HarnessDefinition<S extends BuilderState> = {
21
24
  workflows: NonNullable<S['workflows']>;
22
25
  inspection: HarnessInspection;
23
26
  };
27
+ /**
28
+ * Relay run events from an in-process run to a stream consumer.
29
+ *
30
+ * The unread events live in a bounded queue: consumed events are removed (no
31
+ * growing cursor over a shared array), and on overflow the oldest non-terminal
32
+ * unread event is dropped and counted, so a slow consumer never silently skips
33
+ * an unread event. Delivery is promise-notified rather than time-polled, so
34
+ * there is no fixed per-event latency or periodic timer.
35
+ */
36
+ export declare function relayRunEvents(run: (onEvent: (event: RunEvent) => Promise<void>) => Promise<unknown>): AsyncIterable<RunEvent>;
24
37
  export declare function createSessionHarness<S extends BuilderState>(definition: HarnessDefinition<S>): Harness<S>;
25
38
  export {};