@thxgg/steward 0.1.18 → 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.
Files changed (111) hide show
  1. package/.output/nitro.json +1 -1
  2. package/.output/public/_nuxt/{BA4e9-N5.js → BCiXS3ZV.js} +2 -2
  3. package/.output/public/_nuxt/BfUfZSFp.js +60 -0
  4. package/.output/public/_nuxt/{C_HVaH3B.js → BfV4oCiT.js} +1 -1
  5. package/.output/public/_nuxt/{CGzrvVc6.js → Bs6UO7IT.js} +1 -1
  6. package/.output/public/_nuxt/C9AsKFSQ.js +1 -0
  7. package/.output/public/_nuxt/{CJlXUkTg.js → C9jB6HQI.js} +1 -1
  8. package/.output/public/_nuxt/CCruYste.js +3 -0
  9. package/.output/public/_nuxt/CIIE0-WR.js +1 -0
  10. package/.output/public/_nuxt/{-z_Gr0GN.js → Cg4hnDua.js} +1 -1
  11. package/.output/public/_nuxt/CwdD8083.js +30 -0
  12. package/.output/public/_nuxt/{DAnnHVQP.js → D2RLSKEu.js} +1 -1
  13. package/.output/public/_nuxt/{DEr8q68O.js → D30YtxUg.js} +1 -1
  14. package/.output/public/_nuxt/{QAzsKGuP.js → DEekox9p.js} +1 -1
  15. package/.output/public/_nuxt/{WUF6Thhn.js → DddVAa3N.js} +1 -1
  16. package/.output/public/_nuxt/{TSsR_oCL.js → MO41rxll.js} +1 -1
  17. package/.output/public/_nuxt/builds/latest.json +1 -1
  18. package/.output/public/_nuxt/builds/meta/627332c0-2e14-4849-8cac-e350f64ed513.json +1 -0
  19. package/.output/public/_nuxt/entry.Dp3jx0Yw.css +1 -0
  20. package/.output/public/_nuxt/f7vKgp5U.js +1 -0
  21. package/.output/public/_nuxt/xc3v2JZH.js +1 -0
  22. package/.output/server/chunks/_/prd-service.mjs +101 -68
  23. package/.output/server/chunks/_/prd-service.mjs.map +1 -1
  24. package/.output/server/chunks/_/repos.mjs +3 -179
  25. package/.output/server/chunks/_/repos.mjs.map +1 -1
  26. package/.output/server/chunks/_/task-graph.mjs +8 -4
  27. package/.output/server/chunks/_/task-graph.mjs.map +1 -1
  28. package/.output/server/chunks/_/watcher.mjs +2 -32
  29. package/.output/server/chunks/_/watcher.mjs.map +1 -1
  30. package/.output/server/chunks/build/{Detail-BQSkP9Zm.mjs → Detail-BcQGdJY5.mjs} +5 -6
  31. package/.output/server/chunks/build/Detail-BcQGdJY5.mjs.map +1 -0
  32. package/.output/server/chunks/build/{_prd_-CBR_wm9i.mjs → _prd_-CD_Bds_B.mjs} +81 -6
  33. package/.output/server/chunks/build/_prd_-CD_Bds_B.mjs.map +1 -0
  34. package/.output/server/chunks/build/client.precomputed.mjs +1 -1
  35. package/.output/server/chunks/build/{default-Cao5eO80.mjs → default-BKKgG7HJ.mjs} +221 -23
  36. package/.output/server/chunks/build/default-BKKgG7HJ.mjs.map +1 -0
  37. package/.output/server/chunks/build/error-404-Bf6kdO80.mjs +2 -0
  38. package/.output/server/chunks/build/error-500-D_bcARXN.mjs +2 -0
  39. package/.output/server/chunks/build/{index-ljj9uTXI.mjs → index-DE1tjHAd.mjs} +4 -3
  40. package/.output/server/chunks/build/index-DE1tjHAd.mjs.map +1 -0
  41. package/.output/server/chunks/build/nuxt-link-SvT1nf8Z.mjs +1 -1
  42. package/.output/server/chunks/build/{repo-graph-CVnkmn8i.mjs → repo-graph-CBfhpnd5.mjs} +26 -11
  43. package/.output/server/chunks/build/repo-graph-CBfhpnd5.mjs.map +1 -0
  44. package/.output/server/chunks/build/server.mjs +15 -13
  45. package/.output/server/chunks/build/styles.mjs +2 -2
  46. package/.output/server/chunks/build/{usePrd-f7ylhIqs.mjs → usePrd-hXZOmvAv.mjs} +113 -9
  47. package/.output/server/chunks/build/usePrd-hXZOmvAv.mjs.map +1 -0
  48. package/.output/server/chunks/nitro/nitro.mjs +1292 -580
  49. package/.output/server/chunks/nitro/nitro.mjs.map +1 -1
  50. package/.output/server/chunks/routes/api/index.get.mjs +2 -1
  51. package/.output/server/chunks/routes/api/index.get.mjs.map +1 -1
  52. package/.output/server/chunks/routes/api/index.post.mjs +2 -2
  53. package/.output/server/chunks/routes/api/repos/_repoId/git/commits.get.mjs +2 -1
  54. package/.output/server/chunks/routes/api/repos/_repoId/git/commits.get.mjs.map +1 -1
  55. package/.output/server/chunks/routes/api/repos/_repoId/git/diff.get.mjs +2 -1
  56. package/.output/server/chunks/routes/api/repos/_repoId/git/diff.get.mjs.map +1 -1
  57. package/.output/server/chunks/routes/api/repos/_repoId/git/file-content.get.mjs +2 -1
  58. package/.output/server/chunks/routes/api/repos/_repoId/git/file-content.get.mjs.map +1 -1
  59. package/.output/server/chunks/routes/api/repos/_repoId/git/file-diff.get.mjs +2 -1
  60. package/.output/server/chunks/routes/api/repos/_repoId/git/file-diff.get.mjs.map +1 -1
  61. package/.output/server/chunks/routes/api/repos/_repoId/graph.get.mjs +27 -4
  62. package/.output/server/chunks/routes/api/repos/_repoId/graph.get.mjs.map +1 -1
  63. package/.output/server/chunks/routes/api/repos/_repoId/prd/_prdSlug/archive.post.mjs +93 -0
  64. package/.output/server/chunks/routes/api/repos/_repoId/prd/_prdSlug/archive.post.mjs.map +1 -0
  65. package/.output/server/chunks/routes/api/repos/_repoId/prd/_prdSlug/graph.get.mjs +2 -2
  66. package/.output/server/chunks/routes/api/repos/_repoId/prd/_prdSlug/progress.get.mjs +3 -3
  67. package/.output/server/chunks/routes/api/repos/_repoId/prd/_prdSlug/tasks/_taskId/commits.get.mjs +3 -3
  68. package/.output/server/chunks/routes/api/repos/_repoId/prd/_prdSlug/tasks.get.mjs +3 -3
  69. package/.output/server/chunks/routes/api/repos/_repoId/prd/_prdSlug_.get.mjs +2 -2
  70. package/.output/server/chunks/routes/api/repos/_repoId/prds.get.mjs +27 -4
  71. package/.output/server/chunks/routes/api/repos/_repoId/prds.get.mjs.map +1 -1
  72. package/.output/server/chunks/routes/api/repos/_repoId/refresh-git-repos.post.mjs +2 -1
  73. package/.output/server/chunks/routes/api/repos/_repoId/refresh-git-repos.post.mjs.map +1 -1
  74. package/.output/server/chunks/routes/api/repos/_repoId_.delete.mjs +2 -1
  75. package/.output/server/chunks/routes/api/repos/_repoId_.delete.mjs.map +1 -1
  76. package/.output/server/chunks/routes/api/runtime.get.mjs +2 -0
  77. package/.output/server/chunks/routes/api/runtime.get.mjs.map +1 -1
  78. package/.output/server/chunks/routes/api/state-migration/status.get.mjs +21 -0
  79. package/.output/server/chunks/routes/api/state-migration/status.get.mjs.map +1 -0
  80. package/.output/server/chunks/routes/api/watch.get.mjs +4 -3
  81. package/.output/server/chunks/routes/api/watch.get.mjs.map +1 -1
  82. package/.output/server/chunks/routes/renderer.mjs +1 -1
  83. package/.output/server/index.mjs +3 -1
  84. package/.output/server/index.mjs.map +1 -1
  85. package/.output/server/package.json +1 -1
  86. package/README.md +3 -0
  87. package/dist/server/utils/db.js +15 -0
  88. package/dist/server/utils/prd-archive.js +53 -0
  89. package/dist/server/utils/prd-service.js +26 -6
  90. package/dist/server/utils/prd-state.js +11 -2
  91. package/dist/server/utils/state-migration.js +225 -0
  92. package/dist/server/utils/state-schema.js +181 -4
  93. package/dist/server/utils/task-graph.js +10 -3
  94. package/package.json +1 -1
  95. package/.output/public/_nuxt/5LlyHjkF.js +0 -60
  96. package/.output/public/_nuxt/BA0u_CRT.js +0 -1
  97. package/.output/public/_nuxt/BO8EM227.js +0 -3
  98. package/.output/public/_nuxt/C0XT5P3Q.js +0 -1
  99. package/.output/public/_nuxt/CZsXZugv.js +0 -1
  100. package/.output/public/_nuxt/DrXxYwWw.js +0 -30
  101. package/.output/public/_nuxt/builds/meta/19e0e040-a531-4c25-b46d-a6ca54a1ae3e.json +0 -1
  102. package/.output/public/_nuxt/entry.LcDOtJnR.css +0 -1
  103. package/.output/public/_nuxt/i9wn3hS7.js +0 -1
  104. package/.output/server/chunks/build/Detail-BQSkP9Zm.mjs.map +0 -1
  105. package/.output/server/chunks/build/_prd_-CBR_wm9i.mjs.map +0 -1
  106. package/.output/server/chunks/build/default-Cao5eO80.mjs.map +0 -1
  107. package/.output/server/chunks/build/index-ByZO4Bvq.mjs +0 -76
  108. package/.output/server/chunks/build/index-ByZO4Bvq.mjs.map +0 -1
  109. package/.output/server/chunks/build/index-ljj9uTXI.mjs.map +0 -1
  110. package/.output/server/chunks/build/repo-graph-CVnkmn8i.mjs.map +0 -1
  111. package/.output/server/chunks/build/usePrd-f7ylhIqs.mjs.map +0 -1
