@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,10 +1,11 @@
1
1
  import { z } from 'zod';
2
2
  import { ATTR_GEN_AI_AGENT_ID, ATTR_GEN_AI_AGENT_NAME, ATTR_GEN_AI_TOOL_CALL_ID, ATTR_GEN_AI_TOOL_NAME, ATTR_GEN_AI_TOOL_TYPE } from '@opentelemetry/semantic-conventions/incubating';
3
- import { AgentLoopBudgetError, HarnessError, OperationCancelledError, OperationTimeoutError, PermissionDeniedError, ToolError, ToolNotFoundError, ValidationError, serializeError } from '../errors/index.js';
3
+ import { AgentLoopBudgetError, HarnessConfigError, HarnessError, OperationCancelledError, OperationTimeoutError, PermissionDeniedError, ToolError, ToolNotFoundError, ValidationError, serializeError } from '../errors/index.js';
4
4
  import { createMetrics } from '../telemetry/index.js';
5
5
  import { buildSkillIndex, mountSkillsOnce } from '../skills/index.js';
6
6
  import { BUILTIN_ALIAS_TO_CANONICAL, getBuiltinToolSpecs, invokeBuiltinTool } from '../tools/index.js';
7
7
  import { getMcpToolSpecs, invokeMcpTool, isMcpToolDefinition } from '../tools/mcp/runner.js';
8
+ import { ulid } from '../ulid/index.js';
8
9
  function stringifyInput(input) { return typeof input === 'string' ? input : JSON.stringify(input); }
9
10
  function isReadonlyBuiltin(name) { return ['read', 'list', 'glob', 'grep'].includes(name); }
