dsh-llm-fallbacks 0.6.6 → 0.6.7
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 +59 -230
- package/README.zh-CN.md +76 -247
- package/dist/authorized-route.d.ts +1 -1
- package/dist/index.d.ts +23 -3
- package/dist/index.js +15 -2
- package/dist/role-resolution.d.ts +25 -12
- package/dist/roles.d.ts +6 -4
- package/package.json +1 -1
- package/scripts/repair-session-logs.ts +68 -34
- package/scripts/session-logs/catalog.ts +103 -12
- package/scripts/session-logs/publish.ts +23 -9
package/dist/roles.d.ts
CHANGED
|
@@ -3,10 +3,12 @@
|
|
|
3
3
|
* Task 1).
|
|
4
4
|
*
|
|
5
5
|
* This module is the RULES stage. Dispatch-time role resolution
|
|
6
|
-
* (plan fallbacks-role-automatch
|
|
7
|
-
* `role-resolution.ts` — explicit
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* (plan fallbacks-role-automatch; plan dsh-148 added the dispatch stage) is
|
|
7
|
+
* the four-stage resolver in `role-resolution.ts` — explicit
|
|
8
|
+
* (`session.header.agentPreset`) → dispatch-declared role (the seam record
|
|
9
|
+
* resolved from `**Execute as**`) → this rules stage → an optional auto-match
|
|
10
|
+
* hook; `AgentLike` carries the additive `session.header.agentPreset?`
|
|
11
|
+
* carrier for that explicit stage.
|
|
10
12
|
*
|
|
11
13
|
* Precedence (first hit wins):
|
|
12
14
|
* 1. the first `rules` entry whose specified provider/model patterns match
|
package/package.json
CHANGED
|
@@ -9,8 +9,12 @@
|
|
|
9
9
|
* and — only for the classes whose normalization is provably shape-preserving
|
|
10
10
|
* (the rule registry in `./session-logs/rules.ts`) and only with a resolved
|
|
11
11
|
* released catalog as the oracle (`./session-logs/catalog.ts`) — publishes a
|
|
12
|
-
*
|
|
13
|
-
*
|
|
12
|
+
* REPAIR-FLOOR (v3) successor beside the original (`./session-logs/publish.ts`).
|
|
13
|
+
* The original generation is never modified. A newer installed catalog supplies
|
|
14
|
+
* that floor through its own `historicalSessionFormatCatalog`; the host migrates
|
|
15
|
+
* the published v3 generation onward when it opens the session, with its own
|
|
16
|
+
* historical child evidence (the V3→V4 edge needs evidence no single-log repair
|
|
17
|
+
* can produce — see `CatalogHandle.floor`).
|
|
14
18
|
*
|
|
15
19
|
* This module CONSUMES the frozen Task 1/2 modules; it re-implements none of
|
|
16
20
|
* them. The refusal vocabulary, the rule registry, the oracle resolution and
|
|
@@ -134,8 +138,8 @@
|
|
|
134
138
|
* at all (`skipped`), or a stale `session.repair.*.jsonl.zstd.tmp` was found under
|
|
135
139
|
* the root; 2 = fatal (bad arguments, missing/unreadable
|
|
136
140
|
* `--root`, `--apply` without a resolved catalog, a catalog below the required
|
|
137
|
-
* format version
|
|
138
|
-
* without zstd).
|
|
141
|
+
* format version or a newer catalog without a usable repair floor,
|
|
142
|
+
* `--apply --drop-legacy-events` without `--backup`, or a runtime without zstd).
|
|
139
143
|
*/
|
|
140
144
|
import { createHash } from 'node:crypto'
|
|
141
145
|
import { constants as fsConstants } from 'node:fs'
|
|
@@ -149,6 +153,7 @@ import {
|
|
|
149
153
|
REQUIRED_CATALOG_VERSION,
|
|
150
154
|
catalogVersionRefusal,
|
|
151
155
|
classifyWithCatalog,
|
|
156
|
+
repairCatalog,
|
|
152
157
|
resolveCatalog,
|
|
153
158
|
restoreRows,
|
|
154
159
|
} from './session-logs/catalog.ts'
|
|
@@ -733,6 +738,12 @@ export interface RunResult {
|
|
|
733
738
|
packageVersion: string | null
|
|
734
739
|
/** The format version that catalog declares as current. */
|
|
735
740
|
currentVersion: number
|
|
741
|
+
/**
|
|
742
|
+
* The format version this tool REPAIRS TO (the floor): the declared
|
|
743
|
+
* `currentVersion` on a build at the floor, otherwise that build's
|
|
744
|
+
* historical V0–V3 catalog. Successors are published at this version.
|
|
745
|
+
*/
|
|
746
|
+
repairVersion: number
|
|
736
747
|
}
|
|
737
748
|
| { resolved: false }
|
|
738
749
|
logs: LogOutcome[]
|
|
@@ -801,8 +812,11 @@ function strictRefusalOf(rows: readonly ParsedRow[], catalog: CatalogHandle | nu
|
|
|
801
812
|
// differs from this one exactly by `recovery`, so a refusal found here can only
|
|
802
813
|
// mean the rows after it are dropped — not merely that the session is not
|
|
803
814
|
// current-shaped. Using the publisher's `validation: 'current'` axis here would
|
|
804
|
-
// over-claim truncation for a log that loses no rows at all.
|
|
805
|
-
|
|
815
|
+
// over-claim truncation for a log that loses no rows at all. The restore goes
|
|
816
|
+
// through the REPAIR FLOOR (the pre-V3 chain this tool repairs along and the
|
|
817
|
+
// generation it publishes); the host's floor→current migration needs
|
|
818
|
+
// corpus-level child evidence and is outside a single log's verdict.
|
|
819
|
+
restoreRows(repairCatalog(catalog), structuredClone(rows), { recovery: 'strict', validation: 'transformed' })
|
|
806
820
|
return null
|
|
807
821
|
} catch (error) {
|
|
808
822
|
const cause = error instanceof Error ? error.cause : undefined
|
|
@@ -1004,7 +1018,10 @@ function lossyDropRefusal(
|
|
|
1004
1018
|
try {
|
|
1005
1019
|
// A defensive copy: the released restore normalizes a packed Assistant run's
|
|
1006
1020
|
// stream IN PLACE, and `repaired` is what the publisher is about to write.
|
|
1007
|
-
restoreRows(catalog
|
|
1021
|
+
restoreRows(repairCatalog(catalog), structuredClone(repaired), {
|
|
1022
|
+
recovery: 'strict',
|
|
1023
|
+
validation: 'transformed',
|
|
1024
|
+
})
|
|
1008
1025
|
} catch (error) {
|
|
1009
1026
|
return {
|
|
1010
1027
|
ok: false,
|
|
@@ -1228,23 +1245,25 @@ async function analyzeLog(candidate: LogGeneration, context: InspectContext): Pr
|
|
|
1228
1245
|
}
|
|
1229
1246
|
const renumbered = verdict === null ? 0 : verdict.renumberedEventCount
|
|
1230
1247
|
|
|
1231
|
-
// The successor NAME needs the
|
|
1232
|
-
//
|
|
1233
|
-
//
|
|
1234
|
-
//
|
|
1235
|
-
//
|
|
1248
|
+
// The successor NAME needs the repair floor's version, so it is only known with
|
|
1249
|
+
// a resolved oracle: in report mode without one nothing is probed (the log is
|
|
1250
|
+
// still reported `repairable` from the rule registry alone), and `--apply` was
|
|
1251
|
+
// already refused as fatal by the caller — which is why the null case is checked
|
|
1252
|
+
// here rather than assumed away.
|
|
1236
1253
|
//
|
|
1237
1254
|
// The name comes from the publisher's own exported rule (no local copy). Note
|
|
1238
|
-
// the two INPUTS differ: this probe uses the
|
|
1239
|
-
//
|
|
1240
|
-
//
|
|
1241
|
-
// settles at the
|
|
1242
|
-
//
|
|
1243
|
-
//
|
|
1244
|
-
//
|
|
1245
|
-
//
|
|
1255
|
+
// the two INPUTS differ: this probe uses the handle's REPAIR FLOOR version,
|
|
1256
|
+
// while `publishSuccessor` derives its target from the version the RESTORED
|
|
1257
|
+
// header settles on. They agree for every released catalog (the floor catalog's
|
|
1258
|
+
// restore settles at the floor it declares, and the publisher pins the
|
|
1259
|
+
// successor to it before publication); a catalog whose restore settled at a
|
|
1260
|
+
// different version would leave this probe looking for a name the publication
|
|
1261
|
+
// never used, so report mode alone could call such a log unpublished while
|
|
1262
|
+
// `--apply` accepts the existing successor as byte-identical.
|
|
1246
1263
|
const catalog = context.catalog
|
|
1247
|
-
const expected = catalog === null
|
|
1264
|
+
const expected = catalog === null
|
|
1265
|
+
? null
|
|
1266
|
+
: context.successorFilename(repairCatalog(catalog).currentVersion)
|
|
1248
1267
|
const successorPath = expected === null ? null : join(candidate.sessionDir, expected)
|
|
1249
1268
|
const existingSuccessor =
|
|
1250
1269
|
successorPath !== null && (await fileExists(successorPath)) ? successorPath : null
|
|
@@ -1493,10 +1512,11 @@ export async function runRepair(
|
|
|
1493
1512
|
throw new FatalError(messageOf(error))
|
|
1494
1513
|
}
|
|
1495
1514
|
}
|
|
1496
|
-
// The oracle is pinned to a RELEASE, in every mode: the
|
|
1497
|
-
// successor and reads it back, so "it accepts its own output" is only
|
|
1498
|
-
// when the
|
|
1499
|
-
// floor
|
|
1515
|
+
// The oracle is pinned to a RELEASE, in every mode: the floor catalog encodes
|
|
1516
|
+
// the successor and reads it back, so "it accepts its own output" is only
|
|
1517
|
+
// evidence when the floor comes from the build that will read the session. A
|
|
1518
|
+
// catalog below the floor, or a newer build with no usable floor, is a fatal
|
|
1519
|
+
// (exit 2), not a quieter classification.
|
|
1500
1520
|
const catalogProblem = catalog === null ? null : catalogVersionRefusal(catalog)
|
|
1501
1521
|
if (catalogProblem !== null) throw new FatalError(catalogProblem)
|
|
1502
1522
|
|
|
@@ -1510,6 +1530,7 @@ export async function runRepair(
|
|
|
1510
1530
|
resolvedBy: catalog.resolvedBy,
|
|
1511
1531
|
packageVersion: catalog.packageVersion,
|
|
1512
1532
|
currentVersion: catalog.catalog.currentVersion,
|
|
1533
|
+
repairVersion: repairCatalog(catalog).currentVersion,
|
|
1513
1534
|
}
|
|
1514
1535
|
|
|
1515
1536
|
const reportRules = policyRules(options.dropLegacyEvents)
|
|
@@ -1741,6 +1762,9 @@ function reportHead(head: ReportHead, io: CliIO): void {
|
|
|
1741
1762
|
io.out(
|
|
1742
1763
|
head.catalog.resolved
|
|
1743
1764
|
? `catalog: ${head.catalog.modulePath} (v${head.catalog.currentVersion},`
|
|
1765
|
+
+ `${head.catalog.repairVersion === head.catalog.currentVersion
|
|
1766
|
+
? ''
|
|
1767
|
+
: ` repair target v${head.catalog.repairVersion},`}`
|
|
1744
1768
|
+ ` package ${head.catalog.packageVersion ?? 'version unknown'}, resolved by ${head.catalog.resolvedBy})`
|
|
1745
1769
|
: `catalog: none resolved — classification is STRUCTURAL ONLY and --apply would refuse `
|
|
1746
1770
|
+ `(no --catalog, no ${CATALOG_ENV_VAR}, no dsh on PATH, no npx install)`,
|
|
@@ -1884,15 +1908,19 @@ export function usage(): string {
|
|
|
1884
1908
|
Triage (default, read-only) and repair (--apply) pre-V3 dsh session logs. A log is
|
|
1885
1909
|
refused when the FROZEN released migration chain cannot classify it; this tool names
|
|
1886
1910
|
the refusal class and, for the classes whose normalization is provably
|
|
1887
|
-
shape-preserving, publishes a
|
|
1888
|
-
original generation is never modified and never truncated.
|
|
1911
|
+
shape-preserving, publishes a REPAIR-FLOOR (v${CURRENT_VERSION_FLOOR}) successor generation
|
|
1912
|
+
beside the original. The original generation is never modified and never truncated.
|
|
1913
|
+
The floor stays v${CURRENT_VERSION_FLOOR} even when the installed catalog is newer: the next
|
|
1914
|
+
released edge (v${CURRENT_VERSION_FLOOR}->v${CURRENT_VERSION_FLOOR + 1}) needs historical child evidence a
|
|
1915
|
+
single-log repair cannot produce, so the host migrates the published v${CURRENT_VERSION_FLOOR}
|
|
1916
|
+
generation onward when it opens the session.
|
|
1889
1917
|
|
|
1890
1918
|
--root DIR session root to walk (default: $DSH_HOME or ~/.dsh, then /sessions).
|
|
1891
1919
|
Every <namespace>/<session>/ directory's newest canonical generation
|
|
1892
1920
|
below format v${CURRENT_VERSION_FLOOR} is considered; noncanonical names (staged
|
|
1893
|
-
.tmp files, .bak copies) are ignored, and a
|
|
1894
|
-
present beside it is reported as an already published
|
|
1895
|
-
by a read-back) instead of being a repair target.
|
|
1921
|
+
.tmp files, .bak copies) are ignored, and a repair-floor generation
|
|
1922
|
+
already present beside it is reported as an already published
|
|
1923
|
+
successor (proven by a read-back) instead of being a repair target.
|
|
1896
1924
|
A namespace/session directory that cannot be read, a canonical
|
|
1897
1925
|
generation BELOW the format floor that is a symlink or not a regular
|
|
1898
1926
|
file, and a stale session.repair.*.jsonl.zstd.tmp are REPORTED (a
|
|
@@ -1903,7 +1931,8 @@ original generation is never modified and never truncated.
|
|
|
1903
1931
|
Symlinks are reported, never followed: a repair writes beside the
|
|
1904
1932
|
generation it repairs, which must stay inside --root.
|
|
1905
1933
|
--apply run the rules' proofs and publish a successor generation per repaired
|
|
1906
|
-
log. Requires a resolved catalog.
|
|
1934
|
+
log, at the repair floor (v${CURRENT_VERSION_FLOOR}). Requires a resolved catalog.
|
|
1935
|
+
Default: read-only report.
|
|
1907
1936
|
The revision this run DECODED is digest-checked against the file before
|
|
1908
1937
|
anything is staged, so a concurrent append is refused before the first
|
|
1909
1938
|
write; if the source moves after a publication this run CREATED, that
|
|
@@ -1924,9 +1953,14 @@ original generation is never modified and never truncated.
|
|
|
1924
1953
|
resolves, then the newest ~/.npm/_npx install. The resolved module is
|
|
1925
1954
|
EXECUTED, not parsed (the same privilege as running this tool), and it
|
|
1926
1955
|
must declare currentVersion >= ${CURRENT_VERSION_FLOOR}: a below-floor catalog is
|
|
1927
|
-
refused with exit 2 rather than trusted to verify its own output.
|
|
1928
|
-
|
|
1929
|
-
|
|
1956
|
+
refused with exit 2 rather than trusted to verify its own output. When
|
|
1957
|
+
currentVersion is ABOVE the floor, the module must also export
|
|
1958
|
+
historicalSessionFormatCatalog (that build's V0-V${CURRENT_VERSION_FLOOR} reader/encoder):
|
|
1959
|
+
the floor catalog restores and verifies the published v${CURRENT_VERSION_FLOOR} successor
|
|
1960
|
+
and the host migrates it onward with its own child evidence; a build
|
|
1961
|
+
without a usable floor is refused with exit 2. A file candidate whose
|
|
1962
|
+
owning package.json names a different package is refused too. The
|
|
1963
|
+
report names the module path, its format version, the repair target and
|
|
1930
1964
|
its package version.
|
|
1931
1965
|
--backup copy the original generation to <name>.bak before publishing. A run
|
|
1932
1966
|
that ends up publishing nothing removes the copy IT created again,
|
|
@@ -67,12 +67,20 @@ const CATALOG_PACKAGE_DIR = 'dsh-session-format-catalog'
|
|
|
67
67
|
export const CATALOG_ENV_VAR = 'DSH_SESSION_FORMAT_CATALOG'
|
|
68
68
|
|
|
69
69
|
/**
|
|
70
|
-
* The oldest released catalog this tool accepts as its oracle
|
|
70
|
+
* The oldest released catalog this tool accepts as its oracle AND the format
|
|
71
71
|
* generation it repairs TO (`CURRENT_VERSION_FLOOR`, the pre-V3 scope). A catalog
|
|
72
72
|
* below it cannot read a successor this tool would write, so `--apply` must
|
|
73
73
|
* refuse rather than verify its own output against a build the GUI does not run.
|
|
74
74
|
* One constant for both the selection scope and the oracle floor: they are the
|
|
75
75
|
* same number and must not drift.
|
|
76
|
+
*
|
|
77
|
+
* The repair target stays at this floor even when the installed build is newer:
|
|
78
|
+
* the tool only repairs logs BELOW the floor, and the next released edge
|
|
79
|
+
* (V3→V4) requires historical child evidence (one parent's complete child
|
|
80
|
+
* descriptors) that a single-log repair cannot produce. The successor is
|
|
81
|
+
* therefore encoded through the newer build's own `historicalSessionFormatCatalog`
|
|
82
|
+
* (see {@link CatalogHandle.floor}) and the host migrates it onward, with its own
|
|
83
|
+
* child facts, exactly like any other readable pre-V3 session it opens.
|
|
76
84
|
*/
|
|
77
85
|
export const REQUIRED_CATALOG_VERSION = 3
|
|
78
86
|
|
|
@@ -131,24 +139,77 @@ export interface CatalogHandle {
|
|
|
131
139
|
readonly packageVersion: string | null
|
|
132
140
|
/** The released catalog module's `sessionFormatCatalog` export. */
|
|
133
141
|
readonly catalog: ReleasedCatalog
|
|
142
|
+
/**
|
|
143
|
+
* The catalog generation this tool REPAIRS TO — the format floor
|
|
144
|
+
* ({@link REQUIRED_CATALOG_VERSION}): the declared catalog itself when the
|
|
145
|
+
* resolved build is AT the floor, otherwise that build's
|
|
146
|
+
* `historicalSessionFormatCatalog` export (the V0–V3 reader/encoder).
|
|
147
|
+
*
|
|
148
|
+
* Every restore, classification and published successor goes through this
|
|
149
|
+
* catalog, so the successor's header version (and the `N` in its canonical
|
|
150
|
+
* filename) is the floor, never a generation whose migration needs evidence a
|
|
151
|
+
* single-log repair cannot supply. `null` when the resolved build is newer than
|
|
152
|
+
* the floor but exposes no usable historical catalog: the caller refuses the
|
|
153
|
+
* whole run through {@link catalogVersionRefusal} (fatal, exit 2) instead of
|
|
154
|
+
* publishing a generation it cannot restore, verify or hand to the host.
|
|
155
|
+
*/
|
|
156
|
+
readonly floor: ReleasedCatalog | null
|
|
134
157
|
}
|
|
135
158
|
|
|
136
159
|
/**
|
|
137
160
|
* Why a resolved catalog may not be used as this run's oracle, or `null` when it
|
|
138
161
|
* may. The caller turns a non-`null` reason into a fatal (exit 2) refusal: a
|
|
139
162
|
* below-floor catalog would encode a successor this tool's own read-back cannot
|
|
140
|
-
* vouch for, and the GUI would not run it either
|
|
163
|
+
* vouch for, and the GUI would not run it either; a newer build without a usable
|
|
164
|
+
* repair floor could not be restored or encoded at all.
|
|
141
165
|
*/
|
|
142
166
|
export function catalogVersionRefusal(handle: CatalogHandle): string | null {
|
|
143
167
|
const version = handle.catalog.currentVersion
|
|
144
|
-
if (typeof version === 'number' && Number.isSafeInteger(version) && version >= REQUIRED_CATALOG_VERSION) {
|
|
145
|
-
return
|
|
168
|
+
if (!(typeof version === 'number' && Number.isSafeInteger(version) && version >= REQUIRED_CATALOG_VERSION)) {
|
|
169
|
+
return `the resolved released catalog at ${handle.modulePath}`
|
|
170
|
+
+ `${handle.packageVersion === null ? '' : ` (package version ${handle.packageVersion})`}`
|
|
171
|
+
+ ` declares currentVersion ${JSON.stringify(version)}, but this tool requires at least `
|
|
172
|
+
+ `${REQUIRED_CATALOG_VERSION}: it would publish a successor that build cannot read. `
|
|
173
|
+
+ 'Point --catalog (or DSH_SESSION_FORMAT_CATALOG) at the catalog of the release the GUI runs.'
|
|
174
|
+
}
|
|
175
|
+
if (handle.floor === null) {
|
|
176
|
+
return `the resolved released catalog at ${handle.modulePath}`
|
|
177
|
+
+ `${handle.packageVersion === null ? '' : ` (package version ${handle.packageVersion})`}`
|
|
178
|
+
+ ` declares currentVersion ${version}, but it exposes no usable historicalSessionFormatCatalog at the `
|
|
179
|
+
+ `repair floor (v${REQUIRED_CATALOG_VERSION}). This tool repairs pre-V3 logs by publishing a v`
|
|
180
|
+
+ `${REQUIRED_CATALOG_VERSION} successor: the V3→V4 edge needs historical child evidence that a single-log `
|
|
181
|
+
+ 'repair cannot supply, so the floor reader/encoder is what restores and verifies the successor and the '
|
|
182
|
+
+ 'host migrates it onward when it opens the session. Point --catalog (or DSH_SESSION_FORMAT_CATALOG) at '
|
|
183
|
+
+ `a build that declares currentVersion >= ${REQUIRED_CATALOG_VERSION} and still exports `
|
|
184
|
+
+ 'historicalSessionFormatCatalog.'
|
|
185
|
+
}
|
|
186
|
+
const floorVersion = handle.floor.currentVersion
|
|
187
|
+
if (!(typeof floorVersion === 'number' && Number.isSafeInteger(floorVersion)
|
|
188
|
+
&& floorVersion >= REQUIRED_CATALOG_VERSION && floorVersion <= version)) {
|
|
189
|
+
return `the resolved released catalog at ${handle.modulePath} exposes a repair-floor catalog that declares `
|
|
190
|
+
+ `currentVersion ${JSON.stringify(floorVersion)}, but this tool requires a floor of at least `
|
|
191
|
+
+ `${REQUIRED_CATALOG_VERSION} no greater than the declared currentVersion ${version}. `
|
|
192
|
+
+ 'Point --catalog (or DSH_SESSION_FORMAT_CATALOG) at the catalog of the release the GUI runs.'
|
|
146
193
|
}
|
|
147
|
-
return
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
194
|
+
return null
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The catalog generation this tool repairs TO (see {@link CatalogHandle.floor}).
|
|
199
|
+
*
|
|
200
|
+
* Callers reach this only after {@link catalogVersionRefusal} accepted the handle;
|
|
201
|
+
* the throw is the fail-closed backstop for a caller that skipped that gate (a
|
|
202
|
+
* restore or encode through a missing floor must never degrade to "nothing was
|
|
203
|
+
* published").
|
|
204
|
+
*/
|
|
205
|
+
export function repairCatalog(handle: CatalogHandle): ReleasedCatalog {
|
|
206
|
+
if (handle.floor === null) {
|
|
207
|
+
throw new Error(
|
|
208
|
+
`the resolved session-format catalog exposes no usable repair-floor catalog (v${REQUIRED_CATALOG_VERSION}): `
|
|
209
|
+
+ 'refusing to restore or encode a successor with it',
|
|
210
|
+
)
|
|
211
|
+
}
|
|
212
|
+
return handle.floor
|
|
152
213
|
}
|
|
153
214
|
|
|
154
215
|
/** Inputs for {@link resolveCatalog}. */
|
|
@@ -195,19 +256,44 @@ async function loadCatalog(
|
|
|
195
256
|
const modulePath = resolveCatalogEntry(candidate, env)
|
|
196
257
|
if (modulePath === null) return null
|
|
197
258
|
try {
|
|
198
|
-
const loaded = (await import(pathToFileURL(modulePath).href)) as {
|
|
259
|
+
const loaded = (await import(pathToFileURL(modulePath).href)) as {
|
|
260
|
+
sessionFormatCatalog?: unknown
|
|
261
|
+
historicalSessionFormatCatalog?: unknown
|
|
262
|
+
}
|
|
199
263
|
if (!isReleasedCatalog(loaded.sessionFormatCatalog)) return null
|
|
200
264
|
return {
|
|
201
265
|
modulePath,
|
|
202
266
|
resolvedBy,
|
|
203
267
|
packageVersion: catalogPackageVersion(modulePath),
|
|
204
268
|
catalog: loaded.sessionFormatCatalog,
|
|
269
|
+
floor: floorCatalogOf(loaded.sessionFormatCatalog, loaded.historicalSessionFormatCatalog),
|
|
205
270
|
}
|
|
206
271
|
} catch {
|
|
207
272
|
return null
|
|
208
273
|
}
|
|
209
274
|
}
|
|
210
275
|
|
|
276
|
+
/**
|
|
277
|
+
* The catalog generation this tool repairs TO: the declared catalog itself when
|
|
278
|
+
* the resolved build is at or below the floor, otherwise that build's
|
|
279
|
+
* `historicalSessionFormatCatalog` export.
|
|
280
|
+
*
|
|
281
|
+
* A build newer than the floor publishes nothing but its own current generation,
|
|
282
|
+
* and the released V3→V4 edge needs historical child evidence no single-log
|
|
283
|
+
* repair can produce; the historical export is that build's own V0–V3
|
|
284
|
+
* reader/encoder, so restoring through it and encoding the floor generation is
|
|
285
|
+
* the only successor the tool can both verify and hand to the host.
|
|
286
|
+
*
|
|
287
|
+
* @param catalog the module's declared `sessionFormatCatalog`.
|
|
288
|
+
* @param historical the module's `historicalSessionFormatCatalog`, unknown.
|
|
289
|
+
* @returns the floor catalog, or `null` when a newer build exposes none.
|
|
290
|
+
*/
|
|
291
|
+
function floorCatalogOf(catalog: ReleasedCatalog, historical: unknown): ReleasedCatalog | null {
|
|
292
|
+
if (catalog.currentVersion <= REQUIRED_CATALOG_VERSION) return catalog
|
|
293
|
+
if (isReleasedCatalog(historical) && historical.currentVersion === REQUIRED_CATALOG_VERSION) return historical
|
|
294
|
+
return null
|
|
295
|
+
}
|
|
296
|
+
|
|
211
297
|
/**
|
|
212
298
|
* The owning catalog package's own `version`, walking up from a module entry to
|
|
213
299
|
* the nearest `package.json` that names the catalog package; `null` when none is
|
|
@@ -526,7 +612,12 @@ function refusalClass(error: unknown): RefusalClass {
|
|
|
526
612
|
* The restore runs under the pinned policy
|
|
527
613
|
* `createRestore(header, { recovery: 'recoverable', validation: 'transformed' })`
|
|
528
614
|
* and every row is decoded before `finish()`, so the class is the one the
|
|
529
|
-
* released chain raises first for this log
|
|
615
|
+
* released chain raises first for this log on its way to the REPAIR FLOOR
|
|
616
|
+
* ({@link CatalogHandle.floor}) — the pre-V3 chain this tool repairs along and
|
|
617
|
+
* the generation it publishes. With a newer installed build the floor is that
|
|
618
|
+
* build's `historicalSessionFormatCatalog`; the host's forward migration from
|
|
619
|
+
* the floor to current (which needs corpus-level child evidence) is outside a
|
|
620
|
+
* single log's verdict.
|
|
530
621
|
*
|
|
531
622
|
* Returned classes: `'ok'` when the restore completes, `'source-kind'`,
|
|
532
623
|
* `'subagent-descriptor-version'`, `'unknown-event-type'` for the three released
|
|
@@ -539,7 +630,7 @@ function refusalClass(error: unknown): RefusalClass {
|
|
|
539
630
|
*/
|
|
540
631
|
export function classifyWithCatalog(rows: readonly ParsedRow[], catalog: CatalogHandle): RefusalClass {
|
|
541
632
|
try {
|
|
542
|
-
restoreRows(catalog
|
|
633
|
+
restoreRows(repairCatalog(catalog), rows, { recovery: 'recoverable', validation: 'transformed' })
|
|
543
634
|
return 'ok'
|
|
544
635
|
} catch (error) {
|
|
545
636
|
return refusalClass(error)
|
|
@@ -14,7 +14,11 @@
|
|
|
14
14
|
* (`ZSTD_c_checksumFlag`), exactly like the released writer.
|
|
15
15
|
* The successor's records come from the RELEASED encoder
|
|
16
16
|
* (`encodeCurrentHeader` / `encodeCurrentEvent`), not from the input rows, so
|
|
17
|
-
* the written generation is whatever the catalog considers
|
|
17
|
+
* the written generation is whatever the catalog's REPAIR FLOOR considers
|
|
18
|
+
* current — v3 when the installed build is newer (see `CatalogHandle.floor`).
|
|
19
|
+
* The host then migrates that v3 generation onward to the installed current
|
|
20
|
+
* format with its own historical child evidence; this module never paints a
|
|
21
|
+
* generation whose migration needs corpus-level facts a single log lacks.
|
|
18
22
|
*
|
|
19
23
|
* COMPRESSION — Node's `node:zlib` zstd API, no `zstd` CLI (measured on
|
|
20
24
|
* v24.18.0: `zstdCompress`/`zstdDecompress` exist and are used here):
|
|
@@ -66,7 +70,7 @@ import { link, readFile, rm, writeFile } from 'node:fs/promises'
|
|
|
66
70
|
import { dirname, join, resolve } from 'node:path'
|
|
67
71
|
import { constants, zstdCompressSync, zstdDecompressSync } from 'node:zlib'
|
|
68
72
|
import type { CatalogHandle, ReleasedArtifact, ReleasedCatalog } from './catalog.ts'
|
|
69
|
-
import { restoreRows } from './catalog.ts'
|
|
73
|
+
import { repairCatalog, restoreRows } from './catalog.ts'
|
|
70
74
|
import type { ParsedRow } from './rules.ts'
|
|
71
75
|
|
|
72
76
|
/** Zstandard frame magic (little-endian `0xFD2FB528`). */
|
|
@@ -333,8 +337,9 @@ function sizeToNumber(buffer: Buffer, offset: number, width: number): number | n
|
|
|
333
337
|
* function, which it receives from `runRepair`'s dynamic import of this module
|
|
334
338
|
* (a static import would defeat the `node:zlib` zstd runtime probe that must run
|
|
335
339
|
* first). `publishSuccessor` derives its target from the RESTORED header's
|
|
336
|
-
* version, the CLI's probe
|
|
337
|
-
* `currentVersion` —
|
|
340
|
+
* version (the repair floor), and the CLI's probe resolves the floor's
|
|
341
|
+
* `currentVersion` through the catalog handle — the two agree for every released
|
|
342
|
+
* catalog, whose restore settles at the floor it was chosen for.
|
|
338
343
|
*/
|
|
339
344
|
export function successorFilename(version: number): string {
|
|
340
345
|
return version === 0 ? 'session.jsonl.zstd' : `session.v${version}.jsonl.zstd`
|
|
@@ -382,6 +387,13 @@ export async function publishSuccessor(
|
|
|
382
387
|
sourceDigest: string,
|
|
383
388
|
): Promise<PublishedSuccessor> {
|
|
384
389
|
const handle = assertCatalog(catalog)
|
|
390
|
+
// The generation this tool repairs TO: the declared catalog itself on a build
|
|
391
|
+
// at the floor, the build's own historical V0–V3 catalog on a newer one. A
|
|
392
|
+
// restore through the newer build's current catalog would demand historical
|
|
393
|
+
// child evidence this single-log tool cannot produce; the floor restore has no
|
|
394
|
+
// such requirement, and the host migrates the published floor generation
|
|
395
|
+
// onward with its own child facts.
|
|
396
|
+
const floor = repairCatalog(handle)
|
|
385
397
|
const originalBytes = await readFile(logPath)
|
|
386
398
|
const originalDigest = sha256(originalBytes)
|
|
387
399
|
if (originalDigest !== sourceDigest) {
|
|
@@ -394,7 +406,7 @@ export async function publishSuccessor(
|
|
|
394
406
|
// Defensive copy: the caller's rows alias `form` / `summary` / `sections`
|
|
395
407
|
// objects, and the released restore may normalize the artifact in place.
|
|
396
408
|
const rows = structuredClone(normalizedRows)
|
|
397
|
-
const artifact = restoreRows(
|
|
409
|
+
const artifact = restoreRows(floor, rows, { recovery: 'strict', validation: 'transformed' })
|
|
398
410
|
const generation = artifact.header.version
|
|
399
411
|
const targetPath = join(dirname(logPath), successorFilename(generation))
|
|
400
412
|
if (resolve(targetPath) === resolve(logPath)) {
|
|
@@ -405,10 +417,10 @@ export async function publishSuccessor(
|
|
|
405
417
|
// (lazily): materializing them all first would hold a second copy of the session
|
|
406
418
|
// alongside the restored artifact it was built from.
|
|
407
419
|
const encodedEvents = function* (): Generator<Record<string, unknown>> {
|
|
408
|
-
for (const event of artifact.events) yield
|
|
420
|
+
for (const event of artifact.events) yield floor.encodeCurrentEvent(event)
|
|
409
421
|
}
|
|
410
422
|
const bytes = encodeZstdFrames(
|
|
411
|
-
|
|
423
|
+
floor.encodeCurrentHeader(artifact.header, artifact.inheritedEventCount),
|
|
412
424
|
encodedEvents(),
|
|
413
425
|
)
|
|
414
426
|
|
|
@@ -422,8 +434,10 @@ export async function publishSuccessor(
|
|
|
422
434
|
// names instead of ignoring them.
|
|
423
435
|
await writeFile(temporaryPath, bytes, { flag: 'wx', mode: 0o600 })
|
|
424
436
|
// Read the staged generation back with the host's current-generation policy
|
|
425
|
-
// BEFORE it becomes visible under its canonical name.
|
|
426
|
-
|
|
437
|
+
// BEFORE it becomes visible under its canonical name. The floor catalog is
|
|
438
|
+
// the one that must read it: it is the build whose encoder wrote it and the
|
|
439
|
+
// same historical reader the host uses for a pre-current generation.
|
|
440
|
+
verifyGeneration(floor, await readFile(temporaryPath), generation)
|
|
427
441
|
outcome = await publishExclusive(temporaryPath, targetPath, sha256(bytes))
|
|
428
442
|
} catch (error) {
|
|
429
443
|
try {
|