@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,1015 @@
|
|
|
1
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { appendFile, mkdir, open, readFile, rm, stat } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { SmithError } from './errors.js';
|
|
5
|
+
import { STATE_EVENTS_DIR } from './paths.js';
|
|
6
|
+
import { compileSchemas, validateRecord } from './schemas.js';
|
|
7
|
+
import { taskIdsMatch } from './taskId.js';
|
|
8
|
+
import { loadTaxonomy, validateRequiredDimensions, validateTag, } from './taxonomy.js';
|
|
9
|
+
export class EventError extends SmithError {
|
|
10
|
+
}
|
|
11
|
+
// Root events (session_id's first event) are the only ones allowed a null
|
|
12
|
+
// causal_parent (architecture §7).
|
|
13
|
+
/**
|
|
14
|
+
* The one event type a log may open with, and the only one allowed to name a
|
|
15
|
+
* `causal_parent` in another session's log. Exported since D-263 because
|
|
16
|
+
* db/queries.ts walks the same edge off the projection and a second copy of
|
|
17
|
+
* the literal is a second thing to keep in step.
|
|
18
|
+
*/
|
|
19
|
+
export const ROOT_EVENT_TYPE = 'session-start';
|
|
20
|
+
/**
|
|
21
|
+
* Split an event id into the session that owns it and its line index.
|
|
22
|
+
*
|
|
23
|
+
* Event ids have always been `<session-id>#<index>` — that shape is what makes
|
|
24
|
+
* a cross-session reference resolvable at all (P9-7): given only the id, a
|
|
25
|
+
* reader knows which log file to open. Splitting on the LAST `#` keeps a
|
|
26
|
+
* session id that happens to contain one from silently resolving to the wrong
|
|
27
|
+
* session.
|
|
28
|
+
*/
|
|
29
|
+
export function parseEventId(eventId) {
|
|
30
|
+
// Typed as a string and reached with whatever the writer sent. `smith event
|
|
31
|
+
// append` JSON.parses its argument and hands it straight over, and
|
|
32
|
+
// readEvents() takes a `causal_parent` off a log line that nothing retypes
|
|
33
|
+
// on the way in. A number is the likeliest of those to be written on
|
|
34
|
+
// purpose -- an event id is `<session>#<index>` and the index is the half
|
|
35
|
+
// that is memorable -- and it used to reach `.lastIndexOf` and come back as
|
|
36
|
+
// a bare TypeError with no `code`, the D-135 shape.
|
|
37
|
+
if (typeof eventId !== 'string') {
|
|
38
|
+
throw new EventError('events.malformed-event-id', `${JSON.stringify(eventId) ?? String(eventId)} (${typeof eventId}) is not an event id. The form is <session-id>#<index>, e.g. dogfood-envkit-1#42.`, { event_id: eventId });
|
|
39
|
+
}
|
|
40
|
+
const cut = eventId.lastIndexOf('#');
|
|
41
|
+
const sessionId = cut === -1 ? '' : eventId.slice(0, cut);
|
|
42
|
+
const rawIndex = eventId.slice(cut + 1);
|
|
43
|
+
// Number.parseInt would accept "3abc"; an event id is exact or it is a typo.
|
|
44
|
+
const index = /^\d+$/.test(rawIndex) ? Number(rawIndex) : Number.NaN;
|
|
45
|
+
if (cut === -1 || sessionId.length === 0 || Number.isNaN(index)) {
|
|
46
|
+
throw new EventError('events.malformed-event-id', `"${eventId}" is not an event id. The form is <session-id>#<index>, e.g. dogfood-envkit-1#42.`, { event_id: eventId });
|
|
47
|
+
}
|
|
48
|
+
return { sessionId, index };
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Where an event sits in the order the log actually wrote it.
|
|
52
|
+
*
|
|
53
|
+
* An event id is `<session-id>#<index>` and the index is the event's line in
|
|
54
|
+
* its session's log, so within a session this *is* the order rather than a
|
|
55
|
+
* proxy for it. Across sessions the logs are separate files that nothing
|
|
56
|
+
* interleaves, so the session id is here to make the answer the same on every
|
|
57
|
+
* call, not because one session precedes another.
|
|
58
|
+
*
|
|
59
|
+
* Total where parseEventId throws, and the fallback is unreachable by
|
|
60
|
+
* construction — every id this sees came out of readEvents() or a projection
|
|
61
|
+
* of it, and readEvents builds each one as `<session>#<index>`. It is here
|
|
62
|
+
* because the readers on top of this are a dashboard `/api/pulse` polls every
|
|
63
|
+
* 5s and two audits the operator runs on a whole log, and an id that somehow
|
|
64
|
+
* would not parse should sort somewhere rather than take the caller down with
|
|
65
|
+
* it. Where it lands is deliberately modest: an event whose place in the log
|
|
66
|
+
* is unreadable does not get to win a tie on it.
|
|
67
|
+
*/
|
|
68
|
+
function logOrderOf(eventId) {
|
|
69
|
+
try {
|
|
70
|
+
return parseEventId(eventId);
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return { sessionId: eventId, index: -1 };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* The order the log wrote two events in: negative when `a` came first,
|
|
78
|
+
* positive when `b` did, zero only for the same event.
|
|
79
|
+
*
|
|
80
|
+
* `ts` is stamped at millisecond resolution (appendEvent, just below), so a
|
|
81
|
+
* burst of appends routinely shares one: the test fixture's own last two
|
|
82
|
+
* events tie in roughly one build in three, and a gate outcome and the retry
|
|
83
|
+
* dispatched in answer to it are written back to back. Nothing else in the
|
|
84
|
+
* record carries the sequence — `events_raw` has no such column and the JSONL
|
|
85
|
+
* line has no such field — so on a tie `ts` has nothing left to say, and
|
|
86
|
+
* nothing downstream of it does either. A `>` between two tied rows is not a
|
|
87
|
+
* decision, it is whichever the scan reached first; `ORDER BY ts` is the same
|
|
88
|
+
* non-answer spelled in SQL, since SQLite promises nothing about tied rows and
|
|
89
|
+
* hands them back in physical order, which changes the moment a row is
|
|
90
|
+
* rewritten; and a JS `.sort()` whose comparator returns 0 throughout is
|
|
91
|
+
* stable, so it keeps that same scan order and passes it off as chronology.
|
|
92
|
+
*
|
|
93
|
+
* The log index behind the event id is what actually decides, and it has to be
|
|
94
|
+
* read as a number: ordered as text — which is what `ORDER BY ts, event_id`
|
|
95
|
+
* does — `#9` sorts after `#10`, so the tiebreaker inverts as soon as a
|
|
96
|
+
* session's log passes ten events.
|
|
97
|
+
*
|
|
98
|
+
* It lives beside parseEventId rather than in any one reader because more than
|
|
99
|
+
* one of them asks this question: the dashboard queries fold and sort rows,
|
|
100
|
+
* escalation.ts walks a task's rounds looking for the dispatch on either side
|
|
101
|
+
* of one. Every ordering a reader would call chronological routes through
|
|
102
|
+
* here, so that no two of them can answer the same question about the same two
|
|
103
|
+
* events differently — which is exactly what happened when the callers spelled
|
|
104
|
+
* the comparison themselves, with opposite operators.
|
|
105
|
+
*/
|
|
106
|
+
export function compareLogOrder(a, b) {
|
|
107
|
+
if (a.ts !== b.ts)
|
|
108
|
+
return a.ts < b.ts ? -1 : 1;
|
|
109
|
+
const left = logOrderOf(a.eventId);
|
|
110
|
+
const right = logOrderOf(b.eventId);
|
|
111
|
+
if (left.sessionId !== right.sessionId)
|
|
112
|
+
return left.sessionId < right.sessionId ? -1 : 1;
|
|
113
|
+
return left.index - right.index;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Whether `a` is the later of two events — the one a reader means by "what
|
|
117
|
+
* just happened".
|
|
118
|
+
*/
|
|
119
|
+
export function isLaterEvent(a, b) {
|
|
120
|
+
return compareLogOrder(a, b) > 0;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* The task an event names, read from both places one can be written.
|
|
124
|
+
*
|
|
125
|
+
* D-245. The envelope's `task_id` is the field every projector keys on, and
|
|
126
|
+
* every machine producer stamps it. The payload copy is what a hand-written
|
|
127
|
+
* dispatch carries: `.claude/skills/bs/dispatch.md` lists the dispatch payload's
|
|
128
|
+
* four required fields and never says where the task id goes, so the operator
|
|
129
|
+
* writes it in beside them. 15 of 19 dispatches in dogfood-envkit-1 and 14 of
|
|
130
|
+
* 82 in dogfood-demo-rpg-1 name their task only there.
|
|
131
|
+
*
|
|
132
|
+
* Reading one level is not a partial answer, it is a wrong one. An agent whose
|
|
133
|
+
* dispatch names no task cannot be reached by that task's terminal event, so
|
|
134
|
+
* it reads "no task assigned" until the epic verdict sweeps it `abandoned`,
|
|
135
|
+
* and the task itself never leaves `todo` on the board. budgetAlarm.ts (D-172)
|
|
136
|
+
* and dispatchAudit.ts each found this and fixed it behind a private copy of
|
|
137
|
+
* this function; the rule belongs to the module that owns the record shape, so
|
|
138
|
+
* every reader of that shape can reach it -- the move D-143 made for
|
|
139
|
+
* `taskIdsMatch`.
|
|
140
|
+
*
|
|
141
|
+
* The envelope wins when both are set: it is the indexed column, and a payload
|
|
142
|
+
* that disagrees with it is a producer bug, not a second opinion. An empty
|
|
143
|
+
* string names no task at either level (D-244), and neither does a non-string
|
|
144
|
+
* -- every field here has been through a JSONL round-trip, where TypeScript
|
|
145
|
+
* stopped watching (D-135).
|
|
146
|
+
*/
|
|
147
|
+
export function eventTaskId(record) {
|
|
148
|
+
if (typeof record.task_id === 'string' && record.task_id.length > 0)
|
|
149
|
+
return record.task_id;
|
|
150
|
+
const fromPayload = record.payload?.task_id;
|
|
151
|
+
return typeof fromPayload === 'string' && fromPayload.length > 0 ? fromPayload : null;
|
|
152
|
+
}
|
|
153
|
+
let cachedTaxonomy;
|
|
154
|
+
let cachedSchemas;
|
|
155
|
+
function resolveTaxonomyAndSchemas(opts) {
|
|
156
|
+
if (cachedTaxonomy === undefined)
|
|
157
|
+
cachedTaxonomy = loadTaxonomy();
|
|
158
|
+
if (cachedSchemas === undefined)
|
|
159
|
+
cachedSchemas = compileSchemas(cachedTaxonomy);
|
|
160
|
+
const taxonomy = opts.taxonomy ?? cachedTaxonomy;
|
|
161
|
+
const schemas = opts.schemas ?? cachedSchemas;
|
|
162
|
+
return { taxonomy, schemas };
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* D-197: a session id is a file name before it is anything else.
|
|
166
|
+
*
|
|
167
|
+
* `logPath` interpolates the id straight into a path and `appendEvent` mints
|
|
168
|
+
* `<id>#<index>` from it, so an id that is not one path segment breaks both
|
|
169
|
+
* ends at once and neither end said so. `''` produced the id `#0`, which
|
|
170
|
+
* parseEventId refuses — the session's own second event was impossible,
|
|
171
|
+
* because causal_parent could not name its first. `'a/b/c'` and `'../x'`
|
|
172
|
+
* appended happily (appendEventLocked mkdirs the parent, recursive), and read
|
|
173
|
+
* back through the same id, so nothing local looked wrong: what breaks is
|
|
174
|
+
* every reader that enumerates the directory instead of naming the id.
|
|
175
|
+
* projector.ts's listSessionIds and the UI server's refresher both readdir
|
|
176
|
+
* `state/events` non-recursively and keep entries ending in `.jsonl`, so a log
|
|
177
|
+
* one directory down is not a session as far as the database and the
|
|
178
|
+
* dashboard are concerned, and no row, no warning and no count records that a
|
|
179
|
+
* log was skipped.
|
|
180
|
+
*
|
|
181
|
+
* The check is structural, not a charset: exactly the ids that would land
|
|
182
|
+
* outside `<dir>/<id>.jsonl` are refused, and nothing else. `#` in particular
|
|
183
|
+
* stays legal — parseEventId splits on the LAST one deliberately, and a `#`
|
|
184
|
+
* cannot move a file.
|
|
185
|
+
*/
|
|
186
|
+
function requireSessionIdShape(sessionId) {
|
|
187
|
+
if (sessionId.length > 0 && sessionId !== '.' && sessionId !== '..') {
|
|
188
|
+
if (path.basename(sessionId) === sessionId)
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
throw new EventError('events.malformed-session-id', `"${sessionId}" is not a session id. A session id names one event log file, so it must be a single non-empty path segment — no "/", and not "." or "..".`, { session_id: sessionId });
|
|
192
|
+
}
|
|
193
|
+
function logPath(sessionId, opts) {
|
|
194
|
+
requireSessionIdShape(sessionId);
|
|
195
|
+
const dir = opts.stateDir ?? STATE_EVENTS_DIR;
|
|
196
|
+
return path.join(dir, `${sessionId}.jsonl`);
|
|
197
|
+
}
|
|
198
|
+
async function readEventsAtPath(filePath, sessionId) {
|
|
199
|
+
if (!existsSync(filePath))
|
|
200
|
+
return [];
|
|
201
|
+
const text = await readFile(filePath, 'utf8');
|
|
202
|
+
const lines = text
|
|
203
|
+
.trimEnd()
|
|
204
|
+
.split('\n')
|
|
205
|
+
.filter((line) => line.length > 0);
|
|
206
|
+
return lines.map((line, index) => ({
|
|
207
|
+
event_id: `${sessionId}#${index}`,
|
|
208
|
+
record: JSON.parse(line),
|
|
209
|
+
}));
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Per-log serialization *inside one process*: concurrent appendEvent calls on
|
|
213
|
+
* the same session log must not derive the same line-count index or read a
|
|
214
|
+
* stale event list for causal_parent lookup (TOCTOU). One promise chain per
|
|
215
|
+
* resolved file path.
|
|
216
|
+
*
|
|
217
|
+
* This is the fast path, not the guarantee — see `withLogLock` below for the
|
|
218
|
+
* part that holds across processes. Keeping both matters: the queue means the
|
|
219
|
+
* common case of two appends in one process never touches the filesystem lock
|
|
220
|
+
* at all, so nothing polls when nothing is contended.
|
|
221
|
+
*/
|
|
222
|
+
const fileQueues = new Map();
|
|
223
|
+
function enqueue(key, task) {
|
|
224
|
+
const previous = fileQueues.get(key) ?? Promise.resolve();
|
|
225
|
+
const next = previous.then(task, task);
|
|
226
|
+
// Store a rejection-swallowed continuation so one failed append never
|
|
227
|
+
// permanently jams the queue for the next caller; the real outcome for
|
|
228
|
+
// *this* call is still `next`, returned below.
|
|
229
|
+
fileQueues.set(key, next.then(() => undefined, () => undefined));
|
|
230
|
+
return next;
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Cross-process serialization for one session log.
|
|
234
|
+
*
|
|
235
|
+
* `fileQueues` orders appends within a Node process, and for a long time that
|
|
236
|
+
* was the whole story: queue, gates and CLI ran as one process per factory
|
|
237
|
+
* session, so one promise chain was the only ordering anyone needed. Running a
|
|
238
|
+
* wave in parallel ends that assumption. An orchestrator that dispatches five
|
|
239
|
+
* tasks and records five events in five `smith` invocations has five
|
|
240
|
+
* processes, five queues, and five independent readers of `existing.length` —
|
|
241
|
+
* and they all read the same number.
|
|
242
|
+
*
|
|
243
|
+
* What that costs is subtle enough to be worth naming. The log survives: ids
|
|
244
|
+
* are derived from line position at read time (`readEventsAtPath`), so five
|
|
245
|
+
* concurrent appends leave five well-formed lines and the file reads back
|
|
246
|
+
* correctly. It is the id handed *back* to each caller that is wrong, and the
|
|
247
|
+
* caller feeds that id to the next command as `--causal-parent`. The wrong
|
|
248
|
+
* parent then names an event that genuinely exists, so `validateCausalParent`
|
|
249
|
+
* accepts it and the lineage is quietly mis-shaped. A wrong parent that
|
|
250
|
+
* validates is worse than one that fails.
|
|
251
|
+
*
|
|
252
|
+
* Hence a lock file beside the log. `open(..., 'wx')` is atomic on every
|
|
253
|
+
* filesystem this runs on: exactly one process creates it, everyone else gets
|
|
254
|
+
* EEXIST and waits. The whole read-validate-append sequence happens inside it,
|
|
255
|
+
* which is what makes the returned index true — it counts the lines another
|
|
256
|
+
* process wrote before us, because it is taken after that process let go.
|
|
257
|
+
*
|
|
258
|
+
* The lock is named `<session>.jsonl.lock`, which `listSessionIds` does not
|
|
259
|
+
* see: it selects on `.jsonl` as the suffix, and this is not one.
|
|
260
|
+
*/
|
|
261
|
+
const LOCK_POLL_MS = 12;
|
|
262
|
+
/**
|
|
263
|
+
* A lock older than this belonged to a process that died holding it. The
|
|
264
|
+
* window is generous on purpose: breaking a live lock reintroduces exactly the
|
|
265
|
+
* race the lock exists to remove, while waiting too long only ever costs a
|
|
266
|
+
* stalled append that an operator can see and retry.
|
|
267
|
+
*/
|
|
268
|
+
const LOCK_STALE_MS = 30_000;
|
|
269
|
+
/** How long to wait for a peer before calling the log jammed rather than busy. */
|
|
270
|
+
const LOCK_TIMEOUT_MS = 60_000;
|
|
271
|
+
function sleep(ms) {
|
|
272
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Age of the lock file in milliseconds, or `undefined` if it went away while
|
|
276
|
+
* we were asking — which is not an error but the good outcome: the holder
|
|
277
|
+
* finished, and the next acquire attempt is the one that succeeds.
|
|
278
|
+
*/
|
|
279
|
+
async function lockAgeMs(lockPath) {
|
|
280
|
+
try {
|
|
281
|
+
return Date.now() - (await stat(lockPath)).mtimeMs;
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
return undefined;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
async function withLogLock(filePath, task) {
|
|
288
|
+
const lockPath = `${filePath}.lock`;
|
|
289
|
+
await mkdir(path.dirname(lockPath), { recursive: true });
|
|
290
|
+
const deadline = Date.now() + LOCK_TIMEOUT_MS;
|
|
291
|
+
for (;;) {
|
|
292
|
+
let handle;
|
|
293
|
+
try {
|
|
294
|
+
handle = await open(lockPath, 'wx');
|
|
295
|
+
}
|
|
296
|
+
catch (error) {
|
|
297
|
+
if (error.code !== 'EEXIST')
|
|
298
|
+
throw error;
|
|
299
|
+
const age = await lockAgeMs(lockPath);
|
|
300
|
+
if (age !== undefined && age > LOCK_STALE_MS) {
|
|
301
|
+
// Best-effort: if a peer breaks it first, our next `wx` simply wins or
|
|
302
|
+
// waits again. `force` keeps that harmless.
|
|
303
|
+
await rm(lockPath, { force: true });
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
if (Date.now() > deadline) {
|
|
307
|
+
throw new EventError('events.log-busy', `Another process has held the log for ${filePath} longer than ${LOCK_TIMEOUT_MS}ms. ` +
|
|
308
|
+
'Nothing was written. If no `smith` process is running, delete the stale lock file.', { lock_path: lockPath });
|
|
309
|
+
}
|
|
310
|
+
await sleep(LOCK_POLL_MS);
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
try {
|
|
314
|
+
// Written for a person reading a stuck lock, never read back by code.
|
|
315
|
+
await handle.writeFile(`${process.pid}\n`, 'utf8');
|
|
316
|
+
return await task();
|
|
317
|
+
}
|
|
318
|
+
finally {
|
|
319
|
+
await handle.close();
|
|
320
|
+
await rm(lockPath, { force: true });
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
// event_type -> which taxonomy record type its payload must satisfy, and how
|
|
325
|
+
// payload field names map onto that record type's required dimension names
|
|
326
|
+
// (architecture §7 payload shapes; taxonomy.yml rules.required_dimensions).
|
|
327
|
+
// Extend this map if/when another payload-typed event kind is emitted;
|
|
328
|
+
// edge-recorded is already covered separately via event.schema.json's
|
|
329
|
+
// x-taxonomy on the `edge` sub-object.
|
|
330
|
+
const PAYLOAD_DIMENSION_MAP = {
|
|
331
|
+
dispatch_decision: {
|
|
332
|
+
recordType: 'dispatch',
|
|
333
|
+
fieldMap: {
|
|
334
|
+
agent: 'agent_role',
|
|
335
|
+
provider: 'provider',
|
|
336
|
+
model_tier: 'model_tier',
|
|
337
|
+
// P9-23: the concrete model, not just its tier. Required, because an
|
|
338
|
+
// optional field would be dropped exactly where it matters and the
|
|
339
|
+
// absence would read as compliance; presence-only in taxonomy.ts,
|
|
340
|
+
// because model ids are not a closed vocabulary.
|
|
341
|
+
model: 'model',
|
|
342
|
+
},
|
|
343
|
+
},
|
|
344
|
+
// Phase 8 addition (architecture §6/§7 note, providers/quorum.ts): one
|
|
345
|
+
// per external judge run, `agent`/`provider`/`model_tier` payload fields
|
|
346
|
+
// already match the taxonomy dimension names 1:1 (no renaming needed,
|
|
347
|
+
// unlike dispatch_decision's agent_role -> agent), so fieldMap is an
|
|
348
|
+
// identity map.
|
|
349
|
+
'judge-verdict': {
|
|
350
|
+
recordType: 'dispatch',
|
|
351
|
+
fieldMap: { agent: 'agent', provider: 'provider', model_tier: 'model_tier', model: 'model' },
|
|
352
|
+
},
|
|
353
|
+
'error-logged': {
|
|
354
|
+
recordType: 'error',
|
|
355
|
+
fieldMap: { error: 'error', severity: 'severity', task_ref: 'task_ref' },
|
|
356
|
+
},
|
|
357
|
+
// Phase 5 addition (deviation, see db/projector.ts header comment): the
|
|
358
|
+
// full lesson record — every dimension taxonomy.yml's rules.
|
|
359
|
+
// required_dimensions.lesson demands is present on THIS event's payload.
|
|
360
|
+
'lesson-candidate-raised': {
|
|
361
|
+
recordType: 'lesson',
|
|
362
|
+
fieldMap: {
|
|
363
|
+
lesson_type: 'lesson_type',
|
|
364
|
+
lesson_level: 'lesson_level',
|
|
365
|
+
lesson_status: 'lesson_status',
|
|
366
|
+
lesson_scope: 'lesson_scope',
|
|
367
|
+
provenance_event_ids: 'provenance_event_ids',
|
|
368
|
+
},
|
|
369
|
+
},
|
|
370
|
+
};
|
|
371
|
+
// event_type -> a single taxonomy-valued payload field to check, for event
|
|
372
|
+
// kinds that carry only ONE dimension-tagged field rather than a full
|
|
373
|
+
// record (mirrors finding-transitioned's shape: a status change, not a new
|
|
374
|
+
// record — PAYLOAD_DIMENSION_MAP's required_dimensions check would
|
|
375
|
+
// wrongly demand the OTHER lesson fields that only the raise event carries).
|
|
376
|
+
const PAYLOAD_TAG_MAP = {
|
|
377
|
+
'lesson-status-changed': { dimension: 'lesson_status', payloadField: 'to_status' },
|
|
378
|
+
// P9-11: the terminal half of a judge dispatch (judges.ts). It names a role
|
|
379
|
+
// and nothing else dimension-valued — the provider and tier were settled by
|
|
380
|
+
// the `dispatch_decision` this closes, and repeating them here would be a
|
|
381
|
+
// second place for them to disagree.
|
|
382
|
+
'judge-reported': { dimension: 'agent', payloadField: 'agent_role' },
|
|
383
|
+
};
|
|
384
|
+
// event_type -> several OPTIONAL taxonomy-valued payload fields, each
|
|
385
|
+
// validated only when present. Mirrors PAYLOAD_TAG_MAP, but for an event
|
|
386
|
+
// that carries a PARTIAL update over more than one dimension at once rather
|
|
387
|
+
// than exactly one field — lesson-edited's Edit action lets the operator
|
|
388
|
+
// change just the statement, just the type, just the scope, just the
|
|
389
|
+
// selector the scope names, or any combination (ui/server/src/app.ts's edit
|
|
390
|
+
// route), so neither
|
|
391
|
+
// PAYLOAD_DIMENSION_MAP (demands every field) nor PAYLOAD_TAG_MAP (checks
|
|
392
|
+
// exactly one) fits. Without this, `lessonType: "NOT-A-REAL-TYPE"` would
|
|
393
|
+
// persist silently — db/projector.ts's foldLessons() writes whatever
|
|
394
|
+
// lesson_type/lesson_scope string it is given, unchecked.
|
|
395
|
+
const PAYLOAD_PARTIAL_TAG_MAP = {
|
|
396
|
+
'lesson-edited': [
|
|
397
|
+
{ dimension: 'lesson_type', payloadField: 'lesson_type' },
|
|
398
|
+
{ dimension: 'lesson_scope', payloadField: 'lesson_scope' },
|
|
399
|
+
// D-129 added these two so that re-scoping a lesson to a selector scope
|
|
400
|
+
// can name the selector in the same edit. They are taxonomy-valued like
|
|
401
|
+
// the two above and lessons.ts's transitionLesson checks them -- but a
|
|
402
|
+
// writer's guard is not the log's, and everything the paragraph above
|
|
403
|
+
// says of an unchecked lesson_type is true of these: foldLessons writes
|
|
404
|
+
// the string it is given onto the row, and a lesson scoped to an agent
|
|
405
|
+
// role no agent has is a lesson that reaches nobody, silently.
|
|
406
|
+
//
|
|
407
|
+
// The dimension names are the taxonomy's ('agent', 'case'), not the
|
|
408
|
+
// payload field's -- same rename dispatch_decision's fieldMap makes.
|
|
409
|
+
{ dimension: 'agent', payloadField: 'agent_role' },
|
|
410
|
+
{ dimension: 'case', payloadField: 'case_type' },
|
|
411
|
+
],
|
|
412
|
+
// D-215. taxonomy.yml's rules.required_dimensions names six record types;
|
|
413
|
+
// `dispatch`, `error` and `lesson` are checked above and `edge` goes
|
|
414
|
+
// through validateRequiredDimensions in plan.ts -- `task` was the one with
|
|
415
|
+
// no write-time check anywhere, and `task-added` is the only event that
|
|
416
|
+
// carries one. It matters more than the others, not less: db/projector.ts's
|
|
417
|
+
// `row.taskStatus = p.task_status ?? row.taskStatus` is the ONLY place a
|
|
418
|
+
// payload string becomes a task's status (every other assignment there is a
|
|
419
|
+
// literal from this vocabulary), and ui/src/lib/kanban.ts folds that status
|
|
420
|
+
// through a lookup with no bucket for a value it does not recognise -- so a
|
|
421
|
+
// typo does not render oddly, it removes the task from the board, in "All"
|
|
422
|
+
// mode too. `smith plan ingest` never calls validatePlan(), so the schema's
|
|
423
|
+
// x-taxonomy on task-spec.schema.json is opt-in and this is the only stop.
|
|
424
|
+
//
|
|
425
|
+
// Partial rather than PAYLOAD_DIMENSION_MAP: presence of the three is
|
|
426
|
+
// task-spec.schema.json's business and `plan validate`'s, and demanding
|
|
427
|
+
// them here would reject the sparse `task-added` payloads that legitimately
|
|
428
|
+
// carry only an epic and an objective. This rule is about the value of a
|
|
429
|
+
// field that is present.
|
|
430
|
+
'task-added': [
|
|
431
|
+
{ dimension: 'task_status', payloadField: 'task_status' },
|
|
432
|
+
{ dimension: 'case', payloadField: 'case' },
|
|
433
|
+
{ dimension: 'origin', payloadField: 'origin' },
|
|
434
|
+
],
|
|
435
|
+
};
|
|
436
|
+
/**
|
|
437
|
+
* Every event_type the three maps above key on — a third hand-kept list of
|
|
438
|
+
* event types, after the taxonomy's and queries.ts's. Exported so the P9-37
|
|
439
|
+
* lint (test/eventTypes.test.ts) can hold it against the other two: a key here
|
|
440
|
+
* that names no event anyone writes is a payload rule that never fires, and a
|
|
441
|
+
* typo in the key is exactly that, silently.
|
|
442
|
+
*/
|
|
443
|
+
export const PAYLOAD_TAGGED_EVENT_TYPES = [
|
|
444
|
+
...Object.keys(PAYLOAD_DIMENSION_MAP),
|
|
445
|
+
...Object.keys(PAYLOAD_TAG_MAP),
|
|
446
|
+
...Object.keys(PAYLOAD_PARTIAL_TAG_MAP),
|
|
447
|
+
];
|
|
448
|
+
function checkTag(taxonomy, eventType, dimension, value) {
|
|
449
|
+
try {
|
|
450
|
+
validateTag(taxonomy, dimension, String(value));
|
|
451
|
+
}
|
|
452
|
+
catch (err) {
|
|
453
|
+
throw new EventError('events.invalid-payload-dimensions', `Event payload for "${eventType}" failed taxonomy validation: ${err instanceof Error ? err.message : String(err)}`, { event_type: eventType });
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
function validatePayloadDimensions(taxonomy, record) {
|
|
457
|
+
const mapping = PAYLOAD_DIMENSION_MAP[record.event_type];
|
|
458
|
+
if (mapping) {
|
|
459
|
+
const payload = record.payload;
|
|
460
|
+
const mapped = {};
|
|
461
|
+
for (const [dimensionField, payloadField] of Object.entries(mapping.fieldMap)) {
|
|
462
|
+
mapped[dimensionField] = payload[payloadField];
|
|
463
|
+
}
|
|
464
|
+
try {
|
|
465
|
+
validateRequiredDimensions(taxonomy, mapping.recordType, mapped);
|
|
466
|
+
}
|
|
467
|
+
catch (err) {
|
|
468
|
+
throw new EventError('events.invalid-payload-dimensions', `Event payload for "${record.event_type}" failed taxonomy validation: ${err instanceof Error ? err.message : String(err)}`, { event_type: record.event_type });
|
|
469
|
+
}
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
const tagMapping = PAYLOAD_TAG_MAP[record.event_type];
|
|
473
|
+
if (tagMapping) {
|
|
474
|
+
const value = record.payload[tagMapping.payloadField];
|
|
475
|
+
if (value === undefined)
|
|
476
|
+
return; // presence is the caller's job, not ours
|
|
477
|
+
checkTag(taxonomy, record.event_type, tagMapping.dimension, value);
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
const partialTagMapping = PAYLOAD_PARTIAL_TAG_MAP[record.event_type];
|
|
481
|
+
if (partialTagMapping) {
|
|
482
|
+
for (const { dimension, payloadField } of partialTagMapping) {
|
|
483
|
+
const value = record.payload[payloadField];
|
|
484
|
+
if (value === undefined)
|
|
485
|
+
continue; // this partial update did not touch this field
|
|
486
|
+
checkTag(taxonomy, record.event_type, dimension, value);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* Event types whose payload IS a schema-defined record, not free-form JSON.
|
|
492
|
+
*
|
|
493
|
+
* D-135. The event schema types `payload` as an open object, so the envelope
|
|
494
|
+
* check passes on any payload at all. That is right for most event types —
|
|
495
|
+
* the payload is a note for a human reader. It is wrong for the few whose
|
|
496
|
+
* payload is later rehydrated into a typed object by a reader: `foldFindings`
|
|
497
|
+
* rebuilds each Finding from `record.payload` alone, so a payload missing a
|
|
498
|
+
* required field produces a Finding with `task_id: undefined`, and the crash
|
|
499
|
+
* lands in whichever reader touches it first — three frames and possibly
|
|
500
|
+
* three weeks from the actor that wrote it. Validating here puts the error
|
|
501
|
+
* where the bad record is still attributable, and keeps the log's invariant
|
|
502
|
+
* true for readers rather than merely hoped for.
|
|
503
|
+
*/
|
|
504
|
+
const TYPED_PAYLOAD_SCHEMAS = {
|
|
505
|
+
'finding-raised': 'finding',
|
|
506
|
+
};
|
|
507
|
+
function validateTypedPayload(schemas, taxonomy, record) {
|
|
508
|
+
const schemaName = TYPED_PAYLOAD_SCHEMAS[record.event_type];
|
|
509
|
+
if (schemaName === undefined)
|
|
510
|
+
return;
|
|
511
|
+
const result = validateRecord(schemas, taxonomy, schemaName, record.payload);
|
|
512
|
+
if (!result.valid) {
|
|
513
|
+
throw new EventError('events.invalid-typed-payload', `Event payload for "${record.event_type}" failed "${schemaName}" schema validation. Readers rehydrate this payload into a typed record, so an incomplete one becomes a crash in a later reader rather than an error here.`, { event_type: record.event_type, schema: schemaName, errors: result.errors });
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
/**
|
|
517
|
+
* P9-7: causal_parent may point into ANOTHER session's log, but only from a
|
|
518
|
+
* session root.
|
|
519
|
+
*
|
|
520
|
+
* The concurrency ceiling on an epic is the orchestrator's context window —
|
|
521
|
+
* fan-out is bounded by the claim graph, not by a cap — so the documented way
|
|
522
|
+
* to run a large epic is to split it across operator sessions. That was
|
|
523
|
+
* unexpressible: this check read only the appending session's own log, so a
|
|
524
|
+
* second session's first event could never name the first session's timeline.
|
|
525
|
+
*
|
|
526
|
+
* The edge is restricted to `session-start` deliberately. One entry edge per
|
|
527
|
+
* session makes the log a tree of sessions rather than a general graph: a
|
|
528
|
+
* reader can answer "where did this session come from" by looking at one
|
|
529
|
+
* event, and `sessionLineage` terminates. Letting any event point anywhere
|
|
530
|
+
* would buy nothing the root edge does not already express.
|
|
531
|
+
*/
|
|
532
|
+
async function validateCausalParent(input, opts, existing) {
|
|
533
|
+
const parent = parseEventId(input.causal_parent);
|
|
534
|
+
if (parent.sessionId === input.session_id) {
|
|
535
|
+
if (!existing.some((e) => e.event_id === input.causal_parent)) {
|
|
536
|
+
throw new EventError('events.unknown-causal-parent', `causal_parent "${input.causal_parent}" does not reference an existing event in session "${input.session_id}".`, { causal_parent: input.causal_parent, session_id: input.session_id });
|
|
537
|
+
}
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
if (input.event_type !== ROOT_EVENT_TYPE) {
|
|
541
|
+
throw new EventError('events.cross-session-parent-not-root', `Only a "${ROOT_EVENT_TYPE}" event may name a parent in another session; "${input.event_type}" in session "${input.session_id}" named "${input.causal_parent}". Chain the session root to the other session, then chain this event locally.`, {
|
|
542
|
+
causal_parent: input.causal_parent,
|
|
543
|
+
session_id: input.session_id,
|
|
544
|
+
event_type: input.event_type,
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
// Read outside the parent log's append queue. Safe because logs are
|
|
548
|
+
// append-only: a concurrent append there can add events, never remove the
|
|
549
|
+
// one we are about to point at.
|
|
550
|
+
const parentPath = logPath(parent.sessionId, opts);
|
|
551
|
+
if (!existsSync(parentPath)) {
|
|
552
|
+
throw new EventError('events.unknown-causal-session', `causal_parent "${input.causal_parent}" names session "${parent.sessionId}", which has no event log (expected ${parentPath}).`, { causal_parent: input.causal_parent, session_id: parent.sessionId, path: parentPath });
|
|
553
|
+
}
|
|
554
|
+
const parentEvents = await readEventsAtPath(parentPath, parent.sessionId);
|
|
555
|
+
if (!parentEvents.some((e) => e.event_id === input.causal_parent)) {
|
|
556
|
+
throw new EventError('events.unknown-causal-parent', `causal_parent "${input.causal_parent}" does not reference an existing event in session "${parent.sessionId}" (it holds ${parentEvents.length}).`, { causal_parent: input.causal_parent, session_id: parent.sessionId });
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
async function appendEventLocked(input, opts, filePath) {
|
|
560
|
+
// EventInput requires causal_parent, so inside the orchestrator undefined is
|
|
561
|
+
// unreachable. It is reachable from outside: every event appended through the
|
|
562
|
+
// CLI crosses a JSON boundary where the type guarantees nothing, and omitting
|
|
563
|
+
// a field is the likeliest way a hand-written event is malformed. Both guards
|
|
564
|
+
// below used to compare against `null` alone, so an omitted parent fell
|
|
565
|
+
// through them into parseEventId(undefined) and reached the caller as a raw
|
|
566
|
+
// TypeError with no `code` — the one shape produced by accident was the one
|
|
567
|
+
// shape with nothing to branch on. Normalise once so both guards read it.
|
|
568
|
+
const causalParent = input.causal_parent ?? null;
|
|
569
|
+
if (causalParent === null && input.event_type !== ROOT_EVENT_TYPE) {
|
|
570
|
+
throw new EventError('events.missing-causal-parent', `causal_parent may only be null for "${ROOT_EVENT_TYPE}" events (got event_type "${input.event_type}").`, { event_type: input.event_type });
|
|
571
|
+
}
|
|
572
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
573
|
+
const existing = await readEventsAtPath(filePath, input.session_id);
|
|
574
|
+
if (causalParent !== null) {
|
|
575
|
+
await validateCausalParent(input, opts, existing);
|
|
576
|
+
}
|
|
577
|
+
const record = { ...input, ts: new Date().toISOString() };
|
|
578
|
+
const { taxonomy, schemas } = resolveTaxonomyAndSchemas(opts);
|
|
579
|
+
const result = validateRecord(schemas, taxonomy, 'event', record);
|
|
580
|
+
if (!result.valid) {
|
|
581
|
+
throw new EventError('events.invalid-record', 'Event failed schema/taxonomy validation.', {
|
|
582
|
+
errors: result.errors,
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
validatePayloadDimensions(taxonomy, record);
|
|
586
|
+
validateTypedPayload(schemas, taxonomy, record);
|
|
587
|
+
const index = existing.length;
|
|
588
|
+
await appendFile(filePath, `${JSON.stringify(record)}\n`, 'utf8');
|
|
589
|
+
return { event_id: `${input.session_id}#${index}`, record };
|
|
590
|
+
}
|
|
591
|
+
/**
|
|
592
|
+
* Append one event. Stamps ts, validates via schemas.ts + taxonomy.ts
|
|
593
|
+
* (including payload-level taxonomy dimensions for dispatch/error events),
|
|
594
|
+
* enforces the causal_parent invariant (must reference a real prior event
|
|
595
|
+
* in this session, null only for session-start), and serializes concurrent
|
|
596
|
+
* appends to the same log — a rejected event never touches the log file.
|
|
597
|
+
*
|
|
598
|
+
* Serialized twice, deliberately: `enqueue` orders callers inside this
|
|
599
|
+
* process, `withLogLock` orders this process against every other `smith`
|
|
600
|
+
* running against the same log. A wave dispatched in parallel needs the
|
|
601
|
+
* second one; see its docblock for what a missing lock actually costs.
|
|
602
|
+
*/
|
|
603
|
+
export async function appendEvent(input, opts = {}) {
|
|
604
|
+
const filePath = logPath(input.session_id, opts);
|
|
605
|
+
return enqueue(filePath, () => withLogLock(filePath, () => appendEventLocked(input, opts, filePath)));
|
|
606
|
+
}
|
|
607
|
+
/**
|
|
608
|
+
* Open a session: write the one `session-start` its log is allowed to hold.
|
|
609
|
+
*
|
|
610
|
+
* This exists because `appendEvent` cannot be the thing that enforces "one
|
|
611
|
+
* root per log", and should not become it. That writer is open on purpose
|
|
612
|
+
* (D-163): `event_type` is a free string so a type nobody has declared yet is
|
|
613
|
+
* still recorded rather than lost, and the same openness reaches the root --
|
|
614
|
+
* a second `session-start` with a null `causal_parent`, into a log that
|
|
615
|
+
* already has one, satisfies every rule the writer has and is receipted as
|
|
616
|
+
* `#1` with exit 0.
|
|
617
|
+
*
|
|
618
|
+
* Nothing downstream reads that second line. `sessionLineage` takes the FIRST
|
|
619
|
+
* root, and the tree-of-sessions reading the whole cross-session edge is built
|
|
620
|
+
* on assumes there is exactly one entry point per session. So the second root
|
|
621
|
+
* is not a second beginning; it is a line no reader will ever look at, written
|
|
622
|
+
* by someone who believed they were starting fresh and was told they had.
|
|
623
|
+
*
|
|
624
|
+
* A dedicated verb can be closed where the writer has to stay open, and that
|
|
625
|
+
* is the whole design: the rule lives in the command whose only job is the
|
|
626
|
+
* root, so `appendEvent` keeps the property that makes it safe to hand an
|
|
627
|
+
* unknown record to.
|
|
628
|
+
*
|
|
629
|
+
* The emptiness check runs inside the same lock as the append, for the reason
|
|
630
|
+
* `withLogLock` gives above: checking outside it would read a length another
|
|
631
|
+
* process is in the middle of changing, and a guard that passes because it
|
|
632
|
+
* looked a moment too early is worse than no guard, since it reports success.
|
|
633
|
+
*/
|
|
634
|
+
export async function startSession(sessionId, opts = {}) {
|
|
635
|
+
const filePath = logPath(sessionId, opts);
|
|
636
|
+
const input = {
|
|
637
|
+
session_id: sessionId,
|
|
638
|
+
actor: opts.actor ?? 'operator',
|
|
639
|
+
event_type: ROOT_EVENT_TYPE,
|
|
640
|
+
plan_version: 1,
|
|
641
|
+
causal_parent: opts.continues ?? null,
|
|
642
|
+
payload: {},
|
|
643
|
+
};
|
|
644
|
+
return enqueue(filePath, () => withLogLock(filePath, async () => {
|
|
645
|
+
const existing = await readEventsAtPath(filePath, sessionId);
|
|
646
|
+
const last = existing[existing.length - 1];
|
|
647
|
+
if (last !== undefined) {
|
|
648
|
+
// Names the anchor rather than only the refusal. Someone who reaches
|
|
649
|
+
// for this verb on a live session wants the event id their next
|
|
650
|
+
// command needs as `--causal-parent`, and it is right here.
|
|
651
|
+
throw new EventError('events.session-already-started', `Session "${sessionId}" is already open — its log holds ${existing.length} event(s), the last being "${last.event_id}". A session log has one root, so there is nothing here to start. Chain your next command off "${last.event_id}", or pick a session id nothing has used.`, { session_id: sessionId, event_id: last.event_id, events: existing.length });
|
|
652
|
+
}
|
|
653
|
+
return appendEventLocked(input, opts, filePath);
|
|
654
|
+
}));
|
|
655
|
+
}
|
|
656
|
+
/** Convenience wrapper for edge-recorded events (edge_type + edge_provenance required). */
|
|
657
|
+
export async function appendEdge(input, edge, opts = {}) {
|
|
658
|
+
return appendEvent({ ...input, event_type: input.event_type ?? 'edge-recorded', edge }, opts);
|
|
659
|
+
}
|
|
660
|
+
/**
|
|
661
|
+
* ONE session's log.
|
|
662
|
+
*
|
|
663
|
+
* Since D-119 this is the narrow reader, and the narrow scope is the thing a
|
|
664
|
+
* caller has to ask for: anything that decides something about an epic wants
|
|
665
|
+
* `readLineageEvents`, because an epic is allowed to span sessions and a fold
|
|
666
|
+
* that stops at the session boundary answers `go` for the half it cannot see.
|
|
667
|
+
* The remaining callers here want exactly one session and say so — the
|
|
668
|
+
* projector (it writes rows keyed by session_id), `closeEpic`'s "does THIS
|
|
669
|
+
* session have a log" guard, `walkLineage` itself, and the plain `event tail`.
|
|
670
|
+
*/
|
|
671
|
+
export async function readEvents(sessionId, opts = {}) {
|
|
672
|
+
return readEventsAtPath(logPath(sessionId, opts), sessionId);
|
|
673
|
+
}
|
|
674
|
+
/**
|
|
675
|
+
* Assert that this session has a log at all.
|
|
676
|
+
*
|
|
677
|
+
* P9-28: `readEvents` answers `[]` for a log that does not exist, and it has
|
|
678
|
+
* to — `appendEvent` reads the log to derive the next line index and to check
|
|
679
|
+
* `causal_parent`, so a session's very first event depends on "absent" meaning
|
|
680
|
+
* "empty". The cost lands on the reading side, which then cannot tell *no such
|
|
681
|
+
* session* from *this session did nothing*, and every reader reported the
|
|
682
|
+
* second: `smith event tail typo-in-the-id` printed `[]` and exited 0.
|
|
683
|
+
*
|
|
684
|
+
* So the check belongs here rather than inside readEvents, and readers opt
|
|
685
|
+
* into it — precisely so writers keep the behaviour they need. It says nothing
|
|
686
|
+
* about an existing-but-empty log; that one really is an empty session.
|
|
687
|
+
*/
|
|
688
|
+
export function requireSession(sessionId, opts = {}) {
|
|
689
|
+
const filePath = logPath(sessionId, opts);
|
|
690
|
+
if (!existsSync(filePath)) {
|
|
691
|
+
throw new EventError('events.unknown-session', `No event log for session '${sessionId}' (expected ${filePath}). Check the id, or create the session with a session-start event.`, { session_id: sessionId, path: filePath });
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
/**
|
|
695
|
+
* The chain of sessions this one continues, plus the sessions that continue
|
|
696
|
+
* it, as separate lists.
|
|
697
|
+
*
|
|
698
|
+
* D-266's read side, and the shape `smith event lineage` prints from. Callers
|
|
699
|
+
* that only want the union take `sessionLineage`.
|
|
700
|
+
*/
|
|
701
|
+
export async function sessionTree(sessionId, opts = {}) {
|
|
702
|
+
// `require` — a caller naming a session by hand gets told when the name is
|
|
703
|
+
// wrong. readLineageEvents does not, and its comment says why.
|
|
704
|
+
const { ancestors, continuations } = await walkLineage(sessionId, opts, true);
|
|
705
|
+
const ancestry = ancestors.map((s) => s.sessionId);
|
|
706
|
+
const continued = continuations.map((s) => s.sessionId);
|
|
707
|
+
return { sessions: [...ancestry, ...continued], ancestry, continuations: continued };
|
|
708
|
+
}
|
|
709
|
+
/**
|
|
710
|
+
* Every session in this one's lineage: what it continues, itself, and what
|
|
711
|
+
* continued it.
|
|
712
|
+
*
|
|
713
|
+
* P9-7 shipped the upward half. A session's entry edge is the `causal_parent`
|
|
714
|
+
* of its `session-start` event; when that names another session, the walk
|
|
715
|
+
* follows it back. A session with no cross-session parent and nothing
|
|
716
|
+
* continuing it answers `[sessionId]` — a lineage of one, not an empty list,
|
|
717
|
+
* because the session is always part of its own lineage.
|
|
718
|
+
*
|
|
719
|
+
* D-266 added the downward half. `--continues` was built for a chain: an epic
|
|
720
|
+
* that outlives one operator window, continued in a fresh session and read
|
|
721
|
+
* from the newest end. The tier split the factory needs is not a chain but a
|
|
722
|
+
* fan-out — an epic session dispatching a disposable session per wave, each of
|
|
723
|
+
* which continues it — and every verb that decides something about an epic
|
|
724
|
+
* (`epic close`, `wave audit`, `findings list`, `stats overview`) is run from
|
|
725
|
+
* the epic end. Walking up only, that end saw a lineage of one and said so in
|
|
726
|
+
* exactly the words it uses for an epic that never fanned out at all. Which is
|
|
727
|
+
* D-119's failure mode, arrived at from the other direction.
|
|
728
|
+
*/
|
|
729
|
+
export async function sessionLineage(sessionId, opts = {}) {
|
|
730
|
+
return (await sessionTree(sessionId, opts)).sessions;
|
|
731
|
+
}
|
|
732
|
+
/** The `causal_parent` of a log's `session-start`, however far into the log it sits.
|
|
733
|
+
*
|
|
734
|
+
* The FIRST such event, for the reason `startSession`'s comment gives: a log
|
|
735
|
+
* has one beginning, and a second `session-start` is a line no reader looks at.
|
|
736
|
+
*/
|
|
737
|
+
function entryEdge(events) {
|
|
738
|
+
return events.find((e) => e.record.event_type === ROOT_EVENT_TYPE)?.record.causal_parent ?? null;
|
|
739
|
+
}
|
|
740
|
+
/**
|
|
741
|
+
* How much of a log to read when all that is wanted is its entry edge.
|
|
742
|
+
*
|
|
743
|
+
* One `session-start` record is a few hundred bytes; 8KB is slack enough that
|
|
744
|
+
* the full-read fallback below is for malformed logs rather than for ordinary
|
|
745
|
+
* large ones.
|
|
746
|
+
*/
|
|
747
|
+
const ENTRY_EDGE_PROBE_BYTES = 8192;
|
|
748
|
+
/**
|
|
749
|
+
* The entry edge of one log, without reading the whole log.
|
|
750
|
+
*
|
|
751
|
+
* The downward walk asks this of every log in the state directory, and every
|
|
752
|
+
* log's answer lives on its first line — so this reads the head of the file
|
|
753
|
+
* and stops. `undefined` means the log is gone (a delete racing the readdir
|
|
754
|
+
* that produced the id), which is the one absence that is not an answer about
|
|
755
|
+
* lineage.
|
|
756
|
+
*
|
|
757
|
+
* The full-read fallback is not defensive padding. `startSession` enforces
|
|
758
|
+
* "the root is line 0", but `appendEvent` deliberately does not (D-163), so a
|
|
759
|
+
* log written event-by-event may carry its root anywhere and the upward walk
|
|
760
|
+
* finds it with a scan. A probe that only ever read line 0 would make the two
|
|
761
|
+
* directions disagree, and a session reachable upward but not downward is the
|
|
762
|
+
* exact asymmetry this walk exists to remove.
|
|
763
|
+
*
|
|
764
|
+
* A first line that is not JSON throws rather than being skipped. Skipping it
|
|
765
|
+
* would drop that session out of its parent's lineage silently, and a fold
|
|
766
|
+
* that cannot tell "no session continues you" from "I could not read whether
|
|
767
|
+
* one does" is the thing D-119 is about. Loud, naming the file, is the answer.
|
|
768
|
+
*/
|
|
769
|
+
async function readEntryEdge(sessionId, opts) {
|
|
770
|
+
const filePath = logPath(sessionId, opts);
|
|
771
|
+
let head;
|
|
772
|
+
let bytesRead;
|
|
773
|
+
try {
|
|
774
|
+
const handle = await open(filePath, 'r');
|
|
775
|
+
try {
|
|
776
|
+
const buf = Buffer.alloc(ENTRY_EDGE_PROBE_BYTES);
|
|
777
|
+
({ bytesRead } = await handle.read(buf, 0, ENTRY_EDGE_PROBE_BYTES, 0));
|
|
778
|
+
head = buf.subarray(0, bytesRead).toString('utf8');
|
|
779
|
+
}
|
|
780
|
+
finally {
|
|
781
|
+
await handle.close();
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
catch (err) {
|
|
785
|
+
if (err.code === 'ENOENT')
|
|
786
|
+
return undefined;
|
|
787
|
+
throw err;
|
|
788
|
+
}
|
|
789
|
+
const newline = head.indexOf('\n');
|
|
790
|
+
// No newline inside the probe means the first line is longer than the probe.
|
|
791
|
+
// The cheap read cannot answer, so pay for the whole one.
|
|
792
|
+
if (newline === -1 && bytesRead === ENTRY_EDGE_PROBE_BYTES) {
|
|
793
|
+
return entryEdge(await readEventsAtPath(filePath, sessionId));
|
|
794
|
+
}
|
|
795
|
+
const firstLine = (newline === -1 ? head : head.slice(0, newline)).trim();
|
|
796
|
+
// An empty log continues nothing, exactly as `readEvents` reports it.
|
|
797
|
+
if (firstLine.length === 0)
|
|
798
|
+
return null;
|
|
799
|
+
let record;
|
|
800
|
+
try {
|
|
801
|
+
record = JSON.parse(firstLine);
|
|
802
|
+
}
|
|
803
|
+
catch (err) {
|
|
804
|
+
throw new EventError('events.unreadable-session-log', `The first line of ${filePath} is not JSON, so whether session "${sessionId}" continues another cannot be read: ${errorText(err)}. Every lineage read scans every log in the state directory, so one unparseable log stops all of them — deliberately, because "cannot tell" must not be reported as "continues nothing".`, { session_id: sessionId, path: filePath });
|
|
805
|
+
}
|
|
806
|
+
if (record.event_type === ROOT_EVENT_TYPE)
|
|
807
|
+
return record.causal_parent ?? null;
|
|
808
|
+
return entryEdge(await readEventsAtPath(filePath, sessionId));
|
|
809
|
+
}
|
|
810
|
+
/** Whatever a thrown value has to say for itself, without assuming it is an Error. */
|
|
811
|
+
function errorText(err) {
|
|
812
|
+
return err instanceof Error ? err.message : String(err);
|
|
813
|
+
}
|
|
814
|
+
/**
|
|
815
|
+
* Every session in the state directory that continues another, keyed by the
|
|
816
|
+
* session it continues.
|
|
817
|
+
*
|
|
818
|
+
* Built by probe rather than by full read: the alternative is reading every
|
|
819
|
+
* log on every lineage fold, and `runTick` — the one place that already does
|
|
820
|
+
* that — pays for it because it needs the events, not just the edges. Children
|
|
821
|
+
* come out id-sorted for free, since `listSessionIds` sorts.
|
|
822
|
+
*
|
|
823
|
+
* The cost is one readdir plus one bounded read per log, per lineage fold. It
|
|
824
|
+
* is accepted rather than cached because a cache would have to be invalidated
|
|
825
|
+
* by a writer this module deliberately lets run concurrently in other
|
|
826
|
+
* processes, and a lineage index that is stale is a lineage index that is
|
|
827
|
+
* blind — which is the whole finding.
|
|
828
|
+
*/
|
|
829
|
+
async function continuationIndex(opts) {
|
|
830
|
+
const index = new Map();
|
|
831
|
+
for (const sessionId of listSessionIds(opts.stateDir ?? STATE_EVENTS_DIR)) {
|
|
832
|
+
const edge = await readEntryEdge(sessionId, opts);
|
|
833
|
+
if (edge === undefined || edge === null)
|
|
834
|
+
continue;
|
|
835
|
+
const parent = parseEventId(edge).sessionId;
|
|
836
|
+
// A root naming its own log is not a continuation; the upward walk stops
|
|
837
|
+
// on the same condition.
|
|
838
|
+
if (parent === sessionId)
|
|
839
|
+
continue;
|
|
840
|
+
const siblings = index.get(parent);
|
|
841
|
+
if (siblings === undefined)
|
|
842
|
+
index.set(parent, [sessionId]);
|
|
843
|
+
else
|
|
844
|
+
siblings.push(sessionId);
|
|
845
|
+
}
|
|
846
|
+
return index;
|
|
847
|
+
}
|
|
848
|
+
/**
|
|
849
|
+
* One walk up the causal_parent chain and one walk back down it, carrying each
|
|
850
|
+
* session's log along.
|
|
851
|
+
*
|
|
852
|
+
* Both readers need the same walk and the same cycle guard, and the walk has
|
|
853
|
+
* to read every ancestor's log anyway to find its parent — so the events come
|
|
854
|
+
* back with the ids rather than being read a second time by whoever wanted
|
|
855
|
+
* them.
|
|
856
|
+
*
|
|
857
|
+
* Downward from `sessionId` and not from the root, on purpose: a wave session
|
|
858
|
+
* folds in its own ancestry and its own continuations, never its siblings.
|
|
859
|
+
* Folding in siblings would re-couple exactly what dispatching a session per
|
|
860
|
+
* wave decouples, and would make a disposable session's scope grow with every
|
|
861
|
+
* other wave the epic ever ran.
|
|
862
|
+
*/
|
|
863
|
+
async function walkLineage(sessionId, opts, require) {
|
|
864
|
+
const ancestors = [];
|
|
865
|
+
const seen = new Set();
|
|
866
|
+
let current = sessionId;
|
|
867
|
+
while (current !== null) {
|
|
868
|
+
if (seen.has(current)) {
|
|
869
|
+
throw new EventError('events.session-lineage-cycle', `Session lineage for "${sessionId}" revisits "${current}". A session-start's causal_parent chain must terminate; this log has been edited by hand.`, { session_id: sessionId, revisited: current });
|
|
870
|
+
}
|
|
871
|
+
seen.add(current);
|
|
872
|
+
if (require)
|
|
873
|
+
requireSession(current, opts);
|
|
874
|
+
const events = await readEvents(current, opts);
|
|
875
|
+
ancestors.unshift({ sessionId: current, events });
|
|
876
|
+
const parent = entryEdge(events);
|
|
877
|
+
const parentSession = parent === null ? null : parseEventId(parent).sessionId;
|
|
878
|
+
current = parentSession === current ? null : parentSession;
|
|
879
|
+
}
|
|
880
|
+
const index = await continuationIndex(opts);
|
|
881
|
+
const continuations = [];
|
|
882
|
+
// Breadth-first: a wave's own continuation sorts after every wave of the
|
|
883
|
+
// round it belongs to, which is the order they were dispatched in.
|
|
884
|
+
let frontier = index.get(sessionId) ?? [];
|
|
885
|
+
while (frontier.length > 0) {
|
|
886
|
+
const next = [];
|
|
887
|
+
for (const child of frontier) {
|
|
888
|
+
// A revisit here is a hand-edited cycle, and the upward walk from the
|
|
889
|
+
// session inside it already throws. Down here it is enough to visit once
|
|
890
|
+
// — throwing would take out every reader of an unrelated lineage that
|
|
891
|
+
// merely shares a state directory with the edited log.
|
|
892
|
+
if (seen.has(child))
|
|
893
|
+
continue;
|
|
894
|
+
seen.add(child);
|
|
895
|
+
continuations.push({ sessionId: child, events: await readEvents(child, opts) });
|
|
896
|
+
next.push(...(index.get(child) ?? []));
|
|
897
|
+
}
|
|
898
|
+
frontier = next;
|
|
899
|
+
}
|
|
900
|
+
return { ancestors, continuations };
|
|
901
|
+
}
|
|
902
|
+
/**
|
|
903
|
+
* Every event in this session's lineage, in one causal order.
|
|
904
|
+
*
|
|
905
|
+
* D-119, the S1 this exists for: `sessionLineage` shipped with P9-7 and was
|
|
906
|
+
* then read by exactly two call sites, both read-only display verbs. Every
|
|
907
|
+
* fold that *decided* something — which findings are open, whether an epic may
|
|
908
|
+
* close, whether a task result is a duplicate — read `readEvents(sessionId)`,
|
|
909
|
+
* one session, and reported its answer in the same words it would have used
|
|
910
|
+
* had it read them all. Splitting the real `dogfood-mcp-1` log in two, the
|
|
911
|
+
* findings in one file and everything else in the other, turned `hold` with
|
|
912
|
+
* eleven open findings into `go` with none. A gate that reads a narrower scope
|
|
913
|
+
* than the thing it guards is off for everything outside that scope, and
|
|
914
|
+
* nothing in its output says so.
|
|
915
|
+
*
|
|
916
|
+
* Order: by `ts` ACROSS sessions, by append position WITHIN one, never by `ts`
|
|
917
|
+
* within one. findings.ts's staleFindings is "strictly ordering-based … a
|
|
918
|
+
* timestamp comparison across separately-clocked producers would not" answer
|
|
919
|
+
* correctly, and that caution holds — a session's own log is already the order
|
|
920
|
+
* its writer appended in, and a clock that steps backwards mid-session must
|
|
921
|
+
* not be able to hand a last-write-wins fold a different winner. Between two
|
|
922
|
+
* sessions the wall clock is the only shared reference there is, and the
|
|
923
|
+
* alternative — concatenating whole logs root-first — is strictly worse the
|
|
924
|
+
* moment a parent keeps working after a continuation chains onto it. Ties go
|
|
925
|
+
* to the older session, which is the direction causality runs.
|
|
926
|
+
*
|
|
927
|
+
* A session with no log answers `[]`, exactly as `readEvents` does and for the
|
|
928
|
+
* same reason (P9-28): this is a drop-in at the fold sites, and making them
|
|
929
|
+
* throw where they used to return would change what a session's first write
|
|
930
|
+
* does. `requireSession` stays the opt-in it already was.
|
|
931
|
+
*/
|
|
932
|
+
export async function readLineageEvents(sessionId, opts = {}) {
|
|
933
|
+
const { ancestors, continuations } = await walkLineage(sessionId, opts, false);
|
|
934
|
+
return mergeSessionLogs([...ancestors, ...continuations]);
|
|
935
|
+
}
|
|
936
|
+
/**
|
|
937
|
+
* Every session that has a log in `stateDir`, sorted, with no log read.
|
|
938
|
+
*
|
|
939
|
+
* A session id is only ever a `<id>.jsonl` filename here, so this is the one
|
|
940
|
+
* place that knows it — the projector kept a private copy and the daemon
|
|
941
|
+
* wanted a third, and three answers to "what sessions exist" is how a reader
|
|
942
|
+
* comes to inspect a set the writer never wrote.
|
|
943
|
+
*/
|
|
944
|
+
export function listSessionIds(stateDir = STATE_EVENTS_DIR) {
|
|
945
|
+
if (!existsSync(stateDir))
|
|
946
|
+
return [];
|
|
947
|
+
return readdirSync(stateDir)
|
|
948
|
+
.filter((f) => f.endsWith('.jsonl'))
|
|
949
|
+
.map((f) => f.slice(0, -'.jsonl'.length))
|
|
950
|
+
.sort();
|
|
951
|
+
}
|
|
952
|
+
/**
|
|
953
|
+
* Several sessions' logs in one causal order, under `readLineageEvents`'s
|
|
954
|
+
* ordering rule above: by `ts` ACROSS sessions, by append position WITHIN
|
|
955
|
+
* one, ties to whichever log comes first in `logs`.
|
|
956
|
+
*
|
|
957
|
+
* Extracted from `readLineageEvents` for D-199, which needs the same order
|
|
958
|
+
* over a set of sessions that is not a lineage — the projector's lessons
|
|
959
|
+
* table is keyed globally, so it folds every session, not one chain. Two
|
|
960
|
+
* copies of a k-way merge are two chances to answer a last-write-wins fold
|
|
961
|
+
* with different winners, which is the failure this ordering exists to
|
|
962
|
+
* prevent; callers pass their own set and share the one implementation.
|
|
963
|
+
*/
|
|
964
|
+
export function mergeSessionLogs(logs) {
|
|
965
|
+
// The overwhelmingly common shape, and the one every pre-D-119 caller had:
|
|
966
|
+
// returned as-is so a single session's order is its own log's, untouched.
|
|
967
|
+
const only = logs.length === 1 ? logs[0] : undefined;
|
|
968
|
+
if (only)
|
|
969
|
+
return only.events;
|
|
970
|
+
// A k-way merge over the per-session cursors. Each session's events are
|
|
971
|
+
// consumed in append order, so the merge can only interleave sessions — it
|
|
972
|
+
// can never reorder one.
|
|
973
|
+
const cursors = logs.map((s) => ({ events: s.events, next: 0 }));
|
|
974
|
+
const total = cursors.reduce((n, c) => n + c.events.length, 0);
|
|
975
|
+
const merged = [];
|
|
976
|
+
while (merged.length < total) {
|
|
977
|
+
let pick;
|
|
978
|
+
let head;
|
|
979
|
+
for (const cursor of cursors) {
|
|
980
|
+
const candidate = cursor.events[cursor.next];
|
|
981
|
+
if (candidate === undefined)
|
|
982
|
+
continue; // this session is exhausted
|
|
983
|
+
// Strict `<`: an equal timestamp leaves `pick` on the older session.
|
|
984
|
+
if (head === undefined || candidate.record.ts < head.record.ts) {
|
|
985
|
+
pick = cursor;
|
|
986
|
+
head = candidate;
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
// Unreachable — `total` counts exactly what the cursors hold — but a
|
|
990
|
+
// silent infinite loop is the wrong way to be wrong about that.
|
|
991
|
+
if (pick === undefined || head === undefined)
|
|
992
|
+
break;
|
|
993
|
+
merged.push(head);
|
|
994
|
+
pick.next += 1;
|
|
995
|
+
}
|
|
996
|
+
return merged;
|
|
997
|
+
}
|
|
998
|
+
export async function tailEvents(sessionId, n, opts = {}) {
|
|
999
|
+
const all = await readEvents(sessionId, opts);
|
|
1000
|
+
return all.slice(Math.max(0, all.length - n));
|
|
1001
|
+
}
|
|
1002
|
+
export function filterEvents(events, filter) {
|
|
1003
|
+
return events.filter(({ record }) => {
|
|
1004
|
+
// D-130's two-spelling rule now lives in taskId.ts, where the findings
|
|
1005
|
+
// fold can reach it too (D-143).
|
|
1006
|
+
// D-245: from either level, or a `--task` scope drops the events whose
|
|
1007
|
+
// producer wrote the id in the payload.
|
|
1008
|
+
if (filter.taskId !== undefined &&
|
|
1009
|
+
!taskIdsMatch(eventTaskId(record) ?? undefined, filter.taskId))
|
|
1010
|
+
return false;
|
|
1011
|
+
if (filter.planVersion !== undefined && record.plan_version !== filter.planVersion)
|
|
1012
|
+
return false;
|
|
1013
|
+
return true;
|
|
1014
|
+
});
|
|
1015
|
+
}
|