10
11
  async function checkPermission(agentId, runId, sessionId, def, toolName, input) {
@@ -76,6 +77,7 @@ async function runDefaultAgentInner(args) {
76
77
  throw new ValidationError('Unknown model alias', { where: 'agent_input', issues: { model: args.agent.model } });
77
78
  const skillIds = args.agent.skills ?? [];
78
79
  await mountSkillsOnce(args.session, args.mountedSkills, args.skills, skillIds);
80
+ const activatedSkills = new Set();
79
81
  if (args.agent.handler) {
80
82
  const output = await args.agent.handler({
81
83
  input: parsedInput,
@@ -89,13 +91,20 @@ async function runDefaultAgentInner(args) {
89
91
  metrics: args.metrics
90
92
  });
91
93
  const validated = parseAgentSchema(outputSchema, output, 'agent_output');
92
- return { output: validated, emitted: [{ id: `msg_${Date.now()}_a`, sessionId: args.sessionId, runId: args.runId, role: 'assistant', content: JSON.stringify(validated), timestamp: new Date().toISOString() }] };
94
+ return { output: validated, emitted: [{ id: `msg_${ulid()}_a`, sessionId: args.sessionId, runId: args.runId, role: 'assistant', content: JSON.stringify(validated), timestamp: new Date().toISOString() }] };
93
95
  }
94
96
  const baseInstructions = typeof args.agent.instructions === 'function'
95
97
  ? args.agent.instructions({ input: parsedInput, runId: args.runId, sessionId: args.sessionId, history: { list: async () => args.history }, memory: args.memory, metadata: args.metadata ?? {}, metrics: args.metrics })
96
98
  : args.agent.instructions;
97
99
  const instructions = `${baseInstructions}${buildSkillIndex(args.skills, skillIds)}`;
98
100
  const enabledBuiltins = args.agent.builtinTools === false ? [] : args.agent.builtinTools?.slice() ?? ['bash', 'read', 'write', 'edit', 'glob', 'grep', 'list'];
101
+ if (skillIds.length > 0 && !enabledBuiltins.includes('read')) {
102
+ throw new HarnessConfigError('Agents with skills require the read built-in tool for skill activation.', {
103
+ reason: 'skill_read_tool_missing',
104
+ path: `agents.${args.agentId}.builtinTools`,
105
+ id: args.agentId
106
+ });
107
+ }
99
108
  const builtinSpecs = getBuiltinToolSpecs(enabledBuiltins, args.session);
100
109
  const enabledCustomTools = new Set((args.agent.tools ?? []));
101
110
  const tsCustomSpecs = Object.entries(args.customTools)
@@ -145,13 +154,13 @@ async function runDefaultAgentInner(args) {
145
154
  const toolCalls = response.toolCalls ?? [];
146
155
  if (toolCalls.length === 0) {
147
156
  const validated = parseAgentSchema(outputSchema, response.object, 'agent_output');
148
- emitted.push({ id: `msg_${Date.now()}_a`, sessionId: args.sessionId, runId: args.runId, role: 'assistant', content: JSON.stringify(validated), timestamp: new Date().toISOString() });
157
+ emitted.push({ id: `msg_${ulid()}_a`, sessionId: args.sessionId, runId: args.runId, role: 'assistant', content: JSON.stringify(validated), timestamp: new Date().toISOString() });
149
158
  await args.emitEvent?.({ type: 'model.object', runId: args.runId, agentId: args.agentId, object: validated, usage: response.usage });
150
159
  await args.emitEvent?.({ type: 'agent.finished', runId: args.runId, agentId: args.agentId, at: new Date().toISOString(), output: validated });
151
160
  return { output: validated, emitted };
152
161
  }
153
162
  const assistantMsg = {
154
- id: `msg_${Date.now()}_assistant`, sessionId: args.sessionId, runId: args.runId, role: 'assistant', content: '', toolCalls,
163
+ id: `msg_${ulid()}_assistant`, sessionId: args.sessionId, runId: args.runId, role: 'assistant', content: '', toolCalls,
155
164
  timestamp: new Date().toISOString()
156
165
  };
157
166
  emitted.push(assistantMsg);
@@ -171,7 +180,10 @@ async function runDefaultAgentInner(args) {
171
180
  throw new PermissionDeniedError('Permission denied.', { tool_name: canonical, agent_id: args.agentId, reason: 'hook_deny' });
172
181
  }
173
182
  if (canonical in BUILTIN_ALIAS_TO_CANONICAL) {
174
- return { output: await withToolSignal(args.signal, args.toolTimeoutMs, (signal) => invokeBuiltinTool(canonical, input, withSandboxTelemetry(args, canonical), signal)) };
183
+ const output = await withToolSignal(args.signal, args.toolTimeoutMs, (signal) => invokeBuiltinTool(canonical, input, withSandboxTelemetry(args, canonical), signal));
184
+ if (canonical === 'read')
185
+ markSkillActivation(input, args.skills, activatedSkills);
186
+ return { output };
175
187
  }
176
188
  if (!enabledCustomTools.has(canonical)) {
177
189
  throw new ToolNotFoundError('Tool is not allowed for this agent.', { tool_id: canonical, where: 'agent_allowlist' });
@@ -216,7 +228,7 @@ async function runDefaultAgentInner(args) {
216
228
  }
217
229
  await args.emitEvent?.({ type: 'tool.finished', runId: args.runId, agentId: args.agentId, toolId: canonical, callId: call.id, ...(result.output !== undefined ? { output: result.output } : {}), ...(result.error ? { error: result.error } : {}) });
218
230
  const toolMessage = {
219
- id: `msg_${Date.now()}_${call.id}`,
231
+ id: `msg_${ulid()}_${call.id}`,
220
232
  sessionId: args.sessionId,
221
233
  runId: args.runId,
222
234
  role: 'tool',
@@ -230,6 +242,19 @@ async function runDefaultAgentInner(args) {
230
242
  steps += 1;
231
243
  }
232
244
  }
245
+ function markSkillActivation(input, skills, activated) {
246
+ if (!input || typeof input !== 'object')
247
+ return;
248
+ const readPath = input.path;
249
+ if (typeof readPath !== 'string')
250
+ return;
251
+ for (const skill of Object.values(skills)) {
252
+ if (readPath === `${skill.mountPath}/SKILL.md`) {
253
+ activated.add(skill.name);
254
+ return;
255
+ }
256
+ }
257
+ }
233
258
  async function withToolSignal(parent, timeoutMs, fn) {
234
259
  parent.throwIfAborted();
235
260
  const controller = new AbortController();
@@ -104,8 +104,9 @@ export declare class SkillNotFoundError extends HarnessError {
104
104
  export declare class SkillManifestError extends HarnessError {
105
105
  constructor(message: string, meta: {
106
106
  directory: string;
107
- reason: 'missing_skill_md' | 'invalid_frontmatter' | 'name_mismatch' | 'directory_missing' | 'reserved_name';
107
+ reason: 'missing_skill_md' | 'invalid_frontmatter' | 'missing_description' | 'invalid_name' | 'name_mismatch' | 'directory_missing' | 'collision_shadowed' | 'untrusted_project_skill' | 'scan_limit_reached' | 'reserved_name';
108
108
  skill_id?: string;
109
+ source?: string;
109
110
  }, cause?: unknown);
110
111
  }
111
112
  /** Workflow referenced an unknown agent id. */
@@ -150,17 +151,49 @@ export declare class StateError extends HarnessError {
150
151
  memory_provider?: string;
151
152
  }, cause?: unknown);
152
153
  }
154
+ /** Durable workspace lifecycle, consistency, inspection, or backend failure. */
155
+ export declare class WorkspaceError extends HarnessError {
156
+ constructor(message: string, meta: {
157
+ reason: 'idempotency_conflict' | 'not_found' | 'aborted' | 'expired' | 'missing_checkpoint' | 'backend_failure' | 'unsupported_operation' | 'invalid_reference' | 'checkpoint_conflict' | 'cleanup_pending';
158
+ workspace_ref?: string;
159
+ checkpoint_ref?: string;
160
+ snapshot_ref?: string;
161
+ run_id?: string;
162
+ session_id?: string;
163
+ }, cause?: unknown);
164
+ }
165
+ /** Durable workspace quota would be or was exceeded. */
166
+ export declare class WorkspaceQuotaExceededError extends HarnessError {
167
+ constructor(message: string, meta: {
168
+ quota: string;
169
+ limit?: number;
170
+ actual?: number;
171
+ partial?: boolean;
172
+ workspace_ref?: string;
173
+ run_id?: string;
174
+ session_id?: string;
175
+ }, cause?: unknown);
176
+ }
177
+ /** Durable workspace cleanup could not complete in the current attempt. */
178
+ export declare class WorkspaceCleanupError extends HarnessError {
179
+ constructor(message: string, meta: {
180
+ reason: 'backend_failure' | 'partial_delete' | 'invalid_reference';
181
+ workspace_ref: string;
182
+ remaining_refs?: readonly string[];
183
+ retry_after_ms?: number;
184
+ }, cause?: unknown);
185
+ }
153
186
  /** Timed execution budget expired. */
154
187
  export declare class OperationTimeoutError extends HarnessError {
155
188
  constructor(message: string, meta: {
156
- scope: 'run' | 'model' | 'tool' | 'sandbox_run' | 'memory';
189
+ scope: 'run' | 'model' | 'tool' | 'sandbox_run' | 'memory' | 'workspace';
157
190
  timeout_ms: number;
158
191
  }, cause?: unknown);
159
192
  }
160
193
  /** Operation cancelled by abort signal or explicit cancellation path. */
161
194
  export declare class OperationCancelledError extends HarnessError {
162
195
  constructor(message: string, meta: {
163
- scope: 'run' | 'workflow' | 'agent' | 'model' | 'tool' | 'sandbox' | 'memory';
196
+ scope: 'run' | 'workflow' | 'agent' | 'model' | 'tool' | 'sandbox' | 'memory' | 'workspace';
164
197
  }, cause?: unknown);
165
198
  }
166
199
  /** MCP transport/protocol failure. */
@@ -111,6 +111,25 @@ export class StateError extends HarnessError {
111
111
  super({ code: 'STATE_ERROR', category: 'state', retriable: true, message, meta, cause });
112
112
  }
113
113
  }
114
+ /** Durable workspace lifecycle, consistency, inspection, or backend failure. */
115
+ export class WorkspaceError extends HarnessError {
116
+ constructor(message, meta, cause) {
117
+ const retriable = meta.reason === 'backend_failure' || meta.reason === 'cleanup_pending';
118
+ super({ code: 'WORKSPACE_ERROR', category: 'workspace', retriable, message, meta, cause });
119
+ }
120
+ }
121
+ /** Durable workspace quota would be or was exceeded. */
122
+ export class WorkspaceQuotaExceededError extends HarnessError {
123
+ constructor(message, meta, cause) {
124
+ super({ code: 'WORKSPACE_QUOTA_EXCEEDED', category: 'workspace', retriable: false, message, meta, cause });
125
+ }
126
+ }
127
+ /** Durable workspace cleanup could not complete in the current attempt. */
128
+ export class WorkspaceCleanupError extends HarnessError {
129
+ constructor(message, meta, cause) {
130
+ super({ code: 'WORKSPACE_CLEANUP_ERROR', category: 'workspace', retriable: true, message, meta, cause });
131
+ }
132
+ }
114
133
  /** Timed execution budget expired. */
115
134
  export class OperationTimeoutError extends HarnessError {
116
135
  constructor(message, meta, cause) {
@@ -22,6 +22,8 @@ export type ErrorCategory =
22
22
  | 'session'
23
23
  /** State-store persistence failures. */
24
24
  | 'state'
25
+ /** Durable workspace lifecycle or backend failures. */
26
+ | 'workspace'
25
27
  /** Timeout budget failures. */
26
28
  | 'timeout'
27
29
  /** Cooperative cancellation events. */
@@ -5,6 +5,7 @@ import type { StateStore } from '../ports/state.js';
5
5
  import type { Metrics, TelemetryShim } from '../telemetry/index.js';
6
6
  import type { HarnessAdapterContext } from '../ports/harness-context.js';
7
7
  import type { MemoryAdapter, MemoryFacade, SessionMemory } from '../ports/memory.js';
8
+ import type { DurableWorkspaceStore } from '../ports/workspace.js';
8
9
  import type { JsonValue } from '../models/json.js';
9
10
  import type { Message } from '../models/state.js';
10
11
  import type { RunStatus } from '../models/state.js';
@@ -100,16 +101,47 @@ export interface PermissionContext {
100
101
  export type PermissionDecision = 'allow' | 'deny';
101
102
  /** Async permission hook used for interactive approvals or custom policy engines. */
102
103
  export type OnPermission = (ctx: PermissionContext) => Promise<PermissionDecision>;
104
+ /** Skill frontmatter parsed from `SKILL.md`. */
105
+ export interface SkillFrontmatter {
106
+ name: string;
107
+ description: string;
108
+ license?: string;
109
+ compatibility?: string;
110
+ metadata?: Record<string, string>;
111
+ 'allowed-tools'?: string;
112
+ }
113
+ /** Validation mode for `SKILL.md` frontmatter. */
114
+ export type SkillValidationMode = 'strict' | 'lenient';
115
+ /** Diagnostic produced while parsing or discovering skills. */
116
+ export interface SkillDiagnostic {
117
+ level: 'warn' | 'error';
118
+ code: 'missing_skill_md' | 'invalid_frontmatter' | 'missing_description' | 'invalid_name' | 'name_mismatch' | 'directory_missing' | 'collision_shadowed' | 'untrusted_project_skill' | 'scan_limit_reached';
119
+ message: string;
120
+ skillName?: string;
121
+ directory?: string;
122
+ source?: string;
123
+ }
103
124
  /** Mounted skill metadata after frontmatter parsing. */
104
125
  export interface ResolvedSkill {
105
126
  /** Public skill id. */
106
127
  name: string;
107
128
  /** Short user-facing description from frontmatter. */
108
129
  description: string;
109
- /** Optional skill version. */
110
- version?: string;
111
130
  /** Absolute directory mounted into `/skills/<name>`. */
112
131
  directory: string;
132
+ /** Absolute path to the parsed `SKILL.md`. */
133
+ skillPath: string;
134
+ /** Absolute path exposed as the skill instruction file location. */
135
+ location: string;
136
+ /** Sandbox mount path for this skill. */
137
+ mountPath: `/skills/${string}`;
138
+ license?: string;
139
+ compatibility?: string;
140
+ metadata?: Record<string, string>;
141
+ allowedTools?: string;
142
+ trust: 'trusted' | 'project' | 'user';
143
+ source?: string;
144
+ diagnostics: readonly SkillDiagnostic[];
113
145
  }
114
146
  /** Conversation history accessor for a single session thread. */
115
147
  export interface ConversationHistory {
@@ -214,9 +246,32 @@ export type ToolsConfig = Record<string, ToolDefinition>;
214
246
  export interface SkillDefinition {
215
247
  /** Absolute path to the directory containing `SKILL.md`. */
216
248
  directory: string;
249
+ validationMode?: SkillValidationMode;
250
+ trust?: 'trusted' | 'project' | 'user';
251
+ source?: string;
217
252
  }
218
253
  /** Full skill registry shape. */
219
254
  export type SkillsConfig = Record<string, SkillDefinition>;
255
+ /** Options for local Agent Skills discovery. */
256
+ export interface DiscoverSkillsOptions {
257
+ projectRoot?: string;
258
+ clientName?: string;
259
+ includeProjectAgentsDir?: boolean;
260
+ includeProjectClientDir?: boolean;
261
+ includeUserAgentsDir?: boolean;
262
+ includeUserClientDir?: boolean;
263
+ includeClaudeCompatDir?: boolean;
264
+ includeAncestorProjectDirs?: boolean;
265
+ trustedProjectRoots?: readonly string[];
266
+ validationMode?: SkillValidationMode;
267
+ maxDepth?: number;
268
+ maxDirectories?: number;
269
+ }
270
+ /** Result of local Agent Skills discovery. */
271
+ export interface DiscoveredSkills {
272
+ skills: SkillsConfig;
273
+ diagnostics: readonly SkillDiagnostic[];
274
+ }
220
275
  /** Alias map passed to `.models(...)`. */
221
276
  export type ModelsConfig = Record<string, ModelAlias>;
222
277
  /** Builder-state accumulator used for type propagation across the fluent harness builder. */
@@ -559,6 +614,7 @@ export interface HarnessBuilder<S extends BuilderState = {}> {
559
614
  sandbox(sandbox?: Sandbox<any>): HarnessBuilder<S>;
560
615
  memory(adapter: MemoryAdapter): HarnessBuilder<S>;
561
616
  runtime(runtime: DurableRuntimeAdapter): HarnessBuilder<S>;
617
+ workspaceStore(store: DurableWorkspaceStore): HarnessBuilder<S>;
562
618
  requires(capabilities: readonly AdapterCapability[]): HarnessBuilder<S>;
563
619
  defaults(defaults: HarnessDefaults): HarnessBuilder<S>;
564
620
  models<const M extends ModelsConfig>(models: M): HarnessBuilder<S & {
@@ -2,6 +2,7 @@ import { z } from 'zod';
2
2
  import { JsonLogger } from '../logger/index.js';
3
3
  import { sandboxMemory } from '../memory/sandbox/index.js';
4
4
  import { validateMemoryAdapter } from '../ports/memory.js';
5
+ import { validateDurableWorkspaceStore } from '../ports/workspace.js';
5
6
  import { InMemoryStateStore } from '../state/in-memory.js';
6
7
  import { HarnessConfigError } from '../errors/catalog.js';
7
8
  import { autoDetectSandbox } from '../sandbox/index.js';
@@ -38,6 +39,13 @@ class Builder {
38
39
  runtime(runtime) {
39
40
  return this.clone({ runtime });
40
41
  }
42
+ workspaceStore(workspaceStore) {
43
+ if (this.configured.workspaceStore) {
44
+ throw new HarnessConfigError('Workspace store is already configured.', { reason: 'duplicate_adapter', path: 'workspaceStore' });
45
+ }
46
+ validateDurableWorkspaceStore(workspaceStore);
47
+ return this.clone({ workspaceStore });
48
+ }
41
49
  requires(capabilities) {
42
50
  return this.clone({ requiredCapabilities: uniqueCapabilities(capabilities) });
43
51
  }
@@ -63,6 +71,7 @@ class Builder {
63
71
  const resolved = typeof agents === 'function'
64
72
  ? agents({ agent: (definition) => definition })
65
73
  : agents;
74
+ this.validateAgentSkillReferences(resolved);
66
75
  return this.clone({ agents: resolved });
67
76
  }
68
77
  workflows(workflows) {
@@ -79,6 +88,8 @@ class Builder {
79
88
  const sandbox = this.configured.sandbox ?? autoDetectSandbox();
80
89
  const memory = this.configured.memory ?? sandboxMemory();
81
90
  validateMemoryAdapter(memory);
91
+ if (this.configured.workspaceStore)
92
+ validateDurableWorkspaceStore(this.configured.workspaceStore);
82
93
  const inspection = this.resolveInspection(this.options.name ?? 'agent-harness', sandbox, memory, models);
83
94
  const missing = missingCapabilities(inspection.requiredCapabilities, inspection.capabilities);
84
95
  if (missing.length > 0) {
@@ -115,6 +126,20 @@ class Builder {
115
126
  clone(patch) {
116
127
  return new Builder(this.options, { ...this.configured, ...patch });
117
128
  }
129
+ validateAgentSkillReferences(agents) {
130
+ const configuredSkills = new Set(Object.keys(this.configured.skills ?? {}));
131
+ for (const [agentId, agent] of Object.entries(agents)) {
132
+ for (const skillId of agent.skills ?? []) {
133
+ if (!configuredSkills.has(skillId)) {
134
+ throw new HarnessConfigError('Agent references an unknown skill.', {
135
+ reason: 'invalid_agent',
136
+ path: `agents.${agentId}.skills`,
137
+ id: skillId
138
+ });
139
+ }
140
+ }
141
+ }
142
+ }
118
143
  resolveInspection(name, sandbox, memory, models) {
119
144
  const adapters = [];
120
145
  const sandboxCapabilities = hasAdapterCapabilities(sandbox) ? uniqueCapabilities(sandbox.capabilities) : [];
@@ -139,6 +164,17 @@ class Builder {
139
164
  capabilities: uniqueCapabilities(this.configured.runtime.capabilities)
140
165
  });
141
166
  }
167
+ if (this.configured.workspaceStore) {
168
+ adapters.push({
169
+ kind: 'workspace_store',
170
+ id: this.configured.workspaceStore.info.id,
171
+ capabilities: uniqueCapabilities(this.configured.workspaceStore.info.capabilities),
172
+ metadata: {
173
+ packageName: this.configured.workspaceStore.info.packageName,
174
+ policy: this.configured.workspaceStore.info.policy
175
+ }
176
+ });
177
+ }
142
178
  for (const [alias, model] of Object.entries(models)) {
143
179
  adapters.push({
144
180
  kind: 'model',
package/dist/index.d.ts CHANGED
@@ -11,6 +11,8 @@ export type { SessionRecord, Message, RunRecord, PersistedRunEvent, RunStatus }
11
11
  export * from './models/registry.js';
12
12
  export * from './eval/index.js';
13
13
  export * from './memory/sandbox/index.js';
14
+ export * from './skills/index.js';
14
15
  export * from './sandbox/index.js';
16
+ export * from './workspace/index.js';
15
17
  export * from './tools/mcp/index.js';
16
18
  export * from './harness/defineHarness.js';
package/dist/index.js CHANGED
@@ -9,6 +9,8 @@ export * from './models/json.js';
9
9
  export * from './models/registry.js';
10
10
  export * from './eval/index.js';
11
11
  export * from './memory/sandbox/index.js';
12
+ export * from './skills/index.js';
12
13
  export * from './sandbox/index.js';
14
+ export * from './workspace/index.js';
13
15
  export * from './tools/mcp/index.js';
14
16
  export * from './harness/defineHarness.js';
@@ -25,6 +25,28 @@ export type AdapterCapability =
25
25
  | 'runtime.distributed_lock'
26
26
  /** Runtime can resume from committed checkpoints. */
27
27
  | 'runtime.resume_from_checkpoint'
28
+ /** Runtime checkpoint records can carry durable workspace references. */
29
+ | 'runtime.workspace_checkpoint'
30
+ /** Runtime exposes checkpoint retention and expiry metadata. */
31
+ | 'runtime.checkpoint_retention'
32
+ /** Workspace store persists state beyond process exit. */
33
+ | 'workspace_store.durable'
34
+ /** Workspace store can produce stable checkpoints. */
35
+ | 'workspace_store.checkpoint'
36
+ /** Workspace store can resume committed checkpoints. */
37
+ | 'workspace_store.resume'
38
+ /** Workspace store can abort active or paused workspaces. */
39
+ | 'workspace_store.abort'
40
+ /** Workspace store supports idempotent cleanup. */
41
+ | 'workspace_store.cleanup'
42
+ /** Workspace store supports read-only inspection. */
43
+ | 'workspace_store.inspect'
44
+ /** Workspace store exposes retention policy and expiry metadata. */
45
+ | 'workspace_store.retention'
46
+ /** Workspace store enforces and reports quota policy. */
47
+ | 'workspace_store.quota'
48
+ /** Workspace store encrypts checkpoint, snapshot, file, and metadata storage. */
49
+ | 'workspace_store.encrypted_storage'
28
50
  /** Adapter can record feedback. */
29
51
  | 'feedback.record'
30
52
  /** Memory adapter supports key/value reads and writes. */
@@ -55,7 +77,7 @@ export interface AdapterCapabilities {
55
77
  }
56
78
  /** Adapter descriptor surfaced through `harness.inspect()`. */
57
79
  export interface AdapterInspection {
58
- readonly kind: 'state' | 'sandbox' | 'runtime' | 'feedback' | 'model' | 'memory';
80
+ readonly kind: 'state' | 'sandbox' | 'runtime' | 'workspace_store' | 'feedback' | 'model' | 'memory';
59
81
  readonly id: string;
60
82
  readonly capabilities: readonly AdapterCapability[];
61
83
  readonly metadata?: Record<string, unknown>;
@@ -5,3 +5,4 @@ export * from './harness-context.js';
5
5
  export * from './capabilities.js';
6
6
  export * from './feedback.js';
7
7
  export * from './memory.js';
8
+ export * from './workspace.js';
@@ -5,3 +5,4 @@ export * from './harness-context.js';
5
5
  export * from './capabilities.js';
6
6
  export * from './feedback.js';
7
7
  export * from './memory.js';
8
+ export * from './workspace.js';
@@ -0,0 +1,177 @@
1
+ import type { JsonValue } from '../models/json.js';
2
+ import type { AdapterCapabilities, AdapterCapability } from './capabilities.js';
3
+ import type { HarnessAdapterContext } from './harness-context.js';
4
+ export type WorkspaceLifecycleState = 'active' | 'paused' | 'aborted' | 'cleanup_pending' | 'cleaned';
5
+ export interface WorkspaceRetentionPolicy {
6
+ activeTtlMs?: number;
7
+ pausedTtlMs?: number;
8
+ terminalSuccessTtlMs?: number;
9
+ terminalFailureTtlMs?: number;
10
+ abortedTtlMs?: number;
11
+ orphanTtlMs?: number;
12
+ maxTtlMs?: number;
13
+ cleanupMode: 'adapter_automatic' | 'application_scheduled' | 'manual_only';
14
+ }
15
+ export interface WorkspaceEncryptionInfo {
16
+ encryptedAtRest: boolean;
17
+ keyScope: 'adapter' | 'tenant' | 'project' | 'application';
18
+ rotationSupported: boolean;
19
+ metadataEncrypted: boolean;
20
+ }
21
+ export interface WorkspaceQuotaPolicy {
22
+ maxWorkspaceBytes?: number;
23
+ maxWorkspaceFiles?: number;
24
+ maxSingleFileBytes?: number;
25
+ maxCheckpointPayloadBytes?: number;
26
+ maxSnapshotBytes?: number;
27
+ maxActiveWorkspaces?: number;
28
+ maxPausedWorkspaces?: number;
29
+ maxConcurrentResumes?: number;
30
+ maxWorkspaceAgeMs?: number;
31
+ }
32
+ export interface DurableWorkspacePolicy {
33
+ retention?: WorkspaceRetentionPolicy;
34
+ encryption?: WorkspaceEncryptionInfo;
35
+ quota?: WorkspaceQuotaPolicy;
36
+ }
37
+ export interface DurableWorkspaceStoreInfo {
38
+ id: string;
39
+ packageName: string;
40
+ capabilities: readonly AdapterCapability[];
41
+ policy: DurableWorkspacePolicy;
42
+ }
43
+ export interface WorkspaceStartOptions {
44
+ runId: string;
45
+ sessionId: string;
46
+ workflowId?: string;
47
+ agentId?: string;
48
+ workerId?: string;
49
+ attempt: number;
50
+ idempotencyKey: string;
51
+ metadata?: Record<string, JsonValue>;
52
+ policy?: Partial<DurableWorkspacePolicy>;
53
+ signal?: AbortSignal;
54
+ }
55
+ export interface WorkspaceHandle {
56
+ workspaceRef: string;
57
+ runId: string;
58
+ sessionId: string;
59
+ state: 'active';
60
+ startedAt: string;
61
+ attempt: number;
62
+ metadata?: Record<string, JsonValue>;
63
+ }
64
+ export interface WorkspacePauseOptions {
65
+ handle: WorkspaceHandle;
66
+ stepId: string;
67
+ sequence: number;
68
+ attempt: number;
69
+ checkpointPayload?: JsonValue;
70
+ reason: 'step_completed' | 'manual_pause' | 'timeout' | 'shutdown' | 'retry_boundary';
71
+ idempotencyKey: string;
72
+ signal?: AbortSignal;
73
+ }
74
+ export interface WorkspaceCheckpoint {
75
+ workspaceRef: string;
76
+ checkpointRef: string;
77
+ snapshotRef?: string;
78
+ runId: string;
79
+ sessionId: string;
80
+ stepId: string;
81
+ sequence: number;
82
+ attempt: number;
83
+ committedAt: string;
84
+ expiresAt?: string;
85
+ sizeBytes?: number;
86
+ metadata?: Record<string, JsonValue>;
87
+ }
88
+ export interface WorkspaceResumeOptions {
89
+ workspaceRef: string;
90
+ checkpointRef?: string;
91
+ snapshotRef?: string;
92
+ runId: string;
93
+ sessionId: string;
94
+ attempt: number;
95
+ idempotencyKey: string;
96
+ signal?: AbortSignal;
97
+ }
98
+ export interface WorkspaceAbortOptions {
99
+ workspaceRef: string;
100
+ runId: string;
101
+ sessionId: string;
102
+ reason: 'cancelled' | 'failed' | 'superseded' | 'manual_abort';
103
+ idempotencyKey: string;
104
+ signal?: AbortSignal;
105
+ }
106
+ export interface WorkspaceAbortResult {
107
+ workspaceRef: string;
108
+ state: 'aborted';
109
+ abortedAt: string;
110
+ cleanupEligibleAt?: string;
111
+ }
112
+ export interface WorkspaceCleanupOptions {
113
+ workspaceRef: string;
114
+ reason: 'terminal_success' | 'terminal_failure' | 'aborted' | 'expired' | 'orphan' | 'manual';
115
+ idempotencyKey: string;
116
+ signal?: AbortSignal;
117
+ }
118
+ export interface WorkspaceCleanupResult {
119
+ workspaceRef: string;
120
+ state: 'cleaned' | 'cleanup_pending';
121
+ deletedBytes?: number;
122
+ deletedFiles?: number;
123
+ completedAt?: string;
124
+ retryAfterMs?: number;
125
+ partial?: boolean;
126
+ remainingRefs?: readonly string[];
127
+ }
128
+ export interface WorkspaceInspectionOptions {
129
+ workspaceRef?: string;
130
+ checkpointRef?: string;
131
+ snapshotRef?: string;
132
+ signal?: AbortSignal;
133
+ }
134
+ export interface WorkspaceInspection {
135
+ workspaceRef: string;
136
+ state: WorkspaceLifecycleState;
137
+ checkpoints: readonly WorkspaceCheckpoint[];
138
+ currentCheckpointRef?: string;
139
+ retention?: WorkspaceRetentionPolicy;
140
+ quota?: WorkspaceQuotaPolicy;
141
+ encryption?: WorkspaceEncryptionInfo;
142
+ createdAt: string;
143
+ updatedAt: string;
144
+ expiresAt?: string;
145
+ cleanupEligibleAt?: string;
146
+ metadata?: Record<string, JsonValue>;
147
+ }
148
+ export interface DurableReplayCheckpoint {
149
+ runId: string;
150
+ sessionId: string;
151
+ workerId?: string;
152
+ leaseId?: string;
153
+ stepId: string;
154
+ sequence: number;
155
+ attempt: number;
156
+ checkpointRef: string;
157
+ workspaceRef?: string;
158
+ snapshotRef?: string;
159
+ runtimeCheckpointRef?: string;
160
+ schemaVersion: 1;
161
+ payload?: JsonValue;
162
+ payloadSizeBytes?: number;
163
+ committedAt: string;
164
+ expiresAt?: string;
165
+ metadata?: Record<string, JsonValue>;
166
+ }
167
+ export interface DurableWorkspaceStore extends AdapterCapabilities {
168
+ readonly info: DurableWorkspaceStoreInfo;
169
+ configureHarnessContext?(context: HarnessAdapterContext): void;
170
+ startWorkspace(opts: WorkspaceStartOptions): Promise<WorkspaceHandle>;
171
+ pauseWorkspace(opts: WorkspacePauseOptions): Promise<WorkspaceCheckpoint>;
172
+ resumeWorkspace(opts: WorkspaceResumeOptions): Promise<WorkspaceHandle>;
173
+ abortWorkspace(opts: WorkspaceAbortOptions): Promise<WorkspaceAbortResult>;
174
+ cleanupWorkspace(opts: WorkspaceCleanupOptions): Promise<WorkspaceCleanupResult>;
175
+ inspectWorkspace?(opts: WorkspaceInspectionOptions): Promise<WorkspaceInspection>;
176
+ }
177
+ export declare function validateDurableWorkspaceStore(adapter: DurableWorkspaceStore): void;
@@ -0,0 +1,32 @@
1
+ import { HarnessConfigError } from '../errors/catalog.js';
2
+ const adapterIdPattern = /^[a-z][a-z0-9_.-]{1,63}$/;
3
+ export function validateDurableWorkspaceStore(adapter) {
4
+ if (!adapterIdPattern.test(adapter.info.id)) {
5
+ throw new HarnessConfigError('Workspace store id is invalid.', {
6
+ reason: 'invalid_workspace_store',
7
+ path: 'workspaceStore.info.id',
8
+ id: adapter.info.id
9
+ });
10
+ }
11
+ if (!adapter.info.packageName.trim()) {
12
+ throw new HarnessConfigError('Workspace store packageName is required.', {
13
+ reason: 'invalid_workspace_store',
14
+ path: 'workspaceStore.info.packageName',
15
+ id: adapter.info.id
16
+ });
17
+ }
18
+ if (!adapter.info.capabilities.includes('workspace_store.durable')) {
19
+ throw new HarnessConfigError('Workspace store must support workspace_store.durable.', {
20
+ reason: 'invalid_workspace_store',
21
+ path: 'workspaceStore.info.capabilities',
22
+ id: adapter.info.id
23
+ });
24
+ }
25
+ if (adapter.capabilities.length !== adapter.info.capabilities.length || adapter.capabilities.some((capability) => !adapter.info.capabilities.includes(capability))) {
26
+ throw new HarnessConfigError('Workspace store capabilities must match info.capabilities.', {
27
+ reason: 'invalid_workspace_store',
28
+ path: 'workspaceStore.capabilities',
29
+ id: adapter.info.id
30
+ });
31
+ }
32
+ }