@purista/harness 1.1.0 → 1.2.1

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.
@@ -1,6 +1,7 @@
1
1
  import type { AdapterCapability } from '../ports/capabilities.js';
2
2
  import type { JsonValue } from '../models/json.js';
3
3
  import type { RunStatus, SerializedError } from '../models/state.js';
4
+ import type { DurableReplayCheckpoint } from '../ports/workspace.js';
4
5
  /** Non-terminal run status used while durable work can still be resumed. */
5
6
  export type DurableActiveRunStatus = 'running';
6
7
  /** Terminal run statuses that must never be resumed by a durable runtime. */
@@ -67,6 +68,8 @@ export interface RunCheckpoint {
67
68
  readonly sequence: number;
68
69
  /** JSON-serializable checkpoint payload. */
69
70
  readonly output?: JsonValue;
71
+ /** Optional durable workspace replay checkpoint linked to this runtime checkpoint. */
72
+ readonly replay?: DurableReplayCheckpoint;
70
73
  /** Adapter-neutral checkpoint metadata. */
71
74
  readonly metadata?: Record<string, JsonValue>;
72
75
  /** ISO timestamp for the commit. */
@@ -39,7 +39,8 @@ class InMemoryDurableRuntime {
39
39
  'runtime.checkpoint',
40
40
  'runtime.retry',
41
41
  'runtime.distributed_lock',
42
- 'runtime.resume_from_checkpoint'
42
+ 'runtime.resume_from_checkpoint',
43
+ 'runtime.workspace_checkpoint'
43
44
  ];
44
45
  runs = new Map();
45
46
  runLeases = new Map();
@@ -1,8 +1,9 @@
1
1
  import type { JsonValue } from '../models/json.js';
2
- import type { ResolvedSkill, SkillDefinition } from '../harness/defineHarness.js';
2
+ import type { DiscoveredSkills, DiscoverSkillsOptions, ResolvedSkill, SkillDefinition } from '../harness/defineHarness.js';
3
3
  import type { SandboxSession } from '../sandbox/index.js';
4
4
  export declare function loadSkillsSync(skills: Record<string, SkillDefinition>): Record<string, ResolvedSkill>;
5
5
  export declare function loadSkills(skills: Record<string, SkillDefinition>): Promise<Record<string, ResolvedSkill>>;
6
6
  export declare function mountSkillsOnce(session: SandboxSession, mounted: Set<string>, skills: Record<string, ResolvedSkill>, skillIds: readonly string[]): Promise<void>;
7
7
  export declare function buildSkillIndex(skills: Record<string, ResolvedSkill>, ids: readonly string[]): string;
8
+ export declare function discoverSkills(options?: DiscoverSkillsOptions): Promise<DiscoveredSkills>;
8
9
  export declare function assertSerializable(value: unknown): asserts value is JsonValue;
@@ -1,44 +1,177 @@
1
1
  import fs from 'node:fs';
2
2
  import fsp from 'node:fs/promises';
3
+ import os from 'node:os';
3
4
  import path from 'node:path';
5
+ import { parseDocument } from 'yaml';
4
6
  import { SkillManifestError, SkillNotFoundError } from '../errors/index.js';
5
- function parseFrontmatter(content) {
6
- if (!content.startsWith('---\n'))
7
- throw new SkillManifestError('Invalid SKILL.md frontmatter', { reason: 'invalid_frontmatter', directory: '' });
8
- const end = content.indexOf('\n---\n', 4);
9
- if (end < 0)
10
- throw new SkillManifestError('Invalid SKILL.md frontmatter', { reason: 'invalid_frontmatter', directory: '' });
11
- const raw = content.slice(4, end);
12
- const body = content.slice(end + 5);
13
- const frontmatter = {};
14
- for (const line of raw.split('\n')) {
15
- const idx = line.indexOf(':');
16
- if (idx < 0)
17
- continue;
18
- frontmatter[line.slice(0, idx).trim()] = line.slice(idx + 1).trim();
7
+ const skillNamePattern = /^(?!-)(?!.*--)[a-z0-9-]{1,64}(?<!-)$/;
8
+ const skippedDirectories = new Set(['.git', 'node_modules', 'dist', 'build', '.next', '.astro']);
9
+ function diagnostic(code, message, opts = {}) {
10
+ return {
11
+ level: opts.level ?? 'error',
12
+ code,
13
+ message,
14
+ ...(opts.skillName ? { skillName: opts.skillName } : {}),
15
+ ...(opts.directory ? { directory: opts.directory } : {}),
16
+ ...(opts.source ? { source: opts.source } : {})
17
+ };
18
+ }
19
+ function throwManifest(diag, skillId, cause) {
20
+ throw new SkillManifestError(diag.message, {
21
+ reason: diag.code,
22
+ directory: diag.directory ?? '',
23
+ ...(skillId ? { skill_id: skillId } : {}),
24
+ ...(diag.source ? { source: diag.source } : {})
25
+ }, cause);
26
+ }
27
+ function extractFrontmatter(content, directory) {
28
+ if (!content.startsWith('---\n')) {
29
+ throwManifest(diagnostic('invalid_frontmatter', 'SKILL.md must start with YAML frontmatter.', { directory }));
30
+ }
31
+ const end = content.indexOf('\n---', 4);
32
+ if (end < 0) {
33
+ throwManifest(diagnostic('invalid_frontmatter', 'SKILL.md frontmatter is not terminated.', { directory }));
34
+ }
35
+ return content.slice(4, end);
36
+ }
37
+ function quoteColonScalars(raw) {
38
+ return raw.split('\n').map((line) => {
39
+ const match = /^([A-Za-z0-9_-]+):\s*(.+:.+)$/.exec(line);
40
+ if (!match)
41
+ return line;
42
+ const key = match[1] ?? '';
43
+ const value = match[2] ?? '';
44
+ const trimmed = value.trim();
45
+ if (trimmed.startsWith('"')
46
+ || trimmed.startsWith("'")
47
+ || trimmed.startsWith('|')
48
+ || trimmed.startsWith('>')
49
+ || trimmed.startsWith('{')
50
+ || trimmed.startsWith('[')) {
51
+ return line;
52
+ }
53
+ return `${key}: ${JSON.stringify(trimmed)}`;
54
+ }).join('\n');
55
+ }
56
+ function parseYamlFrontmatter(raw, mode, directory) {
57
+ const first = parseDocument(raw, { strict: true });
58
+ if (!first.errors.length)
59
+ return { value: first.toJSON(), diagnostics: [] };
60
+ if (mode === 'lenient') {
61
+ const retried = parseDocument(quoteColonScalars(raw), { strict: true });
62
+ if (!retried.errors.length) {
63
+ return {
64
+ value: retried.toJSON(),
65
+ diagnostics: [diagnostic('invalid_frontmatter', 'Lenient skill parsing repaired YAML scalar quoting.', { level: 'warn', directory })]
66
+ };
67
+ }
19
68
  }
20
- return { frontmatter, body };
69
+ throwManifest(diagnostic('invalid_frontmatter', 'Invalid SKILL.md YAML frontmatter.', { directory }), undefined, first.errors[0]);
70
+ }
71
+ function asRecord(value) {
72
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
73
+ }
74
+ function validateFrontmatter(value, mode, directory, expectedName, source) {
75
+ const data = asRecord(value);
76
+ const diagnostics = [];
77
+ const nameValue = data['name'];
78
+ const name = typeof nameValue === 'string' ? nameValue.trim() : '';
79
+ if (!skillNamePattern.test(name)) {
80
+ const diag = diagnostic('invalid_name', 'Skill name must be 1-64 lowercase ASCII letters, numbers, or hyphens with no leading, trailing, or consecutive hyphens.', { directory, source, skillName: name });
81
+ if (mode === 'strict')
82
+ throwManifest(diag, expectedName);
83
+ diagnostics.push(diag);
84
+ return undefined;
85
+ }
86
+ const descriptionValue = data['description'];
87
+ const description = typeof descriptionValue === 'string' ? descriptionValue.trim() : '';
88
+ if (description.length < 1 || description.length > 1024) {
89
+ const diag = diagnostic('missing_description', 'Skill description is required and must be 1-1024 characters.', { directory, source, skillName: name });
90
+ if (mode === 'strict')
91
+ throwManifest(diag, expectedName);
92
+ diagnostics.push(diag);
93
+ return undefined;
94
+ }
95
+ const parentName = path.basename(directory);
96
+ if (expectedName && expectedName !== name) {
97
+ const diag = diagnostic('name_mismatch', `Skill key "${expectedName}" must match frontmatter name "${name}".`, { directory, source, skillName: name, level: mode === 'strict' ? 'error' : 'warn' });
98
+ if (mode === 'strict')
99
+ throwManifest(diag, expectedName);
100
+ diagnostics.push(diag);
101
+ }
102
+ else if (!expectedName && parentName !== name) {
103
+ const diag = diagnostic('name_mismatch', `Skill directory "${parentName}" does not match frontmatter name "${name}".`, { directory, source, skillName: name, level: mode === 'strict' ? 'error' : 'warn' });
104
+ if (mode === 'strict')
105
+ throwManifest(diag, name);
106
+ diagnostics.push(diag);
107
+ }
108
+ const metadata = asRecord(data['metadata']);
109
+ const metadataOut = {};
110
+ for (const [key, val] of Object.entries(metadata)) {
111
+ if (typeof val === 'string')
112
+ metadataOut[key] = val;
113
+ }
114
+ return {
115
+ frontmatter: {
116
+ name,
117
+ description,
118
+ ...(typeof data['license'] === 'string' && data['license'].trim() ? { license: data['license'].trim() } : {}),
119
+ ...(typeof data['compatibility'] === 'string' && data['compatibility'].trim() ? { compatibility: data['compatibility'].trim() } : {}),
120
+ ...(Object.keys(metadataOut).length ? { metadata: metadataOut } : {}),
121
+ ...(typeof data['allowed-tools'] === 'string' && data['allowed-tools'].trim() ? { 'allowed-tools': data['allowed-tools'].trim() } : {})
122
+ },
123
+ diagnostics
124
+ };
125
+ }
126
+ function readSkill(directory, mode, expectedName, source) {
127
+ const stat = fs.existsSync(directory) ? fs.statSync(directory) : null;
128
+ if (!stat?.isDirectory()) {
129
+ const diag = diagnostic('directory_missing', 'Skill directory is missing.', { directory, source, skillName: expectedName });
130
+ if (mode === 'strict')
131
+ throwManifest(diag, expectedName);
132
+ return undefined;
133
+ }
134
+ const skillPath = path.resolve(directory, 'SKILL.md');
135
+ if (!fs.existsSync(skillPath)) {
136
+ const diag = diagnostic('missing_skill_md', 'Skill directory must contain SKILL.md.', { directory, source, skillName: expectedName });
137
+ if (mode === 'strict')
138
+ throwManifest(diag, expectedName);
139
+ return undefined;
140
+ }
141
+ const content = fs.readFileSync(skillPath, 'utf8');
142
+ const raw = extractFrontmatter(content, directory);
143
+ const parsed = parseYamlFrontmatter(raw, mode, directory);
144
+ const checked = validateFrontmatter(parsed.value, mode, directory, expectedName, source);
145
+ if (!checked)
146
+ return undefined;
147
+ const frontmatter = checked.frontmatter;
148
+ return {
149
+ name: frontmatter.name,
150
+ description: frontmatter.description,
151
+ directory: path.resolve(directory),
152
+ skillPath,
153
+ location: skillPath,
154
+ mountPath: `/skills/${frontmatter.name}`,
155
+ ...(frontmatter.license ? { license: frontmatter.license } : {}),
156
+ ...(frontmatter.compatibility ? { compatibility: frontmatter.compatibility } : {}),
157
+ ...(frontmatter.metadata ? { metadata: frontmatter.metadata } : {}),
158
+ ...(frontmatter['allowed-tools'] ? { allowedTools: frontmatter['allowed-tools'] } : {}),
159
+ trust: 'trusted',
160
+ ...(source ? { source } : {}),
161
+ diagnostics: [...parsed.diagnostics, ...checked.diagnostics]
162
+ };
21
163
  }
22
- function validateName(name) { return /^[a-z][a-z0-9-]*$/.test(name) && !/anthropic|claude|purista/i.test(name); }
23
164
  export function loadSkillsSync(skills) {
24
165
  const resolved = {};
25
166
  for (const [key, def] of Object.entries(skills)) {
26
- const stat = fs.existsSync(def.directory) ? fs.statSync(def.directory) : null;
27
- if (!stat?.isDirectory())
28
- throw new SkillManifestError('Skill directory missing', { reason: 'directory_missing', directory: def.directory, skill_id: key });
29
- const skillPath = path.join(def.directory, 'SKILL.md');
30
- const content = fs.existsSync(skillPath) ? fs.readFileSync(skillPath, 'utf8') : null;
31
- if (!content)
32
- throw new SkillManifestError('missing SKILL.md', { reason: 'missing_skill_md', directory: def.directory, skill_id: key });
33
- const { frontmatter } = parseFrontmatter(content);
34
- const name = frontmatter['name'] ?? '';
35
- const description = frontmatter['description'] ?? '';
36
- if (!validateName(name) || description.length < 1 || description.length > 1024) {
37
- throw new SkillManifestError('invalid frontmatter', { reason: 'invalid_frontmatter', directory: def.directory, skill_id: key });
38
- }
39
- if (name !== key)
40
- throw new SkillManifestError('name mismatch', { reason: 'name_mismatch', directory: def.directory, skill_id: key });
41
- resolved[key] = { name, description, directory: def.directory, ...(frontmatter['version'] ? { version: frontmatter['version'] } : {}) };
167
+ const skill = readSkill(path.resolve(def.directory), def.validationMode ?? 'strict', key, def.source);
168
+ if (!skill)
169
+ continue;
170
+ resolved[key] = {
171
+ ...skill,
172
+ trust: def.trust ?? 'trusted',
173
+ ...(def.source ? { source: def.source } : {})
174
+ };
42
175
  }
43
176
  return resolved;
44
177
  }
@@ -61,6 +194,9 @@ async function readDirRecursive(root) {
61
194
  return files;
62
195
  }
63
196
  export async function mountSkillsOnce(session, mounted, skills, skillIds) {
197
+ if (skillIds.length > 0 && typeof session.mount !== 'function') {
198
+ throw new SkillManifestError('Sandbox does not support skill mounting.', { reason: 'invalid_frontmatter', directory: '' });
199
+ }
64
200
  for (const skillId of skillIds) {
65
201
  if (mounted.has(skillId))
66
202
  continue;
@@ -68,15 +204,107 @@ export async function mountSkillsOnce(session, mounted, skills, skillIds) {
68
204
  if (!skill)
69
205
  throw new SkillNotFoundError('Skill not found.', { skill_id: skillId });
70
206
  const files = await readDirRecursive(skill.directory);
71
- await session.mount(files, `/skills/${skillId}`);
207
+ await session.mount(files, skill.mountPath);
72
208
  mounted.add(skillId);
73
209
  }
74
210
  }
75
211
  export function buildSkillIndex(skills, ids) {
76
212
  if (ids.length === 0)
77
213
  return '';
78
- const lines = ids.map((id) => `- ${skills[id]?.name ?? id}: ${skills[id]?.description ?? ''}`);
79
- return `\n\nAvailable skills (read /skills/<name>/SKILL.md for full instructions):\n${lines.join('\n')}`;
214
+ const lines = ['', '', 'Available skills:'];
215
+ for (const id of ids) {
216
+ const skill = skills[id];
217
+ if (!skill)
218
+ continue;
219
+ lines.push(`- ${skill.name}: ${skill.description}`);
220
+ lines.push(` Location: ${skill.mountPath}/SKILL.md`);
221
+ if (skill.compatibility)
222
+ lines.push(` Compatibility: ${skill.compatibility}`);
223
+ }
224
+ lines.push('', 'Use the read tool to load /skills/<name>/SKILL.md when a skill is relevant.');
225
+ lines.push('Relative paths in a skill are relative to /skills/<name>/.');
226
+ return lines.join('\n');
227
+ }
228
+ function shouldSkipDirectory(name, clientName) {
229
+ if (skippedDirectories.has(name))
230
+ return true;
231
+ if (!name.startsWith('.'))
232
+ return false;
233
+ return name !== '.agents' && name !== '.claude' && (clientName ? name !== `.${clientName}` : true);
234
+ }
235
+ async function findSkillDirectories(root, opts) {
236
+ const out = [];
237
+ let visited = 0;
238
+ const walk = async (dir, depth) => {
239
+ if (visited >= opts.maxDirectories) {
240
+ opts.diagnostics.push(diagnostic('scan_limit_reached', 'Skill discovery directory limit reached.', { directory: dir }));
241
+ return;
242
+ }
243
+ visited += 1;
244
+ if (depth > opts.maxDepth)
245
+ return;
246
+ let entries;
247
+ try {
248
+ entries = await fsp.readdir(dir, { withFileTypes: true });
249
+ }
250
+ catch {
251
+ return;
252
+ }
253
+ if (entries.some((entry) => entry.isFile() && entry.name === 'SKILL.md')) {
254
+ out.push(dir);
255
+ return;
256
+ }
257
+ for (const entry of entries) {
258
+ if (!entry.isDirectory() || shouldSkipDirectory(entry.name, opts.clientName))
259
+ continue;
260
+ await walk(path.join(dir, entry.name), depth + 1);
261
+ }
262
+ };
263
+ await walk(root, 0);
264
+ return out;
265
+ }
266
+ function addSkillConfig(target, name, def, diagnostics) {
267
+ if (target[name]) {
268
+ diagnostics.push(diagnostic('collision_shadowed', `Skill "${name}" was shadowed by a higher-precedence binding.`, { level: 'warn', skillName: name, directory: def.directory, source: def.source }));
269
+ return;
270
+ }
271
+ target[name] = def;
272
+ }
273
+ export async function discoverSkills(options = {}) {
274
+ const diagnostics = [];
275
+ const skills = {};
276
+ const validationMode = options.validationMode ?? 'lenient';
277
+ const maxDepth = options.maxDepth ?? 6;
278
+ const maxDirectories = options.maxDirectories ?? 2000;
279
+ const trustedRoots = new Set((options.trustedProjectRoots ?? []).map((root) => path.resolve(root)));
280
+ const projectRoot = path.resolve(options.projectRoot ?? process.env['PWD'] ?? '.');
281
+ const roots = [];
282
+ if (options.includeUserAgentsDir)
283
+ roots.push({ root: path.join(os.homedir(), '.agents', 'skills'), trust: 'user', source: 'user_agents', trusted: true });
284
+ if (options.includeUserClientDir && options.clientName)
285
+ roots.push({ root: path.join(os.homedir(), `.${options.clientName}`, 'skills'), trust: 'user', source: 'user_client', trusted: true });
286
+ if (options.includeProjectAgentsDir ?? true)
287
+ roots.push({ root: path.join(projectRoot, '.agents', 'skills'), trust: 'project', source: 'project_agents', trusted: trustedRoots.has(projectRoot) });
288
+ if (options.includeProjectClientDir && options.clientName)
289
+ roots.push({ root: path.join(projectRoot, `.${options.clientName}`, 'skills'), trust: 'project', source: 'project_client', trusted: trustedRoots.has(projectRoot) });
290
+ if (options.includeClaudeCompatDir)
291
+ roots.push({ root: path.join(projectRoot, '.claude', 'skills'), trust: 'project', source: 'project_claude', trusted: trustedRoots.has(projectRoot) });
292
+ for (const rootInfo of roots) {
293
+ if (!fs.existsSync(rootInfo.root))
294
+ continue;
295
+ if (rootInfo.trust === 'project' && !rootInfo.trusted) {
296
+ diagnostics.push(diagnostic('untrusted_project_skill', 'Project skill discovery root is not trusted.', { level: 'warn', directory: rootInfo.root, source: rootInfo.source }));
297
+ continue;
298
+ }
299
+ for (const directory of await findSkillDirectories(rootInfo.root, { maxDepth, maxDirectories, clientName: options.clientName, diagnostics })) {
300
+ const skill = readSkill(directory, validationMode, undefined, rootInfo.source);
301
+ if (!skill)
302
+ continue;
303
+ addSkillConfig(skills, skill.name, { directory, validationMode, trust: rootInfo.trust, source: rootInfo.source }, diagnostics);
304
+ diagnostics.push(...skill.diagnostics);
305
+ }
306
+ }
307
+ return { skills, diagnostics };
80
308
  }
81
309
  export function assertSerializable(value) {
82
310
  try {
@@ -0,0 +1,3 @@
1
+ import type { DurableWorkspaceStore } from '../ports/workspace.js';
2
+ /** Shared Vitest contract for durable workspace store implementations. */
3
+ export declare function durableWorkspaceStoreContract(make: () => DurableWorkspaceStore | Promise<DurableWorkspaceStore>): void;
@@ -0,0 +1,41 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { validateDurableWorkspaceStore } from '../ports/workspace.js';
3
+ /** Shared Vitest contract for durable workspace store implementations. */
4
+ export function durableWorkspaceStoreContract(make) {
5
+ describe('durableWorkspaceStoreContract', () => {
6
+ it('validates metadata and round-trips checkpointed workspaces', async () => {
7
+ const adapter = await make();
8
+ validateDurableWorkspaceStore(adapter);
9
+ const signal = new AbortController().signal;
10
+ const handle = await adapter.startWorkspace({
11
+ sessionId: 'session-1',
12
+ runId: 'run-1',
13
+ agentId: 'agent-1',
14
+ attempt: 1,
15
+ idempotencyKey: 'start-1',
16
+ signal
17
+ });
18
+ const checkpoint = await adapter.pauseWorkspace({
19
+ handle,
20
+ stepId: 'step-1',
21
+ sequence: 1,
22
+ attempt: 1,
23
+ reason: 'step_completed',
24
+ idempotencyKey: 'pause-1',
25
+ signal
26
+ });
27
+ const resumed = await adapter.resumeWorkspace({
28
+ workspaceRef: handle.workspaceRef,
29
+ checkpointRef: checkpoint.checkpointRef,
30
+ sessionId: 'session-1',
31
+ runId: 'run-2',
32
+ attempt: 2,
33
+ idempotencyKey: 'resume-1',
34
+ signal
35
+ });
36
+ const inspection = await adapter.inspectWorkspace?.({ workspaceRef: resumed.workspaceRef, signal });
37
+ expect(resumed.workspaceRef).toBe(handle.workspaceRef);
38
+ expect(inspection?.checkpoints.map((item) => item.checkpointRef)).toEqual([checkpoint.checkpointRef]);
39
+ });
40
+ });
41
+ }
@@ -1,5 +1,7 @@
1
1
  export { FakeModelProvider } from './fakeModelProvider.js';
2
2
  export { FakeMemoryAdapter, memoryAdapterContract } from './fakeMemoryAdapter.js';
3
+ export { InMemoryDurableWorkspaceStore, inMemoryDurableWorkspaceStore } from '../workspace/index.js';
4
+ export { durableWorkspaceStoreContract } from './durableWorkspaceStoreContract.js';
3
5
  export { adapterCapabilitiesContract, fakeCapabilityAdapter, type FakeCapabilityAdapter } from './capabilities.js';
4
6
  export { createInMemoryFeedbackRecorder } from './feedback.js';
5
7
  export { evaluateDeterministicScorer } from '../eval/index.js';
@@ -1,6 +1,8 @@
1
1
  import { defineHarness } from '../harness/defineHarness.js';
2
2
  export { FakeModelProvider } from './fakeModelProvider.js';
3
3
  export { FakeMemoryAdapter, memoryAdapterContract } from './fakeMemoryAdapter.js';
4
+ export { InMemoryDurableWorkspaceStore, inMemoryDurableWorkspaceStore } from '../workspace/index.js';
5
+ export { durableWorkspaceStoreContract } from './durableWorkspaceStoreContract.js';
4
6
  export { adapterCapabilitiesContract, fakeCapabilityAdapter } from './capabilities.js';
5
7
  export { createInMemoryFeedbackRecorder } from './feedback.js';
6
8
  export { evaluateDeterministicScorer } from '../eval/index.js';
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import { SandboxNoExecutorError, ToolNotFoundError, ValidationError, serializeError } from '../errors/index.js';
3
+ import { ulid } from '../ulid/index.js';
3
4
  export const BUILTIN_ALIAS_TO_CANONICAL = {
4
5
  bash: 'bash', Bash: 'bash',
5
6
  read: 'read', Read: 'read',
@@ -113,7 +114,7 @@ export async function invokeBuiltinTool(nameOrAlias, input, session, signal) {
113
114
  }
114
115
  export function toToolErrorMessage(toolCallId, error) {
115
116
  return {
116
- id: `msg_${Date.now()}`,
117
+ id: `msg_${ulid()}`,
117
118
  sessionId: '',
118
119
  role: 'tool',
119
120
  content: '',
@@ -0,0 +1,35 @@
1
+ import type { DurableWorkspaceStore, WorkspaceAbortOptions, WorkspaceAbortResult, WorkspaceCheckpoint, WorkspaceCleanupOptions, WorkspaceCleanupResult, WorkspaceHandle, WorkspaceInspection, WorkspaceInspectionOptions, WorkspacePauseOptions, WorkspaceResumeOptions, WorkspaceStartOptions } from '../ports/workspace.js';
2
+ /** In-process durable workspace store for local development, examples, and tests. */
3
+ export declare class InMemoryDurableWorkspaceStore implements DurableWorkspaceStore {
4
+ readonly info: {
5
+ id: string;
6
+ packageName: string;
7
+ capabilities: readonly ["workspace_store.durable", "workspace_store.checkpoint", "workspace_store.resume", "workspace_store.abort", "workspace_store.cleanup", "workspace_store.inspect", "workspace_store.retention", "workspace_store.quota"];
8
+ policy: {
9
+ retention: {
10
+ pausedTtlMs: number;
11
+ terminalFailureTtlMs: number;
12
+ terminalSuccessTtlMs: number;
13
+ cleanupMode: "manual_only";
14
+ };
15
+ quota: {
16
+ maxActiveWorkspaces: number;
17
+ maxWorkspaceBytes: number;
18
+ };
19
+ };
20
+ };
21
+ readonly capabilities: readonly ["workspace_store.durable", "workspace_store.checkpoint", "workspace_store.resume", "workspace_store.abort", "workspace_store.cleanup", "workspace_store.inspect", "workspace_store.retention", "workspace_store.quota"];
22
+ private readonly workspaces;
23
+ private nextId;
24
+ configureHarnessContext(): void;
25
+ startWorkspace(opts: WorkspaceStartOptions): Promise<WorkspaceHandle>;
26
+ pauseWorkspace(opts: WorkspacePauseOptions): Promise<WorkspaceCheckpoint>;
27
+ resumeWorkspace(opts: WorkspaceResumeOptions): Promise<WorkspaceHandle>;
28
+ abortWorkspace(opts: WorkspaceAbortOptions): Promise<WorkspaceAbortResult>;
29
+ cleanupWorkspace(opts: WorkspaceCleanupOptions): Promise<WorkspaceCleanupResult>;
30
+ inspectWorkspace(opts: WorkspaceInspectionOptions): Promise<WorkspaceInspection>;
31
+ private findWorkspaceByCheckpoint;
32
+ private requireWorkspace;
33
+ }
34
+ /** Creates a fresh in-process durable workspace store. */
35
+ export declare function inMemoryDurableWorkspaceStore(): DurableWorkspaceStore;
@@ -0,0 +1,142 @@
1
+ /** In-process durable workspace store for local development, examples, and tests. */
2
+ export class InMemoryDurableWorkspaceStore {
3
+ info = {
4
+ id: 'in_memory_workspace_store',
5
+ packageName: '@purista/harness',
6
+ capabilities: [
7
+ 'workspace_store.durable',
8
+ 'workspace_store.checkpoint',
9
+ 'workspace_store.resume',
10
+ 'workspace_store.abort',
11
+ 'workspace_store.cleanup',
12
+ 'workspace_store.inspect',
13
+ 'workspace_store.retention',
14
+ 'workspace_store.quota'
15
+ ],
16
+ policy: {
17
+ retention: {
18
+ pausedTtlMs: 86_400_000,
19
+ terminalFailureTtlMs: 86_400_000,
20
+ terminalSuccessTtlMs: 0,
21
+ cleanupMode: 'manual_only'
22
+ },
23
+ quota: { maxActiveWorkspaces: 100, maxWorkspaceBytes: 10_000_000 }
24
+ }
25
+ };
26
+ capabilities = this.info.capabilities;
27
+ workspaces = new Map();
28
+ nextId = 1;
29
+ configureHarnessContext() { }
30
+ async startWorkspace(opts) {
31
+ opts.signal?.throwIfAborted();
32
+ const workspaceRef = `workspace_${this.nextId++}`;
33
+ const now = new Date().toISOString();
34
+ const metadata = { ...(opts.metadata ?? {}) };
35
+ const workspace = {
36
+ workspaceRef,
37
+ state: 'active',
38
+ runId: opts.runId,
39
+ sessionId: opts.sessionId,
40
+ attempt: opts.attempt,
41
+ createdAt: now,
42
+ updatedAt: now,
43
+ metadata,
44
+ checkpoints: []
45
+ };
46
+ this.workspaces.set(workspaceRef, workspace);
47
+ return { workspaceRef, runId: opts.runId, sessionId: opts.sessionId, state: 'active', startedAt: now, attempt: opts.attempt, metadata };
48
+ }
49
+ async pauseWorkspace(opts) {
50
+ opts.signal?.throwIfAborted();
51
+ const workspace = this.requireWorkspace(opts.handle.workspaceRef);
52
+ workspace.state = 'paused';
53
+ workspace.updatedAt = new Date().toISOString();
54
+ const checkpoint = {
55
+ workspaceRef: workspace.workspaceRef,
56
+ checkpointRef: `${workspace.workspaceRef}:checkpoint:${opts.sequence}`,
57
+ runId: workspace.runId,
58
+ sessionId: workspace.sessionId,
59
+ stepId: opts.stepId,
60
+ sequence: opts.sequence,
61
+ attempt: opts.attempt,
62
+ committedAt: workspace.updatedAt,
63
+ metadata: {
64
+ reason: opts.reason,
65
+ ...(opts.checkpointPayload !== undefined ? { checkpointPayload: opts.checkpointPayload } : {})
66
+ }
67
+ };
68
+ workspace.checkpoints.push(checkpoint);
69
+ return checkpoint;
70
+ }
71
+ async resumeWorkspace(opts) {
72
+ opts.signal?.throwIfAborted();
73
+ const workspace = this.requireWorkspace(opts.workspaceRef);
74
+ if (opts.checkpointRef && !workspace.checkpoints.some((checkpoint) => checkpoint.checkpointRef === opts.checkpointRef)) {
75
+ throw new Error(`Unknown workspace checkpoint: ${opts.checkpointRef}`);
76
+ }
77
+ workspace.state = 'active';
78
+ workspace.runId = opts.runId;
79
+ workspace.sessionId = opts.sessionId;
80
+ workspace.attempt = opts.attempt;
81
+ workspace.updatedAt = new Date().toISOString();
82
+ return {
83
+ workspaceRef: workspace.workspaceRef,
84
+ runId: workspace.runId,
85
+ sessionId: workspace.sessionId,
86
+ state: 'active',
87
+ startedAt: workspace.updatedAt,
88
+ attempt: workspace.attempt,
89
+ metadata: workspace.metadata
90
+ };
91
+ }
92
+ async abortWorkspace(opts) {
93
+ opts.signal?.throwIfAborted();
94
+ const workspace = this.requireWorkspace(opts.workspaceRef);
95
+ workspace.state = 'aborted';
96
+ workspace.updatedAt = new Date().toISOString();
97
+ return { workspaceRef: opts.workspaceRef, state: 'aborted', abortedAt: workspace.updatedAt };
98
+ }
99
+ async cleanupWorkspace(opts) {
100
+ opts.signal?.throwIfAborted();
101
+ const workspace = this.requireWorkspace(opts.workspaceRef);
102
+ workspace.state = 'cleaned';
103
+ workspace.updatedAt = new Date().toISOString();
104
+ this.workspaces.delete(opts.workspaceRef);
105
+ return { workspaceRef: opts.workspaceRef, state: 'cleaned', completedAt: workspace.updatedAt };
106
+ }
107
+ async inspectWorkspace(opts) {
108
+ opts.signal?.throwIfAborted();
109
+ const workspaceRef = opts.workspaceRef ?? this.findWorkspaceByCheckpoint(opts.checkpointRef);
110
+ const workspace = this.requireWorkspace(workspaceRef);
111
+ const latest = workspace.checkpoints.at(-1);
112
+ return {
113
+ workspaceRef: workspace.workspaceRef,
114
+ state: workspace.state,
115
+ checkpoints: workspace.checkpoints,
116
+ ...(latest ? { currentCheckpointRef: latest.checkpointRef } : {}),
117
+ retention: this.info.policy.retention,
118
+ quota: this.info.policy.quota,
119
+ createdAt: workspace.createdAt,
120
+ updatedAt: workspace.updatedAt,
121
+ metadata: workspace.metadata
122
+ };
123
+ }
124
+ findWorkspaceByCheckpoint(checkpointRef) {
125
+ if (!checkpointRef)
126
+ throw new Error('workspaceRef or checkpointRef is required.');
127
+ const found = [...this.workspaces.values()].find((workspace) => workspace.checkpoints.some((checkpoint) => checkpoint.checkpointRef === checkpointRef));
128
+ if (!found)
129
+ throw new Error(`Unknown workspace checkpoint: ${checkpointRef}`);
130
+ return found.workspaceRef;
131
+ }
132
+ requireWorkspace(workspaceRef) {
133
+ const workspace = this.workspaces.get(workspaceRef);
134
+ if (!workspace)
135
+ throw new Error(`Unknown workspace: ${workspaceRef}`);
136
+ return workspace;
137
+ }
138
+ }
139
+ /** Creates a fresh in-process durable workspace store. */
140
+ export function inMemoryDurableWorkspaceStore() {
141
+ return new InMemoryDurableWorkspaceStore();
142
+ }
@@ -0,0 +1 @@
1
+ export { InMemoryDurableWorkspaceStore, inMemoryDurableWorkspaceStore } from './in-memory.js';
@@ -0,0 +1 @@
1
+ export { InMemoryDurableWorkspaceStore, inMemoryDurableWorkspaceStore } from './in-memory.js';