@nanmicoder/dsh-agent-teams 0.1.22 → 0.1.23
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 +32 -10
- package/README_ZH.md +32 -10
- 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 +70 -21
- package/lib/index.js +26 -3
- package/lib/lineage.js +198 -0
- package/lib/mailbox.js +59 -34
- package/lib/members.js +301 -53
- 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 +279 -13
- package/lib/tool-policies.js +187 -0
- package/lib/tools.js +370 -89
- 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 +56 -14
- package/lib/types/index.d.ts +26 -1
- package/lib/types/lineage.d.ts +92 -0
- package/lib/types/members.d.ts +112 -1
- 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 +95 -0
- package/lib/types/workspace-audit.d.ts +27 -0
- package/lib/workspace-audit.js +52 -0
- package/package.json +12 -5
- package/release-notes/v0.1.23.md +34 -0
- package/scripts/legacy-session-source-repair.mjs +344 -0
package/lib/types/types.d.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* survives harness restarts.
|
|
8
8
|
* @module dsh-agent-teams/types
|
|
9
9
|
*/
|
|
10
|
+
import type { MemberToolFilter } from './tool-policies.ts';
|
|
10
11
|
/** Task lifecycle statuses in progression order. */
|
|
11
12
|
export type TaskStatus = 'pending' | 'claimed' | 'in_progress' | 'completed' | 'failed' | 'cancelled';
|
|
12
13
|
/** Statuses after which a task can no longer be claimed or worked on. */
|
|
@@ -44,6 +45,49 @@ export interface CommandResult {
|
|
|
44
45
|
exitCode?: number;
|
|
45
46
|
evidence?: string;
|
|
46
47
|
}
|
|
48
|
+
/**
|
|
49
|
+
* Identity of one workspace path at one instant. A verdict is evidence about a
|
|
50
|
+
* revision, so recording the bytes it judged (not only the task id) lets the
|
|
51
|
+
* delivery check refuse to advertise a revision no review ever saw.
|
|
52
|
+
*/
|
|
53
|
+
export interface ArtifactDigest {
|
|
54
|
+
/** Workspace-relative POSIX path. */
|
|
55
|
+
path: string;
|
|
56
|
+
/** Whether a regular file existed when the digest was taken. */
|
|
57
|
+
present: boolean;
|
|
58
|
+
/** Lowercase hex sha256 of the file bytes; absent unless the file was read. */
|
|
59
|
+
sha256?: string;
|
|
60
|
+
/** File size in bytes; absent unless the file was read. */
|
|
61
|
+
size?: number;
|
|
62
|
+
/** File mtime in epoch ms; lets a legacy record detect a later edit. */
|
|
63
|
+
mtimeMs?: number;
|
|
64
|
+
/**
|
|
65
|
+
* The path exists but could not be read or safely resolved (permissions,
|
|
66
|
+
* symlink escaping the workspace, I/O error). Never conflated with "absent":
|
|
67
|
+
* unreadable bytes cannot be claimed as unchanged.
|
|
68
|
+
*/
|
|
69
|
+
unreadable?: boolean;
|
|
70
|
+
/** Why the path could not be read, when `unreadable` is set. */
|
|
71
|
+
reason?: string;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* A declared scope that could not be fully enumerated (file cap reached,
|
|
75
|
+
* unreadable directory, path that is not workspace-relative). Recorded so a
|
|
76
|
+
* partial artifact list is never mistaken for complete coverage.
|
|
77
|
+
*/
|
|
78
|
+
export interface ArtifactScopeGap {
|
|
79
|
+
/** The pattern or path that could not be fully resolved. */
|
|
80
|
+
pattern: string;
|
|
81
|
+
reason: string;
|
|
82
|
+
}
|
|
83
|
+
/** One set of artifact digests captured at a single instant. */
|
|
84
|
+
export interface ArtifactSnapshot {
|
|
85
|
+
/** Epoch ms when these bytes were read. */
|
|
86
|
+
at: number;
|
|
87
|
+
artifacts: ArtifactDigest[];
|
|
88
|
+
/** Present when the recorded coverage is known to be incomplete. */
|
|
89
|
+
gaps?: ArtifactScopeGap[];
|
|
90
|
+
}
|
|
47
91
|
/** Profile / team review-loop limits. */
|
|
48
92
|
export interface ReviewPolicy {
|
|
49
93
|
requirementsMinRounds?: number;
|
|
@@ -121,9 +165,47 @@ export interface TeamTask {
|
|
|
121
165
|
/** Supplemental observations, attributed to their original execution generation. */
|
|
122
166
|
supplementalEvidence?: TaskEvidence[];
|
|
123
167
|
reviewedTaskId?: string;
|
|
168
|
+
/** Execution generation of the reviewed task at the moment of judgment. */
|
|
124
169
|
reviewedAttempt?: number;
|
|
170
|
+
/**
|
|
171
|
+
* Bytes this verdict judged, read from the workspace when the gate closed.
|
|
172
|
+
* A later edit, deletion or re-attempt invalidates the verdict.
|
|
173
|
+
*/
|
|
174
|
+
reviewedArtifacts?: ArtifactDigest[];
|
|
175
|
+
/** Scope of that verdict that could not be fully enumerated, if any. */
|
|
176
|
+
reviewedGaps?: ArtifactScopeGap[];
|
|
177
|
+
/**
|
|
178
|
+
* Scope patterns the verdict bound (e.g. `lib/`). A file that appears under
|
|
179
|
+
* one of them later was never reviewed, so the verdict must not claim it.
|
|
180
|
+
*/
|
|
181
|
+
reviewedScope?: string[];
|
|
182
|
+
/**
|
|
183
|
+
* Drift observed while this verdict was being formed (the workspace changed
|
|
184
|
+
* between claim and close). The verdict cannot be read as binding a stable
|
|
185
|
+
* revision.
|
|
186
|
+
*/
|
|
187
|
+
reviewedDuringDrift?: string[];
|
|
188
|
+
/**
|
|
189
|
+
* Workspace snapshot taken when this verdict's attempt started, so a close
|
|
190
|
+
* can tell whether it judged a revision that was changing underneath it.
|
|
191
|
+
*/
|
|
192
|
+
verdictStartSnapshot?: ArtifactSnapshot;
|
|
193
|
+
/**
|
|
194
|
+
* Workspace snapshot an auto-generated contract (acceptance / inScope /
|
|
195
|
+
* verify) was derived from. When a cited artifact changes underneath it the
|
|
196
|
+
* work order is marked stale instead of being read as authoritative.
|
|
197
|
+
*/
|
|
198
|
+
contractSnapshot?: ArtifactSnapshot;
|
|
125
199
|
/** Repair source: the implementation / previous successful artifact. */
|
|
126
200
|
sourceTaskId?: string;
|
|
201
|
+
/**
|
|
202
|
+
* The failed gate task(s) whose findings this repair fixes. Recorded as an
|
|
203
|
+
* explicit causal edge because `dependencies` cannot carry it: a failed task
|
|
204
|
+
* never reaches `completed`, so a dependency on it would make the repair
|
|
205
|
+
* permanently unclaimable. Delivery reads this to tell a closed loop from
|
|
206
|
+
* genuinely unresolved work (issue #175A).
|
|
207
|
+
*/
|
|
208
|
+
findingSourceTaskIds?: string[];
|
|
127
209
|
sourceFindingIds?: string[];
|
|
128
210
|
/** User-constraint / goal items this task claims to cover. */
|
|
129
211
|
coverageOf?: string[];
|
|
@@ -157,6 +239,19 @@ export interface TeamMember {
|
|
|
157
239
|
activeModel?: string;
|
|
158
240
|
/** Whether the fallback route is currently active. */
|
|
159
241
|
fallbackActive?: boolean;
|
|
242
|
+
/**
|
|
243
|
+
* Owner-declared tool-policy id this member selected (`#59`), absent for a
|
|
244
|
+
* member without a policy (the unchanged default behavior). The Captain
|
|
245
|
+
* model may only select a configured id; it never submits raw tool lists.
|
|
246
|
+
*/
|
|
247
|
+
toolPolicy?: string;
|
|
248
|
+
/**
|
|
249
|
+
* The immutable effective tool filter resolved when the member was created
|
|
250
|
+
* and handed to the child's `toolFilter`. `team.json` keeps it for audit,
|
|
251
|
+
* status, and recovery diagnostics; the continuable descriptor remains the
|
|
252
|
+
* authority that reapplies the filter to a cold-resumed child.
|
|
253
|
+
*/
|
|
254
|
+
toolFilter?: MemberToolFilter;
|
|
160
255
|
joinedAt: number;
|
|
161
256
|
status: MemberStatus;
|
|
162
257
|
/** Execution admission is closed while a failed/pending handoff is drained. */
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single workspace audit shared by every surface that reports a delivery
|
|
3
|
+
* or quality-loop conclusion.
|
|
4
|
+
*
|
|
5
|
+
* `agent_teams_status`, the activity-panel route and any Web API consumer must
|
|
6
|
+
* read the same current bytes: if only one of them compared recorded verdicts
|
|
7
|
+
* against the workspace, the other would keep advertising a passing gate over
|
|
8
|
+
* a revision no review ever saw (issue #161).
|
|
9
|
+
*
|
|
10
|
+
* The audit consumes the artifact reader's *complete* result — digests, the
|
|
11
|
+
* enumeration gaps and unreadable paths — because a partial read must never
|
|
12
|
+
* look like a clean one: a scope that outgrew its cap after the review would
|
|
13
|
+
* otherwise be reported as fully verified.
|
|
14
|
+
* @module dsh-agent-teams/workspace-audit
|
|
15
|
+
*/
|
|
16
|
+
import type { DeliveryAudit } from './quality-gates.ts';
|
|
17
|
+
import type { TeamState } from './types.ts';
|
|
18
|
+
/**
|
|
19
|
+
* Read the current workspace bytes a team's recorded verdicts and generated
|
|
20
|
+
* work orders depend on.
|
|
21
|
+
* @param workspace - absolute path of the owning workspace.
|
|
22
|
+
* @param team - the durable team record.
|
|
23
|
+
* @returns digests of every recorded path, the current contents of the scopes
|
|
24
|
+
* the verdicts bound (so a new file cannot slip into a broad scope), and every
|
|
25
|
+
* gap that makes this audit incomplete.
|
|
26
|
+
*/
|
|
27
|
+
export declare function auditTeamWorkspace(workspace: string, team: TeamState): DeliveryAudit;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single workspace audit shared by every surface that reports a delivery
|
|
3
|
+
* or quality-loop conclusion.
|
|
4
|
+
*
|
|
5
|
+
* `agent_teams_status`, the activity-panel route and any Web API consumer must
|
|
6
|
+
* read the same current bytes: if only one of them compared recorded verdicts
|
|
7
|
+
* against the workspace, the other would keep advertising a passing gate over
|
|
8
|
+
* a revision no review ever saw (issue #161).
|
|
9
|
+
*
|
|
10
|
+
* The audit consumes the artifact reader's *complete* result — digests, the
|
|
11
|
+
* enumeration gaps and unreadable paths — because a partial read must never
|
|
12
|
+
* look like a clean one: a scope that outgrew its cap after the review would
|
|
13
|
+
* otherwise be reported as fully verified.
|
|
14
|
+
* @module dsh-agent-teams/workspace-audit
|
|
15
|
+
*/
|
|
16
|
+
import { readArtifactScope } from "./artifacts.js";
|
|
17
|
+
import { deliveryArtifactPaths, deliveryScopePatterns } from "./quality-gates.js";
|
|
18
|
+
/**
|
|
19
|
+
* Read the current workspace bytes a team's recorded verdicts and generated
|
|
20
|
+
* work orders depend on.
|
|
21
|
+
* @param workspace - absolute path of the owning workspace.
|
|
22
|
+
* @param team - the durable team record.
|
|
23
|
+
* @returns digests of every recorded path, the current contents of the scopes
|
|
24
|
+
* the verdicts bound (so a new file cannot slip into a broad scope), and every
|
|
25
|
+
* gap that makes this audit incomplete.
|
|
26
|
+
*/
|
|
27
|
+
export function auditTeamWorkspace(workspace, team) {
|
|
28
|
+
const paths = deliveryArtifactPaths(team);
|
|
29
|
+
const scopePatterns = deliveryScopePatterns(team);
|
|
30
|
+
const pathRead = paths.length === 0 ? { artifacts: [], gaps: [] } : readArtifactScope(workspace, paths);
|
|
31
|
+
const scopeRead = scopePatterns.length === 0 ? { artifacts: [], gaps: [] } : readArtifactScope(workspace, scopePatterns);
|
|
32
|
+
const gaps = [...pathRead.gaps, ...scopeRead.gaps];
|
|
33
|
+
const seen = new Set(gaps.map((gap) => `${gap.pattern}\u0000${gap.reason}`));
|
|
34
|
+
const recordGap = (pattern, reason) => {
|
|
35
|
+
const key = `${pattern}\u0000${reason}`;
|
|
36
|
+
if (seen.has(key))
|
|
37
|
+
return;
|
|
38
|
+
seen.add(key);
|
|
39
|
+
gaps.push({ pattern, reason });
|
|
40
|
+
};
|
|
41
|
+
for (const artifact of [...pathRead.artifacts, ...scopeRead.artifacts]) {
|
|
42
|
+
// An unreadable path is not a gap in enumeration but a gap in evidence:
|
|
43
|
+
// report it the same way so no surface treats it as a clean read.
|
|
44
|
+
if (artifact.unreadable === true)
|
|
45
|
+
recordGap(artifact.path, artifact.reason ?? 'path could not be read');
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
artifacts: pathRead.artifacts,
|
|
49
|
+
expandedScope: scopeRead.artifacts.map((artifact) => ({ path: artifact.path, present: artifact.present })),
|
|
50
|
+
...gaps.length === 0 ? {} : { gaps },
|
|
51
|
+
};
|
|
52
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanmicoder/dsh-agent-teams",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.23",
|
|
4
4
|
"description": "AgentTeams for DeepSeek Harness: multi-agent team collaboration (captain, members, tasks with dependencies, messaging) driven by natural language, with a tree monitor in the web GUI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -29,7 +29,8 @@
|
|
|
29
29
|
"compatibility.json",
|
|
30
30
|
"scripts/doctor.mjs",
|
|
31
31
|
"scripts/compatibility.mjs",
|
|
32
|
-
"pnpm-workspace.yaml"
|
|
32
|
+
"pnpm-workspace.yaml",
|
|
33
|
+
"scripts/legacy-session-source-repair.mjs"
|
|
33
34
|
],
|
|
34
35
|
"license": "MIT",
|
|
35
36
|
"author": {
|
|
@@ -228,8 +229,10 @@
|
|
|
228
229
|
"update:skills": "node scripts/update-skills.mjs",
|
|
229
230
|
"test:skills-updater": "node --test scripts/update-skills.test.mjs",
|
|
230
231
|
"verify:skill": "node scripts/sync-skill.mjs --check",
|
|
231
|
-
"verify": "node scripts/verify.mjs && node scripts/fallback-tdd.mjs && node scripts/member-failure-tdd.mjs && pnpm verify:quality-gates && node scripts/lifecycle-verify.mjs && node scripts/stress-verify.mjs && node --test scripts/issue-159.test.mjs && pnpm verify:web-routes && pnpm verify:release && pnpm verify:readme && pnpm verify:skill && pnpm verify:harness-contract && pnpm verify:http-body && pnpm verify:compatibility && pnpm verify:capabilities && pnpm verify:member-spawn-recovery && pnpm test:git-install && pnpm verify:source-runner && pnpm verify:git-artifact",
|
|
232
|
-
"verify:quality-gates": "node scripts/quality-gates-tdd.mjs && node --test scripts/quality-gates-repair-scope.test.mjs scripts/quality-gates-amend.test.mjs",
|
|
232
|
+
"verify": "node scripts/verify.mjs && node scripts/fallback-tdd.mjs && node scripts/member-failure-tdd.mjs && pnpm verify:issue-regressions && pnpm verify:quality-gates && node scripts/lifecycle-verify.mjs && node scripts/stress-verify.mjs && node --test scripts/issue-159.test.mjs && pnpm verify:issue-203 && pnpm verify:corrupt-state && pnpm verify:web-routes && pnpm verify:release && pnpm verify:readme && pnpm verify:skill && pnpm verify:harness-contract && pnpm verify:http-body && pnpm verify:compatibility && pnpm verify:capabilities && pnpm verify:tool-policies && pnpm verify:member-spawn-recovery && pnpm test:git-install && pnpm verify:source-runner && pnpm verify:lineage-identity && pnpm verify:legacy-session-repair && pnpm verify:git-artifact",
|
|
233
|
+
"verify:quality-gates": "node scripts/quality-gates-tdd.mjs && node --test scripts/quality-gates-repair-scope.test.mjs scripts/quality-gates-amend.test.mjs scripts/quality-gates-content-bound.test.mjs scripts/quality-gates-work-order.test.mjs scripts/quality-gates-repair-wiring.test.mjs scripts/quality-gates-excluded-scope.test.mjs scripts/quality-gates-cross-binding.test.mjs",
|
|
234
|
+
"verify:issue-203": "node --test scripts/issue-203-card.test.mjs",
|
|
235
|
+
"verify:corrupt-state": "node --test scripts/corrupt-teamjson.test.mjs",
|
|
233
236
|
"verify:web-routes": "node scripts/web-routes-verify.mjs",
|
|
234
237
|
"verify:release": "node --test scripts/release-metadata.test.mjs",
|
|
235
238
|
"verify:readme": "node scripts/readme-version.mjs && node --test scripts/readme-version.test.mjs",
|
|
@@ -238,9 +241,13 @@
|
|
|
238
241
|
"verify:http-body": "node --test scripts/http-body.test.mjs",
|
|
239
242
|
"doctor": "node scripts/doctor.mjs",
|
|
240
243
|
"verify:capabilities": "node --test scripts/capabilities.test.mjs",
|
|
244
|
+
"verify:tool-policies": "node --test scripts/tool-policies.test.mjs",
|
|
241
245
|
"verify:member-spawn-recovery": "node --test scripts/member-spawn-recovery.test.mjs",
|
|
242
246
|
"test:git-install": "node --test scripts/git-artifacts.test.mjs",
|
|
243
247
|
"verify:source-runner": "node --test scripts/harness-source-verify.test.mjs",
|
|
244
|
-
"verify:git-artifact": "node scripts/git-artifacts.mjs"
|
|
248
|
+
"verify:git-artifact": "node scripts/git-artifacts.mjs",
|
|
249
|
+
"verify:issue-regressions": "node --test scripts/issue-188.test.mjs scripts/issue-189.test.mjs scripts/issue-205.test.mjs",
|
|
250
|
+
"verify:legacy-session-repair": "node --test scripts/legacy-session-source-repair.test.mjs",
|
|
251
|
+
"verify:lineage-identity": "node --test scripts/lineage-identity.test.mjs"
|
|
245
252
|
}
|
|
246
253
|
}
|
|
@@ -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,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
|
+
}
|