agentxchain 2.139.0 → 2.141.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 +1 -0
- package/package.json +1 -1
- package/scripts/release-preflight.sh +1 -1
- package/src/commands/intake-resolve.js +5 -1
- package/src/lib/governed-state.js +164 -6
- package/src/lib/intake.js +33 -1
- package/src/lib/repo-observer.js +5 -1
- package/src/lib/verification-replay.js +199 -5
package/bin/agentxchain.js
CHANGED
|
@@ -1031,6 +1031,7 @@ intakeCmd
|
|
|
1031
1031
|
.command('resolve')
|
|
1032
1032
|
.description('Resolve an executing intent by reading the governed run outcome')
|
|
1033
1033
|
.option('--intent <id>', 'Intent ID to resolve')
|
|
1034
|
+
.option('--outcome <status>', 'Force transition to this status (e.g., "completed")')
|
|
1034
1035
|
.option('-j, --json', 'Output as JSON')
|
|
1035
1036
|
.action(intakeResolveCommand);
|
|
1036
1037
|
|
package/package.json
CHANGED
|
@@ -153,7 +153,7 @@ if [[ "$PUBLISH_GATE" -eq 1 ]]; then
|
|
|
153
153
|
test/release-notes-gate.test.js
|
|
154
154
|
test/release-identity-hardening.test.js
|
|
155
155
|
test/normalized-config.test.js
|
|
156
|
-
test/conformance.test.js
|
|
156
|
+
test/protocol-conformance.test.js
|
|
157
157
|
test/beta-scenario-emission-guard.test.js
|
|
158
158
|
test/claim-reality-preflight.test.js
|
|
159
159
|
test/beta-tester-scenarios/*.test.js
|
|
@@ -15,7 +15,11 @@ export async function intakeResolveCommand(opts) {
|
|
|
15
15
|
process.exit(1);
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
const
|
|
18
|
+
const resolveOpts = {};
|
|
19
|
+
if (opts.outcome) {
|
|
20
|
+
resolveOpts.outcome = opts.outcome;
|
|
21
|
+
}
|
|
22
|
+
const result = resolveIntent(root, opts.intent, resolveOpts);
|
|
19
23
|
|
|
20
24
|
if (opts.json) {
|
|
21
25
|
console.log(JSON.stringify(result, null, 2));
|
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
deriveAcceptedRef,
|
|
38
38
|
checkCleanBaseline,
|
|
39
39
|
isOperationalPath,
|
|
40
|
+
isBaselineExemptPath,
|
|
40
41
|
} from './repo-observer.js';
|
|
41
42
|
import { getMaxConcurrentTurns } from './normalized-config.js';
|
|
42
43
|
import { getTurnStagingResultPath, getTurnStagingDir, getDispatchTurnDir, getReviewArtifactPath } from './turn-paths.js';
|
|
@@ -106,6 +107,34 @@ function buildInitialPhaseGateStatus(config) {
|
|
|
106
107
|
);
|
|
107
108
|
}
|
|
108
109
|
|
|
110
|
+
function buildObservationDrift(beforeObservation, afterObservation) {
|
|
111
|
+
const beforeFiles = (Array.isArray(beforeObservation?.files_changed) ? beforeObservation.files_changed : [])
|
|
112
|
+
.filter((filePath) => !isBaselineExemptPath(filePath));
|
|
113
|
+
const afterFiles = (Array.isArray(afterObservation?.files_changed) ? afterObservation.files_changed : [])
|
|
114
|
+
.filter((filePath) => !isBaselineExemptPath(filePath));
|
|
115
|
+
const beforeSet = new Set(beforeFiles);
|
|
116
|
+
const afterSet = new Set(afterFiles);
|
|
117
|
+
const beforeMarkers = beforeObservation?.file_markers && typeof beforeObservation.file_markers === 'object'
|
|
118
|
+
? beforeObservation.file_markers
|
|
119
|
+
: {};
|
|
120
|
+
const afterMarkers = afterObservation?.file_markers && typeof afterObservation.file_markers === 'object'
|
|
121
|
+
? afterObservation.file_markers
|
|
122
|
+
: {};
|
|
123
|
+
|
|
124
|
+
const added = afterFiles.filter((filePath) => !beforeSet.has(filePath));
|
|
125
|
+
const removed = beforeFiles.filter((filePath) => !afterSet.has(filePath));
|
|
126
|
+
const markerChanged = beforeFiles.filter((filePath) => (
|
|
127
|
+
afterSet.has(filePath) && beforeMarkers[filePath] !== afterMarkers[filePath]
|
|
128
|
+
));
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
added,
|
|
132
|
+
removed,
|
|
133
|
+
marker_changed: markerChanged,
|
|
134
|
+
drifted_files: [...new Set([...added, ...removed, ...markerChanged])].sort(),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
109
138
|
function listIntakeIntentFiles(root) {
|
|
110
139
|
const intentsDir = join(root, INTAKE_INTENTS_DIR);
|
|
111
140
|
if (!existsSync(intentsDir)) return [];
|
|
@@ -115,6 +144,50 @@ function listIntakeIntentFiles(root) {
|
|
|
115
144
|
.map((name) => join(intentsDir, name));
|
|
116
145
|
}
|
|
117
146
|
|
|
147
|
+
// BUG-45: Reconcile embedded intake_context against the live intent file.
|
|
148
|
+
// The embedded copy is historical state; the live intent file is authoritative.
|
|
149
|
+
// If the live intent cannot be read, acceptance must fail closed instead of
|
|
150
|
+
// silently reusing the stale embedded contract.
|
|
151
|
+
const INTENT_TERMINAL_STATES = ['completed', 'satisfied', 'superseded', 'suppressed', 'failed', 'rejected'];
|
|
152
|
+
|
|
153
|
+
function reconcileIntakeContext(root, intakeCtx) {
|
|
154
|
+
if (!intakeCtx || !intakeCtx.intent_id) {
|
|
155
|
+
return { ok: true, intakeCtx };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
try {
|
|
159
|
+
const intentPath = join(root, INTAKE_INTENTS_DIR, `${intakeCtx.intent_id}.json`);
|
|
160
|
+
if (!existsSync(intentPath)) {
|
|
161
|
+
return {
|
|
162
|
+
ok: false,
|
|
163
|
+
error: `Intent reconciliation failed: live intent ${intakeCtx.intent_id} not found at ${INTAKE_INTENTS_DIR}/${intakeCtx.intent_id}.json`,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const liveIntent = JSON.parse(readFileSync(intentPath, 'utf8'));
|
|
168
|
+
|
|
169
|
+
// If the intent has reached a terminal state, skip coverage enforcement
|
|
170
|
+
if (INTENT_TERMINAL_STATES.includes(liveIntent.status)) {
|
|
171
|
+
return { ok: true, intakeCtx: null };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Intent is still active — use the CURRENT acceptance_contract from disk
|
|
175
|
+
if (Array.isArray(liveIntent.acceptance_contract)) {
|
|
176
|
+
return {
|
|
177
|
+
ok: true,
|
|
178
|
+
intakeCtx: { ...intakeCtx, acceptance_contract: liveIntent.acceptance_contract },
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return { ok: true, intakeCtx };
|
|
183
|
+
} catch (error) {
|
|
184
|
+
return {
|
|
185
|
+
ok: false,
|
|
186
|
+
error: `Intent reconciliation failed: could not read live intent ${intakeCtx.intent_id}: ${error.message}`,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
118
191
|
function retireApprovedPhaseScopedIntents(root, state, config, exitedPhase, now) {
|
|
119
192
|
const retired = [];
|
|
120
193
|
|
|
@@ -3166,11 +3239,17 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
3166
3239
|
currentTurn,
|
|
3167
3240
|
historyEntries,
|
|
3168
3241
|
);
|
|
3169
|
-
const
|
|
3242
|
+
const observationAttributionOptions = {
|
|
3170
3243
|
currentDeclaredFiles: turnResult.files_changed || [],
|
|
3171
3244
|
concurrentSiblingIds: pendingConcurrentSiblingDeclarations.map((entry) => entry.turn_id),
|
|
3172
3245
|
pendingConcurrentSiblingDeclarations,
|
|
3173
|
-
}
|
|
3246
|
+
};
|
|
3247
|
+
const observation = attributeObservedChangesToTurn(
|
|
3248
|
+
rawObservation,
|
|
3249
|
+
currentTurn,
|
|
3250
|
+
historyEntries,
|
|
3251
|
+
observationAttributionOptions,
|
|
3252
|
+
);
|
|
3174
3253
|
const role = config.roles?.[turnResult.role];
|
|
3175
3254
|
const runtimeId = turnResult.runtime_id;
|
|
3176
3255
|
const runtime = config.runtimes?.[runtimeId];
|
|
@@ -3222,10 +3301,33 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
3222
3301
|
};
|
|
3223
3302
|
}
|
|
3224
3303
|
|
|
3225
|
-
// ──
|
|
3226
|
-
//
|
|
3227
|
-
//
|
|
3228
|
-
|
|
3304
|
+
// ── BUG-45: Reconcile intake_context against live intent state ──────────
|
|
3305
|
+
// The embedded intake_context is a snapshot from dispatch time. The intent
|
|
3306
|
+
// may have since been completed, satisfied, or had its contract updated.
|
|
3307
|
+
// Re-read the live intent file and reconcile before evaluating coverage.
|
|
3308
|
+
const intakeReconciliation = reconcileIntakeContext(root, currentTurn.intake_context);
|
|
3309
|
+
if (!intakeReconciliation.ok) {
|
|
3310
|
+
transitionToFailedAcceptance(root, state, currentTurn, intakeReconciliation.error, {
|
|
3311
|
+
error_code: 'intent_reconciliation_failed',
|
|
3312
|
+
stage: 'intent_reconciliation',
|
|
3313
|
+
extra: {
|
|
3314
|
+
intent_id: currentTurn.intake_context?.intent_id || null,
|
|
3315
|
+
},
|
|
3316
|
+
});
|
|
3317
|
+
return {
|
|
3318
|
+
ok: false,
|
|
3319
|
+
error: intakeReconciliation.error,
|
|
3320
|
+
validation: {
|
|
3321
|
+
...validation,
|
|
3322
|
+
ok: false,
|
|
3323
|
+
stage: 'intent_reconciliation',
|
|
3324
|
+
error_class: 'intent_reconciliation_error',
|
|
3325
|
+
errors: [intakeReconciliation.error],
|
|
3326
|
+
warnings: validation.warnings,
|
|
3327
|
+
},
|
|
3328
|
+
};
|
|
3329
|
+
}
|
|
3330
|
+
const intakeCtx = intakeReconciliation.intakeCtx;
|
|
3229
3331
|
if (intakeCtx && Array.isArray(intakeCtx.acceptance_contract) && intakeCtx.acceptance_contract.length > 0) {
|
|
3230
3332
|
const intentCoverage = evaluateIntentCoverage(turnResult, intakeCtx, {
|
|
3231
3333
|
state,
|
|
@@ -3336,6 +3438,30 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
3336
3438
|
? replayVerificationMachineEvidence({ root, verification: turnResult.verification })
|
|
3337
3439
|
: null;
|
|
3338
3440
|
|
|
3441
|
+
if (verificationReplay?.workspace_guard?.ok === false) {
|
|
3442
|
+
const replayError = verificationReplay.workspace_guard.cleanup_error
|
|
3443
|
+
|| 'Verification replay mutated actor-owned workspace files and cleanup failed.';
|
|
3444
|
+
transitionToFailedAcceptance(root, state, currentTurn, replayError, {
|
|
3445
|
+
error_code: 'verification_replay_drift',
|
|
3446
|
+
stage: 'verification_replay',
|
|
3447
|
+
extra: {
|
|
3448
|
+
replay_side_effect_files: verificationReplay.workspace_guard.restored_files || [],
|
|
3449
|
+
},
|
|
3450
|
+
});
|
|
3451
|
+
return {
|
|
3452
|
+
ok: false,
|
|
3453
|
+
error: replayError,
|
|
3454
|
+
validation: {
|
|
3455
|
+
...validation,
|
|
3456
|
+
ok: false,
|
|
3457
|
+
stage: 'verification_replay',
|
|
3458
|
+
error_class: 'verification_replay_error',
|
|
3459
|
+
errors: [replayError],
|
|
3460
|
+
warnings: validation.warnings,
|
|
3461
|
+
},
|
|
3462
|
+
};
|
|
3463
|
+
}
|
|
3464
|
+
|
|
3339
3465
|
// Policy evaluation — declarative governance rules (spec: POLICY_ENGINE_SPEC.md)
|
|
3340
3466
|
const policyResult = evaluatePolicies(config.policies || [], {
|
|
3341
3467
|
currentPhase: state.phase,
|
|
@@ -3571,6 +3697,38 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
3571
3697
|
}
|
|
3572
3698
|
}
|
|
3573
3699
|
|
|
3700
|
+
const finalObservation = attributeObservedChangesToTurn(
|
|
3701
|
+
observeChanges(root, baseline),
|
|
3702
|
+
currentTurn,
|
|
3703
|
+
historyEntries,
|
|
3704
|
+
observationAttributionOptions,
|
|
3705
|
+
);
|
|
3706
|
+
const observationDrift = buildObservationDrift(observation, finalObservation);
|
|
3707
|
+
if (observationDrift.drifted_files.length > 0) {
|
|
3708
|
+
const driftError = `Acceptance mutated actor-owned workspace after artifact observation: ${observationDrift.drifted_files.join(', ')}`;
|
|
3709
|
+
transitionToFailedAcceptance(root, state, currentTurn, driftError, {
|
|
3710
|
+
error_code: 'acceptance_drift',
|
|
3711
|
+
stage: 'artifact_observation',
|
|
3712
|
+
extra: {
|
|
3713
|
+
added_files: observationDrift.added,
|
|
3714
|
+
removed_files: observationDrift.removed,
|
|
3715
|
+
marker_changed_files: observationDrift.marker_changed,
|
|
3716
|
+
},
|
|
3717
|
+
});
|
|
3718
|
+
return {
|
|
3719
|
+
ok: false,
|
|
3720
|
+
error: driftError,
|
|
3721
|
+
validation: {
|
|
3722
|
+
...validation,
|
|
3723
|
+
ok: false,
|
|
3724
|
+
stage: 'artifact_observation',
|
|
3725
|
+
error_class: 'artifact_error',
|
|
3726
|
+
errors: [driftError],
|
|
3727
|
+
warnings: validation.warnings,
|
|
3728
|
+
},
|
|
3729
|
+
};
|
|
3730
|
+
}
|
|
3731
|
+
|
|
3574
3732
|
const acceptedSequence = (state.turn_sequence || 0) + 1;
|
|
3575
3733
|
const historyEntry = {
|
|
3576
3734
|
turn_id: turnResult.turn_id,
|
package/src/lib/intake.js
CHANGED
|
@@ -1270,7 +1270,7 @@ export function handoffIntent(root, intentId, options = {}) {
|
|
|
1270
1270
|
// Resolve — execution exit and intent closure linkage (V3-S5)
|
|
1271
1271
|
// ---------------------------------------------------------------------------
|
|
1272
1272
|
|
|
1273
|
-
export function resolveIntent(root, intentId) {
|
|
1273
|
+
export function resolveIntent(root, intentId, opts = {}) {
|
|
1274
1274
|
const loadedIntent = readIntent(root, intentId);
|
|
1275
1275
|
if (!loadedIntent.ok) {
|
|
1276
1276
|
return loadedIntent;
|
|
@@ -1298,6 +1298,38 @@ export function resolveIntent(root, intentId) {
|
|
|
1298
1298
|
};
|
|
1299
1299
|
}
|
|
1300
1300
|
|
|
1301
|
+
// BUG-45: operator-forced completion — transition executing → completed
|
|
1302
|
+
// when the operator knows the work is done but the framework hasn't retired
|
|
1303
|
+
// the intent automatically (e.g., retained-turn deadlock recovery).
|
|
1304
|
+
if (opts.outcome === 'completed' && intent.status === 'executing') {
|
|
1305
|
+
const now = nowISO();
|
|
1306
|
+
const previousStatus = intent.status;
|
|
1307
|
+
intent.status = 'completed';
|
|
1308
|
+
intent.completed_at = now;
|
|
1309
|
+
intent.updated_at = now;
|
|
1310
|
+
if (!Array.isArray(intent.history)) intent.history = [];
|
|
1311
|
+
intent.history.push({
|
|
1312
|
+
from: previousStatus,
|
|
1313
|
+
to: 'completed',
|
|
1314
|
+
at: now,
|
|
1315
|
+
reason: opts.reason || 'operator-resolved: intent marked completed via intake resolve --outcome completed',
|
|
1316
|
+
});
|
|
1317
|
+
|
|
1318
|
+
const obsDir = join(dirs.base, 'observations', intent.intent_id);
|
|
1319
|
+
mkdirSync(obsDir, { recursive: true });
|
|
1320
|
+
|
|
1321
|
+
safeWriteJson(intentPath, intent);
|
|
1322
|
+
return {
|
|
1323
|
+
ok: true,
|
|
1324
|
+
intent,
|
|
1325
|
+
previous_status: previousStatus,
|
|
1326
|
+
new_status: 'completed',
|
|
1327
|
+
run_outcome: 'completed',
|
|
1328
|
+
no_change: false,
|
|
1329
|
+
exitCode: 0,
|
|
1330
|
+
};
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1301
1333
|
if (intent.target_workstream) {
|
|
1302
1334
|
return resolveCoordinatorBackedIntent(root, intentPath, dirs, intent);
|
|
1303
1335
|
}
|
package/src/lib/repo-observer.js
CHANGED
|
@@ -29,6 +29,8 @@ const OPERATIONAL_PATH_PREFIXES = [
|
|
|
29
29
|
'.agentxchain/intake/',
|
|
30
30
|
'.agentxchain/locks/',
|
|
31
31
|
'.agentxchain/transactions/',
|
|
32
|
+
'.agentxchain/missions/',
|
|
33
|
+
'.agentxchain/multirepo/',
|
|
32
34
|
];
|
|
33
35
|
|
|
34
36
|
// Orchestrator-owned state files that agents must never be blamed for modifying.
|
|
@@ -38,6 +40,7 @@ const ORCHESTRATOR_STATE_FILES = [
|
|
|
38
40
|
'.agentxchain/session.json',
|
|
39
41
|
'.agentxchain/history.jsonl',
|
|
40
42
|
'.agentxchain/decision-ledger.jsonl',
|
|
43
|
+
'.agentxchain/repo-decisions.jsonl',
|
|
41
44
|
'.agentxchain/lock.json',
|
|
42
45
|
'.agentxchain/hook-audit.jsonl',
|
|
43
46
|
'.agentxchain/hook-annotations.jsonl',
|
|
@@ -50,6 +53,7 @@ const ORCHESTRATOR_STATE_FILES = [
|
|
|
50
53
|
'.agentxchain/human-escalations.jsonl',
|
|
51
54
|
'.agentxchain/sla-reminders.json',
|
|
52
55
|
'TALK.md',
|
|
56
|
+
'HUMAN_TASKS.md',
|
|
53
57
|
];
|
|
54
58
|
|
|
55
59
|
// Evidence paths may legitimately remain dirty across turns without blocking the
|
|
@@ -70,7 +74,7 @@ export function isOperationalPath(filePath) {
|
|
|
70
74
|
|| ORCHESTRATOR_STATE_FILES.includes(filePath);
|
|
71
75
|
}
|
|
72
76
|
|
|
73
|
-
function isBaselineExemptPath(filePath) {
|
|
77
|
+
export function isBaselineExemptPath(filePath) {
|
|
74
78
|
return isOperationalPath(filePath)
|
|
75
79
|
|| BASELINE_EXEMPT_PATH_PREFIXES.some(prefix => filePath.startsWith(prefix));
|
|
76
80
|
}
|
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import { spawnSync } from 'node:child_process';
|
|
1
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
|
+
|
|
7
|
+
import { isOperationalPath } from './repo-observer.js';
|
|
2
8
|
|
|
3
9
|
export const DEFAULT_VERIFICATION_REPLAY_TIMEOUT_MS = 30_000;
|
|
4
10
|
|
|
@@ -22,10 +28,15 @@ export function replayVerificationMachineEvidence({ root, verification, timeoutM
|
|
|
22
28
|
return payload;
|
|
23
29
|
}
|
|
24
30
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
31
|
+
const workspaceGuard = createReplayWorkspaceGuard(root);
|
|
32
|
+
try {
|
|
33
|
+
payload.commands = machineEvidence.map((entry, index) => replayEvidenceCommand(root, entry, index, timeoutMs));
|
|
34
|
+
payload.replayed_commands = payload.commands.length;
|
|
35
|
+
payload.matched_commands = payload.commands.filter((entry) => entry.matched).length;
|
|
36
|
+
payload.overall = payload.commands.every((entry) => entry.matched) ? 'match' : 'mismatch';
|
|
37
|
+
} finally {
|
|
38
|
+
payload.workspace_guard = workspaceGuard.cleanup();
|
|
39
|
+
}
|
|
29
40
|
|
|
30
41
|
return payload;
|
|
31
42
|
}
|
|
@@ -69,3 +80,186 @@ export function summarizeVerificationReplay(payload) {
|
|
|
69
80
|
...(payload.reason ? { reason: payload.reason } : {}),
|
|
70
81
|
};
|
|
71
82
|
}
|
|
83
|
+
|
|
84
|
+
function createReplayWorkspaceGuard(root) {
|
|
85
|
+
if (!isGitRepo(root)) {
|
|
86
|
+
return {
|
|
87
|
+
cleanup() {
|
|
88
|
+
return { ok: true, restored_files: [], cleanup_error: null };
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const backupDir = mkdtempSync(join(tmpdir(), 'axc-verification-replay-'));
|
|
94
|
+
const beforeState = captureDirtyWorkspaceState(root, backupDir);
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
cleanup() {
|
|
98
|
+
const restoredFiles = new Set();
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
const afterFiles = collectDirtyActorFiles(root);
|
|
102
|
+
const candidateFiles = new Set([...beforeState.files.keys(), ...afterFiles]);
|
|
103
|
+
|
|
104
|
+
for (const filePath of candidateFiles) {
|
|
105
|
+
const beforeInfo = beforeState.files.get(filePath);
|
|
106
|
+
const currentMarker = getWorkspaceFileMarker(root, filePath);
|
|
107
|
+
|
|
108
|
+
if (beforeInfo) {
|
|
109
|
+
if (currentMarker !== beforeInfo.marker) {
|
|
110
|
+
restorePreReplayPath(root, filePath, beforeInfo);
|
|
111
|
+
restoredFiles.add(filePath);
|
|
112
|
+
}
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
restoreCleanPath(root, filePath);
|
|
117
|
+
restoredFiles.add(filePath);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const remainingDrift = detectWorkspaceDrift(root, beforeState.files);
|
|
121
|
+
if (remainingDrift.length > 0) {
|
|
122
|
+
return {
|
|
123
|
+
ok: false,
|
|
124
|
+
restored_files: [...restoredFiles].sort(),
|
|
125
|
+
cleanup_error: `Verification replay left actor-owned workspace drift after cleanup: ${remainingDrift.join(', ')}`,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
ok: true,
|
|
131
|
+
restored_files: [...restoredFiles].sort(),
|
|
132
|
+
cleanup_error: null,
|
|
133
|
+
};
|
|
134
|
+
} catch (err) {
|
|
135
|
+
return {
|
|
136
|
+
ok: false,
|
|
137
|
+
restored_files: [...restoredFiles].sort(),
|
|
138
|
+
cleanup_error: err?.message || String(err),
|
|
139
|
+
};
|
|
140
|
+
} finally {
|
|
141
|
+
rmSync(backupDir, { recursive: true, force: true });
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function captureDirtyWorkspaceState(root, backupDir) {
|
|
148
|
+
const files = new Map();
|
|
149
|
+
for (const filePath of collectDirtyActorFiles(root)) {
|
|
150
|
+
const absPath = join(root, filePath);
|
|
151
|
+
const backupPath = join(backupDir, filePath);
|
|
152
|
+
const existed = existsSync(absPath);
|
|
153
|
+
const marker = getWorkspaceFileMarker(root, filePath);
|
|
154
|
+
|
|
155
|
+
if (existed) {
|
|
156
|
+
mkdirSync(dirname(backupPath), { recursive: true });
|
|
157
|
+
cpSync(absPath, backupPath, { force: true, recursive: true });
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
files.set(filePath, { existed, marker, backupPath });
|
|
161
|
+
}
|
|
162
|
+
return { files };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function detectWorkspaceDrift(root, beforeFiles) {
|
|
166
|
+
const afterFiles = collectDirtyActorFiles(root);
|
|
167
|
+
const candidates = new Set([...beforeFiles.keys(), ...afterFiles]);
|
|
168
|
+
const drift = [];
|
|
169
|
+
|
|
170
|
+
for (const filePath of candidates) {
|
|
171
|
+
const beforeInfo = beforeFiles.get(filePath);
|
|
172
|
+
const currentMarker = getWorkspaceFileMarker(root, filePath);
|
|
173
|
+
if (beforeInfo) {
|
|
174
|
+
if (currentMarker !== beforeInfo.marker) {
|
|
175
|
+
drift.push(filePath);
|
|
176
|
+
}
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
drift.push(filePath);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return drift.sort();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function restorePreReplayPath(root, filePath, beforeInfo) {
|
|
186
|
+
const absPath = join(root, filePath);
|
|
187
|
+
if (!beforeInfo.existed) {
|
|
188
|
+
rmSync(absPath, { recursive: true, force: true });
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
rmSync(absPath, { recursive: true, force: true });
|
|
193
|
+
mkdirSync(dirname(absPath), { recursive: true });
|
|
194
|
+
cpSync(beforeInfo.backupPath, absPath, { force: true, recursive: true });
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function restoreCleanPath(root, filePath) {
|
|
198
|
+
const absPath = join(root, filePath);
|
|
199
|
+
if (isTrackedPath(root, filePath)) {
|
|
200
|
+
execFileSync('git', ['restore', '--source=HEAD', '--staged', '--worktree', '--', filePath], {
|
|
201
|
+
cwd: root,
|
|
202
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
203
|
+
});
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
rmSync(absPath, { recursive: true, force: true });
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function collectDirtyActorFiles(root) {
|
|
211
|
+
return [...new Set([
|
|
212
|
+
...readGitLines(root, ['diff', '--name-only', 'HEAD']),
|
|
213
|
+
...readGitLines(root, ['diff', '--name-only', '--cached']),
|
|
214
|
+
...readGitLines(root, ['ls-files', '--others', '--exclude-standard']),
|
|
215
|
+
])]
|
|
216
|
+
.filter((filePath) => filePath && !isOperationalPath(filePath))
|
|
217
|
+
.sort();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function readGitLines(root, args) {
|
|
221
|
+
try {
|
|
222
|
+
const output = execFileSync('git', args, {
|
|
223
|
+
cwd: root,
|
|
224
|
+
encoding: 'utf8',
|
|
225
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
226
|
+
}).trim();
|
|
227
|
+
return output ? output.split('\n').filter(Boolean) : [];
|
|
228
|
+
} catch {
|
|
229
|
+
return [];
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function isTrackedPath(root, filePath) {
|
|
234
|
+
try {
|
|
235
|
+
execFileSync('git', ['ls-files', '--error-unmatch', '--', filePath], {
|
|
236
|
+
cwd: root,
|
|
237
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
238
|
+
});
|
|
239
|
+
return true;
|
|
240
|
+
} catch {
|
|
241
|
+
return false;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function isGitRepo(root) {
|
|
246
|
+
try {
|
|
247
|
+
execFileSync('git', ['rev-parse', '--is-inside-work-tree'], {
|
|
248
|
+
cwd: root,
|
|
249
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
250
|
+
});
|
|
251
|
+
return true;
|
|
252
|
+
} catch {
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function getWorkspaceFileMarker(root, filePath) {
|
|
258
|
+
const absPath = join(root, filePath);
|
|
259
|
+
if (!existsSync(absPath)) {
|
|
260
|
+
return '__deleted__';
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const content = readFileSync(absPath);
|
|
264
|
+
return createHash('sha1').update(content).digest('hex');
|
|
265
|
+
}
|