agentxchain 2.32.0 → 2.34.2
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 +14 -0
- package/package.json +1 -1
- package/src/commands/restart.js +222 -0
- package/src/commands/restore.js +44 -0
- package/src/lib/export-verifier.js +28 -5
- package/src/lib/export.js +127 -3
- package/src/lib/governed-state.js +12 -0
- package/src/lib/repo-observer.js +1 -0
- package/src/lib/restore.js +153 -0
- package/src/lib/session-checkpoint.js +92 -0
package/bin/agentxchain.js
CHANGED
|
@@ -75,6 +75,8 @@ 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';
|
|
79
|
+
import { restartCommand } from '../src/commands/restart.js';
|
|
78
80
|
import { reportCommand } from '../src/commands/report.js';
|
|
79
81
|
import {
|
|
80
82
|
pluginInstallCommand,
|
|
@@ -139,6 +141,18 @@ program
|
|
|
139
141
|
.option('--output <path>', 'Write the export artifact to a file instead of stdout')
|
|
140
142
|
.action(exportCommand);
|
|
141
143
|
|
|
144
|
+
program
|
|
145
|
+
.command('restore')
|
|
146
|
+
.description('Restore governed continuity roots from a run export artifact')
|
|
147
|
+
.requiredOption('--input <path>', 'Path to a prior run export artifact')
|
|
148
|
+
.action(restoreCommand);
|
|
149
|
+
|
|
150
|
+
program
|
|
151
|
+
.command('restart')
|
|
152
|
+
.description('Restart a governed run from the last checkpoint (cross-session recovery)')
|
|
153
|
+
.option('--role <role>', 'Override the next role assignment')
|
|
154
|
+
.action(restartCommand);
|
|
155
|
+
|
|
142
156
|
program
|
|
143
157
|
.command('report')
|
|
144
158
|
.description('Render a human-readable governance summary from an export artifact')
|
package/package.json
CHANGED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agentxchain restart — governed-only command.
|
|
3
|
+
*
|
|
4
|
+
* Reconstructs dispatch context from durable checkpoint state and assigns the
|
|
5
|
+
* next turn. Unlike `resume` (which assumes the caller has session context),
|
|
6
|
+
* `restart` assumes the caller has NO session context and rebuilds everything
|
|
7
|
+
* from .agentxchain/session.json + .agentxchain/state.json.
|
|
8
|
+
*
|
|
9
|
+
* Use case: terminal died mid-run, machine restarted, new agent session
|
|
10
|
+
* picking up a long-horizon governed run.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import chalk from 'chalk';
|
|
14
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
|
|
15
|
+
import { join, dirname } from 'path';
|
|
16
|
+
import { loadProjectContext, loadProjectState } from '../lib/config.js';
|
|
17
|
+
import {
|
|
18
|
+
assignGovernedTurn,
|
|
19
|
+
getActiveTurns,
|
|
20
|
+
getActiveTurnCount,
|
|
21
|
+
reactivateGovernedRun,
|
|
22
|
+
STATE_PATH,
|
|
23
|
+
HISTORY_PATH,
|
|
24
|
+
LEDGER_PATH,
|
|
25
|
+
} from '../lib/governed-state.js';
|
|
26
|
+
import { readSessionCheckpoint, SESSION_PATH } from '../lib/session-checkpoint.js';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Generate a session recovery report summarizing the run state
|
|
30
|
+
* so a new agent session can orient quickly.
|
|
31
|
+
*/
|
|
32
|
+
function generateRecoveryReport(root, state, checkpoint) {
|
|
33
|
+
const lines = [
|
|
34
|
+
'# Session Recovery Report',
|
|
35
|
+
'',
|
|
36
|
+
`> Auto-generated by \`agentxchain restart\` at ${new Date().toISOString()}`,
|
|
37
|
+
'',
|
|
38
|
+
'## Run Identity',
|
|
39
|
+
'',
|
|
40
|
+
`- **Run ID**: \`${state.run_id}\``,
|
|
41
|
+
`- **Project**: \`${state.project_id || 'unknown'}\``,
|
|
42
|
+
`- **Status**: ${state.status}`,
|
|
43
|
+
`- **Phase**: ${state.phase || state.current_phase || 'unknown'}`,
|
|
44
|
+
'',
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
if (checkpoint) {
|
|
48
|
+
lines.push(
|
|
49
|
+
'## Last Checkpoint',
|
|
50
|
+
'',
|
|
51
|
+
`- **Session**: \`${checkpoint.session_id}\``,
|
|
52
|
+
`- **Last turn**: \`${checkpoint.last_turn_id || 'none'}\``,
|
|
53
|
+
`- **Last role**: ${checkpoint.last_role || 'unknown'}`,
|
|
54
|
+
`- **Reason**: ${checkpoint.checkpoint_reason}`,
|
|
55
|
+
`- **Time**: ${checkpoint.last_checkpoint_at}`,
|
|
56
|
+
'',
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Recent decisions from ledger
|
|
61
|
+
const ledgerPath = join(root, LEDGER_PATH);
|
|
62
|
+
if (existsSync(ledgerPath)) {
|
|
63
|
+
try {
|
|
64
|
+
const entries = readFileSync(ledgerPath, 'utf8')
|
|
65
|
+
.trim()
|
|
66
|
+
.split('\n')
|
|
67
|
+
.filter(Boolean)
|
|
68
|
+
.map(l => JSON.parse(l));
|
|
69
|
+
const recent = entries.slice(-5);
|
|
70
|
+
if (recent.length > 0) {
|
|
71
|
+
lines.push('## Recent Decisions', '');
|
|
72
|
+
for (const entry of recent) {
|
|
73
|
+
lines.push(`- \`${entry.decision_id || entry.id || 'unknown'}\`: ${entry.summary || entry.description || JSON.stringify(entry).slice(0, 100)}`);
|
|
74
|
+
}
|
|
75
|
+
lines.push('');
|
|
76
|
+
}
|
|
77
|
+
} catch {
|
|
78
|
+
// Non-fatal
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Turn history summary
|
|
83
|
+
const historyPath = join(root, HISTORY_PATH);
|
|
84
|
+
if (existsSync(historyPath)) {
|
|
85
|
+
try {
|
|
86
|
+
const entries = readFileSync(historyPath, 'utf8')
|
|
87
|
+
.trim()
|
|
88
|
+
.split('\n')
|
|
89
|
+
.filter(Boolean)
|
|
90
|
+
.map(l => JSON.parse(l));
|
|
91
|
+
lines.push(`## Turn History`, '', `Total turns completed: ${entries.length}`, '');
|
|
92
|
+
const recent = entries.slice(-3);
|
|
93
|
+
if (recent.length > 0) {
|
|
94
|
+
lines.push('### Last 3 Turns', '');
|
|
95
|
+
for (const entry of recent) {
|
|
96
|
+
lines.push(`- **${entry.turn_id}** (${entry.role}, phase: ${entry.phase}): ${entry.status}`);
|
|
97
|
+
}
|
|
98
|
+
lines.push('');
|
|
99
|
+
}
|
|
100
|
+
} catch {
|
|
101
|
+
// Non-fatal
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
lines.push('## Next Steps', '', 'The next turn has been assigned. Check the dispatch bundle for context.', '');
|
|
106
|
+
|
|
107
|
+
return lines.join('\n');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function restartCommand(opts) {
|
|
111
|
+
const context = loadProjectContext();
|
|
112
|
+
if (!context) {
|
|
113
|
+
console.log(chalk.red('No agentxchain.json found. Run `agentxchain init` first.'));
|
|
114
|
+
process.exit(1);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const { root, config } = context;
|
|
118
|
+
|
|
119
|
+
if (config.protocol_mode !== 'governed') {
|
|
120
|
+
console.log(chalk.red('The restart command is only available for governed projects.'));
|
|
121
|
+
process.exit(1);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Load state
|
|
125
|
+
const statePath = join(root, STATE_PATH);
|
|
126
|
+
if (!existsSync(statePath)) {
|
|
127
|
+
console.log(chalk.red('No governed run found. Use `agentxchain resume` or `agentxchain run` to start.'));
|
|
128
|
+
process.exit(1);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const state = JSON.parse(readFileSync(statePath, 'utf8'));
|
|
132
|
+
|
|
133
|
+
// Load checkpoint (optional — restart can work without it, just with less context)
|
|
134
|
+
const checkpoint = readSessionCheckpoint(root);
|
|
135
|
+
|
|
136
|
+
// Validate run_id agreement if checkpoint exists
|
|
137
|
+
if (checkpoint && checkpoint.run_id !== state.run_id) {
|
|
138
|
+
console.log(chalk.red(`Checkpoint run_id mismatch: session.json has ${checkpoint.run_id}, state.json has ${state.run_id}`));
|
|
139
|
+
console.log(chalk.dim('The checkpoint is stale. Proceeding with state.json as ground truth.'));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Check terminal states
|
|
143
|
+
if (state.status === 'completed') {
|
|
144
|
+
console.log(chalk.red('Run is in terminal state: completed.'));
|
|
145
|
+
console.log(chalk.dim(`Run ${state.run_id} completed at ${state.completed_at || 'unknown'}.`));
|
|
146
|
+
process.exit(1);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (state.status === 'failed') {
|
|
150
|
+
console.log(chalk.red('Run is in terminal state: failed.'));
|
|
151
|
+
process.exit(1);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (state.status === 'blocked') {
|
|
155
|
+
console.log(chalk.red('Run is blocked. Resolve the blocker first.'));
|
|
156
|
+
if (state.blocked_reason) {
|
|
157
|
+
console.log(chalk.dim(`Reason: ${typeof state.blocked_reason === 'string' ? state.blocked_reason : JSON.stringify(state.blocked_reason)}`));
|
|
158
|
+
}
|
|
159
|
+
console.log(chalk.dim('Use `agentxchain step --resume` or resolve the blocker, then try again.'));
|
|
160
|
+
process.exit(1);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Handle abandoned active turns (assigned but never completed)
|
|
164
|
+
const activeTurns = getActiveTurns(state);
|
|
165
|
+
const activeTurnCount = getActiveTurnCount(state);
|
|
166
|
+
if (activeTurnCount > 0 && state.status === 'active') {
|
|
167
|
+
const turnIds = Object.keys(activeTurns);
|
|
168
|
+
console.log(chalk.yellow(`Warning: ${activeTurnCount} turn(s) were assigned but never completed: ${turnIds.join(', ')}`));
|
|
169
|
+
console.log(chalk.dim('These turns will be available for the next agent to complete.'));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// If paused, reactivate
|
|
173
|
+
if (state.status === 'paused' || state.status === 'idle') {
|
|
174
|
+
const reactivated = reactivateGovernedRun(root, state, {
|
|
175
|
+
reason: 'session_restart',
|
|
176
|
+
});
|
|
177
|
+
if (!reactivated.ok) {
|
|
178
|
+
console.log(chalk.red(`Failed to reactivate run: ${reactivated.error}`));
|
|
179
|
+
process.exit(1);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Determine role from option or routing
|
|
184
|
+
const phase = state.phase || state.current_phase;
|
|
185
|
+
const routing = config.routing?.[phase];
|
|
186
|
+
const roleId = opts.role || routing?.entry_role || Object.keys(config.roles || {})[0] || null;
|
|
187
|
+
|
|
188
|
+
if (!roleId) {
|
|
189
|
+
console.log(chalk.red('Cannot determine which role to assign. Use --role to specify.'));
|
|
190
|
+
process.exit(1);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Assign next turn if no active turn exists
|
|
194
|
+
if (activeTurnCount === 0) {
|
|
195
|
+
const assignment = assignGovernedTurn(root, config, roleId);
|
|
196
|
+
if (!assignment.ok) {
|
|
197
|
+
console.log(chalk.red(`Failed to assign turn: ${assignment.error}`));
|
|
198
|
+
process.exit(1);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
console.log(chalk.green(`✓ Restarted run ${state.run_id}`));
|
|
202
|
+
console.log(chalk.dim(` Phase: ${phase}`));
|
|
203
|
+
console.log(chalk.dim(` Turn: ${assignment.turn?.id || 'assigned'}`));
|
|
204
|
+
console.log(chalk.dim(` Role: ${assignment.turn?.role || roleId || 'routing default'}`));
|
|
205
|
+
if (checkpoint) {
|
|
206
|
+
console.log(chalk.dim(` Last checkpoint: ${checkpoint.checkpoint_reason} at ${checkpoint.last_checkpoint_at}`));
|
|
207
|
+
}
|
|
208
|
+
} else {
|
|
209
|
+
console.log(chalk.green(`✓ Reconnected to run ${state.run_id}`));
|
|
210
|
+
console.log(chalk.dim(` Phase: ${phase}`));
|
|
211
|
+
console.log(chalk.dim(` Active turns: ${Object.keys(activeTurns).join(', ')}`));
|
|
212
|
+
console.log(chalk.dim(' Complete the active turn(s) with `agentxchain accept-turn` or `agentxchain step`.'));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Write session recovery report
|
|
216
|
+
const recoveryReport = generateRecoveryReport(root, state, checkpoint);
|
|
217
|
+
const recoveryPath = join(root, '.agentxchain/SESSION_RECOVERY.md');
|
|
218
|
+
const recoveryDir = dirname(recoveryPath);
|
|
219
|
+
if (!existsSync(recoveryDir)) mkdirSync(recoveryDir, { recursive: true });
|
|
220
|
+
writeFileSync(recoveryPath, recoveryReport);
|
|
221
|
+
console.log(chalk.dim(` Recovery report: .agentxchain/SESSION_RECOVERY.md`));
|
|
222
|
+
}
|
|
@@ -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
|
+
|
|
@@ -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,41 @@ 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/session.json',
|
|
47
|
+
'.agentxchain/history.jsonl',
|
|
48
|
+
'.agentxchain/decision-ledger.jsonl',
|
|
49
|
+
'.agentxchain/hook-audit.jsonl',
|
|
50
|
+
'.agentxchain/hook-annotations.jsonl',
|
|
51
|
+
'.agentxchain/notification-audit.jsonl',
|
|
52
|
+
'.agentxchain/dispatch',
|
|
53
|
+
'.agentxchain/staging',
|
|
54
|
+
'.agentxchain/transactions/accept',
|
|
55
|
+
'.agentxchain/intake',
|
|
56
|
+
'.agentxchain/multirepo',
|
|
57
|
+
'.agentxchain/reviews',
|
|
58
|
+
'.agentxchain/proposed',
|
|
59
|
+
'.agentxchain/reports',
|
|
60
|
+
'.planning',
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
function pathWithinRoots(relPath, roots) {
|
|
64
|
+
return roots.some((root) => relPath === root || relPath.startsWith(`${root}/`));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function isRunRestorePath(relPath) {
|
|
68
|
+
return pathWithinRoots(relPath, RUN_RESTORE_ROOTS);
|
|
69
|
+
}
|
|
70
|
+
|
|
37
71
|
function sha256(buffer) {
|
|
38
72
|
return createHash('sha256').update(buffer).digest('hex');
|
|
39
73
|
}
|
|
@@ -122,6 +156,95 @@ function countDirectoryFiles(files, prefix) {
|
|
|
122
156
|
return Object.keys(files).filter((path) => path.startsWith(`${prefix}/`)).length;
|
|
123
157
|
}
|
|
124
158
|
|
|
159
|
+
function isGitRepo(root) {
|
|
160
|
+
try {
|
|
161
|
+
execSync('git rev-parse --is-inside-work-tree', {
|
|
162
|
+
cwd: root,
|
|
163
|
+
encoding: 'utf8',
|
|
164
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
165
|
+
});
|
|
166
|
+
return true;
|
|
167
|
+
} catch {
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function getGitHeadSha(root) {
|
|
173
|
+
try {
|
|
174
|
+
return execSync('git rev-parse HEAD', {
|
|
175
|
+
cwd: root,
|
|
176
|
+
encoding: 'utf8',
|
|
177
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
178
|
+
}).trim() || null;
|
|
179
|
+
} catch {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function getWorkingTreeChanges(root) {
|
|
185
|
+
try {
|
|
186
|
+
const tracked = execSync('git diff --name-only HEAD', {
|
|
187
|
+
cwd: root,
|
|
188
|
+
encoding: 'utf8',
|
|
189
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
190
|
+
}).trim();
|
|
191
|
+
const staged = execSync('git diff --name-only --cached', {
|
|
192
|
+
cwd: root,
|
|
193
|
+
encoding: 'utf8',
|
|
194
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
195
|
+
}).trim();
|
|
196
|
+
const untracked = execSync('git ls-files --others --exclude-standard', {
|
|
197
|
+
cwd: root,
|
|
198
|
+
encoding: 'utf8',
|
|
199
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
200
|
+
}).trim();
|
|
201
|
+
|
|
202
|
+
return [...new Set([tracked, staged, untracked]
|
|
203
|
+
.flatMap((chunk) => chunk.split('\n').filter(Boolean)))]
|
|
204
|
+
.sort((a, b) => a.localeCompare(b, 'en'));
|
|
205
|
+
} catch {
|
|
206
|
+
return [];
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function buildRunWorkspaceMetadata(root) {
|
|
211
|
+
if (!isGitRepo(root)) {
|
|
212
|
+
return {
|
|
213
|
+
git: {
|
|
214
|
+
is_repo: false,
|
|
215
|
+
head_sha: null,
|
|
216
|
+
dirty_paths: [],
|
|
217
|
+
restore_supported: false,
|
|
218
|
+
restore_blockers: ['Export restore requires a git-backed checkout on the source machine.'],
|
|
219
|
+
},
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const headSha = getGitHeadSha(root);
|
|
224
|
+
const dirtyPaths = getWorkingTreeChanges(root);
|
|
225
|
+
const restoreBlockers = [];
|
|
226
|
+
|
|
227
|
+
if (!headSha) {
|
|
228
|
+
restoreBlockers.push('Export restore requires a stable git HEAD in the source checkout.');
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
for (const dirtyPath of dirtyPaths) {
|
|
232
|
+
if (!isRunRestorePath(dirtyPath)) {
|
|
233
|
+
restoreBlockers.push(`Dirty path outside governed continuity roots: ${dirtyPath}`);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return {
|
|
238
|
+
git: {
|
|
239
|
+
is_repo: true,
|
|
240
|
+
head_sha: headSha,
|
|
241
|
+
dirty_paths: dirtyPaths,
|
|
242
|
+
restore_supported: restoreBlockers.length === 0,
|
|
243
|
+
restore_blockers: restoreBlockers,
|
|
244
|
+
},
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
125
248
|
export function buildRunExport(startDir = process.cwd()) {
|
|
126
249
|
const context = loadProjectContext(startDir);
|
|
127
250
|
if (!context) {
|
|
@@ -141,7 +264,7 @@ export function buildRunExport(startDir = process.cwd()) {
|
|
|
141
264
|
const { root, rawConfig, config, version } = context;
|
|
142
265
|
const state = loadProjectState(root, config);
|
|
143
266
|
|
|
144
|
-
const collectedPaths = [...new Set(
|
|
267
|
+
const collectedPaths = [...new Set(RUN_EXPORT_INCLUDED_ROOTS.flatMap((relPath) => collectPaths(root, relPath)))]
|
|
145
268
|
.sort((a, b) => a.localeCompare(b, 'en'));
|
|
146
269
|
|
|
147
270
|
const files = {};
|
|
@@ -181,6 +304,7 @@ export function buildRunExport(startDir = process.cwd()) {
|
|
|
181
304
|
intake_present: Object.keys(files).some((path) => path.startsWith('.agentxchain/intake/')),
|
|
182
305
|
coordinator_present: Object.keys(files).some((path) => path.startsWith('.agentxchain/multirepo/')),
|
|
183
306
|
},
|
|
307
|
+
workspace: buildRunWorkspaceMetadata(root),
|
|
184
308
|
files,
|
|
185
309
|
config: rawConfig,
|
|
186
310
|
state,
|
|
@@ -35,6 +35,7 @@ import { getMaxConcurrentTurns } from './normalized-config.js';
|
|
|
35
35
|
import { getTurnStagingResultPath, getTurnStagingDir, getDispatchTurnDir, getReviewArtifactPath } from './turn-paths.js';
|
|
36
36
|
import { runHooks } from './hook-runner.js';
|
|
37
37
|
import { emitNotifications } from './notification-runner.js';
|
|
38
|
+
import { writeSessionCheckpoint } from './session-checkpoint.js';
|
|
38
39
|
|
|
39
40
|
// ── Constants ────────────────────────────────────────────────────────────────
|
|
40
41
|
|
|
@@ -2483,6 +2484,11 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
2483
2484
|
}, currentTurn);
|
|
2484
2485
|
}
|
|
2485
2486
|
|
|
2487
|
+
// Session checkpoint — non-fatal, written after every successful acceptance
|
|
2488
|
+
writeSessionCheckpoint(root, updatedState, 'turn_accepted', {
|
|
2489
|
+
role: historyEntry?.role,
|
|
2490
|
+
});
|
|
2491
|
+
|
|
2486
2492
|
return {
|
|
2487
2493
|
ok: true,
|
|
2488
2494
|
state: attachLegacyCurrentTurnAlias(updatedState),
|
|
@@ -2791,6 +2797,9 @@ export function approvePhaseTransition(root, config) {
|
|
|
2791
2797
|
|
|
2792
2798
|
writeState(root, updatedState);
|
|
2793
2799
|
|
|
2800
|
+
// Session checkpoint — non-fatal
|
|
2801
|
+
writeSessionCheckpoint(root, updatedState, 'phase_approved');
|
|
2802
|
+
|
|
2794
2803
|
return {
|
|
2795
2804
|
ok: true,
|
|
2796
2805
|
state: attachLegacyCurrentTurnAlias(updatedState),
|
|
@@ -2889,6 +2898,9 @@ export function approveRunCompletion(root, config) {
|
|
|
2889
2898
|
requested_by_turn: completion.requested_by_turn || null,
|
|
2890
2899
|
}, completion.requested_by_turn ? getActiveTurns(state)[completion.requested_by_turn] || null : null);
|
|
2891
2900
|
|
|
2901
|
+
// Session checkpoint — non-fatal
|
|
2902
|
+
writeSessionCheckpoint(root, updatedState, 'run_completed');
|
|
2903
|
+
|
|
2892
2904
|
return {
|
|
2893
2905
|
ok: true,
|
|
2894
2906
|
state: attachLegacyCurrentTurnAlias(updatedState),
|
package/src/lib/repo-observer.js
CHANGED
|
@@ -34,6 +34,7 @@ const OPERATIONAL_PATH_PREFIXES = [
|
|
|
34
34
|
// These are written exclusively by the orchestrator (§4.1 State Ownership Rule).
|
|
35
35
|
const ORCHESTRATOR_STATE_FILES = [
|
|
36
36
|
'.agentxchain/state.json',
|
|
37
|
+
'.agentxchain/session.json',
|
|
37
38
|
'.agentxchain/history.jsonl',
|
|
38
39
|
'.agentxchain/decision-ledger.jsonl',
|
|
39
40
|
'.agentxchain/lock.json',
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session checkpoint — automatic state markers for cross-session restart.
|
|
3
|
+
*
|
|
4
|
+
* Writes .agentxchain/session.json at every governance boundary
|
|
5
|
+
* (turn acceptance, phase transition, gate approval, run completion)
|
|
6
|
+
* so that `agentxchain restart` can reconstruct dispatch context
|
|
7
|
+
* without any in-memory session state.
|
|
8
|
+
*
|
|
9
|
+
* Design rules:
|
|
10
|
+
* - Checkpoint writes are non-fatal: failures log a warning and do not
|
|
11
|
+
* block the governance operation.
|
|
12
|
+
* - The file is always overwritten, not appended.
|
|
13
|
+
* - run_id in session.json must agree with state.json; mismatch is a
|
|
14
|
+
* corruption signal.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'fs';
|
|
18
|
+
import { join, dirname } from 'path';
|
|
19
|
+
import { randomBytes } from 'crypto';
|
|
20
|
+
|
|
21
|
+
const SESSION_PATH = '.agentxchain/session.json';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Generate a new session ID.
|
|
25
|
+
*/
|
|
26
|
+
function generateSessionId() {
|
|
27
|
+
return `session_${randomBytes(8).toString('hex')}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Read the current session checkpoint, or null if none exists.
|
|
32
|
+
*/
|
|
33
|
+
export function readSessionCheckpoint(root) {
|
|
34
|
+
const filePath = join(root, SESSION_PATH);
|
|
35
|
+
if (!existsSync(filePath)) return null;
|
|
36
|
+
try {
|
|
37
|
+
return JSON.parse(readFileSync(filePath, 'utf8'));
|
|
38
|
+
} catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Write or update the session checkpoint.
|
|
45
|
+
*
|
|
46
|
+
* @param {string} root - project root
|
|
47
|
+
* @param {object} state - current governed state (from state.json)
|
|
48
|
+
* @param {string} reason - checkpoint reason (e.g. 'turn_accepted', 'phase_approved', 'run_completed')
|
|
49
|
+
* @param {object} [extra] - optional extra context fields
|
|
50
|
+
*/
|
|
51
|
+
export function writeSessionCheckpoint(root, state, reason, extra = {}) {
|
|
52
|
+
const filePath = join(root, SESSION_PATH);
|
|
53
|
+
try {
|
|
54
|
+
// Read existing session to preserve session_id across checkpoints in the same session
|
|
55
|
+
const existing = readSessionCheckpoint(root);
|
|
56
|
+
const sessionId = (existing && existing.run_id === state.run_id)
|
|
57
|
+
? existing.session_id
|
|
58
|
+
: generateSessionId();
|
|
59
|
+
|
|
60
|
+
const currentTurn = state.current_turn || null;
|
|
61
|
+
const lastTurnId = currentTurn?.id || currentTurn?.turn_id || state.last_completed_turn_id || null;
|
|
62
|
+
const lastRole = currentTurn?.role || currentTurn?.assigned_role || extra.role || null;
|
|
63
|
+
const lastPhase = state.current_phase || state.phase || null;
|
|
64
|
+
|
|
65
|
+
const checkpoint = {
|
|
66
|
+
session_id: sessionId,
|
|
67
|
+
run_id: state.run_id,
|
|
68
|
+
started_at: existing?.started_at || new Date().toISOString(),
|
|
69
|
+
last_checkpoint_at: new Date().toISOString(),
|
|
70
|
+
last_turn_id: lastTurnId,
|
|
71
|
+
last_phase: lastPhase,
|
|
72
|
+
last_role: lastRole,
|
|
73
|
+
run_status: state.status || null,
|
|
74
|
+
checkpoint_reason: reason,
|
|
75
|
+
agent_context: {
|
|
76
|
+
adapter: extra.adapter || null,
|
|
77
|
+
dispatch_dir: extra.dispatch_dir || null,
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const dir = dirname(filePath);
|
|
82
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
83
|
+
writeFileSync(filePath, JSON.stringify(checkpoint, null, 2) + '\n');
|
|
84
|
+
} catch (err) {
|
|
85
|
+
// Non-fatal — warn but do not block governance operations
|
|
86
|
+
if (process.env.AGENTXCHAIN_DEBUG) {
|
|
87
|
+
console.error(`[session-checkpoint] Warning: failed to write checkpoint: ${err.message}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export { SESSION_PATH };
|