@nanmicoder/dsh-agent-teams 0.1.22 → 0.1.24-rc.1
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 +35 -13
- package/README_ZH.md +35 -13
- package/compatibility.json +6 -2
- package/lib/artifacts.js +342 -0
- package/lib/capabilities.js +6 -4
- package/lib/client/ActivityPanel.js +5 -2
- package/lib/client/agent-teams-card-definition.js +100 -6
- package/lib/client.js +90 -7
- package/lib/client.js.map +1 -1
- package/lib/command.js +3 -2
- package/lib/git-artifact-stamp.json +2 -2
- package/lib/harness-compat.js +139 -24
- package/lib/index.js +26 -3
- package/lib/lineage.js +198 -0
- package/lib/mailbox.js +59 -34
- package/lib/members.js +355 -82
- package/lib/profiles.js +3 -2
- package/lib/quality-gates.js +850 -65
- package/lib/scheduler.js +22 -3
- package/lib/snapshot.js +22 -1
- package/lib/state.js +282 -13
- package/lib/tool-policies.js +187 -0
- package/lib/tools.js +382 -109
- package/lib/types/artifacts.d.ts +66 -0
- package/lib/types/client/activity-monitor.d.ts +6 -0
- package/lib/types/client/agent-teams-card-definition.d.ts +56 -0
- package/lib/types/harness-compat.d.ts +78 -15
- package/lib/types/index.d.ts +26 -1
- package/lib/types/lineage.d.ts +92 -0
- package/lib/types/members.d.ts +113 -2
- package/lib/types/profiles.d.ts +6 -0
- package/lib/types/quality-gates.d.ts +216 -10
- package/lib/types/scheduler.d.ts +2 -1
- package/lib/types/snapshot.d.ts +20 -0
- package/lib/types/state.d.ts +22 -5
- package/lib/types/tool-policies.d.ts +119 -0
- package/lib/types/tools.d.ts +12 -1
- package/lib/types/types.d.ts +100 -0
- package/lib/types/workspace-audit.d.ts +27 -0
- package/lib/workspace-audit.js +52 -0
- package/package.json +61 -54
- package/pnpm-workspace.yaml +304 -273
- package/release-notes/v0.1.23.md +34 -0
- package/release-notes/v0.1.24-rc.1.md +13 -0
- package/scripts/legacy-session-source-repair.mjs +344 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# AgentTeams v0.1.23
|
|
2
|
+
|
|
3
|
+
Stable plugin release on npm `latest`. The recommended host remains DeepSeek Harness `0.2.0-rc.2`; the exact eight-host support matrix is unchanged.
|
|
4
|
+
|
|
5
|
+
## Changes
|
|
6
|
+
|
|
7
|
+
- Fix Unicode team cards and legacy event folding so member rosters use the same team identity as persisted state (#203).
|
|
8
|
+
- Reject invalid finding line numbers before persistence and normalize recoverable historical values (#188).
|
|
9
|
+
- Resolve member models together with their configured provider, and preserve explicit provider selections (#189).
|
|
10
|
+
- Record member turn failures that arrive after task settlement as durable captain notes (#205).
|
|
11
|
+
- Bind review and verification verdicts to the actual artifact bytes and complete repair lineage; changed files block delivery until covered by a sound verdict (#161).
|
|
12
|
+
- Preserve repair contracts, expose stale source warnings, and create repair/reverification follow-ups for failed verification without deadlocking on failed dependencies (#174, #175).
|
|
13
|
+
- Escalate findings outside the permitted repair scope with a reason instead of creating impossible repair tasks (#183).
|
|
14
|
+
- Preserve captain identity across warm and cold forks, including multihop lineage and member wake-ups (#176).
|
|
15
|
+
- Write session source metadata in the current format and ship an explicit, non-destructive historical recovery utility with source backups (#160).
|
|
16
|
+
- Add owner-configured member tool policies. Members select a policy ID; the four required coordination tools remain available. This controls tool availability and is not an operating-system sandbox (#59).
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
In Plugins → Add plugin, enter `@nanmicoder/dsh-agent-teams@0.1.23`. For CLI profiles:
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
dsh plugin --profile web add --save-exact @nanmicoder/dsh-agent-teams@0.1.23
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Restart the target profile after installation. A global CLI update does not change a desktop application's embedded host.
|
|
27
|
+
|
|
28
|
+
## Verification scope
|
|
29
|
+
|
|
30
|
+
The source candidate passed typechecking, build, the full project verification suite, and 121 added regression cases. Local macOS arm64 acceptance used the official Harness `0.2.0-rc.2` Loader, real workspaces, and the existing `deepseek-account / deepseek-flash` login without new API keys. It exercised actual member file writes, failed verification followed by repair and independent rechecking, read-only review, cold fork recovery, and content-drift delivery blocking. Integration acceptance included a captain-driven role/contract correction; the original failed observations are retained.
|
|
31
|
+
|
|
32
|
+
Publication requires Ubuntu/Windows static checks and the eight-host runtime matrix against one immutable tarball. CI uses deterministic model fixtures with real host implementations; those checks are separate from the local real-provider acceptance. Real account HTTP 429 responses, packaged Electron, and Windows product execution were not covered by that local acceptance.
|
|
33
|
+
|
|
34
|
+
See [the issue triage and acceptance records](../docs/issue-maintenance-2026-10-10/README.md) for individual evidence and limits. #103's original reproduction already passed on the baseline; no speculative scheduler change is included. Third-party plugin reports and the broader #70/#71 feature requests are outside this release.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# v0.1.24-rc.1 — Harness 0.2.1 compatibility
|
|
2
|
+
|
|
3
|
+
Prerelease channel: npm `next`. Recommended host: Harness `0.2.1-rc.1`.
|
|
4
|
+
|
|
5
|
+
- Support Harness `0.2.1-rc.1` managed subagent creation and targeted recursive cancellation, retaining legacy host adapters.
|
|
6
|
+
- Restore member persona and tool restrictions before the first request and after cold recovery. Persist exact member setup and refuse recovery when required setup is missing.
|
|
7
|
+
- Restore live member persona and custom tool restrictions after plugin configuration reload, after registering the tools required by saved policies.
|
|
8
|
+
- Apply member delegation limits to the new activation API. Preserve native completion/error notifications and existing successful-report deduplication.
|
|
9
|
+
- Pin the full development host cohort and align Cordis, Schemastery and plugin-group dependencies.
|
|
10
|
+
|
|
11
|
+
Validation scope and limitations: [migration record](../docs/harness-0.2.1-rc.1/README.md).
|
|
12
|
+
|
|
13
|
+
Install the published version with `dsh plugin --profile web add --save-exact @nanmicoder/dsh-agent-teams@0.1.24-rc.1`. This prerelease does not move npm `latest`.
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Repair one historical AgentTeams session generation for #160.
|
|
4
|
+
*
|
|
5
|
+
* The released v2→v3 migration edge audits every message source against a
|
|
6
|
+
* closed list of released kinds, so a stored pre-v3 generation that contains
|
|
7
|
+
* the plugin's own producer kind (`agent-teams-command`) is refused in full by
|
|
8
|
+
* every later Harness (`SessionFormatUnsupportedMigrationError: cannot safely
|
|
9
|
+
* transform unclassified message source`). No plugin can change that read path:
|
|
10
|
+
* the published catalog is assembled from direct imports and runs before any
|
|
11
|
+
* plugin mounts. This tool is the reviewable, non-destructive recovery path:
|
|
12
|
+
*
|
|
13
|
+
* - read-only unless `--apply` is passed;
|
|
14
|
+
* - the stored source generation is never modified: it is copied to a backup
|
|
15
|
+
* beside it (exclusive create, so an existing backup is never overwritten)
|
|
16
|
+
* before anything is published, and its hash is compared before/after;
|
|
17
|
+
* - a generation the installed catalog already migrates is left untouched;
|
|
18
|
+
* - only source kinds outside the released vocabulary are normalized to the
|
|
19
|
+
* released plugin wrapper (`{ kind: 'plugin', plugin: 'dsh-agent-teams' }`,
|
|
20
|
+
* the shape the fixed plugin writes on those hosts). The released wrapper's
|
|
21
|
+
* audit admits exactly those two members, so extra historical source fields
|
|
22
|
+
* (goal, profile, ...) are *not* carried into the repaired generation: they
|
|
23
|
+
* are listed in the report and remain only in the untouched source
|
|
24
|
+
* generation and its byte-identical backup. Message content, ordering and
|
|
25
|
+
* every other row value are preserved;
|
|
26
|
+
* - the artifact is then migrated and validated by the *installed* Harness
|
|
27
|
+
* catalog, re-encoded as the current generation through the installed
|
|
28
|
+
* encoder, verified from its exact bytes, and published exclusively
|
|
29
|
+
* (temporary file + link, never replacing an existing generation).
|
|
30
|
+
*
|
|
31
|
+
* Usage:
|
|
32
|
+
* node scripts/legacy-session-source-repair.mjs --session-dir <dir> [--apply]
|
|
33
|
+
* [--kinds agent-teams-command]
|
|
34
|
+
*
|
|
35
|
+
* Exit codes: 0 success (dry runs report `"applied": false`), 1 refusal or
|
|
36
|
+
* failure (nothing published).
|
|
37
|
+
*/
|
|
38
|
+
import { constants as fsConstants, copyFileSync, existsSync, linkSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
|
|
39
|
+
import { createRequire } from 'node:module'
|
|
40
|
+
import { dirname, join, resolve } from 'node:path'
|
|
41
|
+
import { pathToFileURL } from 'node:url'
|
|
42
|
+
import { createHash } from 'node:crypto'
|
|
43
|
+
import { constants as zlibConstants, zstdCompressSync, zstdDecompressSync } from 'node:zlib'
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Resolve one Harness package through the installed host cohort. The format
|
|
47
|
+
* packages are transitive to the exact `@deepseek-ai/dsh-session` this checkout
|
|
48
|
+
* pins, so they are resolved from that package's own dependency scope instead
|
|
49
|
+
* of duplicating a private copy of the format contract.
|
|
50
|
+
*/
|
|
51
|
+
function harnessImport(name) {
|
|
52
|
+
const require = createRequire(import.meta.url)
|
|
53
|
+
const sessionDir = dirname(require.resolve('@deepseek-ai/dsh-session/package.json'))
|
|
54
|
+
const nested = createRequire(join(sessionDir, 'package.json'))
|
|
55
|
+
return import(pathToFileURL(nested.resolve(name)).href)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const DEFAULT_KINDS = ['agent-teams-command']
|
|
59
|
+
const BACKUP_SUFFIX = '.pre-160-repair.bak'
|
|
60
|
+
|
|
61
|
+
/** Lowercase hex SHA-256 of one file's exact bytes. */
|
|
62
|
+
function sha256(path) {
|
|
63
|
+
return createHash('sha256').update(readFileSync(path)).digest('hex')
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function fail(message) {
|
|
67
|
+
console.error(`legacy session source repair: ${message}`)
|
|
68
|
+
process.exitCode = 1
|
|
69
|
+
return undefined
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function parseArguments(argv) {
|
|
73
|
+
const options = { apply: false, kinds: DEFAULT_KINDS.slice(), sessionDir: undefined }
|
|
74
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
75
|
+
const flag = argv[index]
|
|
76
|
+
if (flag === '--apply') options.apply = true
|
|
77
|
+
else if (flag === '--session-dir') options.sessionDir = argv[++index]
|
|
78
|
+
else if (flag === '--kinds') options.kinds = String(argv[++index] ?? '').split(',').map(value => value.trim()).filter(Boolean)
|
|
79
|
+
else return fail(`unknown argument ${flag}`)
|
|
80
|
+
}
|
|
81
|
+
if (options.sessionDir === undefined || options.sessionDir === '') return fail('--session-dir is required')
|
|
82
|
+
if (options.kinds.length === 0) return fail('--kinds must name at least one source kind')
|
|
83
|
+
return options
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Stored generation files in one session directory, newest generation last. */
|
|
87
|
+
function storedGenerations(sessionDir) {
|
|
88
|
+
if (!existsSync(sessionDir)) return []
|
|
89
|
+
return readdirSync(sessionDir)
|
|
90
|
+
.map(name => {
|
|
91
|
+
const match = name.match(/^session\.v(\d+)\.jsonl(\.zstd)?$/)
|
|
92
|
+
return match === null ? undefined : { name, version: Number(match[1]), zstd: match[2] === '.zstd' }
|
|
93
|
+
})
|
|
94
|
+
.filter(entry => entry !== undefined)
|
|
95
|
+
.sort((left, right) => left.version - right.version)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Measure one complete Zstandard frame starting at `start` (RFC 8878):
|
|
100
|
+
* magic, frame header descriptor, optional window/dictionary/content-size
|
|
101
|
+
* fields, the block chain, and the optional checksum. Node's zstd APIs decode
|
|
102
|
+
* only the first frame of the store's concatenated container, and searching for
|
|
103
|
+
* the magic bytes would split on compressed payload that happens to contain
|
|
104
|
+
* them, so every frame is measured exactly.
|
|
105
|
+
*/
|
|
106
|
+
function zstdFrameLength(buffer, start) {
|
|
107
|
+
// The magic is the byte sequence 28 B5 2F FD, i.e. 0xFD2FB528 little-endian.
|
|
108
|
+
if (start + 5 > buffer.length || buffer.readUInt32LE(start) !== 0xfd2fb528) {
|
|
109
|
+
throw new Error(`corrupt Zstandard session log: invalid frame magic at byte ${start}`)
|
|
110
|
+
}
|
|
111
|
+
const descriptor = buffer[start + 4]
|
|
112
|
+
if (((descriptor >> 3) & 1) !== 0) throw new Error(`corrupt Zstandard session log: reserved frame-header bit at byte ${start}`)
|
|
113
|
+
const contentSizeFlag = descriptor >> 6
|
|
114
|
+
const singleSegment = (descriptor >> 5) & 1
|
|
115
|
+
const checksum = (descriptor >> 2) & 1
|
|
116
|
+
const dictionarySize = [0, 1, 2, 4][descriptor & 3]
|
|
117
|
+
let cursor = start + 5 + (singleSegment === 1 ? 0 : 1) + dictionarySize
|
|
118
|
+
const contentSizeBytes = contentSizeFlag === 0 ? (singleSegment === 1 ? 1 : 0) : [0, 2, 4, 8][contentSizeFlag]
|
|
119
|
+
let contentSize
|
|
120
|
+
if (contentSizeBytes === 1) contentSize = buffer.readUInt8(cursor)
|
|
121
|
+
else if (contentSizeBytes === 2) contentSize = buffer.readUInt16LE(cursor) + 256
|
|
122
|
+
else if (contentSizeBytes === 4) contentSize = buffer.readUInt32LE(cursor)
|
|
123
|
+
else if (contentSizeBytes === 8) contentSize = Number(buffer.readBigUInt64LE(cursor))
|
|
124
|
+
cursor += contentSizeBytes
|
|
125
|
+
for (;;) {
|
|
126
|
+
if (cursor + 3 > buffer.length) throw new Error(`corrupt Zstandard session log: truncated block header at byte ${cursor}`)
|
|
127
|
+
const blockHeader = buffer.readUIntLE(cursor, 3)
|
|
128
|
+
cursor += 3
|
|
129
|
+
const last = blockHeader & 1
|
|
130
|
+
const blockType = (blockHeader >> 1) & 3
|
|
131
|
+
const blockSize = blockHeader >> 3
|
|
132
|
+
if (blockType === 3) throw new Error(`corrupt Zstandard session log: reserved block type at byte ${cursor}`)
|
|
133
|
+
// RFC 8878 §3.1.1.2.2/3: Block_Size counts the block's *content*; an RLE
|
|
134
|
+
// block stores that content as a single repeated byte, so its payload is
|
|
135
|
+
// one byte regardless of Block_Size.
|
|
136
|
+
cursor += blockType === 1 ? 1 : blockSize
|
|
137
|
+
if (last === 1) break
|
|
138
|
+
}
|
|
139
|
+
if (checksum === 1) cursor += 4
|
|
140
|
+
if (cursor > buffer.length) throw new Error('corrupt Zstandard session log: truncated final frame')
|
|
141
|
+
if (contentSize !== undefined && contentSize !== 0) {
|
|
142
|
+
const decoded = zstdDecompressSync(buffer.subarray(start, cursor))
|
|
143
|
+
if (decoded.byteLength !== contentSize) throw new Error('corrupt Zstandard session log: frame content size disagrees with its header')
|
|
144
|
+
}
|
|
145
|
+
return cursor - start
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Decode the store's concatenated-frame container: the first frame holds
|
|
150
|
+
* exactly the header line, later frames hold event batches.
|
|
151
|
+
*/
|
|
152
|
+
export function decodeStoredFrames(buffer) {
|
|
153
|
+
let offset = 0
|
|
154
|
+
let text = ''
|
|
155
|
+
while (offset < buffer.length) {
|
|
156
|
+
const length = zstdFrameLength(buffer, offset)
|
|
157
|
+
text += zstdDecompressSync(buffer.subarray(offset, offset + length)).toString('utf8')
|
|
158
|
+
offset += length
|
|
159
|
+
}
|
|
160
|
+
return text
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Physical log text: the first line is the header, the rest are event rows. */
|
|
164
|
+
function readPhysicalLog(path, zstd) {
|
|
165
|
+
const raw = readFileSync(path)
|
|
166
|
+
const text = zstd ? decodeStoredFrames(raw) : raw.toString('utf8')
|
|
167
|
+
const lines = text.split('\n')
|
|
168
|
+
while (lines.length > 0 && lines[lines.length - 1] === '') lines.pop()
|
|
169
|
+
return { bytes: raw, header: JSON.parse(lines[0]), rows: lines.slice(1).map(line => JSON.parse(line)) }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Normalize one out-of-vocabulary message source to the released plugin wrapper. */
|
|
173
|
+
function normalizeSources(value, kinds, counter, depth = 0) {
|
|
174
|
+
if (depth > 12 || value === null || typeof value !== 'object') return value
|
|
175
|
+
if (Array.isArray(value)) return value.map(item => normalizeSources(item, kinds, counter, depth + 1))
|
|
176
|
+
const record = { ...value }
|
|
177
|
+
const source = record['source']
|
|
178
|
+
if (source !== null && typeof source === 'object' && !Array.isArray(source)
|
|
179
|
+
&& typeof source['kind'] === 'string' && kinds.includes(source['kind'])) {
|
|
180
|
+
// The released wrapper's source audit admits exactly {kind, plugin}, so any
|
|
181
|
+
// extra historical metadata on this source cannot travel into the repaired
|
|
182
|
+
// generation. It is reported instead, and stays readable in the untouched
|
|
183
|
+
// source generation and its byte-identical backup.
|
|
184
|
+
const dropped = Object.keys(source).filter(key => key !== 'kind' && key !== 'plugin')
|
|
185
|
+
record['source'] = { kind: 'plugin', plugin: 'dsh-agent-teams' }
|
|
186
|
+
counter.normalized += 1
|
|
187
|
+
for (const key of dropped) counter.droppedFields.add(key)
|
|
188
|
+
}
|
|
189
|
+
for (const [key, item] of Object.entries(record)) {
|
|
190
|
+
if (key === 'source') continue
|
|
191
|
+
record[key] = normalizeSources(item, kinds, counter, depth + 1)
|
|
192
|
+
}
|
|
193
|
+
return record
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Write the current-generation artifact with the store's checksummed frame container. */
|
|
197
|
+
function encodeFrames(headerLine, rowLines) {
|
|
198
|
+
const frame = text => zstdCompressSync(Buffer.from(text, 'utf8'), { params: { [zlibConstants.ZSTD_c_checksumFlag]: 1 } })
|
|
199
|
+
const frames = [frame(`${headerLine}\n`)]
|
|
200
|
+
for (let index = 0; index < rowLines.length; index += 500) {
|
|
201
|
+
frames.push(frame(`${rowLines.slice(index, index + 500).join('\n')}\n`))
|
|
202
|
+
}
|
|
203
|
+
return Buffer.concat(frames)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function repair(options) {
|
|
207
|
+
const sessionDir = resolve(options.sessionDir)
|
|
208
|
+
const catalogModule = await harnessImport('@deepseek-ai/dsh-session-format-catalog')
|
|
209
|
+
const catalog = catalogModule.sessionFormatCatalog
|
|
210
|
+
const withChildren = catalogModule.createSessionFormatCatalogWithChildren([])
|
|
211
|
+
|
|
212
|
+
const generations = storedGenerations(sessionDir)
|
|
213
|
+
if (generations.length === 0) return fail(`no stored session generation under ${sessionDir}`)
|
|
214
|
+
const source = generations[generations.length - 1]
|
|
215
|
+
if (source.version >= catalog.currentVersion) {
|
|
216
|
+
return fail(`stored generation v${source.version} is already current (v${catalog.currentVersion}); nothing to repair`)
|
|
217
|
+
}
|
|
218
|
+
const publishedName = `session.v${catalog.currentVersion}.jsonl${source.zstd ? '.zstd' : ''}`
|
|
219
|
+
const publishedPath = join(sessionDir, publishedName)
|
|
220
|
+
if (existsSync(publishedPath)) return fail(`${publishedName} already exists; refusing to overwrite it`)
|
|
221
|
+
|
|
222
|
+
const { header, rows } = readPhysicalLog(join(sessionDir, source.name), source.zstd)
|
|
223
|
+
|
|
224
|
+
// Only logs the installed catalog cannot migrate are touched. A generation
|
|
225
|
+
// whose sources are already readable (v3 producer kinds included) is a no-op
|
|
226
|
+
// so this tool never rewrites unrelated provenance.
|
|
227
|
+
const readableAsIs = (() => {
|
|
228
|
+
try {
|
|
229
|
+
const restore = withChildren.createRestore(header, { recovery: 'strict', validation: 'current' })
|
|
230
|
+
for (const row of rows) restore.decodeRow(row)
|
|
231
|
+
restore.finish()
|
|
232
|
+
return true
|
|
233
|
+
} catch {
|
|
234
|
+
return false
|
|
235
|
+
}
|
|
236
|
+
})()
|
|
237
|
+
if (readableAsIs) {
|
|
238
|
+
return {
|
|
239
|
+
sessionDir,
|
|
240
|
+
sourceGeneration: source.version,
|
|
241
|
+
sourceFile: source.name,
|
|
242
|
+
sourceBytes: readFileSync(join(sessionDir, source.name)).byteLength,
|
|
243
|
+
targetGeneration: catalog.currentVersion,
|
|
244
|
+
applied: false,
|
|
245
|
+
skipped: 'already-readable',
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const counter = { normalized: 0, droppedFields: new Set() }
|
|
250
|
+
const normalizedRows = rows.map(row => normalizeSources(row, options.kinds, counter))
|
|
251
|
+
if (counter.normalized === 0) {
|
|
252
|
+
return fail(`${source.name} is refused by the installed Harness but carries none of ${options.kinds.join(', ')}; nothing this tool may repair`)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Migrate and validate with the installed catalog: the whole artifact must be
|
|
256
|
+
// accepted before anything is published.
|
|
257
|
+
let artifact
|
|
258
|
+
try {
|
|
259
|
+
const restore = withChildren.createRestore(header, { recovery: 'strict', validation: 'current' })
|
|
260
|
+
for (const row of normalizedRows) restore.decodeRow(row)
|
|
261
|
+
artifact = restore.finish()
|
|
262
|
+
} catch (error) {
|
|
263
|
+
return fail(`the installed Harness still refuses ${source.name} after source normalization: ${error?.constructor?.name}: ${error?.message}`)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const currentHeader = catalog.encodeCurrentHeader(artifact.header, artifact.inheritedEventCount)
|
|
267
|
+
const currentRows = artifact.events.map(event => catalog.encodeCurrentEvent(event))
|
|
268
|
+
const report = {
|
|
269
|
+
sessionDir,
|
|
270
|
+
sourceGeneration: source.version,
|
|
271
|
+
sourceFile: source.name,
|
|
272
|
+
sourceBytes: readFileSync(join(sessionDir, source.name)).byteLength,
|
|
273
|
+
normalizedSources: counter.normalized,
|
|
274
|
+
normalizedDroppedSourceFields: [...counter.droppedFields].sort(),
|
|
275
|
+
events: artifact.events.length,
|
|
276
|
+
targetGeneration: catalog.currentVersion,
|
|
277
|
+
published: publishedName,
|
|
278
|
+
applied: false,
|
|
279
|
+
}
|
|
280
|
+
if (!options.apply) return report
|
|
281
|
+
|
|
282
|
+
const sourcePath = join(sessionDir, source.name)
|
|
283
|
+
const sourceHashBefore = sha256(sourcePath)
|
|
284
|
+
const backupPath = join(sessionDir, `${source.name}${BACKUP_SUFFIX}`)
|
|
285
|
+
// Exclusive copy (`COPYFILE_EXCL`): an existing backup is never overwritten,
|
|
286
|
+
// and a racing second run fails here instead of silently replacing it.
|
|
287
|
+
try {
|
|
288
|
+
copyFileSync(sourcePath, backupPath, fsConstants.COPYFILE_EXCL)
|
|
289
|
+
} catch (error) {
|
|
290
|
+
if (error?.code === 'EEXIST') return fail(`${backupPath} already exists; refusing to overwrite a backup`)
|
|
291
|
+
throw error
|
|
292
|
+
}
|
|
293
|
+
report.backup = `${source.name}${BACKUP_SUFFIX}`
|
|
294
|
+
|
|
295
|
+
const encoded = encodeFrames(JSON.stringify(currentHeader), currentRows.map(row => JSON.stringify(row)))
|
|
296
|
+
// Read the exact bytes back before publishing: a repair that cannot be read
|
|
297
|
+
// again must not replace a diagnosable state with an unreadable one.
|
|
298
|
+
const verify = (() => {
|
|
299
|
+
const text = decodeStoredFrames(encoded)
|
|
300
|
+
const lines = text.split('\n').filter(line => line !== '')
|
|
301
|
+
const restored = catalog.createRestore(JSON.parse(lines[0]), { recovery: 'strict', validation: 'current' })
|
|
302
|
+
for (const line of lines.slice(1)) restored.decodeRow(JSON.parse(line))
|
|
303
|
+
return restored.finish()
|
|
304
|
+
})()
|
|
305
|
+
if (verify.events.length !== artifact.events.length) {
|
|
306
|
+
return fail(`re-encoded artifact lost events (${verify.events.length} of ${artifact.events.length}); nothing published`)
|
|
307
|
+
}
|
|
308
|
+
// Publish exclusively: the complete artifact is first written to a temporary
|
|
309
|
+
// file in the same directory, verified there, and only then linked to its
|
|
310
|
+
// final name — a half-written current generation can never be visible to the
|
|
311
|
+
// host, and an existing one is never replaced.
|
|
312
|
+
const temporaryPath = join(sessionDir, `${publishedName}.tmp-${process.pid}`)
|
|
313
|
+
// Exclusive create: residue from a crashed run is never overwritten.
|
|
314
|
+
try {
|
|
315
|
+
writeFileSync(temporaryPath, encoded, { flag: 'wx' })
|
|
316
|
+
} catch (error) {
|
|
317
|
+
if (error?.code === 'EEXIST') return fail(`${temporaryPath} exists from an earlier interrupted run; remove it and retry`)
|
|
318
|
+
throw error
|
|
319
|
+
}
|
|
320
|
+
try {
|
|
321
|
+
linkSync(temporaryPath, publishedPath)
|
|
322
|
+
} catch (error) {
|
|
323
|
+
rmSync(temporaryPath, { force: true })
|
|
324
|
+
if (error?.code === 'EEXIST') return fail(`${publishedName} appeared while repairing; refusing to overwrite it`)
|
|
325
|
+
throw error
|
|
326
|
+
}
|
|
327
|
+
rmSync(temporaryPath, { force: true })
|
|
328
|
+
report.applied = true
|
|
329
|
+
report.publishedBytes = encoded.byteLength
|
|
330
|
+
report.sourceUnchanged = sha256(sourcePath) === sourceHashBefore
|
|
331
|
+
report.backupUnchanged = sha256(backupPath) === sourceHashBefore
|
|
332
|
+
return report
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// CLI body: skipped when this module is imported for its container decoder.
|
|
336
|
+
const invokedDirectly = process.argv[1] !== undefined
|
|
337
|
+
&& pathToFileURL(process.argv[1]).href === import.meta.url
|
|
338
|
+
if (invokedDirectly) {
|
|
339
|
+
const options = parseArguments(process.argv.slice(2))
|
|
340
|
+
if (options !== undefined) {
|
|
341
|
+
const report = await repair(options)
|
|
342
|
+
if (report !== undefined) console.log(JSON.stringify(report, null, 2))
|
|
343
|
+
}
|
|
344
|
+
}
|