@yemi33/minions 0.1.2379 → 0.1.2381
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 +3 -4
- package/dashboard/js/render-other.js +43 -23
- package/dashboard/js/render-prd.js +1 -1
- package/dashboard/js/render-work-items.js +13 -6
- package/dashboard/js/settings.js +40 -17
- package/dashboard.js +435 -768
- 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-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/live-checkout-mode.md +45 -26
- 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/ado-status.js +5 -5
- package/engine/ado.js +20 -25
- package/engine/agent-worker-pool.js +58 -1
- 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 +39 -44
- package/engine/github.js +20 -27
- package/engine/lifecycle.js +279 -354
- package/engine/live-checkout.js +193 -149
- 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 +13 -16
- 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 +4 -6
- package/engine/scheduler.js +0 -4
- package/engine/shared-branch-pr-reconcile.js +2 -3
- package/engine/shared.js +268 -699
- 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 +197 -358
- 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 +9 -9
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
|
}
|
|
@@ -420,8 +421,14 @@ function mergeSettingsConfigUpdate(current, candidate, body, patch = {}) {
|
|
|
420
421
|
// `candidate` in handleSettingsUpdate) is silently dropped on the way
|
|
421
422
|
// through mergeSettingsConfigUpdate → mutateDashboardConfig and never
|
|
422
423
|
// reaches disk — the endpoint would return 200 but persist nothing.
|
|
424
|
+
const modeWasRequested = Object.prototype.hasOwnProperty.call(update, 'checkoutMode')
|
|
425
|
+
|| Object.prototype.hasOwnProperty.call(update, 'worktreeMode');
|
|
423
426
|
if (Object.prototype.hasOwnProperty.call(candidateProject, 'checkoutMode')) {
|
|
424
427
|
currentProject.checkoutMode = candidateProject.checkoutMode;
|
|
428
|
+
} else if (Object.prototype.hasOwnProperty.call(candidateProject, 'worktreeMode')) {
|
|
429
|
+
currentProject.checkoutMode = shared.resolveCheckoutMode(candidateProject);
|
|
430
|
+
} else if (!modeWasRequested && Object.prototype.hasOwnProperty.call(currentProject, 'worktreeMode')) {
|
|
431
|
+
currentProject.checkoutMode = shared.resolveCheckoutMode(currentProject);
|
|
425
432
|
} else {
|
|
426
433
|
delete currentProject.checkoutMode;
|
|
427
434
|
}
|
|
@@ -609,7 +616,7 @@ function inferActionPrRecord(action, prs, project = null) {
|
|
|
609
616
|
// real pull request before `copyWorkItemPrFields` promotes it to structured
|
|
610
617
|
// targetPr/pr_id/prNumber fields. GitHub & ADO issues share the PR number
|
|
611
618
|
// namespace, so a bare `owner/repo#244` / `#244` can be an ISSUE. Stamping an
|
|
612
|
-
// issue mints a phantom
|
|
619
|
+
// issue mints a phantom tracked PR record (agent 'human', goes
|
|
613
620
|
// abandoned) AND routes the dispatch through the PR-fix path instead of
|
|
614
621
|
// implement-and-open-a-fresh-PR.
|
|
615
622
|
//
|
|
@@ -767,7 +774,7 @@ async function _fetchLivePrRecord(canonicalId, opts) {
|
|
|
767
774
|
function _findTrackedPrRecord(prRef, project) {
|
|
768
775
|
if (!project) return null;
|
|
769
776
|
try {
|
|
770
|
-
const prs = shared.
|
|
777
|
+
const prs = shared.readPullRequests(project);
|
|
771
778
|
return shared.findPrRecord(prs, prRef, project);
|
|
772
779
|
} catch { return null; }
|
|
773
780
|
}
|
|
@@ -831,7 +838,7 @@ async function copyWorkItemPrFields(item, input, pr = null, opts = {}) {
|
|
|
831
838
|
if (structuredRef) {
|
|
832
839
|
prRef = structuredRef; // explicit operator intent — trusted, no verification
|
|
833
840
|
} else {
|
|
834
|
-
if (String(item?.type || '').toLowerCase()
|
|
841
|
+
if (!shared.isFixLikeWorkType(String(item?.type || '').toLowerCase())) return;
|
|
835
842
|
const looseRef = getWorkItemPrRef(input);
|
|
836
843
|
if (!looseRef) return;
|
|
837
844
|
// issue #246 — confirm the loose ref is a PR (not an issue) before stamping.
|
|
@@ -919,14 +926,13 @@ function normalizeWorkItemDedupTitle(value) {
|
|
|
919
926
|
.toLowerCase();
|
|
920
927
|
}
|
|
921
928
|
|
|
922
|
-
function resolveWorkItemDedupProject(item,
|
|
929
|
+
function resolveWorkItemDedupProject(item, project = null) {
|
|
923
930
|
const projectName = normalizeWorkItemDedupText(item?.project || item?._project || item?._source);
|
|
924
931
|
if (projectName) {
|
|
925
932
|
const namedProject = shared.resolveProjectSource(projectName, PROJECTS, { allowCentral: false });
|
|
926
933
|
if (namedProject.project) return namedProject.project;
|
|
927
934
|
}
|
|
928
|
-
|
|
929
|
-
return shared.resolveProjectSource(wiPath, PROJECTS, { allowCentral: false }).project || null;
|
|
935
|
+
return project;
|
|
930
936
|
}
|
|
931
937
|
|
|
932
938
|
function getWorkItemPrRefCandidates(item) {
|
|
@@ -962,7 +968,7 @@ function normalizeWorkItemDedupPrIdentity(item, project = null) {
|
|
|
962
968
|
}
|
|
963
969
|
|
|
964
970
|
function workItemCreateFingerprint(item, options = {}) {
|
|
965
|
-
const project = resolveWorkItemDedupProject(item, options.
|
|
971
|
+
const project = resolveWorkItemDedupProject(item, options.project);
|
|
966
972
|
return {
|
|
967
973
|
title: normalizeWorkItemDedupTitle(item?.title),
|
|
968
974
|
type: routing.normalizeWorkType(item?.type || item?.workType, WORK_TYPE.IMPLEMENT),
|
|
@@ -1015,10 +1021,11 @@ function findDuplicateWorkItemCreate(items, candidate, options = {}) {
|
|
|
1015
1021
|
}) || null;
|
|
1016
1022
|
}
|
|
1017
1023
|
|
|
1018
|
-
function createWorkItemWithDedup(
|
|
1024
|
+
function createWorkItemWithDedup(source, item, options = {}) {
|
|
1025
|
+
const project = options.project || (source && typeof source === 'object' ? source : null);
|
|
1019
1026
|
let result = null;
|
|
1020
|
-
mutateWorkItems(
|
|
1021
|
-
const existing = findDuplicateWorkItemCreate(items, item, { ...options,
|
|
1027
|
+
mutateWorkItems(source, items => {
|
|
1028
|
+
const existing = findDuplicateWorkItemCreate(items, item, { ...options, project });
|
|
1022
1029
|
if (existing) {
|
|
1023
1030
|
result = { created: false, item: existing, duplicateOf: existing.id };
|
|
1024
1031
|
return items;
|
|
@@ -1153,21 +1160,26 @@ function dispatchPrefixForResolvedSource(target) {
|
|
|
1153
1160
|
return target?.project ? `work-${target.project.name}-` : 'central-work-';
|
|
1154
1161
|
}
|
|
1155
1162
|
|
|
1163
|
+
function matchesResolvedWorkItemDispatch(entry, target, id) {
|
|
1164
|
+
const dispatchKey = dispatchPrefixForResolvedSource(target) + id;
|
|
1165
|
+
return entry?.meta?.dispatchKey === dispatchKey || entry?.meta?.parentKey === dispatchKey;
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1156
1168
|
function findWorkItemsTargetById(id, source, projects = PROJECTS) {
|
|
1157
1169
|
const explicitSource = source !== undefined && source !== null && String(source).trim() !== '';
|
|
1158
1170
|
if (explicitSource) {
|
|
1159
1171
|
const target = resolveProjectSourceTarget(source, projects);
|
|
1160
1172
|
if (target.error) return { error: target.error };
|
|
1161
|
-
const items = shared.
|
|
1173
|
+
const items = shared.readWorkItems(target.scope);
|
|
1162
1174
|
return { ...target, found: items.some(i => i.id === id) };
|
|
1163
1175
|
}
|
|
1164
1176
|
|
|
1165
1177
|
const central = resolveProjectSourceTarget('central', projects);
|
|
1166
|
-
const centralItems = shared.
|
|
1178
|
+
const centralItems = shared.readWorkItems(central.scope);
|
|
1167
1179
|
if (centralItems.some(i => i.id === id)) return { ...central, found: true };
|
|
1168
1180
|
for (const project of projects) {
|
|
1169
1181
|
const target = resolveProjectSourceTarget(project.name, projects);
|
|
1170
|
-
const items = shared.
|
|
1182
|
+
const items = shared.readWorkItems(target.scope);
|
|
1171
1183
|
if (items.some(i => i.id === id)) return { ...target, found: true };
|
|
1172
1184
|
}
|
|
1173
1185
|
return { found: false };
|
|
@@ -1314,23 +1326,18 @@ function validatePipelineProjects(pipeline, projects = PROJECTS) {
|
|
|
1314
1326
|
}
|
|
1315
1327
|
|
|
1316
1328
|
/**
|
|
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.
|
|
1329
|
+
* Aggregate archived work items from central and project SQL scopes.
|
|
1322
1330
|
*
|
|
1323
1331
|
* Exported for testing (P-h3arch-8c19).
|
|
1324
1332
|
*/
|
|
1325
|
-
function collectArchivedWorkItems(
|
|
1333
|
+
function collectArchivedWorkItems(projects = PROJECTS) {
|
|
1334
|
+
const store = require('./engine/work-items-store');
|
|
1326
1335
|
const archived = [];
|
|
1327
|
-
const
|
|
1328
|
-
for (const item of safeJsonArr(centralPath)) {
|
|
1336
|
+
for (const item of store.readArchivedWorkItemsForScope('central')) {
|
|
1329
1337
|
archived.push({ ...item, _source: 'central' });
|
|
1330
1338
|
}
|
|
1331
1339
|
for (const project of projects) {
|
|
1332
|
-
const
|
|
1333
|
-
for (const item of safeJsonArr(archPath)) {
|
|
1340
|
+
for (const item of store.readArchivedWorkItemsForScope(project.name)) {
|
|
1334
1341
|
archived.push({ ...item, _source: project.name });
|
|
1335
1342
|
}
|
|
1336
1343
|
}
|
|
@@ -1344,7 +1351,7 @@ function linkPullRequestForTracking({ url, title, project: projectName, contextO
|
|
|
1344
1351
|
}
|
|
1345
1352
|
const projects = shared.getProjects(config);
|
|
1346
1353
|
const { project: targetProject, resolution: projectResolution } = resolveManualPrLinkProject(url, projectName, projects);
|
|
1347
|
-
const
|
|
1354
|
+
const source = targetProject || 'central';
|
|
1348
1355
|
|
|
1349
1356
|
const prNumMatch = url.match(/\/pull\/(\d+)|pullrequest\/(\d+)/);
|
|
1350
1357
|
const prNum = prNumMatch ? (prNumMatch[1] || prNumMatch[2]) : Date.now().toString().slice(-6);
|
|
@@ -1353,7 +1360,7 @@ function linkPullRequestForTracking({ url, title, project: projectName, contextO
|
|
|
1353
1360
|
const contextText = typeof context === 'string' ? context : (context == null ? '' : JSON.stringify(context));
|
|
1354
1361
|
const metadata = normalizePrMetadata(options.metadata);
|
|
1355
1362
|
const resolvedContextOnly = typeof contextOnly === 'boolean' ? contextOnly : false;
|
|
1356
|
-
const result = shared.upsertPullRequestRecord(
|
|
1363
|
+
const result = shared.upsertPullRequestRecord(source, {
|
|
1357
1364
|
id: prId,
|
|
1358
1365
|
prNumber: parseInt(prNum, 10) || null,
|
|
1359
1366
|
title: (metadata?.title || title || 'PR #' + prNum + ' (polling...)').slice(0, 120),
|
|
@@ -1382,7 +1389,7 @@ function linkPullRequestForTracking({ url, title, project: projectName, contextO
|
|
|
1382
1389
|
// tombstone left by a prior explicit delete rather than silently no-op.
|
|
1383
1390
|
allowResurrect: true,
|
|
1384
1391
|
});
|
|
1385
|
-
return { ...result,
|
|
1392
|
+
return { ...result, scope: shared.stateScope(source), targetProject, projectResolution, prNum };
|
|
1386
1393
|
}
|
|
1387
1394
|
|
|
1388
1395
|
// W-mpmwxkzm0009ba0b — Per-row auto-observe toggle backing helper for
|
|
@@ -1394,9 +1401,9 @@ function linkPullRequestForTracking({ url, title, project: projectName, contextO
|
|
|
1394
1401
|
// fallback (`contextOnly = !observe`) for any caller that still sends it —
|
|
1395
1402
|
// the dashboard client itself now sends `contextOnly` exclusively
|
|
1396
1403
|
// (P-2b6e4d81; see docs/deprecated.json#pr-observe-observe-body-param).
|
|
1397
|
-
// Returns the updated record +
|
|
1404
|
+
// Returns the updated record + SQL scope. Throws an
|
|
1398
1405
|
// Error with `statusCode` for the route handler to map to an HTTP status.
|
|
1399
|
-
function updatePullRequestObserveFlag({ host, slug, number, observe, contextOnly } = {}, config = CONFIG
|
|
1406
|
+
function updatePullRequestObserveFlag({ host, slug, number, observe, contextOnly } = {}, config = CONFIG) {
|
|
1400
1407
|
const hostStr = String(host || '').trim().toLowerCase();
|
|
1401
1408
|
const slugStr = String(slug || '').trim();
|
|
1402
1409
|
const numberInt = Number.parseInt(number, 10);
|
|
@@ -1428,21 +1435,18 @@ function updatePullRequestObserveFlag({ host, slug, number, observe, contextOnly
|
|
|
1428
1435
|
|
|
1429
1436
|
const canonicalId = `${hostStr}:${slugStr}#${numberInt}`;
|
|
1430
1437
|
const projects = shared.getProjects(config);
|
|
1431
|
-
const
|
|
1432
|
-
...projects.map(p => shared.projectPrPath(p)),
|
|
1433
|
-
shared.centralPullRequestsPath(minionsDir),
|
|
1434
|
-
];
|
|
1438
|
+
const sources = [...projects, 'central'];
|
|
1435
1439
|
|
|
1436
1440
|
let updated = null;
|
|
1437
|
-
let
|
|
1438
|
-
for (const
|
|
1441
|
+
let updatedScope = null;
|
|
1442
|
+
for (const source of sources) {
|
|
1439
1443
|
if (updated) break;
|
|
1440
|
-
shared.mutatePullRequests(
|
|
1444
|
+
shared.mutatePullRequests(source, (prs) => {
|
|
1441
1445
|
const pr = prs.find(p => p && p.id === canonicalId);
|
|
1442
1446
|
if (!pr) return prs;
|
|
1443
1447
|
pr.contextOnly = contextOnlyValue;
|
|
1444
1448
|
updated = { id: pr.id, contextOnly: pr.contextOnly };
|
|
1445
|
-
|
|
1449
|
+
updatedScope = shared.stateScope(source);
|
|
1446
1450
|
return prs;
|
|
1447
1451
|
});
|
|
1448
1452
|
}
|
|
@@ -1452,7 +1456,7 @@ function updatePullRequestObserveFlag({ host, slug, number, observe, contextOnly
|
|
|
1452
1456
|
err.statusCode = 404;
|
|
1453
1457
|
throw err;
|
|
1454
1458
|
}
|
|
1455
|
-
return { ...updated,
|
|
1459
|
+
return { ...updated, scope: updatedScope };
|
|
1456
1460
|
}
|
|
1457
1461
|
|
|
1458
1462
|
function _normalizeSkillDirForCompare(dir) {
|
|
@@ -1659,24 +1663,12 @@ const PLANS_CACHE_TTL_MS = 5000;
|
|
|
1659
1663
|
function invalidatePlansCache() { _plansCache = null; _plansCacheTs = 0; }
|
|
1660
1664
|
|
|
1661
1665
|
function statePathExists(filePath) {
|
|
1662
|
-
if (prdStore.parsePrdPath(filePath)) return prdStore.readPrd(filePath) != null;
|
|
1663
1666
|
return fs.existsSync(filePath);
|
|
1664
1667
|
}
|
|
1665
1668
|
|
|
1666
|
-
// Resolve a plan
|
|
1667
|
-
// Validates that the file stays within the expected directory to prevent path traversal.
|
|
1669
|
+
// Resolve a human-authored Markdown plan path.
|
|
1668
1670
|
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/
|
|
1671
|
+
if (!file.endsWith('.md')) throw new Error('expected a Markdown plan filename');
|
|
1680
1672
|
shared.sanitizePath(file, PLANS_DIR);
|
|
1681
1673
|
const active = path.join(PLANS_DIR, file);
|
|
1682
1674
|
if (fs.existsSync(active)) return active;
|
|
@@ -1685,72 +1677,36 @@ function resolvePlanPath(file) {
|
|
|
1685
1677
|
return active;
|
|
1686
1678
|
}
|
|
1687
1679
|
|
|
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;
|
|
1680
|
+
// Archive a PRD in SQL, then move its source Markdown plan.
|
|
1681
|
+
function _archivePrdPostProcess({ planFile, plansDir, _mutate, _read } = {}) {
|
|
1682
|
+
const mutate = typeof _mutate === 'function' ? _mutate : prdStore.mutatePrd;
|
|
1683
|
+
const read = typeof _read === 'function' ? _read : prdStore.readPrd;
|
|
1701
1684
|
const archiveWarnings = [];
|
|
1702
1685
|
let plan = {};
|
|
1703
1686
|
let archivedSource = null;
|
|
1704
1687
|
|
|
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
1688
|
try {
|
|
1711
|
-
plan = mutate(
|
|
1689
|
+
plan = mutate(planFile, (data) => {
|
|
1712
1690
|
if (!data || Array.isArray(data) || typeof data !== 'object') data = {};
|
|
1713
1691
|
data.status = 'archived';
|
|
1714
1692
|
data.archived = true;
|
|
1715
1693
|
data.archivedAt = new Date().toISOString();
|
|
1716
1694
|
return data;
|
|
1717
|
-
}
|
|
1695
|
+
}) || {};
|
|
1718
1696
|
} catch (e) {
|
|
1719
1697
|
const warning = `Archive status flip failed for ${planFile}: ${e.message}`;
|
|
1720
1698
|
archiveWarnings.push(warning);
|
|
1721
1699
|
console.warn(warning);
|
|
1722
1700
|
}
|
|
1723
1701
|
|
|
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
1702
|
if (!plan.source_plan) {
|
|
1728
1703
|
try {
|
|
1729
|
-
const
|
|
1730
|
-
if (
|
|
1731
|
-
} catch { /*
|
|
1704
|
+
const stored = read(planFile);
|
|
1705
|
+
if (stored && typeof stored === 'object' && stored.source_plan) plan = stored;
|
|
1706
|
+
} catch { /* best-effort */ }
|
|
1732
1707
|
}
|
|
1733
1708
|
|
|
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/.
|
|
1709
|
+
// Move the source plan Markdown into plans/archive/.
|
|
1754
1710
|
if (plan.source_plan) {
|
|
1755
1711
|
try {
|
|
1756
1712
|
// Canonical key: a `plans/`-prefixed source_plan would otherwise join to
|
|
@@ -2398,18 +2354,15 @@ function _ifNoneMatchHasEtag(headerValue, currentEtag) {
|
|
|
2398
2354
|
return false;
|
|
2399
2355
|
}
|
|
2400
2356
|
|
|
2401
|
-
//
|
|
2357
|
+
// Cross-process cache invalidation.
|
|
2402
2358
|
//
|
|
2403
2359
|
// Engine and dashboard are independent processes; `invalidateStatusCache()`
|
|
2404
2360
|
// 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.
|
|
2361
|
+
// SQL mutations advance the events table; intentionally file-backed inputs
|
|
2362
|
+
// still use mtime tracking.
|
|
2409
2363
|
//
|
|
2410
2364
|
// The two source-of-truth lists live in `engine/queries.js`:
|
|
2411
|
-
// - getStatusFastStateMtimePaths —
|
|
2412
|
-
// work-items, PR files, watches, qa runs, inbox, notes, meetings, …).
|
|
2365
|
+
// - getStatusFastStateMtimePaths — file-backed hot inputs.
|
|
2413
2366
|
// - getStatusSlowStateMtimePaths — slower-cadence writes (PRD JSON,
|
|
2414
2367
|
// schedule/pipeline runs, pinned, skill discovery roots, MCP configs,
|
|
2415
2368
|
// git refs, …).
|
|
@@ -2591,26 +2544,7 @@ function _buildStatusSlowState() {
|
|
|
2591
2544
|
...status,
|
|
2592
2545
|
mainBranch,
|
|
2593
2546
|
branchMismatch,
|
|
2594
|
-
|
|
2595
|
-
// mode so the Projects view can render a "Worktrees" vs "Live checkout"
|
|
2596
|
-
// pill. resolveCheckoutMode honors the legacy worktreeMode field and
|
|
2597
|
-
// defaults to 'worktree' when unset (matches engine spawn behavior).
|
|
2598
|
-
checkoutMode: shared.resolveCheckoutMode(p),
|
|
2599
|
-
// Surface the hybrid live-validation type (when configured) so the
|
|
2600
|
-
// Projects view can render a distinct "Hybrid" pill: coding WIs author
|
|
2601
|
-
// in isolated worktrees while only the named validation type runs
|
|
2602
|
-
// in-place on the live checkout. Only meaningful when checkoutMode is
|
|
2603
|
-
// 'live'; null otherwise (full-live or worktree projects).
|
|
2604
|
-
liveValidationType: (shared.resolveCheckoutMode(p) === 'live' && p.liveValidation && p.liveValidation.type)
|
|
2605
|
-
? p.liveValidation.type
|
|
2606
|
-
: null,
|
|
2607
|
-
// W-mr2q361a00097e5c — surface liveValidation.autoDispatch alongside
|
|
2608
|
-
// .type so the hybrid picker can preselect the "auto-validate"
|
|
2609
|
-
// checkbox and the pill can visibly distinguish manual vs
|
|
2610
|
-
// auto-validating hybrid projects. Only meaningful when checkoutMode
|
|
2611
|
-
// is 'live'; false otherwise (matches the implicit today-default of
|
|
2612
|
-
// autoDispatch being absent/false).
|
|
2613
|
-
liveValidationAutoDispatch: !!(shared.resolveCheckoutMode(p) === 'live' && p.liveValidation && p.liveValidation.autoDispatch === true),
|
|
2547
|
+
..._projectCheckoutConfigView(p),
|
|
2614
2548
|
};
|
|
2615
2549
|
}),
|
|
2616
2550
|
autoMode: {
|
|
@@ -2937,18 +2871,10 @@ const STATE_READ_ALLOWED_FILES = new Set([
|
|
|
2937
2871
|
const STATE_READ_DENIED_BASENAMES = new Set([
|
|
2938
2872
|
'session.json',
|
|
2939
2873
|
'sessions.json',
|
|
2940
|
-
'cc-session.json',
|
|
2941
|
-
'cc-sessions.json',
|
|
2942
|
-
'qa-sessions.json',
|
|
2943
2874
|
'qa-sessions-history.json',
|
|
2944
2875
|
'session-state.json',
|
|
2945
2876
|
'session-token.json',
|
|
2946
2877
|
'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
2878
|
]);
|
|
2953
2879
|
const STATE_READ_DENIED_PATTERNS = [
|
|
2954
2880
|
/(^|[\\/])state\.db($|-wal$|-shm$)/i,
|
|
@@ -2989,7 +2915,7 @@ function _stateReadContentType(ext) {
|
|
|
2989
2915
|
// serveFreshJson is "run server-side enrichment, ETag off input mtimes."
|
|
2990
2916
|
//
|
|
2991
2917
|
// Used for slices whose payload joins multiple files (agents derived from
|
|
2992
|
-
// config + dispatch + inbox + steering,
|
|
2918
|
+
// config + dispatch + inbox + steering, with SQL-backed metrics
|
|
2993
2919
|
// + dispatch + PRs, PRD derived from PRD dir + work-items + PRs, etc.).
|
|
2994
2920
|
// Replicating those joins in browser JS would be brittle, and shipping the
|
|
2995
2921
|
// fully-enriched result through /api/status pays the staleness tax that
|
|
@@ -3079,6 +3005,44 @@ async function serveFreshJson(req, res, opts) {
|
|
|
3079
3005
|
res.end(JSON.stringify(payload));
|
|
3080
3006
|
}
|
|
3081
3007
|
|
|
3008
|
+
function _buildConstellationBridgeSnapshot() {
|
|
3009
|
+
const config = queries.getConfig() || {};
|
|
3010
|
+
const scheduleRuns = smallStateStore.readScheduleRuns();
|
|
3011
|
+
const schedules = (Array.isArray(config.schedules) ? config.schedules : []).map(schedule => {
|
|
3012
|
+
const run = scheduleRuns[schedule.id];
|
|
3013
|
+
const lastRun = typeof run === 'string'
|
|
3014
|
+
? run
|
|
3015
|
+
: (run?.lastRun || run?.lastCompletedAt || null);
|
|
3016
|
+
return { ...schedule, _lastRun: lastRun };
|
|
3017
|
+
});
|
|
3018
|
+
const plans = prdStore.listPrdRows().map(row => {
|
|
3019
|
+
const plan = row.plan && typeof row.plan === 'object' ? row.plan : {};
|
|
3020
|
+
return {
|
|
3021
|
+
id: row.filename,
|
|
3022
|
+
title: plan.plan_summary || plan.title || row.filename,
|
|
3023
|
+
status: row.archived
|
|
3024
|
+
? 'archived'
|
|
3025
|
+
: (plan.status || (plan.requires_approval && !plan.approvedAt ? 'awaiting-approval' : 'active')),
|
|
3026
|
+
agent: plan.generated_by || null,
|
|
3027
|
+
project: plan.project || null,
|
|
3028
|
+
createdAt: plan.generated_at || null,
|
|
3029
|
+
updatedAt: row.updatedAt || null,
|
|
3030
|
+
};
|
|
3031
|
+
});
|
|
3032
|
+
|
|
3033
|
+
return require('./engine/bridge').buildBridgeSnapshot({
|
|
3034
|
+
minionsVersion: _dashboardVersion.codeVersion,
|
|
3035
|
+
control: queries.getControl(),
|
|
3036
|
+
projects: shared.getProjects(config),
|
|
3037
|
+
workItems: queries.getWorkItems(config, { enrich: false }),
|
|
3038
|
+
pullRequests: queries.getPullRequests(config),
|
|
3039
|
+
plans,
|
|
3040
|
+
schedules,
|
|
3041
|
+
agents: queries.getAgents(config),
|
|
3042
|
+
dispatch: queries.getDispatch(),
|
|
3043
|
+
});
|
|
3044
|
+
}
|
|
3045
|
+
|
|
3082
3046
|
function handleStateRead(req, res) {
|
|
3083
3047
|
const urlPath = req.url.split('?')[0];
|
|
3084
3048
|
const rel = decodeURIComponent(urlPath.slice('/state/'.length));
|
|
@@ -3285,7 +3249,7 @@ async function _handleStatusRequest(req, res) {
|
|
|
3285
3249
|
}
|
|
3286
3250
|
}
|
|
3287
3251
|
|
|
3288
|
-
// Periodic push for engine-driven
|
|
3252
|
+
// Periodic push for engine-driven SQL state changes that bypass invalidateStatusCache.
|
|
3289
3253
|
setInterval(() => {
|
|
3290
3254
|
if (_statusStreamClients.size === 0) return;
|
|
3291
3255
|
const data = getStatusJson();
|
|
@@ -3836,14 +3800,12 @@ function _filterCcTabSessions(sessions) {
|
|
|
3836
3800
|
}
|
|
3837
3801
|
|
|
3838
3802
|
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.
|
|
3803
|
+
if (!prune) return _filterCcTabSessions(smallStateStore.readCcSessions());
|
|
3842
3804
|
let sessions;
|
|
3843
|
-
|
|
3805
|
+
smallStateStore.applyCcSessionsMutation((raw) => {
|
|
3844
3806
|
sessions = _filterCcTabSessions(raw);
|
|
3845
3807
|
return sessions;
|
|
3846
|
-
}
|
|
3808
|
+
});
|
|
3847
3809
|
return sessions;
|
|
3848
3810
|
}
|
|
3849
3811
|
|
|
@@ -3951,7 +3913,7 @@ function _joinCcPromptParts(...parts) {
|
|
|
3951
3913
|
// only restore-time validity checks here are sessionId presence (anything
|
|
3952
3914
|
// else would auto-expire the user's chat without their consent).
|
|
3953
3915
|
try {
|
|
3954
|
-
const saved =
|
|
3916
|
+
const saved = smallStateStore.readCcGlobalSession();
|
|
3955
3917
|
if (saved && saved.sessionId) ccSession = saved;
|
|
3956
3918
|
} catch { /* optional */ }
|
|
3957
3919
|
|
|
@@ -4138,20 +4100,40 @@ function _resetPreambleCache() {
|
|
|
4138
4100
|
_preambleCacheTs = 0;
|
|
4139
4101
|
}
|
|
4140
4102
|
|
|
4141
|
-
|
|
4142
|
-
|
|
4143
|
-
|
|
4144
|
-
|
|
4145
|
-
|
|
4103
|
+
function _projectLiveValidationTypeValue(p) {
|
|
4104
|
+
const types = shared.resolveLiveValidationTypes(p);
|
|
4105
|
+
if (types.length === 0) return null;
|
|
4106
|
+
return Array.isArray(p?.liveValidation?.type) ? types : types[0];
|
|
4107
|
+
}
|
|
4108
|
+
|
|
4109
|
+
function _projectCheckoutConfigView(p) {
|
|
4110
|
+
const checkoutMode = shared.resolveCheckoutMode(p);
|
|
4111
|
+
return {
|
|
4112
|
+
checkoutMode,
|
|
4113
|
+
liveValidationType: checkoutMode === 'live' ? _projectLiveValidationTypeValue(p) : null,
|
|
4114
|
+
liveValidationInvalid: checkoutMode === 'live' && shared.isLiveValidationConfigInvalid(p),
|
|
4115
|
+
liveValidationAutoDispatch: !!shared.resolveLiveValidationAutoDispatchType(p),
|
|
4116
|
+
};
|
|
4117
|
+
}
|
|
4118
|
+
|
|
4119
|
+
// Compact checkout-mode label for Command Center state surfaces.
|
|
4146
4120
|
function _projectCheckoutModeLabel(p) {
|
|
4147
|
-
const
|
|
4148
|
-
|
|
4149
|
-
|
|
4150
|
-
|
|
4151
|
-
|
|
4121
|
+
const {
|
|
4122
|
+
checkoutMode,
|
|
4123
|
+
liveValidationType,
|
|
4124
|
+
liveValidationInvalid,
|
|
4125
|
+
} = _projectCheckoutConfigView(p);
|
|
4126
|
+
if (liveValidationInvalid) {
|
|
4127
|
+
const effectiveTypes = liveValidationType
|
|
4128
|
+
? (Array.isArray(liveValidationType) ? liveValidationType : [liveValidationType])
|
|
4129
|
+
: [];
|
|
4130
|
+
return `hybrid (invalid liveValidation; effective live types: ${effectiveTypes.join(', ') || 'none'})`;
|
|
4131
|
+
}
|
|
4132
|
+
if (checkoutMode === 'live' && liveValidationType) {
|
|
4133
|
+
const types = Array.isArray(liveValidationType) ? liveValidationType : [liveValidationType];
|
|
4152
4134
|
return `hybrid (live-validation: ${types.join(', ')})`;
|
|
4153
4135
|
}
|
|
4154
|
-
return
|
|
4136
|
+
return checkoutMode;
|
|
4155
4137
|
}
|
|
4156
4138
|
|
|
4157
4139
|
function buildCCStatePreamble() {
|
|
@@ -4168,7 +4150,10 @@ function buildCCStatePreamble() {
|
|
|
4168
4150
|
const prCount = getPullRequests().length;
|
|
4169
4151
|
const wiCount = getWorkItems(null, { enrich: false }).length;
|
|
4170
4152
|
|
|
4171
|
-
const planFiles = [
|
|
4153
|
+
const planFiles = [
|
|
4154
|
+
...safeReadDir(PLANS_DIR).filter(file => file.endsWith('.md')),
|
|
4155
|
+
...prdStore.listPrdRows().map(row => row.filename),
|
|
4156
|
+
];
|
|
4172
4157
|
|
|
4173
4158
|
const schedules = CONFIG.schedules || [];
|
|
4174
4159
|
const enabledSchedules = schedules.filter(s => s.enabled !== false).length;
|
|
@@ -4321,13 +4306,13 @@ function getWorkItemPrRef(input) {
|
|
|
4321
4306
|
// afford to be loose because gating would still require explicit
|
|
4322
4307
|
// operator intent.
|
|
4323
4308
|
//
|
|
4324
|
-
// FIX-
|
|
4309
|
+
// FIX-LIKE GATE on the stamp path (W-mqbaby2a000pa8ee): in practice
|
|
4325
4310
|
// the asymmetry leaked — the loose stamp writes to `item.pr_id` (a
|
|
4326
4311
|
// canonical structured field), and the strict gate later reads
|
|
4327
4312
|
// `item.pr_id` and sees the loose stamp AS IF it were structured
|
|
4328
4313
|
// intent. To stop that leak without changing the loose detector
|
|
4329
4314
|
// (which has other legitimate callers), `copyWorkItemPrFields` now
|
|
4330
|
-
// gates the loose result
|
|
4315
|
+
// gates the loose result through `isFixLikeWorkType`. Other WIs only
|
|
4331
4316
|
// get stamped from structured fields (incl. references/follow-up).
|
|
4332
4317
|
// See the comment block on `copyWorkItemPrFields` above for details.
|
|
4333
4318
|
return shared.extractWorkItemPrRef(input);
|
|
@@ -4340,12 +4325,13 @@ function getWorkItemPrRef(input) {
|
|
|
4340
4325
|
// invalidation paths are prompt-hash mismatch (correctness — a stale system
|
|
4341
4326
|
// prompt would carry the old persona/rules into resume turns) and the
|
|
4342
4327
|
// 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
4328
|
const DOC_SESSION_MAX_ENTRIES = shared.ENGINE_DEFAULTS.docSessionMaxEntries;
|
|
4347
4329
|
const docSessions = new Map(); // key → { sessionId, lastActiveAt, turnCount }
|
|
4348
4330
|
|
|
4331
|
+
function persistCcSession() {
|
|
4332
|
+
smallStateStore.applyCcGlobalSessionMutation(() => ccSession);
|
|
4333
|
+
}
|
|
4334
|
+
|
|
4349
4335
|
function _docSessionLastActiveMs(session) {
|
|
4350
4336
|
const ms = Date.parse(session?.lastActiveAt || session?.createdAt || '');
|
|
4351
4337
|
return Number.isFinite(ms) ? ms : 0;
|
|
@@ -4374,7 +4360,7 @@ function pruneDocSessions() {
|
|
|
4374
4360
|
// Load persisted doc sessions on startup — no TTL/turn filtering, sessions
|
|
4375
4361
|
// are non-expiring. Stale prompt-hash entries are dropped by pruneDocSessions.
|
|
4376
4362
|
try {
|
|
4377
|
-
const saved =
|
|
4363
|
+
const saved = smallStateStore.readDocSessions();
|
|
4378
4364
|
if (saved && typeof saved === 'object') {
|
|
4379
4365
|
for (const [key, s] of Object.entries(saved)) {
|
|
4380
4366
|
docSessions.set(key, s);
|
|
@@ -4387,8 +4373,7 @@ function persistDocSessions() {
|
|
|
4387
4373
|
pruneDocSessions();
|
|
4388
4374
|
const obj = {};
|
|
4389
4375
|
for (const [key, s] of docSessions) obj[key] = s;
|
|
4390
|
-
|
|
4391
|
-
mutateJsonFileLocked(DOC_SESSIONS_PATH, () => obj, { defaultValue: {} });
|
|
4376
|
+
smallStateStore.applyDocSessionsMutation(() => obj);
|
|
4392
4377
|
}
|
|
4393
4378
|
|
|
4394
4379
|
// Hourly hygiene sweep — drops prompt-hash mismatches and trims the LRU cap.
|
|
@@ -4456,7 +4441,7 @@ function updateSession(store, key, sessionId, existing) {
|
|
|
4456
4441
|
turnCount: (existing ? ccSession.turnCount : 0) + 1,
|
|
4457
4442
|
_promptHash: _ccPromptHash,
|
|
4458
4443
|
};
|
|
4459
|
-
|
|
4444
|
+
persistCcSession();
|
|
4460
4445
|
} else if (key) {
|
|
4461
4446
|
const prev = docSessions.get(key);
|
|
4462
4447
|
docSessions.set(key, {
|
|
@@ -4831,7 +4816,7 @@ async function ccCall(message, { store = 'cc', sessionKey, extraContext, label =
|
|
|
4831
4816
|
// Invalidate the dead session so future calls don't try to resume it
|
|
4832
4817
|
if (store === 'cc') {
|
|
4833
4818
|
ccSession = { sessionId: null, createdAt: null, lastActiveAt: null, turnCount: 0 };
|
|
4834
|
-
|
|
4819
|
+
persistCcSession();
|
|
4835
4820
|
} else if (sessionKey) {
|
|
4836
4821
|
docSessions.delete(sessionKey);
|
|
4837
4822
|
schedulePersistDocSessions();
|
|
@@ -4980,7 +4965,7 @@ async function ccCallStreaming(message, { store = 'cc', sessionKey, extraContext
|
|
|
4980
4965
|
sessionId = null;
|
|
4981
4966
|
if (store === 'cc') {
|
|
4982
4967
|
ccSession = { sessionId: null, createdAt: null, lastActiveAt: null, turnCount: 0 };
|
|
4983
|
-
|
|
4968
|
+
persistCcSession();
|
|
4984
4969
|
} else if (sessionKey) {
|
|
4985
4970
|
docSessions.delete(sessionKey);
|
|
4986
4971
|
schedulePersistDocSessions();
|
|
@@ -6061,36 +6046,20 @@ const server = http.createServer(async (req, res) => {
|
|
|
6061
6046
|
try {
|
|
6062
6047
|
const body = await readBody(req);
|
|
6063
6048
|
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 });
|
|
6049
|
+
let projectPlan = prdStore.readPrd(body.file);
|
|
6050
|
+
if (!projectPlan && prdStore.readPrd(body.file, { archived: true })) {
|
|
6051
|
+
projectPlan = prdStore.restorePrdFromArchive(body.file);
|
|
6052
|
+
prdStore.mutatePrd(body.file, (data) => {
|
|
6053
|
+
data.status = 'approved';
|
|
6054
|
+
delete data.completedAt;
|
|
6055
|
+
delete data.planStale;
|
|
6056
|
+
return data;
|
|
6057
|
+
});
|
|
6058
|
+
projectPlan = prdStore.readPrd(body.file);
|
|
6087
6059
|
}
|
|
6060
|
+
if (!projectPlan) return jsonReply(res, 404, { error: 'PRD not found' });
|
|
6088
6061
|
|
|
6089
6062
|
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
6063
|
const projectTarget = projectPlan?.project
|
|
6095
6064
|
? resolveProjectSourceTarget(projectPlan.project, PROJECTS, { allowCentral: false })
|
|
6096
6065
|
: null;
|
|
@@ -6098,9 +6067,8 @@ const server = http.createServer(async (req, res) => {
|
|
|
6098
6067
|
|
|
6099
6068
|
// Check for existing verify WI — reset to pending if already done (re-verify)
|
|
6100
6069
|
if (project) {
|
|
6101
|
-
const wiPath = shared.projectWorkItemsPath(project);
|
|
6102
6070
|
let existingVerify = null;
|
|
6103
|
-
mutateWorkItems(
|
|
6071
|
+
mutateWorkItems(project, items => {
|
|
6104
6072
|
const v = items.find(w => w.sourcePlan === body.file && w.itemType === WORK_TYPE.VERIFY);
|
|
6105
6073
|
if (v && (v.status === WI_STATUS.DONE || v.status === WI_STATUS.FAILED)) {
|
|
6106
6074
|
// BUG-M10: use shared.reopenWorkItem so re-verify resets mirror
|
|
@@ -6119,17 +6087,16 @@ const server = http.createServer(async (req, res) => {
|
|
|
6119
6087
|
}
|
|
6120
6088
|
|
|
6121
6089
|
// No existing verify — clear completion flag and trigger fresh creation
|
|
6122
|
-
|
|
6090
|
+
prdStore.mutatePrd(body.file, (planData) => {
|
|
6123
6091
|
if (planData?._completionNotified) planData._completionNotified = false;
|
|
6124
6092
|
return planData;
|
|
6125
|
-
}
|
|
6093
|
+
});
|
|
6126
6094
|
|
|
6127
6095
|
const lifecycle = require('./engine/lifecycle');
|
|
6128
6096
|
lifecycle.checkPlanCompletion({ item: { sourcePlan: body.file, id: 'manual' } }, config);
|
|
6129
6097
|
|
|
6130
6098
|
if (project) {
|
|
6131
|
-
const
|
|
6132
|
-
const items = safeJsonArr(wiPath);
|
|
6099
|
+
const items = shared.readWorkItems(project);
|
|
6133
6100
|
const verify = items.find(w => w.sourcePlan === body.file && w.itemType === WORK_TYPE.VERIFY);
|
|
6134
6101
|
if (verify) {
|
|
6135
6102
|
invalidateStatusCache();
|
|
@@ -6147,36 +6114,24 @@ const server = http.createServer(async (req, res) => {
|
|
|
6147
6114
|
const { id, source } = body;
|
|
6148
6115
|
if (!id) return jsonReply(res, 400, { error: 'id required' });
|
|
6149
6116
|
|
|
6150
|
-
//
|
|
6117
|
+
// Resolve the owning SQL scope.
|
|
6151
6118
|
let resolvedTarget = findWorkItemsTargetById(id, source, PROJECTS);
|
|
6152
6119
|
if (resolvedTarget.error) return jsonReply(res, 404, { error: resolvedTarget.error });
|
|
6153
|
-
let
|
|
6120
|
+
let wiScope = resolvedTarget.found ? resolvedTarget.scope : null;
|
|
6154
6121
|
// If no work item found, attempt to re-materialize from PRD item definition
|
|
6155
|
-
if (!
|
|
6122
|
+
if (!wiScope) {
|
|
6156
6123
|
const prdFile = body.prdFile;
|
|
6157
6124
|
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);
|
|
6125
|
+
const plan = prdStore.readPrd(prdFile);
|
|
6171
6126
|
if (!plan?.missing_features) return jsonReply(res, 404, { error: 'PRD file not found or invalid' });
|
|
6172
6127
|
const prdItem = plan.missing_features.find(f => f.id === id);
|
|
6173
6128
|
if (!prdItem) return jsonReply(res, 404, { error: 'PRD item not found in ' + prdFile });
|
|
6174
6129
|
|
|
6175
|
-
// Determine target
|
|
6130
|
+
// Determine target scope (project from PRD item or plan, fallback to central).
|
|
6176
6131
|
const projName = prdItem.project || plan.project || prdFile.replace(/-\d{4}-\d{2}-\d{2}\.json$/, '');
|
|
6177
6132
|
const prdTarget = resolveProjectSourceTarget(projName, PROJECTS, { allowCentral: false });
|
|
6178
6133
|
const proj = prdTarget.project;
|
|
6179
|
-
const
|
|
6134
|
+
const targetScope = proj || 'central';
|
|
6180
6135
|
|
|
6181
6136
|
// Create new work item from PRD item definition (same logic as materializePlansAsWorkItems)
|
|
6182
6137
|
const complexity = prdItem.estimated_complexity || 'medium';
|
|
@@ -6201,7 +6156,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
6201
6156
|
_source: proj?.name || 'central',
|
|
6202
6157
|
_retryCount: 0,
|
|
6203
6158
|
};
|
|
6204
|
-
mutateWorkItems(
|
|
6159
|
+
mutateWorkItems(targetScope, items => { items.push(newItem); });
|
|
6205
6160
|
|
|
6206
6161
|
// Reset PRD item status to pending
|
|
6207
6162
|
try {
|
|
@@ -6210,15 +6165,14 @@ const server = http.createServer(async (req, res) => {
|
|
|
6210
6165
|
} catch (e) { console.error('PRD status sync:', e.message); }
|
|
6211
6166
|
|
|
6212
6167
|
// Clear dispatch history and cooldowns for this item
|
|
6213
|
-
const dispatchPath = path.join(MINIONS_DIR, 'engine', 'dispatch.json');
|
|
6214
6168
|
const dispatchKey = (proj ? `work-${proj.name}-` : 'central-work-') + id;
|
|
6215
6169
|
try {
|
|
6216
|
-
|
|
6170
|
+
dispatchMod.mutateDispatch((dispatch) => {
|
|
6217
6171
|
dispatch.completed = Array.isArray(dispatch.completed) ? dispatch.completed : [];
|
|
6218
6172
|
dispatch.completed = dispatch.completed.filter(d => d.meta?.dispatchKey !== dispatchKey);
|
|
6219
6173
|
dispatch.completed = dispatch.completed.filter(d => !d.meta?.parentKey || d.meta.parentKey !== dispatchKey);
|
|
6220
6174
|
return dispatch;
|
|
6221
|
-
}
|
|
6175
|
+
});
|
|
6222
6176
|
} catch (e) { console.error('dispatch cleanup:', e.message); }
|
|
6223
6177
|
try {
|
|
6224
6178
|
mutateCooldowns(cooldowns => {
|
|
@@ -6232,7 +6186,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
6232
6186
|
|
|
6233
6187
|
let found = false;
|
|
6234
6188
|
let blocked = null;
|
|
6235
|
-
|
|
6189
|
+
mutateWorkItems(wiScope, (items) => {
|
|
6236
6190
|
if (!Array.isArray(items)) items = [];
|
|
6237
6191
|
const item = items.find(i => i.id === id);
|
|
6238
6192
|
if (!item) return items;
|
|
@@ -6279,15 +6233,14 @@ const server = http.createServer(async (req, res) => {
|
|
|
6279
6233
|
if (!found) return jsonReply(res, 404, { error: 'item not found' });
|
|
6280
6234
|
|
|
6281
6235
|
// Clear completed dispatch entries so the engine doesn't dedup this item
|
|
6282
|
-
const dispatchPath = path.join(MINIONS_DIR, 'engine', 'dispatch.json');
|
|
6283
6236
|
const dispatchKey = dispatchPrefixForResolvedSource(resolvedTarget) + id;
|
|
6284
6237
|
try {
|
|
6285
|
-
|
|
6238
|
+
dispatchMod.mutateDispatch((dispatch) => {
|
|
6286
6239
|
dispatch.completed = Array.isArray(dispatch.completed) ? dispatch.completed : [];
|
|
6287
6240
|
dispatch.completed = dispatch.completed.filter(d => d.meta?.dispatchKey !== dispatchKey);
|
|
6288
6241
|
dispatch.completed = dispatch.completed.filter(d => !d.meta?.parentKey || d.meta.parentKey !== dispatchKey);
|
|
6289
6242
|
return dispatch;
|
|
6290
|
-
}
|
|
6243
|
+
});
|
|
6291
6244
|
} catch (e) { console.error('dispatch cleanup:', e.message); }
|
|
6292
6245
|
|
|
6293
6246
|
// Clear cooldown so item isn't blocked by exponential backoff
|
|
@@ -6310,11 +6263,10 @@ const server = http.createServer(async (req, res) => {
|
|
|
6310
6263
|
|
|
6311
6264
|
const target = resolveProjectSourceTarget(source, PROJECTS);
|
|
6312
6265
|
if (target.error) return jsonReply(res, 404, { error: target.error });
|
|
6313
|
-
const wiPath = target.wiPath;
|
|
6314
6266
|
|
|
6315
6267
|
let item = null;
|
|
6316
6268
|
let found = false;
|
|
6317
|
-
|
|
6269
|
+
mutateWorkItems(target.scope, (items) => {
|
|
6318
6270
|
if (!Array.isArray(items)) return items;
|
|
6319
6271
|
const idx = items.findIndex(i => i.id === id);
|
|
6320
6272
|
if (idx === -1) return items;
|
|
@@ -6322,21 +6274,16 @@ const server = http.createServer(async (req, res) => {
|
|
|
6322
6274
|
items.splice(idx, 1);
|
|
6323
6275
|
found = true;
|
|
6324
6276
|
return items;
|
|
6325
|
-
}
|
|
6277
|
+
});
|
|
6326
6278
|
if (!found) return jsonReply(res, 404, { error: 'item not found' });
|
|
6327
6279
|
|
|
6328
6280
|
// 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
|
-
);
|
|
6281
|
+
const dispatchRemoved = cleanDispatchEntries(d => matchesResolvedWorkItemDispatch(d, target, id));
|
|
6333
6282
|
|
|
6334
6283
|
// Clean cooldown entries so item can be re-created immediately
|
|
6335
6284
|
try {
|
|
6336
6285
|
mutateCooldowns(cooldowns => {
|
|
6337
|
-
|
|
6338
|
-
if (key.includes(id)) delete cooldowns[key];
|
|
6339
|
-
}
|
|
6286
|
+
delete cooldowns[dispatchPrefixForResolvedSource(target) + id];
|
|
6340
6287
|
return cooldowns;
|
|
6341
6288
|
});
|
|
6342
6289
|
} catch (e) { console.error('cooldown cleanup:', e.message); }
|
|
@@ -6362,10 +6309,9 @@ const server = http.createServer(async (req, res) => {
|
|
|
6362
6309
|
|
|
6363
6310
|
const target = resolveProjectSourceTarget(source, PROJECTS);
|
|
6364
6311
|
if (target.error) return jsonReply(res, 404, { error: target.error });
|
|
6365
|
-
const wiPath = target.wiPath;
|
|
6366
6312
|
|
|
6367
6313
|
let result = null;
|
|
6368
|
-
|
|
6314
|
+
mutateWorkItems(target.scope, (items) => {
|
|
6369
6315
|
if (!Array.isArray(items)) items = [];
|
|
6370
6316
|
const item = items.find(i => i.id === id);
|
|
6371
6317
|
if (!item) { result = { code: 404, body: { error: 'item not found' } }; return items; }
|
|
@@ -6384,17 +6330,12 @@ const server = http.createServer(async (req, res) => {
|
|
|
6384
6330
|
if (result.code !== 200) return jsonReply(res, result.code, result.body);
|
|
6385
6331
|
|
|
6386
6332
|
// 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
|
-
);
|
|
6333
|
+
const dispatchRemoved = cleanDispatchEntries(d => matchesResolvedWorkItemDispatch(d, target, id));
|
|
6391
6334
|
|
|
6392
6335
|
// Clean cooldown entries
|
|
6393
6336
|
try {
|
|
6394
6337
|
mutateCooldowns(cooldowns => {
|
|
6395
|
-
|
|
6396
|
-
if (key.includes(id)) delete cooldowns[key];
|
|
6397
|
-
}
|
|
6338
|
+
delete cooldowns[dispatchPrefixForResolvedSource(target) + id];
|
|
6398
6339
|
return cooldowns;
|
|
6399
6340
|
});
|
|
6400
6341
|
} catch (e) { console.error('cooldown cleanup on cancel:', e.message); }
|
|
@@ -6412,37 +6353,13 @@ const server = http.createServer(async (req, res) => {
|
|
|
6412
6353
|
|
|
6413
6354
|
const target = resolveProjectSourceTarget(source, PROJECTS);
|
|
6414
6355
|
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
|
-
});
|
|
6356
|
+
const archivedItem = require('./engine/work-items-store').archiveWorkItem(target.scope, id);
|
|
6426
6357
|
if (!archivedItem) return jsonReply(res, 404, { error: 'item not found' });
|
|
6427
6358
|
|
|
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
6359
|
// Clean dispatch entries for archived item
|
|
6437
|
-
|
|
6438
|
-
cleanDispatchEntries(d =>
|
|
6439
|
-
d.meta?.dispatchKey === sourcePrefix + id ||
|
|
6440
|
-
d.meta?.item?.id === id
|
|
6441
|
-
);
|
|
6360
|
+
cleanDispatchEntries(d => matchesResolvedWorkItemDispatch(d, target, id));
|
|
6442
6361
|
|
|
6443
|
-
//
|
|
6444
|
-
// is part of /api/status fast-state. Match every other mutating handler
|
|
6445
|
-
// and invalidate so the dashboard reflects the archive immediately.
|
|
6362
|
+
// Archiving removes the item from the active status snapshot.
|
|
6446
6363
|
invalidateStatusCache();
|
|
6447
6364
|
return jsonReply(res, 200, { ok: true, id });
|
|
6448
6365
|
} catch (e) { return jsonReply(res, 400, { error: e.message }); }
|
|
@@ -6453,7 +6370,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
6453
6370
|
// collectArchivedWorkItems uses safeJsonArr (typed default + logged parse
|
|
6454
6371
|
// failure), so a corrupt archive file is surfaced via console.error and
|
|
6455
6372
|
// contributes zero items instead of taking down the whole listing.
|
|
6456
|
-
return jsonReply(res, 200, collectArchivedWorkItems(
|
|
6373
|
+
return jsonReply(res, 200, collectArchivedWorkItems(PROJECTS));
|
|
6457
6374
|
} catch (e) { console.error('Archive fetch error:', e.message); return jsonReply(res, e.statusCode || 500, { error: e.message }); }
|
|
6458
6375
|
}
|
|
6459
6376
|
|
|
@@ -6474,10 +6391,15 @@ const server = http.createServer(async (req, res) => {
|
|
|
6474
6391
|
id === 'cancel' || id === 'reopen' || id === 'feedback') {
|
|
6475
6392
|
return jsonReply(res, 404, { error: 'work item not found' });
|
|
6476
6393
|
}
|
|
6477
|
-
|
|
6478
|
-
|
|
6479
|
-
|
|
6480
|
-
|
|
6394
|
+
const source = (new URL(req.url, 'http://localhost').searchParams.get('source') || '').trim();
|
|
6395
|
+
const matches = getWorkItems().filter(w => w && w.id === id && (!source || w._source === source));
|
|
6396
|
+
if (!source && matches.length > 1) {
|
|
6397
|
+
return jsonReply(res, 409, {
|
|
6398
|
+
error: 'work item id is ambiguous; provide source',
|
|
6399
|
+
sources: matches.map(w => w._source),
|
|
6400
|
+
});
|
|
6401
|
+
}
|
|
6402
|
+
const found = matches[0];
|
|
6481
6403
|
if (!found) return jsonReply(res, 404, { error: 'work item not found' });
|
|
6482
6404
|
return jsonReply(res, 200, { item: found });
|
|
6483
6405
|
} catch (e) { return jsonReply(res, 500, { error: e.message }); }
|
|
@@ -6526,10 +6448,9 @@ const server = http.createServer(async (req, res) => {
|
|
|
6526
6448
|
|
|
6527
6449
|
const target = resolveProjectSourceTarget(project, PROJECTS);
|
|
6528
6450
|
if (target.error) return jsonReply(res, 404, { error: target.error });
|
|
6529
|
-
const wiPath = target.wiPath;
|
|
6530
6451
|
|
|
6531
6452
|
let result = null;
|
|
6532
|
-
|
|
6453
|
+
mutateWorkItems(target.scope, (items) => {
|
|
6533
6454
|
if (!Array.isArray(items)) items = [];
|
|
6534
6455
|
const item = items.find(i => i.id === id);
|
|
6535
6456
|
if (!item) { result = { code: 404, body: { error: 'item not found' } }; return items; }
|
|
@@ -6548,13 +6469,12 @@ const server = http.createServer(async (req, res) => {
|
|
|
6548
6469
|
// Clear dispatch history and cooldowns outside lock
|
|
6549
6470
|
const dispatchKey = dispatchPrefixForResolvedSource(target) + id;
|
|
6550
6471
|
try {
|
|
6551
|
-
|
|
6552
|
-
mutateJsonFileLocked(dispatchPath, (dispatch) => {
|
|
6472
|
+
dispatchMod.mutateDispatch((dispatch) => {
|
|
6553
6473
|
dispatch.completed = Array.isArray(dispatch.completed) ? dispatch.completed : [];
|
|
6554
6474
|
dispatch.completed = dispatch.completed.filter(d => d.meta?.dispatchKey !== dispatchKey);
|
|
6555
6475
|
dispatch.completed = dispatch.completed.filter(d => !d.meta?.parentKey || d.meta.parentKey !== dispatchKey);
|
|
6556
6476
|
return dispatch;
|
|
6557
|
-
}
|
|
6477
|
+
});
|
|
6558
6478
|
} catch (e) { console.error('dispatch cleanup on reopen:', e.message); }
|
|
6559
6479
|
try {
|
|
6560
6480
|
mutateCooldowns(cooldowns => {
|
|
@@ -6624,7 +6544,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
6624
6544
|
}
|
|
6625
6545
|
const target = resolveWorkItemsCreateTarget(body.project);
|
|
6626
6546
|
if (target.error) return jsonReply(res, 400, { error: target.error });
|
|
6627
|
-
const
|
|
6547
|
+
const wiScope = target.scope;
|
|
6628
6548
|
const targetProject = target.project;
|
|
6629
6549
|
const id = 'W-' + shared.uid();
|
|
6630
6550
|
const item = {
|
|
@@ -6701,7 +6621,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
6701
6621
|
if (originAgent) item._originAgent = originAgent;
|
|
6702
6622
|
if (originWi) item._originWi = originWi;
|
|
6703
6623
|
await copyWorkItemPrFields(item, body, null, { project: targetProject });
|
|
6704
|
-
// W-mq5wfh1v000e0da9 — Auto-enroll the PR into
|
|
6624
|
+
// W-mq5wfh1v000e0da9 — Auto-enroll the PR into tracked state when
|
|
6705
6625
|
// this is a `type: fix/review/test` WI carrying a structured PR pointer and
|
|
6706
6626
|
// the PR isn't tracked yet. Without this, the engine's `pr_not_found` gate
|
|
6707
6627
|
// skips the WI forever (engine.js dispatch loop). Idempotent — safe to
|
|
@@ -6732,7 +6652,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
6732
6652
|
// statuses) and takes precedence when matched.
|
|
6733
6653
|
if (validatedFollowup) {
|
|
6734
6654
|
let followupConflict = null;
|
|
6735
|
-
mutateWorkItems(
|
|
6655
|
+
mutateWorkItems(wiScope, items => {
|
|
6736
6656
|
followupConflict = findExistingFollowupForComment(items, validatedFollowup.parent_comment_id);
|
|
6737
6657
|
if (followupConflict) return items;
|
|
6738
6658
|
items.push(item);
|
|
@@ -6751,7 +6671,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
6751
6671
|
invalidateStatusCache();
|
|
6752
6672
|
return jsonReply(res, 200, { ok: true, id });
|
|
6753
6673
|
}
|
|
6754
|
-
const createResult = createWorkItemWithDedup(
|
|
6674
|
+
const createResult = createWorkItemWithDedup(wiScope, item, { project: targetProject });
|
|
6755
6675
|
if (!createResult.created) {
|
|
6756
6676
|
const duplicateId = createResult.duplicateOf || createResult.item?.id;
|
|
6757
6677
|
// W-mr466ocx000l1191 — record the CC turn creation on the dedup path
|
|
@@ -6803,10 +6723,9 @@ const server = http.createServer(async (req, res) => {
|
|
|
6803
6723
|
|
|
6804
6724
|
const target = resolveProjectSourceTarget(source, PROJECTS);
|
|
6805
6725
|
if (target.error) return jsonReply(res, 404, { error: target.error });
|
|
6806
|
-
const
|
|
6726
|
+
const wiScope = target.scope;
|
|
6807
6727
|
|
|
6808
|
-
// W-mrazefzz000358e3 — body.project reassigns
|
|
6809
|
-
// work-items.json (or central) file owns this item. Resolve + validate
|
|
6728
|
+
// W-mrazefzz000358e3 — body.project reassigns the owning SQL scope.
|
|
6810
6729
|
// it up front (mirrors resolveWorkItemsCreateTarget's create-path
|
|
6811
6730
|
// contract) so an unknown project name 400s cleanly instead of the
|
|
6812
6731
|
// historical silent no-op. `projectMove` stays null when body.project
|
|
@@ -6820,19 +6739,14 @@ const server = http.createServer(async (req, res) => {
|
|
|
6820
6739
|
knownProjects: PROJECTS.map(p => p.name),
|
|
6821
6740
|
});
|
|
6822
6741
|
}
|
|
6823
|
-
projectMove = {
|
|
6742
|
+
projectMove = { scope: projTarget.scope, projectName: projTarget.project ? projTarget.project.name : null };
|
|
6824
6743
|
}
|
|
6825
6744
|
|
|
6826
6745
|
let result = null;
|
|
6827
6746
|
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
6747
|
let movedItem = null;
|
|
6834
6748
|
let needsMove = false;
|
|
6835
|
-
|
|
6749
|
+
mutateWorkItems(wiScope, (items) => {
|
|
6836
6750
|
if (!Array.isArray(items)) items = [];
|
|
6837
6751
|
const idx = items.findIndex(i => i.id === id);
|
|
6838
6752
|
if (idx === -1) { result = { code: 404, body: { error: 'item not found' } }; return items; }
|
|
@@ -6869,23 +6783,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
6869
6783
|
}
|
|
6870
6784
|
item.updatedAt = new Date().toISOString();
|
|
6871
6785
|
|
|
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)) {
|
|
6786
|
+
if (projectMove && projectMove.scope !== wiScope) {
|
|
6889
6787
|
needsMove = true;
|
|
6890
6788
|
movedItem = { ...item };
|
|
6891
6789
|
if (projectMove.projectName) movedItem.project = projectMove.projectName;
|
|
@@ -6899,79 +6797,14 @@ const server = http.createServer(async (req, res) => {
|
|
|
6899
6797
|
|
|
6900
6798
|
if (needsMove && result.code === 200) {
|
|
6901
6799
|
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
|
-
});
|
|
6800
|
+
const moved = require('./engine/work-items-store')
|
|
6801
|
+
.moveWorkItem(wiScope, projectMove.scope, id, movedItem);
|
|
6802
|
+
if (!moved) {
|
|
6803
|
+
return jsonReply(res, 404, { error: 'item disappeared before project move' });
|
|
6971
6804
|
}
|
|
6972
|
-
|
|
6805
|
+
} catch (moveErr) {
|
|
6973
6806
|
return jsonReply(res, 500, {
|
|
6974
|
-
error: `project move failed
|
|
6807
|
+
error: `project move failed; item remains in its original scope: ${moveErr.message}`,
|
|
6975
6808
|
});
|
|
6976
6809
|
}
|
|
6977
6810
|
}
|
|
@@ -7015,8 +6848,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
7015
6848
|
const planWorkItem = buildPlanWorkItem(body);
|
|
7016
6849
|
if (planWorkItem.error) return jsonReply(res, 400, { error: planWorkItem.error });
|
|
7017
6850
|
// 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); });
|
|
6851
|
+
mutateWorkItems('central', items => { items.push(planWorkItem.item); });
|
|
7020
6852
|
recordCcTurnIfPresent(req, {
|
|
7021
6853
|
kind: 'plan', id: planWorkItem.id, title: planWorkItem.item?.title || body.title.trim(), project: planWorkItem.item?.project || null,
|
|
7022
6854
|
});
|
|
@@ -7032,8 +6864,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
7032
6864
|
if (manualPrd.error) return jsonReply(res, 400, { error: manualPrd.error });
|
|
7033
6865
|
|
|
7034
6866
|
const planFile = 'manual-' + shared.uid() + '.json';
|
|
7035
|
-
|
|
7036
|
-
safeWrite(manualPrdPath, manualPrd.plan);
|
|
6867
|
+
prdStore.writePrd(planFile, manualPrd.plan);
|
|
7037
6868
|
invalidatePlansCache();
|
|
7038
6869
|
return jsonReply(res, 200, { ok: true, id: manualPrd.id, file: planFile });
|
|
7039
6870
|
} catch (e) { return jsonReply(res, 400, { error: e.message }); }
|
|
@@ -7043,19 +6874,14 @@ const server = http.createServer(async (req, res) => {
|
|
|
7043
6874
|
try {
|
|
7044
6875
|
const body = await readBody(req);
|
|
7045
6876
|
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
6877
|
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);
|
|
6878
|
+
const preCheck = prdStore.readPrd(body.source);
|
|
6879
|
+
if (!preCheck) return jsonReply(res, 404, { error: 'plan not found' });
|
|
7053
6880
|
const preItem = (preCheck.missing_features || []).find(f => f.id === body.itemId);
|
|
7054
6881
|
if (!preItem) return jsonReply(res, 404, { error: 'item not found in plan' });
|
|
7055
6882
|
|
|
7056
|
-
// Atomically read-modify-write under file lock
|
|
7057
6883
|
let item;
|
|
7058
|
-
|
|
6884
|
+
prdStore.mutatePrd(body.source, (plan) => {
|
|
7059
6885
|
const target = (plan.missing_features || []).find(f => f.id === body.itemId);
|
|
7060
6886
|
if (!target) return plan; // TOCTOU: item deleted between pre-check and lock acquisition
|
|
7061
6887
|
if (body.name !== undefined) target.name = body.name;
|
|
@@ -7072,13 +6898,10 @@ const server = http.createServer(async (req, res) => {
|
|
|
7072
6898
|
|
|
7073
6899
|
// Feature 3: Sync edits to materialized work item if still pending
|
|
7074
6900
|
let workItemSynced = false;
|
|
7075
|
-
const
|
|
7076
|
-
for (const
|
|
7077
|
-
wiSyncPaths.push(shared.projectWorkItemsPath(proj));
|
|
7078
|
-
}
|
|
7079
|
-
for (const wiPath of wiSyncPaths) {
|
|
6901
|
+
const workItemScopes = ['central', ...PROJECTS];
|
|
6902
|
+
for (const scope of workItemScopes) {
|
|
7080
6903
|
try {
|
|
7081
|
-
mutateWorkItems(
|
|
6904
|
+
mutateWorkItems(scope, items => {
|
|
7082
6905
|
const wi = items.find(w => w.sourcePlan === body.source && w.id === body.itemId);
|
|
7083
6906
|
if (wi && wi.status === WI_STATUS.PENDING) {
|
|
7084
6907
|
if (body.name !== undefined) wi.title = 'Implement: ' + body.name;
|
|
@@ -7103,10 +6926,9 @@ const server = http.createServer(async (req, res) => {
|
|
|
7103
6926
|
const body = await readBody(req);
|
|
7104
6927
|
if (!body.source || !body.itemId) return jsonReply(res, 400, { error: 'source and itemId required' });
|
|
7105
6928
|
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' });
|
|
6929
|
+
if (!prdStore.readPrd(body.source)) return jsonReply(res, 404, { error: 'plan not found' });
|
|
7108
6930
|
let removed = false;
|
|
7109
|
-
|
|
6931
|
+
prdStore.mutatePrd(body.source, (plan) => {
|
|
7110
6932
|
if (!plan || Array.isArray(plan) || typeof plan !== 'object') plan = { missing_features: [] };
|
|
7111
6933
|
const features = Array.isArray(plan.missing_features) ? plan.missing_features : [];
|
|
7112
6934
|
const idx = features.findIndex(f => f.id === body.itemId);
|
|
@@ -7120,13 +6942,10 @@ const server = http.createServer(async (req, res) => {
|
|
|
7120
6942
|
|
|
7121
6943
|
// Also remove any materialized work item for this plan item
|
|
7122
6944
|
let cancelled = false;
|
|
7123
|
-
const
|
|
7124
|
-
for (const
|
|
7125
|
-
allWiPaths.push(shared.projectWorkItemsPath(proj));
|
|
7126
|
-
}
|
|
7127
|
-
for (const wiPath of allWiPaths) {
|
|
6945
|
+
const workItemScopes = ['central', ...PROJECTS];
|
|
6946
|
+
for (const scope of workItemScopes) {
|
|
7128
6947
|
try {
|
|
7129
|
-
mutateWorkItems(
|
|
6948
|
+
mutateWorkItems(scope, items => {
|
|
7130
6949
|
const filtered = items.filter(w => !(w.sourcePlan === body.source && w.id === body.itemId));
|
|
7131
6950
|
if (filtered.length < items.length) {
|
|
7132
6951
|
cancelled = true;
|
|
@@ -7170,22 +6989,20 @@ const server = http.createServer(async (req, res) => {
|
|
|
7170
6989
|
try { fs.unlinkSync(path.join(agentDir, 'steer.md')); } catch { /* optional */ }
|
|
7171
6990
|
|
|
7172
6991
|
// 3. Remove all active dispatch entries for this agent
|
|
7173
|
-
const dispatchPath = path.join(MINIONS_DIR, 'engine', 'dispatch.json');
|
|
7174
6992
|
const removedIds = [];
|
|
7175
|
-
|
|
6993
|
+
dispatchMod.mutateDispatch((dp) => {
|
|
7176
6994
|
const removed = (dp.active || []).filter(d => d.agent === agentId);
|
|
7177
6995
|
removed.forEach(d => removedIds.push(d.id));
|
|
7178
6996
|
dp.active = (dp.active || []).filter(d => d.agent !== agentId);
|
|
7179
6997
|
return dp;
|
|
7180
|
-
}
|
|
6998
|
+
});
|
|
7181
6999
|
|
|
7182
7000
|
// 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));
|
|
7001
|
+
const workItemScopes = ['central', ...PROJECTS];
|
|
7185
7002
|
let resetCount = 0;
|
|
7186
|
-
for (const
|
|
7003
|
+
for (const scope of workItemScopes) {
|
|
7187
7004
|
try {
|
|
7188
|
-
mutateWorkItems(
|
|
7005
|
+
mutateWorkItems(scope, items => {
|
|
7189
7006
|
for (const item of items) {
|
|
7190
7007
|
if (item.dispatched_to === agentId && item.status === WI_STATUS.DISPATCHED) {
|
|
7191
7008
|
item.status = WI_STATUS.PENDING;
|
|
@@ -7214,8 +7031,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
7214
7031
|
if (!requestedAgent && !requestedTask) return jsonReply(res, 400, { error: 'agent or task required' });
|
|
7215
7032
|
|
|
7216
7033
|
// Pre-read for response snapshot. Lockless — cleanDispatchEntries owns the actual mutation.
|
|
7217
|
-
const
|
|
7218
|
-
const dispatch = safeJsonObj(dispatchPath);
|
|
7034
|
+
const dispatch = queries.getDispatch();
|
|
7219
7035
|
const active = Array.isArray(dispatch.active) ? dispatch.active : [];
|
|
7220
7036
|
const matchFn = (d) => {
|
|
7221
7037
|
const matchAgent = requestedAgent && d.agent === requestedAgent;
|
|
@@ -7227,7 +7043,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
7227
7043
|
// Route PID resolution + kill + dispatch removal through the canonical primitive.
|
|
7228
7044
|
// cleanDispatchEntries resolves the PID from engine/tmp/dispatch-<id>-*/pid-<id>.pid
|
|
7229
7045
|
// (see shared.findDispatchPidFile), kills outside the dispatch lock, and removes
|
|
7230
|
-
// the entry
|
|
7046
|
+
// the entry through the transactional dispatch store.
|
|
7231
7047
|
// The defunct per-agent status sidecar reads/writes that used to live here are gone
|
|
7232
7048
|
// (engine/queries.js documents that file no longer exists).
|
|
7233
7049
|
if (cancelled.length > 0) {
|
|
@@ -7751,7 +7567,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
7751
7567
|
// path.join. Without this, cat='..' collapses kbCatDir to MINIONS_DIR
|
|
7752
7568
|
// (path.join normalizes '..') and the next sanitizePath('config.json',
|
|
7753
7569
|
// MINIONS_DIR) check still passes, allowing safeRead to disclose
|
|
7754
|
-
// config.json /
|
|
7570
|
+
// config.json / runtime state to any browser the
|
|
7755
7571
|
// operator has open. The whitelist also rejects encoded variants
|
|
7756
7572
|
// (`%2e%2e`, `..%2f`, etc.) since none of those are valid category
|
|
7757
7573
|
// names. Single source of truth lives in shared.KB_READABLE_CATEGORIES.
|
|
@@ -7991,12 +7807,17 @@ const server = http.createServer(async (req, res) => {
|
|
|
7991
7807
|
|
|
7992
7808
|
async function handlePlansArchiveRead(req, res, match) {
|
|
7993
7809
|
const file = decodeURIComponent(match[1]);
|
|
7994
|
-
if (file.includes('..') || file.includes('\0')
|
|
7995
|
-
|
|
7996
|
-
|
|
7997
|
-
let content =
|
|
7998
|
-
|
|
7999
|
-
|
|
7810
|
+
if (file.includes('..') || file.includes('\0') || file.includes('/') || file.includes('\\')) {
|
|
7811
|
+
return jsonReply(res, 400, { error: 'invalid' });
|
|
7812
|
+
}
|
|
7813
|
+
let content = '';
|
|
7814
|
+
if (file.endsWith('.json')) {
|
|
7815
|
+
const row = prdStore.listPrdRows()
|
|
7816
|
+
.find(candidate => candidate.filename === file && (candidate.archived || shared.isPrdArchived(candidate.plan)));
|
|
7817
|
+
if (row) content = JSON.stringify(row.plan, null, 2);
|
|
7818
|
+
} else {
|
|
7819
|
+
content = safeRead(path.join(PLANS_DIR, 'archive', file));
|
|
7820
|
+
}
|
|
8000
7821
|
if (!content) return jsonReply(res, 404, { error: 'not found' });
|
|
8001
7822
|
const contentType = file.endsWith('.json') ? 'application/json' : 'text/plain';
|
|
8002
7823
|
res.setHeader('Content-Type', contentType + '; charset=utf-8');
|
|
@@ -8007,17 +7828,30 @@ const server = http.createServer(async (req, res) => {
|
|
|
8007
7828
|
async function handlePlansRead(req, res, match) {
|
|
8008
7829
|
const file = decodeURIComponent(match[1]);
|
|
8009
7830
|
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
|
-
|
|
7831
|
+
let content = '';
|
|
7832
|
+
let resolvedPath = null;
|
|
7833
|
+
if (file.endsWith('.json')) {
|
|
7834
|
+
const row = prdStore.listPrdRows().find(candidate => candidate.filename === file);
|
|
7835
|
+
if (row) {
|
|
7836
|
+
content = JSON.stringify(row.plan, null, 2);
|
|
7837
|
+
res.setHeader('Last-Modified', new Date(row.updatedAt).toISOString());
|
|
7838
|
+
res.setHeader('X-Resolved-Path', `sql:prd/${row.archived ? 'archive/' : ''}${file}`);
|
|
7839
|
+
}
|
|
7840
|
+
} else {
|
|
7841
|
+
const candidates = [
|
|
7842
|
+
resolvePlanPath(file),
|
|
7843
|
+
path.join(PRD_DIR, 'guides', file),
|
|
7844
|
+
path.join(PLANS_DIR, 'archive', file),
|
|
7845
|
+
];
|
|
7846
|
+
resolvedPath = candidates.find(candidate => fs.existsSync(candidate)) || null;
|
|
7847
|
+
if (resolvedPath) content = safeRead(resolvedPath);
|
|
7848
|
+
}
|
|
8017
7849
|
if (!content) return jsonReply(res, 404, { error: 'not found' });
|
|
8018
|
-
|
|
8019
|
-
|
|
8020
|
-
|
|
7850
|
+
if (resolvedPath) {
|
|
7851
|
+
const stat = fs.statSync(resolvedPath);
|
|
7852
|
+
res.setHeader('Last-Modified', stat.mtime.toISOString());
|
|
7853
|
+
res.setHeader('X-Resolved-Path', path.relative(MINIONS_DIR, resolvedPath).replace(/\\/g, '/'));
|
|
7854
|
+
}
|
|
8021
7855
|
const contentType = file.endsWith('.json') ? 'application/json' : 'text/plain';
|
|
8022
7856
|
res.setHeader('Content-Type', contentType + '; charset=utf-8');
|
|
8023
7857
|
res.setHeader('Cache-Control', 'no-cache');
|
|
@@ -8030,9 +7864,8 @@ const server = http.createServer(async (req, res) => {
|
|
|
8030
7864
|
const body = await readBody(req);
|
|
8031
7865
|
if (!body.file) return jsonReply(res, 400, { error: 'file required' });
|
|
8032
7866
|
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
7867
|
let wasStale = false;
|
|
8035
|
-
const plan =
|
|
7868
|
+
const plan = prdStore.mutatePrd(body.file, (data) => {
|
|
8036
7869
|
if (!data || Array.isArray(data) || typeof data !== 'object') data = {};
|
|
8037
7870
|
wasStale = !!data.planStale;
|
|
8038
7871
|
data.status = 'approved';
|
|
@@ -8042,7 +7875,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
8042
7875
|
delete data.planStale;
|
|
8043
7876
|
delete data._completionNotified;
|
|
8044
7877
|
return data;
|
|
8045
|
-
}
|
|
7878
|
+
});
|
|
8046
7879
|
|
|
8047
7880
|
// W-mqacrzis0003df4a + W-mqfevwr60018bd09 — Fresh source-plan
|
|
8048
7881
|
// staleness check (content-hash gated). Approve is the last gate
|
|
@@ -8064,13 +7897,10 @@ const server = http.createServer(async (req, res) => {
|
|
|
8064
7897
|
// Resume paused work items across all projects
|
|
8065
7898
|
let resumed = 0;
|
|
8066
7899
|
const resumedItemIds = [];
|
|
8067
|
-
const
|
|
8068
|
-
for (const
|
|
8069
|
-
wiPaths.push(shared.projectWorkItemsPath(proj));
|
|
8070
|
-
}
|
|
8071
|
-
for (const wiPath of wiPaths) {
|
|
7900
|
+
const workItemScopes = ['central', ...PROJECTS];
|
|
7901
|
+
for (const scope of workItemScopes) {
|
|
8072
7902
|
try {
|
|
8073
|
-
|
|
7903
|
+
mutateWorkItems(scope, (items) => {
|
|
8074
7904
|
if (!Array.isArray(items)) return items;
|
|
8075
7905
|
for (const w of items) {
|
|
8076
7906
|
if (w.sourcePlan === body.file && w.status === WI_STATUS.PAUSED && w._pausedBy === 'prd-pause') {
|
|
@@ -8082,19 +7912,18 @@ const server = http.createServer(async (req, res) => {
|
|
|
8082
7912
|
}
|
|
8083
7913
|
}
|
|
8084
7914
|
return items;
|
|
8085
|
-
}
|
|
7915
|
+
});
|
|
8086
7916
|
} catch (e) { console.error('resume work items:', e.message); }
|
|
8087
7917
|
}
|
|
8088
7918
|
|
|
8089
7919
|
// Clear dispatch completed entries for resumed items so they aren't dedup-blocked
|
|
8090
7920
|
if (resumedItemIds.length > 0) {
|
|
8091
|
-
const dispatchPath = path.join(MINIONS_DIR, 'engine', 'dispatch.json');
|
|
8092
7921
|
const resumedSet = new Set(resumedItemIds);
|
|
8093
|
-
|
|
7922
|
+
dispatchMod.mutateDispatch((dispatch) => {
|
|
8094
7923
|
dispatch.completed = Array.isArray(dispatch.completed) ? dispatch.completed : [];
|
|
8095
7924
|
dispatch.completed = dispatch.completed.filter(d => !resumedSet.has(d.meta?.item?.id));
|
|
8096
7925
|
return dispatch;
|
|
8097
|
-
}
|
|
7926
|
+
});
|
|
8098
7927
|
}
|
|
8099
7928
|
|
|
8100
7929
|
// Diff-aware PRD update: if plan was stale (source .md revised), dispatch plan-to-prd
|
|
@@ -8104,7 +7933,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
8104
7933
|
const config = queries.getConfig();
|
|
8105
7934
|
const allWorkItems = queries.getWorkItems(config);
|
|
8106
7935
|
const planWis = allWorkItems.filter(w => w.sourcePlan === body.file && w.itemType !== 'pr' && w.itemType !== 'verify');
|
|
8107
|
-
const allPrs = PROJECTS.flatMap(p => shared.
|
|
7936
|
+
const allPrs = PROJECTS.flatMap(p => shared.readPullRequests(p));
|
|
8108
7937
|
const prLinks = shared.getPrLinks();
|
|
8109
7938
|
const implContext = (plan.missing_features || []).map(f => {
|
|
8110
7939
|
const wi = planWis.find(w => w.id === f.id);
|
|
@@ -8146,19 +7975,18 @@ const server = http.createServer(async (req, res) => {
|
|
|
8146
7975
|
// cleanup; pause never stopped regeneration.)
|
|
8147
7976
|
function stopPlanMaterializedWork(prdFile, prdSourcePlan, opts) {
|
|
8148
7977
|
const targetStatus = opts.targetStatus;
|
|
8149
|
-
const
|
|
8150
|
-
for (const proj of PROJECTS) wiPaths.push(shared.projectWorkItemsPath(proj));
|
|
7978
|
+
const workItemScopes = ['central', ...PROJECTS];
|
|
8151
7979
|
|
|
8152
7980
|
// Step 1: find dispatched item ids (read-only, no lock).
|
|
8153
7981
|
const dispatchedItemIds = new Set();
|
|
8154
|
-
for (const
|
|
7982
|
+
for (const scope of workItemScopes) {
|
|
8155
7983
|
try {
|
|
8156
|
-
for (const w of
|
|
7984
|
+
for (const w of shared.readWorkItems(scope)) {
|
|
8157
7985
|
if (w.sourcePlan !== prdFile) continue;
|
|
8158
7986
|
if (w.completedAt || DONE_STATUSES.has(w.status)) continue;
|
|
8159
7987
|
if (w.status === WI_STATUS.DISPATCHED && w.id) dispatchedItemIds.add(w.id);
|
|
8160
7988
|
}
|
|
8161
|
-
} catch { /*
|
|
7989
|
+
} catch { /* scope may be unavailable */ }
|
|
8162
7990
|
}
|
|
8163
7991
|
|
|
8164
7992
|
// Step 2: kill active dispatches via the canonical primitive (resolves PIDs from
|
|
@@ -8172,11 +8000,11 @@ const server = http.createServer(async (req, res) => {
|
|
|
8172
8000
|
});
|
|
8173
8001
|
}
|
|
8174
8002
|
|
|
8175
|
-
// Step 3: transition WIs per
|
|
8003
|
+
// Step 3: transition WIs per scope.
|
|
8176
8004
|
let affected = 0;
|
|
8177
|
-
for (const
|
|
8005
|
+
for (const scope of workItemScopes) {
|
|
8178
8006
|
try {
|
|
8179
|
-
mutateWorkItems(
|
|
8007
|
+
mutateWorkItems(scope, items => {
|
|
8180
8008
|
for (const w of items) {
|
|
8181
8009
|
if (w.sourcePlan !== prdFile) continue;
|
|
8182
8010
|
if (w.completedAt || DONE_STATUSES.has(w.status)) continue;
|
|
@@ -8198,8 +8026,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
8198
8026
|
// stopped. (delete handles the DONE plan-to-prd WI separately to revert to draft.)
|
|
8199
8027
|
if (prdSourcePlan) {
|
|
8200
8028
|
try {
|
|
8201
|
-
|
|
8202
|
-
mutateWorkItems(centralPath, items => {
|
|
8029
|
+
mutateWorkItems('central', items => {
|
|
8203
8030
|
for (const w of items) {
|
|
8204
8031
|
if (w.type === WORK_TYPE.PLAN_TO_PRD && w.planFile === prdSourcePlan &&
|
|
8205
8032
|
!DONE_STATUSES.has(w.status) && w.status !== WI_STATUS.CANCELLED) {
|
|
@@ -8218,14 +8045,13 @@ const server = http.createServer(async (req, res) => {
|
|
|
8218
8045
|
const body = await readBody(req);
|
|
8219
8046
|
if (!body.file) return jsonReply(res, 400, { error: 'file required' });
|
|
8220
8047
|
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
8048
|
let prdSourcePlan = null;
|
|
8223
|
-
const updated =
|
|
8049
|
+
const updated = prdStore.mutatePrd(body.file, (plan) => {
|
|
8224
8050
|
if (!plan || Array.isArray(plan) || typeof plan !== 'object') plan = {};
|
|
8225
8051
|
plan.status = 'paused';
|
|
8226
8052
|
plan.pausedAt = new Date().toISOString();
|
|
8227
8053
|
return plan;
|
|
8228
|
-
}
|
|
8054
|
+
});
|
|
8229
8055
|
prdSourcePlan = updated?.source_plan || null;
|
|
8230
8056
|
|
|
8231
8057
|
// Propagate pause to materialized work items across all projects + cancel the
|
|
@@ -8279,15 +8105,14 @@ const server = http.createServer(async (req, res) => {
|
|
|
8279
8105
|
const body = await readBody(req);
|
|
8280
8106
|
if (!body.file) return jsonReply(res, 400, { error: 'file required' });
|
|
8281
8107
|
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) => {
|
|
8108
|
+
const plan = prdStore.mutatePrd(body.file, (data) => {
|
|
8284
8109
|
if (!data || Array.isArray(data) || typeof data !== 'object') data = {};
|
|
8285
8110
|
data.status = 'rejected';
|
|
8286
8111
|
data.rejectedAt = new Date().toISOString();
|
|
8287
8112
|
data.rejectedBy = body.rejectedBy || os.userInfo().username;
|
|
8288
8113
|
if (body.reason) data.rejectionReason = body.reason;
|
|
8289
8114
|
return data;
|
|
8290
|
-
}
|
|
8115
|
+
});
|
|
8291
8116
|
|
|
8292
8117
|
// RC3: reject used to flip only the PRD status — its materialized work items
|
|
8293
8118
|
// kept dispatching and any active agent kept running, and a pending plan-to-prd
|
|
@@ -8307,26 +8132,26 @@ const server = http.createServer(async (req, res) => {
|
|
|
8307
8132
|
try {
|
|
8308
8133
|
const body = await readBody(req);
|
|
8309
8134
|
if (!body.source) return jsonReply(res, 400, { error: 'source required' });
|
|
8310
|
-
|
|
8311
|
-
|
|
8312
|
-
|
|
8135
|
+
if (!body.source.endsWith('.json')) return jsonReply(res, 400, { error: 'expected a PRD JSON filename' });
|
|
8136
|
+
const plan = prdStore.readPrd(body.source);
|
|
8137
|
+
if (!plan) return jsonReply(res, 404, { error: 'plan not found' });
|
|
8313
8138
|
const planItems = plan.missing_features || [];
|
|
8314
8139
|
|
|
8315
8140
|
let reset = 0, kept = 0, newCount = 0;
|
|
8316
8141
|
const deletedItemIds = [];
|
|
8317
8142
|
|
|
8318
8143
|
// Scan all work item sources for materialized items from this plan
|
|
8319
|
-
const
|
|
8144
|
+
const workItemScopes = [{ scope: 'central', label: 'central' }];
|
|
8320
8145
|
for (const proj of PROJECTS) {
|
|
8321
|
-
|
|
8146
|
+
workItemScopes.push({ scope: proj, label: proj.name });
|
|
8322
8147
|
}
|
|
8323
8148
|
|
|
8324
8149
|
// Track which plan items have materialized work items
|
|
8325
8150
|
const materializedPlanItemIds = new Set();
|
|
8326
8151
|
|
|
8327
|
-
for (const wiInfo of
|
|
8152
|
+
for (const wiInfo of workItemScopes) {
|
|
8328
8153
|
try {
|
|
8329
|
-
mutateWorkItems(wiInfo.
|
|
8154
|
+
mutateWorkItems(wiInfo.scope, items => {
|
|
8330
8155
|
const filtered = [];
|
|
8331
8156
|
for (const w of items) {
|
|
8332
8157
|
if (w.sourcePlan === body.source) {
|
|
@@ -8372,7 +8197,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
8372
8197
|
}
|
|
8373
8198
|
if (orphanPendingIds.size > 0) {
|
|
8374
8199
|
try {
|
|
8375
|
-
|
|
8200
|
+
prdStore.mutatePrd(body.source, (current) => {
|
|
8376
8201
|
if (!current || !Array.isArray(current.missing_features)) return current;
|
|
8377
8202
|
const stamp = new Date().toISOString();
|
|
8378
8203
|
for (const f of current.missing_features) {
|
|
@@ -8402,50 +8227,37 @@ const server = http.createServer(async (req, res) => {
|
|
|
8402
8227
|
try {
|
|
8403
8228
|
const body = await readBody(req);
|
|
8404
8229
|
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
|
|
8230
|
+
const isPrd = body.file.endsWith('.json');
|
|
8231
|
+
let planPath = null;
|
|
8409
8232
|
let planObj = null;
|
|
8410
8233
|
let prdSourcePlan = null;
|
|
8411
|
-
|
|
8412
|
-
|
|
8234
|
+
let archivedPrd = false;
|
|
8235
|
+
if (isPrd) {
|
|
8236
|
+
planObj = prdStore.readPrd(body.file);
|
|
8237
|
+
if (!planObj) {
|
|
8238
|
+
archivedPrd = true;
|
|
8239
|
+
planObj = prdStore.readPrd(body.file, { archived: true });
|
|
8240
|
+
}
|
|
8241
|
+
if (!planObj) return jsonReply(res, 404, { error: 'plan not found' });
|
|
8242
|
+
prdSourcePlan = planObj.source_plan || null;
|
|
8243
|
+
} else {
|
|
8244
|
+
planPath = resolvePlanPath(body.file);
|
|
8245
|
+
if (!statePathExists(planPath)) return jsonReply(res, 404, { error: 'plan not found' });
|
|
8413
8246
|
}
|
|
8414
8247
|
// Clean up worktrees before deleting work items (needs branch info from work items)
|
|
8415
8248
|
try {
|
|
8416
8249
|
const { cleanupPlanWorktrees } = require('./engine/lifecycle');
|
|
8417
8250
|
cleanupPlanWorktrees(body.file, planObj || {}, PROJECTS, queries.getConfig());
|
|
8418
8251
|
} 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
|
-
}
|
|
8252
|
+
if (isPrd) prdStore.deletePrd(body.file, { archived: archivedPrd });
|
|
8253
|
+
else safeUnlink(planPath);
|
|
8439
8254
|
|
|
8440
8255
|
// Clean up materialized work items from all projects + central
|
|
8441
8256
|
let cleaned = 0;
|
|
8442
|
-
const
|
|
8443
|
-
for (const
|
|
8444
|
-
wiPaths.push(shared.projectWorkItemsPath(proj));
|
|
8445
|
-
}
|
|
8446
|
-
for (const wiPath of wiPaths) {
|
|
8257
|
+
const workItemScopes = ['central', ...PROJECTS];
|
|
8258
|
+
for (const scope of workItemScopes) {
|
|
8447
8259
|
try {
|
|
8448
|
-
mutateWorkItems(
|
|
8260
|
+
mutateWorkItems(scope, items => {
|
|
8449
8261
|
const filtered = items.filter(w => w.sourcePlan !== body.file);
|
|
8450
8262
|
if (filtered.length < items.length) {
|
|
8451
8263
|
cleaned += items.length - filtered.length;
|
|
@@ -8465,8 +8277,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
8465
8277
|
// If deleting a PRD .json, reset the plan-to-prd work item so the source .md reverts to draft
|
|
8466
8278
|
if (prdSourcePlan) {
|
|
8467
8279
|
try {
|
|
8468
|
-
|
|
8469
|
-
mutateWorkItems(centralPath, items => {
|
|
8280
|
+
mutateWorkItems('central', items => {
|
|
8470
8281
|
for (const w of items) {
|
|
8471
8282
|
if (w.type === WORK_TYPE.PLAN_TO_PRD && DONE_STATUSES.has(w.status) && w.planFile === prdSourcePlan) {
|
|
8472
8283
|
w.status = WI_STATUS.CANCELLED;
|
|
@@ -8488,23 +8299,18 @@ const server = http.createServer(async (req, res) => {
|
|
|
8488
8299
|
const body = await readBody(req);
|
|
8489
8300
|
if (!body.file) return jsonReply(res, 400, { error: 'file required' });
|
|
8490
8301
|
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;
|
|
8302
|
+
if (!isPrd) shared.sanitizePath(body.file, PLANS_DIR);
|
|
8303
|
+
let planPath = null;
|
|
8500
8304
|
if (isPrd) {
|
|
8501
|
-
|
|
8305
|
+
if (!prdStore.readPrd(body.file)) return jsonReply(res, 404, { error: 'plan not found' });
|
|
8502
8306
|
} else {
|
|
8307
|
+
planPath = resolvePlanPath(body.file);
|
|
8308
|
+
if (!statePathExists(planPath)) return jsonReply(res, 404, { error: 'plan not found' });
|
|
8503
8309
|
const archiveDir = path.join(PLANS_DIR, 'archive');
|
|
8504
8310
|
// DATA-LOSS GUARD: moveFileNoClobber dedupes the destination so a same-
|
|
8505
8311
|
// basename collision can't silently destroy a previously-archived plan +
|
|
8506
8312
|
// its completed work-item history. Bumps to -2/-3 instead of overwriting.
|
|
8507
|
-
|
|
8313
|
+
shared.moveFileNoClobber(planPath, archiveDir, body.file);
|
|
8508
8314
|
}
|
|
8509
8315
|
|
|
8510
8316
|
let archivedSource = null;
|
|
@@ -8515,8 +8321,6 @@ const server = http.createServer(async (req, res) => {
|
|
|
8515
8321
|
if (isPrd) {
|
|
8516
8322
|
const result = _archivePrdPostProcess({
|
|
8517
8323
|
planFile: body.file,
|
|
8518
|
-
archivePath,
|
|
8519
|
-
planPath,
|
|
8520
8324
|
plansDir: PLANS_DIR,
|
|
8521
8325
|
});
|
|
8522
8326
|
archivedSource = result.archivedSource;
|
|
@@ -8527,38 +8331,17 @@ const server = http.createServer(async (req, res) => {
|
|
|
8527
8331
|
// archive any PRD whose `source_plan` points back at it. Without this,
|
|
8528
8332
|
// the dashboard still renders the PRD as a status-completed plan card
|
|
8529
8333
|
// 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
|
-
}
|
|
8334
|
+
// minions-opg-2026-06-10-2.json).
|
|
8335
|
+
const prdRows = prdStore.listPrdRows().filter(row => !row.archived);
|
|
8336
|
+
for (const { filename: prdFile, plan: prd } of prdRows) {
|
|
8548
8337
|
// Canonical match (prefix/dir-tolerant): a raw `prd.source_plan !==
|
|
8549
8338
|
// body.file` skipped PRDs whose source_plan carried the legacy
|
|
8550
8339
|
// `plans/` prefix, archiving the plan but NOT its PRD — the plan↔PRD
|
|
8551
8340
|
// status desync behind the "old PRDs came back" resurrection.
|
|
8552
8341
|
if (!prd || !shared.prdMatchesSourcePlan(prd.source_plan, body.file)) continue;
|
|
8553
8342
|
|
|
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
8343
|
const cascadeResult = _archivePrdPostProcess({
|
|
8559
8344
|
planFile: prdFile,
|
|
8560
|
-
archivePath: prdLivePath,
|
|
8561
|
-
planPath: prdLivePath,
|
|
8562
8345
|
plansDir: PLANS_DIR,
|
|
8563
8346
|
});
|
|
8564
8347
|
archiveWarnings.push(...cascadeResult.archiveWarnings);
|
|
@@ -8570,10 +8353,10 @@ const server = http.createServer(async (req, res) => {
|
|
|
8570
8353
|
// Cancel pending work items linked to this plan so the engine stops
|
|
8571
8354
|
// dispatching for an archived plan. Done items are preserved as history.
|
|
8572
8355
|
let cancelledItems = 0;
|
|
8573
|
-
const
|
|
8574
|
-
for (const
|
|
8356
|
+
const workItemScopes = ['central', ...PROJECTS];
|
|
8357
|
+
for (const scope of workItemScopes) {
|
|
8575
8358
|
try {
|
|
8576
|
-
mutateWorkItems(
|
|
8359
|
+
mutateWorkItems(scope, items => {
|
|
8577
8360
|
for (const w of items) {
|
|
8578
8361
|
if (w.sourcePlan !== body.file) continue;
|
|
8579
8362
|
if (w.status === WI_STATUS.PENDING || w.status === WI_STATUS.QUEUED) {
|
|
@@ -8605,66 +8388,43 @@ const server = http.createServer(async (req, res) => {
|
|
|
8605
8388
|
try {
|
|
8606
8389
|
const body = await readBody(req);
|
|
8607
8390
|
if (!body.file) return jsonReply(res, 400, { error: 'file required' });
|
|
8608
|
-
try {
|
|
8391
|
+
try {
|
|
8392
|
+
if (!body.file.endsWith('.json')) shared.sanitizePath(body.file, PLANS_DIR);
|
|
8393
|
+
}
|
|
8609
8394
|
catch { return jsonReply(res, 400, { error: 'invalid filename' }); }
|
|
8610
8395
|
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
|
|
8396
|
+
let restoredAs = body.file;
|
|
8656
8397
|
let unarchivedSource = null;
|
|
8657
8398
|
if (isJson) {
|
|
8399
|
+
const archivedRow = prdStore.readPrd(body.file, { archived: true });
|
|
8400
|
+
let prd = archivedRow
|
|
8401
|
+
? prdStore.restorePrdFromArchive(body.file)
|
|
8402
|
+
: prdStore.readPrd(body.file);
|
|
8403
|
+
if (!prd || (!archivedRow && !shared.isPrdArchived(prd))) {
|
|
8404
|
+
return jsonReply(res, 404, { error: 'File not found in archive' });
|
|
8405
|
+
}
|
|
8406
|
+
prdStore.mutatePrd(body.file, (data) => {
|
|
8407
|
+
if (data.status === 'archived') data.status = 'active';
|
|
8408
|
+
delete data.archived;
|
|
8409
|
+
delete data.archivedAt;
|
|
8410
|
+
return data;
|
|
8411
|
+
});
|
|
8412
|
+
prd = prdStore.readPrd(body.file);
|
|
8658
8413
|
try {
|
|
8659
|
-
const prd = safeJson(liveDest);
|
|
8660
8414
|
if (prd?.source_plan) {
|
|
8661
|
-
const
|
|
8415
|
+
const sourcePlan = shared.sourcePlanKey(prd.source_plan);
|
|
8416
|
+
const mdArchivePath = path.join(PLANS_DIR, 'archive', sourcePlan);
|
|
8662
8417
|
if (fs.existsSync(mdArchivePath)) {
|
|
8663
|
-
const liveMdDest = shared.moveFileNoClobber(mdArchivePath, PLANS_DIR,
|
|
8418
|
+
const liveMdDest = shared.moveFileNoClobber(mdArchivePath, PLANS_DIR, sourcePlan);
|
|
8664
8419
|
unarchivedSource = path.basename(liveMdDest);
|
|
8665
8420
|
}
|
|
8666
8421
|
}
|
|
8667
8422
|
} catch { /* optional */ }
|
|
8423
|
+
} else {
|
|
8424
|
+
const archivePath = path.join(PLANS_DIR, 'archive', body.file);
|
|
8425
|
+
if (!fs.existsSync(archivePath)) return jsonReply(res, 404, { error: 'File not found in archive' });
|
|
8426
|
+
const liveDest = shared.moveFileNoClobber(archivePath, PLANS_DIR, body.file);
|
|
8427
|
+
restoredAs = path.basename(liveDest);
|
|
8668
8428
|
}
|
|
8669
8429
|
|
|
8670
8430
|
invalidateStatusCache();
|
|
@@ -8672,7 +8432,6 @@ const server = http.createServer(async (req, res) => {
|
|
|
8672
8432
|
// `restoredAs` reflects the actual restored basename: moveFileNoClobber
|
|
8673
8433
|
// bumps to <name>-2 when a live file of the same name already exists, so
|
|
8674
8434
|
// the client isn't misled into expecting body.file when it differs.
|
|
8675
|
-
const restoredAs = path.basename(liveDest);
|
|
8676
8435
|
const payload = { ok: true, unarchivedSource };
|
|
8677
8436
|
if (restoredAs !== body.file) payload.restoredAs = restoredAs;
|
|
8678
8437
|
return jsonReply(res, 200, payload);
|
|
@@ -8684,20 +8443,18 @@ const server = http.createServer(async (req, res) => {
|
|
|
8684
8443
|
const body = await readBody(req);
|
|
8685
8444
|
if (!body.file || !body.feedback) return jsonReply(res, 400, { error: 'file and feedback required' });
|
|
8686
8445
|
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) => {
|
|
8446
|
+
const plan = prdStore.mutatePrd(body.file, (data) => {
|
|
8689
8447
|
if (!data || Array.isArray(data) || typeof data !== 'object') data = {};
|
|
8690
8448
|
data.status = 'revision-requested';
|
|
8691
8449
|
data.revision_feedback = body.feedback;
|
|
8692
8450
|
data.revisionRequestedAt = new Date().toISOString();
|
|
8693
8451
|
data.revisionRequestedBy = body.requestedBy || os.userInfo().username;
|
|
8694
8452
|
return data;
|
|
8695
|
-
}
|
|
8453
|
+
});
|
|
8696
8454
|
|
|
8697
8455
|
// Create a work item to revise the plan
|
|
8698
|
-
const wiPath = path.join(MINIONS_DIR, 'work-items.json');
|
|
8699
8456
|
const id = 'W-' + shared.uid();
|
|
8700
|
-
mutateWorkItems(
|
|
8457
|
+
mutateWorkItems('central', items => {
|
|
8701
8458
|
items.push({
|
|
8702
8459
|
id, title: 'Revise plan: ' + (plan.plan_summary || body.file),
|
|
8703
8460
|
type: WORK_TYPE.PLAN_TO_PRD, priority: 'high',
|
|
@@ -8718,11 +8475,10 @@ const server = http.createServer(async (req, res) => {
|
|
|
8718
8475
|
try {
|
|
8719
8476
|
const body = await readBody(req);
|
|
8720
8477
|
if (!body.file) return jsonReply(res, 400, { error: 'file required' });
|
|
8721
|
-
|
|
8722
|
-
const
|
|
8723
|
-
if (!
|
|
8724
|
-
|
|
8725
|
-
const plan = JSON.parse(planContent);
|
|
8478
|
+
if (!body.file.endsWith('.json')) return jsonReply(res, 400, { error: 'expected a PRD JSON filename' });
|
|
8479
|
+
const plan = prdStore.readPrd(body.file);
|
|
8480
|
+
if (!plan) return jsonReply(res, 404, { error: 'plan not found' });
|
|
8481
|
+
const planReference = `SQL PRD ${body.file}`;
|
|
8726
8482
|
const projectName = plan.project || 'Unknown';
|
|
8727
8483
|
|
|
8728
8484
|
// Build the session launch script
|
|
@@ -8730,10 +8486,10 @@ const server = http.createServer(async (req, res) => {
|
|
|
8730
8486
|
let sysPrompt;
|
|
8731
8487
|
try {
|
|
8732
8488
|
sysPrompt = fs.readFileSync(path.join(MINIONS_DIR, 'prompts', 'plan-advisor-system.md'), 'utf8')
|
|
8733
|
-
.replace(/\{\{plan_path\}\}/g,
|
|
8489
|
+
.replace(/\{\{plan_path\}\}/g, planReference)
|
|
8734
8490
|
.replace(/\{\{project_name\}\}/g, projectName);
|
|
8735
8491
|
} catch {
|
|
8736
|
-
sysPrompt = `You are a Plan Advisor. The plan is
|
|
8492
|
+
sysPrompt = `You are a Plan Advisor. The plan is ${planReference} for project ${projectName}. Help the user review and approve it.`;
|
|
8737
8493
|
}
|
|
8738
8494
|
|
|
8739
8495
|
const initialPrompt = `Here's the plan awaiting your review:
|
|
@@ -9585,7 +9341,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
9585
9341
|
_clearCcLiveStream(tabId);
|
|
9586
9342
|
}
|
|
9587
9343
|
// P-c2sess-1d8e: lock single-session reset against concurrent updateSession writes.
|
|
9588
|
-
|
|
9344
|
+
persistCcSession();
|
|
9589
9345
|
return jsonReply(res, 200, { ok: true });
|
|
9590
9346
|
}
|
|
9591
9347
|
|
|
@@ -9619,12 +9375,10 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
9619
9375
|
async function handleCCSessionDelete(req, res, match) {
|
|
9620
9376
|
const id = match?.[1];
|
|
9621
9377
|
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) => {
|
|
9378
|
+
smallStateStore.applyCcSessionsMutation((raw) => {
|
|
9625
9379
|
const sessions = _filterCcTabSessions(raw);
|
|
9626
9380
|
return sessions.filter(s => s.id !== id);
|
|
9627
|
-
}
|
|
9381
|
+
});
|
|
9628
9382
|
// Sub-task C of W-mp2w003600196c51: tear down the persistent ACP worker
|
|
9629
9383
|
// for this tab so we don't leak a Copilot process after the user closes
|
|
9630
9384
|
// the tab. closeTab is a no-op when the pool has no entry for the tabId,
|
|
@@ -9814,7 +9568,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
9814
9568
|
// W-mrb1a3950003d9ea — flat, stable label (no per-watch-id interpolation).
|
|
9815
9569
|
// Every other _engine category (kb-sweep, consolidation, command-center, …)
|
|
9816
9570
|
// uses one flat label; a dynamic watchId suffix here used to mint a brand-new
|
|
9817
|
-
// permanent metrics
|
|
9571
|
+
// permanent metrics category per watch firing (20+ confirmed live).
|
|
9818
9572
|
const label = 'watch-cc-triage';
|
|
9819
9573
|
|
|
9820
9574
|
const model = CONFIG.engine?.ccModel || shared.ENGINE_DEFAULTS.ccModel;
|
|
@@ -10692,10 +10446,8 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
10692
10446
|
const _persistTabId = body.tabId;
|
|
10693
10447
|
if (_persistTabId && responseSessionId) {
|
|
10694
10448
|
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
10449
|
const preview = (body.message || '').slice(0, 80);
|
|
10698
|
-
|
|
10450
|
+
smallStateStore.applyCcSessionsMutation((raw) => {
|
|
10699
10451
|
const sessions = _filterCcTabSessions(raw);
|
|
10700
10452
|
const existing = sessions.find(s => s.id === _persistTabId);
|
|
10701
10453
|
if (existing) {
|
|
@@ -10709,7 +10461,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
10709
10461
|
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
10462
|
}
|
|
10711
10463
|
return sessions;
|
|
10712
|
-
}
|
|
10464
|
+
});
|
|
10713
10465
|
} catch { /* non-critical */ }
|
|
10714
10466
|
}
|
|
10715
10467
|
|
|
@@ -10899,9 +10651,8 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
10899
10651
|
participants: Array.isArray(schedForRun.participants) ? schedForRun.participants : [],
|
|
10900
10652
|
});
|
|
10901
10653
|
} else {
|
|
10902
|
-
const centralPath = path.join(MINIONS_DIR, 'work-items.json');
|
|
10903
10654
|
let duplicate = null;
|
|
10904
|
-
|
|
10655
|
+
mutateWorkItems('central', (items) => {
|
|
10905
10656
|
if (!Array.isArray(items)) items = [];
|
|
10906
10657
|
duplicate = items.find(i =>
|
|
10907
10658
|
i._scheduleId === item._scheduleId &&
|
|
@@ -10911,7 +10662,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
10911
10662
|
);
|
|
10912
10663
|
if (!duplicate) items.push(item);
|
|
10913
10664
|
return items;
|
|
10914
|
-
}
|
|
10665
|
+
});
|
|
10915
10666
|
if (duplicate) {
|
|
10916
10667
|
return jsonReply(res, 409, {
|
|
10917
10668
|
error: 'Schedule already has an active work item',
|
|
@@ -11091,19 +10842,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
11091
10842
|
name: p.name,
|
|
11092
10843
|
localPath: p.localPath || null,
|
|
11093
10844
|
mainBranch: p.mainBranch || null,
|
|
11094
|
-
|
|
11095
|
-
// the per-project dropdown. resolveCheckoutMode honors the legacy
|
|
11096
|
-
// worktreeMode field; 'worktree' (default) or 'live'.
|
|
11097
|
-
checkoutMode: shared.resolveCheckoutMode(p),
|
|
11098
|
-
// W-mr9mx93d000x1dda — surface the hybrid liveValidation config
|
|
11099
|
-
// (type(s) + autoDispatch) so the Settings → Projects checkout-mode
|
|
11100
|
-
// control can pre-fill its Hybrid sub-panel authoritatively (mirrors
|
|
11101
|
-
// the /api/status project mapping the Projects-bar picker reads).
|
|
11102
|
-
// Only meaningful under checkoutMode:'live'; null/false otherwise.
|
|
11103
|
-
liveValidationType: (shared.resolveCheckoutMode(p) === 'live' && p.liveValidation && p.liveValidation.type)
|
|
11104
|
-
? p.liveValidation.type
|
|
11105
|
-
: null,
|
|
11106
|
-
liveValidationAutoDispatch: !!(shared.resolveCheckoutMode(p) === 'live' && p.liveValidation && p.liveValidation.autoDispatch === true),
|
|
10845
|
+
..._projectCheckoutConfigView(p),
|
|
11107
10846
|
// W-mqtvnnj1000357fa — surface the RAW per-project auto-stash override
|
|
11108
10847
|
// (true / false / undefined) so the Settings UI can pre-select the
|
|
11109
10848
|
// tri-state dropdown. Undefined means "use fleet default"
|
|
@@ -12117,7 +11856,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
12117
11856
|
|
|
12118
11857
|
// ── Managed processes API (P-4b8d2e57, plan W-mp7k1r760003b5dd item 4) ──
|
|
12119
11858
|
// List + by-name + kill + restart for the engine-owned managed-spawn
|
|
12120
|
-
// services (
|
|
11859
|
+
// services (SQL managed-process state). Mirrors the keep-processes
|
|
12121
11860
|
// pattern above but lives a layer deeper because the state is engine-side
|
|
12122
11861
|
// (not per-agent sidecars).
|
|
12123
11862
|
|
|
@@ -12416,7 +12155,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
12416
12155
|
const projectName = runbook.project || target.owner_project || '';
|
|
12417
12156
|
const resolveTarget = resolveWorkItemsCreateTarget(projectName);
|
|
12418
12157
|
if (resolveTarget.error) return jsonReply(res, 400, { error: resolveTarget.error }, req);
|
|
12419
|
-
const
|
|
12158
|
+
const wiScope = resolveTarget.scope;
|
|
12420
12159
|
const targetProject = resolveTarget.project;
|
|
12421
12160
|
|
|
12422
12161
|
// Create run record BEFORE the WI so we can stamp the WI with qaRunId.
|
|
@@ -12452,12 +12191,12 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
12452
12191
|
if (targetProject) wi.project = targetProject.name;
|
|
12453
12192
|
if (body.agent && typeof body.agent === 'string' && body.agent.trim()) wi.agent = body.agent.trim();
|
|
12454
12193
|
|
|
12455
|
-
//
|
|
12456
|
-
//
|
|
12194
|
+
// createRun and the work-item append are separate transactions; order is
|
|
12195
|
+
// intentional so a
|
|
12457
12196
|
// crash between the two leaves the run as `pending` (recoverable) rather
|
|
12458
12197
|
// than orphaning a WI with no run record.
|
|
12459
12198
|
let added = false;
|
|
12460
|
-
mutateWorkItems(
|
|
12199
|
+
mutateWorkItems(wiScope, (items) => {
|
|
12461
12200
|
if (!Array.isArray(items)) items = [];
|
|
12462
12201
|
if (!items.some(i => i && i.id === wiId)) {
|
|
12463
12202
|
items.push(wi);
|
|
@@ -12633,7 +12372,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
12633
12372
|
}
|
|
12634
12373
|
|
|
12635
12374
|
// W-mpxp90xs000i5732 — DELETE /api/qa/runs/<id>. Hard-delete: removes the
|
|
12636
|
-
// run from
|
|
12375
|
+
// run from SQL QA state and best-effort wipes the on-disk artifact
|
|
12637
12376
|
// directory under engine/qa-artifacts/<runId>/. Terminal-only — refuses
|
|
12638
12377
|
// pending/running runs with 409. Caller must cancel/complete the run via
|
|
12639
12378
|
// the lifecycle helpers before deletion. CSRF/Origin gate is applied by
|
|
@@ -12721,7 +12460,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
12721
12460
|
// Thin HTTP wrappers over engine/qa-sessions.js + engine/qa-runners.js. The
|
|
12722
12461
|
// lifecycle (state machine, work-item builders, dispatch chain) lives in
|
|
12723
12462
|
// those modules; these handlers translate HTTP into the right call,
|
|
12724
|
-
// resolve project/
|
|
12463
|
+
// resolve project/scope the same way handleQaRunbookRun does, and shape
|
|
12725
12464
|
// qa-sessions thrown errors into 400/404/409 instead of 500.
|
|
12726
12465
|
//
|
|
12727
12466
|
// Endpoint summary:
|
|
@@ -12752,22 +12491,17 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
12752
12491
|
return 500;
|
|
12753
12492
|
}
|
|
12754
12493
|
|
|
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.
|
|
12494
|
+
// Resolve the project and SQL scope for QA work items.
|
|
12760
12495
|
function _qaSessionsResolveTarget(projectName) {
|
|
12761
12496
|
const target = resolveWorkItemsCreateTarget(projectName || '');
|
|
12762
12497
|
if (target.error) return { error: target.error };
|
|
12763
|
-
return {
|
|
12498
|
+
return { scope: target.scope, project: target.project ? target.project.name : null };
|
|
12764
12499
|
}
|
|
12765
12500
|
|
|
12766
12501
|
// W-mpq6xqzj000606d0 — Multi-project resolver. Walks each requested project
|
|
12767
12502
|
// 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.
|
|
12503
|
+
// {scope, project} entries (with the first treated as primary) or
|
|
12504
|
+
// { error } on the first failure.
|
|
12771
12505
|
function _qaSessionsResolveTargets(body) {
|
|
12772
12506
|
const list = Array.isArray(body && body.projects) && body.projects.length > 0
|
|
12773
12507
|
? body.projects.slice()
|
|
@@ -12815,7 +12549,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
12815
12549
|
let setupWiId = null;
|
|
12816
12550
|
try {
|
|
12817
12551
|
setupWiId = qaSessions.queueSetup(session.id, {
|
|
12818
|
-
resolvedTargets: resolution.targets.map(t => ({ project: t.project,
|
|
12552
|
+
resolvedTargets: resolution.targets.map(t => ({ project: t.project, scope: t.scope })),
|
|
12819
12553
|
});
|
|
12820
12554
|
} catch (e) {
|
|
12821
12555
|
// queueSetup throws when pending→spawning is rejected (createSession
|
|
@@ -12923,7 +12657,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
12923
12657
|
let setupWiId = null;
|
|
12924
12658
|
try {
|
|
12925
12659
|
setupWiId = qaSessions.queueSetup(session.id, {
|
|
12926
|
-
resolvedTargets: resolution.targets.map(t => ({ project: t.project,
|
|
12660
|
+
resolvedTargets: resolution.targets.map(t => ({ project: t.project, scope: t.scope })),
|
|
12927
12661
|
});
|
|
12928
12662
|
} catch (e) {
|
|
12929
12663
|
return jsonReply(res, _qaSessionsErrorToStatus(e), {
|
|
@@ -13067,7 +12801,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
13067
12801
|
let executeWiId;
|
|
13068
12802
|
try {
|
|
13069
12803
|
executeWiId = qaSessions.approveDraft(session.id, {
|
|
13070
|
-
|
|
12804
|
+
scope: resolved.scope,
|
|
13071
12805
|
qaRunId: run.id,
|
|
13072
12806
|
project: resolved.project,
|
|
13073
12807
|
});
|
|
@@ -13106,7 +12840,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
13106
12840
|
let draftWiId;
|
|
13107
12841
|
try {
|
|
13108
12842
|
draftWiId = qaSessions.editDraft(session.id, {
|
|
13109
|
-
|
|
12843
|
+
scope: resolved.scope,
|
|
13110
12844
|
feedback,
|
|
13111
12845
|
project: resolved.project,
|
|
13112
12846
|
});
|
|
@@ -13260,6 +12994,13 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
13260
12994
|
|
|
13261
12995
|
// Status & health
|
|
13262
12996
|
{ method: 'GET', path: '/api/status', desc: 'Full dashboard status snapshot (agents, PRDs, work items, dispatch, etc.)', handler: handleStatus },
|
|
12997
|
+
{ method: 'GET', path: '/api/constellation/v1/snapshot', desc: 'Versioned metadata-only full snapshot for the local Constellation mirror', handler: (req, res) => {
|
|
12998
|
+
return serveFreshJson(req, res, {
|
|
12999
|
+
tag: `constellation-v1-${_dashboardVersion.codeVersion || 'unknown'}-${_dashboardVersion.codeCommit || 'unknown'}`,
|
|
13000
|
+
inputs: [CONFIG_PATH, queries.CONTROL_PATH, INBOX_DIR, AGENTS_DIR],
|
|
13001
|
+
builder: _buildConstellationBridgeSnapshot,
|
|
13002
|
+
});
|
|
13003
|
+
}},
|
|
13263
13004
|
// Raw state-file passthrough (no /api/ prefix — this serves files, not API
|
|
13264
13005
|
// responses). Allowlisted top-level dirs only; mtime+size ETag → 304.
|
|
13265
13006
|
{ method: 'GET', path: /^\/state\/.+/, desc: 'Serve a raw state file (or directory listing) from MINIONS_DIR with mtime/size ETag', handler: handleStateRead },
|
|
@@ -13272,34 +13013,16 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
13272
13013
|
tag: 'agents',
|
|
13273
13014
|
inputs: [
|
|
13274
13015
|
CONFIG_PATH,
|
|
13275
|
-
path.join(ENGINE_DIR, 'dispatch.json'),
|
|
13276
13016
|
INBOX_DIR,
|
|
13277
13017
|
AGENTS_DIR,
|
|
13278
13018
|
],
|
|
13279
13019
|
builder: () => getAgents(),
|
|
13280
13020
|
});
|
|
13281
13021
|
}},
|
|
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
|
-
}
|
|
13022
|
+
{ 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
13023
|
return serveFreshJson(req, res, {
|
|
13301
13024
|
tag: 'work-items',
|
|
13302
|
-
inputs,
|
|
13025
|
+
inputs: [CONFIG_PATH],
|
|
13303
13026
|
// W-mq5xg5e9000nec0e — Slim every item before stringify so the polled
|
|
13304
13027
|
// refresh-loop payload stays < ~300 KB even when description fields
|
|
13305
13028
|
// include 100+ KB transcripts. slimWorkItemForList shallow-copies so
|
|
@@ -13307,22 +13030,10 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
13307
13030
|
builder: () => getWorkItems().map(slimWorkItemForList),
|
|
13308
13031
|
});
|
|
13309
13032
|
}},
|
|
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
|
-
}
|
|
13033
|
+
{ method: 'GET', path: '/api/pull-requests', desc: 'Fully-enriched SQL-backed pull requests with URL backfill and project stamps', handler: (req, res) => {
|
|
13323
13034
|
return serveFreshJson(req, res, {
|
|
13324
13035
|
tag: 'pull-requests',
|
|
13325
|
-
inputs,
|
|
13036
|
+
inputs: [CONFIG_PATH],
|
|
13326
13037
|
builder: () => queries.getPullRequests(),
|
|
13327
13038
|
});
|
|
13328
13039
|
}},
|
|
@@ -13334,54 +13045,22 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
13334
13045
|
{ method: 'GET', path: '/api/dispatch', desc: 'Live dispatch queue with completion-report summaries (pending/active/completed slices)', handler: (req, res) => {
|
|
13335
13046
|
return serveFreshJson(req, res, {
|
|
13336
13047
|
tag: 'dispatch',
|
|
13337
|
-
inputs: [
|
|
13338
|
-
path.join(ENGINE_DIR, 'dispatch.json'),
|
|
13339
|
-
path.join(ENGINE_DIR, 'metrics.json'),
|
|
13340
|
-
],
|
|
13048
|
+
inputs: [],
|
|
13341
13049
|
builder: () => getDispatchQueue(),
|
|
13342
13050
|
});
|
|
13343
13051
|
}},
|
|
13344
13052
|
{ 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
13053
|
return serveFreshJson(req, res, {
|
|
13359
13054
|
tag: 'metrics',
|
|
13360
|
-
inputs,
|
|
13055
|
+
inputs: [CONFIG_PATH],
|
|
13361
13056
|
builder: () => getMetrics(),
|
|
13362
13057
|
});
|
|
13363
13058
|
}},
|
|
13364
|
-
{ method: 'GET', path: '/api/prd', desc: 'PRD status + progress derived from
|
|
13365
|
-
const config = queries.getConfig();
|
|
13366
|
-
const projects = config.projects || [];
|
|
13059
|
+
{ method: 'GET', path: '/api/prd', desc: 'PRD status + progress derived from SQL-backed PRDs, work items, and pull requests', handler: (req, res) => {
|
|
13367
13060
|
const inputs = [
|
|
13368
13061
|
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
13062
|
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
13063
|
];
|
|
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
13064
|
return serveFreshJson(req, res, {
|
|
13386
13065
|
tag: 'prd',
|
|
13387
13066
|
inputs,
|
|
@@ -13391,7 +13070,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
13391
13070
|
{ method: 'GET', path: '/api/archived-prds', desc: 'List of archived PRD files with status + completedAt', handler: (req, res) => {
|
|
13392
13071
|
return serveFreshJson(req, res, {
|
|
13393
13072
|
tag: 'archived-prds',
|
|
13394
|
-
inputs: [
|
|
13073
|
+
inputs: [],
|
|
13395
13074
|
builder: () => [],
|
|
13396
13075
|
});
|
|
13397
13076
|
}},
|
|
@@ -13418,12 +13097,8 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
13418
13097
|
builder: () => {
|
|
13419
13098
|
// Read config.json directly via queries.getConfig() rather than
|
|
13420
13099
|
// 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.)
|
|
13100
|
+
// ensureConfiguredProjectStateFiles(); the builder only needs
|
|
13101
|
+
// config.schedules, so getConfig avoids unrelated setup work.
|
|
13427
13102
|
const cfg = queries.getConfig() || {};
|
|
13428
13103
|
const scheds = cfg.schedules || [];
|
|
13429
13104
|
const runs = require('./engine/small-state-store').readScheduleRuns();
|
|
@@ -13570,11 +13245,10 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
13570
13245
|
const { id, source, rating, comment } = body;
|
|
13571
13246
|
if (!id || !rating) return jsonReply(res, 400, { error: 'id and rating required' });
|
|
13572
13247
|
const projects = shared.getProjects(CONFIG);
|
|
13573
|
-
const
|
|
13574
|
-
for (const p of projects) paths.push(shared.projectWorkItemsPath(p));
|
|
13248
|
+
const workItemScopes = ['central', ...projects];
|
|
13575
13249
|
let found = null;
|
|
13576
|
-
for (const
|
|
13577
|
-
mutateWorkItems(
|
|
13250
|
+
for (const scope of workItemScopes) {
|
|
13251
|
+
mutateWorkItems(scope, items => {
|
|
13578
13252
|
const item = items.find(i => i.id === id);
|
|
13579
13253
|
if (item && !found) {
|
|
13580
13254
|
item._humanFeedback = { rating, comment: comment || '', at: new Date().toISOString() };
|
|
@@ -13714,7 +13388,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
13714
13388
|
} catch (e) {
|
|
13715
13389
|
return jsonReply(res, e.statusCode || 400, { error: e.message }, req);
|
|
13716
13390
|
}
|
|
13717
|
-
const { id: prId,
|
|
13391
|
+
const { id: prId, scope, prNum, created, linked, targetProject, projectResolution, skipped, resurrected } = linkResult;
|
|
13718
13392
|
invalidateStatusCache();
|
|
13719
13393
|
// W-mrdutzdr000t22f4 — upsertPullRequestRecord can leave the record
|
|
13720
13394
|
// untouched (`skipped: true`) for reasons other than a resurrect, e.g.
|
|
@@ -13763,7 +13437,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
13763
13437
|
} catch { /* ADO token may not be available */ }
|
|
13764
13438
|
}
|
|
13765
13439
|
if (!prData) return;
|
|
13766
|
-
|
|
13440
|
+
shared.mutatePullRequests(scope, (prs) => {
|
|
13767
13441
|
const pr = prs.find(p => p.id === prId);
|
|
13768
13442
|
if (!pr) return prs;
|
|
13769
13443
|
// Remote title always wins — any user-supplied title is a placeholder (closes #1283)
|
|
@@ -13978,14 +13652,14 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
13978
13652
|
const { id, project: requestedProjectName } = body;
|
|
13979
13653
|
if (!id) return jsonReply(res, 400, { error: 'id required' });
|
|
13980
13654
|
reloadConfig();
|
|
13981
|
-
// Search all project
|
|
13655
|
+
// Search all project and central PR scopes.
|
|
13982
13656
|
const projects = shared.getProjects(CONFIG);
|
|
13983
13657
|
const projectByName = new Map(projects.map(project => [project.name, project]));
|
|
13984
13658
|
const store = require('./engine/pull-requests-store');
|
|
13985
13659
|
const storedPrs = store.readAllPullRequests() || [];
|
|
13986
13660
|
const prScopes = new Map([
|
|
13987
|
-
...projects.map(project => [project.name, {
|
|
13988
|
-
['central', {
|
|
13661
|
+
...projects.map(project => [project.name, { source: project, project }]),
|
|
13662
|
+
['central', { source: 'central', project: null }],
|
|
13989
13663
|
]);
|
|
13990
13664
|
const canonicalRequest = shared.parseCanonicalPrId(id);
|
|
13991
13665
|
const requestedIdentities = new Set();
|
|
@@ -14016,7 +13690,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
14016
13690
|
const ownerProject = scope !== 'central' ? projectByName.get(scope) || null : null;
|
|
14017
13691
|
if (!requestedIdentities.has(shared.getPrIdentityKey(pr, ownerProject))) continue;
|
|
14018
13692
|
if (!prScopes.has(scope)) {
|
|
14019
|
-
prScopes.set(scope, {
|
|
13693
|
+
prScopes.set(scope, { source: ownerProject || scope, project: ownerProject });
|
|
14020
13694
|
}
|
|
14021
13695
|
}
|
|
14022
13696
|
let found = false;
|
|
@@ -14027,11 +13701,11 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
14027
13701
|
// the other scope's copy fully untouched — queries.js#getPullRequests
|
|
14028
13702
|
// then de-dupes by picking that untouched duplicate as the "winner"
|
|
14029
13703
|
// and silently resurrects the "deleted" PR on the next read.
|
|
14030
|
-
for (const {
|
|
13704
|
+
for (const { source, project } of prScopes.values()) {
|
|
14031
13705
|
// Issue #384: use a tombstone (userDeleted:true) instead of splice so
|
|
14032
13706
|
// the next poller cycle cannot re-add the PR. The entry stays in the
|
|
14033
|
-
//
|
|
14034
|
-
shared.mutatePullRequests(
|
|
13707
|
+
// state but is filtered from API responses and skipped by pollers.
|
|
13708
|
+
shared.mutatePullRequests(source, (prs) => {
|
|
14035
13709
|
if (!Array.isArray(prs)) return prs;
|
|
14036
13710
|
const matches = prs.filter(pr =>
|
|
14037
13711
|
requestedIdentities.has(shared.getPrIdentityKey(pr, project))
|
|
@@ -14056,22 +13730,19 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
14056
13730
|
if (!prId) return jsonReply(res, 400, { error: 'prId required' });
|
|
14057
13731
|
if (!cause) return jsonReply(res, 400, { error: 'cause required' });
|
|
14058
13732
|
// Validate cause is one of the known PR_FIX_CAUSE values to reject
|
|
14059
|
-
// typos before scanning state
|
|
13733
|
+
// typos before scanning state.
|
|
14060
13734
|
const knownCauses = new Set(Object.values(shared.PR_FIX_CAUSE));
|
|
14061
13735
|
if (!knownCauses.has(cause)) {
|
|
14062
13736
|
return jsonReply(res, 400, { error: `unknown cause: ${cause}` });
|
|
14063
13737
|
}
|
|
14064
13738
|
reloadConfig();
|
|
14065
13739
|
const lifecycle = require('./engine/lifecycle');
|
|
14066
|
-
const
|
|
14067
|
-
...shared.getProjects(CONFIG).map(p => shared.projectPrPath(p)),
|
|
14068
|
-
shared.centralPullRequestsPath(MINIONS_DIR),
|
|
14069
|
-
];
|
|
13740
|
+
const prSources = [...shared.getProjects(CONFIG), 'central'];
|
|
14070
13741
|
let prFound = false;
|
|
14071
13742
|
let causeFound = false;
|
|
14072
|
-
for (const
|
|
13743
|
+
for (const source of prSources) {
|
|
14073
13744
|
if (causeFound) break;
|
|
14074
|
-
shared.mutatePullRequests(
|
|
13745
|
+
shared.mutatePullRequests(source, (prs) => {
|
|
14075
13746
|
if (!Array.isArray(prs)) return prs;
|
|
14076
13747
|
const target = prs.find(p => p && p.id === prId);
|
|
14077
13748
|
if (!target) return prs;
|
|
@@ -14094,7 +13765,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
14094
13765
|
// separately from `_noOpFixes` and so isn't reachable via the endpoint
|
|
14095
13766
|
// above. Once a fresh CI build is queued against a rebased/retried head,
|
|
14096
13767
|
// this lets an operator clear the stale pause without hand-editing
|
|
14097
|
-
//
|
|
13768
|
+
// tracked PR state.
|
|
14098
13769
|
{ 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
13770
|
const body = await readBody(req);
|
|
14100
13771
|
const prId = typeof body?.prId === 'string' ? body.prId.trim() : '';
|
|
@@ -14102,16 +13773,13 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
14102
13773
|
if (!prId) return jsonReply(res, 400, { error: 'prId required' });
|
|
14103
13774
|
reloadConfig();
|
|
14104
13775
|
const lifecycle = require('./engine/lifecycle');
|
|
14105
|
-
const
|
|
14106
|
-
...shared.getProjects(CONFIG).map(p => shared.projectPrPath(p)),
|
|
14107
|
-
shared.centralPullRequestsPath(MINIONS_DIR),
|
|
14108
|
-
];
|
|
13776
|
+
const prSources = [...shared.getProjects(CONFIG), 'central'];
|
|
14109
13777
|
let prFound = false;
|
|
14110
13778
|
let recordCleared = false;
|
|
14111
13779
|
let staleHead = false;
|
|
14112
|
-
for (const
|
|
13780
|
+
for (const source of prSources) {
|
|
14113
13781
|
if (recordCleared || staleHead) break;
|
|
14114
|
-
shared.mutatePullRequests(
|
|
13782
|
+
shared.mutatePullRequests(source, (prs) => {
|
|
14115
13783
|
if (!Array.isArray(prs)) return prs;
|
|
14116
13784
|
const target = prs.find(p => p && p.id === prId);
|
|
14117
13785
|
if (!target) return prs;
|
|
@@ -14150,15 +13818,12 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
14150
13818
|
if (!prId) return jsonReply(res, 400, { error: 'prId required' });
|
|
14151
13819
|
reloadConfig();
|
|
14152
13820
|
const lifecycle = require('./engine/lifecycle');
|
|
14153
|
-
const
|
|
14154
|
-
...shared.getProjects(CONFIG).map(p => shared.projectPrPath(p)),
|
|
14155
|
-
shared.centralPullRequestsPath(MINIONS_DIR),
|
|
14156
|
-
];
|
|
13821
|
+
const prSources = [...shared.getProjects(CONFIG), 'central'];
|
|
14157
13822
|
let prFound = false;
|
|
14158
13823
|
let recordCleared = false;
|
|
14159
|
-
for (const
|
|
13824
|
+
for (const source of prSources) {
|
|
14160
13825
|
if (recordCleared) break;
|
|
14161
|
-
shared.mutatePullRequests(
|
|
13826
|
+
shared.mutatePullRequests(source, (prs) => {
|
|
14162
13827
|
if (!Array.isArray(prs)) return prs;
|
|
14163
13828
|
const target = prs.find(p => p && p.id === prId);
|
|
14164
13829
|
if (!target) return prs;
|
|
@@ -14593,10 +14258,10 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
14593
14258
|
completeRun(body.id, run.runId, 'failed');
|
|
14594
14259
|
// Cancel pending/active work items and dispatches spawned by this run
|
|
14595
14260
|
let cancelled = 0;
|
|
14596
|
-
const
|
|
14597
|
-
for (const
|
|
14261
|
+
const workItemScopes = ['central', ...PROJECTS];
|
|
14262
|
+
for (const scope of workItemScopes) {
|
|
14598
14263
|
try {
|
|
14599
|
-
mutateWorkItems(
|
|
14264
|
+
mutateWorkItems(scope, items => {
|
|
14600
14265
|
for (const w of items) {
|
|
14601
14266
|
if (w._pipelineRun === run.runId && w.status !== shared.WI_STATUS.DONE && w.status !== shared.WI_STATUS.CANCELLED) {
|
|
14602
14267
|
w.status = shared.WI_STATUS.CANCELLED;
|
|
@@ -14971,6 +14636,7 @@ module.exports = {
|
|
|
14971
14636
|
_findDuplicateWorkItemCreate: findDuplicateWorkItemCreate,
|
|
14972
14637
|
_createWorkItemWithDedup: createWorkItemWithDedup,
|
|
14973
14638
|
_resolveWorkItemsCreateTarget: resolveWorkItemsCreateTarget,
|
|
14639
|
+
_matchesResolvedWorkItemDispatch: matchesResolvedWorkItemDispatch, // exported for testing
|
|
14974
14640
|
_validatePrFollowupShape: validatePrFollowupShape,
|
|
14975
14641
|
_findExistingFollowupForComment: findExistingFollowupForComment,
|
|
14976
14642
|
_extractMinionsAgentHeader: extractMinionsAgentHeader,
|
|
@@ -14984,6 +14650,7 @@ module.exports = {
|
|
|
14984
14650
|
buildCCStateRefresh,
|
|
14985
14651
|
_resetRefreshCache,
|
|
14986
14652
|
_routesAsMeta,
|
|
14653
|
+
_buildConstellationBridgeSnapshot,
|
|
14987
14654
|
_server: server,
|
|
14988
14655
|
_buildTranscriptCarryover,
|
|
14989
14656
|
_transcriptHasCarryoverContext,
|