swallowkit 2.0.0-alpha.5 → 2.0.0-alpha.6

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 +2 -1
  2. package/dist/cli/commands/provision.d.ts.map +1 -1
  3. package/dist/cli/commands/provision.js +66 -4
  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 +13 -0
  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 +28 -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 +3 -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 +130 -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 +562 -0
  51. package/src/cli/commands/provision.ts +69 -5
  52. package/src/cli/index.ts +10 -0
  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 +18 -0
  56. package/src/core/project/agent-templates/CLAUDE.md +5 -0
  57. package/src/core/project/agent-templates/copilot-instructions.md +6 -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 +28 -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
@@ -1,9 +1,7 @@
1
1
  /**
2
2
  * Plan / Apply Provision — Azure プロビジョニングの事前計画と承認付き実行。
3
3
  *
4
- * - plan provision: ローカルの決定論的プリフライト(Bicep 解析・az CLI 有無)のみ。
5
- * ネットワークアクセスや az login を必要とする操作は行わない。
6
- * --what-if を明示した場合のみ az deployment group what-if を実行する。
4
+ * - plan provision: Bicep と Azure の現在の状態を確認する。リソースは変更しない。
7
5
  * - apply provision: 常に承認(--approve)必須。承認なしでは requires-human で停止する。
8
6
  * - 対話的な `swallowkit provision` は同じ計画をメモリ上で作成・表示し、確認後に
9
7
  * executeProvisionPlan で同じ安全確認を経て実行する(計画ファイルは保存しない)。
@@ -11,13 +9,19 @@
11
9
 
12
10
  import * as crypto from "crypto";
13
11
  import * as fs from "fs";
14
- import { AzCliAvailability, checkAzCliAvailability, runAzCli } from "../../utils/azure-cli";
12
+ import { AzCliAvailability, AzCliResult, checkAzCliAvailability, runAzCli, runAzCliAsync } from "../../utils/azure-cli";
13
+ import { AzureCliDiagnostic, azureCliDiagnostic, redactAzureText } from "../../utils/azure-diagnostics";
15
14
  import * as path from "path";
16
15
  import { MachineCommandError } from "../../machine/errors";
16
+ import type { MachineNextAction, MachineOperationStatus } from "../../machine/contracts";
17
17
  import { getSwallowKitVersion } from "../../version";
18
18
  import { inspectInfra, InfraInspection } from "../project/infra";
19
- import { deletePlanState, loadPlanState, savePlanState } from "../project/state";
19
+ import { deletePlanState, listProvisionRuns, loadPlanState, loadProvisionRun, lockProvisionTarget, savePlanState, saveProvisionRun } from "../project/state";
20
20
  import { hashFileIfExists } from "./file-session";
21
+ import {
22
+ DEFAULT_PROVISION_WAIT_SECONDS, DEPLOYMENT_QUERY, DeploymentObservation, DeploymentReference,
23
+ MAX_PROVISION_WAIT_SECONDS, queryAzureDeployment, queryDeploymentOperationErrors, waitForAzureDeployment,
24
+ } from "./provision-deployment";
21
25
  import {
22
26
  MIGRATION_MANIFEST_PATH,
23
27
  MigrationManifest,
@@ -49,9 +53,11 @@ export interface PlanProvisionOptions {
49
53
  adoptExisting?: boolean;
50
54
  baseline?: number;
51
55
  reconcile?: boolean;
56
+ /** Attach an existing, matching baseline deployment without submitting Bicep again. */
57
+ resumeDeployment?: string;
52
58
  }
53
59
 
54
- export type ProvisionMode = "offline" | "bootstrap" | "adoption-required" | "adopt" | "migrate" | "noop" | "reconcile";
60
+ export type ProvisionMode = "offline" | "bootstrap" | "adoption-required" | "adopt" | "migrate" | "noop" | "reconcile" | "recover";
55
61
 
56
62
  interface ProvisionCommand {
57
63
  file: "az";
@@ -85,6 +91,7 @@ export interface ProvisionPlanData {
85
91
  requiresApproval: true;
86
92
  fingerprints: Record<string, string | null>;
87
93
  baselineSnapshotChecksum?: string;
94
+ recoveryDeployment?: DeploymentReference;
88
95
  }
89
96
 
90
97
  function validateProvisionInputs(options: PlanProvisionOptions): void {
@@ -112,6 +119,9 @@ function validateProvisionInputs(options: PlanProvisionOptions): void {
112
119
  "failed"
113
120
  );
114
121
  }
122
+ if (options.resumeDeployment && (!/^[A-Za-z0-9._()-]{1,64}$/.test(options.resumeDeployment) || options.reconcile || options.adoptExisting)) {
123
+ throw new MachineCommandError("invalid-arguments", "--resume-deployment requires a valid deployment name and cannot be combined with adoption or reconcile.", undefined, "blocked");
124
+ }
115
125
  }
116
126
 
