@purista/harness 1.1.0 → 1.2.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.
- package/dist/agents/index.js +26 -2
- package/dist/errors/catalog.d.ts +36 -3
- package/dist/errors/catalog.js +19 -0
- package/dist/errors/harness-error.d.ts +2 -0
- package/dist/harness/defineHarness.d.ts +58 -2
- package/dist/harness/defineHarness.js +36 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/ports/capabilities.d.ts +23 -1
- package/dist/ports/index.d.ts +1 -0
- package/dist/ports/index.js +1 -0
- package/dist/ports/workspace.d.ts +177 -0
- package/dist/ports/workspace.js +32 -0
- package/dist/runtime/durable.d.ts +3 -0
- package/dist/runtime/durable.js +2 -1
- package/dist/skills/index.d.ts +2 -1
- package/dist/skills/index.js +263 -35
- package/dist/testing/durableWorkspaceStoreContract.d.ts +3 -0
- package/dist/testing/durableWorkspaceStoreContract.js +41 -0
- package/dist/testing/index.d.ts +2 -0
- package/dist/testing/index.js +2 -0
- package/dist/workspace/in-memory.d.ts +35 -0
- package/dist/workspace/in-memory.js +142 -0
- package/dist/workspace/index.d.ts +1 -0
- package/dist/workspace/index.js +1 -0
- package/package.json +5 -4
package/dist/agents/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
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';
|
|
@@ -76,6 +76,7 @@ async function runDefaultAgentInner(args) {
|
|
|
76
76
|
throw new ValidationError('Unknown model alias', { where: 'agent_input', issues: { model: args.agent.model } });
|
|
77
77
|
const skillIds = args.agent.skills ?? [];
|
|
78
78
|
await mountSkillsOnce(args.session, args.mountedSkills, args.skills, skillIds);
|
|
79
|
+
const activatedSkills = new Set();
|
|
79
80
|
if (args.agent.handler) {
|
|
80
81
|
const output = await args.agent.handler({
|
|
81
82
|
input: parsedInput,
|
|
@@ -96,6 +97,13 @@ async function runDefaultAgentInner(args) {
|
|
|
96
97
|
: args.agent.instructions;
|
|
97
98
|
const instructions = `${baseInstructions}${buildSkillIndex(args.skills, skillIds)}`;
|
|
98
99
|
const enabledBuiltins = args.agent.builtinTools === false ? [] : args.agent.builtinTools?.slice() ?? ['bash', 'read', 'write', 'edit', 'glob', 'grep', 'list'];
|
|
100
|
+
if (skillIds.length > 0 && !enabledBuiltins.includes('read')) {
|
|
101
|
+
throw new HarnessConfigError('Agents with skills require the read built-in tool for skill activation.', {
|
|
102
|
+
reason: 'skill_read_tool_missing',
|
|
103
|
+
path: `agents.${args.agentId}.builtinTools`,
|
|
104
|
+
id: args.agentId
|
|
105
|
+
});
|
|
106
|
+
}
|
|
99
107
|
const builtinSpecs = getBuiltinToolSpecs(enabledBuiltins, args.session);
|
|
100
108
|
const enabledCustomTools = new Set((args.agent.tools ?? []));
|
|
101
109
|
const tsCustomSpecs = Object.entries(args.customTools)
|
|
@@ -171,7 +179,10 @@ async function runDefaultAgentInner(args) {
|
|
|
171
179
|
throw new PermissionDeniedError('Permission denied.', { tool_name: canonical, agent_id: args.agentId, reason: 'hook_deny' });
|
|
172
180
|
}
|
|
173
181
|
if (canonical in BUILTIN_ALIAS_TO_CANONICAL) {
|
|
174
|
-
|
|
182
|
+
const output = await withToolSignal(args.signal, args.toolTimeoutMs, (signal) => invokeBuiltinTool(canonical, input, withSandboxTelemetry(args, canonical), signal));
|
|
183
|
+
if (canonical === 'read')
|
|
184
|
+
markSkillActivation(input, args.skills, activatedSkills);
|
|
185
|
+
return { output };
|
|
175
186
|
}
|
|
176
187
|
if (!enabledCustomTools.has(canonical)) {
|
|
177
188
|
throw new ToolNotFoundError('Tool is not allowed for this agent.', { tool_id: canonical, where: 'agent_allowlist' });
|
|
@@ -230,6 +241,19 @@ async function runDefaultAgentInner(args) {
|
|
|
230
241
|
steps += 1;
|
|
231
242
|
}
|
|
232
243
|
}
|
|
244
|
+
function markSkillActivation(input, skills, activated) {
|
|
245
|
+
if (!input || typeof input !== 'object')
|
|
246
|
+
return;
|
|
247
|
+
const readPath = input.path;
|
|
248
|
+
if (typeof readPath !== 'string')
|
|
249
|
+
return;
|
|
250
|
+
for (const skill of Object.values(skills)) {
|
|
251
|
+
if (readPath === `${skill.mountPath}/SKILL.md`) {
|
|
252
|
+
activated.add(skill.name);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
233
257
|
async function withToolSignal(parent, timeoutMs, fn) {
|
|
234
258
|
parent.throwIfAborted();
|
|
235
259
|
const controller = new AbortController();
|
package/dist/errors/catalog.d.ts
CHANGED
|
@@ -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. */
|
package/dist/errors/catalog.js
CHANGED
|
@@ -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>;
|
package/dist/ports/index.d.ts
CHANGED
package/dist/ports/index.js
CHANGED
|
@@ -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
|
+
}
|
|
@@ -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. */
|
package/dist/runtime/durable.js
CHANGED
|
@@ -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();
|
package/dist/skills/index.d.ts
CHANGED
|
@@ -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;
|
package/dist/skills/index.js
CHANGED
|
@@ -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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
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
|
|
27
|
-
if (!
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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,
|
|
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 =
|
|
79
|
-
|
|
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,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
|
+
}
|
package/dist/testing/index.d.ts
CHANGED
|
@@ -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';
|
package/dist/testing/index.js
CHANGED
|
@@ -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';
|
|
@@ -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';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@purista/harness",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Self-hosted enterprise agent harness for typed tools, agents, workflows, state, sandboxing, and telemetry.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -50,7 +50,8 @@
|
|
|
50
50
|
"test:failure": "vitest run test/failure"
|
|
51
51
|
},
|
|
52
52
|
"dependencies": {
|
|
53
|
-
"@opentelemetry/semantic-conventions": "^1.
|
|
53
|
+
"@opentelemetry/semantic-conventions": "^1.41.1",
|
|
54
|
+
"yaml": "^2.9.0",
|
|
54
55
|
"zod": "^4.4.3"
|
|
55
56
|
},
|
|
56
57
|
"peerDependencies": {
|
|
@@ -69,10 +70,10 @@
|
|
|
69
70
|
"devDependencies": {
|
|
70
71
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
71
72
|
"@opentelemetry/context-async-hooks": "^2.7.1",
|
|
72
|
-
"@types/node": "^25.
|
|
73
|
+
"@types/node": "^25.9.1",
|
|
73
74
|
"just-bash": "^3.0.1",
|
|
74
75
|
"typescript": "^6.0.3",
|
|
75
|
-
"vitest": "^4.1.
|
|
76
|
+
"vitest": "^4.1.8"
|
|
76
77
|
},
|
|
77
78
|
"engines": {
|
|
78
79
|
"node": ">=24.15.0"
|