@juzser/blacksmith 0.3.0 → 0.5.0
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/.claude/agents/{auditor.md → bs-auditor.md} +8 -5
- package/.claude/agents/{coder.md → bs-coder.md} +8 -7
- package/.claude/agents/{grader.md → bs-grader.md} +16 -9
- package/.claude/agents/{merger.md → bs-merger.md} +4 -4
- package/.claude/agents/{planner.md → bs-planner.md} +20 -6
- package/.claude/agents/{researcher.md → bs-researcher.md} +6 -5
- package/.claude/agents/{reviewer.md → bs-reviewer.md} +16 -5
- package/.claude/agents/{scribe.md → bs-scribe.md} +11 -2
- package/.claude/agents/{security-reviewer.md → bs-security-reviewer.md} +9 -6
- package/.claude/agents/{spec-reviewer.md → bs-spec-reviewer.md} +18 -10
- package/.claude/agents/{tester.md → bs-tester.md} +40 -12
- package/.claude/agents/bs-uiux.md +257 -0
- package/.claude/agents/{verifier.md → bs-verifier.md} +7 -4
- package/.claude/agents/{wave-runner.md → bs-wave-runner.md} +14 -5
- package/.claude/skills/bs/SKILL.md +99 -21
- package/.claude/skills/bs/audit.md +45 -31
- package/.claude/skills/bs/dispatch.md +133 -47
- package/.claude/skills/bs/lessons.md +28 -14
- package/.claude/skills/bs/mcp.md +4 -4
- package/.claude/skills/bs/new.md +3 -3
- package/.claude/skills/bs/plan.md +57 -16
- package/.claude/skills/bs/report.md +11 -11
- package/.claude/skills/bs/run.md +190 -52
- package/.claude/skills/bs/status.md +14 -7
- package/.claude/skills/bs/ui.md +21 -5
- package/.claude/skills/bs/waivers.md +78 -2
- package/.claude/skills/bs/wave.md +143 -50
- package/README.md +53 -32
- package/factory/orchestrator/dist/agentNames.js +43 -0
- package/factory/orchestrator/dist/agents-registry.js +11 -4
- package/factory/orchestrator/dist/agentsSync.js +70 -13
- package/factory/orchestrator/dist/artifacts.js +31 -0
- package/factory/orchestrator/dist/attribution.js +4 -0
- package/factory/orchestrator/dist/audit.js +245 -20
- package/factory/orchestrator/dist/autonomy.js +6 -2
- package/factory/orchestrator/dist/budgetAlarm.js +132 -37
- package/factory/orchestrator/dist/budgets.js +302 -84
- package/factory/orchestrator/dist/claims.js +9 -19
- package/factory/orchestrator/dist/cli.js +1087 -116
- package/factory/orchestrator/dist/cliName.js +27 -0
- package/factory/orchestrator/dist/crossFinding.js +20 -0
- package/factory/orchestrator/dist/crosscheck.js +136 -8
- package/factory/orchestrator/dist/daemon.js +352 -67
- package/factory/orchestrator/dist/db/projector.js +327 -54
- package/factory/orchestrator/dist/db/queries.js +1831 -94
- package/factory/orchestrator/dist/db/schema.js +57 -4
- package/factory/orchestrator/dist/declaredArtifactLine.js +18 -0
- package/factory/orchestrator/dist/delegation.js +86 -19
- package/factory/orchestrator/dist/diffstat.js +15 -8
- package/factory/orchestrator/dist/dispatchLint.js +108 -0
- package/factory/orchestrator/dist/effort.js +14 -2
- package/factory/orchestrator/dist/effortTiers.js +13 -0
- package/factory/orchestrator/dist/env.js +26 -0
- package/factory/orchestrator/dist/epic.js +494 -42
- package/factory/orchestrator/dist/epicBudget.js +93 -0
- package/factory/orchestrator/dist/epicWidth.js +22 -5
- package/factory/orchestrator/dist/errorIssues.js +182 -11
- package/factory/orchestrator/dist/escalation.js +2 -2
- package/factory/orchestrator/dist/events.js +164 -14
- package/factory/orchestrator/dist/feedback.js +169 -0
- package/factory/orchestrator/dist/findingContext.js +26 -0
- package/factory/orchestrator/dist/findings.js +108 -14
- package/factory/orchestrator/dist/gate.js +120 -10
- package/factory/orchestrator/dist/gh.js +19 -2
- package/factory/orchestrator/dist/git.js +20 -2
- package/factory/orchestrator/dist/goalCheck.js +2 -2
- package/factory/orchestrator/dist/harness.js +25 -17
- package/factory/orchestrator/dist/hookDecision.js +411 -39
- package/factory/orchestrator/dist/integration.js +20 -8
- package/factory/orchestrator/dist/issueReporter.js +81 -16
- package/factory/orchestrator/dist/judgePreflight.js +25 -4
- package/factory/orchestrator/dist/judgeRoles.js +47 -0
- package/factory/orchestrator/dist/judgeStopHook.js +239 -0
- package/factory/orchestrator/dist/judges.js +419 -21
- package/factory/orchestrator/dist/lessons.js +190 -11
- package/factory/orchestrator/dist/logCache.js +291 -0
- package/factory/orchestrator/dist/mcp.js +6 -6
- package/factory/orchestrator/dist/paths.js +20 -7
- package/factory/orchestrator/dist/plan.js +399 -33
- package/factory/orchestrator/dist/planQuorum.js +5 -2
- package/factory/orchestrator/dist/policy.js +1275 -30
- package/factory/orchestrator/dist/projectName.js +25 -0
- package/factory/orchestrator/dist/projects.js +12 -1
- package/factory/orchestrator/dist/promptCapture.js +306 -0
- package/factory/orchestrator/dist/promptHook.js +33 -0
- package/factory/orchestrator/dist/promptImport.js +281 -0
- package/factory/orchestrator/dist/prompts.js +13 -0
- package/factory/orchestrator/dist/providers/schema-validate.js +2 -2
- package/factory/orchestrator/dist/providers/types.js +1 -1
- package/factory/orchestrator/dist/queue.js +471 -30
- package/factory/orchestrator/dist/quorum.js +18 -5
- package/factory/orchestrator/dist/results.js +25 -7
- package/factory/orchestrator/dist/roadmap.js +10 -5
- package/factory/orchestrator/dist/run-cli.js +15 -10
- package/factory/orchestrator/dist/runner.js +2 -2
- package/factory/orchestrator/dist/scheduler.js +3 -3
- package/factory/orchestrator/dist/schemas.js +56 -8
- package/factory/orchestrator/dist/severity.js +8 -4
- package/factory/orchestrator/dist/spec.js +246 -12
- package/factory/orchestrator/dist/specChange.js +13 -9
- package/factory/orchestrator/dist/stack.js +6 -6
- package/factory/orchestrator/dist/storeRoot.js +90 -0
- package/factory/orchestrator/dist/symbols.js +261 -6
- package/factory/orchestrator/dist/taskEvents.js +192 -31
- package/factory/orchestrator/dist/taskStatus.js +14 -0
- package/factory/orchestrator/dist/taxonomy.js +11 -0
- package/factory/orchestrator/dist/testerAudit.js +31 -12
- package/factory/orchestrator/dist/testgate.js +20 -16
- package/factory/orchestrator/dist/uiuxGate.js +83 -0
- package/factory/orchestrator/dist/usage.js +113 -48
- package/factory/orchestrator/dist/waivers.js +99 -12
- package/factory/orchestrator/dist/waveBudget.js +2 -0
- package/factory/orchestrator/dist/waveConcurrency.js +143 -7
- package/factory/orchestrator/dist/waveSchedule.js +84 -0
- package/factory/orchestrator/dist/workroot.js +1 -1
- package/factory/orchestrator/dist/worktree.js +76 -21
- package/factory/orchestrator/drizzle/0013_operator_feedback.sql +19 -0
- package/factory/orchestrator/drizzle/0014_misty_madripoor.sql +2 -0
- package/factory/orchestrator/drizzle/0015_gifted_omega_flight.sql +1 -0
- package/factory/orchestrator/drizzle/0016_milestones_project_default.sql +30 -0
- package/factory/orchestrator/drizzle/0017_events_raw_cli_session_id.sql +2 -0
- package/factory/orchestrator/drizzle/meta/_journal.json +35 -0
- package/factory/policies/budgets.yml +123 -33
- package/factory/policies/crosscheck.yml +51 -13
- package/factory/policies/delegation.yml +3 -3
- package/factory/policies/effort.yml +18 -10
- package/factory/policies/guardrails.yml +8 -6
- package/factory/policies/harness.yml +5 -5
- package/factory/policies/lessons.md +53 -2
- package/factory/policies/scheduler.yml +4 -4
- package/factory/policies/sensitive-paths.yml +1 -1
- package/factory/policies/severity.yml +13 -1
- package/factory/policies/stack.yml +6 -6
- package/factory/policies/taxonomy.yml +9 -3
- package/factory/policies/worktree.yml +15 -9
- package/factory/specs/roadmap.md +27 -3
- package/factory/specs/schema/event.schema.json +8 -1
- package/factory/specs/schema/result.schema.json +21 -8
- package/factory/specs/schema/task-spec.schema.json +12 -0
- package/package.json +16 -12
- package/.claude/agents/uiux.md +0 -133
|
@@ -4,21 +4,27 @@
|
|
|
4
4
|
// omitted, a query spans every projected session (a single Blacksmith
|
|
5
5
|
// instance is one continuously-running factory, so "no session filter"
|
|
6
6
|
// is the normal case; a session filter is for debugging one run).
|
|
7
|
-
import { and, eq, gte, inArray, lte } from 'drizzle-orm';
|
|
7
|
+
import { and, eq, gte, inArray, isNotNull, lte, max, sql } from 'drizzle-orm';
|
|
8
8
|
import { isOperatorActor } from '../actors.js';
|
|
9
|
-
import { foldAgents, liveAgents as foldLiveAgents, isWorkingAt, REGISTRY_EVENT_TYPES, } from '../agents-registry.js';
|
|
9
|
+
import { DISPATCH_EVENT_TYPE, ERROR_EVENT_TYPE, foldAgents, liveAgents as foldLiveAgents, isWorkingAt, JUDGE_REPORT_EVENT_TYPE, JUDGE_VERDICT_EVENT_TYPE, REGISTRY_EVENT_TYPES, TASK_RESULT_EVENT_TYPE, } from '../agents-registry.js';
|
|
10
|
+
import { isAuditAxisRowId } from '../audit.js';
|
|
11
|
+
import { readTokenUsage } from '../budgetAlarm.js';
|
|
12
|
+
import { SmithError } from '../errors.js';
|
|
10
13
|
import { compareLogOrder, isLaterEvent, parseEventId, ROOT_EVENT_TYPE } from '../events.js';
|
|
11
14
|
import { OPEN_FINDING_STATUSES, WAIVABLE_STATUSES } from '../findings.js';
|
|
12
15
|
import { waveLayers } from '../graph.js';
|
|
16
|
+
import { JUDGE_ROLES } from '../judgeRoles.js';
|
|
17
|
+
import { FACTORY_PROJECT_NAME, normalizeProjectName } from '../projectName.js';
|
|
13
18
|
import { judgeFailureKind } from '../providers/types.js';
|
|
14
19
|
import { severityRank } from '../severity.js';
|
|
15
|
-
import { epicOfTaskId, taskIdsMatch } from '../taskId.js';
|
|
16
|
-
import { TERMINAL_OK_TASK_STATUSES, TERMINAL_TASK_STATUSES } from '../taskStatus.js';
|
|
20
|
+
import { bareTaskId, epicOfTaskId, taskIdsMatch } from '../taskId.js';
|
|
21
|
+
import { CLOSED_TO_FURTHER_WORK, TERMINAL_OK_TASK_STATUSES, TERMINAL_TASK_STATUSES, } from '../taskStatus.js';
|
|
17
22
|
import { loadTaxonomy } from '../taxonomy.js';
|
|
18
23
|
import { WAIVABLE_SEVERITIES } from '../waivers.js';
|
|
19
|
-
import {
|
|
24
|
+
import { unanimousEpicProjects } from './projector.js';
|
|
25
|
+
import { agents, artifacts, dispatches, edges, epics, errors, eventsRaw, findings, lessons, milestones, operatorFeedback, prompts, sessions, tasks, waivers, } from './schema.js';
|
|
20
26
|
/** Phase 6b default: every event/task/etc. logged before the project dimension existed. */
|
|
21
|
-
export const DEFAULT_PROJECT =
|
|
27
|
+
export const DEFAULT_PROJECT = FACTORY_PROJECT_NAME;
|
|
22
28
|
/**
|
|
23
29
|
* `null` (never-tagged, pre-Phase-6b) reads as DEFAULT_PROJECT, same as the
|
|
24
30
|
* schema.ts columns' doc comment promises. Project scoping is applied
|
|
@@ -31,7 +37,7 @@ export const DEFAULT_PROJECT = 'black-smith';
|
|
|
31
37
|
* array — one filter step ahead of the aggregation, not a SQL rewrite).
|
|
32
38
|
*/
|
|
33
39
|
function projectOf(value) {
|
|
34
|
-
return value ?? DEFAULT_PROJECT;
|
|
40
|
+
return normalizeProjectName(value ?? DEFAULT_PROJECT);
|
|
35
41
|
}
|
|
36
42
|
/**
|
|
37
43
|
* The session narrowing for one `session_id` column: `IN (lineage)` when the
|
|
@@ -205,11 +211,19 @@ function projectedContinuations(db, sessionId) {
|
|
|
205
211
|
}
|
|
206
212
|
return out;
|
|
207
213
|
}
|
|
214
|
+
/**
|
|
215
|
+
* `scope.project`, normalized through `normalizeProjectName` -- the one place
|
|
216
|
+
* a caller's REQUESTED project value (an old bookmark, a CLI `--project
|
|
217
|
+
* black-smith`) is read, so every comparison against it sees today's
|
|
218
|
+
* spelling without every call site having to remember to normalize first.
|
|
219
|
+
*/
|
|
220
|
+
function scopeProject(scope) {
|
|
221
|
+
return scope.project === undefined ? undefined : normalizeProjectName(scope.project);
|
|
222
|
+
}
|
|
208
223
|
/** Filters an already-fetched row array to `scope.project`; a no-op in global mode (project omitted). */
|
|
209
224
|
function filterByProject(rows, scope) {
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
: rows;
|
|
225
|
+
const project = scopeProject(scope);
|
|
226
|
+
return project !== undefined ? rows.filter((r) => projectOf(r.project) === project) : rows;
|
|
213
227
|
}
|
|
214
228
|
/** Rows in the order the log wrote them, oldest first. Never mutates the input. */
|
|
215
229
|
function inLogOrder(rows) {
|
|
@@ -328,14 +342,31 @@ function epicTokenMaps(db, scope, taskRows) {
|
|
|
328
342
|
}
|
|
329
343
|
const epicOf = epicResolver(epicByTask);
|
|
330
344
|
const spentByEpic = new Map();
|
|
345
|
+
const unmeasuredByEpic = new Map();
|
|
331
346
|
for (const row of taskResultRows(db, scope)) {
|
|
332
347
|
const epicId = epicOf(resultTaskId(row));
|
|
333
348
|
if (!epicId)
|
|
334
349
|
continue;
|
|
335
|
-
const
|
|
336
|
-
|
|
350
|
+
const usage = readTokenUsage(row.payload.token_usage);
|
|
351
|
+
if (usage.measured) {
|
|
352
|
+
spentByEpic.set(epicId, (spentByEpic.get(epicId) ?? 0) + usage.total);
|
|
353
|
+
}
|
|
354
|
+
else {
|
|
355
|
+
spentByEpic.set(epicId, spentByEpic.get(epicId) ?? 0);
|
|
356
|
+
unmeasuredByEpic.set(epicId, (unmeasuredByEpic.get(epicId) ?? 0) + 1);
|
|
357
|
+
}
|
|
337
358
|
}
|
|
338
|
-
return { budgetByEpic, spentByEpic };
|
|
359
|
+
return { budgetByEpic, spentByEpic, unmeasuredByEpic };
|
|
360
|
+
}
|
|
361
|
+
function epicTokenSpends(maps) {
|
|
362
|
+
const { budgetByEpic, spentByEpic, unmeasuredByEpic } = maps;
|
|
363
|
+
const epicIds = new Set([...budgetByEpic.keys(), ...spentByEpic.keys()]);
|
|
364
|
+
return [...epicIds].sort().map((epicId) => ({
|
|
365
|
+
epicId,
|
|
366
|
+
tokensSpent: spentByEpic.get(epicId) ?? 0,
|
|
367
|
+
tokensBudget: budgetByEpic.get(epicId) ?? null,
|
|
368
|
+
unmeasured: unmeasuredByEpic.get(epicId) ?? 0,
|
|
369
|
+
}));
|
|
339
370
|
}
|
|
340
371
|
/**
|
|
341
372
|
* NEXT hides the statuses the plan has nothing left to ask of — every
|
|
@@ -404,6 +435,76 @@ function milestoneTaskRefs(milestoneTasks, allTaskRows, edgeRows) {
|
|
|
404
435
|
}));
|
|
405
436
|
return { recentDone, nextUp };
|
|
406
437
|
}
|
|
438
|
+
/**
|
|
439
|
+
* DS4 S5b fix round 1 — folds one `task_status` into the same grouping
|
|
440
|
+
* `ui/src/lib/kanban.ts`'s `COLUMN_FOR_STATUS` already uses for the Kanban
|
|
441
|
+
* board's columns, condensed from its 5 (+2 hidden) columns to the Roadmap's
|
|
442
|
+
* 5:
|
|
443
|
+
* - Kanban's "Todo" column (todo, ready) -> 'todo'.
|
|
444
|
+
* - Kanban's "Reviewing" column (reviewing, merging) -> 'review'.
|
|
445
|
+
* - Kanban's "Completed" column (completed, waived) -> 'done': exactly
|
|
446
|
+
* `TERMINAL_OK_TASK_STATUSES`, the same set `tasksCompleted` on the same
|
|
447
|
+
* payload already uses, so the two numbers never disagree (fix round 1,
|
|
448
|
+
* finding 1).
|
|
449
|
+
* - `superseded` -> its own bucket, not `done`: a replanned-away task ended
|
|
450
|
+
* the diff, not well, and folding it into `done` read an epic that was
|
|
451
|
+
* replanned away as fully shipped. Kept out of the switch's `default` on
|
|
452
|
+
* purpose, same reason: nobody confuses it with the fail-open case below.
|
|
453
|
+
* - Everything else (Kanban's "In progress" and "Blocked" columns, plus
|
|
454
|
+
* `failed`) -> 'inProgress': still open work, whether or not somebody is
|
|
455
|
+
* actively on it right now. `failed` is terminal to the projector but a
|
|
456
|
+
* person is still holding it (taskStatus.ts's HELD_OPEN_BY_AN_OPERATOR),
|
|
457
|
+
* same as `escalated`/`blocked` already here. A status this switch
|
|
458
|
+
* doesn't yet know about (taxonomy.yml adding a 13th tomorrow) falls here
|
|
459
|
+
* too — open rather than silently counted as done.
|
|
460
|
+
* Exhaustive over this fold (every input lands in exactly one bucket), so the
|
|
461
|
+
* five counts a caller builds from it always sum to the row count they were
|
|
462
|
+
* built from.
|
|
463
|
+
*/
|
|
464
|
+
export function statusBucketForTaskStatus(taskStatus) {
|
|
465
|
+
if (TERMINAL_OK_TASK_STATUSES.has(taskStatus))
|
|
466
|
+
return 'done';
|
|
467
|
+
switch (taskStatus) {
|
|
468
|
+
case 'todo':
|
|
469
|
+
case 'ready':
|
|
470
|
+
return 'todo';
|
|
471
|
+
case 'reviewing':
|
|
472
|
+
case 'merging':
|
|
473
|
+
return 'review';
|
|
474
|
+
case 'superseded':
|
|
475
|
+
return 'superseded';
|
|
476
|
+
default:
|
|
477
|
+
return 'inProgress';
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
function countStatuses(rows) {
|
|
481
|
+
const counts = { done: 0, review: 0, inProgress: 0, todo: 0, superseded: 0 };
|
|
482
|
+
for (const row of rows)
|
|
483
|
+
counts[statusBucketForTaskStatus(row.taskStatus)] += 1;
|
|
484
|
+
return counts;
|
|
485
|
+
}
|
|
486
|
+
/**
|
|
487
|
+
* DS4 S5b fix round 1 — EpicStatus from a StatusCounts + its total, over the
|
|
488
|
+
* epic's LIVE tasks (`tasksTotal` minus `superseded`): a replanned-away task
|
|
489
|
+
* is not part of what's left to ship, so it neither counts toward `done` nor
|
|
490
|
+
* holds the epic open. Every live task done is `done`; any task in the
|
|
491
|
+
* review or inProgress buckets means at least `in_progress`, rising to
|
|
492
|
+
* `review` only when every non-done live task is itself in the review bucket
|
|
493
|
+
* (no inProgress, no todo left over); some tasks done with the rest not
|
|
494
|
+
* all done is `in_progress` even between waves; otherwise, with nothing
|
|
495
|
+
* done and nothing open yet, `todo` — also the answer for a zero-task epic
|
|
496
|
+
* and for an epic whose tasks are ALL superseded (`live === 0`, so
|
|
497
|
+
* `done === live` is never true).
|
|
498
|
+
*/
|
|
499
|
+
function epicStatusFromCounts(counts, tasksTotal) {
|
|
500
|
+
const live = tasksTotal - counts.superseded;
|
|
501
|
+
if (live > 0 && counts.done === live)
|
|
502
|
+
return 'done';
|
|
503
|
+
if (counts.review > 0 || counts.inProgress > 0) {
|
|
504
|
+
return counts.inProgress === 0 && counts.todo === 0 ? 'review' : 'in_progress';
|
|
505
|
+
}
|
|
506
|
+
return counts.done > 0 ? 'in_progress' : 'todo';
|
|
507
|
+
}
|
|
407
508
|
/**
|
|
408
509
|
* Join factory/specs/roadmap.md's milestones (db/schema.ts's `milestones`
|
|
409
510
|
* table) with each milestone's mapped epics' task/token stats. A milestone
|
|
@@ -414,19 +515,27 @@ function milestoneTaskRefs(milestoneTasks, allTaskRows, edgeRows) {
|
|
|
414
515
|
*/
|
|
415
516
|
function milestoneProgressRows(db, scope = {}, opts = {}) {
|
|
416
517
|
const allMilestoneRows = db.select().from(milestones).orderBy(milestones.sequence).all();
|
|
417
|
-
const
|
|
418
|
-
|
|
518
|
+
const project = scopeProject(scope);
|
|
519
|
+
const milestoneRows = project !== undefined
|
|
520
|
+
? allMilestoneRows.filter((m) => m.project === project)
|
|
419
521
|
: allMilestoneRows;
|
|
420
522
|
if (milestoneRows.length === 0)
|
|
421
523
|
return [];
|
|
422
524
|
const taskRows = allTasksForScope(db, scope);
|
|
423
|
-
const { budgetByEpic, spentByEpic } = epicTokenMaps(db, scope, taskRows);
|
|
525
|
+
const { budgetByEpic, spentByEpic, unmeasuredByEpic } = epicTokenMaps(db, scope, taskRows);
|
|
424
526
|
const edgeSessionCond = scopedToSessions(edges.sessionId, scope);
|
|
425
527
|
const edgeRows = opts.includeTaskRefs
|
|
426
528
|
? edgeSessionCond
|
|
427
529
|
? db.select().from(edges).where(edgeSessionCond).all()
|
|
428
530
|
: db.select().from(edges).all()
|
|
429
531
|
: [];
|
|
532
|
+
const dispatchStartByTask = earliestDispatchByTask(db, scope);
|
|
533
|
+
// DS4 S5b — both prefetched once per call: prUrlByEpic is one scan of
|
|
534
|
+
// eventsRaw (same read kanban() shares via prUrlsByEpic()); epicPromptMemo
|
|
535
|
+
// bounds epicSourcePrompt()'s lineage walk to once per distinct epic id,
|
|
536
|
+
// not once per task, mirroring dispatchStartByTask's per-call prefetch above.
|
|
537
|
+
const prUrlByEpic = prUrlsByEpic(db);
|
|
538
|
+
const epicPromptMemo = new Map();
|
|
430
539
|
return milestoneRows.map((m) => {
|
|
431
540
|
const epicIds = JSON.parse(m.epicIds);
|
|
432
541
|
const epicSet = new Set(epicIds);
|
|
@@ -435,8 +544,10 @@ function milestoneProgressRows(db, scope = {}, opts = {}) {
|
|
|
435
544
|
let tokensSpent = 0;
|
|
436
545
|
let tokensBudget = 0;
|
|
437
546
|
let hasBudget = false;
|
|
547
|
+
let unmeasured = 0;
|
|
438
548
|
for (const epicId of epicIds) {
|
|
439
549
|
tokensSpent += spentByEpic.get(epicId) ?? 0;
|
|
550
|
+
unmeasured += unmeasuredByEpic.get(epicId) ?? 0;
|
|
440
551
|
const budget = budgetByEpic.get(epicId);
|
|
441
552
|
if (budget !== undefined) {
|
|
442
553
|
tokensBudget += budget;
|
|
@@ -446,6 +557,28 @@ function milestoneProgressRows(db, scope = {}, opts = {}) {
|
|
|
446
557
|
const refs = opts.includeTaskRefs
|
|
447
558
|
? milestoneTaskRefs(milestoneTasks, taskRows, edgeRows)
|
|
448
559
|
: undefined;
|
|
560
|
+
const { startedAt, finishedAt } = taskDateRange(milestoneTasks, dispatchStartByTask);
|
|
561
|
+
const epicDates = epicIds.map((epicId) => {
|
|
562
|
+
const epicTasks = milestoneTasks.filter((t) => t.epicId === epicId);
|
|
563
|
+
const statusCounts = countStatuses(epicTasks);
|
|
564
|
+
let sourcePrompt = null;
|
|
565
|
+
if (epicPromptMemo.has(epicId)) {
|
|
566
|
+
sourcePrompt = epicPromptMemo.get(epicId) ?? null;
|
|
567
|
+
}
|
|
568
|
+
else if (epicTasks[0] !== undefined) {
|
|
569
|
+
sourcePrompt = epicSourcePrompt(db, epicTasks[0].sessionId);
|
|
570
|
+
epicPromptMemo.set(epicId, sourcePrompt);
|
|
571
|
+
}
|
|
572
|
+
return {
|
|
573
|
+
epicId,
|
|
574
|
+
...taskDateRange(epicTasks, dispatchStartByTask),
|
|
575
|
+
statusCounts,
|
|
576
|
+
status: epicStatusFromCounts(statusCounts, epicTasks.length),
|
|
577
|
+
project: m.project,
|
|
578
|
+
prUrl: prUrlByEpic.get(epicId) ?? null,
|
|
579
|
+
sourcePrompt,
|
|
580
|
+
};
|
|
581
|
+
});
|
|
449
582
|
return {
|
|
450
583
|
milestoneId: m.milestoneId,
|
|
451
584
|
name: m.name,
|
|
@@ -455,15 +588,73 @@ function milestoneProgressRows(db, scope = {}, opts = {}) {
|
|
|
455
588
|
epicIds,
|
|
456
589
|
tasksTotal: milestoneTasks.length,
|
|
457
590
|
tasksCompleted,
|
|
591
|
+
statusCounts: countStatuses(milestoneTasks),
|
|
458
592
|
tokensSpent,
|
|
459
593
|
tokensBudget: hasBudget ? tokensBudget : null,
|
|
594
|
+
unmeasured,
|
|
460
595
|
project: m.project,
|
|
461
596
|
kind: m.kind,
|
|
462
597
|
errorIssuesEnabled: m.errorIssues,
|
|
598
|
+
startedAt,
|
|
599
|
+
finishedAt,
|
|
600
|
+
epics: epicDates,
|
|
463
601
|
...(refs ? { recentDone: refs.recentDone, nextUp: refs.nextUp } : {}),
|
|
464
602
|
};
|
|
465
603
|
});
|
|
466
604
|
}
|
|
605
|
+
/**
|
|
606
|
+
* Every task's earliest `dispatch_decision` timestamp, across the whole
|
|
607
|
+
* scope, in one query — not per milestone or per epic, so a roadmap with N
|
|
608
|
+
* milestones costs one scan of `dispatches` rather than N. A task never
|
|
609
|
+
* dispatched has no entry. Shared by taskDateRange()'s milestone-level and
|
|
610
|
+
* per-epic rollups.
|
|
611
|
+
*/
|
|
612
|
+
function earliestDispatchByTask(db, scope) {
|
|
613
|
+
const sessionCond = scopedToSessions(dispatches.sessionId, scope);
|
|
614
|
+
const rows = sessionCond
|
|
615
|
+
? db
|
|
616
|
+
.select({ taskId: dispatches.taskId, ts: dispatches.ts })
|
|
617
|
+
.from(dispatches)
|
|
618
|
+
.where(sessionCond)
|
|
619
|
+
.all()
|
|
620
|
+
: db.select({ taskId: dispatches.taskId, ts: dispatches.ts }).from(dispatches).all();
|
|
621
|
+
const earliest = new Map();
|
|
622
|
+
for (const row of rows) {
|
|
623
|
+
if (!row.taskId)
|
|
624
|
+
continue;
|
|
625
|
+
const current = earliest.get(row.taskId);
|
|
626
|
+
if (current === undefined || row.ts < current)
|
|
627
|
+
earliest.set(row.taskId, row.ts);
|
|
628
|
+
}
|
|
629
|
+
return earliest;
|
|
630
|
+
}
|
|
631
|
+
/**
|
|
632
|
+
* `startedAt` is the earliest dispatch among a set of task rows (via
|
|
633
|
+
* `dispatchStartByTask`, see earliestDispatchByTask()), or `null` if none of
|
|
634
|
+
* them has one. `finishedAt` is the latest `terminalAt`, but only once every
|
|
635
|
+
* row is terminal — see MilestoneProgress.startedAt for why these are the
|
|
636
|
+
* right sources. Shared by the milestone-level and per-epic rollups in
|
|
637
|
+
* milestoneProgressRows() so the two never disagree about how a date is
|
|
638
|
+
* derived.
|
|
639
|
+
*/
|
|
640
|
+
function taskDateRange(rows, dispatchStartByTask) {
|
|
641
|
+
let startedAt = null;
|
|
642
|
+
let finishedAt = null;
|
|
643
|
+
let allTerminal = true;
|
|
644
|
+
for (const row of rows) {
|
|
645
|
+
const dispatchedAt = dispatchStartByTask.get(row.taskId);
|
|
646
|
+
if (dispatchedAt !== undefined && (startedAt === null || dispatchedAt < startedAt)) {
|
|
647
|
+
startedAt = dispatchedAt;
|
|
648
|
+
}
|
|
649
|
+
if (!TERMINAL_TASK_STATUSES.has(row.taskStatus)) {
|
|
650
|
+
allTerminal = false;
|
|
651
|
+
}
|
|
652
|
+
else if (row.terminalAt !== null && (finishedAt === null || row.terminalAt > finishedAt)) {
|
|
653
|
+
finishedAt = row.terminalAt;
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
return { startedAt, finishedAt: allTerminal ? finishedAt : null };
|
|
657
|
+
}
|
|
467
658
|
/** Roadmap page (§5.4): every milestone with its progress + mini-timeline, in roadmap.md order. */
|
|
468
659
|
export function roadmapPage(db, scope = {}) {
|
|
469
660
|
return milestoneProgressRows(db, scope, { includeTaskRefs: true });
|
|
@@ -505,6 +696,118 @@ function allAgentsForScope(db, scope) {
|
|
|
505
696
|
return a.taskId !== null && scopedTasks.some((t) => taskIdsMatch(a.taskId, t.taskId));
|
|
506
697
|
});
|
|
507
698
|
}
|
|
699
|
+
/**
|
|
700
|
+
* The projects each session worked on, keyed by session id.
|
|
701
|
+
*
|
|
702
|
+
* Two sources, unioned. The tasks a session owns (tasks.session_id: the
|
|
703
|
+
* first session to touch a task) keep the rule they always had, default
|
|
704
|
+
* project included. But a wave session is a continuation that runs the tasks
|
|
705
|
+
* its epic session added, so it owns almost none: owning alone left most
|
|
706
|
+
* sessions with no project at all. So every task id its events carry and
|
|
707
|
+
* every task and epic its agents name count too, resolved against the tasks
|
|
708
|
+
* table — taskIdsMatch, because the log spells one task both bare and
|
|
709
|
+
* epic-qualified; an epic, or a ref naming an epic (`<epic>/integration`),
|
|
710
|
+
* through the project its tasks agree on.
|
|
711
|
+
*
|
|
712
|
+
* Only the session's own rows: a continuation can start from another
|
|
713
|
+
* project's epic, so the lineage is never unioned in. A ref nothing answers
|
|
714
|
+
* for, or a bare id two projects share, adds nothing — no guess, and no
|
|
715
|
+
* fallback to the default project. Three queries whatever the session count.
|
|
716
|
+
*/
|
|
717
|
+
function sessionProjects(db, scope) {
|
|
718
|
+
const bySession = new Map();
|
|
719
|
+
const add = (sessionId, project) => {
|
|
720
|
+
const set = bySession.get(sessionId) ?? new Set();
|
|
721
|
+
set.add(project);
|
|
722
|
+
bySession.set(sessionId, set);
|
|
723
|
+
};
|
|
724
|
+
for (const t of allTasksForScope(db, scope))
|
|
725
|
+
add(t.sessionId, projectOf(t.project));
|
|
726
|
+
// Every task, not the scoped ones: a bare id is only unambiguous if no
|
|
727
|
+
// other project's task shares it, and only the whole table can say so.
|
|
728
|
+
const allTasks = db
|
|
729
|
+
.select({ taskId: tasks.taskId, epicId: tasks.epicId, project: tasks.project })
|
|
730
|
+
.from(tasks)
|
|
731
|
+
.all()
|
|
732
|
+
.map((t) => ({ ...t, project: t.project === null ? null : normalizeProjectName(t.project) }));
|
|
733
|
+
const byId = new Map(allTasks.map((t) => [t.taskId, t]));
|
|
734
|
+
const byBare = new Map();
|
|
735
|
+
for (const t of allTasks) {
|
|
736
|
+
const bare = bareTaskId(t.taskId);
|
|
737
|
+
byBare.set(bare, [...(byBare.get(bare) ?? []), t]);
|
|
738
|
+
}
|
|
739
|
+
const epicProject = unanimousEpicProjects(allTasks);
|
|
740
|
+
const refProject = (ref) => {
|
|
741
|
+
const exact = byId.get(ref)?.project;
|
|
742
|
+
if (exact)
|
|
743
|
+
return exact;
|
|
744
|
+
const refEpic = epicOfTaskId(ref);
|
|
745
|
+
const matched = new Set();
|
|
746
|
+
for (const t of byBare.get(bareTaskId(ref)) ?? []) {
|
|
747
|
+
// A qualified ref names its epic: another epic's bare row is not it.
|
|
748
|
+
if (refEpic !== null && t.epicId !== null && t.epicId !== refEpic)
|
|
749
|
+
continue;
|
|
750
|
+
if (t.project !== null && taskIdsMatch(ref, t.taskId))
|
|
751
|
+
matched.add(t.project);
|
|
752
|
+
}
|
|
753
|
+
if (matched.size > 1)
|
|
754
|
+
return null;
|
|
755
|
+
return [...matched][0] ?? epicProject.get(epicOfTaskId(ref) ?? ref) ?? null;
|
|
756
|
+
};
|
|
757
|
+
const wanted = scopeProject(scope);
|
|
758
|
+
const addRef = (sessionId, project) => {
|
|
759
|
+
if (project && (wanted === undefined || project === wanted))
|
|
760
|
+
add(sessionId, project);
|
|
761
|
+
};
|
|
762
|
+
const eventRefs = db
|
|
763
|
+
.selectDistinct({ sessionId: eventsRaw.sessionId, taskId: eventsRaw.taskId })
|
|
764
|
+
.from(eventsRaw)
|
|
765
|
+
.where(and(isNotNull(eventsRaw.taskId), scopedToSessions(eventsRaw.sessionId, scope)))
|
|
766
|
+
.all();
|
|
767
|
+
for (const e of eventRefs)
|
|
768
|
+
addRef(e.sessionId, refProject(e.taskId));
|
|
769
|
+
const agentRefs = db
|
|
770
|
+
.selectDistinct({ sessionId: agents.sessionId, taskId: agents.taskId, epicId: agents.epicId })
|
|
771
|
+
.from(agents)
|
|
772
|
+
.where(scopedToSessions(agents.sessionId, scope))
|
|
773
|
+
.all();
|
|
774
|
+
for (const a of agentRefs) {
|
|
775
|
+
if (a.taskId !== null)
|
|
776
|
+
addRef(a.sessionId, refProject(a.taskId));
|
|
777
|
+
if (a.epicId !== null)
|
|
778
|
+
addRef(a.sessionId, epicProject.get(a.epicId));
|
|
779
|
+
}
|
|
780
|
+
return bySession;
|
|
781
|
+
}
|
|
782
|
+
/**
|
|
783
|
+
* One grouped query, not one query per session (plan B, DS8). A title never
|
|
784
|
+
* comes from prompt text (a prompt is whatever the operator typed). It is
|
|
785
|
+
* derived from the epic of the session's earliest dispatched agent: the
|
|
786
|
+
* session id itself when it starts with that epic id and a dash (it names
|
|
787
|
+
* the epic and tells sibling sessions of one epic apart), else the epic id.
|
|
788
|
+
* A session with no dispatched agent has no title.
|
|
789
|
+
*/
|
|
790
|
+
function sessionTitles(db, scope) {
|
|
791
|
+
const titles = new Map();
|
|
792
|
+
const agentCond = scopedToSessions(agents.sessionId, scope);
|
|
793
|
+
const agentRows = agentCond
|
|
794
|
+
? db.select().from(agents).where(agentCond).all()
|
|
795
|
+
: db.select().from(agents).all();
|
|
796
|
+
const agentsBySession = new Map();
|
|
797
|
+
for (const a of agentRows) {
|
|
798
|
+
const list = agentsBySession.get(a.sessionId) ?? [];
|
|
799
|
+
list.push(a);
|
|
800
|
+
agentsBySession.set(a.sessionId, list);
|
|
801
|
+
}
|
|
802
|
+
for (const [sessionId, rows] of agentsBySession) {
|
|
803
|
+
const earliest = inLogOrder(rows.map((a) => ({ ts: a.dispatchedAt, eventId: a.id, row: a })))[0];
|
|
804
|
+
const epicId = earliest?.row.epicId;
|
|
805
|
+
if (!epicId)
|
|
806
|
+
continue;
|
|
807
|
+
titles.set(sessionId, sessionId.startsWith(`${epicId}-`) ? sessionId : epicId);
|
|
808
|
+
}
|
|
809
|
+
return titles;
|
|
810
|
+
}
|
|
508
811
|
/**
|
|
509
812
|
* Every projected session, most recently active first.
|
|
510
813
|
*
|
|
@@ -534,12 +837,8 @@ export function runningSessions(db, scope = {}, opts = {}) {
|
|
|
534
837
|
workingBySession.set(a.sessionId, (workingBySession.get(a.sessionId) ?? 0) + 1);
|
|
535
838
|
}
|
|
536
839
|
}
|
|
537
|
-
const projectsBySession =
|
|
538
|
-
|
|
539
|
-
const set = projectsBySession.get(t.sessionId) ?? new Set();
|
|
540
|
-
set.add(projectOf(t.project));
|
|
541
|
-
projectsBySession.set(t.sessionId, set);
|
|
542
|
-
}
|
|
840
|
+
const projectsBySession = sessionProjects(db, scope);
|
|
841
|
+
const titles = sessionTitles(db, scope);
|
|
543
842
|
// What each session did most recently. events_raw is not guaranteed to come
|
|
544
843
|
// back in ts order, so the latest row is chosen by comparison rather than by
|
|
545
844
|
// trusting scan order — and isLaterEvent settles a tie on ts the same way
|
|
@@ -548,6 +847,7 @@ export function runningSessions(db, scope = {}, opts = {}) {
|
|
|
548
847
|
const eventQuery = db
|
|
549
848
|
.select({
|
|
550
849
|
sessionId: eventsRaw.sessionId,
|
|
850
|
+
taskId: eventsRaw.taskId,
|
|
551
851
|
ts: eventsRaw.ts,
|
|
552
852
|
eventType: eventsRaw.eventType,
|
|
553
853
|
eventId: eventsRaw.eventId,
|
|
@@ -559,6 +859,43 @@ export function runningSessions(db, scope = {}, opts = {}) {
|
|
|
559
859
|
if (seen === undefined || isLaterEvent(e, seen))
|
|
560
860
|
lastEvent.set(e.sessionId, e);
|
|
561
861
|
}
|
|
862
|
+
// A dispatch's role lives in its payload and its task's title in `tasks`;
|
|
863
|
+
// both are read only for the sessions whose last event is a dispatch.
|
|
864
|
+
const dispatchIds = [...lastEvent.values()]
|
|
865
|
+
.filter((e) => e.eventType === 'dispatch_decision')
|
|
866
|
+
.map((e) => e.eventId);
|
|
867
|
+
const roleByEvent = new Map();
|
|
868
|
+
const titleByTask = new Map();
|
|
869
|
+
if (dispatchIds.length > 0) {
|
|
870
|
+
for (const r of db
|
|
871
|
+
.select({ eventId: eventsRaw.eventId, payload: eventsRaw.payload })
|
|
872
|
+
.from(eventsRaw)
|
|
873
|
+
.where(inArray(eventsRaw.eventId, dispatchIds))
|
|
874
|
+
.all()) {
|
|
875
|
+
const role = JSON.parse(r.payload).agent_role;
|
|
876
|
+
if (typeof role === 'string')
|
|
877
|
+
roleByEvent.set(r.eventId, role);
|
|
878
|
+
}
|
|
879
|
+
const taskIds = [...lastEvent.values()].flatMap((e) => (e.taskId ? [e.taskId] : []));
|
|
880
|
+
if (taskIds.length > 0) {
|
|
881
|
+
for (const t of db
|
|
882
|
+
.select({ taskId: tasks.taskId, title: tasks.title })
|
|
883
|
+
.from(tasks)
|
|
884
|
+
.where(inArray(tasks.taskId, taskIds))
|
|
885
|
+
.all()) {
|
|
886
|
+
titleByTask.set(t.taskId, t.title);
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
const lastStep = (sessionId) => {
|
|
891
|
+
const e = lastEvent.get(sessionId);
|
|
892
|
+
if (e?.eventType !== 'dispatch_decision')
|
|
893
|
+
return { role: null, task: null };
|
|
894
|
+
return {
|
|
895
|
+
role: roleByEvent.get(e.eventId) ?? null,
|
|
896
|
+
task: (e.taskId && (titleByTask.get(e.taskId) || e.taskId)) || null,
|
|
897
|
+
};
|
|
898
|
+
};
|
|
562
899
|
return (rows
|
|
563
900
|
.map((s) => ({
|
|
564
901
|
sessionId: s.sessionId,
|
|
@@ -568,15 +905,59 @@ export function runningSessions(db, scope = {}, opts = {}) {
|
|
|
568
905
|
liveAgentCount: liveBySession.get(s.sessionId) ?? 0,
|
|
569
906
|
workingAgentCount: workingBySession.get(s.sessionId) ?? 0,
|
|
570
907
|
lastEventType: lastEvent.get(s.sessionId)?.eventType ?? null,
|
|
908
|
+
lastStepRole: lastStep(s.sessionId).role,
|
|
909
|
+
lastStepTask: lastStep(s.sessionId).task,
|
|
571
910
|
projects: [...(projectsBySession.get(s.sessionId) ?? [])].sort(),
|
|
911
|
+
title: titles.get(s.sessionId) ?? null,
|
|
572
912
|
}))
|
|
573
913
|
// Under a project scope, a session belongs to the project only through
|
|
574
|
-
//
|
|
575
|
-
// not this project's business, and would
|
|
576
|
-
// project's Overview at once.
|
|
914
|
+
// the tasks it touched — one with none (a run that has not planned or
|
|
915
|
+
// dispatched anything yet) is not this project's business, and would
|
|
916
|
+
// otherwise show up in every project's Overview at once.
|
|
577
917
|
.filter((s) => scope.project === undefined || s.projects.length > 0)
|
|
578
918
|
.sort((a, b) => b.lastEventAt.localeCompare(a.lastEventAt) || a.sessionId.localeCompare(b.sessionId)));
|
|
579
919
|
}
|
|
920
|
+
/**
|
|
921
|
+
* Which factory sessions each of `cliIds` wrote into: one row per
|
|
922
|
+
* (CLI session, factory session) pair, ordered by CLI id then session id.
|
|
923
|
+
*
|
|
924
|
+
* The answer is read off `events_raw.cli_session_id`, the stamp events.ts
|
|
925
|
+
* puts on every write made inside a CLI session. It is a link and nothing
|
|
926
|
+
* more: a session continued by a second CLI session answers to both, an
|
|
927
|
+
* unstamped event (written outside one, or before the stamp existed) answers
|
|
928
|
+
* to neither, and since an epic session and its subagents can share one CLI
|
|
929
|
+
* session the pair says nothing about which agent wrote what -- so it is
|
|
930
|
+
* never turn or delegation evidence (architecture §18 rule 4).
|
|
931
|
+
*/
|
|
932
|
+
export function cliSessionLinks(db, cliIds) {
|
|
933
|
+
// No ids, no question -- and no `inArray(col, [])` either; see
|
|
934
|
+
// scopedToSessions for why this file never builds one.
|
|
935
|
+
if (cliIds.length === 0)
|
|
936
|
+
return [];
|
|
937
|
+
// Aggregated in SQL, one row per pair: a CLI session that wrote thousands
|
|
938
|
+
// of events costs one row here, not thousands folded in JS.
|
|
939
|
+
const rows = db
|
|
940
|
+
.select({
|
|
941
|
+
cliSessionId: eventsRaw.cliSessionId,
|
|
942
|
+
sessionId: eventsRaw.sessionId,
|
|
943
|
+
lastEventAt: max(eventsRaw.ts),
|
|
944
|
+
})
|
|
945
|
+
.from(eventsRaw)
|
|
946
|
+
.where(inArray(eventsRaw.cliSessionId, [...new Set(cliIds)]))
|
|
947
|
+
.groupBy(eventsRaw.cliSessionId, eventsRaw.sessionId)
|
|
948
|
+
.all();
|
|
949
|
+
const links = rows.flatMap((row) => row.cliSessionId === null || row.lastEventAt === null
|
|
950
|
+
? []
|
|
951
|
+
: [
|
|
952
|
+
{
|
|
953
|
+
cliSessionId: row.cliSessionId,
|
|
954
|
+
sessionId: row.sessionId,
|
|
955
|
+
lastEventAt: row.lastEventAt,
|
|
956
|
+
},
|
|
957
|
+
]);
|
|
958
|
+
const byCodePoint = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
959
|
+
return links.sort((a, b) => byCodePoint(a.cliSessionId, b.cliSessionId) || byCodePoint(a.sessionId, b.sessionId));
|
|
960
|
+
}
|
|
580
961
|
function groupLiveAgents(rows) {
|
|
581
962
|
const grouped = new Map();
|
|
582
963
|
for (const row of rows) {
|
|
@@ -635,23 +1016,277 @@ function inFlightEpics(taskRows, closed) {
|
|
|
635
1016
|
const closedIds = new Set(closed.map((e) => e.epicId));
|
|
636
1017
|
return [
|
|
637
1018
|
...new Set(taskRows
|
|
638
|
-
.filter(
|
|
1019
|
+
.filter(
|
|
1020
|
+
// ds-spec.md §4.1 "Data/API note" (audit item 2, DS2): a task the
|
|
1021
|
+
// projector will not overwrite (TERMINAL_TASK_STATUSES) is not the
|
|
1022
|
+
// same question as "does this epic still need a person" —
|
|
1023
|
+
// `escalated`/`failed` are HELD_OPEN_BY_AN_OPERATOR (taskStatus.ts),
|
|
1024
|
+
// and no `epic-closed` event exists for them. Filtering on
|
|
1025
|
+
// TERMINAL_TASK_STATUSES alone dropped such an epic out of BOTH
|
|
1026
|
+
// `epicsInFlight` and `closedEpics` the moment its last open task hit
|
|
1027
|
+
// one of those two statuses — it simply disappeared from every
|
|
1028
|
+
// picker that reads this field. CLOSED_TO_FURTHER_WORK excludes
|
|
1029
|
+
// exactly the statuses a person is not expected to come back to.
|
|
1030
|
+
(t) => t.epicId && !CLOSED_TO_FURTHER_WORK.has(t.taskStatus) && !closedIds.has(t.epicId))
|
|
639
1031
|
.map((t) => t.epicId)),
|
|
640
1032
|
].sort();
|
|
641
1033
|
}
|
|
642
1034
|
/**
|
|
643
|
-
*
|
|
644
|
-
*
|
|
645
|
-
*
|
|
646
|
-
*
|
|
647
|
-
*
|
|
648
|
-
*
|
|
649
|
-
|
|
650
|
-
|
|
1035
|
+
* The subset of `inFlightEpics()` an operator would call actually running:
|
|
1036
|
+
* epics with a task in a truly open status, not merely one the projector
|
|
1037
|
+
* still calls non-terminal. An epic whose only open task is `escalated` or
|
|
1038
|
+
* `failed` stays in `inFlightEpics()` (it is still pickable on Kanban/Flow —
|
|
1039
|
+
* see that function's doc), but it has nothing running and belongs in the
|
|
1040
|
+
* inbox, not on the "Running now" card (ds-spec.md §4.1, DS2 review F1).
|
|
1041
|
+
*/
|
|
1042
|
+
function activeEpics(taskRows, closed, idle) {
|
|
1043
|
+
const closedIds = new Set(closed.map((e) => e.epicId));
|
|
1044
|
+
const idleIds = new Set(idle.map((e) => e.epicId));
|
|
1045
|
+
return [
|
|
1046
|
+
...new Set(taskRows
|
|
1047
|
+
.filter((t) => t.epicId &&
|
|
1048
|
+
!TERMINAL_TASK_STATUSES.has(t.taskStatus) &&
|
|
1049
|
+
!closedIds.has(t.epicId) &&
|
|
1050
|
+
!idleIds.has(t.epicId))
|
|
1051
|
+
.map((t) => t.epicId)),
|
|
1052
|
+
].sort();
|
|
1053
|
+
}
|
|
1054
|
+
/** An epic with no activity for longer than this is idle (7 x 24 h; exactly 7 days is not). */
|
|
1055
|
+
export const EPIC_IDLE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
1056
|
+
/**
|
|
1057
|
+
* The newest event per (task id, payload `epic_id`, project), session-scoped. Read once
|
|
1058
|
+
* per `overview()` and shared with every per-project summary, so a dashboard
|
|
1059
|
+
* poll scans `events_raw` once rather than once per project plus once overall.
|
|
1060
|
+
* Rows without a task id are kept: goal-check, spec-review and plan-amend
|
|
1061
|
+
* events carry only the payload's `epic_id`.
|
|
1062
|
+
*/
|
|
1063
|
+
function epicActivityRows(db, scope) {
|
|
1064
|
+
const sessionCond = scopedToSessions(eventsRaw.sessionId, scope);
|
|
1065
|
+
// json_valid() first: json_extract() throws on a non-JSON payload, and one
|
|
1066
|
+
// bad row must not fail the whole overview.
|
|
1067
|
+
const payloadEpic = sql `CASE WHEN json_valid(${eventsRaw.payload}) THEN json_extract(${eventsRaw.payload}, '$.epic_id') END`;
|
|
1068
|
+
const query = db
|
|
1069
|
+
.select({
|
|
1070
|
+
taskId: eventsRaw.taskId,
|
|
1071
|
+
payloadEpic,
|
|
1072
|
+
project: eventsRaw.project,
|
|
1073
|
+
last: max(eventsRaw.ts),
|
|
1074
|
+
})
|
|
1075
|
+
.from(eventsRaw);
|
|
1076
|
+
const rows = (sessionCond ? query.where(sessionCond) : query)
|
|
1077
|
+
.groupBy(eventsRaw.taskId, payloadEpic, eventsRaw.project)
|
|
1078
|
+
.all();
|
|
1079
|
+
return rows.flatMap((r) => r.last === null
|
|
1080
|
+
? []
|
|
1081
|
+
: [
|
|
1082
|
+
{
|
|
1083
|
+
taskId: r.taskId,
|
|
1084
|
+
payloadEpic: typeof r.payloadEpic === 'string' ? r.payloadEpic : null,
|
|
1085
|
+
project: projectOf(r.project),
|
|
1086
|
+
last: r.last,
|
|
1087
|
+
},
|
|
1088
|
+
]);
|
|
1089
|
+
}
|
|
1090
|
+
/**
|
|
1091
|
+
* The in-flight epics nothing has happened to for more than 7 days before
|
|
1092
|
+
* `nowIso`. An epic's last activity is the newest of its tasks' `updatedAt`
|
|
1093
|
+
* and the `ts` of the newest event that belongs to it: `updatedAt` only moves
|
|
1094
|
+
* when the projector touches the row, so a finding, a judge report or a gate
|
|
1095
|
+
* result logged against a task would otherwise read as silence. An event
|
|
1096
|
+
* belongs to an epic by, in order: its payload `epic_id` (the key the
|
|
1097
|
+
* projector itself trusts, and the only one a task-less event has); the task
|
|
1098
|
+
* id through `epicResolver`, so a bare task id lands on its epic as in the
|
|
1099
|
+
* spend figures; the `<epic>/` prefix of a task id no task row carries, which
|
|
1100
|
+
* is how a plan ref (`<epic>/plan-rN`, `<epic>/spec-review-rN`) names its
|
|
1101
|
+
* epic. Timestamps compare as instants, not strings. An idle epic stays in
|
|
1102
|
+
* `inFlightEpics()` (it is still pickable) and only leaves `activeEpics()`.
|
|
1103
|
+
* With `project` set, only that project's events count (an unset one spans all).
|
|
1104
|
+
*/
|
|
1105
|
+
function idleEpics(taskRows, activity, inFlight, nowIso, project) {
|
|
1106
|
+
const lastByEpic = new Map();
|
|
1107
|
+
const bump = (epicId, ts) => {
|
|
1108
|
+
if (epicId === undefined)
|
|
1109
|
+
return;
|
|
1110
|
+
const ms = Date.parse(ts);
|
|
1111
|
+
if (Number.isNaN(ms))
|
|
1112
|
+
return;
|
|
1113
|
+
const seen = lastByEpic.get(epicId);
|
|
1114
|
+
if (seen === undefined || ms > seen)
|
|
1115
|
+
lastByEpic.set(epicId, ms);
|
|
1116
|
+
};
|
|
1117
|
+
const epicByTask = new Map();
|
|
1118
|
+
for (const t of taskRows) {
|
|
1119
|
+
if (!t.epicId)
|
|
1120
|
+
continue;
|
|
1121
|
+
epicByTask.set(t.taskId, t.epicId);
|
|
1122
|
+
bump(t.epicId, t.updatedAt);
|
|
1123
|
+
}
|
|
1124
|
+
const epicOf = epicResolver(epicByTask);
|
|
1125
|
+
for (const r of activity) {
|
|
1126
|
+
if (project !== undefined && r.project !== project)
|
|
1127
|
+
continue;
|
|
1128
|
+
const slash = r.taskId?.lastIndexOf('/') ?? -1;
|
|
1129
|
+
const prefix = r.taskId && slash > 0 ? r.taskId.slice(0, slash) : undefined;
|
|
1130
|
+
bump(r.payloadEpic ?? epicOf(r.taskId ?? undefined) ?? prefix, r.last);
|
|
1131
|
+
}
|
|
1132
|
+
const now = Date.parse(nowIso);
|
|
1133
|
+
const idle = [];
|
|
1134
|
+
for (const epicId of inFlight) {
|
|
1135
|
+
const lastMs = lastByEpic.get(epicId);
|
|
1136
|
+
if (lastMs === undefined)
|
|
1137
|
+
continue;
|
|
1138
|
+
const idleMs = now - lastMs;
|
|
1139
|
+
if (idleMs > EPIC_IDLE_MS)
|
|
1140
|
+
idle.push({ epicId, idleDays: Math.floor(idleMs / (24 * 60 * 60 * 1000)) });
|
|
1141
|
+
}
|
|
1142
|
+
return idle;
|
|
1143
|
+
}
|
|
1144
|
+
/**
|
|
1145
|
+
* A finding the waiver machinery would actually act on. Both rosters are
|
|
1146
|
+
* imported from the modules that own them — the severities from waivers.ts,
|
|
1147
|
+
* the statuses from findings.ts, where they are read off LEGAL_TRANSITIONS —
|
|
1148
|
+
* rather than spelled out here. `bs waivers pending` lists every such finding:
|
|
1149
|
+
* what *could* be waived. The operator's inbox and pending-waiver count are
|
|
1150
|
+
* narrower, see needsOperatorWaiver() below.
|
|
651
1151
|
*/
|
|
652
1152
|
function awaitsWaiverDecision(f) {
|
|
653
1153
|
return WAIVABLE_SEVERITIES.includes(f.severity) && WAIVABLE_STATUSES.includes(f.findingStatus);
|
|
654
1154
|
}
|
|
1155
|
+
/**
|
|
1156
|
+
* The task row a finding's task id names: exact key first, then taskIdsMatch,
|
|
1157
|
+
* the way projectResolver (projector.ts) does it. The log spells one task both
|
|
1158
|
+
* bare ("task-1") and qualified ("epic-a/task-1"), and findings keep whichever
|
|
1159
|
+
* spelling they were raised with. A bare id matches the same-named task of
|
|
1160
|
+
* every epic, so the finding's own epic, when it names one, picks among them.
|
|
1161
|
+
*/
|
|
1162
|
+
function taskResolver(taskRows) {
|
|
1163
|
+
const exact = new Map(taskRows.map((t) => [t.taskId, t]));
|
|
1164
|
+
return (taskId, epicId) => {
|
|
1165
|
+
const hit = exact.get(taskId);
|
|
1166
|
+
if (hit)
|
|
1167
|
+
return hit;
|
|
1168
|
+
const matches = taskRows.filter((t) => taskIdsMatch(taskId, t.taskId));
|
|
1169
|
+
return matches.find((t) => epicId !== null && t.epicId === epicId) ?? matches[0];
|
|
1170
|
+
};
|
|
1171
|
+
}
|
|
1172
|
+
/**
|
|
1173
|
+
* An undecided waivable finding the factory will no longer act on, so only the
|
|
1174
|
+
* operator can: its task shipped, or it is epic-level (no task row, such as
|
|
1175
|
+
* `<epic>/integration`) and its epic is closed. A task still open is in the
|
|
1176
|
+
* factory's own fix loop (a follow-up task exists to fix its finding), a
|
|
1177
|
+
* superseded task's findings are moot, and an escalated task has its own inbox
|
|
1178
|
+
* row. No shared status set names "shipped" alone — TERMINAL_OK_TASK_STATUSES
|
|
1179
|
+
* also holds `waived`, which is decided — so `completed` is spelled out. The
|
|
1180
|
+
* inbox and both pendingWaivers counts read this one rule, so they agree.
|
|
1181
|
+
*/
|
|
1182
|
+
function needsOperatorWaiver(f, resolveTask, closedEpicIds) {
|
|
1183
|
+
if (!awaitsWaiverDecision(f) || f.waiverId !== null)
|
|
1184
|
+
return false;
|
|
1185
|
+
const t = resolveTask(f.taskId, f.epicId);
|
|
1186
|
+
if (t)
|
|
1187
|
+
return t.taskStatus === 'completed';
|
|
1188
|
+
return f.epicId !== null && closedEpicIds.has(f.epicId);
|
|
1189
|
+
}
|
|
1190
|
+
const INBOX_KIND_RANK = {
|
|
1191
|
+
escalation: 0,
|
|
1192
|
+
waiver: 1,
|
|
1193
|
+
lesson_candidate: 2,
|
|
1194
|
+
};
|
|
1195
|
+
/** A task's Kanban display text — the same `objective ?? taskId` fallback
|
|
1196
|
+
* every other query on this table uses; tasks have no `title` column. */
|
|
1197
|
+
function taskDisplayText(t) {
|
|
1198
|
+
return t.objective ?? t.taskId;
|
|
1199
|
+
}
|
|
1200
|
+
export function inboxRows(db, scope = {}) {
|
|
1201
|
+
const taskRows = allTasksForScope(db, scope);
|
|
1202
|
+
const resolveTask = taskResolver(taskRows);
|
|
1203
|
+
const rows = [];
|
|
1204
|
+
// Escalations: task rows the projector parked at `escalated`
|
|
1205
|
+
// (db/projector.ts's error-logged handling, `coordination.*` errors).
|
|
1206
|
+
// Dispatches for every escalated task are fetched in one query, not one
|
|
1207
|
+
// per task, and grouped by taskId below.
|
|
1208
|
+
const escalatedTaskIds = taskRows
|
|
1209
|
+
.filter((t) => t.taskStatus === 'escalated')
|
|
1210
|
+
.map((t) => t.taskId);
|
|
1211
|
+
const lastDispatchByTask = new Map();
|
|
1212
|
+
if (escalatedTaskIds.length > 0) {
|
|
1213
|
+
const dispatchRows = inLogOrder(db.select().from(dispatches).where(inArray(dispatches.taskId, escalatedTaskIds)).all());
|
|
1214
|
+
for (const d of dispatchRows) {
|
|
1215
|
+
if (d.taskId)
|
|
1216
|
+
lastDispatchByTask.set(d.taskId, d);
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
for (const t of taskRows) {
|
|
1220
|
+
if (t.taskStatus !== 'escalated')
|
|
1221
|
+
continue;
|
|
1222
|
+
const lastDispatch = lastDispatchByTask.get(t.taskId);
|
|
1223
|
+
rows.push({
|
|
1224
|
+
id: `escalation:${t.taskId}`,
|
|
1225
|
+
kind: 'escalation',
|
|
1226
|
+
title: taskDisplayText(t),
|
|
1227
|
+
description: lastDispatch ? `Escalated while dispatched as ${lastDispatch.agentRole}` : null,
|
|
1228
|
+
project: projectOf(t.project),
|
|
1229
|
+
taskId: t.taskId,
|
|
1230
|
+
createdAt: t.updatedAt,
|
|
1231
|
+
});
|
|
1232
|
+
}
|
|
1233
|
+
// Pending waivers: findings `needsOperatorWaiver()` covers (shipped work
|
|
1234
|
+
// nothing else will touch), grouped one row per task (the operator waives a
|
|
1235
|
+
// task's batch, not one finding at a time — /bs waivers's own unit).
|
|
1236
|
+
const closedEpicIds = new Set(closedEpicsForScope(db, scope).map((e) => e.epicId));
|
|
1237
|
+
const pendingFindings = allFindingsForScope(db, scope).filter((f) => needsOperatorWaiver(f, resolveTask, closedEpicIds));
|
|
1238
|
+
const pendingByTask = new Map();
|
|
1239
|
+
for (const f of pendingFindings) {
|
|
1240
|
+
const t = resolveTask(f.taskId, f.epicId);
|
|
1241
|
+
const key = t?.taskId ?? f.taskId;
|
|
1242
|
+
const group = pendingByTask.get(key) ?? { t, findingRows: [] };
|
|
1243
|
+
group.findingRows.push(f);
|
|
1244
|
+
pendingByTask.set(key, group);
|
|
1245
|
+
}
|
|
1246
|
+
for (const [taskId, { t, findingRows }] of pendingByTask) {
|
|
1247
|
+
const latest = findingRows.reduce((a, b) => (a.raisedAt > b.raisedAt ? a : b));
|
|
1248
|
+
const count = findingRows.length;
|
|
1249
|
+
rows.push({
|
|
1250
|
+
id: `waiver:${taskId}`,
|
|
1251
|
+
kind: 'waiver',
|
|
1252
|
+
title: t ? taskDisplayText(t) : taskId,
|
|
1253
|
+
description: `${count} finding${count === 1 ? '' : 's'} awaiting a waiver decision`,
|
|
1254
|
+
project: projectOf(latest.project ?? t?.project ?? null),
|
|
1255
|
+
taskId,
|
|
1256
|
+
createdAt: latest.raisedAt,
|
|
1257
|
+
});
|
|
1258
|
+
}
|
|
1259
|
+
// Lesson candidates: what /api/lessons already buckets as 'pending'. No
|
|
1260
|
+
// project column on lessons — always the client's "All projects" group.
|
|
1261
|
+
if (scope.project === undefined) {
|
|
1262
|
+
const sessionCond = scopedToSessions(lessons.sessionId, scope);
|
|
1263
|
+
const lessonRows = sessionCond
|
|
1264
|
+
? db.select().from(lessons).where(sessionCond).all()
|
|
1265
|
+
: db.select().from(lessons).all();
|
|
1266
|
+
for (const l of lessonRows) {
|
|
1267
|
+
if (LESSON_BUCKET_FOR_STATUS[l.lessonStatus] !== 'pending')
|
|
1268
|
+
continue;
|
|
1269
|
+
rows.push({
|
|
1270
|
+
id: `lesson_candidate:${l.lessonId}`,
|
|
1271
|
+
kind: 'lesson_candidate',
|
|
1272
|
+
title: l.statement,
|
|
1273
|
+
description: null,
|
|
1274
|
+
project: null,
|
|
1275
|
+
taskId: null,
|
|
1276
|
+
createdAt: l.validFrom,
|
|
1277
|
+
});
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
return rows.sort((a, b) => {
|
|
1281
|
+
const rank = INBOX_KIND_RANK[a.kind] - INBOX_KIND_RANK[b.kind];
|
|
1282
|
+
if (rank !== 0)
|
|
1283
|
+
return rank;
|
|
1284
|
+
const created = a.createdAt.localeCompare(b.createdAt);
|
|
1285
|
+
if (created !== 0)
|
|
1286
|
+
return created;
|
|
1287
|
+
return a.id.localeCompare(b.id);
|
|
1288
|
+
});
|
|
1289
|
+
}
|
|
655
1290
|
/**
|
|
656
1291
|
* One project's overview slice, computed by the same logic overview() itself
|
|
657
1292
|
* uses (no drift). `nowIso` is the caller's single instant, not a fresh
|
|
@@ -659,24 +1294,34 @@ function awaitsWaiverDecision(f) {
|
|
|
659
1294
|
* for its own counts, so the project rows and the global card can never
|
|
660
1295
|
* disagree about which agents are working.
|
|
661
1296
|
*/
|
|
662
|
-
function projectSummary(db, project, baseScope, nowIso) {
|
|
1297
|
+
function projectSummary(db, project, baseScope, nowIso, activity) {
|
|
663
1298
|
const scope = { ...baseScope, project };
|
|
664
1299
|
const liveRows = allAgentsForScope(db, scope);
|
|
665
1300
|
const taskRows = allTasksForScope(db, scope);
|
|
666
|
-
const
|
|
667
|
-
const
|
|
1301
|
+
const closedEpicsHere = closedEpicsForScope(db, scope);
|
|
1302
|
+
const epicsInFlight = inFlightEpics(taskRows, closedEpicsHere);
|
|
1303
|
+
const epicsIdle = idleEpics(taskRows, activity, epicsInFlight, nowIso, project);
|
|
1304
|
+
const epicsActivelyRunning = activeEpics(taskRows, closedEpicsHere, epicsIdle);
|
|
1305
|
+
const tokenMaps = epicTokenMaps(db, scope, taskRows);
|
|
1306
|
+
const { budgetByEpic, spentByEpic, unmeasuredByEpic } = tokenMaps;
|
|
668
1307
|
const tokensSpent = [...spentByEpic.values()].reduce((s, v) => s + v, 0);
|
|
669
1308
|
const tokensBudget = budgetByEpic.size > 0 ? [...budgetByEpic.values()].reduce((s, v) => s + v, 0) : null;
|
|
1309
|
+
const unmeasured = [...unmeasuredByEpic.values()].reduce((s, v) => s + v, 0);
|
|
670
1310
|
const escalations = taskRows.filter((t) => t.taskStatus === 'escalated').length;
|
|
671
|
-
const
|
|
672
|
-
const
|
|
1311
|
+
const closedEpicIds = new Set(closedEpicsHere.map((e) => e.epicId));
|
|
1312
|
+
const resolveTask = taskResolver(taskRows);
|
|
1313
|
+
const pendingWaivers = allFindingsForScope(db, scope).filter((f) => needsOperatorWaiver(f, resolveTask, closedEpicIds)).length;
|
|
673
1314
|
return {
|
|
674
1315
|
project,
|
|
675
1316
|
liveAgentCount: liveRows.length,
|
|
676
1317
|
workingAgentCount: liveRows.filter((a) => isWorkingAt(a.dispatchedAt, nowIso)).length,
|
|
677
1318
|
epicsInFlight,
|
|
1319
|
+
epicsActivelyRunning,
|
|
1320
|
+
epicsIdle,
|
|
678
1321
|
tokensSpent,
|
|
679
1322
|
tokensBudget,
|
|
1323
|
+
unmeasured,
|
|
1324
|
+
tokensByEpic: epicTokenSpends(tokenMaps),
|
|
680
1325
|
alerts: { escalations, pendingWaivers },
|
|
681
1326
|
};
|
|
682
1327
|
}
|
|
@@ -780,7 +1425,9 @@ function tokensSpentAt(db, scope, cutoffIso, epicByTask) {
|
|
|
780
1425
|
const payload = JSON.parse(r.payload);
|
|
781
1426
|
if (!epicOf(resultTaskId({ payload, envelopeTaskId: r.taskId })))
|
|
782
1427
|
continue;
|
|
783
|
-
|
|
1428
|
+
const usage = readTokenUsage(payload.token_usage);
|
|
1429
|
+
if (usage.measured)
|
|
1430
|
+
total += usage.total;
|
|
784
1431
|
}
|
|
785
1432
|
return total;
|
|
786
1433
|
}
|
|
@@ -845,16 +1492,15 @@ export function overview(db, scope = {}, opts = {}) {
|
|
|
845
1492
|
const taskRows = allTasksForScope(db, scope);
|
|
846
1493
|
const closedEpics = closedEpicsForScope(db, scope);
|
|
847
1494
|
const epicsInFlight = inFlightEpics(taskRows, closedEpics);
|
|
848
|
-
const
|
|
849
|
-
const
|
|
850
|
-
const
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
tokensBudget: budgetByEpic.get(epicId) ?? null,
|
|
854
|
-
}));
|
|
1495
|
+
const activity = epicActivityRows(db, scope);
|
|
1496
|
+
const epicsIdle = idleEpics(taskRows, activity, epicsInFlight, nowIso, scopeProject(scope));
|
|
1497
|
+
const epicsActivelyRunning = activeEpics(taskRows, closedEpics, epicsIdle);
|
|
1498
|
+
const { budgetByEpic, spentByEpic, unmeasuredByEpic } = epicTokenMaps(db, scope, taskRows);
|
|
1499
|
+
const tokensByEpic = epicTokenSpends({ budgetByEpic, spentByEpic, unmeasuredByEpic });
|
|
855
1500
|
const escalations = taskRows.filter((t) => t.taskStatus === 'escalated').length;
|
|
856
|
-
const
|
|
857
|
-
const
|
|
1501
|
+
const closedEpicIds = new Set(closedEpics.map((e) => e.epicId));
|
|
1502
|
+
const resolveTask = taskResolver(taskRows);
|
|
1503
|
+
const pendingWaivers = allFindingsForScope(db, scope).filter((f) => needsOperatorWaiver(f, resolveTask, closedEpicIds)).length;
|
|
858
1504
|
const dispatchSessionCond = scopedToSessions(dispatches.sessionId, scope);
|
|
859
1505
|
const dispatchRows = dispatchSessionCond
|
|
860
1506
|
? db.select().from(dispatches).where(dispatchSessionCond).all()
|
|
@@ -866,10 +1512,21 @@ export function overview(db, scope = {}, opts = {}) {
|
|
|
866
1512
|
// them left the rows in the ascending order they arrived in: the ten this
|
|
867
1513
|
// then kept were the *oldest* ten of the burst, under a heading that says
|
|
868
1514
|
// recent.
|
|
869
|
-
const
|
|
1515
|
+
const recentDispatchRows = inLogOrder(scopedDispatches)
|
|
870
1516
|
.reverse()
|
|
871
|
-
.slice(0, RECENT_DISPATCHES_LIMIT)
|
|
872
|
-
|
|
1517
|
+
.slice(0, RECENT_DISPATCHES_LIMIT);
|
|
1518
|
+
// agents.id IS the dispatch event id (agents-registry.ts's foldAgents()),
|
|
1519
|
+
// so a direct id lookup finds the round regardless of whether that agent
|
|
1520
|
+
// is still live or has since gone terminal.
|
|
1521
|
+
const roundByEventId = new Map(recentDispatchRows.length > 0
|
|
1522
|
+
? db
|
|
1523
|
+
.select({ id: agents.id, round: agents.round })
|
|
1524
|
+
.from(agents)
|
|
1525
|
+
.where(inArray(agents.id, recentDispatchRows.map((d) => d.eventId)))
|
|
1526
|
+
.all()
|
|
1527
|
+
.map((a) => [a.id, a.round])
|
|
1528
|
+
: []);
|
|
1529
|
+
const recentDispatches = recentDispatchRows.map((d) => ({
|
|
873
1530
|
eventId: d.eventId,
|
|
874
1531
|
ts: d.ts,
|
|
875
1532
|
agentRole: d.agentRole,
|
|
@@ -877,6 +1534,7 @@ export function overview(db, scope = {}, opts = {}) {
|
|
|
877
1534
|
modelTier: d.modelTier,
|
|
878
1535
|
taskId: d.taskId,
|
|
879
1536
|
reason: d.reason,
|
|
1537
|
+
round: roundByEventId.get(d.eventId) ?? 1,
|
|
880
1538
|
}));
|
|
881
1539
|
let projects;
|
|
882
1540
|
if (scope.project === undefined) {
|
|
@@ -894,15 +1552,25 @@ export function overview(db, scope = {}, opts = {}) {
|
|
|
894
1552
|
// Milestones carry no session_id and milestoneProgressRows() never
|
|
895
1553
|
// filters them by one, so they join the list under a session scope too.
|
|
896
1554
|
const declaredRows = db.select({ project: milestones.project }).from(milestones).all();
|
|
897
|
-
projects = distinctProjects(allTaskRowsUnfiltered, declaredRows).map((p) => projectSummary(db, p, scope, nowIso));
|
|
1555
|
+
projects = distinctProjects(allTaskRowsUnfiltered, declaredRows).map((p) => projectSummary(db, p, scope, nowIso, activity));
|
|
898
1556
|
}
|
|
899
1557
|
const fiveMinAgo = new Date(now.getTime() - 5 * 60 * 1000).toISOString();
|
|
900
1558
|
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000).toISOString();
|
|
901
1559
|
const liveAgentCountDelta5m = liveRows.length - liveAgentCountAt(db, scope, fiveMinAgo);
|
|
902
1560
|
const workingAgentCountDelta5m = workingRows.length - workingAgentCountAt(db, scope, fiveMinAgo);
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
1561
|
+
// The Budget panel counts the epics the Running-now cards count, so its
|
|
1562
|
+
// 1-hour delta is folded over the same set: running epics that declare a
|
|
1563
|
+
// budget (an unbudgeted epic adds to neither side of the card's ratio).
|
|
1564
|
+
const runningBudgeted = new Set(epicsActivelyRunning.filter((e) => budgetByEpic.has(e)));
|
|
1565
|
+
const epicByTask = new Map(taskRows
|
|
1566
|
+
.filter((t) => t.epicId !== null && runningBudgeted.has(t.epicId))
|
|
1567
|
+
.map((t) => [t.taskId, t.epicId]));
|
|
1568
|
+
const totalBudgetNow = [...budgetByEpic]
|
|
1569
|
+
.filter(([e]) => runningBudgeted.has(e))
|
|
1570
|
+
.reduce((s, [, v]) => s + v, 0);
|
|
1571
|
+
const totalSpentNow = [...spentByEpic]
|
|
1572
|
+
.filter(([e]) => runningBudgeted.has(e))
|
|
1573
|
+
.reduce((s, [, v]) => s + v, 0);
|
|
906
1574
|
let budgetUsedPctPointDelta1h = null;
|
|
907
1575
|
if (totalBudgetNow > 0) {
|
|
908
1576
|
const currentPct = (totalSpentNow / totalBudgetNow) * 100;
|
|
@@ -936,6 +1604,8 @@ export function overview(db, scope = {}, opts = {}) {
|
|
|
936
1604
|
stalledAgentCount: liveRows.length - workingRows.length,
|
|
937
1605
|
runningSessions: runningSessions(db, scope, { nowIso }),
|
|
938
1606
|
epicsInFlight,
|
|
1607
|
+
epicsActivelyRunning,
|
|
1608
|
+
epicsIdle,
|
|
939
1609
|
closedEpics,
|
|
940
1610
|
tokensByEpic,
|
|
941
1611
|
alerts: { escalations, pendingWaivers },
|
|
@@ -1033,6 +1703,14 @@ export const FREE_TIMELINE_EVENT_TYPES = [
|
|
|
1033
1703
|
// only event written after `epic-closed`, so without it the timeline of a
|
|
1034
1704
|
// closed epic ends at the closing and never shows what the closing was for.
|
|
1035
1705
|
'integration-pr-opened',
|
|
1706
|
+
// A comment the operator leaves mid-run on one task (feedback.ts), and its
|
|
1707
|
+
// later resolution -- bounced, turned into a follow-up task, or dismissed.
|
|
1708
|
+
// Both are the operator's own words about a specific task, the same reason
|
|
1709
|
+
// operator-note is on this list, and neither carries a gate_event/graph_event
|
|
1710
|
+
// tag: what happens to the comment is a later PR's playbook wiring, not a
|
|
1711
|
+
// taxonomy outcome this orchestrator declares today.
|
|
1712
|
+
'operator-feedback-recorded',
|
|
1713
|
+
'operator-feedback-resolved',
|
|
1036
1714
|
];
|
|
1037
1715
|
let cachedTaxonomy;
|
|
1038
1716
|
/**
|
|
@@ -1061,7 +1739,93 @@ export function timelineEventTypes() {
|
|
|
1061
1739
|
...(cachedTaxonomy.dimensions.graph_event ?? []),
|
|
1062
1740
|
];
|
|
1063
1741
|
}
|
|
1064
|
-
|
|
1742
|
+
/** Every `EventKind`, for routes validating a `kind=` query param against the real set. */
|
|
1743
|
+
export const EVENT_KINDS = [
|
|
1744
|
+
'Prompt',
|
|
1745
|
+
'Dispatched',
|
|
1746
|
+
'Returned',
|
|
1747
|
+
'Finding',
|
|
1748
|
+
'Gate',
|
|
1749
|
+
'Merge',
|
|
1750
|
+
'Error',
|
|
1751
|
+
'Feedback',
|
|
1752
|
+
'System',
|
|
1753
|
+
];
|
|
1754
|
+
/**
|
|
1755
|
+
* Which of the 9 kinds a raw event renders as (ds-spec.md §4.3's table) —
|
|
1756
|
+
* pure in `(eventType, payload)` so both `timeline()` and `causalChain()` tag
|
|
1757
|
+
* every row the same way. Unnamed types fall to `System`, the feed's
|
|
1758
|
+
* catch-all for rare types.
|
|
1759
|
+
*
|
|
1760
|
+
* DS6 PR3 round 2 reconciles this with `ui/src/lib/timelineDisplay.ts`'s
|
|
1761
|
+
* client-side `kindFor()` fallback switch (which this function already
|
|
1762
|
+
* takes precedence over whenever a paged entry carries a server `kind`):
|
|
1763
|
+
* both now agree that `judge-reported` is `Finding` and
|
|
1764
|
+
* `waiver-granted`/`waiver-denied` are `Feedback`, per §4.3's table
|
|
1765
|
+
* ("Finding (judge verdict)" -> `judge-reported`; "Feedback" ->
|
|
1766
|
+
* `waiver-granted` / `waiver-denied`). The round-1 version of this switch
|
|
1767
|
+
* only matched the 8 event types the mock happens to show, so every other
|
|
1768
|
+
* gate/graph event — `finding-raised`, `schema-check-result`,
|
|
1769
|
+
* `cross-finding-reconciled`, `epic-closed`, and the rest of
|
|
1770
|
+
* taxonomy.yml's `gate_event`/`graph_event` vocabulary — fell through to
|
|
1771
|
+
* `System`, which is where the "most rows are System" visual-pass finding
|
|
1772
|
+
* came from. The groupings below follow the same families `kindFor()`'s
|
|
1773
|
+
* switch already used, just with the one Finding/Feedback swap applied.
|
|
1774
|
+
*/
|
|
1775
|
+
export function eventKind(eventType, _payload) {
|
|
1776
|
+
switch (eventType) {
|
|
1777
|
+
case 'user_prompt':
|
|
1778
|
+
case 'operator-note':
|
|
1779
|
+
return 'Prompt';
|
|
1780
|
+
case DISPATCH_EVENT_TYPE:
|
|
1781
|
+
return 'Dispatched';
|
|
1782
|
+
case TASK_RESULT_EVENT_TYPE:
|
|
1783
|
+
return 'Returned';
|
|
1784
|
+
case JUDGE_REPORT_EVENT_TYPE:
|
|
1785
|
+
case 'finding-raised':
|
|
1786
|
+
case 'finding-reverified':
|
|
1787
|
+
case 'finding-suppressed':
|
|
1788
|
+
case 'finding-transitioned':
|
|
1789
|
+
case 'finding-reattributed':
|
|
1790
|
+
case 'severity-decisions':
|
|
1791
|
+
case 'task-waiver-approved':
|
|
1792
|
+
return 'Finding';
|
|
1793
|
+
case 'gate-outcome':
|
|
1794
|
+
case 'testgate-result':
|
|
1795
|
+
case 'schema-check-result':
|
|
1796
|
+
case 'artifact-check-result':
|
|
1797
|
+
case 'commit-check-result':
|
|
1798
|
+
case 'deps-check-result':
|
|
1799
|
+
case 'judges-outstanding':
|
|
1800
|
+
case 'grader-verdict':
|
|
1801
|
+
case 'budget-check-result':
|
|
1802
|
+
case 'coverage-evidence':
|
|
1803
|
+
case 'integration-check':
|
|
1804
|
+
case 'spec-review-recorded':
|
|
1805
|
+
case 'goal-check-recorded':
|
|
1806
|
+
case 'quorum-decision':
|
|
1807
|
+
case 'issue-reported':
|
|
1808
|
+
return 'Gate';
|
|
1809
|
+
case 'wave-merged':
|
|
1810
|
+
case 'epic-closed':
|
|
1811
|
+
case 'integration-pr-opened':
|
|
1812
|
+
return 'Merge';
|
|
1813
|
+
case ERROR_EVENT_TYPE:
|
|
1814
|
+
case 'error-report-proposed':
|
|
1815
|
+
return 'Error';
|
|
1816
|
+
case 'waiver-granted':
|
|
1817
|
+
case 'waiver-denied':
|
|
1818
|
+
case 'judge-verdict':
|
|
1819
|
+
case 'cross-finding-reconciled':
|
|
1820
|
+
case 'spec-change-proposed':
|
|
1821
|
+
case 'spec-change-decided':
|
|
1822
|
+
return 'Feedback';
|
|
1823
|
+
default:
|
|
1824
|
+
return 'System';
|
|
1825
|
+
}
|
|
1826
|
+
}
|
|
1827
|
+
function toEntry(row, nearestPromptId) {
|
|
1828
|
+
const payload = JSON.parse(row.payload);
|
|
1065
1829
|
return {
|
|
1066
1830
|
eventId: row.eventId,
|
|
1067
1831
|
ts: row.ts,
|
|
@@ -1070,9 +1834,14 @@ function toEntry(row) {
|
|
|
1070
1834
|
agentId: row.agentId,
|
|
1071
1835
|
planVersion: row.planVersion,
|
|
1072
1836
|
causalParent: row.causalParent,
|
|
1073
|
-
payload
|
|
1837
|
+
payload,
|
|
1074
1838
|
project: row.project,
|
|
1075
1839
|
actor: row.actor,
|
|
1840
|
+
kind: eventKind(row.eventType, payload),
|
|
1841
|
+
nearestPromptId,
|
|
1842
|
+
sessionId: row.sessionId,
|
|
1843
|
+
// Filled per-page by joinSessionTitles() below, same as run/gateCounts.
|
|
1844
|
+
sessionTitle: row.sessionId,
|
|
1076
1845
|
};
|
|
1077
1846
|
}
|
|
1078
1847
|
/**
|
|
@@ -1134,35 +1903,50 @@ function applyDecisionsLens(entries) {
|
|
|
1134
1903
|
});
|
|
1135
1904
|
}
|
|
1136
1905
|
/**
|
|
1137
|
-
*
|
|
1138
|
-
*
|
|
1139
|
-
*
|
|
1140
|
-
*
|
|
1141
|
-
* lookup to `sessionId` used to stop the chain dead at the session root of a
|
|
1142
|
-
* continued epic — the projection reported a two-event history for a chain
|
|
1143
|
-
* that ran back through three sessions. `sessionId` still gates the FIRST
|
|
1144
|
-
* lookup, so asking for a chain that does not start in the named session
|
|
1145
|
-
* remains an empty answer rather than a silent redirect.
|
|
1906
|
+
* Row cache shared across calls within one `kanban()` invocation (S3 fix):
|
|
1907
|
+
* event ids are globally unique (see the docstring above), so a row fetched
|
|
1908
|
+
* for one task's walk answers for every other walk that crosses the same
|
|
1909
|
+
* event, and the session gate below still applies to it.
|
|
1146
1910
|
*/
|
|
1147
|
-
function
|
|
1148
|
-
|
|
1911
|
+
function fetchEventRow(db, eventId, cache) {
|
|
1912
|
+
if (cache?.has(eventId))
|
|
1913
|
+
return cache.get(eventId) ?? null;
|
|
1914
|
+
const row = db.select().from(eventsRaw).where(eq(eventsRaw.eventId, eventId)).get() ?? null;
|
|
1915
|
+
cache?.set(eventId, row);
|
|
1916
|
+
return row;
|
|
1917
|
+
}
|
|
1918
|
+
function causalChain(db, sessionId, eventId, eventCache) {
|
|
1919
|
+
const rows = [];
|
|
1149
1920
|
let currentId = eventId;
|
|
1150
1921
|
const seen = new Set();
|
|
1151
1922
|
let first = true;
|
|
1152
1923
|
while (currentId && !seen.has(currentId)) {
|
|
1153
1924
|
seen.add(currentId);
|
|
1154
|
-
const
|
|
1155
|
-
const row = db
|
|
1156
|
-
.select()
|
|
1157
|
-
.from(eventsRaw)
|
|
1158
|
-
.where(first ? and(idMatch, eq(eventsRaw.sessionId, sessionId)) : idMatch)
|
|
1159
|
-
.get();
|
|
1925
|
+
const row = fetchEventRow(db, currentId, eventCache);
|
|
1160
1926
|
if (!row)
|
|
1161
1927
|
break;
|
|
1928
|
+
// Ids are globally unique, so a row fetched for a different session's
|
|
1929
|
+
// walk can still answer this one's first hop — but that hop is only
|
|
1930
|
+
// valid when it actually named a row inside the requested session,
|
|
1931
|
+
// same as the original `and(idMatch, eq(sessionId))` constraint.
|
|
1932
|
+
if (first && row.sessionId !== sessionId)
|
|
1933
|
+
break;
|
|
1162
1934
|
first = false;
|
|
1163
|
-
|
|
1935
|
+
rows.unshift(row);
|
|
1164
1936
|
currentId = row.causalParent;
|
|
1165
1937
|
}
|
|
1938
|
+
// `rows` is now the whole ancestor path, oldest-first, so each row's
|
|
1939
|
+
// nearest `user_prompt` is answered by a single forward pass over this
|
|
1940
|
+
// same array rather than a nested call into `nearestUserPrompt` — that
|
|
1941
|
+
// function calls back into `causalChain`, and feeding it one row at a
|
|
1942
|
+
// time from inside this loop was mutual recursion with no base case.
|
|
1943
|
+
let lastPromptId = null;
|
|
1944
|
+
const chain = [];
|
|
1945
|
+
for (const row of rows) {
|
|
1946
|
+
if (row.eventType === 'user_prompt')
|
|
1947
|
+
lastPromptId = row.eventId;
|
|
1948
|
+
chain.push(toEntry(row, lastPromptId));
|
|
1949
|
+
}
|
|
1166
1950
|
return chain;
|
|
1167
1951
|
}
|
|
1168
1952
|
/**
|
|
@@ -1188,12 +1972,55 @@ function epicOfEntry(entry) {
|
|
|
1188
1972
|
const fromPayload = entry.payload.epic_id;
|
|
1189
1973
|
return typeof fromPayload === 'string' ? fromPayload : null;
|
|
1190
1974
|
}
|
|
1975
|
+
/**
|
|
1976
|
+
* Whether an entry belongs to `epicId`. An entry that names an epic itself
|
|
1977
|
+
* (`epicOfEntry` non-null) is decided by that alone: its own epic wins. A
|
|
1978
|
+
* `user_prompt` naming none is derived at read (nothing stored) and shows
|
|
1979
|
+
* under `epicId` when its session holds an entry of that epic, or when its id
|
|
1980
|
+
* is named by a `parent_prompt_id` of such an entry or of any epic-less entry
|
|
1981
|
+
* (the root `session-start`, which carries only `parent_prompt_id`) in one of
|
|
1982
|
+
* those sessions. A prompt carrying its own epic does not make its session an
|
|
1983
|
+
* epic session; it only decides itself. A session holding entries of two
|
|
1984
|
+
* epics shares its epic-less prompts, and the `parent_prompt_id` naming of its
|
|
1985
|
+
* epic-less entries, with both. The `prompts-<uuid>` home log has no entries
|
|
1986
|
+
* of its own. Every other epic-less entry is dropped.
|
|
1987
|
+
*/
|
|
1988
|
+
function promptInEpic(entries, epicId) {
|
|
1989
|
+
const sessions = new Set();
|
|
1990
|
+
for (const e of entries) {
|
|
1991
|
+
if (e.eventType !== 'user_prompt' && epicOfEntry(e) === epicId)
|
|
1992
|
+
sessions.add(e.sessionId);
|
|
1993
|
+
}
|
|
1994
|
+
const named = new Set();
|
|
1995
|
+
for (const e of entries) {
|
|
1996
|
+
const own = epicOfEntry(e);
|
|
1997
|
+
if (own !== null ? own !== epicId : !sessions.has(e.sessionId))
|
|
1998
|
+
continue;
|
|
1999
|
+
const parent = e.payload.parent_prompt_id;
|
|
2000
|
+
if (typeof parent === 'string')
|
|
2001
|
+
named.add(parent);
|
|
2002
|
+
}
|
|
2003
|
+
return (e) => {
|
|
2004
|
+
const own = epicOfEntry(e);
|
|
2005
|
+
if (own !== null)
|
|
2006
|
+
return own === epicId;
|
|
2007
|
+
return e.eventType === 'user_prompt' && (sessions.has(e.sessionId) || named.has(e.eventId));
|
|
2008
|
+
};
|
|
2009
|
+
}
|
|
1191
2010
|
export function timeline(db, filter = {}) {
|
|
1192
2011
|
if (filter.causalChainFor) {
|
|
1193
2012
|
if (!filter.sessionId) {
|
|
1194
2013
|
throw new RangeError('timeline(): causalChainFor requires sessionId to be set.');
|
|
1195
2014
|
}
|
|
1196
|
-
|
|
2015
|
+
if (filter.limit !== undefined ||
|
|
2016
|
+
filter.before !== undefined ||
|
|
2017
|
+
filter.after !== undefined ||
|
|
2018
|
+
filter.kinds !== undefined) {
|
|
2019
|
+
throw new RangeError('timeline(): causalChainFor cannot be combined with limit, before, after or kinds — the causal chain is not pageable.');
|
|
2020
|
+
}
|
|
2021
|
+
const chain = causalChain(db, filter.sessionId, filter.causalChainFor);
|
|
2022
|
+
joinSessionTitles(db, chain);
|
|
2023
|
+
return chain;
|
|
1197
2024
|
}
|
|
1198
2025
|
const eventTypes = filter.eventTypes ?? timelineEventTypes();
|
|
1199
2026
|
const conditions = [inArray(eventsRaw.eventType, eventTypes)];
|
|
@@ -1207,13 +2034,202 @@ export function timeline(db, filter = {}) {
|
|
|
1207
2034
|
.from(eventsRaw)
|
|
1208
2035
|
.where(and(...conditions))
|
|
1209
2036
|
.all());
|
|
1210
|
-
|
|
2037
|
+
// `kind` is cheap (pure function of eventType/payload) and the `kinds`
|
|
2038
|
+
// filter needs it, so it is computed for every fetched row up front.
|
|
2039
|
+
// `nearestPromptId` is the expensive one (a causal-parent walk), so it is
|
|
2040
|
+
// deferred until AFTER filtering and paging and only computed for the rows
|
|
2041
|
+
// that actually survive onto the page — see `memoizedNearestPromptId`.
|
|
2042
|
+
let entries = rows.map((row) => toEntry(row, null));
|
|
1211
2043
|
entries = filterByProject(entries, filter);
|
|
1212
|
-
if (filter.epicId)
|
|
1213
|
-
entries = entries.filter((
|
|
2044
|
+
if (filter.epicId) {
|
|
2045
|
+
entries = entries.filter(promptInEpic(entries, filter.epicId));
|
|
2046
|
+
}
|
|
2047
|
+
if (filter.kinds?.length) {
|
|
2048
|
+
const kinds = new Set(filter.kinds);
|
|
2049
|
+
entries = entries.filter((e) => kinds.has(e.kind));
|
|
2050
|
+
}
|
|
1214
2051
|
if (filter.decisionsOnly)
|
|
1215
2052
|
entries = applyDecisionsLens(entries);
|
|
1216
|
-
|
|
2053
|
+
const page = paginate(db, entries, filter);
|
|
2054
|
+
// Row lookup for the memo walk: the fetched rows answer most ancestors for
|
|
2055
|
+
// free (one query already brought them all into memory); a fallback cache
|
|
2056
|
+
// handles the rare ancestor outside that set (e.g. a cross-session hop).
|
|
2057
|
+
const rowByEventId = new Map(rows.map((row) => [row.eventId, row]));
|
|
2058
|
+
const ancestorCache = new Map();
|
|
2059
|
+
const getRow = (eventId) => rowByEventId.get(eventId) ?? fetchEventRow(db, eventId, ancestorCache);
|
|
2060
|
+
const promptMemo = new Map();
|
|
2061
|
+
for (const entry of page) {
|
|
2062
|
+
entry.nearestPromptId = memoizedNearestPromptId(entry.eventId, getRow, promptMemo);
|
|
2063
|
+
}
|
|
2064
|
+
joinDispatchRuns(db, page);
|
|
2065
|
+
joinGateCounts(page);
|
|
2066
|
+
joinSessionTitles(db, page);
|
|
2067
|
+
return page;
|
|
2068
|
+
}
|
|
2069
|
+
/**
|
|
2070
|
+
* DS6 PR4b (§4.1 1b) — fills `sessionTitle` on every entry in `page`, one
|
|
2071
|
+
* grouped lookup (`sessionTitles()`, the same logic `runningSessions()` uses
|
|
2072
|
+
* for its "Now running" cards) scoped to only the session ids the page
|
|
2073
|
+
* actually carries, rather than one query per row or an unscoped scan of
|
|
2074
|
+
* every session this factory has ever run.
|
|
2075
|
+
*/
|
|
2076
|
+
function joinSessionTitles(db, page) {
|
|
2077
|
+
const sessionIds = [...new Set(page.map((e) => e.sessionId))];
|
|
2078
|
+
if (sessionIds.length === 0)
|
|
2079
|
+
return;
|
|
2080
|
+
const titles = sessionTitles(db, { sessionIds });
|
|
2081
|
+
for (const entry of page) {
|
|
2082
|
+
entry.sessionTitle = titles.get(entry.sessionId) ?? entry.sessionId;
|
|
2083
|
+
}
|
|
2084
|
+
}
|
|
2085
|
+
/**
|
|
2086
|
+
* DS6 PR2 (§4.3 table) — fills `run` on every `Dispatched` entry in `page`,
|
|
2087
|
+
* batched for the page only (same cost discipline as `nearestPromptId`
|
|
2088
|
+
* above): one `agents` query keyed on the page's own dispatch event ids,
|
|
2089
|
+
* then one `eventsRaw` query for the terminal `task_run_result` rows those
|
|
2090
|
+
* `agents` rows name, rather than one query per row.
|
|
2091
|
+
*
|
|
2092
|
+
* `runStatus` is the task-result payload's `run_status` when present, else the
|
|
2093
|
+
* agent's own `status` once it is no longer `live` (done / superseded / error /
|
|
2094
|
+
* abandoned, so steps ended by judge-reported, error-logged, epic-closed etc.
|
|
2095
|
+
* read as finished), else null (truly live). Tokens and duration still come
|
|
2096
|
+
* only from a task-result payload.
|
|
2097
|
+
*/
|
|
2098
|
+
function joinDispatchRuns(db, page) {
|
|
2099
|
+
const dispatchIds = page.filter((e) => e.kind === 'Dispatched').map((e) => e.eventId);
|
|
2100
|
+
if (dispatchIds.length === 0)
|
|
2101
|
+
return;
|
|
2102
|
+
const agentRows = db.select().from(agents).where(inArray(agents.id, dispatchIds)).all();
|
|
2103
|
+
const agentByDispatchId = new Map(agentRows.map((a) => [a.id, a]));
|
|
2104
|
+
const terminalEventIds = agentRows
|
|
2105
|
+
.map((a) => a.terminalEventId)
|
|
2106
|
+
.filter((id) => id !== null);
|
|
2107
|
+
const resultRows = terminalEventIds.length === 0
|
|
2108
|
+
? []
|
|
2109
|
+
: db
|
|
2110
|
+
.select({ eventId: eventsRaw.eventId, payload: eventsRaw.payload })
|
|
2111
|
+
.from(eventsRaw)
|
|
2112
|
+
.where(and(inArray(eventsRaw.eventId, terminalEventIds), eq(eventsRaw.eventType, TASK_RESULT_EVENT_TYPE)))
|
|
2113
|
+
.all();
|
|
2114
|
+
const resultPayloadByEventId = new Map(resultRows.map((r) => [r.eventId, JSON.parse(r.payload)]));
|
|
2115
|
+
for (const entry of page) {
|
|
2116
|
+
if (entry.kind !== 'Dispatched')
|
|
2117
|
+
continue;
|
|
2118
|
+
const agent = agentByDispatchId.get(entry.eventId);
|
|
2119
|
+
if (!agent)
|
|
2120
|
+
continue;
|
|
2121
|
+
const resultPayload = agent.terminalEventId
|
|
2122
|
+
? resultPayloadByEventId.get(agent.terminalEventId)
|
|
2123
|
+
: undefined;
|
|
2124
|
+
const usage = readTokenUsage(resultPayload?.token_usage);
|
|
2125
|
+
entry.run = {
|
|
2126
|
+
tokensIn: usage.measured ? usage.input : null,
|
|
2127
|
+
tokensOut: usage.measured ? usage.output : null,
|
|
2128
|
+
durationMs: durationMsFromPayload(resultPayload ?? {}),
|
|
2129
|
+
runStatus: typeof resultPayload?.run_status === 'string'
|
|
2130
|
+
? resultPayload.run_status
|
|
2131
|
+
: agent.status !== 'live'
|
|
2132
|
+
? agent.status
|
|
2133
|
+
: null,
|
|
2134
|
+
dispatchedAt: agent.dispatchedAt,
|
|
2135
|
+
round: agent.round,
|
|
2136
|
+
};
|
|
2137
|
+
}
|
|
2138
|
+
}
|
|
2139
|
+
/**
|
|
2140
|
+
* DS6 PR2 (§4.3 table) — normalised `{passed, failed}` counts for every
|
|
2141
|
+
* `Gate` entry in `page`, from `testgate-result.results` (or an artifact check's `checked`/`issues`, or
|
|
2142
|
+
* `gate-outcome.results`, if a future writer adds one). `null` ("not
|
|
2143
|
+
* measured") only where counts are expected and missing: an artifact check
|
|
2144
|
+
* with neither field, or a `testgate-result` with no `results` array. Every
|
|
2145
|
+
* other check type without a `results` array never carries counts, so its
|
|
2146
|
+
* `gateCounts` stays unset and the UI leaves the item out. Pure over
|
|
2147
|
+
* already-fetched payloads, so no extra query is needed.
|
|
2148
|
+
*/
|
|
2149
|
+
function joinGateCounts(page) {
|
|
2150
|
+
for (const entry of page) {
|
|
2151
|
+
if (entry.kind !== 'Gate')
|
|
2152
|
+
continue;
|
|
2153
|
+
if (entry.eventType === 'artifact-check-result') {
|
|
2154
|
+
// Not a results array: { ok, checked, issues }.
|
|
2155
|
+
const { ok, checked, issues } = entry.payload;
|
|
2156
|
+
if (typeof checked !== 'number' && !Array.isArray(issues)) {
|
|
2157
|
+
entry.gateCounts = null;
|
|
2158
|
+
continue;
|
|
2159
|
+
}
|
|
2160
|
+
const failed = Math.max(Array.isArray(issues) ? issues.length : 0, ok === false ? 1 : 0);
|
|
2161
|
+
const total = typeof checked === 'number' ? checked : 0;
|
|
2162
|
+
entry.gateCounts = { passed: Math.max(0, total - failed), failed };
|
|
2163
|
+
continue;
|
|
2164
|
+
}
|
|
2165
|
+
const results = entry.payload.results;
|
|
2166
|
+
if (!Array.isArray(results)) {
|
|
2167
|
+
if (entry.eventType === 'testgate-result')
|
|
2168
|
+
entry.gateCounts = null;
|
|
2169
|
+
continue;
|
|
2170
|
+
}
|
|
2171
|
+
let passed = 0;
|
|
2172
|
+
let failed = 0;
|
|
2173
|
+
for (const r of results) {
|
|
2174
|
+
if (r && typeof r === 'object' && r.pass === true)
|
|
2175
|
+
passed += 1;
|
|
2176
|
+
else if (r && typeof r === 'object' && r.pass === false)
|
|
2177
|
+
failed += 1;
|
|
2178
|
+
}
|
|
2179
|
+
entry.gateCounts = { passed, failed };
|
|
2180
|
+
}
|
|
2181
|
+
}
|
|
2182
|
+
/** DS6 PR2 — a run's `duration_ms`, or null when the payload carries none (only some results carry it). */
|
|
2183
|
+
function durationMsFromPayload(payload) {
|
|
2184
|
+
return typeof payload.duration_ms === 'number' ? payload.duration_ms : null;
|
|
2185
|
+
}
|
|
2186
|
+
/**
|
|
2187
|
+
* Resolves `before`/`after` to the row's own `{ts, eventId}` so paging can
|
|
2188
|
+
* compare it with `compareLogOrder` — the SAME ordering the rest of the log
|
|
2189
|
+
* uses, never a string compare on the id (`<session>#<index>` ids are not
|
|
2190
|
+
* orderable across sessions; `#9` would sort after `#10` as text).
|
|
2191
|
+
*/
|
|
2192
|
+
function resolveCursor(db, eventId) {
|
|
2193
|
+
const row = db.select().from(eventsRaw).where(eq(eventsRaw.eventId, eventId)).get();
|
|
2194
|
+
if (!row) {
|
|
2195
|
+
throw new SmithError('timeline.unknown-cursor', `No event "${eventId}" to page from.`, {
|
|
2196
|
+
eventId,
|
|
2197
|
+
});
|
|
2198
|
+
}
|
|
2199
|
+
return { ts: row.ts, eventId: row.eventId };
|
|
2200
|
+
}
|
|
2201
|
+
/**
|
|
2202
|
+
* DS6 pattern 5 (paginated "Load older" + infinite scroll). A no-op unless
|
|
2203
|
+
* `limit`, `before` or `after` is set — the legacy oldest-first, unbounded
|
|
2204
|
+
* shape stays exactly as it was for every existing caller.
|
|
2205
|
+
*
|
|
2206
|
+
* Paged mode is always newest-first. `before` keeps rows strictly older than
|
|
2207
|
+
* the cursor (the "Load older" page). `after` keeps rows strictly newer; with
|
|
2208
|
+
* `limit` that means the OLDEST `limit` of them — the page adjacent to the
|
|
2209
|
+
* cursor on the newer side, read oldest-first then flipped to the feed's
|
|
2210
|
+
* newest-first order — rather than the newest `limit`, which would skip rows
|
|
2211
|
+
* between the cursor and the page it returned.
|
|
2212
|
+
*/
|
|
2213
|
+
function paginate(db, entries, filter) {
|
|
2214
|
+
if (filter.limit === undefined && filter.before === undefined && filter.after === undefined) {
|
|
2215
|
+
return entries;
|
|
2216
|
+
}
|
|
2217
|
+
let page = entries; // still oldest-first here
|
|
2218
|
+
if (filter.before !== undefined) {
|
|
2219
|
+
const cursor = resolveCursor(db, filter.before);
|
|
2220
|
+
page = page.filter((e) => compareLogOrder(e, cursor) < 0);
|
|
2221
|
+
}
|
|
2222
|
+
if (filter.after !== undefined) {
|
|
2223
|
+
const cursor = resolveCursor(db, filter.after);
|
|
2224
|
+
page = page.filter((e) => compareLogOrder(e, cursor) > 0);
|
|
2225
|
+
}
|
|
2226
|
+
if (filter.limit !== undefined) {
|
|
2227
|
+
page =
|
|
2228
|
+
filter.after !== undefined
|
|
2229
|
+
? page.slice(0, filter.limit)
|
|
2230
|
+
: page.slice(Math.max(0, page.length - filter.limit));
|
|
2231
|
+
}
|
|
2232
|
+
return page.slice().reverse();
|
|
1217
2233
|
}
|
|
1218
2234
|
function worstSeverity(severities) {
|
|
1219
2235
|
let worst = null;
|
|
@@ -1236,6 +2252,160 @@ function worstSeverity(severities) {
|
|
|
1236
2252
|
* task_status, tagged with case/origin/worst-open-finding severity plus
|
|
1237
2253
|
* (Phase 6b) title/agent-role/milestone.
|
|
1238
2254
|
*/
|
|
2255
|
+
function firstLineOf(text) {
|
|
2256
|
+
return (text.split('\n')[0] ?? '').trim();
|
|
2257
|
+
}
|
|
2258
|
+
function humanizeEpicId(epicId) {
|
|
2259
|
+
const spaced = epicId.replace(/-/g, ' ');
|
|
2260
|
+
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
|
|
2261
|
+
}
|
|
2262
|
+
/** DS3 — "<project>: <humanized epic id>[ (finished)]" (audit Kanban-4). */
|
|
2263
|
+
function epicLabelFor(epicId, project, closedEpicIds) {
|
|
2264
|
+
if (!epicId)
|
|
2265
|
+
return null;
|
|
2266
|
+
const label = `${projectOf(project)}: ${humanizeEpicId(epicId)}`;
|
|
2267
|
+
return closedEpicIds.has(epicId) ? `${label} (finished)` : label;
|
|
2268
|
+
}
|
|
2269
|
+
/** The epic a `<epic>/integration` task-ref belongs to, or null for any other ref. */
|
|
2270
|
+
function epicIdOfIntegrationRef(taskRef) {
|
|
2271
|
+
return taskRef.endsWith('/integration') ? taskRef.slice(0, -'/integration'.length) : null;
|
|
2272
|
+
}
|
|
2273
|
+
/**
|
|
2274
|
+
* DS4 S5b — every epic's latest `integration-pr-opened` URL, keyed by
|
|
2275
|
+
* epic id. Extracted out of `kanban()` (where this lived inline) so
|
|
2276
|
+
* `roadmapPage()` can share the exact same read instead of keeping a second
|
|
2277
|
+
* copy; `inLogOrder` means a later row for the same epic overwrites an
|
|
2278
|
+
* earlier one, so the map always holds the latest PR.
|
|
2279
|
+
*/
|
|
2280
|
+
function prUrlsByEpic(db) {
|
|
2281
|
+
const integrationRows = inLogOrder(db.select().from(eventsRaw).where(eq(eventsRaw.eventType, 'integration-pr-opened')).all());
|
|
2282
|
+
const result = new Map();
|
|
2283
|
+
for (const r of integrationRows) {
|
|
2284
|
+
const epicRef = r.taskId ? epicIdOfIntegrationRef(r.taskId) : null;
|
|
2285
|
+
if (!epicRef)
|
|
2286
|
+
continue;
|
|
2287
|
+
const payload = JSON.parse(r.payload);
|
|
2288
|
+
if (typeof payload.pr_url === 'string')
|
|
2289
|
+
result.set(epicRef, payload.pr_url);
|
|
2290
|
+
}
|
|
2291
|
+
return result;
|
|
2292
|
+
}
|
|
2293
|
+
/**
|
|
2294
|
+
* Cheap nearest-user_prompt-id resolver for `timeline()`'s full-table path:
|
|
2295
|
+
* the nearest prompt of X is X itself when X is a `user_prompt`, otherwise
|
|
2296
|
+
* the nearest prompt of X's causal parent. `nearestUserPrompt`/`causalChain`
|
|
2297
|
+
* additionally require a matching row in the `prompts` table, but
|
|
2298
|
+
* `projector.ts` inserts that row for EVERY `user_prompt` event in the same
|
|
2299
|
+
* transaction it inserts `events_raw` from, so that check can never miss in
|
|
2300
|
+
* practice and the eventType check alone is equivalent here.
|
|
2301
|
+
*
|
|
2302
|
+
* `causalChain`'s first-hop session gate (the walk refuses to start outside
|
|
2303
|
+
* the session it is asked for) is also moot against this function: every
|
|
2304
|
+
* caller feeds it a row's own `sessionId` as the walk's session (44ed474's
|
|
2305
|
+
* `nearestUserPrompt(db, row.sessionId, row.eventId, ...)`, now this
|
|
2306
|
+
* function's per-row callers in `timeline()`), so the first hop always names
|
|
2307
|
+
* a row inside that very session and the gate never fires either way. Later
|
|
2308
|
+
* hops were never gated in `causalChain` to begin with, so a cross-session
|
|
2309
|
+
* ancestor is reachable from both, identically (parity test:
|
|
2310
|
+
* `timelinePaging.test.ts`'s session-boundary case).
|
|
2311
|
+
*
|
|
2312
|
+
* Walks iteratively (no recursion — a session can chain thousands deep) and
|
|
2313
|
+
* memoises every eventId it visits along the way, not just the one asked
|
|
2314
|
+
* about, so a page of N rows with shared ancestors costs about one row
|
|
2315
|
+
* lookup per DISTINCT event in play rather than one causal walk per row.
|
|
2316
|
+
*/
|
|
2317
|
+
function memoizedNearestPromptId(startId, getRow, memo) {
|
|
2318
|
+
const path = [];
|
|
2319
|
+
const visiting = new Set();
|
|
2320
|
+
let current = startId;
|
|
2321
|
+
let result = null;
|
|
2322
|
+
while (current !== null) {
|
|
2323
|
+
const cached = memo.get(current);
|
|
2324
|
+
if (cached !== undefined) {
|
|
2325
|
+
result = cached;
|
|
2326
|
+
break;
|
|
2327
|
+
}
|
|
2328
|
+
if (visiting.has(current)) {
|
|
2329
|
+
result = null;
|
|
2330
|
+
break;
|
|
2331
|
+
}
|
|
2332
|
+
visiting.add(current);
|
|
2333
|
+
const row = getRow(current);
|
|
2334
|
+
if (!row) {
|
|
2335
|
+
memo.set(current, null);
|
|
2336
|
+
result = null;
|
|
2337
|
+
break;
|
|
2338
|
+
}
|
|
2339
|
+
if (row.eventType === 'user_prompt') {
|
|
2340
|
+
memo.set(current, current);
|
|
2341
|
+
result = current;
|
|
2342
|
+
break;
|
|
2343
|
+
}
|
|
2344
|
+
path.push(current);
|
|
2345
|
+
current = row.causalParent;
|
|
2346
|
+
}
|
|
2347
|
+
for (const id of path)
|
|
2348
|
+
memo.set(id, result);
|
|
2349
|
+
return result;
|
|
2350
|
+
}
|
|
2351
|
+
function nearestUserPrompt(db, sessionId, eventId, eventCache) {
|
|
2352
|
+
const chain = causalChain(db, sessionId, eventId, eventCache);
|
|
2353
|
+
for (let i = chain.length - 1; i >= 0; i--) {
|
|
2354
|
+
const entry = chain[i];
|
|
2355
|
+
if (entry?.eventType !== 'user_prompt')
|
|
2356
|
+
continue;
|
|
2357
|
+
const row = db.select().from(prompts).where(eq(prompts.eventId, entry.eventId)).get();
|
|
2358
|
+
if (row)
|
|
2359
|
+
return { prompt: row.prompt, ts: row.ts, eventId: row.eventId, source: 'task' };
|
|
2360
|
+
}
|
|
2361
|
+
return null;
|
|
2362
|
+
}
|
|
2363
|
+
export function createQuoteMemo() {
|
|
2364
|
+
return { events: new Map(), epicPrompt: new Map() };
|
|
2365
|
+
}
|
|
2366
|
+
/**
|
|
2367
|
+
* "Epic started from" fallback (§4.7): the earliest `user_prompt` in the
|
|
2368
|
+
* epic's session lineage. Exported (DS4 S5b) so `milestoneProgressRows()`
|
|
2369
|
+
* can reuse it for `epics[].sourcePrompt` — same shape `requestQuoteForTask()`
|
|
2370
|
+
* returns, so `RequestQuote.vue` renders it unchanged.
|
|
2371
|
+
*/
|
|
2372
|
+
export function epicSourcePrompt(db, anySessionIdInEpic) {
|
|
2373
|
+
const lineage = projectedLineage(db, anySessionIdInEpic);
|
|
2374
|
+
if (lineage.length === 0)
|
|
2375
|
+
return null;
|
|
2376
|
+
const rows = inLogOrder(db.select().from(prompts).where(inArray(prompts.sessionId, lineage)).all());
|
|
2377
|
+
const earliest = rows[0];
|
|
2378
|
+
return earliest
|
|
2379
|
+
? { prompt: earliest.prompt, ts: earliest.ts, eventId: earliest.eventId, source: 'epic' }
|
|
2380
|
+
: null;
|
|
2381
|
+
}
|
|
2382
|
+
/**
|
|
2383
|
+
* DS3 §4.7 — the operator prompt that led to `taskId`: a bounded, cycle-safe
|
|
2384
|
+
* walk up `causal_parent` from the task's own events to the nearest
|
|
2385
|
+
* `user_prompt` (reuses `causalChain`'s `seen` guard), falling back to the
|
|
2386
|
+
* epic's own source prompt when the task's own walk finds none.
|
|
2387
|
+
*/
|
|
2388
|
+
export function requestQuoteForTask(db, taskId, taskSessionId, memo) {
|
|
2389
|
+
const firstEvent = inLogOrder(db.select().from(eventsRaw).where(eq(eventsRaw.taskId, taskId)).all())[0];
|
|
2390
|
+
if (firstEvent) {
|
|
2391
|
+
const taskQuote = nearestUserPrompt(db, firstEvent.sessionId, firstEvent.eventId, memo?.events);
|
|
2392
|
+
if (taskQuote)
|
|
2393
|
+
return taskQuote;
|
|
2394
|
+
}
|
|
2395
|
+
// Two tasks named with the same epic share one `epicSourcePrompt` answer
|
|
2396
|
+
// (same lineage walk, same earliest prompt), so the fallback is cached per
|
|
2397
|
+
// epic rather than per task or per session.
|
|
2398
|
+
const epicId = epicOfTaskId(taskId);
|
|
2399
|
+
if (epicId !== null) {
|
|
2400
|
+
const cached = memo?.epicPrompt.get(epicId);
|
|
2401
|
+
if (cached !== undefined)
|
|
2402
|
+
return cached;
|
|
2403
|
+
}
|
|
2404
|
+
const quote = epicSourcePrompt(db, taskSessionId);
|
|
2405
|
+
if (epicId !== null)
|
|
2406
|
+
memo?.epicPrompt.set(epicId, quote);
|
|
2407
|
+
return quote;
|
|
2408
|
+
}
|
|
1239
2409
|
export function kanban(db, epicId, scope = {}, opts = {}) {
|
|
1240
2410
|
const nowIso = opts.nowIso ?? new Date().toISOString();
|
|
1241
2411
|
const epicCond = epicId !== undefined ? eq(tasks.epicId, epicId) : undefined;
|
|
@@ -1247,7 +2417,7 @@ export function kanban(db, epicId, scope = {}, opts = {}) {
|
|
|
1247
2417
|
.from(tasks)
|
|
1248
2418
|
.where(and(...taskConds))
|
|
1249
2419
|
.all()
|
|
1250
|
-
: db.select().from(tasks).all(), scope);
|
|
2420
|
+
: db.select().from(tasks).all(), scope).filter((t) => !isAuditAxisRowId(t.taskId));
|
|
1251
2421
|
const epicIdsInScope = new Set(taskRows.map((t) => t.epicId).filter((e) => e !== null));
|
|
1252
2422
|
const findingCond = epicId !== undefined ? eq(findings.epicId, epicId) : undefined;
|
|
1253
2423
|
const findingRows = findingCond
|
|
@@ -1309,9 +2479,86 @@ export function kanban(db, epicId, scope = {}, opts = {}) {
|
|
|
1309
2479
|
for (const e of JSON.parse(m.epicIds))
|
|
1310
2480
|
milestoneByEpic.set(e, m.milestoneId);
|
|
1311
2481
|
}
|
|
2482
|
+
// DS3 item 2 — attempt count, per task_id, from the same dispatch rows above.
|
|
2483
|
+
const attemptCountByTask = new Map();
|
|
2484
|
+
for (const d of dispatchRows) {
|
|
2485
|
+
if (!d.taskId)
|
|
2486
|
+
continue;
|
|
2487
|
+
attemptCountByTask.set(d.taskId, (attemptCountByTask.get(d.taskId) ?? 0) + 1);
|
|
2488
|
+
}
|
|
2489
|
+
// DS3 item 2 — highest judge-role dispatch round per task_id, from the agents fold.
|
|
2490
|
+
const judgeRoleSet = new Set(JUDGE_ROLES);
|
|
2491
|
+
const agentSessionCond = scopedToSessions(agents.sessionId, scope);
|
|
2492
|
+
const agentRows = agentSessionCond
|
|
2493
|
+
? db.select().from(agents).where(agentSessionCond).all()
|
|
2494
|
+
: db.select().from(agents).all();
|
|
2495
|
+
const judgeRoundByTask = new Map();
|
|
2496
|
+
for (const a of agentRows) {
|
|
2497
|
+
if (!a.taskId || !judgeRoleSet.has(a.agentRole))
|
|
2498
|
+
continue;
|
|
2499
|
+
const current = judgeRoundByTask.get(a.taskId) ?? 0;
|
|
2500
|
+
if (a.round > current)
|
|
2501
|
+
judgeRoundByTask.set(a.taskId, a.round);
|
|
2502
|
+
}
|
|
2503
|
+
// DS3 item 2 — comment count per task_id, from operator_feedback.
|
|
2504
|
+
const feedbackSessionCond = scopedToSessions(operatorFeedback.sessionId, scope);
|
|
2505
|
+
const feedbackRows = feedbackSessionCond
|
|
2506
|
+
? db.select().from(operatorFeedback).where(feedbackSessionCond).all()
|
|
2507
|
+
: db.select().from(operatorFeedback).all();
|
|
2508
|
+
const commentCountByTask = new Map();
|
|
2509
|
+
for (const f of feedbackRows) {
|
|
2510
|
+
commentCountByTask.set(f.taskId, (commentCountByTask.get(f.taskId) ?? 0) + 1);
|
|
2511
|
+
}
|
|
2512
|
+
// DS3 item 2 — epic-level integration PR url, keyed by epic_id (there is no
|
|
2513
|
+
// per-task PR concept today, only the epic's own integration-pr-opened).
|
|
2514
|
+
// DS4 S5b — extracted to prUrlsByEpic() so roadmapPage() shares this exact
|
|
2515
|
+
// read instead of keeping a second copy.
|
|
2516
|
+
const prUrlByEpic = prUrlsByEpic(db);
|
|
2517
|
+
// DS3 item 2 — this task's dependency edges, resolved against the task rows
|
|
2518
|
+
// already in scope; a dependsOn id outside scope resolves to a null title/status.
|
|
2519
|
+
const taskRowByTaskId = new Map(taskRows.map((t) => [t.taskId, t]));
|
|
2520
|
+
const edgeSessionCond = scopedToSessions(edges.sessionId, scope);
|
|
2521
|
+
const edgeRows = edgeSessionCond
|
|
2522
|
+
? db.select().from(edges).where(edgeSessionCond).all()
|
|
2523
|
+
: db.select().from(edges).all();
|
|
2524
|
+
const edgesByTask = new Map();
|
|
2525
|
+
for (const e of edgeRows) {
|
|
2526
|
+
const list = edgesByTask.get(e.taskId) ?? [];
|
|
2527
|
+
list.push(e);
|
|
2528
|
+
edgesByTask.set(e.taskId, list);
|
|
2529
|
+
}
|
|
2530
|
+
// DS3 item 2 — epic friendly label; closed epics (epics table holds only those) get " (finished)".
|
|
2531
|
+
const closedEpicIds = new Set(db
|
|
2532
|
+
.select({ epicId: epics.epicId })
|
|
2533
|
+
.from(epics)
|
|
2534
|
+
.all()
|
|
2535
|
+
.map((e) => e.epicId));
|
|
2536
|
+
// DS3 S3 fix — one shared memo for every task's request-quote walk in this
|
|
2537
|
+
// call: bounds the N+1 causal walk/epic-fallback cost that scaled with the
|
|
2538
|
+
// task count (see QuoteMemo's doc comment).
|
|
2539
|
+
const quoteMemo = createQuoteMemo();
|
|
2540
|
+
// A follow-up's origin lives only on the `finding-reattributed` event that
|
|
2541
|
+
// follows its `task-added`; one read of those events, keyed by the follow-up.
|
|
2542
|
+
const parentByFollowUp = new Map();
|
|
2543
|
+
for (const e of db
|
|
2544
|
+
.select({ payload: eventsRaw.payload })
|
|
2545
|
+
.from(eventsRaw)
|
|
2546
|
+
.where(eq(eventsRaw.eventType, 'finding-reattributed'))
|
|
2547
|
+
.orderBy(sql `rowid`)
|
|
2548
|
+
.all()) {
|
|
2549
|
+
const p = JSON.parse(e.payload);
|
|
2550
|
+
if (p.attribution === 'follow-up' &&
|
|
2551
|
+
typeof p.from_task_id === 'string' &&
|
|
2552
|
+
typeof p.to_task_id === 'string' &&
|
|
2553
|
+
!parentByFollowUp.has(p.to_task_id)) {
|
|
2554
|
+
// First event wins: the origin never moves once it is recorded.
|
|
2555
|
+
parentByFollowUp.set(p.to_task_id, p.from_task_id);
|
|
2556
|
+
}
|
|
2557
|
+
}
|
|
1312
2558
|
const columns = new Map();
|
|
1313
2559
|
for (const t of taskRows) {
|
|
1314
2560
|
const column = columns.get(t.taskStatus) ?? [];
|
|
2561
|
+
const quote = requestQuoteForTask(db, t.taskId, t.sessionId, quoteMemo);
|
|
1315
2562
|
column.push({
|
|
1316
2563
|
taskId: t.taskId,
|
|
1317
2564
|
taskStatus: t.taskStatus,
|
|
@@ -1325,19 +2572,74 @@ export function kanban(db, epicId, scope = {}, opts = {}) {
|
|
|
1325
2572
|
origin: t.origin,
|
|
1326
2573
|
severity: worstSeverity(openSeverityByTask.get(t.taskId) ?? []),
|
|
1327
2574
|
},
|
|
2575
|
+
updatedAt: t.updatedAt,
|
|
2576
|
+
project: t.project,
|
|
2577
|
+
attemptCount: attemptCountByTask.get(t.taskId) ?? 0,
|
|
2578
|
+
judgeRound: judgeRoundByTask.get(t.taskId) ?? null,
|
|
2579
|
+
commentCount: commentCountByTask.get(t.taskId) ?? 0,
|
|
2580
|
+
prUrl: t.epicId ? (prUrlByEpic.get(t.epicId) ?? null) : null,
|
|
2581
|
+
dependencies: (edgesByTask.get(t.taskId) ?? []).map((e) => {
|
|
2582
|
+
const dep = taskRowByTaskId.get(e.dependsOn);
|
|
2583
|
+
return {
|
|
2584
|
+
taskId: e.dependsOn,
|
|
2585
|
+
title: dep?.objective ?? null,
|
|
2586
|
+
status: dep?.taskStatus ?? null,
|
|
2587
|
+
edgeType: e.edgeType,
|
|
2588
|
+
};
|
|
2589
|
+
}),
|
|
2590
|
+
epicLabel: epicLabelFor(t.epicId, t.project, closedEpicIds),
|
|
2591
|
+
hasRequest: quote !== null,
|
|
2592
|
+
requestFirstLine: quote ? firstLineOf(quote.prompt) : null,
|
|
2593
|
+
parentTaskId: parentByFollowUp.get(t.taskId) ?? null,
|
|
2594
|
+
parentTitle: taskRowByTaskId.get(parentByFollowUp.get(t.taskId) ?? '')?.objective ?? null,
|
|
1328
2595
|
});
|
|
1329
2596
|
columns.set(t.taskStatus, column);
|
|
1330
2597
|
}
|
|
2598
|
+
// DS3 item 3 — deterministic order within a column: most recently updated
|
|
2599
|
+
// first, task_id as the tiebreaker so two same-instant rows never flap.
|
|
1331
2600
|
return [...columns.entries()]
|
|
1332
2601
|
.sort(([a], [b]) => a.localeCompare(b))
|
|
1333
|
-
.map(([taskStatus, tasksInColumn]) => ({
|
|
2602
|
+
.map(([taskStatus, tasksInColumn]) => ({
|
|
2603
|
+
taskStatus,
|
|
2604
|
+
tasks: [...tasksInColumn].sort((a, b) => {
|
|
2605
|
+
if (a.updatedAt !== b.updatedAt)
|
|
2606
|
+
return a.updatedAt < b.updatedAt ? 1 : -1;
|
|
2607
|
+
return a.taskId < b.taskId ? -1 : a.taskId > b.taskId ? 1 : 0;
|
|
2608
|
+
}),
|
|
2609
|
+
}));
|
|
1334
2610
|
}
|
|
1335
|
-
|
|
2611
|
+
/**
|
|
2612
|
+
* The per-event position of an artifact row, parsed off its own id
|
|
2613
|
+
* (`${eventId}#${index}`, projector.ts). Splitting on the LAST `#` keeps an
|
|
2614
|
+
* event id that itself contains one (every event id does: `<session>#<n>`)
|
|
2615
|
+
* from being cut in the wrong place.
|
|
2616
|
+
*/
|
|
2617
|
+
function artifactIndexOf(id) {
|
|
2618
|
+
const cut = id.lastIndexOf('#');
|
|
2619
|
+
const index = Number(id.slice(cut + 1));
|
|
2620
|
+
return cut === -1 || Number.isNaN(index) ? 0 : index;
|
|
2621
|
+
}
|
|
2622
|
+
export function taskDetail(db, taskId, opts = {}) {
|
|
2623
|
+
const nowIso = opts.nowIso ?? new Date().toISOString();
|
|
1336
2624
|
const task = db.select().from(tasks).where(eq(tasks.taskId, taskId)).get();
|
|
1337
2625
|
if (!task)
|
|
1338
2626
|
return null;
|
|
1339
2627
|
const dispatchRows = inLogOrder(db.select().from(dispatches).where(eq(dispatches.taskId, taskId)).all());
|
|
1340
2628
|
const agentRows = db.select().from(agents).where(eq(agents.taskId, taskId)).all();
|
|
2629
|
+
// Mirrors kanban()'s activityByTask: a live agents row dispatched inside
|
|
2630
|
+
// the stale window is "working"; a live row past it is "stalled"; a
|
|
2631
|
+
// working row wins over a stalled one on the same task.
|
|
2632
|
+
let agentActivity = null;
|
|
2633
|
+
for (const a of agentRows) {
|
|
2634
|
+
if (a.status !== 'live')
|
|
2635
|
+
continue;
|
|
2636
|
+
if (isWorkingAt(a.dispatchedAt, nowIso)) {
|
|
2637
|
+
agentActivity = 'working';
|
|
2638
|
+
break;
|
|
2639
|
+
}
|
|
2640
|
+
if (agentActivity === null)
|
|
2641
|
+
agentActivity = 'stalled';
|
|
2642
|
+
}
|
|
1341
2643
|
const agentByEventId = new Map(agentRows.map((a) => [a.id, a]));
|
|
1342
2644
|
const attempts = dispatchRows.map((d) => {
|
|
1343
2645
|
const agent = agentByEventId.get(d.eventId);
|
|
@@ -1352,7 +2654,28 @@ export function taskDetail(db, taskId) {
|
|
|
1352
2654
|
};
|
|
1353
2655
|
});
|
|
1354
2656
|
const findingRows = db.select().from(findings).where(eq(findings.taskId, taskId)).all();
|
|
1355
|
-
|
|
2657
|
+
// Outputs tab, newest first (same operator request as taskRuns() above):
|
|
2658
|
+
// newest event first (`compareLogOrder`, the same log-order comparator the
|
|
2659
|
+
// rest of the log uses — plain `id desc` breaks because the index on
|
|
2660
|
+
// `${eventId}#${index}` is unpadded text, so `#10` sorts between `#1` and
|
|
2661
|
+
// `#2`), and within one event, the order the agent listed the artifacts in
|
|
2662
|
+
// (index ascending).
|
|
2663
|
+
const artifactRows = db
|
|
2664
|
+
.select()
|
|
2665
|
+
.from(artifacts)
|
|
2666
|
+
.where(eq(artifacts.taskId, taskId))
|
|
2667
|
+
.all()
|
|
2668
|
+
.sort((a, b) => {
|
|
2669
|
+
const logCmp = compareLogOrder(b, a);
|
|
2670
|
+
if (logCmp !== 0)
|
|
2671
|
+
return logCmp;
|
|
2672
|
+
return artifactIndexOf(a.id) - artifactIndexOf(b.id);
|
|
2673
|
+
});
|
|
2674
|
+
const feedbackRows = db
|
|
2675
|
+
.select()
|
|
2676
|
+
.from(operatorFeedback)
|
|
2677
|
+
.where(eq(operatorFeedback.taskId, taskId))
|
|
2678
|
+
.all();
|
|
1356
2679
|
return {
|
|
1357
2680
|
task,
|
|
1358
2681
|
claims: task.claims ? JSON.parse(task.claims) : [],
|
|
@@ -1360,9 +2683,251 @@ export function taskDetail(db, taskId) {
|
|
|
1360
2683
|
agents: agentRows,
|
|
1361
2684
|
findings: findingRows,
|
|
1362
2685
|
artifacts: artifactRows,
|
|
2686
|
+
feedback: feedbackRows,
|
|
1363
2687
|
branch: task.branch,
|
|
2688
|
+
requestQuote: requestQuoteForTask(db, task.taskId, task.sessionId),
|
|
2689
|
+
agentActivity,
|
|
2690
|
+
};
|
|
2691
|
+
}
|
|
2692
|
+
// ---------------------------------------------------------------------------
|
|
2693
|
+
// taskRuns()
|
|
2694
|
+
// ---------------------------------------------------------------------------
|
|
2695
|
+
const RUN_EVENT_TYPES = [
|
|
2696
|
+
DISPATCH_EVENT_TYPE,
|
|
2697
|
+
JUDGE_REPORT_EVENT_TYPE,
|
|
2698
|
+
TASK_RESULT_EVENT_TYPE,
|
|
2699
|
+
ERROR_EVENT_TYPE,
|
|
2700
|
+
JUDGE_VERDICT_EVENT_TYPE,
|
|
2701
|
+
];
|
|
2702
|
+
const RUN_KIND_BY_EVENT_TYPE = {
|
|
2703
|
+
[DISPATCH_EVENT_TYPE]: 'dispatch',
|
|
2704
|
+
[JUDGE_REPORT_EVENT_TYPE]: 'judge-report',
|
|
2705
|
+
[TASK_RESULT_EVENT_TYPE]: 'result',
|
|
2706
|
+
[ERROR_EVENT_TYPE]: 'error',
|
|
2707
|
+
[JUDGE_VERDICT_EVENT_TYPE]: 'judge-verdict',
|
|
2708
|
+
};
|
|
2709
|
+
function tokensTotalFromPayload(payload) {
|
|
2710
|
+
const usage = readTokenUsage(payload.token_usage);
|
|
2711
|
+
return usage.measured ? usage.total : null;
|
|
2712
|
+
}
|
|
2713
|
+
function outcomeFromPayload(eventType, payload) {
|
|
2714
|
+
if (eventType === TASK_RESULT_EVENT_TYPE && typeof payload.run_status === 'string')
|
|
2715
|
+
return payload.run_status;
|
|
2716
|
+
if (eventType === ERROR_EVENT_TYPE && typeof payload.error === 'string')
|
|
2717
|
+
return payload.error;
|
|
2718
|
+
if (eventType === JUDGE_REPORT_EVENT_TYPE) {
|
|
2719
|
+
// judge-reported carries no accept/dismiss verdict of its own (that is a
|
|
2720
|
+
// separate judge-verdict event, handled below) — the closest the report
|
|
2721
|
+
// payload has to an outcome is whether it attested "no findings" rather
|
|
2722
|
+
// than naming a finding count.
|
|
2723
|
+
if (payload.attested_by !== undefined && payload.attested_by !== null)
|
|
2724
|
+
return 'no-findings';
|
|
2725
|
+
if (typeof payload.finding_count === 'number')
|
|
2726
|
+
return `${payload.finding_count}-findings`;
|
|
2727
|
+
}
|
|
2728
|
+
if (eventType === JUDGE_VERDICT_EVENT_TYPE) {
|
|
2729
|
+
// A verdict that never ran reads like the Activity feed's title: `failed`,
|
|
2730
|
+
// with the error code when the payload names one.
|
|
2731
|
+
if (payload.ok === false)
|
|
2732
|
+
return typeof payload.error_code === 'string' && payload.error_code !== ''
|
|
2733
|
+
? `failed: ${payload.error_code}`
|
|
2734
|
+
: 'failed';
|
|
2735
|
+
if (typeof payload.verdict === 'string')
|
|
2736
|
+
return payload.verdict;
|
|
2737
|
+
}
|
|
2738
|
+
return null;
|
|
2739
|
+
}
|
|
2740
|
+
function fetchTaskRunRows(db, taskId) {
|
|
2741
|
+
return inLogOrder(db
|
|
2742
|
+
.select()
|
|
2743
|
+
.from(eventsRaw)
|
|
2744
|
+
.where(and(eq(eventsRaw.taskId, taskId), inArray(eventsRaw.eventType, [...RUN_EVENT_TYPES])))
|
|
2745
|
+
.all());
|
|
2746
|
+
}
|
|
2747
|
+
export function taskRuns(db, taskId) {
|
|
2748
|
+
// `fetchTaskRunRows` stays oldest-first (shared with `taskTotals()`, which
|
|
2749
|
+
// reads "first dispatch" / "last result" off that order); newest-first is
|
|
2750
|
+
// this function's own output shape, so the rows are reversed here, once.
|
|
2751
|
+
const rows = fetchTaskRunRows(db, taskId).slice().reverse();
|
|
2752
|
+
// A judge-verdict names neither role nor (mostly) round; the agents row it
|
|
2753
|
+
// terminated does. One query for the page.
|
|
2754
|
+
const verdictIds = rows
|
|
2755
|
+
.filter((r) => r.eventType === JUDGE_VERDICT_EVENT_TYPE)
|
|
2756
|
+
.map((r) => r.eventId);
|
|
2757
|
+
const agentByTerminal = new Map(verdictIds.length === 0
|
|
2758
|
+
? []
|
|
2759
|
+
: db
|
|
2760
|
+
.select({ t: agents.terminalEventId, role: agents.agentRole, round: agents.round })
|
|
2761
|
+
.from(agents)
|
|
2762
|
+
.where(inArray(agents.terminalEventId, verdictIds))
|
|
2763
|
+
.all()
|
|
2764
|
+
.map((a) => [a.t, a]));
|
|
2765
|
+
return rows.flatMap((r) => {
|
|
2766
|
+
const kind = RUN_KIND_BY_EVENT_TYPE[r.eventType];
|
|
2767
|
+
if (!kind)
|
|
2768
|
+
return [];
|
|
2769
|
+
const payload = JSON.parse(r.payload);
|
|
2770
|
+
const agentRow = agentByTerminal.get(r.eventId);
|
|
2771
|
+
const round = typeof payload.round === 'number' ? payload.round : (agentRow?.round ?? null);
|
|
2772
|
+
return [
|
|
2773
|
+
{
|
|
2774
|
+
eventId: r.eventId,
|
|
2775
|
+
ts: r.ts,
|
|
2776
|
+
kind,
|
|
2777
|
+
agentRole: typeof payload.agent_role === 'string' ? payload.agent_role : (agentRow?.role ?? null),
|
|
2778
|
+
round: round ?? (r.eventType === DISPATCH_EVENT_TYPE ? 1 : null),
|
|
2779
|
+
tokensTotal: tokensTotalFromPayload(payload),
|
|
2780
|
+
outcome: outcomeFromPayload(r.eventType, payload),
|
|
2781
|
+
},
|
|
2782
|
+
];
|
|
2783
|
+
});
|
|
2784
|
+
}
|
|
2785
|
+
/**
|
|
2786
|
+
* DS3 §2.4c — the per-task totals read above `RunHistoryTimeline`, additive
|
|
2787
|
+
* on `GET /api/tasks/:taskId/runs`. Scoped to the same rows `taskRuns()`
|
|
2788
|
+
* reads (no new event type, no writer); "not measured" is `null`, never a
|
|
2789
|
+
* bare 0 (the spec's own phrasing for this bar).
|
|
2790
|
+
*/
|
|
2791
|
+
export function taskTotals(db, taskId) {
|
|
2792
|
+
const rows = fetchTaskRunRows(db, taskId);
|
|
2793
|
+
let tokensSum = 0;
|
|
2794
|
+
let anyTokens = false;
|
|
2795
|
+
let durationSum = 0;
|
|
2796
|
+
let anyDuration = false;
|
|
2797
|
+
let startTs = null;
|
|
2798
|
+
let endTs = null;
|
|
2799
|
+
for (const r of rows) {
|
|
2800
|
+
// A verdict is history-only: the totals never counted it, and still don't.
|
|
2801
|
+
if (r.eventType === JUDGE_VERDICT_EVENT_TYPE)
|
|
2802
|
+
continue;
|
|
2803
|
+
const payload = JSON.parse(r.payload);
|
|
2804
|
+
const tokens = tokensTotalFromPayload(payload);
|
|
2805
|
+
if (tokens !== null) {
|
|
2806
|
+
tokensSum += tokens;
|
|
2807
|
+
anyTokens = true;
|
|
2808
|
+
}
|
|
2809
|
+
const durationMs = durationMsFromPayload(payload);
|
|
2810
|
+
if (durationMs !== null) {
|
|
2811
|
+
durationSum += durationMs;
|
|
2812
|
+
anyDuration = true;
|
|
2813
|
+
}
|
|
2814
|
+
const kind = RUN_KIND_BY_EVENT_TYPE[r.eventType];
|
|
2815
|
+
if (kind === 'dispatch') {
|
|
2816
|
+
if (startTs === null || r.ts < startTs)
|
|
2817
|
+
startTs = r.ts;
|
|
2818
|
+
}
|
|
2819
|
+
if (kind === 'result' || kind === 'error') {
|
|
2820
|
+
if (endTs === null || r.ts > endTs)
|
|
2821
|
+
endTs = r.ts;
|
|
2822
|
+
}
|
|
2823
|
+
}
|
|
2824
|
+
return {
|
|
2825
|
+
tokens: anyTokens ? tokensSum : null,
|
|
2826
|
+
agentTimeMs: anyDuration ? durationSum : null,
|
|
2827
|
+
elapsedMs: startTs !== null && endTs !== null
|
|
2828
|
+
? new Date(endTs).getTime() - new Date(startTs).getTime()
|
|
2829
|
+
: null,
|
|
2830
|
+
startedAt: startTs,
|
|
2831
|
+
endedAt: endTs,
|
|
1364
2832
|
};
|
|
1365
2833
|
}
|
|
2834
|
+
/** `tokensTotalFromPayload` reads `total_tokens` only; this reads the full measured triple. */
|
|
2835
|
+
function tokenUsageFromPayload(payload) {
|
|
2836
|
+
const usage = readTokenUsage(payload.token_usage);
|
|
2837
|
+
if (usage.measured && usage.input !== null && usage.output !== null) {
|
|
2838
|
+
return { state: 'measured', input: usage.input, output: usage.output, total: usage.total };
|
|
2839
|
+
}
|
|
2840
|
+
return { state: 'unmeasured' };
|
|
2841
|
+
}
|
|
2842
|
+
/**
|
|
2843
|
+
* Projection read only (no new writer): every agent `sessionId` has
|
|
2844
|
+
* dispatched, grouped by role in first-dispatch order. An unknown session
|
|
2845
|
+
* returns `{ roles: [] }` — the route turns that into its own 404, since
|
|
2846
|
+
* "no agents" and "no such session" read the same from here.
|
|
2847
|
+
*/
|
|
2848
|
+
export function sessionAgents(db, sessionId, opts = {}) {
|
|
2849
|
+
const nowIso = opts.nowIso ?? new Date().toISOString();
|
|
2850
|
+
const agentRows = inLogOrder(db
|
|
2851
|
+
.select()
|
|
2852
|
+
.from(agents)
|
|
2853
|
+
.where(eq(agents.sessionId, sessionId))
|
|
2854
|
+
.all()
|
|
2855
|
+
.map((a) => ({ ...a, ts: a.dispatchedAt, eventId: a.id })));
|
|
2856
|
+
if (agentRows.length === 0)
|
|
2857
|
+
return { sessionId, roles: [] };
|
|
2858
|
+
const taskIds = [...new Set(agentRows.flatMap((a) => (a.taskId ? [a.taskId] : [])))];
|
|
2859
|
+
const taskTitleById = new Map(taskIds.length > 0
|
|
2860
|
+
? db
|
|
2861
|
+
.select({ taskId: tasks.taskId, title: tasks.title })
|
|
2862
|
+
.from(tasks)
|
|
2863
|
+
.where(inArray(tasks.taskId, taskIds))
|
|
2864
|
+
.all()
|
|
2865
|
+
.map((t) => [t.taskId, t.title])
|
|
2866
|
+
: []);
|
|
2867
|
+
const eventRows = db.select().from(eventsRaw).where(eq(eventsRaw.sessionId, sessionId)).all();
|
|
2868
|
+
const terminalPayloadById = new Map(eventRows.map((e) => [e.eventId, e]));
|
|
2869
|
+
const roleOrder = [];
|
|
2870
|
+
const byRole = new Map();
|
|
2871
|
+
for (const a of agentRows) {
|
|
2872
|
+
const tokens = a.status === 'live'
|
|
2873
|
+
? { state: 'pending' }
|
|
2874
|
+
: a.status === 'done'
|
|
2875
|
+
? (() => {
|
|
2876
|
+
const terminalEvent = a.terminalEventId
|
|
2877
|
+
? terminalPayloadById.get(a.terminalEventId)
|
|
2878
|
+
: undefined;
|
|
2879
|
+
return terminalEvent
|
|
2880
|
+
? tokenUsageFromPayload(JSON.parse(terminalEvent.payload))
|
|
2881
|
+
: { state: 'unmeasured' };
|
|
2882
|
+
})()
|
|
2883
|
+
: { state: 'none' };
|
|
2884
|
+
const windowEnd = a.terminalAt ?? nowIso;
|
|
2885
|
+
// Scoped to this agent's own task when it has one; an epic-level dispatch
|
|
2886
|
+
// (no task) has no narrower column to filter on than the session itself.
|
|
2887
|
+
const windowEvents = eventRows.filter((e) => {
|
|
2888
|
+
if (e.ts < a.dispatchedAt || e.ts > windowEnd)
|
|
2889
|
+
return false;
|
|
2890
|
+
return a.taskId ? e.taskId === a.taskId : true;
|
|
2891
|
+
});
|
|
2892
|
+
const lastEvent = inLogOrder(windowEvents).at(-1) ?? null;
|
|
2893
|
+
const entry = {
|
|
2894
|
+
id: a.id,
|
|
2895
|
+
agentRole: a.agentRole,
|
|
2896
|
+
provider: a.provider,
|
|
2897
|
+
modelTier: a.modelTier,
|
|
2898
|
+
taskId: a.taskId,
|
|
2899
|
+
taskTitle: a.taskId ? (taskTitleById.get(a.taskId) ?? null) : null,
|
|
2900
|
+
epicId: a.epicId,
|
|
2901
|
+
round: a.round,
|
|
2902
|
+
dispatchedAt: a.dispatchedAt,
|
|
2903
|
+
terminalAt: a.terminalAt,
|
|
2904
|
+
terminalType: a.terminalType,
|
|
2905
|
+
status: a.status,
|
|
2906
|
+
tokens,
|
|
2907
|
+
lastEventType: lastEvent?.eventType ?? null,
|
|
2908
|
+
lastEventAt: lastEvent?.ts ?? null,
|
|
2909
|
+
};
|
|
2910
|
+
if (!byRole.has(a.agentRole)) {
|
|
2911
|
+
roleOrder.push(a.agentRole);
|
|
2912
|
+
byRole.set(a.agentRole, []);
|
|
2913
|
+
}
|
|
2914
|
+
byRole.get(a.agentRole)?.push(entry);
|
|
2915
|
+
}
|
|
2916
|
+
return {
|
|
2917
|
+
sessionId,
|
|
2918
|
+
roles: roleOrder.map((role) => ({ agentRole: role, agents: byRole.get(role) ?? [] })),
|
|
2919
|
+
};
|
|
2920
|
+
}
|
|
2921
|
+
/**
|
|
2922
|
+
* One projected artifact row by its id (`${event_id}#${index}`), for the
|
|
2923
|
+
* dashboard's artifact-serving route — it needs the declaring task and the
|
|
2924
|
+
* declared path to resolve a file, and neither is safe to take from the
|
|
2925
|
+
* request itself.
|
|
2926
|
+
*/
|
|
2927
|
+
export function artifactById(db, artifactId) {
|
|
2928
|
+
return db.select().from(artifacts).where(eq(artifacts.id, artifactId)).get() ?? null;
|
|
2929
|
+
}
|
|
2930
|
+
const LESSONS_PASS_COMPLETED_EVENT_TYPE = 'lessons-pass-completed';
|
|
1366
2931
|
/**
|
|
1367
2932
|
* Every `lesson_status` the taxonomy declares, mapped to the bucket the
|
|
1368
2933
|
* Lessons page renders it in. Total on purpose (D-220).
|
|
@@ -1388,12 +2953,46 @@ export const LESSON_BUCKET_FOR_STATUS = {
|
|
|
1388
2953
|
superseded: 'closed',
|
|
1389
2954
|
invalidated: 'closed',
|
|
1390
2955
|
};
|
|
2956
|
+
/**
|
|
2957
|
+
* The owning-session lookup behind the dashboard's three lesson write routes
|
|
2958
|
+
* (ui/server's `lessonSession()`) — the session whose log a lesson's
|
|
2959
|
+
* transition has to fold and append to, not whichever session a request body
|
|
2960
|
+
* names. Returns `null` rather than throwing when no such lesson is
|
|
2961
|
+
* projected; the caller decides what a missing lesson means.
|
|
2962
|
+
*/
|
|
2963
|
+
export function lessonOwnerSession(db, lessonId) {
|
|
2964
|
+
const row = db
|
|
2965
|
+
.select({ sessionId: lessons.sessionId })
|
|
2966
|
+
.from(lessons)
|
|
2967
|
+
.where(eq(lessons.lessonId, lessonId))
|
|
2968
|
+
.get();
|
|
2969
|
+
return row ? row.sessionId : null;
|
|
2970
|
+
}
|
|
2971
|
+
/** The latest `lessons-pass-completed` event's ts, scoped the same way as the rest of the page. */
|
|
2972
|
+
function lastLessonsPassAt(db, scope) {
|
|
2973
|
+
const eventCond = eq(eventsRaw.eventType, LESSONS_PASS_COMPLETED_EVENT_TYPE);
|
|
2974
|
+
const sessionCond = scopedToSessions(eventsRaw.sessionId, scope);
|
|
2975
|
+
const rows = db
|
|
2976
|
+
.select({ ts: eventsRaw.ts })
|
|
2977
|
+
.from(eventsRaw)
|
|
2978
|
+
.where(sessionCond ? and(eventCond, sessionCond) : eventCond)
|
|
2979
|
+
.all();
|
|
2980
|
+
const first = rows[0];
|
|
2981
|
+
if (!first)
|
|
2982
|
+
return null;
|
|
2983
|
+
return rows.reduce((latest, row) => (row.ts > latest ? row.ts : latest), first.ts);
|
|
2984
|
+
}
|
|
1391
2985
|
export function lessonsPage(db, scope = {}) {
|
|
1392
2986
|
const sessionCond = scopedToSessions(lessons.sessionId, scope);
|
|
1393
2987
|
const rows = sessionCond
|
|
1394
2988
|
? db.select().from(lessons).where(sessionCond).all()
|
|
1395
2989
|
: db.select().from(lessons).all();
|
|
1396
|
-
const result = {
|
|
2990
|
+
const result = {
|
|
2991
|
+
pending: [],
|
|
2992
|
+
approved: [],
|
|
2993
|
+
closed: [],
|
|
2994
|
+
lastCheckedAt: lastLessonsPassAt(db, scope),
|
|
2995
|
+
};
|
|
1397
2996
|
for (const row of rows) {
|
|
1398
2997
|
// An unrecognised status lands in `closed` rather than nowhere: being
|
|
1399
2998
|
// invisible is the defect this map exists to close, and a row the
|
|
@@ -1402,7 +3001,15 @@ export function lessonsPage(db, scope = {}) {
|
|
|
1402
3001
|
}
|
|
1403
3002
|
return result;
|
|
1404
3003
|
}
|
|
1405
|
-
|
|
3004
|
+
const TREND_WINDOW_DAYS = 7;
|
|
3005
|
+
/** The UTC calendar day (`YYYY-MM-DD`) `daysAgo` days before `nowIso`'s own day. */
|
|
3006
|
+
function utcDayOffset(nowIso, daysAgo) {
|
|
3007
|
+
const d = new Date(nowIso);
|
|
3008
|
+
d.setUTCHours(0, 0, 0, 0);
|
|
3009
|
+
d.setUTCDate(d.getUTCDate() - daysAgo);
|
|
3010
|
+
return d.toISOString().slice(0, 10);
|
|
3011
|
+
}
|
|
3012
|
+
export function errorsPage(db, scope = {}, opts = {}) {
|
|
1406
3013
|
const sessionCond = scopedToSessions(errors.sessionId, scope);
|
|
1407
3014
|
const allRows = sessionCond
|
|
1408
3015
|
? db.select().from(errors).where(sessionCond).all()
|
|
@@ -1410,6 +3017,10 @@ export function errorsPage(db, scope = {}) {
|
|
|
1410
3017
|
const rows = filterByProject(allRows, scope);
|
|
1411
3018
|
const byClass = new Map();
|
|
1412
3019
|
const byDay = new Map();
|
|
3020
|
+
const nowIso = opts.nowIso ?? new Date().toISOString();
|
|
3021
|
+
const trendDays = Array.from({ length: TREND_WINDOW_DAYS }, (_, i) => utcDayOffset(nowIso, TREND_WINDOW_DAYS - 1 - i));
|
|
3022
|
+
const trendDayIndex = new Map(trendDays.map((day, i) => [day, i]));
|
|
3023
|
+
const classSummaries = new Map();
|
|
1413
3024
|
for (const row of rows) {
|
|
1414
3025
|
const key = `${row.errorGroup}.${row.errorClass}|${row.severity}`;
|
|
1415
3026
|
const existing = byClass.get(key);
|
|
@@ -1426,15 +3037,62 @@ export function errorsPage(db, scope = {}) {
|
|
|
1426
3037
|
}
|
|
1427
3038
|
const day = row.ts.slice(0, 10);
|
|
1428
3039
|
byDay.set(day, (byDay.get(day) ?? 0) + 1);
|
|
3040
|
+
const classKey = `${row.errorGroup}.${row.errorClass}`;
|
|
3041
|
+
let summary = classSummaries.get(classKey);
|
|
3042
|
+
if (!summary) {
|
|
3043
|
+
summary = {
|
|
3044
|
+
errorGroup: row.errorGroup,
|
|
3045
|
+
errorClass: row.errorClass,
|
|
3046
|
+
count: 0,
|
|
3047
|
+
severityMix: {},
|
|
3048
|
+
lastSeen: row.ts,
|
|
3049
|
+
projects: new Set(),
|
|
3050
|
+
trend7d: new Array(TREND_WINDOW_DAYS).fill(0),
|
|
3051
|
+
};
|
|
3052
|
+
classSummaries.set(classKey, summary);
|
|
3053
|
+
}
|
|
3054
|
+
summary.count += 1;
|
|
3055
|
+
summary.severityMix[row.severity] = (summary.severityMix[row.severity] ?? 0) + 1;
|
|
3056
|
+
if (row.ts > summary.lastSeen)
|
|
3057
|
+
summary.lastSeen = row.ts;
|
|
3058
|
+
if (row.project)
|
|
3059
|
+
summary.projects.add(row.project);
|
|
3060
|
+
const dayIndex = trendDayIndex.get(day);
|
|
3061
|
+
if (dayIndex !== undefined)
|
|
3062
|
+
summary.trend7d[dayIndex] = (summary.trend7d[dayIndex] ?? 0) + 1;
|
|
1429
3063
|
}
|
|
1430
3064
|
return {
|
|
1431
3065
|
byClass: [...byClass.values()].sort((a, b) => b.count - a.count),
|
|
1432
3066
|
byDay: [...byDay.entries()]
|
|
1433
3067
|
.sort(([a], [b]) => a.localeCompare(b))
|
|
1434
3068
|
.map(([day, count]) => ({ day, count })),
|
|
3069
|
+
classSummary: [...classSummaries.entries()]
|
|
3070
|
+
.map(([id, s]) => ({
|
|
3071
|
+
id,
|
|
3072
|
+
errorGroup: s.errorGroup,
|
|
3073
|
+
errorClass: s.errorClass,
|
|
3074
|
+
count: s.count,
|
|
3075
|
+
severityMix: s.severityMix,
|
|
3076
|
+
lastSeen: s.lastSeen,
|
|
3077
|
+
projects: [...s.projects],
|
|
3078
|
+
trend7d: s.trend7d,
|
|
3079
|
+
}))
|
|
3080
|
+
.sort((a, b) => b.count - a.count),
|
|
1435
3081
|
};
|
|
1436
3082
|
}
|
|
1437
|
-
|
|
3083
|
+
const PERIOD_DAYS = { '7d': 7, '30d': 30, '90d': 90 };
|
|
3084
|
+
/** The UTC calendar days in `period`'s window ending on `nowIso`'s UTC date, inclusive, oldest-first. */
|
|
3085
|
+
function periodDays(period, nowIso) {
|
|
3086
|
+
const now = new Date(nowIso);
|
|
3087
|
+
const endUtcMs = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
|
|
3088
|
+
const days = PERIOD_DAYS[period];
|
|
3089
|
+
const out = [];
|
|
3090
|
+
for (let i = days - 1; i >= 0; i -= 1) {
|
|
3091
|
+
out.push(new Date(endUtcMs - i * 86_400_000).toISOString().slice(0, 10));
|
|
3092
|
+
}
|
|
3093
|
+
return out;
|
|
3094
|
+
}
|
|
3095
|
+
export function analytics(db, scope = {}, opts = {}) {
|
|
1438
3096
|
const taskRows = allTasksForScope(db, scope);
|
|
1439
3097
|
const inScope = taskInScope(db, scope);
|
|
1440
3098
|
const throughputByDay = new Map();
|
|
@@ -1450,17 +3108,30 @@ export function analytics(db, scope = {}) {
|
|
|
1450
3108
|
const eventSessionCond = scopedToSessions(eventsRaw.sessionId, scope);
|
|
1451
3109
|
const resultRows = eventSessionCond
|
|
1452
3110
|
? db
|
|
1453
|
-
.select({ taskId: eventsRaw.taskId, payload: eventsRaw.payload })
|
|
3111
|
+
.select({ taskId: eventsRaw.taskId, payload: eventsRaw.payload, ts: eventsRaw.ts })
|
|
1454
3112
|
.from(eventsRaw)
|
|
1455
3113
|
.where(and(eq(eventsRaw.eventType, 'task-result-recorded'), eventSessionCond))
|
|
1456
3114
|
.all()
|
|
1457
3115
|
: db
|
|
1458
|
-
.select({ taskId: eventsRaw.taskId, payload: eventsRaw.payload })
|
|
3116
|
+
.select({ taskId: eventsRaw.taskId, payload: eventsRaw.payload, ts: eventsRaw.ts })
|
|
1459
3117
|
.from(eventsRaw)
|
|
1460
3118
|
.where(eq(eventsRaw.eventType, 'task-result-recorded'))
|
|
1461
3119
|
.all();
|
|
3120
|
+
// No `period`: every row, exactly the pre-DS7 behaviour (backward compat).
|
|
3121
|
+
// With `period`: only rows whose UTC day falls in the window — this also
|
|
3122
|
+
// answers DS7's "per-provider totals for the period" ask (spec §4.4's
|
|
3123
|
+
// "providers with totalTokens > 0"), so no separate field is added for it.
|
|
3124
|
+
const window = opts.period
|
|
3125
|
+
? periodDays(opts.period, opts.nowIso ?? new Date().toISOString())
|
|
3126
|
+
: null;
|
|
3127
|
+
const windowSet = window ? new Set(window) : null;
|
|
3128
|
+
const scopedResultRows = windowSet
|
|
3129
|
+
? resultRows.filter((row) => windowSet.has(row.ts.slice(0, 10)))
|
|
3130
|
+
: resultRows;
|
|
1462
3131
|
const costBuckets = new Map();
|
|
1463
|
-
|
|
3132
|
+
const dailyBuckets = new Map();
|
|
3133
|
+
const roleTierBuckets = new Map();
|
|
3134
|
+
for (const row of scopedResultRows) {
|
|
1464
3135
|
const p = JSON.parse(row.payload);
|
|
1465
3136
|
// Column first, payload second: both spellings occur, and one real row
|
|
1466
3137
|
// omits `payload.task_id` while the column carries it.
|
|
@@ -1469,21 +3140,85 @@ export function analytics(db, scope = {}) {
|
|
|
1469
3140
|
if (!p.model_tier || !p.provider)
|
|
1470
3141
|
continue;
|
|
1471
3142
|
const key = `${p.model_tier}|${p.provider}`;
|
|
1472
|
-
const bucket = costBuckets.get(key) ?? { taskCount: 0, totalTokens: 0 };
|
|
3143
|
+
const bucket = costBuckets.get(key) ?? { taskCount: 0, totalTokens: 0, unmeasuredTaskCount: 0 };
|
|
1473
3144
|
bucket.taskCount += 1;
|
|
1474
|
-
|
|
3145
|
+
const usage = readTokenUsage(p.token_usage);
|
|
3146
|
+
if (usage.measured) {
|
|
3147
|
+
bucket.totalTokens += usage.total;
|
|
3148
|
+
}
|
|
3149
|
+
else {
|
|
3150
|
+
bucket.unmeasuredTaskCount += 1;
|
|
3151
|
+
}
|
|
1475
3152
|
costBuckets.set(key, bucket);
|
|
3153
|
+
if (window) {
|
|
3154
|
+
const role = p.agent ?? 'unattributed';
|
|
3155
|
+
const day = row.ts.slice(0, 10);
|
|
3156
|
+
const daily = dailyBuckets.get(day) ?? {
|
|
3157
|
+
tokensByRole: new Map(),
|
|
3158
|
+
tokensByModelTier: new Map(),
|
|
3159
|
+
unmeasuredRunCount: 0,
|
|
3160
|
+
};
|
|
3161
|
+
if (usage.measured) {
|
|
3162
|
+
daily.tokensByRole.set(role, (daily.tokensByRole.get(role) ?? 0) + usage.total);
|
|
3163
|
+
daily.tokensByModelTier.set(p.model_tier, (daily.tokensByModelTier.get(p.model_tier) ?? 0) + usage.total);
|
|
3164
|
+
}
|
|
3165
|
+
else {
|
|
3166
|
+
daily.unmeasuredRunCount += 1;
|
|
3167
|
+
}
|
|
3168
|
+
dailyBuckets.set(day, daily);
|
|
3169
|
+
const roleTierKey = `${role}|${p.model_tier}`;
|
|
3170
|
+
const roleTier = roleTierBuckets.get(roleTierKey) ?? {
|
|
3171
|
+
runCount: 0,
|
|
3172
|
+
tokens: 0,
|
|
3173
|
+
unmeasuredRunCount: 0,
|
|
3174
|
+
};
|
|
3175
|
+
roleTier.runCount += 1;
|
|
3176
|
+
if (usage.measured) {
|
|
3177
|
+
roleTier.tokens += usage.total;
|
|
3178
|
+
}
|
|
3179
|
+
else {
|
|
3180
|
+
roleTier.unmeasuredRunCount += 1;
|
|
3181
|
+
}
|
|
3182
|
+
roleTierBuckets.set(roleTierKey, roleTier);
|
|
3183
|
+
}
|
|
1476
3184
|
}
|
|
1477
3185
|
const costByModelTierAndProvider = [...costBuckets.entries()].map(([key, v]) => {
|
|
1478
3186
|
const [modelTier, provider] = key.split('|');
|
|
3187
|
+
const measuredTaskCount = v.taskCount - v.unmeasuredTaskCount;
|
|
1479
3188
|
return {
|
|
1480
3189
|
modelTier,
|
|
1481
3190
|
provider,
|
|
1482
3191
|
taskCount: v.taskCount,
|
|
1483
3192
|
totalTokens: v.totalTokens,
|
|
1484
|
-
avgTokensPerTask:
|
|
3193
|
+
avgTokensPerTask: measuredTaskCount > 0 ? v.totalTokens / measuredTaskCount : 0,
|
|
3194
|
+
unmeasuredTaskCount: v.unmeasuredTaskCount,
|
|
1485
3195
|
};
|
|
1486
3196
|
});
|
|
3197
|
+
const tokensByDay = window
|
|
3198
|
+
? window.map((day) => {
|
|
3199
|
+
const daily = dailyBuckets.get(day);
|
|
3200
|
+
return {
|
|
3201
|
+
day,
|
|
3202
|
+
tokensByRole: daily ? Object.fromEntries(daily.tokensByRole) : {},
|
|
3203
|
+
tokensByModelTier: daily ? Object.fromEntries(daily.tokensByModelTier) : {},
|
|
3204
|
+
unmeasuredRunCount: daily?.unmeasuredRunCount ?? 0,
|
|
3205
|
+
};
|
|
3206
|
+
})
|
|
3207
|
+
: undefined;
|
|
3208
|
+
const tokensByRoleAndModelTier = window
|
|
3209
|
+
? [...roleTierBuckets.entries()].map(([key, v]) => {
|
|
3210
|
+
const [role, modelTier] = key.split('|');
|
|
3211
|
+
const measuredRunCount = v.runCount - v.unmeasuredRunCount;
|
|
3212
|
+
return {
|
|
3213
|
+
role,
|
|
3214
|
+
modelTier,
|
|
3215
|
+
runCount: v.runCount,
|
|
3216
|
+
tokens: v.tokens,
|
|
3217
|
+
avgTokensPerRun: measuredRunCount > 0 ? v.tokens / measuredRunCount : null,
|
|
3218
|
+
unmeasuredRunCount: v.unmeasuredRunCount,
|
|
3219
|
+
};
|
|
3220
|
+
})
|
|
3221
|
+
: undefined;
|
|
1487
3222
|
const decisionRows = eventSessionCond
|
|
1488
3223
|
? db
|
|
1489
3224
|
.select({ ts: eventsRaw.ts, taskId: eventsRaw.taskId, payload: eventsRaw.payload })
|
|
@@ -1535,6 +3270,8 @@ export function analytics(db, scope = {}) {
|
|
|
1535
3270
|
// Same db, same scope: a project-scoped page gets project-scoped judge
|
|
1536
3271
|
// stats, and no second round trip is needed to render the card.
|
|
1537
3272
|
providerAgreement: providerAgreement(db, scope),
|
|
3273
|
+
...(tokensByDay ? { tokensByDay } : {}),
|
|
3274
|
+
...(tokensByRoleAndModelTier ? { tokensByRoleAndModelTier } : {}),
|
|
1538
3275
|
};
|
|
1539
3276
|
}
|
|
1540
3277
|
/**
|