agentxchain 2.31.0 → 2.33.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/agentxchain.js +7 -0
- package/dashboard/app.js +3 -0
- package/dashboard/components/artifacts.js +113 -0
- package/dashboard/index.html +1 -0
- package/package.json +1 -1
- package/src/commands/restore.js +44 -0
- package/src/lib/dashboard/bridge-server.js +7 -0
- package/src/lib/dashboard/workflow-kit-artifacts.js +131 -0
- package/src/lib/export-verifier.js +28 -5
- package/src/lib/export.js +126 -3
- package/src/lib/restore.js +153 -0
package/bin/agentxchain.js
CHANGED
|
@@ -75,6 +75,7 @@ import { approveTransitionCommand } from '../src/commands/approve-transition.js'
|
|
|
75
75
|
import { approveCompletionCommand } from '../src/commands/approve-completion.js';
|
|
76
76
|
import { dashboardCommand } from '../src/commands/dashboard.js';
|
|
77
77
|
import { exportCommand } from '../src/commands/export.js';
|
|
78
|
+
import { restoreCommand } from '../src/commands/restore.js';
|
|
78
79
|
import { reportCommand } from '../src/commands/report.js';
|
|
79
80
|
import {
|
|
80
81
|
pluginInstallCommand,
|
|
@@ -139,6 +140,12 @@ program
|
|
|
139
140
|
.option('--output <path>', 'Write the export artifact to a file instead of stdout')
|
|
140
141
|
.action(exportCommand);
|
|
141
142
|
|
|
143
|
+
program
|
|
144
|
+
.command('restore')
|
|
145
|
+
.description('Restore governed continuity roots from a run export artifact')
|
|
146
|
+
.requiredOption('--input <path>', 'Path to a prior run export artifact')
|
|
147
|
+
.action(restoreCommand);
|
|
148
|
+
|
|
142
149
|
program
|
|
143
150
|
.command('report')
|
|
144
151
|
.description('Render a human-readable governance summary from an export artifact')
|
package/dashboard/app.js
CHANGED
|
@@ -13,6 +13,7 @@ import { render as renderGate } from './components/gate.js';
|
|
|
13
13
|
import { render as renderInitiative } from './components/initiative.js';
|
|
14
14
|
import { render as renderCrossRepo } from './components/cross-repo.js';
|
|
15
15
|
import { render as renderBlockers } from './components/blockers.js';
|
|
16
|
+
import { render as renderArtifacts } from './components/artifacts.js';
|
|
16
17
|
|
|
17
18
|
const VIEWS = {
|
|
18
19
|
timeline: { fetch: ['state', 'history', 'audit', 'annotations'], render: renderTimeline },
|
|
@@ -23,6 +24,7 @@ const VIEWS = {
|
|
|
23
24
|
initiative: { fetch: ['coordinatorState', 'coordinatorBarriers', 'barrierLedger', 'coordinatorBlockers'], render: renderInitiative },
|
|
24
25
|
'cross-repo': { fetch: ['coordinatorState', 'coordinatorHistory'], render: renderCrossRepo },
|
|
25
26
|
blockers: { fetch: ['coordinatorBlockers'], render: renderBlockers },
|
|
27
|
+
artifacts: { fetch: ['workflowKitArtifacts'], render: renderArtifacts },
|
|
26
28
|
};
|
|
27
29
|
|
|
28
30
|
const API_MAP = {
|
|
@@ -37,6 +39,7 @@ const API_MAP = {
|
|
|
37
39
|
barrierLedger: '/api/coordinator/barrier-ledger',
|
|
38
40
|
coordinatorAudit: '/api/coordinator/hooks/audit',
|
|
39
41
|
coordinatorBlockers: '/api/coordinator/blockers',
|
|
42
|
+
workflowKitArtifacts: '/api/workflow-kit-artifacts',
|
|
40
43
|
};
|
|
41
44
|
|
|
42
45
|
const viewState = {
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workflow-Kit Artifacts view — renders live workflow-kit artifact status.
|
|
3
|
+
*
|
|
4
|
+
* Pure render function: takes data from /api/workflow-kit-artifacts, returns HTML.
|
|
5
|
+
* Ownership resolution and file-existence checks are server-side. This view
|
|
6
|
+
* renders the snapshot without reimplementing any logic.
|
|
7
|
+
*
|
|
8
|
+
* See: WORKFLOW_KIT_DASHBOARD_SPEC.md
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
function esc(str) {
|
|
12
|
+
if (!str) return '';
|
|
13
|
+
return String(str)
|
|
14
|
+
.replace(/&/g, '&')
|
|
15
|
+
.replace(/</g, '<')
|
|
16
|
+
.replace(/>/g, '>')
|
|
17
|
+
.replace(/"/g, '"')
|
|
18
|
+
.replace(/'/g, ''');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function badge(label, color = 'var(--text-dim)') {
|
|
22
|
+
return `<span class="badge" style="color:${color};border-color:${color}">${esc(label)}</span>`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function statusIndicator(exists) {
|
|
26
|
+
if (exists) {
|
|
27
|
+
return `<span style="color:var(--green);font-weight:600" title="File exists">✓ exists</span>`;
|
|
28
|
+
}
|
|
29
|
+
return `<span style="color:var(--red);font-weight:600" title="File missing">✗ missing</span>`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function renderArtifactRow(artifact) {
|
|
33
|
+
const isMissingRequired = !artifact.exists && artifact.required;
|
|
34
|
+
const rowStyle = isMissingRequired
|
|
35
|
+
? ' style="border-left:3px solid var(--red)"'
|
|
36
|
+
: '';
|
|
37
|
+
|
|
38
|
+
return `<tr${rowStyle}>
|
|
39
|
+
<td class="mono">${esc(artifact.path)}</td>
|
|
40
|
+
<td>${artifact.required ? badge('required', 'var(--yellow)') : badge('optional', 'var(--text-dim)')}</td>
|
|
41
|
+
<td>${artifact.semantics ? `<span class="mono">${esc(artifact.semantics)}</span>` : '<span style="color:var(--text-dim)">—</span>'}</td>
|
|
42
|
+
<td>${artifact.owned_by ? esc(artifact.owned_by) : '<span style="color:var(--text-dim)">—</span>'}</td>
|
|
43
|
+
<td>${artifact.owner_resolution ? badge(artifact.owner_resolution, artifact.owner_resolution === 'explicit' ? 'var(--accent)' : 'var(--text-dim)') : '—'}</td>
|
|
44
|
+
<td>${statusIndicator(artifact.exists)}</td>
|
|
45
|
+
</tr>`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function render({ workflowKitArtifacts }) {
|
|
49
|
+
if (!workflowKitArtifacts) {
|
|
50
|
+
return `<div class="placeholder"><h2>Workflow Artifacts</h2><p>No workflow artifact data available. Ensure a governed run is active.</p></div>`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (workflowKitArtifacts.ok === false) {
|
|
54
|
+
const hint = workflowKitArtifacts.code === 'config_missing' || workflowKitArtifacts.code === 'state_missing'
|
|
55
|
+
? ' Run <code>agentxchain init --governed</code> to get started.'
|
|
56
|
+
: '';
|
|
57
|
+
return `<div class="placeholder"><h2>Workflow Artifacts</h2><p>${esc(workflowKitArtifacts.error || 'Failed to load workflow artifact data.')}${hint}</p></div>`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const data = workflowKitArtifacts;
|
|
61
|
+
const artifacts = data.artifacts;
|
|
62
|
+
|
|
63
|
+
// No workflow_kit configured
|
|
64
|
+
if (artifacts === null) {
|
|
65
|
+
return `<div class="placeholder"><h2>Workflow Artifacts</h2><p>No <code>workflow_kit</code> configured in <code>agentxchain.json</code>. Add a <code>workflow_kit</code> section to track phase artifacts.</p></div>`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Phase has no artifacts
|
|
69
|
+
if (!Array.isArray(artifacts) || artifacts.length === 0) {
|
|
70
|
+
return `<div class="placeholder"><h2>Workflow Artifacts</h2><p>Current phase <strong>${esc(data.phase || 'unknown')}</strong> has no workflow-kit artifacts defined.</p></div>`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const missingRequired = artifacts.filter((a) => a.required && !a.exists).length;
|
|
74
|
+
const totalExists = artifacts.filter((a) => a.exists).length;
|
|
75
|
+
|
|
76
|
+
let html = `<div class="artifacts-view">`;
|
|
77
|
+
|
|
78
|
+
// Header
|
|
79
|
+
html += `<div class="run-header"><div class="run-meta">`;
|
|
80
|
+
if (data.phase) {
|
|
81
|
+
html += `<span class="phase-label">Phase: <strong>${esc(data.phase)}</strong></span>`;
|
|
82
|
+
}
|
|
83
|
+
html += `<span class="turn-count">${artifacts.length} artifact${artifacts.length !== 1 ? 's' : ''}</span>`;
|
|
84
|
+
html += `<span class="turn-count">${totalExists}/${artifacts.length} present</span>`;
|
|
85
|
+
if (missingRequired > 0) {
|
|
86
|
+
html += badge(`${missingRequired} missing required`, 'var(--red)');
|
|
87
|
+
}
|
|
88
|
+
html += `</div></div>`;
|
|
89
|
+
|
|
90
|
+
// Table
|
|
91
|
+
html += `<div class="section"><h3>Workflow-Kit Artifacts</h3>
|
|
92
|
+
<table class="data-table">
|
|
93
|
+
<thead>
|
|
94
|
+
<tr>
|
|
95
|
+
<th>Path</th>
|
|
96
|
+
<th>Required</th>
|
|
97
|
+
<th>Semantics</th>
|
|
98
|
+
<th>Owner</th>
|
|
99
|
+
<th>Resolution</th>
|
|
100
|
+
<th>Status</th>
|
|
101
|
+
</tr>
|
|
102
|
+
</thead>
|
|
103
|
+
<tbody>`;
|
|
104
|
+
|
|
105
|
+
for (const artifact of artifacts) {
|
|
106
|
+
html += renderArtifactRow(artifact);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
html += `</tbody></table></div>`;
|
|
110
|
+
|
|
111
|
+
html += `</div>`;
|
|
112
|
+
return html;
|
|
113
|
+
}
|
package/dashboard/index.html
CHANGED
package/package.json
CHANGED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
|
|
3
|
+
import { loadExportArtifact, verifyExportArtifact } from '../lib/export-verifier.js';
|
|
4
|
+
import { restoreRunExport } from '../lib/restore.js';
|
|
5
|
+
|
|
6
|
+
export async function restoreCommand(opts) {
|
|
7
|
+
const input = opts?.input;
|
|
8
|
+
if (!input) {
|
|
9
|
+
console.error('Restore requires --input <path>.');
|
|
10
|
+
process.exitCode = 1;
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const loaded = loadExportArtifact(input, process.cwd());
|
|
15
|
+
if (!loaded.ok) {
|
|
16
|
+
console.error(loaded.error);
|
|
17
|
+
process.exitCode = 1;
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const verification = verifyExportArtifact(loaded.artifact);
|
|
22
|
+
if (!verification.ok) {
|
|
23
|
+
console.error('Restore input failed export verification:');
|
|
24
|
+
for (const error of verification.errors.slice(0, 20)) {
|
|
25
|
+
console.error(`- ${error}`);
|
|
26
|
+
}
|
|
27
|
+
process.exitCode = 1;
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const result = restoreRunExport(process.cwd(), loaded.artifact);
|
|
32
|
+
if (!result.ok) {
|
|
33
|
+
console.error(result.error);
|
|
34
|
+
process.exitCode = 1;
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
console.log(chalk.green(`Restored governed continuity state from ${loaded.input}`));
|
|
39
|
+
console.log(` ${chalk.dim('Run:')} ${result.run_id || 'none'}`);
|
|
40
|
+
console.log(` ${chalk.dim('Status:')} ${result.status || 'unknown'}`);
|
|
41
|
+
console.log(` ${chalk.dim('Files:')} ${result.restored_files}`);
|
|
42
|
+
console.log(` ${chalk.dim('Next:')} agentxchain resume`);
|
|
43
|
+
}
|
|
44
|
+
|
|
@@ -18,6 +18,7 @@ import { readResource } from './state-reader.js';
|
|
|
18
18
|
import { FileWatcher } from './file-watcher.js';
|
|
19
19
|
import { approvePendingDashboardGate } from './actions.js';
|
|
20
20
|
import { readCoordinatorBlockerSnapshot } from './coordinator-blockers.js';
|
|
21
|
+
import { readWorkflowKitArtifacts } from './workflow-kit-artifacts.js';
|
|
21
22
|
|
|
22
23
|
const MIME_TYPES = {
|
|
23
24
|
'.html': 'text/html; charset=utf-8',
|
|
@@ -279,6 +280,12 @@ export function createBridgeServer({ agentxchainDir, dashboardDir, port = 3847 }
|
|
|
279
280
|
return;
|
|
280
281
|
}
|
|
281
282
|
|
|
283
|
+
if (pathname === '/api/workflow-kit-artifacts') {
|
|
284
|
+
const result = readWorkflowKitArtifacts(workspacePath);
|
|
285
|
+
writeJson(res, result.status, result.body);
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
|
|
282
289
|
// API routes
|
|
283
290
|
if (pathname.startsWith('/api/')) {
|
|
284
291
|
const result = readResource(agentxchainDir, pathname);
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workflow-kit artifact status — computed dashboard endpoint.
|
|
3
|
+
*
|
|
4
|
+
* Reads agentxchain.json config and .agentxchain/state.json to derive
|
|
5
|
+
* current-phase workflow-kit artifact status for live dashboard observation.
|
|
6
|
+
*
|
|
7
|
+
* Per DEC-WK-REPORT-002: ownership resolution uses explicit owned_by first,
|
|
8
|
+
* then falls back to routing[phase].entry_role.
|
|
9
|
+
*
|
|
10
|
+
* See: WORKFLOW_KIT_DASHBOARD_SPEC.md
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { existsSync } from 'fs';
|
|
14
|
+
import { join } from 'path';
|
|
15
|
+
import { loadConfig } from '../config.js';
|
|
16
|
+
import { readJsonFile } from './state-reader.js';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Read workflow-kit artifact status for the current phase.
|
|
20
|
+
*
|
|
21
|
+
* @param {string} workspacePath — project root (parent of .agentxchain/)
|
|
22
|
+
* @returns {{ ok: boolean, status: number, body: object }}
|
|
23
|
+
*/
|
|
24
|
+
export function readWorkflowKitArtifacts(workspacePath) {
|
|
25
|
+
const configResult = loadConfig(workspacePath);
|
|
26
|
+
if (!configResult) {
|
|
27
|
+
return {
|
|
28
|
+
ok: false,
|
|
29
|
+
status: 404,
|
|
30
|
+
body: {
|
|
31
|
+
ok: false,
|
|
32
|
+
code: 'config_missing',
|
|
33
|
+
error: 'Project config not found. Run `agentxchain init --governed` first.',
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const agentxchainDir = join(workspacePath, '.agentxchain');
|
|
39
|
+
const state = readJsonFile(agentxchainDir, 'state.json');
|
|
40
|
+
if (!state) {
|
|
41
|
+
return {
|
|
42
|
+
ok: false,
|
|
43
|
+
status: 404,
|
|
44
|
+
body: {
|
|
45
|
+
ok: false,
|
|
46
|
+
code: 'state_missing',
|
|
47
|
+
error: 'Run state not found. Run `agentxchain init --governed` first.',
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const config = configResult.config;
|
|
53
|
+
const phase = state.phase || null;
|
|
54
|
+
|
|
55
|
+
if (!config.workflow_kit) {
|
|
56
|
+
return {
|
|
57
|
+
ok: true,
|
|
58
|
+
status: 200,
|
|
59
|
+
body: {
|
|
60
|
+
ok: true,
|
|
61
|
+
phase,
|
|
62
|
+
artifacts: null,
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (!phase) {
|
|
68
|
+
return {
|
|
69
|
+
ok: true,
|
|
70
|
+
status: 200,
|
|
71
|
+
body: {
|
|
72
|
+
ok: true,
|
|
73
|
+
phase: null,
|
|
74
|
+
artifacts: [],
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const phaseConfig = config.workflow_kit.phases?.[phase];
|
|
80
|
+
if (!phaseConfig) {
|
|
81
|
+
return {
|
|
82
|
+
ok: true,
|
|
83
|
+
status: 200,
|
|
84
|
+
body: {
|
|
85
|
+
ok: true,
|
|
86
|
+
phase,
|
|
87
|
+
artifacts: [],
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const artifacts = Array.isArray(phaseConfig.artifacts) ? phaseConfig.artifacts : [];
|
|
93
|
+
if (artifacts.length === 0) {
|
|
94
|
+
return {
|
|
95
|
+
ok: true,
|
|
96
|
+
status: 200,
|
|
97
|
+
body: {
|
|
98
|
+
ok: true,
|
|
99
|
+
phase,
|
|
100
|
+
artifacts: [],
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const entryRole = config.routing?.[phase]?.entry_role || null;
|
|
106
|
+
|
|
107
|
+
const result = artifacts
|
|
108
|
+
.filter((a) => a && typeof a.path === 'string')
|
|
109
|
+
.map((a) => {
|
|
110
|
+
const hasExplicitOwner = typeof a.owned_by === 'string' && a.owned_by.length > 0;
|
|
111
|
+
return {
|
|
112
|
+
path: a.path,
|
|
113
|
+
required: a.required !== false,
|
|
114
|
+
semantics: a.semantics || null,
|
|
115
|
+
owned_by: hasExplicitOwner ? a.owned_by : entryRole,
|
|
116
|
+
owner_resolution: hasExplicitOwner ? 'explicit' : 'entry_role',
|
|
117
|
+
exists: existsSync(join(workspacePath, a.path)),
|
|
118
|
+
};
|
|
119
|
+
})
|
|
120
|
+
.sort((a, b) => a.path.localeCompare(b.path, 'en'));
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
ok: true,
|
|
124
|
+
status: 200,
|
|
125
|
+
body: {
|
|
126
|
+
ok: true,
|
|
127
|
+
phase,
|
|
128
|
+
artifacts: result,
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
}
|
|
@@ -3,7 +3,7 @@ import { readFileSync } from 'node:fs';
|
|
|
3
3
|
import { resolve } from 'node:path';
|
|
4
4
|
import { isDeepStrictEqual } from 'node:util';
|
|
5
5
|
|
|
6
|
-
const
|
|
6
|
+
const SUPPORTED_EXPORT_SCHEMA_VERSIONS = new Set(['0.2', '0.3']);
|
|
7
7
|
const VALID_FILE_FORMATS = new Set(['json', 'jsonl', 'text']);
|
|
8
8
|
|
|
9
9
|
function sha256(buffer) {
|
|
@@ -51,8 +51,8 @@ function verifyFileEntry(relPath, entry, errors) {
|
|
|
51
51
|
addError(errors, path, 'sha256 must be a 64-character lowercase hex digest');
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
if (typeof entry.content_base64 !== 'string'
|
|
55
|
-
addError(errors, path, 'content_base64 must be a
|
|
54
|
+
if (typeof entry.content_base64 !== 'string') {
|
|
55
|
+
addError(errors, path, 'content_base64 must be a string');
|
|
56
56
|
return;
|
|
57
57
|
}
|
|
58
58
|
|
|
@@ -161,6 +161,29 @@ function verifyRunExport(artifact, errors) {
|
|
|
161
161
|
addError(errors, 'state', 'must match files..agentxchain/state.json.data');
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
+
if ('workspace' in artifact) {
|
|
165
|
+
if (!artifact.workspace || typeof artifact.workspace !== 'object' || Array.isArray(artifact.workspace)) {
|
|
166
|
+
addError(errors, 'workspace', 'must be an object');
|
|
167
|
+
} else {
|
|
168
|
+
const git = artifact.workspace.git;
|
|
169
|
+
if (!git || typeof git !== 'object' || Array.isArray(git)) {
|
|
170
|
+
addError(errors, 'workspace.git', 'must be an object');
|
|
171
|
+
} else {
|
|
172
|
+
if (typeof git.is_repo !== 'boolean') addError(errors, 'workspace.git.is_repo', 'must be a boolean');
|
|
173
|
+
if (git.head_sha !== null && (typeof git.head_sha !== 'string' || git.head_sha.length === 0)) {
|
|
174
|
+
addError(errors, 'workspace.git.head_sha', 'must be a string or null');
|
|
175
|
+
}
|
|
176
|
+
if (!Array.isArray(git.dirty_paths) || git.dirty_paths.some((entry) => typeof entry !== 'string' || entry.length === 0)) {
|
|
177
|
+
addError(errors, 'workspace.git.dirty_paths', 'must be an array of non-empty strings');
|
|
178
|
+
}
|
|
179
|
+
if (typeof git.restore_supported !== 'boolean') addError(errors, 'workspace.git.restore_supported', 'must be a boolean');
|
|
180
|
+
if (!Array.isArray(git.restore_blockers) || git.restore_blockers.some((entry) => typeof entry !== 'string' || entry.length === 0)) {
|
|
181
|
+
addError(errors, 'workspace.git.restore_blockers', 'must be an array of non-empty strings');
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
164
187
|
const activeTurnIds = Object.keys(artifact.state.active_turns || {}).sort((a, b) => a.localeCompare(b, 'en'));
|
|
165
188
|
const retainedTurnIds = Object.keys(artifact.state.retained_turns || {}).sort((a, b) => a.localeCompare(b, 'en'));
|
|
166
189
|
|
|
@@ -340,8 +363,8 @@ export function verifyExportArtifact(artifact) {
|
|
|
340
363
|
};
|
|
341
364
|
}
|
|
342
365
|
|
|
343
|
-
if (artifact.schema_version
|
|
344
|
-
addError(errors, 'schema_version', `must be "${
|
|
366
|
+
if (!SUPPORTED_EXPORT_SCHEMA_VERSIONS.has(artifact.schema_version)) {
|
|
367
|
+
addError(errors, 'schema_version', `must be one of ${[...SUPPORTED_EXPORT_SCHEMA_VERSIONS].map((v) => `"${v}"`).join(', ')}`);
|
|
345
368
|
}
|
|
346
369
|
|
|
347
370
|
if (typeof artifact.export_kind !== 'string') {
|
package/src/lib/export.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { execSync } from 'node:child_process';
|
|
1
2
|
import { createHash } from 'node:crypto';
|
|
2
3
|
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
3
4
|
import { join, relative, resolve } from 'node:path';
|
|
@@ -6,7 +7,7 @@ import { loadProjectContext, loadProjectState } from './config.js';
|
|
|
6
7
|
import { loadCoordinatorConfig, COORDINATOR_CONFIG_FILE } from './coordinator-config.js';
|
|
7
8
|
import { loadCoordinatorState } from './coordinator-state.js';
|
|
8
9
|
|
|
9
|
-
const EXPORT_SCHEMA_VERSION = '0.
|
|
10
|
+
const EXPORT_SCHEMA_VERSION = '0.3';
|
|
10
11
|
|
|
11
12
|
const COORDINATOR_INCLUDED_ROOTS = [
|
|
12
13
|
'agentxchain-multi.json',
|
|
@@ -18,8 +19,9 @@ const COORDINATOR_INCLUDED_ROOTS = [
|
|
|
18
19
|
'.agentxchain/multirepo/RECOVERY_REPORT.md',
|
|
19
20
|
];
|
|
20
21
|
|
|
21
|
-
const
|
|
22
|
+
export const RUN_EXPORT_INCLUDED_ROOTS = [
|
|
22
23
|
'agentxchain.json',
|
|
24
|
+
'TALK.md',
|
|
23
25
|
'.agentxchain/state.json',
|
|
24
26
|
'.agentxchain/history.jsonl',
|
|
25
27
|
'.agentxchain/decision-ledger.jsonl',
|
|
@@ -31,9 +33,40 @@ const INCLUDED_ROOTS = [
|
|
|
31
33
|
'.agentxchain/transactions/accept',
|
|
32
34
|
'.agentxchain/intake',
|
|
33
35
|
'.agentxchain/multirepo',
|
|
36
|
+
'.agentxchain/reviews',
|
|
37
|
+
'.agentxchain/proposed',
|
|
38
|
+
'.agentxchain/reports',
|
|
34
39
|
'.planning',
|
|
35
40
|
];
|
|
36
41
|
|
|
42
|
+
export const RUN_RESTORE_ROOTS = [
|
|
43
|
+
'agentxchain.json',
|
|
44
|
+
'TALK.md',
|
|
45
|
+
'.agentxchain/state.json',
|
|
46
|
+
'.agentxchain/history.jsonl',
|
|
47
|
+
'.agentxchain/decision-ledger.jsonl',
|
|
48
|
+
'.agentxchain/hook-audit.jsonl',
|
|
49
|
+
'.agentxchain/hook-annotations.jsonl',
|
|
50
|
+
'.agentxchain/notification-audit.jsonl',
|
|
51
|
+
'.agentxchain/dispatch',
|
|
52
|
+
'.agentxchain/staging',
|
|
53
|
+
'.agentxchain/transactions/accept',
|
|
54
|
+
'.agentxchain/intake',
|
|
55
|
+
'.agentxchain/multirepo',
|
|
56
|
+
'.agentxchain/reviews',
|
|
57
|
+
'.agentxchain/proposed',
|
|
58
|
+
'.agentxchain/reports',
|
|
59
|
+
'.planning',
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
function pathWithinRoots(relPath, roots) {
|
|
63
|
+
return roots.some((root) => relPath === root || relPath.startsWith(`${root}/`));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function isRunRestorePath(relPath) {
|
|
67
|
+
return pathWithinRoots(relPath, RUN_RESTORE_ROOTS);
|
|
68
|
+
}
|
|
69
|
+
|
|
37
70
|
function sha256(buffer) {
|
|
38
71
|
return createHash('sha256').update(buffer).digest('hex');
|
|
39
72
|
}
|
|
@@ -122,6 +155,95 @@ function countDirectoryFiles(files, prefix) {
|
|
|
122
155
|
return Object.keys(files).filter((path) => path.startsWith(`${prefix}/`)).length;
|
|
123
156
|
}
|
|
124
157
|
|
|
158
|
+
function isGitRepo(root) {
|
|
159
|
+
try {
|
|
160
|
+
execSync('git rev-parse --is-inside-work-tree', {
|
|
161
|
+
cwd: root,
|
|
162
|
+
encoding: 'utf8',
|
|
163
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
164
|
+
});
|
|
165
|
+
return true;
|
|
166
|
+
} catch {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function getGitHeadSha(root) {
|
|
172
|
+
try {
|
|
173
|
+
return execSync('git rev-parse HEAD', {
|
|
174
|
+
cwd: root,
|
|
175
|
+
encoding: 'utf8',
|
|
176
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
177
|
+
}).trim() || null;
|
|
178
|
+
} catch {
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function getWorkingTreeChanges(root) {
|
|
184
|
+
try {
|
|
185
|
+
const tracked = execSync('git diff --name-only HEAD', {
|
|
186
|
+
cwd: root,
|
|
187
|
+
encoding: 'utf8',
|
|
188
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
189
|
+
}).trim();
|
|
190
|
+
const staged = execSync('git diff --name-only --cached', {
|
|
191
|
+
cwd: root,
|
|
192
|
+
encoding: 'utf8',
|
|
193
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
194
|
+
}).trim();
|
|
195
|
+
const untracked = execSync('git ls-files --others --exclude-standard', {
|
|
196
|
+
cwd: root,
|
|
197
|
+
encoding: 'utf8',
|
|
198
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
199
|
+
}).trim();
|
|
200
|
+
|
|
201
|
+
return [...new Set([tracked, staged, untracked]
|
|
202
|
+
.flatMap((chunk) => chunk.split('\n').filter(Boolean)))]
|
|
203
|
+
.sort((a, b) => a.localeCompare(b, 'en'));
|
|
204
|
+
} catch {
|
|
205
|
+
return [];
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function buildRunWorkspaceMetadata(root) {
|
|
210
|
+
if (!isGitRepo(root)) {
|
|
211
|
+
return {
|
|
212
|
+
git: {
|
|
213
|
+
is_repo: false,
|
|
214
|
+
head_sha: null,
|
|
215
|
+
dirty_paths: [],
|
|
216
|
+
restore_supported: false,
|
|
217
|
+
restore_blockers: ['Export restore requires a git-backed checkout on the source machine.'],
|
|
218
|
+
},
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const headSha = getGitHeadSha(root);
|
|
223
|
+
const dirtyPaths = getWorkingTreeChanges(root);
|
|
224
|
+
const restoreBlockers = [];
|
|
225
|
+
|
|
226
|
+
if (!headSha) {
|
|
227
|
+
restoreBlockers.push('Export restore requires a stable git HEAD in the source checkout.');
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
for (const dirtyPath of dirtyPaths) {
|
|
231
|
+
if (!isRunRestorePath(dirtyPath)) {
|
|
232
|
+
restoreBlockers.push(`Dirty path outside governed continuity roots: ${dirtyPath}`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
git: {
|
|
238
|
+
is_repo: true,
|
|
239
|
+
head_sha: headSha,
|
|
240
|
+
dirty_paths: dirtyPaths,
|
|
241
|
+
restore_supported: restoreBlockers.length === 0,
|
|
242
|
+
restore_blockers: restoreBlockers,
|
|
243
|
+
},
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
125
247
|
export function buildRunExport(startDir = process.cwd()) {
|
|
126
248
|
const context = loadProjectContext(startDir);
|
|
127
249
|
if (!context) {
|
|
@@ -141,7 +263,7 @@ export function buildRunExport(startDir = process.cwd()) {
|
|
|
141
263
|
const { root, rawConfig, config, version } = context;
|
|
142
264
|
const state = loadProjectState(root, config);
|
|
143
265
|
|
|
144
|
-
const collectedPaths = [...new Set(
|
|
266
|
+
const collectedPaths = [...new Set(RUN_EXPORT_INCLUDED_ROOTS.flatMap((relPath) => collectPaths(root, relPath)))]
|
|
145
267
|
.sort((a, b) => a.localeCompare(b, 'en'));
|
|
146
268
|
|
|
147
269
|
const files = {};
|
|
@@ -181,6 +303,7 @@ export function buildRunExport(startDir = process.cwd()) {
|
|
|
181
303
|
intake_present: Object.keys(files).some((path) => path.startsWith('.agentxchain/intake/')),
|
|
182
304
|
coordinator_present: Object.keys(files).some((path) => path.startsWith('.agentxchain/multirepo/')),
|
|
183
305
|
},
|
|
306
|
+
workspace: buildRunWorkspaceMetadata(root),
|
|
184
307
|
files,
|
|
185
308
|
config: rawConfig,
|
|
186
309
|
state,
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { execSync } from 'node:child_process';
|
|
2
|
+
import { Buffer } from 'node:buffer';
|
|
3
|
+
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { dirname, join, resolve } from 'node:path';
|
|
5
|
+
|
|
6
|
+
import { loadProjectContext } from './config.js';
|
|
7
|
+
import { RUN_RESTORE_ROOTS, isRunRestorePath } from './export.js';
|
|
8
|
+
|
|
9
|
+
function isGitRepo(root) {
|
|
10
|
+
try {
|
|
11
|
+
execSync('git rev-parse --is-inside-work-tree', {
|
|
12
|
+
cwd: root,
|
|
13
|
+
encoding: 'utf8',
|
|
14
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
15
|
+
});
|
|
16
|
+
return true;
|
|
17
|
+
} catch {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function getHeadSha(root) {
|
|
23
|
+
try {
|
|
24
|
+
return execSync('git rev-parse HEAD', {
|
|
25
|
+
cwd: root,
|
|
26
|
+
encoding: 'utf8',
|
|
27
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
28
|
+
}).trim() || null;
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function getWorkingTreeChanges(root) {
|
|
35
|
+
try {
|
|
36
|
+
const tracked = execSync('git diff --name-only HEAD', {
|
|
37
|
+
cwd: root,
|
|
38
|
+
encoding: 'utf8',
|
|
39
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
40
|
+
}).trim();
|
|
41
|
+
const staged = execSync('git diff --name-only --cached', {
|
|
42
|
+
cwd: root,
|
|
43
|
+
encoding: 'utf8',
|
|
44
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
45
|
+
}).trim();
|
|
46
|
+
const untracked = execSync('git ls-files --others --exclude-standard', {
|
|
47
|
+
cwd: root,
|
|
48
|
+
encoding: 'utf8',
|
|
49
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
50
|
+
}).trim();
|
|
51
|
+
|
|
52
|
+
return [...new Set([tracked, staged, untracked]
|
|
53
|
+
.flatMap((chunk) => chunk.split('\n').filter(Boolean)))];
|
|
54
|
+
} catch {
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function clearRestoreRoots(root) {
|
|
60
|
+
for (const relPath of RUN_RESTORE_ROOTS) {
|
|
61
|
+
rmSync(join(root, relPath), { recursive: true, force: true });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function writeRestoredFiles(root, files) {
|
|
66
|
+
const relPaths = Object.keys(files).sort((a, b) => a.localeCompare(b, 'en'));
|
|
67
|
+
|
|
68
|
+
for (const relPath of relPaths) {
|
|
69
|
+
if (!isRunRestorePath(relPath)) {
|
|
70
|
+
throw new Error(`Export contains non-restorable file "${relPath}"`);
|
|
71
|
+
}
|
|
72
|
+
const entry = files[relPath];
|
|
73
|
+
if (!entry || typeof entry.content_base64 !== 'string') {
|
|
74
|
+
throw new Error(`Export file "${relPath}" is missing content_base64`);
|
|
75
|
+
}
|
|
76
|
+
const absPath = join(root, relPath);
|
|
77
|
+
mkdirSync(dirname(absPath), { recursive: true });
|
|
78
|
+
writeFileSync(absPath, Buffer.from(entry.content_base64, 'base64'));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function restoreRunExport(targetDir, artifact) {
|
|
83
|
+
if (!artifact || typeof artifact !== 'object' || Array.isArray(artifact)) {
|
|
84
|
+
return { ok: false, error: 'Restore input must be a JSON export artifact.' };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (artifact.export_kind !== 'agentxchain_run_export') {
|
|
88
|
+
return { ok: false, error: `Restore only supports run exports in this slice. Got "${artifact.export_kind || 'unknown'}".` };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const workspaceGit = artifact.workspace?.git;
|
|
92
|
+
if (!workspaceGit || typeof workspaceGit !== 'object') {
|
|
93
|
+
return { ok: false, error: 'Export is missing workspace.git metadata required for restore.' };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (workspaceGit.restore_supported !== true) {
|
|
97
|
+
const blockers = Array.isArray(workspaceGit.restore_blockers) ? workspaceGit.restore_blockers : [];
|
|
98
|
+
return {
|
|
99
|
+
ok: false,
|
|
100
|
+
error: blockers.length > 0
|
|
101
|
+
? `Export cannot be restored safely:\n- ${blockers.join('\n- ')}`
|
|
102
|
+
: 'Export cannot be restored safely because restore_supported is false.',
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const context = loadProjectContext(targetDir);
|
|
107
|
+
if (!context || context.config?.protocol_mode !== 'governed') {
|
|
108
|
+
return { ok: false, error: 'Restore target must be a governed project rooted by agentxchain.json.' };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (context.config?.project?.id !== artifact.project?.id) {
|
|
112
|
+
return {
|
|
113
|
+
ok: false,
|
|
114
|
+
error: `Project mismatch: export is "${artifact.project?.id || 'unknown'}" but target is "${context.config?.project?.id || 'unknown'}".`,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (!isGitRepo(context.root)) {
|
|
119
|
+
return { ok: false, error: 'Restore target must be a git-backed checkout.' };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const targetHead = getHeadSha(context.root);
|
|
123
|
+
if (!targetHead || targetHead !== workspaceGit.head_sha) {
|
|
124
|
+
return {
|
|
125
|
+
ok: false,
|
|
126
|
+
error: `Target HEAD mismatch: export expects "${workspaceGit.head_sha || 'unknown'}" but target is "${targetHead || 'unknown'}".`,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const dirtyPaths = getWorkingTreeChanges(context.root);
|
|
131
|
+
if (dirtyPaths.length > 0) {
|
|
132
|
+
return {
|
|
133
|
+
ok: false,
|
|
134
|
+
error: `Restore target must be clean before applying continuity state. Dirty paths: ${dirtyPaths.slice(0, 10).join(', ')}${dirtyPaths.length > 10 ? '...' : ''}`,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const files = artifact.files;
|
|
139
|
+
if (!files || typeof files !== 'object' || Array.isArray(files) || Object.keys(files).length === 0) {
|
|
140
|
+
return { ok: false, error: 'Export is missing restorable files.' };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
clearRestoreRoots(context.root);
|
|
144
|
+
writeRestoredFiles(context.root, files);
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
ok: true,
|
|
148
|
+
root: resolve(context.root),
|
|
149
|
+
run_id: artifact.summary?.run_id || null,
|
|
150
|
+
status: artifact.summary?.status || null,
|
|
151
|
+
restored_files: Object.keys(files).length,
|
|
152
|
+
};
|
|
153
|
+
}
|