swallowkit 2.0.0-alpha.5 → 2.0.0-alpha.7

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.
Files changed (64) hide show
  1. package/dist/cli/commands/provision.d.ts +4 -1
  2. package/dist/cli/commands/provision.d.ts.map +1 -1
  3. package/dist/cli/commands/provision.js +233 -69
  4. package/dist/cli/commands/provision.js.map +1 -1
  5. package/dist/cli/index.d.ts.map +1 -1
  6. package/dist/cli/index.js +14 -1
  7. package/dist/cli/index.js.map +1 -1
  8. package/dist/core/operations/provision-deployment.d.ts +45 -0
  9. package/dist/core/operations/provision-deployment.d.ts.map +1 -0
  10. package/dist/core/operations/provision-deployment.js +151 -0
  11. package/dist/core/operations/provision-deployment.js.map +1 -0
  12. package/dist/core/operations/provision-operations.d.ts +39 -5
  13. package/dist/core/operations/provision-operations.d.ts.map +1 -1
  14. package/dist/core/operations/provision-operations.js +609 -103
  15. package/dist/core/operations/provision-operations.js.map +1 -1
  16. package/dist/core/project/capabilities.d.ts +8 -0
  17. package/dist/core/project/capabilities.d.ts.map +1 -1
  18. package/dist/core/project/capabilities.js +11 -3
  19. package/dist/core/project/capabilities.js.map +1 -1
  20. package/dist/core/project/state.d.ts +7 -0
  21. package/dist/core/project/state.d.ts.map +1 -1
  22. package/dist/core/project/state.js +86 -0
  23. package/dist/core/project/state.js.map +1 -1
  24. package/dist/core/project/workflows.d.ts.map +1 -1
  25. package/dist/core/project/workflows.js +30 -2
  26. package/dist/core/project/workflows.js.map +1 -1
  27. package/dist/machine/index.d.ts.map +1 -1
  28. package/dist/machine/index.js +17 -11
  29. package/dist/machine/index.js.map +1 -1
  30. package/dist/mcp/index.d.ts +2 -2
  31. package/dist/mcp/index.d.ts.map +1 -1
  32. package/dist/mcp/index.js +58 -12
  33. package/dist/mcp/index.js.map +1 -1
  34. package/dist/utils/azure-cli.d.ts +16 -4
  35. package/dist/utils/azure-cli.d.ts.map +1 -1
  36. package/dist/utils/azure-cli.js +166 -4
  37. package/dist/utils/azure-cli.js.map +1 -1
  38. package/dist/utils/azure-diagnostics.d.ts +16 -0
  39. package/dist/utils/azure-diagnostics.d.ts.map +1 -0
  40. package/dist/utils/azure-diagnostics.js +68 -0
  41. package/dist/utils/azure-diagnostics.js.map +1 -0
  42. package/package.json +1 -1
  43. package/src/__tests__/agent-instructions.test.ts +5 -0
  44. package/src/__tests__/agent-loop-phase34.test.ts +19 -8
  45. package/src/__tests__/azure-cli.test.ts +5 -2
  46. package/src/__tests__/fixtures/az wrapper.cmd +2 -0
  47. package/src/__tests__/fixtures/azure-cli.cjs +138 -0
  48. package/src/__tests__/fixtures/provision-runner.cjs +28 -0
  49. package/src/__tests__/mcp.test.ts +56 -0
  50. package/src/__tests__/provision-runtime.test.ts +636 -0
  51. package/src/cli/commands/provision.ts +243 -70
  52. package/src/cli/index.ts +12 -2
  53. package/src/core/operations/provision-deployment.ts +167 -0
  54. package/src/core/operations/provision-operations.ts +597 -145
  55. package/src/core/project/agent-templates/AGENTS.md +22 -0
  56. package/src/core/project/agent-templates/CLAUDE.md +7 -0
  57. package/src/core/project/agent-templates/copilot-instructions.md +8 -0
  58. package/src/core/project/capabilities.ts +19 -3
  59. package/src/core/project/state.ts +70 -0
  60. package/src/core/project/workflows.ts +30 -2
  61. package/src/machine/index.ts +20 -14
  62. package/src/mcp/index.ts +50 -15
  63. package/src/utils/azure-cli.ts +167 -6
  64. package/src/utils/azure-diagnostics.ts +75 -0
