fix(agentstack): assignTask refuses tasks in a terminal status (#47)

This commit is contained in:
6D0N9 2026-06-14 07:54:08 +02:00 committed by GitHub
parent a4cc229120
commit 90fffb124e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 332 additions and 309 deletions

View file

@ -1,74 +1,74 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { validateManifest } from "@logicsrc/plugin-core"; import { validateManifest } from "@logicsrc/plugin-core";
import { import {
AgentStack, AgentStack,
agentDid, agentDid,
agentStackManifest, agentStackManifest,
agentStackPlugin, agentStackPlugin,
makeDid, makeDid,
parseDid, parseDid,
userDid userDid
} from "./index.js"; } from "./index.js";
import type { AgentProfile } from "./types.js"; import type { AgentProfile } from "./types.js";
const owner = userDid("123"); const owner = userDid("123");
const agent: AgentProfile = { const agent: AgentProfile = {
did: agentDid("abc"), did: agentDid("abc"),
name: "Build Agent", name: "Build Agent",
sourceApp: "commandboard.run", sourceApp: "commandboard.run",
supportedProtocols: ["logicsrc/1"] supportedProtocols: ["logicsrc/1"]
}; };
describe("DID helpers", () => { describe("DID helpers", () => {
it("builds and parses CoinPay DIDs", () => { it("builds and parses CoinPay DIDs", () => {
expect(makeDid("user", "123")).toBe("did:coinpay:user:123"); expect(makeDid("user", "123")).toBe("did:coinpay:user:123");
expect(parseDid(owner)).toEqual({ kind: "user", id: "123" }); expect(parseDid(owner)).toEqual({ kind: "user", id: "123" });
expect(parseDid(agentDid("abc"))).toEqual({ kind: "agent", id: "abc" }); expect(parseDid(agentDid("abc"))).toEqual({ kind: "agent", id: "abc" });
}); });
it("rejects non-CoinPay DIDs", () => { it("rejects non-CoinPay DIDs", () => {
expect(parseDid("did:web:example.com")).toBeNull(); expect(parseDid("did:web:example.com")).toBeNull();
expect(parseDid("did:coinpay:bot:abc")).toBeNull(); expect(parseDid("did:coinpay:bot:abc")).toBeNull();
}); });
}); });
describe("AgentStack manifest", () => { describe("AgentStack manifest", () => {
it("is a valid LogicSRC plugin manifest", () => { it("is a valid LogicSRC plugin manifest", () => {
expect(() => validateManifest(agentStackManifest)).not.toThrow(); expect(() => validateManifest(agentStackManifest)).not.toThrow();
expect(agentStackPlugin.manifest.id).toBe("agentstack"); expect(agentStackPlugin.manifest.id).toBe("agentstack");
}); });
}); });
describe("AgentStack coordinator", () => { describe("AgentStack coordinator", () => {
it("creates pending tasks and queues assigned ones", () => { it("creates pending tasks and queues assigned ones", () => {
const stack = new AgentStack(); const stack = new AgentStack();
const pending = stack.createTask({ ownerDid: owner, sourceApp: "ugig.net", title: "Crawl site" }); const pending = stack.createTask({ ownerDid: owner, sourceApp: "ugig.net", title: "Crawl site" });
expect(pending.status).toBe("pending"); expect(pending.status).toBe("pending");
stack.registerAgent(agent); stack.registerAgent(agent);
const assigned = stack.createTask({ const assigned = stack.createTask({
ownerDid: owner, ownerDid: owner,
sourceApp: "ugig.net", sourceApp: "ugig.net",
title: "Crawl site 2", title: "Crawl site 2",
assigneeDid: agent.did assigneeDid: agent.did
}); });
expect(assigned.status).toBe("queued"); expect(assigned.status).toBe("queued");
}); });
it("moves a task through its lifecycle and binds reputation", () => { it("moves a task through its lifecycle and binds reputation", () => {
const stack = new AgentStack(); const stack = new AgentStack();
stack.registerAgent(agent); stack.registerAgent(agent);
const task = stack.createTask({ ownerDid: owner, sourceApp: "sh1pt.com", title: "Ship build" }); const task = stack.createTask({ ownerDid: owner, sourceApp: "sh1pt.com", title: "Ship build" });
stack.assignTask(task.id, agent.did); stack.assignTask(task.id, agent.did);
stack.updateTaskStatus(task.id, "running"); stack.updateTaskStatus(task.id, "running");
const done = stack.updateTaskStatus(task.id, "complete", { reputationEventId: "rep_1" }); const done = stack.updateTaskStatus(task.id, "complete", { reputationEventId: "rep_1" });
expect(done.status).toBe("complete"); expect(done.status).toBe("complete");
expect(done.assigneeDid).toBe(agent.did); expect(done.assigneeDid).toBe(agent.did);
expect(done.reputationEventId).toBe("rep_1"); expect(done.reputationEventId).toBe("rep_1");
}); });
it("refuses to transition out of a terminal status", () => { it("refuses to transition out of a terminal status", () => {
const stack = new AgentStack(); const stack = new AgentStack();
const task = stack.createTask({ ownerDid: owner, sourceApp: "qaaas.dev", title: "Test run" }); const task = stack.createTask({ ownerDid: owner, sourceApp: "qaaas.dev", title: "Test run" });
@ -76,31 +76,51 @@ describe("AgentStack coordinator", () => {
expect(() => stack.updateTaskStatus(task.id, "running")).toThrow(/cancelled/); expect(() => stack.updateTaskStatus(task.id, "running")).toThrow(/cancelled/);
}); });
it("refuses to assign a task that is already in a terminal status", () => {
const stack = new AgentStack();
stack.registerAgent(agent);
const other: AgentProfile = { ...agent, did: agentDid("xyz") };
stack.registerAgent(other);
const task = stack.createTask({
ownerDid: owner,
sourceApp: "ugig.net",
title: "Paid task",
paymentIntentId: "pi_1",
escrowId: "esc_1"
});
stack.assignTask(task.id, agent.did);
stack.updateTaskStatus(task.id, "complete", { reputationEventId: "rep_1" });
expect(() => stack.assignTask(task.id, other.did)).toThrow(/already complete and cannot be assigned/);
expect(stack.getTask(task.id)?.assigneeDid).toBe(agent.did);
expect(stack.getTask(task.id)?.status).toBe("complete");
});
it("records delegation grants and emits events", () => { it("records delegation grants and emits events", () => {
const stack = new AgentStack(); const stack = new AgentStack();
const listener = vi.fn(); const listener = vi.fn();
stack.on(listener); stack.on(listener);
stack.registerAgent(agent); stack.registerAgent(agent);
const grant = stack.delegate(owner, agent.did, ["tasks:create"]); const grant = stack.delegate(owner, agent.did, ["tasks:create"]);
expect(grant.ownerDid).toBe(owner); expect(grant.ownerDid).toBe(owner);
expect(grant.agentDid).toBe(agent.did); expect(grant.agentDid).toBe(agent.did);
expect(listener).toHaveBeenCalledWith(expect.objectContaining({ type: "agent.registered" })); expect(listener).toHaveBeenCalledWith(expect.objectContaining({ type: "agent.registered" }));
expect(listener).toHaveBeenCalledWith(expect.objectContaining({ type: "delegation.granted" })); expect(listener).toHaveBeenCalledWith(expect.objectContaining({ type: "delegation.granted" }));
}); });
it("rejects unknown agents and invalid DIDs", () => { it("rejects unknown agents and invalid DIDs", () => {
const stack = new AgentStack(); const stack = new AgentStack();
expect(() => stack.createTask({ ownerDid: "nope", sourceApp: "x", title: "t" })).toThrow(); expect(() => stack.createTask({ ownerDid: "nope", sourceApp: "x", title: "t" })).toThrow();
const task = stack.createTask({ ownerDid: owner, sourceApp: "x", title: "t" }); const task = stack.createTask({ ownerDid: owner, sourceApp: "x", title: "t" });
expect(() => stack.assignTask(task.id, agentDid("ghost"))).toThrow(/Unknown agent/); expect(() => stack.assignTask(task.id, agentDid("ghost"))).toThrow(/Unknown agent/);
}); });
it("filters tasks in snapshots", () => { it("filters tasks in snapshots", () => {
const stack = new AgentStack(); const stack = new AgentStack();
stack.createTask({ ownerDid: owner, sourceApp: "x", title: "a" }); stack.createTask({ ownerDid: owner, sourceApp: "x", title: "a" });
stack.createTask({ ownerDid: userDid("999"), sourceApp: "x", title: "b" }); stack.createTask({ ownerDid: userDid("999"), sourceApp: "x", title: "b" });
expect(stack.listTasks({ ownerDid: owner })).toHaveLength(1); expect(stack.listTasks({ ownerDid: owner })).toHaveLength(1);
expect(stack.snapshot().tasks).toHaveLength(2); expect(stack.snapshot().tasks).toHaveLength(2);
}); });
}); });

View file

@ -1,218 +1,221 @@
import type { PluginDefinition } from "@logicsrc/plugin-core"; import type { PluginDefinition } from "@logicsrc/plugin-core";
import { agentStackManifest } from "./manifest.js"; import { agentStackManifest } from "./manifest.js";
import type { import type {
AgentProfile, AgentProfile,
AgentStackEvent, AgentStackEvent,
AgentStackListener, AgentStackListener,
AgentStackSnapshot, AgentStackSnapshot,
CreateTaskInput, CreateTaskInput,
DelegationGrant, DelegationGrant,
DidKind, DidKind,
DidTask, DidTask,
TaskStatus TaskStatus
} from "./types.js"; } from "./types.js";
import { DID_METHOD } from "./types.js"; import { DID_METHOD } from "./types.js";
/** Build a CoinPay-method DID for a user or agent: `did:coinpay:user:123`. */ /** Build a CoinPay-method DID for a user or agent: `did:coinpay:user:123`. */
export function makeDid(kind: DidKind, id: string): string { export function makeDid(kind: DidKind, id: string): string {
return `${DID_METHOD}:${kind}:${id}`; return `${DID_METHOD}:${kind}:${id}`;
} }
export const userDid = (id: string) => makeDid("user", id); export const userDid = (id: string) => makeDid("user", id);
export const agentDid = (id: string) => makeDid("agent", id); export const agentDid = (id: string) => makeDid("agent", id);
/** Parse a CoinPay DID into its kind and id, or return null if it is not one. */ /** Parse a CoinPay DID into its kind and id, or return null if it is not one. */
export function parseDid(did: string): { kind: DidKind; id: string } | null { export function parseDid(did: string): { kind: DidKind; id: string } | null {
const prefix = `${DID_METHOD}:`; const prefix = `${DID_METHOD}:`;
if (!did.startsWith(prefix)) return null; if (!did.startsWith(prefix)) return null;
const [kind, id] = did.slice(prefix.length).split(":"); const [kind, id] = did.slice(prefix.length).split(":");
if ((kind !== "user" && kind !== "agent") || !id) return null; if ((kind !== "user" && kind !== "agent") || !id) return null;
return { kind, id }; return { kind, id };
} }
export function isDidTask(value: unknown): value is DidTask { export function isDidTask(value: unknown): value is DidTask {
return ( return (
typeof value === "object" && typeof value === "object" &&
value !== null && value !== null &&
typeof (value as DidTask).id === "string" && typeof (value as DidTask).id === "string" &&
typeof (value as DidTask).ownerDid === "string" && typeof (value as DidTask).ownerDid === "string" &&
typeof (value as DidTask).status === "string" typeof (value as DidTask).status === "string"
); );
} }
const TERMINAL: ReadonlySet<TaskStatus> = new Set(["complete", "failed", "cancelled"]); const TERMINAL: ReadonlySet<TaskStatus> = new Set(["complete", "failed", "cancelled"]);
/** /**
* In-memory AgentStack coordinator: registers agents, tracks portable tasks through their * In-memory AgentStack coordinator: registers agents, tracks portable tasks through their
* lifecycle, records delegation grants, and emits coordination events. Reference * lifecycle, records delegation grants, and emits coordination events. Reference
* implementation of the `agentstack` capability; storage backends can wrap the same API. * implementation of the `agentstack` capability; storage backends can wrap the same API.
*/ */
export class AgentStack { export class AgentStack {
private readonly agents = new Map<string, AgentProfile>(); private readonly agents = new Map<string, AgentProfile>();
private readonly tasks = new Map<string, DidTask>(); private readonly tasks = new Map<string, DidTask>();
private readonly delegations = new Map<string, DelegationGrant>(); private readonly delegations = new Map<string, DelegationGrant>();
private readonly listeners = new Set<AgentStackListener>(); private readonly listeners = new Set<AgentStackListener>();
private seq = 0; private seq = 0;
constructor(private readonly now: () => string = () => new Date().toISOString()) {} constructor(private readonly now: () => string = () => new Date().toISOString()) {}
on(listener: AgentStackListener): () => void { on(listener: AgentStackListener): () => void {
this.listeners.add(listener); this.listeners.add(listener);
return () => this.listeners.delete(listener); return () => this.listeners.delete(listener);
} }
private emit(event: AgentStackEvent) { private emit(event: AgentStackEvent) {
for (const listener of this.listeners) listener(event); for (const listener of this.listeners) listener(event);
} }
private nextId(prefix: string): string { private nextId(prefix: string): string {
this.seq += 1; this.seq += 1;
return `${prefix}_${this.seq}`; return `${prefix}_${this.seq}`;
} }
registerAgent(agent: AgentProfile): AgentProfile { registerAgent(agent: AgentProfile): AgentProfile {
if (!parseDid(agent.did)) { if (!parseDid(agent.did)) {
throw new Error(`Invalid agent DID: ${agent.did}`); throw new Error(`Invalid agent DID: ${agent.did}`);
} }
this.agents.set(agent.did, agent); this.agents.set(agent.did, agent);
this.emit({ type: "agent.registered", agent }); this.emit({ type: "agent.registered", agent });
return agent; return agent;
} }
getAgent(did: string): AgentProfile | undefined { getAgent(did: string): AgentProfile | undefined {
return this.agents.get(did); return this.agents.get(did);
} }
createTask(input: CreateTaskInput): DidTask { createTask(input: CreateTaskInput): DidTask {
if (!parseDid(input.ownerDid)) { if (!parseDid(input.ownerDid)) {
throw new Error(`Invalid owner DID: ${input.ownerDid}`); throw new Error(`Invalid owner DID: ${input.ownerDid}`);
} }
const ts = this.now(); const ts = this.now();
const task: DidTask = { const task: DidTask = {
id: this.nextId("task"), id: this.nextId("task"),
ownerDid: input.ownerDid, ownerDid: input.ownerDid,
assigneeDid: input.assigneeDid, assigneeDid: input.assigneeDid,
sourceApp: input.sourceApp, sourceApp: input.sourceApp,
title: input.title, title: input.title,
description: input.description, description: input.description,
status: input.assigneeDid ? "queued" : "pending", status: input.assigneeDid ? "queued" : "pending",
paymentIntentId: input.paymentIntentId, paymentIntentId: input.paymentIntentId,
escrowId: input.escrowId, escrowId: input.escrowId,
metadata: input.metadata, metadata: input.metadata,
createdAt: ts, createdAt: ts,
updatedAt: ts updatedAt: ts
}; };
this.tasks.set(task.id, task); this.tasks.set(task.id, task);
this.emit({ type: "task.created", task }); this.emit({ type: "task.created", task });
return task; return task;
} }
getTask(id: string): DidTask | undefined { getTask(id: string): DidTask | undefined {
return this.tasks.get(id); return this.tasks.get(id);
} }
assignTask(taskId: string, agentDidValue: string): DidTask { assignTask(taskId: string, agentDidValue: string): DidTask {
const task = this.requireTask(taskId); const task = this.requireTask(taskId);
if (TERMINAL.has(task.status)) {
throw new Error(`Task ${taskId} is already ${task.status} and cannot be assigned`);
}
if (!this.agents.has(agentDidValue)) { if (!this.agents.has(agentDidValue)) {
throw new Error(`Unknown agent: ${agentDidValue}`); throw new Error(`Unknown agent: ${agentDidValue}`);
} }
const updated: DidTask = { const updated: DidTask = {
...task, ...task,
assigneeDid: agentDidValue, assigneeDid: agentDidValue,
status: task.status === "pending" ? "queued" : task.status, status: task.status === "pending" ? "queued" : task.status,
updatedAt: this.now() updatedAt: this.now()
}; };
this.tasks.set(taskId, updated); this.tasks.set(taskId, updated);
this.emit({ type: "task.assigned", task: updated }); this.emit({ type: "task.assigned", task: updated });
return updated; return updated;
} }
updateTaskStatus( updateTaskStatus(
taskId: string, taskId: string,
status: TaskStatus, status: TaskStatus,
patch: Partial<Pick<DidTask, "reputationEventId" | "paymentIntentId" | "escrowId" | "metadata">> = {} patch: Partial<Pick<DidTask, "reputationEventId" | "paymentIntentId" | "escrowId" | "metadata">> = {}
): DidTask { ): DidTask {
const task = this.requireTask(taskId); const task = this.requireTask(taskId);
if (TERMINAL.has(task.status)) { if (TERMINAL.has(task.status)) {
throw new Error(`Task ${taskId} is already ${task.status} and cannot transition to ${status}`); throw new Error(`Task ${taskId} is already ${task.status} and cannot transition to ${status}`);
} }
const updated: DidTask = { ...task, ...patch, status, updatedAt: this.now() }; const updated: DidTask = { ...task, ...patch, status, updatedAt: this.now() };
this.tasks.set(taskId, updated); this.tasks.set(taskId, updated);
this.emit({ type: "task.updated", task: updated }); this.emit({ type: "task.updated", task: updated });
return updated; return updated;
} }
/** Grant an agent authority to act for an owner. */ /** Grant an agent authority to act for an owner. */
delegate(ownerDidValue: string, agentDidValue: string, scopes: string[], expiresAt?: string): DelegationGrant { delegate(ownerDidValue: string, agentDidValue: string, scopes: string[], expiresAt?: string): DelegationGrant {
if (!parseDid(ownerDidValue)) throw new Error(`Invalid owner DID: ${ownerDidValue}`); if (!parseDid(ownerDidValue)) throw new Error(`Invalid owner DID: ${ownerDidValue}`);
if (!this.agents.has(agentDidValue)) throw new Error(`Unknown agent: ${agentDidValue}`); if (!this.agents.has(agentDidValue)) throw new Error(`Unknown agent: ${agentDidValue}`);
const grant: DelegationGrant = { const grant: DelegationGrant = {
id: this.nextId("grant"), id: this.nextId("grant"),
ownerDid: ownerDidValue, ownerDid: ownerDidValue,
agentDid: agentDidValue, agentDid: agentDidValue,
scopes, scopes,
expiresAt, expiresAt,
createdAt: this.now() createdAt: this.now()
}; };
this.delegations.set(grant.id, grant); this.delegations.set(grant.id, grant);
this.emit({ type: "delegation.granted", grant }); this.emit({ type: "delegation.granted", grant });
return grant; return grant;
} }
revokeDelegation(grantId: string): DelegationGrant { revokeDelegation(grantId: string): DelegationGrant {
const grant = this.delegations.get(grantId); const grant = this.delegations.get(grantId);
if (!grant) throw new Error(`Unknown delegation grant: ${grantId}`); if (!grant) throw new Error(`Unknown delegation grant: ${grantId}`);
this.delegations.delete(grantId); this.delegations.delete(grantId);
this.emit({ type: "delegation.revoked", grant }); this.emit({ type: "delegation.revoked", grant });
return grant; return grant;
} }
listTasks(filter?: { ownerDid?: string; assigneeDid?: string; status?: TaskStatus }): DidTask[] { listTasks(filter?: { ownerDid?: string; assigneeDid?: string; status?: TaskStatus }): DidTask[] {
return [...this.tasks.values()].filter((task) => { return [...this.tasks.values()].filter((task) => {
if (filter?.ownerDid && task.ownerDid !== filter.ownerDid) return false; if (filter?.ownerDid && task.ownerDid !== filter.ownerDid) return false;
if (filter?.assigneeDid && task.assigneeDid !== filter.assigneeDid) return false; if (filter?.assigneeDid && task.assigneeDid !== filter.assigneeDid) return false;
if (filter?.status && task.status !== filter.status) return false; if (filter?.status && task.status !== filter.status) return false;
return true; return true;
}); });
} }
snapshot(): AgentStackSnapshot { snapshot(): AgentStackSnapshot {
return { return {
agents: [...this.agents.values()], agents: [...this.agents.values()],
tasks: [...this.tasks.values()], tasks: [...this.tasks.values()],
delegations: [...this.delegations.values()] delegations: [...this.delegations.values()]
}; };
} }
private requireTask(id: string): DidTask { private requireTask(id: string): DidTask {
const task = this.tasks.get(id); const task = this.tasks.get(id);
if (!task) throw new Error(`Unknown task: ${id}`); if (!task) throw new Error(`Unknown task: ${id}`);
return task; return task;
} }
} }
/** LogicSRC plugin definition exposing AgentStack as a coordination plugin. */ /** LogicSRC plugin definition exposing AgentStack as a coordination plugin. */
export const agentStackPlugin: PluginDefinition = { export const agentStackPlugin: PluginDefinition = {
manifest: agentStackManifest, manifest: agentStackManifest,
configDefaults: { configDefaults: {
enabled: false, enabled: false,
api_url: "${AGENTSTACK_API_URL}", api_url: "${AGENTSTACK_API_URL}",
api_key: "${AGENTSTACK_API_KEY}", api_key: "${AGENTSTACK_API_KEY}",
coinpay_api_base_url: "${COINPAY_API_BASE_URL}" coinpay_api_base_url: "${COINPAY_API_BASE_URL}"
}, },
routes: [ routes: [
{ method: "POST", path: "/api/plugins/agentstack/agents", capability: "agents.register" }, { method: "POST", path: "/api/plugins/agentstack/agents", capability: "agents.register" },
{ method: "POST", path: "/api/plugins/agentstack/tasks", capability: "tasks.create" }, { method: "POST", path: "/api/plugins/agentstack/tasks", capability: "tasks.create" },
{ method: "PATCH", path: "/api/plugins/agentstack/tasks/:id", capability: "tasks.update" }, { method: "PATCH", path: "/api/plugins/agentstack/tasks/:id", capability: "tasks.update" },
{ method: "POST", path: "/api/plugins/agentstack/delegations", capability: "agents.delegate" } { method: "POST", path: "/api/plugins/agentstack/delegations", capability: "agents.delegate" }
], ],
events: [ events: [
{ event: "task.created", capability: "tasks.publish" }, { event: "task.created", capability: "tasks.publish" },
{ event: "task.approved", capability: "reputation.sync" } { event: "task.approved", capability: "reputation.sync" }
], ],
permissions: ["agents:register", "agents:delegate", "tasks:create", "tasks:update", "reputation:sync"], permissions: ["agents:register", "agents:delegate", "tasks:create", "tasks:update", "reputation:sync"],
tuiPanels: [{ id: "agentstack-tasks", title: "AgentStack Tasks" }] tuiPanels: [{ id: "agentstack-tasks", title: "AgentStack Tasks" }]
}; };
export { agentStackManifest }; export { agentStackManifest };
export * from "./types.js"; export * from "./types.js";