@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,91 @@
|
|
|
1
|
+
export interface RunMeta {
|
|
2
|
+
run_id: string;
|
|
3
|
+
source_debate_id: string;
|
|
4
|
+
pa_agent_id: string;
|
|
5
|
+
target_corpus: string;
|
|
6
|
+
owner_role: "a" | "b";
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface RunWorkspace {
|
|
10
|
+
runId: string;
|
|
11
|
+
sourceDebateId: string;
|
|
12
|
+
paAgentId: string;
|
|
13
|
+
targetCorpus: string;
|
|
14
|
+
stagingDir: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface DebateRecord {
|
|
18
|
+
debate_id: string;
|
|
19
|
+
agent_a_id: string;
|
|
20
|
+
agent_b_id: string;
|
|
21
|
+
commons_log_id: string;
|
|
22
|
+
counterparty_user_id: string | null;
|
|
23
|
+
status: string;
|
|
24
|
+
topic?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface CommonsLogEvent {
|
|
28
|
+
seq: number;
|
|
29
|
+
event_type: string;
|
|
30
|
+
payload: Record<string, unknown>;
|
|
31
|
+
appended_at: string;
|
|
32
|
+
signed_by: string;
|
|
33
|
+
signature_hash?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface AgentListing {
|
|
37
|
+
agent_id: string;
|
|
38
|
+
slug: string;
|
|
39
|
+
capabilities: string[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface MandateRecord {
|
|
43
|
+
mandate_id: string;
|
|
44
|
+
principal_agent_id: string;
|
|
45
|
+
delegate_agent_id: string;
|
|
46
|
+
scope: string[];
|
|
47
|
+
expires_at: string | null;
|
|
48
|
+
revoked_at?: string | null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface RunStateSummary {
|
|
52
|
+
run_id: string;
|
|
53
|
+
source_debate_id: string;
|
|
54
|
+
started_at: string | null;
|
|
55
|
+
owner_b_accepted: boolean;
|
|
56
|
+
cruxes: Array<{
|
|
57
|
+
crux_id: string;
|
|
58
|
+
latest_sub_debate_id: string | null;
|
|
59
|
+
sub_debate_chain: string[];
|
|
60
|
+
outcome: string | null;
|
|
61
|
+
bilateral_mandate_intact: boolean | null;
|
|
62
|
+
}>;
|
|
63
|
+
terminal_emitted: boolean;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export type ConvergenceDecisionAction = "accept" | "reject" | "attest";
|
|
67
|
+
|
|
68
|
+
export type ConvergenceAttestation =
|
|
69
|
+
| "bilateral_convergence"
|
|
70
|
+
| "user_attested_with_network_context"
|
|
71
|
+
| "user_attested_no_dialog";
|
|
72
|
+
|
|
73
|
+
export interface CruxDecisionRequest {
|
|
74
|
+
convergence_map_generation_id: string;
|
|
75
|
+
source_crux_map_hash: string;
|
|
76
|
+
latest_sub_debate_id: string | null;
|
|
77
|
+
terminal_outcome: "converged" | "partial_overlap" | "needs_input" | "irreconcilable" | "already_aligned";
|
|
78
|
+
bilateral_mandate_intact: boolean;
|
|
79
|
+
attestation: ConvergenceAttestation;
|
|
80
|
+
synthesis_edited: boolean;
|
|
81
|
+
pa_body_hash: string;
|
|
82
|
+
accepted_body_hash: string;
|
|
83
|
+
synthesis_text: string;
|
|
84
|
+
citations_a: Array<Record<string, unknown>>;
|
|
85
|
+
citations_b: Array<Record<string, unknown>>;
|
|
86
|
+
user_message?: string | null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface CruxDecisionResponse {
|
|
90
|
+
event_id: string;
|
|
91
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
3
|
+
import { load as yamlLoad, dump as yamlDump } from "js-yaml";
|
|
4
|
+
import { LinkedClawError } from "../errors.js";
|
|
5
|
+
import type { RunMeta, RunWorkspace } from "./types.js";
|
|
6
|
+
|
|
7
|
+
export interface ResolveOpts {
|
|
8
|
+
runId?: string;
|
|
9
|
+
stagingDir?: string;
|
|
10
|
+
targetCorpus?: string;
|
|
11
|
+
cwd?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const META_FILENAME = ".run-meta.yaml";
|
|
15
|
+
|
|
16
|
+
export function readRunMeta(stagingDir: string): RunMeta | null {
|
|
17
|
+
const metaPath = join(stagingDir, META_FILENAME);
|
|
18
|
+
if (!existsSync(metaPath)) return null;
|
|
19
|
+
return yamlLoad(readFileSync(metaPath, "utf8")) as RunMeta;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function writeRunMeta(stagingDir: string, meta: RunMeta): void {
|
|
23
|
+
mkdirSync(stagingDir, { recursive: true });
|
|
24
|
+
writeFileSync(join(stagingDir, META_FILENAME), yamlDump(meta), "utf8");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function searchUpward(startDir: string, maxLevels = 5): string | null {
|
|
28
|
+
let dir = startDir;
|
|
29
|
+
for (let i = 0; i < maxLevels; i++) {
|
|
30
|
+
if (existsSync(join(dir, META_FILENAME))) return dir;
|
|
31
|
+
const parent = dirname(dir);
|
|
32
|
+
if (parent === dir) break; // filesystem root
|
|
33
|
+
dir = parent;
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function resolveWorkspace(opts: ResolveOpts): Promise<RunWorkspace> {
|
|
39
|
+
const cwd = opts.cwd ? resolve(opts.cwd) : process.cwd();
|
|
40
|
+
|
|
41
|
+
let stagingDir: string | undefined;
|
|
42
|
+
let meta: RunMeta | null = null;
|
|
43
|
+
|
|
44
|
+
if (opts.stagingDir) {
|
|
45
|
+
stagingDir = isAbsolute(opts.stagingDir) ? opts.stagingDir : resolve(cwd, opts.stagingDir);
|
|
46
|
+
meta = readRunMeta(stagingDir);
|
|
47
|
+
if (!meta) {
|
|
48
|
+
throw new LinkedClawError(
|
|
49
|
+
"meta_not_found",
|
|
50
|
+
`No ${META_FILENAME} found in --staging-dir: ${stagingDir}`,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
} else {
|
|
54
|
+
const found = searchUpward(cwd);
|
|
55
|
+
if (found) {
|
|
56
|
+
stagingDir = found;
|
|
57
|
+
meta = readRunMeta(found)!;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (!meta) {
|
|
62
|
+
if (opts.runId) {
|
|
63
|
+
throw new LinkedClawError(
|
|
64
|
+
"meta_not_found",
|
|
65
|
+
`--run-id given but no ${META_FILENAME} found (searched upward from ${cwd}). Provide --staging-dir to locate the run workspace.`,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
throw new LinkedClawError(
|
|
69
|
+
"meta_not_found",
|
|
70
|
+
`No ${META_FILENAME} found (searched upward from ${cwd}). Run 'lc converge run <debate_id>' first.`,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (opts.runId && opts.runId !== meta.run_id) {
|
|
75
|
+
throw new LinkedClawError(
|
|
76
|
+
"run_id_mismatch",
|
|
77
|
+
`--run-id ${opts.runId} does not match run_id ${meta.run_id} in ${META_FILENAME}`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const targetCorpus = isAbsolute(meta.target_corpus)
|
|
82
|
+
? meta.target_corpus
|
|
83
|
+
: resolve(stagingDir!, meta.target_corpus);
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
runId: meta.run_id,
|
|
87
|
+
sourceDebateId: meta.source_debate_id,
|
|
88
|
+
paAgentId: meta.pa_agent_id,
|
|
89
|
+
targetCorpus,
|
|
90
|
+
stagingDir: stagingDir!,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
@@ -15,10 +15,10 @@ import type { Readable, Writable } from "node:stream";
|
|
|
15
15
|
import { randomUUID } from "node:crypto";
|
|
16
16
|
import { createInterface } from "node:readline";
|
|
17
17
|
import type {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
18
|
+
GigTaskExecuteEvent,
|
|
19
|
+
GigTaskExecuteResult,
|
|
20
|
+
GigTaskOfferDecision,
|
|
21
|
+
GigTaskOfferEvent,
|
|
22
22
|
InvokeEvent,
|
|
23
23
|
InvokeHandlerResult,
|
|
24
24
|
ProviderHandler,
|
|
@@ -100,12 +100,12 @@ export class SubprocessHandler implements ProviderHandler {
|
|
|
100
100
|
return this.request<InvokeHandlerResult>(evt);
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
-
async
|
|
104
|
-
return this.request<
|
|
103
|
+
async onGigTaskOffer(evt: GigTaskOfferEvent): Promise<GigTaskOfferDecision> {
|
|
104
|
+
return this.request<GigTaskOfferDecision>(evt);
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
async
|
|
108
|
-
return this.request<
|
|
107
|
+
async onGigTaskExecute(evt: GigTaskExecuteEvent): Promise<GigTaskExecuteResult> {
|
|
108
|
+
return this.request<GigTaskExecuteResult>(evt);
|
|
109
109
|
}
|
|
110
110
|
|
|
111
111
|
// ───── shutdown ─────
|
package/src/types.ts
CHANGED
|
@@ -52,7 +52,7 @@ export interface TaskManifest {
|
|
|
52
52
|
[key: string]: unknown;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
export interface
|
|
55
|
+
export interface CreateGigTaskRequest {
|
|
56
56
|
capability: string;
|
|
57
57
|
instruction: string;
|
|
58
58
|
target_providers: number;
|
|
@@ -67,12 +67,12 @@ export interface CreateBroadcastRequest {
|
|
|
67
67
|
referred_by?: EntityId;
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
export interface
|
|
70
|
+
export interface AcceptGigTaskRequest {
|
|
71
71
|
agent_id: EntityId;
|
|
72
72
|
slot_key?: string | null;
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
export interface
|
|
75
|
+
export interface GigTaskSubmitRequest {
|
|
76
76
|
result_data: string;
|
|
77
77
|
result_payload?: Record<string, unknown> | null;
|
|
78
78
|
proof?: Array<{ type: string; value: string; label?: string }> | null;
|
|
@@ -81,8 +81,8 @@ export interface BroadcastSubmitRequest {
|
|
|
81
81
|
// Re-export provider-runtime types needed by commands
|
|
82
82
|
export type {
|
|
83
83
|
ProviderHandler,
|
|
84
|
-
|
|
85
|
-
|
|
84
|
+
GigTaskOfferDecision,
|
|
85
|
+
GigTaskExecuteResult,
|
|
86
86
|
InvokeHandlerResult,
|
|
87
87
|
SessionAcceptDecision,
|
|
88
88
|
SessionReply,
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from "vitest";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { EventEmitter } from "node:events";
|
|
4
|
+
import { chmodSync, existsSync, mkdtempSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { Command } from "commander";
|
|
9
|
+
|
|
10
|
+
const spawnMock = vi.hoisted(() => vi.fn());
|
|
11
|
+
|
|
12
|
+
vi.mock("node:child_process", async (importOriginal) => {
|
|
13
|
+
const actual = await importOriginal<typeof import("node:child_process")>();
|
|
14
|
+
return { ...actual, spawn: spawnMock };
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
const BIN = path.resolve(__dirname, "../dist/bin.js");
|
|
19
|
+
const ORIGINAL_CONFIG_ENV = process.env.LINKEDCLAW_OWNER_AGENT_CONFIG;
|
|
20
|
+
const ORIGINAL_EXIT_CODE = process.exitCode;
|
|
21
|
+
|
|
22
|
+
type FakeChild = EventEmitter & {
|
|
23
|
+
killed: boolean;
|
|
24
|
+
kill: ReturnType<typeof vi.fn>;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
let activeChild: FakeChild | undefined;
|
|
28
|
+
|
|
29
|
+
beforeAll(() => {
|
|
30
|
+
if (!existsSync(BIN)) {
|
|
31
|
+
throw new Error(`bin not built — run 'pnpm --filter @linkedclaw/cli build' first (${BIN})`);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
spawnMock.mockReset();
|
|
37
|
+
delete process.env.LINKEDCLAW_OWNER_AGENT_CONFIG;
|
|
38
|
+
activeChild = undefined;
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
afterEach(() => {
|
|
42
|
+
if (activeChild !== undefined) {
|
|
43
|
+
activeChild.emit("exit", 0, null);
|
|
44
|
+
activeChild = undefined;
|
|
45
|
+
}
|
|
46
|
+
if (ORIGINAL_CONFIG_ENV === undefined) {
|
|
47
|
+
delete process.env.LINKEDCLAW_OWNER_AGENT_CONFIG;
|
|
48
|
+
} else {
|
|
49
|
+
process.env.LINKEDCLAW_OWNER_AGENT_CONFIG = ORIGINAL_CONFIG_ENV;
|
|
50
|
+
}
|
|
51
|
+
process.exitCode = ORIGINAL_EXIT_CODE;
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
function run(args: string[]): { code: number | null; stdout: string; stderr: string } {
|
|
55
|
+
const r = spawnSync("node", [BIN, ...args], { encoding: "utf8" });
|
|
56
|
+
return { code: r.status, stdout: r.stdout ?? "", stderr: r.stderr ?? "" };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function makeFakeChild(): FakeChild {
|
|
60
|
+
const child = new EventEmitter() as FakeChild;
|
|
61
|
+
child.killed = false;
|
|
62
|
+
child.kill = vi.fn(() => true);
|
|
63
|
+
activeChild = child;
|
|
64
|
+
return child;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function makeFakePython(): string {
|
|
68
|
+
const dir = mkdtempSync(path.join(tmpdir(), "linkedclaw-agent-cli-"));
|
|
69
|
+
const fakePython = path.join(dir, "fake-python");
|
|
70
|
+
writeFileSync(fakePython, "#!/bin/sh\nexit 0\n", "utf8");
|
|
71
|
+
chmodSync(fakePython, 0o700);
|
|
72
|
+
return fakePython;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function runHiddenAgent(args: string[]): Promise<FakeChild> {
|
|
76
|
+
const child = makeFakeChild();
|
|
77
|
+
spawnMock.mockReturnValueOnce(child);
|
|
78
|
+
const program = new Command();
|
|
79
|
+
program.exitOverride();
|
|
80
|
+
const { registerAgentCommands } = await import("../src/commands/agent.js");
|
|
81
|
+
registerAgentCommands(program);
|
|
82
|
+
await program.parseAsync(["node", "cli", "agent", "run", ...args]);
|
|
83
|
+
return child;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function runHiddenRotate(args: string[]): Promise<FakeChild | undefined> {
|
|
87
|
+
const child = makeFakeChild();
|
|
88
|
+
spawnMock.mockReturnValueOnce(child);
|
|
89
|
+
const program = new Command();
|
|
90
|
+
program.exitOverride();
|
|
91
|
+
const { registerAgentCommands } = await import("../src/commands/agent.js");
|
|
92
|
+
registerAgentCommands(program);
|
|
93
|
+
await program.parseAsync(["node", "cli", "agent", "rotate-mandate", ...args]);
|
|
94
|
+
return process.exitCode === 1 ? undefined : child;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
describe("agent command packaging", () => {
|
|
98
|
+
it("exposes the owner-agent run command in the npm CLI", () => {
|
|
99
|
+
const top = run(["--help"]);
|
|
100
|
+
expect(top.code).toBe(0);
|
|
101
|
+
expect(top.stdout).toContain("agent");
|
|
102
|
+
|
|
103
|
+
const agent = run(["agent", "--help"]);
|
|
104
|
+
expect(agent.code).toBe(0);
|
|
105
|
+
expect(agent.stdout).toContain("run");
|
|
106
|
+
expect(agent.stdout).toContain("rotate-mandate");
|
|
107
|
+
|
|
108
|
+
const runHelp = run(["agent", "run", "--help"]);
|
|
109
|
+
expect(runHelp.code).toBe(0);
|
|
110
|
+
expect(runHelp.stdout).toContain("--config");
|
|
111
|
+
expect(runHelp.stdout).toContain("--watch");
|
|
112
|
+
expect(runHelp.stdout).toContain("--once");
|
|
113
|
+
expect(runHelp.stdout).toContain("--python-command");
|
|
114
|
+
|
|
115
|
+
const rotateHelp = run(["agent", "rotate-mandate", "--help"]);
|
|
116
|
+
expect(rotateHelp.code).toBe(0);
|
|
117
|
+
expect(rotateHelp.stdout).toContain("--config");
|
|
118
|
+
expect(rotateHelp.stdout).toContain("--old-mandate-id");
|
|
119
|
+
expect(rotateHelp.stdout).toContain("--expires-at");
|
|
120
|
+
expect(rotateHelp.stdout).toContain("--python-command");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("reads LINKEDCLAW_OWNER_AGENT_CONFIG when --config is omitted", async () => {
|
|
124
|
+
const configPath = path.join(mkdtempSync(path.join(tmpdir(), "linkedclaw-agent-cli-")), "config.yaml");
|
|
125
|
+
const fakePython = makeFakePython();
|
|
126
|
+
writeFileSync(configPath, "agent_id: ag_owner\n", "utf8");
|
|
127
|
+
process.env.LINKEDCLAW_OWNER_AGENT_CONFIG = configPath;
|
|
128
|
+
|
|
129
|
+
await runHiddenAgent(["--watch", "debate_1:clg_1", "--once", "--python-command", fakePython]);
|
|
130
|
+
|
|
131
|
+
expect(spawnMock).toHaveBeenCalledWith(fakePython, [
|
|
132
|
+
"-m",
|
|
133
|
+
"linkedclaw.owner_agent.cli",
|
|
134
|
+
"run",
|
|
135
|
+
"--config",
|
|
136
|
+
configPath,
|
|
137
|
+
"--watch",
|
|
138
|
+
"debate_1:clg_1",
|
|
139
|
+
"--once",
|
|
140
|
+
], { stdio: "inherit" });
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("forwards SIGTERM and SIGINT to the Python child", async () => {
|
|
144
|
+
const fakePython = makeFakePython();
|
|
145
|
+
const sigtermChild = await runHiddenAgent(["--once", "--python-command", fakePython]);
|
|
146
|
+
|
|
147
|
+
process.emit("SIGTERM");
|
|
148
|
+
expect(sigtermChild.kill).toHaveBeenCalledWith("SIGTERM");
|
|
149
|
+
sigtermChild.emit("exit", 0, null);
|
|
150
|
+
activeChild = undefined;
|
|
151
|
+
|
|
152
|
+
const sigintChild = await runHiddenAgent(["--once", "--python-command", fakePython]);
|
|
153
|
+
process.emit("SIGINT");
|
|
154
|
+
expect(sigintChild.kill).toHaveBeenCalledWith("SIGINT");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("rotates mandates with env config and forwards optional flags", async () => {
|
|
158
|
+
const configPath = path.join(mkdtempSync(path.join(tmpdir(), "linkedclaw-agent-cli-")), "config.yaml");
|
|
159
|
+
const fakePython = makeFakePython();
|
|
160
|
+
writeFileSync(configPath, "agent_id: ag_owner\n", "utf8");
|
|
161
|
+
process.env.LINKEDCLAW_OWNER_AGENT_CONFIG = configPath;
|
|
162
|
+
|
|
163
|
+
await runHiddenRotate([
|
|
164
|
+
"--old-mandate-id",
|
|
165
|
+
"man_old",
|
|
166
|
+
"--expires-at",
|
|
167
|
+
"2026-05-04T00:00:00Z",
|
|
168
|
+
"--python-command",
|
|
169
|
+
fakePython,
|
|
170
|
+
]);
|
|
171
|
+
|
|
172
|
+
expect(spawnMock).toHaveBeenCalledWith(fakePython, [
|
|
173
|
+
"-m",
|
|
174
|
+
"linkedclaw.owner_agent.cli",
|
|
175
|
+
"rotate-mandate",
|
|
176
|
+
"--config",
|
|
177
|
+
configPath,
|
|
178
|
+
"--old-mandate-id",
|
|
179
|
+
"man_old",
|
|
180
|
+
"--expires-at",
|
|
181
|
+
"2026-05-04T00:00:00Z",
|
|
182
|
+
], { stdio: "inherit" });
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("rejects invalid rotate mandate options as JSON", async () => {
|
|
186
|
+
const configPath = path.join(mkdtempSync(path.join(tmpdir(), "linkedclaw-agent-cli-")), "config.yaml");
|
|
187
|
+
const fakePython = makeFakePython();
|
|
188
|
+
writeFileSync(configPath, "agent_id: ag_owner\n", "utf8");
|
|
189
|
+
const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
|
190
|
+
|
|
191
|
+
await runHiddenRotate([
|
|
192
|
+
"--config",
|
|
193
|
+
configPath,
|
|
194
|
+
"--old-mandate-id",
|
|
195
|
+
"",
|
|
196
|
+
"--python-command",
|
|
197
|
+
fakePython,
|
|
198
|
+
]);
|
|
199
|
+
|
|
200
|
+
expect(spawnMock).not.toHaveBeenCalled();
|
|
201
|
+
expect(stderrSpy).toHaveBeenCalled();
|
|
202
|
+
const payload = JSON.parse(String(stderrSpy.mock.calls[0][0]));
|
|
203
|
+
expect(payload.error).toBe("invalid_agent_rotate_mandate_option");
|
|
204
|
+
expect(payload.message).toContain("--old-mandate-id");
|
|
205
|
+
stderrSpy.mockRestore();
|
|
206
|
+
});
|
|
207
|
+
});
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { ApiError } from "../src/errors.js";
|
|
3
|
+
import { makeArenaApi } from "../src/arena/api.js";
|
|
4
|
+
|
|
5
|
+
function jsonResponse(body: unknown, init: { ok?: boolean; status?: number } = {}) {
|
|
6
|
+
return {
|
|
7
|
+
ok: init.ok ?? true,
|
|
8
|
+
status: init.status ?? 200,
|
|
9
|
+
json: vi.fn(async () => body),
|
|
10
|
+
} as any;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe("makeArenaApi", () => {
|
|
14
|
+
const fetchMock = vi.fn();
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
fetchMock.mockResolvedValue(jsonResponse({ ok: true }));
|
|
18
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
vi.unstubAllGlobals();
|
|
23
|
+
vi.restoreAllMocks();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("register posts to the contestant registration route", async () => {
|
|
27
|
+
const api = makeArenaApi("http://services.test/", "k");
|
|
28
|
+
await api.register({
|
|
29
|
+
contestant_agent_id: "agt_1",
|
|
30
|
+
mandate_id: "mand_1",
|
|
31
|
+
category: { topic: "code", subtopic: "rust" },
|
|
32
|
+
});
|
|
33
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
34
|
+
"http://services.test/api/v1/arena/contestants/register",
|
|
35
|
+
expect.objectContaining({ method: "POST" }),
|
|
36
|
+
);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("createTournamentArena posts the exact manifest with idempotency header", async () => {
|
|
40
|
+
const api = makeArenaApi("http://services.test/", "k");
|
|
41
|
+
const manifest = {
|
|
42
|
+
mode: "tournament" as const,
|
|
43
|
+
category: { topic: "code", subtopic: "rust" },
|
|
44
|
+
config: {
|
|
45
|
+
bracket_shape: "single_elim",
|
|
46
|
+
child_mode: "task_submission",
|
|
47
|
+
advancement_rule: "top_k(1)",
|
|
48
|
+
bracket_size: 4,
|
|
49
|
+
seeding: "unseeded",
|
|
50
|
+
child_config: { prompt: "ship a patch" },
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
await api.createTournamentArena(manifest, { idempotencyKey: "idem_123" });
|
|
54
|
+
const [, init] = fetchMock.mock.calls[0];
|
|
55
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
56
|
+
"http://services.test/api/v1/arena/arenas",
|
|
57
|
+
expect.objectContaining({ method: "POST" }),
|
|
58
|
+
);
|
|
59
|
+
expect(init.headers).toMatchObject({
|
|
60
|
+
Authorization: "Bearer k",
|
|
61
|
+
"X-CSRF-Token": "k",
|
|
62
|
+
"Idempotency-Key": "idem_123",
|
|
63
|
+
});
|
|
64
|
+
expect(JSON.parse(init.body)).toEqual(manifest);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("offers uses GET and accepts pending matches", async () => {
|
|
68
|
+
fetchMock.mockResolvedValueOnce(jsonResponse({
|
|
69
|
+
offers: [],
|
|
70
|
+
pending_matches: [{ match_id: "amch_123", prompt: "ship it" }],
|
|
71
|
+
}));
|
|
72
|
+
const api = makeArenaApi("http://services.test", "k");
|
|
73
|
+
const response = await api.listOffers();
|
|
74
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
75
|
+
"http://services.test/api/v1/arena/offers",
|
|
76
|
+
expect.objectContaining({ method: "GET" }),
|
|
77
|
+
);
|
|
78
|
+
expect(response.pending_matches?.[0].match_id).toBe("amch_123");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("acceptOffer posts to the offer accept route", async () => {
|
|
82
|
+
const api = makeArenaApi("http://services.test", "k");
|
|
83
|
+
await api.acceptOffer("aoff_123");
|
|
84
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
85
|
+
"http://services.test/api/v1/arena/offers/aoff_123/accept",
|
|
86
|
+
expect.objectContaining({ method: "POST" }),
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("submit posts to the submissions route and strips only client-only submission_hash", async () => {
|
|
91
|
+
const api = makeArenaApi("http://services.test", "k");
|
|
92
|
+
await api.submit("arn_123", {
|
|
93
|
+
offer_id: "aoff_123",
|
|
94
|
+
raw_content: "hello",
|
|
95
|
+
match_id: "amch_123",
|
|
96
|
+
seq: 1,
|
|
97
|
+
submission_hash: "sha256:" + "a".repeat(64),
|
|
98
|
+
});
|
|
99
|
+
const [, init] = fetchMock.mock.calls[0];
|
|
100
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
101
|
+
"http://services.test/api/v1/arena/arenas/arn_123/submissions",
|
|
102
|
+
expect.objectContaining({ method: "POST" }),
|
|
103
|
+
);
|
|
104
|
+
expect(JSON.parse(init.body)).toEqual({
|
|
105
|
+
offer_id: "aoff_123",
|
|
106
|
+
raw_content: "hello",
|
|
107
|
+
match_id: "amch_123",
|
|
108
|
+
seq: 1,
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("commitJuror posts to the juror commit route with the exact body", async () => {
|
|
113
|
+
const api = makeArenaApi("http://services.test", "k");
|
|
114
|
+
await api.commitJuror("arn_123", {
|
|
115
|
+
juror_agent_id: "agt_juror",
|
|
116
|
+
mandate_id: "mnd_123",
|
|
117
|
+
});
|
|
118
|
+
const [, init] = fetchMock.mock.calls[0];
|
|
119
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
120
|
+
"http://services.test/api/v1/arena/arenas/arn_123/jurors/commit",
|
|
121
|
+
expect.objectContaining({ method: "POST" }),
|
|
122
|
+
);
|
|
123
|
+
expect(JSON.parse(init.body)).toEqual({
|
|
124
|
+
juror_agent_id: "agt_juror",
|
|
125
|
+
mandate_id: "mnd_123",
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("voteTask posts to the task juror vote route with the exact body", async () => {
|
|
130
|
+
const api = makeArenaApi("http://services.test", "k");
|
|
131
|
+
await api.voteTask("arn_123", {
|
|
132
|
+
submission_id: "asub_123",
|
|
133
|
+
score: 0.75,
|
|
134
|
+
rationale_ref: "note://1",
|
|
135
|
+
});
|
|
136
|
+
const [, init] = fetchMock.mock.calls[0];
|
|
137
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
138
|
+
"http://services.test/api/v1/arena/arenas/arn_123/juror-votes",
|
|
139
|
+
expect.objectContaining({ method: "POST" }),
|
|
140
|
+
);
|
|
141
|
+
expect(JSON.parse(init.body)).toEqual({
|
|
142
|
+
submission_id: "asub_123",
|
|
143
|
+
score: 0.75,
|
|
144
|
+
rationale_ref: "note://1",
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("voteMatch posts to the match juror vote route with the exact body", async () => {
|
|
149
|
+
const api = makeArenaApi("http://services.test", "k");
|
|
150
|
+
await api.voteMatch("arn_123", "amch_123", {
|
|
151
|
+
outcome: "a",
|
|
152
|
+
rationale_ref: "note://2",
|
|
153
|
+
});
|
|
154
|
+
const [, init] = fetchMock.mock.calls[0];
|
|
155
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
156
|
+
"http://services.test/api/v1/arena/arenas/arn_123/matches/amch_123/juror-votes",
|
|
157
|
+
expect.objectContaining({ method: "POST" }),
|
|
158
|
+
);
|
|
159
|
+
expect(JSON.parse(init.body)).toEqual({
|
|
160
|
+
outcome: "a",
|
|
161
|
+
rationale_ref: "note://2",
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("listArenas registered uses the registered query parameter", async () => {
|
|
166
|
+
const api = makeArenaApi("http://services.test", "k");
|
|
167
|
+
await api.listArenas({ registered: true });
|
|
168
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
169
|
+
"http://services.test/api/v1/arena/arenas?registered=true",
|
|
170
|
+
expect.objectContaining({ method: "GET" }),
|
|
171
|
+
);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("getLeaderboard hits the leaderboard route", async () => {
|
|
175
|
+
const api = makeArenaApi("http://services.test", "k");
|
|
176
|
+
await api.getLeaderboard("arn_123");
|
|
177
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
178
|
+
"http://services.test/api/v1/arena/arenas/arn_123/leaderboard",
|
|
179
|
+
expect.objectContaining({ method: "GET" }),
|
|
180
|
+
);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("getCategoryLeaderboard hits the category leaderboard route", async () => {
|
|
184
|
+
const api = makeArenaApi("http://services.test", "k");
|
|
185
|
+
await api.getCategoryLeaderboard({ topic: "coding", subtopic: "patches" }, "match");
|
|
186
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
187
|
+
"http://services.test/api/v1/arena/leaderboard?category_topic=coding&category_subtopic=patches&mode=match",
|
|
188
|
+
expect.objectContaining({ method: "GET" }),
|
|
189
|
+
);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("sends authorization and content-type headers", async () => {
|
|
193
|
+
const api = makeArenaApi("http://services.test", "k");
|
|
194
|
+
await api.listOffers();
|
|
195
|
+
const [, init] = fetchMock.mock.calls[0];
|
|
196
|
+
expect(init.headers).toMatchObject({
|
|
197
|
+
Authorization: "Bearer k",
|
|
198
|
+
"Content-Type": "application/json",
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("turns non-2xx responses into ApiError", async () => {
|
|
203
|
+
fetchMock.mockResolvedValueOnce(jsonResponse({ detail: "arena_not_found" }, { ok: false, status: 404 }));
|
|
204
|
+
const api = makeArenaApi("http://services.test", "k");
|
|
205
|
+
await expect(api.getLeaderboard("missing")).rejects.toMatchObject({
|
|
206
|
+
name: "ApiError",
|
|
207
|
+
status: 404,
|
|
208
|
+
detail: "arena_not_found",
|
|
209
|
+
} satisfies Partial<ApiError>);
|
|
210
|
+
});
|
|
211
|
+
});
|