@juzser/blacksmith 0.1.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 +146 -0
- package/.claude/agents/coder.md +164 -0
- package/.claude/agents/grader.md +110 -0
- package/.claude/agents/merger.md +107 -0
- package/.claude/agents/planner.md +92 -0
- package/.claude/agents/researcher.md +105 -0
- package/.claude/agents/reviewer.md +171 -0
- package/.claude/agents/scribe.md +72 -0
- package/.claude/agents/security-reviewer.md +130 -0
- package/.claude/agents/spec-reviewer.md +215 -0
- package/.claude/agents/tester.md +121 -0
- package/.claude/agents/uiux.md +126 -0
- package/.claude/agents/verifier.md +103 -0
- package/.claude/agents/wave-runner.md +74 -0
- package/LICENSE +21 -0
- package/README.md +389 -0
- package/factory/orchestrator/dist/actors.js +46 -0
- package/factory/orchestrator/dist/agents-registry.js +308 -0
- package/factory/orchestrator/dist/args.js +84 -0
- package/factory/orchestrator/dist/artifacts.js +111 -0
- package/factory/orchestrator/dist/attribution.js +160 -0
- package/factory/orchestrator/dist/audit.js +812 -0
- package/factory/orchestrator/dist/autonomy.js +151 -0
- package/factory/orchestrator/dist/budgetAlarm.js +366 -0
- package/factory/orchestrator/dist/budgets.js +196 -0
- package/factory/orchestrator/dist/claims.js +633 -0
- package/factory/orchestrator/dist/cli.js +3185 -0
- package/factory/orchestrator/dist/commit.js +152 -0
- package/factory/orchestrator/dist/coverage.js +188 -0
- package/factory/orchestrator/dist/crossFinding.js +479 -0
- package/factory/orchestrator/dist/crosscheck.js +396 -0
- package/factory/orchestrator/dist/daemon.js +707 -0
- package/factory/orchestrator/dist/db/projector.js +1317 -0
- package/factory/orchestrator/dist/db/queries.js +1714 -0
- package/factory/orchestrator/dist/db/schema.js +305 -0
- package/factory/orchestrator/dist/delegation.js +400 -0
- package/factory/orchestrator/dist/diffstat.js +164 -0
- package/factory/orchestrator/dist/dispatchAudit.js +275 -0
- package/factory/orchestrator/dist/dotenv.js +72 -0
- package/factory/orchestrator/dist/effort.js +202 -0
- package/factory/orchestrator/dist/epic.js +1018 -0
- package/factory/orchestrator/dist/epicWidth.js +238 -0
- package/factory/orchestrator/dist/errorIssues.js +249 -0
- package/factory/orchestrator/dist/errors.js +11 -0
- package/factory/orchestrator/dist/escalation.js +500 -0
- package/factory/orchestrator/dist/events.js +1015 -0
- package/factory/orchestrator/dist/findingAge.js +91 -0
- package/factory/orchestrator/dist/findingContext.js +103 -0
- package/factory/orchestrator/dist/findings.js +1156 -0
- package/factory/orchestrator/dist/gate.js +911 -0
- package/factory/orchestrator/dist/gh.js +146 -0
- package/factory/orchestrator/dist/git.js +87 -0
- package/factory/orchestrator/dist/goalCheck.js +380 -0
- package/factory/orchestrator/dist/graph.js +61 -0
- package/factory/orchestrator/dist/hookDecision.js +106 -0
- package/factory/orchestrator/dist/immutability.js +171 -0
- package/factory/orchestrator/dist/impact.js +280 -0
- package/factory/orchestrator/dist/integration.js +141 -0
- package/factory/orchestrator/dist/judgePreflight.js +155 -0
- package/factory/orchestrator/dist/judges.js +310 -0
- package/factory/orchestrator/dist/lessonAudit.js +399 -0
- package/factory/orchestrator/dist/lessons.js +1329 -0
- package/factory/orchestrator/dist/mcp.js +555 -0
- package/factory/orchestrator/dist/paths.js +82 -0
- package/factory/orchestrator/dist/plan.js +502 -0
- package/factory/orchestrator/dist/planQuorum.js +421 -0
- package/factory/orchestrator/dist/policy.js +1308 -0
- package/factory/orchestrator/dist/policyHook.js +28 -0
- package/factory/orchestrator/dist/preconditions.js +47 -0
- package/factory/orchestrator/dist/projects.js +134 -0
- package/factory/orchestrator/dist/prompts.js +57 -0
- package/factory/orchestrator/dist/provenance.js +201 -0
- package/factory/orchestrator/dist/providers/api-transport.js +117 -0
- package/factory/orchestrator/dist/providers/cli-transport.js +208 -0
- package/factory/orchestrator/dist/providers/index.js +54 -0
- package/factory/orchestrator/dist/providers/schema-validate.js +152 -0
- package/factory/orchestrator/dist/providers/types.js +49 -0
- package/factory/orchestrator/dist/queue.js +263 -0
- package/factory/orchestrator/dist/quorum.js +347 -0
- package/factory/orchestrator/dist/quorumEscalations.js +132 -0
- package/factory/orchestrator/dist/results.js +65 -0
- package/factory/orchestrator/dist/roadmap.js +437 -0
- package/factory/orchestrator/dist/runtime.js +86 -0
- package/factory/orchestrator/dist/sameMistakeKpi.js +243 -0
- package/factory/orchestrator/dist/sandbox.js +165 -0
- package/factory/orchestrator/dist/scaffold.js +373 -0
- package/factory/orchestrator/dist/scheduler.js +408 -0
- package/factory/orchestrator/dist/schemas.js +195 -0
- package/factory/orchestrator/dist/security.js +222 -0
- package/factory/orchestrator/dist/severity.js +293 -0
- package/factory/orchestrator/dist/spec.js +376 -0
- package/factory/orchestrator/dist/specChange.js +497 -0
- package/factory/orchestrator/dist/stack.js +324 -0
- package/factory/orchestrator/dist/symbols.js +966 -0
- package/factory/orchestrator/dist/taskEvents.js +399 -0
- package/factory/orchestrator/dist/taskId.js +126 -0
- package/factory/orchestrator/dist/taxonomy.js +113 -0
- package/factory/orchestrator/dist/testSelect.js +129 -0
- package/factory/orchestrator/dist/testerAudit.js +264 -0
- package/factory/orchestrator/dist/testgate.js +171 -0
- package/factory/orchestrator/dist/usage.js +874 -0
- package/factory/orchestrator/dist/waivers.js +228 -0
- package/factory/orchestrator/dist/waveBudget.js +223 -0
- package/factory/orchestrator/dist/waveConcurrency.js +250 -0
- package/factory/orchestrator/dist/waveNext.js +210 -0
- package/factory/orchestrator/dist/waveSchedule.js +146 -0
- package/factory/orchestrator/dist/worktree.js +207 -0
- package/factory/orchestrator/drizzle/0000_broken_rockslide.sql +183 -0
- package/factory/orchestrator/drizzle/0001_luxuriant_zaladane.sql +1 -0
- package/factory/orchestrator/drizzle/0002_yellow_wild_pack.sql +8 -0
- package/factory/orchestrator/drizzle/0003_damp_riptide.sql +12 -0
- package/factory/orchestrator/drizzle/0004_broad_lady_vermin.sql +1 -0
- package/factory/orchestrator/drizzle/0005_romantic_jocasta.sql +2 -0
- package/factory/orchestrator/drizzle/0006_bent_madelyne_pryor.sql +16 -0
- package/factory/orchestrator/drizzle/0007_overjoyed_goliath.sql +1 -0
- package/factory/orchestrator/drizzle/0008_smart_iron_patriot.sql +2 -0
- package/factory/orchestrator/drizzle/0009_agents_epic_id.sql +2 -0
- package/factory/orchestrator/drizzle/0010_milestone_kind.sql +1 -0
- package/factory/orchestrator/drizzle/meta/_journal.json +83 -0
- package/factory/policies/budgets.yml +262 -0
- package/factory/policies/crosscheck.yml +308 -0
- package/factory/policies/delegation.yml +44 -0
- package/factory/policies/effort.yml +142 -0
- package/factory/policies/guardrails.yml +306 -0
- package/factory/policies/lessons.md +239 -0
- package/factory/policies/scheduler.yml +90 -0
- package/factory/policies/sensitive-paths.yml +148 -0
- package/factory/policies/severity.yml +83 -0
- package/factory/policies/stack.yml +60 -0
- package/factory/policies/taxonomy.yml +340 -0
- package/factory/policies/worktree.yml +95 -0
- package/factory/scaffold/base/.github/workflows/ci.yml +34 -0
- package/factory/scaffold/base/.gitignore.tmpl +11 -0
- package/factory/scaffold/base/AGENTS.md +33 -0
- package/factory/scaffold/base/README.md +25 -0
- package/factory/scaffold/base/biome.json.tmpl +30 -0
- package/factory/scaffold/base/package.json +25 -0
- package/factory/scaffold/base/pnpm-workspace.yaml.tmpl +16 -0
- package/factory/scaffold/base/src/index.ts +4 -0
- package/factory/scaffold/base/test/index.test.ts +8 -0
- package/factory/scaffold/base/tsconfig.json +20 -0
- package/factory/scaffold/base/tsconfig.test.json +9 -0
- package/factory/scaffold/base/vitest.config.ts +29 -0
- package/factory/scaffold/mcp/mcp.manifest.json +22 -0
- package/factory/scaffold/mcp/package.fragment.json +9 -0
- package/factory/scaffold/mcp/src/mcp/README.md +63 -0
- package/factory/scaffold/mcp/src/mcp/guard.ts +110 -0
- package/factory/scaffold/mcp/src/mcp/redact.ts +69 -0
- package/factory/scaffold/mcp/src/mcp/server.ts +55 -0
- package/factory/scaffold/mcp/src/mcp/tools/health.ts +60 -0
- package/factory/scaffold/mcp/test/mcp/guard.test.ts +108 -0
- package/factory/scaffold/mcp/test/mcp/redact.test.ts +61 -0
- package/factory/scaffold/mcp/test/mcp/surface.test.ts +100 -0
- package/factory/scaffold/ui/index.html +12 -0
- package/factory/scaffold/ui/package.fragment.json +13 -0
- package/factory/scaffold/ui/src/App.vue +10 -0
- package/factory/scaffold/ui/src/main.ts +5 -0
- package/factory/scaffold/ui/src/shims.d.ts +11 -0
- package/factory/scaffold/ui/vite.config.ts +20 -0
- package/factory/scaffold/ui-tailwind/package.fragment.json +6 -0
- package/factory/scaffold/ui-tailwind/vite.config.ts +26 -0
- package/factory/specs/schema/event.schema.json +82 -0
- package/factory/specs/schema/finding-evidence.schema.json +45 -0
- package/factory/specs/schema/finding.schema.json +115 -0
- package/factory/specs/schema/grader-verdict.schema.json +50 -0
- package/factory/specs/schema/judge-verdict.schema.json +19 -0
- package/factory/specs/schema/lesson.schema.json +85 -0
- package/factory/specs/schema/mcp-manifest.schema.json +230 -0
- package/factory/specs/schema/result.schema.json +81 -0
- package/factory/specs/schema/spec-change-request.schema.json +46 -0
- package/factory/specs/schema/task-spec.schema.json +134 -0
- package/package.json +98 -0
|
@@ -0,0 +1,1714 @@
|
|
|
1
|
+
// The §10 dashboard page queries, each a typed function over the
|
|
2
|
+
// projection tables in db/schema.ts. Every function accepts a `SmithDb`
|
|
3
|
+
// (see db/projector.ts's openDb()) plus an optional session scope — when
|
|
4
|
+
// omitted, a query spans every projected session (a single Blacksmith
|
|
5
|
+
// instance is one continuously-running factory, so "no session filter"
|
|
6
|
+
// is the normal case; a session filter is for debugging one run).
|
|
7
|
+
import { and, eq, gte, inArray, lte } from 'drizzle-orm';
|
|
8
|
+
import { isOperatorActor } from '../actors.js';
|
|
9
|
+
import { foldAgents, liveAgents as foldLiveAgents, REGISTRY_EVENT_TYPES, } from '../agents-registry.js';
|
|
10
|
+
import { compareLogOrder, isLaterEvent, parseEventId, ROOT_EVENT_TYPE } from '../events.js';
|
|
11
|
+
import { waveLayers } from '../graph.js';
|
|
12
|
+
import { judgeFailureKind } from '../providers/types.js';
|
|
13
|
+
import { severityRank } from '../severity.js';
|
|
14
|
+
import { epicOfTaskId, taskIdsMatch } from '../taskId.js';
|
|
15
|
+
import { loadTaxonomy } from '../taxonomy.js';
|
|
16
|
+
import { agents, artifacts, dispatches, edges, epics, errors, eventsRaw, findings, lessons, milestones, prompts, sessions, tasks, waivers, } from './schema.js';
|
|
17
|
+
/** Phase 6b default: every event/task/etc. logged before the project dimension existed. */
|
|
18
|
+
export const DEFAULT_PROJECT = 'black-smith';
|
|
19
|
+
/**
|
|
20
|
+
* `null` (never-tagged, pre-Phase-6b) reads as DEFAULT_PROJECT, same as the
|
|
21
|
+
* schema.ts columns' doc comment promises. Project scoping is applied
|
|
22
|
+
* JS-side, after a (session-scoped) row fetch, everywhere in this file —
|
|
23
|
+
* dataset sizes here are "one local factory's projection tables", not a
|
|
24
|
+
* multi-tenant SaaS table, so this is the §-ladder "reuse the existing
|
|
25
|
+
* pattern" choice over hand-writing a COALESCE into every one of this
|
|
26
|
+
* file's ~15 query shapes (and it composes for free with every function
|
|
27
|
+
* below that already builds its own JS-side Maps/aggregations from a rows
|
|
28
|
+
* array — one filter step ahead of the aggregation, not a SQL rewrite).
|
|
29
|
+
*/
|
|
30
|
+
function projectOf(value) {
|
|
31
|
+
return value ?? DEFAULT_PROJECT;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The session narrowing for one `session_id` column: `IN (lineage)` when the
|
|
35
|
+
* scope carries one, `= sessionId` when it carries a single session, and
|
|
36
|
+
* `undefined` -- meaning no narrowing at all -- when it carries neither.
|
|
37
|
+
*
|
|
38
|
+
* Every session-scoped read in this file goes through here rather than
|
|
39
|
+
* writing its own `eq()`, so widening the scope is one change instead of
|
|
40
|
+
* twenty, and a reader added later cannot quietly opt out of the lineage by
|
|
41
|
+
* forgetting it exists.
|
|
42
|
+
*/
|
|
43
|
+
function scopedToSessions(column, scope) {
|
|
44
|
+
if (scope.sessionIds !== undefined) {
|
|
45
|
+
if (scope.sessionIds.length === 0) {
|
|
46
|
+
// `inArray(col, [])` is a clause with no rows to compare against, and
|
|
47
|
+
// what a driver makes of it -- match nothing, match everything, refuse
|
|
48
|
+
// to compile -- is not something this file should be relying on either
|
|
49
|
+
// way. Neither reading answers "scope this to no sessions", and
|
|
50
|
+
// `projectedLineage` cannot produce an empty array, so an empty one is
|
|
51
|
+
// a caller bug and gets told so rather than an arbitrary row set.
|
|
52
|
+
throw new RangeError('Scope.sessionIds was empty: a lineage always contains at least the session it was resolved from. Pass sessionId for a single session, or omit both to span every session.');
|
|
53
|
+
}
|
|
54
|
+
return inArray(column, [...scope.sessionIds]);
|
|
55
|
+
}
|
|
56
|
+
if (scope.sessionId !== undefined)
|
|
57
|
+
return eq(column, scope.sessionId);
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* The lineage of `sessionId`, root-first and always containing `sessionId`
|
|
62
|
+
* itself -- the projection's answer to the question `sessionLineage()` in
|
|
63
|
+
* events.ts answers from the raw logs.
|
|
64
|
+
*
|
|
65
|
+
* It is resolved here, and not by calling that one, because everything this
|
|
66
|
+
* file serves is a pure reader over `state/smith.db`: `smith stats` opens the
|
|
67
|
+
* db and nothing else, and the UI server has no access to the log directory
|
|
68
|
+
* at all. A lineage that could only be read off the filesystem would be a
|
|
69
|
+
* lineage the dashboard could never draw.
|
|
70
|
+
*
|
|
71
|
+
* Ancestors, self, then continuations -- exactly as `sessionLineage()` and
|
|
72
|
+
* `smith event tail --lineage` define it, because D-264's whole point was that
|
|
73
|
+
* the dashboard and the CLI must not scope an epic differently. D-266 added
|
|
74
|
+
* the downward half on both sides at once: an epic session that dispatches a
|
|
75
|
+
* session per wave is read from the epic end, and a walk that only went up saw
|
|
76
|
+
* none of the work. Continuations of `sessionId` and not of the root, for the
|
|
77
|
+
* reason walkLineage() gives -- a wave folds in its ancestry and its own
|
|
78
|
+
* continuations, never its siblings.
|
|
79
|
+
*
|
|
80
|
+
* The walk is over `session-start` rows, because that is the only event type
|
|
81
|
+
* allowed a cross-session `causal_parent`
|
|
82
|
+
* (`events.cross-session-parent-not-root`), and it takes the FIRST root of
|
|
83
|
+
* each log for the reason startSession()'s comment gives -- a log has one
|
|
84
|
+
* beginning, and a second `session-start` is a line no reader looks at.
|
|
85
|
+
*/
|
|
86
|
+
export function projectedLineage(db, sessionId) {
|
|
87
|
+
return [...projectedAncestry(db, sessionId), ...projectedContinuations(db, sessionId)];
|
|
88
|
+
}
|
|
89
|
+
/** The chain `sessionId` continues, root first, ending with `sessionId`. */
|
|
90
|
+
function projectedAncestry(db, sessionId) {
|
|
91
|
+
const chain = [sessionId];
|
|
92
|
+
const seen = new Set([sessionId]);
|
|
93
|
+
let current = sessionId;
|
|
94
|
+
for (;;) {
|
|
95
|
+
const roots = db
|
|
96
|
+
.select({
|
|
97
|
+
eventId: eventsRaw.eventId,
|
|
98
|
+
ts: eventsRaw.ts,
|
|
99
|
+
causalParent: eventsRaw.causalParent,
|
|
100
|
+
})
|
|
101
|
+
.from(eventsRaw)
|
|
102
|
+
.where(and(eq(eventsRaw.sessionId, current), eq(eventsRaw.eventType, ROOT_EVENT_TYPE)))
|
|
103
|
+
.all();
|
|
104
|
+
// inLogOrder, not `limit 1`: rows come back in whatever order the table
|
|
105
|
+
// holds them, and "the first root" has to mean first in the log.
|
|
106
|
+
const root = inLogOrder(roots)[0];
|
|
107
|
+
const parentId = root?.causalParent ?? null;
|
|
108
|
+
if (parentId === null)
|
|
109
|
+
return chain;
|
|
110
|
+
const parent = db
|
|
111
|
+
.select({ sessionId: eventsRaw.sessionId })
|
|
112
|
+
.from(eventsRaw)
|
|
113
|
+
.where(eq(eventsRaw.eventId, parentId))
|
|
114
|
+
.all()[0];
|
|
115
|
+
// A parent the projection has not folded is where the walk stops, not
|
|
116
|
+
// where it throws: `smith stats` runs against whatever has been projected
|
|
117
|
+
// so far, and a partial rebuild is a normal state, not a corrupt one. The
|
|
118
|
+
// scope is then the part of the lineage that exists, which is the most
|
|
119
|
+
// this reader can honestly offer.
|
|
120
|
+
if (parent === undefined)
|
|
121
|
+
return chain;
|
|
122
|
+
if (seen.has(parent.sessionId)) {
|
|
123
|
+
throw new RangeError(`Session lineage for "${sessionId}" is a cycle: "${parent.sessionId}" appears twice. A session log's root may name a parent in another log, and the result has to stay a tree.`);
|
|
124
|
+
}
|
|
125
|
+
seen.add(parent.sessionId);
|
|
126
|
+
chain.unshift(parent.sessionId);
|
|
127
|
+
current = parent.sessionId;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* The sessions that continue `sessionId`, breadth-first, ids sorted at each
|
|
132
|
+
* level -- the projection's copy of walkLineage()'s downward half.
|
|
133
|
+
*
|
|
134
|
+
* Every root row in the projection is fetched once and indexed by the session
|
|
135
|
+
* its `causal_parent` names, rather than issuing a query per frontier session:
|
|
136
|
+
* there is one root per log, so the set is small, and the alternative is a
|
|
137
|
+
* query per level per session.
|
|
138
|
+
*
|
|
139
|
+
* The parent session is read off the event id rather than by joining back to
|
|
140
|
+
* the parent row, and the asymmetry with the upward walk is deliberate. Up
|
|
141
|
+
* there, an unresolvable parent is where the walk stops, because a partial
|
|
142
|
+
* rebuild is a normal state and the ancestors are what the reader was
|
|
143
|
+
* promised. Down here the CHILD is the row that was projected, so it belongs
|
|
144
|
+
* in the scope whether or not its parent's log has been folded yet -- and
|
|
145
|
+
* dropping it would be the very blindness this walk exists to remove.
|
|
146
|
+
*/
|
|
147
|
+
function projectedContinuations(db, sessionId) {
|
|
148
|
+
const roots = db
|
|
149
|
+
.select({
|
|
150
|
+
eventId: eventsRaw.eventId,
|
|
151
|
+
sessionId: eventsRaw.sessionId,
|
|
152
|
+
ts: eventsRaw.ts,
|
|
153
|
+
causalParent: eventsRaw.causalParent,
|
|
154
|
+
})
|
|
155
|
+
.from(eventsRaw)
|
|
156
|
+
.where(eq(eventsRaw.eventType, ROOT_EVENT_TYPE))
|
|
157
|
+
.all();
|
|
158
|
+
const childrenOf = new Map();
|
|
159
|
+
const firstRootSeen = new Set();
|
|
160
|
+
for (const root of inLogOrder(roots)) {
|
|
161
|
+
// One entry edge per log: the first root wins, later ones are lines no
|
|
162
|
+
// reader looks at.
|
|
163
|
+
if (firstRootSeen.has(root.sessionId))
|
|
164
|
+
continue;
|
|
165
|
+
firstRootSeen.add(root.sessionId);
|
|
166
|
+
if (root.causalParent === null)
|
|
167
|
+
continue;
|
|
168
|
+
let parentSession;
|
|
169
|
+
try {
|
|
170
|
+
parentSession = parseEventId(root.causalParent).sessionId;
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
// A malformed parent id is not an edge. The raw-log reader throws on
|
|
174
|
+
// this; here the row is already projected and the projection is a
|
|
175
|
+
// derived artefact, so the honest answer is that this session continues
|
|
176
|
+
// nothing that can be named.
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
if (parentSession === root.sessionId)
|
|
180
|
+
continue;
|
|
181
|
+
const siblings = childrenOf.get(parentSession);
|
|
182
|
+
if (siblings === undefined)
|
|
183
|
+
childrenOf.set(parentSession, [root.sessionId]);
|
|
184
|
+
else
|
|
185
|
+
siblings.push(root.sessionId);
|
|
186
|
+
}
|
|
187
|
+
for (const siblings of childrenOf.values())
|
|
188
|
+
siblings.sort();
|
|
189
|
+
const out = [];
|
|
190
|
+
const seen = new Set([sessionId]);
|
|
191
|
+
let frontier = childrenOf.get(sessionId) ?? [];
|
|
192
|
+
while (frontier.length > 0) {
|
|
193
|
+
const next = [];
|
|
194
|
+
for (const child of frontier) {
|
|
195
|
+
if (seen.has(child))
|
|
196
|
+
continue;
|
|
197
|
+
seen.add(child);
|
|
198
|
+
out.push(child);
|
|
199
|
+
next.push(...(childrenOf.get(child) ?? []));
|
|
200
|
+
}
|
|
201
|
+
frontier = next;
|
|
202
|
+
}
|
|
203
|
+
return out;
|
|
204
|
+
}
|
|
205
|
+
/** Filters an already-fetched row array to `scope.project`; a no-op in global mode (project omitted). */
|
|
206
|
+
function filterByProject(rows, scope) {
|
|
207
|
+
return scope.project !== undefined
|
|
208
|
+
? rows.filter((r) => projectOf(r.project) === scope.project)
|
|
209
|
+
: rows;
|
|
210
|
+
}
|
|
211
|
+
/** Rows in the order the log wrote them, oldest first. Never mutates the input. */
|
|
212
|
+
function inLogOrder(rows) {
|
|
213
|
+
return rows.slice().sort(compareLogOrder);
|
|
214
|
+
}
|
|
215
|
+
/** Every distinct project value present across the row sets, DEFAULT_PROJECT-normalized, sorted. */
|
|
216
|
+
function distinctProjects(...rowSets) {
|
|
217
|
+
return [...new Set(rowSets.flat().map((r) => projectOf(r.project)))].sort();
|
|
218
|
+
}
|
|
219
|
+
const NON_TERMINAL_TASK_STATUSES = [
|
|
220
|
+
'todo',
|
|
221
|
+
'ready',
|
|
222
|
+
'in-progress',
|
|
223
|
+
'grading',
|
|
224
|
+
'reviewing',
|
|
225
|
+
'merging',
|
|
226
|
+
'blocked',
|
|
227
|
+
];
|
|
228
|
+
/** Every `tasks` row for `scope`, session-filtered in SQL and project-filtered in JS. */
|
|
229
|
+
function allTasksForScope(db, scope) {
|
|
230
|
+
const sessionCond = scopedToSessions(tasks.sessionId, scope);
|
|
231
|
+
const rows = sessionCond
|
|
232
|
+
? db.select().from(tasks).where(sessionCond).all()
|
|
233
|
+
: db.select().from(tasks).all();
|
|
234
|
+
return filterByProject(rows, scope);
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* A predicate for "this event belongs to `scope.project`", answered through
|
|
238
|
+
* the event's TASK rather than through its own `project` column.
|
|
239
|
+
*
|
|
240
|
+
* That route is the rule the `Scope.project` doc comment states and D-170
|
|
241
|
+
* paid for: a `task-result-recorded` carries no project of its own on any row
|
|
242
|
+
* logged before Phase 6b, so normalizing its null to `default` files real
|
|
243
|
+
* spend under a project that never incurred it. The task row is the thing the
|
|
244
|
+
* projector actually tagged, and `allTasksForScope` has already scoped it.
|
|
245
|
+
*
|
|
246
|
+
* Membership asks `taskIdsMatch`, not `Set.has` (D-130/D-143): the log spells
|
|
247
|
+
* the same task both qualified and bare, and a raw comparison drops the bare
|
|
248
|
+
* rows into no project at all — a partition that silently loses rows reads as
|
|
249
|
+
* a smaller number, not as an error. The Set is only the exact-match fast
|
|
250
|
+
* path in front of it.
|
|
251
|
+
*
|
|
252
|
+
* Global mode (no project) admits everything, including rows that name no
|
|
253
|
+
* task — unchanged behaviour, and the honest one: an untasked row still
|
|
254
|
+
* belongs to the whole.
|
|
255
|
+
*/
|
|
256
|
+
function taskInScope(db, scope) {
|
|
257
|
+
if (scope.project === undefined)
|
|
258
|
+
return () => true;
|
|
259
|
+
const scopedIds = allTasksForScope(db, scope).map((t) => t.taskId);
|
|
260
|
+
const exact = new Set(scopedIds);
|
|
261
|
+
return (taskId) => typeof taskId === 'string' &&
|
|
262
|
+
(exact.has(taskId) || scopedIds.some((id) => taskIdsMatch(taskId, id)));
|
|
263
|
+
}
|
|
264
|
+
function taskResultRows(db, scope) {
|
|
265
|
+
const cols = { payload: eventsRaw.payload, envelopeTaskId: eventsRaw.taskId };
|
|
266
|
+
const sessionCond = scopedToSessions(eventsRaw.sessionId, scope);
|
|
267
|
+
const rows = sessionCond
|
|
268
|
+
? db
|
|
269
|
+
.select(cols)
|
|
270
|
+
.from(eventsRaw)
|
|
271
|
+
.where(and(eq(eventsRaw.eventType, 'task-result-recorded'), sessionCond))
|
|
272
|
+
.all()
|
|
273
|
+
: db.select(cols).from(eventsRaw).where(eq(eventsRaw.eventType, 'task-result-recorded')).all();
|
|
274
|
+
return rows.map((r) => ({
|
|
275
|
+
payload: JSON.parse(r.payload),
|
|
276
|
+
envelopeTaskId: r.envelopeTaskId,
|
|
277
|
+
}));
|
|
278
|
+
}
|
|
279
|
+
/** The task a Result names, payload first, envelope second. */
|
|
280
|
+
function resultTaskId(row) {
|
|
281
|
+
return row.payload.task_id ?? row.envelopeTaskId ?? undefined;
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Resolves the task a Result names to the epic that owns it, against a map
|
|
285
|
+
* keyed by the tasks table's ids.
|
|
286
|
+
*
|
|
287
|
+
* Exact first, then `taskIdsMatch` (D-130/D-143), for the same reason
|
|
288
|
+
* `taskInScope` above folds: the log spells one task both qualified and bare,
|
|
289
|
+
* and today's `state/events` has two `task-result-recorded` rows spelled bare
|
|
290
|
+
* against a tasks table that knows them qualified. An exact `Map.get` drops
|
|
291
|
+
* those rows out of per-epic spend entirely, and a partition that silently
|
|
292
|
+
* loses rows reads as a smaller number, not as an error.
|
|
293
|
+
*
|
|
294
|
+
* Where this deliberately differs from `taskInScope`: an ambiguous bare id —
|
|
295
|
+
* one that folds onto tasks in **two** epics — resolves to neither. Folding is
|
|
296
|
+
* free for a membership predicate, which can only say yes once; a sum counted
|
|
297
|
+
* into both epics would invent spend on the epic that did not incur it and
|
|
298
|
+
* make the per-epic column add up to more than the run. Under-counting an
|
|
299
|
+
* ambiguity is recoverable; over-counting it is a number nobody can reconcile.
|
|
300
|
+
*/
|
|
301
|
+
function epicResolver(epicByTask) {
|
|
302
|
+
const keys = [...epicByTask.keys()];
|
|
303
|
+
return (taskId) => {
|
|
304
|
+
if (taskId === undefined)
|
|
305
|
+
return undefined;
|
|
306
|
+
const exact = epicByTask.get(taskId);
|
|
307
|
+
if (exact !== undefined)
|
|
308
|
+
return exact;
|
|
309
|
+
const matched = new Set(keys.filter((id) => taskIdsMatch(taskId, id)).map((id) => epicByTask.get(id)));
|
|
310
|
+
return matched.size === 1 ? [...matched][0] : undefined;
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Per-epic budget (sum of tasks.budgetTokens) and spend (sum of
|
|
315
|
+
* task-result-recorded token_usage for that epic's tasks) — shared by
|
|
316
|
+
* overview()'s tokensByEpic and roadmapPage()'s per-milestone token roll-up,
|
|
317
|
+
* so the two views never compute the same numbers two different ways.
|
|
318
|
+
*
|
|
319
|
+
* Attribution goes through `epicResolver` (D-207), not a raw `Map.get`: on
|
|
320
|
+
* today's real log that raw lookup dropped 1,585,899 tokens of
|
|
321
|
+
* `envkit-mcp-surface` spend on the floor, because two of its Results spell
|
|
322
|
+
* the task id bare and one omits it from the payload.
|
|
323
|
+
*/
|
|
324
|
+
function epicTokenMaps(db, scope, taskRows) {
|
|
325
|
+
const budgetByEpic = new Map();
|
|
326
|
+
const epicByTask = new Map();
|
|
327
|
+
for (const t of taskRows) {
|
|
328
|
+
if (!t.epicId)
|
|
329
|
+
continue;
|
|
330
|
+
epicByTask.set(t.taskId, t.epicId);
|
|
331
|
+
if (t.budgetTokens !== null) {
|
|
332
|
+
budgetByEpic.set(t.epicId, (budgetByEpic.get(t.epicId) ?? 0) + t.budgetTokens);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
const epicOf = epicResolver(epicByTask);
|
|
336
|
+
const spentByEpic = new Map();
|
|
337
|
+
for (const row of taskResultRows(db, scope)) {
|
|
338
|
+
const epicId = epicOf(resultTaskId(row));
|
|
339
|
+
if (!epicId)
|
|
340
|
+
continue;
|
|
341
|
+
const total = row.payload.token_usage?.total_tokens ?? 0;
|
|
342
|
+
spentByEpic.set(epicId, (spentByEpic.get(epicId) ?? 0) + total);
|
|
343
|
+
}
|
|
344
|
+
return { budgetByEpic, spentByEpic };
|
|
345
|
+
}
|
|
346
|
+
const MILESTONE_COMPLETE_TASK_STATUSES = new Set(['completed', 'waived']);
|
|
347
|
+
const MILESTONE_NEXT_EXCLUDED_STATUSES = new Set(['completed', 'waived', 'failed', 'superseded']);
|
|
348
|
+
const MINI_TIMELINE_LIMIT = 3;
|
|
349
|
+
/**
|
|
350
|
+
* Operator directive 4 (Phase 6b, ui/docs/design-spec.md addendum): up to 3
|
|
351
|
+
* most-recent DONE tasks (success tone) and 3 NEXT tasks (plan/dependency
|
|
352
|
+
* order) per milestone, for the Roadmap page's mini-timeline row under each
|
|
353
|
+
* progress bar. `dependencyReady` = every edge this task depends on
|
|
354
|
+
* (edges.dependsOn) already points at a completed/waived task.
|
|
355
|
+
*/
|
|
356
|
+
function milestoneTaskRefs(milestoneTasks, allTaskRows, edgeRows) {
|
|
357
|
+
const statusById = new Map(allTaskRows.map((t) => [t.taskId, t.taskStatus]));
|
|
358
|
+
const dependsOnByTask = new Map();
|
|
359
|
+
for (const e of edgeRows) {
|
|
360
|
+
const list = dependsOnByTask.get(e.taskId) ?? [];
|
|
361
|
+
list.push(e.dependsOn);
|
|
362
|
+
dependsOnByTask.set(e.taskId, list);
|
|
363
|
+
}
|
|
364
|
+
const dependencyReady = (taskId) => (dependsOnByTask.get(taskId) ?? []).every((dep) => {
|
|
365
|
+
const status = statusById.get(dep);
|
|
366
|
+
return status !== undefined && MILESTONE_COMPLETE_TASK_STATUSES.has(status);
|
|
367
|
+
});
|
|
368
|
+
const recentDone = milestoneTasks
|
|
369
|
+
.filter((t) => MILESTONE_COMPLETE_TASK_STATUSES.has(t.taskStatus))
|
|
370
|
+
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
|
|
371
|
+
.slice(0, MINI_TIMELINE_LIMIT)
|
|
372
|
+
.map((t) => ({
|
|
373
|
+
taskId: t.taskId,
|
|
374
|
+
taskStatus: t.taskStatus,
|
|
375
|
+
title: t.objective,
|
|
376
|
+
updatedAt: t.updatedAt,
|
|
377
|
+
dependencyReady: true,
|
|
378
|
+
}));
|
|
379
|
+
const nextUp = milestoneTasks
|
|
380
|
+
.filter((t) => !MILESTONE_NEXT_EXCLUDED_STATUSES.has(t.taskStatus))
|
|
381
|
+
.map((row) => ({ row, ready: dependencyReady(row.taskId) }))
|
|
382
|
+
// Dependency-ready tasks first (plan/dependency order), then oldest-created
|
|
383
|
+
// first within each group -- `createdAt`, because db/projector.ts's touch()
|
|
384
|
+
// rewrites `updatedAt` on every event carrying the task id: ordering NEXT
|
|
385
|
+
// by it sank the first-planned task below a later one the moment it was
|
|
386
|
+
// dispatched, which is when the operator most expects to see it at the top.
|
|
387
|
+
.sort((a, b) => {
|
|
388
|
+
if (a.ready !== b.ready)
|
|
389
|
+
return a.ready ? -1 : 1;
|
|
390
|
+
return a.row.createdAt.localeCompare(b.row.createdAt);
|
|
391
|
+
})
|
|
392
|
+
.slice(0, MINI_TIMELINE_LIMIT)
|
|
393
|
+
.map(({ row, ready }) => ({
|
|
394
|
+
taskId: row.taskId,
|
|
395
|
+
taskStatus: row.taskStatus,
|
|
396
|
+
title: row.objective,
|
|
397
|
+
updatedAt: row.updatedAt,
|
|
398
|
+
dependencyReady: ready,
|
|
399
|
+
}));
|
|
400
|
+
return { recentDone, nextUp };
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* Join factory/specs/roadmap.md's milestones (db/schema.ts's `milestones`
|
|
404
|
+
* table) with each milestone's mapped epics' task/token stats. A milestone
|
|
405
|
+
* with no epics mapped yet (roadmap.md's `epics: []`) reports zero tasks —
|
|
406
|
+
* not an error, just "nothing tagged to this phase yet". `scope.project`
|
|
407
|
+
* filters to milestones whose OWN `- project:` tag matches (milestones are
|
|
408
|
+
* declared per-project, not derived from their epics' tasks).
|
|
409
|
+
*/
|
|
410
|
+
function milestoneProgressRows(db, scope = {}, opts = {}) {
|
|
411
|
+
const allMilestoneRows = db.select().from(milestones).orderBy(milestones.sequence).all();
|
|
412
|
+
const milestoneRows = scope.project !== undefined
|
|
413
|
+
? allMilestoneRows.filter((m) => m.project === scope.project)
|
|
414
|
+
: allMilestoneRows;
|
|
415
|
+
if (milestoneRows.length === 0)
|
|
416
|
+
return [];
|
|
417
|
+
const taskRows = allTasksForScope(db, scope);
|
|
418
|
+
const { budgetByEpic, spentByEpic } = epicTokenMaps(db, scope, taskRows);
|
|
419
|
+
const edgeSessionCond = scopedToSessions(edges.sessionId, scope);
|
|
420
|
+
const edgeRows = opts.includeTaskRefs
|
|
421
|
+
? edgeSessionCond
|
|
422
|
+
? db.select().from(edges).where(edgeSessionCond).all()
|
|
423
|
+
: db.select().from(edges).all()
|
|
424
|
+
: [];
|
|
425
|
+
return milestoneRows.map((m) => {
|
|
426
|
+
const epicIds = JSON.parse(m.epicIds);
|
|
427
|
+
const epicSet = new Set(epicIds);
|
|
428
|
+
const milestoneTasks = taskRows.filter((t) => t.epicId !== null && epicSet.has(t.epicId));
|
|
429
|
+
const tasksCompleted = milestoneTasks.filter((t) => MILESTONE_COMPLETE_TASK_STATUSES.has(t.taskStatus)).length;
|
|
430
|
+
let tokensSpent = 0;
|
|
431
|
+
let tokensBudget = 0;
|
|
432
|
+
let hasBudget = false;
|
|
433
|
+
for (const epicId of epicIds) {
|
|
434
|
+
tokensSpent += spentByEpic.get(epicId) ?? 0;
|
|
435
|
+
const budget = budgetByEpic.get(epicId);
|
|
436
|
+
if (budget !== undefined) {
|
|
437
|
+
tokensBudget += budget;
|
|
438
|
+
hasBudget = true;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
const refs = opts.includeTaskRefs
|
|
442
|
+
? milestoneTaskRefs(milestoneTasks, taskRows, edgeRows)
|
|
443
|
+
: undefined;
|
|
444
|
+
return {
|
|
445
|
+
milestoneId: m.milestoneId,
|
|
446
|
+
name: m.name,
|
|
447
|
+
status: m.status,
|
|
448
|
+
sequence: m.sequence,
|
|
449
|
+
goal: m.goal,
|
|
450
|
+
epicIds,
|
|
451
|
+
tasksTotal: milestoneTasks.length,
|
|
452
|
+
tasksCompleted,
|
|
453
|
+
tokensSpent,
|
|
454
|
+
tokensBudget: hasBudget ? tokensBudget : null,
|
|
455
|
+
project: m.project,
|
|
456
|
+
kind: m.kind,
|
|
457
|
+
...(refs ? { recentDone: refs.recentDone, nextUp: refs.nextUp } : {}),
|
|
458
|
+
};
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
/** Roadmap page (§5.4): every milestone with its progress + mini-timeline, in roadmap.md order. */
|
|
462
|
+
export function roadmapPage(db, scope = {}) {
|
|
463
|
+
return milestoneProgressRows(db, scope, { includeTaskRefs: true });
|
|
464
|
+
}
|
|
465
|
+
const RECENT_DISPATCHES_LIMIT = 10;
|
|
466
|
+
function allFindingsForScope(db, scope) {
|
|
467
|
+
const sessionCond = scopedToSessions(findings.sessionId, scope);
|
|
468
|
+
const rows = sessionCond
|
|
469
|
+
? db.select().from(findings).where(sessionCond).all()
|
|
470
|
+
: db.select().from(findings).all();
|
|
471
|
+
return filterByProject(rows, scope);
|
|
472
|
+
}
|
|
473
|
+
function allAgentsForScope(db, scope) {
|
|
474
|
+
// agents has no project column of its own (schema.ts) — scope it by
|
|
475
|
+
// membership in this scope's own task set (agents.taskId -> tasks.project),
|
|
476
|
+
// the same "derive via the owning task" approach projectFindings() uses.
|
|
477
|
+
const sessionCond = scopedToSessions(agents.sessionId, scope);
|
|
478
|
+
const liveRows = sessionCond
|
|
479
|
+
? db
|
|
480
|
+
.select()
|
|
481
|
+
.from(agents)
|
|
482
|
+
.where(and(eq(agents.status, 'live'), sessionCond))
|
|
483
|
+
.all()
|
|
484
|
+
: db.select().from(agents).where(eq(agents.status, 'live')).all();
|
|
485
|
+
if (scope.project === undefined)
|
|
486
|
+
return liveRows;
|
|
487
|
+
const scopedTasks = allTasksForScope(db, scope);
|
|
488
|
+
const scopedEpics = new Set(scopedTasks.flatMap((t) => (t.epicId === null ? [] : [t.epicId])));
|
|
489
|
+
// A task OR an epic places a row. Half the dispatches in a real run name an
|
|
490
|
+
// epic and no task at all — a planner, a spec-reviewer, a scribe, the
|
|
491
|
+
// epic-close judges — and matching on the task alone dropped every one of
|
|
492
|
+
// them the moment a project was selected (D-234).
|
|
493
|
+
//
|
|
494
|
+
// taskIdsMatch, not Set.has: the log spells the same task both qualified
|
|
495
|
+
// and bare, and a spelling difference is not a different task (D-130/D-143).
|
|
496
|
+
return liveRows.filter((a) => {
|
|
497
|
+
if (a.epicId !== null && scopedEpics.has(a.epicId))
|
|
498
|
+
return true;
|
|
499
|
+
return a.taskId !== null && scopedTasks.some((t) => taskIdsMatch(a.taskId, t.taskId));
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* Every projected session, most recently active first.
|
|
504
|
+
*
|
|
505
|
+
* Why the Overview needs this at all (dogfood round 2): the "Now running"
|
|
506
|
+
* card was built from `liveAgentEntries`, and an `agents` row stays `live`
|
|
507
|
+
* until a terminal event closes it out — which never happens for a run that
|
|
508
|
+
* was killed, crashed, or simply ended without one. In the real
|
|
509
|
+
* state/smith.db that left the card permanently filled with rows dispatched
|
|
510
|
+
* days earlier, so it never changed no matter what the factory was doing.
|
|
511
|
+
* Sessions are the honest unit: `lastEventAt` moves every time anything at
|
|
512
|
+
* all is appended.
|
|
513
|
+
*
|
|
514
|
+
* Ties on `lastEventAt` break on `sessionId` so the order is stable under the
|
|
515
|
+
* dashboard's 5s poll rather than shuffling between renders.
|
|
516
|
+
*/
|
|
517
|
+
export function runningSessions(db, scope = {}) {
|
|
518
|
+
const sessionCond = scopedToSessions(sessions.sessionId, scope);
|
|
519
|
+
const rows = sessionCond
|
|
520
|
+
? db.select().from(sessions).where(sessionCond).all()
|
|
521
|
+
: db.select().from(sessions).all();
|
|
522
|
+
const liveBySession = new Map();
|
|
523
|
+
for (const a of allAgentsForScope(db, scope)) {
|
|
524
|
+
liveBySession.set(a.sessionId, (liveBySession.get(a.sessionId) ?? 0) + 1);
|
|
525
|
+
}
|
|
526
|
+
const projectsBySession = new Map();
|
|
527
|
+
for (const t of allTasksForScope(db, scope)) {
|
|
528
|
+
const set = projectsBySession.get(t.sessionId) ?? new Set();
|
|
529
|
+
set.add(projectOf(t.project));
|
|
530
|
+
projectsBySession.set(t.sessionId, set);
|
|
531
|
+
}
|
|
532
|
+
// What each session did most recently. events_raw is not guaranteed to come
|
|
533
|
+
// back in ts order, so the latest row is chosen by comparison rather than by
|
|
534
|
+
// trusting scan order — and isLaterEvent settles a tie on ts the same way
|
|
535
|
+
// pulse() does, which is what lets the two agree about one session.
|
|
536
|
+
const lastEvent = new Map();
|
|
537
|
+
const eventQuery = db
|
|
538
|
+
.select({
|
|
539
|
+
sessionId: eventsRaw.sessionId,
|
|
540
|
+
ts: eventsRaw.ts,
|
|
541
|
+
eventType: eventsRaw.eventType,
|
|
542
|
+
eventId: eventsRaw.eventId,
|
|
543
|
+
})
|
|
544
|
+
.from(eventsRaw);
|
|
545
|
+
const eventSessionCond = scopedToSessions(eventsRaw.sessionId, scope);
|
|
546
|
+
for (const e of eventSessionCond ? eventQuery.where(eventSessionCond).all() : eventQuery.all()) {
|
|
547
|
+
const seen = lastEvent.get(e.sessionId);
|
|
548
|
+
if (seen === undefined || isLaterEvent(e, seen))
|
|
549
|
+
lastEvent.set(e.sessionId, e);
|
|
550
|
+
}
|
|
551
|
+
return (rows
|
|
552
|
+
.map((s) => ({
|
|
553
|
+
sessionId: s.sessionId,
|
|
554
|
+
startedAt: s.startedAt,
|
|
555
|
+
lastEventAt: s.lastEventAt,
|
|
556
|
+
eventCount: s.eventCount,
|
|
557
|
+
liveAgentCount: liveBySession.get(s.sessionId) ?? 0,
|
|
558
|
+
lastEventType: lastEvent.get(s.sessionId)?.eventType ?? null,
|
|
559
|
+
projects: [...(projectsBySession.get(s.sessionId) ?? [])].sort(),
|
|
560
|
+
}))
|
|
561
|
+
// Under a project scope, a session belongs to the project only through
|
|
562
|
+
// its tasks — one with none (a run that has not planned anything yet) is
|
|
563
|
+
// not this project's business, and would otherwise show up in every
|
|
564
|
+
// project's Overview at once.
|
|
565
|
+
.filter((s) => scope.project === undefined || s.projects.length > 0)
|
|
566
|
+
.sort((a, b) => b.lastEventAt.localeCompare(a.lastEventAt) || a.sessionId.localeCompare(b.sessionId)));
|
|
567
|
+
}
|
|
568
|
+
function groupLiveAgents(rows) {
|
|
569
|
+
const grouped = new Map();
|
|
570
|
+
for (const row of rows) {
|
|
571
|
+
const key = `${row.agentRole}|${row.provider}|${row.modelTier}`;
|
|
572
|
+
const existing = grouped.get(key);
|
|
573
|
+
if (existing)
|
|
574
|
+
existing.count += 1;
|
|
575
|
+
else {
|
|
576
|
+
grouped.set(key, {
|
|
577
|
+
agentRole: row.agentRole,
|
|
578
|
+
provider: row.provider,
|
|
579
|
+
modelTier: row.modelTier,
|
|
580
|
+
count: 1,
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
return [...grouped.values()];
|
|
585
|
+
}
|
|
586
|
+
/**
|
|
587
|
+
* Every closed epic in scope, newest close first (D-43/P9-27). One reader for
|
|
588
|
+
* both overview() and projectSummary(), so "in flight" can never mean two
|
|
589
|
+
* different things depending on which card you're looking at.
|
|
590
|
+
*/
|
|
591
|
+
function closedEpicsForScope(db, scope) {
|
|
592
|
+
const sessionCond = scopedToSessions(epics.sessionId, scope);
|
|
593
|
+
const rows = sessionCond
|
|
594
|
+
? db.select().from(epics).where(sessionCond).all()
|
|
595
|
+
: db.select().from(epics).all();
|
|
596
|
+
return filterByProject(rows, scope)
|
|
597
|
+
.map((e) => ({
|
|
598
|
+
epicId: e.epicId,
|
|
599
|
+
closedBy: e.closedBy,
|
|
600
|
+
machineVerdict: e.machineVerdict,
|
|
601
|
+
machineReason: e.machineReason,
|
|
602
|
+
overrideRationale: e.overrideRationale,
|
|
603
|
+
blockers: e.blockers ? JSON.parse(e.blockers) : [],
|
|
604
|
+
closedAt: e.closedAt,
|
|
605
|
+
}))
|
|
606
|
+
.sort((a, b) => b.closedAt.localeCompare(a.closedAt));
|
|
607
|
+
}
|
|
608
|
+
/**
|
|
609
|
+
* D-43/P9-27: "in flight" used to mean "has a non-terminal task", full stop —
|
|
610
|
+
* so an epic closed by an operator overriding a hold (exactly the case where a
|
|
611
|
+
* task stays non-terminal) read as in flight forever. A close on the log ends
|
|
612
|
+
* the flight, whatever the task rows still say.
|
|
613
|
+
*/
|
|
614
|
+
function inFlightEpics(taskRows, closed) {
|
|
615
|
+
const closedIds = new Set(closed.map((e) => e.epicId));
|
|
616
|
+
return [
|
|
617
|
+
...new Set(taskRows
|
|
618
|
+
.filter((t) => t.epicId &&
|
|
619
|
+
NON_TERMINAL_TASK_STATUSES.includes(t.taskStatus) &&
|
|
620
|
+
!closedIds.has(t.epicId))
|
|
621
|
+
.map((t) => t.epicId)),
|
|
622
|
+
].sort();
|
|
623
|
+
}
|
|
624
|
+
/** One project's overview slice, computed by the same logic overview() itself uses (no drift). */
|
|
625
|
+
function projectSummary(db, project, baseScope) {
|
|
626
|
+
const scope = { ...baseScope, project };
|
|
627
|
+
const liveRows = allAgentsForScope(db, scope);
|
|
628
|
+
const taskRows = allTasksForScope(db, scope);
|
|
629
|
+
const epicsInFlight = inFlightEpics(taskRows, closedEpicsForScope(db, scope));
|
|
630
|
+
const { budgetByEpic, spentByEpic } = epicTokenMaps(db, scope, taskRows);
|
|
631
|
+
const tokensSpent = [...spentByEpic.values()].reduce((s, v) => s + v, 0);
|
|
632
|
+
const tokensBudget = budgetByEpic.size > 0 ? [...budgetByEpic.values()].reduce((s, v) => s + v, 0) : null;
|
|
633
|
+
const escalations = taskRows.filter((t) => t.taskStatus === 'escalated').length;
|
|
634
|
+
const findingRows = allFindingsForScope(db, scope).filter((f) => (f.severity === 'S3-minor' || f.severity === 'S4-nit') &&
|
|
635
|
+
(f.findingStatus === 'raised' || f.findingStatus === 'confirmed'));
|
|
636
|
+
const pendingWaivers = findingRows.filter((f) => f.waiverId === null).length;
|
|
637
|
+
return {
|
|
638
|
+
project,
|
|
639
|
+
liveAgentCount: liveRows.length,
|
|
640
|
+
epicsInFlight,
|
|
641
|
+
tokensSpent,
|
|
642
|
+
tokensBudget,
|
|
643
|
+
alerts: { escalations, pendingWaivers },
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
// D-161: the fold's own alphabet, not a hand-copied subset of it. The list
|
|
647
|
+
// here used to omit `judge-reported`, so the slice fed to foldAgents() below
|
|
648
|
+
// contained judges being dispatched and never reporting — every judge that
|
|
649
|
+
// ever ran stayed live in the historical count, and the delta reported a drop
|
|
650
|
+
// that never happened.
|
|
651
|
+
const SNAPSHOT_EVENT_TYPES = REGISTRY_EVENT_TYPES;
|
|
652
|
+
/**
|
|
653
|
+
* Phase 6b StatCard deltas (6a DESIGN.md deviation closed): live-agent count
|
|
654
|
+
* as of `cutoffIso`, reconstructed by re-running agents-registry.ts's own
|
|
655
|
+
* fold over a `ts <=` slice of events_raw — a real point-in-time scan, not
|
|
656
|
+
* a fabricated trend (the fold logic is REUSED, not re-implemented, so a
|
|
657
|
+
* snapshot can never disagree with the live agents table's own semantics).
|
|
658
|
+
*
|
|
659
|
+
* Project scope is applied to the folded agents, not to the events going in
|
|
660
|
+
* — the same "derive via the owning task" route `allAgentsForScope()` takes
|
|
661
|
+
* for the live half of the delta. Filtering the events by their own
|
|
662
|
+
* `project` column instead (D-170) counted a different population at each
|
|
663
|
+
* end of the subtraction: a dispatch logged before Phase 6b carries no
|
|
664
|
+
* project, so its agent stayed in the live count and vanished from the
|
|
665
|
+
* historical one, and the StatCard reported an arrival that never happened.
|
|
666
|
+
*/
|
|
667
|
+
function liveAgentCountAt(db, scope, cutoffIso) {
|
|
668
|
+
const conds = [lte(eventsRaw.ts, cutoffIso), inArray(eventsRaw.eventType, SNAPSHOT_EVENT_TYPES)];
|
|
669
|
+
const sessionCond = scopedToSessions(eventsRaw.sessionId, scope);
|
|
670
|
+
if (sessionCond)
|
|
671
|
+
conds.push(sessionCond);
|
|
672
|
+
const rows = inLogOrder(db
|
|
673
|
+
.select()
|
|
674
|
+
.from(eventsRaw)
|
|
675
|
+
.where(and(...conds))
|
|
676
|
+
.all());
|
|
677
|
+
const storedEvents = rows.map((r) => ({
|
|
678
|
+
event_id: r.eventId,
|
|
679
|
+
record: {
|
|
680
|
+
session_id: r.sessionId,
|
|
681
|
+
actor: r.actor ?? 'system',
|
|
682
|
+
event_type: r.eventType,
|
|
683
|
+
task_id: r.taskId ?? undefined,
|
|
684
|
+
agent_id: r.agentId ?? undefined,
|
|
685
|
+
plan_version: r.planVersion,
|
|
686
|
+
causal_parent: r.causalParent,
|
|
687
|
+
payload: JSON.parse(r.payload),
|
|
688
|
+
ts: r.ts,
|
|
689
|
+
},
|
|
690
|
+
}));
|
|
691
|
+
const live = foldLiveAgents(foldAgents(storedEvents));
|
|
692
|
+
if (scope.project === undefined)
|
|
693
|
+
return live.length;
|
|
694
|
+
const scopedTaskIds = new Set(allTasksForScope(db, scope).map((t) => t.taskId));
|
|
695
|
+
return live.filter((a) => a.taskId !== null && scopedTaskIds.has(a.taskId)).length;
|
|
696
|
+
}
|
|
697
|
+
/**
|
|
698
|
+
* Total tokens spent (task-result-recorded token_usage) across `epicByTask`'s tasks, as of `cutoffIso`.
|
|
699
|
+
*
|
|
700
|
+
* Project scope arrives through `epicByTask`, which is built from rows
|
|
701
|
+
* `allTasksForScope()` already filtered — the same route `epicTokenMaps()`
|
|
702
|
+
* takes for the "now" end of the delta. Do NOT also filter these events on
|
|
703
|
+
* their own `project` column (D-170): `task-result-recorded` rows logged
|
|
704
|
+
* before Phase 6b carry no project, so the extra filter dropped spend here
|
|
705
|
+
* that the "now" total still counted, and the subtraction invented a rise
|
|
706
|
+
* that never happened — visible only when a project was selected.
|
|
707
|
+
*
|
|
708
|
+
* For the same reason it resolves the task id through `epicResolver`, exactly
|
|
709
|
+
* as `epicTokenMaps()` does (D-207): the two functions are the two ends of one
|
|
710
|
+
* subtraction, and teaching only the "now" end to read a bare id would turn
|
|
711
|
+
* hours-old spend into a rise that never happened — the identical bug in a new
|
|
712
|
+
* disguise.
|
|
713
|
+
*/
|
|
714
|
+
function tokensSpentAt(db, scope, cutoffIso, epicByTask) {
|
|
715
|
+
const conds = [eq(eventsRaw.eventType, 'task-result-recorded'), lte(eventsRaw.ts, cutoffIso)];
|
|
716
|
+
const sessionCond = scopedToSessions(eventsRaw.sessionId, scope);
|
|
717
|
+
if (sessionCond)
|
|
718
|
+
conds.push(sessionCond);
|
|
719
|
+
const rows = db
|
|
720
|
+
.select()
|
|
721
|
+
.from(eventsRaw)
|
|
722
|
+
.where(and(...conds))
|
|
723
|
+
.all();
|
|
724
|
+
const epicOf = epicResolver(epicByTask);
|
|
725
|
+
let total = 0;
|
|
726
|
+
for (const r of rows) {
|
|
727
|
+
const payload = JSON.parse(r.payload);
|
|
728
|
+
if (!epicOf(resultTaskId({ payload, envelopeTaskId: r.taskId })))
|
|
729
|
+
continue;
|
|
730
|
+
total += payload.token_usage?.total_tokens ?? 0;
|
|
731
|
+
}
|
|
732
|
+
return total;
|
|
733
|
+
}
|
|
734
|
+
/**
|
|
735
|
+
* Total budget across `epicByTask`'s tasks, as of `cutoffIso` — the other
|
|
736
|
+
* term of the 1h budget-used delta, read as of the same instant as the spend.
|
|
737
|
+
*
|
|
738
|
+
* `tasks.budgetTokens` has no history, but it needs none: exactly one event
|
|
739
|
+
* type ever writes it (projector.ts's `task-added` case, `p.budget_tokens ??
|
|
740
|
+
* row.budgetTokens`, keyed on the envelope's task id), so folding those rows
|
|
741
|
+
* up to the cutoff in the same order reproduces the column as it stood then.
|
|
742
|
+
*
|
|
743
|
+
* Scope and epic attribution arrive through `epicByTask`, exactly as in
|
|
744
|
+
* `tokensSpentAt` and for the reasons D-170 and D-207 record there: the two
|
|
745
|
+
* ends of one subtraction have to count the same population, and a term
|
|
746
|
+
* computed as-of-now against a term computed as-of-then reports a change that
|
|
747
|
+
* never happened. Here it was the denominator: a budget that grew during the
|
|
748
|
+
* hour retroactively shrank the historical percentage, so an epic that had
|
|
749
|
+
* burned 90% of its budget and then had a large task planned into it read as
|
|
750
|
+
* a one-point rise while the gauge in fact fell eighty.
|
|
751
|
+
*/
|
|
752
|
+
function tokensBudgetedAt(db, scope, cutoffIso, epicByTask) {
|
|
753
|
+
const conds = [eq(eventsRaw.eventType, 'task-added'), lte(eventsRaw.ts, cutoffIso)];
|
|
754
|
+
const sessionCond = scopedToSessions(eventsRaw.sessionId, scope);
|
|
755
|
+
if (sessionCond)
|
|
756
|
+
conds.push(sessionCond);
|
|
757
|
+
const rows = inLogOrder(db
|
|
758
|
+
.select()
|
|
759
|
+
.from(eventsRaw)
|
|
760
|
+
.where(and(...conds))
|
|
761
|
+
.all());
|
|
762
|
+
const epicOf = epicResolver(epicByTask);
|
|
763
|
+
// Last write per task wins, as the projector's fold does — a re-plan that
|
|
764
|
+
// re-states a task carries the budget it has from then on. Which write is
|
|
765
|
+
// last is the log's answer, not `ts`'s: this read used to break the tie on
|
|
766
|
+
// `event_id` as text, so within one re-plan's burst the eleventh task-added
|
|
767
|
+
// sorted before the ninth and a task could keep the budget it had been
|
|
768
|
+
// re-planned out of.
|
|
769
|
+
const budgetByTask = new Map();
|
|
770
|
+
for (const r of rows) {
|
|
771
|
+
if (!r.taskId || !epicOf(r.taskId))
|
|
772
|
+
continue;
|
|
773
|
+
const payload = JSON.parse(r.payload);
|
|
774
|
+
if (payload.budget_tokens === undefined)
|
|
775
|
+
continue;
|
|
776
|
+
budgetByTask.set(r.taskId, payload.budget_tokens);
|
|
777
|
+
}
|
|
778
|
+
let total = 0;
|
|
779
|
+
for (const v of budgetByTask.values())
|
|
780
|
+
total += v;
|
|
781
|
+
return total;
|
|
782
|
+
}
|
|
783
|
+
export function overview(db, scope = {}, opts = {}) {
|
|
784
|
+
const liveRows = allAgentsForScope(db, scope);
|
|
785
|
+
const taskRows = allTasksForScope(db, scope);
|
|
786
|
+
const closedEpics = closedEpicsForScope(db, scope);
|
|
787
|
+
const epicsInFlight = inFlightEpics(taskRows, closedEpics);
|
|
788
|
+
const { budgetByEpic, spentByEpic } = epicTokenMaps(db, scope, taskRows);
|
|
789
|
+
const epicIds = new Set([...budgetByEpic.keys(), ...spentByEpic.keys()]);
|
|
790
|
+
const tokensByEpic = [...epicIds].sort().map((epicId) => ({
|
|
791
|
+
epicId,
|
|
792
|
+
tokensSpent: spentByEpic.get(epicId) ?? 0,
|
|
793
|
+
tokensBudget: budgetByEpic.get(epicId) ?? null,
|
|
794
|
+
}));
|
|
795
|
+
const escalations = taskRows.filter((t) => t.taskStatus === 'escalated').length;
|
|
796
|
+
const pendingWaiverFindings = allFindingsForScope(db, scope).filter((f) => (f.severity === 'S3-minor' || f.severity === 'S4-nit') &&
|
|
797
|
+
(f.findingStatus === 'raised' || f.findingStatus === 'confirmed'));
|
|
798
|
+
const pendingWaivers = pendingWaiverFindings.filter((f) => f.waiverId === null).length;
|
|
799
|
+
const dispatchSessionCond = scopedToSessions(dispatches.sessionId, scope);
|
|
800
|
+
const dispatchRows = dispatchSessionCond
|
|
801
|
+
? db.select().from(dispatches).where(dispatchSessionCond).all()
|
|
802
|
+
: db.select().from(dispatches).all();
|
|
803
|
+
const scopedDispatches = filterByProject(dispatchRows, scope);
|
|
804
|
+
// Newest first, then the top ten — so the tie has to be broken before the
|
|
805
|
+
// slice, not left to the sort's stability. A wave admits its whole cohort
|
|
806
|
+
// inside one millisecond, and a comparison that returned 0 across all of
|
|
807
|
+
// them left the rows in the ascending order they arrived in: the ten this
|
|
808
|
+
// then kept were the *oldest* ten of the burst, under a heading that says
|
|
809
|
+
// recent.
|
|
810
|
+
const recentDispatches = inLogOrder(scopedDispatches)
|
|
811
|
+
.reverse()
|
|
812
|
+
.slice(0, RECENT_DISPATCHES_LIMIT)
|
|
813
|
+
.map((d) => ({
|
|
814
|
+
eventId: d.eventId,
|
|
815
|
+
ts: d.ts,
|
|
816
|
+
agentRole: d.agentRole,
|
|
817
|
+
provider: d.provider,
|
|
818
|
+
modelTier: d.modelTier,
|
|
819
|
+
taskId: d.taskId,
|
|
820
|
+
reason: d.reason,
|
|
821
|
+
}));
|
|
822
|
+
let projects;
|
|
823
|
+
if (scope.project === undefined) {
|
|
824
|
+
const taskSessionCond = scopedToSessions(tasks.sessionId, scope);
|
|
825
|
+
const allTaskRowsUnfiltered = taskSessionCond
|
|
826
|
+
? db.select().from(tasks).where(taskSessionCond).all()
|
|
827
|
+
: db.select().from(tasks).all();
|
|
828
|
+
// Milestones too, not just tasks: roadmap.md's `- project:` bullet
|
|
829
|
+
// declares a project before any of its work is planned, and
|
|
830
|
+
// milestoneProgressRows() filters phases by that same bullet. This list
|
|
831
|
+
// is the whole of GET /api/projects, which is the whole of the UI's
|
|
832
|
+
// project switcher -- deriving it from `tasks` alone left a declared but
|
|
833
|
+
// unstarted project unselectable, so its phases showed only in "All
|
|
834
|
+
// projects" and the scope built to serve them could not be reached.
|
|
835
|
+
// Milestones carry no session_id and milestoneProgressRows() never
|
|
836
|
+
// filters them by one, so they join the list under a session scope too.
|
|
837
|
+
const declaredRows = db.select({ project: milestones.project }).from(milestones).all();
|
|
838
|
+
projects = distinctProjects(allTaskRowsUnfiltered, declaredRows).map((p) => projectSummary(db, p, scope));
|
|
839
|
+
}
|
|
840
|
+
const now = opts.nowIso ? new Date(opts.nowIso) : new Date();
|
|
841
|
+
const fiveMinAgo = new Date(now.getTime() - 5 * 60 * 1000).toISOString();
|
|
842
|
+
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000).toISOString();
|
|
843
|
+
const liveAgentCountDelta5m = liveRows.length - liveAgentCountAt(db, scope, fiveMinAgo);
|
|
844
|
+
const epicByTask = new Map(taskRows.filter((t) => t.epicId !== null).map((t) => [t.taskId, t.epicId]));
|
|
845
|
+
const totalBudgetNow = [...budgetByEpic.values()].reduce((s, v) => s + v, 0);
|
|
846
|
+
const totalSpentNow = [...spentByEpic.values()].reduce((s, v) => s + v, 0);
|
|
847
|
+
let budgetUsedPctPointDelta1h = null;
|
|
848
|
+
if (totalBudgetNow > 0) {
|
|
849
|
+
const currentPct = (totalSpentNow / totalBudgetNow) * 100;
|
|
850
|
+
const spentAgo = tokensSpentAt(db, scope, oneHourAgo, epicByTask);
|
|
851
|
+
const budgetAgo = tokensBudgetedAt(db, scope, oneHourAgo, epicByTask);
|
|
852
|
+
// Nothing was budgeted an hour ago, so nothing was used: the card read
|
|
853
|
+
// "No budget set" then, and the honest reading of the move since is the
|
|
854
|
+
// whole of today's percentage. Dividing by today's budget instead would
|
|
855
|
+
// be the bug this guard exists to avoid, in its smallest form.
|
|
856
|
+
const pctAgo = budgetAgo > 0 ? (spentAgo / budgetAgo) * 100 : 0;
|
|
857
|
+
budgetUsedPctPointDelta1h = currentPct - pctAgo;
|
|
858
|
+
}
|
|
859
|
+
return {
|
|
860
|
+
liveAgents: groupLiveAgents(liveRows),
|
|
861
|
+
// Newest dispatch first — a live-agents card that's about to show many
|
|
862
|
+
// concurrent agents should surface the most recently-started ones up top.
|
|
863
|
+
liveAgentEntries: [...liveRows]
|
|
864
|
+
.sort((a, b) => b.dispatchedAt.localeCompare(a.dispatchedAt))
|
|
865
|
+
.map((a) => ({
|
|
866
|
+
id: a.id,
|
|
867
|
+
sessionId: a.sessionId,
|
|
868
|
+
agentRole: a.agentRole,
|
|
869
|
+
provider: a.provider,
|
|
870
|
+
modelTier: a.modelTier,
|
|
871
|
+
taskId: a.taskId,
|
|
872
|
+
epicId: a.epicId,
|
|
873
|
+
dispatchedAt: a.dispatchedAt,
|
|
874
|
+
})),
|
|
875
|
+
liveAgentCount: liveRows.length,
|
|
876
|
+
runningSessions: runningSessions(db, scope),
|
|
877
|
+
epicsInFlight,
|
|
878
|
+
closedEpics,
|
|
879
|
+
tokensByEpic,
|
|
880
|
+
alerts: { escalations, pendingWaivers },
|
|
881
|
+
milestoneProgress: milestoneProgressRows(db, scope),
|
|
882
|
+
recentDispatches,
|
|
883
|
+
liveAgentCountDelta5m,
|
|
884
|
+
budgetUsedPctPointDelta1h,
|
|
885
|
+
...(projects ? { projects } : {}),
|
|
886
|
+
};
|
|
887
|
+
}
|
|
888
|
+
// ---------------------------------------------------------------------------
|
|
889
|
+
// timeline()
|
|
890
|
+
// ---------------------------------------------------------------------------
|
|
891
|
+
/**
|
|
892
|
+
* The timeline's event types that are NOT taxonomy values: `event_type` is a
|
|
893
|
+
* free string (event.schema.json), and these predate — or sit outside — the
|
|
894
|
+
* gate/graph dimensions.
|
|
895
|
+
*
|
|
896
|
+
* `lesson-status-changed` is here because of a fix-round finding (code review
|
|
897
|
+
* #11): the "Decisions" lens's DECISION_EVENT_TYPES includes it, but
|
|
898
|
+
* timeline()'s eventType filter runs BEFORE the lens, so a lesson decision
|
|
899
|
+
* could never reach the lens — or the plain Timeline page — at all.
|
|
900
|
+
*
|
|
901
|
+
* The seven below it were found the same way, one layer further out: the
|
|
902
|
+
* event-type lint (P9-37) knows which free types `src` writes, and comparing
|
|
903
|
+
* that set against this one showed seven written correctly, folded correctly,
|
|
904
|
+
* and dropped here — including `epic-closed`, the most consequential event in
|
|
905
|
+
* an epic's life. Over the factory's own logs that filter was hiding 117 of
|
|
906
|
+
* 461 recorded events. The list is now guarded in both directions by
|
|
907
|
+
* factory/orchestrator/test/eventTypes.test.ts, so it cannot fall behind the
|
|
908
|
+
* writers again without a red test naming the type.
|
|
909
|
+
*
|
|
910
|
+
* `operator-note` was the exception that guard cannot catch on its own, and it
|
|
911
|
+
* cost the most: the scan reads `src`, and this type is only ever written from
|
|
912
|
+
* outside it (`smith event append`), so nothing pointed at the gap while it
|
|
913
|
+
* grew into the third most common event in the factory's own logs. It carries
|
|
914
|
+
* the operator's reasoning in their own words — the one thing on the timeline
|
|
915
|
+
* a machine did not write — and none of it reached the screen. The guard now
|
|
916
|
+
* covers it because a human named it in FREE_EVENT_TYPES; that human step is
|
|
917
|
+
* load-bearing for every `writtenBy: 'cli'` type, which is why each one says so.
|
|
918
|
+
*/
|
|
919
|
+
export const FREE_TIMELINE_EVENT_TYPES = [
|
|
920
|
+
'user_prompt',
|
|
921
|
+
'operator-note',
|
|
922
|
+
'dispatch_decision',
|
|
923
|
+
'error-logged',
|
|
924
|
+
'lesson-status-changed',
|
|
925
|
+
'session-start',
|
|
926
|
+
'task-result-recorded',
|
|
927
|
+
'judge-verdict',
|
|
928
|
+
'judge-reported',
|
|
929
|
+
'epic-closed',
|
|
930
|
+
'lesson-candidate-raised',
|
|
931
|
+
'lesson-edited',
|
|
932
|
+
// D-21 Part 4: the whole point of this event is that a reader must not see
|
|
933
|
+
// a clean amendment discharge without learning its obligation was repaired
|
|
934
|
+
// -- hiding the repair itself from the timeline would be the same failure
|
|
935
|
+
// one layer out.
|
|
936
|
+
'finding-obligation-repaired',
|
|
937
|
+
// The independent finder's reconciliation. It is the one event that says a
|
|
938
|
+
// second, differently-vendored eye read this diff at all, and in shadow mode
|
|
939
|
+
// it is the ONLY thing the run produces -- gating nothing by definition. A
|
|
940
|
+
// shadow verdict nobody can see is a shadow deployment nobody can evaluate,
|
|
941
|
+
// which is the whole reason the mode exists.
|
|
942
|
+
'cross-finding-reconciled',
|
|
943
|
+
// The scheduler's three proposals. Architecture §12 has the scheduler
|
|
944
|
+
// propose and the operator dispose, so the timeline is the only place the
|
|
945
|
+
// offer is ever made -- drop the row and the proposal is never put to
|
|
946
|
+
// anyone. They arrive through scheduler.ts eventTypeFor(), a helper return
|
|
947
|
+
// rather than a literal at the `event_type:` position, which is how all
|
|
948
|
+
// three stayed off this list without failing the P9-37 lint (Rule D).
|
|
949
|
+
'recheck-proposed',
|
|
950
|
+
'maintenance-proposed',
|
|
951
|
+
'growth-review-due',
|
|
952
|
+
// The audit verbs (audit.ts, docs/specs/audit-command-scope.md §6). An
|
|
953
|
+
// audit runs against a project outside this factory and carries no task_id,
|
|
954
|
+
// so nothing else on the timeline would show that it happened; each verb's
|
|
955
|
+
// event is stamped with the project's name for the multi-project view.
|
|
956
|
+
'audit-opened',
|
|
957
|
+
'audit-finding-raised',
|
|
958
|
+
'audit-decision',
|
|
959
|
+
'audit-cut',
|
|
960
|
+
'audit-resolved',
|
|
961
|
+
'audit-closed',
|
|
962
|
+
];
|
|
963
|
+
let cachedTaxonomy;
|
|
964
|
+
/**
|
|
965
|
+
* event_types rendered on the timeline: user prompts + dispatch decisions +
|
|
966
|
+
* gate/graph events, interleaved (architecture §7 "a hard requirement";
|
|
967
|
+
* §7 also requires "errors and gate results attach to the same timeline").
|
|
968
|
+
*
|
|
969
|
+
* Read off the taxonomy rather than hand-copied (P9-37). The copy had fallen
|
|
970
|
+
* eight gate events behind — `deps-check-result` had an icon and a title in
|
|
971
|
+
* ui/src/lib/timelineDisplay.ts that could never fire, because this filter
|
|
972
|
+
* dropped the row before the renderer saw it. A gate result the operator
|
|
973
|
+
* cannot see is one the factory may as well not have logged, and a list that
|
|
974
|
+
* has to be updated twice is a list that will be updated once.
|
|
975
|
+
*
|
|
976
|
+
* Exported since D-163: `smith event append` reads it to tell the operator, on
|
|
977
|
+
* the receipt, that the type they just wrote lands outside it. The write side
|
|
978
|
+
* is open on purpose and this side is closed on purpose; the export is what
|
|
979
|
+
* keeps the two from being two different lists.
|
|
980
|
+
*/
|
|
981
|
+
export function timelineEventTypes() {
|
|
982
|
+
if (cachedTaxonomy === undefined)
|
|
983
|
+
cachedTaxonomy = loadTaxonomy();
|
|
984
|
+
return [
|
|
985
|
+
...FREE_TIMELINE_EVENT_TYPES,
|
|
986
|
+
...(cachedTaxonomy.dimensions.gate_event ?? []),
|
|
987
|
+
...(cachedTaxonomy.dimensions.graph_event ?? []),
|
|
988
|
+
];
|
|
989
|
+
}
|
|
990
|
+
function toEntry(row) {
|
|
991
|
+
return {
|
|
992
|
+
eventId: row.eventId,
|
|
993
|
+
ts: row.ts,
|
|
994
|
+
eventType: row.eventType,
|
|
995
|
+
taskId: row.taskId,
|
|
996
|
+
agentId: row.agentId,
|
|
997
|
+
planVersion: row.planVersion,
|
|
998
|
+
causalParent: row.causalParent,
|
|
999
|
+
payload: JSON.parse(row.payload),
|
|
1000
|
+
project: row.project,
|
|
1001
|
+
actor: row.actor,
|
|
1002
|
+
};
|
|
1003
|
+
}
|
|
1004
|
+
/**
|
|
1005
|
+
* Operator-actor decision event kinds (Timeline "Decisions" lens). Limited
|
|
1006
|
+
* to kinds this codebase's write paths attribute to the operator; the actor
|
|
1007
|
+
* is still checked below rather than trusted from the type alone, so a
|
|
1008
|
+
* system-authored event of one of these kinds is correctly excluded rather
|
|
1009
|
+
* than silently miscounted as a decision. `plan-version-created`/
|
|
1010
|
+
* `-superseded` ("plan sign-offs" per the operator's own phrasing) are
|
|
1011
|
+
* deliberately NOT included yet — this codebase has no dedicated event
|
|
1012
|
+
* distinguishing an operator's plan sign-off from the planner's own
|
|
1013
|
+
* automatic version cut, and inventing that distinction was out of this
|
|
1014
|
+
* task's scope; flagged in ui/docs/DESIGN.md rather than guessed.
|
|
1015
|
+
*
|
|
1016
|
+
* D-164: that actor check used to read `entry.actor === 'user'`. The default
|
|
1017
|
+
* is `'user'` only when a caller passes no actor at all — true of the UI, and
|
|
1018
|
+
* of nothing else. The CLI paths the operator guide documents pass
|
|
1019
|
+
* `--actor operator`, the operator's console passes `operator-skill`, and so
|
|
1020
|
+
* across all 668 events the factory has ever recorded the string `'user'`
|
|
1021
|
+
* never appears: 26 waiver and lesson decisions were on the timeline and the
|
|
1022
|
+
* lens returned 0 of them, on every session. `isOperatorActor` is now the one
|
|
1023
|
+
* place that answers this, shared with lessons.ts, which had answered it
|
|
1024
|
+
* differently.
|
|
1025
|
+
*
|
|
1026
|
+
* D-213: `operator-note` is the fourth kind, and it was the one worth the most.
|
|
1027
|
+
* D-153 taught the Prompts chip that "a person said this" means `user_prompt`
|
|
1028
|
+
* OR `operator-note`, because this factory's logs hold 0 of the first and 57 of
|
|
1029
|
+
* the second. This lens asks the stronger form of the same question and knew
|
|
1030
|
+
* only `user_prompt`, so over the real store it answered with 27 of the 103
|
|
1031
|
+
* rows it exists to show: every note the operator wrote gone, and the 19
|
|
1032
|
+
* dispatches those notes caused gone with them — a lens whose subject is the
|
|
1033
|
+
* operator's decisions, returning almost none of them. The type belongs here
|
|
1034
|
+
* rather than beside `user_prompt` above precisely because it needs the actor
|
|
1035
|
+
* check: `smith event append` is open on the write side, so an agent can write
|
|
1036
|
+
* one, and `operator-note` should be read as a decision only when the operator
|
|
1037
|
+
* is who wrote it.
|
|
1038
|
+
*/
|
|
1039
|
+
const DECISION_EVENT_TYPES = new Set([
|
|
1040
|
+
'waiver-granted',
|
|
1041
|
+
'waiver-denied',
|
|
1042
|
+
'lesson-status-changed',
|
|
1043
|
+
'operator-note',
|
|
1044
|
+
]);
|
|
1045
|
+
function isDecisionEntry(entry) {
|
|
1046
|
+
if (entry.eventType === 'user_prompt')
|
|
1047
|
+
return true;
|
|
1048
|
+
return DECISION_EVENT_TYPES.has(entry.eventType) && isOperatorActor(entry.actor);
|
|
1049
|
+
}
|
|
1050
|
+
function applyDecisionsLens(entries) {
|
|
1051
|
+
const decisionIds = new Set(entries.filter(isDecisionEntry).map((e) => e.eventId));
|
|
1052
|
+
return entries.filter((e) => {
|
|
1053
|
+
if (isDecisionEntry(e))
|
|
1054
|
+
return true;
|
|
1055
|
+
if (e.eventType !== 'dispatch_decision')
|
|
1056
|
+
return false;
|
|
1057
|
+
const parentPromptId = e.payload.parent_prompt_id;
|
|
1058
|
+
return ((parentPromptId !== undefined && decisionIds.has(parentPromptId)) ||
|
|
1059
|
+
(e.causalParent !== null && decisionIds.has(e.causalParent)));
|
|
1060
|
+
});
|
|
1061
|
+
}
|
|
1062
|
+
/**
|
|
1063
|
+
* Walk causal_parent pointers back to the root, oldest-first.
|
|
1064
|
+
*
|
|
1065
|
+
* P9-7: the walk crosses session boundaries. Event ids are globally unique
|
|
1066
|
+
* (`<session-id>#<index>`), so a hop needs the id alone; constraining every
|
|
1067
|
+
* lookup to `sessionId` used to stop the chain dead at the session root of a
|
|
1068
|
+
* continued epic — the projection reported a two-event history for a chain
|
|
1069
|
+
* that ran back through three sessions. `sessionId` still gates the FIRST
|
|
1070
|
+
* lookup, so asking for a chain that does not start in the named session
|
|
1071
|
+
* remains an empty answer rather than a silent redirect.
|
|
1072
|
+
*/
|
|
1073
|
+
function causalChain(db, sessionId, eventId) {
|
|
1074
|
+
const chain = [];
|
|
1075
|
+
let currentId = eventId;
|
|
1076
|
+
const seen = new Set();
|
|
1077
|
+
let first = true;
|
|
1078
|
+
while (currentId && !seen.has(currentId)) {
|
|
1079
|
+
seen.add(currentId);
|
|
1080
|
+
const idMatch = eq(eventsRaw.eventId, currentId);
|
|
1081
|
+
const row = db
|
|
1082
|
+
.select()
|
|
1083
|
+
.from(eventsRaw)
|
|
1084
|
+
.where(first ? and(idMatch, eq(eventsRaw.sessionId, sessionId)) : idMatch)
|
|
1085
|
+
.get();
|
|
1086
|
+
if (!row)
|
|
1087
|
+
break;
|
|
1088
|
+
first = false;
|
|
1089
|
+
chain.unshift(toEntry(row));
|
|
1090
|
+
currentId = row.causalParent;
|
|
1091
|
+
}
|
|
1092
|
+
return chain;
|
|
1093
|
+
}
|
|
1094
|
+
/**
|
|
1095
|
+
* Which epic an entry belongs to, or null when it names none.
|
|
1096
|
+
*
|
|
1097
|
+
* The task id decides it whenever it qualifies one, because `<epic>/<task>` is
|
|
1098
|
+
* the row's own identity and a payload is only ever a claim about it. Asked
|
|
1099
|
+
* through `epicOfTaskId`, not `split('/')[0]`: this filter was one of the call
|
|
1100
|
+
* sites D-49 wrote that module for, and it still spelled the derivation by
|
|
1101
|
+
* hand, so a bare `task-2-path-guard` answered "epic task-2-path-guard".
|
|
1102
|
+
*
|
|
1103
|
+
* The fallback is the actual defect (D-206). The events an operator opens an
|
|
1104
|
+
* epic timeline *for* -- `plan-version-created`, `wave-admitted`,
|
|
1105
|
+
* `wave-merged` -- belong to the epic rather than to any one task, so they
|
|
1106
|
+
* carry no task id and were filtered out of the one lens built to show them.
|
|
1107
|
+
* Those name their epic in the payload instead. An entry with neither drops
|
|
1108
|
+
* out, which is the honest answer for a legacy row that recorded neither.
|
|
1109
|
+
*/
|
|
1110
|
+
function epicOfEntry(entry) {
|
|
1111
|
+
const fromTaskId = entry.taskId === null ? null : epicOfTaskId(entry.taskId);
|
|
1112
|
+
if (fromTaskId !== null)
|
|
1113
|
+
return fromTaskId;
|
|
1114
|
+
const fromPayload = entry.payload.epic_id;
|
|
1115
|
+
return typeof fromPayload === 'string' ? fromPayload : null;
|
|
1116
|
+
}
|
|
1117
|
+
export function timeline(db, filter = {}) {
|
|
1118
|
+
if (filter.causalChainFor) {
|
|
1119
|
+
if (!filter.sessionId) {
|
|
1120
|
+
throw new RangeError('timeline(): causalChainFor requires sessionId to be set.');
|
|
1121
|
+
}
|
|
1122
|
+
return causalChain(db, filter.sessionId, filter.causalChainFor);
|
|
1123
|
+
}
|
|
1124
|
+
const eventTypes = filter.eventTypes ?? timelineEventTypes();
|
|
1125
|
+
const conditions = [inArray(eventsRaw.eventType, eventTypes)];
|
|
1126
|
+
const sessionCond = scopedToSessions(eventsRaw.sessionId, filter);
|
|
1127
|
+
if (sessionCond)
|
|
1128
|
+
conditions.push(sessionCond);
|
|
1129
|
+
if (filter.taskId)
|
|
1130
|
+
conditions.push(eq(eventsRaw.taskId, filter.taskId));
|
|
1131
|
+
const rows = inLogOrder(db
|
|
1132
|
+
.select()
|
|
1133
|
+
.from(eventsRaw)
|
|
1134
|
+
.where(and(...conditions))
|
|
1135
|
+
.all());
|
|
1136
|
+
let entries = rows.map(toEntry);
|
|
1137
|
+
entries = filterByProject(entries, filter);
|
|
1138
|
+
if (filter.epicId)
|
|
1139
|
+
entries = entries.filter((e) => epicOfEntry(e) === filter.epicId);
|
|
1140
|
+
if (filter.decisionsOnly)
|
|
1141
|
+
entries = applyDecisionsLens(entries);
|
|
1142
|
+
return entries;
|
|
1143
|
+
}
|
|
1144
|
+
// D-127: `amend-pending` is open. The amendment has been written but the tasks
|
|
1145
|
+
// it obligates have not landed, so nothing is discharged yet. This query has no
|
|
1146
|
+
// obligation data — that lives in epic.ts's summary, which checks each named
|
|
1147
|
+
// task id — so it treats the status as unconditionally open: correct for a
|
|
1148
|
+
// per-task severity chip, and it fails closed.
|
|
1149
|
+
const OPEN_FINDING_STATUSES = new Set([
|
|
1150
|
+
'raised',
|
|
1151
|
+
'confirmed',
|
|
1152
|
+
'fix-pending',
|
|
1153
|
+
'fix-landed',
|
|
1154
|
+
'amend-pending',
|
|
1155
|
+
]);
|
|
1156
|
+
function worstSeverity(severities) {
|
|
1157
|
+
let worst = null;
|
|
1158
|
+
let worstIndex = Number.POSITIVE_INFINITY;
|
|
1159
|
+
for (const s of severities) {
|
|
1160
|
+
// Not worseSeverity(): this list comes out of SQL and may hold a severity a
|
|
1161
|
+
// taxonomy edit has since retired. A chip on a Kanban card degrades to
|
|
1162
|
+
// "no severity" for one; a reconciliation must not, so that one throws.
|
|
1163
|
+
const index = severityRank(s);
|
|
1164
|
+
if (index !== null && index < worstIndex) {
|
|
1165
|
+
worstIndex = index;
|
|
1166
|
+
worst = s;
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
return worst;
|
|
1170
|
+
}
|
|
1171
|
+
/**
|
|
1172
|
+
* Tasks for one epic (or, Phase 6b, every epic when `epicId` is omitted —
|
|
1173
|
+
* closes the 6a DESIGN.md "no all-epics mode" deviation), grouped by
|
|
1174
|
+
* task_status, tagged with case/origin/worst-open-finding severity plus
|
|
1175
|
+
* (Phase 6b) title/agent-role/milestone.
|
|
1176
|
+
*/
|
|
1177
|
+
export function kanban(db, epicId, scope = {}) {
|
|
1178
|
+
const epicCond = epicId !== undefined ? eq(tasks.epicId, epicId) : undefined;
|
|
1179
|
+
const sessionCond = scopedToSessions(tasks.sessionId, scope);
|
|
1180
|
+
const taskConds = [epicCond, sessionCond].filter((c) => c !== undefined);
|
|
1181
|
+
const taskRows = filterByProject(taskConds.length > 0
|
|
1182
|
+
? db
|
|
1183
|
+
.select()
|
|
1184
|
+
.from(tasks)
|
|
1185
|
+
.where(and(...taskConds))
|
|
1186
|
+
.all()
|
|
1187
|
+
: db.select().from(tasks).all(), scope);
|
|
1188
|
+
const epicIdsInScope = new Set(taskRows.map((t) => t.epicId).filter((e) => e !== null));
|
|
1189
|
+
const findingCond = epicId !== undefined ? eq(findings.epicId, epicId) : undefined;
|
|
1190
|
+
const findingRows = findingCond
|
|
1191
|
+
? db.select().from(findings).where(findingCond).all()
|
|
1192
|
+
: db.select().from(findings).all();
|
|
1193
|
+
const openSeverityByTask = new Map();
|
|
1194
|
+
for (const f of findingRows) {
|
|
1195
|
+
if (f.epicId !== null && !epicIdsInScope.has(f.epicId))
|
|
1196
|
+
continue;
|
|
1197
|
+
if (!OPEN_FINDING_STATUSES.has(f.findingStatus))
|
|
1198
|
+
continue;
|
|
1199
|
+
const list = openSeverityByTask.get(f.taskId) ?? [];
|
|
1200
|
+
list.push(f.severity);
|
|
1201
|
+
openSeverityByTask.set(f.taskId, list);
|
|
1202
|
+
}
|
|
1203
|
+
// Latest dispatch per task_id (agentRole + modelTier fields — operator
|
|
1204
|
+
// directive 2, Phase 6b round 3: Kanban's "subagent + model" chip. Same
|
|
1205
|
+
// minimal-widening pattern as the earlier agentRole/title/milestoneId
|
|
1206
|
+
// additions in this function, disclosed the same way.
|
|
1207
|
+
const dispatchSessionCond = scopedToSessions(dispatches.sessionId, scope);
|
|
1208
|
+
const dispatchRows = dispatchSessionCond
|
|
1209
|
+
? db.select().from(dispatches).where(dispatchSessionCond).all()
|
|
1210
|
+
: db.select().from(dispatches).all();
|
|
1211
|
+
const latestAgentRoleByTask = new Map();
|
|
1212
|
+
for (const d of dispatchRows) {
|
|
1213
|
+
if (!d.taskId)
|
|
1214
|
+
continue;
|
|
1215
|
+
const existing = latestAgentRoleByTask.get(d.taskId);
|
|
1216
|
+
if (!existing || isLaterEvent(d, existing))
|
|
1217
|
+
latestAgentRoleByTask.set(d.taskId, {
|
|
1218
|
+
ts: d.ts,
|
|
1219
|
+
eventId: d.eventId,
|
|
1220
|
+
agentRole: d.agentRole,
|
|
1221
|
+
modelTier: d.modelTier,
|
|
1222
|
+
});
|
|
1223
|
+
}
|
|
1224
|
+
// epicId -> milestoneId, from roadmap.md's milestones table.
|
|
1225
|
+
const milestoneRows = db.select().from(milestones).all();
|
|
1226
|
+
const milestoneByEpic = new Map();
|
|
1227
|
+
for (const m of milestoneRows) {
|
|
1228
|
+
for (const e of JSON.parse(m.epicIds))
|
|
1229
|
+
milestoneByEpic.set(e, m.milestoneId);
|
|
1230
|
+
}
|
|
1231
|
+
const columns = new Map();
|
|
1232
|
+
for (const t of taskRows) {
|
|
1233
|
+
const column = columns.get(t.taskStatus) ?? [];
|
|
1234
|
+
column.push({
|
|
1235
|
+
taskId: t.taskId,
|
|
1236
|
+
taskStatus: t.taskStatus,
|
|
1237
|
+
title: t.objective,
|
|
1238
|
+
agentRole: latestAgentRoleByTask.get(t.taskId)?.agentRole ?? null,
|
|
1239
|
+
agentModelTier: latestAgentRoleByTask.get(t.taskId)?.modelTier ?? null,
|
|
1240
|
+
milestoneId: t.epicId ? (milestoneByEpic.get(t.epicId) ?? null) : null,
|
|
1241
|
+
tags: {
|
|
1242
|
+
case: t.caseTag,
|
|
1243
|
+
origin: t.origin,
|
|
1244
|
+
severity: worstSeverity(openSeverityByTask.get(t.taskId) ?? []),
|
|
1245
|
+
},
|
|
1246
|
+
});
|
|
1247
|
+
columns.set(t.taskStatus, column);
|
|
1248
|
+
}
|
|
1249
|
+
return [...columns.entries()]
|
|
1250
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
1251
|
+
.map(([taskStatus, tasksInColumn]) => ({ taskStatus, tasks: tasksInColumn }));
|
|
1252
|
+
}
|
|
1253
|
+
export function taskDetail(db, taskId) {
|
|
1254
|
+
const task = db.select().from(tasks).where(eq(tasks.taskId, taskId)).get();
|
|
1255
|
+
if (!task)
|
|
1256
|
+
return null;
|
|
1257
|
+
const dispatchRows = inLogOrder(db.select().from(dispatches).where(eq(dispatches.taskId, taskId)).all());
|
|
1258
|
+
const agentRows = db.select().from(agents).where(eq(agents.taskId, taskId)).all();
|
|
1259
|
+
const agentByEventId = new Map(agentRows.map((a) => [a.id, a]));
|
|
1260
|
+
const attempts = dispatchRows.map((d) => {
|
|
1261
|
+
const agent = agentByEventId.get(d.eventId);
|
|
1262
|
+
return {
|
|
1263
|
+
eventId: d.eventId,
|
|
1264
|
+
ts: d.ts,
|
|
1265
|
+
agentRole: d.agentRole,
|
|
1266
|
+
provider: d.provider,
|
|
1267
|
+
modelTier: d.modelTier,
|
|
1268
|
+
agentStatus: agent?.status ?? null,
|
|
1269
|
+
terminalAt: agent?.terminalAt ?? null,
|
|
1270
|
+
};
|
|
1271
|
+
});
|
|
1272
|
+
const findingRows = db.select().from(findings).where(eq(findings.taskId, taskId)).all();
|
|
1273
|
+
const artifactRows = db.select().from(artifacts).where(eq(artifacts.taskId, taskId)).all();
|
|
1274
|
+
return {
|
|
1275
|
+
task,
|
|
1276
|
+
claims: task.claims ? JSON.parse(task.claims) : [],
|
|
1277
|
+
attempts,
|
|
1278
|
+
agents: agentRows,
|
|
1279
|
+
findings: findingRows,
|
|
1280
|
+
artifacts: artifactRows,
|
|
1281
|
+
branch: task.branch,
|
|
1282
|
+
};
|
|
1283
|
+
}
|
|
1284
|
+
/**
|
|
1285
|
+
* Every `lesson_status` the taxonomy declares, mapped to the bucket the
|
|
1286
|
+
* Lessons page renders it in. Total on purpose (D-220).
|
|
1287
|
+
*
|
|
1288
|
+
* Buckets used to be two filters over three of the six statuses, so
|
|
1289
|
+
* `novelty-rejected`, `superseded`, and `invalidated` rows reached no surface
|
|
1290
|
+
* at all — not even the filter labelled "All". That silently undid two
|
|
1291
|
+
* contracts: lessons.ts raises a near-duplicate and transitions it to
|
|
1292
|
+
* `novelty-rejected` precisely so it is "never silently dropped"
|
|
1293
|
+
* (architecture §9.3), and §9.6 calls an invalidated lesson a "traceable
|
|
1294
|
+
* rollback, never silent deletion". It also made the page's own Reject button
|
|
1295
|
+
* look broken — the row it wrote vanished instead of closing.
|
|
1296
|
+
*
|
|
1297
|
+
* factory/orchestrator/test/db/queries.test.ts asserts these keys equal
|
|
1298
|
+
* factory/policies/taxonomy.yml's `lesson_status`, so a seventh status cannot
|
|
1299
|
+
* be declared without landing here.
|
|
1300
|
+
*/
|
|
1301
|
+
export const LESSON_BUCKET_FOR_STATUS = {
|
|
1302
|
+
candidate: 'pending',
|
|
1303
|
+
'pending-approval': 'pending',
|
|
1304
|
+
approved: 'approved',
|
|
1305
|
+
'novelty-rejected': 'closed',
|
|
1306
|
+
superseded: 'closed',
|
|
1307
|
+
invalidated: 'closed',
|
|
1308
|
+
};
|
|
1309
|
+
export function lessonsPage(db, scope = {}) {
|
|
1310
|
+
const sessionCond = scopedToSessions(lessons.sessionId, scope);
|
|
1311
|
+
const rows = sessionCond
|
|
1312
|
+
? db.select().from(lessons).where(sessionCond).all()
|
|
1313
|
+
: db.select().from(lessons).all();
|
|
1314
|
+
const result = { pending: [], approved: [], closed: [] };
|
|
1315
|
+
for (const row of rows) {
|
|
1316
|
+
// An unrecognised status lands in `closed` rather than nowhere: being
|
|
1317
|
+
// invisible is the defect this map exists to close, and a row the
|
|
1318
|
+
// operator can see and question beats a row that does not exist.
|
|
1319
|
+
result[LESSON_BUCKET_FOR_STATUS[row.lessonStatus] ?? 'closed'].push(row);
|
|
1320
|
+
}
|
|
1321
|
+
return result;
|
|
1322
|
+
}
|
|
1323
|
+
export function errorsPage(db, scope = {}) {
|
|
1324
|
+
const sessionCond = scopedToSessions(errors.sessionId, scope);
|
|
1325
|
+
const allRows = sessionCond
|
|
1326
|
+
? db.select().from(errors).where(sessionCond).all()
|
|
1327
|
+
: db.select().from(errors).all();
|
|
1328
|
+
const rows = filterByProject(allRows, scope);
|
|
1329
|
+
const byClass = new Map();
|
|
1330
|
+
const byDay = new Map();
|
|
1331
|
+
for (const row of rows) {
|
|
1332
|
+
const key = `${row.errorGroup}.${row.errorClass}|${row.severity}`;
|
|
1333
|
+
const existing = byClass.get(key);
|
|
1334
|
+
if (existing)
|
|
1335
|
+
existing.count += 1;
|
|
1336
|
+
else {
|
|
1337
|
+
byClass.set(key, {
|
|
1338
|
+
id: key,
|
|
1339
|
+
errorGroup: row.errorGroup,
|
|
1340
|
+
errorClass: row.errorClass,
|
|
1341
|
+
severity: row.severity,
|
|
1342
|
+
count: 1,
|
|
1343
|
+
});
|
|
1344
|
+
}
|
|
1345
|
+
const day = row.ts.slice(0, 10);
|
|
1346
|
+
byDay.set(day, (byDay.get(day) ?? 0) + 1);
|
|
1347
|
+
}
|
|
1348
|
+
return {
|
|
1349
|
+
byClass: [...byClass.values()].sort((a, b) => b.count - a.count),
|
|
1350
|
+
byDay: [...byDay.entries()]
|
|
1351
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
1352
|
+
.map(([day, count]) => ({ day, count })),
|
|
1353
|
+
};
|
|
1354
|
+
}
|
|
1355
|
+
export function analytics(db, scope = {}) {
|
|
1356
|
+
const taskRows = allTasksForScope(db, scope);
|
|
1357
|
+
const inScope = taskInScope(db, scope);
|
|
1358
|
+
const throughputByDay = new Map();
|
|
1359
|
+
for (const t of taskRows) {
|
|
1360
|
+
if (t.taskStatus !== 'completed')
|
|
1361
|
+
continue;
|
|
1362
|
+
const day = t.updatedAt.slice(0, 10);
|
|
1363
|
+
throughputByDay.set(day, (throughputByDay.get(day) ?? 0) + 1);
|
|
1364
|
+
}
|
|
1365
|
+
const throughput = [...throughputByDay.entries()]
|
|
1366
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
1367
|
+
.map(([day, completed]) => ({ day, completed }));
|
|
1368
|
+
const eventSessionCond = scopedToSessions(eventsRaw.sessionId, scope);
|
|
1369
|
+
const resultRows = eventSessionCond
|
|
1370
|
+
? db
|
|
1371
|
+
.select({ taskId: eventsRaw.taskId, payload: eventsRaw.payload })
|
|
1372
|
+
.from(eventsRaw)
|
|
1373
|
+
.where(and(eq(eventsRaw.eventType, 'task-result-recorded'), eventSessionCond))
|
|
1374
|
+
.all()
|
|
1375
|
+
: db
|
|
1376
|
+
.select({ taskId: eventsRaw.taskId, payload: eventsRaw.payload })
|
|
1377
|
+
.from(eventsRaw)
|
|
1378
|
+
.where(eq(eventsRaw.eventType, 'task-result-recorded'))
|
|
1379
|
+
.all();
|
|
1380
|
+
const costBuckets = new Map();
|
|
1381
|
+
for (const row of resultRows) {
|
|
1382
|
+
const p = JSON.parse(row.payload);
|
|
1383
|
+
// Column first, payload second: both spellings occur, and one real row
|
|
1384
|
+
// omits `payload.task_id` while the column carries it.
|
|
1385
|
+
if (!inScope(row.taskId ?? p.task_id))
|
|
1386
|
+
continue;
|
|
1387
|
+
if (!p.model_tier || !p.provider)
|
|
1388
|
+
continue;
|
|
1389
|
+
const key = `${p.model_tier}|${p.provider}`;
|
|
1390
|
+
const bucket = costBuckets.get(key) ?? { taskCount: 0, totalTokens: 0 };
|
|
1391
|
+
bucket.taskCount += 1;
|
|
1392
|
+
bucket.totalTokens += p.token_usage?.total_tokens ?? 0;
|
|
1393
|
+
costBuckets.set(key, bucket);
|
|
1394
|
+
}
|
|
1395
|
+
const costByModelTierAndProvider = [...costBuckets.entries()].map(([key, v]) => {
|
|
1396
|
+
const [modelTier, provider] = key.split('|');
|
|
1397
|
+
return {
|
|
1398
|
+
modelTier,
|
|
1399
|
+
provider,
|
|
1400
|
+
taskCount: v.taskCount,
|
|
1401
|
+
totalTokens: v.totalTokens,
|
|
1402
|
+
avgTokensPerTask: v.taskCount > 0 ? v.totalTokens / v.taskCount : 0,
|
|
1403
|
+
};
|
|
1404
|
+
});
|
|
1405
|
+
const decisionRows = eventSessionCond
|
|
1406
|
+
? db
|
|
1407
|
+
.select({ ts: eventsRaw.ts, taskId: eventsRaw.taskId, payload: eventsRaw.payload })
|
|
1408
|
+
.from(eventsRaw)
|
|
1409
|
+
.where(and(eq(eventsRaw.eventType, 'severity-decisions'), eventSessionCond))
|
|
1410
|
+
.all()
|
|
1411
|
+
: db
|
|
1412
|
+
.select({ ts: eventsRaw.ts, taskId: eventsRaw.taskId, payload: eventsRaw.payload })
|
|
1413
|
+
.from(eventsRaw)
|
|
1414
|
+
.where(eq(eventsRaw.eventType, 'severity-decisions'))
|
|
1415
|
+
.all();
|
|
1416
|
+
const sameMistakeByDay = new Map();
|
|
1417
|
+
for (const row of decisionRows) {
|
|
1418
|
+
// The payload names no task at all — gate.ts puts the id in the column.
|
|
1419
|
+
if (!inScope(row.taskId))
|
|
1420
|
+
continue;
|
|
1421
|
+
const p = JSON.parse(row.payload);
|
|
1422
|
+
const day = row.ts.slice(0, 10);
|
|
1423
|
+
const bucket = sameMistakeByDay.get(day) ?? { decisions: 0, sameMistake: 0 };
|
|
1424
|
+
for (const d of p.decisions ?? []) {
|
|
1425
|
+
bucket.decisions += 1;
|
|
1426
|
+
if (d.same_mistake)
|
|
1427
|
+
bucket.sameMistake += 1;
|
|
1428
|
+
}
|
|
1429
|
+
sameMistakeByDay.set(day, bucket);
|
|
1430
|
+
}
|
|
1431
|
+
const sameMistakeRateByDay = [...sameMistakeByDay.entries()]
|
|
1432
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
1433
|
+
.map(([day, v]) => ({
|
|
1434
|
+
day,
|
|
1435
|
+
decisions: v.decisions,
|
|
1436
|
+
sameMistake: v.sameMistake,
|
|
1437
|
+
rate: v.decisions > 0 ? v.sameMistake / v.decisions : null,
|
|
1438
|
+
}));
|
|
1439
|
+
const recheckByStatus = new Map();
|
|
1440
|
+
for (const t of taskRows) {
|
|
1441
|
+
if (t.origin !== 'recheck')
|
|
1442
|
+
continue;
|
|
1443
|
+
recheckByStatus.set(t.taskStatus, (recheckByStatus.get(t.taskStatus) ?? 0) + 1);
|
|
1444
|
+
}
|
|
1445
|
+
const recheckOutcomes = [...recheckByStatus.entries()]
|
|
1446
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
1447
|
+
.map(([taskStatus, count]) => ({ taskStatus, count }));
|
|
1448
|
+
return {
|
|
1449
|
+
throughput,
|
|
1450
|
+
costByModelTierAndProvider,
|
|
1451
|
+
sameMistakeRateByDay,
|
|
1452
|
+
recheckOutcomes,
|
|
1453
|
+
// Same db, same scope: a project-scoped page gets project-scoped judge
|
|
1454
|
+
// stats, and no second round trip is needed to render the card.
|
|
1455
|
+
providerAgreement: providerAgreement(db, scope),
|
|
1456
|
+
};
|
|
1457
|
+
}
|
|
1458
|
+
/**
|
|
1459
|
+
* The active plan's task DAG (Flow page): nodes = tasks (+ live
|
|
1460
|
+
* agent, if one is currently dispatched on that task), edges = dependency
|
|
1461
|
+
* edges, waves = graph.ts's own longest-path layering (docs/standards/
|
|
1462
|
+
* stack.md: "no separate layout library" — this IS that orchestrator
|
|
1463
|
+
* utility, exposed via this query/endpoint per the same doc's instruction).
|
|
1464
|
+
*/
|
|
1465
|
+
export function flowGraph(db, filter = {}) {
|
|
1466
|
+
let taskRows = allTasksForScope(db, filter);
|
|
1467
|
+
if (filter.epicId)
|
|
1468
|
+
taskRows = taskRows.filter((t) => t.epicId === filter.epicId);
|
|
1469
|
+
// The picker's options are read off the scoped tasks BEFORE the filter
|
|
1470
|
+
// below narrows them — see FlowGraph.planVersions.
|
|
1471
|
+
const planVersions = [
|
|
1472
|
+
...new Set(taskRows.map((t) => t.planVersion).filter((v) => v !== null)),
|
|
1473
|
+
].sort((a, b) => b - a);
|
|
1474
|
+
// D-167: `tasks.plan_version` is stamped once, off the task's `task-added`,
|
|
1475
|
+
// so it records the version the task ENTERED at — not the one version it
|
|
1476
|
+
// belongs to. Every amendment carries its unfinished tasks forward
|
|
1477
|
+
// (`draftNextVersion`) and re-emits `task-added` only for the ones it adds,
|
|
1478
|
+
// so a task that entered at v1 is still in v2's plan file wearing a v1
|
|
1479
|
+
// stamp. Membership is therefore "entered at or before", and matching the
|
|
1480
|
+
// stamp exactly hid three of envkit-mcp-followup's four tasks the moment its
|
|
1481
|
+
// v2 added a fourth.
|
|
1482
|
+
//
|
|
1483
|
+
// What this cannot express is departure. `draftNextVersion` drops completed
|
|
1484
|
+
// tasks from the live backlog and a supersede that renames an id leaves the
|
|
1485
|
+
// old one dead, both recorded in the amendment's `diff` — and nothing
|
|
1486
|
+
// projects that diff, so a task that left at v3 still reads as present at
|
|
1487
|
+
// v3. That over-includes, which is the direction to err in: the plan file's
|
|
1488
|
+
// `edges` array carries every earlier version's edges forward, so a node set
|
|
1489
|
+
// narrower than "everything that ever entered" leaves arrows pointing at
|
|
1490
|
+
// tasks the page is not drawing.
|
|
1491
|
+
const belongsTo = (t, version) => t.planVersion !== null && t.planVersion <= version;
|
|
1492
|
+
if (filter.planVersion !== undefined) {
|
|
1493
|
+
const version = filter.planVersion;
|
|
1494
|
+
taskRows = taskRows.filter((t) => belongsTo(t, version));
|
|
1495
|
+
}
|
|
1496
|
+
else {
|
|
1497
|
+
// D-165: "the active plan" is a property of an epic, not of the store.
|
|
1498
|
+
// Taking one global max meant a single re-planned epic's v2 evicted every
|
|
1499
|
+
// other epic's v1 — on the factory's own log that left 1 of 15 tasks on
|
|
1500
|
+
// the page. Each epic now answers for its own latest plan.
|
|
1501
|
+
const latestByEpic = new Map();
|
|
1502
|
+
for (const t of taskRows) {
|
|
1503
|
+
if (t.planVersion === null)
|
|
1504
|
+
continue;
|
|
1505
|
+
const key = t.epicId ?? '';
|
|
1506
|
+
const max = latestByEpic.get(key);
|
|
1507
|
+
if (max === undefined || t.planVersion > max)
|
|
1508
|
+
latestByEpic.set(key, t.planVersion);
|
|
1509
|
+
}
|
|
1510
|
+
// A task whose `task-added` carried no plan_version was never assigned to
|
|
1511
|
+
// a plan, so no re-plan can have superseded it. Dropping it hid it at
|
|
1512
|
+
// every setting of the picker — the Flow page is the only view of the DAG,
|
|
1513
|
+
// so there is no other surface on which it would have been noticed.
|
|
1514
|
+
taskRows = taskRows.filter((t) => {
|
|
1515
|
+
if (t.planVersion === null)
|
|
1516
|
+
return true;
|
|
1517
|
+
const latest = latestByEpic.get(t.epicId ?? '');
|
|
1518
|
+
return latest !== undefined && belongsTo(t, latest);
|
|
1519
|
+
});
|
|
1520
|
+
}
|
|
1521
|
+
const taskIds = new Set(taskRows.map((t) => t.taskId));
|
|
1522
|
+
const edgeSessionCond = scopedToSessions(edges.sessionId, filter);
|
|
1523
|
+
const edgeRows = edgeSessionCond
|
|
1524
|
+
? db.select().from(edges).where(edgeSessionCond).all()
|
|
1525
|
+
: db.select().from(edges).all();
|
|
1526
|
+
const scopedEdges = edgeRows.filter((e) => taskIds.has(e.taskId) && taskIds.has(e.dependsOn));
|
|
1527
|
+
const liveRows = allAgentsForScope(db, filter);
|
|
1528
|
+
const liveRoleByTask = new Map();
|
|
1529
|
+
for (const a of liveRows) {
|
|
1530
|
+
if (a.taskId)
|
|
1531
|
+
liveRoleByTask.set(a.taskId, a.agentRole);
|
|
1532
|
+
}
|
|
1533
|
+
const waveMap = waveLayers([...taskIds], scopedEdges.map((e) => ({ task: e.taskId, dependsOn: e.dependsOn })));
|
|
1534
|
+
const nodes = taskRows.map((t) => ({
|
|
1535
|
+
taskId: t.taskId,
|
|
1536
|
+
taskStatus: t.taskStatus,
|
|
1537
|
+
title: t.objective,
|
|
1538
|
+
liveAgentRole: liveRoleByTask.get(t.taskId) ?? null,
|
|
1539
|
+
planVersion: t.planVersion,
|
|
1540
|
+
wave: waveMap.get(t.taskId) ?? 0,
|
|
1541
|
+
}));
|
|
1542
|
+
const waveBuckets = new Map();
|
|
1543
|
+
for (const n of nodes) {
|
|
1544
|
+
const list = waveBuckets.get(n.wave) ?? [];
|
|
1545
|
+
list.push(n.taskId);
|
|
1546
|
+
waveBuckets.set(n.wave, list);
|
|
1547
|
+
}
|
|
1548
|
+
const waves = [...waveBuckets.entries()].sort(([a], [b]) => a - b).map(([, ids]) => ids.sort());
|
|
1549
|
+
return {
|
|
1550
|
+
nodes,
|
|
1551
|
+
edges: scopedEdges.map((e) => ({
|
|
1552
|
+
task: e.taskId,
|
|
1553
|
+
dependsOn: e.dependsOn,
|
|
1554
|
+
edgeType: e.edgeType,
|
|
1555
|
+
edgeProvenance: e.edgeProvenance,
|
|
1556
|
+
})),
|
|
1557
|
+
waves,
|
|
1558
|
+
planVersions,
|
|
1559
|
+
};
|
|
1560
|
+
}
|
|
1561
|
+
/** Per-provider judge-run calibration stats, read straight off `judge-verdict` events (quorum.ts's recordJudgeRun()) — no dedicated projection table (YAGNI: eventsRaw already carries every event untouched, same pattern analytics() uses for task-result-recorded). */
|
|
1562
|
+
export function providerAgreement(db, scope = {}, opts = {}) {
|
|
1563
|
+
const conds = [eq(eventsRaw.eventType, 'judge-verdict')];
|
|
1564
|
+
const sessionCond = scopedToSessions(eventsRaw.sessionId, scope);
|
|
1565
|
+
if (sessionCond)
|
|
1566
|
+
conds.push(sessionCond);
|
|
1567
|
+
if (opts.since)
|
|
1568
|
+
conds.push(gte(eventsRaw.ts, opts.since));
|
|
1569
|
+
const rows = db
|
|
1570
|
+
.select({ payload: eventsRaw.payload, project: eventsRaw.project })
|
|
1571
|
+
.from(eventsRaw)
|
|
1572
|
+
.where(and(...conds))
|
|
1573
|
+
.all();
|
|
1574
|
+
const scoped = filterByProject(rows, scope);
|
|
1575
|
+
const byProvider = new Map();
|
|
1576
|
+
for (const row of scoped) {
|
|
1577
|
+
const p = JSON.parse(row.payload);
|
|
1578
|
+
if (!p.provider)
|
|
1579
|
+
continue;
|
|
1580
|
+
const bucket = byProvider.get(p.provider) ?? {
|
|
1581
|
+
runs: 0,
|
|
1582
|
+
verdicts: 0,
|
|
1583
|
+
agreements: 0,
|
|
1584
|
+
latencySum: 0,
|
|
1585
|
+
latencyCount: 0,
|
|
1586
|
+
schemaFailures: 0,
|
|
1587
|
+
transportFailures: 0,
|
|
1588
|
+
failuresByCode: new Map(),
|
|
1589
|
+
};
|
|
1590
|
+
bucket.runs += 1;
|
|
1591
|
+
// D-168: `schema_failure` is the one predicate for "this run never came
|
|
1592
|
+
// back with a verdict", so it decides both counters. recordJudgeRun() now
|
|
1593
|
+
// writes `agreement_with_native: null` on a failed run — there was nothing
|
|
1594
|
+
// to compare — but every row written before that carries `false`, and those
|
|
1595
|
+
// are on the log for good. Reading either as a disagreement is the reader's
|
|
1596
|
+
// mistake to avoid, which is why the branch below never looks at the field
|
|
1597
|
+
// on a run that reached no verdict.
|
|
1598
|
+
if (p.schema_failure) {
|
|
1599
|
+
// D-253: which kind of failure is the error code's answer, not this
|
|
1600
|
+
// boolean's — it is true for an answer that would not parse and equally
|
|
1601
|
+
// true for a request that was never sent. A pre-D-253 row has no code,
|
|
1602
|
+
// so it is counted as a failure and named `unclassified` rather than
|
|
1603
|
+
// assigned to a rate on a guess.
|
|
1604
|
+
const code = p.error_code ?? null;
|
|
1605
|
+
bucket.failuresByCode.set(code ?? 'unclassified', (bucket.failuresByCode.get(code ?? 'unclassified') ?? 0) + 1);
|
|
1606
|
+
if (code !== null) {
|
|
1607
|
+
if (judgeFailureKind(code) === 'schema')
|
|
1608
|
+
bucket.schemaFailures += 1;
|
|
1609
|
+
else
|
|
1610
|
+
bucket.transportFailures += 1;
|
|
1611
|
+
}
|
|
1612
|
+
}
|
|
1613
|
+
else {
|
|
1614
|
+
bucket.verdicts += 1;
|
|
1615
|
+
if (p.agreement_with_native)
|
|
1616
|
+
bucket.agreements += 1;
|
|
1617
|
+
}
|
|
1618
|
+
if (typeof p.latency_ms === 'number') {
|
|
1619
|
+
bucket.latencySum += p.latency_ms;
|
|
1620
|
+
bucket.latencyCount += 1;
|
|
1621
|
+
}
|
|
1622
|
+
byProvider.set(p.provider, bucket);
|
|
1623
|
+
}
|
|
1624
|
+
return [...byProvider.entries()]
|
|
1625
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
1626
|
+
.map(([provider, b]) => ({
|
|
1627
|
+
provider,
|
|
1628
|
+
runs: b.runs,
|
|
1629
|
+
verdicts: b.verdicts,
|
|
1630
|
+
// Each rate divides by the observations it actually has, and says `null`
|
|
1631
|
+
// when it has none. The old code divided both by `runs`, which turned
|
|
1632
|
+
// the factory's five all-failing deepseek runs into "agrees 0% of the
|
|
1633
|
+
// time, answers in 0ms" — a dissent and a speed record, both off zero
|
|
1634
|
+
// observations. The two failure rates keep `runs` as their denominator
|
|
1635
|
+
// because every run is evidence for them: a run either reached a verdict
|
|
1636
|
+
// or failed in one of two nameable ways, and all three are recorded.
|
|
1637
|
+
// They do not sum to 1 minus the answer rate when pre-D-253 rows are in
|
|
1638
|
+
// the window — those are in `failuresByCode` under `unclassified` and
|
|
1639
|
+
// nowhere else.
|
|
1640
|
+
agreementRate: b.verdicts > 0 ? b.agreements / b.verdicts : null,
|
|
1641
|
+
latencySamples: b.latencyCount,
|
|
1642
|
+
meanLatencyMs: b.latencyCount > 0 ? b.latencySum / b.latencyCount : null,
|
|
1643
|
+
schemaFailureRate: b.runs > 0 ? b.schemaFailures / b.runs : 0,
|
|
1644
|
+
transportFailureRate: b.runs > 0 ? b.transportFailures / b.runs : 0,
|
|
1645
|
+
failuresByCode: Object.fromEntries(b.failuresByCode),
|
|
1646
|
+
}));
|
|
1647
|
+
}
|
|
1648
|
+
/**
|
|
1649
|
+
* One cheap read the app shell polls from every page: "is the factory still
|
|
1650
|
+
* moving, and what has arrived since I looked?"
|
|
1651
|
+
*
|
|
1652
|
+
* It exists so liveness is a *shell* fact rather than an Overview fact. Every
|
|
1653
|
+
* page polls its own data, and on nine of them a frozen server was
|
|
1654
|
+
* indistinguishable from a quiet factory — which is precisely the confusion
|
|
1655
|
+
* ui/src/lib/liveness.ts was written to end, on the one page that had it.
|
|
1656
|
+
*
|
|
1657
|
+
* This changes nothing about the transport: design-spec.md §8 ("No
|
|
1658
|
+
* WebSockets") still holds, and this is the same polling contract asked once
|
|
1659
|
+
* for the frame instead of once more per page. It is deliberately not a push
|
|
1660
|
+
* and deliberately not a toast — an arrival the operator did not cause
|
|
1661
|
+
* belongs on a surface they can come back to, not in something that expires.
|
|
1662
|
+
*
|
|
1663
|
+
* Column-projected on purpose: `events_raw.payload` holds every event body,
|
|
1664
|
+
* and nothing here reads one, so a 5s shell poll never pulls the log's bodies
|
|
1665
|
+
* into memory just to count its rows.
|
|
1666
|
+
*/
|
|
1667
|
+
export function pulse(db, scope = {}) {
|
|
1668
|
+
const eventCols = {
|
|
1669
|
+
ts: eventsRaw.ts,
|
|
1670
|
+
eventType: eventsRaw.eventType,
|
|
1671
|
+
project: eventsRaw.project,
|
|
1672
|
+
eventId: eventsRaw.eventId,
|
|
1673
|
+
};
|
|
1674
|
+
const eventSessionCond = scopedToSessions(eventsRaw.sessionId, scope);
|
|
1675
|
+
const allEvents = eventSessionCond
|
|
1676
|
+
? db.select(eventCols).from(eventsRaw).where(eventSessionCond).all()
|
|
1677
|
+
: db.select(eventCols).from(eventsRaw).all();
|
|
1678
|
+
const events = filterByProject(allEvents, scope);
|
|
1679
|
+
const errorCols = { project: errors.project };
|
|
1680
|
+
const errorSessionCond = scopedToSessions(errors.sessionId, scope);
|
|
1681
|
+
const allErrors = errorSessionCond
|
|
1682
|
+
? db.select(errorCols).from(errors).where(errorSessionCond).all()
|
|
1683
|
+
: db.select(errorCols).from(errors).all();
|
|
1684
|
+
const lessonCols = { lessonStatus: lessons.lessonStatus };
|
|
1685
|
+
const lessonSessionCond = scopedToSessions(lessons.sessionId, scope);
|
|
1686
|
+
const lessonRows = lessonSessionCond
|
|
1687
|
+
? db.select(lessonCols).from(lessons).where(lessonSessionCond).all()
|
|
1688
|
+
: db.select(lessonCols).from(lessons).all();
|
|
1689
|
+
// Max over `ts`, not "the last row": rows land in the order the projector
|
|
1690
|
+
// folded them, and a rebuild folds one session to completion before it
|
|
1691
|
+
// starts the next — so the final row is the newest event of the last
|
|
1692
|
+
// session, which is not the newest event. Ties on `ts` are common enough to
|
|
1693
|
+
// need an answer of their own; isLaterEvent has it.
|
|
1694
|
+
let newest = null;
|
|
1695
|
+
for (const e of events) {
|
|
1696
|
+
if (newest === null || isLaterEvent(e, newest))
|
|
1697
|
+
newest = e;
|
|
1698
|
+
}
|
|
1699
|
+
return {
|
|
1700
|
+
lastEventAt: newest?.ts ?? null,
|
|
1701
|
+
lastEventType: newest?.eventType ?? null,
|
|
1702
|
+
counts: {
|
|
1703
|
+
events: events.length,
|
|
1704
|
+
errors: filterByProject(allErrors, scope).length,
|
|
1705
|
+
},
|
|
1706
|
+
// An unrecognised status is not pending — same reading as lessonsPage(),
|
|
1707
|
+
// which buckets it as `closed`.
|
|
1708
|
+
lessonsPending: lessonRows.filter((r) => LESSON_BUCKET_FOR_STATUS[r.lessonStatus] === 'pending')
|
|
1709
|
+
.length,
|
|
1710
|
+
};
|
|
1711
|
+
}
|
|
1712
|
+
// Re-exported for callers that only need the raw prompt/edge/waiver rows
|
|
1713
|
+
// (e.g. a future UI's simpler list views) without the composed pages above.
|
|
1714
|
+
export { edges, prompts, waivers };
|