@@ -1,5 +1,5 @@
1
1
  import { createRenderer, getRequestDependencies, getPreloadLinks, getPrefetchLinks } from 'vue-bundle-renderer/runtime';
2
- import { j as joinRelativeURL, u as useRuntimeConfig, b as getResponseStatusText, e as getResponseStatus, f as defineRenderHandler, g as getQuery, c as createError, h as destr, i as getRouteRules, k as joinURL, l as useNitroApp } from '../nitro/nitro.mjs';
2
+ import { n as joinRelativeURL, u as useRuntimeConfig, o as getResponseStatusText, q as getResponseStatus, t as defineRenderHandler, g as getQuery, c as createError, v as destr, w as getRouteRules, x as joinURL, y as useNitroApp } from '../nitro/nitro.mjs';
3
3
  import { renderToString } from 'vue/server-renderer';
4
4
  import { createHead as createHead$1, propsToString, renderSSRHead } from 'unhead/server';
5
5
  import { stringify, uneval } from 'devalue';
@@ -1,10 +1,12 @@
1
1
  import process from 'node:process';globalThis._importMeta_={url:import.meta.url,env:process.env};import 'node:http';
2
2
  import 'node:https';
3
- export { E as default } from './chunks/nitro/nitro.mjs';
3
+ export { O as default } from './chunks/nitro/nitro.mjs';
4
4
  import 'node:events';
