instar 0.28.95 → 0.28.97
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dashboard/index.html +249 -2
- package/dist/commands/server.d.ts.map +1 -1
- package/dist/commands/server.js +90 -1
- package/dist/commands/server.js.map +1 -1
- package/dist/core/MachineHeartbeat.d.ts +94 -0
- package/dist/core/MachineHeartbeat.d.ts.map +1 -0
- package/dist/core/MachineHeartbeat.js +155 -0
- package/dist/core/MachineHeartbeat.js.map +1 -0
- package/dist/core/ProjectAutoAdvancePoller.d.ts +107 -0
- package/dist/core/ProjectAutoAdvancePoller.d.ts.map +1 -0
- package/dist/core/ProjectAutoAdvancePoller.js +176 -0
- package/dist/core/ProjectAutoAdvancePoller.js.map +1 -0
- package/dist/core/ProjectRoundCompleteMessage.d.ts +158 -0
- package/dist/core/ProjectRoundCompleteMessage.d.ts.map +1 -0
- package/dist/core/ProjectRoundCompleteMessage.js +253 -0
- package/dist/core/ProjectRoundCompleteMessage.js.map +1 -0
- package/dist/core/ProjectRoundExecution.d.ts +111 -0
- package/dist/core/ProjectRoundExecution.d.ts.map +1 -0
- package/dist/core/ProjectRoundExecution.js +359 -0
- package/dist/core/ProjectRoundExecution.js.map +1 -0
- package/dist/core/ProjectRoundLock.d.ts +67 -0
- package/dist/core/ProjectRoundLock.d.ts.map +1 -0
- package/dist/core/ProjectRoundLock.js +161 -0
- package/dist/core/ProjectRoundLock.js.map +1 -0
- package/dist/core/ProjectRoundRunner.d.ts +146 -0
- package/dist/core/ProjectRoundRunner.d.ts.map +1 -0
- package/dist/core/ProjectRoundRunner.js +371 -0
- package/dist/core/ProjectRoundRunner.js.map +1 -0
- package/dist/core/ProjectRoundWorktrees.d.ts +57 -0
- package/dist/core/ProjectRoundWorktrees.d.ts.map +1 -0
- package/dist/core/ProjectRoundWorktrees.js +111 -0
- package/dist/core/ProjectRoundWorktrees.js.map +1 -0
- package/dist/server/AgentServer.d.ts +12 -0
- package/dist/server/AgentServer.d.ts.map +1 -1
- package/dist/server/AgentServer.js +3 -0
- package/dist/server/AgentServer.js.map +1 -1
- package/dist/server/routes.d.ts +19 -0
- package/dist/server/routes.d.ts.map +1 -1
- package/dist/server/routes.js +782 -7
- package/dist/server/routes.js.map +1 -1
- package/package.json +1 -1
- package/src/data/builtin-manifest.json +48 -48
- package/upgrades/0.28.96.md +347 -0
- package/upgrades/0.28.97.md +146 -0
- package/upgrades/side-effects/project-scope-phase1-7-skill-surface.md +175 -0
- package/upgrades/side-effects/project-scope-phase1b-connect-the-dots.md +184 -0
- package/upgrades/side-effects/project-scope-phase1b-pr3.md +228 -0
- package/upgrades/side-effects/project-scope-phase1b-pr4.md +198 -0
- package/upgrades/side-effects/project-scope-phase1b-pr5.md +150 -0
- package/upgrades/side-effects/project-scope-phase1b-pr6.md +136 -0
- package/upgrades/side-effects/project-scope-phase1b-pr7.md +175 -0
package/dist/server/routes.js
CHANGED
|
@@ -22,6 +22,7 @@ import { execFile as execFileCb } from 'node:child_process';
|
|
|
22
22
|
import { promisify } from 'node:util';
|
|
23
23
|
import { SafeGitExecutor } from '../core/SafeGitExecutor.js';
|
|
24
24
|
import { SafeFsExecutor } from '../core/SafeFsExecutor.js';
|
|
25
|
+
import { validateStageTransition } from '../core/StageTransitionValidator.js';
|
|
25
26
|
const execFile = promisify(execFileCb);
|
|
26
27
|
/**
|
|
27
28
|
* Per-session continue-ceiling (spec § (b) Outcomes).
|
|
@@ -74,6 +75,7 @@ async function postVerifyEvidence(projectDir, evidence, pointer) {
|
|
|
74
75
|
}
|
|
75
76
|
import { ReflectionMetrics } from '../monitoring/ReflectionMetrics.js';
|
|
76
77
|
import { HomeostasisMonitor } from '../monitoring/HomeostasisMonitor.js';
|
|
78
|
+
import { verifyMergedItemsViaGit } from '../core/ProjectRoundExecution.js';
|
|
77
79
|
import { ProcessIntegrity } from '../core/ProcessIntegrity.js';
|
|
78
80
|
import { getOutboundQueueStatus, cleanupDeliveredOutbound, buildAgentList } from '../messaging/GitSyncTransport.js';
|
|
79
81
|
import { verifyAgentToken, getAgentToken } from '../messaging/AgentTokenManager.js';
|
|
@@ -494,6 +496,27 @@ function pickFields(obj, fields) {
|
|
|
494
496
|
}
|
|
495
497
|
return out;
|
|
496
498
|
}
|
|
499
|
+
/** Maps a /projects/:id/next action verb to the suggested skill invocation.
|
|
500
|
+
* Names are the canonical command set from PROJECT-SCOPE-SPEC § Phase 1.7. */
|
|
501
|
+
function skillCommandForAction(action, projectId, roundIndex) {
|
|
502
|
+
switch (action) {
|
|
503
|
+
case 'await-user-approval':
|
|
504
|
+
return `/project ack ${projectId}`;
|
|
505
|
+
case 'ack-required':
|
|
506
|
+
return `/project ack ${projectId}`;
|
|
507
|
+
case 'resolve-conflict':
|
|
508
|
+
return `/project resolve-conflict ${projectId}`;
|
|
509
|
+
case 'accept-partial':
|
|
510
|
+
return `/project accept-partial ${projectId} ${roundIndex}`;
|
|
511
|
+
case 'run-spec-converge':
|
|
512
|
+
return `/spec-converge`;
|
|
513
|
+
case 'run-drift-check':
|
|
514
|
+
return `/project drift ${projectId} ${roundIndex}`;
|
|
515
|
+
case 'start-round':
|
|
516
|
+
default:
|
|
517
|
+
return `/project run-round ${projectId} ${roundIndex}`;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
497
520
|
export function createRoutes(ctx) {
|
|
498
521
|
const router = Router();
|
|
499
522
|
// ── PR-REVIEW-HARDENING kill-switch (Phase A) ─────────────────────
|
|
@@ -4982,9 +5005,21 @@ export function createRoutes(ctx) {
|
|
|
4982
5005
|
return;
|
|
4983
5006
|
}
|
|
4984
5007
|
const status = typeof req.query.status === 'string' ? req.query.status : undefined;
|
|
4985
|
-
|
|
5008
|
+
// Phase 1b PR 5 — server-side filters added so the dashboard
|
|
5009
|
+
// Initiatives tab can hide project-kind records (rendered in the
|
|
5010
|
+
// separate Projects tab) and any record that's a child of a
|
|
5011
|
+
// project (rendered inside its parent's card).
|
|
5012
|
+
const excludeKind = typeof req.query.excludeKind === 'string' ? req.query.excludeKind : undefined;
|
|
5013
|
+
const excludeParented = req.query.excludeParented === 'true';
|
|
5014
|
+
let items = status
|
|
4986
5015
|
? ctx.initiativeTracker.list({ status: status })
|
|
4987
5016
|
: ctx.initiativeTracker.list();
|
|
5017
|
+
if (excludeKind) {
|
|
5018
|
+
items = items.filter((i) => (i.kind ?? 'task') !== excludeKind);
|
|
5019
|
+
}
|
|
5020
|
+
if (excludeParented) {
|
|
5021
|
+
items = items.filter((i) => !i.parentProjectId);
|
|
5022
|
+
}
|
|
4988
5023
|
res.json({ items, count: items.length });
|
|
4989
5024
|
});
|
|
4990
5025
|
router.get('/initiatives/digest', (_req, res) => {
|
|
@@ -5109,7 +5144,7 @@ export function createRoutes(ctx) {
|
|
|
5109
5144
|
const items = ctx.initiativeTracker.list({ kind: 'project' });
|
|
5110
5145
|
res.json({ items, count: items.length });
|
|
5111
5146
|
});
|
|
5112
|
-
router.get('/projects/:id', (req, res) => {
|
|
5147
|
+
router.get('/projects/:id', async (req, res) => {
|
|
5113
5148
|
if (!ctx.initiativeTracker) {
|
|
5114
5149
|
res.status(503).json({ error: 'Initiative tracker not configured' });
|
|
5115
5150
|
return;
|
|
@@ -5124,9 +5159,105 @@ export function createRoutes(ctx) {
|
|
|
5124
5159
|
return;
|
|
5125
5160
|
}
|
|
5126
5161
|
// Join children that point at this project via parentProjectId.
|
|
5127
|
-
|
|
5162
|
+
let children = ctx.initiativeTracker
|
|
5128
5163
|
.list()
|
|
5129
5164
|
.filter((i) => i.parentProjectId === project.id);
|
|
5165
|
+
// Lazy merged-state reconciler — spec § Phase 1.4 lines 256-258.
|
|
5166
|
+
//
|
|
5167
|
+
// GET /projects/:id is documented as "may mutate": when a 'building' child's
|
|
5168
|
+
// mergeCommitOid is no longer ancestor of origin/main, we transition it to
|
|
5169
|
+
// 'regressed' and clear future autoAdvanceAt on its round.
|
|
5170
|
+
//
|
|
5171
|
+
// Selection contract:
|
|
5172
|
+
// - debounce: skip any child with `ciCheckedAt < 6h ago`
|
|
5173
|
+
// - cap: at most 3 child-revalidations per GET
|
|
5174
|
+
// - order: oldest `ciCheckedAt` first (treat undefined as 1970), ties broken
|
|
5175
|
+
// by `roundIndex` ASC, then `itemId` ASC — no child can starve.
|
|
5176
|
+
// - opt-out: `?reconcile=false` (or `?reconcile=0`) skips entirely
|
|
5177
|
+
const reconcileFlag = req.query.reconcile;
|
|
5178
|
+
const wantReconcile = reconcileFlag !== 'false' && reconcileFlag !== '0';
|
|
5179
|
+
if (wantReconcile && project.targetRepoPath) {
|
|
5180
|
+
const nowMs = Date.now();
|
|
5181
|
+
const debounceMs = 6 * 60 * 60 * 1000; // 6 hours
|
|
5182
|
+
const roundIndexOf = (childId) => {
|
|
5183
|
+
const rounds = project.rounds ?? [];
|
|
5184
|
+
for (let i = 0; i < rounds.length; i++) {
|
|
5185
|
+
if ((rounds[i].itemIds ?? []).includes(childId))
|
|
5186
|
+
return i;
|
|
5187
|
+
}
|
|
5188
|
+
return Number.MAX_SAFE_INTEGER;
|
|
5189
|
+
};
|
|
5190
|
+
const candidates = children
|
|
5191
|
+
.filter((c) => c.pipelineStage === 'building' && typeof c.mergeCommitOid === 'string' && c.mergeCommitOid)
|
|
5192
|
+
.filter((c) => {
|
|
5193
|
+
const t = c.ciCheckedAt ? Date.parse(c.ciCheckedAt) : 0;
|
|
5194
|
+
return !(Number.isFinite(t) && t > 0 && nowMs - t < debounceMs);
|
|
5195
|
+
})
|
|
5196
|
+
.sort((a, b) => {
|
|
5197
|
+
const ta = a.ciCheckedAt ? Date.parse(a.ciCheckedAt) : 0;
|
|
5198
|
+
const tb = b.ciCheckedAt ? Date.parse(b.ciCheckedAt) : 0;
|
|
5199
|
+
if (ta !== tb)
|
|
5200
|
+
return ta - tb;
|
|
5201
|
+
const ra = roundIndexOf(a.id);
|
|
5202
|
+
const rb = roundIndexOf(b.id);
|
|
5203
|
+
if (ra !== rb)
|
|
5204
|
+
return ra - rb;
|
|
5205
|
+
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
|
5206
|
+
})
|
|
5207
|
+
.slice(0, 3);
|
|
5208
|
+
const candidateIds = candidates.map((c) => c.id);
|
|
5209
|
+
if (candidateIds.length > 0) {
|
|
5210
|
+
let verified = new Set();
|
|
5211
|
+
try {
|
|
5212
|
+
verified = await verifyMergedItemsViaGit(project.targetRepoPath, candidateIds, ctx.initiativeTracker);
|
|
5213
|
+
}
|
|
5214
|
+
catch {
|
|
5215
|
+
// git not available, no network, etc. — leave verified empty so we
|
|
5216
|
+
// still update ciCheckedAt to back off; better than a hot loop.
|
|
5217
|
+
}
|
|
5218
|
+
let touched = false;
|
|
5219
|
+
for (const cand of candidates) {
|
|
5220
|
+
const child = ctx.initiativeTracker.get(cand.id);
|
|
5221
|
+
if (!child)
|
|
5222
|
+
continue;
|
|
5223
|
+
const isMerged = verified.has(cand.id);
|
|
5224
|
+
try {
|
|
5225
|
+
await ctx.initiativeTracker.update(cand.id, {
|
|
5226
|
+
pipelineStage: isMerged ? 'merged' : 'regressed',
|
|
5227
|
+
ciCheckedAt: new Date(nowMs).toISOString(),
|
|
5228
|
+
ifMatch: child.version,
|
|
5229
|
+
});
|
|
5230
|
+
touched = true;
|
|
5231
|
+
if (!isMerged) {
|
|
5232
|
+
// On regression: clear future autoAdvanceAt on the child's round
|
|
5233
|
+
// so the next round doesn't auto-fire while this one is broken.
|
|
5234
|
+
const projAfter = ctx.initiativeTracker.get(project.id);
|
|
5235
|
+
const rIdx = roundIndexOf(cand.id);
|
|
5236
|
+
if (projAfter && rIdx < (projAfter.rounds ?? []).length) {
|
|
5237
|
+
const newRounds = (projAfter.rounds ?? []).map((r, i) => i > rIdx ? { ...r, autoAdvanceAt: undefined } : r);
|
|
5238
|
+
try {
|
|
5239
|
+
await ctx.initiativeTracker.update(project.id, {
|
|
5240
|
+
rounds: newRounds,
|
|
5241
|
+
ifMatch: projAfter.version,
|
|
5242
|
+
});
|
|
5243
|
+
}
|
|
5244
|
+
catch {
|
|
5245
|
+
// OCC race; next GET will retry.
|
|
5246
|
+
}
|
|
5247
|
+
}
|
|
5248
|
+
}
|
|
5249
|
+
}
|
|
5250
|
+
catch {
|
|
5251
|
+
// OCC race or validator reject; leave as-is for next GET.
|
|
5252
|
+
}
|
|
5253
|
+
}
|
|
5254
|
+
if (touched) {
|
|
5255
|
+
children = ctx.initiativeTracker
|
|
5256
|
+
.list()
|
|
5257
|
+
.filter((i) => i.parentProjectId === project.id);
|
|
5258
|
+
}
|
|
5259
|
+
}
|
|
5260
|
+
}
|
|
5130
5261
|
// Optional field selector: `?fields=id,title,pipelineStage`. Used by
|
|
5131
5262
|
// the dashboard projects selector (Phase 1.10). Always preserves `id`.
|
|
5132
5263
|
const fieldsParam = typeof req.query.fields === 'string' ? req.query.fields : '';
|
|
@@ -5143,17 +5274,661 @@ export function createRoutes(ctx) {
|
|
|
5143
5274
|
}
|
|
5144
5275
|
res.json({ project, children });
|
|
5145
5276
|
});
|
|
5277
|
+
// GET /projects/:id/next — structured next-action payload.
|
|
5278
|
+
//
|
|
5279
|
+
// Spec § Phase 1.5 (line 268): returns `{ action, params, estimatedCost?,
|
|
5280
|
+
// skillCommand? }` for the FIRST round whose status is not 'complete'.
|
|
5281
|
+
// Ordering: roundIndex ASC, then pipelineStage ASC, then itemId ASC.
|
|
5282
|
+
//
|
|
5283
|
+
// Action verbs the spec enumerates (a non-exhaustive contract):
|
|
5284
|
+
// - 'await-user-approval' — first round needs `firstLaunchAckAt`
|
|
5285
|
+
// - 'ack-required' — unacknowledgedAdvanceCount >= cap
|
|
5286
|
+
// - 'resolve-conflict' — `awaitingReconciliation` non-empty
|
|
5287
|
+
// - 'accept-partial' — round is partially-complete
|
|
5288
|
+
// - 'run-spec-converge' — at least one item is `spec-drafted`
|
|
5289
|
+
// - 'run-drift-check' — at least one item is `approved`, no fresh
|
|
5290
|
+
// drift verdict
|
|
5291
|
+
// - 'start-round' — all preconditions met, ready to fire
|
|
5292
|
+
//
|
|
5293
|
+
// The endpoint does NOT run the runner's preflight — that's a heavier
|
|
5294
|
+
// check at fire time. The action is a hint for the dashboard + skill UI.
|
|
5146
5295
|
router.get('/projects/:id/next', (req, res) => {
|
|
5296
|
+
if (!ctx.initiativeTracker) {
|
|
5297
|
+
res.status(503).json({ error: 'Initiative tracker not configured' });
|
|
5298
|
+
return;
|
|
5299
|
+
}
|
|
5147
5300
|
if (!initiativeIdRe.test(req.params.id)) {
|
|
5148
5301
|
res.status(400).json({ error: 'invalid project id' });
|
|
5149
5302
|
return;
|
|
5150
5303
|
}
|
|
5151
|
-
|
|
5152
|
-
|
|
5153
|
-
|
|
5154
|
-
|
|
5304
|
+
const project = ctx.initiativeTracker.get(req.params.id);
|
|
5305
|
+
if (!project || (project.kind ?? 'task') !== 'project') {
|
|
5306
|
+
res.status(404).json({ error: 'project not found' });
|
|
5307
|
+
return;
|
|
5308
|
+
}
|
|
5309
|
+
const rounds = project.rounds ?? [];
|
|
5310
|
+
const idx = rounds.findIndex((r) => (r.status ?? 'pending') !== 'complete');
|
|
5311
|
+
if (idx === -1) {
|
|
5312
|
+
res.status(204).end();
|
|
5313
|
+
return;
|
|
5314
|
+
}
|
|
5315
|
+
const r = rounds[idx];
|
|
5316
|
+
let action = 'start-round';
|
|
5317
|
+
const childrenById = new Map(ctx.initiativeTracker
|
|
5318
|
+
.list()
|
|
5319
|
+
.filter((i) => i.parentProjectId === project.id)
|
|
5320
|
+
.map((c) => [c.id, c]));
|
|
5321
|
+
const itemsForRound = (r.itemIds ?? []).map((id) => childrenById.get(id)).filter(Boolean);
|
|
5322
|
+
if ((project.awaitingReconciliation ?? []).length > 0) {
|
|
5323
|
+
action = 'resolve-conflict';
|
|
5324
|
+
}
|
|
5325
|
+
else if ((r.status ?? 'pending') === 'partially-complete') {
|
|
5326
|
+
action = 'accept-partial';
|
|
5327
|
+
}
|
|
5328
|
+
else if (idx === 0 && !project.firstLaunchAckAt) {
|
|
5329
|
+
action = 'await-user-approval';
|
|
5330
|
+
}
|
|
5331
|
+
else if ((project.unacknowledgedAdvanceCount ?? 0) >= 2) {
|
|
5332
|
+
action = 'ack-required';
|
|
5333
|
+
}
|
|
5334
|
+
else if (itemsForRound.some((c) => c?.pipelineStage === 'spec-drafted')) {
|
|
5335
|
+
action = 'run-spec-converge';
|
|
5336
|
+
}
|
|
5337
|
+
else if (itemsForRound.some((c) => c?.pipelineStage === 'approved' && !r.lastDriftVerdict)) {
|
|
5338
|
+
action = 'run-drift-check';
|
|
5339
|
+
}
|
|
5340
|
+
res.json({
|
|
5341
|
+
action,
|
|
5342
|
+
params: {
|
|
5343
|
+
projectId: project.id,
|
|
5344
|
+
projectVersion: project.version,
|
|
5345
|
+
roundIndex: idx,
|
|
5346
|
+
name: r.name,
|
|
5347
|
+
itemIds: r.itemIds,
|
|
5348
|
+
status: r.status ?? 'pending',
|
|
5349
|
+
autoAdvanceAt: r.autoAdvanceAt,
|
|
5350
|
+
},
|
|
5351
|
+
skillCommand: skillCommandForAction(action, project.id, idx),
|
|
5155
5352
|
});
|
|
5156
5353
|
});
|
|
5354
|
+
// ── /projects/:id mutating routes (Phase 1b PR 3) ──────────────
|
|
5355
|
+
//
|
|
5356
|
+
// All mutating endpoints require `If-Match: <version>` (OCC). Missing
|
|
5357
|
+
// header → 428. Stale version → 409. Spec § Phase 1.3.
|
|
5358
|
+
//
|
|
5359
|
+
// /advance is a single-item stage transition driven by StageTransitionValidator.
|
|
5360
|
+
// /halt, /ack, /accept-partial route through ProjectRoundRunner.
|
|
5361
|
+
function parseIfMatch(req, res) {
|
|
5362
|
+
const header = req.headers['if-match'];
|
|
5363
|
+
if (typeof header !== 'string' || !header.trim()) {
|
|
5364
|
+
res.status(428).json({ error: 'If-Match header required' });
|
|
5365
|
+
return null;
|
|
5366
|
+
}
|
|
5367
|
+
const n = parseInt(header.replace(/"/g, ''), 10);
|
|
5368
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
5369
|
+
res.status(400).json({ error: 'If-Match must be a positive integer version' });
|
|
5370
|
+
return null;
|
|
5371
|
+
}
|
|
5372
|
+
return n;
|
|
5373
|
+
}
|
|
5374
|
+
function lookupProject(req, res) {
|
|
5375
|
+
if (!ctx.initiativeTracker) {
|
|
5376
|
+
res.status(503).json({ error: 'Initiative tracker not configured' });
|
|
5377
|
+
return null;
|
|
5378
|
+
}
|
|
5379
|
+
if (!initiativeIdRe.test(req.params.id)) {
|
|
5380
|
+
res.status(400).json({ error: 'invalid project id' });
|
|
5381
|
+
return null;
|
|
5382
|
+
}
|
|
5383
|
+
const project = ctx.initiativeTracker.get(req.params.id);
|
|
5384
|
+
if (!project || (project.kind ?? 'task') !== 'project') {
|
|
5385
|
+
res.status(404).json({ error: 'project not found' });
|
|
5386
|
+
return null;
|
|
5387
|
+
}
|
|
5388
|
+
return project;
|
|
5389
|
+
}
|
|
5390
|
+
// POST /projects/:id/advance — advance one child item by one stage.
|
|
5391
|
+
//
|
|
5392
|
+
// Body shape:
|
|
5393
|
+
// {
|
|
5394
|
+
// "itemId": "<child id>",
|
|
5395
|
+
// "fromStage": "<optional explicit current stage>",
|
|
5396
|
+
// "targetStage": "spec-drafted|spec-converged|approved|building|merged|skipped|outline",
|
|
5397
|
+
// "artifact": {
|
|
5398
|
+
// "specPath"?: "...", // outline → spec-drafted
|
|
5399
|
+
// "prNumber"?: 123, // building → merged
|
|
5400
|
+
// "taskFlowRecordId"?: "...", // approved → building
|
|
5401
|
+
// "skippedReason"?: "...", // any → skipped
|
|
5402
|
+
// "skippedBy"?: "...", // any → skipped
|
|
5403
|
+
// "unskippedAt"?: "..." // skipped → outline
|
|
5404
|
+
// }
|
|
5405
|
+
// }
|
|
5406
|
+
//
|
|
5407
|
+
// The HTTP layer does NOT enforce gates the ProjectRoundRunner already
|
|
5408
|
+
// enforces — `/advance` is a single-item transition that operates on
|
|
5409
|
+
// child records, not on round status. Round-level invariants (lock,
|
|
5410
|
+
// first-launch ack, owner machine, etc.) apply only to /run-round
|
|
5411
|
+
// and to the autonomous loop, both of which ship in a later PR.
|
|
5412
|
+
router.post('/projects/:id/advance', async (req, res) => {
|
|
5413
|
+
const project = lookupProject(req, res);
|
|
5414
|
+
if (!project)
|
|
5415
|
+
return;
|
|
5416
|
+
if (!ctx.initiativeTracker)
|
|
5417
|
+
return; // narrow for TS
|
|
5418
|
+
const ifMatch = parseIfMatch(req, res);
|
|
5419
|
+
if (ifMatch === null)
|
|
5420
|
+
return;
|
|
5421
|
+
if (ifMatch !== project.version) {
|
|
5422
|
+
res.status(409).json({ error: 'version mismatch', currentVersion: project.version });
|
|
5423
|
+
return;
|
|
5424
|
+
}
|
|
5425
|
+
const body = (req.body ?? {});
|
|
5426
|
+
const itemId = typeof body.itemId === 'string' ? body.itemId : '';
|
|
5427
|
+
const targetStage = typeof body.targetStage === 'string' ? body.targetStage : null;
|
|
5428
|
+
if (!itemId) {
|
|
5429
|
+
res.status(400).json({ error: '"itemId" required' });
|
|
5430
|
+
return;
|
|
5431
|
+
}
|
|
5432
|
+
if (!targetStage) {
|
|
5433
|
+
res.status(400).json({ error: '"targetStage" required' });
|
|
5434
|
+
return;
|
|
5435
|
+
}
|
|
5436
|
+
const child = ctx.initiativeTracker.get(itemId);
|
|
5437
|
+
if (!child || child.parentProjectId !== project.id) {
|
|
5438
|
+
res.status(404).json({ error: `child item "${itemId}" not found under project ${project.id}` });
|
|
5439
|
+
return;
|
|
5440
|
+
}
|
|
5441
|
+
if (!project.targetRepoPath) {
|
|
5442
|
+
res.status(400).json({ error: 'project has no targetRepoPath; cannot validate artifacts' });
|
|
5443
|
+
return;
|
|
5444
|
+
}
|
|
5445
|
+
const artifact = body.artifact ?? {};
|
|
5446
|
+
const validationCtx = {
|
|
5447
|
+
targetRepoPath: project.targetRepoPath,
|
|
5448
|
+
specPath: typeof artifact.specPath === 'string' ? artifact.specPath : undefined,
|
|
5449
|
+
prNumber: typeof artifact.prNumber === 'number' ? artifact.prNumber : undefined,
|
|
5450
|
+
taskFlowRecordId: typeof artifact.taskFlowRecordId === 'string' ? artifact.taskFlowRecordId : undefined,
|
|
5451
|
+
skippedReason: typeof artifact.skippedReason === 'string' ? artifact.skippedReason : undefined,
|
|
5452
|
+
skippedBy: typeof artifact.skippedBy === 'string' ? artifact.skippedBy : undefined,
|
|
5453
|
+
unskippedAt: typeof artifact.unskippedAt === 'string' ? artifact.unskippedAt : undefined,
|
|
5454
|
+
};
|
|
5455
|
+
const fromStage = typeof body.fromStage === 'string'
|
|
5456
|
+
? body.fromStage
|
|
5457
|
+
: child.pipelineStage;
|
|
5458
|
+
const result = await validateStageTransition(fromStage, targetStage, validationCtx);
|
|
5459
|
+
if (!result.ok) {
|
|
5460
|
+
res.status(409).json({ error: 'stage transition rejected', code: result.code, reason: result.reason });
|
|
5461
|
+
return;
|
|
5462
|
+
}
|
|
5463
|
+
try {
|
|
5464
|
+
const updated = await ctx.initiativeTracker.update(itemId, {
|
|
5465
|
+
pipelineStage: targetStage,
|
|
5466
|
+
ifMatch: child.version,
|
|
5467
|
+
});
|
|
5468
|
+
// Bump the project version too so concurrent advance calls don't
|
|
5469
|
+
// operate on a stale view. We touch description ("last advance
|
|
5470
|
+
// <timestamp>") to force a meaningful update.
|
|
5471
|
+
const refreshed = await ctx.initiativeTracker.update(project.id, {
|
|
5472
|
+
// No-op-but-bump: nudge `lastTouchedAt` indirectly via update.
|
|
5473
|
+
nextCheckAt: project.nextCheckAt,
|
|
5474
|
+
ifMatch: project.version,
|
|
5475
|
+
});
|
|
5476
|
+
res.json({
|
|
5477
|
+
item: { id: updated.id, pipelineStage: updated.pipelineStage, version: updated.version },
|
|
5478
|
+
project: { id: refreshed.id, version: refreshed.version },
|
|
5479
|
+
});
|
|
5480
|
+
}
|
|
5481
|
+
catch (err) {
|
|
5482
|
+
if (err instanceof Error && err.name === 'OccVersionMismatchError') {
|
|
5483
|
+
const cv = err.currentVersion;
|
|
5484
|
+
res.status(409).json({ error: 'version mismatch', currentVersion: cv });
|
|
5485
|
+
return;
|
|
5486
|
+
}
|
|
5487
|
+
res.status(400).json({ error: err instanceof Error ? err.message : String(err) });
|
|
5488
|
+
}
|
|
5489
|
+
});
|
|
5490
|
+
// POST /projects/:id/halt — kills the active round. Idempotent.
|
|
5491
|
+
router.post('/projects/:id/halt', async (req, res) => {
|
|
5492
|
+
const project = lookupProject(req, res);
|
|
5493
|
+
if (!project)
|
|
5494
|
+
return;
|
|
5495
|
+
if (!ctx.projectRoundRunner) {
|
|
5496
|
+
res.status(503).json({ error: 'ProjectRoundRunner not configured' });
|
|
5497
|
+
return;
|
|
5498
|
+
}
|
|
5499
|
+
const reason = typeof (req.body ?? {}).reason === 'string' ? req.body.reason : 'no reason given';
|
|
5500
|
+
try {
|
|
5501
|
+
const result = await ctx.projectRoundRunner.halt(project.id, reason);
|
|
5502
|
+
if (!result) {
|
|
5503
|
+
res.status(409).json({ error: 'project has no halt-able round' });
|
|
5504
|
+
return;
|
|
5505
|
+
}
|
|
5506
|
+
res.json({ id: result.project.id, roundIndex: result.roundIndex, version: result.project.version });
|
|
5507
|
+
}
|
|
5508
|
+
catch (err) {
|
|
5509
|
+
res.status(400).json({ error: err instanceof Error ? err.message : String(err) });
|
|
5510
|
+
}
|
|
5511
|
+
});
|
|
5512
|
+
// POST /projects/:id/ack — record user acknowledgment.
|
|
5513
|
+
router.post('/projects/:id/ack', async (req, res) => {
|
|
5514
|
+
const project = lookupProject(req, res);
|
|
5515
|
+
if (!project)
|
|
5516
|
+
return;
|
|
5517
|
+
if (!ctx.projectRoundRunner) {
|
|
5518
|
+
res.status(503).json({ error: 'ProjectRoundRunner not configured' });
|
|
5519
|
+
return;
|
|
5520
|
+
}
|
|
5521
|
+
const body = (req.body ?? {});
|
|
5522
|
+
const idx = typeof body.forRoundIndex === 'number' ? body.forRoundIndex : (typeof body.roundIndex === 'number' ? body.roundIndex : 0);
|
|
5523
|
+
if (!Number.isInteger(idx) || idx < 0) {
|
|
5524
|
+
res.status(400).json({ error: '"forRoundIndex" must be a non-negative integer' });
|
|
5525
|
+
return;
|
|
5526
|
+
}
|
|
5527
|
+
try {
|
|
5528
|
+
const updated = await ctx.projectRoundRunner.recordAck(project.id, idx);
|
|
5529
|
+
if (!updated) {
|
|
5530
|
+
res.status(404).json({ error: 'project not found' });
|
|
5531
|
+
return;
|
|
5532
|
+
}
|
|
5533
|
+
res.json({
|
|
5534
|
+
id: updated.id,
|
|
5535
|
+
firstLaunchAckAt: updated.firstLaunchAckAt,
|
|
5536
|
+
lastAckedRoundIndex: updated.lastAckedRoundIndex,
|
|
5537
|
+
unacknowledgedAdvanceCount: updated.unacknowledgedAdvanceCount,
|
|
5538
|
+
version: updated.version,
|
|
5539
|
+
});
|
|
5540
|
+
}
|
|
5541
|
+
catch (err) {
|
|
5542
|
+
if (err instanceof Error && err.name === 'OccVersionMismatchError') {
|
|
5543
|
+
const cv = err.currentVersion;
|
|
5544
|
+
res.status(409).json({ error: 'version mismatch', currentVersion: cv });
|
|
5545
|
+
return;
|
|
5546
|
+
}
|
|
5547
|
+
res.status(400).json({ error: err instanceof Error ? err.message : String(err) });
|
|
5548
|
+
}
|
|
5549
|
+
});
|
|
5550
|
+
// POST /projects/:id/accept-partial — close a partially-complete round.
|
|
5551
|
+
router.post('/projects/:id/accept-partial', async (req, res) => {
|
|
5552
|
+
const project = lookupProject(req, res);
|
|
5553
|
+
if (!project)
|
|
5554
|
+
return;
|
|
5555
|
+
if (!ctx.projectRoundRunner) {
|
|
5556
|
+
res.status(503).json({ error: 'ProjectRoundRunner not configured' });
|
|
5557
|
+
return;
|
|
5558
|
+
}
|
|
5559
|
+
const body = (req.body ?? {});
|
|
5560
|
+
const idx = typeof body.roundIndex === 'number' ? body.roundIndex : -1;
|
|
5561
|
+
const reason = typeof body.reason === 'string' ? body.reason : '';
|
|
5562
|
+
const skippedBy = typeof body.skippedBy === 'string' ? body.skippedBy : '';
|
|
5563
|
+
if (!Number.isInteger(idx) || idx < 0) {
|
|
5564
|
+
res.status(400).json({ error: '"roundIndex" must be a non-negative integer' });
|
|
5565
|
+
return;
|
|
5566
|
+
}
|
|
5567
|
+
if (!reason.trim()) {
|
|
5568
|
+
res.status(400).json({ error: '"reason" required' });
|
|
5569
|
+
return;
|
|
5570
|
+
}
|
|
5571
|
+
if (!skippedBy.trim()) {
|
|
5572
|
+
res.status(400).json({ error: '"skippedBy" required' });
|
|
5573
|
+
return;
|
|
5574
|
+
}
|
|
5575
|
+
try {
|
|
5576
|
+
const result = await ctx.projectRoundRunner.acceptPartial(project.id, idx, reason, skippedBy);
|
|
5577
|
+
if (!result) {
|
|
5578
|
+
res.status(404).json({ error: 'project or round not found' });
|
|
5579
|
+
return;
|
|
5580
|
+
}
|
|
5581
|
+
res.json({
|
|
5582
|
+
id: result.project.id,
|
|
5583
|
+
skippedItemIds: result.skippedItemIds,
|
|
5584
|
+
version: result.project.version,
|
|
5585
|
+
});
|
|
5586
|
+
}
|
|
5587
|
+
catch (err) {
|
|
5588
|
+
if (err instanceof Error && err.name === 'OccVersionMismatchError') {
|
|
5589
|
+
const cv = err.currentVersion;
|
|
5590
|
+
res.status(409).json({ error: 'version mismatch', currentVersion: cv });
|
|
5591
|
+
return;
|
|
5592
|
+
}
|
|
5593
|
+
res.status(400).json({ error: err instanceof Error ? err.message : String(err) });
|
|
5594
|
+
}
|
|
5595
|
+
});
|
|
5596
|
+
// POST /projects/:id/claim-ownership — multi-machine ownership transfer.
|
|
5597
|
+
//
|
|
5598
|
+
// Body: {} or { force?: boolean }
|
|
5599
|
+
// Header: If-Match: <version> (OCC)
|
|
5600
|
+
//
|
|
5601
|
+
// Spec § P5: the claimer must (a) commit-and-push the claim before
|
|
5602
|
+
// acting on it, (b) wait 60s for git-sync to converge, (c) re-read
|
|
5603
|
+
// before any auto-action. This endpoint ONLY records the ownership
|
|
5604
|
+
// change; the wait-and-converge happens at the caller level (the
|
|
5605
|
+
// round runner, the auto-advance poller, the dashboard). Claim is
|
|
5606
|
+
// refused (409) if the current owner's heartbeat is still fresh and
|
|
5607
|
+
// `force: true` was not set.
|
|
5608
|
+
router.post('/projects/:id/claim-ownership', async (req, res) => {
|
|
5609
|
+
const project = lookupProject(req, res);
|
|
5610
|
+
if (!project)
|
|
5611
|
+
return;
|
|
5612
|
+
if (!ctx.initiativeTracker)
|
|
5613
|
+
return; // narrow for TS
|
|
5614
|
+
if (!ctx.machineHeartbeat || !ctx.machineHeartbeat.config) {
|
|
5615
|
+
res.status(503).json({ error: 'machine heartbeat not configured' });
|
|
5616
|
+
return;
|
|
5617
|
+
}
|
|
5618
|
+
const ifMatch = parseIfMatch(req, res);
|
|
5619
|
+
if (ifMatch === null)
|
|
5620
|
+
return;
|
|
5621
|
+
if (ifMatch !== project.version) {
|
|
5622
|
+
res.status(409).json({ error: 'version mismatch', currentVersion: project.version });
|
|
5623
|
+
return;
|
|
5624
|
+
}
|
|
5625
|
+
const force = (req.body ?? {}).force === true;
|
|
5626
|
+
const heartbeat = ctx.machineHeartbeat.api;
|
|
5627
|
+
const currentOwner = project.ownerMachineId;
|
|
5628
|
+
const claimer = ctx.machineHeartbeat.config.machineId;
|
|
5629
|
+
if (currentOwner === claimer) {
|
|
5630
|
+
// Already owned by claimer — idempotent success.
|
|
5631
|
+
res.json({ id: project.id, ownerMachineId: claimer, version: project.version, alreadyOwned: true });
|
|
5632
|
+
return;
|
|
5633
|
+
}
|
|
5634
|
+
if (currentOwner && !heartbeat.isStale(currentOwner) && !force) {
|
|
5635
|
+
res.status(409).json({
|
|
5636
|
+
error: 'current owner heartbeat is still fresh; pass {force:true} to claim anyway',
|
|
5637
|
+
currentOwner,
|
|
5638
|
+
});
|
|
5639
|
+
return;
|
|
5640
|
+
}
|
|
5641
|
+
try {
|
|
5642
|
+
const updated = await ctx.initiativeTracker.update(project.id, {
|
|
5643
|
+
ownerMachineId: claimer,
|
|
5644
|
+
ifMatch: project.version,
|
|
5645
|
+
});
|
|
5646
|
+
res.json({
|
|
5647
|
+
id: updated.id,
|
|
5648
|
+
ownerMachineId: updated.ownerMachineId,
|
|
5649
|
+
previousOwner: currentOwner ?? null,
|
|
5650
|
+
version: updated.version,
|
|
5651
|
+
});
|
|
5652
|
+
}
|
|
5653
|
+
catch (err) {
|
|
5654
|
+
if (err instanceof Error && err.name === 'OccVersionMismatchError') {
|
|
5655
|
+
const cv = err.currentVersion;
|
|
5656
|
+
res.status(409).json({ error: 'version mismatch', currentVersion: cv });
|
|
5657
|
+
return;
|
|
5658
|
+
}
|
|
5659
|
+
res.status(400).json({ error: err instanceof Error ? err.message : String(err) });
|
|
5660
|
+
}
|
|
5661
|
+
});
|
|
5662
|
+
// POST /projects/:id/drift-check — run the drift checker for a given
|
|
5663
|
+
// round and return the verdict. Wraps ProjectDriftChecker.run().
|
|
5664
|
+
//
|
|
5665
|
+
// Body: { roundIndex: number, specPath: string, referencedFiles: string[],
|
|
5666
|
+
// timeoutMs?: number, modelId?: string }
|
|
5667
|
+
//
|
|
5668
|
+
// Mutex-guarded per project (spec § Phase 1.5 line 279). Without it two
|
|
5669
|
+
// concurrent calls would double-spend the drift-spend ledger and
|
|
5670
|
+
// double-bill the LLM. A second concurrent call returns 409 with
|
|
5671
|
+
// `{error: 'drift-check already in flight'}` so the caller can poll.
|
|
5672
|
+
//
|
|
5673
|
+
// 400 on validation; 503 if no checker configured; 200 with verdict envelope
|
|
5674
|
+
// on success. We do NOT require If-Match: drift is a read-only signal.
|
|
5675
|
+
const driftInFlight = new Set();
|
|
5676
|
+
router.post('/projects/:id/drift-check', async (req, res) => {
|
|
5677
|
+
if (!ctx.initiativeTracker) {
|
|
5678
|
+
res.status(503).json({ error: 'Initiative tracker not configured' });
|
|
5679
|
+
return;
|
|
5680
|
+
}
|
|
5681
|
+
if (!ctx.projectDriftChecker) {
|
|
5682
|
+
res.status(503).json({ error: 'ProjectDriftChecker not configured' });
|
|
5683
|
+
return;
|
|
5684
|
+
}
|
|
5685
|
+
if (!initiativeIdRe.test(req.params.id)) {
|
|
5686
|
+
res.status(400).json({ error: 'invalid project id' });
|
|
5687
|
+
return;
|
|
5688
|
+
}
|
|
5689
|
+
const project = ctx.initiativeTracker.get(req.params.id);
|
|
5690
|
+
if (!project || (project.kind ?? 'task') !== 'project') {
|
|
5691
|
+
res.status(404).json({ error: 'project not found' });
|
|
5692
|
+
return;
|
|
5693
|
+
}
|
|
5694
|
+
if (!project.targetRepoPath) {
|
|
5695
|
+
res.status(400).json({ error: 'project has no targetRepoPath' });
|
|
5696
|
+
return;
|
|
5697
|
+
}
|
|
5698
|
+
const body = (req.body ?? {});
|
|
5699
|
+
const roundIndex = typeof body.roundIndex === 'number' ? body.roundIndex : -1;
|
|
5700
|
+
const specPath = typeof body.specPath === 'string' ? body.specPath : '';
|
|
5701
|
+
const referencedFiles = Array.isArray(body.referencedFiles)
|
|
5702
|
+
? body.referencedFiles.filter((f) => typeof f === 'string')
|
|
5703
|
+
: [];
|
|
5704
|
+
if (!Number.isInteger(roundIndex) || roundIndex < 0) {
|
|
5705
|
+
res.status(400).json({ error: '"roundIndex" (non-negative integer) required' });
|
|
5706
|
+
return;
|
|
5707
|
+
}
|
|
5708
|
+
if (!specPath.trim()) {
|
|
5709
|
+
res.status(400).json({ error: '"specPath" (string) required' });
|
|
5710
|
+
return;
|
|
5711
|
+
}
|
|
5712
|
+
const rounds = project.rounds ?? [];
|
|
5713
|
+
if (roundIndex >= rounds.length) {
|
|
5714
|
+
res.status(400).json({ error: 'roundIndex out of range', rounds: rounds.length });
|
|
5715
|
+
return;
|
|
5716
|
+
}
|
|
5717
|
+
if (driftInFlight.has(project.id)) {
|
|
5718
|
+
res.status(409).json({ error: 'drift-check already in flight for this project' });
|
|
5719
|
+
return;
|
|
5720
|
+
}
|
|
5721
|
+
driftInFlight.add(project.id);
|
|
5722
|
+
try {
|
|
5723
|
+
const verdict = await ctx.projectDriftChecker.run({
|
|
5724
|
+
projectId: project.id,
|
|
5725
|
+
roundIndex,
|
|
5726
|
+
targetRepoPath: project.targetRepoPath,
|
|
5727
|
+
specPath,
|
|
5728
|
+
referencedFiles,
|
|
5729
|
+
timeoutMs: typeof body.timeoutMs === 'number' ? body.timeoutMs : undefined,
|
|
5730
|
+
modelId: typeof body.modelId === 'string' ? body.modelId : undefined,
|
|
5731
|
+
});
|
|
5732
|
+
res.json({ verdict, projectId: project.id, roundIndex });
|
|
5733
|
+
}
|
|
5734
|
+
catch (err) {
|
|
5735
|
+
res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
|
|
5736
|
+
}
|
|
5737
|
+
finally {
|
|
5738
|
+
driftInFlight.delete(project.id);
|
|
5739
|
+
}
|
|
5740
|
+
});
|
|
5741
|
+
// POST /projects/:id/run-round — manual round-start trigger.
|
|
5742
|
+
//
|
|
5743
|
+
// Spec § Phase 1.7: `/project run-round` is the user-invoked entry path.
|
|
5744
|
+
// Per § Phase 1.5, every entry path goes through `ProjectRoundRunner.preflight`.
|
|
5745
|
+
// On a successful preflight the route schedules the round for the auto-advance
|
|
5746
|
+
// poller by setting `autoAdvanceAt = now`; the poller fires the executor on
|
|
5747
|
+
// its next tick (≤60s). This avoids spawning a long-running child process
|
|
5748
|
+
// from a request handler and keeps a single fire path through the poller.
|
|
5749
|
+
//
|
|
5750
|
+
// Body: { roundIndex: number }
|
|
5751
|
+
// Returns 200 with the preflight verdict + scheduling timestamp on accept,
|
|
5752
|
+
// 409 with the preflight reason on reject, 503 if the runner is not wired.
|
|
5753
|
+
router.post('/projects/:id/run-round', async (req, res) => {
|
|
5754
|
+
const project = lookupProject(req, res);
|
|
5755
|
+
if (!project)
|
|
5756
|
+
return;
|
|
5757
|
+
if (!ctx.initiativeTracker)
|
|
5758
|
+
return;
|
|
5759
|
+
if (!ctx.projectRoundRunner) {
|
|
5760
|
+
res.status(503).json({ error: 'ProjectRoundRunner not configured' });
|
|
5761
|
+
return;
|
|
5762
|
+
}
|
|
5763
|
+
const body = (req.body ?? {});
|
|
5764
|
+
const idx = typeof body.roundIndex === 'number' ? body.roundIndex : 0;
|
|
5765
|
+
if (!Number.isInteger(idx) || idx < 0) {
|
|
5766
|
+
res.status(400).json({ error: '"roundIndex" must be a non-negative integer' });
|
|
5767
|
+
return;
|
|
5768
|
+
}
|
|
5769
|
+
const rounds = project.rounds ?? [];
|
|
5770
|
+
if (idx >= rounds.length) {
|
|
5771
|
+
res.status(404).json({ error: `round index ${idx} out of range (0..${rounds.length - 1})` });
|
|
5772
|
+
return;
|
|
5773
|
+
}
|
|
5774
|
+
const verdict = ctx.projectRoundRunner.preflight(project.id, idx);
|
|
5775
|
+
if (!verdict.ok) {
|
|
5776
|
+
res.status(409).json({ error: 'preflight rejected', code: verdict.code, reason: verdict.reason });
|
|
5777
|
+
return;
|
|
5778
|
+
}
|
|
5779
|
+
try {
|
|
5780
|
+
const now = new Date().toISOString();
|
|
5781
|
+
const nextRounds = rounds.map((r, i) => (i === idx ? { ...r, autoAdvanceAt: now } : r));
|
|
5782
|
+
const updated = await ctx.initiativeTracker.update(project.id, {
|
|
5783
|
+
rounds: nextRounds,
|
|
5784
|
+
ifMatch: project.version,
|
|
5785
|
+
});
|
|
5786
|
+
res.json({
|
|
5787
|
+
id: updated.id,
|
|
5788
|
+
roundIndex: idx,
|
|
5789
|
+
scheduledAt: now,
|
|
5790
|
+
version: updated.version,
|
|
5791
|
+
});
|
|
5792
|
+
}
|
|
5793
|
+
catch (err) {
|
|
5794
|
+
if (err instanceof Error && err.name === 'OccVersionMismatchError') {
|
|
5795
|
+
const cv = err.currentVersion;
|
|
5796
|
+
res.status(409).json({ error: 'version mismatch', currentVersion: cv });
|
|
5797
|
+
return;
|
|
5798
|
+
}
|
|
5799
|
+
res.status(400).json({ error: err instanceof Error ? err.message : String(err) });
|
|
5800
|
+
}
|
|
5801
|
+
});
|
|
5802
|
+
// POST /projects/:id/resume — resume a halted round.
|
|
5803
|
+
//
|
|
5804
|
+
// Spec § Phase 1.5: clears `haltedAt`/`haltReason` on the round and schedules
|
|
5805
|
+
// it for the auto-advance poller. For rounds with status='failed' that have
|
|
5806
|
+
// hit the 3-attempt resume cap (`resumeAttempts >= 3`), the body must include
|
|
5807
|
+
// `{ force: true }`; otherwise the route returns 409. Resetting a `failed`
|
|
5808
|
+
// round also zeroes `resumeAttempts` so the runner gets a fresh budget.
|
|
5809
|
+
//
|
|
5810
|
+
// Body: { roundIndex?: number (default 0), force?: boolean }
|
|
5811
|
+
router.post('/projects/:id/resume', async (req, res) => {
|
|
5812
|
+
const project = lookupProject(req, res);
|
|
5813
|
+
if (!project)
|
|
5814
|
+
return;
|
|
5815
|
+
if (!ctx.initiativeTracker)
|
|
5816
|
+
return;
|
|
5817
|
+
const body = (req.body ?? {});
|
|
5818
|
+
const idx = typeof body.roundIndex === 'number' ? body.roundIndex : 0;
|
|
5819
|
+
const force = body.force === true;
|
|
5820
|
+
if (!Number.isInteger(idx) || idx < 0) {
|
|
5821
|
+
res.status(400).json({ error: '"roundIndex" must be a non-negative integer' });
|
|
5822
|
+
return;
|
|
5823
|
+
}
|
|
5824
|
+
const rounds = project.rounds ?? [];
|
|
5825
|
+
if (idx >= rounds.length) {
|
|
5826
|
+
res.status(404).json({ error: `round index ${idx} out of range (0..${rounds.length - 1})` });
|
|
5827
|
+
return;
|
|
5828
|
+
}
|
|
5829
|
+
const round = rounds[idx];
|
|
5830
|
+
if (round.status === 'failed' && (round.resumeAttempts ?? 0) >= 3 && !force) {
|
|
5831
|
+
res.status(409).json({
|
|
5832
|
+
error: 'round at resume-attempts cap; pass {force:true} to override',
|
|
5833
|
+
resumeAttempts: round.resumeAttempts,
|
|
5834
|
+
});
|
|
5835
|
+
return;
|
|
5836
|
+
}
|
|
5837
|
+
if (!round.haltedAt && round.status !== 'failed') {
|
|
5838
|
+
res.status(409).json({
|
|
5839
|
+
error: 'round is not halted or failed',
|
|
5840
|
+
status: round.status,
|
|
5841
|
+
});
|
|
5842
|
+
return;
|
|
5843
|
+
}
|
|
5844
|
+
try {
|
|
5845
|
+
const now = new Date().toISOString();
|
|
5846
|
+
const nextRounds = rounds.map((r, i) => {
|
|
5847
|
+
if (i !== idx)
|
|
5848
|
+
return r;
|
|
5849
|
+
const { haltedAt: _h, haltReason: _r, ...rest } = r;
|
|
5850
|
+
const resetAttempts = r.status === 'failed' && force;
|
|
5851
|
+
return {
|
|
5852
|
+
...rest,
|
|
5853
|
+
status: 'pending',
|
|
5854
|
+
autoAdvanceAt: now,
|
|
5855
|
+
resumeAttempts: resetAttempts ? 0 : r.resumeAttempts,
|
|
5856
|
+
};
|
|
5857
|
+
});
|
|
5858
|
+
const nextStatus = project.status === 'halted' || project.status === 'abandoned' ? 'active' : project.status;
|
|
5859
|
+
const updated = await ctx.initiativeTracker.update(project.id, {
|
|
5860
|
+
rounds: nextRounds,
|
|
5861
|
+
status: nextStatus,
|
|
5862
|
+
ifMatch: project.version,
|
|
5863
|
+
});
|
|
5864
|
+
res.json({
|
|
5865
|
+
id: updated.id,
|
|
5866
|
+
roundIndex: idx,
|
|
5867
|
+
scheduledAt: now,
|
|
5868
|
+
forced: force,
|
|
5869
|
+
version: updated.version,
|
|
5870
|
+
});
|
|
5871
|
+
}
|
|
5872
|
+
catch (err) {
|
|
5873
|
+
if (err instanceof Error && err.name === 'OccVersionMismatchError') {
|
|
5874
|
+
const cv = err.currentVersion;
|
|
5875
|
+
res.status(409).json({ error: 'version mismatch', currentVersion: cv });
|
|
5876
|
+
return;
|
|
5877
|
+
}
|
|
5878
|
+
res.status(400).json({ error: err instanceof Error ? err.message : String(err) });
|
|
5879
|
+
}
|
|
5880
|
+
});
|
|
5881
|
+
// POST /projects/:id/abandon — archive a halted/failed project.
|
|
5882
|
+
//
|
|
5883
|
+
// Spec § Phase 1.7: "archive a halted round; children remain at current stage."
|
|
5884
|
+
// Sets project.status='abandoned' and clears any future `autoAdvanceAt` on
|
|
5885
|
+
// remaining rounds so the poller stops considering them. Children's
|
|
5886
|
+
// `pipelineStage` is left untouched (per spec). Idempotent.
|
|
5887
|
+
//
|
|
5888
|
+
// Refuses (409) if any round is currently in-progress — halt first.
|
|
5889
|
+
router.post('/projects/:id/abandon', async (req, res) => {
|
|
5890
|
+
const project = lookupProject(req, res);
|
|
5891
|
+
if (!project)
|
|
5892
|
+
return;
|
|
5893
|
+
if (!ctx.initiativeTracker)
|
|
5894
|
+
return;
|
|
5895
|
+
const rounds = project.rounds ?? [];
|
|
5896
|
+
const active = rounds.find((r) => r.status === 'in-progress');
|
|
5897
|
+
if (active) {
|
|
5898
|
+
res.status(409).json({
|
|
5899
|
+
error: 'cannot abandon while a round is in-progress; halt the round first',
|
|
5900
|
+
activeRound: active.name,
|
|
5901
|
+
});
|
|
5902
|
+
return;
|
|
5903
|
+
}
|
|
5904
|
+
if (project.status === 'abandoned') {
|
|
5905
|
+
res.json({ id: project.id, status: 'abandoned', version: project.version, alreadyAbandoned: true });
|
|
5906
|
+
return;
|
|
5907
|
+
}
|
|
5908
|
+
try {
|
|
5909
|
+
const clearedRounds = rounds.map((r) => {
|
|
5910
|
+
if (r.autoAdvanceAt) {
|
|
5911
|
+
const { autoAdvanceAt: _x, ...rest } = r;
|
|
5912
|
+
return rest;
|
|
5913
|
+
}
|
|
5914
|
+
return r;
|
|
5915
|
+
});
|
|
5916
|
+
const updated = await ctx.initiativeTracker.update(project.id, {
|
|
5917
|
+
rounds: clearedRounds,
|
|
5918
|
+
status: 'abandoned',
|
|
5919
|
+
ifMatch: project.version,
|
|
5920
|
+
});
|
|
5921
|
+
res.json({ id: updated.id, status: updated.status, version: updated.version });
|
|
5922
|
+
}
|
|
5923
|
+
catch (err) {
|
|
5924
|
+
if (err instanceof Error && err.name === 'OccVersionMismatchError') {
|
|
5925
|
+
const cv = err.currentVersion;
|
|
5926
|
+
res.status(409).json({ error: 'version mismatch', currentVersion: cv });
|
|
5927
|
+
return;
|
|
5928
|
+
}
|
|
5929
|
+
res.status(400).json({ error: err instanceof Error ? err.message : String(err) });
|
|
5930
|
+
}
|
|
5931
|
+
});
|
|
5157
5932
|
router.post('/projects', async (req, res) => {
|
|
5158
5933
|
if (!ctx.initiativeTracker) {
|
|
5159
5934
|
res.status(503).json({ error: 'Initiative tracker not configured' });
|