instar 0.28.95 → 0.28.96
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 +590 -7
- package/dist/server/routes.js.map +1 -1
- package/package.json +1 -1
- package/src/data/builtin-manifest.json +47 -47
- package/upgrades/0.28.96.md +347 -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,26 @@ 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
|
+
function skillCommandForAction(action, projectId, roundIndex) {
|
|
501
|
+
switch (action) {
|
|
502
|
+
case 'await-user-approval':
|
|
503
|
+
return `/project approve ${projectId}`;
|
|
504
|
+
case 'ack-required':
|
|
505
|
+
return `/project ack ${projectId}`;
|
|
506
|
+
case 'resolve-conflict':
|
|
507
|
+
return `/project resolve-conflict ${projectId}`;
|
|
508
|
+
case 'accept-partial':
|
|
509
|
+
return `/project accept-partial ${projectId} ${roundIndex}`;
|
|
510
|
+
case 'run-spec-converge':
|
|
511
|
+
return `/spec-converge`;
|
|
512
|
+
case 'run-drift-check':
|
|
513
|
+
return `/project drift-check ${projectId} ${roundIndex}`;
|
|
514
|
+
case 'start-round':
|
|
515
|
+
default:
|
|
516
|
+
return `/project run-round ${projectId} ${roundIndex}`;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
497
519
|
export function createRoutes(ctx) {
|
|
498
520
|
const router = Router();
|
|
499
521
|
// ── PR-REVIEW-HARDENING kill-switch (Phase A) ─────────────────────
|
|
@@ -4982,9 +5004,21 @@ export function createRoutes(ctx) {
|
|
|
4982
5004
|
return;
|
|
4983
5005
|
}
|
|
4984
5006
|
const status = typeof req.query.status === 'string' ? req.query.status : undefined;
|
|
4985
|
-
|
|
5007
|
+
// Phase 1b PR 5 — server-side filters added so the dashboard
|
|
5008
|
+
// Initiatives tab can hide project-kind records (rendered in the
|
|
5009
|
+
// separate Projects tab) and any record that's a child of a
|
|
5010
|
+
// project (rendered inside its parent's card).
|
|
5011
|
+
const excludeKind = typeof req.query.excludeKind === 'string' ? req.query.excludeKind : undefined;
|
|
5012
|
+
const excludeParented = req.query.excludeParented === 'true';
|
|
5013
|
+
let items = status
|
|
4986
5014
|
? ctx.initiativeTracker.list({ status: status })
|
|
4987
5015
|
: ctx.initiativeTracker.list();
|
|
5016
|
+
if (excludeKind) {
|
|
5017
|
+
items = items.filter((i) => (i.kind ?? 'task') !== excludeKind);
|
|
5018
|
+
}
|
|
5019
|
+
if (excludeParented) {
|
|
5020
|
+
items = items.filter((i) => !i.parentProjectId);
|
|
5021
|
+
}
|
|
4988
5022
|
res.json({ items, count: items.length });
|
|
4989
5023
|
});
|
|
4990
5024
|
router.get('/initiatives/digest', (_req, res) => {
|
|
@@ -5109,7 +5143,7 @@ export function createRoutes(ctx) {
|
|
|
5109
5143
|
const items = ctx.initiativeTracker.list({ kind: 'project' });
|
|
5110
5144
|
res.json({ items, count: items.length });
|
|
5111
5145
|
});
|
|
5112
|
-
router.get('/projects/:id', (req, res) => {
|
|
5146
|
+
router.get('/projects/:id', async (req, res) => {
|
|
5113
5147
|
if (!ctx.initiativeTracker) {
|
|
5114
5148
|
res.status(503).json({ error: 'Initiative tracker not configured' });
|
|
5115
5149
|
return;
|
|
@@ -5124,9 +5158,105 @@ export function createRoutes(ctx) {
|
|
|
5124
5158
|
return;
|
|
5125
5159
|
}
|
|
5126
5160
|
// Join children that point at this project via parentProjectId.
|
|
5127
|
-
|
|
5161
|
+
let children = ctx.initiativeTracker
|
|
5128
5162
|
.list()
|
|
5129
5163
|
.filter((i) => i.parentProjectId === project.id);
|
|
5164
|
+
// Lazy merged-state reconciler — spec § Phase 1.4 lines 256-258.
|
|
5165
|
+
//
|
|
5166
|
+
// GET /projects/:id is documented as "may mutate": when a 'building' child's
|
|
5167
|
+
// mergeCommitOid is no longer ancestor of origin/main, we transition it to
|
|
5168
|
+
// 'regressed' and clear future autoAdvanceAt on its round.
|
|
5169
|
+
//
|
|
5170
|
+
// Selection contract:
|
|
5171
|
+
// - debounce: skip any child with `ciCheckedAt < 6h ago`
|
|
5172
|
+
// - cap: at most 3 child-revalidations per GET
|
|
5173
|
+
// - order: oldest `ciCheckedAt` first (treat undefined as 1970), ties broken
|
|
5174
|
+
// by `roundIndex` ASC, then `itemId` ASC — no child can starve.
|
|
5175
|
+
// - opt-out: `?reconcile=false` (or `?reconcile=0`) skips entirely
|
|
5176
|
+
const reconcileFlag = req.query.reconcile;
|
|
5177
|
+
const wantReconcile = reconcileFlag !== 'false' && reconcileFlag !== '0';
|
|
5178
|
+
if (wantReconcile && project.targetRepoPath) {
|
|
5179
|
+
const nowMs = Date.now();
|
|
5180
|
+
const debounceMs = 6 * 60 * 60 * 1000; // 6 hours
|
|
5181
|
+
const roundIndexOf = (childId) => {
|
|
5182
|
+
const rounds = project.rounds ?? [];
|
|
5183
|
+
for (let i = 0; i < rounds.length; i++) {
|
|
5184
|
+
if ((rounds[i].itemIds ?? []).includes(childId))
|
|
5185
|
+
return i;
|
|
5186
|
+
}
|
|
5187
|
+
return Number.MAX_SAFE_INTEGER;
|
|
5188
|
+
};
|
|
5189
|
+
const candidates = children
|
|
5190
|
+
.filter((c) => c.pipelineStage === 'building' && typeof c.mergeCommitOid === 'string' && c.mergeCommitOid)
|
|
5191
|
+
.filter((c) => {
|
|
5192
|
+
const t = c.ciCheckedAt ? Date.parse(c.ciCheckedAt) : 0;
|
|
5193
|
+
return !(Number.isFinite(t) && t > 0 && nowMs - t < debounceMs);
|
|
5194
|
+
})
|
|
5195
|
+
.sort((a, b) => {
|
|
5196
|
+
const ta = a.ciCheckedAt ? Date.parse(a.ciCheckedAt) : 0;
|
|
5197
|
+
const tb = b.ciCheckedAt ? Date.parse(b.ciCheckedAt) : 0;
|
|
5198
|
+
if (ta !== tb)
|
|
5199
|
+
return ta - tb;
|
|
5200
|
+
const ra = roundIndexOf(a.id);
|
|
5201
|
+
const rb = roundIndexOf(b.id);
|
|
5202
|
+
if (ra !== rb)
|
|
5203
|
+
return ra - rb;
|
|
5204
|
+
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
|
5205
|
+
})
|
|
5206
|
+
.slice(0, 3);
|
|
5207
|
+
const candidateIds = candidates.map((c) => c.id);
|
|
5208
|
+
if (candidateIds.length > 0) {
|
|
5209
|
+
let verified = new Set();
|
|
5210
|
+
try {
|
|
5211
|
+
verified = await verifyMergedItemsViaGit(project.targetRepoPath, candidateIds, ctx.initiativeTracker);
|
|
5212
|
+
}
|
|
5213
|
+
catch {
|
|
5214
|
+
// git not available, no network, etc. — leave verified empty so we
|
|
5215
|
+
// still update ciCheckedAt to back off; better than a hot loop.
|
|
5216
|
+
}
|
|
5217
|
+
let touched = false;
|
|
5218
|
+
for (const cand of candidates) {
|
|
5219
|
+
const child = ctx.initiativeTracker.get(cand.id);
|
|
5220
|
+
if (!child)
|
|
5221
|
+
continue;
|
|
5222
|
+
const isMerged = verified.has(cand.id);
|
|
5223
|
+
try {
|
|
5224
|
+
await ctx.initiativeTracker.update(cand.id, {
|
|
5225
|
+
pipelineStage: isMerged ? 'merged' : 'regressed',
|
|
5226
|
+
ciCheckedAt: new Date(nowMs).toISOString(),
|
|
5227
|
+
ifMatch: child.version,
|
|
5228
|
+
});
|
|
5229
|
+
touched = true;
|
|
5230
|
+
if (!isMerged) {
|
|
5231
|
+
// On regression: clear future autoAdvanceAt on the child's round
|
|
5232
|
+
// so the next round doesn't auto-fire while this one is broken.
|
|
5233
|
+
const projAfter = ctx.initiativeTracker.get(project.id);
|
|
5234
|
+
const rIdx = roundIndexOf(cand.id);
|
|
5235
|
+
if (projAfter && rIdx < (projAfter.rounds ?? []).length) {
|
|
5236
|
+
const newRounds = (projAfter.rounds ?? []).map((r, i) => i > rIdx ? { ...r, autoAdvanceAt: undefined } : r);
|
|
5237
|
+
try {
|
|
5238
|
+
await ctx.initiativeTracker.update(project.id, {
|
|
5239
|
+
rounds: newRounds,
|
|
5240
|
+
ifMatch: projAfter.version,
|
|
5241
|
+
});
|
|
5242
|
+
}
|
|
5243
|
+
catch {
|
|
5244
|
+
// OCC race; next GET will retry.
|
|
5245
|
+
}
|
|
5246
|
+
}
|
|
5247
|
+
}
|
|
5248
|
+
}
|
|
5249
|
+
catch {
|
|
5250
|
+
// OCC race or validator reject; leave as-is for next GET.
|
|
5251
|
+
}
|
|
5252
|
+
}
|
|
5253
|
+
if (touched) {
|
|
5254
|
+
children = ctx.initiativeTracker
|
|
5255
|
+
.list()
|
|
5256
|
+
.filter((i) => i.parentProjectId === project.id);
|
|
5257
|
+
}
|
|
5258
|
+
}
|
|
5259
|
+
}
|
|
5130
5260
|
// Optional field selector: `?fields=id,title,pipelineStage`. Used by
|
|
5131
5261
|
// the dashboard projects selector (Phase 1.10). Always preserves `id`.
|
|
5132
5262
|
const fieldsParam = typeof req.query.fields === 'string' ? req.query.fields : '';
|
|
@@ -5143,17 +5273,470 @@ export function createRoutes(ctx) {
|
|
|
5143
5273
|
}
|
|
5144
5274
|
res.json({ project, children });
|
|
5145
5275
|
});
|
|
5276
|
+
// GET /projects/:id/next — structured next-action payload.
|
|
5277
|
+
//
|
|
5278
|
+
// Spec § Phase 1.5 (line 268): returns `{ action, params, estimatedCost?,
|
|
5279
|
+
// skillCommand? }` for the FIRST round whose status is not 'complete'.
|
|
5280
|
+
// Ordering: roundIndex ASC, then pipelineStage ASC, then itemId ASC.
|
|
5281
|
+
//
|
|
5282
|
+
// Action verbs the spec enumerates (a non-exhaustive contract):
|
|
5283
|
+
// - 'await-user-approval' — first round needs `firstLaunchAckAt`
|
|
5284
|
+
// - 'ack-required' — unacknowledgedAdvanceCount >= cap
|
|
5285
|
+
// - 'resolve-conflict' — `awaitingReconciliation` non-empty
|
|
5286
|
+
// - 'accept-partial' — round is partially-complete
|
|
5287
|
+
// - 'run-spec-converge' — at least one item is `spec-drafted`
|
|
5288
|
+
// - 'run-drift-check' — at least one item is `approved`, no fresh
|
|
5289
|
+
// drift verdict
|
|
5290
|
+
// - 'start-round' — all preconditions met, ready to fire
|
|
5291
|
+
//
|
|
5292
|
+
// The endpoint does NOT run the runner's preflight — that's a heavier
|
|
5293
|
+
// check at fire time. The action is a hint for the dashboard + skill UI.
|
|
5146
5294
|
router.get('/projects/:id/next', (req, res) => {
|
|
5295
|
+
if (!ctx.initiativeTracker) {
|
|
5296
|
+
res.status(503).json({ error: 'Initiative tracker not configured' });
|
|
5297
|
+
return;
|
|
5298
|
+
}
|
|
5147
5299
|
if (!initiativeIdRe.test(req.params.id)) {
|
|
5148
5300
|
res.status(400).json({ error: 'invalid project id' });
|
|
5149
5301
|
return;
|
|
5150
5302
|
}
|
|
5151
|
-
|
|
5152
|
-
|
|
5153
|
-
|
|
5154
|
-
|
|
5303
|
+
const project = ctx.initiativeTracker.get(req.params.id);
|
|
5304
|
+
if (!project || (project.kind ?? 'task') !== 'project') {
|
|
5305
|
+
res.status(404).json({ error: 'project not found' });
|
|
5306
|
+
return;
|
|
5307
|
+
}
|
|
5308
|
+
const rounds = project.rounds ?? [];
|
|
5309
|
+
const idx = rounds.findIndex((r) => (r.status ?? 'pending') !== 'complete');
|
|
5310
|
+
if (idx === -1) {
|
|
5311
|
+
res.status(204).end();
|
|
5312
|
+
return;
|
|
5313
|
+
}
|
|
5314
|
+
const r = rounds[idx];
|
|
5315
|
+
let action = 'start-round';
|
|
5316
|
+
const childrenById = new Map(ctx.initiativeTracker
|
|
5317
|
+
.list()
|
|
5318
|
+
.filter((i) => i.parentProjectId === project.id)
|
|
5319
|
+
.map((c) => [c.id, c]));
|
|
5320
|
+
const itemsForRound = (r.itemIds ?? []).map((id) => childrenById.get(id)).filter(Boolean);
|
|
5321
|
+
if ((project.awaitingReconciliation ?? []).length > 0) {
|
|
5322
|
+
action = 'resolve-conflict';
|
|
5323
|
+
}
|
|
5324
|
+
else if ((r.status ?? 'pending') === 'partially-complete') {
|
|
5325
|
+
action = 'accept-partial';
|
|
5326
|
+
}
|
|
5327
|
+
else if (idx === 0 && !project.firstLaunchAckAt) {
|
|
5328
|
+
action = 'await-user-approval';
|
|
5329
|
+
}
|
|
5330
|
+
else if ((project.unacknowledgedAdvanceCount ?? 0) >= 2) {
|
|
5331
|
+
action = 'ack-required';
|
|
5332
|
+
}
|
|
5333
|
+
else if (itemsForRound.some((c) => c?.pipelineStage === 'spec-drafted')) {
|
|
5334
|
+
action = 'run-spec-converge';
|
|
5335
|
+
}
|
|
5336
|
+
else if (itemsForRound.some((c) => c?.pipelineStage === 'approved' && !r.lastDriftVerdict)) {
|
|
5337
|
+
action = 'run-drift-check';
|
|
5338
|
+
}
|
|
5339
|
+
res.json({
|
|
5340
|
+
action,
|
|
5341
|
+
params: {
|
|
5342
|
+
projectId: project.id,
|
|
5343
|
+
projectVersion: project.version,
|
|
5344
|
+
roundIndex: idx,
|
|
5345
|
+
name: r.name,
|
|
5346
|
+
itemIds: r.itemIds,
|
|
5347
|
+
status: r.status ?? 'pending',
|
|
5348
|
+
autoAdvanceAt: r.autoAdvanceAt,
|
|
5349
|
+
},
|
|
5350
|
+
skillCommand: skillCommandForAction(action, project.id, idx),
|
|
5155
5351
|
});
|
|
5156
5352
|
});
|
|
5353
|
+
// ── /projects/:id mutating routes (Phase 1b PR 3) ──────────────
|
|
5354
|
+
//
|
|
5355
|
+
// All mutating endpoints require `If-Match: <version>` (OCC). Missing
|
|
5356
|
+
// header → 428. Stale version → 409. Spec § Phase 1.3.
|
|
5357
|
+
//
|
|
5358
|
+
// /advance is a single-item stage transition driven by StageTransitionValidator.
|
|
5359
|
+
// /halt, /ack, /accept-partial route through ProjectRoundRunner.
|
|
5360
|
+
function parseIfMatch(req, res) {
|
|
5361
|
+
const header = req.headers['if-match'];
|
|
5362
|
+
if (typeof header !== 'string' || !header.trim()) {
|
|
5363
|
+
res.status(428).json({ error: 'If-Match header required' });
|
|
5364
|
+
return null;
|
|
5365
|
+
}
|
|
5366
|
+
const n = parseInt(header.replace(/"/g, ''), 10);
|
|
5367
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
5368
|
+
res.status(400).json({ error: 'If-Match must be a positive integer version' });
|
|
5369
|
+
return null;
|
|
5370
|
+
}
|
|
5371
|
+
return n;
|
|
5372
|
+
}
|
|
5373
|
+
function lookupProject(req, res) {
|
|
5374
|
+
if (!ctx.initiativeTracker) {
|
|
5375
|
+
res.status(503).json({ error: 'Initiative tracker not configured' });
|
|
5376
|
+
return null;
|
|
5377
|
+
}
|
|
5378
|
+
if (!initiativeIdRe.test(req.params.id)) {
|
|
5379
|
+
res.status(400).json({ error: 'invalid project id' });
|
|
5380
|
+
return null;
|
|
5381
|
+
}
|
|
5382
|
+
const project = ctx.initiativeTracker.get(req.params.id);
|
|
5383
|
+
if (!project || (project.kind ?? 'task') !== 'project') {
|
|
5384
|
+
res.status(404).json({ error: 'project not found' });
|
|
5385
|
+
return null;
|
|
5386
|
+
}
|
|
5387
|
+
return project;
|
|
5388
|
+
}
|
|
5389
|
+
// POST /projects/:id/advance — advance one child item by one stage.
|
|
5390
|
+
//
|
|
5391
|
+
// Body shape:
|
|
5392
|
+
// {
|
|
5393
|
+
// "itemId": "<child id>",
|
|
5394
|
+
// "fromStage": "<optional explicit current stage>",
|
|
5395
|
+
// "targetStage": "spec-drafted|spec-converged|approved|building|merged|skipped|outline",
|
|
5396
|
+
// "artifact": {
|
|
5397
|
+
// "specPath"?: "...", // outline → spec-drafted
|
|
5398
|
+
// "prNumber"?: 123, // building → merged
|
|
5399
|
+
// "taskFlowRecordId"?: "...", // approved → building
|
|
5400
|
+
// "skippedReason"?: "...", // any → skipped
|
|
5401
|
+
// "skippedBy"?: "...", // any → skipped
|
|
5402
|
+
// "unskippedAt"?: "..." // skipped → outline
|
|
5403
|
+
// }
|
|
5404
|
+
// }
|
|
5405
|
+
//
|
|
5406
|
+
// The HTTP layer does NOT enforce gates the ProjectRoundRunner already
|
|
5407
|
+
// enforces — `/advance` is a single-item transition that operates on
|
|
5408
|
+
// child records, not on round status. Round-level invariants (lock,
|
|
5409
|
+
// first-launch ack, owner machine, etc.) apply only to /run-round
|
|
5410
|
+
// and to the autonomous loop, both of which ship in a later PR.
|
|
5411
|
+
router.post('/projects/:id/advance', async (req, res) => {
|
|
5412
|
+
const project = lookupProject(req, res);
|
|
5413
|
+
if (!project)
|
|
5414
|
+
return;
|
|
5415
|
+
if (!ctx.initiativeTracker)
|
|
5416
|
+
return; // narrow for TS
|
|
5417
|
+
const ifMatch = parseIfMatch(req, res);
|
|
5418
|
+
if (ifMatch === null)
|
|
5419
|
+
return;
|
|
5420
|
+
if (ifMatch !== project.version) {
|
|
5421
|
+
res.status(409).json({ error: 'version mismatch', currentVersion: project.version });
|
|
5422
|
+
return;
|
|
5423
|
+
}
|
|
5424
|
+
const body = (req.body ?? {});
|
|
5425
|
+
const itemId = typeof body.itemId === 'string' ? body.itemId : '';
|
|
5426
|
+
const targetStage = typeof body.targetStage === 'string' ? body.targetStage : null;
|
|
5427
|
+
if (!itemId) {
|
|
5428
|
+
res.status(400).json({ error: '"itemId" required' });
|
|
5429
|
+
return;
|
|
5430
|
+
}
|
|
5431
|
+
if (!targetStage) {
|
|
5432
|
+
res.status(400).json({ error: '"targetStage" required' });
|
|
5433
|
+
return;
|
|
5434
|
+
}
|
|
5435
|
+
const child = ctx.initiativeTracker.get(itemId);
|
|
5436
|
+
if (!child || child.parentProjectId !== project.id) {
|
|
5437
|
+
res.status(404).json({ error: `child item "${itemId}" not found under project ${project.id}` });
|
|
5438
|
+
return;
|
|
5439
|
+
}
|
|
5440
|
+
if (!project.targetRepoPath) {
|
|
5441
|
+
res.status(400).json({ error: 'project has no targetRepoPath; cannot validate artifacts' });
|
|
5442
|
+
return;
|
|
5443
|
+
}
|
|
5444
|
+
const artifact = body.artifact ?? {};
|
|
5445
|
+
const validationCtx = {
|
|
5446
|
+
targetRepoPath: project.targetRepoPath,
|
|
5447
|
+
specPath: typeof artifact.specPath === 'string' ? artifact.specPath : undefined,
|
|
5448
|
+
prNumber: typeof artifact.prNumber === 'number' ? artifact.prNumber : undefined,
|
|
5449
|
+
taskFlowRecordId: typeof artifact.taskFlowRecordId === 'string' ? artifact.taskFlowRecordId : undefined,
|
|
5450
|
+
skippedReason: typeof artifact.skippedReason === 'string' ? artifact.skippedReason : undefined,
|
|
5451
|
+
skippedBy: typeof artifact.skippedBy === 'string' ? artifact.skippedBy : undefined,
|
|
5452
|
+
unskippedAt: typeof artifact.unskippedAt === 'string' ? artifact.unskippedAt : undefined,
|
|
5453
|
+
};
|
|
5454
|
+
const fromStage = typeof body.fromStage === 'string'
|
|
5455
|
+
? body.fromStage
|
|
5456
|
+
: child.pipelineStage;
|
|
5457
|
+
const result = await validateStageTransition(fromStage, targetStage, validationCtx);
|
|
5458
|
+
if (!result.ok) {
|
|
5459
|
+
res.status(409).json({ error: 'stage transition rejected', code: result.code, reason: result.reason });
|
|
5460
|
+
return;
|
|
5461
|
+
}
|
|
5462
|
+
try {
|
|
5463
|
+
const updated = await ctx.initiativeTracker.update(itemId, {
|
|
5464
|
+
pipelineStage: targetStage,
|
|
5465
|
+
ifMatch: child.version,
|
|
5466
|
+
});
|
|
5467
|
+
// Bump the project version too so concurrent advance calls don't
|
|
5468
|
+
// operate on a stale view. We touch description ("last advance
|
|
5469
|
+
// <timestamp>") to force a meaningful update.
|
|
5470
|
+
const refreshed = await ctx.initiativeTracker.update(project.id, {
|
|
5471
|
+
// No-op-but-bump: nudge `lastTouchedAt` indirectly via update.
|
|
5472
|
+
nextCheckAt: project.nextCheckAt,
|
|
5473
|
+
ifMatch: project.version,
|
|
5474
|
+
});
|
|
5475
|
+
res.json({
|
|
5476
|
+
item: { id: updated.id, pipelineStage: updated.pipelineStage, version: updated.version },
|
|
5477
|
+
project: { id: refreshed.id, version: refreshed.version },
|
|
5478
|
+
});
|
|
5479
|
+
}
|
|
5480
|
+
catch (err) {
|
|
5481
|
+
if (err instanceof Error && err.name === 'OccVersionMismatchError') {
|
|
5482
|
+
const cv = err.currentVersion;
|
|
5483
|
+
res.status(409).json({ error: 'version mismatch', currentVersion: cv });
|
|
5484
|
+
return;
|
|
5485
|
+
}
|
|
5486
|
+
res.status(400).json({ error: err instanceof Error ? err.message : String(err) });
|
|
5487
|
+
}
|
|
5488
|
+
});
|
|
5489
|
+
// POST /projects/:id/halt — kills the active round. Idempotent.
|
|
5490
|
+
router.post('/projects/:id/halt', async (req, res) => {
|
|
5491
|
+
const project = lookupProject(req, res);
|
|
5492
|
+
if (!project)
|
|
5493
|
+
return;
|
|
5494
|
+
if (!ctx.projectRoundRunner) {
|
|
5495
|
+
res.status(503).json({ error: 'ProjectRoundRunner not configured' });
|
|
5496
|
+
return;
|
|
5497
|
+
}
|
|
5498
|
+
const reason = typeof (req.body ?? {}).reason === 'string' ? req.body.reason : 'no reason given';
|
|
5499
|
+
try {
|
|
5500
|
+
const result = await ctx.projectRoundRunner.halt(project.id, reason);
|
|
5501
|
+
if (!result) {
|
|
5502
|
+
res.status(409).json({ error: 'project has no halt-able round' });
|
|
5503
|
+
return;
|
|
5504
|
+
}
|
|
5505
|
+
res.json({ id: result.project.id, roundIndex: result.roundIndex, version: result.project.version });
|
|
5506
|
+
}
|
|
5507
|
+
catch (err) {
|
|
5508
|
+
res.status(400).json({ error: err instanceof Error ? err.message : String(err) });
|
|
5509
|
+
}
|
|
5510
|
+
});
|
|
5511
|
+
// POST /projects/:id/ack — record user acknowledgment.
|
|
5512
|
+
router.post('/projects/:id/ack', async (req, res) => {
|
|
5513
|
+
const project = lookupProject(req, res);
|
|
5514
|
+
if (!project)
|
|
5515
|
+
return;
|
|
5516
|
+
if (!ctx.projectRoundRunner) {
|
|
5517
|
+
res.status(503).json({ error: 'ProjectRoundRunner not configured' });
|
|
5518
|
+
return;
|
|
5519
|
+
}
|
|
5520
|
+
const body = (req.body ?? {});
|
|
5521
|
+
const idx = typeof body.forRoundIndex === 'number' ? body.forRoundIndex : (typeof body.roundIndex === 'number' ? body.roundIndex : 0);
|
|
5522
|
+
if (!Number.isInteger(idx) || idx < 0) {
|
|
5523
|
+
res.status(400).json({ error: '"forRoundIndex" must be a non-negative integer' });
|
|
5524
|
+
return;
|
|
5525
|
+
}
|
|
5526
|
+
try {
|
|
5527
|
+
const updated = await ctx.projectRoundRunner.recordAck(project.id, idx);
|
|
5528
|
+
if (!updated) {
|
|
5529
|
+
res.status(404).json({ error: 'project not found' });
|
|
5530
|
+
return;
|
|
5531
|
+
}
|
|
5532
|
+
res.json({
|
|
5533
|
+
id: updated.id,
|
|
5534
|
+
firstLaunchAckAt: updated.firstLaunchAckAt,
|
|
5535
|
+
lastAckedRoundIndex: updated.lastAckedRoundIndex,
|
|
5536
|
+
unacknowledgedAdvanceCount: updated.unacknowledgedAdvanceCount,
|
|
5537
|
+
version: updated.version,
|
|
5538
|
+
});
|
|
5539
|
+
}
|
|
5540
|
+
catch (err) {
|
|
5541
|
+
if (err instanceof Error && err.name === 'OccVersionMismatchError') {
|
|
5542
|
+
const cv = err.currentVersion;
|
|
5543
|
+
res.status(409).json({ error: 'version mismatch', currentVersion: cv });
|
|
5544
|
+
return;
|
|
5545
|
+
}
|
|
5546
|
+
res.status(400).json({ error: err instanceof Error ? err.message : String(err) });
|
|
5547
|
+
}
|
|
5548
|
+
});
|
|
5549
|
+
// POST /projects/:id/accept-partial — close a partially-complete round.
|
|
5550
|
+
router.post('/projects/:id/accept-partial', async (req, res) => {
|
|
5551
|
+
const project = lookupProject(req, res);
|
|
5552
|
+
if (!project)
|
|
5553
|
+
return;
|
|
5554
|
+
if (!ctx.projectRoundRunner) {
|
|
5555
|
+
res.status(503).json({ error: 'ProjectRoundRunner not configured' });
|
|
5556
|
+
return;
|
|
5557
|
+
}
|
|
5558
|
+
const body = (req.body ?? {});
|
|
5559
|
+
const idx = typeof body.roundIndex === 'number' ? body.roundIndex : -1;
|
|
5560
|
+
const reason = typeof body.reason === 'string' ? body.reason : '';
|
|
5561
|
+
const skippedBy = typeof body.skippedBy === 'string' ? body.skippedBy : '';
|
|
5562
|
+
if (!Number.isInteger(idx) || idx < 0) {
|
|
5563
|
+
res.status(400).json({ error: '"roundIndex" must be a non-negative integer' });
|
|
5564
|
+
return;
|
|
5565
|
+
}
|
|
5566
|
+
if (!reason.trim()) {
|
|
5567
|
+
res.status(400).json({ error: '"reason" required' });
|
|
5568
|
+
return;
|
|
5569
|
+
}
|
|
5570
|
+
if (!skippedBy.trim()) {
|
|
5571
|
+
res.status(400).json({ error: '"skippedBy" required' });
|
|
5572
|
+
return;
|
|
5573
|
+
}
|
|
5574
|
+
try {
|
|
5575
|
+
const result = await ctx.projectRoundRunner.acceptPartial(project.id, idx, reason, skippedBy);
|
|
5576
|
+
if (!result) {
|
|
5577
|
+
res.status(404).json({ error: 'project or round not found' });
|
|
5578
|
+
return;
|
|
5579
|
+
}
|
|
5580
|
+
res.json({
|
|
5581
|
+
id: result.project.id,
|
|
5582
|
+
skippedItemIds: result.skippedItemIds,
|
|
5583
|
+
version: result.project.version,
|
|
5584
|
+
});
|
|
5585
|
+
}
|
|
5586
|
+
catch (err) {
|
|
5587
|
+
if (err instanceof Error && err.name === 'OccVersionMismatchError') {
|
|
5588
|
+
const cv = err.currentVersion;
|
|
5589
|
+
res.status(409).json({ error: 'version mismatch', currentVersion: cv });
|
|
5590
|
+
return;
|
|
5591
|
+
}
|
|
5592
|
+
res.status(400).json({ error: err instanceof Error ? err.message : String(err) });
|
|
5593
|
+
}
|
|
5594
|
+
});
|
|
5595
|
+
// POST /projects/:id/claim-ownership — multi-machine ownership transfer.
|
|
5596
|
+
//
|
|
5597
|
+
// Body: {} or { force?: boolean }
|
|
5598
|
+
// Header: If-Match: <version> (OCC)
|
|
5599
|
+
//
|
|
5600
|
+
// Spec § P5: the claimer must (a) commit-and-push the claim before
|
|
5601
|
+
// acting on it, (b) wait 60s for git-sync to converge, (c) re-read
|
|
5602
|
+
// before any auto-action. This endpoint ONLY records the ownership
|
|
5603
|
+
// change; the wait-and-converge happens at the caller level (the
|
|
5604
|
+
// round runner, the auto-advance poller, the dashboard). Claim is
|
|
5605
|
+
// refused (409) if the current owner's heartbeat is still fresh and
|
|
5606
|
+
// `force: true` was not set.
|
|
5607
|
+
router.post('/projects/:id/claim-ownership', async (req, res) => {
|
|
5608
|
+
const project = lookupProject(req, res);
|
|
5609
|
+
if (!project)
|
|
5610
|
+
return;
|
|
5611
|
+
if (!ctx.initiativeTracker)
|
|
5612
|
+
return; // narrow for TS
|
|
5613
|
+
if (!ctx.machineHeartbeat || !ctx.machineHeartbeat.config) {
|
|
5614
|
+
res.status(503).json({ error: 'machine heartbeat not configured' });
|
|
5615
|
+
return;
|
|
5616
|
+
}
|
|
5617
|
+
const ifMatch = parseIfMatch(req, res);
|
|
5618
|
+
if (ifMatch === null)
|
|
5619
|
+
return;
|
|
5620
|
+
if (ifMatch !== project.version) {
|
|
5621
|
+
res.status(409).json({ error: 'version mismatch', currentVersion: project.version });
|
|
5622
|
+
return;
|
|
5623
|
+
}
|
|
5624
|
+
const force = (req.body ?? {}).force === true;
|
|
5625
|
+
const heartbeat = ctx.machineHeartbeat.api;
|
|
5626
|
+
const currentOwner = project.ownerMachineId;
|
|
5627
|
+
const claimer = ctx.machineHeartbeat.config.machineId;
|
|
5628
|
+
if (currentOwner === claimer) {
|
|
5629
|
+
// Already owned by claimer — idempotent success.
|
|
5630
|
+
res.json({ id: project.id, ownerMachineId: claimer, version: project.version, alreadyOwned: true });
|
|
5631
|
+
return;
|
|
5632
|
+
}
|
|
5633
|
+
if (currentOwner && !heartbeat.isStale(currentOwner) && !force) {
|
|
5634
|
+
res.status(409).json({
|
|
5635
|
+
error: 'current owner heartbeat is still fresh; pass {force:true} to claim anyway',
|
|
5636
|
+
currentOwner,
|
|
5637
|
+
});
|
|
5638
|
+
return;
|
|
5639
|
+
}
|
|
5640
|
+
try {
|
|
5641
|
+
const updated = await ctx.initiativeTracker.update(project.id, {
|
|
5642
|
+
ownerMachineId: claimer,
|
|
5643
|
+
ifMatch: project.version,
|
|
5644
|
+
});
|
|
5645
|
+
res.json({
|
|
5646
|
+
id: updated.id,
|
|
5647
|
+
ownerMachineId: updated.ownerMachineId,
|
|
5648
|
+
previousOwner: currentOwner ?? null,
|
|
5649
|
+
version: updated.version,
|
|
5650
|
+
});
|
|
5651
|
+
}
|
|
5652
|
+
catch (err) {
|
|
5653
|
+
if (err instanceof Error && err.name === 'OccVersionMismatchError') {
|
|
5654
|
+
const cv = err.currentVersion;
|
|
5655
|
+
res.status(409).json({ error: 'version mismatch', currentVersion: cv });
|
|
5656
|
+
return;
|
|
5657
|
+
}
|
|
5658
|
+
res.status(400).json({ error: err instanceof Error ? err.message : String(err) });
|
|
5659
|
+
}
|
|
5660
|
+
});
|
|
5661
|
+
// POST /projects/:id/drift-check — run the drift checker for a given
|
|
5662
|
+
// round and return the verdict. Wraps ProjectDriftChecker.run().
|
|
5663
|
+
//
|
|
5664
|
+
// Body: { roundIndex: number, specPath: string, referencedFiles: string[],
|
|
5665
|
+
// timeoutMs?: number, modelId?: string }
|
|
5666
|
+
//
|
|
5667
|
+
// Mutex-guarded per project (spec § Phase 1.5 line 279). Without it two
|
|
5668
|
+
// concurrent calls would double-spend the drift-spend ledger and
|
|
5669
|
+
// double-bill the LLM. A second concurrent call returns 409 with
|
|
5670
|
+
// `{error: 'drift-check already in flight'}` so the caller can poll.
|
|
5671
|
+
//
|
|
5672
|
+
// 400 on validation; 503 if no checker configured; 200 with verdict envelope
|
|
5673
|
+
// on success. We do NOT require If-Match: drift is a read-only signal.
|
|
5674
|
+
const driftInFlight = new Set();
|
|
5675
|
+
router.post('/projects/:id/drift-check', async (req, res) => {
|
|
5676
|
+
if (!ctx.initiativeTracker) {
|
|
5677
|
+
res.status(503).json({ error: 'Initiative tracker not configured' });
|
|
5678
|
+
return;
|
|
5679
|
+
}
|
|
5680
|
+
if (!ctx.projectDriftChecker) {
|
|
5681
|
+
res.status(503).json({ error: 'ProjectDriftChecker not configured' });
|
|
5682
|
+
return;
|
|
5683
|
+
}
|
|
5684
|
+
if (!initiativeIdRe.test(req.params.id)) {
|
|
5685
|
+
res.status(400).json({ error: 'invalid project id' });
|
|
5686
|
+
return;
|
|
5687
|
+
}
|
|
5688
|
+
const project = ctx.initiativeTracker.get(req.params.id);
|
|
5689
|
+
if (!project || (project.kind ?? 'task') !== 'project') {
|
|
5690
|
+
res.status(404).json({ error: 'project not found' });
|
|
5691
|
+
return;
|
|
5692
|
+
}
|
|
5693
|
+
if (!project.targetRepoPath) {
|
|
5694
|
+
res.status(400).json({ error: 'project has no targetRepoPath' });
|
|
5695
|
+
return;
|
|
5696
|
+
}
|
|
5697
|
+
const body = (req.body ?? {});
|
|
5698
|
+
const roundIndex = typeof body.roundIndex === 'number' ? body.roundIndex : -1;
|
|
5699
|
+
const specPath = typeof body.specPath === 'string' ? body.specPath : '';
|
|
5700
|
+
const referencedFiles = Array.isArray(body.referencedFiles)
|
|
5701
|
+
? body.referencedFiles.filter((f) => typeof f === 'string')
|
|
5702
|
+
: [];
|
|
5703
|
+
if (!Number.isInteger(roundIndex) || roundIndex < 0) {
|
|
5704
|
+
res.status(400).json({ error: '"roundIndex" (non-negative integer) required' });
|
|
5705
|
+
return;
|
|
5706
|
+
}
|
|
5707
|
+
if (!specPath.trim()) {
|
|
5708
|
+
res.status(400).json({ error: '"specPath" (string) required' });
|
|
5709
|
+
return;
|
|
5710
|
+
}
|
|
5711
|
+
const rounds = project.rounds ?? [];
|
|
5712
|
+
if (roundIndex >= rounds.length) {
|
|
5713
|
+
res.status(400).json({ error: 'roundIndex out of range', rounds: rounds.length });
|
|
5714
|
+
return;
|
|
5715
|
+
}
|
|
5716
|
+
if (driftInFlight.has(project.id)) {
|
|
5717
|
+
res.status(409).json({ error: 'drift-check already in flight for this project' });
|
|
5718
|
+
return;
|
|
5719
|
+
}
|
|
5720
|
+
driftInFlight.add(project.id);
|
|
5721
|
+
try {
|
|
5722
|
+
const verdict = await ctx.projectDriftChecker.run({
|
|
5723
|
+
projectId: project.id,
|
|
5724
|
+
roundIndex,
|
|
5725
|
+
targetRepoPath: project.targetRepoPath,
|
|
5726
|
+
specPath,
|
|
5727
|
+
referencedFiles,
|
|
5728
|
+
timeoutMs: typeof body.timeoutMs === 'number' ? body.timeoutMs : undefined,
|
|
5729
|
+
modelId: typeof body.modelId === 'string' ? body.modelId : undefined,
|
|
5730
|
+
});
|
|
5731
|
+
res.json({ verdict, projectId: project.id, roundIndex });
|
|
5732
|
+
}
|
|
5733
|
+
catch (err) {
|
|
5734
|
+
res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
|
|
5735
|
+
}
|
|
5736
|
+
finally {
|
|
5737
|
+
driftInFlight.delete(project.id);
|
|
5738
|
+
}
|
|
5739
|
+
});
|
|
5157
5740
|
router.post('/projects', async (req, res) => {
|
|
5158
5741
|
if (!ctx.initiativeTracker) {
|
|
5159
5742
|
res.status(503).json({ error: 'Initiative tracker not configured' });
|