diff --git a/packages/agentstack/src/index.ts b/packages/agentstack/src/index.ts index 92f84ef..f84b443 100644 --- a/packages/agentstack/src/index.ts +++ b/packages/agentstack/src/index.ts @@ -24,93 +24,125 @@ 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" + ); +} + +/** Return false if the grant has an expiresAt that is in the past compared to `now`. */ +export function isGrantActive(grant: DelegationGrant, now: string): boolean { + if (!grant.expiresAt) return true; + return grant.expiresAt > now; +} + +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)) { @@ -119,70 +151,6 @@ export class AgentStack { 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()] }; }