@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.
- package/dist/operations/providers/default.js +2 -0
- package/dist/operations/services/git-workflow.d.ts +47 -3
- package/dist/operations/services/git-workflow.js +125 -19
- package/dist/operations/services/github-automation.d.ts +25 -0
- package/dist/operations/services/github-automation.js +82 -1
- package/dist/operations/services/workspace-save.d.ts +10 -1
- package/dist/operations/services/workspace-save.js +54 -3
- package/dist/operations/services/workspace-tools.d.ts +1 -0
- package/dist/operations/services/workspace-tools.js +20 -5
- package/dist/operations-registry.js +8 -6
- package/dist/operations-types.d.ts +2 -2
- package/dist/scripts/workspace-start-warning.js +2 -2
- package/dist/workflow/operations.d.ts +515 -264
- package/dist/workflow/operations.js +1680 -213
- package/dist/workflow/runs.d.ts +90 -0
- package/dist/workflow/runs.js +242 -0
- package/dist/workflow/session.d.ts +31 -0
- package/dist/workflow/session.js +97 -0
- package/dist/workflow-state.d.ts +34 -0
- package/dist/workflow-state.js +118 -2
- package/dist/workflow.d.ts +64 -3
- package/dist/workflow.js +12 -0
- package/package.json +1 -1
- package/dist/scripts/workspace-close.js +0 -24
- package/dist/scripts/workspace-release.js +0 -42
- package/dist/scripts/workspace-start.js +0 -71
|
@@ -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
|
-
|
|
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 {
|
|
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
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
|
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
|
|
676
|
+
const root = workspaceRoot(tenantRoot);
|
|
677
|
+
const repoDir = gitWorkflowRoot(root);
|
|
369
678
|
const before = resolveRepoState(repoDir);
|
|
370
|
-
|
|
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
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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
|
-
|
|
442
|
-
|
|
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
|
-
|
|
450
|
-
|
|
451
|
-
|
|
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
|
-
|
|
461
|
-
|
|
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
|
|
550
|
-
const
|
|
551
|
-
|
|
552
|
-
|
|
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
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
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
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
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
|
-
|
|
571
|
-
|
|
572
|
-
|
|
1039
|
+
if (mode === "recursive-workspace") {
|
|
1040
|
+
assertWorkspaceClean(root);
|
|
1041
|
+
assertSessionBranchSafety("switch", session);
|
|
1042
|
+
} else {
|
|
1043
|
+
assertCleanWorktree(root);
|
|
573
1044
|
}
|
|
574
|
-
const
|
|
575
|
-
return buildWorkflowResult(
|
|
1045
|
+
const workflowRun = acquireWorkflowRun(
|
|
576
1046
|
"switch",
|
|
577
|
-
|
|
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
|
-
|
|
595
|
-
|
|
596
|
-
|
|
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
|
|
678
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
736
|
-
|
|
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
|
|
754
|
-
const
|
|
755
|
-
|
|
756
|
-
const
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
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
|
-
|
|
1570
|
+
const rootRepo = createWorkspaceRootRepoReport(root);
|
|
1571
|
+
const packageReports = createWorkspacePackageReports(root);
|
|
1572
|
+
const workflowRun = acquireWorkflowRun(
|
|
764
1573
|
"close",
|
|
765
|
-
|
|
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:
|
|
1634
|
+
remoteDeleted: rootRepo.deletedRemote,
|
|
1635
|
+
localDeleted: rootRepo.deletedLocal,
|
|
775
1636
|
finalBranch: currentBranch(repoDir) || STAGING_BRANCH
|
|
776
|
-
}
|
|
777
|
-
|
|
778
|
-
|
|
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
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
1866
|
+
remoteDeleted: rootRepo.deletedRemote,
|
|
1867
|
+
localDeleted: rootRepo.deletedLocal,
|
|
828
1868
|
finalBranch: currentBranch(repoDir) || STAGING_BRANCH
|
|
829
|
-
}
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
880
|
-
|
|
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("
|
|
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 =
|
|
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:
|
|
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
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
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
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
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
|
-
|
|
933
|
-
|
|
934
|
-
|
|
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,
|