taskplane 0.15.0 → 0.17.0
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/dashboard/server.cjs +13 -3
- package/extensions/taskplane/cleanup.ts +416 -0
- package/extensions/taskplane/engine.ts +35 -1
- package/extensions/taskplane/extension.ts +61 -0
- package/extensions/taskplane/index.ts +2 -0
- package/extensions/taskplane/migrations.ts +275 -0
- package/package.json +1 -1
package/dashboard/server.cjs
CHANGED
|
@@ -295,8 +295,18 @@ function tailJsonlFile(filePath) {
|
|
|
295
295
|
return []; // No new data
|
|
296
296
|
}
|
|
297
297
|
|
|
298
|
-
//
|
|
299
|
-
|
|
298
|
+
// Cap read size per tick to avoid ERR_STRING_TOO_LONG on large files.
|
|
299
|
+
// If there's more data remaining, the next SSE tick will pick up the rest.
|
|
300
|
+
const MAX_TAIL_BYTES = 10 * 1024 * 1024; // 10 MB per tick
|
|
301
|
+
|
|
302
|
+
// Skip-to-tail on fresh dashboard start with large files.
|
|
303
|
+
// The partial-line handling below already discards the first partial line.
|
|
304
|
+
if (tailState.offset === 0 && fileSize > MAX_TAIL_BYTES) {
|
|
305
|
+
tailState.offset = fileSize - MAX_TAIL_BYTES;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Read new bytes from offset, capped to MAX_TAIL_BYTES
|
|
309
|
+
const bytesToRead = Math.min(fileSize - tailState.offset, MAX_TAIL_BYTES);
|
|
300
310
|
const buf = Buffer.alloc(bytesToRead);
|
|
301
311
|
let fd;
|
|
302
312
|
try {
|
|
@@ -311,7 +321,7 @@ function tailJsonlFile(filePath) {
|
|
|
311
321
|
return []; // Read error — try again next tick
|
|
312
322
|
}
|
|
313
323
|
fs.closeSync(fd);
|
|
314
|
-
tailState.offset
|
|
324
|
+
tailState.offset += bytesToRead;
|
|
315
325
|
|
|
316
326
|
// Split into lines, preserving partial trailing line
|
|
317
327
|
const chunk = tailState.partial + buf.toString("utf-8");
|
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Artifact cleanup and log rotation for orchestrator runtime files.
|
|
3
|
+
*
|
|
4
|
+
* Three cleanup layers prevent unbounded disk growth:
|
|
5
|
+
*
|
|
6
|
+
* 1. **Post-Integrate Cleanup** — Deletes batch-specific telemetry and merge
|
|
7
|
+
* result files after successful /orch-integrate. Scoped by batchId.
|
|
8
|
+
*
|
|
9
|
+
* 2. **Age-Based Preflight Sweep** — On /orch start, removes telemetry and
|
|
10
|
+
* merge artifacts older than 7 days. Catches files missed by Layer 1
|
|
11
|
+
* (e.g., aborted batches, manual branch deletions).
|
|
12
|
+
*
|
|
13
|
+
* 3. **Size-Capped Log Rotation** — Rotates append-only supervisor logs
|
|
14
|
+
* (events.jsonl, actions.jsonl) at a 5MB threshold during preflight.
|
|
15
|
+
* Keeps one .old generation.
|
|
16
|
+
*
|
|
17
|
+
* All cleanup is **non-fatal** — failures warn but never block execution.
|
|
18
|
+
*
|
|
19
|
+
* @module orch/cleanup
|
|
20
|
+
* @since TP-065
|
|
21
|
+
*/
|
|
22
|
+
import { existsSync, readdirSync, statSync, unlinkSync, renameSync, mkdirSync } from "fs";
|
|
23
|
+
import { join } from "path";
|
|
24
|
+
|
|
25
|
+
// ── Layer 1: Post-Integrate Cleanup ─────────────────────────────────
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Result of post-integrate artifact cleanup.
|
|
29
|
+
*/
|
|
30
|
+
export interface PostIntegrateCleanupResult {
|
|
31
|
+
/** Number of telemetry files deleted */
|
|
32
|
+
telemetryFilesDeleted: number;
|
|
33
|
+
/** Number of merge result/request files deleted */
|
|
34
|
+
mergeFilesDeleted: number;
|
|
35
|
+
/** Number of lane prompt files deleted */
|
|
36
|
+
promptFilesDeleted: number;
|
|
37
|
+
/** Warnings from non-fatal cleanup failures */
|
|
38
|
+
warnings: string[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Clean up batch-specific telemetry and merge result files after integrate.
|
|
43
|
+
*
|
|
44
|
+
* Targets files whose names contain the batchId:
|
|
45
|
+
* - `.pi/telemetry/*-{batchId}-*.jsonl` — worker/merger sidecar files
|
|
46
|
+
* - `.pi/telemetry/*-{batchId}-*-exit.json` — exit summaries
|
|
47
|
+
* - `.pi/telemetry/lane-prompt-*.txt` — temporary prompt files (all, not scoped)
|
|
48
|
+
* - `.pi/merge-result-*-{batchId}.json` — merge result files
|
|
49
|
+
* - `.pi/merge-request-*-{batchId}.txt` — merge request files
|
|
50
|
+
*
|
|
51
|
+
* @param stateRoot - Root directory containing .pi/ (workspace root or repo root)
|
|
52
|
+
* @param batchId - Batch ID to scope deletion
|
|
53
|
+
* @returns Cleanup result with counts and warnings
|
|
54
|
+
*/
|
|
55
|
+
export function cleanupPostIntegrate(stateRoot: string, batchId: string): PostIntegrateCleanupResult {
|
|
56
|
+
const result: PostIntegrateCleanupResult = {
|
|
57
|
+
telemetryFilesDeleted: 0,
|
|
58
|
+
mergeFilesDeleted: 0,
|
|
59
|
+
promptFilesDeleted: 0,
|
|
60
|
+
warnings: [],
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
if (!batchId) {
|
|
64
|
+
result.warnings.push("No batchId provided — skipping post-integrate cleanup");
|
|
65
|
+
return result;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ── Telemetry files (.pi/telemetry/) ─────────────────────────
|
|
69
|
+
const telemetryDir = join(stateRoot, ".pi", "telemetry");
|
|
70
|
+
if (existsSync(telemetryDir)) {
|
|
71
|
+
try {
|
|
72
|
+
const entries = readdirSync(telemetryDir);
|
|
73
|
+
for (const entry of entries) {
|
|
74
|
+
// Delete batch-scoped sidecar/exit files containing the batchId
|
|
75
|
+
if (entry.includes(batchId) && (entry.endsWith(".jsonl") || entry.endsWith("-exit.json"))) {
|
|
76
|
+
try {
|
|
77
|
+
unlinkSync(join(telemetryDir, entry));
|
|
78
|
+
result.telemetryFilesDeleted++;
|
|
79
|
+
} catch (err: unknown) {
|
|
80
|
+
result.warnings.push(`Failed to delete telemetry file ${entry}: ${(err as Error).message}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
// Delete all lane-prompt-*.txt files (not batch-scoped — they're
|
|
84
|
+
// temporary and should be cleaned up with any batch)
|
|
85
|
+
if (entry.startsWith("lane-prompt-") && entry.endsWith(".txt")) {
|
|
86
|
+
try {
|
|
87
|
+
unlinkSync(join(telemetryDir, entry));
|
|
88
|
+
result.promptFilesDeleted++;
|
|
89
|
+
} catch (err: unknown) {
|
|
90
|
+
result.warnings.push(`Failed to delete prompt file ${entry}: ${(err as Error).message}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
} catch (err: unknown) {
|
|
95
|
+
result.warnings.push(`Failed to read telemetry directory: ${(err as Error).message}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ── Merge result/request files (.pi/) ────────────────────────
|
|
100
|
+
const piDir = join(stateRoot, ".pi");
|
|
101
|
+
if (existsSync(piDir)) {
|
|
102
|
+
try {
|
|
103
|
+
const entries = readdirSync(piDir);
|
|
104
|
+
for (const entry of entries) {
|
|
105
|
+
if (entry.includes(batchId) && (
|
|
106
|
+
(entry.startsWith("merge-result-") && entry.endsWith(".json")) ||
|
|
107
|
+
(entry.startsWith("merge-request-") && entry.endsWith(".txt"))
|
|
108
|
+
)) {
|
|
109
|
+
try {
|
|
110
|
+
unlinkSync(join(piDir, entry));
|
|
111
|
+
result.mergeFilesDeleted++;
|
|
112
|
+
} catch (err: unknown) {
|
|
113
|
+
result.warnings.push(`Failed to delete merge file ${entry}: ${(err as Error).message}`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
} catch (err: unknown) {
|
|
118
|
+
result.warnings.push(`Failed to read .pi directory: ${(err as Error).message}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return result;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Format post-integrate cleanup result for user-facing notification.
|
|
127
|
+
*/
|
|
128
|
+
export function formatPostIntegrateCleanup(result: PostIntegrateCleanupResult): string {
|
|
129
|
+
const parts: string[] = [];
|
|
130
|
+
const totalDeleted = result.telemetryFilesDeleted + result.mergeFilesDeleted + result.promptFilesDeleted;
|
|
131
|
+
|
|
132
|
+
if (totalDeleted > 0) {
|
|
133
|
+
const segments: string[] = [];
|
|
134
|
+
if (result.telemetryFilesDeleted > 0) segments.push(`${result.telemetryFilesDeleted} telemetry`);
|
|
135
|
+
if (result.mergeFilesDeleted > 0) segments.push(`${result.mergeFilesDeleted} merge`);
|
|
136
|
+
if (result.promptFilesDeleted > 0) segments.push(`${result.promptFilesDeleted} prompt`);
|
|
137
|
+
parts.push(`🧹 Cleaned up ${totalDeleted} artifact file(s): ${segments.join(", ")}`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
for (const warning of result.warnings) {
|
|
141
|
+
parts.push(` ⚠️ ${warning}`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return parts.join("\n");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ── Layer 2: Age-Based Preflight Sweep ──────────────────────────────
|
|
148
|
+
|
|
149
|
+
/** Default max age for stale artifacts (7 days in milliseconds). */
|
|
150
|
+
export const STALE_ARTIFACT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Result of a preflight age-based sweep.
|
|
154
|
+
*/
|
|
155
|
+
export interface PreflightSweepResult {
|
|
156
|
+
/** Number of stale files deleted */
|
|
157
|
+
staleFilesDeleted: number;
|
|
158
|
+
/** Whether the sweep was skipped (e.g., active batch) */
|
|
159
|
+
skipped: boolean;
|
|
160
|
+
/** Reason for skipping (if skipped) */
|
|
161
|
+
skipReason?: string;
|
|
162
|
+
/** Warnings from non-fatal cleanup failures */
|
|
163
|
+
warnings: string[];
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Dependencies injected into sweepStaleArtifacts for testability.
|
|
168
|
+
*/
|
|
169
|
+
export interface SweepDeps {
|
|
170
|
+
/** Check if a batch is currently active (phase is not terminal). */
|
|
171
|
+
isBatchActive: () => boolean;
|
|
172
|
+
/** Get the current timestamp (for deterministic testing). */
|
|
173
|
+
now: () => number;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Sweep stale artifacts older than maxAgeMs during preflight.
|
|
178
|
+
*
|
|
179
|
+
* Targets:
|
|
180
|
+
* - `.pi/telemetry/*.jsonl` — sidecar files
|
|
181
|
+
* - `.pi/telemetry/*-exit.json` — exit summaries
|
|
182
|
+
* - `.pi/telemetry/lane-prompt-*.txt` — temporary prompt files
|
|
183
|
+
* - `.pi/merge-result-*.json` — merge result files
|
|
184
|
+
* - `.pi/merge-request-*.txt` — merge request files
|
|
185
|
+
*
|
|
186
|
+
* Uses file mtime for age detection. Skips files modified within maxAgeMs.
|
|
187
|
+
* If a batch is currently active (executing/merging), skips ALL cleanup.
|
|
188
|
+
*
|
|
189
|
+
* @param stateRoot - Root directory containing .pi/
|
|
190
|
+
* @param deps - Injectable dependencies for testability
|
|
191
|
+
* @param maxAgeMs - Maximum file age in milliseconds (default: 7 days)
|
|
192
|
+
* @returns Sweep result with count and warnings
|
|
193
|
+
*/
|
|
194
|
+
export function sweepStaleArtifacts(
|
|
195
|
+
stateRoot: string,
|
|
196
|
+
deps: SweepDeps,
|
|
197
|
+
maxAgeMs: number = STALE_ARTIFACT_MAX_AGE_MS,
|
|
198
|
+
): PreflightSweepResult {
|
|
199
|
+
const result: PreflightSweepResult = {
|
|
200
|
+
staleFilesDeleted: 0,
|
|
201
|
+
skipped: false,
|
|
202
|
+
warnings: [],
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
// Guard: skip if batch is actively executing
|
|
206
|
+
try {
|
|
207
|
+
if (deps.isBatchActive()) {
|
|
208
|
+
result.skipped = true;
|
|
209
|
+
result.skipReason = "Active batch detected — skipping stale artifact sweep";
|
|
210
|
+
return result;
|
|
211
|
+
}
|
|
212
|
+
} catch {
|
|
213
|
+
// If we can't determine batch state, proceed cautiously
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const now = deps.now();
|
|
217
|
+
const cutoff = now - maxAgeMs;
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Delete files older than cutoff from a directory, matching a filter.
|
|
221
|
+
*/
|
|
222
|
+
const sweepDir = (dir: string, filter: (name: string) => boolean): void => {
|
|
223
|
+
if (!existsSync(dir)) return;
|
|
224
|
+
try {
|
|
225
|
+
const entries = readdirSync(dir);
|
|
226
|
+
for (const entry of entries) {
|
|
227
|
+
if (!filter(entry)) continue;
|
|
228
|
+
const filePath = join(dir, entry);
|
|
229
|
+
try {
|
|
230
|
+
const stat = statSync(filePath);
|
|
231
|
+
if (!stat.isFile()) continue;
|
|
232
|
+
if (stat.mtimeMs < cutoff) {
|
|
233
|
+
unlinkSync(filePath);
|
|
234
|
+
result.staleFilesDeleted++;
|
|
235
|
+
}
|
|
236
|
+
} catch (err: unknown) {
|
|
237
|
+
result.warnings.push(`Failed to process ${entry}: ${(err as Error).message}`);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
} catch (err: unknown) {
|
|
241
|
+
result.warnings.push(`Failed to read directory ${dir}: ${(err as Error).message}`);
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
// Sweep telemetry files
|
|
246
|
+
sweepDir(join(stateRoot, ".pi", "telemetry"), (name) =>
|
|
247
|
+
name.endsWith(".jsonl") ||
|
|
248
|
+
name.endsWith("-exit.json") ||
|
|
249
|
+
(name.startsWith("lane-prompt-") && name.endsWith(".txt")),
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
// Sweep merge result/request files
|
|
253
|
+
sweepDir(join(stateRoot, ".pi"), (name) =>
|
|
254
|
+
(name.startsWith("merge-result-") && name.endsWith(".json")) ||
|
|
255
|
+
(name.startsWith("merge-request-") && name.endsWith(".txt")),
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
return result;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Format preflight sweep result for logging.
|
|
263
|
+
*/
|
|
264
|
+
export function formatPreflightSweep(result: PreflightSweepResult): string {
|
|
265
|
+
if (result.skipped) {
|
|
266
|
+
return `ℹ️ Preflight sweep skipped: ${result.skipReason}`;
|
|
267
|
+
}
|
|
268
|
+
if (result.staleFilesDeleted === 0 && result.warnings.length === 0) {
|
|
269
|
+
return ""; // Nothing to report
|
|
270
|
+
}
|
|
271
|
+
const parts: string[] = [];
|
|
272
|
+
if (result.staleFilesDeleted > 0) {
|
|
273
|
+
parts.push(`🧹 Preflight cleanup: removed ${result.staleFilesDeleted} stale artifact(s) (>7 days old)`);
|
|
274
|
+
}
|
|
275
|
+
for (const warning of result.warnings) {
|
|
276
|
+
parts.push(` ⚠️ ${warning}`);
|
|
277
|
+
}
|
|
278
|
+
return parts.join("\n");
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ── Layer 3: Size-Capped Log Rotation ───────────────────────────────
|
|
282
|
+
|
|
283
|
+
/** Default rotation threshold: 5MB. */
|
|
284
|
+
export const LOG_ROTATION_THRESHOLD_BYTES = 5 * 1024 * 1024;
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Result of log rotation.
|
|
288
|
+
*/
|
|
289
|
+
export interface LogRotationResult {
|
|
290
|
+
/** Files that were rotated */
|
|
291
|
+
rotated: string[];
|
|
292
|
+
/** Warnings from non-fatal rotation failures */
|
|
293
|
+
warnings: string[];
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Rotate supervisor append-only logs at a size threshold.
|
|
298
|
+
*
|
|
299
|
+
* Checks `events.jsonl` and `actions.jsonl` in `.pi/supervisor/`.
|
|
300
|
+
* If a file exceeds the threshold, renames it to `.old` (overwriting
|
|
301
|
+
* any existing `.old`), allowing a fresh file to be created on next write.
|
|
302
|
+
*
|
|
303
|
+
* Only call during preflight (not mid-batch).
|
|
304
|
+
*
|
|
305
|
+
* @param stateRoot - Root directory containing .pi/
|
|
306
|
+
* @param thresholdBytes - Maximum file size before rotation (default: 5MB)
|
|
307
|
+
* @returns Rotation result
|
|
308
|
+
*/
|
|
309
|
+
export function rotateSupervisorLogs(
|
|
310
|
+
stateRoot: string,
|
|
311
|
+
thresholdBytes: number = LOG_ROTATION_THRESHOLD_BYTES,
|
|
312
|
+
): LogRotationResult {
|
|
313
|
+
const result: LogRotationResult = {
|
|
314
|
+
rotated: [],
|
|
315
|
+
warnings: [],
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
const supervisorDir = join(stateRoot, ".pi", "supervisor");
|
|
319
|
+
if (!existsSync(supervisorDir)) {
|
|
320
|
+
return result; // Nothing to rotate
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const filesToRotate = ["events.jsonl", "actions.jsonl"];
|
|
324
|
+
|
|
325
|
+
for (const fileName of filesToRotate) {
|
|
326
|
+
const filePath = join(supervisorDir, fileName);
|
|
327
|
+
if (!existsSync(filePath)) continue;
|
|
328
|
+
|
|
329
|
+
try {
|
|
330
|
+
const stat = statSync(filePath);
|
|
331
|
+
if (!stat.isFile() || stat.size <= thresholdBytes) continue;
|
|
332
|
+
|
|
333
|
+
const oldPath = `${filePath}.old`;
|
|
334
|
+
renameSync(filePath, oldPath);
|
|
335
|
+
result.rotated.push(fileName);
|
|
336
|
+
} catch (err: unknown) {
|
|
337
|
+
result.warnings.push(`Failed to rotate ${fileName}: ${(err as Error).message}`);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return result;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Format log rotation result for logging.
|
|
346
|
+
*/
|
|
347
|
+
export function formatLogRotation(result: LogRotationResult): string {
|
|
348
|
+
if (result.rotated.length === 0 && result.warnings.length === 0) {
|
|
349
|
+
return ""; // Nothing to report
|
|
350
|
+
}
|
|
351
|
+
const parts: string[] = [];
|
|
352
|
+
if (result.rotated.length > 0) {
|
|
353
|
+
parts.push(`🔄 Rotated ${result.rotated.length} supervisor log(s): ${result.rotated.join(", ")}`);
|
|
354
|
+
}
|
|
355
|
+
for (const warning of result.warnings) {
|
|
356
|
+
parts.push(` ⚠️ ${warning}`);
|
|
357
|
+
}
|
|
358
|
+
return parts.join("\n");
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// ── Combined Preflight Cleanup ──────────────────────────────────────
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Combined result of preflight cleanup (Layer 2 + Layer 3).
|
|
365
|
+
*/
|
|
366
|
+
export interface PreflightCleanupResult {
|
|
367
|
+
sweep: PreflightSweepResult;
|
|
368
|
+
rotation: LogRotationResult;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Run all preflight cleanup operations (Layer 2 + Layer 3).
|
|
373
|
+
*
|
|
374
|
+
* Called from the engine's preflight phase before batch starts.
|
|
375
|
+
* Always non-fatal.
|
|
376
|
+
*
|
|
377
|
+
* @param stateRoot - Root directory containing .pi/
|
|
378
|
+
* @param deps - Sweep dependencies (active batch check)
|
|
379
|
+
* @returns Combined cleanup result
|
|
380
|
+
*/
|
|
381
|
+
export function runPreflightCleanup(
|
|
382
|
+
stateRoot: string,
|
|
383
|
+
deps: SweepDeps,
|
|
384
|
+
): PreflightCleanupResult {
|
|
385
|
+
const sweep = sweepStaleArtifacts(stateRoot, deps);
|
|
386
|
+
const rotation = rotateSupervisorLogs(stateRoot);
|
|
387
|
+
return { sweep, rotation };
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Format combined preflight cleanup result for user notification.
|
|
392
|
+
*
|
|
393
|
+
* Returns an empty string if nothing happened (no files cleaned/rotated).
|
|
394
|
+
*/
|
|
395
|
+
export function formatPreflightCleanup(result: PreflightCleanupResult): string {
|
|
396
|
+
const parts: string[] = [];
|
|
397
|
+
|
|
398
|
+
// Layer 2: age-based sweep
|
|
399
|
+
if (!result.sweep.skipped && result.sweep.staleFilesDeleted > 0) {
|
|
400
|
+
parts.push(`removed ${result.sweep.staleFilesDeleted} stale artifact(s) (>7 days old)`);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Layer 3: log rotation
|
|
404
|
+
if (result.rotation.rotated.length > 0) {
|
|
405
|
+
parts.push(`rotated ${result.rotation.rotated.join(", ")} (>5 MB)`);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Collect warnings from both layers
|
|
409
|
+
const warnings = [...result.sweep.warnings, ...result.rotation.warnings];
|
|
410
|
+
if (warnings.length > 0) {
|
|
411
|
+
parts.push(`⚠️ ${warnings.length} cleanup warning(s)`);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
if (parts.length === 0) return "";
|
|
415
|
+
return `🧹 Preflight cleanup: ${parts.join("; ")}`;
|
|
416
|
+
}
|
|
@@ -16,12 +16,13 @@ import { applyMergeRetryLoop, computeCleanupGatePolicy, computeMergeFailurePolic
|
|
|
16
16
|
import type { CleanupGateRepoFailure } from "./messages.ts";
|
|
17
17
|
import { assembleDiagnosticInput, emitDiagnosticReports } from "./diagnostic-reports.ts";
|
|
18
18
|
import { resolveOperatorId } from "./naming.ts";
|
|
19
|
-
import { applyPartialProgressToOutcomes, buildTier0EventBase, deleteBatchState, emitEngineEvent, emitTier0Event, loadBatchHistory, persistRuntimeState, saveBatchHistory, seedPendingOutcomesForAllocatedLanes, syncTaskOutcomesFromMonitor, upsertTaskOutcome } from "./persistence.ts";
|
|
19
|
+
import { applyPartialProgressToOutcomes, buildTier0EventBase, deleteBatchState, emitEngineEvent, emitTier0Event, loadBatchHistory, loadBatchState, persistRuntimeState, saveBatchHistory, seedPendingOutcomesForAllocatedLanes, syncTaskOutcomesFromMonitor, upsertTaskOutcome } from "./persistence.ts";
|
|
20
20
|
import { listOrchSessions } from "./sessions.ts";
|
|
21
21
|
import { buildEngineEventBase, defaultResilienceState, FATAL_DISCOVERY_CODES, generateBatchId, TIER0_RETRYABLE_CLASSIFICATIONS, TIER0_RETRY_BUDGETS, tier0ScopeKey, tier0WaveScopeKey } from "./types.ts";
|
|
22
22
|
import type { AllocatedLane, AllocatedTask, BatchHistorySummary, BatchTaskSummary, BatchWaveSummary, DiscoveryResult, EngineEventCallback, EscalationContext, LaneExecutionResult, LaneTaskOutcome, MergeWaveResult, OrchBatchPhase, OrchBatchRuntimeState, OrchestratorConfig, TaskRunnerConfig, Tier0EscalationPattern, Tier0RecoveryPattern, TokenCounts, WaveExecutionResult, WorkspaceConfig } from "./types.ts";
|
|
23
23
|
import { buildDependencyGraph, computeWaves, resolveBaseBranch, resolveRepoRoot, validateGraph } from "./waves.ts";
|
|
24
24
|
import { deleteBranchBestEffort, forceCleanupWorktree, formatPreflightResults, listWorktrees, preserveFailedLaneProgress, removeAllWorktrees, removeWorktree, runPreflight, safeResetWorktree, sleepSync } from "./worktree.ts";
|
|
25
|
+
import { runPreflightCleanup, formatPreflightCleanup } from "./cleanup.ts";
|
|
25
26
|
|
|
26
27
|
// ── Tier 0: Automatic Recovery Helpers (TP-039) ─────────────────────
|
|
27
28
|
|
|
@@ -853,6 +854,39 @@ export async function executeOrchBatch(
|
|
|
853
854
|
return;
|
|
854
855
|
}
|
|
855
856
|
|
|
857
|
+
// ── TP-065: Preflight artifact cleanup (Layer 2 + Layer 3) ───
|
|
858
|
+
// Sweep stale artifacts and rotate oversized logs before batch starts.
|
|
859
|
+
// Always non-fatal — failures warn but never block batch execution.
|
|
860
|
+
try {
|
|
861
|
+
// Layer 2: Age-based sweep of stale telemetry/merge artifacts (>7 days)
|
|
862
|
+
const sweepResult = sweepStaleArtifacts(stateRoot, {
|
|
863
|
+
isBatchActive: () => {
|
|
864
|
+
// Check persisted state — a prior batch may still be active
|
|
865
|
+
try {
|
|
866
|
+
const state = loadBatchState(stateRoot);
|
|
867
|
+
if (state && state.phase !== "completed" && state.phase !== "failed" && state.phase !== "stopped") {
|
|
868
|
+
return true;
|
|
869
|
+
}
|
|
870
|
+
} catch { /* state unreadable — safe to sweep */ }
|
|
871
|
+
return false;
|
|
872
|
+
},
|
|
873
|
+
now: () => Date.now(),
|
|
874
|
+
});
|
|
875
|
+
const sweepMsg = formatPreflightSweep(sweepResult);
|
|
876
|
+
if (sweepMsg) {
|
|
877
|
+
onNotify(sweepMsg, "info");
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
// Layer 3: Size-capped rotation of supervisor append-only logs
|
|
881
|
+
const rotationResult = rotateSupervisorLogs(stateRoot);
|
|
882
|
+
const rotationMsg = formatLogRotation(rotationResult);
|
|
883
|
+
if (rotationMsg) {
|
|
884
|
+
onNotify(rotationMsg, "info");
|
|
885
|
+
}
|
|
886
|
+
} catch {
|
|
887
|
+
// Non-fatal — never block batch start for cleanup errors
|
|
888
|
+
}
|
|
889
|
+
|
|
856
890
|
// Discovery — task area paths in task-runner.yaml are workspace-relative.
|
|
857
891
|
// In repo mode workspaceRoot === repoRoot, so this is always correct.
|
|
858
892
|
const discoveryRoot = workspaceRoot ?? cwd;
|
|
@@ -46,6 +46,8 @@ import {
|
|
|
46
46
|
import { buildExecutionContext } from "./workspace.ts";
|
|
47
47
|
import { openSettingsTui } from "./settings-tui.ts";
|
|
48
48
|
import { loadProjectConfig } from "./config-loader.ts";
|
|
49
|
+
import { runMigrations } from "./migrations.ts";
|
|
50
|
+
import { cleanupPostIntegrate, formatPostIntegrateCleanup, sweepStaleArtifacts, formatPreflightSweep, rotateSupervisorLogs, formatLogRotation } from "./cleanup.ts";
|
|
49
51
|
import {
|
|
50
52
|
activateSupervisor,
|
|
51
53
|
deactivateSupervisor,
|
|
@@ -976,6 +978,12 @@ export function buildIntegrationExecutor(repoRoot: string, opId?: string): Integ
|
|
|
976
978
|
deleteStaleBranches(repoRoot, opId, context.batchId);
|
|
977
979
|
dropBatchAutostash(repoRoot, context.batchId);
|
|
978
980
|
} catch { /* best effort — don't fail integration for cleanup errors */ }
|
|
981
|
+
|
|
982
|
+
// TP-065: Post-integrate artifact cleanup (Layer 1).
|
|
983
|
+
// Also runs on the supervisor auto-integration path.
|
|
984
|
+
try {
|
|
985
|
+
cleanupPostIntegrate(repoRoot, context.batchId);
|
|
986
|
+
} catch { /* best effort — don't fail integration for cleanup errors */ }
|
|
979
987
|
}
|
|
980
988
|
|
|
981
989
|
return result;
|
|
@@ -1493,6 +1501,23 @@ export default function (pi: ExtensionAPI) {
|
|
|
1493
1501
|
};
|
|
1494
1502
|
}
|
|
1495
1503
|
|
|
1504
|
+
// TP-063: Run additive migrations before batch start (primary trigger).
|
|
1505
|
+
// Non-fatal — failures warn but never block batch execution.
|
|
1506
|
+
try {
|
|
1507
|
+
const migrationResult = runMigrations(execCtx.repoRoot);
|
|
1508
|
+
if (migrationResult.messages.length > 0) {
|
|
1509
|
+
ctx.ui.notify(migrationResult.messages.join("\n"), "info");
|
|
1510
|
+
}
|
|
1511
|
+
if (migrationResult.errors.length > 0) {
|
|
1512
|
+
ctx.ui.notify(
|
|
1513
|
+
`⚠️ Migration warnings:\n${migrationResult.errors.map(e => ` ⚠ ${e.id}: ${e.error}`).join("\n")}`,
|
|
1514
|
+
"warning",
|
|
1515
|
+
);
|
|
1516
|
+
}
|
|
1517
|
+
} catch {
|
|
1518
|
+
// Swallow — migrations must never block /orch
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1496
1521
|
// TP-128: Transition from routing-mode supervisor to batch execution
|
|
1497
1522
|
if (supervisorState.active && supervisorState.routingContext) {
|
|
1498
1523
|
await deactivateSupervisor(pi, supervisorState);
|
|
@@ -2264,6 +2289,29 @@ export default function (pi: ExtensionAPI) {
|
|
|
2264
2289
|
|
|
2265
2290
|
try { deleteBatchState(repoRoot); } catch { /* best effort */ }
|
|
2266
2291
|
|
|
2292
|
+
// ── TP-065: Post-integrate artifact cleanup (Layer 1) ────
|
|
2293
|
+
// Delete batch-specific telemetry and merge result files.
|
|
2294
|
+
// Non-fatal — failures warn but don't block integration.
|
|
2295
|
+
if (batchId) {
|
|
2296
|
+
try {
|
|
2297
|
+
const artifactCleanup = cleanupPostIntegrate(repoRoot, batchId);
|
|
2298
|
+
const totalCleaned = artifactCleanup.telemetryFilesDeleted + artifactCleanup.mergeFilesDeleted + artifactCleanup.promptFilesDeleted;
|
|
2299
|
+
if (totalCleaned > 0) {
|
|
2300
|
+
outputLines.push(
|
|
2301
|
+
`🧹 Cleaned up ${artifactCleanup.telemetryFilesDeleted} telemetry file(s), ` +
|
|
2302
|
+
`${artifactCleanup.mergeFilesDeleted} merge result(s), ` +
|
|
2303
|
+
`${artifactCleanup.promptFilesDeleted} prompt file(s) for batch ${batchId}`,
|
|
2304
|
+
);
|
|
2305
|
+
}
|
|
2306
|
+
if (artifactCleanup.warnings.length > 0) {
|
|
2307
|
+
hasWarning = true;
|
|
2308
|
+
outputLines.push(`⚠️ Artifact cleanup warnings: ${artifactCleanup.warnings.join("; ")}`);
|
|
2309
|
+
}
|
|
2310
|
+
} catch {
|
|
2311
|
+
// Non-fatal — never block integration for cleanup failures
|
|
2312
|
+
}
|
|
2313
|
+
}
|
|
2314
|
+
|
|
2267
2315
|
const integrationSummary = wsConfig
|
|
2268
2316
|
? `✅ Integrated ${resolvedOrchBranch} across ${reposToIntegrate.length} repo(s).\n${repoMessages.join("\n")}\n${totalCommits} total commit(s) applied.`
|
|
2269
2317
|
: `${repoMessages[0] || "✅ Integrated."}\n${commitsAhead} commit(s) applied.`;
|
|
@@ -2853,6 +2901,19 @@ export default function (pi: ExtensionAPI) {
|
|
|
2853
2901
|
supervisorConfig = { ...DEFAULT_SUPERVISOR_CONFIG };
|
|
2854
2902
|
}
|
|
2855
2903
|
|
|
2904
|
+
// TP-063: Run additive migrations on session start (safety net trigger).
|
|
2905
|
+
// This ensures migrations run even if the user doesn't invoke /orch.
|
|
2906
|
+
// Non-fatal — failures are silently swallowed so startup is never blocked.
|
|
2907
|
+
try {
|
|
2908
|
+
const migrationResult = runMigrations(execCtx.repoRoot);
|
|
2909
|
+
if (migrationResult.messages.length > 0) {
|
|
2910
|
+
ctx.ui.notify(migrationResult.messages.join("\n"), "info");
|
|
2911
|
+
}
|
|
2912
|
+
// Errors on session_start are silent — avoid noisy warnings at startup
|
|
2913
|
+
} catch {
|
|
2914
|
+
// Swallow — migrations must never block session startup
|
|
2915
|
+
}
|
|
2916
|
+
|
|
2856
2917
|
// Set status line
|
|
2857
2918
|
const areaCount = Object.keys(runnerConfig.task_areas).length;
|
|
2858
2919
|
const modeLabel = execCtx.mode === "workspace" ? "workspace" : "repo";
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Additive Upgrade Migrations for Taskplane
|
|
3
|
+
*
|
|
4
|
+
* Provides a lightweight migration runner that applies additive-only
|
|
5
|
+
* changes (e.g., creating missing scaffold files) when extensions load
|
|
6
|
+
* or `/orch` starts. Migrations never overwrite existing files.
|
|
7
|
+
*
|
|
8
|
+
* Migration state is tracked in `.pi/taskplane.json` under the
|
|
9
|
+
* `migrations` key, preserving all existing version-tracker fields.
|
|
10
|
+
*
|
|
11
|
+
* @module migrations
|
|
12
|
+
* @since TP-063
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, copyFileSync } from "fs";
|
|
16
|
+
import { join, dirname } from "path";
|
|
17
|
+
import { fileURLToPath } from "url";
|
|
18
|
+
|
|
19
|
+
// ── Types ────────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Metadata for a single additive migration.
|
|
23
|
+
*/
|
|
24
|
+
export interface Migration {
|
|
25
|
+
/** Unique, stable identifier (e.g., "add-supervisor-local-template-v1") */
|
|
26
|
+
id: string;
|
|
27
|
+
/** Human-readable description for logs */
|
|
28
|
+
description: string;
|
|
29
|
+
/**
|
|
30
|
+
* Execute the migration. Should only create files that don't exist.
|
|
31
|
+
*
|
|
32
|
+
* @param projectRoot - Project root directory
|
|
33
|
+
* @param packageRoot - Taskplane package root (for template resolution)
|
|
34
|
+
* @returns A short message describing what was created, or null if skipped (already exists)
|
|
35
|
+
* @throws If the migration cannot complete (e.g., missing template source)
|
|
36
|
+
*/
|
|
37
|
+
run(projectRoot: string, packageRoot: string): string | null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Record of a single applied migration in `.pi/taskplane.json`.
|
|
42
|
+
*/
|
|
43
|
+
export interface AppliedMigration {
|
|
44
|
+
/** ISO timestamp when the migration was applied */
|
|
45
|
+
appliedAt: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The `migrations` section within `.pi/taskplane.json`.
|
|
50
|
+
*/
|
|
51
|
+
export interface MigrationState {
|
|
52
|
+
applied: Record<string, AppliedMigration>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Shape of `.pi/taskplane.json` (partial — only fields we read/write).
|
|
57
|
+
* Other fields (version, installedAt, lastUpgraded, components) are
|
|
58
|
+
* preserved as-is during read-modify-write.
|
|
59
|
+
*/
|
|
60
|
+
export interface TaskplaneMeta {
|
|
61
|
+
[key: string]: unknown;
|
|
62
|
+
migrations?: MigrationState;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Result of running migrations.
|
|
67
|
+
*/
|
|
68
|
+
export interface MigrationRunResult {
|
|
69
|
+
/** Migration IDs that were applied in this run */
|
|
70
|
+
applied: string[];
|
|
71
|
+
/** Migration IDs that were skipped (already applied or target exists) */
|
|
72
|
+
skipped: string[];
|
|
73
|
+
/** Migrations that failed with errors (non-fatal — logged and skipped) */
|
|
74
|
+
errors: Array<{ id: string; error: string }>;
|
|
75
|
+
/** Human-readable messages for each applied migration */
|
|
76
|
+
messages: string[];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── Meta File Helpers ────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
const TASKPLANE_META_FILENAME = "taskplane.json";
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Load `.pi/taskplane.json`, returning its content or an empty object
|
|
85
|
+
* if the file doesn't exist or is malformed.
|
|
86
|
+
*
|
|
87
|
+
* Never throws — returns `{}` for any read/parse error.
|
|
88
|
+
*/
|
|
89
|
+
export function loadTaskplaneMeta(projectRoot: string): TaskplaneMeta {
|
|
90
|
+
const metaPath = join(projectRoot, ".pi", TASKPLANE_META_FILENAME);
|
|
91
|
+
try {
|
|
92
|
+
if (!existsSync(metaPath)) return {};
|
|
93
|
+
const raw = readFileSync(metaPath, "utf-8");
|
|
94
|
+
const parsed = JSON.parse(raw);
|
|
95
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {};
|
|
96
|
+
return parsed as TaskplaneMeta;
|
|
97
|
+
} catch {
|
|
98
|
+
return {};
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Save `.pi/taskplane.json`, merging the provided meta with any
|
|
104
|
+
* existing content. Creates the `.pi/` directory if needed.
|
|
105
|
+
*
|
|
106
|
+
* Performs a shallow merge at the top level — existing keys not in
|
|
107
|
+
* `meta` are preserved. The `migrations` key is always taken from
|
|
108
|
+
* the provided `meta` object (deep replacement).
|
|
109
|
+
*/
|
|
110
|
+
export function saveTaskplaneMeta(projectRoot: string, meta: TaskplaneMeta): void {
|
|
111
|
+
const piDir = join(projectRoot, ".pi");
|
|
112
|
+
mkdirSync(piDir, { recursive: true });
|
|
113
|
+
|
|
114
|
+
const metaPath = join(piDir, TASKPLANE_META_FILENAME);
|
|
115
|
+
|
|
116
|
+
// Read existing content to preserve version-tracker fields
|
|
117
|
+
let existing: TaskplaneMeta = {};
|
|
118
|
+
try {
|
|
119
|
+
if (existsSync(metaPath)) {
|
|
120
|
+
const raw = readFileSync(metaPath, "utf-8");
|
|
121
|
+
const parsed = JSON.parse(raw);
|
|
122
|
+
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
|
|
123
|
+
existing = parsed as TaskplaneMeta;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
} catch {
|
|
127
|
+
// Existing file unreadable — start fresh but we'll overwrite only our keys
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Merge: existing fields preserved, our fields override
|
|
131
|
+
const merged = { ...existing, ...meta };
|
|
132
|
+
writeFileSync(metaPath, JSON.stringify(merged, null, 2) + "\n", "utf-8");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ── Package Root Resolution ──────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Resolve the taskplane package root directory.
|
|
139
|
+
*
|
|
140
|
+
* Uses ESM `import.meta.url` to compute the path deterministically.
|
|
141
|
+
* The package root is two levels up from this file:
|
|
142
|
+
* `<package-root>/extensions/taskplane/migrations.ts`
|
|
143
|
+
*
|
|
144
|
+
* @param importMetaUrl - Pass `import.meta.url` from the calling module
|
|
145
|
+
* @returns Absolute path to the package root
|
|
146
|
+
*/
|
|
147
|
+
export function resolvePackageRoot(importMetaUrl?: string): string {
|
|
148
|
+
const url = importMetaUrl ?? import.meta.url;
|
|
149
|
+
const thisDir = dirname(fileURLToPath(url));
|
|
150
|
+
// extensions/taskplane/ → extensions/ → package root
|
|
151
|
+
return join(thisDir, "..", "..");
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ── Migration Registry ──────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Registry of all additive migrations, ordered by creation date.
|
|
158
|
+
*
|
|
159
|
+
* New migrations are appended to this array. Each migration must:
|
|
160
|
+
* - Have a unique, stable `id` (never renamed after release)
|
|
161
|
+
* - Only create files that don't exist (additive-only)
|
|
162
|
+
* - Throw on unrecoverable errors (e.g., missing template source)
|
|
163
|
+
* - Return null if the target already exists (skip)
|
|
164
|
+
*/
|
|
165
|
+
export const MIGRATION_REGISTRY: Migration[] = [
|
|
166
|
+
{
|
|
167
|
+
id: "add-supervisor-local-template-v1",
|
|
168
|
+
description: "Create .pi/agents/supervisor.md from template if missing",
|
|
169
|
+
run(projectRoot: string, packageRoot: string): string | null {
|
|
170
|
+
const targetPath = join(projectRoot, ".pi", "agents", "supervisor.md");
|
|
171
|
+
|
|
172
|
+
// Skip if file already exists — never overwrite
|
|
173
|
+
if (existsSync(targetPath)) {
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Resolve template source
|
|
178
|
+
const templatePath = join(packageRoot, "templates", "agents", "local", "supervisor.md");
|
|
179
|
+
if (!existsSync(templatePath)) {
|
|
180
|
+
throw new Error(
|
|
181
|
+
`Migration template not found: ${templatePath}. ` +
|
|
182
|
+
`This may indicate a packaging issue with the taskplane package.`,
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Create target directory and copy template
|
|
187
|
+
mkdirSync(dirname(targetPath), { recursive: true });
|
|
188
|
+
copyFileSync(templatePath, targetPath);
|
|
189
|
+
|
|
190
|
+
return "Created .pi/agents/supervisor.md from template";
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
];
|
|
194
|
+
|
|
195
|
+
// ── Migration Runner ─────────────────────────────────────────────────
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Run all pending additive migrations.
|
|
199
|
+
*
|
|
200
|
+
* Loads migration state from `.pi/taskplane.json`, runs only unapplied
|
|
201
|
+
* migrations from the registry, and persists applied IDs + timestamps.
|
|
202
|
+
*
|
|
203
|
+
* Each migration is individually try/caught:
|
|
204
|
+
* - Success → recorded as applied, message logged
|
|
205
|
+
* - Skip (returns null) → recorded as applied (target already exists)
|
|
206
|
+
* - Error → logged and skipped (NOT recorded — will be retried next time)
|
|
207
|
+
*
|
|
208
|
+
* @param projectRoot - Project root directory
|
|
209
|
+
* @param packageRoot - Taskplane package root (for template resolution).
|
|
210
|
+
* If omitted, resolved from import.meta.url.
|
|
211
|
+
* @returns Migration run result with applied/skipped/error details
|
|
212
|
+
*/
|
|
213
|
+
export function runMigrations(
|
|
214
|
+
projectRoot: string,
|
|
215
|
+
packageRoot?: string,
|
|
216
|
+
): MigrationRunResult {
|
|
217
|
+
const pkgRoot = packageRoot ?? resolvePackageRoot();
|
|
218
|
+
const result: MigrationRunResult = {
|
|
219
|
+
applied: [],
|
|
220
|
+
skipped: [],
|
|
221
|
+
errors: [],
|
|
222
|
+
messages: [],
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
// Load current state
|
|
226
|
+
const meta = loadTaskplaneMeta(projectRoot);
|
|
227
|
+
const migrationState: MigrationState = meta.migrations ?? { applied: {} };
|
|
228
|
+
|
|
229
|
+
let stateChanged = false;
|
|
230
|
+
|
|
231
|
+
for (const migration of MIGRATION_REGISTRY) {
|
|
232
|
+
// Skip already-applied migrations
|
|
233
|
+
if (migrationState.applied[migration.id]) {
|
|
234
|
+
result.skipped.push(migration.id);
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
try {
|
|
239
|
+
const message = migration.run(projectRoot, pkgRoot);
|
|
240
|
+
|
|
241
|
+
// Record as applied (whether it created something or skipped)
|
|
242
|
+
migrationState.applied[migration.id] = {
|
|
243
|
+
appliedAt: new Date().toISOString(),
|
|
244
|
+
};
|
|
245
|
+
stateChanged = true;
|
|
246
|
+
|
|
247
|
+
if (message) {
|
|
248
|
+
result.applied.push(migration.id);
|
|
249
|
+
result.messages.push(`📦 Migration: ${message}`);
|
|
250
|
+
} else {
|
|
251
|
+
// Target already existed — still mark as applied so we don't recheck
|
|
252
|
+
result.skipped.push(migration.id);
|
|
253
|
+
}
|
|
254
|
+
} catch (err: unknown) {
|
|
255
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
256
|
+
result.errors.push({ id: migration.id, error: errMsg });
|
|
257
|
+
// NOT recorded as applied — will be retried next time
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Persist state if anything changed
|
|
262
|
+
if (stateChanged) {
|
|
263
|
+
try {
|
|
264
|
+
saveTaskplaneMeta(projectRoot, { ...meta, migrations: migrationState });
|
|
265
|
+
} catch (err: unknown) {
|
|
266
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
267
|
+
result.errors.push({
|
|
268
|
+
id: "__state_save",
|
|
269
|
+
error: `Failed to persist migration state: ${errMsg}`,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return result;
|
|
275
|
+
}
|