@purista/harness 1.2.6 → 1.5.0

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 (73) hide show
  1. package/README.md +6 -0
  2. package/dist/agents/index.d.ts +7 -1
  3. package/dist/agents/index.js +56 -38
  4. package/dist/errors/catalog.d.ts +18 -2
  5. package/dist/errors/catalog.js +10 -0
  6. package/dist/eval/index.d.ts +3 -3
  7. package/dist/eval/index.js +15 -1
  8. package/dist/harness/defineHarness.d.ts +91 -1
  9. package/dist/harness/defineHarness.js +110 -1
  10. package/dist/index.d.ts +37 -17
  11. package/dist/index.js +30 -16
  12. package/dist/local/index.d.ts +36 -0
  13. package/dist/local/index.js +24 -0
  14. package/dist/local/local-sandbox.d.ts +25 -0
  15. package/dist/local/local-sandbox.js +368 -0
  16. package/dist/local/local-workspace.d.ts +56 -0
  17. package/dist/local/local-workspace.js +496 -0
  18. package/dist/local/ref-hash.d.ts +6 -0
  19. package/dist/local/ref-hash.js +9 -0
  20. package/dist/local/sqlite-storage.d.ts +106 -0
  21. package/dist/local/sqlite-storage.js +680 -0
  22. package/dist/models/adapter-utils.d.ts +52 -0
  23. package/dist/models/adapter-utils.js +81 -0
  24. package/dist/models/registry.js +28 -37
  25. package/dist/models/stream-pump.d.ts +16 -0
  26. package/dist/models/stream-pump.js +77 -0
  27. package/dist/ports/base-model-provider.d.ts +7 -1
  28. package/dist/ports/base-model-provider.js +384 -87
  29. package/dist/ports/capabilities.d.ts +16 -2
  30. package/dist/ports/context-checkpoints.d.ts +63 -0
  31. package/dist/ports/context-checkpoints.js +33 -0
  32. package/dist/ports/index.d.ts +1 -0
  33. package/dist/ports/index.js +1 -0
  34. package/dist/ports/model-provider.d.ts +94 -0
  35. package/dist/runtime/durable.d.ts +11 -0
  36. package/dist/runtime/durable.js +15 -2
  37. package/dist/runtime/sessionDurable.js +47 -21
  38. package/dist/sessions/index.d.ts +17 -6
  39. package/dist/sessions/index.js +337 -81
  40. package/dist/skills/index.d.ts +0 -2
  41. package/dist/skills/index.js +0 -8
  42. package/dist/state/in-memory.js +6 -6
  43. package/dist/telemetry/shim.js +2 -6
  44. package/dist/telemetry/span-attrs.d.ts +9 -0
  45. package/dist/telemetry/span-attrs.js +27 -0
  46. package/dist/testing/durableWorkspaceStoreContract.js +69 -0
  47. package/dist/testing/fakeLogger.d.ts +29 -0
  48. package/dist/testing/fakeLogger.js +47 -0
  49. package/dist/testing/fakeSandbox.d.ts +27 -0
  50. package/dist/testing/fakeSandbox.js +153 -0
  51. package/dist/testing/fakeStateStore.d.ts +36 -0
  52. package/dist/testing/fakeStateStore.js +66 -0
  53. package/dist/testing/index.d.ts +10 -4
  54. package/dist/testing/index.js +14 -4
  55. package/dist/testing/loggerContract.d.ts +9 -0
  56. package/dist/testing/loggerContract.js +62 -0
  57. package/dist/testing/modelProviderContract.d.ts +12 -0
  58. package/dist/testing/modelProviderContract.js +222 -0
  59. package/dist/testing/recordEvents.d.ts +3 -0
  60. package/dist/testing/recordEvents.js +8 -0
  61. package/dist/testing/stateStoreContract.js +27 -0
  62. package/dist/tools/index.js +26 -1
  63. package/dist/tools/mcp/http.d.ts +2 -0
  64. package/dist/tools/mcp/http.js +34 -21
  65. package/dist/tools/mcp/runner.d.ts +4 -0
  66. package/dist/tools/mcp/runner.js +75 -21
  67. package/dist/tools/mcp/stdio.d.ts +7 -1
  68. package/dist/tools/mcp/stdio.js +102 -23
  69. package/dist/version.d.ts +1 -1
  70. package/dist/version.js +1 -1
  71. package/dist/workspace/in-memory.d.ts +1 -0
  72. package/dist/workspace/in-memory.js +47 -12
  73. package/package.json +2 -1
