@treeseed/sdk 0.4.12 → 0.4.13

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.
@@ -1,4 +1,4 @@
1
- import { readFileSync, writeFileSync } from "node:fs";
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { resolve } from "node:path";
3
3
  import { spawn, spawnSync } from "node:child_process";
4
4
  import {
@@ -33,32 +33,36 @@ import {
33
33
  } from "../operations/services/deploy.js";
34
34
  import {
35
35
  assertCleanWorktree,
36
+ assertCleanWorktrees,
36
37
  assertFeatureBranch,
37
38
  branchExists,
38
39
  checkoutBranch,
40
+ checkoutTaskBranchFromStaging,
39
41
  createDeprecatedTaskTag,
40
- createFeatureBranchFromStaging,
41
- currentManagedBranch,
42
42
  deleteLocalBranch,
43
43
  deleteRemoteBranch,
44
44
  ensureLocalBranchTracking,
45
45
  gitWorkflowRoot,
46
+ headCommit,
46
47
  listTaskBranches,
47
- mergeCurrentBranchIntoStaging,
48
+ mergeBranchIntoTarget,
48
49
  mergeStagingIntoMain,
49
50
  prepareReleaseBranches,
50
51
  PRODUCTION_BRANCH,
51
52
  pushBranch,
52
53
  remoteBranchExists,
53
54
  STAGING_BRANCH,
55
+ squashMergeBranchIntoStaging,
54
56
  syncBranchWithOrigin,
55
57
  waitForStagingAutomation
56
58
  } from "../operations/services/git-workflow.js";
59
+ import { waitForGitHubWorkflowCompletion } from "../operations/services/github-automation.js";
57
60
  import { loadCliDeployConfig, packageScriptPath, resolveWranglerBin } from "../operations/services/runtime-tools.js";
58
61
  import { runTenantDeployPreflight, runWorkspaceSavePreflight } from "../operations/services/save-deploy-preflight.js";
59
62
  import { collectCliPreflight } from "../operations/services/workspace-preflight.js";
60
63
  import {
61
64
  applyWorkspaceVersionChanges,
65
+ assertWorkspaceVersionConsistency,
62
66
  collectMergeConflictReport,
63
67
  currentBranch,
64
68
  formatMergeConflictReport,
@@ -69,8 +73,30 @@ import {
69
73
  planWorkspaceReleaseBump,
70
74
  repoRoot
71
75
  } from "../operations/services/workspace-save.js";
72
- import { run, workspaceRoot } from "../operations/services/workspace-tools.js";
76
+ import {
77
+ changedWorkspacePackages,
78
+ publishableWorkspacePackages,
79
+ run,
80
+ sortWorkspacePackages,
81
+ workspaceRoot
82
+ } from "../operations/services/workspace-tools.js";
73
83
  import { resolveTreeseedWorkflowState } from "../workflow-state.js";
84
+ import {
85
+ acquireWorkflowLock,
86
+ createWorkflowRunJournal,
87
+ generateWorkflowRunId,
88
+ inspectWorkflowLock,
89
+ listInterruptedWorkflowRuns,
90
+ listWorkflowRunJournals,
91
+ readWorkflowRunJournal,
92
+ refreshWorkflowLock,
93
+ releaseWorkflowLock,
94
+ updateWorkflowRunJournal
95
+ } from "./runs.js";
96
+ import {
97
+ checkedOutWorkspacePackageRepos,
98
+ resolveTreeseedWorkflowSession
99
+ } from "./session.js";
74
100
  import {
75
101
  classifyTreeseedBranchRole,
76
102
  resolveTreeseedWorkflowPaths
@@ -167,17 +193,81 @@ function resolveRepoState(repoDir) {
167
193
  dirtyWorktree: gitStatusPorcelain(repoDir).length > 0
168
194
  };
169
195
  }
170
- function buildWorkflowResult(operation, cwd, payload, nextSteps) {
196
+ function createRepoReport(name, path, branch, dirty) {
197
+ return {
198
+ name,
199
+ path,
200
+ branch,
201
+ dirty,
202
+ created: false,
203
+ resumed: false,
204
+ merged: false,
205
+ verified: false,
206
+ committed: false,
207
+ pushed: false,
208
+ deletedLocal: false,
209
+ deletedRemote: false,
210
+ tagName: null,
211
+ commitSha: branch ? headCommit(path) : null,
212
+ skippedReason: null,
213
+ publishWait: null
214
+ };
215
+ }
216
+ function createWorkspaceRootRepoReport(root) {
217
+ const gitRoot = repoRoot(root);
218
+ return createRepoReport("@treeseed/market", gitRoot, currentBranch(gitRoot) || null, hasMeaningfulChanges(gitRoot));
219
+ }
220
+ function createWorkspacePackageReports(root) {
221
+ return checkedOutWorkspacePackageRepos(root).map((pkg) => createRepoReport(pkg.name, pkg.dir, currentBranch(pkg.dir) || null, hasMeaningfulChanges(pkg.dir)));
222
+ }
223
+ function findReportByName(reports, name) {
224
+ return reports.find((report) => report.name === name) ?? null;
225
+ }
226
+ function findReportByPath(reports, path) {
227
+ return reports.find((report) => report.path === path) ?? null;
228
+ }
229
+ function assertWorkspaceClean(root) {
230
+ const repoDirs = [repoRoot(root), ...checkedOutWorkspacePackageRepos(root).map((pkg) => pkg.dir)];
231
+ assertCleanWorktrees(repoDirs);
232
+ return repoDirs;
233
+ }
234
+ function buildWorkflowResult(operation, cwd, payload, options = {}) {
235
+ const resolvedPayload = options.includeFinalState ?? true ? {
236
+ ...payload,
237
+ finalState: resolveWorkflowStateSnapshot(cwd)
238
+ } : payload;
171
239
  return {
240
+ schemaVersion: 1,
241
+ kind: "treeseed.workflow.result",
242
+ command: operation,
243
+ executionMode: options.executionMode ?? "execute",
244
+ runId: options.runId ?? null,
172
245
  ok: true,
173
246
  operation,
174
- payload: {
175
- ...payload,
176
- finalState: resolveWorkflowStateSnapshot(cwd)
177
- },
178
- nextSteps
247
+ summary: options.summary,
248
+ facts: options.facts,
249
+ payload: resolvedPayload,
250
+ result: resolvedPayload,
251
+ nextSteps: options.nextSteps,
252
+ recovery: options.recovery ?? null,
253
+ errors: options.errors ?? []
179
254
  };
180
255
  }
256
+ function normalizeExecutionMode(input) {
257
+ return input?.plan === true || input?.dryRun === true ? "plan" : "execute";
258
+ }
259
+ function submodulePointerForRef(repoDir, ref, relativeDir) {
260
+ try {
261
+ const output = run("git", ["ls-tree", ref, relativeDir], { cwd: repoDir, capture: true }).trim();
262
+ if (!output) {
263
+ return null;
264
+ }
265
+ const match = output.match(/^[0-9]{6}\s+commit\s+([0-9a-f]{40})\t/u);
266
+ return match?.[1] ?? null;
267
+ } catch {
268
+ return null;
269
+ }
270
+ }
181
271
  function ensureLocalReadinessOrThrow(operation, tenantRoot) {
182
272
  const state = resolveWorkflowStateSnapshot(tenantRoot);
183
273
  if (!state.readiness.local.ready) {
@@ -207,12 +297,10 @@ function createNextSteps(steps) {
207
297
  }
208
298
  function createStatusResult(cwd) {
209
299
  const state = resolveTreeseedWorkflowState(cwd);
210
- return {
211
- ok: true,
212
- operation: "status",
213
- payload: state,
214
- nextSteps: createNextSteps(state.recommendations)
215
- };
300
+ return buildWorkflowResult("status", cwd, state, {
301
+ nextSteps: createNextSteps(state.recommendations),
302
+ includeFinalState: false
303
+ });
216
304
  }
217
305
  function createTasksResult(cwd) {
218
306
  const tenantRoot = cwd;
@@ -223,6 +311,21 @@ function createTasksResult(cwd) {
223
311
  const previewState = loadDeployState(tenantRoot, deployConfig, {
224
312
  target: createBranchPreviewDeployTarget(branch.name)
225
313
  });
314
+ const packages = checkedOutWorkspacePackageRepos(tenantRoot).map((pkg) => {
315
+ const packageBranches = listTaskBranches(pkg.dir);
316
+ const match = packageBranches.find((candidate) => candidate.name === branch.name) ?? null;
317
+ const pointer = submodulePointerForRef(repoDir, branch.name, pkg.relativeDir);
318
+ return {
319
+ name: pkg.name,
320
+ path: pkg.relativeDir,
321
+ local: match?.local === true,
322
+ remote: match?.remote === true,
323
+ current: match?.current === true,
324
+ head: match?.head ?? null,
325
+ pointer,
326
+ aligned: pointer != null && match?.head != null ? pointer === match.head : match != null
327
+ };
328
+ });
226
329
  return {
227
330
  ...branch,
228
331
  ageDays: ageDays(branch.lastCommitDate),
@@ -231,10 +334,11 @@ function createTasksResult(cwd) {
231
334
  enabled: previewState.previewEnabled === true || previewState.readiness?.initialized === true,
232
335
  url: previewState.lastDeployedUrl ?? null,
233
336
  lastDeploymentTimestamp: previewState.lastDeploymentTimestamp ?? null
234
- }
337
+ },
338
+ packages
235
339
  };
236
340
  });
237
- return { ok: true, operation: "tasks", payload: { tasks } };
341
+ return buildWorkflowResult("tasks", cwd, { tasks }, { includeFinalState: false });
238
342
  }
239
343
  function maybePrint(write, line, stream = "stdout") {
240
344
  if (!line) return;
@@ -259,6 +363,210 @@ function toError(operation, error) {
259
363
  }
260
364
  throw new TreeseedWorkflowError(operation, "unsupported_state", String(error));
261
365
  }
366
+ function workflowSessionSnapshot(session) {
367
+ return {
368
+ root: session.root,
369
+ mode: session.mode,
370
+ branchName: session.branchName,
371
+ repos: [session.rootRepo, ...session.packageRepos].map((repo) => ({
372
+ name: repo.name,
373
+ path: repo.path,
374
+ branchName: repo.branchName
375
+ }))
376
+ };
377
+ }
378
+ function nextPendingJournalStep(journal) {
379
+ return journal.steps.find((step) => step.status === "pending") ?? null;
380
+ }
381
+ async function executeJournalStep(root, runId, stepId, action) {
382
+ const current = readWorkflowRunJournal(root, runId);
383
+ const step = current?.steps.find((entry) => entry.id === stepId) ?? null;
384
+ if (!current || !step) {
385
+ throw new Error(`Unknown workflow step "${stepId}" for run ${runId}.`);
386
+ }
387
+ if (step.status === "completed") {
388
+ return step.data ?? null;
389
+ }
390
+ const data = await Promise.resolve(action());
391
+ updateWorkflowRunJournal(root, runId, (journal) => ({
392
+ ...journal,
393
+ steps: journal.steps.map((entry) => entry.id === stepId ? {
394
+ ...entry,
395
+ status: "completed",
396
+ completedAt: (/* @__PURE__ */ new Date()).toISOString(),
397
+ data: data ?? null
398
+ } : entry)
399
+ }));
400
+ refreshWorkflowLock(root, runId);
401
+ return data;
402
+ }
403
+ function skipJournalStep(root, runId, stepId, data = null) {
404
+ updateWorkflowRunJournal(root, runId, (journal) => ({
405
+ ...journal,
406
+ steps: journal.steps.map((entry) => entry.id === stepId ? {
407
+ ...entry,
408
+ status: "skipped",
409
+ completedAt: (/* @__PURE__ */ new Date()).toISOString(),
410
+ data
411
+ } : entry)
412
+ }));
413
+ refreshWorkflowLock(root, runId);
414
+ }
415
+ function acquireWorkflowRun(operation, session, input, steps, context) {
416
+ const resumeRunId = context.workflow?.resumeRunId;
417
+ if (resumeRunId) {
418
+ const existing = readWorkflowRunJournal(session.root, resumeRunId);
419
+ if (!existing || existing.command !== operation) {
420
+ workflowError(operation, "resume_unavailable", `Treeseed ${operation} cannot resume run ${resumeRunId}.`, {
421
+ details: { runId: resumeRunId, command: operation }
422
+ });
423
+ }
424
+ const lockResult2 = acquireWorkflowLock(session.root, operation, resumeRunId);
425
+ if (!lockResult2.acquired) {
426
+ workflowError(operation, "workflow_locked", `Treeseed ${operation} is blocked by active run ${lockResult2.lock.runId}.`, {
427
+ details: {
428
+ lock: lockResult2.lock,
429
+ recovery: {
430
+ resumable: true,
431
+ runId: lockResult2.lock.runId,
432
+ command: lockResult2.lock.command,
433
+ recoverCommand: "treeseed recover",
434
+ resumeCommand: `treeseed resume ${lockResult2.lock.runId}`
435
+ }
436
+ }
437
+ });
438
+ }
439
+ return {
440
+ runId: resumeRunId,
441
+ session,
442
+ journal: existing,
443
+ resumed: true
444
+ };
445
+ }
446
+ const runId = generateWorkflowRunId(operation);
447
+ const lockResult = acquireWorkflowLock(session.root, operation, runId);
448
+ if (!lockResult.acquired) {
449
+ workflowError(operation, "workflow_locked", `Treeseed ${operation} is blocked by active run ${lockResult.lock.runId}.`, {
450
+ details: {
451
+ lock: lockResult.lock,
452
+ recovery: {
453
+ resumable: true,
454
+ runId: lockResult.lock.runId,
455
+ command: lockResult.lock.command,
456
+ recoverCommand: "treeseed recover",
457
+ resumeCommand: `treeseed resume ${lockResult.lock.runId}`
458
+ }
459
+ }
460
+ });
461
+ }
462
+ const journal = createWorkflowRunJournal(session.root, {
463
+ runId,
464
+ command: operation,
465
+ input,
466
+ session: workflowSessionSnapshot(session),
467
+ steps
468
+ });
469
+ return {
470
+ runId,
471
+ session,
472
+ journal,
473
+ resumed: false
474
+ };
475
+ }
476
+ function completeWorkflowRun(root, runId, result) {
477
+ updateWorkflowRunJournal(root, runId, (journal) => ({
478
+ ...journal,
479
+ status: "completed",
480
+ result,
481
+ failure: null
482
+ }));
483
+ releaseWorkflowLock(root, runId);
484
+ }
485
+ function failWorkflowRun(root, runId, error, recovery) {
486
+ const message = error instanceof Error ? error.message : String(error);
487
+ const code = error instanceof TreeseedWorkflowError ? error.code : "unsupported_state";
488
+ const details = error instanceof TreeseedWorkflowError ? {
489
+ ...error.details ?? {},
490
+ recovery: recovery ?? error.details?.recovery ?? null
491
+ } : recovery ? { recovery } : null;
492
+ updateWorkflowRunJournal(root, runId, (journal) => ({
493
+ ...journal,
494
+ status: "failed",
495
+ failure: {
496
+ code,
497
+ message,
498
+ details,
499
+ at: (/* @__PURE__ */ new Date()).toISOString()
500
+ }
501
+ }));
502
+ releaseWorkflowLock(root, runId);
503
+ }
504
+ function validatePackageReleaseWorkflows(root, packageNames) {
505
+ if (process.env.TREESEED_GITHUB_AUTOMATION_MODE === "stub") {
506
+ return;
507
+ }
508
+ const missing = checkedOutWorkspacePackageRepos(root).filter((pkg) => packageNames.includes(pkg.name)).filter((pkg) => !existsSync(resolve(pkg.dir, ".github", "workflows", "publish.yml"))).map((pkg) => pkg.name);
509
+ if (missing.length > 0) {
510
+ workflowError("release", "workflow_contract_missing", `Treeseed release requires .github/workflows/publish.yml in: ${missing.join(", ")}.`, {
511
+ details: {
512
+ missing
513
+ }
514
+ });
515
+ }
516
+ }
517
+ function validateStagingWorkflowContracts(root) {
518
+ if (process.env.TREESEED_GITHUB_AUTOMATION_MODE === "stub" || process.env.TREESEED_STAGE_WAIT_MODE === "skip") {
519
+ return;
520
+ }
521
+ const missing = [];
522
+ for (const fileName of ["verify.yml", "deploy.yml"]) {
523
+ if (!existsSync(resolve(root, ".github", "workflows", fileName))) {
524
+ missing.push(fileName);
525
+ }
526
+ }
527
+ if (missing.length > 0) {
528
+ workflowError("stage", "workflow_contract_missing", `Treeseed stage requires standardized root workflows: ${missing.join(", ")}.`, {
529
+ details: { missing }
530
+ });
531
+ }
532
+ }
533
+ function assertSessionBranchSafety(operation, session, {
534
+ requireCleanPackages = false,
535
+ requireCurrentBranch = false,
536
+ allowPackageReposWithoutOrigin = false
537
+ } = {}) {
538
+ const detached = session.packageRepos.filter((repo) => repo.detached).map((repo) => repo.name);
539
+ if (detached.length > 0) {
540
+ workflowError(operation, "validation_failed", `Detached package heads detected: ${detached.join(", ")}.`, {
541
+ details: { detached }
542
+ });
543
+ }
544
+ if (requireCleanPackages) {
545
+ const dirty = session.packageRepos.filter((repo) => repo.dirty).map((repo) => repo.name);
546
+ if (dirty.length > 0) {
547
+ workflowError(operation, "validation_failed", `Dirty package repos block ${operation}: ${dirty.join(", ")}.`, {
548
+ details: { dirty }
549
+ });
550
+ }
551
+ }
552
+ if (requireCurrentBranch && session.branchName) {
553
+ const missing = session.packageRepos.filter((repo) => repo.branchName !== session.branchName).map((repo) => ({ name: repo.name, branchName: repo.branchName }));
554
+ if (missing.length > 0) {
555
+ workflowError(operation, "validation_failed", `Package branch alignment is required for ${operation}.`, {
556
+ details: { expectedBranch: session.branchName, repos: missing }
557
+ });
558
+ }
559
+ }
560
+ const missingOriginRepos = [
561
+ session.rootRepo,
562
+ ...allowPackageReposWithoutOrigin ? [] : session.packageRepos
563
+ ].filter((repo) => !repo.hasOriginRemote).map((repo) => repo.name);
564
+ if (missingOriginRepos.length > 0 && operation !== "destroy") {
565
+ workflowError(operation, "validation_failed", `Missing origin remote on: ${missingOriginRepos.join(", ")}.`, {
566
+ details: { missingOrigin: missingOriginRepos }
567
+ });
568
+ }
569
+ }
262
570
  function previewStateFor(tenantRoot, branchName) {
263
571
  const deployConfig = loadCliDeployConfig(tenantRoot);
264
572
  return loadDeployState(tenantRoot, deployConfig, {
@@ -365,9 +673,11 @@ function syncCurrentBranchToOrigin(operation, repoDir, branch) {
365
673
  }
366
674
  async function maybeAutoSaveCurrentTaskBranch(helpers, operation, input) {
367
675
  const tenantRoot = resolveProjectRootOrThrow(operation, helpers.cwd());
368
- const repoDir = gitWorkflowRoot(tenantRoot);
676
+ const root = workspaceRoot(tenantRoot);
677
+ const repoDir = gitWorkflowRoot(root);
369
678
  const before = resolveRepoState(repoDir);
370
- if (!before.dirtyWorktree) {
679
+ const packageDirty = checkedOutWorkspacePackageRepos(root).some((pkg) => hasMeaningfulChanges(pkg.dir));
680
+ if (!before.dirtyWorktree && !packageDirty) {
371
681
  return { performed: false, save: null };
372
682
  }
373
683
  if (input.autoSave === false) {
@@ -384,6 +694,142 @@ async function maybeAutoSaveCurrentTaskBranch(helpers, operation, input) {
384
694
  save: saveResult.payload
385
695
  };
386
696
  }
697
+ function checkoutOrCreateSaveBranch(repoDir, branch) {
698
+ const current = currentBranch(repoDir);
699
+ if (current === branch) {
700
+ return current;
701
+ }
702
+ if (branchExists(repoDir, branch)) {
703
+ checkoutBranch(repoDir, branch);
704
+ return branch;
705
+ }
706
+ if (remoteBranchExists(repoDir, branch)) {
707
+ run("git", ["checkout", "-b", branch, `origin/${branch}`], { cwd: repoDir });
708
+ return branch;
709
+ }
710
+ run("git", ["checkout", "-b", branch], { cwd: repoDir });
711
+ return branch;
712
+ }
713
+ function runPackageVerifyLocal(pkgDir) {
714
+ run("npm", ["run", "verify:local"], { cwd: pkgDir });
715
+ }
716
+ function branchNeedsSync(repoDir, branch) {
717
+ if (!remoteBranchExists(repoDir, branch)) {
718
+ return true;
719
+ }
720
+ const localHead = run("git", ["rev-parse", "HEAD"], { cwd: repoDir, capture: true }).trim();
721
+ const remoteHead = run("git", ["rev-parse", `origin/${branch}`], { cwd: repoDir, capture: true }).trim();
722
+ return localHead !== remoteHead;
723
+ }
724
+ function savePackageRepo(report, message, branch, shouldVerify) {
725
+ checkoutOrCreateSaveBranch(report.path, branch);
726
+ report.branch = currentBranch(report.path);
727
+ report.dirty = hasMeaningfulChanges(report.path);
728
+ const needsSync = branchNeedsSync(report.path, branch);
729
+ if (!report.dirty && !needsSync) {
730
+ report.skippedReason = "clean";
731
+ report.commitSha = run("git", ["rev-parse", "HEAD"], { cwd: report.path, capture: true }).trim();
732
+ return report;
733
+ }
734
+ if (shouldVerify && report.dirty) {
735
+ runPackageVerifyLocal(report.path);
736
+ report.verified = true;
737
+ }
738
+ if (report.dirty) {
739
+ run("git", ["add", "-A"], { cwd: report.path });
740
+ run("git", ["commit", "-m", message], { cwd: report.path });
741
+ report.committed = true;
742
+ }
743
+ report.commitSha = run("git", ["rev-parse", "HEAD"], { cwd: report.path, capture: true }).trim();
744
+ const branchSync = syncCurrentBranchToOrigin("save", report.path, branch);
745
+ report.pushed = branchSync.pushed === true;
746
+ if (!report.dirty && needsSync) {
747
+ report.skippedReason = "sync-only";
748
+ }
749
+ return report;
750
+ }
751
+ function createSaveFailure(message, repos, rootRepo, failingRepo, error) {
752
+ const rendered = error instanceof Error ? error.message : String(error);
753
+ const code = error instanceof TreeseedWorkflowError ? error.code : "unsupported_state";
754
+ const exitCode = error instanceof TreeseedWorkflowError ? error.exitCode : void 0;
755
+ throw new TreeseedWorkflowError("save", code, `${message}
756
+ ${rendered}`, {
757
+ details: {
758
+ partialFailure: {
759
+ message,
760
+ failingRepo: failingRepo?.name ?? null,
761
+ repos,
762
+ rootRepo,
763
+ error: rendered
764
+ }
765
+ },
766
+ exitCode
767
+ });
768
+ }
769
+ function ensureLocalTaskBranch(repoDir, branchName) {
770
+ if (!branchExists(repoDir, branchName) && !remoteBranchExists(repoDir, branchName)) {
771
+ return false;
772
+ }
773
+ if (!branchExists(repoDir, branchName) && remoteBranchExists(repoDir, branchName)) {
774
+ ensureLocalBranchTracking(repoDir, branchName);
775
+ }
776
+ if (currentBranch(repoDir) !== branchName) {
777
+ checkoutBranch(repoDir, branchName);
778
+ }
779
+ return true;
780
+ }
781
+ function cleanupTaskBranchReport(report, branchName, message, { deleteBranch = true, targetBranch = STAGING_BRANCH } = {}) {
782
+ if (!ensureLocalTaskBranch(report.path, branchName)) {
783
+ report.skippedReason = "branch-missing";
784
+ return report;
785
+ }
786
+ const deprecatedTag = createDeprecatedTaskTag(report.path, branchName, message);
787
+ report.tagName = deprecatedTag.tagName;
788
+ report.commitSha = deprecatedTag.head;
789
+ report.deletedRemote = deleteBranch ? deleteRemoteBranch(report.path, branchName) : false;
790
+ syncBranchWithOrigin(report.path, targetBranch);
791
+ if (deleteBranch) {
792
+ deleteLocalBranch(report.path, branchName);
793
+ report.deletedLocal = true;
794
+ }
795
+ report.branch = currentBranch(report.path) || targetBranch;
796
+ report.dirty = hasMeaningfulChanges(report.path);
797
+ return report;
798
+ }
799
+ function syncAllCheckedOutPackageRepos(root, branchName) {
800
+ for (const pkg of checkedOutWorkspacePackageRepos(root)) {
801
+ syncBranchWithOrigin(pkg.dir, branchName);
802
+ }
803
+ }
804
+ function collectReleasePackageSelection(root) {
805
+ const publishable = sortWorkspacePackages(
806
+ publishableWorkspacePackages(root).filter((pkg) => pkg.name?.startsWith("@treeseed/"))
807
+ );
808
+ const changed = changedWorkspacePackages({
809
+ root,
810
+ baseRef: PRODUCTION_BRANCH,
811
+ includeDependents: false,
812
+ packages: publishable
813
+ });
814
+ const selected = changedWorkspacePackages({
815
+ root,
816
+ baseRef: PRODUCTION_BRANCH,
817
+ includeDependents: true,
818
+ packages: publishable
819
+ });
820
+ const changedNames = changed.map((pkg) => pkg.name);
821
+ const selectedNames = selected.map((pkg) => pkg.name);
822
+ const dependents = selected.filter((pkg) => !changedNames.includes(pkg.name)).map((pkg) => pkg.name);
823
+ return {
824
+ changed: changedNames,
825
+ dependents,
826
+ selected: selectedNames,
827
+ publishable
828
+ };
829
+ }
830
+ function hasStagedChanges(repoDir) {
831
+ return run("git", ["diff", "--cached", "--name-only"], { cwd: repoDir, capture: true }).trim().length > 0;
832
+ }
387
833
  async function workflowStatus(helpers) {
388
834
  return withContextEnv(helpers.context.env, () => createStatusResult(helpers.cwd()));
389
835
  }
@@ -425,10 +871,10 @@ async function workflowConfig(helpers, input = {}) {
425
871
  }),
426
872
  provider: checkTreeseedProviderConnections({ tenantRoot, scope, env: helpers.context.env })
427
873
  }));
428
- return {
429
- ok: true,
430
- operation: "config",
431
- payload: {
874
+ return buildWorkflowResult(
875
+ "config",
876
+ tenantRoot,
877
+ {
432
878
  mode: "print-env-only",
433
879
  scopes,
434
880
  sync,
@@ -438,17 +884,19 @@ async function workflowConfig(helpers, input = {}) {
438
884
  preflight,
439
885
  toolHealth
440
886
  },
441
- nextSteps: createNextSteps([
442
- { operation: "config", reason: "Initialize the selected environment after reviewing the generated values.", input: { environment: scopes } }
443
- ])
444
- };
887
+ {
888
+ nextSteps: createNextSteps([
889
+ { operation: "config", reason: "Initialize the selected environment after reviewing the generated values.", input: { environment: scopes } }
890
+ ])
891
+ }
892
+ );
445
893
  }
446
894
  if (rotateMachineKeyFlag) {
447
895
  const result = rotateTreeseedMachineKey(tenantRoot);
448
- return {
449
- ok: true,
450
- operation: "config",
451
- payload: {
896
+ return buildWorkflowResult(
897
+ "config",
898
+ tenantRoot,
899
+ {
452
900
  mode: "rotate-machine-key",
453
901
  scopes,
454
902
  sync,
@@ -457,10 +905,12 @@ async function workflowConfig(helpers, input = {}) {
457
905
  preflight,
458
906
  toolHealth
459
907
  },
460
- nextSteps: createNextSteps([
461
- { operation: "config", reason: "Inspect the regenerated local environment after the machine key rotation.", input: { environment: ["local"], printEnvOnly: true } }
462
- ])
463
- };
908
+ {
909
+ nextSteps: createNextSteps([
910
+ { operation: "config", reason: "Inspect the regenerated local environment after the machine key rotation.", input: { environment: ["local"], printEnvOnly: true } }
911
+ ])
912
+ }
913
+ );
464
914
  }
465
915
  const explicitUpdates = Array.isArray(input.updates) ? input.updates.map((update) => ({
466
916
  scope: update.scope,
@@ -539,46 +989,129 @@ async function workflowExport(helpers, input = {}) {
539
989
  }
540
990
  async function workflowSwitch(helpers, input) {
541
991
  try {
542
- return withContextEnv(helpers.context.env, () => {
992
+ return await withContextEnv(helpers.context.env, async () => {
543
993
  const tenantRoot = resolveProjectRootOrThrow("switch", helpers.cwd());
994
+ const root = workspaceRoot(tenantRoot);
995
+ const session = resolveTreeseedWorkflowSession(root);
544
996
  const branchName = String(input.branch ?? input.branchName ?? "").trim();
545
997
  if (!branchName) {
546
998
  workflowError("switch", "validation_failed", "Treeseed switch requires a branch name.");
547
999
  }
548
1000
  const preview = input.preview === true;
549
- const repoDir = gitWorkflowRoot(tenantRoot);
550
- const currentBranchName = currentManagedBranch(tenantRoot);
551
- let created = false;
552
- let resumed = false;
1001
+ const executionMode = normalizeExecutionMode(input);
1002
+ const mode = session.mode;
1003
+ const repoDir = session.gitRoot;
1004
+ const rootRepo = createWorkspaceRootRepoReport(root);
1005
+ const packageReports = createWorkspacePackageReports(root);
553
1006
  let previewResult = null;
554
- if (currentBranchName === branchName) {
555
- resumed = true;
556
- } else if (!branchExists(repoDir, branchName) && !remoteBranchExists(repoDir, branchName)) {
557
- if (input.createIfMissing === false) {
558
- workflowError("switch", "validation_failed", `Branch "${branchName}" does not exist locally or on origin.`);
1007
+ const dirtyRepos = [rootRepo, ...packageReports].filter((repo) => repo.dirty).map((repo) => repo.name);
1008
+ if (executionMode === "plan") {
1009
+ for (const report of [rootRepo, ...packageReports]) {
1010
+ const local = branchExists(report.path, branchName);
1011
+ const remote = remoteBranchExists(report.path, branchName);
1012
+ report.created = !local && !remote;
1013
+ report.resumed = local || remote;
559
1014
  }
560
- const result = createFeatureBranchFromStaging(tenantRoot, branchName);
561
- pushBranch(result.repoDir, branchName, { setUpstream: true });
562
- created = true;
563
- } else {
564
- assertCleanWorktree(tenantRoot);
565
- ensureLocalBranchTracking(repoDir, branchName);
566
- checkoutBranch(repoDir, branchName);
567
- syncBranchWithOrigin(repoDir, branchName);
568
- resumed = true;
1015
+ return buildWorkflowResult(
1016
+ "switch",
1017
+ root,
1018
+ {
1019
+ mode,
1020
+ branchName,
1021
+ rootRepo,
1022
+ repos: packageReports,
1023
+ previewRequested: preview,
1024
+ blockers: dirtyRepos.length > 0 ? [`Clean worktrees required: ${dirtyRepos.join(", ")}`] : [],
1025
+ plannedSteps: [
1026
+ { id: "switch-root", description: `Switch market repo to ${branchName}` },
1027
+ ...packageReports.map((report) => ({ id: `switch-${report.name}`, description: `Mirror ${branchName} into ${report.name}` })),
1028
+ ...preview ? [{ id: "preview", description: `Provision or refresh preview for ${branchName}` }] : []
1029
+ ]
1030
+ },
1031
+ {
1032
+ executionMode,
1033
+ nextSteps: createNextSteps([
1034
+ { operation: "switch", reason: "Run without --plan to create or resume the task branch.", input: { branch: branchName, preview } }
1035
+ ])
1036
+ }
1037
+ );
569
1038
  }
570
- const stateAfterSwitch = resolveTreeseedWorkflowState(tenantRoot);
571
- if (preview && !stateAfterSwitch.preview.enabled) {
572
- previewResult = deployBranchPreview(tenantRoot, branchName, helpers.context, { initialize: true });
1039
+ if (mode === "recursive-workspace") {
1040
+ assertWorkspaceClean(root);
1041
+ assertSessionBranchSafety("switch", session);
1042
+ } else {
1043
+ assertCleanWorktree(root);
573
1044
  }
574
- const state = resolveTreeseedWorkflowState(tenantRoot);
575
- return buildWorkflowResult(
1045
+ const workflowRun = acquireWorkflowRun(
576
1046
  "switch",
577
- tenantRoot,
578
- {
1047
+ session,
1048
+ { branch: branchName, preview },
1049
+ [
1050
+ { id: "switch-root", description: `Switch market repo to ${branchName}`, repoName: rootRepo.name, repoPath: rootRepo.path, branch: branchName, resumable: true },
1051
+ ...packageReports.map((report) => ({
1052
+ id: `switch-${report.name}`,
1053
+ description: `Mirror ${branchName} into ${report.name}`,
1054
+ repoName: report.name,
1055
+ repoPath: report.path,
1056
+ branch: branchName,
1057
+ resumable: true
1058
+ })),
1059
+ ...preview ? [{ id: "preview", description: `Provision or refresh preview ${branchName}`, repoName: rootRepo.name, repoPath: rootRepo.path, branch: branchName, resumable: true }] : []
1060
+ ],
1061
+ helpers.context
1062
+ );
1063
+ try {
1064
+ const rootSwitch = await executeJournalStep(
1065
+ root,
1066
+ workflowRun.runId,
1067
+ "switch-root",
1068
+ () => checkoutTaskBranchFromStaging(repoDir, branchName, {
1069
+ createIfMissing: input.createIfMissing !== false,
1070
+ pushIfCreated: true
1071
+ })
1072
+ );
1073
+ rootRepo.branch = currentBranch(repoDir) || branchName;
1074
+ rootRepo.created = rootSwitch.created;
1075
+ rootRepo.resumed = rootSwitch.resumed;
1076
+ rootRepo.commitSha = headCommit(repoDir);
1077
+ rootRepo.pushed = rootSwitch.created;
1078
+ for (const pkg of checkedOutWorkspacePackageRepos(root)) {
1079
+ const report = findReportByName(packageReports, pkg.name);
1080
+ if (!report) {
1081
+ continue;
1082
+ }
1083
+ const packageSwitch = await executeJournalStep(
1084
+ root,
1085
+ workflowRun.runId,
1086
+ `switch-${report.name}`,
1087
+ () => checkoutTaskBranchFromStaging(pkg.dir, branchName, {
1088
+ createIfMissing: input.createIfMissing !== false,
1089
+ pushIfCreated: false
1090
+ })
1091
+ );
1092
+ report.branch = currentBranch(pkg.dir) || branchName;
1093
+ report.created = packageSwitch.created;
1094
+ report.resumed = packageSwitch.resumed;
1095
+ report.commitSha = headCommit(pkg.dir);
1096
+ report.dirty = hasMeaningfulChanges(pkg.dir);
1097
+ }
1098
+ const stateAfterSwitch = resolveTreeseedWorkflowState(root);
1099
+ if (preview) {
1100
+ previewResult = await executeJournalStep(
1101
+ root,
1102
+ workflowRun.runId,
1103
+ "preview",
1104
+ () => deployBranchPreview(root, branchName, helpers.context, { initialize: !stateAfterSwitch.preview.enabled })
1105
+ ) ?? null;
1106
+ }
1107
+ const state = resolveTreeseedWorkflowState(root);
1108
+ const payload = {
1109
+ mode,
579
1110
  branchName,
580
- created,
581
- resumed,
1111
+ created: rootRepo.created,
1112
+ resumed: rootRepo.resumed,
1113
+ repos: packageReports,
1114
+ rootRepo,
582
1115
  previewRequested: preview,
583
1116
  preview: {
584
1117
  enabled: state.preview.enabled,
@@ -590,12 +1123,31 @@ async function workflowSwitch(helpers, input) {
590
1123
  cleanWorktreeRequired: true,
591
1124
  baseBranch: STAGING_BRANCH
592
1125
  }
593
- },
594
- createNextSteps([
595
- state.preview.enabled ? { operation: "save", reason: "Persist and verify the current task branch, then refresh its preview deployment.", input: { message: "describe your change", preview: true } } : { operation: "dev", reason: "Start the local development environment for this task branch." },
596
- { operation: "stage", reason: "Merge the task into staging once the task branch is verified.", input: { message: "describe the resolution" } }
597
- ])
598
- );
1126
+ };
1127
+ completeWorkflowRun(root, workflowRun.runId, payload);
1128
+ return buildWorkflowResult(
1129
+ "switch",
1130
+ root,
1131
+ payload,
1132
+ {
1133
+ runId: workflowRun.runId,
1134
+ nextSteps: createNextSteps([
1135
+ state.preview.enabled ? { operation: "save", reason: "Persist and verify the current task branch, then refresh its preview deployment.", input: { message: "describe your change", preview: true } } : { operation: "dev", reason: "Start the local development environment for this task branch." },
1136
+ { operation: "stage", reason: "Merge the task into staging once the task branch is verified.", input: { message: "describe the resolution" } }
1137
+ ])
1138
+ }
1139
+ );
1140
+ } catch (error) {
1141
+ failWorkflowRun(root, workflowRun.runId, error, {
1142
+ resumable: true,
1143
+ runId: workflowRun.runId,
1144
+ command: "switch",
1145
+ message: `Resume the interrupted switch for ${branchName}.`,
1146
+ recoverCommand: "treeseed recover",
1147
+ resumeCommand: `treeseed resume ${workflowRun.runId}`
1148
+ });
1149
+ throw error;
1150
+ }
599
1151
  });
600
1152
  } catch (error) {
601
1153
  toError("switch", error);
@@ -669,15 +1221,19 @@ async function workflowDev(helpers, input = {}) {
669
1221
  }
670
1222
  async function workflowSave(helpers, input) {
671
1223
  try {
672
- return withContextEnv(helpers.context.env, () => {
1224
+ return await withContextEnv(helpers.context.env, async () => {
673
1225
  const tenantRoot = resolveProjectRootOrThrow("save", helpers.cwd());
674
1226
  const message = ensureMessage("save", input.message, "a commit message");
675
1227
  const optionsHotfix = input.hotfix === true;
676
1228
  const root = workspaceRoot(tenantRoot);
677
- const gitRoot = repoRoot(root);
678
- const branch = currentBranch(gitRoot);
1229
+ const session = resolveTreeseedWorkflowSession(root);
1230
+ const gitRoot = session.gitRoot;
1231
+ const branch = session.branchName;
679
1232
  const scope = branch === STAGING_BRANCH ? "staging" : branch === PRODUCTION_BRANCH ? "prod" : "local";
680
1233
  const beforeState = resolveTreeseedWorkflowState(root);
1234
+ const recursiveWorkspace = session.mode === "recursive-workspace";
1235
+ const mode = session.mode;
1236
+ const executionMode = normalizeExecutionMode(input);
681
1237
  applyTreeseedEnvironmentToProcess({ tenantRoot: root, scope, override: true });
682
1238
  if (!branch) {
683
1239
  workflowError("save", "validation_failed", "Treeseed save requires an active git branch.");
@@ -685,42 +1241,218 @@ async function workflowSave(helpers, input) {
685
1241
  if (branch === PRODUCTION_BRANCH && !optionsHotfix) {
686
1242
  workflowError("save", "unsupported_state", "Treeseed save is blocked on main unless --hotfix is explicitly set.");
687
1243
  }
1244
+ const packageReports = createWorkspacePackageReports(root);
1245
+ const rootRepo = createRepoReport("@treeseed/market", gitRoot, branch, hasMeaningfulChanges(gitRoot));
1246
+ const blockers = [];
1247
+ if (executionMode === "plan") {
1248
+ if (!session.rootRepo.hasOriginRemote) {
1249
+ blockers.push("Market repo is missing origin remote.");
1250
+ }
1251
+ if (branch === PRODUCTION_BRANCH && !optionsHotfix) {
1252
+ blockers.push("Main saves require --hotfix.");
1253
+ }
1254
+ if (recursiveWorkspace) {
1255
+ try {
1256
+ assertWorkspaceVersionConsistency(root);
1257
+ } catch (error) {
1258
+ blockers.push(error instanceof Error ? error.message : String(error));
1259
+ }
1260
+ }
1261
+ return buildWorkflowResult(
1262
+ "save",
1263
+ root,
1264
+ {
1265
+ mode,
1266
+ branch,
1267
+ scope,
1268
+ hotfix: optionsHotfix,
1269
+ message,
1270
+ repos: packageReports,
1271
+ rootRepo,
1272
+ blockers,
1273
+ plannedSteps: [
1274
+ ...packageReports.map((report) => ({ id: `save-${report.name}`, description: `Verify, commit, and push ${report.name}` })),
1275
+ ...input.verify !== false ? [{ id: "verify-root", description: "Run market workspace verification" }] : [],
1276
+ { id: "commit-root", description: "Commit market repo changes if present" },
1277
+ { id: "sync-root", description: `Push ${branch} to origin` },
1278
+ ...beforeState.branchRole === "feature" && (input.preview === true || beforeState.preview.enabled) ? [{ id: "preview", description: `Refresh preview deployment for ${branch}` }] : []
1279
+ ]
1280
+ },
1281
+ {
1282
+ executionMode,
1283
+ nextSteps: createNextSteps([
1284
+ { operation: "save", reason: "Run without --plan to persist the workspace checkpoint.", input: { message, hotfix: optionsHotfix, preview: input.preview === true } }
1285
+ ])
1286
+ }
1287
+ );
1288
+ }
1289
+ assertSessionBranchSafety("save", session, {
1290
+ allowPackageReposWithoutOrigin: true
1291
+ });
688
1292
  try {
689
1293
  originRemoteUrl(gitRoot);
690
1294
  } catch {
691
1295
  workflowError("save", "validation_failed", "Treeseed save requires an origin remote.");
692
1296
  }
693
- if (input.verify !== false) {
694
- runWorkspaceSavePreflight({ cwd: root });
695
- }
696
- const hadMeaningfulChanges = hasMeaningfulChanges(gitRoot);
697
- let head = run("git", ["rev-parse", "HEAD"], { cwd: gitRoot, capture: true }).trim();
698
- let commitCreated = false;
699
- if (hadMeaningfulChanges) {
700
- run("git", ["add", "-A"], { cwd: gitRoot });
701
- run("git", ["commit", "-m", message], { cwd: gitRoot });
702
- head = run("git", ["rev-parse", "HEAD"], { cwd: gitRoot, capture: true }).trim();
703
- commitCreated = true;
704
- }
705
- const branchSync = syncCurrentBranchToOrigin("save", gitRoot, branch);
706
- let previewAction = { status: "skipped" };
707
- if (beforeState.branchRole === "feature" && branch) {
708
- if (input.preview === true) {
709
- previewAction = {
710
- status: beforeState.preview.enabled ? "refreshed" : "created",
711
- details: deployBranchPreview(root, branch, helpers.context, { initialize: !beforeState.preview.enabled })
712
- };
713
- } else if (input.refreshPreview !== false && beforeState.preview.enabled) {
714
- previewAction = {
715
- status: "refreshed",
716
- details: deployBranchPreview(root, branch, helpers.context, { initialize: false })
717
- };
718
- }
719
- }
720
- return buildWorkflowResult(
1297
+ const workflowRun = acquireWorkflowRun(
721
1298
  "save",
722
- root,
1299
+ session,
723
1300
  {
1301
+ message,
1302
+ hotfix: optionsHotfix,
1303
+ preview: input.preview === true,
1304
+ refreshPreview: input.refreshPreview !== false,
1305
+ verify: input.verify !== false
1306
+ },
1307
+ [
1308
+ ...packageReports.map((report) => ({
1309
+ id: `save-${report.name}`,
1310
+ description: `Save ${report.name}`,
1311
+ repoName: report.name,
1312
+ repoPath: report.path,
1313
+ branch,
1314
+ resumable: true
1315
+ })),
1316
+ ...input.verify !== false ? [{
1317
+ id: "verify-root",
1318
+ description: "Verify market workspace",
1319
+ repoName: rootRepo.name,
1320
+ repoPath: rootRepo.path,
1321
+ branch,
1322
+ resumable: true
1323
+ }] : [],
1324
+ {
1325
+ id: "commit-root",
1326
+ description: "Commit market workspace changes",
1327
+ repoName: rootRepo.name,
1328
+ repoPath: rootRepo.path,
1329
+ branch,
1330
+ resumable: true
1331
+ },
1332
+ {
1333
+ id: "sync-root",
1334
+ description: `Push ${branch} to origin`,
1335
+ repoName: rootRepo.name,
1336
+ repoPath: rootRepo.path,
1337
+ branch,
1338
+ resumable: true
1339
+ },
1340
+ ...beforeState.branchRole === "feature" && (input.preview === true || input.refreshPreview !== false && beforeState.preview.enabled) ? [{
1341
+ id: "preview",
1342
+ description: `Refresh preview ${branch}`,
1343
+ repoName: rootRepo.name,
1344
+ repoPath: rootRepo.path,
1345
+ branch,
1346
+ resumable: true
1347
+ }] : []
1348
+ ],
1349
+ helpers.context
1350
+ );
1351
+ try {
1352
+ if (recursiveWorkspace) {
1353
+ assertWorkspaceVersionConsistency(root);
1354
+ for (const pkg of checkedOutWorkspacePackageRepos(root)) {
1355
+ const report = findReportByName(packageReports, pkg.name);
1356
+ if (!report) {
1357
+ continue;
1358
+ }
1359
+ try {
1360
+ const step = readWorkflowRunJournal(root, workflowRun.runId)?.steps.find((entry) => entry.id === `save-${report.name}`) ?? null;
1361
+ const resumePendingSync = workflowRun.resumed && step?.status === "pending" && branchNeedsSync(report.path, branch);
1362
+ if (!report.dirty && !resumePendingSync) {
1363
+ report.skippedReason = "clean";
1364
+ skipJournalStep(root, workflowRun.runId, `save-${report.name}`, {
1365
+ skippedReason: "clean"
1366
+ });
1367
+ continue;
1368
+ }
1369
+ const savedReport = await executeJournalStep(root, workflowRun.runId, `save-${report.name}`, () => savePackageRepo(report, message, branch, input.verify !== false));
1370
+ Object.assign(report, savedReport);
1371
+ } catch (error) {
1372
+ createSaveFailure(
1373
+ `Treeseed save stopped while saving workspace package ${pkg.name}.`,
1374
+ packageReports,
1375
+ rootRepo,
1376
+ report,
1377
+ error
1378
+ );
1379
+ }
1380
+ }
1381
+ }
1382
+ if (input.verify !== false) {
1383
+ try {
1384
+ await executeJournalStep(root, workflowRun.runId, "verify-root", () => {
1385
+ runWorkspaceSavePreflight({ cwd: root });
1386
+ rootRepo.verified = true;
1387
+ return {
1388
+ verified: true
1389
+ };
1390
+ });
1391
+ } catch (error) {
1392
+ createSaveFailure(
1393
+ "Treeseed save stopped while verifying the market workspace.",
1394
+ packageReports,
1395
+ rootRepo,
1396
+ null,
1397
+ error
1398
+ );
1399
+ }
1400
+ }
1401
+ const hadMeaningfulChanges = hasMeaningfulChanges(gitRoot);
1402
+ let head = run("git", ["rev-parse", "HEAD"], { cwd: gitRoot, capture: true }).trim();
1403
+ let commitCreated = false;
1404
+ if (hadMeaningfulChanges) {
1405
+ const commitResult = await executeJournalStep(root, workflowRun.runId, "commit-root", () => {
1406
+ run("git", ["add", "-A"], { cwd: gitRoot });
1407
+ run("git", ["commit", "-m", message], { cwd: gitRoot });
1408
+ return {
1409
+ commitSha: run("git", ["rev-parse", "HEAD"], { cwd: gitRoot, capture: true }).trim()
1410
+ };
1411
+ });
1412
+ head = String(commitResult?.commitSha ?? head);
1413
+ commitCreated = true;
1414
+ rootRepo.committed = true;
1415
+ } else {
1416
+ skipJournalStep(root, workflowRun.runId, "commit-root", {
1417
+ skippedReason: "clean"
1418
+ });
1419
+ }
1420
+ rootRepo.commitSha = head;
1421
+ let branchSync;
1422
+ try {
1423
+ branchSync = await executeJournalStep(root, workflowRun.runId, "sync-root", () => syncCurrentBranchToOrigin("save", gitRoot, branch));
1424
+ } catch (error) {
1425
+ createSaveFailure(
1426
+ "Treeseed save stopped while syncing the market repository.",
1427
+ packageReports,
1428
+ rootRepo,
1429
+ rootRepo,
1430
+ error
1431
+ );
1432
+ }
1433
+ rootRepo.pushed = branchSync.pushed === true;
1434
+ if (input.verify !== false) {
1435
+ rootRepo.verified = true;
1436
+ }
1437
+ if (!hadMeaningfulChanges) {
1438
+ rootRepo.skippedReason = "clean";
1439
+ }
1440
+ let previewAction = { status: "skipped" };
1441
+ if (beforeState.branchRole === "feature" && branch) {
1442
+ if (input.preview === true) {
1443
+ previewAction = {
1444
+ status: beforeState.preview.enabled ? "refreshed" : "created",
1445
+ details: await executeJournalStep(root, workflowRun.runId, "preview", () => deployBranchPreview(root, branch, helpers.context, { initialize: !beforeState.preview.enabled }))
1446
+ };
1447
+ } else if (input.refreshPreview !== false && beforeState.preview.enabled) {
1448
+ previewAction = {
1449
+ status: "refreshed",
1450
+ details: await executeJournalStep(root, workflowRun.runId, "preview", () => deployBranchPreview(root, branch, helpers.context, { initialize: false }))
1451
+ };
1452
+ }
1453
+ }
1454
+ const payload = {
1455
+ mode,
724
1456
  branch,
725
1457
  scope,
726
1458
  hotfix: optionsHotfix,
@@ -729,13 +1461,54 @@ async function workflowSave(helpers, input) {
729
1461
  commitCreated,
730
1462
  noChanges: !hadMeaningfulChanges,
731
1463
  branchSync,
1464
+ repos: packageReports,
1465
+ rootRepo,
1466
+ partialFailure: null,
732
1467
  previewAction,
733
1468
  mergeConflict: null
734
- },
735
- createNextSteps([
736
- branch === STAGING_BRANCH ? { operation: "release", reason: "Promote the validated staging branch into production.", input: { bump: "patch" } } : branch === PRODUCTION_BRANCH ? { operation: "status", reason: "Inspect production state after the explicit hotfix save." } : { operation: "stage", reason: "Merge the verified task branch into staging.", input: { message: "describe the resolution" } }
737
- ])
738
- );
1469
+ };
1470
+ completeWorkflowRun(root, workflowRun.runId, payload);
1471
+ return buildWorkflowResult(
1472
+ "save",
1473
+ root,
1474
+ payload,
1475
+ {
1476
+ runId: workflowRun.runId,
1477
+ nextSteps: createNextSteps([
1478
+ branch === STAGING_BRANCH ? { operation: "release", reason: "Promote the validated staging branch into production.", input: { bump: "patch" } } : branch === PRODUCTION_BRANCH ? { operation: "status", reason: "Inspect production state after the explicit hotfix save." } : { operation: "stage", reason: "Merge the verified task branch into staging.", input: { message: "describe the resolution" } }
1479
+ ])
1480
+ }
1481
+ );
1482
+ } catch (error) {
1483
+ const failingRepo = packageReports.find((report) => report.dirty && report.pushed !== true) ?? rootRepo;
1484
+ const wrappedError = error instanceof TreeseedWorkflowError && error.details?.partialFailure != null ? error : new TreeseedWorkflowError(
1485
+ "save",
1486
+ error instanceof TreeseedWorkflowError ? error.code : "unsupported_state",
1487
+ error instanceof Error ? error.message : String(error),
1488
+ {
1489
+ details: {
1490
+ ...error instanceof TreeseedWorkflowError ? error.details ?? {} : {},
1491
+ partialFailure: {
1492
+ message: "Treeseed save stopped before the workspace could finish syncing.",
1493
+ failingRepo: failingRepo.name,
1494
+ repos: packageReports,
1495
+ rootRepo,
1496
+ error: error instanceof Error ? error.message : String(error)
1497
+ }
1498
+ },
1499
+ exitCode: error instanceof TreeseedWorkflowError ? error.exitCode : void 0
1500
+ }
1501
+ );
1502
+ failWorkflowRun(root, workflowRun.runId, wrappedError, {
1503
+ resumable: true,
1504
+ runId: workflowRun.runId,
1505
+ command: "save",
1506
+ message: `Resume the interrupted save on ${branch}.`,
1507
+ recoverCommand: "treeseed recover",
1508
+ resumeCommand: `treeseed resume ${workflowRun.runId}`
1509
+ });
1510
+ throw wrappedError;
1511
+ }
739
1512
  });
740
1513
  } catch (error) {
741
1514
  toError("save", error);
@@ -743,41 +1516,148 @@ async function workflowSave(helpers, input) {
743
1516
  }
744
1517
  async function workflowClose(helpers, input) {
745
1518
  try {
746
- return withContextEnv(helpers.context.env, async () => {
1519
+ return await withContextEnv(helpers.context.env, async () => {
747
1520
  const tenantRoot = resolveProjectRootOrThrow("close", helpers.cwd());
1521
+ const root = workspaceRoot(tenantRoot);
748
1522
  const message = ensureMessage("close", input.message, "a close reason");
1523
+ const executionMode = normalizeExecutionMode(input);
1524
+ const session = resolveTreeseedWorkflowSession(root);
1525
+ if (executionMode === "plan") {
1526
+ const branchName = session.branchName;
1527
+ const blockers = session.branchRole !== "feature" ? ["Close only applies to task branches."] : [];
1528
+ return buildWorkflowResult(
1529
+ "close",
1530
+ root,
1531
+ {
1532
+ mode: session.mode,
1533
+ branchName,
1534
+ message,
1535
+ autoSaveRequired: session.rootRepo.dirty || session.packageRepos.some((repo) => repo.dirty),
1536
+ repos: createWorkspacePackageReports(root),
1537
+ rootRepo: createWorkspaceRootRepoReport(root),
1538
+ blockers,
1539
+ plannedSteps: [
1540
+ { id: "preview-cleanup", description: `Destroy preview resources for ${branchName ?? "(current task)"}` },
1541
+ { id: "cleanup-root", description: `Archive and delete ${branchName ?? "(current task)"} in market` },
1542
+ ...checkedOutWorkspacePackageRepos(root).map((pkg) => ({
1543
+ id: `cleanup-${pkg.name}`,
1544
+ description: `Archive and delete ${branchName ?? "(current task)"} in ${pkg.name}`
1545
+ }))
1546
+ ]
1547
+ },
1548
+ {
1549
+ executionMode,
1550
+ nextSteps: createNextSteps([
1551
+ { operation: "close", reason: "Run without --plan to archive and delete the task branch.", input: { message } }
1552
+ ])
1553
+ }
1554
+ );
1555
+ }
749
1556
  const autoSave = await maybeAutoSaveCurrentTaskBranch(helpers, "close", {
750
1557
  message,
751
1558
  autoSave: input.autoSave
752
1559
  });
753
- const featureBranch = assertFeatureBranch(tenantRoot);
754
- const repoDir = gitWorkflowRoot(tenantRoot);
755
- assertCleanWorktree(tenantRoot);
756
- const previewCleanup = input.deletePreview === false ? { performed: false } : destroyPreviewIfPresent(tenantRoot, featureBranch);
757
- const deprecatedTag = createDeprecatedTaskTag(repoDir, featureBranch, `close: ${message}`);
758
- const remoteDeleted = input.deleteBranch === false ? false : deleteRemoteBranch(repoDir, featureBranch);
759
- syncBranchWithOrigin(repoDir, STAGING_BRANCH);
760
- if (input.deleteBranch !== false) {
761
- deleteLocalBranch(repoDir, featureBranch);
1560
+ const activeSession = resolveTreeseedWorkflowSession(root);
1561
+ const featureBranch = assertFeatureBranch(root);
1562
+ const mode = activeSession.mode;
1563
+ const repoDir = activeSession.gitRoot;
1564
+ assertSessionBranchSafety("close", activeSession);
1565
+ if (mode === "recursive-workspace") {
1566
+ assertWorkspaceClean(root);
1567
+ } else {
1568
+ assertCleanWorktree(root);
762
1569
  }
763
- return buildWorkflowResult(
1570
+ const rootRepo = createWorkspaceRootRepoReport(root);
1571
+ const packageReports = createWorkspacePackageReports(root);
1572
+ const workflowRun = acquireWorkflowRun(
764
1573
  "close",
765
- tenantRoot,
766
- {
1574
+ activeSession,
1575
+ { message, deletePreview: input.deletePreview !== false, deleteBranch: input.deleteBranch !== false },
1576
+ [
1577
+ { id: "preview-cleanup", description: `Destroy preview resources for ${featureBranch}`, repoName: rootRepo.name, repoPath: rootRepo.path, branch: featureBranch, resumable: true },
1578
+ { id: "cleanup-root", description: `Archive ${featureBranch} in market`, repoName: rootRepo.name, repoPath: rootRepo.path, branch: featureBranch, resumable: true },
1579
+ ...packageReports.map((report) => ({
1580
+ id: `cleanup-${report.name}`,
1581
+ description: `Archive ${featureBranch} in ${report.name}`,
1582
+ repoName: report.name,
1583
+ repoPath: report.path,
1584
+ branch: featureBranch,
1585
+ resumable: true
1586
+ }))
1587
+ ],
1588
+ helpers.context
1589
+ );
1590
+ try {
1591
+ const previewCleanup = input.deletePreview === false ? (skipJournalStep(root, workflowRun.runId, "preview-cleanup", { performed: false }), { performed: false }) : await executeJournalStep(root, workflowRun.runId, "preview-cleanup", () => destroyPreviewIfPresent(root, featureBranch));
1592
+ const rootCleanup = await executeJournalStep(root, workflowRun.runId, "cleanup-root", () => {
1593
+ const deprecatedTag = createDeprecatedTaskTag(repoDir, featureBranch, `close: ${message}`);
1594
+ const deletedRemote = input.deleteBranch === false ? false : deleteRemoteBranch(repoDir, featureBranch);
1595
+ syncBranchWithOrigin(repoDir, STAGING_BRANCH);
1596
+ if (input.deleteBranch !== false) {
1597
+ deleteLocalBranch(repoDir, featureBranch);
1598
+ }
1599
+ return {
1600
+ deprecatedTag,
1601
+ deletedRemote,
1602
+ deletedLocal: input.deleteBranch !== false,
1603
+ branch: currentBranch(repoDir) || STAGING_BRANCH,
1604
+ dirty: hasMeaningfulChanges(repoDir)
1605
+ };
1606
+ });
1607
+ rootRepo.tagName = String(rootCleanup?.deprecatedTag?.tagName ?? null);
1608
+ rootRepo.commitSha = String(rootCleanup?.deprecatedTag?.head ?? rootRepo.commitSha ?? "");
1609
+ rootRepo.deletedRemote = rootCleanup?.deletedRemote === true;
1610
+ rootRepo.deletedLocal = rootCleanup?.deletedLocal === true;
1611
+ rootRepo.branch = typeof rootCleanup?.branch === "string" ? rootCleanup.branch : currentBranch(repoDir) || STAGING_BRANCH;
1612
+ rootRepo.dirty = rootCleanup?.dirty === true;
1613
+ for (const pkg of checkedOutWorkspacePackageRepos(root)) {
1614
+ const report = findReportByName(packageReports, pkg.name);
1615
+ if (!report) {
1616
+ continue;
1617
+ }
1618
+ const cleanup = await executeJournalStep(root, workflowRun.runId, `cleanup-${report.name}`, () => cleanupTaskBranchReport(report, featureBranch, `close: ${message}`, {
1619
+ deleteBranch: input.deleteBranch !== false,
1620
+ targetBranch: STAGING_BRANCH
1621
+ }));
1622
+ Object.assign(report, cleanup);
1623
+ }
1624
+ const payload = {
1625
+ mode,
767
1626
  branchName: featureBranch,
768
1627
  message,
769
1628
  autoSaved: autoSave.performed,
770
1629
  autoSaveResult: autoSave.save,
771
- deprecatedTag,
1630
+ deprecatedTag: rootCleanup?.deprecatedTag ?? null,
1631
+ repos: packageReports,
1632
+ rootRepo,
772
1633
  previewCleanup,
773
- remoteDeleted,
774
- localDeleted: input.deleteBranch !== false,
1634
+ remoteDeleted: rootRepo.deletedRemote,
1635
+ localDeleted: rootRepo.deletedLocal,
775
1636
  finalBranch: currentBranch(repoDir) || STAGING_BRANCH
776
- },
777
- createNextSteps([
778
- { operation: "tasks", reason: "Inspect the remaining task branches after closing this one." }
779
- ])
780
- );
1637
+ };
1638
+ completeWorkflowRun(root, workflowRun.runId, payload);
1639
+ return buildWorkflowResult(
1640
+ "close",
1641
+ root,
1642
+ payload,
1643
+ {
1644
+ runId: workflowRun.runId,
1645
+ nextSteps: createNextSteps([
1646
+ { operation: "tasks", reason: "Inspect the remaining task branches after closing this one." }
1647
+ ])
1648
+ }
1649
+ );
1650
+ } catch (error) {
1651
+ failWorkflowRun(root, workflowRun.runId, error, {
1652
+ resumable: true,
1653
+ runId: workflowRun.runId,
1654
+ command: "close",
1655
+ message: `Resume the interrupted close for ${featureBranch}.`,
1656
+ recoverCommand: "treeseed recover",
1657
+ resumeCommand: `treeseed resume ${workflowRun.runId}`
1658
+ });
1659
+ throw error;
1660
+ }
781
1661
  });
782
1662
  } catch (error) {
783
1663
  toError("close", error);
@@ -785,53 +1665,232 @@ async function workflowClose(helpers, input) {
785
1665
  }
786
1666
  async function workflowStage(helpers, input) {
787
1667
  try {
788
- return withContextEnv(helpers.context.env, async () => {
1668
+ return await withContextEnv(helpers.context.env, async () => {
789
1669
  const tenantRoot = resolveProjectRootOrThrow("stage", helpers.cwd());
1670
+ const root = workspaceRoot(tenantRoot);
790
1671
  const message = ensureMessage("stage", input.message, "a resolution message");
1672
+ const executionMode = normalizeExecutionMode(input);
1673
+ const initialSession = resolveTreeseedWorkflowSession(root);
1674
+ if (executionMode === "plan") {
1675
+ const blockers = [];
1676
+ try {
1677
+ validateStagingWorkflowContracts(root);
1678
+ } catch (error) {
1679
+ blockers.push(error instanceof Error ? error.message : String(error));
1680
+ }
1681
+ return buildWorkflowResult(
1682
+ "stage",
1683
+ root,
1684
+ {
1685
+ mode: initialSession.mode,
1686
+ branchName: initialSession.branchName,
1687
+ mergeTarget: STAGING_BRANCH,
1688
+ mergeStrategy: "squash",
1689
+ message,
1690
+ autoSaveRequired: initialSession.rootRepo.dirty || initialSession.packageRepos.some((repo) => repo.dirty),
1691
+ blockers,
1692
+ rootRepo: createWorkspaceRootRepoReport(root),
1693
+ repos: createWorkspacePackageReports(root),
1694
+ plannedSteps: [
1695
+ ...checkedOutWorkspacePackageRepos(root).map((pkg) => ({
1696
+ id: `merge-${pkg.name}`,
1697
+ description: `Squash-merge ${initialSession.branchName ?? "(current task)"} into ${pkg.name} staging`
1698
+ })),
1699
+ { id: "merge-root", description: `Squash-merge ${initialSession.branchName ?? "(current task)"} into market staging` },
1700
+ { id: "wait-staging", description: "Wait for staging automation" },
1701
+ { id: "preview-cleanup", description: "Destroy preview resources" },
1702
+ { id: "cleanup-root", description: "Archive and delete the task branch from market" },
1703
+ ...checkedOutWorkspacePackageRepos(root).map((pkg) => ({
1704
+ id: `cleanup-${pkg.name}`,
1705
+ description: `Archive and delete the task branch from ${pkg.name}`
1706
+ }))
1707
+ ]
1708
+ },
1709
+ {
1710
+ executionMode,
1711
+ nextSteps: createNextSteps([
1712
+ { operation: "stage", reason: "Run without --plan to promote the task branch into staging.", input: { message } }
1713
+ ])
1714
+ }
1715
+ );
1716
+ }
791
1717
  const autoSave = await maybeAutoSaveCurrentTaskBranch(helpers, "stage", {
792
1718
  message,
793
1719
  autoSave: input.autoSave
794
1720
  });
795
- const featureBranch = assertFeatureBranch(tenantRoot);
796
- runWorkspaceSavePreflight({ cwd: tenantRoot });
797
- let repoDir;
798
- try {
799
- repoDir = mergeCurrentBranchIntoStaging(tenantRoot, featureBranch);
800
- } catch (error) {
801
- const report = collectMergeConflictReport(gitWorkflowRoot(tenantRoot));
802
- throw new TreeseedWorkflowError("stage", "merge_conflict", formatMergeConflictReport(report, gitWorkflowRoot(tenantRoot), STAGING_BRANCH), {
803
- details: { branch: featureBranch, report, originalError: error instanceof Error ? error.message : String(error) },
804
- exitCode: 12
805
- });
806
- }
807
- const stagingWait = input.waitForStaging === false ? { status: "skipped", reason: "disabled" } : waitForStagingAutomation(repoDir);
808
- const previewCleanup = input.deletePreview === false ? { performed: false } : destroyPreviewIfPresent(tenantRoot, featureBranch);
809
- const deprecatedTag = createDeprecatedTaskTag(repoDir, featureBranch, `stage: ${message}`);
810
- const remoteDeleted = input.deleteBranch === false ? false : deleteRemoteBranch(repoDir, featureBranch);
811
- if (input.deleteBranch !== false) {
812
- deleteLocalBranch(repoDir, featureBranch);
1721
+ const session = resolveTreeseedWorkflowSession(root);
1722
+ const featureBranch = assertFeatureBranch(root);
1723
+ const mode = session.mode;
1724
+ assertSessionBranchSafety("stage", session);
1725
+ if (mode === "recursive-workspace") {
1726
+ assertWorkspaceClean(root);
1727
+ } else {
1728
+ assertCleanWorktree(root);
813
1729
  }
814
- return buildWorkflowResult(
1730
+ validateStagingWorkflowContracts(root);
1731
+ runWorkspaceSavePreflight({ cwd: root });
1732
+ const repoDir = session.gitRoot;
1733
+ const rootRepo = createWorkspaceRootRepoReport(root);
1734
+ const packageReports = createWorkspacePackageReports(root);
1735
+ const workflowRun = acquireWorkflowRun(
815
1736
  "stage",
816
- tenantRoot,
817
- {
1737
+ session,
1738
+ { message, waitForStaging: input.waitForStaging !== false, deletePreview: input.deletePreview !== false, deleteBranch: input.deleteBranch !== false },
1739
+ [
1740
+ ...packageReports.map((report) => ({
1741
+ id: `merge-${report.name}`,
1742
+ description: `Merge ${featureBranch} into ${report.name} staging`,
1743
+ repoName: report.name,
1744
+ repoPath: report.path,
1745
+ branch: featureBranch,
1746
+ resumable: true
1747
+ })),
1748
+ { id: "merge-root", description: `Merge ${featureBranch} into market staging`, repoName: rootRepo.name, repoPath: rootRepo.path, branch: featureBranch, resumable: true },
1749
+ { id: "wait-staging", description: "Wait for staging automation", repoName: rootRepo.name, repoPath: rootRepo.path, branch: STAGING_BRANCH, resumable: true },
1750
+ { id: "preview-cleanup", description: "Destroy preview resources", repoName: rootRepo.name, repoPath: rootRepo.path, branch: featureBranch, resumable: true },
1751
+ { id: "cleanup-root", description: `Archive ${featureBranch} in market`, repoName: rootRepo.name, repoPath: rootRepo.path, branch: featureBranch, resumable: true },
1752
+ ...packageReports.map((report) => ({
1753
+ id: `cleanup-${report.name}`,
1754
+ description: `Archive ${featureBranch} in ${report.name}`,
1755
+ repoName: report.name,
1756
+ repoPath: report.path,
1757
+ branch: featureBranch,
1758
+ resumable: true
1759
+ }))
1760
+ ],
1761
+ helpers.context
1762
+ );
1763
+ try {
1764
+ for (const pkg of checkedOutWorkspacePackageRepos(root)) {
1765
+ const report = findReportByName(packageReports, pkg.name);
1766
+ if (!report) {
1767
+ continue;
1768
+ }
1769
+ if (!ensureLocalTaskBranch(pkg.dir, featureBranch)) {
1770
+ report.skippedReason = "branch-missing";
1771
+ skipJournalStep(root, workflowRun.runId, `merge-${report.name}`, { skippedReason: "branch-missing" });
1772
+ continue;
1773
+ }
1774
+ try {
1775
+ const mergeResult = await executeJournalStep(root, workflowRun.runId, `merge-${report.name}`, () => squashMergeBranchIntoStaging(pkg.dir, featureBranch, message, { pushTarget: true }));
1776
+ report.merged = mergeResult.committed;
1777
+ report.committed = mergeResult.committed;
1778
+ report.pushed = mergeResult.pushed;
1779
+ report.commitSha = mergeResult.commitSha;
1780
+ report.branch = STAGING_BRANCH;
1781
+ checkoutBranch(pkg.dir, featureBranch);
1782
+ report.branch = featureBranch;
1783
+ } catch (error) {
1784
+ const reportData = collectMergeConflictReport(pkg.dir);
1785
+ throw new TreeseedWorkflowError("stage", "merge_conflict", formatMergeConflictReport(reportData, pkg.dir, STAGING_BRANCH), {
1786
+ details: { branch: featureBranch, packageName: pkg.name, report: reportData, originalError: error instanceof Error ? error.message : String(error) },
1787
+ exitCode: 12
1788
+ });
1789
+ }
1790
+ }
1791
+ try {
1792
+ const rootMerge = await executeJournalStep(root, workflowRun.runId, "merge-root", () => {
1793
+ assertCleanWorktree(root);
1794
+ syncBranchWithOrigin(repoDir, STAGING_BRANCH);
1795
+ run("git", ["merge", "--squash", featureBranch], { cwd: repoDir });
1796
+ if (mode === "recursive-workspace") {
1797
+ syncAllCheckedOutPackageRepos(root, STAGING_BRANCH);
1798
+ }
1799
+ if (hasStagedChanges(repoDir) || hasMeaningfulChanges(repoDir)) {
1800
+ run("git", ["add", "-A"], { cwd: repoDir });
1801
+ run("git", ["commit", "-m", message], { cwd: repoDir });
1802
+ }
1803
+ pushBranch(repoDir, STAGING_BRANCH);
1804
+ return {
1805
+ commitSha: headCommit(repoDir),
1806
+ branch: currentBranch(repoDir) || STAGING_BRANCH,
1807
+ committed: hasMeaningfulChanges(repoDir) ? false : true
1808
+ };
1809
+ });
1810
+ rootRepo.merged = true;
1811
+ rootRepo.committed = true;
1812
+ rootRepo.commitSha = String(rootMerge?.commitSha ?? headCommit(repoDir));
1813
+ rootRepo.pushed = true;
1814
+ rootRepo.branch = typeof rootMerge?.branch === "string" ? rootMerge.branch : currentBranch(repoDir) || STAGING_BRANCH;
1815
+ } catch (error) {
1816
+ const report = collectMergeConflictReport(repoDir);
1817
+ throw new TreeseedWorkflowError("stage", "merge_conflict", formatMergeConflictReport(report, repoDir, STAGING_BRANCH), {
1818
+ details: { branch: featureBranch, report, originalError: error instanceof Error ? error.message : String(error) },
1819
+ exitCode: 12
1820
+ });
1821
+ }
1822
+ const stagingWait = input.waitForStaging === false ? (skipJournalStep(root, workflowRun.runId, "wait-staging", { status: "skipped", reason: "disabled" }), { status: "skipped", reason: "disabled" }) : await executeJournalStep(root, workflowRun.runId, "wait-staging", () => waitForStagingAutomation(repoDir));
1823
+ const previewCleanup = input.deletePreview === false ? (skipJournalStep(root, workflowRun.runId, "preview-cleanup", { performed: false }), { performed: false }) : await executeJournalStep(root, workflowRun.runId, "preview-cleanup", () => destroyPreviewIfPresent(root, featureBranch));
1824
+ const rootCleanup = await executeJournalStep(root, workflowRun.runId, "cleanup-root", () => {
1825
+ const deprecatedTag = createDeprecatedTaskTag(repoDir, featureBranch, `stage: ${message}`);
1826
+ const deletedRemote = input.deleteBranch === false ? false : deleteRemoteBranch(repoDir, featureBranch);
1827
+ if (input.deleteBranch !== false) {
1828
+ deleteLocalBranch(repoDir, featureBranch);
1829
+ }
1830
+ return {
1831
+ deprecatedTag,
1832
+ deletedRemote,
1833
+ deletedLocal: input.deleteBranch !== false,
1834
+ branch: currentBranch(repoDir) || STAGING_BRANCH
1835
+ };
1836
+ });
1837
+ rootRepo.tagName = String(rootCleanup?.deprecatedTag?.tagName ?? rootRepo.tagName ?? "");
1838
+ rootRepo.commitSha = String(rootCleanup?.deprecatedTag?.head ?? rootRepo.commitSha ?? "");
1839
+ rootRepo.deletedRemote = rootCleanup?.deletedRemote === true;
1840
+ rootRepo.deletedLocal = rootCleanup?.deletedLocal === true;
1841
+ rootRepo.branch = typeof rootCleanup?.branch === "string" ? rootCleanup.branch : currentBranch(repoDir) || STAGING_BRANCH;
1842
+ for (const pkg of checkedOutWorkspacePackageRepos(root)) {
1843
+ const report = findReportByName(packageReports, pkg.name);
1844
+ if (!report) {
1845
+ continue;
1846
+ }
1847
+ const cleanup = await executeJournalStep(root, workflowRun.runId, `cleanup-${report.name}`, () => cleanupTaskBranchReport(report, featureBranch, `stage: ${message}`, {
1848
+ deleteBranch: input.deleteBranch !== false,
1849
+ targetBranch: STAGING_BRANCH
1850
+ }));
1851
+ Object.assign(report, cleanup);
1852
+ }
1853
+ const payload = {
1854
+ mode,
818
1855
  branchName: featureBranch,
819
1856
  mergeTarget: STAGING_BRANCH,
1857
+ mergeStrategy: "squash",
820
1858
  message,
821
1859
  autoSaved: autoSave.performed,
822
1860
  autoSaveResult: autoSave.save,
823
- deprecatedTag,
1861
+ deprecatedTag: rootCleanup?.deprecatedTag ?? null,
1862
+ repos: packageReports,
1863
+ rootRepo,
824
1864
  stagingWait,
825
1865
  previewCleanup,
826
- remoteDeleted,
827
- localDeleted: input.deleteBranch !== false,
1866
+ remoteDeleted: rootRepo.deletedRemote,
1867
+ localDeleted: rootRepo.deletedLocal,
828
1868
  finalBranch: currentBranch(repoDir) || STAGING_BRANCH
829
- },
830
- createNextSteps([
831
- { operation: "release", reason: "Promote the updated staging branch into production when ready.", input: { bump: "patch" } },
832
- { operation: "status", reason: "Inspect staging readiness after the task branch merge." }
833
- ])
834
- );
1869
+ };
1870
+ completeWorkflowRun(root, workflowRun.runId, payload);
1871
+ return buildWorkflowResult(
1872
+ "stage",
1873
+ root,
1874
+ payload,
1875
+ {
1876
+ runId: workflowRun.runId,
1877
+ nextSteps: createNextSteps([
1878
+ { operation: "release", reason: "Promote the updated staging branch into production when ready.", input: { bump: "patch" } },
1879
+ { operation: "status", reason: "Inspect staging readiness after the task branch merge." }
1880
+ ])
1881
+ }
1882
+ );
1883
+ } catch (error) {
1884
+ failWorkflowRun(root, workflowRun.runId, error, {
1885
+ resumable: true,
1886
+ runId: workflowRun.runId,
1887
+ command: "stage",
1888
+ message: `Resume the interrupted stage for ${featureBranch}.`,
1889
+ recoverCommand: "treeseed recover",
1890
+ resumeCommand: `treeseed resume ${workflowRun.runId}`
1891
+ });
1892
+ throw error;
1893
+ }
835
1894
  });
836
1895
  } catch (error) {
837
1896
  toError("stage", error);
@@ -839,101 +1898,507 @@ async function workflowStage(helpers, input) {
839
1898
  }
840
1899
  async function workflowRelease(helpers, input) {
841
1900
  try {
842
- return withContextEnv(helpers.context.env, () => {
1901
+ return await withContextEnv(helpers.context.env, async () => {
843
1902
  const level = input.bump ?? "patch";
844
1903
  const root = resolveProjectRootOrThrow("release", helpers.cwd());
845
- const gitRoot = repoRoot(root);
1904
+ const session = resolveTreeseedWorkflowSession(root);
1905
+ const gitRoot = session.gitRoot;
1906
+ const mode = session.mode;
1907
+ const executionMode = normalizeExecutionMode(input);
1908
+ const rootRepo = createWorkspaceRootRepoReport(root);
1909
+ const packageReports = createWorkspacePackageReports(root);
1910
+ const packageSelection = session.packageSelection;
1911
+ const selectedPackageNames = new Set(packageSelection.selected);
1912
+ const blockers = [];
1913
+ if (session.branchName !== STAGING_BRANCH) {
1914
+ blockers.push("Release must start from staging.");
1915
+ }
1916
+ if (mode === "recursive-workspace") {
1917
+ try {
1918
+ assertWorkspaceVersionConsistency(root);
1919
+ validatePackageReleaseWorkflows(root, packageSelection.selected);
1920
+ } catch (error) {
1921
+ blockers.push(error instanceof Error ? error.message : String(error));
1922
+ }
1923
+ }
1924
+ const versionPlan = planWorkspaceReleaseBump(level, root, mode === "recursive-workspace" ? { selectedPackageNames } : {});
1925
+ const plannedVersions = Object.fromEntries(versionPlan.versions.entries());
1926
+ if (executionMode === "plan") {
1927
+ return buildWorkflowResult(
1928
+ "release",
1929
+ root,
1930
+ {
1931
+ mode,
1932
+ mergeStrategy: "merge-commit",
1933
+ level,
1934
+ stagingBranch: STAGING_BRANCH,
1935
+ productionBranch: PRODUCTION_BRANCH,
1936
+ packageSelection,
1937
+ plannedVersions,
1938
+ repos: packageReports,
1939
+ rootRepo,
1940
+ plannedSteps: [
1941
+ ...packageReports.filter((report) => selectedPackageNames.has(report.name)).map((report) => ({
1942
+ id: `release-${report.name}`,
1943
+ description: `Release ${report.name} from staging to main and tag ${plannedVersions[report.name] ?? "(planned)"}`
1944
+ })),
1945
+ { id: "release-root", description: `Release market ${plannedVersions["@treeseed/market"] ?? "(planned)"}` }
1946
+ ],
1947
+ blockers
1948
+ },
1949
+ {
1950
+ executionMode,
1951
+ nextSteps: createNextSteps([
1952
+ { operation: "release", reason: "Run without --plan to promote staging into production.", input: { bump: level } }
1953
+ ])
1954
+ }
1955
+ );
1956
+ }
1957
+ if (blockers.length > 0) {
1958
+ workflowError("release", "validation_failed", blockers.join("\n"), {
1959
+ details: { blockers }
1960
+ });
1961
+ }
1962
+ assertSessionBranchSafety("release", session);
846
1963
  prepareReleaseBranches(root);
847
1964
  applyTreeseedEnvironmentToProcess({ tenantRoot: root, scope: "staging", override: true });
848
1965
  runWorkspaceSavePreflight({ cwd: root });
849
- const plan = planWorkspaceReleaseBump(level, root);
850
- applyWorkspaceVersionChanges(plan);
851
- const rootVersion = bumpRootPackageJson(root, level);
852
- run("git", ["checkout", STAGING_BRANCH], { cwd: gitRoot });
853
- run("git", ["add", "-A"], { cwd: gitRoot });
854
- run("git", ["commit", "-m", `release: ${level} bump`], { cwd: gitRoot });
855
- pushBranch(gitRoot, STAGING_BRANCH);
856
- mergeStagingIntoMain(root);
857
- const releasedCommit = run("git", ["rev-parse", PRODUCTION_BRANCH], { cwd: gitRoot, capture: true }).trim();
858
- run("git", ["tag", "-a", rootVersion, "-m", `release: ${rootVersion}`], { cwd: gitRoot });
859
- run("git", ["push", "origin", rootVersion], { cwd: gitRoot });
860
- syncBranchWithOrigin(gitRoot, STAGING_BRANCH);
861
- return buildWorkflowResult(
1966
+ const workflowRun = acquireWorkflowRun(
862
1967
  "release",
863
- root,
864
- {
1968
+ session,
1969
+ { bump: level },
1970
+ [
1971
+ ...packageReports.filter((report) => selectedPackageNames.has(report.name)).map((report) => ({
1972
+ id: `release-${report.name}`,
1973
+ description: `Release ${report.name}`,
1974
+ repoName: report.name,
1975
+ repoPath: report.path,
1976
+ branch: STAGING_BRANCH,
1977
+ resumable: true
1978
+ })),
1979
+ { id: "release-root", description: "Release market repo", repoName: rootRepo.name, repoPath: rootRepo.path, branch: STAGING_BRANCH, resumable: true }
1980
+ ],
1981
+ helpers.context
1982
+ );
1983
+ try {
1984
+ if (mode === "root-only") {
1985
+ const rootRelease2 = await executeJournalStep(root, workflowRun.runId, "release-root", () => {
1986
+ applyWorkspaceVersionChanges(versionPlan);
1987
+ const rootVersion = bumpRootPackageJson(root, level);
1988
+ run("git", ["checkout", STAGING_BRANCH], { cwd: gitRoot });
1989
+ run("git", ["add", "-A"], { cwd: gitRoot });
1990
+ run("git", ["commit", "-m", `release: ${level} bump`], { cwd: gitRoot });
1991
+ pushBranch(gitRoot, STAGING_BRANCH);
1992
+ const released = mergeStagingIntoMain(root);
1993
+ run("git", ["tag", "-a", rootVersion, "-m", `release: ${rootVersion}`], { cwd: gitRoot });
1994
+ run("git", ["push", "origin", rootVersion], { cwd: gitRoot });
1995
+ syncBranchWithOrigin(gitRoot, STAGING_BRANCH);
1996
+ return {
1997
+ rootVersion,
1998
+ releasedCommit: released.commitSha
1999
+ };
2000
+ });
2001
+ rootRepo.committed = true;
2002
+ rootRepo.pushed = true;
2003
+ rootRepo.merged = true;
2004
+ rootRepo.branch = PRODUCTION_BRANCH;
2005
+ rootRepo.commitSha = String(rootRelease2?.releasedCommit ?? headCommit(gitRoot));
2006
+ rootRepo.tagName = String(rootRelease2?.rootVersion ?? "");
2007
+ const payload2 = {
2008
+ mode,
2009
+ mergeStrategy: "merge-commit",
2010
+ level,
2011
+ rootVersion: String(rootRelease2?.rootVersion ?? ""),
2012
+ releaseTag: String(rootRelease2?.rootVersion ?? ""),
2013
+ releasedCommit: String(rootRelease2?.releasedCommit ?? rootRepo.commitSha ?? ""),
2014
+ stagingBranch: STAGING_BRANCH,
2015
+ productionBranch: PRODUCTION_BRANCH,
2016
+ touchedPackages: [...versionPlan.touched],
2017
+ packageSelection: { changed: [], dependents: [], selected: [] },
2018
+ publishWait: [],
2019
+ repos: [],
2020
+ rootRepo,
2021
+ finalBranch: currentBranch(gitRoot) || STAGING_BRANCH,
2022
+ pushStatus: { stagingPushed: true, productionPushed: true, tagPushed: true }
2023
+ };
2024
+ completeWorkflowRun(root, workflowRun.runId, payload2);
2025
+ return buildWorkflowResult("release", root, payload2, {
2026
+ runId: workflowRun.runId,
2027
+ nextSteps: createNextSteps([
2028
+ { operation: "status", reason: "Inspect release readiness and production state after the promotion." }
2029
+ ])
2030
+ });
2031
+ }
2032
+ assertWorkspaceVersionConsistency(root);
2033
+ validatePackageReleaseWorkflows(root, packageSelection.selected);
2034
+ for (const pkg of checkedOutWorkspacePackageRepos(root)) {
2035
+ if (selectedPackageNames.has(pkg.name)) {
2036
+ prepareReleaseBranches(pkg.dir);
2037
+ }
2038
+ }
2039
+ applyWorkspaceVersionChanges(versionPlan);
2040
+ const publishWait = [];
2041
+ for (const pkg of checkedOutWorkspacePackageRepos(root)) {
2042
+ const report = findReportByName(packageReports, pkg.name);
2043
+ if (!report || !selectedPackageNames.has(pkg.name)) {
2044
+ if (report) {
2045
+ report.skippedReason = "unchanged";
2046
+ }
2047
+ continue;
2048
+ }
2049
+ const releasedPackage = await executeJournalStep(root, workflowRun.runId, `release-${report.name}`, () => {
2050
+ checkoutBranch(pkg.dir, STAGING_BRANCH);
2051
+ if (hasMeaningfulChanges(pkg.dir)) {
2052
+ run("git", ["add", "-A"], { cwd: pkg.dir });
2053
+ run("git", ["commit", "-m", `release: ${versionPlan.versions.get(pkg.name)}`], { cwd: pkg.dir });
2054
+ }
2055
+ pushBranch(pkg.dir, STAGING_BRANCH);
2056
+ const mergeResult = mergeBranchIntoTarget(pkg.dir, {
2057
+ sourceBranch: STAGING_BRANCH,
2058
+ targetBranch: PRODUCTION_BRANCH,
2059
+ message: `release: ${STAGING_BRANCH} -> ${PRODUCTION_BRANCH}`,
2060
+ pushTarget: true
2061
+ });
2062
+ const tagName = String(versionPlan.versions.get(pkg.name));
2063
+ run("git", ["tag", "-a", tagName, "-m", `release: ${tagName}`], { cwd: pkg.dir });
2064
+ run("git", ["push", "origin", tagName], { cwd: pkg.dir });
2065
+ const publish = waitForGitHubWorkflowCompletion(pkg.dir, {
2066
+ workflow: "publish.yml",
2067
+ headSha: mergeResult.commitSha,
2068
+ branch: PRODUCTION_BRANCH
2069
+ });
2070
+ syncBranchWithOrigin(pkg.dir, STAGING_BRANCH);
2071
+ return {
2072
+ commitSha: mergeResult.commitSha,
2073
+ tagName,
2074
+ publish
2075
+ };
2076
+ });
2077
+ report.committed = true;
2078
+ report.pushed = true;
2079
+ report.merged = true;
2080
+ report.tagName = String(releasedPackage?.tagName ?? "");
2081
+ report.commitSha = String(releasedPackage?.commitSha ?? report.commitSha ?? "");
2082
+ report.publishWait = releasedPackage?.publish ?? null;
2083
+ report.branch = STAGING_BRANCH;
2084
+ publishWait.push({
2085
+ name: report.name,
2086
+ ...releasedPackage?.publish ?? {}
2087
+ });
2088
+ }
2089
+ const rootRelease = await executeJournalStep(root, workflowRun.runId, "release-root", () => {
2090
+ const rootVersion = bumpRootPackageJson(root, level);
2091
+ run("git", ["checkout", STAGING_BRANCH], { cwd: gitRoot });
2092
+ run("git", ["add", "-A"], { cwd: gitRoot });
2093
+ run("git", ["commit", "-m", `release: ${level} bump`], { cwd: gitRoot });
2094
+ pushBranch(gitRoot, STAGING_BRANCH);
2095
+ const released = mergeBranchIntoTarget(root, {
2096
+ sourceBranch: STAGING_BRANCH,
2097
+ targetBranch: PRODUCTION_BRANCH,
2098
+ message: `release: ${STAGING_BRANCH} -> ${PRODUCTION_BRANCH}`,
2099
+ pushTarget: false
2100
+ });
2101
+ for (const pkg of checkedOutWorkspacePackageRepos(root)) {
2102
+ if (selectedPackageNames.has(pkg.name)) {
2103
+ syncBranchWithOrigin(pkg.dir, PRODUCTION_BRANCH);
2104
+ }
2105
+ }
2106
+ run("git", ["add", "-A"], { cwd: gitRoot });
2107
+ if (hasMeaningfulChanges(gitRoot)) {
2108
+ run("git", ["commit", "-m", "release: sync package main heads"], { cwd: gitRoot });
2109
+ }
2110
+ run("git", ["tag", "-a", rootVersion, "-m", `release: ${rootVersion}`], { cwd: gitRoot });
2111
+ run("git", ["push", "origin", PRODUCTION_BRANCH], { cwd: gitRoot });
2112
+ run("git", ["push", "origin", rootVersion], { cwd: gitRoot });
2113
+ syncAllCheckedOutPackageRepos(root, STAGING_BRANCH);
2114
+ syncBranchWithOrigin(gitRoot, STAGING_BRANCH);
2115
+ return {
2116
+ rootVersion,
2117
+ releasedCommit: headCommit(gitRoot)
2118
+ };
2119
+ });
2120
+ rootRepo.committed = true;
2121
+ rootRepo.pushed = true;
2122
+ rootRepo.merged = true;
2123
+ rootRepo.branch = PRODUCTION_BRANCH;
2124
+ rootRepo.commitSha = String(rootRelease?.releasedCommit ?? headCommit(gitRoot));
2125
+ rootRepo.tagName = String(rootRelease?.rootVersion ?? "");
2126
+ const payload = {
2127
+ mode,
2128
+ mergeStrategy: "merge-commit",
865
2129
  level,
866
- rootVersion,
867
- releaseTag: rootVersion,
868
- releasedCommit,
2130
+ rootVersion: String(rootRelease?.rootVersion ?? ""),
2131
+ releaseTag: String(rootRelease?.rootVersion ?? ""),
2132
+ releasedCommit: String(rootRelease?.releasedCommit ?? rootRepo.commitSha ?? ""),
869
2133
  stagingBranch: STAGING_BRANCH,
870
2134
  productionBranch: PRODUCTION_BRANCH,
871
- touchedPackages: [...plan.touched],
2135
+ touchedPackages: packageSelection.selected,
2136
+ packageSelection,
2137
+ publishWait,
2138
+ repos: packageReports,
2139
+ rootRepo,
872
2140
  finalBranch: currentBranch(gitRoot) || STAGING_BRANCH,
873
2141
  pushStatus: {
874
2142
  stagingPushed: true,
875
2143
  productionPushed: true,
876
2144
  tagPushed: true
877
2145
  }
2146
+ };
2147
+ completeWorkflowRun(root, workflowRun.runId, payload);
2148
+ return buildWorkflowResult(
2149
+ "release",
2150
+ root,
2151
+ payload,
2152
+ {
2153
+ runId: workflowRun.runId,
2154
+ nextSteps: createNextSteps([
2155
+ { operation: "status", reason: "Inspect release readiness and production state after the promotion." }
2156
+ ])
2157
+ }
2158
+ );
2159
+ } catch (error) {
2160
+ failWorkflowRun(root, workflowRun.runId, error, {
2161
+ resumable: true,
2162
+ runId: workflowRun.runId,
2163
+ command: "release",
2164
+ message: `Resume the interrupted release on ${STAGING_BRANCH}.`,
2165
+ recoverCommand: "treeseed recover",
2166
+ resumeCommand: `treeseed resume ${workflowRun.runId}`
2167
+ });
2168
+ throw error;
2169
+ }
2170
+ });
2171
+ } catch (error) {
2172
+ toError("release", error);
2173
+ }
2174
+ }
2175
+ async function workflowResume(helpers, input) {
2176
+ try {
2177
+ return await withContextEnv(helpers.context.env, async () => {
2178
+ const root = resolveProjectRootOrThrow("resume", helpers.cwd());
2179
+ const runId = String(input.runId ?? "").trim();
2180
+ if (!runId) {
2181
+ workflowError("resume", "validation_failed", "Treeseed resume requires a run id.");
2182
+ }
2183
+ const journal = readWorkflowRunJournal(root, runId);
2184
+ if (!journal) {
2185
+ workflowError("resume", "resume_unavailable", `Treeseed resume could not find run ${runId}.`, {
2186
+ details: { runId }
2187
+ });
2188
+ }
2189
+ if (journal.status === "completed") {
2190
+ workflowError("resume", "resume_unavailable", `Run ${runId} is already completed.`, {
2191
+ details: { runId, status: journal.status }
2192
+ });
2193
+ }
2194
+ if (!journal.resumable) {
2195
+ workflowError("resume", "resume_unavailable", `Run ${runId} is not resumable.`, {
2196
+ details: { runId, status: journal.status }
2197
+ });
2198
+ }
2199
+ const resumedHelpers = {
2200
+ ...helpers,
2201
+ context: {
2202
+ ...helpers.context,
2203
+ workflow: {
2204
+ ...helpers.context.workflow ?? {},
2205
+ resumeRunId: runId
2206
+ }
2207
+ }
2208
+ };
2209
+ switch (journal.command) {
2210
+ case "switch":
2211
+ return workflowSwitch(resumedHelpers, journal.input);
2212
+ case "save":
2213
+ return workflowSave(resumedHelpers, journal.input);
2214
+ case "close":
2215
+ return workflowClose(resumedHelpers, journal.input);
2216
+ case "stage":
2217
+ return workflowStage(resumedHelpers, journal.input);
2218
+ case "release":
2219
+ return workflowRelease(resumedHelpers, journal.input);
2220
+ case "destroy":
2221
+ return workflowDestroy(resumedHelpers, journal.input);
2222
+ default:
2223
+ workflowError("resume", "resume_unavailable", `Run ${runId} uses unsupported command ${journal.command}.`, {
2224
+ details: { runId, command: journal.command }
2225
+ });
2226
+ }
2227
+ });
2228
+ } catch (error) {
2229
+ toError("resume", error);
2230
+ }
2231
+ }
2232
+ async function workflowRecover(helpers, input = {}) {
2233
+ try {
2234
+ return await withContextEnv(helpers.context.env, async () => {
2235
+ const root = resolveProjectRootOrThrow("recover", helpers.cwd());
2236
+ const lock = inspectWorkflowLock(root);
2237
+ const journals = listWorkflowRunJournals(root);
2238
+ const interruptedRuns = listInterruptedWorkflowRuns(root).map((journal) => ({
2239
+ runId: journal.runId,
2240
+ command: journal.command,
2241
+ status: journal.status,
2242
+ createdAt: journal.createdAt,
2243
+ updatedAt: journal.updatedAt,
2244
+ nextStep: nextPendingJournalStep(journal)?.description ?? null,
2245
+ failure: journal.failure,
2246
+ resumeCommand: `treeseed resume ${journal.runId}`
2247
+ }));
2248
+ const selectedRun = input.runId ? readWorkflowRunJournal(root, input.runId) : null;
2249
+ return buildWorkflowResult(
2250
+ "recover",
2251
+ root,
2252
+ {
2253
+ lock,
2254
+ interruptedRuns,
2255
+ selectedRun,
2256
+ runCount: journals.length
878
2257
  },
879
- createNextSteps([
880
- { operation: "status", reason: "Inspect release readiness and production state after the promotion." }
881
- ])
2258
+ {
2259
+ includeFinalState: false,
2260
+ nextSteps: createNextSteps([
2261
+ ...interruptedRuns.length > 0 ? [{ operation: "resume", reason: "Resume the most recent interrupted workflow run.", input: { runId: interruptedRuns[0].runId } }] : [{ operation: "status", reason: "No interrupted runs were found; inspect current workflow state instead." }]
2262
+ ])
2263
+ }
882
2264
  );
883
2265
  });
884
2266
  } catch (error) {
885
- toError("release", error);
2267
+ toError("recover", error);
886
2268
  }
887
2269
  }
888
2270
  async function workflowDestroy(helpers, input) {
889
2271
  try {
890
2272
  return withContextEnv(helpers.context.env, async () => {
891
- const tenantRoot = helpers.cwd();
2273
+ const tenantRoot = resolveProjectRootOrThrow("destroy", helpers.cwd());
2274
+ const root = workspaceRoot(tenantRoot);
2275
+ const session = resolveTreeseedWorkflowSession(root);
892
2276
  const scope = String(input.environment ?? input.target ?? "");
893
2277
  if (!scope) {
894
2278
  workflowError("destroy", "validation_failed", "Treeseed destroy requires an environment target.");
895
2279
  }
2280
+ const executionMode = normalizeExecutionMode(input);
896
2281
  const target = createPersistentDeployTarget(scope);
897
- const dryRun = input.dryRun === true;
2282
+ const dryRun = executionMode === "plan";
898
2283
  const force = input.force === true;
899
2284
  const destroyRemote = input.destroyRemote !== false;
900
2285
  const destroyLocal = input.destroyLocal !== false;
901
2286
  const removeBuildArtifacts = input.removeBuildArtifacts === true;
902
2287
  applyTreeseedEnvironmentToProcess({ tenantRoot, scope, override: true });
903
2288
  assertTreeseedCommandEnvironment({ tenantRoot, scope, purpose: "destroy" });
904
- const deployConfig = validateDestroyPrerequisites(tenantRoot, { requireRemote: !dryRun && destroyRemote });
2289
+ const deployConfig = validateDestroyPrerequisites(tenantRoot, { requireRemote: executionMode === "execute" && destroyRemote });
905
2290
  const state = loadDeployState(tenantRoot, deployConfig, { target });
906
2291
  const expectedConfirmation = deployConfig.slug;
907
- const confirmed = await Promise.resolve(resolveDestroyConfirmation(helpers.context, expectedConfirmation, input));
908
- if (!confirmed) {
909
- workflowError("destroy", "confirmation_required", `Destroy confirmation required. Re-run with confirm="${expectedConfirmation}".`);
910
- }
911
- const result = destroyRemote ? destroyCloudflareResources(tenantRoot, { dryRun, force, target }) : null;
912
- if (!dryRun && destroyLocal) {
913
- cleanupDestroyedState(tenantRoot, { target, removeBuildArtifacts });
2292
+ const payload = {
2293
+ scope,
2294
+ dryRun,
2295
+ force,
2296
+ destroyRemote,
2297
+ destroyLocal,
2298
+ removeBuildArtifacts,
2299
+ expectedConfirmation,
2300
+ stateSummary: {
2301
+ workerName: state.workerName,
2302
+ lastDeploymentTimestamp: state.lastDeploymentTimestamp ?? null
2303
+ },
2304
+ plannedSteps: [
2305
+ ...destroyRemote ? [{ id: "destroy-remote", description: `Destroy remote ${scope} resources` }] : [],
2306
+ ...destroyLocal ? [{ id: "cleanup-local", description: `Clean local ${scope} state${removeBuildArtifacts ? " and build artifacts" : ""}` }] : []
2307
+ ],
2308
+ remoteResult: null
2309
+ };
2310
+ if (executionMode === "plan") {
2311
+ return buildWorkflowResult(
2312
+ "destroy",
2313
+ tenantRoot,
2314
+ payload,
2315
+ {
2316
+ executionMode,
2317
+ nextSteps: createNextSteps([
2318
+ { operation: "destroy", reason: "Run without --plan to destroy the selected environment.", input: { environment: scope, force, removeBuildArtifacts } },
2319
+ { operation: "status", reason: "Confirm the current environment state before making destructive changes." }
2320
+ ])
2321
+ }
2322
+ );
914
2323
  }
915
- return {
916
- ok: true,
917
- operation: "destroy",
918
- payload: {
919
- scope,
920
- dryRun,
2324
+ const workflowRun = acquireWorkflowRun(
2325
+ "destroy",
2326
+ session,
2327
+ {
2328
+ environment: scope,
921
2329
  force,
922
2330
  destroyRemote,
923
2331
  destroyLocal,
924
- removeBuildArtifacts,
925
- expectedConfirmation,
926
- stateSummary: {
927
- workerName: state.workerName,
928
- lastDeploymentTimestamp: state.lastDeploymentTimestamp ?? null
929
- },
930
- remoteResult: result
2332
+ removeBuildArtifacts
931
2333
  },
932
- nextSteps: createNextSteps([
933
- { operation: "config", reason: "Recreate the destroyed environment before using it again.", input: { environment: [scope] } },
934
- { operation: "status", reason: "Confirm the environment teardown state and any remaining local runtime setup." }
935
- ])
936
- };
2334
+ [
2335
+ ...destroyRemote ? [{
2336
+ id: "destroy-remote",
2337
+ description: `Destroy remote ${scope} resources`,
2338
+ repoName: session.rootRepo.name,
2339
+ repoPath: session.rootRepo.path,
2340
+ branch: session.branchName,
2341
+ resumable: false
2342
+ }] : [],
2343
+ ...destroyLocal ? [{
2344
+ id: "cleanup-local",
2345
+ description: `Clean local ${scope} state${removeBuildArtifacts ? " and build artifacts" : ""}`,
2346
+ repoName: session.rootRepo.name,
2347
+ repoPath: session.rootRepo.path,
2348
+ branch: session.branchName,
2349
+ resumable: false
2350
+ }] : []
2351
+ ],
2352
+ helpers.context
2353
+ );
2354
+ try {
2355
+ const confirmed = await Promise.resolve(resolveDestroyConfirmation(helpers.context, expectedConfirmation, input));
2356
+ if (!confirmed) {
2357
+ workflowError("destroy", "confirmation_required", `Destroy confirmation required. Re-run with confirm="${expectedConfirmation}".`);
2358
+ }
2359
+ const remoteResult = destroyRemote ? await executeJournalStep(root, workflowRun.runId, "destroy-remote", () => destroyCloudflareResources(tenantRoot, { dryRun: false, force, target })) : null;
2360
+ if (!destroyRemote) {
2361
+ skipJournalStep(root, workflowRun.runId, "destroy-remote", { skippedReason: "destroyRemote=false" });
2362
+ }
2363
+ if (destroyLocal) {
2364
+ await executeJournalStep(root, workflowRun.runId, "cleanup-local", () => {
2365
+ cleanupDestroyedState(tenantRoot, { target, removeBuildArtifacts });
2366
+ return {
2367
+ cleaned: true,
2368
+ removeBuildArtifacts
2369
+ };
2370
+ });
2371
+ } else {
2372
+ skipJournalStep(root, workflowRun.runId, "cleanup-local", { skippedReason: "destroyLocal=false" });
2373
+ }
2374
+ const resultPayload = {
2375
+ ...payload,
2376
+ dryRun: false,
2377
+ remoteResult
2378
+ };
2379
+ completeWorkflowRun(root, workflowRun.runId, resultPayload);
2380
+ return buildWorkflowResult(
2381
+ "destroy",
2382
+ tenantRoot,
2383
+ resultPayload,
2384
+ {
2385
+ runId: workflowRun.runId,
2386
+ nextSteps: createNextSteps([
2387
+ { operation: "config", reason: "Recreate the destroyed environment before using it again.", input: { environment: [scope] } },
2388
+ { operation: "status", reason: "Confirm the environment teardown state and any remaining local runtime setup." }
2389
+ ])
2390
+ }
2391
+ );
2392
+ } catch (error) {
2393
+ failWorkflowRun(root, workflowRun.runId, error, {
2394
+ resumable: false,
2395
+ runId: workflowRun.runId,
2396
+ command: "destroy",
2397
+ message: `Inspect the failed destroy run for ${scope} before retrying manually.`,
2398
+ recoverCommand: "treeseed recover"
2399
+ });
2400
+ throw error;
2401
+ }
937
2402
  });
938
2403
  } catch (error) {
939
2404
  toError("destroy", error);
@@ -946,7 +2411,9 @@ export {
946
2411
  workflowDestroy,
947
2412
  workflowDev,
948
2413
  workflowExport,
2414
+ workflowRecover,
949
2415
  workflowRelease,
2416
+ workflowResume,
950
2417
  workflowSave,
951
2418
  workflowStage,
952
2419
  workflowStatus,