5
5
  import 'node:buffer';
6
6
  import 'node:fs';
7
7
  import 'node:path';
8
8
  import 'node:crypto';
9
+ import 'node:os';
10
+ import 'zod';
9
11
  import 'node:url';
10
12
  //# sourceMappingURL=index.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.mjs","sources":[],"names":[],"mappings":";;;;;;;;"}
1
+ {"version":3,"file":"index.mjs","sources":[],"names":[],"mappings":";;;;;;;;;;"}
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thxgg/steward-prod",
3
- "version": "0.1.18",
3
+ "version": "0.1.19",
4
4
  "type": "module",
5
5
  "private": true,
6
6
  "dependencies": {
package/README.md CHANGED
@@ -146,6 +146,9 @@ Steward reads local filesystem and git metadata by design.
146
146
  - UI/API accept loopback requests only
147
147
  - Non-loopback requests are rejected
148
148
  - Treat as a workstation tool, not a hosted multi-user service
149
+ - `npm run dev` skips loopback enforcement because Nuxt dev proxying can mask loopback source addresses
150
+
151
+ On startup, Steward also performs a one-time automatic state migration when legacy `progress_json` data is detected. During this migration, the UI shows a blocking progress overlay until migration completes.
149
152
 
150
153
  ## Storage
151
154
 
@@ -182,7 +182,22 @@ async function initializeDatabase() {
182
182
  FOREIGN KEY (repo_id) REFERENCES repos(id) ON DELETE CASCADE
183
183
  );
184
184
 
185
+ CREATE TABLE IF NOT EXISTS prd_archives (
186
+ repo_id TEXT NOT NULL,
187
+ slug TEXT NOT NULL,
188
+ archived_at TEXT NOT NULL,
189
+ PRIMARY KEY (repo_id, slug),
190
+ FOREIGN KEY (repo_id) REFERENCES repos(id) ON DELETE CASCADE
191
+ );
192
+
193
+ CREATE TABLE IF NOT EXISTS app_meta (
194
+ key TEXT PRIMARY KEY,
195
+ value TEXT NOT NULL,
196
+ updated_at TEXT NOT NULL
197
+ );
198
+
185
199
  CREATE INDEX IF NOT EXISTS idx_prd_states_repo_id ON prd_states(repo_id);
200
+ CREATE INDEX IF NOT EXISTS idx_prd_archives_repo_id ON prd_archives(repo_id);
186
201
  `);
187
202
  return adapter;
188
203
  }
@@ -0,0 +1,53 @@
1
+ import { emitChange } from './change-events.js';
2
+ import { dbAll, dbGet, dbRun } from './db.js';
3
+ function toArchiveState(archivedAt) {
4
+ if (!archivedAt) {
5
+ return { archived: false };
6
+ }
7
+ return {
8
+ archived: true,
9
+ archivedAt
10
+ };
11
+ }
12
+ export async function getPrdArchiveMap(repoId) {
13
+ const rows = await dbAll('SELECT slug, archived_at FROM prd_archives WHERE repo_id = ?', [repoId]);
14
+ const archiveMap = new Map();
15
+ for (const row of rows) {
16
+ archiveMap.set(row.slug, row.archived_at);
17
+ }
18
+ return archiveMap;
19
+ }
20
+ export async function getPrdArchiveState(repoId, slug) {
21
+ const row = await dbGet('SELECT archived_at FROM prd_archives WHERE repo_id = ? AND slug = ?', [repoId, slug]);
22
+ return toArchiveState(row?.archived_at);
23
+ }
24
+ export async function setPrdArchived(repoId, slug, archived) {
25
+ if (archived) {
26
+ const archivedAt = new Date().toISOString();
27
+ const result = await dbRun(`
28
+ INSERT INTO prd_archives (repo_id, slug, archived_at)
29
+ VALUES (?, ?, ?)
30
+ ON CONFLICT(repo_id, slug) DO NOTHING
31
+ `, [repoId, slug, archivedAt]);
32
+ const row = await dbGet('SELECT archived_at FROM prd_archives WHERE repo_id = ? AND slug = ?', [repoId, slug]);
33
+ if (result.changes > 0) {
34
+ emitChange({
35
+ type: 'change',
36
+ path: `state://${repoId}/${slug}.archive`,
37
+ repoId,
38
+ category: 'prd'
39
+ });
40
+ }
41
+ return toArchiveState(row?.archived_at || archivedAt);
42
+ }
43
+ const result = await dbRun('DELETE FROM prd_archives WHERE repo_id = ? AND slug = ?', [repoId, slug]);
44
+ if (result.changes > 0) {
45
+ emitChange({
46
+ type: 'change',
47
+ path: `state://${repoId}/${slug}.archive`,
48
+ repoId,
49
+ category: 'prd'
50
+ });
51
+ }
52
+ return { archived: false };
53
+ }
@@ -1,6 +1,7 @@
1
1
  import { promises as fs } from 'node:fs';
