Skip to content

Commit 93d2299

Browse files
committed
feat(agent): version agent profiles with latest snapshots
1 parent f5823fc commit 93d2299

22 files changed

Lines changed: 466 additions & 236 deletions
Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
ALTER TABLE agents ADD COLUMN version TEXT NOT NULL DEFAULT 'latest';
2-
ALTER TABLE agents ADD COLUMN soul_sha1 TEXT NOT NULL DEFAULT '';
32

43
DROP INDEX IF EXISTS idx_agents_username;
54
CREATE UNIQUE INDEX idx_agents_username_version ON agents(username, version);

apps/web/server/agentRepo.ts

Lines changed: 123 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,36 @@ 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> {
9+
async function shortHash(value: string): Promise<string> {
1010
const bytes = new TextEncoder().encode(value);
1111
const hash = await crypto.subtle.digest("SHA-1", bytes);
12-
return [...new Uint8Array(hash)].map((b) => b.toString(16).padStart(2, "0")).join("");
12+
return [...new Uint8Array(hash)]
13+
.map((b) => b.toString(16).padStart(2, "0"))
14+
.join("")
15+
.slice(0, 10);
1316
}
1417

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);
18+
type AgentProfile = Pick<Agent, "name" | "bio" | "soul" | "role" | "kind" | "handoff_to" | "runtime" | "model" | "skills" | "subagents">;
19+
20+
function profileJson(agent: AgentProfile): string {
21+
return JSON.stringify({
22+
name: agent.name,
23+
bio: agent.bio,
24+
soul: agent.soul,
25+
role: agent.role,
26+
kind: agent.kind,
27+
handoff_to: agent.handoff_to ?? [],
28+
runtime: agent.runtime,
29+
model: agent.model,
30+
skills: agent.skills ?? [],
31+
subagents: agent.subagents ?? [],
32+
});
33+
}
34+
35+
async function profileVersion(
36+
agent: Pick<Agent, "name" | "bio" | "soul" | "role" | "kind" | "handoff_to" | "runtime" | "model" | "skills" | "subagents">,
37+
): Promise<string> {
38+
return shortHash(profileJson(agent));
2239
}
2340

