Skip to content

Commit f5823fc

Browse files
committed
feat(agent): add versioned agent publishing
1 parent d042ee6 commit f5823fc

30 files changed

Lines changed: 736 additions & 26 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
ALTER TABLE agents ADD COLUMN version TEXT NOT NULL DEFAULT 'latest';
2+
ALTER TABLE agents ADD COLUMN soul_sha1 TEXT NOT NULL DEFAULT '';
3+
4+
DROP INDEX IF EXISTS idx_agents_username;
5+
CREATE UNIQUE INDEX idx_agents_username_version ON agents(username, version);

apps/web/server/agentRepo.ts

Lines changed: 132 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,21 @@ import { runtimeReadyPredicateSql } from "./machineRepo";
66

77
const parseAgent = <T extends Agent>(row: T) => parseJsonFields(row, ["skills", "subagents", "handoff_to"]);
88

9+
async function sha1(value: string): Promise<string> {
10+
const bytes = new TextEncoder().encode(value);
11+
const hash = await crypto.subtle.digest("SHA-1", bytes);
12+
return [...new Uint8Array(hash)].map((b) => b.toString(16).padStart(2, "0")).join("");
13+
}
14+
15+
async function nextVersion(db: D1, username: string, builtin: boolean): Promise<string> {
16+
if (builtin) return "latest";
17+
const row = await db
18+
.prepare("SELECT MAX(CAST(version AS INTEGER)) as version FROM agents WHERE username = ? AND version != 'latest'")
19+
.bind(username)
20+
.first<{ version: number | null }>();
21+
return String((row?.version ?? 0) + 1);
22+
}
23+
924
export interface PreparedAgent extends Agent {
1025
privateKeyJwk: JsonWebKey;
1126
}
@@ -26,21 +41,24 @@ export async function prepareAgent(
2641
): Promise<PreparedAgent> {
2742
const { id, publicKeyBase64, fingerprint, privateKeyJwk } = identity;
2843
const now = new Date().toISOString();
44+
const soul = input.soul ?? null;
2945
return {
3046
id,
3147
owner_id: ownerId,
3248
name: input.name || input.username,
3349
username: input.username,
3450
gpg_subkey_id: null,
3551
bio: input.bio ?? null,
36-
soul: input.soul ?? null,
52+
soul,
3753
role: input.role ?? null,
3854
kind: input.kind ?? "worker",
3955
handoff_to: input.handoff_to ?? null,
4056
runtime: input.runtime,
4157
model: input.model ?? null,
4258
skills: input.skills ?? null,
4359
subagents: input.subagents ?? null,
60+
version: await nextVersion(db, input.username, builtin),
61+
soul_sha1: await sha1(soul ?? ""),
4462
public_key: publicKeyBase64,
4563
fingerprint,
4664
builtin: builtin ? 1 : 0,
@@ -56,8 +74,8 @@ export async function insertAgent(db: D1, agent: PreparedAgent, extras?: { mailb
5674
const handoffJson = agent.handoff_to ? JSON.stringify(agent.handoff_to) : null;
5775
await db
5876
.prepare(`
59-
INSERT INTO agents (id, owner_id, name, username, bio, soul, role, kind, handoff_to, runtime, model, skills, subagents, public_key, private_key, fingerprint, builtin, mailbox_token, gpg_subkey_id, created_at, updated_at)
60-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
77+
INSERT INTO agents (id, owner_id, name, username, bio, soul, role, kind, handoff_to, runtime, model, skills, subagents, version, soul_sha1, public_key, private_key, fingerprint, builtin, mailbox_token, gpg_subkey_id, created_at, updated_at)
78+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
6179
`)
6280
.bind(
6381
agent.id,
@@ -73,6 +91,8 @@ export async function insertAgent(db: D1, agent: PreparedAgent, extras?: { mailb
7391
agent.model,
7492
skillsJson,
7593
subagentsJson,
94+
agent.version,
95+
agent.soul_sha1,
7696
agent.public_key,
7797
JSON.stringify(agent.privateKeyJwk),
7898
agent.fingerprint,
@@ -127,6 +147,7 @@ export async function listAgents(db: D1, ownerId: string): Promise<AgentWithActi
127147
const result = await db
128148
.prepare(`
129149
SELECT a.id, a.owner_id, a.name, a.username, a.gpg_subkey_id, a.bio, a.soul, a.role, a.kind, a.handoff_to, a.runtime, a.model, a.skills, a.subagents,
150+
a.version, a.soul_sha1,
130151
a.public_key, a.fingerprint, a.builtin, a.created_at, a.updated_at,
131152
CASE WHEN EXISTS (
132153
SELECT 1 FROM machines m, json_each(m.runtimes) rt
@@ -159,6 +180,7 @@ export async function getAgent(db: D1, agentId: string, ownerId: string): Promis
159180
return db
160181
.prepare(`
161182
SELECT a.id, a.owner_id, a.name, a.username, a.gpg_subkey_id, a.bio, a.soul, a.role, a.kind, a.handoff_to, a.runtime, a.model, a.skills, a.subagents,
183+
a.version, a.soul_sha1,
162184
a.public_key, a.fingerprint, a.builtin, a.created_at, a.updated_at,
163185
CASE WHEN EXISTS (
164186
SELECT 1 FROM machines m, json_each(m.runtimes) rt
@@ -192,7 +214,7 @@ export async function updateAgent(
192214
): Promise<Agent | null> {
193215
const agent = await db
194216
.prepare(
195-
"SELECT id, owner_id, name, username, gpg_subkey_id, bio, soul, role, kind, handoff_to, runtime, model, skills, subagents, public_key, fingerprint, builtin, created_at, updated_at FROM agents WHERE id = ?",
217+
"SELECT id, owner_id, name, username, gpg_subkey_id, bio, soul, role, kind, handoff_to, runtime, model, skills, subagents, version, soul_sha1, public_key, fingerprint, builtin, created_at, updated_at FROM agents WHERE id = ?",
196218
)
197219
.bind(agentId)
198220
.first<Agent>();
@@ -213,6 +235,12 @@ export async function updateAgent(
213235
(applied as any)[field] = val;
214236
}
215237
}
238+
if ("soul" in updates && updates.soul !== undefined) {
239+
const digest = await sha1(updates.soul ?? "");
240+
sets.push("soul_sha1 = ?");
241+
binds.push(digest);
242+
applied.soul_sha1 = digest;
243+
}
216244

217245
binds.push(agentId);
218246
await db
@@ -222,6 +250,106 @@ export async function updateAgent(
222250
return parseAgent({ ...agent, ...applied, updated_at: now });
223251
}
224252

253+
type AgentSnapshot = Agent & { private_key: string; mailbox_token: string | null };
254+
255+
function jsonOrNull(value: unknown[] | null): string | null {
256+
return value ? JSON.stringify(value) : null;
257+
}
258+
259+
async function findLatestAgentId(db: D1, username: string): Promise<string | null> {
260+
const row = await db.prepare("SELECT id FROM agents WHERE username = ? AND version = 'latest'").bind(username).first<{ id: string }>();
261+
return row?.id ?? null;
262+
}
263+
264+
async function updateLatestAgent(db: D1, latestId: string, source: AgentSnapshot, updatedAt: string): Promise<void> {
265+
await db
266+
.prepare(`
267+
UPDATE agents
268+
SET name = ?, gpg_subkey_id = ?, bio = ?, soul = ?, role = ?, kind = ?, handoff_to = ?, runtime = ?, model = ?,
269+
skills = ?, subagents = ?, soul_sha1 = ?, public_key = ?, private_key = ?, fingerprint = ?, builtin = ?, mailbox_token = ?, updated_at = ?
270+
WHERE id = ?
271+
`)
272+
.bind(
273+
source.name,
274+
source.gpg_subkey_id,
275+
source.bio,
276+
source.soul,
277+
source.role,
278+
source.kind,
279+
jsonOrNull(source.handoff_to),
280+
source.runtime,
281+
source.model,
282+
jsonOrNull(source.skills),
283+
jsonOrNull(source.subagents),
284+
source.soul_sha1,
285+
source.public_key,
286+
source.private_key,
287+
source.fingerprint,
288+
source.builtin,
289+
source.mailbox_token,
290+
updatedAt,
291+
latestId,
292+
)
293+
.run();
294+
}
295+
296+
async function insertLatestAgent(db: D1, ownerId: string, source: AgentSnapshot, now: string): Promise<string> {
297+
const latestId = crypto.randomUUID();
298+
await db
299+
.prepare(`
300+
INSERT INTO agents (id, owner_id, name, username, gpg_subkey_id, bio, soul, role, kind, handoff_to, runtime, model, skills, subagents, version, soul_sha1, public_key, private_key, fingerprint, builtin, mailbox_token, created_at, updated_at)
301+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'latest', ?, ?, ?, ?, ?, ?, ?, ?)
302+
`)
303+
.bind(
304+
latestId,
305+
ownerId,
306+
source.name,
307+
source.username,
308+
source.gpg_subkey_id,
309+
source.bio,
310+
source.soul,
311+
source.role,
312+
source.kind,
313+
jsonOrNull(source.handoff_to),
314+
source.runtime,
315+
source.model,
316+
jsonOrNull(source.skills),
317+
jsonOrNull(source.subagents),
318+
source.soul_sha1,
319+
source.public_key,
320+
source.private_key,
321+
source.fingerprint,
322+
source.builtin,
323+
source.mailbox_token,
324+
now,
325+
now,
326+
)
327+
.run();
328+
return latestId;
329+
}
330+
331+
export async function publishAgent(db: D1, username: string, agentId: string, ownerId: string): Promise<Agent | null> {
332+
const target = await db
333+
.prepare(
334+
"SELECT id, owner_id, name, username, gpg_subkey_id, bio, soul, role, kind, handoff_to, runtime, model, skills, subagents, version, soul_sha1, public_key, private_key, fingerprint, builtin, mailbox_token FROM agents WHERE id = ? AND owner_id = ?",
335+
)
336+
.bind(agentId, ownerId)
337+
.first<Agent & { private_key: string; mailbox_token: string | null }>();
338+
if (!target) return null;
339+
const source = parseAgent(target) as AgentSnapshot;
340+
if (source.username !== username) return null;
341+
if (source.version === "latest") return getAgent(db, agentId, ownerId);
342+
343+
const now = new Date().toISOString();
344+
const latestId = await findLatestAgentId(db, source.username);
345+
if (latestId) {
346+
await updateLatestAgent(db, latestId, source, now);
347+
return getAgent(db, latestId, ownerId);
348+
}
349+
350+
return getAgent(db, await insertLatestAgent(db, ownerId, source, now), ownerId);
351+
}
352+
225353
export async function deleteAgent(db: D1, agentId: string): Promise<boolean> {
226354
await db.prepare("UPDATE tasks SET assigned_to = NULL WHERE assigned_to = ? AND status IN ('todo', 'in_progress')").bind(agentId).run();
227355
const result = await db.prepare("DELETE FROM agents WHERE id = ?").bind(agentId).run();

apps/web/server/auth.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ const ROUTE_RULES: { method: string; pattern: RegExp; rule: RouteRule }[] = [
1919

2020
// Agents — user/machine/leader creates, user/leader manages
2121
{ method: "POST", pattern: /^\/api\/agents$/, rule: { allow: ["user", "machine", "agent:leader"] } },
22+
{ method: "PUT", pattern: /^\/api\/agents\/[^/]+\/versions\/latest$/, rule: { allow: ["user", "agent:leader"] } },
2223
{ method: "PATCH", pattern: /^\/api\/agents\/[^/]+$/, rule: { allow: ["user", "agent:leader"] } },
2324
{ method: "DELETE", pattern: /^\/api\/agents\/[^/]+$/, rule: { allow: ["user", "agent:leader"] } },
2425

apps/web/server/routes.ts

Lines changed: 35 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
insertAgent,
2020
listAgents,
2121
prepareAgent,
22+
publishAgent,
2223
updateAgent,
2324
} from "./agentRepo";
2425
import { closeSession, createSession, listSessions, reopenSession, updateSessionUsage } from "./agentSessionRepo";
@@ -286,7 +287,11 @@ api.get("/api/share/:slug/stream", async (c) => {
286287

287288
api.get("/agents/:file{.+\\.gpg$}", async (c) => {
288289
const username = c.req.param("file").replace(/\.gpg$/, "");
289-
const agent = await c.env.DB.prepare("SELECT owner_id FROM agents WHERE username = ?").bind(username).first<{ owner_id: string }>();
290+
const agent = await c.env.DB.prepare(
291+
"SELECT owner_id FROM agents WHERE username = ? ORDER BY CASE WHEN version = 'latest' THEN 0 ELSE 1 END LIMIT 1",
292+
)
293+
.bind(username)
294+
.first<{ owner_id: string }>();
290295
if (!agent) throw new HTTPException(404, { message: "Agent not found" });
291296
const armoredPublicKey = await getRootPublicKey(c.env.DB, agent.owner_id);
292297
if (!armoredPublicKey) throw new HTTPException(404, { message: "GPG key not found" });
@@ -301,7 +306,11 @@ api.get("/.well-known/openpgpkey/hu/:hash", async (c) => {
301306
const hash = c.req.param("hash");
302307
const localPart = c.req.query("l");
303308
if (!localPart) throw new HTTPException(400, { message: "Missing l= query parameter" });
304-
const agent = await c.env.DB.prepare("SELECT owner_id FROM agents WHERE username = ?").bind(localPart).first<{ owner_id: string }>();
309+
const agent = await c.env.DB.prepare(
310+
"SELECT owner_id FROM agents WHERE username = ? ORDER BY CASE WHEN version = 'latest' THEN 0 ELSE 1 END LIMIT 1",
311+
)
312+
.bind(localPart)
313+
.first<{ owner_id: string }>();
305314
if (!agent) throw new HTTPException(404, { message: "Agent not found" });
306315
// Verify the hash matches the local part (WKD uses SHA-1 + z-base-32)
307316
const expectedHash = await wkdHash(localPart);
@@ -521,9 +530,12 @@ api.post("/api/agents", async (c) => {
521530
const ownerId = c.get("ownerId");
522531
await assertRegisteredWorkerSubagents(c.env.DB, ownerId, body.subagents);
523532

524-
// Validate username uniqueness before GPG mutation
525-
const taken = await c.env.DB.prepare("SELECT 1 FROM agents WHERE username = ?").bind(body.username).first();
526-
if (taken) throw new HTTPException(409, { message: `Username "${body.username}" is already taken` });
533+
const existingUsername = await c.env.DB.prepare("SELECT owner_id FROM agents WHERE username = ? LIMIT 1")
534+
.bind(body.username)
535+
.first<{ owner_id: string }>();
536+
if (existingUsername && existingUsername.owner_id !== ownerId) {
537+
throw new HTTPException(409, { message: `Username "${body.username}" is already taken` });
538+
}
527539
if (body.kind === "leader") {
528540
const existingLeader = await c.env.DB.prepare("SELECT 1 FROM agents WHERE owner_id = ? AND runtime = ? AND kind = 'leader'")
529541
.bind(ownerId, body.runtime)
@@ -539,7 +551,7 @@ api.post("/api/agents", async (c) => {
539551
const prepared = await prepareAgent(c.env.DB, ownerId, body as CreateAgentInput, identity);
540552

541553
// External service — create mailbox (skip if MAILS_ADMIN_TOKEN not configured)
542-
const mailboxToken = c.env.MAILS_ADMIN_TOKEN ? await createMailbox(c.env.MAILS_ADMIN_TOKEN, email) : undefined;
554+
const mailboxToken = c.env.MAILS_ADMIN_TOKEN && !existingUsername ? await createMailbox(c.env.MAILS_ADMIN_TOKEN, email) : undefined;
543555

544556
try {
545557
// Single atomic insert with all fields
@@ -557,13 +569,24 @@ api.post("/api/agents", async (c) => {
557569

558570
return c.json(agent, 201);
559571
} catch (err) {
560-
await deleteMailbox(c.env.MAILS_ADMIN_TOKEN, email).catch((cleanupErr: unknown) => {
561-
logger.warn(`mailbox cleanup failed for ${email}: ${cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr)}`);
562-
});
572+
if (!existingUsername) {
573+
await deleteMailbox(c.env.MAILS_ADMIN_TOKEN, email).catch((cleanupErr: unknown) => {
574+
logger.warn(`mailbox cleanup failed for ${email}: ${cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr)}`);
575+
});
576+
}
563577
throw err;
564578
}
565579
});
566580

581+
api.put("/api/agents/:username/versions/latest", async (c) => {
582+
const body = await c.req.json<{ agent_id: string }>();
583+
assertJsonObject(body, "latest agent version");
584+
if (!body.agent_id) throw new HTTPException(400, { message: "agent_id is required" });
585+
const agent = await publishAgent(c.env.DB, c.req.param("username"), body.agent_id, c.get("ownerId"));
586+
if (!agent) throw new HTTPException(404, { message: "Agent not found" });
587+
return c.json(agent);
588+
});
589+
567590
api.patch("/api/agents/:id", async (c) => {
568591
const ownerId = c.get("ownerId");
569592
const existing = await getAgent(c.env.DB, c.req.param("id"), ownerId);
@@ -591,13 +614,14 @@ api.delete("/api/agents/:id", async (c) => {
591614
await assertAgentNotReferencedAsSubagent(c.env.DB, ownerId, agent.id);
592615
const email = agentEmail(agent.username);
593616
await deleteAgent(c.env.DB, agent.id);
594-
if (c.env.MAILS_ADMIN_TOKEN) {
617+
const remaining = await c.env.DB.prepare("SELECT 1 FROM agents WHERE username = ? LIMIT 1").bind(agent.username).first();
618+
if (c.env.MAILS_ADMIN_TOKEN && !remaining) {
595619
await deleteMailbox(c.env.MAILS_ADMIN_TOKEN, email);
596620
}
597621

598622
// Remove email from GitHub (best-effort)
599623
const token = await getGithubToken(c.env.DB, c.get("ownerId"));
600-
if (token) {
624+
if (token && !remaining) {
601625
await removeAgentEmail(token, email).catch((err: unknown) => {
602626
logger.warn(`github email cleanup failed for ${email}: ${err instanceof Error ? err.message : String(err)}`);
603627
});

packages/cli/src/client/base.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,10 @@ export abstract class ApiClient {
119119
deleteAgent(agentId: string) {
120120
return this.request("DELETE", `/api/agents/${agentId}`);
121121
}
122+
async publishAgent(agentId: string) {
123+
const agent = (await this.getAgent(agentId)) as { username: string };
124+
return this.request("PUT", `/api/agents/${agent.username}/versions/latest`, { agent_id: agentId });
125+
}
122126

123127
// Machines
124128
registerMachine(info: { name: string; os: string; version: string; runtimes: MachineRuntime[]; device_id: string }) {

0 commit comments

Comments
 (0)