diff --git a/packages/agentstack/src/index.test.ts b/packages/agentstack/src/index.test.ts index 99bc1a7..1e7cd45 100644 --- a/packages/agentstack/src/index.test.ts +++ b/packages/agentstack/src/index.test.ts @@ -1,106 +1,126 @@ -import { describe, expect, it, vi } from "vitest"; -import { validateManifest } from "@logicsrc/plugin-core"; -import { - AgentStack, - agentDid, - agentStackManifest, - agentStackPlugin, - makeDid, - parseDid, - userDid -} from "./index.js"; -import type { AgentProfile } from "./types.js"; - -const owner = userDid("123"); -const agent: AgentProfile = { - did: agentDid("abc"), - name: "Build Agent", - sourceApp: "commandboard.run", - supportedProtocols: ["logicsrc/1"] -}; - -describe("DID helpers", () => { - it("builds and parses CoinPay DIDs", () => { - expect(makeDid("user", "123")).toBe("did:coinpay:user:123"); - expect(parseDid(owner)).toEqual({ kind: "user", id: "123" }); - expect(parseDid(agentDid("abc"))).toEqual({ kind: "agent", id: "abc" }); - }); - - it("rejects non-CoinPay DIDs", () => { - expect(parseDid("did:web:example.com")).toBeNull(); - expect(parseDid("did:coinpay:bot:abc")).toBeNull(); - }); -}); - -describe("AgentStack manifest", () => { - it("is a valid LogicSRC plugin manifest", () => { - expect(() => validateManifest(agentStackManifest)).not.toThrow(); - expect(agentStackPlugin.manifest.id).toBe("agentstack"); +import { describe, expect, it, vi } from "vitest"; +import { validateManifest } from "@logicsrc/plugin-core"; +import { + AgentStack, + agentDid, + agentStackManifest, + agentStackPlugin, + makeDid, + parseDid, + userDid +} from "./index.js"; +import type { AgentProfile } from "./types.js"; + +const owner = userDid("123"); +const agent: AgentProfile = { + did: agentDid("abc"), + name: "Build Agent", + sourceApp: "commandboard.run", + supportedProtocols: ["logicsrc/1"] +}; + +describe("DID helpers", () => { + it("builds and parses CoinPay DIDs", () => { + expect(makeDid("user", "123")).toBe("did:coinpay:user:123"); + expect(parseDid(owner)).toEqual({ kind: "user", id: "123" }); + expect(parseDid(agentDid("abc"))).toEqual({ kind: "agent", id: "abc" }); + }); + + it("rejects non-CoinPay DIDs", () => { + expect(parseDid("did:web:example.com")).toBeNull(); + expect(parseDid("did:coinpay:bot:abc")).toBeNull(); + }); +}); + +describe("AgentStack manifest", () => { + it("is a valid LogicSRC plugin manifest", () => { + expect(() => validateManifest(agentStackManifest)).not.toThrow(); + expect(agentStackPlugin.manifest.id).toBe("agentstack"); + }); +}); + +describe("AgentStack coordinator", () => { + it("creates pending tasks and queues assigned ones", () => { + const stack = new AgentStack(); + const pending = stack.createTask({ ownerDid: owner, sourceApp: "ugig.net", title: "Crawl site" }); + expect(pending.status).toBe("pending"); + + stack.registerAgent(agent); + const assigned = stack.createTask({ + ownerDid: owner, + sourceApp: "ugig.net", + title: "Crawl site 2", + assigneeDid: agent.did + }); + expect(assigned.status).toBe("queued"); + }); + + it("moves a task through its lifecycle and binds reputation", () => { + const stack = new AgentStack(); + stack.registerAgent(agent); + const task = stack.createTask({ ownerDid: owner, sourceApp: "sh1pt.com", title: "Ship build" }); + + stack.assignTask(task.id, agent.did); + stack.updateTaskStatus(task.id, "running"); + const done = stack.updateTaskStatus(task.id, "complete", { reputationEventId: "rep_1" }); + + expect(done.status).toBe("complete"); + expect(done.assigneeDid).toBe(agent.did); + expect(done.reputationEventId).toBe("rep_1"); + }); + + it("refuses to transition out of a terminal status", () => { + const stack = new AgentStack(); + const task = stack.createTask({ ownerDid: owner, sourceApp: "qaaas.dev", title: "Test run" }); + stack.updateTaskStatus(task.id, "cancelled"); + expect(() => stack.updateTaskStatus(task.id, "running")).toThrow(/cancelled/); }); -}); -describe("AgentStack coordinator", () => { - it("creates pending tasks and queues assigned ones", () => { + it("refuses to assign a task that is already in a terminal status", () => { const stack = new AgentStack(); - const pending = stack.createTask({ ownerDid: owner, sourceApp: "ugig.net", title: "Crawl site" }); - expect(pending.status).toBe("pending"); - stack.registerAgent(agent); - const assigned = stack.createTask({ + const other: AgentProfile = { ...agent, did: agentDid("xyz") }; + stack.registerAgent(other); + const task = stack.createTask({ ownerDid: owner, sourceApp: "ugig.net", - title: "Crawl site 2", - assigneeDid: agent.did + title: "Paid task", + paymentIntentId: "pi_1", + escrowId: "esc_1" }); - expect(assigned.status).toBe("queued"); - }); - - it("moves a task through its lifecycle and binds reputation", () => { - const stack = new AgentStack(); - stack.registerAgent(agent); - const task = stack.createTask({ ownerDid: owner, sourceApp: "sh1pt.com", title: "Ship build" }); - stack.assignTask(task.id, agent.did); - stack.updateTaskStatus(task.id, "running"); - const done = stack.updateTaskStatus(task.id, "complete", { reputationEventId: "rep_1" }); + stack.updateTaskStatus(task.id, "complete", { reputationEventId: "rep_1" }); - expect(done.status).toBe("complete"); - expect(done.assigneeDid).toBe(agent.did); - expect(done.reputationEventId).toBe("rep_1"); - }); - - it("refuses to transition out of a terminal status", () => { - const stack = new AgentStack(); - const task = stack.createTask({ ownerDid: owner, sourceApp: "qaaas.dev", title: "Test run" }); - stack.updateTaskStatus(task.id, "cancelled"); - expect(() => stack.updateTaskStatus(task.id, "running")).toThrow(/cancelled/); + 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", () => { const stack = new AgentStack(); const listener = vi.fn(); - stack.on(listener); - stack.registerAgent(agent); - const grant = stack.delegate(owner, agent.did, ["tasks:create"]); - - expect(grant.ownerDid).toBe(owner); - expect(grant.agentDid).toBe(agent.did); - expect(listener).toHaveBeenCalledWith(expect.objectContaining({ type: "agent.registered" })); - expect(listener).toHaveBeenCalledWith(expect.objectContaining({ type: "delegation.granted" })); - }); - - it("rejects unknown agents and invalid DIDs", () => { - const stack = new AgentStack(); - expect(() => stack.createTask({ ownerDid: "nope", sourceApp: "x", title: "t" })).toThrow(); - const task = stack.createTask({ ownerDid: owner, sourceApp: "x", title: "t" }); - expect(() => stack.assignTask(task.id, agentDid("ghost"))).toThrow(/Unknown agent/); - }); - - it("filters tasks in snapshots", () => { - const stack = new AgentStack(); - stack.createTask({ ownerDid: owner, sourceApp: "x", title: "a" }); - stack.createTask({ ownerDid: userDid("999"), sourceApp: "x", title: "b" }); - expect(stack.listTasks({ ownerDid: owner })).toHaveLength(1); - expect(stack.snapshot().tasks).toHaveLength(2); - }); -}); + stack.on(listener); + stack.registerAgent(agent); + const grant = stack.delegate(owner, agent.did, ["tasks:create"]); + + expect(grant.ownerDid).toBe(owner); + expect(grant.agentDid).toBe(agent.did); + expect(listener).toHaveBeenCalledWith(expect.objectContaining({ type: "agent.registered" })); + expect(listener).toHaveBeenCalledWith(expect.objectContaining({ type: "delegation.granted" })); + }); + + it("rejects unknown agents and invalid DIDs", () => { + const stack = new AgentStack(); + expect(() => stack.createTask({ ownerDid: "nope", sourceApp: "x", title: "t" })).toThrow(); + const task = stack.createTask({ ownerDid: owner, sourceApp: "x", title: "t" }); + expect(() => stack.assignTask(task.id, agentDid("ghost"))).toThrow(/Unknown agent/); + }); + + it("filters tasks in snapshots", () => { + const stack = new AgentStack(); + stack.createTask({ ownerDid: owner, sourceApp: "x", title: "a" }); + stack.createTask({ ownerDid: userDid("999"), sourceApp: "x", title: "b" }); + expect(stack.listTasks({ ownerDid: owner })).toHaveLength(1); + expect(stack.snapshot().tasks).toHaveLength(2); + }); +}); diff --git a/packages/agentstack/src/index.ts b/packages/agentstack/src/index.ts index d5ea46c..92f84ef 100644 --- a/packages/agentstack/src/index.ts +++ b/packages/agentstack/src/index.ts @@ -1,218 +1,221 @@ -import type { PluginDefinition } from "@logicsrc/plugin-core"; -import { agentStackManifest } from "./manifest.js"; -import type { - AgentProfile, - AgentStackEvent, - AgentStackListener, - AgentStackSnapshot, - CreateTaskInput, - DelegationGrant, - DidKind, - DidTask, - TaskStatus -} from "./types.js"; -import { DID_METHOD } from "./types.js"; - -/** Build a CoinPay-method DID for a user or agent: `did:coinpay:user:123`. */ -export function makeDid(kind: DidKind, id: string): string { - return `${DID_METHOD}:${kind}:${id}`; -} - -export const userDid = (id: string) => makeDid("user", 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. */ -export function parseDid(did: string): { kind: DidKind; id: string } | null { - const prefix = `${DID_METHOD}:`; - if (!did.startsWith(prefix)) return null; - const [kind, id] = did.slice(prefix.length).split(":"); - if ((kind !== "user" && kind !== "agent") || !id) return null; - return { kind, id }; -} - -export function isDidTask(value: unknown): value is DidTask { - return ( - typeof value === "object" && - value !== null && - typeof (value as DidTask).id === "string" && - typeof (value as DidTask).ownerDid === "string" && - typeof (value as DidTask).status === "string" - ); -} - -const TERMINAL: ReadonlySet = new Set(["complete", "failed", "cancelled"]); - -/** - * In-memory AgentStack coordinator: registers agents, tracks portable tasks through their - * lifecycle, records delegation grants, and emits coordination events. Reference - * implementation of the `agentstack` capability; storage backends can wrap the same API. - */ -export class AgentStack { - private readonly agents = new Map(); - private readonly tasks = new Map(); - private readonly delegations = new Map(); - private readonly listeners = new Set(); - private seq = 0; - - constructor(private readonly now: () => string = () => new Date().toISOString()) {} - - on(listener: AgentStackListener): () => void { - this.listeners.add(listener); - return () => this.listeners.delete(listener); - } - - private emit(event: AgentStackEvent) { - for (const listener of this.listeners) listener(event); - } - - private nextId(prefix: string): string { - this.seq += 1; - return `${prefix}_${this.seq}`; - } - - registerAgent(agent: AgentProfile): AgentProfile { - if (!parseDid(agent.did)) { - throw new Error(`Invalid agent DID: ${agent.did}`); - } - this.agents.set(agent.did, agent); - this.emit({ type: "agent.registered", agent }); - return agent; - } - - getAgent(did: string): AgentProfile | undefined { - return this.agents.get(did); - } - - createTask(input: CreateTaskInput): DidTask { - if (!parseDid(input.ownerDid)) { - throw new Error(`Invalid owner DID: ${input.ownerDid}`); - } - const ts = this.now(); - const task: DidTask = { - id: this.nextId("task"), - ownerDid: input.ownerDid, - assigneeDid: input.assigneeDid, - sourceApp: input.sourceApp, - title: input.title, - description: input.description, - status: input.assigneeDid ? "queued" : "pending", - paymentIntentId: input.paymentIntentId, - escrowId: input.escrowId, - metadata: input.metadata, - createdAt: ts, - updatedAt: ts - }; - this.tasks.set(task.id, task); - this.emit({ type: "task.created", task }); - return task; - } - - getTask(id: string): DidTask | undefined { - return this.tasks.get(id); - } - +import type { PluginDefinition } from "@logicsrc/plugin-core"; +import { agentStackManifest } from "./manifest.js"; +import type { + AgentProfile, + AgentStackEvent, + AgentStackListener, + AgentStackSnapshot, + CreateTaskInput, + DelegationGrant, + DidKind, + DidTask, + TaskStatus +} from "./types.js"; +import { DID_METHOD } from "./types.js"; + +/** Build a CoinPay-method DID for a user or agent: `did:coinpay:user:123`. */ +export function makeDid(kind: DidKind, id: string): string { + return `${DID_METHOD}:${kind}:${id}`; +} + +export const userDid = (id: string) => makeDid("user", 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. */ +export function parseDid(did: string): { kind: DidKind; id: string } | null { + const prefix = `${DID_METHOD}:`; + if (!did.startsWith(prefix)) return null; + const [kind, id] = did.slice(prefix.length).split(":"); + if ((kind !== "user" && kind !== "agent") || !id) return null; + return { kind, id }; +} + +export function isDidTask(value: unknown): value is DidTask { + return ( + typeof value === "object" && + value !== null && + typeof (value as DidTask).id === "string" && + typeof (value as DidTask).ownerDid === "string" && + typeof (value as DidTask).status === "string" + ); +} + +const TERMINAL: ReadonlySet = new Set(["complete", "failed", "cancelled"]); + +/** + * In-memory AgentStack coordinator: registers agents, tracks portable tasks through their + * lifecycle, records delegation grants, and emits coordination events. Reference + * implementation of the `agentstack` capability; storage backends can wrap the same API. + */ +export class AgentStack { + private readonly agents = new Map(); + private readonly tasks = new Map(); + private readonly delegations = new Map(); + private readonly listeners = new Set(); + private seq = 0; + + constructor(private readonly now: () => string = () => new Date().toISOString()) {} + + on(listener: AgentStackListener): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + private emit(event: AgentStackEvent) { + for (const listener of this.listeners) listener(event); + } + + private nextId(prefix: string): string { + this.seq += 1; + return `${prefix}_${this.seq}`; + } + + registerAgent(agent: AgentProfile): AgentProfile { + if (!parseDid(agent.did)) { + throw new Error(`Invalid agent DID: ${agent.did}`); + } + this.agents.set(agent.did, agent); + this.emit({ type: "agent.registered", agent }); + return agent; + } + + getAgent(did: string): AgentProfile | undefined { + return this.agents.get(did); + } + + createTask(input: CreateTaskInput): DidTask { + if (!parseDid(input.ownerDid)) { + throw new Error(`Invalid owner DID: ${input.ownerDid}`); + } + const ts = this.now(); + const task: DidTask = { + id: this.nextId("task"), + ownerDid: input.ownerDid, + assigneeDid: input.assigneeDid, + sourceApp: input.sourceApp, + title: input.title, + description: input.description, + status: input.assigneeDid ? "queued" : "pending", + paymentIntentId: input.paymentIntentId, + escrowId: input.escrowId, + metadata: input.metadata, + createdAt: ts, + updatedAt: ts + }; + this.tasks.set(task.id, task); + this.emit({ type: "task.created", task }); + return task; + } + + getTask(id: string): DidTask | undefined { + return this.tasks.get(id); + } + assignTask(taskId: string, agentDidValue: string): DidTask { 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)) { throw new Error(`Unknown agent: ${agentDidValue}`); } - const updated: DidTask = { - ...task, - assigneeDid: agentDidValue, - status: task.status === "pending" ? "queued" : task.status, - updatedAt: this.now() - }; - this.tasks.set(taskId, updated); - this.emit({ type: "task.assigned", task: updated }); - return updated; - } - - updateTaskStatus( - taskId: string, - status: TaskStatus, - patch: Partial> = {} - ): DidTask { - const task = this.requireTask(taskId); - if (TERMINAL.has(task.status)) { - throw new Error(`Task ${taskId} is already ${task.status} and cannot transition to ${status}`); - } - const updated: DidTask = { ...task, ...patch, status, updatedAt: this.now() }; - this.tasks.set(taskId, updated); - this.emit({ type: "task.updated", task: updated }); - return updated; - } - - /** Grant an agent authority to act for an owner. */ - delegate(ownerDidValue: string, agentDidValue: string, scopes: string[], expiresAt?: string): DelegationGrant { - if (!parseDid(ownerDidValue)) throw new Error(`Invalid owner DID: ${ownerDidValue}`); - if (!this.agents.has(agentDidValue)) throw new Error(`Unknown agent: ${agentDidValue}`); - const grant: DelegationGrant = { - id: this.nextId("grant"), - ownerDid: ownerDidValue, - agentDid: agentDidValue, - scopes, - expiresAt, - createdAt: this.now() - }; - this.delegations.set(grant.id, grant); - this.emit({ type: "delegation.granted", grant }); - return grant; - } - - revokeDelegation(grantId: string): DelegationGrant { - const grant = this.delegations.get(grantId); - if (!grant) throw new Error(`Unknown delegation grant: ${grantId}`); - this.delegations.delete(grantId); - this.emit({ type: "delegation.revoked", grant }); - return grant; - } - - listTasks(filter?: { ownerDid?: string; assigneeDid?: string; status?: TaskStatus }): DidTask[] { - return [...this.tasks.values()].filter((task) => { - if (filter?.ownerDid && task.ownerDid !== filter.ownerDid) return false; - if (filter?.assigneeDid && task.assigneeDid !== filter.assigneeDid) return false; - if (filter?.status && task.status !== filter.status) return false; - return true; - }); - } - - snapshot(): AgentStackSnapshot { - return { - agents: [...this.agents.values()], - tasks: [...this.tasks.values()], - delegations: [...this.delegations.values()] - }; - } - - private requireTask(id: string): DidTask { - const task = this.tasks.get(id); - if (!task) throw new Error(`Unknown task: ${id}`); - return task; - } -} - -/** LogicSRC plugin definition exposing AgentStack as a coordination plugin. */ -export const agentStackPlugin: PluginDefinition = { - manifest: agentStackManifest, - configDefaults: { - enabled: false, - api_url: "${AGENTSTACK_API_URL}", - api_key: "${AGENTSTACK_API_KEY}", - coinpay_api_base_url: "${COINPAY_API_BASE_URL}" - }, - routes: [ - { method: "POST", path: "/api/plugins/agentstack/agents", capability: "agents.register" }, - { method: "POST", path: "/api/plugins/agentstack/tasks", capability: "tasks.create" }, - { method: "PATCH", path: "/api/plugins/agentstack/tasks/:id", capability: "tasks.update" }, - { method: "POST", path: "/api/plugins/agentstack/delegations", capability: "agents.delegate" } - ], - events: [ - { event: "task.created", capability: "tasks.publish" }, - { event: "task.approved", capability: "reputation.sync" } - ], - permissions: ["agents:register", "agents:delegate", "tasks:create", "tasks:update", "reputation:sync"], - tuiPanels: [{ id: "agentstack-tasks", title: "AgentStack Tasks" }] -}; - -export { agentStackManifest }; -export * from "./types.js"; + const updated: DidTask = { + ...task, + assigneeDid: agentDidValue, + status: task.status === "pending" ? "queued" : task.status, + updatedAt: this.now() + }; + this.tasks.set(taskId, updated); + this.emit({ type: "task.assigned", task: updated }); + return updated; + } + + updateTaskStatus( + taskId: string, + status: TaskStatus, + patch: Partial> = {} + ): DidTask { + const task = this.requireTask(taskId); + if (TERMINAL.has(task.status)) { + throw new Error(`Task ${taskId} is already ${task.status} and cannot transition to ${status}`); + } + const updated: DidTask = { ...task, ...patch, status, updatedAt: this.now() }; + this.tasks.set(taskId, updated); + this.emit({ type: "task.updated", task: updated }); + return updated; + } + + /** Grant an agent authority to act for an owner. */ + delegate(ownerDidValue: string, agentDidValue: string, scopes: string[], expiresAt?: string): DelegationGrant { + if (!parseDid(ownerDidValue)) throw new Error(`Invalid owner DID: ${ownerDidValue}`); + if (!this.agents.has(agentDidValue)) throw new Error(`Unknown agent: ${agentDidValue}`); + const grant: DelegationGrant = { + id: this.nextId("grant"), + ownerDid: ownerDidValue, + agentDid: agentDidValue, + scopes, + expiresAt, + createdAt: this.now() + }; + this.delegations.set(grant.id, grant); + this.emit({ type: "delegation.granted", grant }); + return grant; + } + + revokeDelegation(grantId: string): DelegationGrant { + const grant = this.delegations.get(grantId); + if (!grant) throw new Error(`Unknown delegation grant: ${grantId}`); + this.delegations.delete(grantId); + this.emit({ type: "delegation.revoked", grant }); + return grant; + } + + listTasks(filter?: { ownerDid?: string; assigneeDid?: string; status?: TaskStatus }): DidTask[] { + return [...this.tasks.values()].filter((task) => { + if (filter?.ownerDid && task.ownerDid !== filter.ownerDid) return false; + if (filter?.assigneeDid && task.assigneeDid !== filter.assigneeDid) return false; + if (filter?.status && task.status !== filter.status) return false; + return true; + }); + } + + snapshot(): AgentStackSnapshot { + return { + agents: [...this.agents.values()], + tasks: [...this.tasks.values()], + delegations: [...this.delegations.values()] + }; + } + + private requireTask(id: string): DidTask { + const task = this.tasks.get(id); + if (!task) throw new Error(`Unknown task: ${id}`); + return task; + } +} + +/** LogicSRC plugin definition exposing AgentStack as a coordination plugin. */ +export const agentStackPlugin: PluginDefinition = { + manifest: agentStackManifest, + configDefaults: { + enabled: false, + api_url: "${AGENTSTACK_API_URL}", + api_key: "${AGENTSTACK_API_KEY}", + coinpay_api_base_url: "${COINPAY_API_BASE_URL}" + }, + routes: [ + { method: "POST", path: "/api/plugins/agentstack/agents", capability: "agents.register" }, + { method: "POST", path: "/api/plugins/agentstack/tasks", capability: "tasks.create" }, + { method: "PATCH", path: "/api/plugins/agentstack/tasks/:id", capability: "tasks.update" }, + { method: "POST", path: "/api/plugins/agentstack/delegations", capability: "agents.delegate" } + ], + events: [ + { event: "task.created", capability: "tasks.publish" }, + { event: "task.approved", capability: "reputation.sync" } + ], + permissions: ["agents:register", "agents:delegate", "tasks:create", "tasks:update", "reputation:sync"], + tuiPanels: [{ id: "agentstack-tasks", title: "AgentStack Tasks" }] +}; + +export { agentStackManifest }; +export * from "./types.js";