@treeseed/sdk 0.4.11 → 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/platform/tenant-config.js +9 -0
- 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
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { TreeseedWorkflowMode } from './session.ts';
|
|
2
|
+
export type TreeseedWorkflowRunCommand = 'switch' | 'save' | 'close' | 'stage' | 'release' | 'destroy';
|
|
3
|
+
export type TreeseedWorkflowExecutionMode = 'execute' | 'plan';
|
|
4
|
+
export type TreeseedWorkflowRunStatus = 'running' | 'failed' | 'completed';
|
|
5
|
+
export type TreeseedWorkflowRunStep = {
|
|
6
|
+
id: string;
|
|
7
|
+
description: string;
|
|
8
|
+
repoName: string | null;
|
|
9
|
+
repoPath: string | null;
|
|
10
|
+
branch: string | null;
|
|
11
|
+
resumable: boolean;
|
|
12
|
+
status: 'pending' | 'completed' | 'skipped';
|
|
13
|
+
completedAt: string | null;
|
|
14
|
+
data: Record<string, unknown> | null;
|
|
15
|
+
};
|
|
16
|
+
export type TreeseedWorkflowRunJournal = {
|
|
17
|
+
schemaVersion: 1;
|
|
18
|
+
kind: 'treeseed.workflow.run';
|
|
19
|
+
runId: string;
|
|
20
|
+
command: TreeseedWorkflowRunCommand;
|
|
21
|
+
executionMode: TreeseedWorkflowExecutionMode;
|
|
22
|
+
status: TreeseedWorkflowRunStatus;
|
|
23
|
+
createdAt: string;
|
|
24
|
+
updatedAt: string;
|
|
25
|
+
resumable: boolean;
|
|
26
|
+
input: Record<string, unknown>;
|
|
27
|
+
session: {
|
|
28
|
+
root: string;
|
|
29
|
+
mode: TreeseedWorkflowMode;
|
|
30
|
+
branchName: string | null;
|
|
31
|
+
repos: Array<{
|
|
32
|
+
name: string;
|
|
33
|
+
path: string;
|
|
34
|
+
branchName: string | null;
|
|
35
|
+
}>;
|
|
36
|
+
};
|
|
37
|
+
steps: TreeseedWorkflowRunStep[];
|
|
38
|
+
failure: null | {
|
|
39
|
+
code: string;
|
|
40
|
+
message: string;
|
|
41
|
+
details: Record<string, unknown> | null;
|
|
42
|
+
at: string;
|
|
43
|
+
};
|
|
44
|
+
result: Record<string, unknown> | null;
|
|
45
|
+
};
|
|
46
|
+
export type TreeseedWorkflowLockRecord = {
|
|
47
|
+
schemaVersion: 1;
|
|
48
|
+
kind: 'treeseed.workflow.lock';
|
|
49
|
+
runId: string;
|
|
50
|
+
command: TreeseedWorkflowRunCommand;
|
|
51
|
+
root: string;
|
|
52
|
+
host: string;
|
|
53
|
+
pid: number | null;
|
|
54
|
+
createdAt: string;
|
|
55
|
+
updatedAt: string;
|
|
56
|
+
stale: boolean;
|
|
57
|
+
staleReason: string | null;
|
|
58
|
+
};
|
|
59
|
+
export type TreeseedWorkflowLockInspection = {
|
|
60
|
+
lock: TreeseedWorkflowLockRecord | null;
|
|
61
|
+
active: boolean;
|
|
62
|
+
stale: boolean;
|
|
63
|
+
staleReason: string | null;
|
|
64
|
+
};
|
|
65
|
+
export declare function generateWorkflowRunId(command: TreeseedWorkflowRunCommand): string;
|
|
66
|
+
export declare function inspectWorkflowLock(root: string): TreeseedWorkflowLockInspection;
|
|
67
|
+
export declare function acquireWorkflowLock(root: string, command: TreeseedWorkflowRunCommand, runId: string): {
|
|
68
|
+
readonly acquired: false;
|
|
69
|
+
readonly lock: TreeseedWorkflowLockRecord;
|
|
70
|
+
readonly replacedStale?: undefined;
|
|
71
|
+
} | {
|
|
72
|
+
readonly acquired: true;
|
|
73
|
+
readonly lock: TreeseedWorkflowLockRecord;
|
|
74
|
+
readonly replacedStale: boolean;
|
|
75
|
+
};
|
|
76
|
+
export declare function refreshWorkflowLock(root: string, runId: string): TreeseedWorkflowLockRecord | null;
|
|
77
|
+
export declare function releaseWorkflowLock(root: string, runId: string): boolean;
|
|
78
|
+
export declare function writeWorkflowRunJournal(root: string, journal: TreeseedWorkflowRunJournal): TreeseedWorkflowRunJournal;
|
|
79
|
+
export declare function readWorkflowRunJournal(root: string, runId: string): TreeseedWorkflowRunJournal | null;
|
|
80
|
+
export declare function updateWorkflowRunJournal(root: string, runId: string, updater: (journal: TreeseedWorkflowRunJournal) => TreeseedWorkflowRunJournal): TreeseedWorkflowRunJournal | null;
|
|
81
|
+
export declare function createWorkflowRunJournal(root: string, options: {
|
|
82
|
+
runId: string;
|
|
83
|
+
command: TreeseedWorkflowRunCommand;
|
|
84
|
+
executionMode?: TreeseedWorkflowExecutionMode;
|
|
85
|
+
input: Record<string, unknown>;
|
|
86
|
+
session: TreeseedWorkflowRunJournal['session'];
|
|
87
|
+
steps: Omit<TreeseedWorkflowRunStep, 'status' | 'completedAt' | 'data'>[];
|
|
88
|
+
}): TreeseedWorkflowRunJournal;
|
|
89
|
+
export declare function listWorkflowRunJournals(root: string): TreeseedWorkflowRunJournal[];
|
|
90
|
+
export declare function listInterruptedWorkflowRuns(root: string): TreeseedWorkflowRunJournal[];
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { hostname } from "node:os";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
const WORKFLOW_CONTROL_DIR = ".treeseed/workflow";
|
|
5
|
+
const WORKFLOW_RUNS_DIR = `${WORKFLOW_CONTROL_DIR}/runs`;
|
|
6
|
+
const LOCK_STALE_AFTER_MS = 4 * 60 * 60 * 1e3;
|
|
7
|
+
function nowIso() {
|
|
8
|
+
return (/* @__PURE__ */ new Date()).toISOString();
|
|
9
|
+
}
|
|
10
|
+
function workflowControlRoot(root) {
|
|
11
|
+
return resolve(root, WORKFLOW_CONTROL_DIR);
|
|
12
|
+
}
|
|
13
|
+
function workflowRunsRoot(root) {
|
|
14
|
+
return resolve(root, WORKFLOW_RUNS_DIR);
|
|
15
|
+
}
|
|
16
|
+
function workflowLockPath(root) {
|
|
17
|
+
return resolve(root, WORKFLOW_CONTROL_DIR, "lock.json");
|
|
18
|
+
}
|
|
19
|
+
function workflowRunPath(root, runId) {
|
|
20
|
+
return resolve(root, WORKFLOW_RUNS_DIR, `${runId}.json`);
|
|
21
|
+
}
|
|
22
|
+
function resolveGitDir(root) {
|
|
23
|
+
const gitPath = resolve(root, ".git");
|
|
24
|
+
if (existsSync(resolve(gitPath, "info"))) {
|
|
25
|
+
return gitPath;
|
|
26
|
+
}
|
|
27
|
+
if (!existsSync(gitPath)) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
const gitFile = readFileSync(gitPath, "utf8").trim();
|
|
32
|
+
const match = gitFile.match(/^gitdir:\s*(.+)$/u);
|
|
33
|
+
return match ? resolve(root, match[1]) : null;
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function ensureWorkflowExcludeRule(root) {
|
|
39
|
+
const gitDir = resolveGitDir(root);
|
|
40
|
+
if (!gitDir) {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const excludePath = resolve(gitDir, "info", "exclude");
|
|
44
|
+
const pattern = "/.treeseed/workflow/";
|
|
45
|
+
const current = existsSync(excludePath) ? readFileSync(excludePath, "utf8") : "";
|
|
46
|
+
if (current.includes(pattern)) {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
writeFileSync(excludePath, `${current}${current.endsWith("\n") || current.length === 0 ? "" : "\n"}${pattern}
|
|
50
|
+
`, "utf8");
|
|
51
|
+
}
|
|
52
|
+
function ensureWorkflowControlDirs(root) {
|
|
53
|
+
const controlDir = workflowControlRoot(root);
|
|
54
|
+
const runsDir = workflowRunsRoot(root);
|
|
55
|
+
mkdirSync(runsDir, { recursive: true });
|
|
56
|
+
ensureWorkflowExcludeRule(root);
|
|
57
|
+
writeFileSync(resolve(controlDir, ".gitignore"), "*\n!.gitignore\n!runs/\nruns/*\n!runs/.gitignore\n", "utf8");
|
|
58
|
+
writeFileSync(resolve(runsDir, ".gitignore"), "*\n!.gitignore\n", "utf8");
|
|
59
|
+
return {
|
|
60
|
+
controlDir,
|
|
61
|
+
runsDir,
|
|
62
|
+
lockPath: workflowLockPath(root)
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
function safeJsonParse(filePath) {
|
|
66
|
+
if (!existsSync(filePath)) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
return JSON.parse(readFileSync(filePath, "utf8"));
|
|
71
|
+
} catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function pidIsAlive(pid) {
|
|
76
|
+
if (!Number.isInteger(pid) || (pid ?? 0) <= 0) {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
process.kill(pid, 0);
|
|
81
|
+
return true;
|
|
82
|
+
} catch {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
function generateWorkflowRunId(command) {
|
|
87
|
+
return `${command}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
88
|
+
}
|
|
89
|
+
function inspectWorkflowLock(root) {
|
|
90
|
+
const lock = safeJsonParse(workflowLockPath(root));
|
|
91
|
+
if (!lock) {
|
|
92
|
+
return {
|
|
93
|
+
lock: null,
|
|
94
|
+
active: false,
|
|
95
|
+
stale: false,
|
|
96
|
+
staleReason: null
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
let staleReason = null;
|
|
100
|
+
if (lock.host === hostname() && lock.pid != null && !pidIsAlive(lock.pid)) {
|
|
101
|
+
staleReason = `process ${lock.pid} is no longer running`;
|
|
102
|
+
} else if (Date.now() - Date.parse(lock.updatedAt) > LOCK_STALE_AFTER_MS) {
|
|
103
|
+
staleReason = "lock heartbeat expired";
|
|
104
|
+
}
|
|
105
|
+
return {
|
|
106
|
+
lock: {
|
|
107
|
+
...lock,
|
|
108
|
+
stale: staleReason != null,
|
|
109
|
+
staleReason
|
|
110
|
+
},
|
|
111
|
+
active: staleReason == null,
|
|
112
|
+
stale: staleReason != null,
|
|
113
|
+
staleReason
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
function acquireWorkflowLock(root, command, runId) {
|
|
117
|
+
const dirs = ensureWorkflowControlDirs(root);
|
|
118
|
+
const inspection = inspectWorkflowLock(root);
|
|
119
|
+
if (inspection.active && inspection.lock && inspection.lock.runId !== runId) {
|
|
120
|
+
return {
|
|
121
|
+
acquired: false,
|
|
122
|
+
lock: inspection.lock
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
if (inspection.stale) {
|
|
126
|
+
rmSync(dirs.lockPath, { force: true });
|
|
127
|
+
}
|
|
128
|
+
const timestamp = nowIso();
|
|
129
|
+
const lock = {
|
|
130
|
+
schemaVersion: 1,
|
|
131
|
+
kind: "treeseed.workflow.lock",
|
|
132
|
+
runId,
|
|
133
|
+
command,
|
|
134
|
+
root,
|
|
135
|
+
host: hostname(),
|
|
136
|
+
pid: process.pid,
|
|
137
|
+
createdAt: inspection.lock?.runId === runId ? inspection.lock.createdAt : timestamp,
|
|
138
|
+
updatedAt: timestamp,
|
|
139
|
+
stale: false,
|
|
140
|
+
staleReason: null
|
|
141
|
+
};
|
|
142
|
+
writeFileSync(dirs.lockPath, `${JSON.stringify(lock, null, 2)}
|
|
143
|
+
`, "utf8");
|
|
144
|
+
return {
|
|
145
|
+
acquired: true,
|
|
146
|
+
lock,
|
|
147
|
+
replacedStale: inspection.stale
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
function refreshWorkflowLock(root, runId) {
|
|
151
|
+
const path = workflowLockPath(root);
|
|
152
|
+
const lock = safeJsonParse(path);
|
|
153
|
+
if (!lock || lock.runId !== runId) {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
const updated = {
|
|
157
|
+
...lock,
|
|
158
|
+
updatedAt: nowIso(),
|
|
159
|
+
stale: false,
|
|
160
|
+
staleReason: null
|
|
161
|
+
};
|
|
162
|
+
writeFileSync(path, `${JSON.stringify(updated, null, 2)}
|
|
163
|
+
`, "utf8");
|
|
164
|
+
return updated;
|
|
165
|
+
}
|
|
166
|
+
function releaseWorkflowLock(root, runId) {
|
|
167
|
+
const path = workflowLockPath(root);
|
|
168
|
+
const lock = safeJsonParse(path);
|
|
169
|
+
if (!lock || lock.runId !== runId) {
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
rmSync(path, { force: true });
|
|
173
|
+
return true;
|
|
174
|
+
}
|
|
175
|
+
function writeWorkflowRunJournal(root, journal) {
|
|
176
|
+
ensureWorkflowControlDirs(root);
|
|
177
|
+
writeFileSync(workflowRunPath(root, journal.runId), `${JSON.stringify(journal, null, 2)}
|
|
178
|
+
`, "utf8");
|
|
179
|
+
return journal;
|
|
180
|
+
}
|
|
181
|
+
function readWorkflowRunJournal(root, runId) {
|
|
182
|
+
return safeJsonParse(workflowRunPath(root, runId));
|
|
183
|
+
}
|
|
184
|
+
function updateWorkflowRunJournal(root, runId, updater) {
|
|
185
|
+
const current = readWorkflowRunJournal(root, runId);
|
|
186
|
+
if (!current) {
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
const updated = updater(current);
|
|
190
|
+
writeWorkflowRunJournal(root, {
|
|
191
|
+
...updated,
|
|
192
|
+
updatedAt: nowIso()
|
|
193
|
+
});
|
|
194
|
+
return readWorkflowRunJournal(root, runId);
|
|
195
|
+
}
|
|
196
|
+
function createWorkflowRunJournal(root, options) {
|
|
197
|
+
const timestamp = nowIso();
|
|
198
|
+
return writeWorkflowRunJournal(root, {
|
|
199
|
+
schemaVersion: 1,
|
|
200
|
+
kind: "treeseed.workflow.run",
|
|
201
|
+
runId: options.runId,
|
|
202
|
+
command: options.command,
|
|
203
|
+
executionMode: options.executionMode ?? "execute",
|
|
204
|
+
status: "running",
|
|
205
|
+
createdAt: timestamp,
|
|
206
|
+
updatedAt: timestamp,
|
|
207
|
+
resumable: options.steps.every((step) => step.resumable),
|
|
208
|
+
input: options.input,
|
|
209
|
+
session: options.session,
|
|
210
|
+
steps: options.steps.map((step) => ({
|
|
211
|
+
...step,
|
|
212
|
+
status: "pending",
|
|
213
|
+
completedAt: null,
|
|
214
|
+
data: null
|
|
215
|
+
})),
|
|
216
|
+
failure: null,
|
|
217
|
+
result: null
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
function listWorkflowRunJournals(root) {
|
|
221
|
+
const runsDir = workflowRunsRoot(root);
|
|
222
|
+
if (!existsSync(runsDir)) {
|
|
223
|
+
return [];
|
|
224
|
+
}
|
|
225
|
+
return readdirSync(runsDir).filter((entry) => entry.endsWith(".json")).map((entry) => safeJsonParse(resolve(runsDir, entry))).filter((entry) => entry != null).sort((left, right) => right.createdAt.localeCompare(left.createdAt));
|
|
226
|
+
}
|
|
227
|
+
function listInterruptedWorkflowRuns(root) {
|
|
228
|
+
return listWorkflowRunJournals(root).filter((journal) => journal.status === "failed" && journal.resumable);
|
|
229
|
+
}
|
|
230
|
+
export {
|
|
231
|
+
acquireWorkflowLock,
|
|
232
|
+
createWorkflowRunJournal,
|
|
233
|
+
generateWorkflowRunId,
|
|
234
|
+
inspectWorkflowLock,
|
|
235
|
+
listInterruptedWorkflowRuns,
|
|
236
|
+
listWorkflowRunJournals,
|
|
237
|
+
readWorkflowRunJournal,
|
|
238
|
+
refreshWorkflowLock,
|
|
239
|
+
releaseWorkflowLock,
|
|
240
|
+
updateWorkflowRunJournal,
|
|
241
|
+
writeWorkflowRunJournal
|
|
242
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type TreeseedWorkflowBranchRole } from './policy.ts';
|
|
2
|
+
export type TreeseedWorkflowMode = 'root-only' | 'recursive-workspace';
|
|
3
|
+
export type TreeseedWorkflowSessionRepo = {
|
|
4
|
+
name: string;
|
|
5
|
+
path: string;
|
|
6
|
+
relativePath: string;
|
|
7
|
+
branchName: string | null;
|
|
8
|
+
branchRole: TreeseedWorkflowBranchRole;
|
|
9
|
+
dirty: boolean;
|
|
10
|
+
detached: boolean;
|
|
11
|
+
hasOriginRemote: boolean;
|
|
12
|
+
};
|
|
13
|
+
export type TreeseedWorkflowPackageSelection = {
|
|
14
|
+
changed: string[];
|
|
15
|
+
dependents: string[];
|
|
16
|
+
selected: string[];
|
|
17
|
+
};
|
|
18
|
+
export type TreeseedWorkflowSession = {
|
|
19
|
+
root: string;
|
|
20
|
+
gitRoot: string;
|
|
21
|
+
mode: TreeseedWorkflowMode;
|
|
22
|
+
branchName: string | null;
|
|
23
|
+
branchRole: TreeseedWorkflowBranchRole;
|
|
24
|
+
rootRepo: TreeseedWorkflowSessionRepo;
|
|
25
|
+
packageRepos: TreeseedWorkflowSessionRepo[];
|
|
26
|
+
packageSelection: TreeseedWorkflowPackageSelection;
|
|
27
|
+
};
|
|
28
|
+
export declare function checkedOutWorkspacePackageRepos(root: string): any[];
|
|
29
|
+
export declare function workflowModeForRoot(root: string): TreeseedWorkflowMode;
|
|
30
|
+
export declare function collectReleasePackageSelection(root: string): TreeseedWorkflowPackageSelection;
|
|
31
|
+
export declare function resolveTreeseedWorkflowSession(cwd: string): TreeseedWorkflowSession;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { relative } from "node:path";
|
|
2
|
+
import {
|
|
3
|
+
currentBranch,
|
|
4
|
+
gitStatusPorcelain,
|
|
5
|
+
originRemoteUrl,
|
|
6
|
+
repoRoot
|
|
7
|
+
} from "../operations/services/workspace-save.js";
|
|
8
|
+
import {
|
|
9
|
+
changedWorkspacePackages,
|
|
10
|
+
hasCompleteTreeseedPackageCheckout,
|
|
11
|
+
publishableWorkspacePackages,
|
|
12
|
+
sortWorkspacePackages,
|
|
13
|
+
workspacePackages,
|
|
14
|
+
workspaceRoot
|
|
15
|
+
} from "../operations/services/workspace-tools.js";
|
|
16
|
+
import {
|
|
17
|
+
classifyTreeseedBranchRole,
|
|
18
|
+
resolveTreeseedWorkflowPaths
|
|
19
|
+
} from "./policy.js";
|
|
20
|
+
function hasOriginRemote(repoDir) {
|
|
21
|
+
try {
|
|
22
|
+
originRemoteUrl(repoDir);
|
|
23
|
+
return true;
|
|
24
|
+
} catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function repoState(root, name, repoDir) {
|
|
29
|
+
const branchName = currentBranch(repoDir) || null;
|
|
30
|
+
return {
|
|
31
|
+
name,
|
|
32
|
+
path: repoDir,
|
|
33
|
+
relativePath: relative(root, repoDir).replaceAll("\\", "/") || ".",
|
|
34
|
+
branchName,
|
|
35
|
+
branchRole: classifyTreeseedBranchRole(branchName, repoDir),
|
|
36
|
+
dirty: gitStatusPorcelain(repoDir).length > 0,
|
|
37
|
+
detached: branchName == null,
|
|
38
|
+
hasOriginRemote: hasOriginRemote(repoDir)
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
function checkedOutWorkspacePackageRepos(root) {
|
|
42
|
+
if (!hasCompleteTreeseedPackageCheckout(root)) {
|
|
43
|
+
return [];
|
|
44
|
+
}
|
|
45
|
+
return sortWorkspacePackages(
|
|
46
|
+
workspacePackages(root).filter((pkg) => pkg.name?.startsWith("@treeseed/"))
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
function workflowModeForRoot(root) {
|
|
50
|
+
return hasCompleteTreeseedPackageCheckout(root) ? "recursive-workspace" : "root-only";
|
|
51
|
+
}
|
|
52
|
+
function collectReleasePackageSelection(root) {
|
|
53
|
+
const publishable = sortWorkspacePackages(
|
|
54
|
+
publishableWorkspacePackages(root).filter((pkg) => pkg.name?.startsWith("@treeseed/"))
|
|
55
|
+
);
|
|
56
|
+
const changed = changedWorkspacePackages({
|
|
57
|
+
root,
|
|
58
|
+
baseRef: "main",
|
|
59
|
+
includeDependents: false,
|
|
60
|
+
packages: publishable
|
|
61
|
+
});
|
|
62
|
+
const selected = changedWorkspacePackages({
|
|
63
|
+
root,
|
|
64
|
+
baseRef: "main",
|
|
65
|
+
includeDependents: true,
|
|
66
|
+
packages: publishable
|
|
67
|
+
});
|
|
68
|
+
const changedNames = changed.map((pkg) => pkg.name);
|
|
69
|
+
return {
|
|
70
|
+
changed: changedNames,
|
|
71
|
+
dependents: selected.filter((pkg) => !changedNames.includes(pkg.name)).map((pkg) => pkg.name),
|
|
72
|
+
selected: selected.map((pkg) => pkg.name)
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
function resolveTreeseedWorkflowSession(cwd) {
|
|
76
|
+
const resolved = resolveTreeseedWorkflowPaths(cwd);
|
|
77
|
+
const root = workspaceRoot(resolved.cwd);
|
|
78
|
+
const gitRoot = repoRoot(root);
|
|
79
|
+
const mode = workflowModeForRoot(root);
|
|
80
|
+
const packageRepos = checkedOutWorkspacePackageRepos(root).map((pkg) => repoState(root, pkg.name, pkg.dir));
|
|
81
|
+
return {
|
|
82
|
+
root,
|
|
83
|
+
gitRoot,
|
|
84
|
+
mode,
|
|
85
|
+
branchName: currentBranch(gitRoot) || null,
|
|
86
|
+
branchRole: classifyTreeseedBranchRole(currentBranch(gitRoot) || null, gitRoot),
|
|
87
|
+
rootRepo: repoState(root, "@treeseed/market", gitRoot),
|
|
88
|
+
packageRepos,
|
|
89
|
+
packageSelection: collectReleasePackageSelection(root)
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
export {
|
|
93
|
+
checkedOutWorkspacePackageRepos,
|
|
94
|
+
collectReleasePackageSelection,
|
|
95
|
+
resolveTreeseedWorkflowSession,
|
|
96
|
+
workflowModeForRoot
|
|
97
|
+
};
|
package/dist/workflow-state.d.ts
CHANGED
|
@@ -12,6 +12,40 @@ export type TreeseedWorkflowState = {
|
|
|
12
12
|
branchRole: TreeseedBranchRole;
|
|
13
13
|
environment: 'local' | 'staging' | 'prod' | 'none';
|
|
14
14
|
dirtyWorktree: boolean;
|
|
15
|
+
workflowControl: {
|
|
16
|
+
lock: {
|
|
17
|
+
active: boolean;
|
|
18
|
+
stale: boolean;
|
|
19
|
+
runId: string | null;
|
|
20
|
+
command: string | null;
|
|
21
|
+
updatedAt: string | null;
|
|
22
|
+
staleReason: string | null;
|
|
23
|
+
};
|
|
24
|
+
interruptedRuns: Array<{
|
|
25
|
+
runId: string;
|
|
26
|
+
command: string;
|
|
27
|
+
updatedAt: string;
|
|
28
|
+
nextStep: string | null;
|
|
29
|
+
}>;
|
|
30
|
+
blockers: string[];
|
|
31
|
+
};
|
|
32
|
+
packageSync: {
|
|
33
|
+
mode: 'root-only' | 'recursive-workspace';
|
|
34
|
+
completeCheckout: boolean;
|
|
35
|
+
expectedBranch: string | null;
|
|
36
|
+
aligned: boolean;
|
|
37
|
+
dirty: boolean;
|
|
38
|
+
repos: Array<{
|
|
39
|
+
name: string;
|
|
40
|
+
path: string;
|
|
41
|
+
branchName: string | null;
|
|
42
|
+
dirty: boolean;
|
|
43
|
+
aligned: boolean;
|
|
44
|
+
localBranch: boolean;
|
|
45
|
+
remoteBranch: boolean;
|
|
46
|
+
}>;
|
|
47
|
+
blockers: string[];
|
|
48
|
+
};
|
|
15
49
|
preview: {
|
|
16
50
|
enabled: boolean;
|
|
17
51
|
url: string | null;
|
package/dist/workflow-state.js
CHANGED
|
@@ -8,8 +8,9 @@ import {
|
|
|
8
8
|
} from "./operations/services/deploy.js";
|
|
9
9
|
import { loadCliDeployConfig } from "./operations/services/runtime-tools.js";
|
|
10
10
|
import { collectCliPreflight } from "./operations/services/workspace-preflight.js";
|
|
11
|
-
import { gitStatusPorcelain } from "./operations/services/workspace-save.js";
|
|
12
|
-
import { isWorkspaceRoot } from "./operations/services/workspace-tools.js";
|
|
11
|
+
import { currentBranch, gitStatusPorcelain } from "./operations/services/workspace-save.js";
|
|
12
|
+
import { hasCompleteTreeseedPackageCheckout, isWorkspaceRoot, run, workspacePackages } from "./operations/services/workspace-tools.js";
|
|
13
|
+
import { inspectWorkflowLock, listInterruptedWorkflowRuns } from "./workflow/runs.js";
|
|
13
14
|
import {
|
|
14
15
|
resolveTreeseedWorkflowPaths,
|
|
15
16
|
workflowEnvironmentForBranchRole
|
|
@@ -48,6 +49,14 @@ function readinessForEnvironment(state, scope) {
|
|
|
48
49
|
warnings
|
|
49
50
|
};
|
|
50
51
|
}
|
|
52
|
+
function knownRemoteTrackingBranchExists(repoDir, branchName) {
|
|
53
|
+
try {
|
|
54
|
+
run("git", ["show-ref", "--verify", "--quiet", `refs/remotes/origin/${branchName}`], { cwd: repoDir, capture: true });
|
|
55
|
+
return true;
|
|
56
|
+
} catch {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
51
60
|
function resolveTreeseedWorkflowState(cwd) {
|
|
52
61
|
const resolved = resolveTreeseedWorkflowPaths(cwd);
|
|
53
62
|
const effectiveCwd = resolved.cwd;
|
|
@@ -58,8 +67,67 @@ function resolveTreeseedWorkflowState(cwd) {
|
|
|
58
67
|
const branchName = resolved.branchName;
|
|
59
68
|
const branchRole = resolved.branchRole;
|
|
60
69
|
const dirtyWorktree = root ? gitStatusPorcelain(root).length > 0 : false;
|
|
70
|
+
const completePackageCheckout = hasCompleteTreeseedPackageCheckout(effectiveCwd);
|
|
71
|
+
const packageSyncRepos = completePackageCheckout ? workspacePackages(effectiveCwd).filter((pkg) => pkg.name?.startsWith("@treeseed/")).map((pkg) => {
|
|
72
|
+
const repoBranch = currentBranch(pkg.dir) || null;
|
|
73
|
+
const dirty = gitStatusPorcelain(pkg.dir).length > 0;
|
|
74
|
+
const expectedBranch = branchName;
|
|
75
|
+
let localBranch = false;
|
|
76
|
+
if (expectedBranch) {
|
|
77
|
+
if (repoBranch === expectedBranch) {
|
|
78
|
+
localBranch = true;
|
|
79
|
+
} else {
|
|
80
|
+
try {
|
|
81
|
+
run("git", ["show-ref", "--verify", "--quiet", `refs/heads/${expectedBranch}`], { cwd: pkg.dir, capture: true });
|
|
82
|
+
localBranch = true;
|
|
83
|
+
} catch {
|
|
84
|
+
localBranch = false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const remoteBranch = Boolean(expectedBranch) ? knownRemoteTrackingBranchExists(pkg.dir, expectedBranch) : false;
|
|
89
|
+
return {
|
|
90
|
+
name: pkg.name,
|
|
91
|
+
path: pkg.relativeDir,
|
|
92
|
+
branchName: repoBranch,
|
|
93
|
+
dirty,
|
|
94
|
+
aligned: expectedBranch ? repoBranch === expectedBranch : true,
|
|
95
|
+
localBranch,
|
|
96
|
+
remoteBranch
|
|
97
|
+
};
|
|
98
|
+
}) : [];
|
|
99
|
+
const packageSyncBlockers = [];
|
|
100
|
+
for (const repo of packageSyncRepos) {
|
|
101
|
+
if (repo.dirty) {
|
|
102
|
+
packageSyncBlockers.push(`${repo.name} has uncommitted changes.`);
|
|
103
|
+
}
|
|
104
|
+
if (branchName && !repo.localBranch && !repo.remoteBranch) {
|
|
105
|
+
packageSyncBlockers.push(`${repo.name} is missing branch ${branchName}.`);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (branchName && !repo.aligned) {
|
|
109
|
+
packageSyncBlockers.push(`${repo.name} is on ${repo.branchName ?? "(detached)"} instead of ${branchName}.`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
61
112
|
const preflight = collectCliPreflight({ cwd: effectiveCwd, requireAuth: false });
|
|
62
113
|
const { configPath, keyPath } = getTreeseedMachineConfigPaths(effectiveCwd);
|
|
114
|
+
const workflowLock = inspectWorkflowLock(effectiveCwd);
|
|
115
|
+
const interruptedRuns = listInterruptedWorkflowRuns(effectiveCwd).map((journal) => ({
|
|
116
|
+
runId: journal.runId,
|
|
117
|
+
command: journal.command,
|
|
118
|
+
updatedAt: journal.updatedAt,
|
|
119
|
+
nextStep: journal.steps.find((step) => step.status === "pending")?.description ?? null
|
|
120
|
+
}));
|
|
121
|
+
const workflowBlockers = [];
|
|
122
|
+
if (workflowLock.active && workflowLock.lock) {
|
|
123
|
+
workflowBlockers.push(`Workflow lock active for ${workflowLock.lock.command} (${workflowLock.lock.runId}).`);
|
|
124
|
+
}
|
|
125
|
+
if (workflowLock.stale && workflowLock.lock) {
|
|
126
|
+
workflowBlockers.push(`Workflow lock is stale: ${workflowLock.staleReason}.`);
|
|
127
|
+
}
|
|
128
|
+
if (interruptedRuns.length > 0) {
|
|
129
|
+
workflowBlockers.push(`Interrupted workflow runs detected: ${interruptedRuns.map((run2) => run2.runId).join(", ")}.`);
|
|
130
|
+
}
|
|
63
131
|
const state = {
|
|
64
132
|
cwd: effectiveCwd,
|
|
65
133
|
workspaceRoot,
|
|
@@ -70,6 +138,27 @@ function resolveTreeseedWorkflowState(cwd) {
|
|
|
70
138
|
branchRole,
|
|
71
139
|
environment: workflowEnvironmentForBranchRole(branchRole),
|
|
72
140
|
dirtyWorktree,
|
|
141
|
+
workflowControl: {
|
|
142
|
+
lock: {
|
|
143
|
+
active: workflowLock.active,
|
|
144
|
+
stale: workflowLock.stale,
|
|
145
|
+
runId: workflowLock.lock?.runId ?? null,
|
|
146
|
+
command: workflowLock.lock?.command ?? null,
|
|
147
|
+
updatedAt: workflowLock.lock?.updatedAt ?? null,
|
|
148
|
+
staleReason: workflowLock.staleReason
|
|
149
|
+
},
|
|
150
|
+
interruptedRuns,
|
|
151
|
+
blockers: workflowBlockers
|
|
152
|
+
},
|
|
153
|
+
packageSync: {
|
|
154
|
+
mode: completePackageCheckout ? "recursive-workspace" : "root-only",
|
|
155
|
+
completeCheckout: completePackageCheckout,
|
|
156
|
+
expectedBranch: branchName,
|
|
157
|
+
aligned: packageSyncRepos.every((repo) => repo.aligned),
|
|
158
|
+
dirty: packageSyncRepos.some((repo) => repo.dirty),
|
|
159
|
+
repos: packageSyncRepos,
|
|
160
|
+
blockers: packageSyncBlockers
|
|
161
|
+
},
|
|
73
162
|
preview: {
|
|
74
163
|
enabled: false,
|
|
75
164
|
url: null,
|
|
@@ -171,7 +260,27 @@ function recommendTreeseedNextSteps(state) {
|
|
|
171
260
|
recommendations.push({ operation: "config", reason: "Bootstrap the local machine config and local environment files." });
|
|
172
261
|
return recommendations;
|
|
173
262
|
}
|
|
263
|
+
if (state.workflowControl.interruptedRuns.length > 0) {
|
|
264
|
+
recommendations.push({
|
|
265
|
+
operation: "resume",
|
|
266
|
+
reason: "Resume the most recent interrupted workflow run before making new branch changes.",
|
|
267
|
+
input: { runId: state.workflowControl.interruptedRuns[0].runId }
|
|
268
|
+
});
|
|
269
|
+
recommendations.push({ operation: "recover", reason: "Inspect active workflow locks and interrupted runs." });
|
|
270
|
+
return recommendations.slice(0, 3);
|
|
271
|
+
}
|
|
272
|
+
if (state.workflowControl.lock.active && state.workflowControl.lock.runId) {
|
|
273
|
+
recommendations.push({ operation: "recover", reason: "Inspect the active workflow lock before starting another mutating command." });
|
|
274
|
+
return recommendations.slice(0, 3);
|
|
275
|
+
}
|
|
174
276
|
if (state.branchRole === "feature") {
|
|
277
|
+
if (state.packageSync.mode === "recursive-workspace" && state.packageSync.blockers.length > 0 && state.branchName) {
|
|
278
|
+
recommendations.push({
|
|
279
|
+
operation: "switch",
|
|
280
|
+
reason: "Realign the checked-out package repos to the current task branch before continuing.",
|
|
281
|
+
input: { branch: state.branchName }
|
|
282
|
+
});
|
|
283
|
+
}
|
|
175
284
|
recommendations.push({ operation: "stage", reason: "Merge this task branch into staging and clean up branch artifacts.", input: { message: "describe the resolution" } });
|
|
176
285
|
recommendations.push({ operation: "save", reason: "Persist, verify, and push the current task branch before or independently of staging it.", input: { message: "describe your change" } });
|
|
177
286
|
if (state.preview.enabled && state.branchName) {
|
|
@@ -183,6 +292,13 @@ function recommendTreeseedNextSteps(state) {
|
|
|
183
292
|
return recommendations.slice(0, 3);
|
|
184
293
|
}
|
|
185
294
|
if (state.branchRole === "staging") {
|
|
295
|
+
if (state.packageSync.mode === "recursive-workspace" && state.packageSync.blockers.length > 0 && state.branchName) {
|
|
296
|
+
recommendations.push({
|
|
297
|
+
operation: "switch",
|
|
298
|
+
reason: "Realign the checked-out package repos to staging before releasing.",
|
|
299
|
+
input: { branch: state.branchName }
|
|
300
|
+
});
|
|
301
|
+
}
|
|
186
302
|
if (!state.persistentEnvironments.staging.initialized) {
|
|
187
303
|
recommendations.push({ operation: "config", reason: "Initialize the staging environment before releasing.", input: { environment: ["staging"] } });
|
|
188
304
|
} else {
|