2
2
  import { basename, isAbsolute, join, relative, resolve } from 'node:path';
3
3
  import { resolveCommitRepo } from './git.js';
4
+ import { getPrdArchiveMap, getPrdArchiveState } from './prd-archive.js';
4
5
  import { getPrdState, getPrdStateSummaries } from './prd-state.js';
5
6
  import { discoverGitRepos, updateRepoGitRepos } from './repos.js';
6
7
  const PRD_SLUG_PATTERN = /^[A-Za-z0-9][A-Za-z0-9-]*$/;
@@ -83,14 +84,18 @@ export async function readPrdDocument(repo, prdSlug) {
83
84
  catch {
84
85
  throw new Error('PRD not found');
85
86
  }
87
+ const archiveState = await getPrdArchiveState(repo.id, prdSlug);
86
88
  return {
87
89
  slug: prdSlug,
88
90
  name: extractPrdTitle(content, prdSlug),
89
91
  content,
90
- metadata: parseMetadata(content)
92
+ metadata: parseMetadata(content),
93
+ archived: archiveState.archived,
94
+ ...(archiveState.archivedAt && { archivedAt: archiveState.archivedAt })
91
95
  };
92
96
  }
93
- export async function listPrdDocuments(repo) {
97
+ export async function listPrdDocuments(repo, options = {}) {
98
+ const includeArchived = options.includeArchived === true;
94
99
  const prdDir = join(repo.path, 'docs', 'prd');
95
100
  let prdFiles = [];
96
101
  try {
@@ -106,9 +111,17 @@ export async function listPrdDocuments(repo) {
106
111
  catch {
107
112
  return [];
108
113
  }
109
- const stateSummaries = await getPrdStateSummaries(repo.id);
110
- const items = await Promise.all(prdFiles.map(async (filename) => {
114
+ const [stateSummaries, archiveMap] = await Promise.all([
115
+ getPrdStateSummaries(repo.id),
116
+ getPrdArchiveMap(repo.id)
117
+ ]);
118
+ const items = (await Promise.all(prdFiles.map(async (filename) => {
111
119
  const slug = basename(filename, '.md');
120
+ const archivedAt = archiveMap.get(slug);
121
+ const archived = typeof archivedAt === 'string';
122
+ if (!includeArchived && archived) {
123
+ return null;
124
+ }
112
125
  const filePath = join(prdDir, filename);
113
126
  let name = slug;
114
127
  let modifiedAt = 0;
@@ -130,11 +143,18 @@ export async function listPrdDocuments(repo) {
130
143
  source: `docs/prd/${filename}`,
131
144
  hasState: !!stateSummary?.hasState,
132
145
  modifiedAt,
146
+ archived,
147
+ ...(archivedAt && { archivedAt }),
133
148
  ...(stateSummary?.taskCount !== undefined && { taskCount: stateSummary.taskCount }),
134
149
  ...(stateSummary?.completedCount !== undefined && { completedCount: stateSummary.completedCount })
135
150
  };
136
- }));
137
- items.sort((a, b) => b.modifiedAt - a.modifiedAt);
151
+ }))).filter((item) => item !== null);
152
+ items.sort((a, b) => {
153
+ if (a.archived !== b.archived) {
154
+ return a.archived ? 1 : -1;
155
+ }
156
+ return b.modifiedAt - a.modifiedAt;
157
+ });
138
158
  return items;
139
159
  }
140
160
  export async function readPrdTasks(repo, prdSlug) {
@@ -1,6 +1,7 @@
1
1
  import { emitChange } from './change-events.js';
2
2
  import { dbAll, dbGet, dbRun } from './db.js';
3
- import { parseProgressFile, parseTasksFile } from './state-schema.js';
3
+ import { ensureStateMigrationReady } from './state-migration.js';
4
+ import { parseProgressFile, parseStoredProgressFile, parseTasksFile } from './state-schema.js';
4
5
  function parseStoredJson(raw, fieldName, parseValue) {
5
6
  if (!raw) {
6
7
  return null;
@@ -23,6 +24,7 @@ function getTaskCounts(tasksFile) {
23
24
  return { taskCount, completedCount };
24
25
  }
25
26
  export async function getPrdState(repoId, slug) {
27
+ await ensureStateMigrationReady();
26
28
  const row = await dbGet(`
27
29
  SELECT repo_id, slug, tasks_json, progress_json, notes_md, updated_at
28
30
  FROM prd_states
@@ -32,7 +34,12 @@ export async function getPrdState(repoId, slug) {
32
34
  return null;
33
35
  }
34
36
  const tasks = parseStoredJson(row.tasks_json, 'prd_states.tasks_json', parseTasksFile);
35
- const progress = parseStoredJson(row.progress_json, 'prd_states.progress_json', parseProgressFile);
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
+ }));
36
43
  return {
37
44
  slug: row.slug,
38
45
  tasks,
@@ -42,6 +49,7 @@ export async function getPrdState(repoId, slug) {
42
49
  };
43
50
  }
44
51
  export async function getPrdStateSummaries(repoId) {
52
+ await ensureStateMigrationReady();
45
53
  const rows = await dbAll('SELECT slug, tasks_json FROM prd_states WHERE repo_id = ?', [repoId]);
46
54
  const summaries = new Map();
47
55
  for (const row of rows) {
@@ -64,6 +72,7 @@ export async function getPrdStateSummaries(repoId) {
64
72
  return summaries;
65
73
  }
66
74
  export async function upsertPrdState(repoId, slug, update) {
75
+ await ensureStateMigrationReady();
67
76
  const validatedTasks = update.tasks === undefined
68
77
  ? undefined
69
78
  : (update.tasks === null ? null : parseTasksFile(update.tasks));
@@ -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(z.object({
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
  }