@yemi33/minions 0.1.2378 → 0.1.2380
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/bin/minions.js +19 -9
- package/dashboard/js/refresh.js +2 -3
- package/dashboard/js/render-prd.js +1 -1
- package/dashboard/js/render-work-items.js +13 -6
- package/dashboard.js +393 -721
- package/docs/README.md +1 -0
- package/docs/architecture-review-2026-07-09.md +2 -4
- package/docs/auto-discovery.md +36 -49
- package/docs/blog-first-successful-dispatch.md +1 -1
- package/docs/branch-derivation.md +2 -2
- package/docs/command-center.md +1 -1
- package/docs/completion-reports.md +14 -4
- package/docs/constellation-bridge.md +59 -10
- package/docs/constellation-style-telemetry.md +6 -6
- package/docs/cooldown-merge-semantics.md +4 -0
- package/docs/copilot-cli-schema.md +3 -3
- package/docs/cross-repo-plans.md +17 -17
- package/docs/deprecated.json +2 -2
- package/docs/design-inbox-entries-schema.md +31 -39
- package/docs/design-state-storage.md +1 -1
- package/docs/documentation-audit-2026-07-09.md +2 -2
- package/docs/engine-restart.md +20 -8
- package/docs/harness-mode.md +1 -1
- package/docs/managed-spawn.md +4 -4
- package/docs/onboarding.md +1 -2
- package/docs/pr-comment-followup.md +3 -3
- package/docs/pr-review-fix-loop.md +1 -1
- package/docs/proposals/repo-pool-for-live-checkout.md +1 -2
- package/docs/qa-runbook-lifecycle.md +4 -4
- package/docs/qa-runbooks.md +2 -2
- package/docs/rfc-completion-json.md +4 -1
- package/docs/runtime-adapters.md +1 -1
- package/docs/self-improvement.md +4 -5
- package/docs/shared-lifecycle-module-map.md +3 -1
- package/docs/slim-ux/architecture-suggestions.md +5 -6
- package/docs/slim-ux/concepts.md +23 -25
- package/docs/watches.md +7 -7
- package/docs/workspace-manifests.md +1 -1
- package/docs/worktree-lifecycle.md +1 -1
- package/engine/abandoned-pr-reconciliation.js +4 -5
- package/engine/acp-transport.js +49 -8
- package/engine/ado-status.js +5 -5
- package/engine/ado.js +20 -25
- package/engine/agent-worker-pool.js +124 -15
- package/engine/bridge.js +260 -5
- package/engine/cleanup.js +48 -131
- package/engine/cli.js +125 -83
- package/engine/cooldown.js +9 -16
- package/engine/db/index.js +22 -9
- package/engine/db/migrations/009-qa.js +1 -1
- package/engine/db/migrations/020-qa-session-scopes.js +23 -0
- package/engine/db/migrations/021-archived-work-items.js +72 -0
- package/engine/db/migrations/022-global-cc-session.js +31 -0
- package/engine/db/migrations/023-engine-state.js +30 -0
- package/engine/db/migrations/024-prd-ghost-collisions.js +53 -0
- package/engine/db/migrations/025-malformed-work-item-phantoms.js +33 -0
- package/engine/dispatch-store.js +2 -7
- package/engine/dispatch.js +34 -41
- package/engine/github.js +20 -27
- package/engine/lifecycle.js +221 -283
- package/engine/llm.js +1 -1
- package/engine/logs-store.js +2 -2
- package/engine/managed-spawn.js +2 -23
- package/engine/meeting.js +6 -6
- package/engine/metrics-store.js +2 -2
- package/engine/note-link-backfill.js +6 -11
- package/engine/pipeline.js +18 -36
- package/engine/playbook.js +1 -1
- package/engine/pooled-agent-process.js +46 -26
- package/engine/prd-store.js +73 -54
- package/engine/preflight.js +2 -5
- package/engine/projects.js +15 -62
- package/engine/pull-requests-store.js +0 -17
- package/engine/qa-runbooks.js +2 -2
- package/engine/qa-runs.js +1 -8
- package/engine/qa-sessions.js +41 -64
- package/engine/queries.js +120 -219
- package/engine/routing.js +3 -5
- package/engine/scheduler.js +0 -4
- package/engine/shared-branch-pr-reconcile.js +2 -3
- package/engine/shared.js +132 -637
- package/engine/small-state-store.js +89 -10
- package/engine/state-operations.js +16 -4
- package/engine/stdio-timestamps.js +1 -1
- package/engine/timeout.js +5 -12
- package/engine/watch-actions.js +20 -22
- package/engine/watches-store.js +1 -1
- package/engine/watches.js +6 -10
- package/engine/work-item-validation.js +52 -0
- package/engine/work-items-store.js +127 -29
- package/engine/worktree-gc.js +2 -2
- package/engine/worktree-pool.js +8 -18
- package/engine.js +167 -349
- package/minions.js +2 -2
- package/package.json +1 -1
- package/playbooks/plan-to-prd.md +2 -2
- package/playbooks/shared-rules.md +3 -3
- package/playbooks/templates/followup-dispatch.md +1 -1
- package/playbooks/verify.md +1 -1
- package/prompts/cc-system.md +2 -2
package/dashboard.js
CHANGED
|
@@ -66,13 +66,14 @@ const features = require('./engine/features');
|
|
|
66
66
|
const ccWorkerPool = require('./engine/cc-worker-pool');
|
|
67
67
|
const diagnosticsMemory = require('./engine/diagnostics-memory');
|
|
68
68
|
const memoryStore = require('./engine/memory-store');
|
|
69
|
+
const smallStateStore = require('./engine/small-state-store');
|
|
69
70
|
const os = require('os');
|
|
70
71
|
|
|
71
72
|
const { safeRead, safeReadOrNull, safeReadDir, safeWrite, safeJson, safeJsonObj, safeJsonArr, safeJsonNoRestore, safeUnlink, mutateJsonFileLocked, mutateTextFileLocked, mutateControl, mutateCooldowns, mutateWorkItems, getProjects: _getProjects, DONE_STATUSES, WI_STATUS, WORK_TYPE, WORKTREE_REQUIRING_TYPES, reopenWorkItem } = shared;
|
|
72
73
|
const { getAgents, getAgentDetail, getPrdInfo, getWorkItems, getDispatchQueue,
|
|
73
74
|
getSkills, getCommands, getInbox, getNotesWithMeta, getPullRequests,
|
|
74
75
|
getEngineLog, getMetrics, getKnowledgeBaseEntries, getKnowledgeBaseEntriesSnapshot, getProjectGitStatus, timeSince,
|
|
75
|
-
MINIONS_DIR, AGENTS_DIR, ENGINE_DIR, INBOX_DIR,
|
|
76
|
+
MINIONS_DIR, AGENTS_DIR, ENGINE_DIR, INBOX_DIR, PRD_DIR } = queries;
|
|
76
77
|
|
|
77
78
|
// Dev vs binary differentiation. When two dashboards run side-by-side (npm
|
|
78
79
|
// install on 7331, local checkout on 7332), the favicon and title need to
|
|
@@ -230,7 +231,7 @@ function ensureConfiguredProjectStateFiles() {
|
|
|
230
231
|
const root = p.localPath ? path.resolve(p.localPath) : null;
|
|
231
232
|
if (!root || !fs.existsSync(root)) continue;
|
|
232
233
|
try {
|
|
233
|
-
shared.
|
|
234
|
+
shared.initializeProjectState(p);
|
|
234
235
|
} catch (e) {
|
|
235
236
|
console.warn(`[dashboard] project state setup failed for "${p.name}": ${e.message}`);
|
|
236
237
|
}
|
|
@@ -609,7 +610,7 @@ function inferActionPrRecord(action, prs, project = null) {
|
|
|
609
610
|
// real pull request before `copyWorkItemPrFields` promotes it to structured
|
|
610
611
|
// targetPr/pr_id/prNumber fields. GitHub & ADO issues share the PR number
|
|
611
612
|
// namespace, so a bare `owner/repo#244` / `#244` can be an ISSUE. Stamping an
|
|
612
|
-
// issue mints a phantom
|
|
613
|
+
// issue mints a phantom tracked PR record (agent 'human', goes
|
|
613
614
|
// abandoned) AND routes the dispatch through the PR-fix path instead of
|
|
614
615
|
// implement-and-open-a-fresh-PR.
|
|
615
616
|
//
|
|
@@ -767,7 +768,7 @@ async function _fetchLivePrRecord(canonicalId, opts) {
|
|
|
767
768
|
function _findTrackedPrRecord(prRef, project) {
|
|
768
769
|
if (!project) return null;
|
|
769
770
|
try {
|
|
770
|
-
const prs = shared.
|
|
771
|
+
const prs = shared.readPullRequests(project);
|
|
771
772
|
return shared.findPrRecord(prs, prRef, project);
|
|
772
773
|
} catch { return null; }
|
|
773
774
|
}
|
|
@@ -919,14 +920,13 @@ function normalizeWorkItemDedupTitle(value) {
|
|
|
919
920
|
.toLowerCase();
|
|
920
921
|
}
|
|
921
922
|
|
|
922
|
-
function resolveWorkItemDedupProject(item,
|
|
923
|
+
function resolveWorkItemDedupProject(item, project = null) {
|
|
923
924
|
const projectName = normalizeWorkItemDedupText(item?.project || item?._project || item?._source);
|
|
924
925
|
if (projectName) {
|
|
925
926
|
const namedProject = shared.resolveProjectSource(projectName, PROJECTS, { allowCentral: false });
|
|
926
927
|
if (namedProject.project) return namedProject.project;
|
|
927
928
|
}
|
|
928
|
-
|
|
929
|
-
return shared.resolveProjectSource(wiPath, PROJECTS, { allowCentral: false }).project || null;
|
|
929
|
+
return project;
|
|
930
930
|
}
|
|
931
931
|
|
|
932
932
|
function getWorkItemPrRefCandidates(item) {
|
|
@@ -962,7 +962,7 @@ function normalizeWorkItemDedupPrIdentity(item, project = null) {
|
|
|
962
962
|
}
|
|
963
963
|
|
|
964
964
|
function workItemCreateFingerprint(item, options = {}) {
|
|
965
|
-
const project = resolveWorkItemDedupProject(item, options.
|
|
965
|
+
const project = resolveWorkItemDedupProject(item, options.project);
|
|
966
966
|
return {
|
|
967
967
|
title: normalizeWorkItemDedupTitle(item?.title),
|
|
968
968
|
type: routing.normalizeWorkType(item?.type || item?.workType, WORK_TYPE.IMPLEMENT),
|
|
@@ -1015,10 +1015,11 @@ function findDuplicateWorkItemCreate(items, candidate, options = {}) {
|
|
|
1015
1015
|
}) || null;
|
|
1016
1016
|
}
|
|
1017
1017
|
|
|
1018
|
-
function createWorkItemWithDedup(
|
|
1018
|
+
function createWorkItemWithDedup(source, item, options = {}) {
|
|
1019
|
+
const project = options.project || (source && typeof source === 'object' ? source : null);
|
|
1019
1020
|
let result = null;
|
|
1020
|
-
mutateWorkItems(
|
|
1021
|
-
const existing = findDuplicateWorkItemCreate(items, item, { ...options,
|
|
1021
|
+
mutateWorkItems(source, items => {
|
|
1022
|
+
const existing = findDuplicateWorkItemCreate(items, item, { ...options, project });
|
|
1022
1023
|
if (existing) {
|
|
1023
1024
|
result = { created: false, item: existing, duplicateOf: existing.id };
|
|
1024
1025
|
return items;
|
|
@@ -1153,21 +1154,26 @@ function dispatchPrefixForResolvedSource(target) {
|
|
|
1153
1154
|
return target?.project ? `work-${target.project.name}-` : 'central-work-';
|
|
1154
1155
|
}
|
|
1155
1156
|
|
|
1157
|
+
function matchesResolvedWorkItemDispatch(entry, target, id) {
|
|
1158
|
+
const dispatchKey = dispatchPrefixForResolvedSource(target) + id;
|
|
1159
|
+
return entry?.meta?.dispatchKey === dispatchKey || entry?.meta?.parentKey === dispatchKey;
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1156
1162
|
function findWorkItemsTargetById(id, source, projects = PROJECTS) {
|
|
1157
1163
|
const explicitSource = source !== undefined && source !== null && String(source).trim() !== '';
|
|
1158
1164
|
if (explicitSource) {
|
|
1159
1165
|
const target = resolveProjectSourceTarget(source, projects);
|
|
1160
1166
|
if (target.error) return { error: target.error };
|
|
1161
|
-
const items = shared.
|
|
1167
|
+
const items = shared.readWorkItems(target.scope);
|
|
1162
1168
|
return { ...target, found: items.some(i => i.id === id) };
|
|
1163
1169
|
}
|
|
1164
1170
|
|
|
1165
1171
|
const central = resolveProjectSourceTarget('central', projects);
|
|
1166
|
-
const centralItems = shared.
|
|
1172
|
+
const centralItems = shared.readWorkItems(central.scope);
|
|
1167
1173
|
if (centralItems.some(i => i.id === id)) return { ...central, found: true };
|
|
1168
1174
|
for (const project of projects) {
|
|
1169
1175
|
const target = resolveProjectSourceTarget(project.name, projects);
|
|
1170
|
-
const items = shared.
|
|
1176
|
+
const items = shared.readWorkItems(target.scope);
|
|
1171
1177
|
if (items.some(i => i.id === id)) return { ...target, found: true };
|
|
1172
1178
|
}
|
|
1173
1179
|
return { found: false };
|
|
@@ -1314,23 +1320,18 @@ function validatePipelineProjects(pipeline, projects = PROJECTS) {
|
|
|
1314
1320
|
}
|
|
1315
1321
|
|
|
1316
1322
|
/**
|
|
1317
|
-
* Aggregate archived work items from
|
|
1318
|
-
* archive. Each item is tagged with `_source` (`'central'` or the project name)
|
|
1319
|
-
* so the UI can group/filter. Reads via `safeJsonArr` — a corrupt archive
|
|
1320
|
-
* surfaces a logged parse failure and contributes zero items, instead of
|
|
1321
|
-
* throwing 500 or silently dropping the file.
|
|
1323
|
+
* Aggregate archived work items from central and project SQL scopes.
|
|
1322
1324
|
*
|
|
1323
1325
|
* Exported for testing (P-h3arch-8c19).
|
|
1324
1326
|
*/
|
|
1325
|
-
function collectArchivedWorkItems(
|
|
1327
|
+
function collectArchivedWorkItems(projects = PROJECTS) {
|
|
1328
|
+
const store = require('./engine/work-items-store');
|
|
1326
1329
|
const archived = [];
|
|
1327
|
-
const
|
|
1328
|
-
for (const item of safeJsonArr(centralPath)) {
|
|
1330
|
+
for (const item of store.readArchivedWorkItemsForScope('central')) {
|
|
1329
1331
|
archived.push({ ...item, _source: 'central' });
|
|
1330
1332
|
}
|
|
1331
1333
|
for (const project of projects) {
|
|
1332
|
-
const
|
|
1333
|
-
for (const item of safeJsonArr(archPath)) {
|
|
1334
|
+
for (const item of store.readArchivedWorkItemsForScope(project.name)) {
|
|
1334
1335
|
archived.push({ ...item, _source: project.name });
|
|
1335
1336
|
}
|
|
1336
1337
|
}
|
|
@@ -1344,7 +1345,7 @@ function linkPullRequestForTracking({ url, title, project: projectName, contextO
|
|
|
1344
1345
|
}
|
|
1345
1346
|
const projects = shared.getProjects(config);
|
|
1346
1347
|
const { project: targetProject, resolution: projectResolution } = resolveManualPrLinkProject(url, projectName, projects);
|
|
1347
|
-
const
|
|
1348
|
+
const source = targetProject || 'central';
|
|
1348
1349
|
|
|
1349
1350
|
const prNumMatch = url.match(/\/pull\/(\d+)|pullrequest\/(\d+)/);
|
|
1350
1351
|
const prNum = prNumMatch ? (prNumMatch[1] || prNumMatch[2]) : Date.now().toString().slice(-6);
|
|
@@ -1353,7 +1354,7 @@ function linkPullRequestForTracking({ url, title, project: projectName, contextO
|
|
|
1353
1354
|
const contextText = typeof context === 'string' ? context : (context == null ? '' : JSON.stringify(context));
|
|
1354
1355
|
const metadata = normalizePrMetadata(options.metadata);
|
|
1355
1356
|
const resolvedContextOnly = typeof contextOnly === 'boolean' ? contextOnly : false;
|
|
1356
|
-
const result = shared.upsertPullRequestRecord(
|
|
1357
|
+
const result = shared.upsertPullRequestRecord(source, {
|
|
1357
1358
|
id: prId,
|
|
1358
1359
|
prNumber: parseInt(prNum, 10) || null,
|
|
1359
1360
|
title: (metadata?.title || title || 'PR #' + prNum + ' (polling...)').slice(0, 120),
|
|
@@ -1382,7 +1383,7 @@ function linkPullRequestForTracking({ url, title, project: projectName, contextO
|
|
|
1382
1383
|
// tombstone left by a prior explicit delete rather than silently no-op.
|
|
1383
1384
|
allowResurrect: true,
|
|
1384
1385
|
});
|
|
1385
|
-
return { ...result,
|
|
1386
|
+
return { ...result, scope: shared.stateScope(source), targetProject, projectResolution, prNum };
|
|
1386
1387
|
}
|
|
1387
1388
|
|
|
1388
1389
|
// W-mpmwxkzm0009ba0b — Per-row auto-observe toggle backing helper for
|
|
@@ -1394,9 +1395,9 @@ function linkPullRequestForTracking({ url, title, project: projectName, contextO
|
|
|
1394
1395
|
// fallback (`contextOnly = !observe`) for any caller that still sends it —
|
|
1395
1396
|
// the dashboard client itself now sends `contextOnly` exclusively
|
|
1396
1397
|
// (P-2b6e4d81; see docs/deprecated.json#pr-observe-observe-body-param).
|
|
1397
|
-
// Returns the updated record +
|
|
1398
|
+
// Returns the updated record + SQL scope. Throws an
|
|
1398
1399
|
// Error with `statusCode` for the route handler to map to an HTTP status.
|
|
1399
|
-
function updatePullRequestObserveFlag({ host, slug, number, observe, contextOnly } = {}, config = CONFIG
|
|
1400
|
+
function updatePullRequestObserveFlag({ host, slug, number, observe, contextOnly } = {}, config = CONFIG) {
|
|
1400
1401
|
const hostStr = String(host || '').trim().toLowerCase();
|
|
1401
1402
|
const slugStr = String(slug || '').trim();
|
|
1402
1403
|
const numberInt = Number.parseInt(number, 10);
|
|
@@ -1428,21 +1429,18 @@ function updatePullRequestObserveFlag({ host, slug, number, observe, contextOnly
|
|
|
1428
1429
|
|
|
1429
1430
|
const canonicalId = `${hostStr}:${slugStr}#${numberInt}`;
|
|
1430
1431
|
const projects = shared.getProjects(config);
|
|
1431
|
-
const
|
|
1432
|
-
...projects.map(p => shared.projectPrPath(p)),
|
|
1433
|
-
shared.centralPullRequestsPath(minionsDir),
|
|
1434
|
-
];
|
|
1432
|
+
const sources = [...projects, 'central'];
|
|
1435
1433
|
|
|
1436
1434
|
let updated = null;
|
|
1437
|
-
let
|
|
1438
|
-
for (const
|
|
1435
|
+
let updatedScope = null;
|
|
1436
|
+
for (const source of sources) {
|
|
1439
1437
|
if (updated) break;
|
|
1440
|
-
shared.mutatePullRequests(
|
|
1438
|
+
shared.mutatePullRequests(source, (prs) => {
|
|
1441
1439
|
const pr = prs.find(p => p && p.id === canonicalId);
|
|
1442
1440
|
if (!pr) return prs;
|
|
1443
1441
|
pr.contextOnly = contextOnlyValue;
|
|
1444
1442
|
updated = { id: pr.id, contextOnly: pr.contextOnly };
|
|
1445
|
-
|
|
1443
|
+
updatedScope = shared.stateScope(source);
|
|
1446
1444
|
return prs;
|
|
1447
1445
|
});
|
|
1448
1446
|
}
|
|
@@ -1452,7 +1450,7 @@ function updatePullRequestObserveFlag({ host, slug, number, observe, contextOnly
|
|
|
1452
1450
|
err.statusCode = 404;
|
|
1453
1451
|
throw err;
|
|
1454
1452
|
}
|
|
1455
|
-
return { ...updated,
|
|
1453
|
+
return { ...updated, scope: updatedScope };
|
|
1456
1454
|
}
|
|
1457
1455
|
|
|
1458
1456
|
function _normalizeSkillDirForCompare(dir) {
|
|
@@ -1659,24 +1657,12 @@ const PLANS_CACHE_TTL_MS = 5000;
|
|
|
1659
1657
|
function invalidatePlansCache() { _plansCache = null; _plansCacheTs = 0; }
|
|
1660
1658
|
|
|
1661
1659
|
function statePathExists(filePath) {
|
|
1662
|
-
if (prdStore.parsePrdPath(filePath)) return prdStore.readPrd(filePath) != null;
|
|
1663
1660
|
return fs.existsSync(filePath);
|
|
1664
1661
|
}
|
|
1665
1662
|
|
|
1666
|
-
// Resolve a plan
|
|
1667
|
-
// Validates that the file stays within the expected directory to prevent path traversal.
|
|
1663
|
+
// Resolve a human-authored Markdown plan path.
|
|
1668
1664
|
function resolvePlanPath(file) {
|
|
1669
|
-
if (file.endsWith('.
|
|
1670
|
-
// Validate against both prd/ and prd/archive/
|
|
1671
|
-
shared.sanitizePath(file, PRD_DIR);
|
|
1672
|
-
const active = path.join(PRD_DIR, file);
|
|
1673
|
-
if (prdStore.readPrd(active)) return active;
|
|
1674
|
-
const archived = path.join(PRD_DIR, 'archive', file);
|
|
1675
|
-
if (prdStore.readPrd(archived)) return archived;
|
|
1676
|
-
return active;
|
|
1677
|
-
}
|
|
1678
|
-
|
|
1679
|
-
// Validate against both plans/ and plans/archive/
|
|
1665
|
+
if (!file.endsWith('.md')) throw new Error('expected a Markdown plan filename');
|
|
1680
1666
|
shared.sanitizePath(file, PLANS_DIR);
|
|
1681
1667
|
const active = path.join(PLANS_DIR, file);
|
|
1682
1668
|
if (fs.existsSync(active)) return active;
|
|
@@ -1685,72 +1671,36 @@ function resolvePlanPath(file) {
|
|
|
1685
1671
|
return active;
|
|
1686
1672
|
}
|
|
1687
1673
|
|
|
1688
|
-
//
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
// mutateJsonFileLocked) does not silently swallow the others — the bug that
|
|
1693
|
-
// orphaned plans/killswitches-and-granular-controls.md on 2026-06-11 after its
|
|
1694
|
-
// PRD archived OK. Failures are pushed into archiveWarnings (returned to the
|
|
1695
|
-
// dashboard as `warnings: [...]`) so the user sees them instead of a silent
|
|
1696
|
-
// `archivedSource: null`. _mutate / _safeJsonObj injection points are test
|
|
1697
|
-
// seams; production callers omit them.
|
|
1698
|
-
function _archivePrdPostProcess({ planFile, archivePath, planPath, plansDir, _mutate, _safeJsonObj } = {}) {
|
|
1699
|
-
const mutate = typeof _mutate === 'function' ? _mutate : mutateJsonFileLocked;
|
|
1700
|
-
const readObj = typeof _safeJsonObj === 'function' ? _safeJsonObj : safeJsonObj;
|
|
1674
|
+
// Archive a PRD in SQL, then move its source Markdown plan.
|
|
1675
|
+
function _archivePrdPostProcess({ planFile, plansDir, _mutate, _read } = {}) {
|
|
1676
|
+
const mutate = typeof _mutate === 'function' ? _mutate : prdStore.mutatePrd;
|
|
1677
|
+
const read = typeof _read === 'function' ? _read : prdStore.readPrd;
|
|
1701
1678
|
const archiveWarnings = [];
|
|
1702
1679
|
let plan = {};
|
|
1703
1680
|
let archivedSource = null;
|
|
1704
1681
|
|
|
1705
|
-
// (a) Flip the archived FLAG + status + archivedAt. Phase 10 step 4.2b:
|
|
1706
|
-
// archived-ness is the flag now (not the directory), so set `archived:true`
|
|
1707
|
-
// explicitly. The path-shaped mutator routes PRDs into SQL.
|
|
1708
|
-
// When archivePath === planPath (in-place archive) this flags the PRD where it
|
|
1709
|
-
// sits; when they differ (.md-cascade / legacy move) it flags the moved copy.
|
|
1710
1682
|
try {
|
|
1711
|
-
plan = mutate(
|
|
1683
|
+
plan = mutate(planFile, (data) => {
|
|
1712
1684
|
if (!data || Array.isArray(data) || typeof data !== 'object') data = {};
|
|
1713
1685
|
data.status = 'archived';
|
|
1714
1686
|
data.archived = true;
|
|
1715
1687
|
data.archivedAt = new Date().toISOString();
|
|
1716
1688
|
return data;
|
|
1717
|
-
}
|
|
1689
|
+
}) || {};
|
|
1718
1690
|
} catch (e) {
|
|
1719
1691
|
const warning = `Archive status flip failed for ${planFile}: ${e.message}`;
|
|
1720
1692
|
archiveWarnings.push(warning);
|
|
1721
1693
|
console.warn(warning);
|
|
1722
1694
|
}
|
|
1723
1695
|
|
|
1724
|
-
// Re-read the PRD from disk if mutate threw or returned an empty object —
|
|
1725
|
-
// the file was already renamed into the archive dir, so the source_plan
|
|
1726
|
-
// field is still on disk and the markdown move below needs it.
|
|
1727
1696
|
if (!plan.source_plan) {
|
|
1728
1697
|
try {
|
|
1729
|
-
const
|
|
1730
|
-
if (
|
|
1731
|
-
} catch { /*
|
|
1698
|
+
const stored = read(planFile);
|
|
1699
|
+
if (stored && typeof stored === 'object' && stored.source_plan) plan = stored;
|
|
1700
|
+
} catch { /* best-effort */ }
|
|
1732
1701
|
}
|
|
1733
1702
|
|
|
1734
|
-
//
|
|
1735
|
-
// actually moved (archivePath !== planPath) — a stale prd/<f>.json.backup left
|
|
1736
|
-
// behind by a move is resurrection fuel. For an in-place archive the file (and
|
|
1737
|
-
// its current .backup) stay put and are valid, so there is nothing to neutralize.
|
|
1738
|
-
if (planPath !== archivePath) {
|
|
1739
|
-
try {
|
|
1740
|
-
const backupCleanup = shared.neutralizeJsonBackupSidecar(planPath);
|
|
1741
|
-
if (!backupCleanup.ok) {
|
|
1742
|
-
const warning = `Archive backup cleanup failed for ${planFile}: unlink failed (${backupCleanup.unlinkError}); fallback neutralize failed (${backupCleanup.writeError})`;
|
|
1743
|
-
archiveWarnings.push(warning);
|
|
1744
|
-
console.warn(warning);
|
|
1745
|
-
}
|
|
1746
|
-
} catch (e) {
|
|
1747
|
-
const warning = `Archive backup cleanup failed for ${planFile}: ${e.message}`;
|
|
1748
|
-
archiveWarnings.push(warning);
|
|
1749
|
-
console.warn(warning);
|
|
1750
|
-
}
|
|
1751
|
-
}
|
|
1752
|
-
|
|
1753
|
-
// (c) Move the source plan markdown into plans/archive/.
|
|
1703
|
+
// Move the source plan Markdown into plans/archive/.
|
|
1754
1704
|
if (plan.source_plan) {
|
|
1755
1705
|
try {
|
|
1756
1706
|
// Canonical key: a `plans/`-prefixed source_plan would otherwise join to
|
|
@@ -2398,18 +2348,15 @@ function _ifNoneMatchHasEtag(headerValue, currentEtag) {
|
|
|
2398
2348
|
return false;
|
|
2399
2349
|
}
|
|
2400
2350
|
|
|
2401
|
-
//
|
|
2351
|
+
// Cross-process cache invalidation.
|
|
2402
2352
|
//
|
|
2403
2353
|
// Engine and dashboard are independent processes; `invalidateStatusCache()`
|
|
2404
2354
|
// lives in dashboard.js memory and is unreachable from engine code. We
|
|
2405
|
-
//
|
|
2406
|
-
//
|
|
2407
|
-
// statSync'ing a registry of input files on every status request. Any
|
|
2408
|
-
// mtime advance → cache busted → fresh rebuild on the same poll.
|
|
2355
|
+
// SQL mutations advance the events table; intentionally file-backed inputs
|
|
2356
|
+
// still use mtime tracking.
|
|
2409
2357
|
//
|
|
2410
2358
|
// The two source-of-truth lists live in `engine/queries.js`:
|
|
2411
|
-
// - getStatusFastStateMtimePaths —
|
|
2412
|
-
// work-items, PR files, watches, qa runs, inbox, notes, meetings, …).
|
|
2359
|
+
// - getStatusFastStateMtimePaths — file-backed hot inputs.
|
|
2413
2360
|
// - getStatusSlowStateMtimePaths — slower-cadence writes (PRD JSON,
|
|
2414
2361
|
// schedule/pipeline runs, pinned, skill discovery roots, MCP configs,
|
|
2415
2362
|
// git refs, …).
|
|
@@ -2937,18 +2884,10 @@ const STATE_READ_ALLOWED_FILES = new Set([
|
|
|
2937
2884
|
const STATE_READ_DENIED_BASENAMES = new Set([
|
|
2938
2885
|
'session.json',
|
|
2939
2886
|
'sessions.json',
|
|
2940
|
-
'cc-session.json',
|
|
2941
|
-
'cc-sessions.json',
|
|
2942
|
-
'qa-sessions.json',
|
|
2943
2887
|
'qa-sessions-history.json',
|
|
2944
2888
|
'session-state.json',
|
|
2945
2889
|
'session-token.json',
|
|
2946
2890
|
'keep-pids.json',
|
|
2947
|
-
// doc-sessions.json holds user-authored doc-chat conversation state
|
|
2948
|
-
// (questions + document context + LLM responses). Was previously denied
|
|
2949
|
-
// via a broad *sessions?.json regex that round-2 #9 narrowed; restoring
|
|
2950
|
-
// explicit coverage here. (Round-3 review finding #2.)
|
|
2951
|
-
'doc-sessions.json',
|
|
2952
2891
|
]);
|
|
2953
2892
|
const STATE_READ_DENIED_PATTERNS = [
|
|
2954
2893
|
/(^|[\\/])state\.db($|-wal$|-shm$)/i,
|
|
@@ -2989,7 +2928,7 @@ function _stateReadContentType(ext) {
|
|
|
2989
2928
|
// serveFreshJson is "run server-side enrichment, ETag off input mtimes."
|
|
2990
2929
|
//
|
|
2991
2930
|
// Used for slices whose payload joins multiple files (agents derived from
|
|
2992
|
-
// config + dispatch + inbox + steering,
|
|
2931
|
+
// config + dispatch + inbox + steering, with SQL-backed metrics
|
|
2993
2932
|
// + dispatch + PRs, PRD derived from PRD dir + work-items + PRs, etc.).
|
|
2994
2933
|
// Replicating those joins in browser JS would be brittle, and shipping the
|
|
2995
2934
|
// fully-enriched result through /api/status pays the staleness tax that
|
|
@@ -3079,6 +3018,44 @@ async function serveFreshJson(req, res, opts) {
|
|
|
3079
3018
|
res.end(JSON.stringify(payload));
|
|
3080
3019
|
}
|
|
3081
3020
|
|
|
3021
|
+
function _buildConstellationBridgeSnapshot() {
|
|
3022
|
+
const config = queries.getConfig() || {};
|
|
3023
|
+
const scheduleRuns = smallStateStore.readScheduleRuns();
|
|
3024
|
+
const schedules = (Array.isArray(config.schedules) ? config.schedules : []).map(schedule => {
|
|
3025
|
+
const run = scheduleRuns[schedule.id];
|
|
3026
|
+
const lastRun = typeof run === 'string'
|
|
3027
|
+
? run
|
|
3028
|
+
: (run?.lastRun || run?.lastCompletedAt || null);
|
|
3029
|
+
return { ...schedule, _lastRun: lastRun };
|
|
3030
|
+
});
|
|
3031
|
+
const plans = prdStore.listPrdRows().map(row => {
|
|
3032
|
+
const plan = row.plan && typeof row.plan === 'object' ? row.plan : {};
|
|
3033
|
+
return {
|
|
3034
|
+
id: row.filename,
|
|
3035
|
+
title: plan.plan_summary || plan.title || row.filename,
|
|
3036
|
+
status: row.archived
|
|
3037
|
+
? 'archived'
|
|
3038
|
+
: (plan.status || (plan.requires_approval && !plan.approvedAt ? 'awaiting-approval' : 'active')),
|
|
3039
|
+
agent: plan.generated_by || null,
|
|
3040
|
+
project: plan.project || null,
|
|
3041
|
+
createdAt: plan.generated_at || null,
|
|
3042
|
+
updatedAt: row.updatedAt || null,
|
|
3043
|
+
};
|
|
3044
|
+
});
|
|
3045
|
+
|
|
3046
|
+
return require('./engine/bridge').buildBridgeSnapshot({
|
|
3047
|
+
minionsVersion: _dashboardVersion.codeVersion,
|
|
3048
|
+
control: queries.getControl(),
|
|
3049
|
+
projects: shared.getProjects(config),
|
|
3050
|
+
workItems: queries.getWorkItems(config, { enrich: false }),
|
|
3051
|
+
pullRequests: queries.getPullRequests(config),
|
|
3052
|
+
plans,
|
|
3053
|
+
schedules,
|
|
3054
|
+
agents: queries.getAgents(config),
|
|
3055
|
+
dispatch: queries.getDispatch(),
|
|
3056
|
+
});
|
|
3057
|
+
}
|
|
3058
|
+
|
|
3082
3059
|
function handleStateRead(req, res) {
|
|
3083
3060
|
const urlPath = req.url.split('?')[0];
|
|
3084
3061
|
const rel = decodeURIComponent(urlPath.slice('/state/'.length));
|
|
@@ -3285,7 +3262,7 @@ async function _handleStatusRequest(req, res) {
|
|
|
3285
3262
|
}
|
|
3286
3263
|
}
|
|
3287
3264
|
|
|
3288
|
-
// Periodic push for engine-driven
|
|
3265
|
+
// Periodic push for engine-driven SQL state changes that bypass invalidateStatusCache.
|
|
3289
3266
|
setInterval(() => {
|
|
3290
3267
|
if (_statusStreamClients.size === 0) return;
|
|
3291
3268
|
const data = getStatusJson();
|
|
@@ -3836,14 +3813,12 @@ function _filterCcTabSessions(sessions) {
|
|
|
3836
3813
|
}
|
|
3837
3814
|
|
|
3838
3815
|
function _readCcTabSessions({ prune = true } = {}) {
|
|
3839
|
-
if (!prune) return _filterCcTabSessions(
|
|
3840
|
-
// P-c2sess-1d8e: read+filter+write atomically under the file lock so a
|
|
3841
|
-
// concurrent tab upsert/delete cannot lose entries to last-write-wins.
|
|
3816
|
+
if (!prune) return _filterCcTabSessions(smallStateStore.readCcSessions());
|
|
3842
3817
|
let sessions;
|
|
3843
|
-
|
|
3818
|
+
smallStateStore.applyCcSessionsMutation((raw) => {
|
|
3844
3819
|
sessions = _filterCcTabSessions(raw);
|
|
3845
3820
|
return sessions;
|
|
3846
|
-
}
|
|
3821
|
+
});
|
|
3847
3822
|
return sessions;
|
|
3848
3823
|
}
|
|
3849
3824
|
|
|
@@ -3951,7 +3926,7 @@ function _joinCcPromptParts(...parts) {
|
|
|
3951
3926
|
// only restore-time validity checks here are sessionId presence (anything
|
|
3952
3927
|
// else would auto-expire the user's chat without their consent).
|
|
3953
3928
|
try {
|
|
3954
|
-
const saved =
|
|
3929
|
+
const saved = smallStateStore.readCcGlobalSession();
|
|
3955
3930
|
if (saved && saved.sessionId) ccSession = saved;
|
|
3956
3931
|
} catch { /* optional */ }
|
|
3957
3932
|
|
|
@@ -4168,7 +4143,10 @@ function buildCCStatePreamble() {
|
|
|
4168
4143
|
const prCount = getPullRequests().length;
|
|
4169
4144
|
const wiCount = getWorkItems(null, { enrich: false }).length;
|
|
4170
4145
|
|
|
4171
|
-
const planFiles = [
|
|
4146
|
+
const planFiles = [
|
|
4147
|
+
...safeReadDir(PLANS_DIR).filter(file => file.endsWith('.md')),
|
|
4148
|
+
...prdStore.listPrdRows().map(row => row.filename),
|
|
4149
|
+
];
|
|
4172
4150
|
|
|
4173
4151
|
const schedules = CONFIG.schedules || [];
|
|
4174
4152
|
const enabledSchedules = schedules.filter(s => s.enabled !== false).length;
|
|
@@ -4340,12 +4318,13 @@ function getWorkItemPrRef(input) {
|
|
|
4340
4318
|
// invalidation paths are prompt-hash mismatch (correctness — a stale system
|
|
4341
4319
|
// prompt would carry the old persona/rules into resume turns) and the
|
|
4342
4320
|
// LRU cap below (storage hygiene). Users can resume a doc-chat any time.
|
|
4343
|
-
const CC_SESSIONS_PATH = path.join(ENGINE_DIR, 'cc-sessions.json');
|
|
4344
|
-
const DOC_SESSIONS_PATH = path.join(ENGINE_DIR, 'doc-sessions.json');
|
|
4345
|
-
const CC_SESSION_PATH = path.join(ENGINE_DIR, 'cc-session.json');
|
|
4346
4321
|
const DOC_SESSION_MAX_ENTRIES = shared.ENGINE_DEFAULTS.docSessionMaxEntries;
|
|
4347
4322
|
const docSessions = new Map(); // key → { sessionId, lastActiveAt, turnCount }
|
|
4348
4323
|
|
|
4324
|
+
function persistCcSession() {
|
|
4325
|
+
smallStateStore.applyCcGlobalSessionMutation(() => ccSession);
|
|
4326
|
+
}
|
|
4327
|
+
|
|
4349
4328
|
function _docSessionLastActiveMs(session) {
|
|
4350
4329
|
const ms = Date.parse(session?.lastActiveAt || session?.createdAt || '');
|
|
4351
4330
|
return Number.isFinite(ms) ? ms : 0;
|
|
@@ -4374,7 +4353,7 @@ function pruneDocSessions() {
|
|
|
4374
4353
|
// Load persisted doc sessions on startup — no TTL/turn filtering, sessions
|
|
4375
4354
|
// are non-expiring. Stale prompt-hash entries are dropped by pruneDocSessions.
|
|
4376
4355
|
try {
|
|
4377
|
-
const saved =
|
|
4356
|
+
const saved = smallStateStore.readDocSessions();
|
|
4378
4357
|
if (saved && typeof saved === 'object') {
|
|
4379
4358
|
for (const [key, s] of Object.entries(saved)) {
|
|
4380
4359
|
docSessions.set(key, s);
|
|
@@ -4387,8 +4366,7 @@ function persistDocSessions() {
|
|
|
4387
4366
|
pruneDocSessions();
|
|
4388
4367
|
const obj = {};
|
|
4389
4368
|
for (const [key, s] of docSessions) obj[key] = s;
|
|
4390
|
-
|
|
4391
|
-
mutateJsonFileLocked(DOC_SESSIONS_PATH, () => obj, { defaultValue: {} });
|
|
4369
|
+
smallStateStore.applyDocSessionsMutation(() => obj);
|
|
4392
4370
|
}
|
|
4393
4371
|
|
|
4394
4372
|
// Hourly hygiene sweep — drops prompt-hash mismatches and trims the LRU cap.
|
|
@@ -4456,7 +4434,7 @@ function updateSession(store, key, sessionId, existing) {
|
|
|
4456
4434
|
turnCount: (existing ? ccSession.turnCount : 0) + 1,
|
|
4457
4435
|
_promptHash: _ccPromptHash,
|
|
4458
4436
|
};
|
|
4459
|
-
|
|
4437
|
+
persistCcSession();
|
|
4460
4438
|
} else if (key) {
|
|
4461
4439
|
const prev = docSessions.get(key);
|
|
4462
4440
|
docSessions.set(key, {
|
|
@@ -4831,7 +4809,7 @@ async function ccCall(message, { store = 'cc', sessionKey, extraContext, label =
|
|
|
4831
4809
|
// Invalidate the dead session so future calls don't try to resume it
|
|
4832
4810
|
if (store === 'cc') {
|
|
4833
4811
|
ccSession = { sessionId: null, createdAt: null, lastActiveAt: null, turnCount: 0 };
|
|
4834
|
-
|
|
4812
|
+
persistCcSession();
|
|
4835
4813
|
} else if (sessionKey) {
|
|
4836
4814
|
docSessions.delete(sessionKey);
|
|
4837
4815
|
schedulePersistDocSessions();
|
|
@@ -4980,7 +4958,7 @@ async function ccCallStreaming(message, { store = 'cc', sessionKey, extraContext
|
|
|
4980
4958
|
sessionId = null;
|
|
4981
4959
|
if (store === 'cc') {
|
|
4982
4960
|
ccSession = { sessionId: null, createdAt: null, lastActiveAt: null, turnCount: 0 };
|
|
4983
|
-
|
|
4961
|
+
persistCcSession();
|
|
4984
4962
|
} else if (sessionKey) {
|
|
4985
4963
|
docSessions.delete(sessionKey);
|
|
4986
4964
|
schedulePersistDocSessions();
|
|
@@ -6061,36 +6039,20 @@ const server = http.createServer(async (req, res) => {
|
|
|
6061
6039
|
try {
|
|
6062
6040
|
const body = await readBody(req);
|
|
6063
6041
|
if (!body.file) return jsonReply(res, 400, { error: 'file required' });
|
|
6064
|
-
|
|
6065
|
-
|
|
6066
|
-
|
|
6067
|
-
|
|
6068
|
-
|
|
6069
|
-
|
|
6070
|
-
|
|
6071
|
-
|
|
6072
|
-
|
|
6073
|
-
|
|
6074
|
-
if (!statePathExists(prdPath)) return jsonReply(res, 404, { error: 'PRD not found' });
|
|
6075
|
-
|
|
6076
|
-
// If archived, temporarily restore to active so checkPlanCompletion can find it
|
|
6077
|
-
const activePath = path.join(prdDir, body.file);
|
|
6078
|
-
if (fromArchive) {
|
|
6079
|
-
const archivedPlan = safeJson(prdPath);
|
|
6080
|
-
if (!archivedPlan) return jsonReply(res, 500, { error: 'Could not parse PRD file' });
|
|
6081
|
-
mutateJsonFileLocked(activePath, () => {
|
|
6082
|
-
archivedPlan.status = 'approved';
|
|
6083
|
-
delete archivedPlan.completedAt;
|
|
6084
|
-
delete archivedPlan.planStale;
|
|
6085
|
-
return archivedPlan;
|
|
6086
|
-
}, { defaultValue: archivedPlan });
|
|
6042
|
+
let projectPlan = prdStore.readPrd(body.file);
|
|
6043
|
+
if (!projectPlan && prdStore.readPrd(body.file, { archived: true })) {
|
|
6044
|
+
projectPlan = prdStore.restorePrdFromArchive(body.file);
|
|
6045
|
+
prdStore.mutatePrd(body.file, (data) => {
|
|
6046
|
+
data.status = 'approved';
|
|
6047
|
+
delete data.completedAt;
|
|
6048
|
+
delete data.planStale;
|
|
6049
|
+
return data;
|
|
6050
|
+
});
|
|
6051
|
+
projectPlan = prdStore.readPrd(body.file);
|
|
6087
6052
|
}
|
|
6053
|
+
if (!projectPlan) return jsonReply(res, 404, { error: 'PRD not found' });
|
|
6088
6054
|
|
|
6089
6055
|
const config = queries.getConfig();
|
|
6090
|
-
// safeJsonNoRestore — never resurrect an archived PRD's .backup sidecar
|
|
6091
|
-
// during project resolution (W-mouptdh1000h9f39). The active-from-archive
|
|
6092
|
-
// write above (mutateJsonFileLocked) already created activePath when needed.
|
|
6093
|
-
const projectPlan = safeJsonNoRestore(activePath) || safeJsonNoRestore(prdPath);
|
|
6094
6056
|
const projectTarget = projectPlan?.project
|
|
6095
6057
|
? resolveProjectSourceTarget(projectPlan.project, PROJECTS, { allowCentral: false })
|
|
6096
6058
|
: null;
|
|
@@ -6098,9 +6060,8 @@ const server = http.createServer(async (req, res) => {
|
|
|
6098
6060
|
|
|
6099
6061
|
// Check for existing verify WI — reset to pending if already done (re-verify)
|
|
6100
6062
|
if (project) {
|
|
6101
|
-
const wiPath = shared.projectWorkItemsPath(project);
|
|
6102
6063
|
let existingVerify = null;
|
|
6103
|
-
mutateWorkItems(
|
|
6064
|
+
mutateWorkItems(project, items => {
|
|
6104
6065
|
const v = items.find(w => w.sourcePlan === body.file && w.itemType === WORK_TYPE.VERIFY);
|
|
6105
6066
|
if (v && (v.status === WI_STATUS.DONE || v.status === WI_STATUS.FAILED)) {
|
|
6106
6067
|
// BUG-M10: use shared.reopenWorkItem so re-verify resets mirror
|
|
@@ -6119,17 +6080,16 @@ const server = http.createServer(async (req, res) => {
|
|
|
6119
6080
|
}
|
|
6120
6081
|
|
|
6121
6082
|
// No existing verify — clear completion flag and trigger fresh creation
|
|
6122
|
-
|
|
6083
|
+
prdStore.mutatePrd(body.file, (planData) => {
|
|
6123
6084
|
if (planData?._completionNotified) planData._completionNotified = false;
|
|
6124
6085
|
return planData;
|
|
6125
|
-
}
|
|
6086
|
+
});
|
|
6126
6087
|
|
|
6127
6088
|
const lifecycle = require('./engine/lifecycle');
|
|
6128
6089
|
lifecycle.checkPlanCompletion({ item: { sourcePlan: body.file, id: 'manual' } }, config);
|
|
6129
6090
|
|
|
6130
6091
|
if (project) {
|
|
6131
|
-
const
|
|
6132
|
-
const items = safeJsonArr(wiPath);
|
|
6092
|
+
const items = shared.readWorkItems(project);
|
|
6133
6093
|
const verify = items.find(w => w.sourcePlan === body.file && w.itemType === WORK_TYPE.VERIFY);
|
|
6134
6094
|
if (verify) {
|
|
6135
6095
|
invalidateStatusCache();
|
|
@@ -6147,36 +6107,24 @@ const server = http.createServer(async (req, res) => {
|
|
|
6147
6107
|
const { id, source } = body;
|
|
6148
6108
|
if (!id) return jsonReply(res, 400, { error: 'id required' });
|
|
6149
6109
|
|
|
6150
|
-
//
|
|
6110
|
+
// Resolve the owning SQL scope.
|
|
6151
6111
|
let resolvedTarget = findWorkItemsTargetById(id, source, PROJECTS);
|
|
6152
6112
|
if (resolvedTarget.error) return jsonReply(res, 404, { error: resolvedTarget.error });
|
|
6153
|
-
let
|
|
6113
|
+
let wiScope = resolvedTarget.found ? resolvedTarget.scope : null;
|
|
6154
6114
|
// If no work item found, attempt to re-materialize from PRD item definition
|
|
6155
|
-
if (!
|
|
6115
|
+
if (!wiScope) {
|
|
6156
6116
|
const prdFile = body.prdFile;
|
|
6157
6117
|
if (!prdFile) return jsonReply(res, 404, { error: 'work item not found in any source' });
|
|
6158
|
-
|
|
6159
|
-
// traversal/absolute paths and anything that resolves outside PRD_DIR
|
|
6160
|
-
// before it ever reaches a filesystem read.
|
|
6161
|
-
try { shared.sanitizePath(prdFile, PRD_DIR); }
|
|
6162
|
-
catch { return jsonReply(res, 400, { error: 'invalid prdFile path' }); }
|
|
6163
|
-
|
|
6164
|
-
// Look up PRD item to create a new work item on-demand.
|
|
6165
|
-
// safeJsonNoRestore — PRDs are terminal artifacts; a stale .backup
|
|
6166
|
-
// sidecar from an archived PRD must not resurrect the active PRD
|
|
6167
|
-
// just because a user clicked retry on a stray work item
|
|
6168
|
-
// (W-mouptdh1000h9f39).
|
|
6169
|
-
const prdPath = path.join(PRD_DIR, prdFile);
|
|
6170
|
-
const plan = shared.safeJsonNoRestore(prdPath);
|
|
6118
|
+
const plan = prdStore.readPrd(prdFile);
|
|
6171
6119
|
if (!plan?.missing_features) return jsonReply(res, 404, { error: 'PRD file not found or invalid' });
|
|
6172
6120
|
const prdItem = plan.missing_features.find(f => f.id === id);
|
|
6173
6121
|
if (!prdItem) return jsonReply(res, 404, { error: 'PRD item not found in ' + prdFile });
|
|
6174
6122
|
|
|
6175
|
-
// Determine target
|
|
6123
|
+
// Determine target scope (project from PRD item or plan, fallback to central).
|
|
6176
6124
|
const projName = prdItem.project || plan.project || prdFile.replace(/-\d{4}-\d{2}-\d{2}\.json$/, '');
|
|
6177
6125
|
const prdTarget = resolveProjectSourceTarget(projName, PROJECTS, { allowCentral: false });
|
|
6178
6126
|
const proj = prdTarget.project;
|
|
6179
|
-
const
|
|
6127
|
+
const targetScope = proj || 'central';
|
|
6180
6128
|
|
|
6181
6129
|
// Create new work item from PRD item definition (same logic as materializePlansAsWorkItems)
|
|
6182
6130
|
const complexity = prdItem.estimated_complexity || 'medium';
|
|
@@ -6201,7 +6149,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
6201
6149
|
_source: proj?.name || 'central',
|
|
6202
6150
|
_retryCount: 0,
|
|
6203
6151
|
};
|
|
6204
|
-
mutateWorkItems(
|
|
6152
|
+
mutateWorkItems(targetScope, items => { items.push(newItem); });
|
|
6205
6153
|
|
|
6206
6154
|
// Reset PRD item status to pending
|
|
6207
6155
|
try {
|
|
@@ -6210,15 +6158,14 @@ const server = http.createServer(async (req, res) => {
|
|
|
6210
6158
|
} catch (e) { console.error('PRD status sync:', e.message); }
|
|
6211
6159
|
|
|
6212
6160
|
// Clear dispatch history and cooldowns for this item
|
|
6213
|
-
const dispatchPath = path.join(MINIONS_DIR, 'engine', 'dispatch.json');
|
|
6214
6161
|
const dispatchKey = (proj ? `work-${proj.name}-` : 'central-work-') + id;
|
|
6215
6162
|
try {
|
|
6216
|
-
|
|
6163
|
+
dispatchMod.mutateDispatch((dispatch) => {
|
|
6217
6164
|
dispatch.completed = Array.isArray(dispatch.completed) ? dispatch.completed : [];
|
|
6218
6165
|
dispatch.completed = dispatch.completed.filter(d => d.meta?.dispatchKey !== dispatchKey);
|
|
6219
6166
|
dispatch.completed = dispatch.completed.filter(d => !d.meta?.parentKey || d.meta.parentKey !== dispatchKey);
|
|
6220
6167
|
return dispatch;
|
|
6221
|
-
}
|
|
6168
|
+
});
|
|
6222
6169
|
} catch (e) { console.error('dispatch cleanup:', e.message); }
|
|
6223
6170
|
try {
|
|
6224
6171
|
mutateCooldowns(cooldowns => {
|
|
@@ -6232,7 +6179,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
6232
6179
|
|
|
6233
6180
|
let found = false;
|
|
6234
6181
|
let blocked = null;
|
|
6235
|
-
|
|
6182
|
+
mutateWorkItems(wiScope, (items) => {
|
|
6236
6183
|
if (!Array.isArray(items)) items = [];
|
|
6237
6184
|
const item = items.find(i => i.id === id);
|
|
6238
6185
|
if (!item) return items;
|
|
@@ -6279,15 +6226,14 @@ const server = http.createServer(async (req, res) => {
|
|
|
6279
6226
|
if (!found) return jsonReply(res, 404, { error: 'item not found' });
|
|
6280
6227
|
|
|
6281
6228
|
// Clear completed dispatch entries so the engine doesn't dedup this item
|
|
6282
|
-
const dispatchPath = path.join(MINIONS_DIR, 'engine', 'dispatch.json');
|
|
6283
6229
|
const dispatchKey = dispatchPrefixForResolvedSource(resolvedTarget) + id;
|
|
6284
6230
|
try {
|
|
6285
|
-
|
|
6231
|
+
dispatchMod.mutateDispatch((dispatch) => {
|
|
6286
6232
|
dispatch.completed = Array.isArray(dispatch.completed) ? dispatch.completed : [];
|
|
6287
6233
|
dispatch.completed = dispatch.completed.filter(d => d.meta?.dispatchKey !== dispatchKey);
|
|
6288
6234
|
dispatch.completed = dispatch.completed.filter(d => !d.meta?.parentKey || d.meta.parentKey !== dispatchKey);
|
|
6289
6235
|
return dispatch;
|
|
6290
|
-
}
|
|
6236
|
+
});
|
|
6291
6237
|
} catch (e) { console.error('dispatch cleanup:', e.message); }
|
|
6292
6238
|
|
|
6293
6239
|
// Clear cooldown so item isn't blocked by exponential backoff
|
|
@@ -6310,11 +6256,10 @@ const server = http.createServer(async (req, res) => {
|
|
|
6310
6256
|
|
|
6311
6257
|
const target = resolveProjectSourceTarget(source, PROJECTS);
|
|
6312
6258
|
if (target.error) return jsonReply(res, 404, { error: target.error });
|
|
6313
|
-
const wiPath = target.wiPath;
|
|
6314
6259
|
|
|
6315
6260
|
let item = null;
|
|
6316
6261
|
let found = false;
|
|
6317
|
-
|
|
6262
|
+
mutateWorkItems(target.scope, (items) => {
|
|
6318
6263
|
if (!Array.isArray(items)) return items;
|
|
6319
6264
|
const idx = items.findIndex(i => i.id === id);
|
|
6320
6265
|
if (idx === -1) return items;
|
|
@@ -6322,21 +6267,16 @@ const server = http.createServer(async (req, res) => {
|
|
|
6322
6267
|
items.splice(idx, 1);
|
|
6323
6268
|
found = true;
|
|
6324
6269
|
return items;
|
|
6325
|
-
}
|
|
6270
|
+
});
|
|
6326
6271
|
if (!found) return jsonReply(res, 404, { error: 'item not found' });
|
|
6327
6272
|
|
|
6328
6273
|
// Clean dispatch entries + kill running agent (outside lock)
|
|
6329
|
-
const dispatchRemoved = cleanDispatchEntries(d =>
|
|
6330
|
-
d.meta?.item?.id === id ||
|
|
6331
|
-
d.meta?.dispatchKey?.endsWith(id)
|
|
6332
|
-
);
|
|
6274
|
+
const dispatchRemoved = cleanDispatchEntries(d => matchesResolvedWorkItemDispatch(d, target, id));
|
|
6333
6275
|
|
|
6334
6276
|
// Clean cooldown entries so item can be re-created immediately
|
|
6335
6277
|
try {
|
|
6336
6278
|
mutateCooldowns(cooldowns => {
|
|
6337
|
-
|
|
6338
|
-
if (key.includes(id)) delete cooldowns[key];
|
|
6339
|
-
}
|
|
6279
|
+
delete cooldowns[dispatchPrefixForResolvedSource(target) + id];
|
|
6340
6280
|
return cooldowns;
|
|
6341
6281
|
});
|
|
6342
6282
|
} catch (e) { console.error('cooldown cleanup:', e.message); }
|
|
@@ -6362,10 +6302,9 @@ const server = http.createServer(async (req, res) => {
|
|
|
6362
6302
|
|
|
6363
6303
|
const target = resolveProjectSourceTarget(source, PROJECTS);
|
|
6364
6304
|
if (target.error) return jsonReply(res, 404, { error: target.error });
|
|
6365
|
-
const wiPath = target.wiPath;
|
|
6366
6305
|
|
|
6367
6306
|
let result = null;
|
|
6368
|
-
|
|
6307
|
+
mutateWorkItems(target.scope, (items) => {
|
|
6369
6308
|
if (!Array.isArray(items)) items = [];
|
|
6370
6309
|
const item = items.find(i => i.id === id);
|
|
6371
6310
|
if (!item) { result = { code: 404, body: { error: 'item not found' } }; return items; }
|
|
@@ -6384,17 +6323,12 @@ const server = http.createServer(async (req, res) => {
|
|
|
6384
6323
|
if (result.code !== 200) return jsonReply(res, result.code, result.body);
|
|
6385
6324
|
|
|
6386
6325
|
// Clean dispatch entries + kill running agent (outside lock)
|
|
6387
|
-
const dispatchRemoved = cleanDispatchEntries(d =>
|
|
6388
|
-
d.meta?.item?.id === id ||
|
|
6389
|
-
d.meta?.dispatchKey?.endsWith(id)
|
|
6390
|
-
);
|
|
6326
|
+
const dispatchRemoved = cleanDispatchEntries(d => matchesResolvedWorkItemDispatch(d, target, id));
|
|
6391
6327
|
|
|
6392
6328
|
// Clean cooldown entries
|
|
6393
6329
|
try {
|
|
6394
6330
|
mutateCooldowns(cooldowns => {
|
|
6395
|
-
|
|
6396
|
-
if (key.includes(id)) delete cooldowns[key];
|
|
6397
|
-
}
|
|
6331
|
+
delete cooldowns[dispatchPrefixForResolvedSource(target) + id];
|
|
6398
6332
|
return cooldowns;
|
|
6399
6333
|
});
|
|
6400
6334
|
} catch (e) { console.error('cooldown cleanup on cancel:', e.message); }
|
|
@@ -6412,37 +6346,13 @@ const server = http.createServer(async (req, res) => {
|
|
|
6412
6346
|
|
|
6413
6347
|
const target = resolveProjectSourceTarget(source, PROJECTS);
|
|
6414
6348
|
if (target.error) return jsonReply(res, 404, { error: target.error });
|
|
6415
|
-
const
|
|
6416
|
-
|
|
6417
|
-
let archivedItem = null;
|
|
6418
|
-
mutateJsonFileLocked(wiPath, (items) => {
|
|
6419
|
-
if (!Array.isArray(items)) items = [];
|
|
6420
|
-
const idx = items.findIndex(i => i.id === id);
|
|
6421
|
-
if (idx === -1) return items;
|
|
6422
|
-
archivedItem = items.splice(idx, 1)[0];
|
|
6423
|
-
archivedItem.archivedAt = new Date().toISOString();
|
|
6424
|
-
return items;
|
|
6425
|
-
});
|
|
6349
|
+
const archivedItem = require('./engine/work-items-store').archiveWorkItem(target.scope, id);
|
|
6426
6350
|
if (!archivedItem) return jsonReply(res, 404, { error: 'item not found' });
|
|
6427
6351
|
|
|
6428
|
-
// Append to archive file (outside lock)
|
|
6429
|
-
const archivePath = wiPath.replace('.json', '-archive.json');
|
|
6430
|
-
mutateJsonFileLocked(archivePath, (archive) => {
|
|
6431
|
-
if (!Array.isArray(archive)) archive = [];
|
|
6432
|
-
archive.push(archivedItem);
|
|
6433
|
-
return archive;
|
|
6434
|
-
}, { defaultValue: [] });
|
|
6435
|
-
|
|
6436
6352
|
// Clean dispatch entries for archived item
|
|
6437
|
-
|
|
6438
|
-
cleanDispatchEntries(d =>
|
|
6439
|
-
d.meta?.dispatchKey === sourcePrefix + id ||
|
|
6440
|
-
d.meta?.item?.id === id
|
|
6441
|
-
);
|
|
6353
|
+
cleanDispatchEntries(d => matchesResolvedWorkItemDispatch(d, target, id));
|
|
6442
6354
|
|
|
6443
|
-
//
|
|
6444
|
-
// is part of /api/status fast-state. Match every other mutating handler
|
|
6445
|
-
// and invalidate so the dashboard reflects the archive immediately.
|
|
6355
|
+
// Archiving removes the item from the active status snapshot.
|
|
6446
6356
|
invalidateStatusCache();
|
|
6447
6357
|
return jsonReply(res, 200, { ok: true, id });
|
|
6448
6358
|
} catch (e) { return jsonReply(res, 400, { error: e.message }); }
|
|
@@ -6453,7 +6363,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
6453
6363
|
// collectArchivedWorkItems uses safeJsonArr (typed default + logged parse
|
|
6454
6364
|
// failure), so a corrupt archive file is surfaced via console.error and
|
|
6455
6365
|
// contributes zero items instead of taking down the whole listing.
|
|
6456
|
-
return jsonReply(res, 200, collectArchivedWorkItems(
|
|
6366
|
+
return jsonReply(res, 200, collectArchivedWorkItems(PROJECTS));
|
|
6457
6367
|
} catch (e) { console.error('Archive fetch error:', e.message); return jsonReply(res, e.statusCode || 500, { error: e.message }); }
|
|
6458
6368
|
}
|
|
6459
6369
|
|
|
@@ -6474,10 +6384,15 @@ const server = http.createServer(async (req, res) => {
|
|
|
6474
6384
|
id === 'cancel' || id === 'reopen' || id === 'feedback') {
|
|
6475
6385
|
return jsonReply(res, 404, { error: 'work item not found' });
|
|
6476
6386
|
}
|
|
6477
|
-
|
|
6478
|
-
|
|
6479
|
-
|
|
6480
|
-
|
|
6387
|
+
const source = (new URL(req.url, 'http://localhost').searchParams.get('source') || '').trim();
|
|
6388
|
+
const matches = getWorkItems().filter(w => w && w.id === id && (!source || w._source === source));
|
|
6389
|
+
if (!source && matches.length > 1) {
|
|
6390
|
+
return jsonReply(res, 409, {
|
|
6391
|
+
error: 'work item id is ambiguous; provide source',
|
|
6392
|
+
sources: matches.map(w => w._source),
|
|
6393
|
+
});
|
|
6394
|
+
}
|
|
6395
|
+
const found = matches[0];
|
|
6481
6396
|
if (!found) return jsonReply(res, 404, { error: 'work item not found' });
|
|
6482
6397
|
return jsonReply(res, 200, { item: found });
|
|
6483
6398
|
} catch (e) { return jsonReply(res, 500, { error: e.message }); }
|
|
@@ -6526,10 +6441,9 @@ const server = http.createServer(async (req, res) => {
|
|
|
6526
6441
|
|
|
6527
6442
|
const target = resolveProjectSourceTarget(project, PROJECTS);
|
|
6528
6443
|
if (target.error) return jsonReply(res, 404, { error: target.error });
|
|
6529
|
-
const wiPath = target.wiPath;
|
|
6530
6444
|
|
|
6531
6445
|
let result = null;
|
|
6532
|
-
|
|
6446
|
+
mutateWorkItems(target.scope, (items) => {
|
|
6533
6447
|
if (!Array.isArray(items)) items = [];
|
|
6534
6448
|
const item = items.find(i => i.id === id);
|
|
6535
6449
|
if (!item) { result = { code: 404, body: { error: 'item not found' } }; return items; }
|
|
@@ -6548,13 +6462,12 @@ const server = http.createServer(async (req, res) => {
|
|
|
6548
6462
|
// Clear dispatch history and cooldowns outside lock
|
|
6549
6463
|
const dispatchKey = dispatchPrefixForResolvedSource(target) + id;
|
|
6550
6464
|
try {
|
|
6551
|
-
|
|
6552
|
-
mutateJsonFileLocked(dispatchPath, (dispatch) => {
|
|
6465
|
+
dispatchMod.mutateDispatch((dispatch) => {
|
|
6553
6466
|
dispatch.completed = Array.isArray(dispatch.completed) ? dispatch.completed : [];
|
|
6554
6467
|
dispatch.completed = dispatch.completed.filter(d => d.meta?.dispatchKey !== dispatchKey);
|
|
6555
6468
|
dispatch.completed = dispatch.completed.filter(d => !d.meta?.parentKey || d.meta.parentKey !== dispatchKey);
|
|
6556
6469
|
return dispatch;
|
|
6557
|
-
}
|
|
6470
|
+
});
|
|
6558
6471
|
} catch (e) { console.error('dispatch cleanup on reopen:', e.message); }
|
|
6559
6472
|
try {
|
|
6560
6473
|
mutateCooldowns(cooldowns => {
|
|
@@ -6624,7 +6537,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
6624
6537
|
}
|
|
6625
6538
|
const target = resolveWorkItemsCreateTarget(body.project);
|
|
6626
6539
|
if (target.error) return jsonReply(res, 400, { error: target.error });
|
|
6627
|
-
const
|
|
6540
|
+
const wiScope = target.scope;
|
|
6628
6541
|
const targetProject = target.project;
|
|
6629
6542
|
const id = 'W-' + shared.uid();
|
|
6630
6543
|
const item = {
|
|
@@ -6701,7 +6614,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
6701
6614
|
if (originAgent) item._originAgent = originAgent;
|
|
6702
6615
|
if (originWi) item._originWi = originWi;
|
|
6703
6616
|
await copyWorkItemPrFields(item, body, null, { project: targetProject });
|
|
6704
|
-
// W-mq5wfh1v000e0da9 — Auto-enroll the PR into
|
|
6617
|
+
// W-mq5wfh1v000e0da9 — Auto-enroll the PR into tracked state when
|
|
6705
6618
|
// this is a `type: fix/review/test` WI carrying a structured PR pointer and
|
|
6706
6619
|
// the PR isn't tracked yet. Without this, the engine's `pr_not_found` gate
|
|
6707
6620
|
// skips the WI forever (engine.js dispatch loop). Idempotent — safe to
|
|
@@ -6732,7 +6645,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
6732
6645
|
// statuses) and takes precedence when matched.
|
|
6733
6646
|
if (validatedFollowup) {
|
|
6734
6647
|
let followupConflict = null;
|
|
6735
|
-
mutateWorkItems(
|
|
6648
|
+
mutateWorkItems(wiScope, items => {
|
|
6736
6649
|
followupConflict = findExistingFollowupForComment(items, validatedFollowup.parent_comment_id);
|
|
6737
6650
|
if (followupConflict) return items;
|
|
6738
6651
|
items.push(item);
|
|
@@ -6751,7 +6664,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
6751
6664
|
invalidateStatusCache();
|
|
6752
6665
|
return jsonReply(res, 200, { ok: true, id });
|
|
6753
6666
|
}
|
|
6754
|
-
const createResult = createWorkItemWithDedup(
|
|
6667
|
+
const createResult = createWorkItemWithDedup(wiScope, item, { project: targetProject });
|
|
6755
6668
|
if (!createResult.created) {
|
|
6756
6669
|
const duplicateId = createResult.duplicateOf || createResult.item?.id;
|
|
6757
6670
|
// W-mr466ocx000l1191 — record the CC turn creation on the dedup path
|
|
@@ -6803,10 +6716,9 @@ const server = http.createServer(async (req, res) => {
|
|
|
6803
6716
|
|
|
6804
6717
|
const target = resolveProjectSourceTarget(source, PROJECTS);
|
|
6805
6718
|
if (target.error) return jsonReply(res, 404, { error: target.error });
|
|
6806
|
-
const
|
|
6719
|
+
const wiScope = target.scope;
|
|
6807
6720
|
|
|
6808
|
-
// W-mrazefzz000358e3 — body.project reassigns
|
|
6809
|
-
// work-items.json (or central) file owns this item. Resolve + validate
|
|
6721
|
+
// W-mrazefzz000358e3 — body.project reassigns the owning SQL scope.
|
|
6810
6722
|
// it up front (mirrors resolveWorkItemsCreateTarget's create-path
|
|
6811
6723
|
// contract) so an unknown project name 400s cleanly instead of the
|
|
6812
6724
|
// historical silent no-op. `projectMove` stays null when body.project
|
|
@@ -6820,19 +6732,14 @@ const server = http.createServer(async (req, res) => {
|
|
|
6820
6732
|
knownProjects: PROJECTS.map(p => p.name),
|
|
6821
6733
|
});
|
|
6822
6734
|
}
|
|
6823
|
-
projectMove = {
|
|
6735
|
+
projectMove = { scope: projTarget.scope, projectName: projTarget.project ? projTarget.project.name : null };
|
|
6824
6736
|
}
|
|
6825
6737
|
|
|
6826
6738
|
let result = null;
|
|
6827
6739
|
let agentChanged = false;
|
|
6828
|
-
// Set inside the source-file lock below when a project move is
|
|
6829
|
-
// needed. W-mrbbmltq000fce9f: the move itself (target push + source
|
|
6830
|
-
// removal) is executed AFTER this lock releases, target-write-first,
|
|
6831
|
-
// so a failure never straddles "spliced from source, not yet in
|
|
6832
|
-
// target" — see the two-phase block below.
|
|
6833
6740
|
let movedItem = null;
|
|
6834
6741
|
let needsMove = false;
|
|
6835
|
-
|
|
6742
|
+
mutateWorkItems(wiScope, (items) => {
|
|
6836
6743
|
if (!Array.isArray(items)) items = [];
|
|
6837
6744
|
const idx = items.findIndex(i => i.id === id);
|
|
6838
6745
|
if (idx === -1) { result = { code: 404, body: { error: 'item not found' } }; return items; }
|
|
@@ -6869,23 +6776,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
6869
6776
|
}
|
|
6870
6777
|
item.updatedAt = new Date().toISOString();
|
|
6871
6778
|
|
|
6872
|
-
|
|
6873
|
-
// between work-items.json files when the resolved target differs
|
|
6874
|
-
// from where it lives today, rather than just stamping item.project
|
|
6875
|
-
// in place. Only trip this when the resolved wiPath actually
|
|
6876
|
-
// changes — re-assigning the same project the item already lives in
|
|
6877
|
-
// is a no-op move.
|
|
6878
|
-
//
|
|
6879
|
-
// W-mrbbmltq000fce9f — do NOT splice the item out of the source
|
|
6880
|
-
// array here. Doing the splice-then-push as two independent locked
|
|
6881
|
-
// calls (the historical bug) means a target-write failure after
|
|
6882
|
-
// this callback commits leaves the item spliced from source and
|
|
6883
|
-
// never written to target: silently lost from both stores. Instead
|
|
6884
|
-
// only STAGE the move — clone the (already field-edited) item with
|
|
6885
|
-
// its new `project` value — and leave the source array untouched.
|
|
6886
|
-
// The actual splice happens in a later, separate lock, only once
|
|
6887
|
-
// the target push below has durably committed.
|
|
6888
|
-
if (projectMove && path.resolve(projectMove.wiPath) !== path.resolve(wiPath)) {
|
|
6779
|
+
if (projectMove && projectMove.scope !== wiScope) {
|
|
6889
6780
|
needsMove = true;
|
|
6890
6781
|
movedItem = { ...item };
|
|
6891
6782
|
if (projectMove.projectName) movedItem.project = projectMove.projectName;
|
|
@@ -6899,79 +6790,14 @@ const server = http.createServer(async (req, res) => {
|
|
|
6899
6790
|
|
|
6900
6791
|
if (needsMove && result.code === 200) {
|
|
6901
6792
|
try {
|
|
6902
|
-
|
|
6903
|
-
|
|
6904
|
-
|
|
6905
|
-
|
|
6906
|
-
// — nothing to roll back, nothing lost.
|
|
6907
|
-
mutateJsonFileLocked(projectMove.wiPath, (targetItems) => {
|
|
6908
|
-
if (!Array.isArray(targetItems)) targetItems = [];
|
|
6909
|
-
targetItems.push(movedItem);
|
|
6910
|
-
return targetItems;
|
|
6911
|
-
});
|
|
6912
|
-
} catch (targetErr) {
|
|
6913
|
-
return jsonReply(res, 500, {
|
|
6914
|
-
error: `project move failed while writing to target store; item left unchanged in its original project: ${targetErr.message}`,
|
|
6915
|
-
});
|
|
6916
|
-
}
|
|
6917
|
-
|
|
6918
|
-
try {
|
|
6919
|
-
// Phase 2: the target write is confirmed durable — now it's safe
|
|
6920
|
-
// to remove the item from the source store.
|
|
6921
|
-
mutateJsonFileLocked(wiPath, (items) => {
|
|
6922
|
-
if (!Array.isArray(items)) items = [];
|
|
6923
|
-
const spliceIdx = items.findIndex(i => i.id === id);
|
|
6924
|
-
if (spliceIdx !== -1) items.splice(spliceIdx, 1);
|
|
6925
|
-
return items;
|
|
6926
|
-
});
|
|
6927
|
-
} catch (spliceErr) {
|
|
6928
|
-
// Removing from source failed AFTER the target write already
|
|
6929
|
-
// committed. Compensate by removing the item from the target
|
|
6930
|
-
// store again, so the item ends up in exactly one store (its
|
|
6931
|
-
// original) instead of duplicated across two.
|
|
6932
|
-
//
|
|
6933
|
-
// W-mrciooy1000e2ac3 — the compensating rollback write can ITSELF
|
|
6934
|
-
// fail (double-failure: destination insert succeeded, source
|
|
6935
|
-
// splice failed, and the compensating removal from destination
|
|
6936
|
-
// also failed). Swallowing that second error and unconditionally
|
|
6937
|
-
// reporting "move was rolled back" is a lie: the item is still
|
|
6938
|
-
// duplicated in both stores. Track the rollback outcome and
|
|
6939
|
-
// surface the double-failure distinctly so the caller/UI never
|
|
6940
|
-
// believes a rollback happened when it didn't.
|
|
6941
|
-
let rollbackErr = null;
|
|
6942
|
-
try {
|
|
6943
|
-
mutateJsonFileLocked(projectMove.wiPath, (targetItems) => {
|
|
6944
|
-
if (!Array.isArray(targetItems)) targetItems = [];
|
|
6945
|
-
const ti = targetItems.findIndex(i => i.id === id);
|
|
6946
|
-
if (ti !== -1) targetItems.splice(ti, 1);
|
|
6947
|
-
return targetItems;
|
|
6948
|
-
});
|
|
6949
|
-
} catch (rbErr) { rollbackErr = rbErr; }
|
|
6950
|
-
|
|
6951
|
-
if (rollbackErr) {
|
|
6952
|
-
console.error(
|
|
6953
|
-
`[work-items/update] DOUBLE FAILURE on cross-project move of ${id}: ` +
|
|
6954
|
-
`source-splice failed (${spliceErr.message}) AND compensating rollback ` +
|
|
6955
|
-
`from target store also failed (${rollbackErr.message}). Item may now be ` +
|
|
6956
|
-
`duplicated across source (${wiPath}) and target (${projectMove.wiPath}) ` +
|
|
6957
|
-
`— manual reconciliation required.`
|
|
6958
|
-
);
|
|
6959
|
-
return jsonReply(res, 500, {
|
|
6960
|
-
error: `project move failed while removing from source store, and the compensating rollback also failed: ${rollbackErr.message}. ` +
|
|
6961
|
-
`Item may be duplicated across both stores — manual reconciliation required.`,
|
|
6962
|
-
reconciliation: {
|
|
6963
|
-
required: true,
|
|
6964
|
-
id,
|
|
6965
|
-
sourceStore: wiPath,
|
|
6966
|
-
targetStore: projectMove.wiPath,
|
|
6967
|
-
spliceError: spliceErr.message,
|
|
6968
|
-
rollbackError: rollbackErr.message,
|
|
6969
|
-
},
|
|
6970
|
-
});
|
|
6793
|
+
const moved = require('./engine/work-items-store')
|
|
6794
|
+
.moveWorkItem(wiScope, projectMove.scope, id, movedItem);
|
|
6795
|
+
if (!moved) {
|
|
6796
|
+
return jsonReply(res, 404, { error: 'item disappeared before project move' });
|
|
6971
6797
|
}
|
|
6972
|
-
|
|
6798
|
+
} catch (moveErr) {
|
|
6973
6799
|
return jsonReply(res, 500, {
|
|
6974
|
-
error: `project move failed
|
|
6800
|
+
error: `project move failed; item remains in its original scope: ${moveErr.message}`,
|
|
6975
6801
|
});
|
|
6976
6802
|
}
|
|
6977
6803
|
}
|
|
@@ -7015,8 +6841,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
7015
6841
|
const planWorkItem = buildPlanWorkItem(body);
|
|
7016
6842
|
if (planWorkItem.error) return jsonReply(res, 400, { error: planWorkItem.error });
|
|
7017
6843
|
// Write as a work item with type 'plan' — user must explicitly execute plan-to-prd after reviewing
|
|
7018
|
-
|
|
7019
|
-
mutateWorkItems(wiPath, items => { items.push(planWorkItem.item); });
|
|
6844
|
+
mutateWorkItems('central', items => { items.push(planWorkItem.item); });
|
|
7020
6845
|
recordCcTurnIfPresent(req, {
|
|
7021
6846
|
kind: 'plan', id: planWorkItem.id, title: planWorkItem.item?.title || body.title.trim(), project: planWorkItem.item?.project || null,
|
|
7022
6847
|
});
|
|
@@ -7032,8 +6857,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
7032
6857
|
if (manualPrd.error) return jsonReply(res, 400, { error: manualPrd.error });
|
|
7033
6858
|
|
|
7034
6859
|
const planFile = 'manual-' + shared.uid() + '.json';
|
|
7035
|
-
|
|
7036
|
-
safeWrite(manualPrdPath, manualPrd.plan);
|
|
6860
|
+
prdStore.writePrd(planFile, manualPrd.plan);
|
|
7037
6861
|
invalidatePlansCache();
|
|
7038
6862
|
return jsonReply(res, 200, { ok: true, id: manualPrd.id, file: planFile });
|
|
7039
6863
|
} catch (e) { return jsonReply(res, 400, { error: e.message }); }
|
|
@@ -7043,19 +6867,14 @@ const server = http.createServer(async (req, res) => {
|
|
|
7043
6867
|
try {
|
|
7044
6868
|
const body = await readBody(req);
|
|
7045
6869
|
if (!body.source || !body.itemId) return jsonReply(res, 400, { error: 'source and itemId required' });
|
|
7046
|
-
// Reject markdown filenames — mutateJsonFileLocked below silently overwrites
|
|
7047
|
-
// the file with JSON, destroying source plans (see /api/plans/approve trap).
|
|
7048
6870
|
if (!body.source.endsWith('.json')) return jsonReply(res, 400, { error: 'expected a PRD JSON filename in `source` (got `' + body.source + '`). Pass prd/<plan>.json, not plans/<plan>.md.' });
|
|
7049
|
-
const
|
|
7050
|
-
if (!
|
|
7051
|
-
// Pre-check: verify item exists before taking the lock
|
|
7052
|
-
const preCheck = safeJsonObj(planPath);
|
|
6871
|
+
const preCheck = prdStore.readPrd(body.source);
|
|
6872
|
+
if (!preCheck) return jsonReply(res, 404, { error: 'plan not found' });
|
|
7053
6873
|
const preItem = (preCheck.missing_features || []).find(f => f.id === body.itemId);
|
|
7054
6874
|
if (!preItem) return jsonReply(res, 404, { error: 'item not found in plan' });
|
|
7055
6875
|
|
|
7056
|
-
// Atomically read-modify-write under file lock
|
|
7057
6876
|
let item;
|
|
7058
|
-
|
|
6877
|
+
prdStore.mutatePrd(body.source, (plan) => {
|
|
7059
6878
|
const target = (plan.missing_features || []).find(f => f.id === body.itemId);
|
|
7060
6879
|
if (!target) return plan; // TOCTOU: item deleted between pre-check and lock acquisition
|
|
7061
6880
|
if (body.name !== undefined) target.name = body.name;
|
|
@@ -7072,13 +6891,10 @@ const server = http.createServer(async (req, res) => {
|
|
|
7072
6891
|
|
|
7073
6892
|
// Feature 3: Sync edits to materialized work item if still pending
|
|
7074
6893
|
let workItemSynced = false;
|
|
7075
|
-
const
|
|
7076
|
-
for (const
|
|
7077
|
-
wiSyncPaths.push(shared.projectWorkItemsPath(proj));
|
|
7078
|
-
}
|
|
7079
|
-
for (const wiPath of wiSyncPaths) {
|
|
6894
|
+
const workItemScopes = ['central', ...PROJECTS];
|
|
6895
|
+
for (const scope of workItemScopes) {
|
|
7080
6896
|
try {
|
|
7081
|
-
mutateWorkItems(
|
|
6897
|
+
mutateWorkItems(scope, items => {
|
|
7082
6898
|
const wi = items.find(w => w.sourcePlan === body.source && w.id === body.itemId);
|
|
7083
6899
|
if (wi && wi.status === WI_STATUS.PENDING) {
|
|
7084
6900
|
if (body.name !== undefined) wi.title = 'Implement: ' + body.name;
|
|
@@ -7103,10 +6919,9 @@ const server = http.createServer(async (req, res) => {
|
|
|
7103
6919
|
const body = await readBody(req);
|
|
7104
6920
|
if (!body.source || !body.itemId) return jsonReply(res, 400, { error: 'source and itemId required' });
|
|
7105
6921
|
if (!body.source.endsWith('.json')) return jsonReply(res, 400, { error: 'expected a PRD JSON filename in `source` (got `' + body.source + '`). Pass prd/<plan>.json, not plans/<plan>.md.' });
|
|
7106
|
-
|
|
7107
|
-
if (!statePathExists(planPath)) return jsonReply(res, 404, { error: 'plan file not found' });
|
|
6922
|
+
if (!prdStore.readPrd(body.source)) return jsonReply(res, 404, { error: 'plan not found' });
|
|
7108
6923
|
let removed = false;
|
|
7109
|
-
|
|
6924
|
+
prdStore.mutatePrd(body.source, (plan) => {
|
|
7110
6925
|
if (!plan || Array.isArray(plan) || typeof plan !== 'object') plan = { missing_features: [] };
|
|
7111
6926
|
const features = Array.isArray(plan.missing_features) ? plan.missing_features : [];
|
|
7112
6927
|
const idx = features.findIndex(f => f.id === body.itemId);
|
|
@@ -7120,13 +6935,10 @@ const server = http.createServer(async (req, res) => {
|
|
|
7120
6935
|
|
|
7121
6936
|
// Also remove any materialized work item for this plan item
|
|
7122
6937
|
let cancelled = false;
|
|
7123
|
-
const
|
|
7124
|
-
for (const
|
|
7125
|
-
allWiPaths.push(shared.projectWorkItemsPath(proj));
|
|
7126
|
-
}
|
|
7127
|
-
for (const wiPath of allWiPaths) {
|
|
6938
|
+
const workItemScopes = ['central', ...PROJECTS];
|
|
6939
|
+
for (const scope of workItemScopes) {
|
|
7128
6940
|
try {
|
|
7129
|
-
mutateWorkItems(
|
|
6941
|
+
mutateWorkItems(scope, items => {
|
|
7130
6942
|
const filtered = items.filter(w => !(w.sourcePlan === body.source && w.id === body.itemId));
|
|
7131
6943
|
if (filtered.length < items.length) {
|
|
7132
6944
|
cancelled = true;
|
|
@@ -7170,22 +6982,20 @@ const server = http.createServer(async (req, res) => {
|
|
|
7170
6982
|
try { fs.unlinkSync(path.join(agentDir, 'steer.md')); } catch { /* optional */ }
|
|
7171
6983
|
|
|
7172
6984
|
// 3. Remove all active dispatch entries for this agent
|
|
7173
|
-
const dispatchPath = path.join(MINIONS_DIR, 'engine', 'dispatch.json');
|
|
7174
6985
|
const removedIds = [];
|
|
7175
|
-
|
|
6986
|
+
dispatchMod.mutateDispatch((dp) => {
|
|
7176
6987
|
const removed = (dp.active || []).filter(d => d.agent === agentId);
|
|
7177
6988
|
removed.forEach(d => removedIds.push(d.id));
|
|
7178
6989
|
dp.active = (dp.active || []).filter(d => d.agent !== agentId);
|
|
7179
6990
|
return dp;
|
|
7180
|
-
}
|
|
6991
|
+
});
|
|
7181
6992
|
|
|
7182
6993
|
// 4. Reset work items from dispatched → pending so they can be retried
|
|
7183
|
-
const
|
|
7184
|
-
for (const proj of PROJECTS) allWiPaths.push(shared.projectWorkItemsPath(proj));
|
|
6994
|
+
const workItemScopes = ['central', ...PROJECTS];
|
|
7185
6995
|
let resetCount = 0;
|
|
7186
|
-
for (const
|
|
6996
|
+
for (const scope of workItemScopes) {
|
|
7187
6997
|
try {
|
|
7188
|
-
mutateWorkItems(
|
|
6998
|
+
mutateWorkItems(scope, items => {
|
|
7189
6999
|
for (const item of items) {
|
|
7190
7000
|
if (item.dispatched_to === agentId && item.status === WI_STATUS.DISPATCHED) {
|
|
7191
7001
|
item.status = WI_STATUS.PENDING;
|
|
@@ -7214,8 +7024,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
7214
7024
|
if (!requestedAgent && !requestedTask) return jsonReply(res, 400, { error: 'agent or task required' });
|
|
7215
7025
|
|
|
7216
7026
|
// Pre-read for response snapshot. Lockless — cleanDispatchEntries owns the actual mutation.
|
|
7217
|
-
const
|
|
7218
|
-
const dispatch = safeJsonObj(dispatchPath);
|
|
7027
|
+
const dispatch = queries.getDispatch();
|
|
7219
7028
|
const active = Array.isArray(dispatch.active) ? dispatch.active : [];
|
|
7220
7029
|
const matchFn = (d) => {
|
|
7221
7030
|
const matchAgent = requestedAgent && d.agent === requestedAgent;
|
|
@@ -7227,7 +7036,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
7227
7036
|
// Route PID resolution + kill + dispatch removal through the canonical primitive.
|
|
7228
7037
|
// cleanDispatchEntries resolves the PID from engine/tmp/dispatch-<id>-*/pid-<id>.pid
|
|
7229
7038
|
// (see shared.findDispatchPidFile), kills outside the dispatch lock, and removes
|
|
7230
|
-
// the entry
|
|
7039
|
+
// the entry through the transactional dispatch store.
|
|
7231
7040
|
// The defunct per-agent status sidecar reads/writes that used to live here are gone
|
|
7232
7041
|
// (engine/queries.js documents that file no longer exists).
|
|
7233
7042
|
if (cancelled.length > 0) {
|
|
@@ -7751,7 +7560,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
7751
7560
|
// path.join. Without this, cat='..' collapses kbCatDir to MINIONS_DIR
|
|
7752
7561
|
// (path.join normalizes '..') and the next sanitizePath('config.json',
|
|
7753
7562
|
// MINIONS_DIR) check still passes, allowing safeRead to disclose
|
|
7754
|
-
// config.json /
|
|
7563
|
+
// config.json / runtime state to any browser the
|
|
7755
7564
|
// operator has open. The whitelist also rejects encoded variants
|
|
7756
7565
|
// (`%2e%2e`, `..%2f`, etc.) since none of those are valid category
|
|
7757
7566
|
// names. Single source of truth lives in shared.KB_READABLE_CATEGORIES.
|
|
@@ -7991,12 +7800,17 @@ const server = http.createServer(async (req, res) => {
|
|
|
7991
7800
|
|
|
7992
7801
|
async function handlePlansArchiveRead(req, res, match) {
|
|
7993
7802
|
const file = decodeURIComponent(match[1]);
|
|
7994
|
-
if (file.includes('..') || file.includes('\0')
|
|
7995
|
-
|
|
7996
|
-
|
|
7997
|
-
let content =
|
|
7998
|
-
|
|
7999
|
-
|
|
7803
|
+
if (file.includes('..') || file.includes('\0') || file.includes('/') || file.includes('\\')) {
|
|
7804
|
+
return jsonReply(res, 400, { error: 'invalid' });
|
|
7805
|
+
}
|
|
7806
|
+
let content = '';
|
|
7807
|
+
if (file.endsWith('.json')) {
|
|
7808
|
+
const row = prdStore.listPrdRows()
|
|
7809
|
+
.find(candidate => candidate.filename === file && (candidate.archived || shared.isPrdArchived(candidate.plan)));
|
|
7810
|
+
if (row) content = JSON.stringify(row.plan, null, 2);
|
|
7811
|
+
} else {
|
|
7812
|
+
content = safeRead(path.join(PLANS_DIR, 'archive', file));
|
|
7813
|
+
}
|
|
8000
7814
|
if (!content) return jsonReply(res, 404, { error: 'not found' });
|
|
8001
7815
|
const contentType = file.endsWith('.json') ? 'application/json' : 'text/plain';
|
|
8002
7816
|
res.setHeader('Content-Type', contentType + '; charset=utf-8');
|
|
@@ -8007,17 +7821,30 @@ const server = http.createServer(async (req, res) => {
|
|
|
8007
7821
|
async function handlePlansRead(req, res, match) {
|
|
8008
7822
|
const file = decodeURIComponent(match[1]);
|
|
8009
7823
|
if (file.includes('..') || file.includes('\0') || file.includes('/') || file.includes('\\')) return jsonReply(res, 400, { error: 'invalid' });
|
|
8010
|
-
let content =
|
|
8011
|
-
|
|
8012
|
-
if (
|
|
8013
|
-
|
|
8014
|
-
|
|
8015
|
-
|
|
8016
|
-
|
|
7824
|
+
let content = '';
|
|
7825
|
+
let resolvedPath = null;
|
|
7826
|
+
if (file.endsWith('.json')) {
|
|
7827
|
+
const row = prdStore.listPrdRows().find(candidate => candidate.filename === file);
|
|
7828
|
+
if (row) {
|
|
7829
|
+
content = JSON.stringify(row.plan, null, 2);
|
|
7830
|
+
res.setHeader('Last-Modified', new Date(row.updatedAt).toISOString());
|
|
7831
|
+
res.setHeader('X-Resolved-Path', `sql:prd/${row.archived ? 'archive/' : ''}${file}`);
|
|
7832
|
+
}
|
|
7833
|
+
} else {
|
|
7834
|
+
const candidates = [
|
|
7835
|
+
resolvePlanPath(file),
|
|
7836
|
+
path.join(PRD_DIR, 'guides', file),
|
|
7837
|
+
path.join(PLANS_DIR, 'archive', file),
|
|
7838
|
+
];
|
|
7839
|
+
resolvedPath = candidates.find(candidate => fs.existsSync(candidate)) || null;
|
|
7840
|
+
if (resolvedPath) content = safeRead(resolvedPath);
|
|
7841
|
+
}
|
|
8017
7842
|
if (!content) return jsonReply(res, 404, { error: 'not found' });
|
|
8018
|
-
|
|
8019
|
-
|
|
8020
|
-
|
|
7843
|
+
if (resolvedPath) {
|
|
7844
|
+
const stat = fs.statSync(resolvedPath);
|
|
7845
|
+
res.setHeader('Last-Modified', stat.mtime.toISOString());
|
|
7846
|
+
res.setHeader('X-Resolved-Path', path.relative(MINIONS_DIR, resolvedPath).replace(/\\/g, '/'));
|
|
7847
|
+
}
|
|
8021
7848
|
const contentType = file.endsWith('.json') ? 'application/json' : 'text/plain';
|
|
8022
7849
|
res.setHeader('Content-Type', contentType + '; charset=utf-8');
|
|
8023
7850
|
res.setHeader('Cache-Control', 'no-cache');
|
|
@@ -8030,9 +7857,8 @@ const server = http.createServer(async (req, res) => {
|
|
|
8030
7857
|
const body = await readBody(req);
|
|
8031
7858
|
if (!body.file) return jsonReply(res, 400, { error: 'file required' });
|
|
8032
7859
|
if (!body.file.endsWith('.json')) return jsonReply(res, 400, { error: 'expected a PRD JSON filename (got `' + body.file + '`). To approve a plan, pass prd/<plan>.json, not the source plans/<plan>.md.' });
|
|
8033
|
-
const planPath = resolvePlanPath(body.file);
|
|
8034
7860
|
let wasStale = false;
|
|
8035
|
-
const plan =
|
|
7861
|
+
const plan = prdStore.mutatePrd(body.file, (data) => {
|
|
8036
7862
|
if (!data || Array.isArray(data) || typeof data !== 'object') data = {};
|
|
8037
7863
|
wasStale = !!data.planStale;
|
|
8038
7864
|
data.status = 'approved';
|
|
@@ -8042,7 +7868,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
8042
7868
|
delete data.planStale;
|
|
8043
7869
|
delete data._completionNotified;
|
|
8044
7870
|
return data;
|
|
8045
|
-
}
|
|
7871
|
+
});
|
|
8046
7872
|
|
|
8047
7873
|
// W-mqacrzis0003df4a + W-mqfevwr60018bd09 — Fresh source-plan
|
|
8048
7874
|
// staleness check (content-hash gated). Approve is the last gate
|
|
@@ -8064,13 +7890,10 @@ const server = http.createServer(async (req, res) => {
|
|
|
8064
7890
|
// Resume paused work items across all projects
|
|
8065
7891
|
let resumed = 0;
|
|
8066
7892
|
const resumedItemIds = [];
|
|
8067
|
-
const
|
|
8068
|
-
for (const
|
|
8069
|
-
wiPaths.push(shared.projectWorkItemsPath(proj));
|
|
8070
|
-
}
|
|
8071
|
-
for (const wiPath of wiPaths) {
|
|
7893
|
+
const workItemScopes = ['central', ...PROJECTS];
|
|
7894
|
+
for (const scope of workItemScopes) {
|
|
8072
7895
|
try {
|
|
8073
|
-
|
|
7896
|
+
mutateWorkItems(scope, (items) => {
|
|
8074
7897
|
if (!Array.isArray(items)) return items;
|
|
8075
7898
|
for (const w of items) {
|
|
8076
7899
|
if (w.sourcePlan === body.file && w.status === WI_STATUS.PAUSED && w._pausedBy === 'prd-pause') {
|
|
@@ -8082,19 +7905,18 @@ const server = http.createServer(async (req, res) => {
|
|
|
8082
7905
|
}
|
|
8083
7906
|
}
|
|
8084
7907
|
return items;
|
|
8085
|
-
}
|
|
7908
|
+
});
|
|
8086
7909
|
} catch (e) { console.error('resume work items:', e.message); }
|
|
8087
7910
|
}
|
|
8088
7911
|
|
|
8089
7912
|
// Clear dispatch completed entries for resumed items so they aren't dedup-blocked
|
|
8090
7913
|
if (resumedItemIds.length > 0) {
|
|
8091
|
-
const dispatchPath = path.join(MINIONS_DIR, 'engine', 'dispatch.json');
|
|
8092
7914
|
const resumedSet = new Set(resumedItemIds);
|
|
8093
|
-
|
|
7915
|
+
dispatchMod.mutateDispatch((dispatch) => {
|
|
8094
7916
|
dispatch.completed = Array.isArray(dispatch.completed) ? dispatch.completed : [];
|
|
8095
7917
|
dispatch.completed = dispatch.completed.filter(d => !resumedSet.has(d.meta?.item?.id));
|
|
8096
7918
|
return dispatch;
|
|
8097
|
-
}
|
|
7919
|
+
});
|
|
8098
7920
|
}
|
|
8099
7921
|
|
|
8100
7922
|
// Diff-aware PRD update: if plan was stale (source .md revised), dispatch plan-to-prd
|
|
@@ -8104,7 +7926,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
8104
7926
|
const config = queries.getConfig();
|
|
8105
7927
|
const allWorkItems = queries.getWorkItems(config);
|
|
8106
7928
|
const planWis = allWorkItems.filter(w => w.sourcePlan === body.file && w.itemType !== 'pr' && w.itemType !== 'verify');
|
|
8107
|
-
const allPrs = PROJECTS.flatMap(p => shared.
|
|
7929
|
+
const allPrs = PROJECTS.flatMap(p => shared.readPullRequests(p));
|
|
8108
7930
|
const prLinks = shared.getPrLinks();
|
|
8109
7931
|
const implContext = (plan.missing_features || []).map(f => {
|
|
8110
7932
|
const wi = planWis.find(w => w.id === f.id);
|
|
@@ -8146,19 +7968,18 @@ const server = http.createServer(async (req, res) => {
|
|
|
8146
7968
|
// cleanup; pause never stopped regeneration.)
|
|
8147
7969
|
function stopPlanMaterializedWork(prdFile, prdSourcePlan, opts) {
|
|
8148
7970
|
const targetStatus = opts.targetStatus;
|
|
8149
|
-
const
|
|
8150
|
-
for (const proj of PROJECTS) wiPaths.push(shared.projectWorkItemsPath(proj));
|
|
7971
|
+
const workItemScopes = ['central', ...PROJECTS];
|
|
8151
7972
|
|
|
8152
7973
|
// Step 1: find dispatched item ids (read-only, no lock).
|
|
8153
7974
|
const dispatchedItemIds = new Set();
|
|
8154
|
-
for (const
|
|
7975
|
+
for (const scope of workItemScopes) {
|
|
8155
7976
|
try {
|
|
8156
|
-
for (const w of
|
|
7977
|
+
for (const w of shared.readWorkItems(scope)) {
|
|
8157
7978
|
if (w.sourcePlan !== prdFile) continue;
|
|
8158
7979
|
if (w.completedAt || DONE_STATUSES.has(w.status)) continue;
|
|
8159
7980
|
if (w.status === WI_STATUS.DISPATCHED && w.id) dispatchedItemIds.add(w.id);
|
|
8160
7981
|
}
|
|
8161
|
-
} catch { /*
|
|
7982
|
+
} catch { /* scope may be unavailable */ }
|
|
8162
7983
|
}
|
|
8163
7984
|
|
|
8164
7985
|
// Step 2: kill active dispatches via the canonical primitive (resolves PIDs from
|
|
@@ -8172,11 +7993,11 @@ const server = http.createServer(async (req, res) => {
|
|
|
8172
7993
|
});
|
|
8173
7994
|
}
|
|
8174
7995
|
|
|
8175
|
-
// Step 3: transition WIs per
|
|
7996
|
+
// Step 3: transition WIs per scope.
|
|
8176
7997
|
let affected = 0;
|
|
8177
|
-
for (const
|
|
7998
|
+
for (const scope of workItemScopes) {
|
|
8178
7999
|
try {
|
|
8179
|
-
mutateWorkItems(
|
|
8000
|
+
mutateWorkItems(scope, items => {
|
|
8180
8001
|
for (const w of items) {
|
|
8181
8002
|
if (w.sourcePlan !== prdFile) continue;
|
|
8182
8003
|
if (w.completedAt || DONE_STATUSES.has(w.status)) continue;
|
|
@@ -8198,8 +8019,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
8198
8019
|
// stopped. (delete handles the DONE plan-to-prd WI separately to revert to draft.)
|
|
8199
8020
|
if (prdSourcePlan) {
|
|
8200
8021
|
try {
|
|
8201
|
-
|
|
8202
|
-
mutateWorkItems(centralPath, items => {
|
|
8022
|
+
mutateWorkItems('central', items => {
|
|
8203
8023
|
for (const w of items) {
|
|
8204
8024
|
if (w.type === WORK_TYPE.PLAN_TO_PRD && w.planFile === prdSourcePlan &&
|
|
8205
8025
|
!DONE_STATUSES.has(w.status) && w.status !== WI_STATUS.CANCELLED) {
|
|
@@ -8218,14 +8038,13 @@ const server = http.createServer(async (req, res) => {
|
|
|
8218
8038
|
const body = await readBody(req);
|
|
8219
8039
|
if (!body.file) return jsonReply(res, 400, { error: 'file required' });
|
|
8220
8040
|
if (!body.file.endsWith('.json')) return jsonReply(res, 400, { error: 'expected a PRD JSON filename (got `' + body.file + '`). Pass prd/<plan>.json, not the source plans/<plan>.md.' });
|
|
8221
|
-
const planPath = resolvePlanPath(body.file);
|
|
8222
8041
|
let prdSourcePlan = null;
|
|
8223
|
-
const updated =
|
|
8042
|
+
const updated = prdStore.mutatePrd(body.file, (plan) => {
|
|
8224
8043
|
if (!plan || Array.isArray(plan) || typeof plan !== 'object') plan = {};
|
|
8225
8044
|
plan.status = 'paused';
|
|
8226
8045
|
plan.pausedAt = new Date().toISOString();
|
|
8227
8046
|
return plan;
|
|
8228
|
-
}
|
|
8047
|
+
});
|
|
8229
8048
|
prdSourcePlan = updated?.source_plan || null;
|
|
8230
8049
|
|
|
8231
8050
|
// Propagate pause to materialized work items across all projects + cancel the
|
|
@@ -8279,15 +8098,14 @@ const server = http.createServer(async (req, res) => {
|
|
|
8279
8098
|
const body = await readBody(req);
|
|
8280
8099
|
if (!body.file) return jsonReply(res, 400, { error: 'file required' });
|
|
8281
8100
|
if (!body.file.endsWith('.json')) return jsonReply(res, 400, { error: 'expected a PRD JSON filename (got `' + body.file + '`). Pass prd/<plan>.json, not the source plans/<plan>.md.' });
|
|
8282
|
-
const
|
|
8283
|
-
const plan = mutateJsonFileLocked(planPath, (data) => {
|
|
8101
|
+
const plan = prdStore.mutatePrd(body.file, (data) => {
|
|
8284
8102
|
if (!data || Array.isArray(data) || typeof data !== 'object') data = {};
|
|
8285
8103
|
data.status = 'rejected';
|
|
8286
8104
|
data.rejectedAt = new Date().toISOString();
|
|
8287
8105
|
data.rejectedBy = body.rejectedBy || os.userInfo().username;
|
|
8288
8106
|
if (body.reason) data.rejectionReason = body.reason;
|
|
8289
8107
|
return data;
|
|
8290
|
-
}
|
|
8108
|
+
});
|
|
8291
8109
|
|
|
8292
8110
|
// RC3: reject used to flip only the PRD status — its materialized work items
|
|
8293
8111
|
// kept dispatching and any active agent kept running, and a pending plan-to-prd
|
|
@@ -8307,26 +8125,26 @@ const server = http.createServer(async (req, res) => {
|
|
|
8307
8125
|
try {
|
|
8308
8126
|
const body = await readBody(req);
|
|
8309
8127
|
if (!body.source) return jsonReply(res, 400, { error: 'source required' });
|
|
8310
|
-
|
|
8311
|
-
|
|
8312
|
-
|
|
8128
|
+
if (!body.source.endsWith('.json')) return jsonReply(res, 400, { error: 'expected a PRD JSON filename' });
|
|
8129
|
+
const plan = prdStore.readPrd(body.source);
|
|
8130
|
+
if (!plan) return jsonReply(res, 404, { error: 'plan not found' });
|
|
8313
8131
|
const planItems = plan.missing_features || [];
|
|
8314
8132
|
|
|
8315
8133
|
let reset = 0, kept = 0, newCount = 0;
|
|
8316
8134
|
const deletedItemIds = [];
|
|
8317
8135
|
|
|
8318
8136
|
// Scan all work item sources for materialized items from this plan
|
|
8319
|
-
const
|
|
8137
|
+
const workItemScopes = [{ scope: 'central', label: 'central' }];
|
|
8320
8138
|
for (const proj of PROJECTS) {
|
|
8321
|
-
|
|
8139
|
+
workItemScopes.push({ scope: proj, label: proj.name });
|
|
8322
8140
|
}
|
|
8323
8141
|
|
|
8324
8142
|
// Track which plan items have materialized work items
|
|
8325
8143
|
const materializedPlanItemIds = new Set();
|
|
8326
8144
|
|
|
8327
|
-
for (const wiInfo of
|
|
8145
|
+
for (const wiInfo of workItemScopes) {
|
|
8328
8146
|
try {
|
|
8329
|
-
mutateWorkItems(wiInfo.
|
|
8147
|
+
mutateWorkItems(wiInfo.scope, items => {
|
|
8330
8148
|
const filtered = [];
|
|
8331
8149
|
for (const w of items) {
|
|
8332
8150
|
if (w.sourcePlan === body.source) {
|
|
@@ -8372,7 +8190,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
8372
8190
|
}
|
|
8373
8191
|
if (orphanPendingIds.size > 0) {
|
|
8374
8192
|
try {
|
|
8375
|
-
|
|
8193
|
+
prdStore.mutatePrd(body.source, (current) => {
|
|
8376
8194
|
if (!current || !Array.isArray(current.missing_features)) return current;
|
|
8377
8195
|
const stamp = new Date().toISOString();
|
|
8378
8196
|
for (const f of current.missing_features) {
|
|
@@ -8402,50 +8220,37 @@ const server = http.createServer(async (req, res) => {
|
|
|
8402
8220
|
try {
|
|
8403
8221
|
const body = await readBody(req);
|
|
8404
8222
|
if (!body.file) return jsonReply(res, 400, { error: 'file required' });
|
|
8405
|
-
|
|
8406
|
-
|
|
8407
|
-
if (!statePathExists(planPath)) return jsonReply(res, 404, { error: 'plan not found' });
|
|
8408
|
-
// Read plan content before deleting — needed for worktree cleanup and source_plan
|
|
8223
|
+
const isPrd = body.file.endsWith('.json');
|
|
8224
|
+
let planPath = null;
|
|
8409
8225
|
let planObj = null;
|
|
8410
8226
|
let prdSourcePlan = null;
|
|
8411
|
-
|
|
8412
|
-
|
|
8227
|
+
let archivedPrd = false;
|
|
8228
|
+
if (isPrd) {
|
|
8229
|
+
planObj = prdStore.readPrd(body.file);
|
|
8230
|
+
if (!planObj) {
|
|
8231
|
+
archivedPrd = true;
|
|
8232
|
+
planObj = prdStore.readPrd(body.file, { archived: true });
|
|
8233
|
+
}
|
|
8234
|
+
if (!planObj) return jsonReply(res, 404, { error: 'plan not found' });
|
|
8235
|
+
prdSourcePlan = planObj.source_plan || null;
|
|
8236
|
+
} else {
|
|
8237
|
+
planPath = resolvePlanPath(body.file);
|
|
8238
|
+
if (!statePathExists(planPath)) return jsonReply(res, 404, { error: 'plan not found' });
|
|
8413
8239
|
}
|
|
8414
8240
|
// Clean up worktrees before deleting work items (needs branch info from work items)
|
|
8415
8241
|
try {
|
|
8416
8242
|
const { cleanupPlanWorktrees } = require('./engine/lifecycle');
|
|
8417
8243
|
cleanupPlanWorktrees(body.file, planObj || {}, PROJECTS, queries.getConfig());
|
|
8418
8244
|
} catch (e) { console.error('plan worktree cleanup:', e.message); }
|
|
8419
|
-
|
|
8420
|
-
|
|
8421
|
-
// (step 3) would resurrect it from a stale mirror row. Best-effort; no-op
|
|
8422
|
-
// for non-PRD paths (parsePrdPath returns null for plan .md deletes).
|
|
8423
|
-
if (body.file.endsWith('.json')) prdStore.deletePrd(planPath);
|
|
8424
|
-
// Neutralize the `.backup` sidecar so `safeJson` auto-restore can't
|
|
8425
|
-
// RESURRECT the PRD we just deleted (the live file is gone, but a stray
|
|
8426
|
-
// `prd/<plan>.json.backup` would be auto-restored on the next safeJson read
|
|
8427
|
-
// — the PRD comes back, with its prior `status: approved/active`, and the
|
|
8428
|
-
// materializer re-dispatches its work items). Mirrors the Archive handler's
|
|
8429
|
-
// backup cleanup (handlePlansArchive). A PRD delete must remove the restore
|
|
8430
|
-
// fuel, not just the live file.
|
|
8431
|
-
if (body.file.endsWith('.json')) {
|
|
8432
|
-
try {
|
|
8433
|
-
const backupCleanup = shared.neutralizeJsonBackupSidecar(planPath);
|
|
8434
|
-
if (!backupCleanup.ok) {
|
|
8435
|
-
console.warn(`Delete backup cleanup failed for ${body.file}: unlink (${backupCleanup.unlinkError}) / neutralize (${backupCleanup.writeError})`);
|
|
8436
|
-
}
|
|
8437
|
-
} catch (e) { console.warn(`Delete backup cleanup threw for ${body.file}: ${e.message}`); }
|
|
8438
|
-
}
|
|
8245
|
+
if (isPrd) prdStore.deletePrd(body.file, { archived: archivedPrd });
|
|
8246
|
+
else safeUnlink(planPath);
|
|
8439
8247
|
|
|
8440
8248
|
// Clean up materialized work items from all projects + central
|
|
8441
8249
|
let cleaned = 0;
|
|
8442
|
-
const
|
|
8443
|
-
for (const
|
|
8444
|
-
wiPaths.push(shared.projectWorkItemsPath(proj));
|
|
8445
|
-
}
|
|
8446
|
-
for (const wiPath of wiPaths) {
|
|
8250
|
+
const workItemScopes = ['central', ...PROJECTS];
|
|
8251
|
+
for (const scope of workItemScopes) {
|
|
8447
8252
|
try {
|
|
8448
|
-
mutateWorkItems(
|
|
8253
|
+
mutateWorkItems(scope, items => {
|
|
8449
8254
|
const filtered = items.filter(w => w.sourcePlan !== body.file);
|
|
8450
8255
|
if (filtered.length < items.length) {
|
|
8451
8256
|
cleaned += items.length - filtered.length;
|
|
@@ -8465,8 +8270,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
8465
8270
|
// If deleting a PRD .json, reset the plan-to-prd work item so the source .md reverts to draft
|
|
8466
8271
|
if (prdSourcePlan) {
|
|
8467
8272
|
try {
|
|
8468
|
-
|
|
8469
|
-
mutateWorkItems(centralPath, items => {
|
|
8273
|
+
mutateWorkItems('central', items => {
|
|
8470
8274
|
for (const w of items) {
|
|
8471
8275
|
if (w.type === WORK_TYPE.PLAN_TO_PRD && DONE_STATUSES.has(w.status) && w.planFile === prdSourcePlan) {
|
|
8472
8276
|
w.status = WI_STATUS.CANCELLED;
|
|
@@ -8488,23 +8292,18 @@ const server = http.createServer(async (req, res) => {
|
|
|
8488
8292
|
const body = await readBody(req);
|
|
8489
8293
|
if (!body.file) return jsonReply(res, 400, { error: 'file required' });
|
|
8490
8294
|
const isPrd = body.file.endsWith('.json');
|
|
8491
|
-
shared.sanitizePath(body.file,
|
|
8492
|
-
|
|
8493
|
-
if (!statePathExists(planPath)) return jsonReply(res, 404, { error: 'plan not found' });
|
|
8494
|
-
|
|
8495
|
-
// Phase 10 step 4.2b: a PRD is archived IN PLACE (flag flip via
|
|
8496
|
-
// _archivePrdPostProcess — the json stays in prd/, no move, so the
|
|
8497
|
-
// live↔archive basename-collision class / footgun #7 is gone). Only the
|
|
8498
|
-
// .md plan still physically moves to plans/archive/.
|
|
8499
|
-
let archivePath;
|
|
8295
|
+
if (!isPrd) shared.sanitizePath(body.file, PLANS_DIR);
|
|
8296
|
+
let planPath = null;
|
|
8500
8297
|
if (isPrd) {
|
|
8501
|
-
|
|
8298
|
+
if (!prdStore.readPrd(body.file)) return jsonReply(res, 404, { error: 'plan not found' });
|
|
8502
8299
|
} else {
|
|
8300
|
+
planPath = resolvePlanPath(body.file);
|
|
8301
|
+
if (!statePathExists(planPath)) return jsonReply(res, 404, { error: 'plan not found' });
|
|
8503
8302
|
const archiveDir = path.join(PLANS_DIR, 'archive');
|
|
8504
8303
|
// DATA-LOSS GUARD: moveFileNoClobber dedupes the destination so a same-
|
|
8505
8304
|
// basename collision can't silently destroy a previously-archived plan +
|
|
8506
8305
|
// its completed work-item history. Bumps to -2/-3 instead of overwriting.
|
|
8507
|
-
|
|
8306
|
+
shared.moveFileNoClobber(planPath, archiveDir, body.file);
|
|
8508
8307
|
}
|
|
8509
8308
|
|
|
8510
8309
|
let archivedSource = null;
|
|
@@ -8515,8 +8314,6 @@ const server = http.createServer(async (req, res) => {
|
|
|
8515
8314
|
if (isPrd) {
|
|
8516
8315
|
const result = _archivePrdPostProcess({
|
|
8517
8316
|
planFile: body.file,
|
|
8518
|
-
archivePath,
|
|
8519
|
-
planPath,
|
|
8520
8317
|
plansDir: PLANS_DIR,
|
|
8521
8318
|
});
|
|
8522
8319
|
archivedSource = result.archivedSource;
|
|
@@ -8527,38 +8324,17 @@ const server = http.createServer(async (req, res) => {
|
|
|
8527
8324
|
// archive any PRD whose `source_plan` points back at it. Without this,
|
|
8528
8325
|
// the dashboard still renders the PRD as a status-completed plan card
|
|
8529
8326
|
// forever (real incident: killswitches-and-granular-controls.md →
|
|
8530
|
-
// minions-opg-2026-06-10-2.json).
|
|
8531
|
-
|
|
8532
|
-
|
|
8533
|
-
// shipped for the PRD branch in W-mqa13ulk0002def5 (PR #3222) — the
|
|
8534
|
-
// helper's source-plan move is a safe no-op here because the outer
|
|
8535
|
-
// handler already renamed body.file into plans/archive/.
|
|
8536
|
-
const prdFiles = prdStore.listPrdRows().filter(row => !row.archived).map(row => row.filename);
|
|
8537
|
-
for (const prdFile of prdFiles) {
|
|
8538
|
-
const prdLivePath = path.join(PRD_DIR, prdFile);
|
|
8539
|
-
let prd = null;
|
|
8540
|
-
try {
|
|
8541
|
-
prd = safeJsonObj(prdLivePath);
|
|
8542
|
-
} catch (e) {
|
|
8543
|
-
const warning = `Archive could not read PRD ${prdFile}: ${e.message}`;
|
|
8544
|
-
archiveWarnings.push(warning);
|
|
8545
|
-
console.warn(warning);
|
|
8546
|
-
continue;
|
|
8547
|
-
}
|
|
8327
|
+
// minions-opg-2026-06-10-2.json).
|
|
8328
|
+
const prdRows = prdStore.listPrdRows().filter(row => !row.archived);
|
|
8329
|
+
for (const { filename: prdFile, plan: prd } of prdRows) {
|
|
8548
8330
|
// Canonical match (prefix/dir-tolerant): a raw `prd.source_plan !==
|
|
8549
8331
|
// body.file` skipped PRDs whose source_plan carried the legacy
|
|
8550
8332
|
// `plans/` prefix, archiving the plan but NOT its PRD — the plan↔PRD
|
|
8551
8333
|
// status desync behind the "old PRDs came back" resurrection.
|
|
8552
8334
|
if (!prd || !shared.prdMatchesSourcePlan(prd.source_plan, body.file)) continue;
|
|
8553
8335
|
|
|
8554
|
-
// Phase 10 step 4.2b: flag the PRD archived IN PLACE (no move). The
|
|
8555
|
-
// outer handler already moved the .md to plans/archive/, so the
|
|
8556
|
-
// helper's (c) source-plan move is a no-op; (a) flips the flag (dual-
|
|
8557
|
-
// writing SQL) and (b) skips backup-neutralize since the file stayed.
|
|
8558
8336
|
const cascadeResult = _archivePrdPostProcess({
|
|
8559
8337
|
planFile: prdFile,
|
|
8560
|
-
archivePath: prdLivePath,
|
|
8561
|
-
planPath: prdLivePath,
|
|
8562
8338
|
plansDir: PLANS_DIR,
|
|
8563
8339
|
});
|
|
8564
8340
|
archiveWarnings.push(...cascadeResult.archiveWarnings);
|
|
@@ -8570,10 +8346,10 @@ const server = http.createServer(async (req, res) => {
|
|
|
8570
8346
|
// Cancel pending work items linked to this plan so the engine stops
|
|
8571
8347
|
// dispatching for an archived plan. Done items are preserved as history.
|
|
8572
8348
|
let cancelledItems = 0;
|
|
8573
|
-
const
|
|
8574
|
-
for (const
|
|
8349
|
+
const workItemScopes = ['central', ...PROJECTS];
|
|
8350
|
+
for (const scope of workItemScopes) {
|
|
8575
8351
|
try {
|
|
8576
|
-
mutateWorkItems(
|
|
8352
|
+
mutateWorkItems(scope, items => {
|
|
8577
8353
|
for (const w of items) {
|
|
8578
8354
|
if (w.sourcePlan !== body.file) continue;
|
|
8579
8355
|
if (w.status === WI_STATUS.PENDING || w.status === WI_STATUS.QUEUED) {
|
|
@@ -8605,66 +8381,43 @@ const server = http.createServer(async (req, res) => {
|
|
|
8605
8381
|
try {
|
|
8606
8382
|
const body = await readBody(req);
|
|
8607
8383
|
if (!body.file) return jsonReply(res, 400, { error: 'file required' });
|
|
8608
|
-
try {
|
|
8384
|
+
try {
|
|
8385
|
+
if (!body.file.endsWith('.json')) shared.sanitizePath(body.file, PLANS_DIR);
|
|
8386
|
+
}
|
|
8609
8387
|
catch { return jsonReply(res, 400, { error: 'invalid filename' }); }
|
|
8610
8388
|
const isJson = body.file.endsWith('.json');
|
|
8611
|
-
|
|
8612
|
-
const archivePath = path.join(targetDir, 'archive', body.file);
|
|
8613
|
-
const livePath = path.join(targetDir, body.file);
|
|
8614
|
-
// Phase 10 step 4.2b: a PRD can be archived two ways now — legacy
|
|
8615
|
-
// (physically in <dir>/archive/) or in-place (in <dir>/ with the archived
|
|
8616
|
-
// flag set). Restore from whichever it is.
|
|
8617
|
-
let liveDest;
|
|
8618
|
-
// Existence probes only — NEVER used as the write payload. The actual
|
|
8619
|
-
// restore below (prdStore.restorePrdFromArchive) re-reads the archived
|
|
8620
|
-
// row fresh inside its own transaction immediately before writing, so a
|
|
8621
|
-
// concurrent engine write to that row between this probe and the write
|
|
8622
|
-
// can't be clobbered by a stale in-memory snapshot (W-mrenu075000a94c9).
|
|
8623
|
-
const archivedPrdExists = isJson ? prdStore.readPrd(archivePath) != null : false;
|
|
8624
|
-
const activePrdData = isJson ? prdStore.readPrd(livePath) : null;
|
|
8625
|
-
if (isJson && archivedPrdExists) {
|
|
8626
|
-
const restored = prdStore.restorePrdFromArchive(body.file);
|
|
8627
|
-
if (!restored) return jsonReply(res, 404, { error: 'File not found in archive' });
|
|
8628
|
-
liveDest = livePath;
|
|
8629
|
-
} else if (fs.existsSync(archivePath)) {
|
|
8630
|
-
// DATA-LOSS GUARD: restoring must not clobber a LIVE plan/PRD of the same
|
|
8631
|
-
// basename (renameSync overwrites). moveFileNoClobber bumps the restored
|
|
8632
|
-
// name instead so the existing live file survives.
|
|
8633
|
-
liveDest = shared.moveFileNoClobber(archivePath, targetDir, body.file);
|
|
8634
|
-
} else if (isJson && activePrdData && shared.isPrdArchived(activePrdData)) {
|
|
8635
|
-
liveDest = livePath; // in-place flag-archived — already in prd/, just clear the flag below
|
|
8636
|
-
} else {
|
|
8637
|
-
return jsonReply(res, 404, { error: 'File not found in archive' });
|
|
8638
|
-
}
|
|
8639
|
-
// Clear the archived flag/status so the unarchived PRD renders LIVE again.
|
|
8640
|
-
// Archived-ness is the FLAG now (4.2a), so moving the file back is no longer
|
|
8641
|
-
// enough — a legacy PRD carries status='archived' and would still render
|
|
8642
|
-
// archived without this. Dual-writes the cleared state into SQL.
|
|
8643
|
-
if (isJson) {
|
|
8644
|
-
try {
|
|
8645
|
-
mutateJsonFileLocked(liveDest, (d) => {
|
|
8646
|
-
if (!d || typeof d !== 'object' || Array.isArray(d)) return d;
|
|
8647
|
-
if (d.status === 'archived') d.status = 'active';
|
|
8648
|
-
delete d.archived;
|
|
8649
|
-
delete d.archivedAt;
|
|
8650
|
-
return d;
|
|
8651
|
-
}, { skipWriteIfUnchanged: true });
|
|
8652
|
-
} catch (e) { console.warn(`Unarchive flag-clear failed for ${body.file}: ${e.message}`); }
|
|
8653
|
-
}
|
|
8654
|
-
|
|
8655
|
-
// Also unarchive linked source plan
|
|
8389
|
+
let restoredAs = body.file;
|
|
8656
8390
|
let unarchivedSource = null;
|
|
8657
8391
|
if (isJson) {
|
|
8392
|
+
const archivedRow = prdStore.readPrd(body.file, { archived: true });
|
|
8393
|
+
let prd = archivedRow
|
|
8394
|
+
? prdStore.restorePrdFromArchive(body.file)
|
|
8395
|
+
: prdStore.readPrd(body.file);
|
|
8396
|
+
if (!prd || (!archivedRow && !shared.isPrdArchived(prd))) {
|
|
8397
|
+
return jsonReply(res, 404, { error: 'File not found in archive' });
|
|
8398
|
+
}
|
|
8399
|
+
prdStore.mutatePrd(body.file, (data) => {
|
|
8400
|
+
if (data.status === 'archived') data.status = 'active';
|
|
8401
|
+
delete data.archived;
|
|
8402
|
+
delete data.archivedAt;
|
|
8403
|
+
return data;
|
|
8404
|
+
});
|
|
8405
|
+
prd = prdStore.readPrd(body.file);
|
|
8658
8406
|
try {
|
|
8659
|
-
const prd = safeJson(liveDest);
|
|
8660
8407
|
if (prd?.source_plan) {
|
|
8661
|
-
const
|
|
8408
|
+
const sourcePlan = shared.sourcePlanKey(prd.source_plan);
|
|
8409
|
+
const mdArchivePath = path.join(PLANS_DIR, 'archive', sourcePlan);
|
|
8662
8410
|
if (fs.existsSync(mdArchivePath)) {
|
|
8663
|
-
const liveMdDest = shared.moveFileNoClobber(mdArchivePath, PLANS_DIR,
|
|
8411
|
+
const liveMdDest = shared.moveFileNoClobber(mdArchivePath, PLANS_DIR, sourcePlan);
|
|
8664
8412
|
unarchivedSource = path.basename(liveMdDest);
|
|
8665
8413
|
}
|
|
8666
8414
|
}
|
|
8667
8415
|
} catch { /* optional */ }
|
|
8416
|
+
} else {
|
|
8417
|
+
const archivePath = path.join(PLANS_DIR, 'archive', body.file);
|
|
8418
|
+
if (!fs.existsSync(archivePath)) return jsonReply(res, 404, { error: 'File not found in archive' });
|
|
8419
|
+
const liveDest = shared.moveFileNoClobber(archivePath, PLANS_DIR, body.file);
|
|
8420
|
+
restoredAs = path.basename(liveDest);
|
|
8668
8421
|
}
|
|
8669
8422
|
|
|
8670
8423
|
invalidateStatusCache();
|
|
@@ -8672,7 +8425,6 @@ const server = http.createServer(async (req, res) => {
|
|
|
8672
8425
|
// `restoredAs` reflects the actual restored basename: moveFileNoClobber
|
|
8673
8426
|
// bumps to <name>-2 when a live file of the same name already exists, so
|
|
8674
8427
|
// the client isn't misled into expecting body.file when it differs.
|
|
8675
|
-
const restoredAs = path.basename(liveDest);
|
|
8676
8428
|
const payload = { ok: true, unarchivedSource };
|
|
8677
8429
|
if (restoredAs !== body.file) payload.restoredAs = restoredAs;
|
|
8678
8430
|
return jsonReply(res, 200, payload);
|
|
@@ -8684,20 +8436,18 @@ const server = http.createServer(async (req, res) => {
|
|
|
8684
8436
|
const body = await readBody(req);
|
|
8685
8437
|
if (!body.file || !body.feedback) return jsonReply(res, 400, { error: 'file and feedback required' });
|
|
8686
8438
|
if (!body.file.endsWith('.json')) return jsonReply(res, 400, { error: 'expected a PRD JSON filename (got `' + body.file + '`). Pass prd/<plan>.json, not the source plans/<plan>.md.' });
|
|
8687
|
-
const
|
|
8688
|
-
const plan = mutateJsonFileLocked(planPath, (data) => {
|
|
8439
|
+
const plan = prdStore.mutatePrd(body.file, (data) => {
|
|
8689
8440
|
if (!data || Array.isArray(data) || typeof data !== 'object') data = {};
|
|
8690
8441
|
data.status = 'revision-requested';
|
|
8691
8442
|
data.revision_feedback = body.feedback;
|
|
8692
8443
|
data.revisionRequestedAt = new Date().toISOString();
|
|
8693
8444
|
data.revisionRequestedBy = body.requestedBy || os.userInfo().username;
|
|
8694
8445
|
return data;
|
|
8695
|
-
}
|
|
8446
|
+
});
|
|
8696
8447
|
|
|
8697
8448
|
// Create a work item to revise the plan
|
|
8698
|
-
const wiPath = path.join(MINIONS_DIR, 'work-items.json');
|
|
8699
8449
|
const id = 'W-' + shared.uid();
|
|
8700
|
-
mutateWorkItems(
|
|
8450
|
+
mutateWorkItems('central', items => {
|
|
8701
8451
|
items.push({
|
|
8702
8452
|
id, title: 'Revise plan: ' + (plan.plan_summary || body.file),
|
|
8703
8453
|
type: WORK_TYPE.PLAN_TO_PRD, priority: 'high',
|
|
@@ -8718,11 +8468,10 @@ const server = http.createServer(async (req, res) => {
|
|
|
8718
8468
|
try {
|
|
8719
8469
|
const body = await readBody(req);
|
|
8720
8470
|
if (!body.file) return jsonReply(res, 400, { error: 'file required' });
|
|
8721
|
-
|
|
8722
|
-
const
|
|
8723
|
-
if (!
|
|
8724
|
-
|
|
8725
|
-
const plan = JSON.parse(planContent);
|
|
8471
|
+
if (!body.file.endsWith('.json')) return jsonReply(res, 400, { error: 'expected a PRD JSON filename' });
|
|
8472
|
+
const plan = prdStore.readPrd(body.file);
|
|
8473
|
+
if (!plan) return jsonReply(res, 404, { error: 'plan not found' });
|
|
8474
|
+
const planReference = `SQL PRD ${body.file}`;
|
|
8726
8475
|
const projectName = plan.project || 'Unknown';
|
|
8727
8476
|
|
|
8728
8477
|
// Build the session launch script
|
|
@@ -8730,10 +8479,10 @@ const server = http.createServer(async (req, res) => {
|
|
|
8730
8479
|
let sysPrompt;
|
|
8731
8480
|
try {
|
|
8732
8481
|
sysPrompt = fs.readFileSync(path.join(MINIONS_DIR, 'prompts', 'plan-advisor-system.md'), 'utf8')
|
|
8733
|
-
.replace(/\{\{plan_path\}\}/g,
|
|
8482
|
+
.replace(/\{\{plan_path\}\}/g, planReference)
|
|
8734
8483
|
.replace(/\{\{project_name\}\}/g, projectName);
|
|
8735
8484
|
} catch {
|
|
8736
|
-
sysPrompt = `You are a Plan Advisor. The plan is
|
|
8485
|
+
sysPrompt = `You are a Plan Advisor. The plan is ${planReference} for project ${projectName}. Help the user review and approve it.`;
|
|
8737
8486
|
}
|
|
8738
8487
|
|
|
8739
8488
|
const initialPrompt = `Here's the plan awaiting your review:
|
|
@@ -9585,7 +9334,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
9585
9334
|
_clearCcLiveStream(tabId);
|
|
9586
9335
|
}
|
|
9587
9336
|
// P-c2sess-1d8e: lock single-session reset against concurrent updateSession writes.
|
|
9588
|
-
|
|
9337
|
+
persistCcSession();
|
|
9589
9338
|
return jsonReply(res, 200, { ok: true });
|
|
9590
9339
|
}
|
|
9591
9340
|
|
|
@@ -9619,12 +9368,10 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
9619
9368
|
async function handleCCSessionDelete(req, res, match) {
|
|
9620
9369
|
const id = match?.[1];
|
|
9621
9370
|
if (!id) return jsonReply(res, 400, { error: 'id required' });
|
|
9622
|
-
|
|
9623
|
-
// handler cannot resurrect the deleted tab between read and write.
|
|
9624
|
-
mutateJsonFileLocked(CC_SESSIONS_PATH, (raw) => {
|
|
9371
|
+
smallStateStore.applyCcSessionsMutation((raw) => {
|
|
9625
9372
|
const sessions = _filterCcTabSessions(raw);
|
|
9626
9373
|
return sessions.filter(s => s.id !== id);
|
|
9627
|
-
}
|
|
9374
|
+
});
|
|
9628
9375
|
// Sub-task C of W-mp2w003600196c51: tear down the persistent ACP worker
|
|
9629
9376
|
// for this tab so we don't leak a Copilot process after the user closes
|
|
9630
9377
|
// the tab. closeTab is a no-op when the pool has no entry for the tabId,
|
|
@@ -9814,7 +9561,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
9814
9561
|
// W-mrb1a3950003d9ea — flat, stable label (no per-watch-id interpolation).
|
|
9815
9562
|
// Every other _engine category (kb-sweep, consolidation, command-center, …)
|
|
9816
9563
|
// uses one flat label; a dynamic watchId suffix here used to mint a brand-new
|
|
9817
|
-
// permanent metrics
|
|
9564
|
+
// permanent metrics category per watch firing (20+ confirmed live).
|
|
9818
9565
|
const label = 'watch-cc-triage';
|
|
9819
9566
|
|
|
9820
9567
|
const model = CONFIG.engine?.ccModel || shared.ENGINE_DEFAULTS.ccModel;
|
|
@@ -10692,10 +10439,8 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
10692
10439
|
const _persistTabId = body.tabId;
|
|
10693
10440
|
if (_persistTabId && responseSessionId) {
|
|
10694
10441
|
try {
|
|
10695
|
-
// P-c2sess-1d8e: one locked RMW so concurrent multi-tab streams can't
|
|
10696
|
-
// race on read+modify+write — both upsert paths share the lock.
|
|
10697
10442
|
const preview = (body.message || '').slice(0, 80);
|
|
10698
|
-
|
|
10443
|
+
smallStateStore.applyCcSessionsMutation((raw) => {
|
|
10699
10444
|
const sessions = _filterCcTabSessions(raw);
|
|
10700
10445
|
const existing = sessions.find(s => s.id === _persistTabId);
|
|
10701
10446
|
if (existing) {
|
|
@@ -10709,7 +10454,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
10709
10454
|
sessions.push({ id: _persistTabId, title: (body.message || 'New chat').slice(0, 40), sessionId: responseSessionId, createdAt: new Date(now).toISOString(), lastActiveAt: new Date(now).toISOString(), turnCount: 1, preview, _promptHash: _ccPromptHash, runtime: currentRuntime });
|
|
10710
10455
|
}
|
|
10711
10456
|
return sessions;
|
|
10712
|
-
}
|
|
10457
|
+
});
|
|
10713
10458
|
} catch { /* non-critical */ }
|
|
10714
10459
|
}
|
|
10715
10460
|
|
|
@@ -10899,9 +10644,8 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
10899
10644
|
participants: Array.isArray(schedForRun.participants) ? schedForRun.participants : [],
|
|
10900
10645
|
});
|
|
10901
10646
|
} else {
|
|
10902
|
-
const centralPath = path.join(MINIONS_DIR, 'work-items.json');
|
|
10903
10647
|
let duplicate = null;
|
|
10904
|
-
|
|
10648
|
+
mutateWorkItems('central', (items) => {
|
|
10905
10649
|
if (!Array.isArray(items)) items = [];
|
|
10906
10650
|
duplicate = items.find(i =>
|
|
10907
10651
|
i._scheduleId === item._scheduleId &&
|
|
@@ -10911,7 +10655,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
10911
10655
|
);
|
|
10912
10656
|
if (!duplicate) items.push(item);
|
|
10913
10657
|
return items;
|
|
10914
|
-
}
|
|
10658
|
+
});
|
|
10915
10659
|
if (duplicate) {
|
|
10916
10660
|
return jsonReply(res, 409, {
|
|
10917
10661
|
error: 'Schedule already has an active work item',
|
|
@@ -12117,7 +11861,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
12117
11861
|
|
|
12118
11862
|
// ── Managed processes API (P-4b8d2e57, plan W-mp7k1r760003b5dd item 4) ──
|
|
12119
11863
|
// List + by-name + kill + restart for the engine-owned managed-spawn
|
|
12120
|
-
// services (
|
|
11864
|
+
// services (SQL managed-process state). Mirrors the keep-processes
|
|
12121
11865
|
// pattern above but lives a layer deeper because the state is engine-side
|
|
12122
11866
|
// (not per-agent sidecars).
|
|
12123
11867
|
|
|
@@ -12416,7 +12160,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
12416
12160
|
const projectName = runbook.project || target.owner_project || '';
|
|
12417
12161
|
const resolveTarget = resolveWorkItemsCreateTarget(projectName);
|
|
12418
12162
|
if (resolveTarget.error) return jsonReply(res, 400, { error: resolveTarget.error }, req);
|
|
12419
|
-
const
|
|
12163
|
+
const wiScope = resolveTarget.scope;
|
|
12420
12164
|
const targetProject = resolveTarget.project;
|
|
12421
12165
|
|
|
12422
12166
|
// Create run record BEFORE the WI so we can stamp the WI with qaRunId.
|
|
@@ -12452,12 +12196,12 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
12452
12196
|
if (targetProject) wi.project = targetProject.name;
|
|
12453
12197
|
if (body.agent && typeof body.agent === 'string' && body.agent.trim()) wi.agent = body.agent.trim();
|
|
12454
12198
|
|
|
12455
|
-
//
|
|
12456
|
-
//
|
|
12199
|
+
// createRun and the work-item append are separate transactions; order is
|
|
12200
|
+
// intentional so a
|
|
12457
12201
|
// crash between the two leaves the run as `pending` (recoverable) rather
|
|
12458
12202
|
// than orphaning a WI with no run record.
|
|
12459
12203
|
let added = false;
|
|
12460
|
-
mutateWorkItems(
|
|
12204
|
+
mutateWorkItems(wiScope, (items) => {
|
|
12461
12205
|
if (!Array.isArray(items)) items = [];
|
|
12462
12206
|
if (!items.some(i => i && i.id === wiId)) {
|
|
12463
12207
|
items.push(wi);
|
|
@@ -12633,7 +12377,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
12633
12377
|
}
|
|
12634
12378
|
|
|
12635
12379
|
// W-mpxp90xs000i5732 — DELETE /api/qa/runs/<id>. Hard-delete: removes the
|
|
12636
|
-
// run from
|
|
12380
|
+
// run from SQL QA state and best-effort wipes the on-disk artifact
|
|
12637
12381
|
// directory under engine/qa-artifacts/<runId>/. Terminal-only — refuses
|
|
12638
12382
|
// pending/running runs with 409. Caller must cancel/complete the run via
|
|
12639
12383
|
// the lifecycle helpers before deletion. CSRF/Origin gate is applied by
|
|
@@ -12721,7 +12465,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
12721
12465
|
// Thin HTTP wrappers over engine/qa-sessions.js + engine/qa-runners.js. The
|
|
12722
12466
|
// lifecycle (state machine, work-item builders, dispatch chain) lives in
|
|
12723
12467
|
// those modules; these handlers translate HTTP into the right call,
|
|
12724
|
-
// resolve project/
|
|
12468
|
+
// resolve project/scope the same way handleQaRunbookRun does, and shape
|
|
12725
12469
|
// qa-sessions thrown errors into 400/404/409 instead of 500.
|
|
12726
12470
|
//
|
|
12727
12471
|
// Endpoint summary:
|
|
@@ -12752,22 +12496,17 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
12752
12496
|
return 500;
|
|
12753
12497
|
}
|
|
12754
12498
|
|
|
12755
|
-
// Resolve the
|
|
12756
|
-
// request. session.spec.project (or the create-time project field) decides
|
|
12757
|
-
// which work-items.json receives the new WI. central → root-level
|
|
12758
|
-
// work-items.json. Returns { error } on resolution failure so the handler
|
|
12759
|
-
// can reply 400.
|
|
12499
|
+
// Resolve the project and SQL scope for QA work items.
|
|
12760
12500
|
function _qaSessionsResolveTarget(projectName) {
|
|
12761
12501
|
const target = resolveWorkItemsCreateTarget(projectName || '');
|
|
12762
12502
|
if (target.error) return { error: target.error };
|
|
12763
|
-
return {
|
|
12503
|
+
return { scope: target.scope, project: target.project ? target.project.name : null };
|
|
12764
12504
|
}
|
|
12765
12505
|
|
|
12766
12506
|
// W-mpq6xqzj000606d0 — Multi-project resolver. Walks each requested project
|
|
12767
12507
|
// through resolveWorkItemsCreateTarget, returning either the full list of
|
|
12768
|
-
// {
|
|
12769
|
-
// { error } on the first failure.
|
|
12770
|
-
// and falls back to the legacy single `project` string.
|
|
12508
|
+
// {scope, project} entries (with the first treated as primary) or
|
|
12509
|
+
// { error } on the first failure.
|
|
12771
12510
|
function _qaSessionsResolveTargets(body) {
|
|
12772
12511
|
const list = Array.isArray(body && body.projects) && body.projects.length > 0
|
|
12773
12512
|
? body.projects.slice()
|
|
@@ -12815,7 +12554,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
12815
12554
|
let setupWiId = null;
|
|
12816
12555
|
try {
|
|
12817
12556
|
setupWiId = qaSessions.queueSetup(session.id, {
|
|
12818
|
-
resolvedTargets: resolution.targets.map(t => ({ project: t.project,
|
|
12557
|
+
resolvedTargets: resolution.targets.map(t => ({ project: t.project, scope: t.scope })),
|
|
12819
12558
|
});
|
|
12820
12559
|
} catch (e) {
|
|
12821
12560
|
// queueSetup throws when pending→spawning is rejected (createSession
|
|
@@ -12923,7 +12662,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
12923
12662
|
let setupWiId = null;
|
|
12924
12663
|
try {
|
|
12925
12664
|
setupWiId = qaSessions.queueSetup(session.id, {
|
|
12926
|
-
resolvedTargets: resolution.targets.map(t => ({ project: t.project,
|
|
12665
|
+
resolvedTargets: resolution.targets.map(t => ({ project: t.project, scope: t.scope })),
|
|
12927
12666
|
});
|
|
12928
12667
|
} catch (e) {
|
|
12929
12668
|
return jsonReply(res, _qaSessionsErrorToStatus(e), {
|
|
@@ -13067,7 +12806,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
13067
12806
|
let executeWiId;
|
|
13068
12807
|
try {
|
|
13069
12808
|
executeWiId = qaSessions.approveDraft(session.id, {
|
|
13070
|
-
|
|
12809
|
+
scope: resolved.scope,
|
|
13071
12810
|
qaRunId: run.id,
|
|
13072
12811
|
project: resolved.project,
|
|
13073
12812
|
});
|
|
@@ -13106,7 +12845,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
13106
12845
|
let draftWiId;
|
|
13107
12846
|
try {
|
|
13108
12847
|
draftWiId = qaSessions.editDraft(session.id, {
|
|
13109
|
-
|
|
12848
|
+
scope: resolved.scope,
|
|
13110
12849
|
feedback,
|
|
13111
12850
|
project: resolved.project,
|
|
13112
12851
|
});
|
|
@@ -13260,6 +12999,13 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
13260
12999
|
|
|
13261
13000
|
// Status & health
|
|
13262
13001
|
{ method: 'GET', path: '/api/status', desc: 'Full dashboard status snapshot (agents, PRDs, work items, dispatch, etc.)', handler: handleStatus },
|
|
13002
|
+
{ method: 'GET', path: '/api/constellation/v1/snapshot', desc: 'Versioned metadata-only full snapshot for the local Constellation mirror', handler: (req, res) => {
|
|
13003
|
+
return serveFreshJson(req, res, {
|
|
13004
|
+
tag: `constellation-v1-${_dashboardVersion.codeVersion || 'unknown'}-${_dashboardVersion.codeCommit || 'unknown'}`,
|
|
13005
|
+
inputs: [CONFIG_PATH, queries.CONTROL_PATH, INBOX_DIR, AGENTS_DIR],
|
|
13006
|
+
builder: _buildConstellationBridgeSnapshot,
|
|
13007
|
+
});
|
|
13008
|
+
}},
|
|
13263
13009
|
// Raw state-file passthrough (no /api/ prefix — this serves files, not API
|
|
13264
13010
|
// responses). Allowlisted top-level dirs only; mtime+size ETag → 304.
|
|
13265
13011
|
{ method: 'GET', path: /^\/state\/.+/, desc: 'Serve a raw state file (or directory listing) from MINIONS_DIR with mtime/size ETag', handler: handleStateRead },
|
|
@@ -13272,34 +13018,16 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
13272
13018
|
tag: 'agents',
|
|
13273
13019
|
inputs: [
|
|
13274
13020
|
CONFIG_PATH,
|
|
13275
|
-
path.join(ENGINE_DIR, 'dispatch.json'),
|
|
13276
13021
|
INBOX_DIR,
|
|
13277
13022
|
AGENTS_DIR,
|
|
13278
13023
|
],
|
|
13279
13024
|
builder: () => getAgents(),
|
|
13280
13025
|
});
|
|
13281
13026
|
}},
|
|
13282
|
-
{ method: 'GET', path: '/api/work-items', desc: 'Fully-enriched work items
|
|
13283
|
-
const config = queries.getConfig();
|
|
13284
|
-
const projects = config.projects || [];
|
|
13285
|
-
const inputs = [
|
|
13286
|
-
CONFIG_PATH,
|
|
13287
|
-
path.join(ENGINE_DIR, 'dispatch.json'),
|
|
13288
|
-
// Central work-items.json at MINIONS_DIR root — backs the 'central'
|
|
13289
|
-
// scope (schedule/pipeline/CC-created rootless WIs, per
|
|
13290
|
-
// work-items-store.js#_filePathForScope). Without this, a new
|
|
13291
|
-
// central-scope WI never busts the ETag until dispatch.json changes.
|
|
13292
|
-
shared.centralWorkItemsPath(MINIONS_DIR),
|
|
13293
|
-
];
|
|
13294
|
-
for (const p of projects) {
|
|
13295
|
-
if (p && p.name) {
|
|
13296
|
-
inputs.push(path.join(MINIONS_DIR, 'projects', p.name, 'work-items.json'));
|
|
13297
|
-
inputs.push(path.join(MINIONS_DIR, 'projects', p.name, 'pull-requests.json'));
|
|
13298
|
-
}
|
|
13299
|
-
}
|
|
13027
|
+
{ method: 'GET', path: '/api/work-items', desc: 'Fully-enriched SQL-backed work items joined with dispatch/PR state — fresh on every request; description hard-capped + acceptanceCriteria/references replaced with *Count integers (W-mq5xg5e9000nec0e); detail modal lazy-loads the full record via GET /api/work-items/<id>', handler: (req, res) => {
|
|
13300
13028
|
return serveFreshJson(req, res, {
|
|
13301
13029
|
tag: 'work-items',
|
|
13302
|
-
inputs,
|
|
13030
|
+
inputs: [CONFIG_PATH],
|
|
13303
13031
|
// W-mq5xg5e9000nec0e — Slim every item before stringify so the polled
|
|
13304
13032
|
// refresh-loop payload stays < ~300 KB even when description fields
|
|
13305
13033
|
// include 100+ KB transcripts. slimWorkItemForList shallow-copies so
|
|
@@ -13307,22 +13035,10 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
13307
13035
|
builder: () => getWorkItems().map(slimWorkItemForList),
|
|
13308
13036
|
});
|
|
13309
13037
|
}},
|
|
13310
|
-
{ method: 'GET', path: '/api/pull-requests', desc: 'Fully-enriched pull requests
|
|
13311
|
-
const config = queries.getConfig();
|
|
13312
|
-
const projects = config.projects || [];
|
|
13313
|
-
const inputs = [
|
|
13314
|
-
CONFIG_PATH,
|
|
13315
|
-
// Central pull-requests.json at MINIONS_DIR root — backs the
|
|
13316
|
-
// 'central' scope per pull-requests-store.js#_filePathForScope.
|
|
13317
|
-
// Mirrors the same class of staleness fix as /api/work-items.
|
|
13318
|
-
shared.centralPullRequestsPath(MINIONS_DIR),
|
|
13319
|
-
];
|
|
13320
|
-
for (const p of projects) {
|
|
13321
|
-
if (p && p.name) inputs.push(path.join(MINIONS_DIR, 'projects', p.name, 'pull-requests.json'));
|
|
13322
|
-
}
|
|
13038
|
+
{ method: 'GET', path: '/api/pull-requests', desc: 'Fully-enriched SQL-backed pull requests with URL backfill and project stamps', handler: (req, res) => {
|
|
13323
13039
|
return serveFreshJson(req, res, {
|
|
13324
13040
|
tag: 'pull-requests',
|
|
13325
|
-
inputs,
|
|
13041
|
+
inputs: [CONFIG_PATH],
|
|
13326
13042
|
builder: () => queries.getPullRequests(),
|
|
13327
13043
|
});
|
|
13328
13044
|
}},
|
|
@@ -13334,54 +13050,22 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
13334
13050
|
{ method: 'GET', path: '/api/dispatch', desc: 'Live dispatch queue with completion-report summaries (pending/active/completed slices)', handler: (req, res) => {
|
|
13335
13051
|
return serveFreshJson(req, res, {
|
|
13336
13052
|
tag: 'dispatch',
|
|
13337
|
-
inputs: [
|
|
13338
|
-
path.join(ENGINE_DIR, 'dispatch.json'),
|
|
13339
|
-
path.join(ENGINE_DIR, 'metrics.json'),
|
|
13340
|
-
],
|
|
13053
|
+
inputs: [],
|
|
13341
13054
|
builder: () => getDispatchQueue(),
|
|
13342
13055
|
});
|
|
13343
13056
|
}},
|
|
13344
13057
|
{ method: 'GET', path: '/api/metrics', desc: 'Per-agent metrics with PR/runtime enrichment (joined against pull-requests + dispatch.completed)', handler: (req, res) => {
|
|
13345
|
-
const config = queries.getConfig();
|
|
13346
|
-
const projects = config.projects || [];
|
|
13347
|
-
const inputs = [
|
|
13348
|
-
path.join(ENGINE_DIR, 'metrics.json'),
|
|
13349
|
-
path.join(ENGINE_DIR, 'dispatch.json'),
|
|
13350
|
-
// getMetrics() enriches from getPullRequests(), which includes the
|
|
13351
|
-
// central pull-requests.json scope — include it here too so a new
|
|
13352
|
-
// central-scope PR busts the ETag (same class of bug as /api/work-items).
|
|
13353
|
-
shared.centralPullRequestsPath(MINIONS_DIR),
|
|
13354
|
-
];
|
|
13355
|
-
for (const p of projects) {
|
|
13356
|
-
if (p && p.name) inputs.push(path.join(MINIONS_DIR, 'projects', p.name, 'pull-requests.json'));
|
|
13357
|
-
}
|
|
13358
13058
|
return serveFreshJson(req, res, {
|
|
13359
13059
|
tag: 'metrics',
|
|
13360
|
-
inputs,
|
|
13060
|
+
inputs: [CONFIG_PATH],
|
|
13361
13061
|
builder: () => getMetrics(),
|
|
13362
13062
|
});
|
|
13363
13063
|
}},
|
|
13364
|
-
{ method: 'GET', path: '/api/prd', desc: 'PRD status + progress derived from
|
|
13365
|
-
const config = queries.getConfig();
|
|
13366
|
-
const projects = config.projects || [];
|
|
13064
|
+
{ method: 'GET', path: '/api/prd', desc: 'PRD status + progress derived from SQL-backed PRDs, work items, and pull requests', handler: (req, res) => {
|
|
13367
13065
|
const inputs = [
|
|
13368
13066
|
CONFIG_PATH,
|
|
13369
|
-
PRD_DIR,
|
|
13370
|
-
path.join(PRD_DIR, 'archive'),
|
|
13371
|
-
// Plans dir — getPrdInfo statSyncs plans/<plan.source_plan> to
|
|
13372
|
-
// compute the `planStale` flag; edits to plans/*.md need to bust
|
|
13373
|
-
// the ETag so the Regenerate prompt surfaces. (Review finding #7.)
|
|
13374
13067
|
path.join(MINIONS_DIR, 'plans'),
|
|
13375
|
-
// Central work-items.json at MINIONS_DIR root — getPrdInfo
|
|
13376
|
-
// cross-references plan items lacking a project scope. (Review #7.)
|
|
13377
|
-
path.join(MINIONS_DIR, 'work-items.json'),
|
|
13378
13068
|
];
|
|
13379
|
-
for (const p of projects) {
|
|
13380
|
-
if (p && p.name) {
|
|
13381
|
-
inputs.push(path.join(MINIONS_DIR, 'projects', p.name, 'work-items.json'));
|
|
13382
|
-
inputs.push(path.join(MINIONS_DIR, 'projects', p.name, 'pull-requests.json'));
|
|
13383
|
-
}
|
|
13384
|
-
}
|
|
13385
13069
|
return serveFreshJson(req, res, {
|
|
13386
13070
|
tag: 'prd',
|
|
13387
13071
|
inputs,
|
|
@@ -13391,7 +13075,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
13391
13075
|
{ method: 'GET', path: '/api/archived-prds', desc: 'List of archived PRD files with status + completedAt', handler: (req, res) => {
|
|
13392
13076
|
return serveFreshJson(req, res, {
|
|
13393
13077
|
tag: 'archived-prds',
|
|
13394
|
-
inputs: [
|
|
13078
|
+
inputs: [],
|
|
13395
13079
|
builder: () => [],
|
|
13396
13080
|
});
|
|
13397
13081
|
}},
|
|
@@ -13418,12 +13102,8 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
13418
13102
|
builder: () => {
|
|
13419
13103
|
// Read config.json directly via queries.getConfig() rather than
|
|
13420
13104
|
// calling reloadConfig() — the latter cascades into
|
|
13421
|
-
// ensureConfiguredProjectStateFiles()
|
|
13422
|
-
//
|
|
13423
|
-
// pull-requests.json, putting the schedules sidebar poll on the
|
|
13424
|
-
// critical path of every engine PR/WI writer. The builder only
|
|
13425
|
-
// needs config.schedules; getConfig is a pure disk read.
|
|
13426
|
-
// (Round-2 review finding #3.)
|
|
13105
|
+
// ensureConfiguredProjectStateFiles(); the builder only needs
|
|
13106
|
+
// config.schedules, so getConfig avoids unrelated setup work.
|
|
13427
13107
|
const cfg = queries.getConfig() || {};
|
|
13428
13108
|
const scheds = cfg.schedules || [];
|
|
13429
13109
|
const runs = require('./engine/small-state-store').readScheduleRuns();
|
|
@@ -13570,11 +13250,10 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
13570
13250
|
const { id, source, rating, comment } = body;
|
|
13571
13251
|
if (!id || !rating) return jsonReply(res, 400, { error: 'id and rating required' });
|
|
13572
13252
|
const projects = shared.getProjects(CONFIG);
|
|
13573
|
-
const
|
|
13574
|
-
for (const p of projects) paths.push(shared.projectWorkItemsPath(p));
|
|
13253
|
+
const workItemScopes = ['central', ...projects];
|
|
13575
13254
|
let found = null;
|
|
13576
|
-
for (const
|
|
13577
|
-
mutateWorkItems(
|
|
13255
|
+
for (const scope of workItemScopes) {
|
|
13256
|
+
mutateWorkItems(scope, items => {
|
|
13578
13257
|
const item = items.find(i => i.id === id);
|
|
13579
13258
|
if (item && !found) {
|
|
13580
13259
|
item._humanFeedback = { rating, comment: comment || '', at: new Date().toISOString() };
|
|
@@ -13714,7 +13393,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
13714
13393
|
} catch (e) {
|
|
13715
13394
|
return jsonReply(res, e.statusCode || 400, { error: e.message }, req);
|
|
13716
13395
|
}
|
|
13717
|
-
const { id: prId,
|
|
13396
|
+
const { id: prId, scope, prNum, created, linked, targetProject, projectResolution, skipped, resurrected } = linkResult;
|
|
13718
13397
|
invalidateStatusCache();
|
|
13719
13398
|
// W-mrdutzdr000t22f4 — upsertPullRequestRecord can leave the record
|
|
13720
13399
|
// untouched (`skipped: true`) for reasons other than a resurrect, e.g.
|
|
@@ -13763,7 +13442,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
13763
13442
|
} catch { /* ADO token may not be available */ }
|
|
13764
13443
|
}
|
|
13765
13444
|
if (!prData) return;
|
|
13766
|
-
|
|
13445
|
+
shared.mutatePullRequests(scope, (prs) => {
|
|
13767
13446
|
const pr = prs.find(p => p.id === prId);
|
|
13768
13447
|
if (!pr) return prs;
|
|
13769
13448
|
// Remote title always wins — any user-supplied title is a placeholder (closes #1283)
|
|
@@ -13978,14 +13657,14 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
13978
13657
|
const { id, project: requestedProjectName } = body;
|
|
13979
13658
|
if (!id) return jsonReply(res, 400, { error: 'id required' });
|
|
13980
13659
|
reloadConfig();
|
|
13981
|
-
// Search all project
|
|
13660
|
+
// Search all project and central PR scopes.
|
|
13982
13661
|
const projects = shared.getProjects(CONFIG);
|
|
13983
13662
|
const projectByName = new Map(projects.map(project => [project.name, project]));
|
|
13984
13663
|
const store = require('./engine/pull-requests-store');
|
|
13985
13664
|
const storedPrs = store.readAllPullRequests() || [];
|
|
13986
13665
|
const prScopes = new Map([
|
|
13987
|
-
...projects.map(project => [project.name, {
|
|
13988
|
-
['central', {
|
|
13666
|
+
...projects.map(project => [project.name, { source: project, project }]),
|
|
13667
|
+
['central', { source: 'central', project: null }],
|
|
13989
13668
|
]);
|
|
13990
13669
|
const canonicalRequest = shared.parseCanonicalPrId(id);
|
|
13991
13670
|
const requestedIdentities = new Set();
|
|
@@ -14016,7 +13695,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
14016
13695
|
const ownerProject = scope !== 'central' ? projectByName.get(scope) || null : null;
|
|
14017
13696
|
if (!requestedIdentities.has(shared.getPrIdentityKey(pr, ownerProject))) continue;
|
|
14018
13697
|
if (!prScopes.has(scope)) {
|
|
14019
|
-
prScopes.set(scope, {
|
|
13698
|
+
prScopes.set(scope, { source: ownerProject || scope, project: ownerProject });
|
|
14020
13699
|
}
|
|
14021
13700
|
}
|
|
14022
13701
|
let found = false;
|
|
@@ -14027,11 +13706,11 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
14027
13706
|
// the other scope's copy fully untouched — queries.js#getPullRequests
|
|
14028
13707
|
// then de-dupes by picking that untouched duplicate as the "winner"
|
|
14029
13708
|
// and silently resurrects the "deleted" PR on the next read.
|
|
14030
|
-
for (const {
|
|
13709
|
+
for (const { source, project } of prScopes.values()) {
|
|
14031
13710
|
// Issue #384: use a tombstone (userDeleted:true) instead of splice so
|
|
14032
13711
|
// the next poller cycle cannot re-add the PR. The entry stays in the
|
|
14033
|
-
//
|
|
14034
|
-
shared.mutatePullRequests(
|
|
13712
|
+
// state but is filtered from API responses and skipped by pollers.
|
|
13713
|
+
shared.mutatePullRequests(source, (prs) => {
|
|
14035
13714
|
if (!Array.isArray(prs)) return prs;
|
|
14036
13715
|
const matches = prs.filter(pr =>
|
|
14037
13716
|
requestedIdentities.has(shared.getPrIdentityKey(pr, project))
|
|
@@ -14056,22 +13735,19 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
14056
13735
|
if (!prId) return jsonReply(res, 400, { error: 'prId required' });
|
|
14057
13736
|
if (!cause) return jsonReply(res, 400, { error: 'cause required' });
|
|
14058
13737
|
// Validate cause is one of the known PR_FIX_CAUSE values to reject
|
|
14059
|
-
// typos before scanning state
|
|
13738
|
+
// typos before scanning state.
|
|
14060
13739
|
const knownCauses = new Set(Object.values(shared.PR_FIX_CAUSE));
|
|
14061
13740
|
if (!knownCauses.has(cause)) {
|
|
14062
13741
|
return jsonReply(res, 400, { error: `unknown cause: ${cause}` });
|
|
14063
13742
|
}
|
|
14064
13743
|
reloadConfig();
|
|
14065
13744
|
const lifecycle = require('./engine/lifecycle');
|
|
14066
|
-
const
|
|
14067
|
-
...shared.getProjects(CONFIG).map(p => shared.projectPrPath(p)),
|
|
14068
|
-
shared.centralPullRequestsPath(MINIONS_DIR),
|
|
14069
|
-
];
|
|
13745
|
+
const prSources = [...shared.getProjects(CONFIG), 'central'];
|
|
14070
13746
|
let prFound = false;
|
|
14071
13747
|
let causeFound = false;
|
|
14072
|
-
for (const
|
|
13748
|
+
for (const source of prSources) {
|
|
14073
13749
|
if (causeFound) break;
|
|
14074
|
-
shared.mutatePullRequests(
|
|
13750
|
+
shared.mutatePullRequests(source, (prs) => {
|
|
14075
13751
|
if (!Array.isArray(prs)) return prs;
|
|
14076
13752
|
const target = prs.find(p => p && p.id === prId);
|
|
14077
13753
|
if (!target) return prs;
|
|
@@ -14094,7 +13770,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
14094
13770
|
// separately from `_noOpFixes` and so isn't reachable via the endpoint
|
|
14095
13771
|
// above. Once a fresh CI build is queued against a rebased/retried head,
|
|
14096
13772
|
// this lets an operator clear the stale pause without hand-editing
|
|
14097
|
-
//
|
|
13773
|
+
// tracked PR state.
|
|
14098
13774
|
{ method: 'POST', path: '/api/pull-requests/clear-build-fix-ineffective', desc: 'Resume a paused build-fix-ineffective ("infra issue suspected") pause (issue #671) — clears _buildFixIneffective so BUILD_FAILURE auto-fix dispatch can resume', params: 'prId (canonical host:slug#number), headSha? (optional — if provided, must match the paused record\'s headSha or the clear is rejected as stale)', handler: async (req, res) => {
|
|
14099
13775
|
const body = await readBody(req);
|
|
14100
13776
|
const prId = typeof body?.prId === 'string' ? body.prId.trim() : '';
|
|
@@ -14102,16 +13778,13 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
14102
13778
|
if (!prId) return jsonReply(res, 400, { error: 'prId required' });
|
|
14103
13779
|
reloadConfig();
|
|
14104
13780
|
const lifecycle = require('./engine/lifecycle');
|
|
14105
|
-
const
|
|
14106
|
-
...shared.getProjects(CONFIG).map(p => shared.projectPrPath(p)),
|
|
14107
|
-
shared.centralPullRequestsPath(MINIONS_DIR),
|
|
14108
|
-
];
|
|
13781
|
+
const prSources = [...shared.getProjects(CONFIG), 'central'];
|
|
14109
13782
|
let prFound = false;
|
|
14110
13783
|
let recordCleared = false;
|
|
14111
13784
|
let staleHead = false;
|
|
14112
|
-
for (const
|
|
13785
|
+
for (const source of prSources) {
|
|
14113
13786
|
if (recordCleared || staleHead) break;
|
|
14114
|
-
shared.mutatePullRequests(
|
|
13787
|
+
shared.mutatePullRequests(source, (prs) => {
|
|
14115
13788
|
if (!Array.isArray(prs)) return prs;
|
|
14116
13789
|
const target = prs.find(p => p && p.id === prId);
|
|
14117
13790
|
if (!target) return prs;
|
|
@@ -14150,15 +13823,12 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
14150
13823
|
if (!prId) return jsonReply(res, 400, { error: 'prId required' });
|
|
14151
13824
|
reloadConfig();
|
|
14152
13825
|
const lifecycle = require('./engine/lifecycle');
|
|
14153
|
-
const
|
|
14154
|
-
...shared.getProjects(CONFIG).map(p => shared.projectPrPath(p)),
|
|
14155
|
-
shared.centralPullRequestsPath(MINIONS_DIR),
|
|
14156
|
-
];
|
|
13826
|
+
const prSources = [...shared.getProjects(CONFIG), 'central'];
|
|
14157
13827
|
let prFound = false;
|
|
14158
13828
|
let recordCleared = false;
|
|
14159
|
-
for (const
|
|
13829
|
+
for (const source of prSources) {
|
|
14160
13830
|
if (recordCleared) break;
|
|
14161
|
-
shared.mutatePullRequests(
|
|
13831
|
+
shared.mutatePullRequests(source, (prs) => {
|
|
14162
13832
|
if (!Array.isArray(prs)) return prs;
|
|
14163
13833
|
const target = prs.find(p => p && p.id === prId);
|
|
14164
13834
|
if (!target) return prs;
|
|
@@ -14593,10 +14263,10 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
14593
14263
|
completeRun(body.id, run.runId, 'failed');
|
|
14594
14264
|
// Cancel pending/active work items and dispatches spawned by this run
|
|
14595
14265
|
let cancelled = 0;
|
|
14596
|
-
const
|
|
14597
|
-
for (const
|
|
14266
|
+
const workItemScopes = ['central', ...PROJECTS];
|
|
14267
|
+
for (const scope of workItemScopes) {
|
|
14598
14268
|
try {
|
|
14599
|
-
mutateWorkItems(
|
|
14269
|
+
mutateWorkItems(scope, items => {
|
|
14600
14270
|
for (const w of items) {
|
|
14601
14271
|
if (w._pipelineRun === run.runId && w.status !== shared.WI_STATUS.DONE && w.status !== shared.WI_STATUS.CANCELLED) {
|
|
14602
14272
|
w.status = shared.WI_STATUS.CANCELLED;
|
|
@@ -14971,6 +14641,7 @@ module.exports = {
|
|
|
14971
14641
|
_findDuplicateWorkItemCreate: findDuplicateWorkItemCreate,
|
|
14972
14642
|
_createWorkItemWithDedup: createWorkItemWithDedup,
|
|
14973
14643
|
_resolveWorkItemsCreateTarget: resolveWorkItemsCreateTarget,
|
|
14644
|
+
_matchesResolvedWorkItemDispatch: matchesResolvedWorkItemDispatch, // exported for testing
|
|
14974
14645
|
_validatePrFollowupShape: validatePrFollowupShape,
|
|
14975
14646
|
_findExistingFollowupForComment: findExistingFollowupForComment,
|
|
14976
14647
|
_extractMinionsAgentHeader: extractMinionsAgentHeader,
|
|
@@ -14984,6 +14655,7 @@ module.exports = {
|
|
|
14984
14655
|
buildCCStateRefresh,
|
|
14985
14656
|
_resetRefreshCache,
|
|
14986
14657
|
_routesAsMeta,
|
|
14658
|
+
_buildConstellationBridgeSnapshot,
|
|
14987
14659
|
_server: server,
|
|
14988
14660
|
_buildTranscriptCarryover,
|
|
14989
14661
|
_transcriptHasCarryoverContext,
|