@linkedclaw/cli 0.1.3 → 0.1.5
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 +172 -10
- package/dist/bin.js +1921 -163
- package/dist/bin.js.map +1 -1
- package/package.json +3 -3
- package/src/arena/api.ts +154 -0
- package/src/arena/hash.ts +15 -0
- package/src/arena/types.ts +106 -0
- package/src/bin.ts +12 -2
- package/src/commands/agent.ts +264 -0
- package/src/commands/arena.ts +393 -0
- package/src/commands/converge.ts +969 -0
- package/src/commands/provider.ts +8 -8
- package/src/commands/requester.ts +64 -21
- package/src/config.ts +11 -2
- package/src/converge/api.ts +213 -0
- package/src/converge/hash.ts +35 -0
- package/src/converge/lock.ts +30 -0
- package/src/converge/staging.ts +83 -0
- package/src/converge/types.ts +91 -0
- package/src/converge/workspace.ts +92 -0
- package/src/handlers/subprocess.ts +8 -8
- package/src/types.ts +5 -5
- package/test/agent-help.test.ts +207 -0
- package/test/arena-api.test.ts +211 -0
- package/test/arena-commands.test.ts +559 -0
- package/test/arena-hash.test.ts +33 -0
- package/test/cli-help.test.ts +23 -3
- package/test/converge-accept.test.ts +206 -0
- package/test/converge-decision.test.ts +274 -0
- package/test/converge-hash.test.ts +58 -0
- package/test/converge-help.test.ts +58 -0
- package/test/converge-lock.test.ts +48 -0
- package/test/converge-review.test.ts +135 -0
- package/test/converge-run.test.ts +286 -0
- package/test/converge-staging.test.ts +161 -0
- package/test/converge-status.test.ts +141 -0
- package/test/converge-workspace.test.ts +92 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { registerConvergeCommands } from "../src/commands/converge.js";
|
|
7
|
+
import { sha256OfCanonicalJson } from "../src/converge/hash.js";
|
|
8
|
+
import { computePaBodyHash, readStaging, stagingPathFor, writeStaging } from "../src/converge/staging.js";
|
|
9
|
+
import { writeRunMeta } from "../src/converge/workspace.js";
|
|
10
|
+
import { LinkedClawError } from "../src/errors.js";
|
|
11
|
+
|
|
12
|
+
const RUN_ID = "clg_run_accept001";
|
|
13
|
+
const SOURCE_DEBATE_ID = "dbt_src001";
|
|
14
|
+
const PA_AGENT_ID = "agt_pa001";
|
|
15
|
+
const CRUX_ID = "crux_001";
|
|
16
|
+
const SUB_DEBATE_ID = "dbt_sub001";
|
|
17
|
+
const SOURCE_HASH = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
|
18
|
+
const BODY = "# Synthesis\n\nBoth sides agree on X.\n\n# Open questions\n\n(none)\n";
|
|
19
|
+
|
|
20
|
+
const apiRef = vi.hoisted(() => ({ current: {} as any }));
|
|
21
|
+
|
|
22
|
+
vi.mock("../src/context.js", () => ({
|
|
23
|
+
buildContext: () => ({
|
|
24
|
+
cfg: { apiKey: "lc_test", cloudUrl: "http://cloud.test" },
|
|
25
|
+
consumer: { getMe: vi.fn(async () => ({ user_id: "usr_test" })) },
|
|
26
|
+
}),
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
vi.mock("../src/converge/api.js", () => ({
|
|
30
|
+
makeConvergeApi: () => apiRef.current,
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
function mapEvent() {
|
|
34
|
+
return {
|
|
35
|
+
seq: 10,
|
|
36
|
+
event_type: "convergence_map",
|
|
37
|
+
payload: {
|
|
38
|
+
event_type: "convergence_map",
|
|
39
|
+
source_crux_map_hash: SOURCE_HASH,
|
|
40
|
+
cruxes: [
|
|
41
|
+
{
|
|
42
|
+
crux_id: CRUX_ID,
|
|
43
|
+
outcome: "converged",
|
|
44
|
+
sub_debate_chain: [SUB_DEBATE_ID],
|
|
45
|
+
latest_sub_debate_id: SUB_DEBATE_ID,
|
|
46
|
+
synthesis_text: "Both sides agree on X.",
|
|
47
|
+
citations_a: [],
|
|
48
|
+
citations_b: [],
|
|
49
|
+
open_questions: [],
|
|
50
|
+
final_progress_signal: {},
|
|
51
|
+
generation_id: "gen_accept001",
|
|
52
|
+
bilateral_mandate_intact_at_outcome: true,
|
|
53
|
+
},
|
|
54
|
+
],
|
|
55
|
+
},
|
|
56
|
+
appended_at: "2026-04-30T10:00:00Z",
|
|
57
|
+
signed_by: "usr_operator",
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function terminalAcceptEvent() {
|
|
62
|
+
return {
|
|
63
|
+
seq: 11,
|
|
64
|
+
event_type: "accept_attestation",
|
|
65
|
+
payload: {
|
|
66
|
+
event_type: "accept_attestation",
|
|
67
|
+
crux_id: CRUX_ID,
|
|
68
|
+
convergence_map_generation_id: "gen_accept001",
|
|
69
|
+
},
|
|
70
|
+
appended_at: "2026-04-30T10:01:00Z",
|
|
71
|
+
signed_by: "usr_operator",
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function makeProgram(): Command {
|
|
76
|
+
const program = new Command();
|
|
77
|
+
program.exitOverride();
|
|
78
|
+
registerConvergeCommands(program);
|
|
79
|
+
return program;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function run(args: string[]): Promise<{ stdout: string; stderr: string; exitCode: number | undefined }> {
|
|
83
|
+
let stdout = "";
|
|
84
|
+
let stderr = "";
|
|
85
|
+
const out = vi.spyOn(process.stdout, "write").mockImplementation((chunk: any) => {
|
|
86
|
+
stdout += String(chunk);
|
|
87
|
+
return true;
|
|
88
|
+
});
|
|
89
|
+
const err = vi.spyOn(process.stderr, "write").mockImplementation((chunk: any) => {
|
|
90
|
+
stderr += String(chunk);
|
|
91
|
+
return true;
|
|
92
|
+
});
|
|
93
|
+
const prevExitCode = process.exitCode;
|
|
94
|
+
process.exitCode = undefined;
|
|
95
|
+
try {
|
|
96
|
+
await makeProgram().parseAsync(["node", "test", ...args], { from: "node" });
|
|
97
|
+
return { stdout, stderr, exitCode: process.exitCode as number | undefined };
|
|
98
|
+
} finally {
|
|
99
|
+
process.exitCode = prevExitCode;
|
|
100
|
+
out.mockRestore();
|
|
101
|
+
err.mockRestore();
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function writeWorkspace(tmp: string): { stagingDir: string; stagingPath: string } {
|
|
106
|
+
const stagingDir = join(tmp, "converged", "staging", RUN_ID);
|
|
107
|
+
writeRunMeta(stagingDir, {
|
|
108
|
+
run_id: RUN_ID,
|
|
109
|
+
source_debate_id: SOURCE_DEBATE_ID,
|
|
110
|
+
pa_agent_id: PA_AGENT_ID,
|
|
111
|
+
target_corpus: tmp,
|
|
112
|
+
owner_role: "a",
|
|
113
|
+
});
|
|
114
|
+
const stagingPath = stagingPathFor(stagingDir, CRUX_ID);
|
|
115
|
+
writeStaging(stagingPath, {
|
|
116
|
+
frontmatter: {
|
|
117
|
+
debate_id: SOURCE_DEBATE_ID,
|
|
118
|
+
run_id: RUN_ID,
|
|
119
|
+
crux_id: CRUX_ID,
|
|
120
|
+
sub_debate_chain: [SUB_DEBATE_ID],
|
|
121
|
+
latest_sub_debate_id: SUB_DEBATE_ID,
|
|
122
|
+
source_crux_map_hash: SOURCE_HASH,
|
|
123
|
+
generation_id: "gen_accept001",
|
|
124
|
+
generated_at: "2026-04-30T10:00:00Z",
|
|
125
|
+
pa_body_hash: computePaBodyHash(BODY),
|
|
126
|
+
outcome: "converged",
|
|
127
|
+
bilateral_mandate_intact: true,
|
|
128
|
+
citations_a: [],
|
|
129
|
+
citations_b: [],
|
|
130
|
+
mod_progress_summary: {},
|
|
131
|
+
attested_by_user: false,
|
|
132
|
+
},
|
|
133
|
+
userResponse: "",
|
|
134
|
+
body: BODY,
|
|
135
|
+
});
|
|
136
|
+
return { stagingDir, stagingPath };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
let tmp: string;
|
|
140
|
+
let runEvents: any[];
|
|
141
|
+
|
|
142
|
+
beforeEach(() => {
|
|
143
|
+
tmp = mkdtempSync(join(tmpdir(), "lc-accept-test-"));
|
|
144
|
+
runEvents = [mapEvent()];
|
|
145
|
+
apiRef.current = {
|
|
146
|
+
getCommonsLogEvents: vi.fn(async () => ({ events: runEvents, next_offset: runEvents.length })),
|
|
147
|
+
acceptCruxDecision: vi.fn(async () => {
|
|
148
|
+
runEvents = [mapEvent(), terminalAcceptEvent()];
|
|
149
|
+
return { event_id: "sig_accept001" };
|
|
150
|
+
}),
|
|
151
|
+
getDebate: vi.fn(async () => ({
|
|
152
|
+
debate_id: SOURCE_DEBATE_ID,
|
|
153
|
+
agent_a_id: "agt_a",
|
|
154
|
+
agent_b_id: "agt_b",
|
|
155
|
+
commons_log_id: "clg_src",
|
|
156
|
+
counterparty_user_id: "usr_b",
|
|
157
|
+
status: "completed",
|
|
158
|
+
topic: "Trust scoring approach",
|
|
159
|
+
})),
|
|
160
|
+
};
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
afterEach(() => {
|
|
164
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
165
|
+
vi.restoreAllMocks();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe("accept --with-sync", () => {
|
|
169
|
+
it("posts the PA decision, then materializes the old accepted-file path", async () => {
|
|
170
|
+
const { stagingDir, stagingPath } = writeWorkspace(tmp);
|
|
171
|
+
const result = await run(["converge", "accept", CRUX_ID, "--staging-dir", stagingDir, "--with-sync"]);
|
|
172
|
+
expect(result.exitCode).toBeUndefined();
|
|
173
|
+
const payload = JSON.parse(result.stdout);
|
|
174
|
+
expect(apiRef.current.acceptCruxDecision).toHaveBeenCalledOnce();
|
|
175
|
+
expect(payload.event_id).toBe("sig_accept001");
|
|
176
|
+
expect(payload.synced).toBe(true);
|
|
177
|
+
expect(payload.materialized).toHaveLength(1);
|
|
178
|
+
expect(existsSync(payload.materialized[0])).toBe(true);
|
|
179
|
+
expect(existsSync(stagingPath)).toBe(false);
|
|
180
|
+
const accepted = readStaging(payload.materialized[0]);
|
|
181
|
+
expect(accepted.frontmatter.provenance?.attestation).toBe("bilateral_convergence");
|
|
182
|
+
expect(accepted.frontmatter.provenance?.signed_off_by).toBe("usr_test");
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("does not materialize local files when the PA decision is rejected", async () => {
|
|
186
|
+
const { stagingDir, stagingPath } = writeWorkspace(tmp);
|
|
187
|
+
apiRef.current.acceptCruxDecision = vi.fn(async () => {
|
|
188
|
+
throw new LinkedClawError("api_409", "HTTP 409");
|
|
189
|
+
});
|
|
190
|
+
const result = await run(["converge", "accept", CRUX_ID, "--staging-dir", stagingDir, "--with-sync"]);
|
|
191
|
+
expect(result.exitCode).toBe(1);
|
|
192
|
+
expect(existsSync(stagingPath)).toBe(true);
|
|
193
|
+
expect(readFileSync(stagingPath, "utf8")).toContain("Both sides agree on X.");
|
|
194
|
+
expect(existsSync(join(tmp, "converged", "trust-scoring-approach"))).toBe(false);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("uses the PA-compatible canonical body hash in the endpoint request", async () => {
|
|
198
|
+
const { stagingDir } = writeWorkspace(tmp);
|
|
199
|
+
await run(["converge", "accept", CRUX_ID, "--staging-dir", stagingDir, "--with-sync"]);
|
|
200
|
+
const body = apiRef.current.acceptCruxDecision.mock.calls[0][2];
|
|
201
|
+
expect(body.pa_body_hash).toBe(
|
|
202
|
+
sha256OfCanonicalJson({ citations_a: [], citations_b: [], synthesis_text: "Both sides agree on X." }),
|
|
203
|
+
);
|
|
204
|
+
expect(body.pa_body_hash).not.toBe(computePaBodyHash(BODY));
|
|
205
|
+
});
|
|
206
|
+
});
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { registerConvergeCommands } from "../src/commands/converge.js";
|
|
7
|
+
import { sha256OfCanonicalJson } from "../src/converge/hash.js";
|
|
8
|
+
import { computePaBodyHash, stagingPathFor, writeStaging } from "../src/converge/staging.js";
|
|
9
|
+
import { writeRunMeta } from "../src/converge/workspace.js";
|
|
10
|
+
|
|
11
|
+
const RUN_ID = "clg_run_decision001";
|
|
12
|
+
const SOURCE_DEBATE_ID = "dbt_src001";
|
|
13
|
+
const PA_AGENT_ID = "agt_pa001";
|
|
14
|
+
const CRUX_ID = "crux_001";
|
|
15
|
+
const SOURCE_HASH = "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
|
|
16
|
+
const STAGING_BODY = "# Synthesis\n\nLocal edited draft that must not be posted.\n";
|
|
17
|
+
|
|
18
|
+
const apiRef = vi.hoisted(() => ({ current: {} as any }));
|
|
19
|
+
|
|
20
|
+
vi.mock("../src/context.js", () => ({
|
|
21
|
+
buildContext: () => ({
|
|
22
|
+
cfg: { apiKey: "lc_test", cloudUrl: "http://cloud.test" },
|
|
23
|
+
consumer: { getMe: vi.fn(async () => ({ user_id: "usr_test" })) },
|
|
24
|
+
}),
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
vi.mock("../src/converge/api.js", () => ({
|
|
28
|
+
makeConvergeApi: () => apiRef.current,
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
function makeMap(outcome: string, extras: Record<string, unknown> = {}) {
|
|
32
|
+
return {
|
|
33
|
+
seq: 7,
|
|
34
|
+
event_type: "convergence_map",
|
|
35
|
+
payload: {
|
|
36
|
+
event_type: "convergence_map",
|
|
37
|
+
source_crux_map_hash: SOURCE_HASH,
|
|
38
|
+
cruxes: [
|
|
39
|
+
{
|
|
40
|
+
crux_id: CRUX_ID,
|
|
41
|
+
outcome,
|
|
42
|
+
sub_debate_chain: outcome === "already_aligned" ? [] : ["dbt_sub001"],
|
|
43
|
+
latest_sub_debate_id: outcome === "already_aligned" ? null : "dbt_sub001",
|
|
44
|
+
synthesis_text: "PA canonical synthesis.",
|
|
45
|
+
citations_a: [{ file: "a.md", line_range: "1-2" }],
|
|
46
|
+
citations_b: [],
|
|
47
|
+
generation_id: "gen_decision001",
|
|
48
|
+
bilateral_mandate_intact_at_outcome: true,
|
|
49
|
+
...extras,
|
|
50
|
+
},
|
|
51
|
+
],
|
|
52
|
+
},
|
|
53
|
+
appended_at: "2026-04-30T10:00:00Z",
|
|
54
|
+
signed_by: "usr_operator",
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function makeProgram(): Command {
|
|
59
|
+
const program = new Command();
|
|
60
|
+
program.exitOverride();
|
|
61
|
+
registerConvergeCommands(program);
|
|
62
|
+
return program;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function run(args: string[]): Promise<{ stdout: string; stderr: string; exitCode: number | undefined }> {
|
|
66
|
+
let stdout = "";
|
|
67
|
+
let stderr = "";
|
|
68
|
+
const out = vi.spyOn(process.stdout, "write").mockImplementation((chunk: any) => {
|
|
69
|
+
stdout += String(chunk);
|
|
70
|
+
return true;
|
|
71
|
+
});
|
|
72
|
+
const err = vi.spyOn(process.stderr, "write").mockImplementation((chunk: any) => {
|
|
73
|
+
stderr += String(chunk);
|
|
74
|
+
return true;
|
|
75
|
+
});
|
|
76
|
+
const prevExitCode = process.exitCode;
|
|
77
|
+
process.exitCode = undefined;
|
|
78
|
+
try {
|
|
79
|
+
await makeProgram().parseAsync(["node", "test", ...args], { from: "node" });
|
|
80
|
+
return { stdout, stderr, exitCode: process.exitCode as number | undefined };
|
|
81
|
+
} finally {
|
|
82
|
+
process.exitCode = prevExitCode;
|
|
83
|
+
out.mockRestore();
|
|
84
|
+
err.mockRestore();
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function writeWorkspace(tmp: string): { stagingDir: string; stagingPath: string; originalText: string } {
|
|
89
|
+
const stagingDir = join(tmp, "converged", "staging", RUN_ID);
|
|
90
|
+
writeRunMeta(stagingDir, {
|
|
91
|
+
run_id: RUN_ID,
|
|
92
|
+
source_debate_id: SOURCE_DEBATE_ID,
|
|
93
|
+
pa_agent_id: PA_AGENT_ID,
|
|
94
|
+
target_corpus: tmp,
|
|
95
|
+
owner_role: "a",
|
|
96
|
+
});
|
|
97
|
+
const stagingPath = stagingPathFor(stagingDir, CRUX_ID);
|
|
98
|
+
writeStaging(stagingPath, {
|
|
99
|
+
frontmatter: {
|
|
100
|
+
debate_id: SOURCE_DEBATE_ID,
|
|
101
|
+
run_id: RUN_ID,
|
|
102
|
+
crux_id: CRUX_ID,
|
|
103
|
+
sub_debate_chain: ["dbt_sub001"],
|
|
104
|
+
latest_sub_debate_id: "dbt_sub001",
|
|
105
|
+
source_crux_map_hash: SOURCE_HASH,
|
|
106
|
+
generation_id: "gen_old",
|
|
107
|
+
generated_at: "2026-04-30T09:00:00Z",
|
|
108
|
+
pa_body_hash: computePaBodyHash(STAGING_BODY),
|
|
109
|
+
outcome: "converged",
|
|
110
|
+
bilateral_mandate_intact: true,
|
|
111
|
+
citations_a: [],
|
|
112
|
+
citations_b: [],
|
|
113
|
+
mod_progress_summary: {},
|
|
114
|
+
attested_by_user: false,
|
|
115
|
+
},
|
|
116
|
+
userResponse: "",
|
|
117
|
+
body: STAGING_BODY,
|
|
118
|
+
});
|
|
119
|
+
return { stagingDir, stagingPath, originalText: readFileSync(stagingPath, "utf8") };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
let tmp: string;
|
|
123
|
+
let runEvents: any[];
|
|
124
|
+
|
|
125
|
+
beforeEach(() => {
|
|
126
|
+
tmp = mkdtempSync(join(tmpdir(), "lc-decision-test-"));
|
|
127
|
+
runEvents = [makeMap("converged")];
|
|
128
|
+
apiRef.current = {
|
|
129
|
+
getCommonsLogEvents: vi.fn(async () => ({ events: runEvents, next_offset: runEvents.length })),
|
|
130
|
+
acceptCruxDecision: vi.fn(async () => ({ event_id: "sig_accept" })),
|
|
131
|
+
rejectCruxDecision: vi.fn(async () => ({ event_id: "sig_reject" })),
|
|
132
|
+
attestCruxDecision: vi.fn(async () => ({ event_id: "sig_attest" })),
|
|
133
|
+
};
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
afterEach(() => {
|
|
137
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
138
|
+
vi.restoreAllMocks();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe("network-only convergence decisions", () => {
|
|
142
|
+
it("accept posts the PA decision endpoint and does not touch staging or final files", async () => {
|
|
143
|
+
const { stagingDir, stagingPath, originalText } = writeWorkspace(tmp);
|
|
144
|
+
const result = await run(["converge", "accept", CRUX_ID, "--staging-dir", stagingDir]);
|
|
145
|
+
expect(result.exitCode).toBeUndefined();
|
|
146
|
+
expect(apiRef.current.acceptCruxDecision).toHaveBeenCalledOnce();
|
|
147
|
+
expect(readFileSync(stagingPath, "utf8")).toBe(originalText);
|
|
148
|
+
expect(existsSync(join(tmp, "converged", "dbt-src001"))).toBe(false);
|
|
149
|
+
const payload = JSON.parse(result.stdout);
|
|
150
|
+
expect(payload).toMatchObject({ run_id: RUN_ID, crux_id: CRUX_ID, action: "accept", event_id: "sig_accept", synced: false });
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("reject posts the PA decision endpoint and leaves staging intact", async () => {
|
|
154
|
+
runEvents = [makeMap("irreconcilable", { bilateral_mandate_intact_at_outcome: false })];
|
|
155
|
+
const { stagingDir, stagingPath, originalText } = writeWorkspace(tmp);
|
|
156
|
+
await run(["converge", "reject", CRUX_ID, "--staging-dir", stagingDir]);
|
|
157
|
+
expect(apiRef.current.rejectCruxDecision).toHaveBeenCalledOnce();
|
|
158
|
+
expect(readFileSync(stagingPath, "utf8")).toBe(originalText);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("attest posts the PA decision endpoint and leaves staging intact", async () => {
|
|
162
|
+
runEvents = [makeMap("already_aligned")];
|
|
163
|
+
const { stagingDir, stagingPath, originalText } = writeWorkspace(tmp);
|
|
164
|
+
await run(["converge", "attest", CRUX_ID, "--staging-dir", stagingDir]);
|
|
165
|
+
expect(apiRef.current.attestCruxDecision).toHaveBeenCalledOnce();
|
|
166
|
+
expect(readFileSync(stagingPath, "utf8")).toBe(originalText);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("builds accept/reject/attest requests from the latest convergence_map", async () => {
|
|
170
|
+
const { stagingDir } = writeWorkspace(tmp);
|
|
171
|
+
await run(["converge", "accept", CRUX_ID, "--staging-dir", stagingDir, "--message", "reviewed"]);
|
|
172
|
+
const body = apiRef.current.acceptCruxDecision.mock.calls[0][2];
|
|
173
|
+
expect(body).toMatchObject({
|
|
174
|
+
convergence_map_generation_id: "gen_decision001",
|
|
175
|
+
source_crux_map_hash: SOURCE_HASH,
|
|
176
|
+
latest_sub_debate_id: "dbt_sub001",
|
|
177
|
+
terminal_outcome: "converged",
|
|
178
|
+
bilateral_mandate_intact: true,
|
|
179
|
+
attestation: "bilateral_convergence",
|
|
180
|
+
synthesis_edited: false,
|
|
181
|
+
synthesis_text: "PA canonical synthesis.",
|
|
182
|
+
citations_a: [{ file: "a.md", line_range: "1-2" }],
|
|
183
|
+
citations_b: [],
|
|
184
|
+
user_message: "reviewed",
|
|
185
|
+
});
|
|
186
|
+
expect(body.pa_body_hash).toBe(
|
|
187
|
+
sha256OfCanonicalJson({
|
|
188
|
+
citations_a: [{ file: "a.md", line_range: "1-2" }],
|
|
189
|
+
citations_b: [],
|
|
190
|
+
synthesis_text: "PA canonical synthesis.",
|
|
191
|
+
}),
|
|
192
|
+
);
|
|
193
|
+
expect(body.accepted_body_hash).toBe(body.pa_body_hash);
|
|
194
|
+
|
|
195
|
+
runEvents = [makeMap("irreconcilable", { bilateral_mandate_intact_at_outcome: false })];
|
|
196
|
+
await run(["converge", "reject", CRUX_ID, "--staging-dir", stagingDir]);
|
|
197
|
+
expect(apiRef.current.rejectCruxDecision.mock.calls[0][2].attestation).toBe("user_attested_with_network_context");
|
|
198
|
+
|
|
199
|
+
runEvents = [makeMap("already_aligned")];
|
|
200
|
+
await run(["converge", "attest", CRUX_ID, "--staging-dir", stagingDir]);
|
|
201
|
+
expect(apiRef.current.attestCruxDecision.mock.calls[0][2].attestation).toBe("user_attested_no_dialog");
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("accept with edited staging body sets synthesis_edited=true and posts the edited body", async () => {
|
|
205
|
+
const { stagingDir, stagingPath } = writeWorkspace(tmp);
|
|
206
|
+
const editedBody = "# Synthesis\n\nLocal edits the user made before accepting.\n";
|
|
207
|
+
const paBody = "PA canonical synthesis.";
|
|
208
|
+
writeStaging(stagingPath, {
|
|
209
|
+
frontmatter: {
|
|
210
|
+
debate_id: SOURCE_DEBATE_ID,
|
|
211
|
+
run_id: RUN_ID,
|
|
212
|
+
crux_id: CRUX_ID,
|
|
213
|
+
sub_debate_chain: ["dbt_sub001"],
|
|
214
|
+
latest_sub_debate_id: "dbt_sub001",
|
|
215
|
+
source_crux_map_hash: SOURCE_HASH,
|
|
216
|
+
generation_id: "gen_decision001",
|
|
217
|
+
generated_at: "2026-04-30T09:00:00Z",
|
|
218
|
+
pa_body_hash: computePaBodyHash(paBody),
|
|
219
|
+
outcome: "converged",
|
|
220
|
+
bilateral_mandate_intact: true,
|
|
221
|
+
citations_a: [{ file: "a.md", line_range: "1-2" }],
|
|
222
|
+
citations_b: [],
|
|
223
|
+
mod_progress_summary: {},
|
|
224
|
+
attested_by_user: false,
|
|
225
|
+
},
|
|
226
|
+
userResponse: "",
|
|
227
|
+
body: editedBody,
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
await run(["converge", "accept", CRUX_ID, "--staging-dir", stagingDir]);
|
|
231
|
+
|
|
232
|
+
const body = apiRef.current.acceptCruxDecision.mock.calls[0][2];
|
|
233
|
+
expect(body.synthesis_edited).toBe(true);
|
|
234
|
+
expect(body.attestation).toBe("user_attested_with_network_context");
|
|
235
|
+
expect(body.synthesis_text).toBe(editedBody);
|
|
236
|
+
expect(body.pa_body_hash).toBe(
|
|
237
|
+
sha256OfCanonicalJson({
|
|
238
|
+
citations_a: [{ file: "a.md", line_range: "1-2" }],
|
|
239
|
+
citations_b: [],
|
|
240
|
+
synthesis_text: paBody,
|
|
241
|
+
}),
|
|
242
|
+
);
|
|
243
|
+
expect(body.accepted_body_hash).toBe(
|
|
244
|
+
sha256OfCanonicalJson({
|
|
245
|
+
citations_a: [{ file: "a.md", line_range: "1-2" }],
|
|
246
|
+
citations_b: [],
|
|
247
|
+
synthesis_text: editedBody,
|
|
248
|
+
}),
|
|
249
|
+
);
|
|
250
|
+
expect(body.accepted_body_hash).not.toBe(body.pa_body_hash);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("sync removes staging files for terminal non-accept decisions", async () => {
|
|
254
|
+
runEvents = [
|
|
255
|
+
makeMap("irreconcilable", { bilateral_mandate_intact_at_outcome: false }),
|
|
256
|
+
{
|
|
257
|
+
seq: 8,
|
|
258
|
+
event_type: "reject_attestation",
|
|
259
|
+
payload: {
|
|
260
|
+
event_type: "reject_attestation",
|
|
261
|
+
crux_id: CRUX_ID,
|
|
262
|
+
convergence_map_generation_id: "gen_decision001",
|
|
263
|
+
},
|
|
264
|
+
appended_at: "2026-04-30T10:01:00Z",
|
|
265
|
+
signed_by: "usr_operator",
|
|
266
|
+
},
|
|
267
|
+
];
|
|
268
|
+
const { stagingDir, stagingPath } = writeWorkspace(tmp);
|
|
269
|
+
const result = await run(["converge", "sync", "--staging-dir", stagingDir]);
|
|
270
|
+
expect(result.exitCode).toBeUndefined();
|
|
271
|
+
expect(existsSync(stagingPath)).toBe(false);
|
|
272
|
+
expect(JSON.parse(result.stdout).cleaned).toEqual([CRUX_ID]);
|
|
273
|
+
});
|
|
274
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { sha256OfCanonicalJson, canonicalize } from "../src/converge/hash.js";
|
|
3
|
+
|
|
4
|
+
describe("sha256OfCanonicalJson", () => {
|
|
5
|
+
it("key-order independence: {b,a} === {a,b}", () => {
|
|
6
|
+
expect(sha256OfCanonicalJson({ b: 1, a: 2 })).toBe(sha256OfCanonicalJson({ a: 2, b: 1 }));
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it("output is deterministic across calls", () => {
|
|
10
|
+
const v = { x: [1, 2, 3], y: null };
|
|
11
|
+
expect(sha256OfCanonicalJson(v)).toBe(sha256OfCanonicalJson(v));
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("output starts with sha256:", () => {
|
|
15
|
+
expect(sha256OfCanonicalJson({})).toMatch(/^sha256:[0-9a-f]{64}$/);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("different values produce different hashes", () => {
|
|
19
|
+
expect(sha256OfCanonicalJson({ a: 1 })).not.toBe(sha256OfCanonicalJson({ a: 2 }));
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("canonicalize handles nested objects and arrays", () => {
|
|
23
|
+
const a = canonicalize({ z: [{ b: 1, a: 2 }] });
|
|
24
|
+
const b = canonicalize({ z: [{ a: 2, b: 1 }] });
|
|
25
|
+
expect(a).toBe(b);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// Python equivalence fixtures:
|
|
30
|
+
// import hashlib, json
|
|
31
|
+
// json.dumps(data, sort_keys=True, separators=(",",":"), ensure_ascii=True)
|
|
32
|
+
// "sha256:" + hashlib.sha256(canonical.encode()).hexdigest()
|
|
33
|
+
describe("Python hash equivalence", () => {
|
|
34
|
+
it("matches Python for pure-ASCII crux_map fixture", () => {
|
|
35
|
+
// Python: json.dumps({"cruxes":[{"crux_id":"c1","statement":"foo"}],"version":1}, sort_keys=True, separators=(",",":"))
|
|
36
|
+
// → '{"cruxes":[{"crux_id":"c1","statement":"foo"}],"version":1}'
|
|
37
|
+
// → sha256:2e55d19757e251b4aefc7eb59ef941d99087d3cfaf7be24fe790c83a9f7ba7e7
|
|
38
|
+
const data = { cruxes: [{ crux_id: "c1", statement: "foo" }], version: 1 };
|
|
39
|
+
expect(sha256OfCanonicalJson(data)).toBe(
|
|
40
|
+
"sha256:2e55d19757e251b4aefc7eb59ef941d99087d3cfaf7be24fe790c83a9f7ba7e7",
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("matches Python for non-ASCII string (ensure_ascii=True escapes to \\uXXXX)", () => {
|
|
45
|
+
// Python (ensure_ascii=True default): {"n":2,"text":"café"}
|
|
46
|
+
// → sha256:5429b44c1e9e972c80e540b67fc1498b68b6e1afa2822736ef98b38dcecf8f6b
|
|
47
|
+
const data = { text: "café", n: 2 };
|
|
48
|
+
expect(sha256OfCanonicalJson(data)).toBe(
|
|
49
|
+
"sha256:5429b44c1e9e972c80e540b67fc1498b68b6e1afa2822736ef98b38dcecf8f6b",
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("canonicalize emits \\uXXXX for non-ASCII chars (not raw UTF-8)", () => {
|
|
54
|
+
// Node JSON.stringify does NOT escape non-ASCII by default;
|
|
55
|
+
// our canonicalize must do it explicitly to match Python.
|
|
56
|
+
expect(canonicalize("café")).toBe('"caf\\u00e9"');
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll } from "vitest";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
const BIN = path.resolve(__dirname, "../dist/bin.js");
|
|
9
|
+
|
|
10
|
+
beforeAll(() => {
|
|
11
|
+
if (!existsSync(BIN)) {
|
|
12
|
+
throw new Error(`bin not built — run 'pnpm --filter @linkedclaw/cli build' first (${BIN})`);
|
|
13
|
+
}
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
function run(args: string[]): { code: number | null; stdout: string; stderr: string } {
|
|
17
|
+
const r = spawnSync("node", [BIN, ...args], { encoding: "utf8" });
|
|
18
|
+
return { code: r.status, stdout: r.stdout ?? "", stderr: r.stderr ?? "" };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe("converge help", () => {
|
|
22
|
+
it("converge --help lists all 8 verbs", () => {
|
|
23
|
+
const { code, stdout } = run(["converge", "--help"]);
|
|
24
|
+
expect(code).toBe(0);
|
|
25
|
+
for (const verb of ["run", "clarify", "attest", "accept", "reject", "sync", "review", "status"]) {
|
|
26
|
+
expect(stdout).toContain(verb);
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("converge run --help lists expected options", () => {
|
|
31
|
+
const { code, stdout } = run(["converge", "run", "--help"]);
|
|
32
|
+
expect(code).toBe(0);
|
|
33
|
+
expect(stdout).toContain("--target-corpus");
|
|
34
|
+
expect(stdout).toContain("--accept");
|
|
35
|
+
expect(stdout).toContain("--force-regenerate");
|
|
36
|
+
expect(stdout).toContain("--wait");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("converge status --help lists --all", () => {
|
|
40
|
+
const { code, stdout } = run(["converge", "status", "--help"]);
|
|
41
|
+
expect(code).toBe(0);
|
|
42
|
+
expect(stdout).toContain("--all");
|
|
43
|
+
expect(stdout).toContain("--run-id");
|
|
44
|
+
expect(stdout).toContain("--staging-dir");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("converge accept --help lists --with-sync", () => {
|
|
48
|
+
const { code, stdout } = run(["converge", "accept", "--help"]);
|
|
49
|
+
expect(code).toBe(0);
|
|
50
|
+
expect(stdout).toContain("--with-sync");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("top-level --help still lists converge", () => {
|
|
54
|
+
const { code, stdout } = run(["--help"]);
|
|
55
|
+
expect(code).toBe(0);
|
|
56
|
+
expect(stdout).toContain("converge");
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { mkdtempSync, rmSync, existsSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { acquireLock } from "../src/converge/lock.js";
|
|
6
|
+
|
|
7
|
+
let tmp: string;
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
tmp = mkdtempSync(join(tmpdir(), "lc-lock-test-"));
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe("acquireLock", () => {
|
|
18
|
+
it("succeeds and creates .lock file", () => {
|
|
19
|
+
const release = acquireLock(tmp);
|
|
20
|
+
expect(existsSync(join(tmp, ".lock"))).toBe(true);
|
|
21
|
+
release();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("second concurrent call throws lock_held", () => {
|
|
25
|
+
const release = acquireLock(tmp);
|
|
26
|
+
try {
|
|
27
|
+
expect(() => acquireLock(tmp)).toThrow(
|
|
28
|
+
expect.objectContaining({ code: "lock_held" }),
|
|
29
|
+
);
|
|
30
|
+
} finally {
|
|
31
|
+
release();
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("release deletes .lock file", () => {
|
|
36
|
+
const release = acquireLock(tmp);
|
|
37
|
+
release();
|
|
38
|
+
expect(existsSync(join(tmp, ".lock"))).toBe(false);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("re-acquire after release succeeds", () => {
|
|
42
|
+
const r1 = acquireLock(tmp);
|
|
43
|
+
r1();
|
|
44
|
+
const r2 = acquireLock(tmp);
|
|
45
|
+
expect(existsSync(join(tmp, ".lock"))).toBe(true);
|
|
46
|
+
r2();
|
|
47
|
+
});
|
|
48
|
+
});
|