Add AgentOS daemon control plane and maintenance tooling
This commit is contained in:
6
packages/backend/convex/_generated/api.d.ts
vendored
6
packages/backend/convex/_generated/api.d.ts
vendored
@@ -9,6 +9,9 @@
|
||||
*/
|
||||
|
||||
import type * as auth from "../auth.js";
|
||||
import type * as daemonCommands from "../daemonCommands.js";
|
||||
import type * as daemonRuntime from "../daemonRuntime.js";
|
||||
import type * as daemons from "../daemons.js";
|
||||
import type * as healthCheck from "../healthCheck.js";
|
||||
import type * as http from "../http.js";
|
||||
import type * as privateData from "../privateData.js";
|
||||
@@ -22,6 +25,9 @@ import type {
|
||||
|
||||
declare const fullApi: ApiFromModules<{
|
||||
auth: typeof auth;
|
||||
daemonCommands: typeof daemonCommands;
|
||||
daemonRuntime: typeof daemonRuntime;
|
||||
daemons: typeof daemons;
|
||||
healthCheck: typeof healthCheck;
|
||||
http: typeof http;
|
||||
privateData: typeof privateData;
|
||||
|
||||
199
packages/backend/convex/daemonCommands.ts
Normal file
199
packages/backend/convex/daemonCommands.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import { v } from "convex/values";
|
||||
|
||||
import { mutation, query } from "./_generated/server";
|
||||
|
||||
const commandStatus = v.union(
|
||||
v.literal("queued"),
|
||||
v.literal("claimed"),
|
||||
v.literal("running"),
|
||||
v.literal("succeeded"),
|
||||
v.literal("failed"),
|
||||
v.literal("cancelled")
|
||||
);
|
||||
|
||||
export const enqueue = mutation({
|
||||
args: {
|
||||
daemonId: v.string(),
|
||||
method: v.string(),
|
||||
args: v.any(),
|
||||
actorKey: v.array(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const timestamp = Date.now();
|
||||
return await ctx.db.insert("daemonCommands", {
|
||||
...args,
|
||||
status: "queued",
|
||||
attempts: 0,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const list = query({
|
||||
args: {
|
||||
daemonId: v.string(),
|
||||
status: v.optional(commandStatus),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
if (args.status) {
|
||||
return await ctx.db
|
||||
.query("daemonCommands")
|
||||
.withIndex("by_daemonId_and_status", (q) =>
|
||||
q.eq("daemonId", args.daemonId).eq("status", args.status!)
|
||||
)
|
||||
.order("desc")
|
||||
.take(100);
|
||||
}
|
||||
return await ctx.db
|
||||
.query("daemonCommands")
|
||||
.filter((q) => q.eq(q.field("daemonId"), args.daemonId))
|
||||
.order("desc")
|
||||
.take(100);
|
||||
},
|
||||
});
|
||||
|
||||
export const available = query({
|
||||
args: { daemonId: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const queued = await ctx.db
|
||||
.query("daemonCommands")
|
||||
.withIndex("by_daemonId_and_status", (q) =>
|
||||
q.eq("daemonId", args.daemonId).eq("status", "queued")
|
||||
)
|
||||
.order("asc")
|
||||
.take(25);
|
||||
const expiredClaims = await ctx.db
|
||||
.query("daemonCommands")
|
||||
.withIndex("by_daemonId_and_status", (q) =>
|
||||
q.eq("daemonId", args.daemonId).eq("status", "claimed")
|
||||
)
|
||||
.filter((q) => q.lt(q.field("leaseExpiresAt"), Date.now()))
|
||||
.order("asc")
|
||||
.take(25);
|
||||
return [...expiredClaims, ...queued].slice(0, 25);
|
||||
},
|
||||
});
|
||||
|
||||
export const claim = mutation({
|
||||
args: {
|
||||
commandId: v.id("daemonCommands"),
|
||||
daemonId: v.string(),
|
||||
sessionId: v.string(),
|
||||
leaseDurationMs: v.number(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const command = await ctx.db.get("daemonCommands", args.commandId);
|
||||
if (!command || command.daemonId !== args.daemonId) {
|
||||
return null;
|
||||
}
|
||||
const timestamp = Date.now();
|
||||
const canClaim =
|
||||
command.status === "queued" ||
|
||||
(command.status === "claimed" &&
|
||||
command.leaseExpiresAt !== undefined &&
|
||||
command.leaseExpiresAt <= timestamp);
|
||||
if (!canClaim) {
|
||||
return null;
|
||||
}
|
||||
await ctx.db.patch("daemonCommands", command._id, {
|
||||
status: "claimed",
|
||||
claimedBySessionId: args.sessionId,
|
||||
leaseExpiresAt: timestamp + args.leaseDurationMs,
|
||||
attempts: command.attempts + 1,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
return { ...command, status: "claimed" as const };
|
||||
},
|
||||
});
|
||||
|
||||
export const start = mutation({
|
||||
args: {
|
||||
commandId: v.id("daemonCommands"),
|
||||
sessionId: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const command = await ctx.db.get("daemonCommands", args.commandId);
|
||||
if (
|
||||
!command ||
|
||||
command.status !== "claimed" ||
|
||||
command.claimedBySessionId !== args.sessionId
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const timestamp = Date.now();
|
||||
await ctx.db.patch("daemonCommands", command._id, {
|
||||
status: "running",
|
||||
startedAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
export const succeed = mutation({
|
||||
args: {
|
||||
commandId: v.id("daemonCommands"),
|
||||
sessionId: v.string(),
|
||||
result: v.any(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const command = await ctx.db.get("daemonCommands", args.commandId);
|
||||
if (!command || command.claimedBySessionId !== args.sessionId) {
|
||||
return false;
|
||||
}
|
||||
const timestamp = Date.now();
|
||||
await ctx.db.patch("daemonCommands", command._id, {
|
||||
status: "succeeded",
|
||||
result: args.result,
|
||||
completedAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
leaseExpiresAt: undefined,
|
||||
});
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
export const fail = mutation({
|
||||
args: {
|
||||
commandId: v.id("daemonCommands"),
|
||||
sessionId: v.string(),
|
||||
error: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const command = await ctx.db.get("daemonCommands", args.commandId);
|
||||
if (!command || command.claimedBySessionId !== args.sessionId) {
|
||||
return false;
|
||||
}
|
||||
const timestamp = Date.now();
|
||||
await ctx.db.patch("daemonCommands", command._id, {
|
||||
status: "failed",
|
||||
error: args.error,
|
||||
completedAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
leaseExpiresAt: undefined,
|
||||
});
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
export const cancel = mutation({
|
||||
args: { commandId: v.id("daemonCommands") },
|
||||
handler: async (ctx, args) => {
|
||||
const command = await ctx.db.get("daemonCommands", args.commandId);
|
||||
if (
|
||||
!command ||
|
||||
["succeeded", "failed", "cancelled"].includes(command.status)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const timestamp = Date.now();
|
||||
await ctx.db.patch("daemonCommands", command._id, {
|
||||
status: "cancelled",
|
||||
completedAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
leaseExpiresAt: undefined,
|
||||
});
|
||||
return true;
|
||||
},
|
||||
});
|
||||
107
packages/backend/convex/daemonRuntime.ts
Normal file
107
packages/backend/convex/daemonRuntime.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { v } from "convex/values";
|
||||
|
||||
import { mutation, query } from "./_generated/server";
|
||||
|
||||
export const connect = mutation({
|
||||
args: {
|
||||
daemonId: v.string(),
|
||||
sessionId: v.string(),
|
||||
hostname: v.string(),
|
||||
platform: v.string(),
|
||||
architecture: v.string(),
|
||||
version: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const existing = await ctx.db
|
||||
.query("daemonPresence")
|
||||
.withIndex("by_sessionId", (q) => q.eq("sessionId", args.sessionId))
|
||||
.unique();
|
||||
const timestamp = Date.now();
|
||||
if (existing) {
|
||||
await ctx.db.patch("daemonPresence", existing._id, {
|
||||
status: "online",
|
||||
lastHeartbeatAt: timestamp,
|
||||
});
|
||||
return existing._id;
|
||||
}
|
||||
return await ctx.db.insert("daemonPresence", {
|
||||
...args,
|
||||
status: "online",
|
||||
startedAt: timestamp,
|
||||
lastHeartbeatAt: timestamp,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const heartbeat = mutation({
|
||||
args: {
|
||||
daemonId: v.string(),
|
||||
sessionId: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const presence = await ctx.db
|
||||
.query("daemonPresence")
|
||||
.withIndex("by_sessionId", (q) => q.eq("sessionId", args.sessionId))
|
||||
.unique();
|
||||
if (!presence || presence.daemonId !== args.daemonId) {
|
||||
return false;
|
||||
}
|
||||
await ctx.db.patch("daemonPresence", presence._id, {
|
||||
status: "online",
|
||||
lastHeartbeatAt: Date.now(),
|
||||
});
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
export const disconnect = mutation({
|
||||
args: {
|
||||
daemonId: v.string(),
|
||||
sessionId: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const presence = await ctx.db
|
||||
.query("daemonPresence")
|
||||
.withIndex("by_sessionId", (q) => q.eq("sessionId", args.sessionId))
|
||||
.unique();
|
||||
if (!presence || presence.daemonId !== args.daemonId) {
|
||||
return false;
|
||||
}
|
||||
await ctx.db.patch("daemonPresence", presence._id, {
|
||||
status: "offline",
|
||||
lastHeartbeatAt: Date.now(),
|
||||
});
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
export const recordEvent = mutation({
|
||||
args: {
|
||||
daemonId: v.string(),
|
||||
commandId: v.optional(v.id("daemonCommands")),
|
||||
kind: v.string(),
|
||||
data: v.any(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
return await ctx.db.insert("daemonCommandEvents", {
|
||||
...args,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const listEvents = query({
|
||||
args: {
|
||||
daemonId: v.string(),
|
||||
limit: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
return await ctx.db
|
||||
.query("daemonCommandEvents")
|
||||
.withIndex("by_daemonId_and_createdAt", (q) =>
|
||||
q.eq("daemonId", args.daemonId)
|
||||
)
|
||||
.order("desc")
|
||||
.take(Math.min(args.limit ?? 100, 500));
|
||||
},
|
||||
});
|
||||
60
packages/backend/convex/daemons.ts
Normal file
60
packages/backend/convex/daemons.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { v } from "convex/values";
|
||||
|
||||
import { mutation, query } from "./_generated/server";
|
||||
|
||||
const now = () => Date.now();
|
||||
|
||||
export const upsert = mutation({
|
||||
args: {
|
||||
daemonId: v.string(),
|
||||
name: v.string(),
|
||||
desiredState: v.union(v.literal("online"), v.literal("offline")),
|
||||
agentOsConfig: v.any(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const existing = await ctx.db
|
||||
.query("daemonDefinitions")
|
||||
.withIndex("by_daemonId", (q) => q.eq("daemonId", args.daemonId))
|
||||
.unique();
|
||||
const timestamp = now();
|
||||
if (existing) {
|
||||
await ctx.db.patch("daemonDefinitions", existing._id, {
|
||||
name: args.name,
|
||||
desiredState: args.desiredState,
|
||||
agentOsConfig: args.agentOsConfig,
|
||||
configVersion: existing.configVersion + 1,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
return existing._id;
|
||||
}
|
||||
return await ctx.db.insert("daemonDefinitions", {
|
||||
...args,
|
||||
configVersion: 1,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const get = query({
|
||||
args: { daemonId: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const definition = await ctx.db
|
||||
.query("daemonDefinitions")
|
||||
.withIndex("by_daemonId", (q) => q.eq("daemonId", args.daemonId))
|
||||
.unique();
|
||||
const presence = await ctx.db
|
||||
.query("daemonPresence")
|
||||
.withIndex("by_daemonId", (q) => q.eq("daemonId", args.daemonId))
|
||||
.order("desc")
|
||||
.first();
|
||||
return { definition, presence };
|
||||
},
|
||||
});
|
||||
|
||||
export const list = query({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
return await ctx.db.query("daemonDefinitions").order("desc").collect();
|
||||
},
|
||||
});
|
||||
@@ -2,6 +2,66 @@ import { defineSchema, defineTable } from "convex/server";
|
||||
import { v } from "convex/values";
|
||||
|
||||
export default defineSchema({
|
||||
daemonDefinitions: defineTable({
|
||||
daemonId: v.string(),
|
||||
name: v.string(),
|
||||
desiredState: v.union(v.literal("online"), v.literal("offline")),
|
||||
agentOsConfig: v.any(),
|
||||
configVersion: v.number(),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
}).index("by_daemonId", ["daemonId"]),
|
||||
daemonPresence: defineTable({
|
||||
daemonId: v.string(),
|
||||
sessionId: v.string(),
|
||||
status: v.union(
|
||||
v.literal("online"),
|
||||
v.literal("draining"),
|
||||
v.literal("offline")
|
||||
),
|
||||
hostname: v.string(),
|
||||
platform: v.string(),
|
||||
architecture: v.string(),
|
||||
version: v.string(),
|
||||
startedAt: v.number(),
|
||||
lastHeartbeatAt: v.number(),
|
||||
})
|
||||
.index("by_daemonId", ["daemonId"])
|
||||
.index("by_sessionId", ["sessionId"]),
|
||||
daemonCommands: defineTable({
|
||||
daemonId: v.string(),
|
||||
method: v.string(),
|
||||
args: v.any(),
|
||||
actorKey: v.array(v.string()),
|
||||
status: v.union(
|
||||
v.literal("queued"),
|
||||
v.literal("claimed"),
|
||||
v.literal("running"),
|
||||
v.literal("succeeded"),
|
||||
v.literal("failed"),
|
||||
v.literal("cancelled")
|
||||
),
|
||||
attempts: v.number(),
|
||||
claimedBySessionId: v.optional(v.string()),
|
||||
leaseExpiresAt: v.optional(v.number()),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
startedAt: v.optional(v.number()),
|
||||
completedAt: v.optional(v.number()),
|
||||
result: v.optional(v.any()),
|
||||
error: v.optional(v.string()),
|
||||
})
|
||||
.index("by_daemonId_and_status", ["daemonId", "status"])
|
||||
.index("by_status_and_leaseExpiresAt", ["status", "leaseExpiresAt"]),
|
||||
daemonCommandEvents: defineTable({
|
||||
daemonId: v.string(),
|
||||
commandId: v.optional(v.id("daemonCommands")),
|
||||
kind: v.string(),
|
||||
data: v.any(),
|
||||
createdAt: v.number(),
|
||||
})
|
||||
.index("by_daemonId_and_createdAt", ["daemonId", "createdAt"])
|
||||
.index("by_commandId_and_createdAt", ["commandId", "createdAt"]),
|
||||
todos: defineTable({
|
||||
text: v.string(),
|
||||
completed: v.boolean(),
|
||||
|
||||
Reference in New Issue
Block a user