agentic-workflow-manager 3.3.0 → 3.3.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/dist/src/commands/init.js +79 -8
- package/dist/src/core/init/failure.js +74 -0
- package/dist/src/core/init/steps.js +19 -3
- package/dist/src/core/registries.js +44 -0
- package/dist/tests/commands/init.test.js +82 -0
- package/dist/tests/core/init/steps-registry-sync.test.js +109 -0
- package/package.json +1 -1
|
@@ -49,6 +49,7 @@ const bundles_1 = require("../core/bundles");
|
|
|
49
49
|
const registries_1 = require("../core/registries");
|
|
50
50
|
const orchestrator_1 = require("../core/init/orchestrator");
|
|
51
51
|
const steps_1 = require("../core/init/steps");
|
|
52
|
+
const failure_1 = require("../core/init/failure");
|
|
52
53
|
const mutation_targets_1 = require("../core/init/mutation-targets");
|
|
53
54
|
const provider_facts_1 = require("../core/init/provider-facts");
|
|
54
55
|
const install_transaction_1 = require("../core/install-transaction");
|
|
@@ -94,6 +95,35 @@ function renderInitOutcome(o) {
|
|
|
94
95
|
lines.push(`status: ${status} · ${pendingCount} steps require an agent (skills above)`);
|
|
95
96
|
return lines.join('\n');
|
|
96
97
|
}
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
// Failure reporting
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
/**
|
|
102
|
+
* Single exit point for every failed `awm init`. Honours `--json`'s contract on
|
|
103
|
+
* the error path — the whole point of the flag for a headless bootstrap is to
|
|
104
|
+
* learn WHICH step failed — and, in human mode, renders the same evidence
|
|
105
|
+
* through the normal init dashboard instead of discarding it.
|
|
106
|
+
*
|
|
107
|
+
* stdout carries the machine-readable document (JSON mode) or the dashboard
|
|
108
|
+
* (human mode); stderr always carries the one-line summary plus the
|
|
109
|
+
* transaction verdict, so `2>&1`-free scripts still see a reason.
|
|
110
|
+
*/
|
|
111
|
+
function reportInitFailure(o) {
|
|
112
|
+
const payload = (0, failure_1.buildInitFailureOutput)({
|
|
113
|
+
error: o.error,
|
|
114
|
+
outcome: o.outcome,
|
|
115
|
+
transaction: o.transaction,
|
|
116
|
+
});
|
|
117
|
+
if (o.json) {
|
|
118
|
+
process.stdout.write(JSON.stringify(payload, null, 2) + '\n');
|
|
119
|
+
}
|
|
120
|
+
else if (o.outcome) {
|
|
121
|
+
process.stdout.write(renderInitOutcome(o.outcome) + '\n');
|
|
122
|
+
}
|
|
123
|
+
process.stderr.write(`awm init: ${payload.error}\n`);
|
|
124
|
+
process.stderr.write(`awm init: ${payload.transaction.note}\n`);
|
|
125
|
+
return 2;
|
|
126
|
+
}
|
|
97
127
|
async function runInit(opts = {}) {
|
|
98
128
|
const cwd = opts.cwd ?? process.cwd();
|
|
99
129
|
const agent = opts.agent === undefined ? 'claude-code' : (0, providers_1.requireAgentTarget)(opts.agent);
|
|
@@ -125,6 +155,10 @@ async function runInit(opts = {}) {
|
|
|
125
155
|
// and would incorrectly be flagged as a violation.
|
|
126
156
|
const beforeClaudeFacts = agent === 'claude-code' ? null : (0, provider_facts_1.gatherProviderFacts)('claude-code');
|
|
127
157
|
let outcome;
|
|
158
|
+
// Populated as soon as each becomes available, so the failure reporter can
|
|
159
|
+
// emit whatever evidence THIS run got far enough to produce.
|
|
160
|
+
let pipelineOutcome;
|
|
161
|
+
let transaction;
|
|
128
162
|
try {
|
|
129
163
|
const mergedActions = {
|
|
130
164
|
...steps_1.defaultActions,
|
|
@@ -155,7 +189,11 @@ async function runInit(opts = {}) {
|
|
|
155
189
|
(0, config_1.savePreferences)(nextPreferences);
|
|
156
190
|
(0, registries_1.seedBaselineRegistry)();
|
|
157
191
|
if ((0, registries_1.listRegistries)().some((r) => !fs_1.default.existsSync(r.contentRoot))) {
|
|
158
|
-
|
|
192
|
+
// `syncRegistries()` reports per-registry failures as RESULTS,
|
|
193
|
+
// never as throws (core/registries.ts) — swallowing them here
|
|
194
|
+
// let an unavailable registry degrade silently into some later
|
|
195
|
+
// step's failure instead of being reported as its own cause.
|
|
196
|
+
(0, registries_1.assertSyncedRegistriesUsable)((await mergedActions.syncCache()) ?? []);
|
|
159
197
|
}
|
|
160
198
|
const bundles = (0, bundles_1.discoverAllBundles)();
|
|
161
199
|
const ctx = (0, context_1.gatherContext)({ cwd, bundles, agent });
|
|
@@ -177,8 +215,13 @@ async function runInit(opts = {}) {
|
|
|
177
215
|
confirmExtensions,
|
|
178
216
|
actions: mergedActions,
|
|
179
217
|
});
|
|
218
|
+
pipelineOutcome = outcome;
|
|
180
219
|
if (outcome.failed > 0) {
|
|
181
|
-
|
|
220
|
+
// Typed so the outcome — the only record of WHICH step failed
|
|
221
|
+
// and why — survives the rollback below and reaches the
|
|
222
|
+
// reporter. A bare Error here is what made `--json` emit
|
|
223
|
+
// nothing on the one path that most needed it.
|
|
224
|
+
throw new failure_1.InitStepsFailedError(outcome);
|
|
182
225
|
}
|
|
183
226
|
if (beforeClaudeFacts) {
|
|
184
227
|
(0, provider_facts_1.assertClaudeBaselinePreserved)(beforeClaudeFacts, (0, provider_facts_1.gatherProviderFacts)('claude-code'));
|
|
@@ -188,21 +231,49 @@ async function runInit(opts = {}) {
|
|
|
188
231
|
outcome.modifiedFiles = backup.targetPaths;
|
|
189
232
|
}
|
|
190
233
|
catch (error) {
|
|
191
|
-
|
|
234
|
+
// Rollback is best-effort and must never mask the original failure
|
|
235
|
+
// (same policy as applyInstallPlan's own rollback loop): the
|
|
236
|
+
// operator needs the step that failed, not the restore that also did.
|
|
237
|
+
let rollbackError;
|
|
238
|
+
try {
|
|
239
|
+
backup.rollback();
|
|
240
|
+
}
|
|
241
|
+
catch (e) {
|
|
242
|
+
rollbackError = e instanceof Error ? e.message : String(e);
|
|
243
|
+
}
|
|
244
|
+
transaction = {
|
|
245
|
+
committed: false,
|
|
246
|
+
rolledBack: rollbackError === undefined,
|
|
247
|
+
transactionId: backup.transactionId,
|
|
248
|
+
restoredFiles: backup.targetPaths,
|
|
249
|
+
...(rollbackError === undefined ? {} : { rollbackError }),
|
|
250
|
+
note: (0, failure_1.transactionNote)(rollbackError === undefined),
|
|
251
|
+
};
|
|
192
252
|
throw error;
|
|
193
253
|
}
|
|
194
254
|
}
|
|
195
255
|
catch (err) {
|
|
196
|
-
|
|
197
|
-
|
|
256
|
+
// The typed error carries its own outcome (so it stays self-sufficient
|
|
257
|
+
// for any caller of runInit); `pipelineOutcome` covers the other way a
|
|
258
|
+
// run can fail *after* the pipeline produced one — e.g. the R19
|
|
259
|
+
// Claude-baseline assertion.
|
|
260
|
+
return reportInitFailure({
|
|
261
|
+
error: err,
|
|
262
|
+
outcome: err instanceof failure_1.InitStepsFailedError ? err.outcome : pipelineOutcome,
|
|
263
|
+
transaction,
|
|
264
|
+
json: opts.json,
|
|
265
|
+
});
|
|
198
266
|
}
|
|
267
|
+
// `result` mirrors the exit code, so a consumer can branch on one field
|
|
268
|
+
// instead of correlating stdout with $?: ok → 0, degraded → 1, failed → 2.
|
|
269
|
+
const result = outcome.after.overall === 'healthy' ? 'ok' : 'degraded';
|
|
199
270
|
if (opts.json) {
|
|
200
|
-
process.stdout.write(JSON.stringify(outcome, null, 2) + '\n');
|
|
271
|
+
process.stdout.write(JSON.stringify({ result, ...outcome }, null, 2) + '\n');
|
|
201
272
|
}
|
|
202
273
|
else {
|
|
203
274
|
process.stdout.write(renderInitOutcome(outcome) + '\n');
|
|
204
275
|
}
|
|
205
|
-
return
|
|
276
|
+
return result === 'ok' ? 0 : 1;
|
|
206
277
|
}
|
|
207
278
|
// ---------------------------------------------------------------------------
|
|
208
279
|
// Extension confirmation factory
|
|
@@ -240,7 +311,7 @@ function registerInitCommand(program) {
|
|
|
240
311
|
.option('-y, --yes', 'Skip confirmation prompts')
|
|
241
312
|
.option('-a, --agent <agent>', 'Target agent (default: claude-code)')
|
|
242
313
|
.option('--machine-only', 'Only run machine-level steps (skip project steps)')
|
|
243
|
-
.option('--json', 'Emit the InitOutcome as JSON')
|
|
314
|
+
.option('--json', 'Emit the InitOutcome as JSON — on success and on failure (failed steps + rollback)')
|
|
244
315
|
.action(async (options) => {
|
|
245
316
|
(0, paths_1.warnIfUnsupportedPlatform)((m) => console.warn(picocolors_1.default.yellow(`⚠ ${m}`)));
|
|
246
317
|
const code = await runInit({
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// src/core/init/failure.ts
|
|
3
|
+
//
|
|
4
|
+
// Failure evidence for `awm init`.
|
|
5
|
+
//
|
|
6
|
+
// `awm init` is transactional: any failure rolls every write back. That part
|
|
7
|
+
// was always right — what was missing is the EVIDENCE. `runInitSteps` already
|
|
8
|
+
// records, per step, `{ id, action: 'failed', error }` (orchestrator.ts's
|
|
9
|
+
// `wrapStep`), but commands/init.ts used to collapse the whole outcome into a
|
|
10
|
+
// bare `new Error('one or more init steps failed')`, so `--json` — whose only
|
|
11
|
+
// job is to emit that outcome — printed nothing at all on the exact path an
|
|
12
|
+
// operator needs it: a headless cloud bootstrap that just aborted.
|
|
13
|
+
//
|
|
14
|
+
// `InitStepsFailedError` carries the outcome across the rollback boundary, and
|
|
15
|
+
// `buildInitFailureOutput` shapes it into the JSON envelope the CLI emits.
|
|
16
|
+
// The envelope is deliberately self-describing (`result`, `failedSteps`,
|
|
17
|
+
// `transaction`) so a bootstrap script can branch on it without re-deriving
|
|
18
|
+
// anything from prose on stderr.
|
|
19
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
20
|
+
exports.InitStepsFailedError = void 0;
|
|
21
|
+
exports.failedSteps = failedSteps;
|
|
22
|
+
exports.transactionNote = transactionNote;
|
|
23
|
+
exports.buildInitFailureOutput = buildInitFailureOutput;
|
|
24
|
+
/** Only the steps that actually failed — the subset an operator needs first. */
|
|
25
|
+
function failedSteps(outcome) {
|
|
26
|
+
return outcome.steps.filter((s) => s.action === 'failed');
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Thrown when the step pipeline ran to completion but ≥1 step failed. Carries
|
|
30
|
+
* the full `InitOutcome` so the rollback path can still emit it, and builds a
|
|
31
|
+
* message that names every failed step instead of the old generic sentence.
|
|
32
|
+
*/
|
|
33
|
+
class InitStepsFailedError extends Error {
|
|
34
|
+
outcome;
|
|
35
|
+
constructor(outcome) {
|
|
36
|
+
const detail = failedSteps(outcome)
|
|
37
|
+
.map((s) => `${s.id}: ${s.error ?? 'no error recorded'}`)
|
|
38
|
+
.join('; ');
|
|
39
|
+
super(`one or more init steps failed — ${detail || 'no failed step recorded'}`);
|
|
40
|
+
this.name = 'InitStepsFailedError';
|
|
41
|
+
this.outcome = outcome;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
exports.InitStepsFailedError = InitStepsFailedError;
|
|
45
|
+
const NO_TRANSACTION_NOTE = 'init failed before a backup session was opened — nothing was written, so there was nothing to roll back.';
|
|
46
|
+
/** The note explaining what a given rollback outcome means for the machine. */
|
|
47
|
+
function transactionNote(rolledBack) {
|
|
48
|
+
return rolledBack
|
|
49
|
+
? 'the transaction was NOT committed: every path in restoredFiles was restored to its pre-init state. '
|
|
50
|
+
+ '`after` reflects the state observed at the end of the step pipeline, BEFORE this rollback ran.'
|
|
51
|
+
: 'the transaction was NOT committed and the rollback did not complete — see rollbackError and '
|
|
52
|
+
+ 'restore manually with `awm backup restore <transactionId>`.';
|
|
53
|
+
}
|
|
54
|
+
function buildInitFailureOutput(o) {
|
|
55
|
+
const steps = o.outcome?.steps ?? [];
|
|
56
|
+
return {
|
|
57
|
+
result: 'failed',
|
|
58
|
+
error: o.error.message,
|
|
59
|
+
steps,
|
|
60
|
+
failedSteps: steps.filter((s) => s.action === 'failed'),
|
|
61
|
+
applied: o.outcome?.applied ?? 0,
|
|
62
|
+
pending: o.outcome?.pending ?? 0,
|
|
63
|
+
failed: o.outcome?.failed ?? 0,
|
|
64
|
+
before: o.outcome?.before ?? null,
|
|
65
|
+
after: o.outcome?.after ?? null,
|
|
66
|
+
transaction: o.transaction ?? {
|
|
67
|
+
committed: false,
|
|
68
|
+
rolledBack: false,
|
|
69
|
+
transactionId: null,
|
|
70
|
+
restoredFiles: [],
|
|
71
|
+
note: NO_TRANSACTION_NOTE,
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
@@ -39,7 +39,7 @@ const codex_agents_1 = require("../context/strategies/codex-agents");
|
|
|
39
39
|
// ---------------------------------------------------------------------------
|
|
40
40
|
const realInjectionOrchestrator = new orchestrator_1.InjectionOrchestrator();
|
|
41
41
|
exports.defaultActions = {
|
|
42
|
-
syncCache: async () =>
|
|
42
|
+
syncCache: async () => (0, registries_1.syncRegistries)(),
|
|
43
43
|
installHook: (o) => (0, install_1.installHook)({
|
|
44
44
|
agent: o.agent,
|
|
45
45
|
registryRoot: o.registryRoot,
|
|
@@ -135,14 +135,30 @@ async function stepCache(d) {
|
|
|
135
135
|
const needsSync = !registryCache.present || registryCache.gitState === 'behind';
|
|
136
136
|
if (!needsSync)
|
|
137
137
|
return ok('machine.cache', 'machine', 'skipped');
|
|
138
|
+
let results;
|
|
138
139
|
try {
|
|
139
|
-
await d.actions.syncCache();
|
|
140
|
-
return ok('machine.cache', 'machine', 'applied');
|
|
140
|
+
results = (await d.actions.syncCache()) ?? [];
|
|
141
141
|
}
|
|
142
142
|
catch (e) {
|
|
143
143
|
const msg = e instanceof Error ? e.message : String(e);
|
|
144
144
|
return failed('machine.cache', 'machine', msg);
|
|
145
145
|
}
|
|
146
|
+
// `syncRegistries()` reports per-registry failures as RESULTS, not throws
|
|
147
|
+
// (registries.ts), so the `try` above catches none of them. Ignoring them
|
|
148
|
+
// is what let an unavailable base registry degrade silently into some
|
|
149
|
+
// later step's failure — with its own cause already gone.
|
|
150
|
+
const errors = (0, registries_1.registrySyncErrors)(results);
|
|
151
|
+
if (errors.length === 0)
|
|
152
|
+
return ok('machine.cache', 'machine', 'applied');
|
|
153
|
+
// A registry that errored and has no content on disk is unusable, and every
|
|
154
|
+
// later step that reads it will fail for a reason that no longer names this
|
|
155
|
+
// cause — so machine.cache owns it here. One that errored but still has
|
|
156
|
+
// content is stale, not broken: record it and carry on.
|
|
157
|
+
const unusable = (0, registries_1.unusableSyncedRegistries)(results);
|
|
158
|
+
if (unusable.length > 0) {
|
|
159
|
+
return failed('machine.cache', 'machine', `registry unavailable — ${(0, registries_1.describeRegistrySyncErrors)(unusable)}`);
|
|
160
|
+
}
|
|
161
|
+
return ok('machine.cache', 'machine', 'applied', `stale registries — ${(0, registries_1.describeRegistrySyncErrors)(errors)}`);
|
|
146
162
|
}
|
|
147
163
|
/** Step 2 – Install the session-start hook for the target agent. */
|
|
148
164
|
function stepHook(d) {
|
|
@@ -13,6 +13,10 @@ exports.contentRoots = contentRoots;
|
|
|
13
13
|
exports.capabilityRoot = capabilityRoot;
|
|
14
14
|
exports.validateRegistryLayout = validateRegistryLayout;
|
|
15
15
|
exports.syncRegistries = syncRegistries;
|
|
16
|
+
exports.registrySyncErrors = registrySyncErrors;
|
|
17
|
+
exports.describeRegistrySyncErrors = describeRegistrySyncErrors;
|
|
18
|
+
exports.unusableSyncedRegistries = unusableSyncedRegistries;
|
|
19
|
+
exports.assertSyncedRegistriesUsable = assertSyncedRegistriesUsable;
|
|
16
20
|
exports.readRegistryManifest = readRegistryManifest;
|
|
17
21
|
exports.registryNameForPath = registryNameForPath;
|
|
18
22
|
exports.verifyMinCliVersions = verifyMinCliVersions;
|
|
@@ -144,6 +148,46 @@ async function syncRegistries() {
|
|
|
144
148
|
}
|
|
145
149
|
return results;
|
|
146
150
|
}
|
|
151
|
+
/** The errored entries of a `syncRegistries()` run, in `listRegistries()` order. */
|
|
152
|
+
function registrySyncErrors(results) {
|
|
153
|
+
return results
|
|
154
|
+
.filter((r) => r.action === 'error')
|
|
155
|
+
.map((r) => ({ name: r.name, error: r.error }));
|
|
156
|
+
}
|
|
157
|
+
/** `name: reason; name: reason` — the shape both init and stepCache report errors in. */
|
|
158
|
+
function describeRegistrySyncErrors(errors) {
|
|
159
|
+
return errors.map((e) => `${e.name}: ${e.error}`).join('; ');
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Registries that both errored during sync AND have no content on disk
|
|
163
|
+
* afterwards — i.e. genuinely unusable, as opposed to merely stale.
|
|
164
|
+
*
|
|
165
|
+
* `syncRegistries()` deliberately reports per-registry failures as results
|
|
166
|
+
* rather than throwing, so a flaky secondary registry never aborts a whole
|
|
167
|
+
* run. Callers that go on to READ registry content (init) still need to know
|
|
168
|
+
* whether what they are about to read exists: otherwise a missing registry
|
|
169
|
+
* resurfaces much later as an unrelated step's failure, with its real cause
|
|
170
|
+
* already discarded. Note that "usable" is deliberately about content on disk,
|
|
171
|
+
* not about being a healthy git clone — a seeded content root with no `.git`
|
|
172
|
+
* fails to sync every time and is still perfectly readable.
|
|
173
|
+
*/
|
|
174
|
+
function unusableSyncedRegistries(results) {
|
|
175
|
+
const errors = registrySyncErrors(results);
|
|
176
|
+
if (errors.length === 0)
|
|
177
|
+
return [];
|
|
178
|
+
const roots = new Map(listRegistries().map((r) => [r.name, r.contentRoot]));
|
|
179
|
+
return errors.filter((e) => {
|
|
180
|
+
const root = roots.get(e.name);
|
|
181
|
+
return root === undefined || !fs_1.default.existsSync(root);
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
/** `unusableSyncedRegistries` as a guard: throws naming every unusable registry. */
|
|
185
|
+
function assertSyncedRegistriesUsable(results) {
|
|
186
|
+
const unusable = unusableSyncedRegistries(results);
|
|
187
|
+
if (unusable.length === 0)
|
|
188
|
+
return;
|
|
189
|
+
throw new Error(`registry sync failed and left no content on disk — ${describeRegistrySyncErrors(unusable)}`);
|
|
190
|
+
}
|
|
147
191
|
exports.REGISTRY_MANIFEST_NAME = 'awm-registry.json';
|
|
148
192
|
function readRegistryManifest(root) {
|
|
149
193
|
const file = path_1.default.join(root, exports.REGISTRY_MANIFEST_NAME);
|
|
@@ -91,6 +91,7 @@ describe('runInit', () => {
|
|
|
91
91
|
const parsed = JSON.parse(written);
|
|
92
92
|
expect(Array.isArray(parsed.steps)).toBe(true);
|
|
93
93
|
expect(parsed.after.overall).toBe('degraded');
|
|
94
|
+
expect(parsed.result).toBe('degraded'); // success envelope is self-describing too
|
|
94
95
|
expect(code).toBe(1);
|
|
95
96
|
});
|
|
96
97
|
const prefsFile = () => path_1.default.join(process.env.AWM_HOME, 'preferences.json');
|
|
@@ -291,4 +292,85 @@ describe('runInit', () => {
|
|
|
291
292
|
// rollback must restore its exact pre-run content, not just leave it be.
|
|
292
293
|
expect(JSON.parse(fs_1.default.readFileSync(settingsPath, 'utf8'))).toEqual({ pristine: true, unrelated: 'keep' });
|
|
293
294
|
});
|
|
295
|
+
// -----------------------------------------------------------------------
|
|
296
|
+
// Failure evidence — `--json` must honour its contract on the ERROR path
|
|
297
|
+
// -----------------------------------------------------------------------
|
|
298
|
+
describe('failure evidence', () => {
|
|
299
|
+
let errSpy;
|
|
300
|
+
beforeEach(() => {
|
|
301
|
+
errSpy = jest.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
|
302
|
+
});
|
|
303
|
+
afterEach(() => errSpy.mockRestore());
|
|
304
|
+
const stdout = () => writeSpy.mock.calls.map((c) => c[0]).join('');
|
|
305
|
+
const stderr = () => errSpy.mock.calls.map((c) => c[0]).join('');
|
|
306
|
+
/** InitActions whose `machine.hook` step blows up inside the step pipeline. */
|
|
307
|
+
function actionsWithFailingHook() {
|
|
308
|
+
const actions = fakeActions([]);
|
|
309
|
+
actions.installHook = () => { throw new Error('hook boom'); };
|
|
310
|
+
return actions;
|
|
311
|
+
}
|
|
312
|
+
it('--json emits parseable JSON carrying the failed step id and error', async () => {
|
|
313
|
+
const { runInit } = require('../../src/commands/init');
|
|
314
|
+
const code = await runInit({
|
|
315
|
+
cwd: tmpHome,
|
|
316
|
+
yes: true,
|
|
317
|
+
json: true,
|
|
318
|
+
actions: actionsWithFailingHook(),
|
|
319
|
+
});
|
|
320
|
+
expect(code).not.toBe(0);
|
|
321
|
+
expect(code).toBe(2);
|
|
322
|
+
const parsed = JSON.parse(stdout());
|
|
323
|
+
expect(parsed.result).toBe('failed');
|
|
324
|
+
const failedStep = parsed.steps.find((s) => s.action === 'failed');
|
|
325
|
+
expect(failedStep.id).toBe('machine.hook');
|
|
326
|
+
expect(failedStep.error).toBe('hook boom');
|
|
327
|
+
expect(parsed.failedSteps).toEqual([failedStep]);
|
|
328
|
+
expect(parsed.failed).toBe(1);
|
|
329
|
+
// The top-level message names the step too — no generic "internal error".
|
|
330
|
+
expect(parsed.error).toContain('machine.hook');
|
|
331
|
+
expect(parsed.error).toContain('hook boom');
|
|
332
|
+
// `before` is the pre-run snapshot the issue asks for.
|
|
333
|
+
expect(parsed.before.results.length).toBeGreaterThan(0);
|
|
334
|
+
});
|
|
335
|
+
it('--json reports the transaction as not committed and rolled back', async () => {
|
|
336
|
+
const { runInit } = require('../../src/commands/init');
|
|
337
|
+
await runInit({ cwd: tmpHome, yes: true, json: true, actions: actionsWithFailingHook() });
|
|
338
|
+
const { transaction } = JSON.parse(stdout());
|
|
339
|
+
expect(transaction.committed).toBe(false);
|
|
340
|
+
expect(transaction.rolledBack).toBe(true);
|
|
341
|
+
expect(typeof transaction.transactionId).toBe('string');
|
|
342
|
+
expect(Array.isArray(transaction.restoredFiles)).toBe(true);
|
|
343
|
+
expect(transaction.note).toBeTruthy();
|
|
344
|
+
// The backup manifest on disk agrees: nothing was committed.
|
|
345
|
+
const { listBackups } = require('../../src/core/install-transaction');
|
|
346
|
+
const backups = listBackups();
|
|
347
|
+
expect(backups.length).toBeGreaterThan(0);
|
|
348
|
+
expect(backups.every((b) => b.committed)).toBe(false);
|
|
349
|
+
// …and the run really did not persist anything.
|
|
350
|
+
expect(fs_1.default.existsSync(prefsFile())).toBe(false);
|
|
351
|
+
});
|
|
352
|
+
it('--json still emits a failure envelope when init fails before the step pipeline', async () => {
|
|
353
|
+
const actions = fakeActions([]);
|
|
354
|
+
actions.syncCache = async () => { throw new Error('registry unreachable'); };
|
|
355
|
+
const { runInit } = require('../../src/commands/init');
|
|
356
|
+
const code = await runInit({ cwd: tmpHome, yes: true, json: true, actions });
|
|
357
|
+
expect(code).toBe(2);
|
|
358
|
+
const parsed = JSON.parse(stdout());
|
|
359
|
+
expect(parsed.result).toBe('failed');
|
|
360
|
+
expect(parsed.error).toContain('registry unreachable');
|
|
361
|
+
expect(parsed.steps).toEqual([]);
|
|
362
|
+
expect(parsed.before).toBeNull();
|
|
363
|
+
expect(parsed.transaction.committed).toBe(false);
|
|
364
|
+
});
|
|
365
|
+
it('human mode renders the failed outcome instead of swallowing it', async () => {
|
|
366
|
+
const { runInit } = require('../../src/commands/init');
|
|
367
|
+
const code = await runInit({ cwd: tmpHome, yes: true, actions: actionsWithFailingHook() });
|
|
368
|
+
expect(code).toBe(2);
|
|
369
|
+
expect(stdout()).toContain('AWM · init');
|
|
370
|
+
expect(stdout()).toContain('machine.hook');
|
|
371
|
+
expect(stdout()).toContain('hook boom');
|
|
372
|
+
expect(stderr()).toContain('machine.hook');
|
|
373
|
+
expect(stderr()).toContain('hook boom');
|
|
374
|
+
});
|
|
375
|
+
});
|
|
294
376
|
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
// stepCache vs. syncRegistries()'s RESULT-shaped failures.
|
|
7
|
+
//
|
|
8
|
+
// `syncRegistries()` never throws on a per-registry failure — it returns
|
|
9
|
+
// `{ action: 'error', error }` for that registry and keeps going. stepCache
|
|
10
|
+
// used to `await` it and report a blanket 'applied', so a registry that never
|
|
11
|
+
// landed on disk degraded silently into some later step's failure with its own
|
|
12
|
+
// cause already gone.
|
|
13
|
+
//
|
|
14
|
+
// Isolated in its own file (not steps.test.ts) because these assertions reach
|
|
15
|
+
// `listRegistries()`, which resolves AWM_HOME at module require time — the env
|
|
16
|
+
// override therefore has to happen before the module is required, which means
|
|
17
|
+
// `jest.resetModules()` + `require`, not a static import.
|
|
18
|
+
const fs_1 = __importDefault(require("fs"));
|
|
19
|
+
const os_1 = __importDefault(require("os"));
|
|
20
|
+
const path_1 = __importDefault(require("path"));
|
|
21
|
+
describe('stepCache — registry sync error results', () => {
|
|
22
|
+
let tmpHome;
|
|
23
|
+
let originalHome;
|
|
24
|
+
let originalAwmHome;
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
tmpHome = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-stepcache-'));
|
|
27
|
+
originalHome = process.env.HOME;
|
|
28
|
+
originalAwmHome = process.env.AWM_HOME;
|
|
29
|
+
process.env.HOME = tmpHome;
|
|
30
|
+
process.env.AWM_HOME = path_1.default.join(tmpHome, '.awm');
|
|
31
|
+
jest.resetModules();
|
|
32
|
+
});
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
fs_1.default.rmSync(tmpHome, { recursive: true, force: true });
|
|
35
|
+
if (originalHome === undefined)
|
|
36
|
+
delete process.env.HOME;
|
|
37
|
+
else
|
|
38
|
+
process.env.HOME = originalHome;
|
|
39
|
+
if (originalAwmHome === undefined)
|
|
40
|
+
delete process.env.AWM_HOME;
|
|
41
|
+
else
|
|
42
|
+
process.env.AWM_HOME = originalAwmHome;
|
|
43
|
+
});
|
|
44
|
+
const awmHome = () => process.env.AWM_HOME;
|
|
45
|
+
/** Declares `names` in registries.json; creates a content root only for `withContent`. */
|
|
46
|
+
function configureRegistries(names, withContent) {
|
|
47
|
+
fs_1.default.mkdirSync(awmHome(), { recursive: true });
|
|
48
|
+
fs_1.default.writeFileSync(path_1.default.join(awmHome(), 'registries.json'), JSON.stringify(names.map((name) => ({ name, remote: `https://example.com/${name}.git` })), null, 2));
|
|
49
|
+
for (const name of withContent) {
|
|
50
|
+
const skills = path_1.default.join(awmHome(), 'registries', name, 'skills');
|
|
51
|
+
fs_1.default.mkdirSync(skills, { recursive: true });
|
|
52
|
+
fs_1.default.writeFileSync(path_1.default.join(skills, 'placeholder.md'), '# placeholder\n');
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/** InitDeps just complete enough for stepCache: a machine with no registry cache yet. */
|
|
56
|
+
function deps(results) {
|
|
57
|
+
const ctx = {
|
|
58
|
+
machine: {
|
|
59
|
+
registryCache: { present: false },
|
|
60
|
+
hook: { present: true, degraded: false },
|
|
61
|
+
devCore: { present: true, brokenLinks: [] },
|
|
62
|
+
ambient: { wanted: [], installed: [] },
|
|
63
|
+
contextInjection: [],
|
|
64
|
+
globalSkills: { valid: [], repairable: [], dead: [] },
|
|
65
|
+
},
|
|
66
|
+
project: null,
|
|
67
|
+
};
|
|
68
|
+
const actions = { syncCache: async () => results };
|
|
69
|
+
return {
|
|
70
|
+
cwd: tmpHome, ctx, bundles: [], agent: 'claude-code', enabledAgents: ['claude-code'],
|
|
71
|
+
installMethod: 'symlink', registryRoot: '', contentDir: '', sensorPacksRoot: '',
|
|
72
|
+
confirmExtensions: async (p) => p, actions,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
async function run(results) {
|
|
76
|
+
const { stepCache } = require('../../../src/core/init/steps');
|
|
77
|
+
return stepCache(deps(results));
|
|
78
|
+
}
|
|
79
|
+
it('fails, naming the registry, when a sync error left no content on disk', async () => {
|
|
80
|
+
configureRegistries(['baseline'], []);
|
|
81
|
+
const r = await run([{ name: 'baseline', action: 'error', error: 'could not clone' }]);
|
|
82
|
+
expect(r.action).toBe('failed');
|
|
83
|
+
expect(r.error).toContain('baseline');
|
|
84
|
+
expect(r.error).toContain('could not clone');
|
|
85
|
+
});
|
|
86
|
+
it('stays applied but records the error when content is already on disk', async () => {
|
|
87
|
+
configureRegistries(['baseline'], ['baseline']);
|
|
88
|
+
const r = await run([{ name: 'baseline', action: 'error', error: 'pull timed out' }]);
|
|
89
|
+
expect(r.action).toBe('applied');
|
|
90
|
+
expect(r.detail).toContain('baseline');
|
|
91
|
+
expect(r.detail).toContain('pull timed out');
|
|
92
|
+
});
|
|
93
|
+
it('fails on a secondary registry that never landed, even when the base one synced', async () => {
|
|
94
|
+
configureRegistries(['baseline', 'documentation'], ['baseline']);
|
|
95
|
+
const r = await run([
|
|
96
|
+
{ name: 'baseline', action: 'pulled', version: 'v1.0.0' },
|
|
97
|
+
{ name: 'documentation', action: 'error', error: 'host unreachable' },
|
|
98
|
+
]);
|
|
99
|
+
expect(r.action).toBe('failed');
|
|
100
|
+
expect(r.error).toContain('documentation');
|
|
101
|
+
expect(r.error).not.toContain('baseline');
|
|
102
|
+
});
|
|
103
|
+
it('reports applied with no detail when every registry synced', async () => {
|
|
104
|
+
configureRegistries(['baseline'], ['baseline']);
|
|
105
|
+
const r = await run([{ name: 'baseline', action: 'pulled', version: 'v1.0.0' }]);
|
|
106
|
+
expect(r.action).toBe('applied');
|
|
107
|
+
expect(r.detail).toBeUndefined();
|
|
108
|
+
});
|
|
109
|
+
});
|