@@ -0,0 +1,496 @@
1
+ import { mkdir, rm, cp, readFile, writeFile, readdir, rename, stat, realpath } from 'node:fs/promises';
2
+ import { resolve, join, dirname, sep } from 'node:path';
3
+ import { ulid } from '../ulid/index.js';
4
+ import { OperationCancelledError, WorkspaceCleanupError, WorkspaceError, WorkspaceQuotaExceededError } from '../errors/index.js';
5
+ import { sha256Hex } from './ref-hash.js';
6
+ export function createLocalWorkspaceCoordinator() {
7
+ const bindings = new Map();
8
+ const key = (runId, sessionId) => `${sessionId}\n${runId}`;
9
+ return {
10
+ bind: (runId, sessionId, workspaceRef, activePath) => bindings.set(key(runId, sessionId), { workspaceRef, activePath }),
11
+ get: (runId, sessionId) => bindings.get(key(runId, sessionId)),
12
+ unbind: (runId, sessionId) => { bindings.delete(key(runId, sessionId)); }
13
+ };
14
+ }
15
+ const DEFAULT_POLICY = {
16
+ retention: { cleanupMode: 'manual_only' },
17
+ encryption: { encryptedAtRest: false, keyScope: 'application', rotationSupported: false, metadataEncrypted: false }
18
+ };
19
+ /** Refs are always `workspace_${ulid()}`; anything else is rejected before path use (spec 22 §4). */
20
+ const WORKSPACE_REF_PATTERN = /^workspace_[A-Z0-9]+$/;
21
+ /** Host-directory durable workspace store used by localDurableExecution. */
22
+ export class LocalDirectoryWorkspaceStore {
23
+ info;
24
+ capabilities;
25
+ root;
26
+ coordinator;
27
+ /** In-process lookup caches; the persisted `meta.json` files stay authoritative. */
28
+ runIdIndex = new Map();
29
+ opKeyIndex = new Map();
30
+ telemetry;
31
+ constructor(options) {
32
+ this.root = resolve(options.root, 'workspaces');
33
+ this.coordinator = options.coordinator;
34
+ const retention = { cleanupMode: options.policy?.retention?.cleanupMode ?? DEFAULT_POLICY.retention.cleanupMode };
35
+ const encryption = {
36
+ encryptedAtRest: options.policy?.encryption?.encryptedAtRest ?? DEFAULT_POLICY.encryption.encryptedAtRest,
37
+ keyScope: options.policy?.encryption?.keyScope ?? DEFAULT_POLICY.encryption.keyScope,
38
+ rotationSupported: options.policy?.encryption?.rotationSupported ?? DEFAULT_POLICY.encryption.rotationSupported,
39
+ metadataEncrypted: options.policy?.encryption?.metadataEncrypted ?? DEFAULT_POLICY.encryption.metadataEncrypted
40
+ };
41
+ this.info = {
42
+ id: 'local_directory_workspace_store',
43
+ packageName: '@purista/harness',
44
+ capabilities: [
45
+ 'workspace_store.durable',
46
+ 'workspace_store.persistent',
47
+ 'workspace_store.checkpoint',
48
+ 'workspace_store.resume',
49
+ 'workspace_store.abort',
50
+ 'workspace_store.cleanup',
51
+ 'workspace_store.inspect',
52
+ 'workspace_store.retention'
53
+ ],
54
+ policy: {
55
+ retention,
56
+ encryption,
57
+ ...(options.policy?.quota ? { quota: options.policy.quota } : {})
58
+ }
59
+ };
60
+ this.capabilities = this.info.capabilities;
61
+ }
62
+ configureHarnessContext(context) {
63
+ this.telemetry = context.telemetry;
64
+ }
65
+ /** Drops the run→sandbox coordinator binding once a durable run is finished/disposed. */
66
+ releaseRunBinding(runId, sessionId) {
67
+ this.coordinator?.unbind(runId, sessionId);
68
+ }
69
+ async startWorkspace(opts) {
70
+ return this.workspaceSpan('start', {
71
+ 'harness.run.id': opts.runId,
72
+ 'harness.session.id': opts.sessionId,
73
+ 'harness.workspace.attempt': opts.attempt
74
+ }, async (recordAttrs) => {
75
+ throwIfAborted(opts.signal);
76
+ const replayed = await this.findPersistedOp(opts.idempotencyKey);
77
+ if (replayed) {
78
+ if (replayed.runId !== opts.runId || replayed.sessionId !== opts.sessionId) {
79
+ throw new WorkspaceError('Workspace start idempotency key reused with a different run/session.', {
80
+ reason: 'idempotency_conflict',
81
+ workspace_ref: replayed.result.workspaceRef,
82
+ run_id: opts.runId,
83
+ session_id: opts.sessionId
84
+ });
85
+ }
86
+ const handle = replayed.result;
87
+ this.coordinator?.bind(opts.runId, opts.sessionId, handle.workspaceRef, this.activePath(handle.workspaceRef));
88
+ recordAttrs({ 'harness.workspace.state': 'active', 'harness.workspace.ref_hash': sha256Hex(handle.workspaceRef) });
89
+ return handle;
90
+ }
91
+ const existing = await this.findByRun(opts.runId);
92
+ const meta = existing ?? {
93
+ workspaceRef: `workspace_${ulid()}`,
94
+ state: 'active',
95
+ runId: opts.runId,
96
+ sessionId: opts.sessionId,
97
+ attempt: opts.attempt,
98
+ createdAt: now(),
99
+ updatedAt: now(),
100
+ ...(opts.metadata ? { metadata: opts.metadata } : {}),
101
+ checkpoints: []
102
+ };
103
+ meta.state = 'active';
104
+ meta.runId = opts.runId;
105
+ meta.sessionId = opts.sessionId;
106
+ meta.attempt = opts.attempt;
107
+ meta.updatedAt = now();
108
+ await mkdir(this.activePath(meta.workspaceRef), { recursive: true });
109
+ await mkdir(join(this.activePath(meta.workspaceRef), 'workspace'), { recursive: true });
110
+ const handle = toHandle(meta);
111
+ this.persistOp(meta, opts.idempotencyKey, { kind: 'start', runId: opts.runId, sessionId: opts.sessionId, result: handle });
112
+ await this.writeMeta(meta);
113
+ this.coordinator?.bind(opts.runId, opts.sessionId, meta.workspaceRef, this.activePath(meta.workspaceRef));
114
+ recordAttrs({ 'harness.workspace.state': 'active', 'harness.workspace.ref_hash': sha256Hex(meta.workspaceRef) });
115
+ return handle;
116
+ });
117
+ }
118
+ async pauseWorkspace(opts) {
119
+ return this.workspaceSpan('pause', {
120
+ 'harness.run.id': opts.handle.runId,
121
+ 'harness.session.id': opts.handle.sessionId,
122
+ 'harness.workspace.ref_hash': sha256Hex(opts.handle.workspaceRef),
123
+ 'harness.workspace.attempt': opts.attempt,
124
+ 'harness.workspace.sequence': opts.sequence,
125
+ 'harness.workflow.step_id': opts.stepId
126
+ }, async (recordAttrs) => {
127
+ throwIfAborted(opts.signal);
128
+ const meta = await this.readMeta(opts.handle.workspaceRef);
129
+ const replay = meta.ops?.[opts.idempotencyKey];
130
+ if (replay) {
131
+ assertReplayMatches(replay, 'pause', opts.handle.runId, opts.handle.sessionId, meta.workspaceRef);
132
+ const checkpoint = replay.result;
133
+ recordAttrs({ 'harness.workspace.state': meta.state, 'harness.workspace.checkpoint_ref_hash': sha256Hex(checkpoint.checkpointRef) });
134
+ return checkpoint;
135
+ }
136
+ if (meta.state === 'aborted' || meta.state === 'cleaned')
137
+ throw new WorkspaceError('Workspace cannot be checkpointed.', { reason: meta.state === 'aborted' ? 'aborted' : 'not_found', workspace_ref: meta.workspaceRef });
138
+ const checkpointRef = `checkpoint_${opts.sequence}_${ulid()}`;
139
+ const checkpointPath = this.checkpointPath(meta.workspaceRef, checkpointRef);
140
+ await rm(checkpointPath, { recursive: true, force: true });
141
+ await mkdir(dirname(checkpointPath), { recursive: true });
142
+ await cp(this.activePath(meta.workspaceRef), checkpointPath, { recursive: true, force: true });
143
+ const sizeBytes = await directorySize(checkpointPath);
144
+ const maxWorkspaceBytes = this.info.policy.quota?.maxWorkspaceBytes;
145
+ if (maxWorkspaceBytes !== undefined && sizeBytes > maxWorkspaceBytes) {
146
+ await rm(checkpointPath, { recursive: true, force: true });
147
+ this.telemetry?.recordCounter('harness.workspace_store.quota.exceeded', 1, {
148
+ 'harness.workspace.adapter': this.info.id,
149
+ 'harness.workspace.operation': 'pause',
150
+ 'harness.workspace_store.quota': 'maxWorkspaceBytes'
151
+ });
152
+ recordAttrs({ 'harness.workspace_store.quota': 'maxWorkspaceBytes' });
153
+ throw new WorkspaceQuotaExceededError('Workspace byte quota exceeded.', {
154
+ quota: 'maxWorkspaceBytes',
155
+ limit: maxWorkspaceBytes,
156
+ actual: sizeBytes,
157
+ workspace_ref: meta.workspaceRef
158
+ });
159
+ }
160
+ const checkpoint = {
161
+ workspaceRef: meta.workspaceRef,
162
+ checkpointRef,
163
+ snapshotRef: checkpointRef,
164
+ runId: meta.runId,
165
+ sessionId: meta.sessionId,
166
+ stepId: opts.stepId,
167
+ sequence: opts.sequence,
168
+ attempt: opts.attempt,
169
+ committedAt: now(),
170
+ sizeBytes,
171
+ metadata: { reason: opts.reason }
172
+ };
173
+ meta.state = 'paused';
174
+ meta.updatedAt = checkpoint.committedAt;
175
+ meta.checkpoints.push(checkpoint);
176
+ this.persistOp(meta, opts.idempotencyKey, { kind: 'pause', runId: meta.runId, sessionId: meta.sessionId, result: checkpoint });
177
+ await this.writeMeta(meta);
178
+ this.telemetry?.recordHistogram('harness.workspace.bytes', sizeBytes, {
179
+ 'harness.workspace.adapter': this.info.id,
180
+ 'harness.workspace.operation': 'pause'
181
+ });
182
+ recordAttrs({ 'harness.workspace.state': 'paused', 'harness.workspace.checkpoint_ref_hash': sha256Hex(checkpointRef) });
183
+ return checkpoint;
184
+ });
185
+ }
186
+ async resumeWorkspace(opts) {
187
+ return this.workspaceSpan('resume', {
188
+ 'harness.run.id': opts.runId,
189
+ 'harness.session.id': opts.sessionId,
190
+ 'harness.workspace.ref_hash': sha256Hex(opts.workspaceRef),
191
+ 'harness.workspace.attempt': opts.attempt,
192
+ ...(opts.checkpointRef ? { 'harness.workspace.checkpoint_ref_hash': sha256Hex(opts.checkpointRef) } : {})
193
+ }, async (recordAttrs) => {
194
+ throwIfAborted(opts.signal);
195
+ const meta = await this.readMeta(opts.workspaceRef);
196
+ const replay = meta.ops?.[opts.idempotencyKey];
197
+ if (replay) {
198
+ assertReplayMatches(replay, 'resume', opts.runId, opts.sessionId, meta.workspaceRef);
199
+ const handle = replay.result;
200
+ this.coordinator?.bind(opts.runId, opts.sessionId, meta.workspaceRef, this.activePath(meta.workspaceRef));
201
+ recordAttrs({ 'harness.workspace.state': meta.state });
202
+ return handle;
203
+ }
204
+ if (meta.state === 'aborted')
205
+ throw new WorkspaceError('Workspace was aborted.', { reason: 'aborted', workspace_ref: opts.workspaceRef });
206
+ if (meta.state === 'cleaned')
207
+ throw new WorkspaceError('Workspace was cleaned.', { reason: 'not_found', workspace_ref: opts.workspaceRef });
208
+ const checkpoint = opts.checkpointRef ? meta.checkpoints.find((item) => item.checkpointRef === opts.checkpointRef) : meta.checkpoints.at(-1);
209
+ if (opts.checkpointRef && !checkpoint)
210
+ throw new WorkspaceError('Workspace checkpoint not found.', { reason: 'missing_checkpoint', workspace_ref: opts.workspaceRef, checkpoint_ref: opts.checkpointRef });
211
+ if (checkpoint) {
212
+ await rm(this.activePath(meta.workspaceRef), { recursive: true, force: true });
213
+ await cp(this.checkpointPath(meta.workspaceRef, checkpoint.checkpointRef), this.activePath(meta.workspaceRef), { recursive: true, force: true });
214
+ }
215
+ await mkdir(join(this.activePath(meta.workspaceRef), 'workspace'), { recursive: true });
216
+ meta.state = 'active';
217
+ meta.runId = opts.runId;
218
+ meta.sessionId = opts.sessionId;
219
+ meta.attempt = opts.attempt;
220
+ meta.updatedAt = now();
221
+ const handle = toHandle(meta);
222
+ this.persistOp(meta, opts.idempotencyKey, { kind: 'resume', runId: opts.runId, sessionId: opts.sessionId, result: handle });
223
+ await this.writeMeta(meta);
224
+ this.coordinator?.bind(opts.runId, opts.sessionId, meta.workspaceRef, this.activePath(meta.workspaceRef));
225
+ recordAttrs({ 'harness.workspace.state': 'active' });
226
+ return handle;
227
+ });
228
+ }
229
+ async abortWorkspace(opts) {
230
+ return this.workspaceSpan('abort', {
231
+ 'harness.run.id': opts.runId,
232
+ 'harness.session.id': opts.sessionId,
233
+ 'harness.workspace.ref_hash': sha256Hex(opts.workspaceRef)
234
+ }, async (recordAttrs) => {
235
+ throwIfAborted(opts.signal);
236
+ const meta = await this.readMeta(opts.workspaceRef);
237
+ const replay = meta.ops?.[opts.idempotencyKey];
238
+ if (replay) {
239
+ assertReplayMatches(replay, 'abort', opts.runId, opts.sessionId, meta.workspaceRef);
240
+ recordAttrs({ 'harness.workspace.state': 'aborted' });
241
+ this.coordinator?.unbind(opts.runId, opts.sessionId);
242
+ return replay.result;
243
+ }
244
+ meta.state = 'aborted';
245
+ meta.updatedAt = now();
246
+ const result = { workspaceRef: opts.workspaceRef, state: 'aborted', abortedAt: meta.updatedAt };
247
+ this.persistOp(meta, opts.idempotencyKey, { kind: 'abort', runId: opts.runId, sessionId: opts.sessionId, result });
248
+ await this.writeMeta(meta);
249
+ this.coordinator?.unbind(opts.runId, opts.sessionId);
250
+ recordAttrs({ 'harness.workspace.state': 'aborted' });
251
+ return result;
252
+ });
253
+ }
254
+ async cleanupWorkspace(opts) {
255
+ return this.workspaceSpan('cleanup', {
256
+ 'harness.workspace.ref_hash': sha256Hex(opts.workspaceRef),
257
+ 'harness.workspace_store.cleanup.reason': opts.reason
258
+ }, async (recordAttrs) => {
259
+ throwIfAborted(opts.signal);
260
+ const root = this.workspacePath(opts.workspaceRef);
261
+ try {
262
+ // Realpath jail: only delete when the addressed directory truly resolves
263
+ // inside `<root>/workspaces` (spec 22 §4).
264
+ const target = await assertInsideRealpath(this.root, root);
265
+ if (target)
266
+ await rm(target, { recursive: true, force: true });
267
+ }
268
+ catch (error) {
269
+ this.telemetry?.recordCounter('harness.workspace_store.cleanup.failures', 1, {
270
+ 'harness.workspace.adapter': this.info.id,
271
+ 'harness.workspace.operation': 'cleanup',
272
+ 'harness.workspace_store.cleanup.reason': opts.reason,
273
+ 'error.type': error instanceof Error ? error.name : 'unknown'
274
+ });
275
+ if (error instanceof WorkspaceError)
276
+ throw error;
277
+ throw new WorkspaceCleanupError('Workspace cleanup failed.', { reason: 'backend_failure', workspace_ref: opts.workspaceRef }, error);
278
+ }
279
+ this.evictFromIndexes(opts.workspaceRef);
280
+ recordAttrs({ 'harness.workspace.state': 'cleaned' });
281
+ return { workspaceRef: opts.workspaceRef, state: 'cleaned', completedAt: now() };
282
+ });
283
+ }
284
+ async inspectWorkspace(opts) {
285
+ return this.workspaceSpan('inspect', {
286
+ ...(opts.workspaceRef ? { 'harness.workspace.ref_hash': sha256Hex(opts.workspaceRef) } : {}),
287
+ ...(opts.checkpointRef ? { 'harness.workspace.checkpoint_ref_hash': sha256Hex(opts.checkpointRef) } : {})
288
+ }, async (recordAttrs) => {
289
+ throwIfAborted(opts.signal);
290
+ const workspaceRef = opts.workspaceRef ?? await this.findRefByCheckpoint(opts.checkpointRef);
291
+ const meta = await this.readMeta(workspaceRef);
292
+ recordAttrs({ 'harness.workspace.state': meta.state, 'harness.workspace.ref_hash': sha256Hex(workspaceRef) });
293
+ return {
294
+ workspaceRef: meta.workspaceRef,
295
+ state: meta.state,
296
+ checkpoints: meta.checkpoints,
297
+ ...(meta.checkpoints.at(-1) ? { currentCheckpointRef: meta.checkpoints.at(-1).checkpointRef } : {}),
298
+ ...(this.info.policy.retention ? { retention: this.info.policy.retention } : {}),
299
+ ...(this.info.policy.quota ? { quota: this.info.policy.quota } : {}),
300
+ ...(this.info.policy.encryption ? { encryption: this.info.policy.encryption } : {}),
301
+ createdAt: meta.createdAt,
302
+ updatedAt: meta.updatedAt,
303
+ ...(meta.metadata ? { metadata: meta.metadata } : {})
304
+ };
305
+ });
306
+ }
307
+ workspacePath(workspaceRef) {
308
+ if (!WORKSPACE_REF_PATTERN.test(workspaceRef)) {
309
+ throw new WorkspaceError('Workspace reference is invalid.', { reason: 'invalid_reference', workspace_ref: workspaceRef });
310
+ }
311
+ return join(this.root, workspaceRef);
312
+ }
313
+ activePath(workspaceRef) { return join(this.workspacePath(workspaceRef), 'active'); }
314
+ checkpointPath(workspaceRef, checkpointRef) { return join(this.workspacePath(workspaceRef), 'checkpoints', checkpointRef); }
315
+ metaPath(workspaceRef) { return join(this.workspacePath(workspaceRef), 'meta.json'); }
316
+ async readMeta(workspaceRef) {
317
+ const path = this.metaPath(workspaceRef);
318
+ try {
319
+ return JSON.parse(await readFile(path, 'utf8'));
320
+ }
321
+ catch (error) {
322
+ if (error instanceof WorkspaceError)
323
+ throw error;
324
+ throw new WorkspaceError('Workspace not found.', { reason: 'not_found', workspace_ref: workspaceRef }, error);
325
+ }
326
+ }
327
+ /** Crash-atomic meta write: temp file plus rename (spec 21 §9 pause-failure semantics). */
328
+ async writeMeta(meta) {
329
+ await mkdir(this.workspacePath(meta.workspaceRef), { recursive: true });
330
+ const path = this.metaPath(meta.workspaceRef);
331
+ const tmp = `${path}.tmp`;
332
+ await writeFile(tmp, JSON.stringify(meta, null, 2));
333
+ await rename(tmp, path);
334
+ this.runIdIndex.set(meta.runId, meta.workspaceRef);
335
+ for (const key of Object.keys(meta.ops ?? {}))
336
+ this.opKeyIndex.set(key, meta.workspaceRef);
337
+ }
338
+ persistOp(meta, key, op) {
339
+ meta.ops = { ...meta.ops, [key]: op };
340
+ }
341
+ evictFromIndexes(workspaceRef) {
342
+ for (const [runId, ref] of this.runIdIndex) {
343
+ if (ref === workspaceRef)
344
+ this.runIdIndex.delete(runId);
345
+ }
346
+ for (const [key, ref] of this.opKeyIndex) {
347
+ if (ref === workspaceRef)
348
+ this.opKeyIndex.delete(key);
349
+ }
350
+ }
351
+ async findPersistedOp(idempotencyKey) {
352
+ const indexed = this.opKeyIndex.get(idempotencyKey);
353
+ if (indexed) {
354
+ const meta = await this.readMeta(indexed).catch(() => undefined);
355
+ const op = meta?.ops?.[idempotencyKey];
356
+ if (op)
357
+ return op;
358
+ this.opKeyIndex.delete(idempotencyKey);
359
+ }
360
+ for (const meta of await this.scanMetas()) {
361
+ const op = meta.ops?.[idempotencyKey];
362
+ if (op) {
363
+ this.opKeyIndex.set(idempotencyKey, meta.workspaceRef);
364
+ return op;
365
+ }
366
+ }
367
+ return undefined;
368
+ }
369
+ async findByRun(runId) {
370
+ const indexed = this.runIdIndex.get(runId);
371
+ if (indexed) {
372
+ const meta = await this.readMeta(indexed).catch(() => undefined);
373
+ if (meta && meta.runId === runId && meta.state !== 'cleaned')
374
+ return meta;
375
+ this.runIdIndex.delete(runId);
376
+ }
377
+ for (const meta of await this.scanMetas()) {
378
+ if (meta.runId === runId && meta.state !== 'cleaned') {
379
+ this.runIdIndex.set(runId, meta.workspaceRef);
380
+ return meta;
381
+ }
382
+ }
383
+ return undefined;
384
+ }
385
+ async findRefByCheckpoint(checkpointRef) {
386
+ if (!checkpointRef)
387
+ throw new WorkspaceError('workspaceRef or checkpointRef is required.', { reason: 'invalid_reference' });
388
+ for (const meta of await this.scanMetas()) {
389
+ if (meta.checkpoints.some((checkpoint) => checkpoint.checkpointRef === checkpointRef))
390
+ return meta.workspaceRef;
391
+ }
392
+ throw new WorkspaceError('Workspace checkpoint not found.', { reason: 'missing_checkpoint', checkpoint_ref: checkpointRef });
393
+ }
394
+ async scanMetas() {
395
+ await mkdir(this.root, { recursive: true });
396
+ const metas = [];
397
+ for (const name of await readdir(this.root)) {
398
+ if (!WORKSPACE_REF_PATTERN.test(name))
399
+ continue;
400
+ const meta = await this.readMeta(name).catch(() => undefined);
401
+ if (meta)
402
+ metas.push(meta);
403
+ }
404
+ return metas;
405
+ }
406
+ async workspaceSpan(operation, attrs, fn) {
407
+ const merged = {
408
+ 'harness.workspace.adapter': this.info.id,
409
+ 'harness.workspace.operation': operation,
410
+ 'harness.workspace.persistent': true,
411
+ ...attrs
412
+ };
413
+ const started = Date.now();
414
+ const run = async (span) => {
415
+ const recordAttrs = (extra) => {
416
+ Object.assign(merged, extra);
417
+ span?.setAttributes(definedAttrs(extra));
418
+ };
419
+ try {
420
+ const result = await fn(recordAttrs);
421
+ this.telemetry?.recordCounter('harness.workspace.operations', 1, merged);
422
+ return result;
423
+ }
424
+ finally {
425
+ this.telemetry?.recordHistogram('harness.workspace.operation.duration', (Date.now() - started) / 1000, merged);
426
+ }
427
+ };
428
+ return this.telemetry ? this.telemetry.span(`harness.workspace.${operation}`, merged, (span) => run(span)) : run();
429
+ }
430
+ }
431
+ /**
432
+ * Guards a persisted-op replay: a stored entry may only replay when it belongs
433
+ * to the same operation kind and run/session identity, otherwise the reused key
434
+ * is an `idempotency_conflict` (spec 21 §9, spec 22 §4).
435
+ */
436
+ function assertReplayMatches(op, kind, runId, sessionId, workspaceRef) {
437
+ if (op.kind !== kind || op.runId !== runId || op.sessionId !== sessionId) {
438
+ throw new WorkspaceError(`Workspace ${kind} idempotency key reused with a different operation or run/session.`, {
439
+ reason: 'idempotency_conflict',
440
+ workspace_ref: workspaceRef,
441
+ run_id: runId,
442
+ session_id: sessionId
443
+ });
444
+ }
445
+ }
446
+ function definedAttrs(attrs) {
447
+ const out = {};
448
+ for (const [key, value] of Object.entries(attrs)) {
449
+ if (value !== undefined)
450
+ out[key] = value;
451
+ }
452
+ return out;
453
+ }
454
+ function toHandle(meta) {
455
+ return {
456
+ workspaceRef: meta.workspaceRef,
457
+ runId: meta.runId,
458
+ sessionId: meta.sessionId,
459
+ state: 'active',
460
+ startedAt: meta.updatedAt,
461
+ attempt: meta.attempt,
462
+ ...(meta.metadata ? { metadata: meta.metadata } : {})
463
+ };
464
+ }
465
+ function now() { return new Date().toISOString(); }
466
+ function throwIfAborted(signal) {
467
+ if (signal?.aborted)
468
+ throw new OperationCancelledError('Workspace operation was cancelled.', { scope: 'workspace' });
469
+ }
470
+ async function directorySize(root) {
471
+ let total = 0;
472
+ for (const entry of await readdir(root, { withFileTypes: true })) {
473
+ const full = join(root, entry.name);
474
+ total += entry.isDirectory() ? await directorySize(full) : (await stat(full)).size;
475
+ }
476
+ return total;
477
+ }
478
+ /**
479
+ * Resolves the cleanup target through `realpath` and verifies it stays inside
480
+ * the store root. Returns `undefined` when the target no longer exists.
481
+ */
482
+ async function assertInsideRealpath(root, target) {
483
+ const rootReal = await realpath(root).catch(() => undefined);
484
+ if (!rootReal)
485
+ return undefined;
486
+ const targetReal = await realpath(target).catch(() => undefined);
487
+ if (!targetReal)
488
+ return undefined;
489
+ if (targetReal !== rootReal && !targetReal.startsWith(`${rootReal}${sep}`)) {
490
+ throw new WorkspaceError('Workspace path escaped local root.', { reason: 'invalid_reference' });
491
+ }
492
+ return targetReal;
493
+ }
494
+ export function localDirectoryWorkspaceStore(options) {
495
+ return new LocalDirectoryWorkspaceStore(options);
496
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * SHA-256 hex digest used for privacy-safe telemetry reference attributes
3
+ * (`harness.workspace.ref_hash`, `harness.context_checkpoint.ref_hash`, ...).
4
+ * Raw refs stay in return values and persisted records only (spec 14, 21 §15).
5
+ */
6
+ export declare function sha256Hex(value: string): string;
@@ -0,0 +1,9 @@
1
+ import { createHash } from 'node:crypto';
2
+ /**
3
+ * SHA-256 hex digest used for privacy-safe telemetry reference attributes
4
+ * (`harness.workspace.ref_hash`, `harness.context_checkpoint.ref_hash`, ...).
5
+ * Raw refs stay in return values and persisted records only (spec 14, 21 §15).
6
+ */
7
+ export function sha256Hex(value) {
8
+ return createHash('sha256').update(value).digest('hex');
9
+ }
@@ -0,0 +1,106 @@
1
+ import type { Message, PersistedRunEvent, RunRecord, SessionRecord } from '../models/state.js';
2
+ import type { ContextCheckpoint, ContextCheckpointQuery, ContextCheckpointRef, ContextCheckpointStore, ContextCheckpointStoreInfo } from '../ports/context-checkpoints.js';
3
+ import type { HarnessAdapterContext } from '../ports/harness-context.js';
4
+ import type { FinishRunPatch, StateStore } from '../ports/state.js';
5
+ import { type DurableRunLease, type DurableRunStart, type DurableRuntime, type RunCheckpoint } from '../runtime/durable.js';
6
+ export interface SqliteDurableRuntimeOptions {
7
+ /** SQLite database file. */
8
+ file: string;
9
+ /** Lease takeover window for crashed workers. Default: `120_000`. */
10
+ leaseTtlMs?: number;
11
+ /** Injectable epoch-millisecond clock for lease tests. Default: `Date.now`. */
12
+ now?: () => number;
13
+ }
14
+ export interface SqliteContextCheckpointStoreOptions {
15
+ /** SQLite database file. */
16
+ file: string;
17
+ }
18
+ export interface SqliteStateStoreOptions {
19
+ /** SQLite database file. */
20
+ file: string;
21
+ }
22
+ /** SQLite-backed local storage implementing StateStore, DurableRuntime, and ContextCheckpointStore. */
23
+ export declare class SqliteHarnessStorage implements StateStore, DurableRuntime, ContextCheckpointStore {
24
+ readonly capabilities: readonly ["runtime.checkpoint", "runtime.retry", "runtime.distributed_lock", "runtime.resume_from_checkpoint", "runtime.workspace_checkpoint", "runtime.persistent", "context_checkpoint.write", "context_checkpoint.read", "context_checkpoint.list", "context_checkpoint.delete", "context_checkpoint.persistent"];
25
+ readonly id = "sqlite_runtime";
26
+ readonly info: ContextCheckpointStoreInfo;
27
+ private readonly db;
28
+ private readonly leaseTtlMs;
29
+ private readonly clock;
30
+ /**
31
+ * In-process serialization for SQLite transactions: one connection allows a
32
+ * single open transaction, so every transactional entry point goes through
33
+ * this mutex before issuing `begin immediate`.
34
+ */
35
+ private readonly dbLock;
36
+ private readonly sessionLocks;
37
+ private readonly statements;
38
+ private closed;
39
+ private logger;
40
+ private telemetry;
41
+ constructor(options: SqliteDurableRuntimeOptions);
42
+ configureHarnessContext(context: HarnessAdapterContext): void;
43
+ getSession(id: string): Promise<SessionRecord | undefined>;
44
+ upsertSession(record: SessionRecord): Promise<void>;
45
+ closeSession(id: string): Promise<void>;
46
+ appendMessages(sessionId: string, messages: Message[]): Promise<void>;
47
+ listMessages(sessionId: string, opts?: {
48
+ limit?: number;
49
+ before?: string;
50
+ }): Promise<Message[]>;
51
+ clearMessages(sessionId: string): Promise<void>;
52
+ replaceMessages(sessionId: string, messages: Message[]): Promise<void>;
53
+ createRun(record: RunRecord): Promise<void>;
54
+ finishRun(runId: string, patch: FinishRunPatch): Promise<void>;
55
+ getRun(runId: string): Promise<RunRecord | undefined>;
56
+ listRuns(sessionId: string, opts?: {
57
+ limit?: number;
58
+ before?: string;
59
+ }): Promise<RunRecord[]>;
60
+ appendEvents(runId: string, events: PersistedRunEvent[]): Promise<void>;
61
+ listEvents(runId: string, opts?: {
62
+ limit?: number;
63
+ after?: string;
64
+ }): Promise<PersistedRunEvent[]>;
65
+ startRun(record: DurableRunStart): Promise<DurableRunLease>;
66
+ loadCheckpoint(runId: string): Promise<RunCheckpoint | undefined>;
67
+ commitCheckpoint(checkpoint: RunCheckpoint): Promise<void>;
68
+ withSessionLock<T>(sessionId: string, fn: () => Promise<T>): Promise<T>;
69
+ write(checkpoint: ContextCheckpoint, opts?: {
70
+ signal?: AbortSignal;
71
+ }): Promise<void>;
72
+ list(query: ContextCheckpointQuery): Promise<readonly ContextCheckpoint[]>;
73
+ read(ref: ContextCheckpointRef): Promise<ContextCheckpoint | undefined>;
74
+ delete(ref: ContextCheckpointRef): Promise<void>;
75
+ close(): Promise<void>;
76
+ private migrate;
77
+ private stmt;
78
+ private nowIso;
79
+ /**
80
+ * Runs a synchronous statement batch inside a single SQLite transaction.
81
+ * The in-process mutex guarantees only one open transaction per connection;
82
+ * the callback must stay synchronous so the transaction never spans an await.
83
+ */
84
+ private transaction;
85
+ private loadRun;
86
+ private loadDurableRun;
87
+ private assertLeaseAvailable;
88
+ private toLease;
89
+ private rowToSession;
90
+ private rowToMessage;
91
+ private rowToRun;
92
+ private rowToCheckpoint;
93
+ private rowToContextCheckpoint;
94
+ private runtimeSpan;
95
+ private contextSpan;
96
+ private operationSpan;
97
+ }
98
+ export declare function sqliteDurableRuntime(options: SqliteDurableRuntimeOptions): DurableRuntime & {
99
+ close(): Promise<void>;
100
+ };
101
+ export declare function sqliteStateStore(options: SqliteStateStoreOptions): StateStore & {
102
+ close(): Promise<void>;
103
+ };
104
+ export declare function sqliteContextCheckpointStore(options: SqliteContextCheckpointStoreOptions): ContextCheckpointStore & {
105
+ close(): Promise<void>;
106
+ };