mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-19 11:51:35 +00:00
Centralized managed worktrees under <state-dir>/worktrees/<repo-fingerprint>/<name> with branch-per-task (openclaw/<name>), .worktreeinclude provisioning, an optional .openclaw/worktree-setup.sh repo hook, and a SQLite registry in the shared state DB. Removal always snapshots the tree (untracked included, gitignored excluded) to refs/openclaw/snapshots/<id>; restore rebuilds the branch at the original commit with the snapshot content as uncommitted state. Lossless run-end cleanup, 7-day idle GC for run-owned worktrees (manual exempt), orphan reconciliation, 30-day snapshot retention. Surfaces: worktrees.* gateway RPC (operator.admin mutations), openclaw worktrees CLI, Control UI page, plugin-SDK facade + Workboard kind:"worktree" materialization. E2E-verified on Testbox: full create->work->remove->restore->gc lifecycle.
426 lines
15 KiB
TypeScript
426 lines
15 KiB
TypeScript
// Workboard tests cover gateway plugin behavior.
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import type { OpenClawPluginApi } from "../api.js";
|
|
import { registerWorkboardGatewayMethods } from "./gateway.js";
|
|
import { WorkboardStore, type PersistedWorkboardCard, type WorkboardKeyedStore } from "./store.js";
|
|
|
|
function createMemoryStore<T = PersistedWorkboardCard>(): WorkboardKeyedStore<T> {
|
|
const entries = new Map<string, T>();
|
|
return {
|
|
async register(key, value) {
|
|
entries.set(key, value);
|
|
},
|
|
async lookup(key) {
|
|
return entries.get(key);
|
|
},
|
|
async delete(key) {
|
|
return entries.delete(key);
|
|
},
|
|
async entries() {
|
|
return [...entries].flatMap(([key, value]) => (value ? [{ key, value }] : []));
|
|
},
|
|
};
|
|
}
|
|
|
|
describe("workboard gateway methods", () => {
|
|
it("registers CRUD methods with read/write scopes", async () => {
|
|
type RegisteredMethod = {
|
|
handler: Parameters<OpenClawPluginApi["registerGatewayMethod"]>[1];
|
|
opts: Parameters<OpenClawPluginApi["registerGatewayMethod"]>[2];
|
|
};
|
|
const methods = new Map<string, RegisteredMethod>();
|
|
const api = {
|
|
runtime: {
|
|
state: {
|
|
openKeyedStore: vi.fn(() => createMemoryStore()),
|
|
},
|
|
},
|
|
registerGatewayMethod: vi.fn(
|
|
(method: string, handler: RegisteredMethod["handler"], opts: RegisteredMethod["opts"]) => {
|
|
methods.set(method, { handler, opts });
|
|
},
|
|
),
|
|
} as unknown as OpenClawPluginApi;
|
|
|
|
registerWorkboardGatewayMethods({ api, store: new WorkboardStore(createMemoryStore()) });
|
|
|
|
expect([...methods.keys()]).toEqual([
|
|
"workboard.cards.list",
|
|
"workboard.cards.create",
|
|
"workboard.cards.update",
|
|
"workboard.cards.move",
|
|
"workboard.cards.delete",
|
|
"workboard.cards.comment",
|
|
"workboard.cards.link",
|
|
"workboard.cards.linkDependency",
|
|
"workboard.cards.proof",
|
|
"workboard.cards.artifact",
|
|
"workboard.cards.claim",
|
|
"workboard.cards.heartbeat",
|
|
"workboard.cards.release",
|
|
"workboard.cards.promote",
|
|
"workboard.cards.reassign",
|
|
"workboard.cards.reclaim",
|
|
"workboard.cards.complete",
|
|
"workboard.cards.block",
|
|
"workboard.cards.unblock",
|
|
"workboard.cards.bulk",
|
|
"workboard.cards.diagnostics",
|
|
"workboard.cards.diagnostics.refresh",
|
|
"workboard.cards.dispatch",
|
|
"workboard.boards.list",
|
|
"workboard.boards.upsert",
|
|
"workboard.boards.archive",
|
|
"workboard.boards.delete",
|
|
"workboard.cards.stats",
|
|
"workboard.cards.runs",
|
|
"workboard.cards.specify",
|
|
"workboard.cards.decompose",
|
|
"workboard.notifications.subscribe",
|
|
"workboard.notifications.list",
|
|
"workboard.notifications.delete",
|
|
"workboard.notifications.events",
|
|
"workboard.notifications.advance",
|
|
"workboard.cards.attachments.list",
|
|
"workboard.cards.attachments.get",
|
|
"workboard.cards.attachments.add",
|
|
"workboard.cards.attachments.delete",
|
|
"workboard.cards.workerLog",
|
|
"workboard.cards.protocolViolation",
|
|
"workboard.cards.archive",
|
|
"workboard.cards.export",
|
|
]);
|
|
expect(methods.get("workboard.cards.list")?.opts).toEqual({ scope: "operator.read" });
|
|
expect(methods.get("workboard.cards.diagnostics")?.opts).toEqual({ scope: "operator.read" });
|
|
expect(methods.get("workboard.cards.diagnostics.refresh")?.opts).toEqual({
|
|
scope: "operator.write",
|
|
});
|
|
expect(methods.get("workboard.cards.export")?.opts).toEqual({ scope: "operator.read" });
|
|
expect(methods.get("workboard.cards.create")?.opts).toEqual({ scope: "operator.write" });
|
|
expect(methods.get("workboard.cards.runs")?.opts).toEqual({ scope: "operator.read" });
|
|
expect(methods.get("workboard.cards.attachments.get")?.opts).toEqual({
|
|
scope: "operator.read",
|
|
});
|
|
expect(methods.get("workboard.cards.attachments.add")?.opts).toEqual({
|
|
scope: "operator.write",
|
|
});
|
|
expect(methods.get("workboard.boards.upsert")?.opts).toEqual({ scope: "operator.write" });
|
|
expect(methods.get("workboard.notifications.list")?.opts).toEqual({
|
|
scope: "operator.read",
|
|
});
|
|
expect(methods.get("workboard.notifications.events")?.opts).toEqual({
|
|
scope: "operator.read",
|
|
});
|
|
expect(methods.get("workboard.notifications.advance")?.opts).toEqual({
|
|
scope: "operator.write",
|
|
});
|
|
|
|
const createHandler = methods.get("workboard.cards.create")?.handler;
|
|
const listHandler = methods.get("workboard.cards.list")?.handler;
|
|
const createRespond = vi.fn();
|
|
await createHandler?.({
|
|
params: { title: "Investigate queue drift", priority: "urgent" },
|
|
respond: createRespond,
|
|
} as never);
|
|
expect(createRespond.mock.calls[0]?.[0]).toBe(true);
|
|
|
|
const listRespond = vi.fn();
|
|
await listHandler?.({ params: {}, respond: listRespond } as never);
|
|
expect(listRespond.mock.calls[0]?.[1]).toMatchObject({
|
|
cards: [expect.objectContaining({ title: "Investigate queue drift" })],
|
|
});
|
|
|
|
const eventsRespond = vi.fn();
|
|
await methods.get("workboard.notifications.events")?.handler({
|
|
params: { advance: true },
|
|
respond: eventsRespond,
|
|
} as never);
|
|
expect(eventsRespond.mock.calls[0]?.[0]).toBe(false);
|
|
expect(eventsRespond.mock.calls[0]?.[2]?.message).toContain("workboard.notifications.advance");
|
|
});
|
|
|
|
it("stores metadata updates through dedicated card methods", async () => {
|
|
type RegisteredMethod = {
|
|
handler: Parameters<OpenClawPluginApi["registerGatewayMethod"]>[1];
|
|
opts: Parameters<OpenClawPluginApi["registerGatewayMethod"]>[2];
|
|
};
|
|
const methods = new Map<string, RegisteredMethod>();
|
|
const api = {
|
|
runtime: {
|
|
state: {
|
|
openKeyedStore: vi.fn(() => createMemoryStore()),
|
|
},
|
|
},
|
|
registerGatewayMethod: vi.fn(
|
|
(method: string, handler: RegisteredMethod["handler"], opts: RegisteredMethod["opts"]) => {
|
|
methods.set(method, { handler, opts });
|
|
},
|
|
),
|
|
} as unknown as OpenClawPluginApi;
|
|
|
|
registerWorkboardGatewayMethods({ api, store: new WorkboardStore(createMemoryStore()) });
|
|
|
|
const createRespond = vi.fn();
|
|
await methods.get("workboard.cards.create")?.handler({
|
|
params: { title: "Carry metadata" },
|
|
respond: createRespond,
|
|
} as never);
|
|
const cardId = createRespond.mock.calls[0]?.[1]?.card.id;
|
|
|
|
const commentRespond = vi.fn();
|
|
await methods.get("workboard.cards.comment")?.handler({
|
|
params: { id: cardId, body: "Waiting on CI" },
|
|
respond: commentRespond,
|
|
} as never);
|
|
|
|
expect(commentRespond.mock.calls[0]?.[0]).toBe(true);
|
|
expect(commentRespond.mock.calls[0]?.[1]).toMatchObject({
|
|
card: {
|
|
metadata: {
|
|
comments: [expect.objectContaining({ body: "Waiting on CI" })],
|
|
},
|
|
events: expect.arrayContaining([expect.objectContaining({ kind: "comment_added" })]),
|
|
},
|
|
});
|
|
});
|
|
|
|
it("validates labels from comma-separated gateway input", async () => {
|
|
type RegisteredMethod = {
|
|
handler: Parameters<OpenClawPluginApi["registerGatewayMethod"]>[1];
|
|
opts: Parameters<OpenClawPluginApi["registerGatewayMethod"]>[2];
|
|
};
|
|
const methods = new Map<string, RegisteredMethod>();
|
|
const api = {
|
|
runtime: {
|
|
state: {
|
|
openKeyedStore: vi.fn(() => createMemoryStore()),
|
|
},
|
|
},
|
|
registerGatewayMethod: vi.fn(
|
|
(method: string, handler: RegisteredMethod["handler"], opts: RegisteredMethod["opts"]) => {
|
|
methods.set(method, { handler, opts });
|
|
},
|
|
),
|
|
} as unknown as OpenClawPluginApi;
|
|
|
|
registerWorkboardGatewayMethods({ api, store: new WorkboardStore(createMemoryStore()) });
|
|
|
|
const createHandler = methods.get("workboard.cards.create")?.handler;
|
|
const respond = vi.fn();
|
|
await createHandler?.({
|
|
params: { title: "Check labels", labels: `valid, ${"x".repeat(41)}` },
|
|
respond,
|
|
} as never);
|
|
|
|
expect(respond.mock.calls[0]?.[0]).toBe(false);
|
|
expect(respond.mock.calls[0]?.[2]).toMatchObject({
|
|
message: "labels must be 40 characters or fewer.",
|
|
});
|
|
});
|
|
|
|
it("dispatches workboard cards when gateway params are omitted", async () => {
|
|
type RegisteredMethod = {
|
|
handler: Parameters<OpenClawPluginApi["registerGatewayMethod"]>[1];
|
|
opts: Parameters<OpenClawPluginApi["registerGatewayMethod"]>[2];
|
|
};
|
|
const methods = new Map<string, RegisteredMethod>();
|
|
const run = vi.fn().mockResolvedValue({ runId: "run-card" });
|
|
const api = {
|
|
runtime: {
|
|
state: {
|
|
openKeyedStore: vi.fn(() => createMemoryStore()),
|
|
},
|
|
subagent: { run },
|
|
},
|
|
registerGatewayMethod: vi.fn(
|
|
(method: string, handler: RegisteredMethod["handler"], opts: RegisteredMethod["opts"]) => {
|
|
methods.set(method, { handler, opts });
|
|
},
|
|
),
|
|
} as unknown as OpenClawPluginApi;
|
|
const store = new WorkboardStore(createMemoryStore());
|
|
const card = await store.create({
|
|
title: "Ready worker",
|
|
status: "ready",
|
|
priority: "urgent",
|
|
});
|
|
|
|
registerWorkboardGatewayMethods({ api, store });
|
|
|
|
const respond = vi.fn();
|
|
await methods.get("workboard.cards.dispatch")?.handler({ respond } as never);
|
|
|
|
expect(respond.mock.calls[0]?.[0]).toBe(true);
|
|
expect(respond.mock.calls[0]?.[1]).toMatchObject({
|
|
started: [expect.objectContaining({ cardId: card.id, runId: "run-card" })],
|
|
});
|
|
expect(run).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
sessionKey: `subagent:workboard-default-${card.id}`,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("requires admin scope for managed-worktree dispatch", async () => {
|
|
type RegisteredMethod = {
|
|
handler: Parameters<OpenClawPluginApi["registerGatewayMethod"]>[1];
|
|
opts: Parameters<OpenClawPluginApi["registerGatewayMethod"]>[2];
|
|
};
|
|
const methods = new Map<string, RegisteredMethod>();
|
|
const run = vi.fn().mockResolvedValue({ runId: "run-card" });
|
|
const createWorktree = vi.fn().mockResolvedValue({
|
|
id: "managed-id",
|
|
path: "/state/worktrees/fingerprint/wb-card",
|
|
branch: "openclaw/wb-card",
|
|
});
|
|
const api = {
|
|
runtime: {
|
|
subagent: { run },
|
|
worktrees: {
|
|
create: createWorktree,
|
|
release: vi.fn(),
|
|
removeIfLossless: vi.fn(),
|
|
},
|
|
},
|
|
registerGatewayMethod: vi.fn(
|
|
(method: string, handler: RegisteredMethod["handler"], opts: RegisteredMethod["opts"]) => {
|
|
methods.set(method, { handler, opts });
|
|
},
|
|
),
|
|
} as unknown as OpenClawPluginApi;
|
|
const store = new WorkboardStore(createMemoryStore());
|
|
const denied = await store.create({
|
|
title: "Denied checkout",
|
|
status: "ready",
|
|
workspace: { kind: "worktree", path: "/repo-denied" },
|
|
});
|
|
registerWorkboardGatewayMethods({ api, store });
|
|
const handler = methods.get("workboard.cards.dispatch")?.handler;
|
|
|
|
const deniedRespond = vi.fn();
|
|
await handler?.({
|
|
client: { connect: { scopes: ["operator.write"] } },
|
|
respond: deniedRespond,
|
|
} as never);
|
|
|
|
expect(createWorktree).not.toHaveBeenCalled();
|
|
expect(deniedRespond.mock.calls[0]?.[1]).toMatchObject({
|
|
startFailures: [
|
|
expect.objectContaining({
|
|
cardId: denied.id,
|
|
error: "managed worktree dispatch requires operator.admin",
|
|
}),
|
|
],
|
|
});
|
|
await expect(store.get(denied.id)).resolves.toMatchObject({ status: "ready" });
|
|
await store.update(denied.id, { status: "blocked" });
|
|
|
|
const allowed = await store.create({
|
|
title: "Allowed checkout",
|
|
status: "ready",
|
|
workspace: { kind: "worktree", path: "/repo-allowed" },
|
|
});
|
|
await handler?.({
|
|
client: { connect: { scopes: ["operator.admin"] } },
|
|
respond: vi.fn(),
|
|
} as never);
|
|
|
|
expect(createWorktree).toHaveBeenCalledWith(
|
|
expect.objectContaining({ repoRoot: "/repo-allowed", ownerId: allowed.id }),
|
|
);
|
|
expect(run).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("claims, heartbeats, and bulk-updates cards through gateway methods", async () => {
|
|
type RegisteredMethod = {
|
|
handler: Parameters<OpenClawPluginApi["registerGatewayMethod"]>[1];
|
|
opts: Parameters<OpenClawPluginApi["registerGatewayMethod"]>[2];
|
|
};
|
|
const methods = new Map<string, RegisteredMethod>();
|
|
const api = {
|
|
runtime: {
|
|
state: {
|
|
openKeyedStore: vi.fn(() => createMemoryStore()),
|
|
},
|
|
},
|
|
registerGatewayMethod: vi.fn(
|
|
(method: string, handler: RegisteredMethod["handler"], opts: RegisteredMethod["opts"]) => {
|
|
methods.set(method, { handler, opts });
|
|
},
|
|
),
|
|
} as unknown as OpenClawPluginApi;
|
|
|
|
registerWorkboardGatewayMethods({ api, store: new WorkboardStore(createMemoryStore()) });
|
|
|
|
const createRespond = vi.fn();
|
|
await methods.get("workboard.cards.create")?.handler({
|
|
params: { title: "Claim me" },
|
|
respond: createRespond,
|
|
} as never);
|
|
const cardId = createRespond.mock.calls[0]?.[1]?.card.id;
|
|
|
|
const claimRespond = vi.fn();
|
|
await methods.get("workboard.cards.claim")?.handler({
|
|
params: { id: cardId, ownerId: "main" },
|
|
respond: claimRespond,
|
|
} as never);
|
|
expect(claimRespond.mock.calls[0]?.[1]).toMatchObject({
|
|
card: { status: "running", metadata: { claim: { ownerId: "main" } } },
|
|
token: expect.any(String),
|
|
});
|
|
|
|
const heartbeatRespond = vi.fn();
|
|
await methods.get("workboard.cards.heartbeat")?.handler({
|
|
params: { id: cardId, ownerId: "main", note: "alive" },
|
|
respond: heartbeatRespond,
|
|
} as never);
|
|
expect(heartbeatRespond.mock.calls[0]?.[1]).toMatchObject({
|
|
card: { metadata: { comments: [expect.objectContaining({ body: "alive" })] } },
|
|
});
|
|
|
|
const bulkRespond = vi.fn();
|
|
await methods.get("workboard.cards.bulk")?.handler({
|
|
params: { ids: [cardId], patch: { priority: "urgent" } },
|
|
respond: bulkRespond,
|
|
} as never);
|
|
expect(bulkRespond.mock.calls[0]?.[1]).toMatchObject({
|
|
cards: [expect.objectContaining({ priority: "urgent" })],
|
|
});
|
|
|
|
const completeRespond = vi.fn();
|
|
await methods.get("workboard.cards.complete")?.handler({
|
|
params: { id: cardId, summary: "Operator closed it." },
|
|
respond: completeRespond,
|
|
} as never);
|
|
expect(completeRespond.mock.calls[0]?.[1]).toMatchObject({
|
|
card: {
|
|
status: "done",
|
|
metadata: {
|
|
comments: expect.arrayContaining([
|
|
expect.objectContaining({ body: "Operator closed it." }),
|
|
]),
|
|
},
|
|
},
|
|
});
|
|
|
|
const blockedCreateRespond = vi.fn();
|
|
await methods.get("workboard.cards.create")?.handler({
|
|
params: { title: "Block me" },
|
|
respond: blockedCreateRespond,
|
|
} as never);
|
|
const blockedCardId = blockedCreateRespond.mock.calls[0]?.[1]?.card.id;
|
|
await methods.get("workboard.cards.claim")?.handler({
|
|
params: { id: blockedCardId, ownerId: "main" },
|
|
respond: vi.fn(),
|
|
} as never);
|
|
const blockRespond = vi.fn();
|
|
await methods.get("workboard.cards.block")?.handler({
|
|
params: { id: blockedCardId, reason: "Operator blocked it." },
|
|
respond: blockRespond,
|
|
} as never);
|
|
expect(blockRespond.mock.calls[0]?.[1]).toMatchObject({
|
|
card: { status: "blocked" },
|
|
});
|
|
});
|
|
});
|