117
127
  function azCliUnavailableWarning(availability: Exclude<AzCliAvailability, { available: true }>): string {
@@ -124,32 +134,22 @@ function displayCommand(command: ProvisionCommand): string {
124
134
  return [command.file, ...command.args.map((arg) => /\s/.test(arg) ? JSON.stringify(arg) : arg)].join(" ");
125
135
  }
126
136
 
127
- interface AzResult {
128
- status: number | null;
129
- stdout: string;
130
- stderr: string;
131
- }
132
-
133
- function runAz(args: string[], timeout = 300_000): AzResult {
134
- const result = runAzCli(args, { timeout });
135
- return {
136
- status: result.status,
137
- stdout: result.stdout,
138
- stderr: result.stderr || (result.error ? result.error.message : ""),
139
- };
137
+ function runAz(args: string[], timeout = 60_000): AzCliResult {
138
+ return runAzCli(args, { timeout });
140
139
  }
141
140
 
142
- function azQueryError(code: string, message: string, result: AzResult): MachineCommandError {
143
- const output = (result.stderr || result.stdout || "").trim();
141
+ function azQueryError(code: string, message: string, result: AzCliResult, secretValues: readonly string[] = []): MachineCommandError {
142
+ const diagnostic = azureCliDiagnostic(result, "azure-state", secretValues);
143
+ const output = (diagnostic.stderr || diagnostic.stdout).trim().slice(0, 2_000);
144
144
  if (/az login|not logged in|AADSTS|expired/i.test(output)) {
145
145
  return new MachineCommandError(
146
146
  "az-not-logged-in",
147
147
  `Azure CLI is not signed in (or the session expired). Run "az login", then try again.\n${output.slice(0, 1000)}`,
148
- undefined,
148
+ { diagnostic },
149
149
  "requires-human"
150
150
  );
151
151
  }
152
- return new MachineCommandError(code, `${message}: ${output}`, undefined, "blocked");
152
+ return new MachineCommandError(code, `${message}: ${output || result.errorCode || result.failureKind || `exit ${result.status}`}`, { diagnostic }, "blocked");
153
153
  }
154
154
 
155
155
  function subscriptionArgs(subscription?: string): string[] {
@@ -158,10 +158,10 @@ function subscriptionArgs(subscription?: string): string[] {
158
158
 
159
159
  function buildBootstrapCommands(options: PlanProvisionOptions, mainBicepPath: string, parametersPath: string): ProvisionCommand[] {
160
160
  const commands: ProvisionCommand[] = [];
161
- commands.push({ file: "az", args: ["group", "create", "--name", options.resourceGroup, "--location", options.location, ...subscriptionArgs(options.subscription)], kind: "resource-group" });
161
+ commands.push({ file: "az", args: ["group", "create", "--name", options.resourceGroup, "--location", options.location, ...subscriptionArgs(options.subscription), "--query", "id", "-o", "json"], kind: "resource-group" });
162
162
  commands.push({
163
163
  file: "az",
164
- args: ["deployment", "group", "create", "--name", "swallowkit-baseline", "--resource-group", options.resourceGroup, "--template-file", mainBicepPath, "--parameters", parametersPath, "--parameters", `location=${options.location}`, `swaLocation=${options.swaLocation}`, ...subscriptionArgs(options.subscription)],
164
+ args: ["deployment", "group", "create", "--name", "swallowkit-baseline", "--resource-group", options.resourceGroup, "--template-file", mainBicepPath, "--parameters", parametersPath, "--parameters", `location=${options.location}`, `swaLocation=${options.swaLocation}`, ...subscriptionArgs(options.subscription), "--no-wait", "--query", DEPLOYMENT_QUERY, "-o", "json"],
165
165
  kind: "deployment",
166
166
  });
167
167
  return commands;
@@ -186,24 +186,30 @@ interface RemoteGroupState {
186
186
 
187
187
  function inspectRemoteGroup(options: PlanProvisionOptions): RemoteGroupState {
188
188
  const existsResult = runAz(["group", "exists", "--name", options.resourceGroup, ...subscriptionArgs(options.subscription)], 60_000);
189
- if (existsResult.status !== 0) {
189
+ if (existsResult.status !== 0 || existsResult.failureKind || !/^(true|false)$/.test(existsResult.stdout.trim())) {
190
190
  throw azQueryError("azure-state-query-failed", "Could not query resource group", existsResult);
191
191
  }
192
192
  if ((existsResult.stdout || "").trim() !== "true") return { exists: false, tags: {} };
193
193
  const showResult = runAz(["group", "show", "--name", options.resourceGroup, ...subscriptionArgs(options.subscription), "--query", "{id:id,tags:tags}", "-o", "json"], 60_000);
194
- if (showResult.status !== 0) {
194
+ if (showResult.status !== 0 || showResult.failureKind || showResult.stdoutTruncated) {
195
195
  throw azQueryError("azure-state-query-failed", "Could not read resource group tags", showResult);
196
196
  }
197
- const parsed = JSON.parse(showResult.stdout || "{}");
197
+ let parsed: { id?: string; tags?: Record<string, string> };
198
+ try { parsed = JSON.parse(showResult.stdout); } catch {
199
+ throw azQueryError("azure-state-query-failed", "Invalid resource group response", showResult);
200
+ }
201
+ if (!parsed || typeof parsed.id !== "string") {
202
+ throw azQueryError("azure-state-query-failed", "Invalid resource group identity", showResult);
203
+ }
198
204
  return { exists: true, id: parsed.id, tags: parsed.tags ?? {} };
199
205
  }
200
206
 
201
207
  function tagCommand(resourceId: string, key: string, state: MigrationState, expectedState: MigrationState | null): ProvisionCommand {
202
- return { file: "az", args: ["tag", "update", "--resource-id", resourceId, "--operation", "Merge", "--tags", `${key}=${encodeMigrationState(state)}`], kind: "tag", expectedState };
208
+ return { file: "az", args: ["tag", "update", "--resource-id", resourceId, "--operation", "Merge", "--tags", `${key}=${encodeMigrationState(state)}`, "-o", "none"], kind: "tag", expectedState };
203
209
  }
204
210
 
205
211
  function bootstrapTagCommand(options: PlanProvisionOptions, key: string, state: MigrationState): ProvisionCommand {
206
- return { file: "az", args: ["group", "update", "--name", options.resourceGroup, "--set", `tags.${key}=${encodeMigrationState(state)}`, ...subscriptionArgs(options.subscription)], kind: "tag", expectedState: null };
212
+ return { file: "az", args: ["group", "update", "--name", options.resourceGroup, "--set", `tags.${key}=${encodeMigrationState(state)}`, ...subscriptionArgs(options.subscription), "-o", "none"], kind: "tag", expectedState: null };
207
213
  }
208
214
 
209
215
  function runBaseWhatIf(options: PlanProvisionOptions, mainBicepPath: string, parametersPath: string): string {
@@ -219,7 +225,7 @@ function runBaseWhatIf(options: PlanProvisionOptions, mainBicepPath: string, par
219
225
  "-o", "json",
220
226
  ]);
221
227
  if (result.status !== 0) {
222
- throw new MachineCommandError("what-if-failed", `az deployment group what-if failed: ${(result.stderr || result.stdout).trim().slice(0, 2000)}`, undefined, "blocked");
228
+ throw azQueryError("what-if-failed", "az deployment group what-if failed", result);
223
229
  }
224
230
  return result.stdout.trim();
225
231
  }
@@ -242,6 +248,90 @@ function assertAdoptionWhatIfIsSafe(whatIfResult: string): void {
242
248
  }
243
249
  }
244
250
 
251
+ function resolveSubscription(options: PlanProvisionOptions): string {
252
+ if (options.subscription) return options.subscription;
253
+ const result = runAz(["account", "show", "--query", "id", "-o", "tsv"]);
254
+ const subscription = result.stdout.trim();
255
+ if (result.status !== 0 || !SUBSCRIPTION_PATTERN.test(subscription)) {
256
+ throw azQueryError("azure-subscription-query-failed", "Could not identify the Azure subscription for this provision run", result);
257
+ }
258
+ return subscription;
259
+ }
260
+
261
+ function canonicalTemplate(value: unknown): string {
262
+ const normalize = (item: unknown): unknown => {
263
+ if (Array.isArray(item)) return item.map(normalize);
264
+ if (item && typeof item === "object") {
265
+ return Object.fromEntries(Object.entries(item).filter(([key]) => key !== "_generator")
266
+ .sort(([left], [right]) => left.localeCompare(right)).map(([key, entry]) => [key, normalize(entry)]));
267
+ }
268
+ return item;
269
+ };
270
+ return JSON.stringify(normalize(value));
271
+ }
272
+
273
+ async function verifyExistingBaseline(
274
+ options: PlanProvisionOptions,
275
+ mainBicepPath: string,
276
+ parametersPath: string
277
+ ): Promise<DeploymentReference> {
278
+ const projectRoot = path.dirname(path.dirname(mainBicepPath));
279
+ const secretValues = provisionSecretValues({
280
+ fingerprints: Object.fromEntries(collectLegacyBaselineFiles(projectRoot).map(file => [file.path, file.checksum])),
281
+ }, projectRoot);
282
+ const reference: DeploymentReference = {
283
+ subscription: options.subscription!, resourceGroup: options.resourceGroup, name: options.resumeDeployment!,
284
+ };
285
+ const observation = await queryAzureDeployment(reference, { secretValues });
286
+ if (observation.state === "unknown") {
287
+ throw new MachineCommandError("recovery-deployment-unknown", "The existing deployment could not be identified. No Bicep will be submitted.", { deployment: observation }, "blocked");
288
+ }
289
+ const compiled = await runAzCliAsync(["bicep", "build", "--file", mainBicepPath, "--stdout"], { timeout: 60_000, captureBytes: 16_777_216 });
290
+ const exported = await runAzCliAsync([
291
+ "deployment", "group", "export", "--name", reference.name,
292
+ "--resource-group", reference.resourceGroup, "--subscription", reference.subscription,
293
+ ], { timeout: 60_000, captureBytes: 16_777_216 });
294
+ const parameters = await runAzCliAsync([
295
+ "deployment", "group", "show", "--name", reference.name, "--resource-group", reference.resourceGroup,
296
+ "--subscription", reference.subscription, "--query", "properties.parameters", "-o", "json",
297
+ ], { timeout: 30_000 });
298
+ if ([compiled, exported, parameters].some((result) => result.status !== 0 || result.failureKind || result.stdoutTruncated)) {
299
+ throw new MachineCommandError("recovery-evidence-unavailable", "Recovery requires the existing deployment template and parameters to match the local baseline. Evidence could not be read; nothing will be redeployed.", {
300
+ diagnostics: [compiled, exported, parameters].map((result, index) => ({
301
+ ...azureCliDiagnostic(result, ["local-bicep-build", "recovery-template", "recovery-parameters"][index], secretValues),
302
+ stdout: "[template/parameter payload omitted]", stderr: "[template/parameter payload omitted]", outputTruncated: true,
303
+ })),
304
+ }, "blocked");
305
+ }
306
+ let template: Record<string, unknown>;
307
+ let remoteParameters: Record<string, { type?: string; value?: unknown }>;
308
+ let localParameters: Record<string, { value?: unknown }>;
309
+ try {
310
+ template = JSON.parse(compiled.stdout) as Record<string, unknown>;
311
+ if (canonicalTemplate(template) !== canonicalTemplate(JSON.parse(exported.stdout))) {
312
+ throw new MachineCommandError("recovery-template-mismatch", "The deployed ARM template differs from the local Bicep. Restore the matching reviewed files before recovery; do not redeploy to repair migration bookkeeping.", undefined, "blocked");
313
+ }
314
+ remoteParameters = JSON.parse(parameters.stdout) as Record<string, { type?: string; value?: unknown }>;
315
+ localParameters = JSON.parse(fs.readFileSync(parametersPath, "utf-8")).parameters as Record<string, { value?: unknown }>;
316
+ if (!remoteParameters || !localParameters || typeof remoteParameters !== "object" || typeof localParameters !== "object") throw new Error("Invalid parameters");
317
+ } catch (error) {
318
+ if (error instanceof MachineCommandError) throw error;
319
+ throw new MachineCommandError("recovery-invalid-evidence", "Recovery template/parameter evidence is not valid JSON.", undefined, "blocked");
320
+ }
321
+ const declarations = template.parameters as Record<string, { type?: string }> | undefined;
322
+ if (Object.values(declarations ?? {}).some((parameter) => /^secure/i.test(parameter.type ?? ""))) {
323
+ throw new MachineCommandError("recovery-secure-parameters", "This untracked deployment has secure parameters that cannot be verified. Recovery requires the original execution checkpoint; no secret values will be requested or printed.", undefined, "blocked");
324
+ }
325
+ const expected = { ...localParameters, location: { value: options.location }, swaLocation: { value: options.swaLocation } };
326
+ for (const [name, parameter] of Object.entries(expected)) {
327
+ if (!parameter || !Object.prototype.hasOwnProperty.call(parameter, "value")
328
+ || canonicalTemplate(parameter.value) !== canonicalTemplate(remoteParameters[name]?.value)) {
329
+ throw new MachineCommandError("recovery-parameters-mismatch", "The deployed parameters differ from the local reviewed parameters/regions. No tag or ledger will be changed.", undefined, "blocked");
330
+ }
331
+ }
332
+ return observation.reference;
333
+ }
334
+
245
335
  /**
246
336
  * Builds the provision plan. Machine/MCP callers persist it for a later `apply provision --plan`;
247
337
  * the interactive `swallowkit provision` command keeps it in memory and executes it after confirmation.
@@ -284,6 +374,7 @@ export async function planProvisionOperation(
284
374
  let whatIfResult: string | undefined;
285
375
  let manifestAfterApply: MigrationManifest | undefined;
286
376
  let baselineDrift: string[] = [];
377
+ let recoveryDeployment: DeploymentReference | undefined;
287
378
 
288
379
  const manifestPath = path.join(projectRoot, MIGRATION_MANIFEST_PATH);
289
380
  const manifest = fs.existsSync(manifestPath) ? loadMigrationManifest(projectRoot) : null;
@@ -300,6 +391,18 @@ export async function planProvisionOperation(
300
391
  }
301
392
 
302
393
  if (azCliAvailable) {
394
+ options = { ...options, subscription: resolveSubscription(options) };
395
+ const unfinished = readRuns(projectRoot).find((run) =>
396
+ run.outcome !== "succeeded" && run.outcome !== "failed" && run.plan.resourceGroup === options.resourceGroup
397
+ && run.plan.subscription === options.subscription);
398
+ if (unfinished) {
399
+ throw new MachineCommandError(
400
+ "provision-run-pending",
401
+ `Provision run ${unfinished.runId} is unfinished. Inspect or resume that run instead of submitting Bicep again.`,
402
+ { runId: unfinished.runId, outcome: unfinished.outcome },
403
+ "blocked", provisionNextActions(unfinished.runId)
404
+ );
405
+ }
303
406
  const remoteGroup = inspectRemoteGroup(options);
304
407
  if (!manifest) {
305
408
  if (remoteGroup.exists) {
@@ -314,7 +417,32 @@ export async function planProvisionOperation(
314
417
  }
315
418
 
316
419
  migrationTagKey = getMigrationTagKey(manifest.projectId);
317
- if (!remoteGroup.exists) {
420
+ if (options.resumeDeployment) {
421
+ if (!remoteGroup.exists || manifest.baseline.legacy) {
422
+ throw new MachineCommandError("recovery-baseline-required", "--resume-deployment requires an existing resource group and the original non-legacy baseline manifest.", undefined, "blocked");
423
+ }
424
+ const tagValue = remoteGroup.tags[migrationTagKey];
425
+ remoteState = tagValue ? decodeMigrationState(tagValue) : null;
426
+ if (remoteState && remoteState.version !== 0) {
427
+ throw new MachineCommandError("recovery-baseline-only", "Untracked-deployment recovery supports baseline 0 only. Use the original checkpoint to resume migrations.", undefined, "blocked");
428
+ }
429
+ recoveryDeployment = await verifyExistingBaseline(options, mainBicepPath, parametersPath);
430
+ const baselineFiles = collectLegacyBaselineFiles(projectRoot);
431
+ manifestAfterApply = {
432
+ ...manifest,
433
+ baseline: { ...manifest.baseline, files: baselineFiles, checksum: computeBaselineChecksum(baselineFiles) },
434
+ };
435
+ if (remoteState && remoteState.checksum !== manifestAfterApply.baseline.checksum) {
436
+ throw new MachineCommandError("recovery-checksum-mismatch", "The existing baseline tag does not match this project. Nothing will be overwritten.", undefined, "blocked");
437
+ }
438
+ mode = "recover";
439
+ warnings.push("Recovery only observes the matching existing deployment, then records migration state after Azure Succeeded. It never submits Bicep.");
440
+ commandSpecs = [{
441
+ file: "az",
442
+ args: ["deployment", "group", "show", "--name", recoveryDeployment.name, "--resource-group", options.resourceGroup, ...subscriptionArgs(options.subscription), "--query", DEPLOYMENT_QUERY, "-o", "json"],
443
+ kind: "deployment",
444
+ }, tagCommand(remoteGroup.id!, migrationTagKey, { schemaVersion: 1, version: 0, checksum: manifestAfterApply.baseline.checksum }, remoteState)];
445
+ } else if (!remoteGroup.exists) {
318
446
  mode = "bootstrap";
319
447
  const baselineFiles = collectLegacyBaselineFiles(projectRoot);
320
448
  manifestAfterApply = {
@@ -340,7 +468,7 @@ export async function planProvisionOperation(
340
468
  commandSpecs = [];
341
469
  if (options.reconcile && !manifest.baseline.legacy) {
342
470
  whatIfResult = runBaseWhatIf(options, mainBicepPath, parametersPath);
343
- warnings.push("Recovering an interrupted bootstrap by reconciling the baseline before recording migration state.");
471
+ warnings.push("Explicit reconcile redeploys the untagged baseline. For an interrupted wait/tag/ledger update use --resume-deployment instead.");
344
472
  mode = "reconcile";
345
473
  const baselineFiles = collectLegacyBaselineFiles(projectRoot);
346
474
  manifestAfterApply = {
@@ -398,12 +526,13 @@ export async function planProvisionOperation(
398
526
  file: "az",
399
527
  args: [
400
528
  "deployment", "group", "create",
401
- "--name", "swallowkit-migration",
529
+ "--name", `swallowkit-migration-${migration.version}-${migration.checksum.slice(0, 12)}`,
402
530
  "--resource-group", options.resourceGroup,
403
531
  "--template-file", path.join(projectRoot, migration.template),
404
532
  ...(migration.parameters ? ["--parameters", path.join(projectRoot, migration.parameters)] : []),
405
533
  "--parameters", `projectName=${projectName}`, `location=${options.location}`,
406
534
  ...subscriptionArgs(options.subscription),
535
+ "--no-wait", "--query", DEPLOYMENT_QUERY, "-o", "json",
407
536
  ],
408
537
  kind: "migration",
409
538
  expectedState,
@@ -414,11 +543,11 @@ export async function planProvisionOperation(
414
543
  }
415
544
  }
416
545
  }
417
- } else if (options.whatIf || options.adoptExisting || options.reconcile) {
546
+ } else if (options.whatIf || options.adoptExisting || options.reconcile || options.resumeDeployment) {
418
547
  const detail = azCli.available ? "" : ` ${azCliUnavailableWarning(azCli)}`;
419
548
  throw new MachineCommandError(
420
549
  !azCli.available && azCli.reason === "not-runnable" ? "az-cli-not-runnable" : "az-cli-not-found",
421
- `Azure CLI is required for --what-if, --adopt-existing, and --reconcile.${detail}`,
550
+ `Azure CLI is required for --what-if, --adopt-existing, --reconcile, and --resume-deployment.${detail}`,
422
551
  undefined,
423
552
  "blocked"
424
553
  );
@@ -453,6 +582,8 @@ export async function planProvisionOperation(
453
582
  mode,
454
583
  remoteState,
455
584
  pendingMigrations: pendingMigrations.map((migration) => ({ version: migration.version, checksum: migration.checksum })),
585
+ recoveryDeployment,
586
+ nonce: crypto.randomUUID(),
456
587
  baselineSnapshotChecksum,
457
588
  fingerprints,
458
589
  })
@@ -483,6 +614,7 @@ export async function planProvisionOperation(
483
614
  requiresApproval: true,
484
615
  fingerprints,
485
616
  ...(baselineSnapshotChecksum ? { baselineSnapshotChecksum } : {}),
617
+ ...(recoveryDeployment ? { recoveryDeployment } : {}),
486
618
  };
487
619
 
488
620
  if (planOptions.persist !== false) savePlanState(plan, projectRoot);
@@ -492,67 +624,221 @@ export async function planProvisionOperation(
492
624
  export interface ApplyProvisionOptions {
493
625
  planId: string;
494
626
  approve?: boolean;
627
+ waitSeconds?: number;
495
628
  }
496
629
 
497
630
  export interface ApplyProvisionResult {
631
+ status: MachineOperationStatus;
632
+ outcome: "succeeded" | "in-progress" | "unknown" | "partial" | "failed";
633
+ runId: string;
498
634
  planId: string;
499
635
  resourceGroup: string;
500
636
  location: string;
501
637
  swaLocation: string;
502
638
  executedCommands: string[];
503
- deploymentOutputs?: Record<string, unknown>;
504
639
  warnings: string[];
640
+ diagnostics: AzureCliDiagnostic[];
641
+ deployment?: DeploymentObservation;
642
+ nextActions: MachineNextAction[];
643
+ interrupted?: NodeJS.Signals;
644
+ }
645
+
646
+ export interface ProvisionRun {
647
+ schemaVersion: 1;
648
+ runId: string;
649
+ projectRoot: string;
650
+ plan: ProvisionPlanData;
651
+ cursor: number;
652
+ attemptedIndex?: number;
653
+ deployment?: DeploymentReference;
654
+ observation?: DeploymentObservation;
655
+ outcome: ApplyProvisionResult["outcome"];
656
+ executedCommands: string[];
657
+ warnings: string[];
658
+ diagnostics: AzureCliDiagnostic[];
659
+ updatedAt: string;
505
660
  }
506
661
 
507
662
  function statesEqual(left: MigrationState | null | undefined, right: MigrationState | null | undefined): boolean {
508
663
  return left?.version === right?.version && left?.checksum === right?.checksum;
509
664
  }
510
665
 
511
- function readRemoteMigrationState(plan: ProvisionPlanData): { group: RemoteGroupState; state: MigrationState | null } {
512
- const group = inspectRemoteGroup(plan);
513
- if (!group.exists || !plan.migrationTagKey) return { group, state: null };
514
- const tagValue = group.tags[plan.migrationTagKey];
515
- return { group, state: tagValue ? decodeMigrationState(tagValue) : null };
666
+ const MACHINE_RETRY_HINT = "Re-run plan provision.";
667
+ export const INTERACTIVE_PROVISION_RETRY_HINT = 'Run "swallowkit provision" again to review the current state.';
668
+
669
+ export function provisionNextActions(runId: string): MachineNextAction[] {
670
+ return [
671
+ { command: `swallowkit machine inspect provision --plan ${runId}`, description: "Read the original Azure deployment state without creating, cancelling, deleting, tagging, or updating the ledger." },
672
+ { command: `swallowkit machine apply provision --plan ${runId} --approve`, description: "After explicit human approval, resume this checkpoint: observe the original deployment and finish tags/ledger; never resubmit an attempted deployment." },
673
+ ];
674
+ }
675
+
676
+ function resultForRun(run: ProvisionRun, outcome = run.outcome, interrupted?: NodeJS.Signals): ApplyProvisionResult {
677
+ run.warnings = run.warnings.slice(-20);
678
+ run.diagnostics = run.diagnostics.slice(-20);
679
+ return {
680
+ status: outcome === "succeeded" ? "complete" : outcome === "in-progress" ? "in-progress" : outcome === "failed" ? "failed" : "blocked",
681
+ outcome, runId: run.runId, planId: run.plan.planId,
682
+ resourceGroup: run.plan.resourceGroup, location: run.plan.location, swaLocation: run.plan.swaLocation,
683
+ executedCommands: run.executedCommands, warnings: run.warnings, diagnostics: run.diagnostics,
684
+ ...(run.observation ? { deployment: run.observation } : {}),
685
+ nextActions: outcome === "succeeded"
686
+ ? [{ command: "swallowkit machine inspect infra", description: "Inspect local infrastructure assets after deployment and migration bookkeeping completed." }]
687
+ : provisionNextActions(run.runId),
688
+ ...(interrupted ? { interrupted } : {}),
689
+ };
516
690
  }
517
691
 
518
- function assertPlanRemoteState(plan: ProvisionPlanData, expected: MigrationState | null | undefined, retryHint: string): void {
519
- if (plan.mode === "offline") return;
520
- const current = readRemoteMigrationState(plan);
521
- if (plan.mode === "bootstrap") {
522
- if (current.group.exists) {
523
- throw new MachineCommandError("stale-azure-state", `The resource group was created after the preview was computed. ${retryHint}`, undefined, "blocked");
524
- }
525
- return;
692
+ function readRun(runId: string, projectRoot: string): ProvisionRun | null {
693
+ let run: ProvisionRun | null;
694
+ try { run = loadProvisionRun<ProvisionRun>(runId, projectRoot); } catch {
695
+ throw new MachineCommandError("invalid-provision-checkpoint", "The execution checkpoint cannot be read. Do not re-submit Bicep; inspect the original deployment first.", { runId }, "blocked");
526
696
  }
527
- if (!current.group.exists || !statesEqual(current.state, expected)) {
528
- throw new MachineCommandError(
529
- "stale-azure-state",
530
- `Azure migration state changed after the preview was computed. ${retryHint}`,
531
- { expected, actual: current.state, resourceGroupExists: current.group.exists },
532
- "blocked"
533
- );
697
+ if (run && (run.schemaVersion !== 1 || run.runId !== runId || run.plan?.planId !== runId
698
+ || run.projectRoot !== path.resolve(projectRoot) || !Number.isInteger(run.cursor)
699
+ || !Array.isArray(run.plan.commandSpecs) || !run.plan.fingerprints
700
+ || !Array.isArray(run.warnings) || !Array.isArray(run.diagnostics) || !Array.isArray(run.executedCommands)
701
+ || !["succeeded", "in-progress", "unknown", "partial", "failed"].includes(run.outcome)
702
+ || run.cursor < 0 || run.cursor > run.plan.commandSpecs.length)) {
703
+ throw new MachineCommandError("invalid-provision-checkpoint", "The execution checkpoint is invalid or belongs to another checkout. Nothing will be re-submitted.", { runId }, "blocked");
534
704
  }
705
+ return run;
535
706
  }
536
707
 
537
- function assertTagExpectedState(plan: ProvisionPlanData, expected: MigrationState | null | undefined, retryHint: string): void {
538
- const current = readRemoteMigrationState(plan);
539
- if (!current.group.exists || !statesEqual(current.state, expected)) {
540
- throw new MachineCommandError(
541
- "stale-azure-state",
542
- `Azure migration state changed before its tag could be updated. ${retryHint}`,
543
- { expected, actual: current.state, resourceGroupExists: current.group.exists },
544
- "blocked"
545
- );
708
+ function readRuns(projectRoot: string): ProvisionRun[] {
709
+ try {
710
+ return listProvisionRuns<ProvisionRun>(projectRoot).map(record => {
711
+ if (!record || typeof record.runId !== "string") throw new Error("Invalid checkpoint identity.");
712
+ const run = readRun(record.runId, projectRoot);
713
+ if (!run) throw new Error("Missing execution checkpoint.");
714
+ return run;
715
+ });
716
+ } catch (error) {
717
+ if (error instanceof MachineCommandError) throw error;
718
+ throw new MachineCommandError("invalid-provision-checkpoint", "An execution checkpoint is corrupt. Preserve it and inspect the original deployment; nothing will be re-submitted.", undefined, "blocked");
546
719
  }
547
720
  }
548
721
 
549
- const MACHINE_RETRY_HINT = "Re-run plan provision.";
550
- export const INTERACTIVE_PROVISION_RETRY_HINT = 'Run "swallowkit provision" again to review the current state.';
722
+ function validateWaitSeconds(value = DEFAULT_PROVISION_WAIT_SECONDS): number {
723
+ if (!Number.isInteger(value) || value < 0 || value > MAX_PROVISION_WAIT_SECONDS) {
724
+ throw new MachineCommandError("invalid-arguments", `--wait-seconds must be an integer from 0 to ${MAX_PROVISION_WAIT_SECONDS}.`, undefined, "blocked");
725
+ }
726
+ return value;
727
+ }
728
+
729
+ function assertLocalFiles(plan: ProvisionPlanData, projectRoot: string, retryHint: string, allowRecordedManifest = false): void {
730
+ const changedFiles = Object.entries(plan.fingerprints).filter(([relativePath, expectedHash]) => {
731
+ const file = path.join(projectRoot, relativePath);
732
+ if (allowRecordedManifest && relativePath === MIGRATION_MANIFEST_PATH && plan.manifestAfterApply && fs.existsSync(file)
733
+ && fs.readFileSync(file, "utf-8") === `${JSON.stringify(plan.manifestAfterApply, null, 2)}\n`) return false;
734
+ return hashFileIfExists(file) !== expectedHash;
735
+ }).map(([file]) => file);
736
+ if (changedFiles.length || (plan.baselineSnapshotChecksum
737
+ && computeBaselineChecksum(collectLegacyBaselineFiles(projectRoot)) !== plan.baselineSnapshotChecksum)) {
738
+ throw new MachineCommandError("stale-plan", `${changedFiles.join(", ") || "Baseline infrastructure files"} changed after the preview was computed. ${retryHint}`, { planId: plan.planId, changedFiles }, "blocked");
739
+ }
740
+ }
741
+
742
+ async function readMigrationState(plan: ProvisionPlanData, signal?: AbortSignal, secretValues: readonly string[] = []): Promise<{ group: RemoteGroupState; state: MigrationState | null }> {
743
+ const exists = await runAzCliAsync(["group", "exists", "--name", plan.resourceGroup, ...subscriptionArgs(plan.subscription)], { timeout: 30_000, signal });
744
+ if (exists.status !== 0 || exists.failureKind || !/^(true|false)$/.test(exists.stdout.trim())) {
745
+ throw azQueryError("azure-state-query-failed", "Could not query resource group", exists, secretValues);
746
+ }
747
+ if (exists.stdout.trim() === "false") return { group: { exists: false, tags: {} }, state: null };
748
+ const show = await runAzCliAsync(["group", "show", "--name", plan.resourceGroup, ...subscriptionArgs(plan.subscription), "--query", "{id:id,tags:tags}", "-o", "json"], { timeout: 30_000, signal });
749
+ if (show.status !== 0 || show.failureKind || show.stdoutTruncated) throw azQueryError("azure-state-query-failed", "Could not read resource group tags", show, secretValues);
750
+ let parsed: { id?: unknown; tags?: Record<string, string> };
751
+ try { parsed = JSON.parse(show.stdout); } catch { throw azQueryError("azure-state-query-failed", "Invalid resource group response", show, secretValues); }
752
+ const expectedId = `/subscriptions/${plan.subscription}/resourceGroups/${plan.resourceGroup}`;
753
+ if (typeof parsed.id !== "string" || parsed.id.toLowerCase() !== expectedId.toLowerCase()) {
754
+ throw new MachineCommandError("azure-resource-group-mismatch", "Azure returned a different resource group/subscription. Nothing will be updated.", undefined, "blocked");
755
+ }
756
+ const group = { exists: true, id: parsed.id, tags: parsed.tags ?? {} };
757
+ const tag = plan.migrationTagKey ? group.tags[plan.migrationTagKey] : undefined;
758
+ return { group, state: tag ? decodeMigrationState(tag) : null };
759
+ }
760
+
761
+ function desiredTagState(command: ProvisionCommand, key?: string): MigrationState {
762
+ const value = command.args.find((arg) => arg.startsWith(`${key}=`) || arg.startsWith(`tags.${key}=`));
763
+ if (!value) throw new MachineCommandError("incompatible-plan", "The reviewed migration tag is missing.", undefined, "blocked");
764
+ return decodeMigrationState(value.slice(value.indexOf("=") + 1));
765
+ }
766
+
767
+ function provisionSecretValues(plan: Pick<ProvisionPlanData, "fingerprints">, projectRoot: string): string[] {
768
+ const files = Object.keys(plan.fingerprints);
769
+ const secureBicep = files.filter(file => file.endsWith(".bicep")).some(file => /@secure\s*\(/.test(fs.readFileSync(path.join(projectRoot, file), "utf-8")));
770
+ const values: string[] = [];
771
+ const collect = (value: unknown): void => {
772
+ if (typeof value === "string" && value) values.push(value);
773
+ else if (Array.isArray(value)) value.forEach(collect);
774
+ else if (value && typeof value === "object") Object.values(value).forEach(collect);
775
+ };
776
+ for (const file of files.filter(file => file.endsWith(".json") && file !== MIGRATION_MANIFEST_PATH)) {
777
+ let parsed: { parameters?: Record<string, { value?: unknown }> };
778
+ try { parsed = JSON.parse(fs.readFileSync(path.join(projectRoot, file), "utf-8")); } catch {
779
+ throw new MachineCommandError("invalid-infra-parameters", "Infrastructure JSON is invalid. Correct it before provision; unstructured Azure output cannot safely redact unknown parameter secrets.", undefined, "blocked");
780
+ }
781
+ for (const [name, parameter] of Object.entries(parsed.parameters ?? {})) {
782
+ if (secureBicep || /secret|password|token|key|connection.?string/i.test(name)) collect(parameter?.value);
783
+ }
784
+ }
785
+ return [...new Set(values)].sort((left, right) => right.length - left.length);
786
+ }
787
+
788
+ function rememberDiagnostic(run: ProvisionRun, result: AzCliResult, phase: string, secretValues: readonly string[]): void {
789
+ run.diagnostics.push(azureCliDiagnostic(result, phase, secretValues));
790
+ run.diagnostics = run.diagnostics.slice(-20);
791
+ if (result.stderr.trim()) {
792
+ run.warnings.push(result.stderrTruncated ? "Azure CLI stderr exceeded the capture limit; output omitted." : redactAzureText(result.stderr, secretValues).slice(0, 4_000));
793
+ run.warnings = run.warnings.slice(-20);
794
+ }
795
+ }
796
+
797
+ export async function inspectProvisionOperation(options: { planId: string; waitSeconds?: number }): Promise<ApplyProvisionResult> {
798
+ const run = readRun(options.planId, process.cwd());
799
+ if (!run) throw new MachineCommandError("provision-run-not-found", "No execution checkpoint exists for this ID. For an older interrupted bootstrap use plan provision --resume-deployment <name> with the original resource group/subscription/regions.", { runId: options.planId }, "blocked");
800
+ const waitSeconds = validateWaitSeconds(options.waitSeconds ?? 0);
801
+ if (run.deployment) {
802
+ let secretValues: string[] = [];
803
+ let omitOutput = false;
804
+ try {
805
+ assertLocalFiles(run.plan, process.cwd(), "Reviewed files no longer match.", true);
806
+ secretValues = provisionSecretValues(run.plan, process.cwd());
807
+ } catch {
808
+ omitOutput = true;
809
+ run.warnings.push("Reviewed files are unavailable or changed; deployment state is still read, but diagnostic output/causes are omitted to avoid exposing unknown secrets.");
810
+ }
811
+ run.observation = await waitForAzureDeployment(run.deployment, { deadline: Date.now() + waitSeconds * 1_000, secretValues });
812
+ if (omitOutput) {
813
+ run.observation.errors = [];
814
+ run.observation.diagnostic = { ...run.observation.diagnostic, stdout: "[output omitted: reviewed parameters unavailable]", stderr: "", outputTruncated: true };
815
+ }
816
+ run.diagnostics = [...run.diagnostics, run.observation.diagnostic].slice(-20);
817
+ let outcome: ApplyProvisionResult["outcome"] = run.observation.state === "Failed" || run.observation.state === "Canceled" ? "failed"
818
+ : run.observation.state === "Succeeded" ? run.outcome === "succeeded" ? "succeeded" : "partial"
819
+ : run.observation.state === "unknown" ? "unknown" : "in-progress";
820
+ if (outcome === "succeeded") {
821
+ try {
822
+ assertLocalFiles(run.plan, process.cwd(), "The original reviewed files no longer match.", true);
823
+ const tag = [...run.plan.commandSpecs].reverse().find(command => command.kind === "tag");
824
+ if (tag && !statesEqual((await readMigrationState(run.plan, undefined, secretValues)).state, desiredTagState(tag, run.plan.migrationTagKey))) {
825
+ outcome = "partial";
826
+ run.warnings.push("Azure Succeeded, but the completed run's migration tag no longer matches. No bookkeeping or deployment write was performed.");
827
+ }
828
+ } catch {
829
+ outcome = "partial";
830
+ run.warnings.push("Azure Succeeded, but current local files or migration bookkeeping could not be verified. No writes were performed.");
831
+ }
832
+ }
833
+ return resultForRun(run, outcome);
834
+ }
835
+ return resultForRun(run);
836
+ }
551
837
 
552
838
  export async function applyProvisionOperation(options: ApplyProvisionOptions): Promise<ApplyProvisionResult> {
553
839
  const projectRoot = process.cwd();
554
-
555
- const plan = loadPlanState<ProvisionPlanData>(options.planId, projectRoot);
840
+ const run = /^[a-f0-9]{12}$/.test(options.planId) ? readRun(options.planId, projectRoot) : null;
841
+ const plan = run?.plan ?? loadPlanState<ProvisionPlanData>(options.planId, projectRoot);
556
842
  if (!plan) {
557
843
  throw new MachineCommandError(
558
844
  "plan-not-found",
@@ -562,8 +848,12 @@ export async function applyProvisionOperation(options: ApplyProvisionOptions): P
562
848
  );
563
849
  }
564
850
 
565
- const result = await executeProvisionPlan(plan, { approve: options.approve, retryHint: MACHINE_RETRY_HINT, projectRoot });
566
- deletePlanState(plan.planId, projectRoot);
851
+ const result = await executeProvisionPlan(plan, { approve: options.approve, waitSeconds: options.waitSeconds, retryHint: MACHINE_RETRY_HINT, projectRoot });
852
+ if (result.status === "complete") {
853
+ try { deletePlanState(plan.planId, projectRoot); } catch {
854
+ result.warnings.push("Azure and migration bookkeeping completed, but the old preview could not be removed. The completed execution checkpoint prevents replay.");
855
+ }
856
+ }
567
857
  return result;
568
858
  }
569
859
 
@@ -573,43 +863,18 @@ export async function applyProvisionOperation(options: ApplyProvisionOptions): P
573
863
  */
574
864
  export async function executeProvisionPlan(
575
865
  plan: ProvisionPlanData,
576
- options: { approve?: boolean; retryHint?: string; projectRoot?: string } = {}
866
+ options: { approve?: boolean; retryHint?: string; projectRoot?: string; waitSeconds?: number; signal?: AbortSignal; pollIntervalMs?: number } = {}
577
867
  ): Promise<ApplyProvisionResult> {
578
868
  const projectRoot = options.projectRoot ?? process.cwd();
579
869
  const retryHint = options.retryHint ?? MACHINE_RETRY_HINT;
580
-
581
- // Bicep が plan 後に変更されていないか確認
582
- const changedFiles: string[] = [];
583
- for (const [relativePath, expectedHash] of Object.entries(plan.fingerprints)) {
584
- const currentHash = hashFileIfExists(path.join(projectRoot, relativePath));
585
- if (currentHash !== expectedHash) changedFiles.push(relativePath);
586
- }
587
- if (changedFiles.length > 0) {
588
- throw new MachineCommandError(
589
- "stale-plan",
590
- `${changedFiles.join(", ")} changed after the preview was computed. ${retryHint}`,
591
- { planId: plan.planId, changedFiles },
592
- "blocked"
593
- );
594
- }
595
- if (plan.baselineSnapshotChecksum) {
596
- const currentBaselineChecksum = computeBaselineChecksum(collectLegacyBaselineFiles(projectRoot));
597
- if (currentBaselineChecksum !== plan.baselineSnapshotChecksum) {
598
- throw new MachineCommandError(
599
- "stale-plan",
600
- `Baseline infrastructure files changed after the preview was computed. ${retryHint}`,
601
- { planId: plan.planId },
602
- "blocked"
603
- );
604
- }
605
- }
606
-
870
+ const waitSeconds = validateWaitSeconds(options.waitSeconds);
607
871
  if (!options.approve) {
608
872
  throw new MachineCommandError(
609
873
  "approval-required",
610
874
  "Provisioning creates billable Azure resources and always requires human approval. Review plan.commands and re-run with --approve.",
611
875
  { planId: plan.planId, commands: plan.commands },
612
- "requires-human"
876
+ "requires-human",
877
+ [{ command: `swallowkit machine apply provision --plan ${plan.planId} --approve`, description: "Review the commands and obtain explicit human consent before applying or resuming." }]
613
878
  );
614
879
  }
615
880
  if (!plan.mode || !plan.commandSpecs) {
@@ -627,62 +892,249 @@ export async function executeProvisionPlan(
627
892
  if (plan.mode === "adoption-required") {
628
893
  throw new MachineCommandError(
629
894
  "adoption-required",
630
- 'This existing resource group has no SwallowKit migration state. Re-plan with --adopt-existing --baseline 0 --what-if.',
895
+ 'This resource group has no migration tag. Recover a matching existing baseline with --resume-deployment <name>; adoption applies only to a legacy manifest. Do not reconcile just to repair bookkeeping.',
631
896
  { planId: plan.planId },
632
897
  "requires-human"
633
898
  );
634
899
  }
635
-
900
+ let run = readRun(plan.planId, projectRoot);
901
+ const resuming = run !== null;
902
+ if (run) plan = run.plan;
903
+ try {
904
+ assertLocalFiles(plan, projectRoot, run ? "Restore the original reviewed files before resuming this checkpoint; do not resubmit Bicep." : retryHint, run?.cursor === plan.commandSpecs.length);
905
+ } catch (error) {
906
+ if (run && error instanceof MachineCommandError) {
907
+ throw new MachineCommandError(error.code, error.message, { ...resultForRun(run), cause: error.details }, error.status, provisionNextActions(run.runId));
908
+ }
909
+ throw error;
910
+ }
911
+ const secretValues = provisionSecretValues(plan, projectRoot);
912
+ if (!plan.subscription || plan.commandSpecs.some((command) => (command.kind === "deployment" || command.kind === "migration")
913
+ && !plan.recoveryDeployment && !command.args.includes("--no-wait"))) {
914
+ throw new MachineCommandError("incompatible-plan", `Create a fresh preview with bounded deployment tracking. For an older interrupted deployment use --resume-deployment instead of resubmitting Bicep. ${retryHint}`, undefined, "blocked");
915
+ }
636
916
  const azCli = checkAzCliAvailability();
637
917
  if (!azCli.available) {
638
918
  throw new MachineCommandError(azCli.reason === "not-found" ? "az-cli-not-found" : "az-cli-not-runnable", azCliUnavailableWarning(azCli), undefined, "blocked");
639
919
  }
640
920
 
641
- const executedCommands: string[] = [];
642
- const warnings = [...plan.warnings];
643
- let deploymentOutputs: Record<string, unknown> | undefined;
644
-
645
- assertPlanRemoteState(plan, plan.remoteState, retryHint);
646
-
647
- for (const command of plan.commandSpecs) {
648
- if (command.kind === "migration") {
649
- assertPlanRemoteState(plan, command.expectedState, retryHint);
921
+ const targetKey = crypto.createHash("sha256").update(`${plan.subscription}/${plan.resourceGroup.toLowerCase()}`).digest("hex");
922
+ let unlock: () => void;
923
+ try { unlock = lockProvisionTarget(targetKey, projectRoot); } catch (error) {
924
+ throw new MachineCommandError("provision-target-locked", error instanceof Error ? error.message : "Provision target is locked.", { runId: plan.planId }, "blocked", provisionNextActions(plan.planId));
925
+ }
926
+ const controller = new AbortController();
927
+ let interrupted: NodeJS.Signals | undefined;
928
+ const interrupt = (signal: NodeJS.Signals) => {
929
+ interrupted = signal;
930
+ controller.abort();
931
+ };
932
+ const sigint = () => interrupt("SIGINT");
933
+ const sigterm = () => interrupt("SIGTERM");
934
+ const externalAbort = () => controller.abort();
935
+ process.on("SIGINT", sigint);
936
+ process.on("SIGTERM", sigterm);
937
+ options.signal?.addEventListener("abort", externalAbort, { once: true });
938
+ if (options.signal?.aborted) controller.abort();
939
+ const signal = controller.signal;
940
+ const deadline = Date.now() + waitSeconds * 1_000;
941
+ const persist = () => {
942
+ if (!run) return;
943
+ run.warnings = run.warnings.slice(-20);
944
+ run.diagnostics = run.diagnostics.slice(-20);
945
+ run.updatedAt = new Date().toISOString();
946
+ saveProvisionRun(run, projectRoot);
947
+ };
948
+ const pause = (outcome: ApplyProvisionResult["outcome"], warning: string) => {
949
+ run!.outcome = outcome;
950
+ run!.warnings.push(warning);
951
+ persist();
952
+ return resultForRun(run!, outcome, interrupted);
953
+ };
954
+ const observe = async () => {
955
+ const observation = await waitForAzureDeployment(run!.deployment!, {
956
+ deadline: signal.aborted ? Date.now() : deadline,
957
+ // A final short read after Ctrl+C must not inherit the aborted wait signal.
958
+ ...(signal.aborted ? {} : { signal }),
959
+ ...(signal.aborted ? { queryTimeoutMs: 5_000 } : {}),
960
+ pollIntervalMs: options.pollIntervalMs,
961
+ secretValues,
962
+ onObservation: (value) => {
963
+ run!.observation = value;
964
+ if (value.state !== "unknown") run!.deployment = value.reference;
965
+ persist();
966
+ },
967
+ });
968
+ run!.observation = observation;
969
+ run!.diagnostics.push(observation.diagnostic);
970
+ run!.diagnostics = run!.diagnostics.slice(-20);
971
+ return observation;
972
+ };
973
+ try {
974
+ if (!run) {
975
+ const unfinished = readRuns(projectRoot).find((other) =>
976
+ other.outcome !== "succeeded" && other.outcome !== "failed"
977
+ && other.plan.resourceGroup === plan.resourceGroup && other.plan.subscription === plan.subscription);
978
+ if (unfinished) throw new MachineCommandError("provision-run-pending", "Resume the existing execution checkpoint instead of creating another deployment.", { runId: unfinished.runId }, "blocked", provisionNextActions(unfinished.runId));
979
+ const current = await readMigrationState(plan, signal, secretValues);
980
+ if (plan.mode === "bootstrap" ? current.group.exists : !current.group.exists || !statesEqual(current.state, plan.remoteState)) {
981
+ throw new MachineCommandError("stale-azure-state", plan.mode === "bootstrap"
982
+ ? `The resource group was created after the preview was computed. ${retryHint}`
983
+ : `Azure migration state changed after the preview was computed. ${retryHint}`, { expected: plan.remoteState, actual: current.state }, "blocked");
984
+ }
985
+ const checkpointPlan = { ...plan };
986
+ delete checkpointPlan.whatIfResult;
987
+ run = {
988
+ schemaVersion: 1, runId: plan.planId, projectRoot: path.resolve(projectRoot), plan: checkpointPlan,
989
+ cursor: 0, outcome: "in-progress", executedCommands: [], warnings: [...plan.warnings], diagnostics: [],
990
+ updatedAt: new Date().toISOString(),
991
+ };
992
+ persist();
650
993
  }
651
- if (command.kind === "tag") {
652
- assertTagExpectedState(plan, command.expectedState, retryHint);
994
+ for (; run.cursor < plan.commandSpecs.length;) {
995
+ if (signal.aborted) return pause(run.observation?.state === "Succeeded" ? "partial" : "unknown", "Local provisioning was interrupted. Azure may continue; use status/resume. No Azure cancel/delete was requested and the ledger is not marked successful.");
996
+ assertLocalFiles(plan, projectRoot, "Restore the original reviewed files before resuming; do not resubmit Bicep.");
997
+ const command = plan.commandSpecs[run.cursor];
998
+ if (command.kind === "deployment" || command.kind === "migration") {
999
+ if (command.kind === "migration" && run.attemptedIndex !== run.cursor && run.observation?.state === "Succeeded" && Date.now() >= deadline) {
1000
+ return pause("partial", "The bounded wait budget ended before the next reviewed migration. Resume this checkpoint after approval; completed deployments will not be re-submitted.");
1001
+ }
1002
+ if (run.attemptedIndex !== run.cursor) {
1003
+ let observationOnly = Boolean(plan.recoveryDeployment);
1004
+ if (plan.recoveryDeployment) {
1005
+ run.deployment = plan.recoveryDeployment;
1006
+ } else {
1007
+ const current = await readMigrationState(plan, signal, secretValues);
1008
+ if (!current.group.exists || !statesEqual(current.state, command.expectedState ?? plan.remoteState)) {
1009
+ throw new MachineCommandError("stale-azure-state", `Azure migration state changed before deployment. ${retryHint}`, { expected: command.expectedState ?? plan.remoteState, actual: current.state }, "blocked");
1010
+ }
1011
+ const name = command.args[command.args.indexOf("--name") + 1];
1012
+ const reference: DeploymentReference = { name, resourceGroup: plan.resourceGroup, subscription: plan.subscription! };
1013
+ const previous = await queryAzureDeployment(reference, { signal, secretValues });
1014
+ if (previous.state === "unknown" && previous.reason !== "not-found") {
1015
+ run.deployment = reference;
1016
+ run.observation = previous;
1017
+ return pause("unknown", "The deployment name could not be checked before submission. No create request was sent; inspect the original deployment before proceeding.");
1018
+ }
1019
+ if (previous.state !== "unknown" && (plan.mode !== "reconcile" || previous.state === "Running" || previous.state === "Accepted")) {
1020
+ run.deployment = previous.reference;
1021
+ run.observation = previous;
1022
+ if (!resuming || command.kind !== "deployment") {
1023
+ return pause("unknown", "A deployment already exists under the reviewed name. No create request was sent. Resume only after matching its template/parameters; do not overwrite or re-submit it.");
1024
+ }
1025
+ run.deployment = await verifyExistingBaseline({ ...plan, resumeDeployment: name },
1026
+ path.join(projectRoot, "infra", "main.bicep"), path.join(projectRoot, "infra", "main.parameters.json"));
1027
+ observationOnly = true;
1028
+ } else {
1029
+ run.deployment = {
1030
+ ...reference,
1031
+ ...(previous.state !== "unknown" ? { previousCorrelationId: previous.reference.correlationId } : {}),
1032
+ };
1033
+ }
1034
+ }
1035
+ // Persist intent before spawn: even a hard process termination must never replay this create.
1036
+ run.attemptedIndex = run.cursor;
1037
+ persist();
1038
+ if (!observationOnly) {
1039
+ const result = await runAzCliAsync(command.args, { timeout: 60_000, signal });
1040
+ run.executedCommands.push(displayCommand(command));
1041
+ rememberDiagnostic(run, result, command.kind === "migration" ? "migration-submit" : "baseline-submit", secretValues);
1042
+ persist();
1043
+ }
1044
+ }
1045
+ const observation = await observe();
1046
+ if (signal.aborted) return pause(observation.state === "Succeeded" ? "partial" : observation.state === "Running" || observation.state === "Accepted" ? "in-progress" : "unknown", "Local provisioning was interrupted. Azure may continue. The original deployment is tracked; no cancellation or successful ledger update was performed.");
1047
+ if (observation.state === "Failed" || observation.state === "Canceled") {
1048
+ const operations = await queryDeploymentOperationErrors(observation.reference, secretValues, signal);
1049
+ observation.errors.push(...operations.errors);
1050
+ run.diagnostics.push(operations.diagnostic);
1051
+ run.outcome = "failed";
1052
+ persist();
1053
+ throw new MachineCommandError("azure-deployment-failed",
1054
+ `Azure deployment ${observation.reference.name} is ${observation.state}.${observation.errors.length ? `\n${observation.errors.map((error) => `${error.code}: ${error.message}`).join("\n")}` : " Azure did not return a structured cause; inspect deployment operations."}`,
1055
+ resultForRun(run), "failed", provisionNextActions(run.runId));
1056
+ }
1057
+ if (observation.state !== "Succeeded") {
1058
+ return pause(observation.state === "unknown" ? "unknown" : "in-progress",
1059
+ observation.state === "unknown" ? `Azure deployment result is unknown (${observation.reason}); a local az error is not proof that Azure failed. No create will be automatically retried.`
1060
+ : `Azure deployment is ${observation.state}. The bounded local wait ended; status/resume tracks the same deployment without submitting Bicep again.`);
1061
+ }
1062
+ } else if (command.kind === "tag") {
1063
+ if (run.deployment) {
1064
+ const observation = await queryAzureDeployment(run.deployment, { signal, secretValues });
1065
+ run.observation = observation;
1066
+ if (observation.state !== "Succeeded") return pause("partial", "Azure Succeeded was previously observed, but its identity/terminal state could not be confirmed before migration tagging. No tag or ledger update was performed.");
1067
+ }
1068
+ const current = await readMigrationState(plan, signal, secretValues);
1069
+ const desired = desiredTagState(command, plan.migrationTagKey);
1070
+ if (!current.group.exists) throw new MachineCommandError("stale-azure-state", "The resource group disappeared before tagging.", undefined, "blocked");
1071
+ if (!statesEqual(current.state, desired)) {
1072
+ if (!statesEqual(current.state, command.expectedState)) {
1073
+ throw new MachineCommandError("stale-azure-state", `Azure migration state changed before its tag could be updated. ${retryHint}`, { expected: command.expectedState, actual: current.state }, "blocked");
1074
+ }
1075
+ run.attemptedIndex = run.cursor;
1076
+ persist();
1077
+ const result = await runAzCliAsync(command.args, { timeout: 60_000, signal });
1078
+ run.executedCommands.push(displayCommand(command));
1079
+ rememberDiagnostic(run, result, "migration-tag", secretValues);
1080
+ if (result.status !== 0 || result.failureKind || signal.aborted) {
1081
+ return pause("partial", "Azure deployment succeeded, but migration tagging did not complete locally. Resume this checkpoint to verify/finish tagging; do not redeploy the baseline.");
1082
+ }
1083
+ const after = await readMigrationState(plan, signal, secretValues);
1084
+ if (!statesEqual(after.state, desired)) return pause("partial", "The tag update returned successfully but its version/checksum could not be confirmed. Resume bookkeeping without redeploying Bicep.");
1085
+ }
1086
+ } else {
1087
+ const current = await readMigrationState(plan, signal, secretValues);
1088
+ if (run.attemptedIndex === run.cursor) {
1089
+ if (!current.group.exists) return pause("unknown", "Resource group creation was attempted, but its result is unknown. Inspect the target before resuming; nothing will be re-created automatically.");
1090
+ } else {
1091
+ if (current.group.exists) throw new MachineCommandError("stale-azure-state", `The resource group was created after the preview was computed. ${retryHint}`, undefined, "blocked");
1092
+ run.attemptedIndex = run.cursor;
1093
+ persist();
1094
+ const result = await runAzCliAsync(command.args, { timeout: 60_000, signal });
1095
+ run.executedCommands.push(displayCommand(command));
1096
+ rememberDiagnostic(run, result, "resource-group-create", secretValues);
1097
+ if (result.status !== 0 || result.failureKind || signal.aborted) return pause("unknown", "Local resource-group creation did not complete. Azure may have accepted it; inspect/resume this checkpoint, not a new bootstrap.");
1098
+ }
1099
+ }
1100
+ run.cursor++;
1101
+ delete run.attemptedIndex;
1102
+ persist();
653
1103
  }
654
- const result = runAz(command.args);
655
- const commandText = displayCommand(command);
656
- executedCommands.push(commandText);
657
- if (result.status !== 0) {
658
- throw new MachineCommandError(
659
- "provision-failed",
660
- `Command failed: ${commandText}\n${(result.stderr || result.stdout).trim().slice(0, 4000)}`,
661
- { executedCommands },
662
- "failed"
663
- );
1104
+ if (signal.aborted) return pause("partial", "Local provisioning was interrupted before ledger completion. Resume bookkeeping; no deployment will be submitted again.");
1105
+ assertLocalFiles(plan, projectRoot, "Restore the reviewed files before completing the local ledger.", true);
1106
+ const finalTag = [...plan.commandSpecs].reverse().find((command) => command.kind === "tag");
1107
+ if (finalTag) {
1108
+ const current = await readMigrationState(plan, signal, secretValues);
1109
+ if (!statesEqual(current.state, desiredTagState(finalTag, plan.migrationTagKey))) return pause("partial", "The final remote migration version/checksum changed before local ledger completion. No ledger success was recorded.");
664
1110
  }
665
- if (command.kind === "deployment" || command.kind === "migration") {
666
- try {
667
- const deployment = JSON.parse(result.stdout || "{}");
668
- deploymentOutputs = deployment.properties?.outputs;
669
- } catch {
670
- warnings.push("Could not parse deployment output JSON.");
671
- }
1111
+ if (run.deployment) {
1112
+ run.observation = await queryAzureDeployment(run.deployment, { signal, secretValues });
1113
+ if (run.observation.state !== "Succeeded") return pause("partial", "The original deployment identity/terminal state could not be confirmed before local ledger completion. No successful ledger update was performed.");
1114
+ }
1115
+ if (plan.manifestAfterApply && run.outcome !== "succeeded") replaceMigrationManifest(plan.manifestAfterApply, projectRoot);
1116
+ run.outcome = "succeeded";
1117
+ persist();
1118
+ return resultForRun(run);
1119
+ } catch (error) {
1120
+ if (!run) throw error;
1121
+ if (error instanceof MachineCommandError && error.code === "azure-deployment-failed") throw error;
1122
+ run.outcome = run.observation?.state === "Succeeded" ? "partial" : "unknown";
1123
+ run.warnings.push(`Provisioning stopped: ${redactAzureText(error instanceof Error ? error.message : "Unknown local error", secretValues)}. Azure deployment and local bookkeeping are separate; inspect/resume this checkpoint.`);
1124
+ try { persist(); } catch {
1125
+ run.warnings.push("The checkpoint could not be updated. Preserve the existing checkpoint; inspect the original Azure deployment before any further write.");
1126
+ }
1127
+ if (error instanceof MachineCommandError) {
1128
+ throw new MachineCommandError(error.code, error.message, { ...resultForRun(run), cause: error.details }, error.status ?? "blocked", provisionNextActions(run.runId));
1129
+ }
1130
+ return resultForRun(run, run.outcome, interrupted);
1131
+ } finally {
1132
+ process.removeListener("SIGINT", sigint);
1133
+ process.removeListener("SIGTERM", sigterm);
1134
+ options.signal?.removeEventListener("abort", externalAbort);
1135
+ try { unlock(); } catch {
1136
+ run?.warnings.push("The local execution lock could not be removed. Inspect its owner before retrying; Azure deployment/ledger results are unchanged.");
1137
+ console.warn("Provision lock cleanup failed; inspect the checkpoint before another apply.");
672
1138
  }
673
1139
  }
674
-
675
- if (plan.manifestAfterApply) {
676
- replaceMigrationManifest(plan.manifestAfterApply, projectRoot);
677
- }
678
-
679
- return {
680
- planId: plan.planId,
681
- resourceGroup: plan.resourceGroup,
682
- location: plan.location,
683
- swaLocation: plan.swaLocation,
684
- executedCommands,
685
- ...(deploymentOutputs ? { deploymentOutputs } : {}),
686
- warnings,
687
- };
688
1140
  }