@thxgg/steward 0.1.17 → 0.1.19
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/.env.example +0 -6
- package/.output/nitro.json +1 -1
- package/.output/public/_nuxt/{BlTKcjLJ.js → BCiXS3ZV.js} +2 -2
- package/.output/public/_nuxt/BfUfZSFp.js +60 -0
- package/.output/public/_nuxt/{BMdjSp24.js → BfV4oCiT.js} +1 -1
- package/.output/public/_nuxt/{BSZqAKg4.js → Bs6UO7IT.js} +1 -1
- package/.output/public/_nuxt/C9AsKFSQ.js +1 -0
- package/.output/public/_nuxt/{BdjPva1I.js → C9jB6HQI.js} +1 -1
- package/.output/public/_nuxt/CCruYste.js +3 -0
- package/.output/public/_nuxt/CIIE0-WR.js +1 -0
- package/.output/public/_nuxt/{By7gAVcL.js → Cg4hnDua.js} +1 -1
- package/.output/public/_nuxt/CwdD8083.js +30 -0
- package/.output/public/_nuxt/{4r0X30JV.js → D2RLSKEu.js} +1 -1
- package/.output/public/_nuxt/{CbkpNvIu.js → D30YtxUg.js} +1 -1
- package/.output/public/_nuxt/{Beeir9iR.js → DEekox9p.js} +1 -1
- package/.output/public/_nuxt/{nX8Sf7cz.js → DddVAa3N.js} +1 -1
- package/.output/public/_nuxt/{Bh3vsUvl.js → MO41rxll.js} +1 -1
- package/.output/public/_nuxt/builds/latest.json +1 -1
- package/.output/public/_nuxt/builds/meta/627332c0-2e14-4849-8cac-e350f64ed513.json +1 -0
- package/.output/public/_nuxt/entry.Dp3jx0Yw.css +1 -0
- package/.output/public/_nuxt/f7vKgp5U.js +1 -0
- package/.output/public/_nuxt/xc3v2JZH.js +1 -0
- package/.output/server/chunks/_/git-api.mjs +1 -1
- package/.output/server/chunks/_/prd-service.mjs +177 -12
- package/.output/server/chunks/_/prd-service.mjs.map +1 -1
- package/.output/server/chunks/_/repos.mjs +272 -0
- package/.output/server/chunks/_/repos.mjs.map +1 -0
- package/.output/server/chunks/_/task-graph.mjs +19 -16
- package/.output/server/chunks/_/task-graph.mjs.map +1 -1
- package/.output/server/chunks/_/watcher.mjs +11 -35
- package/.output/server/chunks/_/watcher.mjs.map +1 -1
- package/.output/server/chunks/build/{Detail-MGwP_u2d.mjs → Detail-BcQGdJY5.mjs} +112 -46
- package/.output/server/chunks/build/Detail-BcQGdJY5.mjs.map +1 -0
- package/.output/server/chunks/build/{_prd_-C-Aj4fVa.mjs → _prd_-CD_Bds_B.mjs} +80 -7
- package/.output/server/chunks/build/_prd_-CD_Bds_B.mjs.map +1 -0
- package/.output/server/chunks/build/client.precomputed.mjs +1 -1
- package/.output/server/chunks/build/{default-Cao5eO80.mjs → default-BKKgG7HJ.mjs} +220 -24
- package/.output/server/chunks/build/default-BKKgG7HJ.mjs.map +1 -0
- package/.output/server/chunks/build/error-404-Bf6kdO80.mjs +1 -1
- package/.output/server/chunks/build/error-500-D_bcARXN.mjs +1 -1
- package/.output/server/chunks/build/{index-ljj9uTXI.mjs → index-DE1tjHAd.mjs} +3 -4
- package/.output/server/chunks/build/index-DE1tjHAd.mjs.map +1 -0
- package/.output/server/chunks/build/nuxt-link-SvT1nf8Z.mjs +1 -1
- package/.output/server/chunks/build/{repo-graph-EuhMeFt7.mjs → repo-graph-CBfhpnd5.mjs} +25 -12
- package/.output/server/chunks/build/repo-graph-CBfhpnd5.mjs.map +1 -0
- package/.output/server/chunks/build/server.mjs +14 -14
- package/.output/server/chunks/build/styles.mjs +2 -2
- package/.output/server/chunks/build/{usePrd-f7ylhIqs.mjs → usePrd-hXZOmvAv.mjs} +113 -9
- package/.output/server/chunks/build/usePrd-hXZOmvAv.mjs.map +1 -0
- package/.output/server/chunks/nitro/nitro.mjs +1051 -1365
- package/.output/server/chunks/nitro/nitro.mjs.map +1 -1
- package/.output/server/chunks/routes/api/browse.get.mjs +24 -6
- package/.output/server/chunks/routes/api/browse.get.mjs.map +1 -1
- package/.output/server/chunks/routes/api/index.get.mjs +3 -2
- package/.output/server/chunks/routes/api/index.get.mjs.map +1 -1
- package/.output/server/chunks/routes/api/index.post.mjs +22 -8
- package/.output/server/chunks/routes/api/index.post.mjs.map +1 -1
- package/.output/server/chunks/routes/api/repos/_repoId/git/commits.get.mjs +20 -11
- package/.output/server/chunks/routes/api/repos/_repoId/git/commits.get.mjs.map +1 -1
- package/.output/server/chunks/routes/api/repos/_repoId/git/diff.get.mjs +3 -2
- package/.output/server/chunks/routes/api/repos/_repoId/git/diff.get.mjs.map +1 -1
- package/.output/server/chunks/routes/api/repos/_repoId/git/file-content.get.mjs +3 -2
- package/.output/server/chunks/routes/api/repos/_repoId/git/file-content.get.mjs.map +1 -1
- package/.output/server/chunks/routes/api/repos/_repoId/git/file-diff.get.mjs +3 -2
- package/.output/server/chunks/routes/api/repos/_repoId/git/file-diff.get.mjs.map +1 -1
- package/.output/server/chunks/routes/api/repos/_repoId/graph.get.mjs +27 -3
- package/.output/server/chunks/routes/api/repos/_repoId/graph.get.mjs.map +1 -1
- package/.output/server/chunks/routes/api/repos/_repoId/prd/_prdSlug/archive.post.mjs +93 -0
- package/.output/server/chunks/routes/api/repos/_repoId/prd/_prdSlug/archive.post.mjs.map +1 -0
- package/.output/server/chunks/routes/api/repos/_repoId/prd/_prdSlug/graph.get.mjs +3 -2
- package/.output/server/chunks/routes/api/repos/_repoId/prd/_prdSlug/graph.get.mjs.map +1 -1
- package/.output/server/chunks/routes/api/repos/_repoId/prd/_prdSlug/progress.get.mjs +4 -3
- package/.output/server/chunks/routes/api/repos/_repoId/prd/_prdSlug/progress.get.mjs.map +1 -1
- package/.output/server/chunks/routes/api/repos/_repoId/prd/_prdSlug/tasks/_taskId/commits.get.mjs +4 -3
- package/.output/server/chunks/routes/api/repos/_repoId/prd/_prdSlug/tasks/_taskId/commits.get.mjs.map +1 -1
- package/.output/server/chunks/routes/api/repos/_repoId/prd/_prdSlug/tasks.get.mjs +4 -3
- package/.output/server/chunks/routes/api/repos/_repoId/prd/_prdSlug/tasks.get.mjs.map +1 -1
- package/.output/server/chunks/routes/api/repos/_repoId/prd/_prdSlug_.get.mjs +3 -2
- package/.output/server/chunks/routes/api/repos/_repoId/prd/_prdSlug_.get.mjs.map +1 -1
- package/.output/server/chunks/routes/api/repos/_repoId/prds.get.mjs +27 -3
- package/.output/server/chunks/routes/api/repos/_repoId/prds.get.mjs.map +1 -1
- package/.output/server/chunks/routes/api/repos/_repoId/refresh-git-repos.post.mjs +3 -2
- package/.output/server/chunks/routes/api/repos/_repoId/refresh-git-repos.post.mjs.map +1 -1
- package/.output/server/chunks/routes/api/repos/_repoId_.delete.mjs +3 -2
- package/.output/server/chunks/routes/api/repos/_repoId_.delete.mjs.map +1 -1
- package/.output/server/chunks/routes/api/runtime.get.mjs +2 -2
- package/.output/server/chunks/routes/api/state-migration/status.get.mjs +21 -0
- package/.output/server/chunks/routes/api/state-migration/status.get.mjs.map +1 -0
- package/.output/server/chunks/routes/api/watch.get.mjs +3 -2
- package/.output/server/chunks/routes/api/watch.get.mjs.map +1 -1
- package/.output/server/chunks/routes/renderer.mjs +1 -1
- package/.output/server/index.mjs +2 -2
- package/.output/server/package.json +1 -1
- package/README.md +5 -7
- package/dist/host/src/api/repos.js +0 -2
- package/dist/host/src/api/state.js +1 -7
- package/dist/host/src/index.js +2 -7
- package/dist/host/src/ui.js +2 -7
- package/dist/server/utils/db.js +15 -0
- package/dist/server/utils/prd-archive.js +53 -0
- package/dist/server/utils/prd-service.js +27 -11
- package/dist/server/utils/prd-state.js +11 -122
- package/dist/server/utils/repos.js +14 -4
- package/dist/server/utils/state-migration.js +225 -0
- package/dist/server/utils/state-schema.js +181 -4
- package/dist/server/utils/task-graph.js +21 -14
- package/package.json +1 -1
- package/.output/public/_nuxt/CbJfCtEa.js +0 -1
- package/.output/public/_nuxt/CmhLcqDu.js +0 -1
- package/.output/public/_nuxt/DC6iPLz1.js +0 -30
- package/.output/public/_nuxt/DD--ojY9.js +0 -3
- package/.output/public/_nuxt/DhKWRjCh.js +0 -60
- package/.output/public/_nuxt/builds/meta/f3f42dbd-d501-442b-871c-3d06157e7aa1.json +0 -1
- package/.output/public/_nuxt/c1sXju8w.js +0 -1
- package/.output/public/_nuxt/eGCjCghR.js +0 -1
- package/.output/public/_nuxt/entry.LcDOtJnR.css +0 -1
- package/.output/server/chunks/build/Detail-MGwP_u2d.mjs.map +0 -1
- package/.output/server/chunks/build/_prd_-C-Aj4fVa.mjs.map +0 -1
- package/.output/server/chunks/build/default-Cao5eO80.mjs.map +0 -1
- package/.output/server/chunks/build/index-ByZO4Bvq.mjs +0 -76
- package/.output/server/chunks/build/index-ByZO4Bvq.mjs.map +0 -1
- package/.output/server/chunks/build/index-ljj9uTXI.mjs.map +0 -1
- package/.output/server/chunks/build/repo-graph-EuhMeFt7.mjs.map +0 -1
- package/.output/server/chunks/build/usePrd-f7ylhIqs.mjs.map +0 -1
|
@@ -1,11 +1,7 @@
|
|
|
1
|
-
import { promises as fs } from 'node:fs';
|
|
2
|
-
import { join } from 'node:path';
|
|
3
1
|
import { emitChange } from './change-events.js';
|
|
4
2
|
import { dbAll, dbGet, dbRun } from './db.js';
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
const migrationInFlight = new Map();
|
|
8
|
-
const cleanupCompletedRepoIds = new Set();
|
|
3
|
+
import { ensureStateMigrationReady } from './state-migration.js';
|
|
4
|
+
import { parseProgressFile, parseStoredProgressFile, parseTasksFile } from './state-schema.js';
|
|
9
5
|
function parseStoredJson(raw, fieldName, parseValue) {
|
|
10
6
|
if (!raw) {
|
|
11
7
|
return null;
|
|
@@ -28,6 +24,7 @@ function getTaskCounts(tasksFile) {
|
|
|
28
24
|
return { taskCount, completedCount };
|
|
29
25
|
}
|
|
30
26
|
export async function getPrdState(repoId, slug) {
|
|
27
|
+
await ensureStateMigrationReady();
|
|
31
28
|
const row = await dbGet(`
|
|
32
29
|
SELECT repo_id, slug, tasks_json, progress_json, notes_md, updated_at
|
|
33
30
|
FROM prd_states
|
|
@@ -37,7 +34,12 @@ export async function getPrdState(repoId, slug) {
|
|
|
37
34
|
return null;
|
|
38
35
|
}
|
|
39
36
|
const tasks = parseStoredJson(row.tasks_json, 'prd_states.tasks_json', parseTasksFile);
|
|
40
|
-
const
|
|
37
|
+
const tasksCountHint = Array.isArray(tasks?.tasks) ? tasks.tasks.length : undefined;
|
|
38
|
+
const prdNameFallback = tasks?.prd?.name || row.slug;
|
|
39
|
+
const progress = parseStoredJson(row.progress_json, 'prd_states.progress_json', (value) => parseStoredProgressFile(value, {
|
|
40
|
+
totalTasksHint: tasksCountHint,
|
|
41
|
+
prdNameFallback
|
|
42
|
+
}));
|
|
41
43
|
return {
|
|
42
44
|
slug: row.slug,
|
|
43
45
|
tasks,
|
|
@@ -47,6 +49,7 @@ export async function getPrdState(repoId, slug) {
|
|
|
47
49
|
};
|
|
48
50
|
}
|
|
49
51
|
export async function getPrdStateSummaries(repoId) {
|
|
52
|
+
await ensureStateMigrationReady();
|
|
50
53
|
const rows = await dbAll('SELECT slug, tasks_json FROM prd_states WHERE repo_id = ?', [repoId]);
|
|
51
54
|
const summaries = new Map();
|
|
52
55
|
for (const row of rows) {
|
|
@@ -69,6 +72,7 @@ export async function getPrdStateSummaries(repoId) {
|
|
|
69
72
|
return summaries;
|
|
70
73
|
}
|
|
71
74
|
export async function upsertPrdState(repoId, slug, update) {
|
|
75
|
+
await ensureStateMigrationReady();
|
|
72
76
|
const validatedTasks = update.tasks === undefined
|
|
73
77
|
? undefined
|
|
74
78
|
: (update.tasks === null ? null : parseTasksFile(update.tasks));
|
|
@@ -124,118 +128,3 @@ export async function upsertPrdState(repoId, slug, update) {
|
|
|
124
128
|
});
|
|
125
129
|
}
|
|
126
130
|
}
|
|
127
|
-
async function readStableLegacyFile(filePath, minFileAgeMs) {
|
|
128
|
-
try {
|
|
129
|
-
const stats = await fs.stat(filePath);
|
|
130
|
-
if (!stats.isFile()) {
|
|
131
|
-
return null;
|
|
132
|
-
}
|
|
133
|
-
if (Date.now() - stats.mtimeMs < minFileAgeMs) {
|
|
134
|
-
return null;
|
|
135
|
-
}
|
|
136
|
-
return await fs.readFile(filePath, 'utf-8');
|
|
137
|
-
}
|
|
138
|
-
catch {
|
|
139
|
-
return null;
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
async function readLegacyJsonFile(filePath, label, minFileAgeMs) {
|
|
143
|
-
const content = await readStableLegacyFile(filePath, minFileAgeMs);
|
|
144
|
-
if (!content) {
|
|
145
|
-
return { value: null, imported: false };
|
|
146
|
-
}
|
|
147
|
-
try {
|
|
148
|
-
return { value: JSON.parse(content), imported: true };
|
|
149
|
-
}
|
|
150
|
-
catch (error) {
|
|
151
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
152
|
-
console.warn(`[legacy-state] Skipping invalid ${label} at ${filePath}: ${message}`);
|
|
153
|
-
return { value: null, imported: false };
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
async function removeIfExists(filePath) {
|
|
157
|
-
try {
|
|
158
|
-
await fs.unlink(filePath);
|
|
159
|
-
}
|
|
160
|
-
catch {
|
|
161
|
-
// File may already be removed.
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
async function removeDirIfEmpty(dirPath) {
|
|
165
|
-
try {
|
|
166
|
-
const entries = await fs.readdir(dirPath);
|
|
167
|
-
if (entries.length === 0) {
|
|
168
|
-
await fs.rmdir(dirPath);
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
catch {
|
|
172
|
-
// Directory may not exist or may contain files.
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
async function runLegacyStateMigration(repo, cleanupLegacyFiles, minFileAgeMs) {
|
|
176
|
-
const legacyStateDir = join(repo.path, '.claude', 'state');
|
|
177
|
-
const entries = await fs.readdir(legacyStateDir, { withFileTypes: true, encoding: 'utf8' }).catch(() => null);
|
|
178
|
-
if (!entries) {
|
|
179
|
-
return;
|
|
180
|
-
}
|
|
181
|
-
for (const entry of entries) {
|
|
182
|
-
if (!entry.isDirectory()) {
|
|
183
|
-
continue;
|
|
184
|
-
}
|
|
185
|
-
const slug = entry.name;
|
|
186
|
-
const slugDir = join(legacyStateDir, slug);
|
|
187
|
-
const tasksPath = join(slugDir, 'tasks.json');
|
|
188
|
-
const progressPath = join(slugDir, 'progress.json');
|
|
189
|
-
const notesPath = join(slugDir, 'notes.md');
|
|
190
|
-
const [tasksResult, progressResult, notesContent] = await Promise.all([
|
|
191
|
-
readLegacyJsonFile(tasksPath, 'tasks.json', minFileAgeMs),
|
|
192
|
-
readLegacyJsonFile(progressPath, 'progress.json', minFileAgeMs),
|
|
193
|
-
readStableLegacyFile(notesPath, minFileAgeMs)
|
|
194
|
-
]);
|
|
195
|
-
const shouldImportNotes = notesContent !== null;
|
|
196
|
-
const shouldImport = tasksResult.imported || progressResult.imported || shouldImportNotes;
|
|
197
|
-
if (!shouldImport) {
|
|
198
|
-
continue;
|
|
199
|
-
}
|
|
200
|
-
await upsertPrdState(repo.id, slug, {
|
|
201
|
-
...(tasksResult.imported && { tasks: tasksResult.value }),
|
|
202
|
-
...(progressResult.imported && { progress: progressResult.value }),
|
|
203
|
-
...(shouldImportNotes && { notes: notesContent })
|
|
204
|
-
});
|
|
205
|
-
if (cleanupLegacyFiles) {
|
|
206
|
-
if (tasksResult.imported) {
|
|
207
|
-
await removeIfExists(tasksPath);
|
|
208
|
-
}
|
|
209
|
-
if (progressResult.imported) {
|
|
210
|
-
await removeIfExists(progressPath);
|
|
211
|
-
}
|
|
212
|
-
if (shouldImportNotes) {
|
|
213
|
-
await removeIfExists(notesPath);
|
|
214
|
-
}
|
|
215
|
-
await removeDirIfEmpty(slugDir);
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
if (cleanupLegacyFiles) {
|
|
219
|
-
await removeDirIfEmpty(legacyStateDir);
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
export async function migrateLegacyStateForRepo(repo, options = {}) {
|
|
223
|
-
const cleanupLegacyFiles = options.cleanupLegacyFiles
|
|
224
|
-
?? !cleanupCompletedRepoIds.has(repo.id);
|
|
225
|
-
const minFileAgeMs = options.minFileAgeMs ?? LEGACY_STATE_STABLE_MS;
|
|
226
|
-
const inFlight = migrationInFlight.get(repo.id);
|
|
227
|
-
if (inFlight) {
|
|
228
|
-
return inFlight;
|
|
229
|
-
}
|
|
230
|
-
const migrationPromise = runLegacyStateMigration(repo, cleanupLegacyFiles, minFileAgeMs)
|
|
231
|
-
.then(() => {
|
|
232
|
-
if (cleanupLegacyFiles) {
|
|
233
|
-
cleanupCompletedRepoIds.add(repo.id);
|
|
234
|
-
}
|
|
235
|
-
})
|
|
236
|
-
.finally(() => {
|
|
237
|
-
migrationInFlight.delete(repo.id);
|
|
238
|
-
});
|
|
239
|
-
migrationInFlight.set(repo.id, migrationPromise);
|
|
240
|
-
return migrationPromise;
|
|
241
|
-
}
|
|
@@ -274,12 +274,22 @@ export async function discoverGitRepos(basePath, maxDepth = 4) {
|
|
|
274
274
|
return discovered;
|
|
275
275
|
}
|
|
276
276
|
export async function validateRepoPath(path) {
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
277
|
+
const trimmedPath = path.trim();
|
|
278
|
+
if (trimmedPath.length === 0) {
|
|
279
|
+
return { valid: false, error: 'Path is required' };
|
|
280
|
+
}
|
|
281
|
+
if (trimmedPath.length > 4096) {
|
|
282
|
+
return { valid: false, error: 'Path is too long' };
|
|
283
|
+
}
|
|
284
|
+
if (trimmedPath.includes('\u0000')) {
|
|
285
|
+
return { valid: false, error: 'Path contains invalid characters' };
|
|
286
|
+
}
|
|
287
|
+
const isWindowsAbsolutePath = /^[A-Za-z]:[\\/]/.test(trimmedPath);
|
|
288
|
+
if (!isAbsolute(trimmedPath) && !isWindowsAbsolutePath) {
|
|
281
289
|
return { valid: false, error: 'Path must be absolute' };
|
|
282
290
|
}
|
|
291
|
+
// Normalize the path
|
|
292
|
+
const resolvedPath = resolve(trimmedPath);
|
|
283
293
|
try {
|
|
284
294
|
const stats = await fs.stat(resolvedPath);
|
|
285
295
|
if (!stats.isDirectory()) {
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { emitChange } from './change-events.js';
|
|
2
|
+
import { dbAll, dbExec, dbGet, dbRun } from './db.js';
|
|
3
|
+
import { needsProgressMigration, parseStoredProgressFile, parseTasksFile } from './state-schema.js';
|
|
4
|
+
const MIGRATION_VERSION = 'progress-json-v2';
|
|
5
|
+
const MIGRATION_META_KEY = `state-migration:${MIGRATION_VERSION}`;
|
|
6
|
+
let status = {
|
|
7
|
+
state: 'idle',
|
|
8
|
+
version: MIGRATION_VERSION,
|
|
9
|
+
startedAt: null,
|
|
10
|
+
completedAt: null,
|
|
11
|
+
totalRows: 0,
|
|
12
|
+
processedRows: 0,
|
|
13
|
+
migratedRows: 0,
|
|
14
|
+
failedRows: 0,
|
|
15
|
+
currentSlug: null,
|
|
16
|
+
errorMessage: null,
|
|
17
|
+
percent: 0
|
|
18
|
+
};
|
|
19
|
+
let migrationPromise = null;
|
|
20
|
+
function nowIso() {
|
|
21
|
+
return new Date().toISOString();
|
|
22
|
+
}
|
|
23
|
+
function toPercent(processedRows, totalRows) {
|
|
24
|
+
if (totalRows <= 0) {
|
|
25
|
+
return 100;
|
|
26
|
+
}
|
|
27
|
+
return Math.min(100, Math.floor((processedRows / totalRows) * 100));
|
|
28
|
+
}
|
|
29
|
+
function resetRunningStatus(totalRows) {
|
|
30
|
+
status = {
|
|
31
|
+
state: 'running',
|
|
32
|
+
version: MIGRATION_VERSION,
|
|
33
|
+
startedAt: nowIso(),
|
|
34
|
+
completedAt: null,
|
|
35
|
+
totalRows,
|
|
36
|
+
processedRows: 0,
|
|
37
|
+
migratedRows: 0,
|
|
38
|
+
failedRows: 0,
|
|
39
|
+
currentSlug: null,
|
|
40
|
+
errorMessage: null,
|
|
41
|
+
percent: totalRows === 0 ? 100 : 0
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
function markCompleted(marker) {
|
|
45
|
+
const completedAt = marker?.completedAt || nowIso();
|
|
46
|
+
const totalRows = marker?.totalRows ?? status.totalRows;
|
|
47
|
+
const migratedRows = marker?.migratedRows ?? status.migratedRows;
|
|
48
|
+
status = {
|
|
49
|
+
...status,
|
|
50
|
+
state: 'completed',
|
|
51
|
+
completedAt,
|
|
52
|
+
totalRows,
|
|
53
|
+
processedRows: totalRows,
|
|
54
|
+
migratedRows,
|
|
55
|
+
failedRows: 0,
|
|
56
|
+
currentSlug: null,
|
|
57
|
+
errorMessage: null,
|
|
58
|
+
percent: 100
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
function markFailed(message) {
|
|
62
|
+
status = {
|
|
63
|
+
...status,
|
|
64
|
+
state: 'failed',
|
|
65
|
+
completedAt: nowIso(),
|
|
66
|
+
currentSlug: null,
|
|
67
|
+
errorMessage: message,
|
|
68
|
+
percent: toPercent(status.processedRows, status.totalRows)
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
async function ensureMetaTable() {
|
|
72
|
+
await dbExec(`
|
|
73
|
+
CREATE TABLE IF NOT EXISTS app_meta (
|
|
74
|
+
key TEXT PRIMARY KEY,
|
|
75
|
+
value TEXT NOT NULL,
|
|
76
|
+
updated_at TEXT NOT NULL
|
|
77
|
+
);
|
|
78
|
+
`);
|
|
79
|
+
}
|
|
80
|
+
async function readMigrationMarker() {
|
|
81
|
+
const row = await dbGet('SELECT value FROM app_meta WHERE key = ?', [MIGRATION_META_KEY]);
|
|
82
|
+
if (!row?.value) {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
const parsed = JSON.parse(row.value);
|
|
87
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
if (parsed.version !== MIGRATION_VERSION) {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
if (typeof parsed.completedAt !== 'string') {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
if (typeof parsed.totalRows !== 'number' || typeof parsed.migratedRows !== 'number') {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
return parsed;
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
async function writeMigrationMarker(totalRows, migratedRows) {
|
|
106
|
+
const completedAt = nowIso();
|
|
107
|
+
const marker = {
|
|
108
|
+
version: MIGRATION_VERSION,
|
|
109
|
+
completedAt,
|
|
110
|
+
totalRows,
|
|
111
|
+
migratedRows
|
|
112
|
+
};
|
|
113
|
+
await dbRun(`
|
|
114
|
+
INSERT INTO app_meta (key, value, updated_at)
|
|
115
|
+
VALUES (?, ?, ?)
|
|
116
|
+
ON CONFLICT(key) DO UPDATE SET
|
|
117
|
+
value = excluded.value,
|
|
118
|
+
updated_at = excluded.updated_at
|
|
119
|
+
`, [MIGRATION_META_KEY, JSON.stringify(marker), completedAt]);
|
|
120
|
+
}
|
|
121
|
+
async function migrateProgressRows() {
|
|
122
|
+
await ensureMetaTable();
|
|
123
|
+
const marker = await readMigrationMarker();
|
|
124
|
+
if (marker) {
|
|
125
|
+
markCompleted(marker);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const rows = await dbAll(`
|
|
129
|
+
SELECT repo_id, slug, tasks_json, progress_json
|
|
130
|
+
FROM prd_states
|
|
131
|
+
WHERE progress_json IS NOT NULL
|
|
132
|
+
ORDER BY repo_id ASC, slug ASC
|
|
133
|
+
`);
|
|
134
|
+
resetRunningStatus(rows.length);
|
|
135
|
+
for (let index = 0; index < rows.length; index += 1) {
|
|
136
|
+
const row = rows[index];
|
|
137
|
+
status.currentSlug = row.slug;
|
|
138
|
+
try {
|
|
139
|
+
const parsedProgress = row.progress_json
|
|
140
|
+
? JSON.parse(row.progress_json)
|
|
141
|
+
: null;
|
|
142
|
+
let tasksCountHint;
|
|
143
|
+
let prdNameFallback;
|
|
144
|
+
if (row.tasks_json) {
|
|
145
|
+
try {
|
|
146
|
+
const tasks = parseTasksFile(JSON.parse(row.tasks_json));
|
|
147
|
+
tasksCountHint = tasks.tasks.length;
|
|
148
|
+
prdNameFallback = tasks.prd.name;
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
// Keep fallback below when tasks_json is malformed.
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const shouldMigrate = needsProgressMigration(parsedProgress);
|
|
155
|
+
if (shouldMigrate) {
|
|
156
|
+
const normalized = parseStoredProgressFile(parsedProgress, {
|
|
157
|
+
prdNameFallback: prdNameFallback || row.slug,
|
|
158
|
+
totalTasksHint: tasksCountHint
|
|
159
|
+
});
|
|
160
|
+
const updatedAt = nowIso();
|
|
161
|
+
await dbRun(`
|
|
162
|
+
UPDATE prd_states
|
|
163
|
+
SET progress_json = ?, updated_at = ?
|
|
164
|
+
WHERE repo_id = ? AND slug = ?
|
|
165
|
+
`, [JSON.stringify(normalized), updatedAt, row.repo_id, row.slug]);
|
|
166
|
+
status.migratedRows += 1;
|
|
167
|
+
emitChange({
|
|
168
|
+
type: 'change',
|
|
169
|
+
path: `state://${row.repo_id}/${row.slug}/progress.json`,
|
|
170
|
+
repoId: row.repo_id,
|
|
171
|
+
category: 'progress'
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
status.failedRows += 1;
|
|
177
|
+
}
|
|
178
|
+
status.processedRows = index + 1;
|
|
179
|
+
status.percent = toPercent(status.processedRows, status.totalRows);
|
|
180
|
+
}
|
|
181
|
+
if (status.failedRows > 0) {
|
|
182
|
+
markFailed(`Failed to migrate ${status.failedRows} PRD progress row(s).`);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
await writeMigrationMarker(status.totalRows, status.migratedRows);
|
|
186
|
+
markCompleted();
|
|
187
|
+
}
|
|
188
|
+
async function runMigration() {
|
|
189
|
+
try {
|
|
190
|
+
await migrateProgressRows();
|
|
191
|
+
}
|
|
192
|
+
catch (error) {
|
|
193
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
194
|
+
markFailed(message);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
export function getStateMigrationStatus() {
|
|
198
|
+
return { ...status };
|
|
199
|
+
}
|
|
200
|
+
export function startStateMigration() {
|
|
201
|
+
if (!migrationPromise) {
|
|
202
|
+
if (status.state === 'idle') {
|
|
203
|
+
status = {
|
|
204
|
+
...status,
|
|
205
|
+
state: 'running',
|
|
206
|
+
startedAt: nowIso(),
|
|
207
|
+
completedAt: null,
|
|
208
|
+
errorMessage: null,
|
|
209
|
+
percent: 0
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
migrationPromise = runMigration().finally(() => {
|
|
213
|
+
migrationPromise = null;
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
return migrationPromise;
|
|
217
|
+
}
|
|
218
|
+
export async function ensureStateMigrationReady() {
|
|
219
|
+
if (status.state !== 'completed') {
|
|
220
|
+
await startStateMigration();
|
|
221
|
+
}
|
|
222
|
+
if (status.state === 'failed') {
|
|
223
|
+
throw new Error(status.errorMessage || 'State migration failed');
|
|
224
|
+
}
|
|
225
|
+
}
|
|
@@ -6,6 +6,7 @@ const commitRefSchema = z.object({
|
|
|
6
6
|
sha: z.string().min(1),
|
|
7
7
|
repo: z.string()
|
|
8
8
|
});
|
|
9
|
+
const taskStatusValues = new Set(['pending', 'in_progress', 'completed']);
|
|
9
10
|
const taskSchema = z.object({
|
|
10
11
|
id: z.string().min(1),
|
|
11
12
|
category: taskCategorySchema,
|
|
@@ -38,6 +39,19 @@ const taskLogSchema = z.object({
|
|
|
38
39
|
learnings: z.string().optional(),
|
|
39
40
|
commits: z.array(z.union([z.string().min(1), commitRefSchema])).optional()
|
|
40
41
|
});
|
|
42
|
+
const progressPatternSchema = z.union([
|
|
43
|
+
z.string().trim().min(1).transform((value) => ({
|
|
44
|
+
name: value,
|
|
45
|
+
description: value
|
|
46
|
+
})),
|
|
47
|
+
z.object({
|
|
48
|
+
name: z.string().trim().min(1),
|
|
49
|
+
description: z.string().trim().min(1).optional()
|
|
50
|
+
}).transform((value) => ({
|
|
51
|
+
name: value.name,
|
|
52
|
+
description: value.description ?? value.name
|
|
53
|
+
}))
|
|
54
|
+
]);
|
|
41
55
|
const progressFileSchema = z.object({
|
|
42
56
|
prdName: z.string(),
|
|
43
57
|
shortcutStory: z.string().optional(),
|
|
@@ -47,12 +61,175 @@ const progressFileSchema = z.object({
|
|
|
47
61
|
blocked: z.number(),
|
|
48
62
|
startedAt: z.string().nullable(),
|
|
49
63
|
lastUpdated: z.string(),
|
|
50
|
-
patterns: z.array(
|
|
51
|
-
name: z.string(),
|
|
52
|
-
description: z.string()
|
|
53
|
-
})),
|
|
64
|
+
patterns: z.array(progressPatternSchema),
|
|
54
65
|
taskLogs: z.array(taskLogSchema)
|
|
55
66
|
});
|
|
67
|
+
const progressInputSchema = z.object({
|
|
68
|
+
prdName: z.string().trim().min(1).optional(),
|
|
69
|
+
startedAt: z.union([z.string(), z.null()]).optional(),
|
|
70
|
+
started: z.union([z.string(), z.null()]).optional(),
|
|
71
|
+
totalTasks: z.number().optional(),
|
|
72
|
+
completed: z.union([z.number(), z.array(z.unknown())]).optional(),
|
|
73
|
+
inProgress: z.union([z.number(), z.null()]).optional(),
|
|
74
|
+
blocked: z.number().optional(),
|
|
75
|
+
lastUpdated: z.string().optional(),
|
|
76
|
+
patterns: z.array(z.unknown()).optional(),
|
|
77
|
+
taskLogs: z.array(z.unknown()).optional(),
|
|
78
|
+
taskProgress: z.record(z.object({
|
|
79
|
+
status: z.string().optional(),
|
|
80
|
+
startedAt: z.string().optional(),
|
|
81
|
+
completedAt: z.string().optional(),
|
|
82
|
+
implemented: z.string().optional(),
|
|
83
|
+
filesChanged: z.array(z.string()).optional(),
|
|
84
|
+
learnings: z.string().optional(),
|
|
85
|
+
commits: z.array(z.union([z.string().min(1), commitRefSchema])).optional()
|
|
86
|
+
})).optional()
|
|
87
|
+
}).passthrough();
|
|
88
|
+
function coerceNonNegativeNumber(value) {
|
|
89
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
return Math.max(0, Math.floor(value));
|
|
93
|
+
}
|
|
94
|
+
function normalizeTaskLogStatus(value) {
|
|
95
|
+
if (typeof value === 'string' && taskStatusValues.has(value)) {
|
|
96
|
+
return value;
|
|
97
|
+
}
|
|
98
|
+
return 'pending';
|
|
99
|
+
}
|
|
100
|
+
function normalizeLegacyTaskProgress(taskProgress, now) {
|
|
101
|
+
if (!taskProgress) {
|
|
102
|
+
return [];
|
|
103
|
+
}
|
|
104
|
+
return Object.entries(taskProgress).map(([taskId, value]) => ({
|
|
105
|
+
taskId,
|
|
106
|
+
status: normalizeTaskLogStatus(value.status),
|
|
107
|
+
startedAt: typeof value.startedAt === 'string' ? value.startedAt : now,
|
|
108
|
+
...(typeof value.completedAt === 'string' && { completedAt: value.completedAt }),
|
|
109
|
+
...(typeof value.implemented === 'string' && { implemented: value.implemented }),
|
|
110
|
+
...(Array.isArray(value.filesChanged) && { filesChanged: value.filesChanged }),
|
|
111
|
+
...(typeof value.learnings === 'string' && { learnings: value.learnings }),
|
|
112
|
+
...(Array.isArray(value.commits) && { commits: value.commits })
|
|
113
|
+
}));
|
|
114
|
+
}
|
|
115
|
+
function normalizePatterns(patterns) {
|
|
116
|
+
if (!patterns) {
|
|
117
|
+
return [];
|
|
118
|
+
}
|
|
119
|
+
const normalized = [];
|
|
120
|
+
for (const pattern of patterns) {
|
|
121
|
+
const parsed = progressPatternSchema.safeParse(pattern);
|
|
122
|
+
if (parsed.success) {
|
|
123
|
+
normalized.push(parsed.data);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return normalized;
|
|
127
|
+
}
|
|
128
|
+
function normalizeTaskLogs(taskLogs, now) {
|
|
129
|
+
if (!taskLogs) {
|
|
130
|
+
return [];
|
|
131
|
+
}
|
|
132
|
+
const normalized = [];
|
|
133
|
+
for (const taskLog of taskLogs) {
|
|
134
|
+
const parsed = taskLogSchema.safeParse(taskLog);
|
|
135
|
+
if (parsed.success) {
|
|
136
|
+
normalized.push(parsed.data);
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (!taskLog || typeof taskLog !== 'object' || Array.isArray(taskLog)) {
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
const raw = taskLog;
|
|
143
|
+
if (typeof raw.taskId !== 'string' || raw.taskId.trim().length === 0) {
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
const startedAt = typeof raw.startedAt === 'string' ? raw.startedAt : now;
|
|
147
|
+
const commits = Array.isArray(raw.commits)
|
|
148
|
+
? raw.commits.filter((commit) => {
|
|
149
|
+
if (typeof commit === 'string' && commit.trim().length > 0) {
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
if (!commit || typeof commit !== 'object' || Array.isArray(commit)) {
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
const ref = commit;
|
|
156
|
+
return typeof ref.sha === 'string' && ref.sha.trim().length > 0 && typeof ref.repo === 'string';
|
|
157
|
+
})
|
|
158
|
+
: undefined;
|
|
159
|
+
normalized.push({
|
|
160
|
+
taskId: raw.taskId,
|
|
161
|
+
status: normalizeTaskLogStatus(raw.status),
|
|
162
|
+
startedAt,
|
|
163
|
+
...(typeof raw.completedAt === 'string' && { completedAt: raw.completedAt }),
|
|
164
|
+
...(typeof raw.implemented === 'string' && { implemented: raw.implemented }),
|
|
165
|
+
...(Array.isArray(raw.filesChanged) && {
|
|
166
|
+
filesChanged: raw.filesChanged.filter((file) => typeof file === 'string')
|
|
167
|
+
}),
|
|
168
|
+
...(typeof raw.learnings === 'string' && { learnings: raw.learnings }),
|
|
169
|
+
...(commits && commits.length > 0 && { commits })
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
return normalized;
|
|
173
|
+
}
|
|
174
|
+
export function normalizeProgressFile(value, options = {}) {
|
|
175
|
+
const now = options.now || new Date().toISOString();
|
|
176
|
+
const parsed = progressInputSchema.safeParse(value);
|
|
177
|
+
const input = parsed.success ? parsed.data : {};
|
|
178
|
+
const prdName = input.prdName
|
|
179
|
+
|| options.prdNameFallback
|
|
180
|
+
|| 'Unknown PRD';
|
|
181
|
+
const patterns = normalizePatterns(input.patterns);
|
|
182
|
+
const taskLogs = input.taskLogs && input.taskLogs.length > 0
|
|
183
|
+
? normalizeTaskLogs(input.taskLogs, now)
|
|
184
|
+
: normalizeLegacyTaskProgress(input.taskProgress, now);
|
|
185
|
+
const completed = typeof input.completed === 'number'
|
|
186
|
+
? Math.max(0, Math.floor(input.completed))
|
|
187
|
+
: (Array.isArray(input.completed) ? input.completed.length : taskLogs.filter((log) => log.status === 'completed').length);
|
|
188
|
+
const inProgress = coerceNonNegativeNumber(input.inProgress)
|
|
189
|
+
?? taskLogs.filter((log) => log.status === 'in_progress').length;
|
|
190
|
+
const blocked = coerceNonNegativeNumber(input.blocked) ?? 0;
|
|
191
|
+
const totalTasks = coerceNonNegativeNumber(input.totalTasks)
|
|
192
|
+
?? coerceNonNegativeNumber(options.totalTasksHint)
|
|
193
|
+
?? Math.max(completed + inProgress + blocked, taskLogs.length);
|
|
194
|
+
return progressFileSchema.parse({
|
|
195
|
+
prdName,
|
|
196
|
+
shortcutStory: undefined,
|
|
197
|
+
totalTasks,
|
|
198
|
+
completed,
|
|
199
|
+
inProgress,
|
|
200
|
+
blocked,
|
|
201
|
+
startedAt: input.startedAt ?? input.started ?? null,
|
|
202
|
+
lastUpdated: input.lastUpdated || now,
|
|
203
|
+
patterns,
|
|
204
|
+
taskLogs
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
export function parseStoredProgressFile(value, options = {}) {
|
|
208
|
+
return normalizeProgressFile(value, options);
|
|
209
|
+
}
|
|
210
|
+
export function needsProgressMigration(value) {
|
|
211
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
212
|
+
return true;
|
|
213
|
+
}
|
|
214
|
+
const record = value;
|
|
215
|
+
if (Object.prototype.hasOwnProperty.call(record, 'started')) {
|
|
216
|
+
return true;
|
|
217
|
+
}
|
|
218
|
+
if (Object.prototype.hasOwnProperty.call(record, 'taskProgress')) {
|
|
219
|
+
return true;
|
|
220
|
+
}
|
|
221
|
+
if (!Array.isArray(record.patterns) || !Array.isArray(record.taskLogs)) {
|
|
222
|
+
return true;
|
|
223
|
+
}
|
|
224
|
+
if (Array.isArray(record.completed)) {
|
|
225
|
+
return true;
|
|
226
|
+
}
|
|
227
|
+
if (record.inProgress === null) {
|
|
228
|
+
return true;
|
|
229
|
+
}
|
|
230
|
+
const result = progressFileSchema.safeParse(value);
|
|
231
|
+
return !result.success;
|
|
232
|
+
}
|
|
56
233
|
export function parseTasksFile(value) {
|
|
57
234
|
return tasksFileSchema.parse(value);
|
|
58
235
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { promises as fs } from 'node:fs';
|
|
2
|
-
import {
|
|
2
|
+
import { getPrdArchiveMap } from './prd-archive.js';
|
|
3
|
+
import { getPrdState, getPrdStateSummaries } from './prd-state.js';
|
|
3
4
|
import { resolvePrdMarkdownPath } from './prd-service.js';
|
|
4
5
|
const NODE_SEPARATOR = '::';
|
|
5
6
|
const MISSING_PREFIX = 'missing';
|
|
@@ -139,7 +140,6 @@ function buildGraphFromInputs(inputs) {
|
|
|
139
140
|
};
|
|
140
141
|
}
|
|
141
142
|
export async function buildPrdGraph(repo, prdSlug) {
|
|
142
|
-
await migrateLegacyStateForRepo(repo);
|
|
143
143
|
const [state, prdName] = await Promise.all([
|
|
144
144
|
getPrdState(repo.id, prdSlug),
|
|
145
145
|
resolvePrdName(repo, prdSlug)
|
|
@@ -161,23 +161,30 @@ export async function buildPrdGraph(repo, prdSlug) {
|
|
|
161
161
|
stats: graph.stats
|
|
162
162
|
};
|
|
163
163
|
}
|
|
164
|
-
export async function buildRepoGraph(repo) {
|
|
165
|
-
|
|
166
|
-
const summaries = await
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
164
|
+
export async function buildRepoGraph(repo, options = {}) {
|
|
165
|
+
const includeArchived = options.includeArchived === true;
|
|
166
|
+
const [summaries, archiveMap] = await Promise.all([
|
|
167
|
+
getPrdStateSummaries(repo.id),
|
|
168
|
+
getPrdArchiveMap(repo.id)
|
|
169
|
+
]);
|
|
170
|
+
const slugs = [...summaries.keys()]
|
|
171
|
+
.filter((slug) => includeArchived || !archiveMap.has(slug))
|
|
172
|
+
.sort((a, b) => a.localeCompare(b));
|
|
173
|
+
const inputs = (await Promise.all(slugs.map(async (slug) => {
|
|
174
|
+
const [state, prdName] = await Promise.all([
|
|
175
|
+
getPrdState(repo.id, slug),
|
|
176
|
+
resolvePrdName(repo, slug)
|
|
177
|
+
]);
|
|
171
178
|
const tasks = state?.tasks?.tasks;
|
|
172
179
|
if (!Array.isArray(tasks)) {
|
|
173
|
-
|
|
180
|
+
return null;
|
|
174
181
|
}
|
|
175
|
-
|
|
182
|
+
return {
|
|
176
183
|
prdSlug: slug,
|
|
177
|
-
prdName
|
|
184
|
+
prdName,
|
|
178
185
|
tasks
|
|
179
|
-
}
|
|
180
|
-
}
|
|
186
|
+
};
|
|
187
|
+
}))).filter((input) => input !== null);
|
|
181
188
|
const graph = buildGraphFromInputs(inputs);
|
|
182
189
|
return {
|
|
183
190
|
scope: 'repo',
|