@juzser/blacksmith 0.1.0 → 0.2.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/coder.md +9 -3
- package/.claude/agents/grader.md +13 -5
- package/.claude/agents/merger.md +2 -0
- package/.claude/agents/planner.md +22 -1
- package/.claude/agents/researcher.md +3 -1
- package/.claude/agents/reviewer.md +9 -2
- package/.claude/agents/security-reviewer.md +15 -7
- package/.claude/agents/spec-reviewer.md +12 -3
- package/.claude/agents/tester.md +5 -2
- package/.claude/agents/uiux.md +10 -3
- package/.claude/agents/verifier.md +8 -3
- package/.claude/skills/bs/SKILL.md +119 -0
- package/.claude/skills/bs/audit.md +252 -0
- package/.claude/skills/bs/dispatch.md +394 -0
- package/.claude/skills/bs/lessons.md +73 -0
- package/.claude/skills/bs/mcp.md +29 -0
- package/.claude/skills/bs/new.md +35 -0
- package/.claude/skills/bs/plan.md +172 -0
- package/.claude/skills/bs/report.md +129 -0
- package/.claude/skills/bs/run.md +327 -0
- package/.claude/skills/bs/status.md +54 -0
- package/.claude/skills/bs/ui.md +19 -0
- package/.claude/skills/bs/waivers.md +13 -0
- package/.claude/skills/bs/wave.md +267 -0
- package/README.md +136 -79
- package/factory/orchestrator/dist/actors.js +4 -3
- package/factory/orchestrator/dist/agents-registry.js +96 -19
- package/factory/orchestrator/dist/artifacts.js +36 -4
- package/factory/orchestrator/dist/budgets.js +7 -5
- package/factory/orchestrator/dist/claims.js +27 -1
- package/factory/orchestrator/dist/cli.js +196 -27
- package/factory/orchestrator/dist/crosscheck.js +11 -0
- package/factory/orchestrator/dist/daemon.js +30 -5
- package/factory/orchestrator/dist/db/projector.js +272 -90
- package/factory/orchestrator/dist/db/queries.js +83 -14
- package/factory/orchestrator/dist/db/schema.js +7 -0
- package/factory/orchestrator/dist/events.js +88 -4
- package/factory/orchestrator/dist/harness.js +516 -0
- package/factory/orchestrator/dist/impact.js +76 -5
- package/factory/orchestrator/dist/judges.js +49 -8
- package/factory/orchestrator/dist/lessons.js +4 -4
- package/factory/orchestrator/dist/mcp.js +4 -3
- package/factory/orchestrator/dist/paths.js +169 -15
- package/factory/orchestrator/dist/plan.js +61 -1
- package/factory/orchestrator/dist/planQuorum.js +36 -8
- package/factory/orchestrator/dist/providers/api-transport.js +62 -5
- package/factory/orchestrator/dist/providers/cli-transport.js +7 -73
- package/factory/orchestrator/dist/providers/index.js +1 -0
- package/factory/orchestrator/dist/providers/schema-validate.js +58 -7
- package/factory/orchestrator/dist/providers/types.js +5 -0
- package/factory/orchestrator/dist/roadmap.js +3 -3
- package/factory/orchestrator/dist/run-cli.js +130 -0
- package/factory/orchestrator/dist/runner.js +264 -0
- package/factory/orchestrator/dist/scaffold.js +3 -2
- package/factory/orchestrator/dist/spawn.js +84 -0
- package/factory/orchestrator/dist/stack.js +10 -2
- package/factory/orchestrator/dist/templateFragments.js +178 -0
- package/factory/orchestrator/dist/usage.js +30 -9
- package/factory/orchestrator/dist/waveNext.js +42 -14
- package/factory/orchestrator/dist/waveSchedule.js +7 -1
- package/factory/orchestrator/dist/workroot.js +113 -0
- package/factory/orchestrator/drizzle/0011_findings_spec_ref.sql +3 -0
- package/factory/orchestrator/drizzle/meta/_journal.json +7 -0
- package/factory/policies/budgets.yml +7 -4
- package/factory/policies/crosscheck.yml +10 -8
- package/factory/policies/harness.yml +82 -0
- package/factory/policies/lessons.md +122 -0
- package/factory/policies/severity.yml +1 -1
- package/factory/policies/taxonomy.yml +6 -2
- package/factory/specs/roadmap.md +154 -0
- package/factory/specs/schema/task-spec.schema.json +5 -0
- package/package.json +10 -7
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// is the normal case; a session filter is for debugging one run).
|
|
7
7
|
import { and, eq, gte, inArray, lte } from 'drizzle-orm';
|
|
8
8
|
import { isOperatorActor } from '../actors.js';
|
|
9
|
-
import { foldAgents, liveAgents as foldLiveAgents, REGISTRY_EVENT_TYPES, } from '../agents-registry.js';
|
|
9
|
+
import { foldAgents, liveAgents as foldLiveAgents, isWorkingAt, REGISTRY_EVENT_TYPES, } from '../agents-registry.js';
|
|
10
10
|
import { compareLogOrder, isLaterEvent, parseEventId, ROOT_EVENT_TYPE } from '../events.js';
|
|
11
11
|
import { waveLayers } from '../graph.js';
|
|
12
12
|
import { judgeFailureKind } from '../providers/types.js';
|
|
@@ -514,14 +514,19 @@ function allAgentsForScope(db, scope) {
|
|
|
514
514
|
* Ties on `lastEventAt` break on `sessionId` so the order is stable under the
|
|
515
515
|
* dashboard's 5s poll rather than shuffling between renders.
|
|
516
516
|
*/
|
|
517
|
-
export function runningSessions(db, scope = {}) {
|
|
517
|
+
export function runningSessions(db, scope = {}, opts = {}) {
|
|
518
|
+
const nowIso = opts.nowIso ?? new Date().toISOString();
|
|
518
519
|
const sessionCond = scopedToSessions(sessions.sessionId, scope);
|
|
519
520
|
const rows = sessionCond
|
|
520
521
|
? db.select().from(sessions).where(sessionCond).all()
|
|
521
522
|
: db.select().from(sessions).all();
|
|
522
523
|
const liveBySession = new Map();
|
|
524
|
+
const workingBySession = new Map();
|
|
523
525
|
for (const a of allAgentsForScope(db, scope)) {
|
|
524
526
|
liveBySession.set(a.sessionId, (liveBySession.get(a.sessionId) ?? 0) + 1);
|
|
527
|
+
if (isWorkingAt(a.dispatchedAt, nowIso)) {
|
|
528
|
+
workingBySession.set(a.sessionId, (workingBySession.get(a.sessionId) ?? 0) + 1);
|
|
529
|
+
}
|
|
525
530
|
}
|
|
526
531
|
const projectsBySession = new Map();
|
|
527
532
|
for (const t of allTasksForScope(db, scope)) {
|
|
@@ -555,6 +560,7 @@ export function runningSessions(db, scope = {}) {
|
|
|
555
560
|
lastEventAt: s.lastEventAt,
|
|
556
561
|
eventCount: s.eventCount,
|
|
557
562
|
liveAgentCount: liveBySession.get(s.sessionId) ?? 0,
|
|
563
|
+
workingAgentCount: workingBySession.get(s.sessionId) ?? 0,
|
|
558
564
|
lastEventType: lastEvent.get(s.sessionId)?.eventType ?? null,
|
|
559
565
|
projects: [...(projectsBySession.get(s.sessionId) ?? [])].sort(),
|
|
560
566
|
}))
|
|
@@ -621,8 +627,14 @@ function inFlightEpics(taskRows, closed) {
|
|
|
621
627
|
.map((t) => t.epicId)),
|
|
622
628
|
].sort();
|
|
623
629
|
}
|
|
624
|
-
/**
|
|
625
|
-
|
|
630
|
+
/**
|
|
631
|
+
* One project's overview slice, computed by the same logic overview() itself
|
|
632
|
+
* uses (no drift). `nowIso` is the caller's single instant, not a fresh
|
|
633
|
+
* read: overview() hands every per-project summary the same "now" it used
|
|
634
|
+
* for its own counts, so the project rows and the global card can never
|
|
635
|
+
* disagree about which agents are working.
|
|
636
|
+
*/
|
|
637
|
+
function projectSummary(db, project, baseScope, nowIso) {
|
|
626
638
|
const scope = { ...baseScope, project };
|
|
627
639
|
const liveRows = allAgentsForScope(db, scope);
|
|
628
640
|
const taskRows = allTasksForScope(db, scope);
|
|
@@ -637,6 +649,7 @@ function projectSummary(db, project, baseScope) {
|
|
|
637
649
|
return {
|
|
638
650
|
project,
|
|
639
651
|
liveAgentCount: liveRows.length,
|
|
652
|
+
workingAgentCount: liveRows.filter((a) => isWorkingAt(a.dispatchedAt, nowIso)).length,
|
|
640
653
|
epicsInFlight,
|
|
641
654
|
tokensSpent,
|
|
642
655
|
tokensBudget,
|
|
@@ -664,7 +677,7 @@ const SNAPSHOT_EVENT_TYPES = REGISTRY_EVENT_TYPES;
|
|
|
664
677
|
* project, so its agent stayed in the live count and vanished from the
|
|
665
678
|
* historical one, and the StatCard reported an arrival that never happened.
|
|
666
679
|
*/
|
|
667
|
-
function
|
|
680
|
+
function liveAgentsAt(db, scope, cutoffIso) {
|
|
668
681
|
const conds = [lte(eventsRaw.ts, cutoffIso), inArray(eventsRaw.eventType, SNAPSHOT_EVENT_TYPES)];
|
|
669
682
|
const sessionCond = scopedToSessions(eventsRaw.sessionId, scope);
|
|
670
683
|
if (sessionCond)
|
|
@@ -690,9 +703,25 @@ function liveAgentCountAt(db, scope, cutoffIso) {
|
|
|
690
703
|
}));
|
|
691
704
|
const live = foldLiveAgents(foldAgents(storedEvents));
|
|
692
705
|
if (scope.project === undefined)
|
|
693
|
-
return live
|
|
706
|
+
return live;
|
|
694
707
|
const scopedTaskIds = new Set(allTasksForScope(db, scope).map((t) => t.taskId));
|
|
695
|
-
return live.filter((a) => a.taskId !== null && scopedTaskIds.has(a.taskId))
|
|
708
|
+
return live.filter((a) => a.taskId !== null && scopedTaskIds.has(a.taskId));
|
|
709
|
+
}
|
|
710
|
+
function liveAgentCountAt(db, scope, cutoffIso) {
|
|
711
|
+
return liveAgentsAt(db, scope, cutoffIso).length;
|
|
712
|
+
}
|
|
713
|
+
/**
|
|
714
|
+
* The "working" end of workingAgentCountDelta5m's subtraction: the agents
|
|
715
|
+
* live as of `cutoffIso` (the very re-fold liveAgentCountAt does, project
|
|
716
|
+
* scoping included) that were ALSO within DEFAULT_STALE_HOURS of dispatch
|
|
717
|
+
* *at that cutoff*. Judging the historical rows against today's clock
|
|
718
|
+
* instead would be D-170 again in a new disguise — the "now" end would
|
|
719
|
+
* apply a 4h window and the "ago" end a 4h05m one, and an agent that
|
|
720
|
+
* stalled since the cutoff would read as no change.
|
|
721
|
+
*/
|
|
722
|
+
function workingAgentCountAt(db, scope, cutoffIso) {
|
|
723
|
+
return liveAgentsAt(db, scope, cutoffIso).filter((a) => isWorkingAt(a.dispatchedAt, cutoffIso))
|
|
724
|
+
.length;
|
|
696
725
|
}
|
|
697
726
|
/**
|
|
698
727
|
* Total tokens spent (task-result-recorded token_usage) across `epicByTask`'s tasks, as of `cutoffIso`.
|
|
@@ -781,7 +810,14 @@ function tokensBudgetedAt(db, scope, cutoffIso, epicByTask) {
|
|
|
781
810
|
return total;
|
|
782
811
|
}
|
|
783
812
|
export function overview(db, scope = {}, opts = {}) {
|
|
813
|
+
// One instant for everything this call reports. runningSessions(), the
|
|
814
|
+
// per-project summaries and the deltas each take the clock as an argument
|
|
815
|
+
// rather than reading it, so a request that straddles a threshold cannot
|
|
816
|
+
// count an agent as working in one field and stalled in the next.
|
|
817
|
+
const now = opts.nowIso ? new Date(opts.nowIso) : new Date();
|
|
818
|
+
const nowIso = now.toISOString();
|
|
784
819
|
const liveRows = allAgentsForScope(db, scope);
|
|
820
|
+
const workingRows = liveRows.filter((a) => isWorkingAt(a.dispatchedAt, nowIso));
|
|
785
821
|
const taskRows = allTasksForScope(db, scope);
|
|
786
822
|
const closedEpics = closedEpicsForScope(db, scope);
|
|
787
823
|
const epicsInFlight = inFlightEpics(taskRows, closedEpics);
|
|
@@ -835,12 +871,12 @@ export function overview(db, scope = {}, opts = {}) {
|
|
|
835
871
|
// Milestones carry no session_id and milestoneProgressRows() never
|
|
836
872
|
// filters them by one, so they join the list under a session scope too.
|
|
837
873
|
const declaredRows = db.select({ project: milestones.project }).from(milestones).all();
|
|
838
|
-
projects = distinctProjects(allTaskRowsUnfiltered, declaredRows).map((p) => projectSummary(db, p, scope));
|
|
874
|
+
projects = distinctProjects(allTaskRowsUnfiltered, declaredRows).map((p) => projectSummary(db, p, scope, nowIso));
|
|
839
875
|
}
|
|
840
|
-
const now = opts.nowIso ? new Date(opts.nowIso) : new Date();
|
|
841
876
|
const fiveMinAgo = new Date(now.getTime() - 5 * 60 * 1000).toISOString();
|
|
842
877
|
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000).toISOString();
|
|
843
878
|
const liveAgentCountDelta5m = liveRows.length - liveAgentCountAt(db, scope, fiveMinAgo);
|
|
879
|
+
const workingAgentCountDelta5m = workingRows.length - workingAgentCountAt(db, scope, fiveMinAgo);
|
|
844
880
|
const epicByTask = new Map(taskRows.filter((t) => t.epicId !== null).map((t) => [t.taskId, t.epicId]));
|
|
845
881
|
const totalBudgetNow = [...budgetByEpic.values()].reduce((s, v) => s + v, 0);
|
|
846
882
|
const totalSpentNow = [...spentByEpic.values()].reduce((s, v) => s + v, 0);
|
|
@@ -873,7 +909,9 @@ export function overview(db, scope = {}, opts = {}) {
|
|
|
873
909
|
dispatchedAt: a.dispatchedAt,
|
|
874
910
|
})),
|
|
875
911
|
liveAgentCount: liveRows.length,
|
|
876
|
-
|
|
912
|
+
workingAgentCount: workingRows.length,
|
|
913
|
+
stalledAgentCount: liveRows.length - workingRows.length,
|
|
914
|
+
runningSessions: runningSessions(db, scope, { nowIso }),
|
|
877
915
|
epicsInFlight,
|
|
878
916
|
closedEpics,
|
|
879
917
|
tokensByEpic,
|
|
@@ -881,6 +919,7 @@ export function overview(db, scope = {}, opts = {}) {
|
|
|
881
919
|
milestoneProgress: milestoneProgressRows(db, scope),
|
|
882
920
|
recentDispatches,
|
|
883
921
|
liveAgentCountDelta5m,
|
|
922
|
+
workingAgentCountDelta5m,
|
|
884
923
|
budgetUsedPctPointDelta1h,
|
|
885
924
|
...(projects ? { projects } : {}),
|
|
886
925
|
};
|
|
@@ -959,6 +998,12 @@ export const FREE_TIMELINE_EVENT_TYPES = [
|
|
|
959
998
|
'audit-cut',
|
|
960
999
|
'audit-resolved',
|
|
961
1000
|
'audit-closed',
|
|
1001
|
+
// The integration PR run.md step 17 opens, appended by hand through `smith
|
|
1002
|
+
// event append` under `<epic>/integration`. It is the epic's terminal
|
|
1003
|
+
// deliverable -- the one thing the operator is asked to merge -- and the
|
|
1004
|
+
// only event written after `epic-closed`, so without it the timeline of a
|
|
1005
|
+
// closed epic ends at the closing and never shows what the closing was for.
|
|
1006
|
+
'integration-pr-opened',
|
|
962
1007
|
];
|
|
963
1008
|
let cachedTaxonomy;
|
|
964
1009
|
/**
|
|
@@ -1174,7 +1219,8 @@ function worstSeverity(severities) {
|
|
|
1174
1219
|
* task_status, tagged with case/origin/worst-open-finding severity plus
|
|
1175
1220
|
* (Phase 6b) title/agent-role/milestone.
|
|
1176
1221
|
*/
|
|
1177
|
-
export function kanban(db, epicId, scope = {}) {
|
|
1222
|
+
export function kanban(db, epicId, scope = {}, opts = {}) {
|
|
1223
|
+
const nowIso = opts.nowIso ?? new Date().toISOString();
|
|
1178
1224
|
const epicCond = epicId !== undefined ? eq(tasks.epicId, epicId) : undefined;
|
|
1179
1225
|
const sessionCond = scopedToSessions(tasks.sessionId, scope);
|
|
1180
1226
|
const taskConds = [epicCond, sessionCond].filter((c) => c !== undefined);
|
|
@@ -1221,6 +1267,18 @@ export function kanban(db, epicId, scope = {}) {
|
|
|
1221
1267
|
modelTier: d.modelTier,
|
|
1222
1268
|
});
|
|
1223
1269
|
}
|
|
1270
|
+
// Who is still on each task, from the agents rows (live only — see
|
|
1271
|
+
// allAgentsForScope). A working row wins over a stalled one on the same
|
|
1272
|
+
// task for the reason flowGraph gives: the chip says who is on the task.
|
|
1273
|
+
const activityByTask = new Map();
|
|
1274
|
+
for (const a of allAgentsForScope(db, scope)) {
|
|
1275
|
+
if (!a.taskId)
|
|
1276
|
+
continue;
|
|
1277
|
+
if (isWorkingAt(a.dispatchedAt, nowIso))
|
|
1278
|
+
activityByTask.set(a.taskId, 'working');
|
|
1279
|
+
else if (!activityByTask.has(a.taskId))
|
|
1280
|
+
activityByTask.set(a.taskId, 'stalled');
|
|
1281
|
+
}
|
|
1224
1282
|
// epicId -> milestoneId, from roadmap.md's milestones table.
|
|
1225
1283
|
const milestoneRows = db.select().from(milestones).all();
|
|
1226
1284
|
const milestoneByEpic = new Map();
|
|
@@ -1237,6 +1295,7 @@ export function kanban(db, epicId, scope = {}) {
|
|
|
1237
1295
|
title: t.objective,
|
|
1238
1296
|
agentRole: latestAgentRoleByTask.get(t.taskId)?.agentRole ?? null,
|
|
1239
1297
|
agentModelTier: latestAgentRoleByTask.get(t.taskId)?.modelTier ?? null,
|
|
1298
|
+
agentActivity: activityByTask.get(t.taskId) ?? null,
|
|
1240
1299
|
milestoneId: t.epicId ? (milestoneByEpic.get(t.epicId) ?? null) : null,
|
|
1241
1300
|
tags: {
|
|
1242
1301
|
case: t.caseTag,
|
|
@@ -1462,7 +1521,8 @@ export function analytics(db, scope = {}) {
|
|
|
1462
1521
|
* stack.md: "no separate layout library" — this IS that orchestrator
|
|
1463
1522
|
* utility, exposed via this query/endpoint per the same doc's instruction).
|
|
1464
1523
|
*/
|
|
1465
|
-
export function flowGraph(db, filter = {}) {
|
|
1524
|
+
export function flowGraph(db, filter = {}, opts = {}) {
|
|
1525
|
+
const nowIso = opts.nowIso ?? new Date().toISOString();
|
|
1466
1526
|
let taskRows = allTasksForScope(db, filter);
|
|
1467
1527
|
if (filter.epicId)
|
|
1468
1528
|
taskRows = taskRows.filter((t) => t.epicId === filter.epicId);
|
|
@@ -1526,9 +1586,17 @@ export function flowGraph(db, filter = {}) {
|
|
|
1526
1586
|
const scopedEdges = edgeRows.filter((e) => taskIds.has(e.taskId) && taskIds.has(e.dependsOn));
|
|
1527
1587
|
const liveRows = allAgentsForScope(db, filter);
|
|
1528
1588
|
const liveRoleByTask = new Map();
|
|
1589
|
+
// The same last-write-wins walk, over the working rows only. Where a task
|
|
1590
|
+
// carries both a stalled live row and a working one, the working role is
|
|
1591
|
+
// the one to name: the marker says who is on the task, not who was
|
|
1592
|
+
// dispatched to it most recently.
|
|
1593
|
+
const workingRoleByTask = new Map();
|
|
1529
1594
|
for (const a of liveRows) {
|
|
1530
|
-
if (a.taskId)
|
|
1531
|
-
|
|
1595
|
+
if (!a.taskId)
|
|
1596
|
+
continue;
|
|
1597
|
+
liveRoleByTask.set(a.taskId, a.agentRole);
|
|
1598
|
+
if (isWorkingAt(a.dispatchedAt, nowIso))
|
|
1599
|
+
workingRoleByTask.set(a.taskId, a.agentRole);
|
|
1532
1600
|
}
|
|
1533
1601
|
const waveMap = waveLayers([...taskIds], scopedEdges.map((e) => ({ task: e.taskId, dependsOn: e.dependsOn })));
|
|
1534
1602
|
const nodes = taskRows.map((t) => ({
|
|
@@ -1536,6 +1604,7 @@ export function flowGraph(db, filter = {}) {
|
|
|
1536
1604
|
taskStatus: t.taskStatus,
|
|
1537
1605
|
title: t.objective,
|
|
1538
1606
|
liveAgentRole: liveRoleByTask.get(t.taskId) ?? null,
|
|
1607
|
+
workingAgentRole: workingRoleByTask.get(t.taskId) ?? null,
|
|
1539
1608
|
planVersion: t.planVersion,
|
|
1540
1609
|
wave: waveMap.get(t.taskId) ?? 0,
|
|
1541
1610
|
}));
|
|
@@ -189,6 +189,13 @@ export const findings = sqliteTable('findings', {
|
|
|
189
189
|
raisedAt: text('raised_at').notNull(),
|
|
190
190
|
updatedAt: text('updated_at').notNull(),
|
|
191
191
|
project: text('project'), // Phase 6b — see eventsRaw's project comment above.
|
|
192
|
+
// What a spec finding is about (findings.ts SpecRef). The scope is filled
|
|
193
|
+
// in from findingScope(), so an absent field lands as 'diff' rather than
|
|
194
|
+
// as a null the reader has to know the default for; the two spec_ref
|
|
195
|
+
// columns are null on every diff finding because there is no criterion.
|
|
196
|
+
findingScope: text('finding_scope').notNull().default('diff'),
|
|
197
|
+
specPlanVersion: integer('spec_plan_version'),
|
|
198
|
+
criterionRef: text('criterion_ref'),
|
|
192
199
|
}, (t) => [
|
|
193
200
|
index('findings_session_idx').on(t.sessionId),
|
|
194
201
|
index('findings_task_idx').on(t.taskId),
|
|
@@ -203,10 +203,20 @@ async function readEventsAtPath(filePath, sessionId) {
|
|
|
203
203
|
.trimEnd()
|
|
204
204
|
.split('\n')
|
|
205
205
|
.filter((line) => line.length > 0);
|
|
206
|
-
return lines.map((line, index) =>
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
206
|
+
return lines.map((line, index) => {
|
|
207
|
+
let record;
|
|
208
|
+
try {
|
|
209
|
+
record = JSON.parse(line);
|
|
210
|
+
}
|
|
211
|
+
catch (err) {
|
|
212
|
+
// A bare SyntaxError says "Unexpected token" and nothing else: not which
|
|
213
|
+
// log, not which line. The dashboard prints this for every session it
|
|
214
|
+
// then fails to fold (the global folds read every log), so the message
|
|
215
|
+
// has to carry the path the operator needs to open.
|
|
216
|
+
throw new EventError('events.unreadable-session-log', `Line ${index + 1} of ${filePath} is not JSON, so session "${sessionId}" cannot be read: ${errorText(err)}.`, { session_id: sessionId, path: filePath, line: index + 1 });
|
|
217
|
+
}
|
|
218
|
+
return { event_id: `${sessionId}#${index}`, record };
|
|
219
|
+
});
|
|
210
220
|
}
|
|
211
221
|
/**
|
|
212
222
|
* Per-log serialization *inside one process*: concurrent appendEvent calls on
|
|
@@ -504,7 +514,31 @@ function validatePayloadDimensions(taxonomy, record) {
|
|
|
504
514
|
const TYPED_PAYLOAD_SCHEMAS = {
|
|
505
515
|
'finding-raised': 'finding',
|
|
506
516
|
};
|
|
517
|
+
/**
|
|
518
|
+
* The one field of a `task-result-recorded` payload a reader iterates. The
|
|
519
|
+
* projector writes `artifacts[]` into rows inside the session's transaction,
|
|
520
|
+
* so an object here (csb-audit-1#100 carried `{ claude_half, external_half,
|
|
521
|
+
* repair_brief }`) rolled back the whole session and the dashboard drew
|
|
522
|
+
* nothing for it. Pinning only this field is deliberate: the full result
|
|
523
|
+
* schema is not enforced on the event, because real logs carry many
|
|
524
|
+
* hand-written result payloads that no reader rehydrates -- refusing them
|
|
525
|
+
* would refuse history for a shape nothing reads.
|
|
526
|
+
*/
|
|
527
|
+
function validateResultArtifactsShape(record) {
|
|
528
|
+
if (record.event_type !== 'task-result-recorded')
|
|
529
|
+
return;
|
|
530
|
+
const payload = record.payload;
|
|
531
|
+
const artifacts = payload?.artifacts;
|
|
532
|
+
if (artifacts === undefined || Array.isArray(artifacts))
|
|
533
|
+
return;
|
|
534
|
+
throw new EventError('events.invalid-typed-payload', 'Event payload for "task-result-recorded" carries an `artifacts` that is not an array. The projector iterates this list to write artifact rows, so any other shape becomes a crash that rolls back the whole session rather than an error here.', {
|
|
535
|
+
event_type: record.event_type,
|
|
536
|
+
schema: 'result',
|
|
537
|
+
errors: [{ path: '/artifacts', message: `must be an array, got ${typeof artifacts}` }],
|
|
538
|
+
});
|
|
539
|
+
}
|
|
507
540
|
function validateTypedPayload(schemas, taxonomy, record) {
|
|
541
|
+
validateResultArtifactsShape(record);
|
|
508
542
|
const schemaName = TYPED_PAYLOAD_SCHEMAS[record.event_type];
|
|
509
543
|
if (schemaName === undefined)
|
|
510
544
|
return;
|
|
@@ -1013,3 +1047,53 @@ export function filterEvents(events, filter) {
|
|
|
1013
1047
|
return true;
|
|
1014
1048
|
});
|
|
1015
1049
|
}
|
|
1050
|
+
/**
|
|
1051
|
+
* How often `--follow` re-reads a log it is following.
|
|
1052
|
+
*
|
|
1053
|
+
* A second is well under the interval an operator perceives as lag, and a
|
|
1054
|
+
* re-read costs what one `smith event tail` costs -- the poll is not what
|
|
1055
|
+
* makes a busy factory expensive. There is no flag for it on purpose: a
|
|
1056
|
+
* knob no answer depends on is a knob every reader of `--help` pays for.
|
|
1057
|
+
*/
|
|
1058
|
+
export const FOLLOW_TICK_MS = 1000;
|
|
1059
|
+
/**
|
|
1060
|
+
* The events in `events` that `seen` does not already hold, in `events`'
|
|
1061
|
+
* order.
|
|
1062
|
+
*
|
|
1063
|
+
* The cursor is a set of ids rather than a count because `--lineage` merges
|
|
1064
|
+
* several logs by `ts` (mergeSessionLogs above): an event appended now can
|
|
1065
|
+
* sort BEHIND one already printed, and a stream cannot un-print. A length
|
|
1066
|
+
* cursor would either re-emit the tail or skip the new event outright, and
|
|
1067
|
+
* both are worse than holding the ids -- which cost what the read they came
|
|
1068
|
+
* from already cost.
|
|
1069
|
+
*/
|
|
1070
|
+
export function unseenEvents(events, seen) {
|
|
1071
|
+
return events.filter((event) => !seen.has(event.event_id));
|
|
1072
|
+
}
|
|
1073
|
+
/**
|
|
1074
|
+
* Read, emit what is new, sleep, repeat -- until `shouldContinue` says stop.
|
|
1075
|
+
*
|
|
1076
|
+
* The seams are runDaemon's, for the same reason: the contract of a loop is
|
|
1077
|
+
* what it does AROUND the read, and a loop that owns its own clock can only
|
|
1078
|
+
* be tested by waiting. `sleep` is where an interrupt lands, so the caller
|
|
1079
|
+
* that wires SIGINT resolves the sleep rather than waiting out the interval.
|
|
1080
|
+
*
|
|
1081
|
+
* Returns how many events it emitted, which is the only thing about a stream
|
|
1082
|
+
* worth having after it ends.
|
|
1083
|
+
*/
|
|
1084
|
+
export async function followEvents(opts) {
|
|
1085
|
+
const intervalMs = opts.intervalMs ?? FOLLOW_TICK_MS;
|
|
1086
|
+
const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
1087
|
+
const shouldContinue = opts.shouldContinue ?? (() => true);
|
|
1088
|
+
const seen = new Set(opts.seen ?? []);
|
|
1089
|
+
let emitted = 0;
|
|
1090
|
+
while (shouldContinue()) {
|
|
1091
|
+
for (const event of unseenEvents(await opts.read(), seen)) {
|
|
1092
|
+
seen.add(event.event_id);
|
|
1093
|
+
opts.emit(event);
|
|
1094
|
+
emitted += 1;
|
|
1095
|
+
}
|
|
1096
|
+
await sleep(intervalMs);
|
|
1097
|
+
}
|
|
1098
|
+
return emitted;
|
|
1099
|
+
}
|