dsh-llm-fallbacks 0.5.0 → 0.5.2
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 +5 -1
- package/README.zh-CN.md +5 -1
- package/dist/client/SubagentRoleBadge.d.ts +40 -15
- package/dist/client/fallbacks-store.d.ts +0 -53
- package/dist/client/index.d.ts +1 -1
- package/dist/client/index.js +68 -145
- package/dist/gateway.d.ts +2 -55
- package/dist/index.d.ts +0 -23
- package/dist/index.js +557 -66
- package/dist/role-notice.d.ts +156 -0
- package/dist/role-projection-key.d.ts +41 -0
- package/dist/role-projection.d.ts +151 -0
- package/dist/subagents-seam.d.ts +292 -0
- package/package.json +24 -23
- package/scripts/build-client.ts +1 -1
- package/scripts/repair-session-logs.ts +239 -57
- package/scripts/session-logs/publish.ts +79 -26
package/package.json
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"dsh",
|
|
6
6
|
"dsh-plugin"
|
|
7
7
|
],
|
|
8
|
-
"version": "0.5.
|
|
8
|
+
"version": "0.5.2",
|
|
9
9
|
"type": "module",
|
|
10
10
|
"main": "./dist/index.js",
|
|
11
11
|
"types": "./dist/index.d.ts",
|
|
@@ -61,28 +61,29 @@
|
|
|
61
61
|
},
|
|
62
62
|
"peerDependencies": {
|
|
63
63
|
"@deepseek-ai/cordis": "^4.0.2",
|
|
64
|
-
"@deepseek-ai/dsh-agent": "^0.1.5-rc.
|
|
65
|
-
"@deepseek-ai/dsh-api-gateway": "^0.1.5-rc.
|
|
66
|
-
"@deepseek-ai/dsh-api-remotes": "^0.1.5-rc.
|
|
67
|
-
"@deepseek-ai/dsh-api-session-controller": "^0.1.5-rc.
|
|
68
|
-
"@deepseek-ai/dsh-client-connection": "^0.1.5-rc.
|
|
69
|
-
"@deepseek-ai/dsh-client-locale": "^0.1.5-rc.
|
|
70
|
-
"@deepseek-ai/dsh-client-store": "^0.1.5-rc.
|
|
71
|
-
"@deepseek-ai/dsh-client-ui-chat": "^0.1.5-rc.
|
|
72
|
-
"@deepseek-ai/dsh-client-ui-conversation": "^0.1.5-rc.
|
|
73
|
-
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.5-rc.
|
|
74
|
-
"@deepseek-ai/dsh-client-ui-renderer": "^0.1.5-rc.
|
|
75
|
-
"@deepseek-ai/dsh-client-ui-settings": "^0.1.5-rc.
|
|
76
|
-
"@deepseek-ai/dsh-client-ui-settings-plugins": "^0.1.5-rc.
|
|
77
|
-
"@deepseek-ai/dsh-client-ui-slots": "^0.1.5-rc.
|
|
78
|
-
"@deepseek-ai/dsh-commands": "^0.1.5-rc.
|
|
79
|
-
"@deepseek-ai/dsh-llm": "^0.1.5-rc.
|
|
80
|
-
"@deepseek-ai/dsh-llm-retry": "^0.1.5-rc.
|
|
81
|
-
"@deepseek-ai/dsh-session": "^0.1.5-rc.
|
|
82
|
-
"@deepseek-ai/dsh-
|
|
83
|
-
"@deepseek-ai/dsh-
|
|
84
|
-
"@deepseek-ai/dsh-
|
|
85
|
-
"@deepseek-ai/dsh-typert-
|
|
64
|
+
"@deepseek-ai/dsh-agent": "^0.1.5-rc.2",
|
|
65
|
+
"@deepseek-ai/dsh-api-gateway": "^0.1.5-rc.2",
|
|
66
|
+
"@deepseek-ai/dsh-api-remotes": "^0.1.5-rc.2",
|
|
67
|
+
"@deepseek-ai/dsh-api-session-controller": "^0.1.5-rc.2",
|
|
68
|
+
"@deepseek-ai/dsh-client-connection": "^0.1.5-rc.2",
|
|
69
|
+
"@deepseek-ai/dsh-client-locale": "^0.1.5-rc.2",
|
|
70
|
+
"@deepseek-ai/dsh-client-store": "^0.1.5-rc.2",
|
|
71
|
+
"@deepseek-ai/dsh-client-ui-chat": "^0.1.5-rc.2",
|
|
72
|
+
"@deepseek-ai/dsh-client-ui-conversation": "^0.1.5-rc.2",
|
|
73
|
+
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.5-rc.2",
|
|
74
|
+
"@deepseek-ai/dsh-client-ui-renderer": "^0.1.5-rc.2",
|
|
75
|
+
"@deepseek-ai/dsh-client-ui-settings": "^0.1.5-rc.2",
|
|
76
|
+
"@deepseek-ai/dsh-client-ui-settings-plugins": "^0.1.5-rc.2",
|
|
77
|
+
"@deepseek-ai/dsh-client-ui-slots": "^0.1.5-rc.2",
|
|
78
|
+
"@deepseek-ai/dsh-commands": "^0.1.5-rc.2",
|
|
79
|
+
"@deepseek-ai/dsh-llm": "^0.1.5-rc.2",
|
|
80
|
+
"@deepseek-ai/dsh-llm-retry": "^0.1.5-rc.2",
|
|
81
|
+
"@deepseek-ai/dsh-session": "^0.1.5-rc.2",
|
|
82
|
+
"@deepseek-ai/dsh-session-projection": "^0.1.5-rc.2",
|
|
83
|
+
"@deepseek-ai/dsh-settings": "^0.1.5-rc.2",
|
|
84
|
+
"@deepseek-ai/dsh-tool-subagent": "^0.1.5-rc.2",
|
|
85
|
+
"@deepseek-ai/dsh-typert-protocol": "^0.1.5-rc.2",
|
|
86
|
+
"@deepseek-ai/dsh-typert-registry": "^0.1.5-rc.2",
|
|
86
87
|
"@deepseek-ai/schemastery": "^3.18.2",
|
|
87
88
|
"react": "^18.2.0"
|
|
88
89
|
},
|
package/scripts/build-client.ts
CHANGED
|
@@ -48,7 +48,7 @@ const OUT_FILE = 'index.js'
|
|
|
48
48
|
|
|
49
49
|
/**
|
|
50
50
|
* Frozen loader module table (dsh mechanism-guide): the plan-documented
|
|
51
|
-
* composition = PLATFORM_MODULES (9 entries in 0.1.5-rc.
|
|
51
|
+
* composition = PLATFORM_MODULES (9 entries in 0.1.5-rc.2,
|
|
52
52
|
* `web/src/platform.ts:8-16` — the old runtime `./client` exemption became
|
|
53
53
|
* the `@deepseek-ai/dsh-client-store` platform module, so the store VALUE
|
|
54
54
|
* import stays external and resolves from the host loader table) + the
|
|
@@ -76,6 +76,19 @@
|
|
|
76
76
|
* stay inside `--root`. A stale `session.repair.*.jsonl.zstd.tmp` (the residue of
|
|
77
77
|
* an interrupted run) is reported too, and never deleted by this tool.
|
|
78
78
|
*
|
|
79
|
+
* THE TEXT REPORT IS LIVE: the header and the input diagnostics print as soon as
|
|
80
|
+
* discovery is done, every per-log line prints the moment that log has been
|
|
81
|
+
* triaged (and, under `--apply`, published), and only the by-class table and the
|
|
82
|
+
* summary line wait for the end of the walk — so a run that dies mid-walk (a
|
|
83
|
+
* crash, an OOM, a Ctrl-C, a closed stdout) keeps the lines it has already written
|
|
84
|
+
* out (stream bytes not yet flushed can still be lost with the process). `--json`
|
|
85
|
+
* is unchanged: ONE document built from the finished run.
|
|
86
|
+
* `--quiet` is unchanged too (see its own contract below). A consumer that stops
|
|
87
|
+
* reading — a closed stdout/stderr, the ordinary `| head -n 5` — is not a store
|
|
88
|
+
* event: the first EPIPE stops writes to that stream, the run still finishes (an
|
|
89
|
+
* `--apply` pass publishes every remaining successor) and the exit code keeps
|
|
90
|
+
* describing the store (see `pipeSafeWriter`).
|
|
91
|
+
*
|
|
79
92
|
* `--class NAME` restricts RULE APPLICATION to one class (default: all rules),
|
|
80
93
|
* which is what `--apply` will repair. It never narrows the listing, the class
|
|
81
94
|
* table or the exit code: the report's `repairable` verdict is always computed
|
|
@@ -528,12 +541,20 @@ async function readableDirectoryProblem(dir: string): Promise<string | null> {
|
|
|
528
541
|
* row fails loudly (the log is then reported as `decompress-failed`, never
|
|
529
542
|
* silently truncated).
|
|
530
543
|
*
|
|
544
|
+
* Frames are consumed one at a time from ANY iterable (the publisher's decoder is
|
|
545
|
+
* a generator), so the frames are never joined into one string and no array of every
|
|
546
|
+
* line is built. Each frame's own plaintext is materialized whole, and the event
|
|
547
|
+
* batch is ONE frame — so that frame is the session's whole event plaintext (see the
|
|
548
|
+
* frame ceiling in `session-logs/publish.ts`). A line split across two frames is
|
|
549
|
+
* reassembled exactly like concatenating the frames first would, so the framing
|
|
550
|
+
* stays irrelevant to the row stream.
|
|
551
|
+
*
|
|
531
552
|
* @param frames decoded frame plaintexts, in file order.
|
|
532
553
|
*/
|
|
533
|
-
export function decodeRows(frames:
|
|
554
|
+
export function decodeRows(frames: Iterable<string>): ParsedRow[] {
|
|
534
555
|
const rows: ParsedRow[] = []
|
|
535
|
-
|
|
536
|
-
if (line.length === 0)
|
|
556
|
+
const push = (line: string): void => {
|
|
557
|
+
if (line.length === 0) return
|
|
537
558
|
let record: unknown
|
|
538
559
|
try {
|
|
539
560
|
record = JSON.parse(line)
|
|
@@ -545,28 +566,33 @@ export function decodeRows(frames: readonly string[]): ParsedRow[] {
|
|
|
545
566
|
}
|
|
546
567
|
if (rows.length === 0) {
|
|
547
568
|
rows.push({ seq: 0, ...(record as Record<string, unknown>) } as unknown as ParsedRow)
|
|
548
|
-
|
|
569
|
+
return
|
|
549
570
|
}
|
|
550
571
|
if (typeof (record as Record<string, unknown>)['type'] !== 'string') {
|
|
551
572
|
throw new Error(`row ${rows.length + 1} has no string "type"`)
|
|
552
573
|
}
|
|
553
574
|
rows.push(record as unknown as ParsedRow)
|
|
554
575
|
}
|
|
576
|
+
// One frame's plaintext at a time: only the current line and the current frame
|
|
577
|
+
// are alive, and the frame's last (possibly partial) line is carried into the
|
|
578
|
+
// next frame so a line split across a frame boundary still parses.
|
|
579
|
+
let pending = ''
|
|
580
|
+
for (const frame of frames) {
|
|
581
|
+
const text = pending === '' ? frame : pending + frame
|
|
582
|
+
let start = 0
|
|
583
|
+
for (;;) {
|
|
584
|
+
const end = text.indexOf('\n', start)
|
|
585
|
+
if (end < 0) break
|
|
586
|
+
push(text.slice(start, end))
|
|
587
|
+
start = end + 1
|
|
588
|
+
}
|
|
589
|
+
pending = text.slice(start)
|
|
590
|
+
}
|
|
591
|
+
push(pending)
|
|
555
592
|
if (rows.length === 0) throw new Error('the log carries no JSONL record')
|
|
556
593
|
return rows
|
|
557
594
|
}
|
|
558
595
|
|
|
559
|
-
/**
|
|
560
|
-
* Canonical successor filename for one generation.
|
|
561
|
-
*
|
|
562
|
-
* Mirrors the released `generationLogFilename` (and `publish.ts`'s private
|
|
563
|
-
* `successorFilename`, which is not exported): version 0 keeps the suffix-only
|
|
564
|
-
* name, later generations carry `v<N>`.
|
|
565
|
-
*/
|
|
566
|
-
export function successorFilename(generation: number): string {
|
|
567
|
-
return generation === 0 ? 'session.jsonl.zstd' : `session.v${generation}.jsonl.zstd`
|
|
568
|
-
}
|
|
569
|
-
|
|
570
596
|
/* ------------------------------------------------------------------ */
|
|
571
597
|
/* per-log work */
|
|
572
598
|
/* ------------------------------------------------------------------ */
|
|
@@ -800,7 +826,20 @@ interface InspectContext {
|
|
|
800
826
|
rules: readonly LogRule[]
|
|
801
827
|
dropLegacyEvents: boolean
|
|
802
828
|
catalog: CatalogHandle | null
|
|
803
|
-
|
|
829
|
+
/**
|
|
830
|
+
* Canonical successor filename for one generation: the publisher's exported
|
|
831
|
+
* rule, taken from the same module instance that publishes (this module is
|
|
832
|
+
* imported dynamically, AFTER the `node:zlib` zstd runtime probe, so the CLI
|
|
833
|
+
* cannot import this statically). One implementation — no local copy here.
|
|
834
|
+
*/
|
|
835
|
+
successorFilename(version: number): string
|
|
836
|
+
/**
|
|
837
|
+
* The publisher's frame decoder, one frame's plaintext at a time (`decodeRows`
|
|
838
|
+
* consumes it lazily, so no two frames are materialized at once). Typed as an
|
|
839
|
+
* iterable — not `string[]` — on purpose: the array form would hold every frame,
|
|
840
|
+
* the whole event batch included, simultaneously.
|
|
841
|
+
*/
|
|
842
|
+
decodeFrameTexts(bytes: Buffer): Iterable<string>
|
|
804
843
|
publishSuccessor: (
|
|
805
844
|
logPath: string,
|
|
806
845
|
rows: readonly ParsedRow[],
|
|
@@ -1048,7 +1087,7 @@ async function analyzeLog(candidate: LogGeneration, context: InspectContext): Pr
|
|
|
1048
1087
|
// successor from any other revision (C-2): decoding and publishing must be
|
|
1049
1088
|
// about the same bytes.
|
|
1050
1089
|
sourceDigest = sha256(sourceBytes)
|
|
1051
|
-
rows = decodeRows(context.
|
|
1090
|
+
rows = decodeRows(context.decodeFrameTexts(sourceBytes))
|
|
1052
1091
|
} catch (error) {
|
|
1053
1092
|
return {
|
|
1054
1093
|
...base,
|
|
@@ -1194,8 +1233,18 @@ async function analyzeLog(candidate: LogGeneration, context: InspectContext): Pr
|
|
|
1194
1233
|
// is still reported `repairable` from the rule registry alone), and `--apply`
|
|
1195
1234
|
// was already refused as fatal by the caller — which is why the null case is
|
|
1196
1235
|
// checked here rather than assumed away.
|
|
1236
|
+
//
|
|
1237
|
+
// The name comes from the publisher's own exported rule (no local copy). Note
|
|
1238
|
+
// the two INPUTS differ: this probe uses the resolved catalog's declared
|
|
1239
|
+
// `currentVersion`, while `publishSuccessor` derives its target from the version
|
|
1240
|
+
// the RESTORED header settles on. They agree for every catalog whose restore
|
|
1241
|
+
// settles at the version it declares (the released one does — its successor is
|
|
1242
|
+
// read back and pinned to that version before publication); a catalog that
|
|
1243
|
+
// declared one version and settled at another would leave this probe looking for
|
|
1244
|
+
// a name the publication never used, so report mode alone could call such a log
|
|
1245
|
+
// unpublished while `--apply` accepts the existing successor as byte-identical.
|
|
1197
1246
|
const catalog = context.catalog
|
|
1198
|
-
const expected = catalog === null ? null : successorFilename(catalog.catalog.currentVersion)
|
|
1247
|
+
const expected = catalog === null ? null : context.successorFilename(catalog.catalog.currentVersion)
|
|
1199
1248
|
const successorPath = expected === null ? null : join(candidate.sessionDir, expected)
|
|
1200
1249
|
const existingSuccessor =
|
|
1201
1250
|
successorPath !== null && (await fileExists(successorPath)) ? successorPath : null
|
|
@@ -1316,7 +1365,7 @@ async function analyzeLog(candidate: LogGeneration, context: InspectContext): Pr
|
|
|
1316
1365
|
*/
|
|
1317
1366
|
async function classifyFile(path: string, context: InspectContext): Promise<RefusalClass> {
|
|
1318
1367
|
try {
|
|
1319
|
-
const rows = decodeRows(context.
|
|
1368
|
+
const rows = decodeRows(context.decodeFrameTexts(await readFile(path)))
|
|
1320
1369
|
// The report's structural pass uses the RUN'S policy rules (not the default
|
|
1321
1370
|
// registry): an existing successor is judged by the same rule set this run was
|
|
1322
1371
|
// invoked with. The catalog-null arm is DEFENSIVE — this function's only call
|
|
@@ -1373,10 +1422,35 @@ function policyRules(dropLegacyEvents: boolean): readonly LogRule[] {
|
|
|
1373
1422
|
]
|
|
1374
1423
|
}
|
|
1375
1424
|
|
|
1425
|
+
/**
|
|
1426
|
+
* The live progress seam of one walk: the text report uses it to print each log's
|
|
1427
|
+
* line while the run is still working, instead of buffering the whole report until
|
|
1428
|
+
* the walk ends. A run that dies mid-walk (a crash, an OOM, a Ctrl-C, a closed
|
|
1429
|
+
* stdout) therefore loses at most the output its stream had not written out yet.
|
|
1430
|
+
* `--json` — and every library caller — passes no sink and sees the buffered
|
|
1431
|
+
* behaviour.
|
|
1432
|
+
*/
|
|
1433
|
+
export interface RunProgress {
|
|
1434
|
+
/**
|
|
1435
|
+
* Discovery is done and the catalog is resolved: everything the text report
|
|
1436
|
+
* prints BEFORE the first per-log line is now known — the header, the inputs the
|
|
1437
|
+
* walk could not inspect (`skipped`) and the stale staging residue.
|
|
1438
|
+
*/
|
|
1439
|
+
onDiscovery(discovery: DiscoveryResult, catalog: RunResult['catalog']): void
|
|
1440
|
+
/**
|
|
1441
|
+
* One finished log, in walk order. Called after all of that log's own work
|
|
1442
|
+
* (classification, proof and any publication) is done.
|
|
1443
|
+
*/
|
|
1444
|
+
onLog(log: LogOutcome): void
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1376
1447
|
/**
|
|
1377
1448
|
* Do the work: resolve the oracle, triage every candidate, and publish when
|
|
1378
1449
|
* `--apply` asks for it.
|
|
1379
1450
|
*
|
|
1451
|
+
* @param progress optional live sink; see {@link RunProgress}. Every callback
|
|
1452
|
+
* fires after the corresponding fact is known, so no fatal path (all of which
|
|
1453
|
+
* precede discovery) can report a partial line.
|
|
1380
1454
|
* @throws FatalError on a missing or unreadable `--root`, a runtime without
|
|
1381
1455
|
* `node:zlib` zstd, `--apply` without a resolved catalog, a resolved catalog
|
|
1382
1456
|
* below {@link REQUIRED_CATALOG_VERSION}, or `--apply --drop-legacy-events`
|
|
@@ -1385,6 +1459,7 @@ function policyRules(dropLegacyEvents: boolean): readonly LogRule[] {
|
|
|
1385
1459
|
export async function runRepair(
|
|
1386
1460
|
options: CliOptions,
|
|
1387
1461
|
env: NodeJS.ProcessEnv = process.env,
|
|
1462
|
+
progress?: RunProgress,
|
|
1388
1463
|
): Promise<RunResult> {
|
|
1389
1464
|
// The lossy opt-in is the one write that removes data from the readable
|
|
1390
1465
|
// session, so an `--apply` run must not proceed without a byte copy of the only
|
|
@@ -1425,6 +1500,18 @@ export async function runRepair(
|
|
|
1425
1500
|
const catalogProblem = catalog === null ? null : catalogVersionRefusal(catalog)
|
|
1426
1501
|
if (catalogProblem !== null) throw new FatalError(catalogProblem)
|
|
1427
1502
|
|
|
1503
|
+
// Built once and used by BOTH the progress callback and the returned result, so
|
|
1504
|
+
// a streaming run reports exactly the catalog the finished document carries.
|
|
1505
|
+
const catalogReport: RunResult['catalog'] = catalog === null
|
|
1506
|
+
? { resolved: false }
|
|
1507
|
+
: {
|
|
1508
|
+
resolved: true,
|
|
1509
|
+
modulePath: catalog.modulePath,
|
|
1510
|
+
resolvedBy: catalog.resolvedBy,
|
|
1511
|
+
packageVersion: catalog.packageVersion,
|
|
1512
|
+
currentVersion: catalog.catalog.currentVersion,
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1428
1515
|
const reportRules = policyRules(options.dropLegacyEvents)
|
|
1429
1516
|
const rules =
|
|
1430
1517
|
options.classFilter === null
|
|
@@ -1432,6 +1519,9 @@ export async function runRepair(
|
|
|
1432
1519
|
: reportRules.filter((rule) => rule.class === options.classFilter)
|
|
1433
1520
|
const discovery = await findGenerations(options.root)
|
|
1434
1521
|
if (discovery.rootFailure !== null) throw new FatalError(discovery.rootFailure)
|
|
1522
|
+
// Every fatal check is behind us, so a text run may report its header now: the
|
|
1523
|
+
// resolved catalog and the walk's inputs are known, the logs are not.
|
|
1524
|
+
progress?.onDiscovery(discovery, catalogReport)
|
|
1435
1525
|
const context: InspectContext = {
|
|
1436
1526
|
apply: options.apply,
|
|
1437
1527
|
backup: options.backup,
|
|
@@ -1440,30 +1530,28 @@ export async function runRepair(
|
|
|
1440
1530
|
rules,
|
|
1441
1531
|
dropLegacyEvents: options.dropLegacyEvents,
|
|
1442
1532
|
catalog,
|
|
1443
|
-
|
|
1533
|
+
successorFilename: publishing.successorFilename,
|
|
1534
|
+
decodeFrameTexts: publishing.decodeZstdFrameTexts,
|
|
1444
1535
|
publishSuccessor: publishing.publishSuccessor,
|
|
1445
1536
|
stalePublicationPath: (error) =>
|
|
1446
1537
|
error instanceof publishing.PublishedFromStaleSourceError ? error.successorPath : null,
|
|
1447
1538
|
}
|
|
1448
1539
|
|
|
1449
1540
|
const logs: LogOutcome[] = []
|
|
1450
|
-
for (const candidate of discovery.generations)
|
|
1541
|
+
for (const candidate of discovery.generations) {
|
|
1542
|
+
const log = await inspectLog(candidate, context)
|
|
1543
|
+
logs.push(log)
|
|
1544
|
+
// Report THIS log now (its publication, if any, is complete): a later crash,
|
|
1545
|
+
// OOM or Ctrl-C can only take down output this run had not written out yet.
|
|
1546
|
+
progress?.onLog(log)
|
|
1547
|
+
}
|
|
1451
1548
|
|
|
1452
1549
|
const summary = summarize(logs, discovery.skipped.length)
|
|
1453
1550
|
return {
|
|
1454
1551
|
root: options.root,
|
|
1455
1552
|
mode: options.apply ? 'apply' : 'report',
|
|
1456
1553
|
classFilter: options.classFilter,
|
|
1457
|
-
catalog:
|
|
1458
|
-
catalog === null
|
|
1459
|
-
? { resolved: false }
|
|
1460
|
-
: {
|
|
1461
|
-
resolved: true,
|
|
1462
|
-
modulePath: catalog.modulePath,
|
|
1463
|
-
resolvedBy: catalog.resolvedBy,
|
|
1464
|
-
packageVersion: catalog.packageVersion,
|
|
1465
|
-
currentVersion: catalog.catalog.currentVersion,
|
|
1466
|
-
},
|
|
1554
|
+
catalog: catalogReport,
|
|
1467
1555
|
logs,
|
|
1468
1556
|
summary,
|
|
1469
1557
|
skipped: discovery.skipped,
|
|
@@ -1523,15 +1611,52 @@ export interface CliIO {
|
|
|
1523
1611
|
err(text: string): void
|
|
1524
1612
|
}
|
|
1525
1613
|
|
|
1526
|
-
/**
|
|
1614
|
+
/**
|
|
1615
|
+
* A stream's guarded writer, once: the closed-pipe state belongs to the STREAM, so a
|
|
1616
|
+
* second `consoleIO()` in the same process reuses the writer instead of stacking a
|
|
1617
|
+
* second 'error' listener on a process stream.
|
|
1618
|
+
*/
|
|
1619
|
+
const guardedWriters = new WeakMap<NodeJS.WriteStream, (text: string) => void>()
|
|
1620
|
+
|
|
1621
|
+
/**
|
|
1622
|
+
* A writer for ONE process stream that survives its reader going away.
|
|
1623
|
+
*
|
|
1624
|
+
* Piping the report into a consumer that stops reading (`… | head -n 5`) closes the
|
|
1625
|
+
* read end, and the next write then raises `EPIPE` as an UNHANDLED `'error'` event:
|
|
1626
|
+
* a raw `node:events … write EPIPE` stack trace and an exit code that says nothing
|
|
1627
|
+
* about the store — the exact opposite of what this tool's exit codes mean. A reader
|
|
1628
|
+
* is not a store event, so the FIRST `EPIPE` marks this stream dead, every later
|
|
1629
|
+
* write to it is skipped, and the run itself finishes normally (an `--apply` pass
|
|
1630
|
+
* still publishes every remaining successor) with the exit code it computed.
|
|
1631
|
+
*
|
|
1632
|
+
* A stream error that is NOT `EPIPE` keeps the previous behaviour (an uncaught
|
|
1633
|
+
* error): a report that genuinely could not be delivered (a full disk, an I/O
|
|
1634
|
+
* failure) must never be swallowed silently.
|
|
1635
|
+
*/
|
|
1636
|
+
function pipeSafeWriter(stream: NodeJS.WriteStream): (text: string) => void {
|
|
1637
|
+
const existing = guardedWriters.get(stream)
|
|
1638
|
+
if (existing !== undefined) return existing
|
|
1639
|
+
let closed = false
|
|
1640
|
+
stream.on('error', (error: NodeJS.ErrnoException) => {
|
|
1641
|
+
if (error.code === 'EPIPE') {
|
|
1642
|
+
closed = true
|
|
1643
|
+
return
|
|
1644
|
+
}
|
|
1645
|
+
throw error
|
|
1646
|
+
})
|
|
1647
|
+
const write = (text: string): void => {
|
|
1648
|
+
if (closed) return
|
|
1649
|
+
stream.write(`${text}\n`)
|
|
1650
|
+
}
|
|
1651
|
+
guardedWriters.set(stream, write)
|
|
1652
|
+
return write
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
/** The default sinks: one line per call on stdout / stderr, closed-pipe safe. */
|
|
1527
1656
|
export function consoleIO(): CliIO {
|
|
1528
1657
|
return {
|
|
1529
|
-
out: (
|
|
1530
|
-
|
|
1531
|
-
},
|
|
1532
|
-
err: (text) => {
|
|
1533
|
-
process.stderr.write(`${text}\n`)
|
|
1534
|
-
},
|
|
1658
|
+
out: pipeSafeWriter(process.stdout),
|
|
1659
|
+
err: pipeSafeWriter(process.stderr),
|
|
1535
1660
|
}
|
|
1536
1661
|
}
|
|
1537
1662
|
|
|
@@ -1598,24 +1723,31 @@ export function lossyResultNotice(result: RunResult): string | null {
|
|
|
1598
1723
|
].join('\n')
|
|
1599
1724
|
}
|
|
1600
1725
|
|
|
1601
|
-
/** The
|
|
1602
|
-
|
|
1726
|
+
/** The run's report minus the parts that only exist once the walk is over. */
|
|
1727
|
+
type ReportHead = Omit<RunResult, 'logs' | 'summary' | 'exitCode'>
|
|
1728
|
+
|
|
1729
|
+
/**
|
|
1730
|
+
* The text report's head: the header lines plus the input diagnostics, i.e.
|
|
1731
|
+
* everything that precedes the first per-log line. A streaming text run prints it
|
|
1732
|
+
* the moment discovery is done (see {@link textProgress}).
|
|
1733
|
+
*/
|
|
1734
|
+
function reportHead(head: ReportHead, io: CliIO): void {
|
|
1603
1735
|
io.out(
|
|
1604
1736
|
`${PROGRAM}: ${
|
|
1605
|
-
|
|
1737
|
+
head.mode === 'apply' ? 'apply (publishes successor generations; originals are never modified)' : 'report only (no write)'
|
|
1606
1738
|
}`,
|
|
1607
1739
|
)
|
|
1608
|
-
io.out(`root: ${
|
|
1740
|
+
io.out(`root: ${head.root}`)
|
|
1609
1741
|
io.out(
|
|
1610
|
-
|
|
1611
|
-
? `catalog: ${
|
|
1612
|
-
+ ` package ${
|
|
1742
|
+
head.catalog.resolved
|
|
1743
|
+
? `catalog: ${head.catalog.modulePath} (v${head.catalog.currentVersion},`
|
|
1744
|
+
+ ` package ${head.catalog.packageVersion ?? 'version unknown'}, resolved by ${head.catalog.resolvedBy})`
|
|
1613
1745
|
: `catalog: none resolved — classification is STRUCTURAL ONLY and --apply would refuse `
|
|
1614
1746
|
+ `(no --catalog, no ${CATALOG_ENV_VAR}, no dsh on PATH, no npx install)`,
|
|
1615
1747
|
)
|
|
1616
|
-
if (
|
|
1748
|
+
if (head.classFilter !== null) {
|
|
1617
1749
|
io.out(
|
|
1618
|
-
`class filter: ${
|
|
1750
|
+
`class filter: ${head.classFilter} (rule application only; the listing, the class table and the exit code `
|
|
1619
1751
|
+ 'still cover every log, and the repairable verdict is always computed over the full policy)',
|
|
1620
1752
|
)
|
|
1621
1753
|
}
|
|
@@ -1624,23 +1756,39 @@ function reportText(result: RunResult, io: CliIO, quiet: boolean): void {
|
|
|
1624
1756
|
// (named with its errno) and the residue of an interrupted publication. These are
|
|
1625
1757
|
// diagnostics about the ROOT, not per-log report rows, and each of them makes the
|
|
1626
1758
|
// run exit 1 — which is what the README promises.
|
|
1627
|
-
|
|
1628
|
-
io.out(` no session log with a canonical generation below v${CURRENT_VERSION_FLOOR} under this root`)
|
|
1629
|
-
}
|
|
1630
|
-
for (const entry of result.skipped) {
|
|
1759
|
+
for (const entry of head.skipped) {
|
|
1631
1760
|
io.out(` ${'skipped'.padEnd(TOKEN_WIDTH)} ${entry.path}: ${entry.reason}`)
|
|
1632
1761
|
}
|
|
1633
|
-
for (const path of
|
|
1762
|
+
for (const path of head.staleStagingFiles) {
|
|
1634
1763
|
io.out(
|
|
1635
1764
|
` ${'stale staging'.padEnd(TOKEN_WIDTH)} ${path}: residue of an interrupted publication; `
|
|
1636
1765
|
+ 'this tool never removes it, delete it once no run is active',
|
|
1637
1766
|
)
|
|
1638
1767
|
}
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
/** One per-log line — the single place the per-log report format lives. */
|
|
1771
|
+
function logLine(log: LogOutcome, mode: RunMode): string {
|
|
1772
|
+
return ` ${token(log, mode).padEnd(TOKEN_WIDTH)} ${log.path} (v${log.generation}): ${log.detail}`
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
/**
|
|
1776
|
+
* The text report's tail, printed once the walk is over: the empty-root line, the
|
|
1777
|
+
* by-class table (unless `--quiet`) and the summary line. The per-log lines are
|
|
1778
|
+
* NOT printed here — a text run emitted each of them during the walk (see
|
|
1779
|
+
* {@link textProgress}), which is what lets an interrupted run keep them.
|
|
1780
|
+
*
|
|
1781
|
+
* The empty-root line only prints when there is no per-log line at all (no
|
|
1782
|
+
* candidate, no skipped entry, no stale staging file), so keeping it in the tail
|
|
1783
|
+
* leaves the printed order identical to the buffered report — a streaming run
|
|
1784
|
+
* simply learns `logs.length` one walk later than it learns the header.
|
|
1785
|
+
*/
|
|
1786
|
+
function reportTail(result: RunResult, io: CliIO, quiet: boolean): void {
|
|
1787
|
+
if (result.logs.length === 0 && result.skipped.length === 0 && result.staleStagingFiles.length === 0) {
|
|
1788
|
+
io.out(` no session log with a canonical generation below v${CURRENT_VERSION_FLOOR} under this root`)
|
|
1789
|
+
}
|
|
1639
1790
|
|
|
1640
1791
|
if (!quiet) {
|
|
1641
|
-
for (const log of result.logs) {
|
|
1642
|
-
io.out(` ${token(log, result.mode).padEnd(TOKEN_WIDTH)} ${log.path} (v${log.generation}): ${log.detail}`)
|
|
1643
|
-
}
|
|
1644
1792
|
io.out('')
|
|
1645
1793
|
io.out('class logs')
|
|
1646
1794
|
for (const name of REFUSAL_CLASSES) {
|
|
@@ -1659,6 +1807,31 @@ function reportText(result: RunResult, io: CliIO, quiet: boolean): void {
|
|
|
1659
1807
|
)
|
|
1660
1808
|
}
|
|
1661
1809
|
|
|
1810
|
+
/**
|
|
1811
|
+
* The text report's live sink: the head prints at discovery and each per-log line
|
|
1812
|
+
* prints as that log finishes, so an interrupted run keeps what it has already
|
|
1813
|
+
* written out (R-004). `--quiet` still prints no per-log line (its documented
|
|
1814
|
+
* contract: only the per-log lines and the by-class table are suppressed), and it
|
|
1815
|
+
* still prints the head and the summary.
|
|
1816
|
+
*/
|
|
1817
|
+
function textProgress(options: CliOptions, io: CliIO): RunProgress {
|
|
1818
|
+
const mode: RunMode = options.apply ? 'apply' : 'report'
|
|
1819
|
+
return {
|
|
1820
|
+
onDiscovery: (discovery, catalog) =>
|
|
1821
|
+
reportHead({
|
|
1822
|
+
root: options.root,
|
|
1823
|
+
mode,
|
|
1824
|
+
classFilter: options.classFilter,
|
|
1825
|
+
catalog,
|
|
1826
|
+
skipped: discovery.skipped,
|
|
1827
|
+
staleStagingFiles: discovery.staleStagingFiles,
|
|
1828
|
+
}, io),
|
|
1829
|
+
onLog: (log) => {
|
|
1830
|
+
if (!options.quiet) io.out(logLine(log, mode))
|
|
1831
|
+
},
|
|
1832
|
+
}
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1662
1835
|
/** The machine-readable report: the same data the text report prints. */
|
|
1663
1836
|
function reportJson(result: RunResult, io: CliIO): void {
|
|
1664
1837
|
io.out(JSON.stringify(result, null, 2))
|
|
@@ -1678,9 +1851,12 @@ export async function execute(
|
|
|
1678
1851
|
): Promise<number> {
|
|
1679
1852
|
if (options.apply) io.err(preconditionNotice())
|
|
1680
1853
|
if (options.apply && options.dropLegacyEvents) io.err(lossyApplyNotice())
|
|
1854
|
+
// Text mode streams the report through the walk; `--json` passes no sink and
|
|
1855
|
+
// stays ONE document built from the finished result.
|
|
1856
|
+
const progress = options.json ? undefined : textProgress(options, io)
|
|
1681
1857
|
let result: RunResult
|
|
1682
1858
|
try {
|
|
1683
|
-
result = await runRepair(options, env)
|
|
1859
|
+
result = await runRepair(options, env, progress)
|
|
1684
1860
|
} catch (error) {
|
|
1685
1861
|
if (error instanceof FatalError) {
|
|
1686
1862
|
io.err(`${PROGRAM}: ${error.message}`)
|
|
@@ -1689,7 +1865,7 @@ export async function execute(
|
|
|
1689
1865
|
throw error
|
|
1690
1866
|
}
|
|
1691
1867
|
if (options.json) reportJson(result, io)
|
|
1692
|
-
else
|
|
1868
|
+
else reportTail(result, io, options.quiet)
|
|
1693
1869
|
const lossy = lossyResultNotice(result)
|
|
1694
1870
|
if (lossy !== null) io.err(lossy)
|
|
1695
1871
|
return result.exitCode
|
|
@@ -1802,6 +1978,12 @@ host's flock lease (that lease is host-internal and cannot be taken from this re
|
|
|
1802
1978
|
a dsh that is still appending to the old generation would be orphaned once the host
|
|
1803
1979
|
prefers the successor. Stop dsh first. Rollback: delete the successor generation.
|
|
1804
1980
|
|
|
1981
|
+
CLOSED OUTPUT: a consumer that stops reading the report — a closed stdout or stderr,
|
|
1982
|
+
the ordinary "| head -n 5" — is NOT a store event. The first EPIPE stops further
|
|
1983
|
+
writes to that stream (no stack trace), while the run itself still finishes: an
|
|
1984
|
+
--apply pass publishes every remaining successor, and the exit code keeps describing
|
|
1985
|
+
the STORE (see EXIT CODES). Any other stream error is not swallowed.
|
|
1986
|
+
|
|
1805
1987
|
RUNTIME: reading and writing session logs needs node:zlib zstd (Node >= 22.15, while
|
|
1806
1988
|
engines.node allows >= 22); a runtime without it fails closed with exit 2. A frame whose
|
|
1807
1989
|
declared plaintext exceeds the tool's frame ceiling is refused as decompress-failed.
|