Add AgentOS daemon control plane and maintenance tooling
This commit is contained in:
7
apps/daemon/.env.example
Normal file
7
apps/daemon/.env.example
Normal file
@@ -0,0 +1,7 @@
|
||||
CONVEX_URL=https://example.convex.cloud
|
||||
DAEMON_ID=local-macbook
|
||||
DAEMON_NAME=Local MacBook
|
||||
DAEMON_VERSION=0.0.0
|
||||
DAEMON_HEARTBEAT_MS=15000
|
||||
DAEMON_COMMAND_LEASE_MS=60000
|
||||
# RIVET_ENDPOINT=http://localhost:6420
|
||||
29
apps/daemon/package.json
Normal file
29
apps/daemon/package.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "daemon",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"module": "src/index.ts",
|
||||
"scripts": {
|
||||
"dev": "bun run --watch src/index.ts",
|
||||
"build": "bun build src/index.ts --compile --outfile dist/code-daemon",
|
||||
"start": "./dist/code-daemon",
|
||||
"check-types": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@code/backend": "workspace:*",
|
||||
"@code/env": "workspace:*",
|
||||
"@code/primitives": "workspace:*",
|
||||
"@effect/platform-bun": "4.0.0-beta.99",
|
||||
"@rivet-dev/agentos": "^0.2.7",
|
||||
"convex": "catalog:",
|
||||
"effect": "4.0.0-beta.99",
|
||||
"rivetkit": "0.0.0-stack-feat-rivetkit-forward-inspector-tabs-to-native-plugin-actors-qkwmksyy.d2859b0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@code/config": "workspace:*",
|
||||
"@types/bun": "latest",
|
||||
"@types/node": "^22.13.14",
|
||||
"typescript": "^6"
|
||||
}
|
||||
}
|
||||
74
apps/daemon/src/agent-os.ts
Normal file
74
apps/daemon/src/agent-os.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import type { Id } from "@code/backend/convex/_generated/dataModel";
|
||||
import { env } from "@code/env/server";
|
||||
import { AgentOs, createAgentOsActorEffect } from "@code/primitives/agent-os";
|
||||
import { Effect, Schema } from "effect";
|
||||
import { setup } from "rivetkit";
|
||||
import { createClient } from "rivetkit/client";
|
||||
|
||||
import type { DaemonCommand } from "./convex";
|
||||
import { ConvexControlPlane } from "./convex";
|
||||
|
||||
export class AgentOsCommandError extends Schema.TaggedErrorClass<AgentOsCommandError>()(
|
||||
"AgentOsCommandError",
|
||||
{
|
||||
method: Schema.String,
|
||||
cause: Schema.Defect(),
|
||||
}
|
||||
) {}
|
||||
|
||||
const asArgs = (value: unknown): unknown[] => {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new TypeError("agentOS command args must be an array");
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
export const makeAgentOsRuntime = Effect.gen(function* () {
|
||||
const actor = yield* createAgentOsActorEffect();
|
||||
const registry = setup({ use: { agentOs: actor } });
|
||||
registry.start();
|
||||
const client = createClient<typeof registry>(env.RIVET_ENDPOINT);
|
||||
|
||||
const execute = Effect.fn("AgentOsRuntime.execute")(function* (
|
||||
command: DaemonCommand
|
||||
) {
|
||||
const controlPlane = yield* ConvexControlPlane;
|
||||
const handle = client.agentOs.getOrCreate([...command.actorKey]);
|
||||
return yield* Effect.tryPromise({
|
||||
try: () =>
|
||||
handle.action({
|
||||
name: command.method,
|
||||
args: asArgs(command.args),
|
||||
}),
|
||||
catch: (cause) =>
|
||||
new AgentOsCommandError({ method: command.method, cause }),
|
||||
}).pipe(
|
||||
Effect.tap((result) =>
|
||||
controlPlane.recordEvent({
|
||||
commandId: command._id,
|
||||
kind: "agentos.action.succeeded",
|
||||
data: { method: command.method, result },
|
||||
})
|
||||
),
|
||||
Effect.tapError((error) =>
|
||||
controlPlane.recordEvent({
|
||||
commandId: command._id,
|
||||
kind: "agentos.action.failed",
|
||||
data: { method: command.method, error: String(error.cause) },
|
||||
})
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
const recordLifecycleEvent = (
|
||||
commandId: Id<"daemonCommands"> | undefined,
|
||||
kind: string,
|
||||
data: unknown
|
||||
) =>
|
||||
Effect.gen(function* () {
|
||||
const controlPlane = yield* ConvexControlPlane;
|
||||
yield* controlPlane.recordEvent({ commandId, kind, data });
|
||||
});
|
||||
|
||||
return { execute, recordLifecycleEvent } as const;
|
||||
}).pipe(Effect.provide(AgentOs.layer));
|
||||
174
apps/daemon/src/convex.ts
Normal file
174
apps/daemon/src/convex.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import { api } from "@code/backend/convex/_generated/api";
|
||||
import type { Id } from "@code/backend/convex/_generated/dataModel";
|
||||
import { env } from "@code/env/server";
|
||||
import { ConvexClient } from "convex/browser";
|
||||
import { Cause, Context, Effect, Layer, Queue, Schema, Stream } from "effect";
|
||||
|
||||
export class ConvexControlPlaneError extends Schema.TaggedErrorClass<ConvexControlPlaneError>()(
|
||||
"ConvexControlPlaneError",
|
||||
{
|
||||
operation: Schema.String,
|
||||
cause: Schema.Defect(),
|
||||
}
|
||||
) {}
|
||||
|
||||
export interface DaemonCommand {
|
||||
readonly _id: Id<"daemonCommands">;
|
||||
readonly daemonId: string;
|
||||
readonly actorKey: readonly string[];
|
||||
readonly method: string;
|
||||
readonly args: unknown;
|
||||
}
|
||||
|
||||
const tryPromise = <A>(operation: string, evaluate: () => Promise<A>) =>
|
||||
Effect.tryPromise({
|
||||
try: evaluate,
|
||||
catch: (cause) => new ConvexControlPlaneError({ operation, cause }),
|
||||
});
|
||||
|
||||
export class ConvexControlPlane extends Context.Service<
|
||||
ConvexControlPlane,
|
||||
{
|
||||
readonly commands: Stream.Stream<
|
||||
readonly DaemonCommand[],
|
||||
ConvexControlPlaneError
|
||||
>;
|
||||
readonly connect: (input: {
|
||||
readonly sessionId: string;
|
||||
readonly hostname: string;
|
||||
readonly platform: string;
|
||||
readonly architecture: string;
|
||||
}) => Effect.Effect<void, ConvexControlPlaneError>;
|
||||
readonly heartbeat: (
|
||||
sessionId: string
|
||||
) => Effect.Effect<void, ConvexControlPlaneError>;
|
||||
readonly disconnect: (
|
||||
sessionId: string
|
||||
) => Effect.Effect<void, ConvexControlPlaneError>;
|
||||
readonly claim: (
|
||||
commandId: Id<"daemonCommands">,
|
||||
sessionId: string
|
||||
) => Effect.Effect<DaemonCommand | null, ConvexControlPlaneError>;
|
||||
readonly start: (
|
||||
commandId: Id<"daemonCommands">,
|
||||
sessionId: string
|
||||
) => Effect.Effect<boolean, ConvexControlPlaneError>;
|
||||
readonly succeed: (
|
||||
commandId: Id<"daemonCommands">,
|
||||
sessionId: string,
|
||||
result: unknown
|
||||
) => Effect.Effect<boolean, ConvexControlPlaneError>;
|
||||
readonly fail: (
|
||||
commandId: Id<"daemonCommands">,
|
||||
sessionId: string,
|
||||
error: string
|
||||
) => Effect.Effect<boolean, ConvexControlPlaneError>;
|
||||
readonly recordEvent: (input: {
|
||||
readonly commandId?: Id<"daemonCommands">;
|
||||
readonly kind: string;
|
||||
readonly data: unknown;
|
||||
}) => Effect.Effect<void, ConvexControlPlaneError>;
|
||||
readonly close: Effect.Effect<void, ConvexControlPlaneError>;
|
||||
}
|
||||
>()("@code/daemon/ConvexControlPlane") {
|
||||
static readonly layer = Layer.effect(
|
||||
ConvexControlPlane,
|
||||
Effect.gen(function* () {
|
||||
const client = yield* Effect.acquireRelease(
|
||||
Effect.sync(() => new ConvexClient(env.CONVEX_URL)),
|
||||
(client) => Effect.promise(() => client.close())
|
||||
);
|
||||
const commands = Stream.callback<
|
||||
readonly DaemonCommand[],
|
||||
ConvexControlPlaneError
|
||||
>((queue) =>
|
||||
Effect.acquireRelease(
|
||||
Effect.sync(() =>
|
||||
client.onUpdate(
|
||||
api.daemonCommands.available,
|
||||
{ daemonId: env.DAEMON_ID },
|
||||
(items) => {
|
||||
Queue.offerUnsafe(queue, items);
|
||||
},
|
||||
(cause) => {
|
||||
Queue.failCauseUnsafe(
|
||||
queue,
|
||||
Cause.fail(
|
||||
new ConvexControlPlaneError({
|
||||
operation: "subscribe commands",
|
||||
cause,
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
)
|
||||
),
|
||||
(unsubscribe) => Effect.sync(unsubscribe)
|
||||
)
|
||||
);
|
||||
|
||||
return ConvexControlPlane.of({
|
||||
commands,
|
||||
connect: (input) =>
|
||||
tryPromise("connect daemon", async () => {
|
||||
await client.mutation(api.daemonRuntime.connect, {
|
||||
daemonId: env.DAEMON_ID,
|
||||
version: env.DAEMON_VERSION,
|
||||
...input,
|
||||
});
|
||||
}),
|
||||
heartbeat: (sessionId) =>
|
||||
tryPromise("heartbeat daemon", async () => {
|
||||
await client.mutation(api.daemonRuntime.heartbeat, {
|
||||
daemonId: env.DAEMON_ID,
|
||||
sessionId,
|
||||
});
|
||||
}),
|
||||
disconnect: (sessionId) =>
|
||||
tryPromise("disconnect daemon", async () => {
|
||||
await client.mutation(api.daemonRuntime.disconnect, {
|
||||
daemonId: env.DAEMON_ID,
|
||||
sessionId,
|
||||
});
|
||||
}),
|
||||
claim: (commandId, sessionId) =>
|
||||
tryPromise("claim command", () =>
|
||||
client.mutation(api.daemonCommands.claim, {
|
||||
commandId,
|
||||
daemonId: env.DAEMON_ID,
|
||||
sessionId,
|
||||
leaseDurationMs: env.DAEMON_COMMAND_LEASE_MS,
|
||||
})
|
||||
),
|
||||
start: (commandId, sessionId) =>
|
||||
tryPromise("start command", () =>
|
||||
client.mutation(api.daemonCommands.start, { commandId, sessionId })
|
||||
),
|
||||
succeed: (commandId, sessionId, result) =>
|
||||
tryPromise("complete command", () =>
|
||||
client.mutation(api.daemonCommands.succeed, {
|
||||
commandId,
|
||||
sessionId,
|
||||
result,
|
||||
})
|
||||
),
|
||||
fail: (commandId, sessionId, error) =>
|
||||
tryPromise("fail command", () =>
|
||||
client.mutation(api.daemonCommands.fail, {
|
||||
commandId,
|
||||
sessionId,
|
||||
error,
|
||||
})
|
||||
),
|
||||
recordEvent: (input) =>
|
||||
tryPromise("record daemon event", async () => {
|
||||
await client.mutation(api.daemonRuntime.recordEvent, {
|
||||
daemonId: env.DAEMON_ID,
|
||||
...input,
|
||||
});
|
||||
}),
|
||||
close: tryPromise("close Convex client", () => client.close()),
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
7
apps/daemon/src/index.ts
Normal file
7
apps/daemon/src/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { BunRuntime } from "@effect/platform-bun";
|
||||
import { Effect } from "effect";
|
||||
|
||||
import { ConvexControlPlane } from "./convex";
|
||||
import { runDaemon } from "./runtime";
|
||||
|
||||
BunRuntime.runMain(runDaemon.pipe(Effect.provide(ConvexControlPlane.layer)));
|
||||
83
apps/daemon/src/runtime.ts
Normal file
83
apps/daemon/src/runtime.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { env } from "@code/env/server";
|
||||
import { Console, Effect, FiberSet, Result, Stream } from "effect";
|
||||
|
||||
import { makeAgentOsRuntime } from "./agent-os";
|
||||
import { ConvexControlPlane, type DaemonCommand } from "./convex";
|
||||
|
||||
const errorMessage = (cause: unknown) =>
|
||||
cause instanceof Error ? cause.message : String(cause);
|
||||
|
||||
export const runDaemon = Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const controlPlane = yield* ConvexControlPlane;
|
||||
const agentOs = yield* makeAgentOsRuntime;
|
||||
const sessionId = crypto.randomUUID();
|
||||
const hostname = yield* Effect.promise(() => import("node:os")).pipe(
|
||||
Effect.map((os) => os.hostname())
|
||||
);
|
||||
const fibers = yield* FiberSet.make<void>();
|
||||
|
||||
yield* controlPlane.connect({
|
||||
sessionId,
|
||||
hostname,
|
||||
platform: process.platform,
|
||||
architecture: process.arch,
|
||||
});
|
||||
yield* Console.log(`daemon ${env.DAEMON_ID} connected as ${sessionId}`);
|
||||
|
||||
yield* Effect.addFinalizer(() =>
|
||||
controlPlane
|
||||
.disconnect(sessionId)
|
||||
.pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Console.error("failed to mark daemon offline", cause)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
yield* Stream.tick(env.DAEMON_HEARTBEAT_MS).pipe(
|
||||
Stream.runForEach(() => controlPlane.heartbeat(sessionId)),
|
||||
Effect.forkScoped
|
||||
);
|
||||
|
||||
const processCommand = Effect.fn("Daemon.processCommand")(function* (
|
||||
candidate: DaemonCommand
|
||||
) {
|
||||
const command = yield* controlPlane.claim(candidate._id, sessionId);
|
||||
if (!command) {
|
||||
return;
|
||||
}
|
||||
const started = yield* controlPlane.start(command._id, sessionId);
|
||||
if (!started) {
|
||||
return;
|
||||
}
|
||||
yield* controlPlane.recordEvent({
|
||||
commandId: command._id,
|
||||
kind: "command.started",
|
||||
data: { method: command.method, actorKey: command.actorKey },
|
||||
});
|
||||
const result = yield* agentOs.execute(command).pipe(Effect.result);
|
||||
if (Result.isSuccess(result)) {
|
||||
yield* controlPlane.succeed(command._id, sessionId, result.success);
|
||||
return;
|
||||
}
|
||||
yield* controlPlane.fail(
|
||||
command._id,
|
||||
sessionId,
|
||||
errorMessage(result.failure)
|
||||
);
|
||||
});
|
||||
|
||||
yield* controlPlane.commands.pipe(
|
||||
Stream.runForEach((commands) =>
|
||||
Effect.forEach(
|
||||
commands,
|
||||
(command) => FiberSet.run(fibers, processCommand(command)),
|
||||
{
|
||||
discard: true,
|
||||
}
|
||||
)
|
||||
)
|
||||
);
|
||||
})
|
||||
);
|
||||
8
apps/daemon/tsconfig.json
Normal file
8
apps/daemon/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@code/config/tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"types": ["node", "bun"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user