@treeseed/sdk 0.6.9 → 0.6.11
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/index.d.ts +1 -1
- package/dist/index.js +2 -0
- package/dist/managed-dependencies.d.ts +23 -0
- package/dist/managed-dependencies.js +133 -12
- package/dist/operations/providers/default.js +35 -0
- package/dist/operations/services/config-runtime.js +16 -3
- package/dist/operations/services/deploy.js +14 -1
- package/dist/operations/services/export-runtime.js +4 -0
- package/dist/operations/services/git-workflow.d.ts +2 -0
- package/dist/operations/services/git-workflow.js +39 -4
- package/dist/operations/services/github-api.d.ts +10 -0
- package/dist/operations/services/github-api.js +20 -1
- package/dist/operations/services/github-automation.d.ts +3 -0
- package/dist/operations/services/repository-save-orchestrator.js +10 -4
- package/dist/operations/services/workspace-dependency-mode.js +10 -18
- package/dist/operations-registry.js +1 -0
- package/dist/scripts/patch-starlight-content-path.js +2 -1
- package/dist/workflow/operations.d.ts +259 -429
- package/dist/workflow/operations.js +687 -78
- package/dist/workflow/runs.d.ts +38 -0
- package/dist/workflow/runs.js +182 -15
- package/dist/workflow/worktrees.d.ts +39 -0
- package/dist/workflow/worktrees.js +224 -0
- package/dist/workflow-state.d.ts +13 -0
- package/dist/workflow-state.js +35 -2
- package/dist/workflow-support.d.ts +1 -1
- package/dist/workflow-support.js +2 -0
- package/dist/workflow.d.ts +14 -1
- package/package.json +1 -1
- package/templates/github/deploy.workflow.yml +100 -5
package/dist/workflow/runs.d.ts
CHANGED
|
@@ -2,6 +2,25 @@ import type { TreeseedWorkflowMode } from './session.ts';
|
|
|
2
2
|
export type TreeseedWorkflowRunCommand = 'switch' | 'save' | 'close' | 'stage' | 'release' | 'destroy';
|
|
3
3
|
export type TreeseedWorkflowExecutionMode = 'execute' | 'plan';
|
|
4
4
|
export type TreeseedWorkflowRunStatus = 'running' | 'failed' | 'completed';
|
|
5
|
+
export type TreeseedWorkflowRunClassificationState = 'resumable' | 'stale' | 'obsolete';
|
|
6
|
+
export type TreeseedWorkflowRunClassification = {
|
|
7
|
+
state: TreeseedWorkflowRunClassificationState;
|
|
8
|
+
reasons: string[];
|
|
9
|
+
classifiedAt: string;
|
|
10
|
+
archivedAt?: string | null;
|
|
11
|
+
};
|
|
12
|
+
export type TreeseedWorkflowGateCacheEntry = {
|
|
13
|
+
repo: string | null;
|
|
14
|
+
workflow: string;
|
|
15
|
+
headSha: string;
|
|
16
|
+
branch: string | null;
|
|
17
|
+
status: string;
|
|
18
|
+
conclusion: string | null;
|
|
19
|
+
runId: string | number | null;
|
|
20
|
+
url: string | null;
|
|
21
|
+
cachedAt: string;
|
|
22
|
+
result: Record<string, unknown>;
|
|
23
|
+
};
|
|
5
24
|
export type TreeseedWorkflowRunStep = {
|
|
6
25
|
id: string;
|
|
7
26
|
description: string;
|
|
@@ -42,6 +61,8 @@ export type TreeseedWorkflowRunJournal = {
|
|
|
42
61
|
at: string;
|
|
43
62
|
};
|
|
44
63
|
result: Record<string, unknown> | null;
|
|
64
|
+
classification?: TreeseedWorkflowRunClassification | null;
|
|
65
|
+
gateCache?: TreeseedWorkflowGateCacheEntry[];
|
|
45
66
|
};
|
|
46
67
|
export type TreeseedWorkflowLockRecord = {
|
|
47
68
|
schemaVersion: 1;
|
|
@@ -78,6 +99,23 @@ export declare function releaseWorkflowLock(root: string, runId: string): boolea
|
|
|
78
99
|
export declare function writeWorkflowRunJournal(root: string, journal: TreeseedWorkflowRunJournal): TreeseedWorkflowRunJournal;
|
|
79
100
|
export declare function readWorkflowRunJournal(root: string, runId: string): TreeseedWorkflowRunJournal | null;
|
|
80
101
|
export declare function updateWorkflowRunJournal(root: string, runId: string, updater: (journal: TreeseedWorkflowRunJournal) => TreeseedWorkflowRunJournal): TreeseedWorkflowRunJournal | null;
|
|
102
|
+
export declare function classifyWorkflowRunJournal(journal: TreeseedWorkflowRunJournal, options?: {
|
|
103
|
+
currentBranch?: string | null;
|
|
104
|
+
currentHeads?: Record<string, string | null | undefined>;
|
|
105
|
+
now?: string;
|
|
106
|
+
}): TreeseedWorkflowRunClassification;
|
|
107
|
+
export declare function classifyWorkflowRunJournals(root: string, options?: Parameters<typeof classifyWorkflowRunJournal>[1]): {
|
|
108
|
+
journal: TreeseedWorkflowRunJournal;
|
|
109
|
+
classification: TreeseedWorkflowRunClassification;
|
|
110
|
+
}[];
|
|
111
|
+
export declare function archiveWorkflowRun(root: string, runId: string, classification: TreeseedWorkflowRunClassification): TreeseedWorkflowRunJournal | null;
|
|
112
|
+
export declare function getCachedSuccessfulWorkflowGate(root: string, runId: string, gate: {
|
|
113
|
+
repository?: string | null;
|
|
114
|
+
workflow: string;
|
|
115
|
+
headSha: string;
|
|
116
|
+
branch?: string | null;
|
|
117
|
+
}): TreeseedWorkflowGateCacheEntry | null;
|
|
118
|
+
export declare function cacheWorkflowGateResult(root: string, runId: string, result: Record<string, unknown>): TreeseedWorkflowGateCacheEntry | null;
|
|
81
119
|
export declare function createWorkflowRunJournal(root: string, options: {
|
|
82
120
|
runId: string;
|
|
83
121
|
command: TreeseedWorkflowRunCommand;
|
package/dist/workflow/runs.js
CHANGED
|
@@ -1,23 +1,45 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { hostname } from "node:os";
|
|
3
|
-
import { resolve } from "node:path";
|
|
3
|
+
import { dirname, resolve } from "node:path";
|
|
4
4
|
const WORKFLOW_CONTROL_DIR = ".treeseed/workflow";
|
|
5
5
|
const WORKFLOW_RUNS_DIR = `${WORKFLOW_CONTROL_DIR}/runs`;
|
|
6
|
+
const WORKTREE_METADATA_PATH = ".treeseed/worktree.json";
|
|
6
7
|
const LOCK_STALE_AFTER_MS = 4 * 60 * 60 * 1e3;
|
|
8
|
+
const WORKFLOW_RUN_STORAGE_ROOTS = /* @__PURE__ */ new Map();
|
|
7
9
|
function nowIso() {
|
|
8
10
|
return (/* @__PURE__ */ new Date()).toISOString();
|
|
9
11
|
}
|
|
10
|
-
function
|
|
11
|
-
|
|
12
|
+
function managedWorktreePrimaryRoot(root) {
|
|
13
|
+
const metadataPath = resolve(root, WORKTREE_METADATA_PATH);
|
|
14
|
+
if (!existsSync(metadataPath)) return null;
|
|
15
|
+
try {
|
|
16
|
+
const metadata = JSON.parse(readFileSync(metadataPath, "utf8"));
|
|
17
|
+
return metadata.kind === "treeseed.workflow.worktree" && typeof metadata.primaryRoot === "string" ? metadata.primaryRoot : null;
|
|
18
|
+
} catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function workflowStorageRoot(root, runId) {
|
|
23
|
+
if (runId && WORKFLOW_RUN_STORAGE_ROOTS.has(runId)) {
|
|
24
|
+
return WORKFLOW_RUN_STORAGE_ROOTS.get(runId);
|
|
25
|
+
}
|
|
26
|
+
const storageRoot = managedWorktreePrimaryRoot(root) ?? root;
|
|
27
|
+
if (runId) {
|
|
28
|
+
WORKFLOW_RUN_STORAGE_ROOTS.set(runId, storageRoot);
|
|
29
|
+
}
|
|
30
|
+
return storageRoot;
|
|
31
|
+
}
|
|
32
|
+
function workflowControlRoot(root, runId) {
|
|
33
|
+
return resolve(workflowStorageRoot(root, runId), WORKFLOW_CONTROL_DIR);
|
|
12
34
|
}
|
|
13
|
-
function workflowRunsRoot(root) {
|
|
14
|
-
return resolve(root, WORKFLOW_RUNS_DIR);
|
|
35
|
+
function workflowRunsRoot(root, runId) {
|
|
36
|
+
return resolve(workflowStorageRoot(root, runId), WORKFLOW_RUNS_DIR);
|
|
15
37
|
}
|
|
16
|
-
function workflowLockPath(root) {
|
|
17
|
-
return resolve(root, WORKFLOW_CONTROL_DIR, "lock.json");
|
|
38
|
+
function workflowLockPath(root, runId) {
|
|
39
|
+
return resolve(workflowStorageRoot(root, runId), WORKFLOW_CONTROL_DIR, "lock.json");
|
|
18
40
|
}
|
|
19
41
|
function workflowRunPath(root, runId) {
|
|
20
|
-
return resolve(root, WORKFLOW_RUNS_DIR, `${runId}.json`);
|
|
42
|
+
return resolve(workflowStorageRoot(root, runId), WORKFLOW_RUNS_DIR, `${runId}.json`);
|
|
21
43
|
}
|
|
22
44
|
function resolveGitDir(root) {
|
|
23
45
|
const gitPath = resolve(root, ".git");
|
|
@@ -46,12 +68,13 @@ function ensureWorkflowExcludeRule(root) {
|
|
|
46
68
|
if (current.includes(pattern)) {
|
|
47
69
|
return;
|
|
48
70
|
}
|
|
71
|
+
mkdirSync(dirname(excludePath), { recursive: true });
|
|
49
72
|
writeFileSync(excludePath, `${current}${current.endsWith("\n") || current.length === 0 ? "" : "\n"}${pattern}
|
|
50
73
|
`, "utf8");
|
|
51
74
|
}
|
|
52
|
-
function ensureWorkflowControlDirs(root) {
|
|
53
|
-
const controlDir = workflowControlRoot(root);
|
|
54
|
-
const runsDir = workflowRunsRoot(root);
|
|
75
|
+
function ensureWorkflowControlDirs(root, runId) {
|
|
76
|
+
const controlDir = workflowControlRoot(root, runId);
|
|
77
|
+
const runsDir = workflowRunsRoot(root, runId);
|
|
55
78
|
mkdirSync(runsDir, { recursive: true });
|
|
56
79
|
ensureWorkflowExcludeRule(root);
|
|
57
80
|
writeFileSync(resolve(controlDir, ".gitignore"), "*\n!.gitignore\n!runs/\nruns/*\n!runs/.gitignore\n", "utf8");
|
|
@@ -114,7 +137,7 @@ function inspectWorkflowLock(root) {
|
|
|
114
137
|
};
|
|
115
138
|
}
|
|
116
139
|
function acquireWorkflowLock(root, command, runId) {
|
|
117
|
-
const dirs = ensureWorkflowControlDirs(root);
|
|
140
|
+
const dirs = ensureWorkflowControlDirs(root, runId);
|
|
118
141
|
const inspection = inspectWorkflowLock(root);
|
|
119
142
|
if (inspection.active && inspection.lock && inspection.lock.runId !== runId) {
|
|
120
143
|
return {
|
|
@@ -148,7 +171,7 @@ function acquireWorkflowLock(root, command, runId) {
|
|
|
148
171
|
};
|
|
149
172
|
}
|
|
150
173
|
function refreshWorkflowLock(root, runId) {
|
|
151
|
-
const path = workflowLockPath(root);
|
|
174
|
+
const path = workflowLockPath(root, runId);
|
|
152
175
|
const lock = safeJsonParse(path);
|
|
153
176
|
if (!lock || lock.runId !== runId) {
|
|
154
177
|
return null;
|
|
@@ -164,7 +187,7 @@ function refreshWorkflowLock(root, runId) {
|
|
|
164
187
|
return updated;
|
|
165
188
|
}
|
|
166
189
|
function releaseWorkflowLock(root, runId) {
|
|
167
|
-
const path = workflowLockPath(root);
|
|
190
|
+
const path = workflowLockPath(root, runId);
|
|
168
191
|
const lock = safeJsonParse(path);
|
|
169
192
|
if (!lock || lock.runId !== runId) {
|
|
170
193
|
return false;
|
|
@@ -173,7 +196,7 @@ function releaseWorkflowLock(root, runId) {
|
|
|
173
196
|
return true;
|
|
174
197
|
}
|
|
175
198
|
function writeWorkflowRunJournal(root, journal) {
|
|
176
|
-
ensureWorkflowControlDirs(root);
|
|
199
|
+
ensureWorkflowControlDirs(root, journal.runId);
|
|
177
200
|
writeFileSync(workflowRunPath(root, journal.runId), `${JSON.stringify(journal, null, 2)}
|
|
178
201
|
`, "utf8");
|
|
179
202
|
return journal;
|
|
@@ -193,6 +216,145 @@ function updateWorkflowRunJournal(root, runId, updater) {
|
|
|
193
216
|
});
|
|
194
217
|
return readWorkflowRunJournal(root, runId);
|
|
195
218
|
}
|
|
219
|
+
function stringRecord(value) {
|
|
220
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
221
|
+
}
|
|
222
|
+
function journalReleasePlanHead(plan, repoName) {
|
|
223
|
+
if (repoName === "@treeseed/market") {
|
|
224
|
+
const rootRepo = stringRecord(plan.rootRepo);
|
|
225
|
+
return typeof rootRepo?.commitSha === "string" ? rootRepo.commitSha : null;
|
|
226
|
+
}
|
|
227
|
+
const repos = Array.isArray(plan.repos) ? plan.repos : [];
|
|
228
|
+
for (const repo of repos) {
|
|
229
|
+
const record = stringRecord(repo);
|
|
230
|
+
if (record?.name === repoName) {
|
|
231
|
+
return typeof record.commitSha === "string" ? record.commitSha : null;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
function selectedReleasePackageNames(plan) {
|
|
237
|
+
const selection = stringRecord(plan.packageSelection);
|
|
238
|
+
const selected = Array.isArray(selection?.selected) ? selection.selected.filter((name) => typeof name === "string") : [];
|
|
239
|
+
return selected;
|
|
240
|
+
}
|
|
241
|
+
function classifyWorkflowRunJournal(journal, options = {}) {
|
|
242
|
+
const reasons = [];
|
|
243
|
+
const now = options.now ?? nowIso();
|
|
244
|
+
if (journal.classification?.archivedAt) {
|
|
245
|
+
return {
|
|
246
|
+
state: "obsolete",
|
|
247
|
+
reasons: journal.classification.reasons.length > 0 ? journal.classification.reasons : ["workflow run was archived"],
|
|
248
|
+
classifiedAt: now,
|
|
249
|
+
archivedAt: journal.classification.archivedAt
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
if (journal.status !== "failed") {
|
|
253
|
+
return {
|
|
254
|
+
state: "obsolete",
|
|
255
|
+
reasons: [`workflow run status is ${journal.status}`],
|
|
256
|
+
classifiedAt: now
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
if (!journal.resumable) {
|
|
260
|
+
return {
|
|
261
|
+
state: "obsolete",
|
|
262
|
+
reasons: ["workflow run is not marked resumable"],
|
|
263
|
+
classifiedAt: now
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
if (journal.command === "switch" && journal.steps.every((step) => step.status === "pending")) {
|
|
267
|
+
return {
|
|
268
|
+
state: "obsolete",
|
|
269
|
+
reasons: ["switch failed before completing any checkout step; rerun switch instead"],
|
|
270
|
+
classifiedAt: now
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
if (options.currentBranch && journal.session.branchName && options.currentBranch !== journal.session.branchName) {
|
|
274
|
+
reasons.push(`current branch ${options.currentBranch} does not match journal branch ${journal.session.branchName}`);
|
|
275
|
+
}
|
|
276
|
+
if (journal.command === "release" && options.currentHeads) {
|
|
277
|
+
const releasePlan = stringRecord(journal.steps.find((step) => step.id === "release-plan")?.data);
|
|
278
|
+
if (releasePlan) {
|
|
279
|
+
const rootHead = options.currentHeads["@treeseed/market"];
|
|
280
|
+
const plannedRootHead = journalReleasePlanHead(releasePlan, "@treeseed/market");
|
|
281
|
+
if (rootHead && plannedRootHead && rootHead !== plannedRootHead) {
|
|
282
|
+
reasons.push(`market head changed from ${plannedRootHead} to ${rootHead}`);
|
|
283
|
+
}
|
|
284
|
+
for (const name of selectedReleasePackageNames(releasePlan)) {
|
|
285
|
+
const currentHead = options.currentHeads[name];
|
|
286
|
+
const plannedHead = journalReleasePlanHead(releasePlan, name);
|
|
287
|
+
if (currentHead && plannedHead && currentHead !== plannedHead) {
|
|
288
|
+
reasons.push(`${name} head changed from ${plannedHead} to ${currentHead}`);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return {
|
|
294
|
+
state: reasons.length > 0 ? "stale" : "resumable",
|
|
295
|
+
reasons: reasons.length > 0 ? reasons : ["workflow run can be resumed"],
|
|
296
|
+
classifiedAt: now
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
function classifyWorkflowRunJournals(root, options = {}) {
|
|
300
|
+
return listWorkflowRunJournals(root).map((journal) => ({
|
|
301
|
+
journal,
|
|
302
|
+
classification: classifyWorkflowRunJournal(journal, options)
|
|
303
|
+
}));
|
|
304
|
+
}
|
|
305
|
+
function archiveWorkflowRun(root, runId, classification) {
|
|
306
|
+
const archivedAt = nowIso();
|
|
307
|
+
return updateWorkflowRunJournal(root, runId, (journal) => ({
|
|
308
|
+
...journal,
|
|
309
|
+
resumable: false,
|
|
310
|
+
classification: {
|
|
311
|
+
...classification,
|
|
312
|
+
state: classification.state === "resumable" ? "stale" : classification.state,
|
|
313
|
+
archivedAt,
|
|
314
|
+
classifiedAt: archivedAt
|
|
315
|
+
}
|
|
316
|
+
}));
|
|
317
|
+
}
|
|
318
|
+
function gateCacheMatches(entry, gate) {
|
|
319
|
+
return entry.workflow === gate.workflow && entry.headSha === gate.headSha && (gate.repository == null || entry.repo === gate.repository) && (gate.branch == null || entry.branch === gate.branch);
|
|
320
|
+
}
|
|
321
|
+
function getCachedSuccessfulWorkflowGate(root, runId, gate) {
|
|
322
|
+
const journal = readWorkflowRunJournal(root, runId);
|
|
323
|
+
const cache = journal?.gateCache ?? [];
|
|
324
|
+
return cache.find((entry) => gateCacheMatches(entry, gate) && entry.status === "completed" && entry.conclusion === "success") ?? null;
|
|
325
|
+
}
|
|
326
|
+
function cacheWorkflowGateResult(root, runId, result) {
|
|
327
|
+
const workflow = typeof result.workflow === "string" ? result.workflow : null;
|
|
328
|
+
const headSha = typeof result.headSha === "string" ? result.headSha : null;
|
|
329
|
+
if (!workflow || !headSha) {
|
|
330
|
+
return null;
|
|
331
|
+
}
|
|
332
|
+
const entry = {
|
|
333
|
+
repo: typeof result.repository === "string" ? result.repository : null,
|
|
334
|
+
workflow,
|
|
335
|
+
headSha,
|
|
336
|
+
branch: typeof result.branch === "string" ? result.branch : null,
|
|
337
|
+
status: typeof result.status === "string" ? result.status : "unknown",
|
|
338
|
+
conclusion: typeof result.conclusion === "string" ? result.conclusion : null,
|
|
339
|
+
runId: typeof result.runId === "string" || typeof result.runId === "number" ? result.runId : null,
|
|
340
|
+
url: typeof result.url === "string" ? result.url : null,
|
|
341
|
+
cachedAt: nowIso(),
|
|
342
|
+
result
|
|
343
|
+
};
|
|
344
|
+
updateWorkflowRunJournal(root, runId, (journal) => ({
|
|
345
|
+
...journal,
|
|
346
|
+
gateCache: [
|
|
347
|
+
...(journal.gateCache ?? []).filter((candidate) => !gateCacheMatches(candidate, {
|
|
348
|
+
repository: entry.repo,
|
|
349
|
+
workflow: entry.workflow,
|
|
350
|
+
headSha: entry.headSha,
|
|
351
|
+
branch: entry.branch
|
|
352
|
+
})),
|
|
353
|
+
entry
|
|
354
|
+
]
|
|
355
|
+
}));
|
|
356
|
+
return entry;
|
|
357
|
+
}
|
|
196
358
|
function createWorkflowRunJournal(root, options) {
|
|
197
359
|
const timestamp = nowIso();
|
|
198
360
|
return writeWorkflowRunJournal(root, {
|
|
@@ -229,8 +391,13 @@ function listInterruptedWorkflowRuns(root) {
|
|
|
229
391
|
}
|
|
230
392
|
export {
|
|
231
393
|
acquireWorkflowLock,
|
|
394
|
+
archiveWorkflowRun,
|
|
395
|
+
cacheWorkflowGateResult,
|
|
396
|
+
classifyWorkflowRunJournal,
|
|
397
|
+
classifyWorkflowRunJournals,
|
|
232
398
|
createWorkflowRunJournal,
|
|
233
399
|
generateWorkflowRunId,
|
|
400
|
+
getCachedSuccessfulWorkflowGate,
|
|
234
401
|
inspectWorkflowLock,
|
|
235
402
|
listInterruptedWorkflowRuns,
|
|
236
403
|
listWorkflowRunJournals,
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { TreeseedWorkflowWorktreeMode } from '../workflow.ts';
|
|
2
|
+
export type ManagedWorkflowWorktreeMetadata = {
|
|
3
|
+
schemaVersion: 1;
|
|
4
|
+
kind: 'treeseed.workflow.worktree';
|
|
5
|
+
branch: string;
|
|
6
|
+
worktreePath: string;
|
|
7
|
+
primaryRoot: string;
|
|
8
|
+
ownerMode: 'agent' | 'human' | 'explicit';
|
|
9
|
+
createdAt: string;
|
|
10
|
+
lastUsedAt: string;
|
|
11
|
+
};
|
|
12
|
+
export type ManagedWorkflowWorktreeResult = ManagedWorkflowWorktreeMetadata & {
|
|
13
|
+
created: boolean;
|
|
14
|
+
resumed: boolean;
|
|
15
|
+
};
|
|
16
|
+
export declare function effectiveWorkflowWorktreeMode(mode: TreeseedWorkflowWorktreeMode | undefined, env?: NodeJS.ProcessEnv | Record<string, string | undefined>): "off" | "on";
|
|
17
|
+
export declare function isManagedWorkflowWorktree(root: string): boolean;
|
|
18
|
+
export declare function managedWorkflowWorktreeMetadata(root: string): ManagedWorkflowWorktreeMetadata | null;
|
|
19
|
+
export declare function plannedManagedWorkflowWorktreePath(root: string, branchName: string): string;
|
|
20
|
+
export declare function primaryWorkspaceRoot(root: string): any;
|
|
21
|
+
export declare function ensureManagedWorkflowWorktree({ root, branchName, mode, env, }: {
|
|
22
|
+
root: string;
|
|
23
|
+
branchName: string;
|
|
24
|
+
mode?: TreeseedWorkflowWorktreeMode;
|
|
25
|
+
env?: NodeJS.ProcessEnv | Record<string, string | undefined>;
|
|
26
|
+
}): ManagedWorkflowWorktreeResult;
|
|
27
|
+
export declare function removeManagedWorkflowWorktree(root: string): {
|
|
28
|
+
removed: boolean;
|
|
29
|
+
reason: string;
|
|
30
|
+
worktreePath?: undefined;
|
|
31
|
+
primaryRoot?: undefined;
|
|
32
|
+
branch?: undefined;
|
|
33
|
+
} | {
|
|
34
|
+
removed: boolean;
|
|
35
|
+
worktreePath: string;
|
|
36
|
+
primaryRoot: string;
|
|
37
|
+
branch: string;
|
|
38
|
+
reason?: undefined;
|
|
39
|
+
};
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, resolve } from "node:path";
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
import { sortWorkspacePackages, workspacePackages, workspaceRoot } from "../operations/services/workspace-tools.js";
|
|
6
|
+
import { repoRoot } from "../operations/services/workspace-save.js";
|
|
7
|
+
const WORKTREE_METADATA_PATH = ".treeseed/worktree.json";
|
|
8
|
+
const WORKTREE_ROOT = ".treeseed/worktrees";
|
|
9
|
+
function nowIso() {
|
|
10
|
+
return (/* @__PURE__ */ new Date()).toISOString();
|
|
11
|
+
}
|
|
12
|
+
function runGit(args, { cwd, capture = true, allowFailure = false }) {
|
|
13
|
+
const result = spawnSync("git", args, {
|
|
14
|
+
cwd,
|
|
15
|
+
stdio: capture ? "pipe" : "inherit",
|
|
16
|
+
encoding: "utf8"
|
|
17
|
+
});
|
|
18
|
+
if (result.status !== 0 && !allowFailure) {
|
|
19
|
+
throw new Error(result.stderr?.trim() || result.stdout?.trim() || `git ${args.join(" ")} failed`);
|
|
20
|
+
}
|
|
21
|
+
return result;
|
|
22
|
+
}
|
|
23
|
+
function slugifyBranch(branchName) {
|
|
24
|
+
return branchName.trim().toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64) || "branch";
|
|
25
|
+
}
|
|
26
|
+
function worktreeDirectoryName(branchName) {
|
|
27
|
+
const hash = createHash("sha256").update(branchName).digest("hex").slice(0, 10);
|
|
28
|
+
return `${slugifyBranch(branchName)}-${hash}`;
|
|
29
|
+
}
|
|
30
|
+
function parseWorktreeList(output) {
|
|
31
|
+
const entries = [];
|
|
32
|
+
let current = null;
|
|
33
|
+
for (const line of output.split(/\r?\n/u)) {
|
|
34
|
+
if (line.startsWith("worktree ")) {
|
|
35
|
+
if (current) entries.push(current);
|
|
36
|
+
current = { worktree: line.slice("worktree ".length).trim(), branch: null };
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (current && line.startsWith("branch ")) {
|
|
40
|
+
current.branch = line.slice("branch ".length).trim().replace(/^refs\/heads\//u, "");
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (current) entries.push(current);
|
|
44
|
+
return entries;
|
|
45
|
+
}
|
|
46
|
+
function worktreeList(repoDir) {
|
|
47
|
+
return parseWorktreeList(runGit(["worktree", "list", "--porcelain"], { cwd: repoDir }).stdout ?? "");
|
|
48
|
+
}
|
|
49
|
+
function currentBranchName(repoDir) {
|
|
50
|
+
return runGit(["branch", "--show-current"], { cwd: repoDir, allowFailure: true }).stdout?.trim() || null;
|
|
51
|
+
}
|
|
52
|
+
function localBranchExists(repoDir, branchName) {
|
|
53
|
+
return runGit(["show-ref", "--verify", "--quiet", `refs/heads/${branchName}`], { cwd: repoDir, allowFailure: true }).status === 0;
|
|
54
|
+
}
|
|
55
|
+
function remoteBranchExists(repoDir, branchName) {
|
|
56
|
+
return Boolean(runGit(["ls-remote", "--heads", "origin", branchName], { cwd: repoDir, allowFailure: true }).stdout?.trim());
|
|
57
|
+
}
|
|
58
|
+
function checkoutManagedPackageBranch(repoDir, branchName) {
|
|
59
|
+
runGit(["fetch", "origin"], { cwd: repoDir, allowFailure: true });
|
|
60
|
+
const baseBranch = remoteBranchExists(repoDir, "staging") ? "staging" : remoteBranchExists(repoDir, "main") ? "main" : null;
|
|
61
|
+
if (localBranchExists(repoDir, branchName)) {
|
|
62
|
+
runGit(["checkout", branchName], { cwd: repoDir });
|
|
63
|
+
} else if (remoteBranchExists(repoDir, branchName)) {
|
|
64
|
+
runGit(["checkout", "-b", branchName, `origin/${branchName}`], { cwd: repoDir });
|
|
65
|
+
} else if (baseBranch) {
|
|
66
|
+
runGit(["checkout", "-b", branchName, `origin/${baseBranch}`], { cwd: repoDir });
|
|
67
|
+
} else {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (remoteBranchExists(repoDir, branchName)) {
|
|
71
|
+
runGit(["merge", "--ff-only", `origin/${branchName}`], { cwd: repoDir, allowFailure: true });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
function checkoutManagedPackageBranches(worktreePath, branchName) {
|
|
75
|
+
for (const pkg of sortWorkspacePackages(workspacePackages(worktreePath))) {
|
|
76
|
+
if (!pkg.name?.startsWith("@treeseed/")) continue;
|
|
77
|
+
if (!existsSync(resolve(pkg.dir, ".git"))) continue;
|
|
78
|
+
checkoutManagedPackageBranch(pkg.dir, branchName);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
function metadataPath(root) {
|
|
82
|
+
return resolve(root, WORKTREE_METADATA_PATH);
|
|
83
|
+
}
|
|
84
|
+
function readMetadata(root) {
|
|
85
|
+
const filePath = metadataPath(root);
|
|
86
|
+
if (!existsSync(filePath)) return null;
|
|
87
|
+
try {
|
|
88
|
+
const parsed = JSON.parse(readFileSync(filePath, "utf8"));
|
|
89
|
+
return parsed?.kind === "treeseed.workflow.worktree" ? parsed : null;
|
|
90
|
+
} catch {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
function writeMetadata(root, metadata) {
|
|
95
|
+
const filePath = metadataPath(root);
|
|
96
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
97
|
+
writeFileSync(filePath, `${JSON.stringify(metadata, null, 2)}
|
|
98
|
+
`, "utf8");
|
|
99
|
+
}
|
|
100
|
+
function ensureManagedWorktreeExclude(root) {
|
|
101
|
+
const commonGitDir = runGit(["rev-parse", "--git-common-dir"], { cwd: root }).stdout?.trim();
|
|
102
|
+
if (!commonGitDir) return;
|
|
103
|
+
const absolutePath = resolve(root, commonGitDir, "info", "exclude");
|
|
104
|
+
const current = existsSync(absolutePath) ? readFileSync(absolutePath, "utf8") : "";
|
|
105
|
+
const patterns = ["/.treeseed/worktree.json", "/.treeseed/workflow/", "/.treeseed/worktrees/"];
|
|
106
|
+
const missing = patterns.filter((pattern) => !current.includes(pattern));
|
|
107
|
+
if (missing.length === 0) return;
|
|
108
|
+
mkdirSync(dirname(absolutePath), { recursive: true });
|
|
109
|
+
writeFileSync(
|
|
110
|
+
absolutePath,
|
|
111
|
+
`${current}${current.endsWith("\n") || current.length === 0 ? "" : "\n"}${missing.join("\n")}
|
|
112
|
+
`,
|
|
113
|
+
"utf8"
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
function effectiveWorkflowWorktreeMode(mode, env = process.env) {
|
|
117
|
+
const envMode = String(env.TREESEED_WORKTREE_MODE ?? "").trim().toLowerCase();
|
|
118
|
+
if (mode === "on" || envMode === "on" || envMode === "true" || envMode === "1") return "on";
|
|
119
|
+
if (mode === "off" || envMode === "off" || envMode === "false" || envMode === "0") return "off";
|
|
120
|
+
const agentMarkers = [
|
|
121
|
+
env.TREESEED_WORKFLOW_ACTOR === "agent",
|
|
122
|
+
env.TREESEED_AGENT_ID,
|
|
123
|
+
env.TREESEED_AGENT_RUN_ID,
|
|
124
|
+
env.CODEX_AGENT_ID,
|
|
125
|
+
env.CODEX_TASK_ID
|
|
126
|
+
].some(Boolean);
|
|
127
|
+
return agentMarkers ? "on" : "off";
|
|
128
|
+
}
|
|
129
|
+
function isManagedWorkflowWorktree(root) {
|
|
130
|
+
return readMetadata(root) != null;
|
|
131
|
+
}
|
|
132
|
+
function managedWorkflowWorktreeMetadata(root) {
|
|
133
|
+
return readMetadata(root);
|
|
134
|
+
}
|
|
135
|
+
function plannedManagedWorkflowWorktreePath(root, branchName) {
|
|
136
|
+
const primaryRoot = primaryWorkspaceRoot(root);
|
|
137
|
+
return resolve(primaryRoot, WORKTREE_ROOT, worktreeDirectoryName(branchName));
|
|
138
|
+
}
|
|
139
|
+
function primaryWorkspaceRoot(root) {
|
|
140
|
+
const gitRoot = repoRoot(workspaceRoot(root));
|
|
141
|
+
const entries = worktreeList(gitRoot);
|
|
142
|
+
const first = entries[0]?.worktree;
|
|
143
|
+
return first ? workspaceRoot(first) : workspaceRoot(root);
|
|
144
|
+
}
|
|
145
|
+
function ensureManagedWorkflowWorktree({
|
|
146
|
+
root,
|
|
147
|
+
branchName,
|
|
148
|
+
mode = "auto",
|
|
149
|
+
env = process.env
|
|
150
|
+
}) {
|
|
151
|
+
const effectiveMode = effectiveWorkflowWorktreeMode(mode, env);
|
|
152
|
+
if (effectiveMode !== "on") {
|
|
153
|
+
throw new Error("Managed workflow worktree mode is disabled.");
|
|
154
|
+
}
|
|
155
|
+
const primaryRoot = primaryWorkspaceRoot(root);
|
|
156
|
+
const primaryGitRoot = repoRoot(primaryRoot);
|
|
157
|
+
const worktreePath = plannedManagedWorkflowWorktreePath(primaryRoot, branchName);
|
|
158
|
+
const entries = worktreeList(primaryGitRoot);
|
|
159
|
+
const existingEntry = entries.find((entry) => entry.worktree === worktreePath);
|
|
160
|
+
const duplicate = entries.find((entry) => entry.branch === branchName && entry.worktree !== worktreePath);
|
|
161
|
+
if (duplicate) {
|
|
162
|
+
throw new Error(`Branch ${branchName} is already checked out in ${duplicate.worktree}.`);
|
|
163
|
+
}
|
|
164
|
+
const created = !existingEntry && !existsSync(worktreePath);
|
|
165
|
+
runGit(["fetch", "origin"], { cwd: primaryGitRoot });
|
|
166
|
+
const branchExists = remoteBranchExists(primaryGitRoot, branchName);
|
|
167
|
+
const baseRef = branchExists ? `origin/${branchName}` : "origin/staging";
|
|
168
|
+
if (created) {
|
|
169
|
+
mkdirSync(dirname(worktreePath), { recursive: true });
|
|
170
|
+
runGit(["worktree", "add", "--detach", worktreePath, baseRef], { cwd: primaryGitRoot });
|
|
171
|
+
} else if (!existingEntry) {
|
|
172
|
+
runGit(["worktree", "prune"], { cwd: primaryGitRoot, allowFailure: true });
|
|
173
|
+
} else if (!currentBranchName(worktreePath)) {
|
|
174
|
+
runGit(["fetch", "origin"], { cwd: worktreePath, allowFailure: true });
|
|
175
|
+
runGit(["reset", "--hard", baseRef], { cwd: worktreePath });
|
|
176
|
+
}
|
|
177
|
+
runGit(["submodule", "update", "--init", "--recursive"], { cwd: worktreePath });
|
|
178
|
+
checkoutManagedPackageBranches(worktreePath, branchName);
|
|
179
|
+
ensureManagedWorktreeExclude(worktreePath);
|
|
180
|
+
const timestamp = nowIso();
|
|
181
|
+
const previous = readMetadata(worktreePath);
|
|
182
|
+
const metadata = {
|
|
183
|
+
schemaVersion: 1,
|
|
184
|
+
kind: "treeseed.workflow.worktree",
|
|
185
|
+
branch: branchName,
|
|
186
|
+
worktreePath,
|
|
187
|
+
primaryRoot,
|
|
188
|
+
ownerMode: mode === "on" ? "explicit" : "agent",
|
|
189
|
+
createdAt: previous?.createdAt ?? timestamp,
|
|
190
|
+
lastUsedAt: timestamp
|
|
191
|
+
};
|
|
192
|
+
writeMetadata(worktreePath, metadata);
|
|
193
|
+
return {
|
|
194
|
+
...metadata,
|
|
195
|
+
created,
|
|
196
|
+
resumed: !created
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
function removeManagedWorkflowWorktree(root) {
|
|
200
|
+
const metadata = readMetadata(root);
|
|
201
|
+
if (!metadata) {
|
|
202
|
+
return { removed: false, reason: "not-managed" };
|
|
203
|
+
}
|
|
204
|
+
const primaryRoot = metadata.primaryRoot;
|
|
205
|
+
const primaryGitRoot = repoRoot(primaryRoot);
|
|
206
|
+
process.chdir(primaryRoot);
|
|
207
|
+
runGit(["worktree", "remove", "--force", metadata.worktreePath], { cwd: primaryGitRoot });
|
|
208
|
+
rmSync(metadata.worktreePath, { recursive: true, force: true });
|
|
209
|
+
return {
|
|
210
|
+
removed: true,
|
|
211
|
+
worktreePath: metadata.worktreePath,
|
|
212
|
+
primaryRoot,
|
|
213
|
+
branch: metadata.branch
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
export {
|
|
217
|
+
effectiveWorkflowWorktreeMode,
|
|
218
|
+
ensureManagedWorkflowWorktree,
|
|
219
|
+
isManagedWorkflowWorktree,
|
|
220
|
+
managedWorkflowWorktreeMetadata,
|
|
221
|
+
plannedManagedWorkflowWorktreePath,
|
|
222
|
+
primaryWorkspaceRoot,
|
|
223
|
+
removeManagedWorkflowWorktree
|
|
224
|
+
};
|
package/dist/workflow-state.d.ts
CHANGED
|
@@ -62,6 +62,19 @@ export type TreeseedWorkflowState = {
|
|
|
62
62
|
updatedAt: string;
|
|
63
63
|
nextStep: string | null;
|
|
64
64
|
}>;
|
|
65
|
+
staleRuns: Array<{
|
|
66
|
+
runId: string;
|
|
67
|
+
command: string;
|
|
68
|
+
updatedAt: string;
|
|
69
|
+
nextStep: string | null;
|
|
70
|
+
reasons: string[];
|
|
71
|
+
}>;
|
|
72
|
+
obsoleteRuns: Array<{
|
|
73
|
+
runId: string;
|
|
74
|
+
command: string;
|
|
75
|
+
updatedAt: string;
|
|
76
|
+
reasons: string[];
|
|
77
|
+
}>;
|
|
65
78
|
blockers: string[];
|
|
66
79
|
};
|
|
67
80
|
packageSync: {
|
package/dist/workflow-state.js
CHANGED
|
@@ -24,7 +24,7 @@ import { collectCliPreflight } from "./operations/services/workspace-preflight.j
|
|
|
24
24
|
import { currentBranch, gitStatusPorcelain } from "./operations/services/workspace-save.js";
|
|
25
25
|
import { hasCompleteTreeseedPackageCheckout, isWorkspaceRoot, run, workspacePackages } from "./operations/services/workspace-tools.js";
|
|
26
26
|
import { inspectWorkspaceDependencyMode } from "./operations/services/workspace-dependency-mode.js";
|
|
27
|
-
import {
|
|
27
|
+
import { classifyWorkflowRunJournals, inspectWorkflowLock } from "./workflow/runs.js";
|
|
28
28
|
import { createTreeseedManagedToolEnv, resolveTreeseedToolCommand } from "./managed-dependencies.js";
|
|
29
29
|
import {
|
|
30
30
|
resolveTreeseedWorkflowPaths,
|
|
@@ -248,6 +248,13 @@ function knownRemoteTrackingBranchExists(repoDir, branchName) {
|
|
|
248
248
|
return false;
|
|
249
249
|
}
|
|
250
250
|
}
|
|
251
|
+
function safeHeadCommit(repoDir) {
|
|
252
|
+
try {
|
|
253
|
+
return run("git", ["rev-parse", "HEAD"], { cwd: repoDir, capture: true }).trim();
|
|
254
|
+
} catch {
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
251
258
|
function resolveLocalStatusUrl(deployConfig) {
|
|
252
259
|
return deployConfig.surfaces?.web?.localBaseUrl ?? deployConfig.surfaces?.api?.localBaseUrl ?? Object.values(deployConfig.services ?? {}).find((service) => service?.enabled !== false && service.environments?.local?.baseUrl)?.environments?.local?.baseUrl ?? null;
|
|
253
260
|
}
|
|
@@ -312,12 +319,36 @@ function resolveTreeseedWorkflowState(cwd, options = {}) {
|
|
|
312
319
|
const runnerHostId = typeof marketSettings?.runnerHostId === "string" && marketSettings.runnerHostId.trim() ? marketSettings.runnerHostId.trim() : typeof marketSettings?.projectId === "string" && marketSettings.projectId.trim() ? `market-runner:${marketSettings.projectId.trim()}` : null;
|
|
313
320
|
const runnerSession = runnerHostId ? safeResolveRemoteSession(effectiveCwd, runnerHostId) : null;
|
|
314
321
|
const workflowLock = inspectWorkflowLock(effectiveCwd);
|
|
315
|
-
const
|
|
322
|
+
const workflowRunHeads = {};
|
|
323
|
+
if (root) {
|
|
324
|
+
workflowRunHeads["@treeseed/market"] = safeHeadCommit(root);
|
|
325
|
+
}
|
|
326
|
+
for (const pkg of completePackageCheckout ? workspacePackages(effectiveCwd).filter((entry) => entry.name?.startsWith("@treeseed/")) : []) {
|
|
327
|
+
workflowRunHeads[pkg.name] = safeHeadCommit(pkg.dir);
|
|
328
|
+
}
|
|
329
|
+
const classifiedRuns = classifyWorkflowRunJournals(effectiveCwd, {
|
|
330
|
+
currentBranch: branchName,
|
|
331
|
+
currentHeads: workflowRunHeads
|
|
332
|
+
});
|
|
333
|
+
const interruptedRuns = classifiedRuns.filter((entry) => entry.classification.state === "resumable").map(({ journal }) => ({
|
|
316
334
|
runId: journal.runId,
|
|
317
335
|
command: journal.command,
|
|
318
336
|
updatedAt: journal.updatedAt,
|
|
319
337
|
nextStep: journal.steps.find((step) => step.status === "pending")?.description ?? null
|
|
320
338
|
}));
|
|
339
|
+
const staleRuns = classifiedRuns.filter((entry) => entry.classification.state === "stale").map(({ journal, classification }) => ({
|
|
340
|
+
runId: journal.runId,
|
|
341
|
+
command: journal.command,
|
|
342
|
+
updatedAt: journal.updatedAt,
|
|
343
|
+
nextStep: journal.steps.find((step) => step.status === "pending")?.description ?? null,
|
|
344
|
+
reasons: classification.reasons
|
|
345
|
+
}));
|
|
346
|
+
const obsoleteRuns = classifiedRuns.filter((entry) => entry.classification.state === "obsolete").map(({ journal, classification }) => ({
|
|
347
|
+
runId: journal.runId,
|
|
348
|
+
command: journal.command,
|
|
349
|
+
updatedAt: journal.updatedAt,
|
|
350
|
+
reasons: classification.reasons
|
|
351
|
+
}));
|
|
321
352
|
const workflowBlockers = [];
|
|
322
353
|
if (workflowLock.active && workflowLock.lock) {
|
|
323
354
|
workflowBlockers.push(`Workflow lock active for ${workflowLock.lock.command} (${workflowLock.lock.runId}).`);
|
|
@@ -348,6 +379,8 @@ function resolveTreeseedWorkflowState(cwd, options = {}) {
|
|
|
348
379
|
staleReason: workflowLock.staleReason
|
|
349
380
|
},
|
|
350
381
|
interruptedRuns,
|
|
382
|
+
staleRuns,
|
|
383
|
+
obsoleteRuns,
|
|
351
384
|
blockers: workflowBlockers
|
|
352
385
|
},
|
|
353
386
|
packageSync: {
|