dsh-llm-fallbacks 0.4.4 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +81 -24
- package/README.zh-CN.md +81 -24
- package/dist/client/index.js +12 -8
- package/dist/client/locales.d.ts +2 -0
- package/dist/events.d.ts +2 -2
- package/dist/index.d.ts +6 -4
- package/dist/index.js +156 -63
- package/dist/role-resolution.d.ts +11 -0
- package/dist/roles.d.ts +4 -1
- package/dist/seeds.d.ts +63 -21
- package/dist/time-slots.d.ts +3 -2
- package/dist/virtual-adapter.d.ts +42 -6
- package/package.json +4 -3
- package/scripts/repair-session-logs.ts +1858 -0
- package/scripts/session-logs/catalog.ts +547 -0
- package/scripts/session-logs/classify.ts +47 -0
- package/scripts/session-logs/publish.ts +530 -0
- package/scripts/session-logs/rules.ts +1227 -0
- package/scripts/repair-fallbacks-switch-logs.ts +0 -342
|
@@ -0,0 +1,1227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* rules.ts — the session-log refusal rule registry and its built-in rules.
|
|
3
|
+
*
|
|
4
|
+
* A session log reaches the GUI only through the FROZEN released migration
|
|
5
|
+
* chain, so a pre-V3 log written by an older release (or by a plugin that
|
|
6
|
+
* merged a custom `MessageSource.kind`) can be refused before it is read. Every
|
|
7
|
+
* such refusal belongs to one class, and each class is one rule here.
|
|
8
|
+
*
|
|
9
|
+
* Rule shape: `detect` reports the refusals (read-only); `normalize` returns a
|
|
10
|
+
* NEW row array with the class repaired, or `{ refused: reason }` when the
|
|
11
|
+
* repaired shape is not provably admissible. The released catalog stays the
|
|
12
|
+
* final oracle (a candidate generation is read back with
|
|
13
|
+
* `validation: 'current'`); a rule never claims more than its own proof covers.
|
|
14
|
+
*
|
|
15
|
+
* Released formats this file encodes (SSOT paths are in the `deepseek-harness`
|
|
16
|
+
* checkout at `dsh 0.1.5-rc.1`, the build the running harness uses):
|
|
17
|
+
* - `source-kind`:
|
|
18
|
+
* `packages/session/session-format-v2-to-v3/src/payload.ts` —
|
|
19
|
+
* `SOURCE_KINDS` (the first-party vocabulary) and `assertSource` (the
|
|
20
|
+
* message-source positions that check it). The sanctioned target shape is
|
|
21
|
+
* the `plugin` arm DSH itself uses
|
|
22
|
+
* (`packages/llm/llm/src/message.ts` `MessageSourceMap['plugin']`); the
|
|
23
|
+
* members that arm admits are enforced by
|
|
24
|
+
* `packages/session/session-format-v0-to-v1/src/payload-validation.ts`
|
|
25
|
+
* `pluginSourceValue`.
|
|
26
|
+
* - `subagent-descriptor-version`:
|
|
27
|
+
* `packages/session/session-format-v0-to-v1/src/validation.ts` — the V0→V1
|
|
28
|
+
* edge admits only descriptor `version: 3`. The admitted payload key set
|
|
29
|
+
* per `mode` is `packages/subagent/subagent/src/descriptor.ts`
|
|
30
|
+
* (`ONE_SHOT_DESCRIPTOR_KEYS` / `CONTINUABLE_DESCRIPTOR_KEYS`), and the
|
|
31
|
+
* value shape is `subagentDescriptorValue` in the payload-validation file
|
|
32
|
+
* above.
|
|
33
|
+
* - `unknown-event-type`: the same V0→V1 edge refuses an event type outside
|
|
34
|
+
* the released inventory even when the row carries `ignorable: true`, so
|
|
35
|
+
* the legacy `fallbacks/switch` rows are NEVER repairable by the default
|
|
36
|
+
* registry — the only in-repo recovery is the LOSSY `drop-legacy-events`
|
|
37
|
+
* rule below, which an explicit opt-in must add to a run.
|
|
38
|
+
* - `drop-legacy-events` (OPT-IN, lossy, deliberately NOT in `BUILT_IN_RULES`):
|
|
39
|
+
* removes exactly the `fallbacks/switch` rows, renumbers the survivors (the
|
|
40
|
+
* released V0→V1 codec requires `seq === eventCount` for every row, so a
|
|
41
|
+
* removed mid-sequence row leaves every later row with a stale `seq`), and
|
|
42
|
+
* refuses the whole file when that renumber would change what a surviving
|
|
43
|
+
* row's Session-seq reference points at.
|
|
44
|
+
*
|
|
45
|
+
* Purity: no rule mutates its input. `detect` only reads; `normalize` always
|
|
46
|
+
* returns a NEW row array, and copies a row (plus every container on the path of
|
|
47
|
+
* a change) only when it actually changes that row — a pass-through row keeps its
|
|
48
|
+
* identity.
|
|
49
|
+
* Fail closed: a rule refuses whenever admissibility is unproven — it never
|
|
50
|
+
* bumps a value because the change "looks" safe.
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
export type RefusalClass =
|
|
54
|
+
| 'ok'
|
|
55
|
+
| 'source-kind'
|
|
56
|
+
| 'subagent-descriptor-version'
|
|
57
|
+
| 'unknown-event-type'
|
|
58
|
+
| 'other-refusal'
|
|
59
|
+
| 'decompress-failed'
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Every {@link RefusalClass} member, as a COMPILE-CHECKED record: a new class
|
|
63
|
+
* added to the union without a member here fails to compile, so the vocabulary
|
|
64
|
+
* cannot grow silently.
|
|
65
|
+
*/
|
|
66
|
+
export const REFUSAL_CLASS_MEMBERS: Readonly<Record<RefusalClass, true>> = {
|
|
67
|
+
ok: true,
|
|
68
|
+
'source-kind': true,
|
|
69
|
+
'subagent-descriptor-version': true,
|
|
70
|
+
'unknown-event-type': true,
|
|
71
|
+
'other-refusal': true,
|
|
72
|
+
'decompress-failed': true,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The refusal vocabulary in REPORT order — the single source of truth for the
|
|
77
|
+
* `--class` values, the report's per-class table and the usage text. Kept
|
|
78
|
+
* member-for-member with {@link REFUSAL_CLASS_MEMBERS} by that record's type and
|
|
79
|
+
* by an equality pin in the suite, so the next class fails loudly in both places
|
|
80
|
+
* instead of yielding a `NaN` report row.
|
|
81
|
+
*/
|
|
82
|
+
export const REFUSAL_CLASSES: readonly RefusalClass[] = [
|
|
83
|
+
'ok',
|
|
84
|
+
'source-kind',
|
|
85
|
+
'subagent-descriptor-version',
|
|
86
|
+
'unknown-event-type',
|
|
87
|
+
'other-refusal',
|
|
88
|
+
'decompress-failed',
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
/** One decoded JSONL row of a session log (the physical envelope only). */
|
|
92
|
+
export interface ParsedRow {
|
|
93
|
+
type: string
|
|
94
|
+
seq: number
|
|
95
|
+
time?: number
|
|
96
|
+
data?: unknown
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* One refusal (`detect`) or one applied rewrite (`normalize`).
|
|
101
|
+
* `detail` is human-facing evidence for the CLI report.
|
|
102
|
+
*/
|
|
103
|
+
export interface Finding {
|
|
104
|
+
ruleId: string
|
|
105
|
+
class: RefusalClass
|
|
106
|
+
detail: string
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* One refusal class as a rule.
|
|
111
|
+
*
|
|
112
|
+
* `detect` is ROW-LOCAL: it reports the refusals carried by the rows it is
|
|
113
|
+
* given, in row order. `classifyRows` calls it once per row (a single-row
|
|
114
|
+
* slice) so the class of a log's FIRST refused row is preserved; `normalize` is
|
|
115
|
+
* called once with the whole log.
|
|
116
|
+
*
|
|
117
|
+
* `normalize` is all-or-nothing per rule: when any finding of that rule cannot
|
|
118
|
+
* be repaired it returns `{ refused }` and the caller must discard the rows it
|
|
119
|
+
* would otherwise have produced.
|
|
120
|
+
*
|
|
121
|
+
* `repairable` is the report's own predicate for "this rule can make a log of its
|
|
122
|
+
* class load AT ALL": a detector that never rewrites anything answers `false`, and
|
|
123
|
+
* a rule whose repair scope depends on the rows (the opt-in lossy rule repairs a
|
|
124
|
+
* log only when it actually carries a removable row) answers per call. The CLI
|
|
125
|
+
* derives its repairable-class set from this member rather than from a literal, so
|
|
126
|
+
* adding a rule is enough to make its class repairable.
|
|
127
|
+
*/
|
|
128
|
+
export interface LogRule {
|
|
129
|
+
id: string
|
|
130
|
+
class: RefusalClass
|
|
131
|
+
detect(rows: readonly ParsedRow[]): Finding[]
|
|
132
|
+
normalize(rows: readonly ParsedRow[]): { rows: ParsedRow[]; findings: Finding[] } | { refused: string }
|
|
133
|
+
repairable(rows: readonly ParsedRow[]): boolean
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/* ------------------------------------------------------------------ */
|
|
137
|
+
/* shared helpers */
|
|
138
|
+
/* ------------------------------------------------------------------ */
|
|
139
|
+
|
|
140
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
141
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function isNonEmptyString(value: unknown): value is string {
|
|
145
|
+
return typeof value === 'string' && value.length > 0
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Render a JSON value for a diagnostic message.
|
|
150
|
+
*
|
|
151
|
+
* Not total: `JSON.stringify` throws on a `BigInt` member, so the only values
|
|
152
|
+
* this is called with are the streamed JSON payload members of a parsed row
|
|
153
|
+
* (which can never carry one). A violation surfaces as a loud rule failure, not
|
|
154
|
+
* as a silent misreport.
|
|
155
|
+
*/
|
|
156
|
+
function describe(value: unknown): string {
|
|
157
|
+
return JSON.stringify(value) ?? String(value)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/* ------------------------------------------------------------------ */
|
|
161
|
+
/* source-kind */
|
|
162
|
+
/* ------------------------------------------------------------------ */
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The released first-party message-source vocabulary.
|
|
166
|
+
*
|
|
167
|
+
* SSOT: `packages/session/session-format-v2-to-v3/src/payload.ts` `SOURCE_KINDS`
|
|
168
|
+
* (`deepseek-harness`, `dsh 0.1.5-rc.1`). A V2 message whose `source.kind` is
|
|
169
|
+
* outside this set is refused with
|
|
170
|
+
* `cannot safely transform unclassified message source`; the GUI surfaces that
|
|
171
|
+
* as a failed session load.
|
|
172
|
+
*
|
|
173
|
+
* Exported so the vocabulary itself is pinned by a test (exact set equality),
|
|
174
|
+
* not just its use: one spurious EXTRA kind here would make this registry stop
|
|
175
|
+
* detecting the RCA's dominant driver (`source-kind`) for that value, silently.
|
|
176
|
+
*/
|
|
177
|
+
export const RELEASED_SOURCE_KINDS: ReadonlySet<string> = new Set([
|
|
178
|
+
'user',
|
|
179
|
+
'plugin',
|
|
180
|
+
'model',
|
|
181
|
+
'tool',
|
|
182
|
+
'agent-instructions',
|
|
183
|
+
'session-reference',
|
|
184
|
+
'team-message',
|
|
185
|
+
'goal',
|
|
186
|
+
'skill-invocation',
|
|
187
|
+
'skill-catalog',
|
|
188
|
+
'coordinator',
|
|
189
|
+
'subagent-report',
|
|
190
|
+
'subagent-settled',
|
|
191
|
+
'webhook',
|
|
192
|
+
'agent-message',
|
|
193
|
+
])
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Whether one value is a member of the released message-source vocabulary
|
|
197
|
+
* ({@link RELEASED_SOURCE_KINDS}).
|
|
198
|
+
*
|
|
199
|
+
* The one predicate the write-surface guard (`tests/session-write-surface.spec.ts`)
|
|
200
|
+
* checks this repo's own `MessageSource.kind` literals against, so "released" has
|
|
201
|
+
* exactly one definition in this repository.
|
|
202
|
+
*/
|
|
203
|
+
export function isReleasedSourceKind(value: unknown): value is string {
|
|
204
|
+
return typeof value === 'string' && RELEASED_SOURCE_KINDS.has(value)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Forms the released `plugin` arm admits (SSOT: `ContextFormed` in
|
|
209
|
+
* `packages/llm/llm/src/message.ts`, enforced by `pluginSourceValue`).
|
|
210
|
+
*/
|
|
211
|
+
export const RELEASED_PLUGIN_FORMS: ReadonlySet<string> = new Set([
|
|
212
|
+
'instructions',
|
|
213
|
+
'catalog',
|
|
214
|
+
'snapshot',
|
|
215
|
+
'notice',
|
|
216
|
+
'relay',
|
|
217
|
+
'recall',
|
|
218
|
+
])
|
|
219
|
+
|
|
220
|
+
/** `source` member path inside `row.data`, e.g. `['message', 'source']`. */
|
|
221
|
+
type SourcePath = readonly (string | number)[]
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Message-source positions of one row: exactly the rows the released V2→V3
|
|
225
|
+
* edge classifies (`assertSource`, called from `assertEvent` in
|
|
226
|
+
* `session-format-v2-to-v3/src/payload.ts`):
|
|
227
|
+
* - `user/message` → `data.source`
|
|
228
|
+
* - `assistant/message` / `tool/result` → `data.message.source`
|
|
229
|
+
* - `agent/inbox/spliced` → every `data.inserted[i].source`
|
|
230
|
+
* - `session/title-llm-request` → every `data.messages[i].source`
|
|
231
|
+
*
|
|
232
|
+
* A `kind` member anywhere else is NOT what refuses a log — content blocks
|
|
233
|
+
* carry their own `type`/`kind` — so such members are deliberately left alone.
|
|
234
|
+
*/
|
|
235
|
+
function sourcePaths(row: ParsedRow): readonly SourcePath[] {
|
|
236
|
+
if (!isRecord(row.data)) return []
|
|
237
|
+
switch (row.type) {
|
|
238
|
+
case 'user/message':
|
|
239
|
+
return [['source']]
|
|
240
|
+
case 'assistant/message':
|
|
241
|
+
case 'tool/result':
|
|
242
|
+
return [['message', 'source']]
|
|
243
|
+
case 'agent/inbox/spliced':
|
|
244
|
+
return memberSourcePaths(row.data, 'inserted')
|
|
245
|
+
case 'session/title-llm-request':
|
|
246
|
+
return memberSourcePaths(row.data, 'messages')
|
|
247
|
+
default:
|
|
248
|
+
return []
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function memberSourcePaths(data: Record<string, unknown>, member: string): SourcePath[] {
|
|
253
|
+
const list = data[member]
|
|
254
|
+
if (!Array.isArray(list)) return []
|
|
255
|
+
return list.map((_member, index) => [member, index, 'source'])
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function pathLabel(path: SourcePath): string {
|
|
259
|
+
return path.map((step) => String(step)).join('.')
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function readPath(root: unknown, path: SourcePath): unknown {
|
|
263
|
+
let node = root
|
|
264
|
+
for (const step of path) {
|
|
265
|
+
if (typeof step === 'number') {
|
|
266
|
+
if (!Array.isArray(node)) return undefined
|
|
267
|
+
node = node[step]
|
|
268
|
+
} else {
|
|
269
|
+
if (!isRecord(node)) return undefined
|
|
270
|
+
node = node[step]
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return node
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Copy-as-you-go write at `path`: every container on the path is re-created, so
|
|
278
|
+
* the input tree is never mutated. Only called for a path `readPath` resolved.
|
|
279
|
+
*/
|
|
280
|
+
function writePath(node: unknown, path: SourcePath, value: unknown): unknown {
|
|
281
|
+
const [step, ...rest] = path
|
|
282
|
+
if (typeof step === 'number') {
|
|
283
|
+
const copy: unknown[] = Array.isArray(node) ? [...node] : []
|
|
284
|
+
copy[step] = rest.length === 0 ? value : writePath(copy[step], rest, value)
|
|
285
|
+
return copy
|
|
286
|
+
}
|
|
287
|
+
const copy: Record<string, unknown> = isRecord(node) ? { ...node } : {}
|
|
288
|
+
copy[step] = rest.length === 0 ? value : writePath(copy[step], rest, value)
|
|
289
|
+
return copy
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** A source is foreign when its `kind` is absent or outside the vocabulary. */
|
|
293
|
+
function isForeignKind(value: unknown): boolean {
|
|
294
|
+
return !isReleasedSourceKind(value)
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Rewrite one foreign source into the released `plugin` arm, or explain why its
|
|
299
|
+
* shape cannot be carried over:
|
|
300
|
+
* - `plugin` records the original kind (stable identity, DSH's own
|
|
301
|
+
* precedent);
|
|
302
|
+
* - `form` / `summary` / `sections` survive when the released
|
|
303
|
+
* `pluginSourceValue` admits the combination (`summary` with the `notice`
|
|
304
|
+
* form, `sections` with `snapshot` as exact `{ name, text }` members);
|
|
305
|
+
* - every other member is dropped, because the released arm admits none.
|
|
306
|
+
*
|
|
307
|
+
* This is a CONSERVATIVE SUBSET of that released arm, deliberately stricter:
|
|
308
|
+
* `pluginSourceValue` returns early when `form` is absent, so it would admit a
|
|
309
|
+
* foreign `summary` / `sections` payload with no `form` at all — this rewrite
|
|
310
|
+
* refuses those instead, because the missing `form` is what gave those members
|
|
311
|
+
* their meaning, and a rewrite whose result is not provably the same message is
|
|
312
|
+
* a rewrite this rule must not make.
|
|
313
|
+
*/
|
|
314
|
+
function toPluginSource(source: Record<string, unknown>): { source: Record<string, unknown> } | { refusal: string } {
|
|
315
|
+
const kind = source['kind']
|
|
316
|
+
if (!isNonEmptyString(kind)) {
|
|
317
|
+
return { refusal: `source.kind ${describe(kind)} is not a non-empty string, so no stable plugin id can replace it` }
|
|
318
|
+
}
|
|
319
|
+
if (kind === 'compact') {
|
|
320
|
+
// `compact` is a reserved plugin id whose arm additionally requires
|
|
321
|
+
// compactionId / sourceCommandId — members this rewrite drops.
|
|
322
|
+
return { refusal: 'source.kind "compact" is the reserved compact plugin id, whose arm requires dropped members' }
|
|
323
|
+
}
|
|
324
|
+
const form = source['form']
|
|
325
|
+
if (form !== undefined && !(typeof form === 'string' && RELEASED_PLUGIN_FORMS.has(form))) {
|
|
326
|
+
return { refusal: `source.form ${describe(form)} is outside the released plugin forms, so the form cannot be preserved` }
|
|
327
|
+
}
|
|
328
|
+
const summary = source['summary']
|
|
329
|
+
if (summary !== undefined && (form !== 'notice' || typeof summary !== 'string')) {
|
|
330
|
+
return { refusal: 'source.summary is admitted only by the notice form, as a string' }
|
|
331
|
+
}
|
|
332
|
+
const sections = source['sections']
|
|
333
|
+
if (sections !== undefined && (form !== 'snapshot' || !isSnapshotSections(sections))) {
|
|
334
|
+
return { refusal: 'source.sections are admitted only by the snapshot form, as { name, text } members' }
|
|
335
|
+
}
|
|
336
|
+
if (form === 'snapshot' && sections === undefined) return { refusal: 'the snapshot form requires source.sections' }
|
|
337
|
+
if (form === 'notice' && summary === undefined) return { refusal: 'the notice form requires source.summary' }
|
|
338
|
+
|
|
339
|
+
const next: Record<string, unknown> = { kind: 'plugin', plugin: kind }
|
|
340
|
+
if (form !== undefined) next['form'] = form
|
|
341
|
+
if (summary !== undefined) next['summary'] = summary
|
|
342
|
+
if (sections !== undefined) next['sections'] = sections
|
|
343
|
+
return { source: next }
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** `pluginSourceValue` admits snapshot sections as exactly `{ name, text }`. */
|
|
347
|
+
function isSnapshotSections(value: unknown): boolean {
|
|
348
|
+
if (!Array.isArray(value)) return false
|
|
349
|
+
return value.every(
|
|
350
|
+
(section) =>
|
|
351
|
+
isRecord(section) &&
|
|
352
|
+
Object.keys(section).length === 2 &&
|
|
353
|
+
isNonEmptyString(section['name']) &&
|
|
354
|
+
typeof section['text'] === 'string',
|
|
355
|
+
)
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function detectSourceKinds(rows: readonly ParsedRow[]): Finding[] {
|
|
359
|
+
const findings: Finding[] = []
|
|
360
|
+
for (const row of rows) {
|
|
361
|
+
for (const path of sourcePaths(row)) {
|
|
362
|
+
const source = readPath(row.data, path)
|
|
363
|
+
if (!isRecord(source) || !isForeignKind(source['kind'])) continue
|
|
364
|
+
findings.push({
|
|
365
|
+
ruleId: 'source-kind',
|
|
366
|
+
class: 'source-kind',
|
|
367
|
+
detail: `${row.type} ${row.seq} ${pathLabel(path)} carries source.kind ${describe(source['kind'])}, outside the released first-party vocabulary`,
|
|
368
|
+
})
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
return findings
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function normalizeSourceKinds(
|
|
375
|
+
rows: readonly ParsedRow[],
|
|
376
|
+
): { rows: ParsedRow[]; findings: Finding[] } | { refused: string } {
|
|
377
|
+
const out: ParsedRow[] = []
|
|
378
|
+
const findings: Finding[] = []
|
|
379
|
+
for (const row of rows) {
|
|
380
|
+
let next = row
|
|
381
|
+
for (const path of sourcePaths(row)) {
|
|
382
|
+
const source = readPath(next.data, path)
|
|
383
|
+
if (!isRecord(source) || !isForeignKind(source['kind'])) continue
|
|
384
|
+
const rewritten = toPluginSource(source)
|
|
385
|
+
if ('refusal' in rewritten) {
|
|
386
|
+
return { refused: `${row.type} ${row.seq} ${pathLabel(path)}: ${rewritten.refusal}` }
|
|
387
|
+
}
|
|
388
|
+
next = { ...next, data: writePath(next.data, path, rewritten.source) }
|
|
389
|
+
findings.push({
|
|
390
|
+
ruleId: 'source-kind',
|
|
391
|
+
class: 'source-kind',
|
|
392
|
+
detail: `${row.type} ${row.seq} ${pathLabel(path)}: source.kind ${describe(source['kind'])} → { kind: "plugin", plugin: ${JSON.stringify(source['kind'])} }`,
|
|
393
|
+
})
|
|
394
|
+
}
|
|
395
|
+
out.push(next)
|
|
396
|
+
}
|
|
397
|
+
return { rows: out, findings }
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/* ------------------------------------------------------------------ */
|
|
401
|
+
/* subagent-descriptor-version */
|
|
402
|
+
/* ------------------------------------------------------------------ */
|
|
403
|
+
|
|
404
|
+
const SUBAGENT_DESCRIPTOR_TYPE = 'subagent/descriptor'
|
|
405
|
+
const RELEASED_DESCRIPTOR_VERSION = 3
|
|
406
|
+
|
|
407
|
+
type DescriptorMode = 'one-shot' | 'continuable'
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Version-3 admitted descriptor payload keys per `mode`.
|
|
411
|
+
*
|
|
412
|
+
* SSOT: `packages/subagent/subagent/src/descriptor.ts` — `DESCRIPTOR_BASE_KEYS`
|
|
413
|
+
* is `version, mode, provider, label`; `CONTINUABLE_DESCRIPTOR_KEYS` adds
|
|
414
|
+
* `agentProvider, agentModel, agentReasoningEffort, persona, toolFilter`.
|
|
415
|
+
*
|
|
416
|
+
* The observed legacy payloads are subsets of these sets, which is what makes a
|
|
417
|
+
* `2 → 3` bump shape-preserving:
|
|
418
|
+
* - `2|one-shot|{label, mode, provider, version}`
|
|
419
|
+
* - `2|continuable|{agentModel, agentProvider, label, mode, provider, version}`
|
|
420
|
+
*/
|
|
421
|
+
export const DESCRIPTOR_V3_KEYS_BY_MODE: Readonly<Record<DescriptorMode, readonly string[]>> = {
|
|
422
|
+
'one-shot': ['version', 'mode', 'provider', 'label'],
|
|
423
|
+
continuable: [
|
|
424
|
+
'version',
|
|
425
|
+
'mode',
|
|
426
|
+
'provider',
|
|
427
|
+
'label',
|
|
428
|
+
'agentProvider',
|
|
429
|
+
'agentModel',
|
|
430
|
+
'agentReasoningEffort',
|
|
431
|
+
'persona',
|
|
432
|
+
'toolFilter',
|
|
433
|
+
],
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Prove that a descriptor payload of an older `version` becomes admissible by
|
|
438
|
+
* changing `version` to 3 and nothing else. Key-subset membership is the
|
|
439
|
+
* primary proof; the value checks mirror the released
|
|
440
|
+
* `subagentDescriptorValue` so a bump can never trade one refusal for another.
|
|
441
|
+
* `null` means "provably admissible".
|
|
442
|
+
*/
|
|
443
|
+
function descriptorBumpRefusal(data: Record<string, unknown>): string | null {
|
|
444
|
+
const mode = data['mode']
|
|
445
|
+
if (mode !== 'one-shot' && mode !== 'continuable') {
|
|
446
|
+
return `mode ${describe(mode)} is neither of the released descriptor modes`
|
|
447
|
+
}
|
|
448
|
+
const admitted = DESCRIPTOR_V3_KEYS_BY_MODE[mode]
|
|
449
|
+
const unexpected = Object.keys(data).filter((key) => !admitted.includes(key))
|
|
450
|
+
if (unexpected.length > 0) {
|
|
451
|
+
return `payload key(s) ${unexpected.join(', ')} are outside the version-3 admitted set for mode ${mode} (${admitted.join(', ')})`
|
|
452
|
+
}
|
|
453
|
+
if (!isNonEmptyString(data['provider'])) return 'provider is not a non-empty string'
|
|
454
|
+
if (mode === 'one-shot') {
|
|
455
|
+
if (data['label'] !== undefined && typeof data['label'] !== 'string') return 'label is not a string'
|
|
456
|
+
return null
|
|
457
|
+
}
|
|
458
|
+
if (!isNonEmptyString(data['label'])) return 'label is not a non-empty string'
|
|
459
|
+
for (const key of ['agentProvider', 'agentModel', 'agentReasoningEffort', 'persona']) {
|
|
460
|
+
if (data[key] !== undefined && !isNonEmptyString(data[key])) return `${key} is not a non-empty string`
|
|
461
|
+
}
|
|
462
|
+
if ((data['agentProvider'] === undefined) !== (data['agentModel'] === undefined)) {
|
|
463
|
+
return 'agentProvider and agentModel must be paired'
|
|
464
|
+
}
|
|
465
|
+
return toolFilterRefusal(data['toolFilter'])
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/** `subagentDescriptorValue` admits `toolFilter` as a non-empty allow/deny. */
|
|
469
|
+
function toolFilterRefusal(value: unknown): string | null {
|
|
470
|
+
if (value === undefined) return null
|
|
471
|
+
if (!isRecord(value)) return 'toolFilter is not an object'
|
|
472
|
+
if (Object.keys(value).some((key) => key !== 'allow' && key !== 'deny')) {
|
|
473
|
+
return 'toolFilter admits only the allow/deny members'
|
|
474
|
+
}
|
|
475
|
+
if (value['allow'] === undefined && value['deny'] === undefined) {
|
|
476
|
+
return 'toolFilter requires allow or deny'
|
|
477
|
+
}
|
|
478
|
+
for (const key of ['allow', 'deny']) {
|
|
479
|
+
const list = value[key]
|
|
480
|
+
if (list === undefined) continue
|
|
481
|
+
if (!Array.isArray(list) || !list.every(isNonEmptyString)) {
|
|
482
|
+
return `toolFilter ${key} is not an array of non-empty strings`
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
return null
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function detectDescriptorVersions(rows: readonly ParsedRow[]): Finding[] {
|
|
489
|
+
const findings: Finding[] = []
|
|
490
|
+
for (const row of rows) {
|
|
491
|
+
if (row.type !== SUBAGENT_DESCRIPTOR_TYPE) continue
|
|
492
|
+
if (!isRecord(row.data) || row.data['version'] === RELEASED_DESCRIPTOR_VERSION) continue
|
|
493
|
+
findings.push({
|
|
494
|
+
ruleId: 'subagent-descriptor-version',
|
|
495
|
+
class: 'subagent-descriptor-version',
|
|
496
|
+
detail: `${SUBAGENT_DESCRIPTOR_TYPE} ${row.seq} uses unsupported descriptor version ${describe(row.data['version'])}`,
|
|
497
|
+
})
|
|
498
|
+
}
|
|
499
|
+
return findings
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function normalizeDescriptorVersions(
|
|
503
|
+
rows: readonly ParsedRow[],
|
|
504
|
+
): { rows: ParsedRow[]; findings: Finding[] } | { refused: string } {
|
|
505
|
+
const out: ParsedRow[] = []
|
|
506
|
+
const findings: Finding[] = []
|
|
507
|
+
for (const row of rows) {
|
|
508
|
+
if (row.type !== SUBAGENT_DESCRIPTOR_TYPE) {
|
|
509
|
+
out.push(row)
|
|
510
|
+
continue
|
|
511
|
+
}
|
|
512
|
+
const data = row.data
|
|
513
|
+
if (!isRecord(data) || data['version'] === RELEASED_DESCRIPTOR_VERSION) {
|
|
514
|
+
out.push(row)
|
|
515
|
+
continue
|
|
516
|
+
}
|
|
517
|
+
const version = data['version']
|
|
518
|
+
if (version !== 1 && version !== 2) {
|
|
519
|
+
return {
|
|
520
|
+
refused: `${SUBAGENT_DESCRIPTOR_TYPE} ${row.seq}: version ${describe(version)} is not a released earlier version (1 or 2), so a bump to ${RELEASED_DESCRIPTOR_VERSION} is not provable`,
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
const reason = descriptorBumpRefusal(data)
|
|
524
|
+
if (reason !== null) {
|
|
525
|
+
return { refused: `${SUBAGENT_DESCRIPTOR_TYPE} ${row.seq}: ${reason}` }
|
|
526
|
+
}
|
|
527
|
+
out.push({ ...row, data: { ...data, version: RELEASED_DESCRIPTOR_VERSION } })
|
|
528
|
+
findings.push({
|
|
529
|
+
ruleId: 'subagent-descriptor-version',
|
|
530
|
+
class: 'subagent-descriptor-version',
|
|
531
|
+
detail: `${SUBAGENT_DESCRIPTOR_TYPE} ${row.seq}: version ${describe(version)} → ${RELEASED_DESCRIPTOR_VERSION} (payload keys are a subset of the version-3 ${describe(data['mode'])} set)`,
|
|
532
|
+
})
|
|
533
|
+
}
|
|
534
|
+
return { rows: out, findings }
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/* ------------------------------------------------------------------ */
|
|
538
|
+
/* fallbacks-switch (legacy, never repairable by default) */
|
|
539
|
+
/* ------------------------------------------------------------------ */
|
|
540
|
+
|
|
541
|
+
const FALLBACKS_SWITCH_TYPE = 'fallbacks/switch'
|
|
542
|
+
|
|
543
|
+
const FALLBACKS_SWITCH_REFUSAL =
|
|
544
|
+
'fallbacks/switch is an unknown historical event type: the frozen V0→V1 edge refuses unknown historical events even when the row carries ignorable: true, so no rewrite can make such a log load'
|
|
545
|
+
|
|
546
|
+
function detectFallbacksSwitch(rows: readonly ParsedRow[]): Finding[] {
|
|
547
|
+
return rows
|
|
548
|
+
.filter((row) => row.type === FALLBACKS_SWITCH_TYPE)
|
|
549
|
+
.map((row) => ({
|
|
550
|
+
ruleId: 'fallbacks-switch',
|
|
551
|
+
class: 'unknown-event-type' as const,
|
|
552
|
+
detail: `${FALLBACKS_SWITCH_TYPE} ${row.seq} is outside the released event inventory; ${FALLBACKS_SWITCH_REFUSAL}`,
|
|
553
|
+
}))
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/* ------------------------------------------------------------------ */
|
|
557
|
+
/* drop-legacy-events (OPT-IN, lossy, never in the registry) */
|
|
558
|
+
/* ------------------------------------------------------------------ */
|
|
559
|
+
|
|
560
|
+
const DROP_LEGACY_EVENTS_RULE_ID = 'drop-legacy-events'
|
|
561
|
+
|
|
562
|
+
const DROP_LEGACY_EVENTS_LOSS =
|
|
563
|
+
'no rewrite can make a fallbacks/switch row load, so the row can only be removed — which loses the '
|
|
564
|
+
+ 'provider/model switch audit entry it carries'
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* Whether one PARSED row is a removable legacy event.
|
|
568
|
+
*
|
|
569
|
+
* The decision is the parsed record's own `type` member compared for exact
|
|
570
|
+
* equality — never a substring/pattern match on the serialized line, because the
|
|
571
|
+
* string `fallbacks/switch` legitimately occurs inside user data and inside
|
|
572
|
+
* message bodies. A row that did not parse never reaches a rule at all: the
|
|
573
|
+
* reader refuses the whole log (`decompress-failed`) before any rule runs.
|
|
574
|
+
*/
|
|
575
|
+
function isRemovableLegacyEvent(row: ParsedRow): boolean {
|
|
576
|
+
return row.type === FALLBACKS_SWITCH_TYPE
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function detectDroppableLegacyEvents(rows: readonly ParsedRow[]): Finding[] {
|
|
580
|
+
return rows
|
|
581
|
+
.filter(isRemovableLegacyEvent)
|
|
582
|
+
.map((row) => ({
|
|
583
|
+
ruleId: DROP_LEGACY_EVENTS_RULE_ID,
|
|
584
|
+
class: 'unknown-event-type' as const,
|
|
585
|
+
detail: `${FALLBACKS_SWITCH_TYPE} ${row.seq} is dropped, not repaired: ${DROP_LEGACY_EVENTS_LOSS}`,
|
|
586
|
+
}))
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* The drop's own proof, run over the rule's own input/output pair rather than
|
|
591
|
+
* assumed from the loop that produced it: walk the input rows in order, re-
|
|
592
|
+
* serialize each one (`JSON.stringify`) and match it against the next survivor.
|
|
593
|
+
* A survivor whose bytes differ from the row it came from, or a removed row that
|
|
594
|
+
* is NOT a parsed `fallbacks/switch` row, refuses the whole drop — so the
|
|
595
|
+
* survivor count is provably `input − dropped`.
|
|
596
|
+
*
|
|
597
|
+
* Exported because this is the rule's load-bearing safety property: the pinning
|
|
598
|
+
* tests feed it a tampered survivor / a removed foreign row directly, which the
|
|
599
|
+
* rule's own construction path can never produce.
|
|
600
|
+
*
|
|
601
|
+
* @returns the reason to refuse, or `null` when the drop is proven.
|
|
602
|
+
*/
|
|
603
|
+
export function legacyDropRefusal(
|
|
604
|
+
input: readonly ParsedRow[],
|
|
605
|
+
survivors: readonly ParsedRow[],
|
|
606
|
+
): string | null {
|
|
607
|
+
let cursor = 0
|
|
608
|
+
for (const [index, row] of input.entries()) {
|
|
609
|
+
const survivor = survivors[cursor]
|
|
610
|
+
if (survivor !== undefined && JSON.stringify(survivor) === JSON.stringify(row)) {
|
|
611
|
+
cursor += 1
|
|
612
|
+
continue
|
|
613
|
+
}
|
|
614
|
+
if (isRemovableLegacyEvent(row)) continue
|
|
615
|
+
return `row ${index + 1} (${row.type}) is neither kept byte-identically nor a removable ${FALLBACKS_SWITCH_TYPE} row, so it must not be dropped`
|
|
616
|
+
}
|
|
617
|
+
if (cursor !== survivors.length) {
|
|
618
|
+
return `the drop produced ${survivors.length} survivor(s) from ${input.length} row(s), but only ${cursor} match the input in order`
|
|
619
|
+
}
|
|
620
|
+
return null
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Split one log into the drop's candidate rows and the survivors it keeps —
|
|
625
|
+
* `rows` minus every parsed legacy row, in order, with the survivor rows
|
|
626
|
+
* themselves untouched.
|
|
627
|
+
*
|
|
628
|
+
* This is the ONLY place the drop's row policy is written down: `normalize` and
|
|
629
|
+
* any caller that wants to re-derive the renumber (the CLI's pre-write proof does)
|
|
630
|
+
* both go through it, so neither can disagree about which rows are removable.
|
|
631
|
+
*
|
|
632
|
+
* @param rows parsed log rows (header first).
|
|
633
|
+
* @returns the survivors (byte-identical source rows) and the dropped row count.
|
|
634
|
+
*/
|
|
635
|
+
export function legacyDropSplit(rows: readonly ParsedRow[]): { survivors: ParsedRow[]; dropped: number } {
|
|
636
|
+
const survivors = rows.filter((row) => !isRemovableLegacyEvent(row))
|
|
637
|
+
return { survivors, dropped: rows.length - survivors.length }
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* Remove every `fallbacks/switch` row and nothing else, in row order; one
|
|
642
|
+
* finding per removed row, so `findings.length` IS the dropped-event count (see
|
|
643
|
+
* {@link droppedEventCount}).
|
|
644
|
+
*
|
|
645
|
+
* All-or-nothing: when {@link legacyDropRefusal} cannot prove the survivors
|
|
646
|
+
* byte-identical to their source rows the rule refuses, and the caller writes
|
|
647
|
+
* nothing. The drop itself is not enough to make such a log load — the released
|
|
648
|
+
* V0→V1 codec requires `seq === eventCount` for every row — so the drop is
|
|
649
|
+
* followed by {@link renumberSurvivingEvents}, which renumbers the survivors,
|
|
650
|
+
* refuses to change what any surviving Session-seq reference points at, and
|
|
651
|
+
* proves every written byte is one of its audited remap steps.
|
|
652
|
+
*/
|
|
653
|
+
function normalizeDroppedLegacyEvents(
|
|
654
|
+
rows: readonly ParsedRow[],
|
|
655
|
+
): { rows: ParsedRow[]; findings: Finding[] } | { refused: string } {
|
|
656
|
+
const { survivors } = legacyDropSplit(rows)
|
|
657
|
+
const refusal = legacyDropRefusal(rows, survivors)
|
|
658
|
+
if (refusal !== null) return { refused: refusal }
|
|
659
|
+
const renumbered = renumberSurvivingEvents(rows, survivors)
|
|
660
|
+
if ('refused' in renumbered) return { refused: renumbered.refused }
|
|
661
|
+
return { rows: renumbered.rows, findings: detectDroppableLegacyEvents(rows) }
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* The rows the opt-in lossy rule removes from one log: its `normalize` reports
|
|
666
|
+
* exactly one finding per dropped row, so counting them is exact.
|
|
667
|
+
*/
|
|
668
|
+
export function droppedEventCount(findings: readonly Finding[]): number {
|
|
669
|
+
return findings.filter((finding) => finding.ruleId === DROP_LEGACY_EVENTS_RULE_ID).length
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/* ------------------------------------------------------------------ */
|
|
673
|
+
/* the Session-seq reference surface (the renumber's field list) */
|
|
674
|
+
/* ------------------------------------------------------------------ */
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* Row-relative JSON path of one audited seq member. The `SourcePath` alias is
|
|
678
|
+
* the same structural type; naming it here keeps the intent readable.
|
|
679
|
+
*/
|
|
680
|
+
type SeqPath = SourcePath
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* Packed Assistant runs of the released v0 physical layout.
|
|
684
|
+
*
|
|
685
|
+
* SSOT: `PACKED_TAGS` in `session-format-v0-to-v1/src/codec.ts`. A packed row
|
|
686
|
+
* carries `seq0` (the FIRST event seq of the run) and no `seq` at all; the number
|
|
687
|
+
* of events it occupies IS its payload length (`data.texts` for `text-chunks` /
|
|
688
|
+
* `reasoning-chunks`, `data.args` for `tool-call-chunks`), and the codec advances
|
|
689
|
+
* its running count by exactly that (`codec.ts` `eventCount += run.eventCount`).
|
|
690
|
+
*/
|
|
691
|
+
const PACKED_RUN_TYPES: ReadonlySet<string> = new Set(['text-chunks', 'reasoning-chunks', 'tool-call-chunks'])
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* One audited Session-seq reference inside one row.
|
|
695
|
+
*
|
|
696
|
+
* `positions` is what the renumber REWRITES; `spans` is every old seq the
|
|
697
|
+
* reference NAMES (a `[start, end]` range covers its whole interior, not just its
|
|
698
|
+
* endpoints), which is what the integrity gate tests against the dropped seqs.
|
|
699
|
+
*/
|
|
700
|
+
interface SeqReference {
|
|
701
|
+
/** Diagnostic label, e.g. `sourceEventSeqs.3` or `data.messageSeqs.0`. */
|
|
702
|
+
label: string
|
|
703
|
+
/** The writable members of this reference (a range has two). */
|
|
704
|
+
positions: readonly { path: SeqPath; value: number }[]
|
|
705
|
+
/** Inclusive old-seq spans this reference names. */
|
|
706
|
+
spans: readonly { start: number; end: number }[]
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/** The physical envelope member one row carries (`ParsedRow` is the logical view). */
|
|
710
|
+
function envelopeValue(row: ParsedRow, member: string): unknown {
|
|
711
|
+
return (row as unknown as Record<string, unknown>)[member]
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/** Whether one row is a packed Assistant run rather than one single-event row. */
|
|
715
|
+
function isPackedRun(row: ParsedRow): boolean {
|
|
716
|
+
return PACKED_RUN_TYPES.has(row.type)
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/** Events one row occupies, or `null` when a packed run's payload is malformed. */
|
|
720
|
+
function rowEventCount(row: ParsedRow): number | null {
|
|
721
|
+
if (!isPackedRun(row)) return 1
|
|
722
|
+
const data = row.data
|
|
723
|
+
if (!isRecord(data)) return null
|
|
724
|
+
const payload = data[row.type === 'tool-call-chunks' ? 'args' : 'texts']
|
|
725
|
+
// The released codec requires a NON-EMPTY string array; anything else is a row
|
|
726
|
+
// it refuses outright, so this rule models no extent for it.
|
|
727
|
+
return Array.isArray(payload) && payload.length > 0 ? payload.length : null
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/** The event position one row DECLARES (`seq`, or `seq0` for a packed run). */
|
|
731
|
+
function declaredEventSeq(row: ParsedRow): unknown {
|
|
732
|
+
return isPackedRun(row) ? envelopeValue(row, 'seq0') : row.seq
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/** The physical record of one row, mirroring `rowRecord` in `catalog.ts`. */
|
|
736
|
+
function rowPayload(row: ParsedRow): unknown {
|
|
737
|
+
const data = row.data
|
|
738
|
+
if (row.type === 'session' && isRecord(data) && typeof data['version'] === 'number' && typeof data['id'] === 'string') {
|
|
739
|
+
return data
|
|
740
|
+
}
|
|
741
|
+
return row
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* The header's seed cut: the number of leading events the released V0 header
|
|
746
|
+
* declares as inherited (`seedLength`, `codec.ts` `decodePhysicalHeader`).
|
|
747
|
+
* Events below it belong to the inherited lifecycle, so renumbering across it
|
|
748
|
+
* would silently re-classify the first own event as inherited.
|
|
749
|
+
*/
|
|
750
|
+
function headerSeedCut(rows: readonly ParsedRow[]): number {
|
|
751
|
+
const header = rows[0]
|
|
752
|
+
if (header === undefined) return 0
|
|
753
|
+
const payload = rowPayload(header)
|
|
754
|
+
if (!isRecord(payload)) return 0
|
|
755
|
+
const seedLength = payload['seedLength']
|
|
756
|
+
return typeof seedLength === 'number' ? seedLength : 0
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* Every Session-seq reference the released reader resolves inside one row.
|
|
761
|
+
*
|
|
762
|
+
* The field list is the union of the two released remappers and the validators
|
|
763
|
+
* that reject what they miss:
|
|
764
|
+
* - `row.seq` / `row.seq0` — the row's own coordinate (see the renumber below);
|
|
765
|
+
* - `row.sourceEventSeqs` — absolute seqs, `[start, end]` ranges allowed
|
|
766
|
+
* (`codec.ts` `decodeSeqRanges`), each `< row.seq` and unique
|
|
767
|
+
* (`validation.ts` `assertReleasedSurfaceMetadata`), must cover the shadowed
|
|
768
|
+
* surface span (`relationships.ts` `applySurface`);
|
|
769
|
+
* - `row.surfaceOp.start` / `.end` (a `{ op: 'replace', … }` marker) — same
|
|
770
|
+
* family, `< row.seq` (`validation.ts`), must be on the current surface
|
|
771
|
+
* (`relationships.ts`) and index the message-id map by seq (`migration.ts`);
|
|
772
|
+
* - `data.sourceEventSeq` — `command/done`, an event index
|
|
773
|
+
* (`payload-validation.ts` `earlierSeq`, `relationships.ts`);
|
|
774
|
+
* - `data.shadowedRange.start` / `.end` and `data.shadowedSeqs` —
|
|
775
|
+
* `compaction/prune` / `compaction/summary` (`payload-validation.ts`
|
|
776
|
+
* `shadowedValue`, `relationships.ts` `assertCurrentSurfaceSpan`);
|
|
777
|
+
* - `data.messageSeqs` — `session/title` / `session/title-llm-request`
|
|
778
|
+
* (`payload-validation.ts` `seqArray`, `relationships.ts` `assertTitleSources`).
|
|
779
|
+
*
|
|
780
|
+
* Deliberately NOT references:
|
|
781
|
+
* - `data.throughSeq` (`session-log-deepseek/delivery-accepted`) — a cursor over
|
|
782
|
+
* THIS log's own delivered stream: `session-log-deepseek/src/index.ts:119-148`
|
|
783
|
+
* folds it as an accepted-sequence watermark and `:168` emits the last seq of
|
|
784
|
+
* this snapshot, and the shipped invariant is exactly "must identify an earlier
|
|
785
|
+
* event" (`session-log-deepseek/src/invariant.ts:34-44`). It is left opaque
|
|
786
|
+
* because the released remappers do not shift it
|
|
787
|
+
* (`session-format-v2-to-v3/src/references.ts:31-50` — the same switch that
|
|
788
|
+
* renumbers the seven members above — and its V1→V2 twin
|
|
789
|
+
* `session-format-v1-to-v2/src/migration.ts:566-661`), the released V2→V3
|
|
790
|
+
* migration only *inspects* the delivery marker (`migration.ts:64-67,88-91`),
|
|
791
|
+
* and an invalid value is refused by `earlierSeq`
|
|
792
|
+
* (`payload-validation.ts:179`) → the pre-write strict restore fails the whole
|
|
793
|
+
* file closed (reason `other`). Known residual, deliberately not code-guarded:
|
|
794
|
+
* a drop *preceding* a still-valid `throughSeq` leaves it naming a different,
|
|
795
|
+
* later event (0 occurrences in the measured corpus).
|
|
796
|
+
* - `data.seq` (`tool-workflow/*`) — a workflow-local counter;
|
|
797
|
+
* - `data.start` (`agent/inbox/spliced`) — an inbox position, only counted
|
|
798
|
+
* (`payload-validation.ts` `countValue`), never used as an event index.
|
|
799
|
+
* A member that is present but not a safe integer is skipped: the released codec
|
|
800
|
+
* refuses such a row outright, and this rule must not "repair" a reference it
|
|
801
|
+
* cannot read. A Session seq is non-negative by contract, but these scalars are
|
|
802
|
+
* admitted as any safe integer, so an already-invalid NEGATIVE member (`-1`) is
|
|
803
|
+
* enumerated too and the remap's boundary check is one-sided for it (it passes
|
|
804
|
+
* `after < newSeq`, is skipped because `after === value`, and is written nowhere
|
|
805
|
+
* — see {@link renumberSurvivingEvents}). Not a regression: neither this
|
|
806
|
+
* enumeration nor the remap can create one, and the CLI's strict pre-write
|
|
807
|
+
* restore refuses such a row upstream.
|
|
808
|
+
*/
|
|
809
|
+
function seqReferences(row: ParsedRow): SeqReference[] {
|
|
810
|
+
const references: SeqReference[] = []
|
|
811
|
+
const addScalar = (path: SeqPath, value: unknown): void => {
|
|
812
|
+
if (typeof value !== 'number' || !Number.isSafeInteger(value)) return
|
|
813
|
+
references.push({ label: pathLabel(path), positions: [{ path, value }], spans: [{ start: value, end: value }] })
|
|
814
|
+
}
|
|
815
|
+
const sourceEventSeqs = envelopeValue(row, 'sourceEventSeqs')
|
|
816
|
+
if (Array.isArray(sourceEventSeqs)) {
|
|
817
|
+
sourceEventSeqs.forEach((entry, index) => {
|
|
818
|
+
const path: SeqPath = ['sourceEventSeqs', index]
|
|
819
|
+
const endpoints = Array.isArray(entry) && entry.length === 2 ? entry : null
|
|
820
|
+
if (endpoints === null) {
|
|
821
|
+
addScalar(path, entry)
|
|
822
|
+
return
|
|
823
|
+
}
|
|
824
|
+
const [start, end] = endpoints as [unknown, unknown]
|
|
825
|
+
if (typeof start !== 'number' || typeof end !== 'number'
|
|
826
|
+
|| !Number.isSafeInteger(start) || !Number.isSafeInteger(end) || end < start) {
|
|
827
|
+
return
|
|
828
|
+
}
|
|
829
|
+
references.push({
|
|
830
|
+
label: pathLabel(path),
|
|
831
|
+
positions: [{ path: [...path, 0], value: start }, { path: [...path, 1], value: end }],
|
|
832
|
+
spans: [{ start, end }],
|
|
833
|
+
})
|
|
834
|
+
})
|
|
835
|
+
}
|
|
836
|
+
const operation = envelopeValue(row, 'surfaceOp')
|
|
837
|
+
if (isRecord(operation) && operation['op'] === 'replace') {
|
|
838
|
+
addScalar(['surfaceOp', 'start'], operation['start'])
|
|
839
|
+
addScalar(['surfaceOp', 'end'], operation['end'])
|
|
840
|
+
}
|
|
841
|
+
const data = row.data
|
|
842
|
+
if (!isRecord(data)) return references
|
|
843
|
+
switch (row.type) {
|
|
844
|
+
case 'command/done':
|
|
845
|
+
addScalar(['data', 'sourceEventSeq'], data['sourceEventSeq'])
|
|
846
|
+
break
|
|
847
|
+
case 'compaction/prune':
|
|
848
|
+
case 'compaction/summary': {
|
|
849
|
+
const range = data['shadowedRange']
|
|
850
|
+
if (isRecord(range)) {
|
|
851
|
+
addScalar(['data', 'shadowedRange', 'start'], range['start'])
|
|
852
|
+
addScalar(['data', 'shadowedRange', 'end'], range['end'])
|
|
853
|
+
}
|
|
854
|
+
const shadowedSeqs = data['shadowedSeqs']
|
|
855
|
+
if (Array.isArray(shadowedSeqs)) {
|
|
856
|
+
shadowedSeqs.forEach((value, index) => addScalar(['data', 'shadowedSeqs', index], value))
|
|
857
|
+
}
|
|
858
|
+
break
|
|
859
|
+
}
|
|
860
|
+
case 'session/title':
|
|
861
|
+
case 'session/title-llm-request': {
|
|
862
|
+
const messageSeqs = data['messageSeqs']
|
|
863
|
+
if (Array.isArray(messageSeqs)) {
|
|
864
|
+
messageSeqs.forEach((value, index) => addScalar(['data', 'messageSeqs', index], value))
|
|
865
|
+
}
|
|
866
|
+
break
|
|
867
|
+
}
|
|
868
|
+
default:
|
|
869
|
+
break
|
|
870
|
+
}
|
|
871
|
+
return references
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
/** One leaf difference between a source row and its renumbered survivor. */
|
|
875
|
+
interface RowDifference {
|
|
876
|
+
path: SeqPath
|
|
877
|
+
before: unknown
|
|
878
|
+
after: unknown
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/**
|
|
882
|
+
* Every leaf difference between two JSON trees, in path order.
|
|
883
|
+
*
|
|
884
|
+
* This is the renumber's byte-identity proof: the renumber writes through
|
|
885
|
+
* {@link writePath} copies, so anything it changed that it did not plan shows up
|
|
886
|
+
* here as an unaudited difference, and any planned write that did not land shows
|
|
887
|
+
* up as a missing one.
|
|
888
|
+
*/
|
|
889
|
+
function rowDifferences(before: unknown, after: unknown, path: SeqPath = []): RowDifference[] {
|
|
890
|
+
if (before === after) return []
|
|
891
|
+
if (Array.isArray(before) && Array.isArray(after)) {
|
|
892
|
+
if (before.length !== after.length) return [{ path, before: before.length, after: after.length }]
|
|
893
|
+
return before.flatMap((member, index) => rowDifferences(member, after[index], [...path, index]))
|
|
894
|
+
}
|
|
895
|
+
if (isRecord(before) && isRecord(after)) {
|
|
896
|
+
const keys = new Set([...Object.keys(before), ...Object.keys(after)])
|
|
897
|
+
return [...keys].flatMap((key) => rowDifferences(before[key], after[key], [...path, key]))
|
|
898
|
+
}
|
|
899
|
+
return [{ path, before, after }]
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
/** Key of one written/observed member, unique per path (paths may contain `.`). */
|
|
903
|
+
function writeKey(path: SeqPath): string {
|
|
904
|
+
return path.map((step) => String(step)).join('\u0000')
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
/** One audited write the renumber performed. */
|
|
908
|
+
interface RenumberWrite {
|
|
909
|
+
path: SeqPath
|
|
910
|
+
before: number
|
|
911
|
+
after: number
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
/** Whether one observed difference IS one audited write. */
|
|
915
|
+
function isWritten(write: RenumberWrite, difference: RowDifference): boolean {
|
|
916
|
+
return writeKey(write.path) === writeKey(difference.path)
|
|
917
|
+
&& write.before === difference.before
|
|
918
|
+
&& write.after === difference.after
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
/** How many dropped events precede `seq` — the monotone shift of that seq. */
|
|
922
|
+
function droppedBefore(dropped: readonly number[], seq: number): number {
|
|
923
|
+
let low = 0
|
|
924
|
+
let high = dropped.length
|
|
925
|
+
while (low < high) {
|
|
926
|
+
const middle = (low + high) >>> 1
|
|
927
|
+
if ((dropped[middle] as number) < seq) low = middle + 1
|
|
928
|
+
else high = middle
|
|
929
|
+
}
|
|
930
|
+
return low
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
/** Prefix of the refusal that means "a survivor names a dropped seq". */
|
|
934
|
+
const REFERENCE_INTEGRITY_REFUSAL_PREFIX = 'reference integrity: '
|
|
935
|
+
|
|
936
|
+
/** Prefix of the refusal that means "the renumber would cross the seed cut". */
|
|
937
|
+
const SEED_CUT_REFUSAL_PREFIX = 'the renumber crosses the header seed cut: '
|
|
938
|
+
|
|
939
|
+
/** Why the opt-in lossy recovery refused one log (see `lossyRefusalReason`). */
|
|
940
|
+
export type LossyRefusalReason = 'reference-integrity' | 'seed-cut' | 'other'
|
|
941
|
+
|
|
942
|
+
/**
|
|
943
|
+
* The category of one lossy refusal, from the rule's own refusal text. The two
|
|
944
|
+
* prefixes are built from the constants above, so this classifies what
|
|
945
|
+
* {@link renumberSurvivingEvents} raises by construction — never by guessing.
|
|
946
|
+
*/
|
|
947
|
+
export function lossyRefusalReason(refusal: string): LossyRefusalReason {
|
|
948
|
+
if (refusal.includes(REFERENCE_INTEGRITY_REFUSAL_PREFIX)) return 'reference-integrity'
|
|
949
|
+
if (refusal.includes(SEED_CUT_REFUSAL_PREFIX)) return 'seed-cut'
|
|
950
|
+
return 'other'
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
/**
|
|
954
|
+
* Renumber the survivors of a proven drop so the released V0→V1 edge accepts
|
|
955
|
+
* them again, or refuse the whole file.
|
|
956
|
+
*
|
|
957
|
+
* WHY: the codec requires `seq === eventCount` for every row, and a packed run's
|
|
958
|
+
* `firstSeq` to equal the event count reached so far, so removing any row that is
|
|
959
|
+
* not at the tail leaves every later row with a stale coordinate. Each surviving
|
|
960
|
+
* event's coordinate becomes its 0-based position in the SURVIVING event stream
|
|
961
|
+
* (a packed run keeps its extent and moves its `seq0`), and every audited
|
|
962
|
+
* reference is shifted monotonically by the number of dropped events before it.
|
|
963
|
+
*
|
|
964
|
+
* The gate runs over the whole file first and is fail closed:
|
|
965
|
+
* - every source row (kept or dropped) must declare exactly the event position
|
|
966
|
+
* it occupies, or the survivors are not densely numbered and no remap is
|
|
967
|
+
* provable;
|
|
968
|
+
* - no surviving reference may name a dropped seq (or cover one inside a
|
|
969
|
+
* `[start, end]` range) — that reference would silently point at a different
|
|
970
|
+
* event after the renumber;
|
|
971
|
+
* - no dropped event may precede the header's seed cut, which would move the
|
|
972
|
+
* first own event into the inherited region.
|
|
973
|
+
* Then every written member is proven to be exactly one audited remap step
|
|
974
|
+
* (see {@link rowDifferences}), and every written reference is re-proven at the
|
|
975
|
+
* remap boundary: it names no dropped seq, and its new coordinate is still an
|
|
976
|
+
* EARLIER surviving event (`after < newSeq`).
|
|
977
|
+
*
|
|
978
|
+
* Exported because this is the load-bearing half of the opt-in lossy rule: the
|
|
979
|
+
* CLI re-derives it over the drop rule's own output before any write.
|
|
980
|
+
*
|
|
981
|
+
* @param input the unmodified log rows (header first), exactly as parsed.
|
|
982
|
+
* @param survivors the drop's output: `input` minus the removable legacy rows.
|
|
983
|
+
* @returns the renumbered rows and how many surviving events moved, or the reason
|
|
984
|
+
* the file must not be written.
|
|
985
|
+
*/
|
|
986
|
+
export function renumberSurvivingEvents(
|
|
987
|
+
input: readonly ParsedRow[],
|
|
988
|
+
survivors: readonly ParsedRow[],
|
|
989
|
+
): { rows: ParsedRow[]; renumberedEventCount: number } | { refused: string } {
|
|
990
|
+
const [header, ...events] = input
|
|
991
|
+
const [survivorHeader, ...survivorEvents] = survivors
|
|
992
|
+
if (header === undefined) return { refused: 'the log carries no header row' }
|
|
993
|
+
if (survivorHeader === undefined || JSON.stringify(survivorHeader) !== JSON.stringify(header)) {
|
|
994
|
+
return { refused: 'the renumber would change or remove the session header row' }
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
interface Kept {
|
|
998
|
+
source: ParsedRow
|
|
999
|
+
survivor: ParsedRow
|
|
1000
|
+
oldSeq: number
|
|
1001
|
+
newSeq: number
|
|
1002
|
+
events: number
|
|
1003
|
+
}
|
|
1004
|
+
const kept: Kept[] = []
|
|
1005
|
+
const dropped: number[] = []
|
|
1006
|
+
let position = 0
|
|
1007
|
+
let newPosition = 0
|
|
1008
|
+
let cursor = 0
|
|
1009
|
+
for (const [offset, row] of events.entries()) {
|
|
1010
|
+
const events_ = rowEventCount(row)
|
|
1011
|
+
if (events_ === null) {
|
|
1012
|
+
return {
|
|
1013
|
+
refused: `${row.type} ${describe(envelopeValue(row, 'seq0'))} is a packed Assistant run whose payload is not the string array the released codec requires`,
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
const declared = declaredEventSeq(row)
|
|
1017
|
+
if (declared !== position) {
|
|
1018
|
+
return {
|
|
1019
|
+
refused: `row ${offset + 2} (${row.type}) declares seq ${describe(declared)} at event position ${position}, so the survivors are not densely numbered`,
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
const survivor = survivorEvents[cursor]
|
|
1023
|
+
if (survivor !== undefined && JSON.stringify(survivor) === JSON.stringify(row)) {
|
|
1024
|
+
kept.push({ source: row, survivor, oldSeq: position, newSeq: newPosition, events: events_ })
|
|
1025
|
+
cursor += 1
|
|
1026
|
+
newPosition += events_
|
|
1027
|
+
} else if (isRemovableLegacyEvent(row)) {
|
|
1028
|
+
if (events_ !== 1) {
|
|
1029
|
+
return { refused: `the removable ${FALLBACKS_SWITCH_TYPE} row ${position} spans ${events_} events` }
|
|
1030
|
+
}
|
|
1031
|
+
dropped.push(position)
|
|
1032
|
+
} else {
|
|
1033
|
+
return {
|
|
1034
|
+
refused: `row ${offset + 2} (${row.type}) is neither kept byte-identically nor a removable ${FALLBACKS_SWITCH_TYPE} row, so the renumber cannot be proven`,
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
position += events_
|
|
1038
|
+
}
|
|
1039
|
+
if (cursor !== survivorEvents.length) {
|
|
1040
|
+
return { refused: `only ${cursor} of the ${survivorEvents.length} survivor event(s) match the input rows in order` }
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
for (const { source, oldSeq } of kept) {
|
|
1044
|
+
for (const reference of seqReferences(source)) {
|
|
1045
|
+
for (const span of reference.spans) {
|
|
1046
|
+
// Binary search over the ASCENDING dropped positions: a span holds a
|
|
1047
|
+
// dropped seq exactly when the count below its end exceeds the count
|
|
1048
|
+
// below its start. Linear in the reference surface, not in the drop count.
|
|
1049
|
+
if (droppedBefore(dropped, span.end + 1) !== droppedBefore(dropped, span.start)) {
|
|
1050
|
+
const hit = dropped[droppedBefore(dropped, span.start)] as number
|
|
1051
|
+
return {
|
|
1052
|
+
refused: `${REFERENCE_INTEGRITY_REFUSAL_PREFIX}${source.type} ${oldSeq} ${reference.label} names the dropped ${FALLBACKS_SWITCH_TYPE} seq ${hit}`,
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
const seedCut = headerSeedCut(input)
|
|
1060
|
+
const belowCut = dropped.find((seq) => seq < seedCut)
|
|
1061
|
+
if (belowCut !== undefined) {
|
|
1062
|
+
return {
|
|
1063
|
+
refused: `${SEED_CUT_REFUSAL_PREFIX}the dropped ${FALLBACKS_SWITCH_TYPE} seq ${belowCut} precedes the header's seedLength ${seedCut}, `
|
|
1064
|
+
+ 'so renumbering outside it would move the first own event into the inherited region',
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
const droppedSeqs = new Set(dropped)
|
|
1069
|
+
const rows: ParsedRow[] = [header]
|
|
1070
|
+
let renumberedEventCount = 0
|
|
1071
|
+
for (const { source, survivor, oldSeq, newSeq, events: extent } of kept) {
|
|
1072
|
+
let next = survivor
|
|
1073
|
+
const written: RenumberWrite[] = []
|
|
1074
|
+
if (newSeq !== oldSeq) {
|
|
1075
|
+
const path: SeqPath = [isPackedRun(survivor) ? 'seq0' : 'seq']
|
|
1076
|
+
const before = declaredEventSeq(survivor)
|
|
1077
|
+
next = writePath(next, path, newSeq) as ParsedRow
|
|
1078
|
+
written.push({ path, before: before as number, after: newSeq })
|
|
1079
|
+
renumberedEventCount += extent
|
|
1080
|
+
}
|
|
1081
|
+
for (const reference of seqReferences(survivor)) {
|
|
1082
|
+
for (const { path, value } of reference.positions) {
|
|
1083
|
+
const after = value - droppedBefore(dropped, value)
|
|
1084
|
+
// The post-remap assertion, at the remap boundary and in the only
|
|
1085
|
+
// coordinate space where each half means something:
|
|
1086
|
+
// - the OLD value must not be a dropped seq (`droppedSeqs`; the gate above
|
|
1087
|
+
// already proves it for every span, this re-proves it per written member);
|
|
1088
|
+
// - the NEW value must still name an EARLIER surviving event (`after < newSeq`).
|
|
1089
|
+
// Both halves are one-sided for an already-invalid NEGATIVE member: `-1`
|
|
1090
|
+
// is not a dropped seq and passes `after < newSeq`, then `after === value`
|
|
1091
|
+
// skips it, so it is written nowhere (see `seqReferences`).
|
|
1092
|
+
// `after` is a NEW position, so testing it against the dropped OLD positions
|
|
1093
|
+
// would be a category error: a surviving event legitimately moves down onto a
|
|
1094
|
+
// dropped event's old index.
|
|
1095
|
+
if (droppedSeqs.has(value) || after >= newSeq) {
|
|
1096
|
+
return {
|
|
1097
|
+
refused: `${REFERENCE_INTEGRITY_REFUSAL_PREFIX}${survivor.type} ${oldSeq} ${pathLabel(path)} names ${value}, `
|
|
1098
|
+
+ `which is not an earlier surviving event (remapped to ${after}, before ${newSeq} required)`,
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
if (after === value) continue
|
|
1102
|
+
next = writePath(next, path, after) as ParsedRow
|
|
1103
|
+
written.push({ path, before: value, after })
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
// Two-sided proof, keyed by path: a row can carry hundreds of thousands of
|
|
1107
|
+
// reference members, so neither side may scan the other linearly.
|
|
1108
|
+
const differences = rowDifferences(source, next)
|
|
1109
|
+
const writesByPath = new Map(written.map((write) => [writeKey(write.path), write]))
|
|
1110
|
+
const unaudited = differences.find((difference) => {
|
|
1111
|
+
const write = writesByPath.get(writeKey(difference.path))
|
|
1112
|
+
return write === undefined || !isWritten(write, difference)
|
|
1113
|
+
})
|
|
1114
|
+
if (unaudited !== undefined) {
|
|
1115
|
+
return {
|
|
1116
|
+
refused: `the renumber changed ${pathLabel(unaudited.path)} (${describe(unaudited.before)} → ${describe(unaudited.after)}) outside its audited remap fields`,
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
const differencesByPath = new Map(differences.map((difference) => [writeKey(difference.path), difference]))
|
|
1120
|
+
const missing = written.find((write) => {
|
|
1121
|
+
const difference = differencesByPath.get(writeKey(write.path))
|
|
1122
|
+
return difference === undefined || !isWritten(write, difference)
|
|
1123
|
+
})
|
|
1124
|
+
if (missing !== undefined) {
|
|
1125
|
+
return { refused: `the renumber did not apply its own ${pathLabel(missing.path)} step` }
|
|
1126
|
+
}
|
|
1127
|
+
rows.push(next)
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
// Output-side invariant, independent of the input-side walk above: the rows this
|
|
1131
|
+
// function RETURNS must satisfy the released edge's own `seq === eventCount` rule
|
|
1132
|
+
// (packed runs included), so a shared bug in the input walk cannot produce a
|
|
1133
|
+
// successor that merely looks renumbered.
|
|
1134
|
+
let densePosition = 0
|
|
1135
|
+
for (const row of rows.slice(1)) {
|
|
1136
|
+
const extent = rowEventCount(row)
|
|
1137
|
+
const declared = declaredEventSeq(row)
|
|
1138
|
+
if (extent === null || declared !== densePosition) {
|
|
1139
|
+
return {
|
|
1140
|
+
refused: `the renumber produced ${row.type} at event position ${densePosition} declaring ${describe(declared)}; the released edge requires dense seqs`,
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
densePosition += extent
|
|
1144
|
+
}
|
|
1145
|
+
return { rows, renumberedEventCount }
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
/* ------------------------------------------------------------------ */
|
|
1149
|
+
/* registry */
|
|
1150
|
+
/* ------------------------------------------------------------------ */
|
|
1151
|
+
|
|
1152
|
+
export const sourceKindRule: LogRule = {
|
|
1153
|
+
id: 'source-kind',
|
|
1154
|
+
class: 'source-kind',
|
|
1155
|
+
detect: detectSourceKinds,
|
|
1156
|
+
normalize: normalizeSourceKinds,
|
|
1157
|
+
// A foreign message source is always rewritable into the released plugin arm.
|
|
1158
|
+
repairable: () => true,
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
export const subagentDescriptorVersionRule: LogRule = {
|
|
1162
|
+
id: 'subagent-descriptor-version',
|
|
1163
|
+
class: 'subagent-descriptor-version',
|
|
1164
|
+
detect: detectDescriptorVersions,
|
|
1165
|
+
normalize: normalizeDescriptorVersions,
|
|
1166
|
+
// An earlier descriptor version is bumped only when its payload proves admissible,
|
|
1167
|
+
// but the class itself is repairable: it always rewrites or refuses, never drops.
|
|
1168
|
+
repairable: () => true,
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
export const fallbacksSwitchRule: LogRule = {
|
|
1172
|
+
id: 'fallbacks-switch',
|
|
1173
|
+
class: 'unknown-event-type',
|
|
1174
|
+
detect: detectFallbacksSwitch,
|
|
1175
|
+
normalize(rows) {
|
|
1176
|
+
const findings = detectFallbacksSwitch(rows)
|
|
1177
|
+
if (findings.length > 0) return { refused: FALLBACKS_SWITCH_REFUSAL }
|
|
1178
|
+
return { rows: [...rows], findings: [] }
|
|
1179
|
+
},
|
|
1180
|
+
// A detector, never a repair: this rule exists so the class is REPORTED when the
|
|
1181
|
+
// lossy opt-in is absent.
|
|
1182
|
+
repairable: () => false,
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
/**
|
|
1186
|
+
* The OPT-IN lossy recovery for the legacy `fallbacks/switch` rows: `normalize`
|
|
1187
|
+
* removes exactly those rows, renumbers the survivors so the released V0→V1 edge
|
|
1188
|
+
* accepts them again, and reports one finding per removed row (so
|
|
1189
|
+
* {@link droppedEventCount} stays the drop count; the renumbered-event count is
|
|
1190
|
+
* {@link renumberSurvivingEvents}'s, not a finding).
|
|
1191
|
+
*
|
|
1192
|
+
* DELIBERATELY NOT a member of {@link BUILT_IN_RULES}: the default registry stays
|
|
1193
|
+
* strictly non-lossy, so this rule can only enter a run through the CLI's
|
|
1194
|
+
* explicit `--drop-legacy-events`. Scope is exact and self-proven — only a row
|
|
1195
|
+
* whose PARSED `type` is `fallbacks/switch` is removed, every survivor is
|
|
1196
|
+
* re-serialized and matched against its source row before the rule returns, a
|
|
1197
|
+
* row of any other unknown event type is left in place (which leaves such a log
|
|
1198
|
+
* unrepairable rather than silently lossy), and the renumber refuses the file
|
|
1199
|
+
* outright when a surviving reference names a dropped seq. It therefore runs
|
|
1200
|
+
* FIRST in a lossy chain, so its input is the unmodified log. Replacing the
|
|
1201
|
+
* detector it stands beside (`fallbacks-switch`) is the caller's decision: both
|
|
1202
|
+
* policies cover the same rows, and the detector refuses what this rule removes.
|
|
1203
|
+
*/
|
|
1204
|
+
export const dropLegacyEventsRule: LogRule = {
|
|
1205
|
+
id: DROP_LEGACY_EVENTS_RULE_ID,
|
|
1206
|
+
class: 'unknown-event-type',
|
|
1207
|
+
detect: detectDroppableLegacyEvents,
|
|
1208
|
+
normalize: normalizeDroppedLegacyEvents,
|
|
1209
|
+
// Repairable only for a log that actually carries a row this rule may remove: a
|
|
1210
|
+
// differently-unknown event type stays unrepairable even under the opt-in, which
|
|
1211
|
+
// is what the CLI's class gate used to special-case.
|
|
1212
|
+
repairable: (rows) => rows.some(isRemovableLegacyEvent),
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
/**
|
|
1216
|
+
* Built-in rules in check order: the first refused row decides the log's class,
|
|
1217
|
+
* and within one row the first rule here decides it.
|
|
1218
|
+
*
|
|
1219
|
+
* Strictly non-lossy by construction: every rule either proves a shape-preserving
|
|
1220
|
+
* rewrite or refuses, so an unrepairable log is never silently modified. The
|
|
1221
|
+
* lossy `dropLegacyEventsRule` is absent here on purpose.
|
|
1222
|
+
*/
|
|
1223
|
+
export const BUILT_IN_RULES: readonly LogRule[] = [
|
|
1224
|
+
sourceKindRule,
|
|
1225
|
+
subagentDescriptorVersionRule,
|
|
1226
|
+
fallbacksSwitchRule,
|
|
1227
|
+
]
|