@llm-dev-ops/agentics-cli 1.4.7 → 1.4.8
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/cli/index.js +109 -0
- package/dist/cli/index.js.map +1 -1
- package/dist/contracts/adr-006-claude-code-synthesis-runner.d.ts +196 -0
- package/dist/contracts/adr-006-claude-code-synthesis-runner.d.ts.map +1 -0
- package/dist/contracts/adr-006-claude-code-synthesis-runner.js +177 -0
- package/dist/contracts/adr-006-claude-code-synthesis-runner.js.map +1 -0
- package/dist/contracts/adr-007-subcommand-synthesis-router.d.ts +273 -0
- package/dist/contracts/adr-007-subcommand-synthesis-router.d.ts.map +1 -0
- package/dist/contracts/adr-007-subcommand-synthesis-router.js +226 -0
- package/dist/contracts/adr-007-subcommand-synthesis-router.js.map +1 -0
- package/dist/contracts/adr-008-synthesis-artifact-persistence.d.ts +323 -0
- package/dist/contracts/adr-008-synthesis-artifact-persistence.d.ts.map +1 -0
- package/dist/contracts/adr-008-synthesis-artifact-persistence.js +184 -0
- package/dist/contracts/adr-008-synthesis-artifact-persistence.js.map +1 -0
- package/dist/mcp/mcp-server.d.ts +35 -0
- package/dist/mcp/mcp-server.d.ts.map +1 -0
- package/dist/mcp/mcp-server.js +692 -0
- package/dist/mcp/mcp-server.js.map +1 -0
- package/dist/runtime/claude-code-runner.d.ts +93 -0
- package/dist/runtime/claude-code-runner.d.ts.map +1 -0
- package/dist/runtime/claude-code-runner.js +588 -0
- package/dist/runtime/claude-code-runner.js.map +1 -0
- package/dist/runtime/index.d.ts +5 -0
- package/dist/runtime/index.d.ts.map +1 -0
- package/dist/runtime/index.js +5 -0
- package/dist/runtime/index.js.map +1 -0
- package/dist/synthesis/artifact-writer.d.ts +62 -0
- package/dist/synthesis/artifact-writer.d.ts.map +1 -0
- package/dist/synthesis/artifact-writer.js +603 -0
- package/dist/synthesis/artifact-writer.js.map +1 -0
- package/dist/synthesis/index.d.ts +7 -0
- package/dist/synthesis/index.d.ts.map +1 -0
- package/dist/synthesis/index.js +7 -0
- package/dist/synthesis/index.js.map +1 -0
- package/dist/synthesis/prompts/index.d.ts +50 -0
- package/dist/synthesis/prompts/index.d.ts.map +1 -0
- package/dist/synthesis/prompts/index.js +502 -0
- package/dist/synthesis/prompts/index.js.map +1 -0
- package/dist/synthesis/router.d.ts +70 -0
- package/dist/synthesis/router.d.ts.map +1 -0
- package/dist/synthesis/router.js +346 -0
- package/dist/synthesis/router.js.map +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Artifact Writer — ADR-008 Implementation
|
|
3
|
+
*
|
|
4
|
+
* Enterprise-grade synthesis artifact persistence to ~/.agentics/runs/<run-id>/.
|
|
5
|
+
* Provides a local audit trail for offline inspection via `agentics inspect run`.
|
|
6
|
+
*
|
|
7
|
+
* INVARIANTS:
|
|
8
|
+
* - Every write produces exactly four artifact files in the run directory
|
|
9
|
+
* - All file content is serialized BEFORE any I/O begins (fail-fast)
|
|
10
|
+
* - File writes use atomic temp-then-rename to prevent partial artifacts
|
|
11
|
+
* - Files are written with mode 0o600 (owner read/write only)
|
|
12
|
+
* - Directories are created with mode 0o700 (owner read/write/execute only)
|
|
13
|
+
* - All run-id values are validated for path traversal and format safety
|
|
14
|
+
* - SHA-256 checksums are computed for every artifact file
|
|
15
|
+
* - All I/O errors are wrapped with CLIError for structured diagnostics
|
|
16
|
+
*
|
|
17
|
+
* FORBIDDEN:
|
|
18
|
+
* - Validating artifact content semantics (already validated by runner/router)
|
|
19
|
+
* - Calling external APIs
|
|
20
|
+
* - Modifying artifact data (write-through only)
|
|
21
|
+
* - Following symlinks outside the base directory
|
|
22
|
+
*/
|
|
23
|
+
import type { RunManifest, PlatformReceipt, ArtifactWriterInput, IArtifactWriter, ArtifactWriteResult, RunSummary, ListRunsOptions } from '../contracts/adr-008-synthesis-artifact-persistence.js';
|
|
24
|
+
/**
|
|
25
|
+
* Enterprise-grade artifact writer implementing the IArtifactWriter contract.
|
|
26
|
+
*
|
|
27
|
+
* Persists synthesis run artifacts to the local filesystem with:
|
|
28
|
+
* - Atomic writes (temp-then-rename per file)
|
|
29
|
+
* - SHA-256 integrity checksums
|
|
30
|
+
* - Owner-only file permissions (0o600 files, 0o700 directories)
|
|
31
|
+
* - Path traversal prevention
|
|
32
|
+
* - Structured error handling with correlation IDs
|
|
33
|
+
* - Size guards for claude_output and prompt
|
|
34
|
+
* - Read-back methods for offline inspection
|
|
35
|
+
* - Listing and filtering for run discovery
|
|
36
|
+
*/
|
|
37
|
+
export declare class ArtifactWriter implements IArtifactWriter {
|
|
38
|
+
private readonly baseDir;
|
|
39
|
+
constructor(baseDir?: string);
|
|
40
|
+
getRunsDir(): string;
|
|
41
|
+
getRunDir(runId: string): string;
|
|
42
|
+
runExists(runId: string): boolean;
|
|
43
|
+
write(input: ArtifactWriterInput): ArtifactWriteResult;
|
|
44
|
+
readManifest(runId: string): RunManifest | null;
|
|
45
|
+
readReceipt(runId: string): PlatformReceipt | null;
|
|
46
|
+
readClaudeOutput(runId: string): unknown;
|
|
47
|
+
readPrompt(runId: string): string | null;
|
|
48
|
+
listRuns(options?: ListRunsOptions): RunSummary[];
|
|
49
|
+
deleteRun(runId: string): boolean;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Create an IArtifactWriter instance.
|
|
53
|
+
*
|
|
54
|
+
* Base directory resolution order:
|
|
55
|
+
* 1. Explicit `baseDir` parameter
|
|
56
|
+
* 2. AGENTICS_RUNS_DIR environment variable
|
|
57
|
+
* 3. ~/.agentics/runs/ (default)
|
|
58
|
+
*
|
|
59
|
+
* @param baseDir Optional base directory override for artifact storage
|
|
60
|
+
*/
|
|
61
|
+
export declare function createArtifactWriter(baseDir?: string): IArtifactWriter;
|
|
62
|
+
//# sourceMappingURL=artifact-writer.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"artifact-writer.d.ts","sourceRoot":"","sources":["../../src/synthesis/artifact-writer.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAOH,OAAO,KAAK,EACV,WAAW,EACX,eAAe,EACf,mBAAmB,EACnB,eAAe,EACf,mBAAmB,EACnB,UAAU,EACV,eAAe,EAChB,MAAM,wDAAwD,CAAC;AA4VhE;;;;;;;;;;;;GAYG;AACH,qBAAa,cAAe,YAAW,eAAe;IACpD,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAS;gBAErB,OAAO,CAAC,EAAE,MAAM;IAU5B,UAAU,IAAI,MAAM;IAIpB,SAAS,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM;IAQhC,SAAS,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO;IAejC,KAAK,CAAC,KAAK,EAAE,mBAAmB,GAAG,mBAAmB;IAoItD,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,WAAW,GAAG,IAAI;IAK/C,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,eAAe,GAAG,IAAI;IAKlD,gBAAgB,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO;IAKxC,UAAU,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI;IASxC,QAAQ,CAAC,OAAO,CAAC,EAAE,eAAe,GAAG,UAAU,EAAE;IAuDjD,SAAS,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO;CAclC;AAMD;;;;;;;;;GASG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,CAAC,EAAE,MAAM,GAAG,eAAe,CAEtE"}
|
|
@@ -0,0 +1,603 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Artifact Writer — ADR-008 Implementation
|
|
3
|
+
*
|
|
4
|
+
* Enterprise-grade synthesis artifact persistence to ~/.agentics/runs/<run-id>/.
|
|
5
|
+
* Provides a local audit trail for offline inspection via `agentics inspect run`.
|
|
6
|
+
*
|
|
7
|
+
* INVARIANTS:
|
|
8
|
+
* - Every write produces exactly four artifact files in the run directory
|
|
9
|
+
* - All file content is serialized BEFORE any I/O begins (fail-fast)
|
|
10
|
+
* - File writes use atomic temp-then-rename to prevent partial artifacts
|
|
11
|
+
* - Files are written with mode 0o600 (owner read/write only)
|
|
12
|
+
* - Directories are created with mode 0o700 (owner read/write/execute only)
|
|
13
|
+
* - All run-id values are validated for path traversal and format safety
|
|
14
|
+
* - SHA-256 checksums are computed for every artifact file
|
|
15
|
+
* - All I/O errors are wrapped with CLIError for structured diagnostics
|
|
16
|
+
*
|
|
17
|
+
* FORBIDDEN:
|
|
18
|
+
* - Validating artifact content semantics (already validated by runner/router)
|
|
19
|
+
* - Calling external APIs
|
|
20
|
+
* - Modifying artifact data (write-through only)
|
|
21
|
+
* - Following symlinks outside the base directory
|
|
22
|
+
*/
|
|
23
|
+
import * as fs from 'node:fs';
|
|
24
|
+
import * as path from 'node:path';
|
|
25
|
+
import * as os from 'node:os';
|
|
26
|
+
import * as crypto from 'node:crypto';
|
|
27
|
+
import { CLIError } from '../errors/index.js';
|
|
28
|
+
// ============================================================================
|
|
29
|
+
// Constants
|
|
30
|
+
// ============================================================================
|
|
31
|
+
/** File permission: owner read/write only (matches credentials.ts pattern) */
|
|
32
|
+
const FILE_MODE = 0o600;
|
|
33
|
+
/** Directory permission: owner read/write/execute only */
|
|
34
|
+
const DIR_MODE = 0o700;
|
|
35
|
+
/** Artifact filenames — the canonical set for every run directory */
|
|
36
|
+
const MANIFEST_FILE = 'manifest.json';
|
|
37
|
+
const CLAUDE_OUTPUT_FILE = 'claude_output.json';
|
|
38
|
+
const PLATFORM_RECEIPT_FILE = 'platform_receipt.json';
|
|
39
|
+
const PROMPT_FILE = 'prompt.txt';
|
|
40
|
+
/** Maximum allowed run-id length to prevent filesystem path issues */
|
|
41
|
+
const MAX_RUN_ID_LENGTH = 200;
|
|
42
|
+
/** Maximum claude_output.json serialized size: 10 MiB */
|
|
43
|
+
const MAX_JSON_BYTES = 10 * 1024 * 1024;
|
|
44
|
+
/** Warning threshold for prompt.txt: 1 MiB */
|
|
45
|
+
const MAX_PROMPT_WARN_BYTES = 1 * 1024 * 1024;
|
|
46
|
+
/**
|
|
47
|
+
* Pattern for valid run-id characters.
|
|
48
|
+
* Must start with alphanumeric, followed by alphanumeric, hyphens, dots, or underscores.
|
|
49
|
+
* This prevents path traversal and shell metacharacter injection.
|
|
50
|
+
*/
|
|
51
|
+
const SAFE_RUN_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
|
|
52
|
+
// ============================================================================
|
|
53
|
+
// Validation Helpers
|
|
54
|
+
// ============================================================================
|
|
55
|
+
/**
|
|
56
|
+
* Type guard: check if a value is a non-empty string.
|
|
57
|
+
*/
|
|
58
|
+
function isNonEmptyString(value) {
|
|
59
|
+
return typeof value === 'string' && value.length > 0;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Validate a run-id for format safety and path traversal prevention.
|
|
63
|
+
*
|
|
64
|
+
* @throws CLIError (ECLI-ART-001) on format/length violations
|
|
65
|
+
* @throws CLIError (ECLI-ART-002) on path traversal characters
|
|
66
|
+
*/
|
|
67
|
+
function validateRunId(runId) {
|
|
68
|
+
if (!runId || typeof runId !== 'string') {
|
|
69
|
+
throw new CLIError({
|
|
70
|
+
code: 'ECLI-ART-001',
|
|
71
|
+
category: 'INTERNAL_ERROR',
|
|
72
|
+
message: 'Run ID is empty or not a string',
|
|
73
|
+
details: { run_id: String(runId) },
|
|
74
|
+
module: 'artifact-writer',
|
|
75
|
+
recoverable: false,
|
|
76
|
+
exitCode: 70,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
if (runId.length > MAX_RUN_ID_LENGTH) {
|
|
80
|
+
throw new CLIError({
|
|
81
|
+
code: 'ECLI-ART-001',
|
|
82
|
+
category: 'INTERNAL_ERROR',
|
|
83
|
+
message: `Run ID exceeds maximum length (${runId.length} > ${MAX_RUN_ID_LENGTH})`,
|
|
84
|
+
details: { run_id_length: runId.length, max_length: MAX_RUN_ID_LENGTH },
|
|
85
|
+
module: 'artifact-writer',
|
|
86
|
+
recoverable: false,
|
|
87
|
+
exitCode: 70,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
// Path traversal prevention (defense-in-depth)
|
|
91
|
+
if (runId.includes('..') || runId.includes('/') || runId.includes('\\') || runId.includes('\0')) {
|
|
92
|
+
throw new CLIError({
|
|
93
|
+
code: 'ECLI-ART-002',
|
|
94
|
+
category: 'INTERNAL_ERROR',
|
|
95
|
+
message: 'Run ID contains path traversal or null byte characters',
|
|
96
|
+
details: { run_id_preview: runId.slice(0, 50) },
|
|
97
|
+
module: 'artifact-writer',
|
|
98
|
+
recoverable: false,
|
|
99
|
+
exitCode: 70,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
if (!SAFE_RUN_ID_PATTERN.test(runId)) {
|
|
103
|
+
throw new CLIError({
|
|
104
|
+
code: 'ECLI-ART-001',
|
|
105
|
+
category: 'INTERNAL_ERROR',
|
|
106
|
+
message: `Run ID contains invalid characters: must match ${SAFE_RUN_ID_PATTERN.source}`,
|
|
107
|
+
details: { run_id_preview: runId.slice(0, 50) },
|
|
108
|
+
module: 'artifact-writer',
|
|
109
|
+
recoverable: false,
|
|
110
|
+
exitCode: 70,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Validate that a RunManifest has all required fields with correct types.
|
|
116
|
+
*
|
|
117
|
+
* @throws CLIError (ECLI-ART-003) on missing or invalid manifest fields
|
|
118
|
+
*/
|
|
119
|
+
function validateManifestFields(manifest) {
|
|
120
|
+
// Validate required string fields
|
|
121
|
+
const requiredChecks = [
|
|
122
|
+
['run_id', manifest.run_id],
|
|
123
|
+
['command', manifest.command],
|
|
124
|
+
['subcommand', manifest.subcommand],
|
|
125
|
+
['synthesis_class', manifest.synthesis_class],
|
|
126
|
+
['contract_schema', manifest.contract_schema],
|
|
127
|
+
['model', manifest.model],
|
|
128
|
+
['created_at', manifest.created_at],
|
|
129
|
+
['user_id', manifest.user_id],
|
|
130
|
+
['org_id', manifest.org_id],
|
|
131
|
+
['trace_id', manifest.trace_id],
|
|
132
|
+
];
|
|
133
|
+
for (const [fieldName, value] of requiredChecks) {
|
|
134
|
+
if (!isNonEmptyString(value)) {
|
|
135
|
+
throw new CLIError({
|
|
136
|
+
code: 'ECLI-ART-003',
|
|
137
|
+
category: 'INTERNAL_ERROR',
|
|
138
|
+
message: `RunManifest.${fieldName} is missing or empty`,
|
|
139
|
+
details: { field: fieldName, run_id: manifest.run_id },
|
|
140
|
+
module: 'artifact-writer',
|
|
141
|
+
recoverable: false,
|
|
142
|
+
exitCode: 70,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
// Validate synthesis_class enum
|
|
147
|
+
if (manifest.synthesis_class !== 'SYNTHESIS_REQUIRED' && manifest.synthesis_class !== 'COMMITMENT_GRADE') {
|
|
148
|
+
throw new CLIError({
|
|
149
|
+
code: 'ECLI-ART-003',
|
|
150
|
+
category: 'INTERNAL_ERROR',
|
|
151
|
+
message: `RunManifest.synthesis_class must be 'SYNTHESIS_REQUIRED' or 'COMMITMENT_GRADE', got: "${String(manifest.synthesis_class)}"`,
|
|
152
|
+
details: { synthesis_class: manifest.synthesis_class },
|
|
153
|
+
module: 'artifact-writer',
|
|
154
|
+
recoverable: false,
|
|
155
|
+
exitCode: 70,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
// Validate numeric fields
|
|
159
|
+
if (typeof manifest.duration_ms !== 'number' || manifest.duration_ms < 0) {
|
|
160
|
+
throw new CLIError({
|
|
161
|
+
code: 'ECLI-ART-003',
|
|
162
|
+
category: 'INTERNAL_ERROR',
|
|
163
|
+
message: 'RunManifest.duration_ms must be a non-negative number',
|
|
164
|
+
details: { duration_ms: manifest.duration_ms },
|
|
165
|
+
module: 'artifact-writer',
|
|
166
|
+
recoverable: false,
|
|
167
|
+
exitCode: 70,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
if (typeof manifest.seed !== 'number') {
|
|
171
|
+
throw new CLIError({
|
|
172
|
+
code: 'ECLI-ART-003',
|
|
173
|
+
category: 'INTERNAL_ERROR',
|
|
174
|
+
message: 'RunManifest.seed must be a number',
|
|
175
|
+
details: { seed: manifest.seed },
|
|
176
|
+
module: 'artifact-writer',
|
|
177
|
+
recoverable: false,
|
|
178
|
+
exitCode: 70,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Validate all fields of an ArtifactWriterInput before any I/O.
|
|
184
|
+
*
|
|
185
|
+
* @throws CLIError (ECLI-ART-003) on missing or invalid input fields
|
|
186
|
+
*/
|
|
187
|
+
function validateWriteInput(input) {
|
|
188
|
+
if (!input || typeof input !== 'object') {
|
|
189
|
+
throw new CLIError({
|
|
190
|
+
code: 'ECLI-ART-003',
|
|
191
|
+
category: 'INTERNAL_ERROR',
|
|
192
|
+
message: 'ArtifactWriterInput is null or not an object',
|
|
193
|
+
module: 'artifact-writer',
|
|
194
|
+
recoverable: false,
|
|
195
|
+
exitCode: 70,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
validateManifestFields(input.manifest);
|
|
199
|
+
validateRunId(input.manifest.run_id);
|
|
200
|
+
if (typeof input.prompt !== 'string') {
|
|
201
|
+
throw new CLIError({
|
|
202
|
+
code: 'ECLI-ART-003',
|
|
203
|
+
category: 'INTERNAL_ERROR',
|
|
204
|
+
message: 'ArtifactWriterInput.prompt must be a string',
|
|
205
|
+
details: { prompt_type: typeof input.prompt },
|
|
206
|
+
module: 'artifact-writer',
|
|
207
|
+
recoverable: false,
|
|
208
|
+
exitCode: 70,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
if (!input.platformReceipt || typeof input.platformReceipt !== 'object') {
|
|
212
|
+
throw new CLIError({
|
|
213
|
+
code: 'ECLI-ART-003',
|
|
214
|
+
category: 'INTERNAL_ERROR',
|
|
215
|
+
message: 'ArtifactWriterInput.platformReceipt is missing or not an object',
|
|
216
|
+
module: 'artifact-writer',
|
|
217
|
+
recoverable: false,
|
|
218
|
+
exitCode: 70,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
// ============================================================================
|
|
223
|
+
// Safe I/O Helpers
|
|
224
|
+
// ============================================================================
|
|
225
|
+
/**
|
|
226
|
+
* Safely serialize data to JSON with structured error handling.
|
|
227
|
+
* Catches circular reference errors and other serialization issues.
|
|
228
|
+
*
|
|
229
|
+
* @throws CLIError (ECLI-ART-010) on serialization failure
|
|
230
|
+
*/
|
|
231
|
+
function safeSerialize(data, label, correlationId) {
|
|
232
|
+
try {
|
|
233
|
+
return JSON.stringify(data, null, 2);
|
|
234
|
+
}
|
|
235
|
+
catch (error) {
|
|
236
|
+
throw new CLIError({
|
|
237
|
+
code: 'ECLI-ART-010',
|
|
238
|
+
category: 'INTERNAL_ERROR',
|
|
239
|
+
message: `Failed to serialize ${label}: ${error instanceof Error ? error.message : String(error)}`,
|
|
240
|
+
details: { label, data_type: typeof data },
|
|
241
|
+
module: 'artifact-writer',
|
|
242
|
+
correlationId,
|
|
243
|
+
recoverable: false,
|
|
244
|
+
exitCode: 70,
|
|
245
|
+
cause: error instanceof Error ? error : undefined,
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Compute a SHA-256 checksum of UTF-8 content.
|
|
251
|
+
* Returns the full 64-character hex digest.
|
|
252
|
+
*/
|
|
253
|
+
function computeChecksum(content) {
|
|
254
|
+
return crypto.createHash('sha256').update(content, 'utf-8').digest('hex');
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Write a file atomically by writing to a temp file then renaming.
|
|
258
|
+
* This prevents partially-written files if the process crashes mid-write.
|
|
259
|
+
*
|
|
260
|
+
* The temp file is created in the same directory as the target (ensuring
|
|
261
|
+
* same-filesystem rename, which is atomic on POSIX systems).
|
|
262
|
+
*
|
|
263
|
+
* @throws Error on I/O failure (caller wraps with CLIError)
|
|
264
|
+
*/
|
|
265
|
+
function atomicWriteFile(filePath, content, mode) {
|
|
266
|
+
const tmpPath = `${filePath}.tmp.${process.pid}`;
|
|
267
|
+
try {
|
|
268
|
+
fs.writeFileSync(tmpPath, content, { encoding: 'utf-8', mode });
|
|
269
|
+
fs.renameSync(tmpPath, filePath);
|
|
270
|
+
}
|
|
271
|
+
catch (error) {
|
|
272
|
+
// Best-effort cleanup of temp file on failure
|
|
273
|
+
try {
|
|
274
|
+
fs.unlinkSync(tmpPath);
|
|
275
|
+
}
|
|
276
|
+
catch { /* ignore cleanup error */ }
|
|
277
|
+
throw error;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Safely read a file, returning null on any error.
|
|
282
|
+
* Handles ENOENT, EACCES, and other I/O errors gracefully.
|
|
283
|
+
* Used for read operations where absence is a valid state.
|
|
284
|
+
*/
|
|
285
|
+
function safeReadFile(filePath) {
|
|
286
|
+
try {
|
|
287
|
+
return fs.readFileSync(filePath, 'utf-8');
|
|
288
|
+
}
|
|
289
|
+
catch {
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Safely read and parse a JSON file, returning null on any error.
|
|
295
|
+
* Handles missing files, permission errors, and malformed JSON.
|
|
296
|
+
*/
|
|
297
|
+
function safeReadJsonFile(filePath) {
|
|
298
|
+
const content = safeReadFile(filePath);
|
|
299
|
+
if (content === null)
|
|
300
|
+
return null;
|
|
301
|
+
try {
|
|
302
|
+
return JSON.parse(content);
|
|
303
|
+
}
|
|
304
|
+
catch {
|
|
305
|
+
return null;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
// ============================================================================
|
|
309
|
+
// Path Resolution
|
|
310
|
+
// ============================================================================
|
|
311
|
+
/**
|
|
312
|
+
* Resolve a run directory path and verify it stays within the base directory.
|
|
313
|
+
*
|
|
314
|
+
* Defense-in-depth: even though validateRunId blocks traversal characters,
|
|
315
|
+
* this absolute path check prevents edge cases where path.join produces
|
|
316
|
+
* a path outside the base directory.
|
|
317
|
+
*
|
|
318
|
+
* @throws CLIError (ECLI-ART-001, ECLI-ART-002) on invalid run-id
|
|
319
|
+
* @throws CLIError (ECLI-ART-002) if resolved path escapes base directory
|
|
320
|
+
*/
|
|
321
|
+
function resolveRunDir(baseDir, runId) {
|
|
322
|
+
validateRunId(runId);
|
|
323
|
+
const runDir = path.join(baseDir, runId);
|
|
324
|
+
const resolvedRunDir = path.resolve(runDir);
|
|
325
|
+
const resolvedBase = path.resolve(baseDir);
|
|
326
|
+
// Ensure resolved path is strictly inside the base directory
|
|
327
|
+
if (!resolvedRunDir.startsWith(resolvedBase + path.sep) && resolvedRunDir !== resolvedBase) {
|
|
328
|
+
throw new CLIError({
|
|
329
|
+
code: 'ECLI-ART-002',
|
|
330
|
+
category: 'INTERNAL_ERROR',
|
|
331
|
+
message: 'Resolved run directory escapes base directory (path traversal prevented)',
|
|
332
|
+
details: {
|
|
333
|
+
run_id: runId,
|
|
334
|
+
resolved_run_dir: resolvedRunDir,
|
|
335
|
+
base_dir: resolvedBase,
|
|
336
|
+
},
|
|
337
|
+
module: 'artifact-writer',
|
|
338
|
+
recoverable: false,
|
|
339
|
+
exitCode: 70,
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
return resolvedRunDir;
|
|
343
|
+
}
|
|
344
|
+
// ============================================================================
|
|
345
|
+
// Implementation
|
|
346
|
+
// ============================================================================
|
|
347
|
+
/**
|
|
348
|
+
* Enterprise-grade artifact writer implementing the IArtifactWriter contract.
|
|
349
|
+
*
|
|
350
|
+
* Persists synthesis run artifacts to the local filesystem with:
|
|
351
|
+
* - Atomic writes (temp-then-rename per file)
|
|
352
|
+
* - SHA-256 integrity checksums
|
|
353
|
+
* - Owner-only file permissions (0o600 files, 0o700 directories)
|
|
354
|
+
* - Path traversal prevention
|
|
355
|
+
* - Structured error handling with correlation IDs
|
|
356
|
+
* - Size guards for claude_output and prompt
|
|
357
|
+
* - Read-back methods for offline inspection
|
|
358
|
+
* - Listing and filtering for run discovery
|
|
359
|
+
*/
|
|
360
|
+
export class ArtifactWriter {
|
|
361
|
+
baseDir;
|
|
362
|
+
constructor(baseDir) {
|
|
363
|
+
this.baseDir = baseDir
|
|
364
|
+
?? process.env['AGENTICS_RUNS_DIR']
|
|
365
|
+
?? path.join(os.homedir(), '.agentics', 'runs');
|
|
366
|
+
}
|
|
367
|
+
// --------------------------------------------------------------------------
|
|
368
|
+
// Directory Accessors
|
|
369
|
+
// --------------------------------------------------------------------------
|
|
370
|
+
getRunsDir() {
|
|
371
|
+
return this.baseDir;
|
|
372
|
+
}
|
|
373
|
+
getRunDir(runId) {
|
|
374
|
+
return resolveRunDir(this.baseDir, runId);
|
|
375
|
+
}
|
|
376
|
+
// --------------------------------------------------------------------------
|
|
377
|
+
// Existence Check
|
|
378
|
+
// --------------------------------------------------------------------------
|
|
379
|
+
runExists(runId) {
|
|
380
|
+
const runDir = resolveRunDir(this.baseDir, runId);
|
|
381
|
+
const manifestPath = path.join(runDir, MANIFEST_FILE);
|
|
382
|
+
try {
|
|
383
|
+
fs.accessSync(manifestPath, fs.constants.R_OK);
|
|
384
|
+
return true;
|
|
385
|
+
}
|
|
386
|
+
catch {
|
|
387
|
+
return false;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
// --------------------------------------------------------------------------
|
|
391
|
+
// Write (ADR-008 Core Operation)
|
|
392
|
+
// --------------------------------------------------------------------------
|
|
393
|
+
write(input) {
|
|
394
|
+
// Phase 1: Validate all inputs (fail-fast, zero I/O)
|
|
395
|
+
validateWriteInput(input);
|
|
396
|
+
const correlationId = input.manifest.trace_id;
|
|
397
|
+
const runDir = resolveRunDir(this.baseDir, input.manifest.run_id);
|
|
398
|
+
const verbose = process.env['AGENTICS_DEV'] === '1' || process.env['AGENTICS_DEV'] === 'true';
|
|
399
|
+
// Phase 2: Serialize all content BEFORE any I/O (fail-fast on circular refs)
|
|
400
|
+
const manifestJson = safeSerialize(input.manifest, 'manifest', correlationId);
|
|
401
|
+
const claudeOutputJson = safeSerialize(input.claudeOutput, 'claude_output', correlationId);
|
|
402
|
+
const receiptJson = safeSerialize(input.platformReceipt, 'platform_receipt', correlationId);
|
|
403
|
+
const promptContent = input.prompt;
|
|
404
|
+
// Phase 3: Size guards
|
|
405
|
+
if (claudeOutputJson.length > MAX_JSON_BYTES) {
|
|
406
|
+
throw new CLIError({
|
|
407
|
+
code: 'ECLI-ART-011',
|
|
408
|
+
category: 'INTERNAL_ERROR',
|
|
409
|
+
message: `Claude output JSON exceeds size limit (${claudeOutputJson.length} bytes > ${MAX_JSON_BYTES} bytes)`,
|
|
410
|
+
details: {
|
|
411
|
+
actual_bytes: claudeOutputJson.length,
|
|
412
|
+
limit_bytes: MAX_JSON_BYTES,
|
|
413
|
+
run_id: input.manifest.run_id,
|
|
414
|
+
},
|
|
415
|
+
module: 'artifact-writer',
|
|
416
|
+
correlationId,
|
|
417
|
+
recoverable: false,
|
|
418
|
+
exitCode: 70,
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
if (promptContent.length > MAX_PROMPT_WARN_BYTES) {
|
|
422
|
+
process.stderr.write(`[artifact-writer] WARNING: Prompt is ${promptContent.length} bytes ` +
|
|
423
|
+
`(recommended max: ${MAX_PROMPT_WARN_BYTES}). Large prompts may indicate an issue.\n`);
|
|
424
|
+
}
|
|
425
|
+
// Phase 4: Compute SHA-256 checksums for integrity verification
|
|
426
|
+
const checksums = {
|
|
427
|
+
[MANIFEST_FILE]: computeChecksum(manifestJson),
|
|
428
|
+
[CLAUDE_OUTPUT_FILE]: computeChecksum(claudeOutputJson),
|
|
429
|
+
[PLATFORM_RECEIPT_FILE]: computeChecksum(receiptJson),
|
|
430
|
+
[PROMPT_FILE]: computeChecksum(promptContent),
|
|
431
|
+
};
|
|
432
|
+
if (verbose) {
|
|
433
|
+
process.stderr.write(`[artifact-writer] writing run=${input.manifest.run_id} dir=${runDir} ` +
|
|
434
|
+
`files=4 correlationId=${correlationId}\n`);
|
|
435
|
+
}
|
|
436
|
+
// Phase 5: Create run directory with restricted permissions
|
|
437
|
+
try {
|
|
438
|
+
fs.mkdirSync(runDir, { recursive: true, mode: DIR_MODE });
|
|
439
|
+
}
|
|
440
|
+
catch (error) {
|
|
441
|
+
throw new CLIError({
|
|
442
|
+
code: 'ECLI-ART-020',
|
|
443
|
+
category: 'INTERNAL_ERROR',
|
|
444
|
+
message: `Failed to create run directory: ${error instanceof Error ? error.message : String(error)}`,
|
|
445
|
+
details: {
|
|
446
|
+
run_dir: runDir,
|
|
447
|
+
run_id: input.manifest.run_id,
|
|
448
|
+
error_code: error.code,
|
|
449
|
+
},
|
|
450
|
+
module: 'artifact-writer',
|
|
451
|
+
correlationId,
|
|
452
|
+
recoverable: false,
|
|
453
|
+
exitCode: 74,
|
|
454
|
+
cause: error instanceof Error ? error : undefined,
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
// Phase 6: Write artifact files (atomic temp-then-rename per file)
|
|
458
|
+
const filePairs = [
|
|
459
|
+
[MANIFEST_FILE, manifestJson],
|
|
460
|
+
[CLAUDE_OUTPUT_FILE, claudeOutputJson],
|
|
461
|
+
[PLATFORM_RECEIPT_FILE, receiptJson],
|
|
462
|
+
[PROMPT_FILE, promptContent],
|
|
463
|
+
];
|
|
464
|
+
let bytesWritten = 0;
|
|
465
|
+
const filesWritten = [];
|
|
466
|
+
for (const [filename, content] of filePairs) {
|
|
467
|
+
const filePath = path.join(runDir, filename);
|
|
468
|
+
try {
|
|
469
|
+
atomicWriteFile(filePath, content, FILE_MODE);
|
|
470
|
+
bytesWritten += Buffer.byteLength(content, 'utf-8');
|
|
471
|
+
filesWritten.push(filename);
|
|
472
|
+
}
|
|
473
|
+
catch (error) {
|
|
474
|
+
throw new CLIError({
|
|
475
|
+
code: 'ECLI-ART-021',
|
|
476
|
+
category: 'INTERNAL_ERROR',
|
|
477
|
+
message: `Failed to write artifact "${filename}": ${error instanceof Error ? error.message : String(error)}`,
|
|
478
|
+
details: {
|
|
479
|
+
file: filename,
|
|
480
|
+
file_path: filePath,
|
|
481
|
+
run_id: input.manifest.run_id,
|
|
482
|
+
files_written_before_failure: filesWritten,
|
|
483
|
+
error_code: error.code,
|
|
484
|
+
},
|
|
485
|
+
module: 'artifact-writer',
|
|
486
|
+
correlationId,
|
|
487
|
+
recoverable: false,
|
|
488
|
+
exitCode: 74,
|
|
489
|
+
cause: error instanceof Error ? error : undefined,
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
if (verbose) {
|
|
494
|
+
process.stderr.write(`[artifact-writer] complete: ${filesWritten.length} files, ${bytesWritten} bytes, ` +
|
|
495
|
+
`checksums=[${Object.values(checksums).map(c => c.slice(0, 8)).join(',')}]\n`);
|
|
496
|
+
}
|
|
497
|
+
return {
|
|
498
|
+
runDir,
|
|
499
|
+
filesWritten,
|
|
500
|
+
checksums,
|
|
501
|
+
bytesWritten,
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
// --------------------------------------------------------------------------
|
|
505
|
+
// Read Methods (Offline Inspection)
|
|
506
|
+
// --------------------------------------------------------------------------
|
|
507
|
+
readManifest(runId) {
|
|
508
|
+
const runDir = resolveRunDir(this.baseDir, runId);
|
|
509
|
+
return safeReadJsonFile(path.join(runDir, MANIFEST_FILE));
|
|
510
|
+
}
|
|
511
|
+
readReceipt(runId) {
|
|
512
|
+
const runDir = resolveRunDir(this.baseDir, runId);
|
|
513
|
+
return safeReadJsonFile(path.join(runDir, PLATFORM_RECEIPT_FILE));
|
|
514
|
+
}
|
|
515
|
+
readClaudeOutput(runId) {
|
|
516
|
+
const runDir = resolveRunDir(this.baseDir, runId);
|
|
517
|
+
return safeReadJsonFile(path.join(runDir, CLAUDE_OUTPUT_FILE));
|
|
518
|
+
}
|
|
519
|
+
readPrompt(runId) {
|
|
520
|
+
const runDir = resolveRunDir(this.baseDir, runId);
|
|
521
|
+
return safeReadFile(path.join(runDir, PROMPT_FILE));
|
|
522
|
+
}
|
|
523
|
+
// --------------------------------------------------------------------------
|
|
524
|
+
// Discovery (Run Listing)
|
|
525
|
+
// --------------------------------------------------------------------------
|
|
526
|
+
listRuns(options) {
|
|
527
|
+
// Enumerate directories in the base runs directory
|
|
528
|
+
let entries;
|
|
529
|
+
try {
|
|
530
|
+
entries = fs.readdirSync(this.baseDir, { withFileTypes: true });
|
|
531
|
+
}
|
|
532
|
+
catch {
|
|
533
|
+
// Directory doesn't exist or is unreadable → empty list
|
|
534
|
+
return [];
|
|
535
|
+
}
|
|
536
|
+
// Filter to directories with valid run-id-like names
|
|
537
|
+
const dirs = entries.filter(e => e.isDirectory() && SAFE_RUN_ID_PATTERN.test(e.name));
|
|
538
|
+
// Read manifests and apply filters
|
|
539
|
+
const summaries = [];
|
|
540
|
+
for (const dir of dirs) {
|
|
541
|
+
const manifest = this.readManifest(dir.name);
|
|
542
|
+
if (manifest === null)
|
|
543
|
+
continue;
|
|
544
|
+
// Apply command filter
|
|
545
|
+
if (options?.command !== undefined && manifest.command !== options.command)
|
|
546
|
+
continue;
|
|
547
|
+
// Apply subcommand filter
|
|
548
|
+
if (options?.subcommand !== undefined && manifest.subcommand !== options.subcommand)
|
|
549
|
+
continue;
|
|
550
|
+
summaries.push({
|
|
551
|
+
run_id: manifest.run_id,
|
|
552
|
+
command: manifest.command,
|
|
553
|
+
subcommand: manifest.subcommand,
|
|
554
|
+
synthesis_class: manifest.synthesis_class,
|
|
555
|
+
created_at: manifest.created_at,
|
|
556
|
+
model: manifest.model,
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
// Sort by created_at (newest first by default)
|
|
560
|
+
const newestFirst = options?.newestFirst !== false;
|
|
561
|
+
summaries.sort((a, b) => newestFirst
|
|
562
|
+
? b.created_at.localeCompare(a.created_at)
|
|
563
|
+
: a.created_at.localeCompare(b.created_at));
|
|
564
|
+
// Apply pagination (offset + limit)
|
|
565
|
+
const offset = options?.offset ?? 0;
|
|
566
|
+
const limit = options?.limit ?? summaries.length;
|
|
567
|
+
return summaries.slice(offset, offset + limit);
|
|
568
|
+
}
|
|
569
|
+
// --------------------------------------------------------------------------
|
|
570
|
+
// Deletion (Cleanup)
|
|
571
|
+
// --------------------------------------------------------------------------
|
|
572
|
+
deleteRun(runId) {
|
|
573
|
+
const runDir = resolveRunDir(this.baseDir, runId);
|
|
574
|
+
try {
|
|
575
|
+
// Verify directory exists and is a directory before deletion
|
|
576
|
+
const stat = fs.statSync(runDir);
|
|
577
|
+
if (!stat.isDirectory())
|
|
578
|
+
return false;
|
|
579
|
+
fs.rmSync(runDir, { recursive: true, force: true });
|
|
580
|
+
return true;
|
|
581
|
+
}
|
|
582
|
+
catch {
|
|
583
|
+
return false;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
// ============================================================================
|
|
588
|
+
// Factory
|
|
589
|
+
// ============================================================================
|
|
590
|
+
/**
|
|
591
|
+
* Create an IArtifactWriter instance.
|
|
592
|
+
*
|
|
593
|
+
* Base directory resolution order:
|
|
594
|
+
* 1. Explicit `baseDir` parameter
|
|
595
|
+
* 2. AGENTICS_RUNS_DIR environment variable
|
|
596
|
+
* 3. ~/.agentics/runs/ (default)
|
|
597
|
+
*
|
|
598
|
+
* @param baseDir Optional base directory override for artifact storage
|
|
599
|
+
*/
|
|
600
|
+
export function createArtifactWriter(baseDir) {
|
|
601
|
+
return new ArtifactWriter(baseDir);
|
|
602
|
+
}
|
|
603
|
+
//# sourceMappingURL=artifact-writer.js.map
|