2441
export interface PreparedAgent extends Agent {
@@ -57,8 +74,7 @@ export async function prepareAgent(
5774
model: input.model ?? null,
5875
skills: input.skills ?? null,
5976
subagents: input.subagents ?? null,
60-
version: await nextVersion(db, input.username, builtin),
61-
soul_sha1: await sha1(soul ?? ""),
77+
version: "latest",
6278
public_key: publicKeyBase64,
6379
fingerprint,
6480
builtin: builtin ? 1 : 0,
@@ -74,8 +90,8 @@ export async function insertAgent(db: D1, agent: PreparedAgent, extras?: { mailb
7490
const handoffJson = agent.handoff_to ? JSON.stringify(agent.handoff_to) : null;
7591
await db
7692
.prepare(`
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
93+
INSERT INTO agents (id, owner_id, name, username, bio, soul, role, kind, handoff_to, runtime, model, skills, subagents, version, public_key, private_key, fingerprint, builtin, mailbox_token, gpg_subkey_id, created_at, updated_at)
94+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
7995
`)
8096
.bind(
8197
agent.id,
@@ -92,7 +108,6 @@ export async function insertAgent(db: D1, agent: PreparedAgent, extras?: { mailb
92108
skillsJson,
93109
subagentsJson,
94110
agent.version,
95-
agent.soul_sha1,
96111
agent.public_key,
97112
JSON.stringify(agent.privateKeyJwk),
98113
agent.fingerprint,
@@ -124,7 +139,7 @@ export async function createAgentIdentity(db: D1, ownerId: string, agentEmail: s
124139

125140
export async function createAgent(db: D1, ownerId: string, input: CreateAgentInput, identity: AgentIdentity, builtin = false): Promise<Agent> {
126141
const prepared = await prepareAgent(db, ownerId, input, identity, builtin);
127-
return insertAgent(db, prepared);
142+
return upsertLatestAgent(db, prepared);
128143
}
129144

130145
export async function seedBuiltinAgents(db: D1, ownerId: string): Promise<void> {
@@ -147,7 +162,7 @@ export async function listAgents(db: D1, ownerId: string): Promise<AgentWithActi
147162
const result = await db
148163
.prepare(`
149164
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,
165+
a.version,
151166
a.public_key, a.fingerprint, a.builtin, a.created_at, a.updated_at,
152167
CASE WHEN EXISTS (
153168
SELECT 1 FROM machines m, json_each(m.runtimes) rt
@@ -180,7 +195,7 @@ export async function getAgent(db: D1, agentId: string, ownerId: string): Promis
180195
return db
181196
.prepare(`
182197
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,
198+
a.version,
184199
a.public_key, a.fingerprint, a.builtin, a.created_at, a.updated_at,
185200
CASE WHEN EXISTS (
186201
SELECT 1 FROM machines m, json_each(m.runtimes) rt
@@ -214,11 +229,12 @@ export async function updateAgent(
214229
): Promise<Agent | null> {
215230
const agent = await db
216231
.prepare(
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 = ?",
232+
"SELECT id, owner_id, name, username, gpg_subkey_id, bio, soul, role, kind, handoff_to, runtime, model, skills, subagents, version, public_key, private_key, fingerprint, builtin, mailbox_token, created_at, updated_at FROM agents WHERE id = ?",
218233
)
219234
.bind(agentId)
220-
.first<Agent>();
235+
.first<Agent & { private_key: string; mailbox_token: string | null }>();
221236
if (!agent) return null;
237+
if (agent.version !== "latest") return null;
222238

223239
const now = new Date().toISOString();
224240
const sets: string[] = ["updated_at = ?"];
@@ -235,19 +251,18 @@ export async function updateAgent(
235251
(applied as any)[field] = val;
236252
}
237253
}
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;
254+
const updatedProfile = { ...parseAgent(agent), ...applied } as AgentSnapshot;
255+
if (profileJson(parseAgent(agent) as AgentSnapshot) === profileJson(updatedProfile)) {
256+
return getAgent(db, agentId, agent.owner_id);
243257
}
244258

259+
await insertAgentSnapshot(db, parseAgent(agent) as AgentSnapshot, await profileVersion(parseAgent(agent) as AgentSnapshot), now);
245260
binds.push(agentId);
246261
await db
247262
.prepare(`UPDATE agents SET ${sets.join(", ")} WHERE id = ?`)
248263
.bind(...binds)
249264
.run();
250-
return parseAgent({ ...agent, ...applied, updated_at: now });
265+
return getAgent(db, agentId, agent.owner_id);
251266
}
252267

253268
type AgentSnapshot = Agent & { private_key: string; mailbox_token: string | null };
@@ -256,21 +271,39 @@ function jsonOrNull(value: unknown[] | null): string | null {
256271
return value ? JSON.stringify(value) : null;
257272
}
258273

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;
274+
async function getLatestAgentSnapshot(db: D1, username: string, ownerId: string): Promise<AgentSnapshot | null> {
275+
const row = await db
276+
.prepare(
277+
"SELECT id, owner_id, name, username, gpg_subkey_id, bio, soul, role, kind, handoff_to, runtime, model, skills, subagents, version, public_key, private_key, fingerprint, builtin, mailbox_token, created_at, updated_at FROM agents WHERE username = ? AND owner_id = ? AND version = 'latest'",
278+
)
279+
.bind(username, ownerId)
280+
.first<Agent & { private_key: string; mailbox_token: string | null }>();
281+
return row ? (parseAgent(row) as AgentSnapshot) : null;
262282
}
263283

264-
async function updateLatestAgent(db: D1, latestId: string, source: AgentSnapshot, updatedAt: string): Promise<void> {
284+
async function insertAgentSnapshot(db: D1, source: AgentSnapshot, version: string, now: string): Promise<string> {
285+
const existing = await db
286+
.prepare("SELECT id, name, bio, soul, role, kind, handoff_to, runtime, model, skills, subagents FROM agents WHERE username = ? AND version = ?")
287+
.bind(source.username, version)
288+
.first<AgentProfile & { id: string }>();
289+
if (existing) {
290+
if (profileJson(parseAgent(existing as Agent)) !== profileJson(source)) {
291+
throw new Error(`Agent snapshot hash collision: ${source.username}@${version}`);
292+
}
293+
return existing.id;
294+
}
295+
296+
const snapshotId = crypto.randomUUID();
265297
await db
266298
.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 = ?
299+
INSERT INTO agents (id, owner_id, name, username, gpg_subkey_id, bio, soul, role, kind, handoff_to, runtime, model, skills, subagents, version, public_key, private_key, fingerprint, builtin, mailbox_token, created_at, updated_at)
300+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
271301
`)
272302
.bind(
303+
snapshotId,
304+
source.owner_id,
273305
source.name,
306+
source.username,
274307
source.gpg_subkey_id,
275308
source.bio,
276309
source.soul,
@@ -281,78 +314,88 @@ async function updateLatestAgent(db: D1, latestId: string, source: AgentSnapshot
281314
source.model,
282315
jsonOrNull(source.skills),
283316
jsonOrNull(source.subagents),
284-
source.soul_sha1,
317+
version,
285318
source.public_key,
286319
source.private_key,
287320
source.fingerprint,
288321
source.builtin,
289322
source.mailbox_token,
290-
updatedAt,
291-
latestId,
323+
now,
324+
now,
292325
)
293326
.run();
327+
return snapshotId;
294328
}
295329

296-
async function insertLatestAgent(db: D1, ownerId: string, source: AgentSnapshot, now: string): Promise<string> {
297-
const latestId = crypto.randomUUID();
330+
async function updateLatestFromPrepared(
331+
db: D1,
332+
latest: AgentSnapshot,
333+
agent: PreparedAgent,
334+
extras: { mailboxToken?: string; gpgSubkeyId?: string } | undefined,
335+
now: string,
336+
): Promise<void> {
298337
await db
299338
.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', ?, ?, ?, ?, ?, ?, ?, ?)
339+
UPDATE agents
340+
SET name = ?, gpg_subkey_id = ?, bio = ?, soul = ?, role = ?, kind = ?, handoff_to = ?, runtime = ?, model = ?,
341+
skills = ?, subagents = ?, public_key = ?, private_key = ?, fingerprint = ?, builtin = ?, mailbox_token = ?, updated_at = ?
342+
WHERE id = ?
302343
`)
303344
.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,
345+
agent.name,
346+
extras?.gpgSubkeyId ?? latest.gpg_subkey_id,
347+
agent.bio,
348+
agent.soul,
349+
agent.role,
350+
agent.kind,
351+
jsonOrNull(agent.handoff_to),
352+
agent.runtime,
353+
agent.model,
354+
jsonOrNull(agent.skills),
355+
jsonOrNull(agent.subagents),
356+
latest.public_key,
357+
latest.private_key,
358+
latest.fingerprint,
359+
agent.builtin,
360+
extras?.mailboxToken ?? latest.mailbox_token,
325361
now,
362+
latest.id,
326363
)
327364
.run();
328-
return latestId;
329365
}
330366

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);
367+
export async function upsertLatestAgent(db: D1, agent: PreparedAgent, extras?: { mailboxToken?: string; gpgSubkeyId?: string }): Promise<Agent> {
368+
const latest = await getLatestAgentSnapshot(db, agent.username, agent.owner_id);
369+
if (!latest) return insertAgent(db, agent, extras);
342370

343371
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);
372+
if (profileJson(latest) === profileJson(agent)) {
373+
const current = await getAgent(db, latest.id, agent.owner_id);
374+
if (!current) throw new Error("Latest agent missing during update");
375+
return current;
348376
}
349377

350-
return getAgent(db, await insertLatestAgent(db, ownerId, source, now), ownerId);
378+
await insertAgentSnapshot(db, latest, await profileVersion(latest), now);
379+
await updateLatestFromPrepared(db, latest, agent, extras, now);
380+
const updated = await getAgent(db, latest.id, agent.owner_id);
381+
if (!updated) throw new Error("Latest agent missing after update");
382+
return updated;
351383
}
352384

353385
export async function deleteAgent(db: D1, agentId: string): Promise<boolean> {
354-
await db.prepare("UPDATE tasks SET assigned_to = NULL WHERE assigned_to = ? AND status IN ('todo', 'in_progress')").bind(agentId).run();
355-
const result = await db.prepare("DELETE FROM agents WHERE id = ?").bind(agentId).run();
386+
const agent = await db
387+
.prepare("SELECT owner_id, username, version FROM agents WHERE id = ?")
388+
.bind(agentId)
389+
.first<Pick<Agent, "owner_id" | "username" | "version">>();
390+
if (!agent || agent.version !== "latest") return false;
391+
392+
await db
393+
.prepare(
394+
"UPDATE tasks SET assigned_to = NULL WHERE assigned_to IN (SELECT id FROM agents WHERE owner_id = ? AND username = ?) AND status IN ('todo', 'in_progress')",
395+
)
396+
.bind(agent.owner_id, agent.username)
397+
.run();
398+
const result = await db.prepare("DELETE FROM agents WHERE owner_id = ? AND username = ?").bind(agent.owner_id, agent.username).run();
356399
return result.meta.changes > 0;
357400
}
358401

apps/web/server/auth.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ 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"] } },
2322
{ method: "PATCH", pattern: /^\/api\/agents\/[^/]+$/, rule: { allow: ["user", "agent:leader"] } },
2423
{ method: "DELETE", pattern: /^\/api\/agents\/[^/]+$/, rule: { allow: ["user", "agent:leader"] } },
2524

0 commit comments

Comments
 (0)