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.
@@ -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
- await mergedActions.syncCache();
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
- throw new Error('one or more init steps failed');
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
- backup.rollback();
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
- process.stderr.write(`awm init: internal error: ${err.message}\n`);
197
- return 2;
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 outcome.after.overall === 'healthy' ? 0 : 1;
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 () => { await (0, registries_1.syncRegistries)(); },
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
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentic-workflow-manager",
3
- "version": "3.3.0",
3
+ "version": "3.3.1",
4
4
  "main": "dist/src/index.js",
5
5
  "bin": {
6
6
  "awm": "./dist/src/index.js"