@@ -0,0 +1,636 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import { spawn } from "child_process";
4
+ import * as azureCli from "../utils/azure-cli";
5
+ import { azureCliDiagnostic, redactAzureText } from "../utils/azure-diagnostics";
6
+ import {
7
+ ApplyProvisionResult, applyProvisionOperation, executeProvisionPlan, inspectProvisionOperation, planProvisionOperation,
8
+ } from "../core/operations/provision-operations";
9
+ import * as migrations from "../core/project/migrations";
10
+ import { loadProvisionRun, saveProvisionRun } from "../core/project/state";
11
+ import { formatProvisionResult, formatProvisionError } from "../cli/commands/provision";
12
+ import { formatCliError, runCli } from "../cli";
13
+ import { runMachineCli } from "../machine";
14
+ import { buildSwallowKitToolDefinitions } from "../mcp";
15
+
16
+ const fixture = path.join(__dirname, "fixtures", "azure-cli.cjs");
17
+ const subscription = "11111111-1111-1111-1111-111111111111";
18
+ const invocation = { command: process.execPath, prefixArgs: [fixture], source: "isolated az fixture" };
19
+ const originalCwd = process.cwd();
20
+
21
+ describe("actual Azure CLI subprocess behavior (no Azure)", () => {
22
+ it("reproduces the old timeout hiding behind warning-only stderr", () => {
23
+ const result = azureCli.runAzCli(["process", "slow", "5000"], { invocation, timeout: 1000 });
24
+ expect(result).toMatchObject({ status: null, errorCode: "ETIMEDOUT", failureKind: "timeout" });
25
+ expect(result.stderr).toContain("WARNING:");
26
+ expect(azureCliDiagnostic(result, "submit")).toMatchObject({ exitCode: null, elapsedMs: expect.any(Number), failureKind: "timeout" });
27
+ });
28
+
29
+ it("reproduces the old maxBuffer process termination", () => {
30
+ const result = azureCli.runAzCli(["process", "flood"], { invocation, timeout: 2000, captureBytes: 4096 });
31
+ expect(result).toMatchObject({ status: null, errorCode: "ENOBUFS", failureKind: "buffer", stdoutTruncated: true });
32
+ });
33
+
34
+ it("accepts warning-only stderr with exit zero and waits for a slow subprocess", async () => {
35
+ const result = await azureCli.runAzCliAsync(["process", "slow", "250"], { invocation, timeout: 2000 });
36
+ expect(result).toMatchObject({ status: 0, stderr: expect.stringContaining("WARNING:"), stdoutTruncated: false });
37
+ expect(result.failureKind).toBeUndefined();
38
+ expect(result.elapsedMs).toBeGreaterThanOrEqual(250);
39
+ });
40
+
41
+ it("drains large stdout/stderr without killing the process and bounds both captures", async () => {
42
+ const result = await azureCli.runAzCliAsync(["process", "flood"], { invocation, timeout: 3000, captureBytes: 4096 });
43
+ expect(result).toMatchObject({ status: 0, stdoutTruncated: true, stderrTruncated: true });
44
+ expect(Buffer.byteLength(result.stdout)).toBeLessThanOrEqual(4096);
45
+ expect(Buffer.byteLength(result.stderr)).toBeLessThanOrEqual(4096);
46
+ expect(azureCliDiagnostic(result, "submit").outputTruncated).toBe(true);
47
+ });
48
+
49
+ it.each<[string, string, number]>([["nonzero", "exit", 7], ["communication", "communication", 1]])("distinguishes %s failures", async (mode, failureKind, status) => {
50
+ expect(await azureCli.runAzCliAsync(["process", mode], { invocation, timeout: 2000 })).toMatchObject({ status, failureKind });
51
+ });
52
+
53
+ it("distinguishes timeout, cancellation, and spawn failure", async () => {
54
+ const timeout = await azureCli.runAzCliAsync(["process", "slow", "2000"], { invocation, timeout: 180 });
55
+ expect(timeout.failureKind).toBe("timeout");
56
+ const controller = new AbortController();
57
+ const timer = setTimeout(() => controller.abort(), 200);
58
+ const cancelled = await azureCli.runAzCliAsync(["process", "slow", "2000"], { invocation, signal: controller.signal, timeout: 2000 });
59
+ clearTimeout(timer);
60
+ expect(cancelled.failureKind).toBe("cancelled");
61
+ const missing = await azureCli.runAzCliAsync([], { invocation: { command: path.join(originalCwd, "does-not-exist.exe"), prefixArgs: [], source: "fixture" } });
62
+ expect(missing).toMatchObject({ status: null, errorCode: "ENOENT", failureKind: "spawn" });
63
+ });
64
+
65
+ it("rejects unbounded timeout options", async () => {
66
+ await expect(azureCli.runAzCliAsync([], { invocation, timeout: 0 })).rejects.toThrow(/finite positive/);
67
+ await expect(azureCli.runAzCliAsync([], { invocation, timeout: Infinity })).rejects.toThrow(/finite positive/);
68
+ });
69
+
70
+ it("preserves the actual OS exit/signal distinction instead of guessing", async () => {
71
+ const result = await azureCli.runAzCliAsync(["process", "terminate"], { invocation, timeout: 3000 });
72
+ expect(result.status).not.toBe(0);
73
+ if (process.platform === "win32") expect(result).toMatchObject({ signal: null, failureKind: "exit" });
74
+ else expect(result).toMatchObject({ signal: "SIGTERM", failureKind: "signal" });
75
+ });
76
+
77
+ it("redacts keys, credentials, JWTs, URL tokens, connection strings and deployment outputs", () => {
78
+ const secret = "fixture-secret-do-not-print";
79
+ const text = `functionKey=${secret} token=${secret} password=${secret} Bearer ${secret} https://host/?code=${secret} AccountEndpoint=https://host/;AccountKey=${secret};`;
80
+ expect(redactAzureText(text)).not.toContain(secret);
81
+ const json = JSON.stringify({ properties: { outputs: { plainName: { value: secret } }, parameters: { plainName: { value: secret } } }, error: { message: `apiKey=${secret}` } });
82
+ expect(redactAzureText(json)).not.toContain(secret);
83
+ expect(redactAzureText('{"outputs":{"unknown":{"value":"sensitive')).toContain("omitted");
84
+ const escaped = 'opaque"sensitive\\value';
85
+ const payload = JSON.stringify({ error: { message: `Invalid value '${escaped}'` } });
86
+ expect(JSON.parse(redactAzureText(payload, [escaped])).error.message).not.toContain(escaped);
87
+ expect(redactAzureText(`${payload}\nWARNING: fixture`, [escaped])).not.toContain(JSON.stringify(escaped).slice(1, -1));
88
+ });
89
+
90
+ (process.platform === "win32" ? it : it.skip)("passes actual Windows batch-wrapper arguments without shell expansion", async () => {
91
+ const args = ["C:\\fixture app\\main.bicep", "a&b", "x|y", "(value)", "x!y", "x^y", "C:\\trailing space\\"];
92
+ const result = await azureCli.runAzCliAsync(["process", "echo", ...args], {
93
+ invocation: {
94
+ command: process.env.ComSpec ?? "cmd.exe", prefixArgs: [],
95
+ batchScript: path.join(__dirname, "fixtures", "az wrapper.cmd"),
96
+ env: { SWALLOWKIT_FIXTURE_NODE: process.execPath }, source: "batch fixture",
97
+ },
98
+ timeout: 3000,
99
+ });
100
+ expect(result.status).toBe(0);
101
+ expect(JSON.parse(result.stdout)).toEqual(args);
102
+ });
103
+ });
104
+
105
+ interface FixtureState {
106
+ subscription: string;
107
+ resourceGroup: string;
108
+ groupExists: boolean;
109
+ groupCreates: number;
110
+ deploymentCreates: number;
111
+ tagAttempts: number;
112
+ failTags: number;
113
+ applyTagBeforeFailure?: boolean;
114
+ tags: Record<string, string>;
115
+ deployments: Record<string, { correlationId: string; templateHash: string }>;
116
+ states: string[];
117
+ calls: string[][];
118
+ warning?: boolean;
119
+ submitMode?: string;
120
+ submitStderr?: string;
121
+ submitDelay?: number;
122
+ queryMode?: string;
123
+ template: Record<string, unknown>;
124
+ remoteTemplate?: Record<string, unknown>;
125
+ parameters: Record<string, { value: unknown; type?: string }>;
126
+ error?: unknown;
127
+ operations?: unknown[];
128
+ outputs?: Record<string, string>;
129
+ outputQueryFails?: boolean;
130
+ }
131
+
132
+ describe("provision execution, diagnosis and recovery using an az subprocess fixture", () => {
133
+ let project: string;
134
+ let stateFile: string;
135
+ const options = { resourceGroup: "rg-provision-fixture", location: "japaneast", swaLocation: "eastasia" };
136
+ const read = (): FixtureState => JSON.parse(fs.readFileSync(stateFile, "utf-8")) as FixtureState;
137
+ const update = (patch: Partial<FixtureState>) => {
138
+ const temporary = `${stateFile}.update-${process.pid}`;
139
+ fs.writeFileSync(temporary, JSON.stringify({ ...read(), ...patch }));
140
+ fs.renameSync(temporary, stateFile);
141
+ };
142
+ const ledger = () => fs.readFileSync(path.join(project, migrations.MIGRATION_MANIFEST_PATH), "utf-8");
143
+
144
+ beforeEach(() => {
145
+ project = fs.mkdtempSync(path.join(originalCwd, ".tmp-provision-"));
146
+ fs.mkdirSync(path.join(project, "infra"), { recursive: true });
147
+ fs.writeFileSync(path.join(project, "infra", "main.bicep"), "targetScope = 'resourceGroup'\nparam projectName string\nparam location string\nparam swaLocation string\n");
148
+ fs.writeFileSync(path.join(project, "infra", "main.parameters.json"), JSON.stringify({ parameters: { projectName: { value: "fixture-app" } } }));
149
+ fs.writeFileSync(path.join(project, "swallowkit.config.js"), "module.exports = {};\n");
150
+ migrations.saveMigrationManifest(migrations.createMigrationManifest(project, { projectId: "fixture-project" }), project);
151
+ stateFile = path.join(project, "az-state.json");
152
+ const state: FixtureState = {
153
+ subscription, resourceGroup: options.resourceGroup, groupExists: false, groupCreates: 0,
154
+ deploymentCreates: 0, tagAttempts: 0, failTags: 0, tags: {}, deployments: {}, states: ["Succeeded"], calls: [],
155
+ template: { $schema: "fixture", parameters: { projectName: { type: "string" }, location: { type: "string" }, swaLocation: { type: "string" } }, resources: [] },
156
+ parameters: { projectName: { value: "fixture-app" }, location: { value: "japaneast" }, swaLocation: { value: "eastasia" } },
157
+ };
158
+ fs.writeFileSync(stateFile, JSON.stringify(state));
159
+ process.chdir(project);
160
+ azureCli.setAzCliInvocationForTesting({ ...invocation, env: { SWALLOWKIT_AZ_FIXTURE_STATE: stateFile } });
161
+ });
162
+ afterEach(() => {
163
+ process.chdir(originalCwd);
164
+ azureCli.resetAzCliInvocationCache();
165
+ jest.restoreAllMocks();
166
+ fs.rmSync(project, { recursive: true, force: true });
167
+ });
168
+
169
+ it("keeps approval mandatory, makes no Azure writes before approval, and pins the subscription", async () => {
170
+ const plan = await planProvisionOperation(options);
171
+ expect(plan.subscription).toBe(subscription);
172
+ await expect(executeProvisionPlan(plan)).rejects.toMatchObject({ code: "approval-required", status: "requires-human" });
173
+ expect(read().groupCreates).toBe(0);
174
+ expect(read().deploymentCreates).toBe(0);
175
+ expect(read().tagAttempts).toBe(0);
176
+ expect(plan.commandSpecs.filter(command => command.kind === "deployment")[0].args).toContain("--no-wait");
177
+ });
178
+
179
+ it("gives each newly reviewed provision plan its own identity", async () => {
180
+ const first = await planProvisionOperation(options);
181
+ const second = await planProvisionOperation(options);
182
+ expect(second.planId).not.toBe(first.planId);
183
+ expect(second.fingerprints).toEqual(first.fingerprints);
184
+ });
185
+
186
+ it("accepts warning-only stderr/exit zero and completes only after Azure, tag and ledger succeed", async () => {
187
+ update({ warning: true });
188
+ const plan = await planProvisionOperation(options, { persist: false });
189
+ const result = await executeProvisionPlan(plan, { approve: true });
190
+ expect(result).toMatchObject({ status: "complete", outcome: "succeeded", runId: plan.planId });
191
+ expect(result.warnings.join(" ")).toContain("bicep.use_binary_from_path");
192
+ expect(result.deployment?.state).toBe("Succeeded");
193
+ expect(read()).toMatchObject({ deploymentCreates: 1, tagAttempts: 1 });
194
+ expect(result).not.toHaveProperty("deploymentOutputs");
195
+ expect(loadProvisionRun<{ outcome: string }>(plan.planId)?.outcome).toBe("succeeded");
196
+ expect(read().calls.some(args => args.includes("--debug") || args.includes("cancel") || args.includes("delete"))).toBe(false);
197
+ });
198
+
199
+ it("returns trackable Running and resumes the same deployment, without any second create", async () => {
200
+ update({ states: ["Running"] });
201
+ const plan = await planProvisionOperation(options);
202
+ const before = ledger();
203
+ const running = await executeProvisionPlan(plan, { approve: true, waitSeconds: 0 });
204
+ expect(running).toMatchObject({ status: "in-progress", outcome: "in-progress", deployment: { state: "Running" } });
205
+ expect(ledger()).toBe(before);
206
+ expect(read().tagAttempts).toBe(0);
207
+ expect(formatProvisionResult(running)).not.toContain("Provisioning completed");
208
+ const status = await inspectProvisionOperation({ planId: running.runId });
209
+ expect(status.status).toBe("in-progress");
210
+ expect(read().deploymentCreates).toBe(1);
211
+ const callsBeforeDeniedResume = read().calls.length;
212
+ await expect(applyProvisionOperation({ planId: running.runId })).rejects.toMatchObject({ code: "approval-required" });
213
+ expect(read().calls).toHaveLength(callsBeforeDeniedResume);
214
+ await expect(planProvisionOperation(options)).rejects.toMatchObject({ code: "provision-run-pending" });
215
+ update({ states: ["Succeeded"] });
216
+ const resumed = await applyProvisionOperation({ planId: running.runId, approve: true });
217
+ expect(resumed.status).toBe("complete");
218
+ expect(read().deploymentCreates).toBe(1);
219
+ expect(read().tagAttempts).toBe(1);
220
+ expect((await applyProvisionOperation({ planId: running.runId, approve: true })).status).toBe("complete");
221
+ expect(read().deploymentCreates).toBe(1);
222
+ });
223
+
224
+ it("polls a long-running Azure deployment to terminal state within a bounded wait", async () => {
225
+ update({ states: ["Accepted", "Running", "Succeeded"] });
226
+ const result = await executeProvisionPlan(await planProvisionOperation(options), { approve: true, waitSeconds: 5, pollIntervalMs: 10 });
227
+ expect(result.status).toBe("complete");
228
+ expect(read().deploymentCreates).toBe(1);
229
+ });
230
+
231
+ it.each(["communication", "nonzero", "slow"])("distinguishes local %s from Azure Running", async (submitMode) => {
232
+ update({ submitMode, states: ["Running"] });
233
+ const original = azureCli.runAzCliAsync;
234
+ jest.spyOn(azureCli, "runAzCliAsync").mockImplementation((args, runOptions) => original(args, {
235
+ ...runOptions, ...(args[0] === "deployment" && args[2] === "create" ? { timeout: 1000 } : {}),
236
+ }));
237
+ const result = await executeProvisionPlan(await planProvisionOperation(options), { approve: true, waitSeconds: 0 });
238
+ expect(result).toMatchObject({ status: "in-progress", deployment: { state: "Running" } });
239
+ const diagnostic = result.diagnostics.find(entry => entry.phase === "baseline-submit")!;
240
+ expect(diagnostic.failureKind).toBe(submitMode === "slow" ? "timeout" : submitMode === "communication" ? "communication" : "exit");
241
+ expect(diagnostic.elapsedMs).toBeGreaterThan(0);
242
+ expect(formatProvisionResult(result)).toContain(`kind=${diagnostic.failureKind}`);
243
+ expect(read()).toMatchObject({ deploymentCreates: 1, tagAttempts: 0 });
244
+ await executeProvisionPlan(await planForRun(result.runId), { approve: true, waitSeconds: 0 });
245
+ expect(read().deploymentCreates).toBe(1);
246
+ });
247
+
248
+ async function planForRun(runId: string) {
249
+ return loadProvisionRun<{ plan: Awaited<ReturnType<typeof planProvisionOperation>> }>(runId)!.plan;
250
+ }
251
+
252
+ it.each(["communication", "not-found", "invalid"])("reports unknown for a %s state query instead of inventing an Azure failure", async (queryMode) => {
253
+ const plan = await planProvisionOperation(options);
254
+ update({ queryMode });
255
+ // Pre-submit reads must succeed/not-found; fail only the post-submit state query.
256
+ const original = azureCli.runAzCliAsync;
257
+ jest.spyOn(azureCli, "runAzCliAsync").mockImplementation(async (args, runOptions) => {
258
+ if (args[0] === "deployment" && args[2] === "show" && read().deploymentCreates === 0) {
259
+ update({ queryMode: undefined });
260
+ const result = await original(args, runOptions);
261
+ update({ queryMode });
262
+ return result;
263
+ }
264
+ return original(args, runOptions);
265
+ });
266
+ const result = await executeProvisionPlan(plan, { approve: true, waitSeconds: 0 });
267
+ expect(result).toMatchObject({ status: "blocked", outcome: "unknown", deployment: { state: "unknown" } });
268
+ expect(result.warnings.join(" ")).toContain("not proof that Azure failed");
269
+ expect(read()).toMatchObject({ deploymentCreates: 1, tagAttempts: 0 });
270
+ });
271
+
272
+ it("shows terminal Azure and deployment-operation causes alongside a nonzero az exit, with secrets redacted", async () => {
273
+ const secret = "fixture-secret-do-not-print";
274
+ update({
275
+ submitMode: "nonzero", states: ["Failed"], error: { code: "DeploymentFailed", message: `apiKey=${secret}`, details: [{ code: "ParentError", message: "Nested module failed" }] },
276
+ operations: [{ error: { error: { code: "SkuNotAvailable", message: `Capacity unavailable; functionKey=${secret}` } } }],
277
+ });
278
+ const plan = await planProvisionOperation(options);
279
+ let failure: unknown;
280
+ try { await executeProvisionPlan(plan, { approve: true }); } catch (error) { failure = error; }
281
+ expect(failure).toMatchObject({ code: "azure-deployment-failed", status: "failed" });
282
+ expect(formatCliError(failure)).toContain("SkuNotAvailable");
283
+ expect(formatCliError(failure)).toContain("exit=7");
284
+ expect(JSON.stringify(failure)).not.toContain(secret);
285
+ expect(read().tagAttempts).toBe(0);
286
+ });
287
+
288
+ it("does not mark Canceled deployments as successful", async () => {
289
+ update({ states: ["Canceled"], error: { code: "DeploymentCanceled", message: "Cancelled outside SwallowKit" } });
290
+ await expect(executeProvisionPlan(await planProvisionOperation(options), { approve: true })).rejects.toMatchObject({ code: "azure-deployment-failed" });
291
+ expect(read().tagAttempts).toBe(0);
292
+ });
293
+
294
+ it("uses proven Azure Succeeded even after a local nonzero exit, while preserving that local error diagnostic", async () => {
295
+ update({ submitMode: "nonzero" });
296
+ const result = await executeProvisionPlan(await planProvisionOperation(options), { approve: true });
297
+ expect(result).toMatchObject({ status: "complete", outcome: "succeeded", deployment: { state: "Succeeded" } });
298
+ expect(result.diagnostics).toEqual(expect.arrayContaining([expect.objectContaining({ phase: "baseline-submit", exitCode: 7, failureKind: "exit" })]));
299
+ expect(read()).toMatchObject({ deploymentCreates: 1, tagAttempts: 1 });
300
+ });
301
+
302
+ it.each([false, true])("recovers a tag failure (tag already applied: %s) without redeploying Bicep", async (applyTagBeforeFailure) => {
303
+ update({ failTags: 1, applyTagBeforeFailure });
304
+ const result = await executeProvisionPlan(await planProvisionOperation(options), { approve: true });
305
+ expect(result).toMatchObject({ status: "blocked", outcome: "partial", deployment: { state: "Succeeded" } });
306
+ const before = read().tagAttempts;
307
+ const status = await inspectProvisionOperation({ planId: result.runId });
308
+ expect(status.outcome).toBe("partial");
309
+ expect(read().tagAttempts).toBe(before);
310
+ expect((await applyProvisionOperation({ planId: result.runId, approve: true })).status).toBe("complete");
311
+ expect(read().deploymentCreates).toBe(1);
312
+ expect(read().tagAttempts).toBe(applyTagBeforeFailure ? 1 : 2);
313
+ });
314
+
315
+ it("resumes local ledger failure after successful Azure/tag completion without any Azure create or tag replay", async () => {
316
+ jest.spyOn(migrations, "replaceMigrationManifest").mockImplementationOnce(() => { throw new Error("fixture ledger write locked"); });
317
+ const result = await executeProvisionPlan(await planProvisionOperation(options), { approve: true });
318
+ expect(result.outcome).toBe("partial");
319
+ expect(formatProvisionResult(result)).toContain("checkpoint");
320
+ expect((await applyProvisionOperation({ planId: result.runId, approve: true })).status).toBe("complete");
321
+ expect(read()).toMatchObject({ deploymentCreates: 1, tagAttempts: 1 });
322
+ });
323
+
324
+ it("handles Ctrl+C during az waiting without cancelling Azure or updating the ledger", async () => {
325
+ update({ submitMode: "slow", states: ["Running"] });
326
+ const original = azureCli.runAzCliAsync;
327
+ jest.spyOn(azureCli, "runAzCliAsync").mockImplementation((args, runOptions) => {
328
+ if (args[0] === "deployment" && args[2] === "create") {
329
+ const deadline = Date.now() + 3000;
330
+ const timer = setInterval(() => {
331
+ if (read().deploymentCreates > 0 || Date.now() >= deadline) {
332
+ clearInterval(timer);
333
+ process.emit("SIGINT");
334
+ }
335
+ }, 20);
336
+ }
337
+ return original(args, runOptions);
338
+ });
339
+ const result = await executeProvisionPlan(await planProvisionOperation(options), { approve: true });
340
+ expect(result.interrupted).toBe("SIGINT");
341
+ expect(result.status).not.toBe("complete");
342
+ expect(read()).toMatchObject({ deploymentCreates: 1, tagAttempts: 0 });
343
+ expect(formatProvisionResult(result)).toContain("Azure may continue");
344
+ update({ states: ["Succeeded"], submitMode: undefined });
345
+ expect((await applyProvisionOperation({ planId: result.runId, approve: true })).status).toBe("complete");
346
+ expect(read().deploymentCreates).toBe(1);
347
+ });
348
+
349
+ it("recovers after hard process termination from the pre-submit checkpoint without a second create", async () => {
350
+ update({ submitMode: "slow", submitDelay: 1000, states: ["Running"] });
351
+ const child = spawn(process.execPath, [path.join(__dirname, "fixtures", "provision-runner.cjs")], {
352
+ cwd: project, env: { ...process.env, SWALLOWKIT_AZ_FIXTURE_STATE: stateFile },
353
+ stdio: ["ignore", "pipe", "pipe"],
354
+ });
355
+ child.stdout.resume();
356
+ child.stderr.resume();
357
+ const closed = new Promise<void>((resolve) => child.once("close", () => resolve()));
358
+ try {
359
+ const deadline = Date.now() + 10000;
360
+ while (read().deploymentCreates === 0 && Date.now() < deadline) {
361
+ await new Promise(resolve => setTimeout(resolve, 30));
362
+ }
363
+ expect(read().deploymentCreates).toBe(1);
364
+ child.kill("SIGKILL");
365
+ await closed;
366
+ const file = fs.readdirSync(path.join(project, ".swallowkit", "state", "provision")).find(name => name.endsWith(".json"))!;
367
+ const runId = file.slice(0, -5);
368
+ expect(read().tagAttempts).toBe(0);
369
+ update({ states: ["Succeeded"], submitMode: undefined });
370
+ expect((await applyProvisionOperation({ planId: runId, approve: true })).status).toBe("complete");
371
+ expect(read().deploymentCreates).toBe(1);
372
+ } finally {
373
+ if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
374
+ await closed;
375
+ }
376
+ }, 20000);
377
+
378
+ it("does not let large output kill provisioning or expose deployment output secrets", async () => {
379
+ update({ submitMode: "flood" });
380
+ const result = await executeProvisionPlan(await planProvisionOperation(options), { approve: true });
381
+ expect(result.status).toBe("complete");
382
+ expect(result.diagnostics.some(entry => entry.outputTruncated)).toBe(true);
383
+ expect(JSON.stringify(result)).not.toContain("fixture-secret-do-not-print");
384
+ expect(formatProvisionResult(result)).toContain("output omitted/truncated");
385
+ });
386
+
387
+ it("preserves fingerprint checks on resume and detects replacement of the original deployment identity", async () => {
388
+ update({ states: ["Running"] });
389
+ const result = await executeProvisionPlan(await planProvisionOperation(options), { approve: true, waitSeconds: 0 });
390
+ const file = path.join(project, "infra", "main.bicep");
391
+ const original = fs.readFileSync(file, "utf-8");
392
+ fs.appendFileSync(file, "// modified after approval");
393
+ await expect(applyProvisionOperation({ planId: result.runId, approve: true })).rejects.toMatchObject({ code: "stale-plan" });
394
+ fs.writeFileSync(file, original);
395
+ update({ states: ["Succeeded"], deployments: { "swallowkit-baseline": { correlationId: "replacement", templateHash: "fixture-template-hash" } } });
396
+ const status = await inspectProvisionOperation({ planId: result.runId });
397
+ expect(status).toMatchObject({ outcome: "unknown", deployment: { reason: "identity-changed" } });
398
+ expect(read().tagAttempts).toBe(0);
399
+ });
400
+
401
+ it("does not silently discard corrupt checkpoints and start another bootstrap", async () => {
402
+ update({ states: ["Running"] });
403
+ const result = await executeProvisionPlan(await planProvisionOperation(options), { approve: true, waitSeconds: 0 });
404
+ fs.writeFileSync(path.join(project, ".swallowkit", "state", "provision", `${result.runId}.json`), "{corrupt");
405
+ await expect(applyProvisionOperation({ planId: result.runId, approve: true })).rejects.toMatchObject({ code: "invalid-provision-checkpoint" });
406
+ expect(read().deploymentCreates).toBe(1);
407
+ });
408
+
409
+ it("blocks semantic checkpoint corruption even when its outcome claims success", async () => {
410
+ const plan = await planProvisionOperation(options);
411
+ saveProvisionRun({
412
+ schemaVersion: 1, runId: plan.planId, projectRoot: path.resolve(project), cursor: 0,
413
+ plan: { planId: plan.planId }, outcome: "succeeded", warnings: [], diagnostics: [], executedCommands: [],
414
+ });
415
+ await expect(planProvisionOperation(options)).rejects.toMatchObject({ code: "invalid-provision-checkpoint" });
416
+ expect(read().deploymentCreates).toBe(0);
417
+ });
418
+
419
+ it("still provides read-only state when local reviewed files are unavailable, without exposing unredactable causes", async () => {
420
+ update({ states: ["Running"] });
421
+ const result = await executeProvisionPlan(await planProvisionOperation(options), { approve: true, waitSeconds: 0 });
422
+ fs.unlinkSync(path.join(project, "infra", "main.bicep"));
423
+ update({ states: ["Failed"], error: { code: "FixtureError", message: "opaque-unavailable-secret" } });
424
+ const status = await inspectProvisionOperation({ planId: result.runId });
425
+ expect(status.deployment?.state).toBe("Failed");
426
+ expect(JSON.stringify(status)).not.toContain("opaque-unavailable-secret");
427
+ expect(status.warnings.join(" ")).toContain("omitted");
428
+ expect(read().tagAttempts).toBe(0);
429
+ });
430
+
431
+ it("redacts opaque secure parameter values from warning text and Azure causes, even without a key label", async () => {
432
+ const secret = "opaque-secure-parameter-value";
433
+ fs.appendFileSync(path.join(project, "infra", "main.bicep"), "@secure()\nparam opaqueInput string\n");
434
+ fs.writeFileSync(path.join(project, "infra", "main.parameters.json"), JSON.stringify({
435
+ parameters: { projectName: { value: "fixture-app" }, opaqueInput: { value: secret } },
436
+ }));
437
+ update({ submitStderr: `WARNING: rejected value '${secret}'`, states: ["Failed"], error: { code: "ValidationFailed", message: `Invalid value '${secret}'` } });
438
+ let failure: unknown;
439
+ try { await executeProvisionPlan(await planProvisionOperation(options), { approve: true }); } catch (error) { failure = error; }
440
+ expect(failure).toMatchObject({ code: "azure-deployment-failed" });
441
+ expect(JSON.stringify(failure)).not.toContain(secret);
442
+ expect(formatCliError(failure)).not.toContain(secret);
443
+ });
444
+
445
+ it("omits unsafe status diagnostics when reviewed parameters were replaced with different valid JSON", async () => {
446
+ const secret = "opaque-original-parameter-value";
447
+ fs.appendFileSync(path.join(project, "infra", "main.bicep"), "@secure()\nparam opaqueInput string\n");
448
+ fs.writeFileSync(path.join(project, "infra", "main.parameters.json"), JSON.stringify({ parameters: { opaqueInput: { value: secret } } }));
449
+ update({ states: ["Running"] });
450
+ const result = await executeProvisionPlan(await planProvisionOperation(options), { approve: true, waitSeconds: 0 });
451
+ fs.writeFileSync(path.join(project, "infra", "main.parameters.json"), JSON.stringify({ parameters: { opaqueInput: { value: "replacement" } } }));
452
+ update({ states: ["Failed"], error: { code: "InvalidInput", message: secret } });
453
+ const status = await inspectProvisionOperation({ planId: result.runId });
454
+ expect(status.deployment?.state).toBe("Failed");
455
+ expect(JSON.stringify(status)).not.toContain(secret);
456
+ expect(status.warnings.join(" ")).toContain("changed");
457
+ expect(read()).toMatchObject({ deploymentCreates: 1, tagAttempts: 0 });
458
+ });
459
+
460
+ it("redacts reviewed secure values from resource-group query failures as well as deployment failures", async () => {
461
+ const secret = "opaque-group-query-secret";
462
+ fs.appendFileSync(path.join(project, "infra", "main.bicep"), "@secure()\nparam opaqueInput string\n");
463
+ fs.writeFileSync(path.join(project, "infra", "main.parameters.json"), JSON.stringify({ parameters: { opaqueInput: { value: secret } } }));
464
+ const plan = await planProvisionOperation(options);
465
+ const original = azureCli.runAzCliAsync;
466
+ jest.spyOn(azureCli, "runAzCliAsync").mockImplementation((args, runOptions) =>
467
+ args[0] === "group" && args[1] === "show"
468
+ ? Promise.resolve({ status: 1, stdout: "", stderr: `Invalid value '${secret}'`, signal: null, failureKind: "exit", elapsedMs: 1, stdoutTruncated: false, stderrTruncated: false })
469
+ : original(args, runOptions));
470
+ let failure: unknown;
471
+ try { await executeProvisionPlan(plan, { approve: true }); } catch (error) { failure = error; }
472
+ expect(failure).toMatchObject({ code: "azure-state-query-failed" });
473
+ expect(JSON.stringify(failure)).not.toContain(secret);
474
+ expect(read().deploymentCreates).toBe(0);
475
+ });
476
+
477
+ it("rejects remote migration-state changes before tag/ledger completion", async () => {
478
+ update({ states: ["Running"] });
479
+ const result = await executeProvisionPlan(await planProvisionOperation(options), { approve: true, waitSeconds: 0 });
480
+ const manifest = migrations.loadMigrationManifest(project);
481
+ update({ states: ["Succeeded"], tags: { [migrations.getMigrationTagKey(manifest.projectId)]: migrations.encodeMigrationState({ schemaVersion: 1, version: 1, checksum: "f".repeat(64) }) } });
482
+ await expect(applyProvisionOperation({ planId: result.runId, approve: true })).rejects.toMatchObject({ code: "stale-azure-state", nextActions: expect.any(Array) });
483
+ expect(read()).toMatchObject({ deploymentCreates: 1, tagAttempts: 0 });
484
+ });
485
+
486
+ it("does not reuse a completed run's cached success for a newly reviewed bootstrap", async () => {
487
+ const first = await executeProvisionPlan(await planProvisionOperation(options), { approve: true });
488
+ update({ groupExists: false, tags: {}, deployments: {} });
489
+ const secondPlan = await planProvisionOperation(options);
490
+ expect(secondPlan.planId).not.toBe(first.runId);
491
+ expect((await executeProvisionPlan(secondPlan, { approve: true })).status).toBe("complete");
492
+ expect(read().deploymentCreates).toBe(2);
493
+ });
494
+
495
+ it("read-only status rechecks completed migration bookkeeping instead of trusting cached success", async () => {
496
+ const result = await executeProvisionPlan(await planProvisionOperation(options), { approve: true });
497
+ update({ tags: {} });
498
+ const status = await inspectProvisionOperation({ planId: result.runId });
499
+ expect(status).toMatchObject({ outcome: "partial", status: "blocked", deployment: { state: "Succeeded" } });
500
+ expect(read()).toMatchObject({ deploymentCreates: 1, tagAttempts: 1 });
501
+ });
502
+
503
+ it("bounds a multi-migration run and resumes only the remaining reviewed migration", async () => {
504
+ await executeProvisionPlan(await planProvisionOperation(options), { approve: true });
505
+ const { createCustomMigration } = await import("../core/operations/migration-generator");
506
+ createCustomMigration("first", project);
507
+ createCustomMigration("second", project);
508
+ const plan = await planProvisionOperation(options);
509
+ const result = await executeProvisionPlan(plan, { approve: true, waitSeconds: 0 });
510
+ expect(result.outcome).toBe("partial");
511
+ expect(read().deploymentCreates).toBe(2);
512
+ expect((await applyProvisionOperation({ planId: result.runId, approve: true })).status).toBe("complete");
513
+ expect(read().deploymentCreates).toBe(3);
514
+ expect(read().calls.filter(args => args[0] === "deployment" && args[2] === "create").map(args => args[args.indexOf("--name") + 1]))
515
+ .toEqual(expect.arrayContaining(["swallowkit-baseline", ...plan.pendingMigrations.map(migration => `swallowkit-migration-${migration.version}-${migration.checksum.slice(0, 12)}`)]));
516
+ }, 20000);
517
+
518
+ it("recovers an older untracked Running deployment using matching ARM/parameter evidence and no create", async () => {
519
+ update({ groupExists: true, states: ["Running"], deployments: { "swallowkit-baseline": { correlationId: "old-correlation", templateHash: "fixture-template-hash" } } });
520
+ const plan = await planProvisionOperation({ ...options, resumeDeployment: "swallowkit-baseline" });
521
+ expect(plan.mode).toBe("recover");
522
+ expect(plan.commands.every(command => !command.includes(" create "))).toBe(true);
523
+ expect((await executeProvisionPlan(plan, { approve: true, waitSeconds: 0 })).status).toBe("in-progress");
524
+ update({ states: ["Succeeded"] });
525
+ expect((await applyProvisionOperation({ planId: plan.planId, approve: true })).status).toBe("complete");
526
+ expect(read()).toMatchObject({ groupCreates: 0, deploymentCreates: 0, tagAttempts: 1 });
527
+ });
528
+
529
+ it.each(["template", "parameters", "secure"])("blocks unsafe untracked-deployment recovery: %s evidence", async (kind) => {
530
+ update({ groupExists: true, deployments: { "swallowkit-baseline": { correlationId: "old", templateHash: "fixture-template-hash" } } });
531
+ if (kind === "template") update({ remoteTemplate: { resources: [{ type: "unexpected" }] } });
532
+ if (kind === "parameters") update({ parameters: { ...read().parameters, location: { value: "westus2" } } });
533
+ if (kind === "secure") update({ template: { ...read().template, parameters: { password: { type: "secureString" } } } });
534
+ await expect(planProvisionOperation({ ...options, resumeDeployment: "swallowkit-baseline" })).rejects.toMatchObject({
535
+ code: kind === "template" ? "recovery-template-mismatch" : kind === "parameters" ? "recovery-parameters-mismatch" : "recovery-secure-parameters",
536
+ });
537
+ expect(read()).toMatchObject({ deploymentCreates: 0, tagAttempts: 0 });
538
+ });
539
+
540
+ it("keeps regular CLI, machine, MCP status and nextActions consistent", async () => {
541
+ update({ states: ["Running"] });
542
+ const logs: string[] = [];
543
+ jest.spyOn(console, "log").mockImplementation((text: unknown) => { logs.push(String(text)); });
544
+ await runCli(["node", "swallowkit", "provision", "-g", options.resourceGroup, "--location", options.location, "--swa-location", options.swaLocation, "--approve", "--wait-seconds", "0"]);
545
+ expect(logs.join("\n")).toContain("still in progress");
546
+ expect(logs.join("\n")).not.toContain("Provisioning completed");
547
+ const file = fs.readdirSync(path.join(project, ".swallowkit", "state", "provision")).find(name => name.endsWith(".json"))!;
548
+ const runId = file.slice(0, -5);
549
+ const writes: string[] = [];
550
+ jest.spyOn(process.stdout, "write").mockImplementation((chunk: string | Uint8Array) => { writes.push(String(chunk)); return true; });
551
+ const previousExitCode = process.exitCode;
552
+ await runMachineCli(["node", "swallowkit", "machine", "inspect", "provision", "--plan", runId]);
553
+ process.exitCode = previousExitCode;
554
+ const machine = JSON.parse(writes.join("")) as { ok: boolean; status: string; data: ApplyProvisionResult; nextActions: unknown[] };
555
+ expect(machine.status).toBe("in-progress");
556
+ expect(machine.nextActions).toEqual(machine.data.nextActions);
557
+ const runner = jest.fn(async () => ({ stdout: JSON.stringify(machine), stderr: "", exitCode: 0 }));
558
+ const tool = buildSwallowKitToolDefinitions(runner).find(candidate => candidate.name === "swallowkit_inspect_provision")!;
559
+ const mcp = JSON.parse((await tool.handler({ planId: runId, waitSeconds: 0 })).content[0].text);
560
+ expect(mcp).toMatchObject({ status: "in-progress", runId, nextActions: machine.nextActions, metadata: { swallowkitVersion: expect.any(String) } });
561
+ expect(runner).toHaveBeenCalledWith(["inspect", "provision", "--plan", runId, "--wait-seconds", "0"]);
562
+ expect(read()).toMatchObject({ deploymentCreates: 1, tagAttempts: 0 });
563
+ });
564
+
565
+ it("treats normal provision invocation as consent without an extra prompt or machine instructions", async () => {
566
+ update({ outputs: { functionsAppName: "func-fixture", staticWebAppName: "swa-fixture", apiKey: "secret-not-for-terminal" } });
567
+ const logs: string[] = [];
568
+ jest.spyOn(console, "log").mockImplementation((text: unknown) => { logs.push(String(text)); });
569
+ await runCli(["node", "swallowkit", "provision", "-g", options.resourceGroup, "--location", options.location, "--swa-location", options.swaLocation]);
570
+ const output = logs.join("\n");
571
+ expect(output).toContain("Provisioning completed");
572
+ expect(output).toContain("AZURE_FUNCTIONAPP_NAME: func-fixture");
573
+ expect(output).toContain("AZURE_STATIC_WEB_APPS_API_TOKEN");
574
+ expect(output).not.toContain("secret-not-for-terminal");
575
+ expect(output).not.toMatch(/machine |--approve|Run these Azure commands|Plan /);
576
+ expect(read()).toMatchObject({ deploymentCreates: 1, tagAttempts: 1 });
577
+ expect(read().calls.some(args => args[0] === "staticwebapp" && args[1] === "secrets")).toBe(false);
578
+ });
579
+
580
+ it("keeps normal CLI terminal failure instructions human-only and does not suggest resuming as a repair", async () => {
581
+ update({ states: ["Failed"], error: { code: "NotFound", message: "Request url is invalid." } });
582
+ let failure: unknown;
583
+ try {
584
+ await runCli(["node", "swallowkit", "provision", "-g", options.resourceGroup, "--location", options.location, "--swa-location", options.swaLocation]);
585
+ } catch (error) { failure = error; }
586
+ const output = formatCliError(failure);
587
+ expect(output).toContain("NotFound: Request url is invalid.");
588
+ expect(output).toContain("swallowkit provision --status");
589
+ expect(output).not.toMatch(/machine |--plan|--approve|Resume:/);
590
+ expect(read()).toMatchObject({ deploymentCreates: 1, tagAttempts: 0 });
591
+ });
592
+
593
+ it("shows human commands when existing execution state blocks a new provision", async () => {
594
+ update({ states: ["Running"] });
595
+ const running = await executeProvisionPlan(await planProvisionOperation(options), { approve: true, waitSeconds: 0 });
596
+ let failure: unknown;
597
+ try { await planProvisionOperation(options); } catch (error) { failure = error; }
598
+ const output = formatProvisionError(failure);
599
+ expect(output).toContain(`swallowkit provision --status ${running.runId}`);
600
+ expect(output).not.toMatch(/machine |--plan|--approve/);
601
+ expect(read().deploymentCreates).toBe(1);
602
+ });
603
+
604
+ it("does not redeploy or report deployment failure when resource outputs cannot be read after success", async () => {
605
+ update({ outputQueryFails: true });
606
+ const warnings: string[] = [];
607
+ jest.spyOn(console, "log").mockImplementation(() => {});
608
+ jest.spyOn(console, "warn").mockImplementation((text: unknown) => { warnings.push(String(text)); });
609
+ await runCli(["node", "swallowkit", "provision", "-g", options.resourceGroup, "--location", options.location, "--swa-location", options.swaLocation]);
610
+ expect(warnings.join("\n")).toContain("Deployment succeeded, but resource information could not be read");
611
+ expect(read()).toMatchObject({ deploymentCreates: 1, tagAttempts: 1 });
612
+ });
613
+
614
+ it("resumes a human-invoked run without an additional approval and never resubmits Bicep", async () => {
615
+ update({ states: ["Running"] });
616
+ jest.spyOn(console, "log").mockImplementation(() => {});
617
+ await runCli(["node", "swallowkit", "provision", "-g", options.resourceGroup, "--location", options.location, "--swa-location", options.swaLocation, "--wait-seconds", "0"]);
618
+ const file = fs.readdirSync(path.join(project, ".swallowkit", "state", "provision")).find(name => name.endsWith(".json"))!;
619
+ const runId = file.slice(0, -5);
620
+ update({ states: ["Succeeded"] });
621
+ await runCli(["node", "swallowkit", "provision", "--resume", runId]);
622
+ expect(read()).toMatchObject({ deploymentCreates: 1, tagAttempts: 1 });
623
+ expect(loadProvisionRun<{ outcome: string }>(runId)?.outcome).toBe("succeeded");
624
+ });
625
+
626
+ it("retains successful deployment when the output-reading helper throws", async () => {
627
+ const original = azureCli.runAzCliAsync;
628
+ jest.spyOn(azureCli, "runAzCliAsync").mockImplementation((args, runOptions) =>
629
+ args.some(arg => arg.includes("properties.outputs.")) ? Promise.reject(new Error("Fixture output transport failed")) : original(args, runOptions));
630
+ jest.spyOn(console, "log").mockImplementation(() => {});
631
+ const warning = jest.spyOn(console, "warn").mockImplementation(() => {});
632
+ await runCli(["node", "swallowkit", "provision", "-g", options.resourceGroup, "--location", options.location, "--swa-location", options.swaLocation]);
633
+ expect(warning).toHaveBeenCalledWith(expect.stringContaining("Deployment succeeded, but resource information could not be read"));
634
+ expect(read()).toMatchObject({ deploymentCreates: 1, tagAttempts: 1 });
635
+ });
636
+ });