diff --git a/README.md b/README.md index 30bfd11..21be2b2 100644 --- a/README.md +++ b/README.md @@ -92,14 +92,27 @@ over an stdin/stdout RPC protocol. The server starts one **Prime** agent per session and spawns sub-agents on demand, streaming their events to the UI over Socket.IO rooms. -- **REST API** (`/api/*`) — sessions CRUD, file uploads and artifact serving, agent bundles, - global memory, and current-user lookup. +- **REST API** (`/api/*`) — sessions CRUD, external session launches, file uploads and artifact + serving, agent bundles, global memory, and current-user lookup. - **Internal API** (`/internal/*`) — called by agents (guarded by a bearer token) to spawn and message sub-agents, read/write memory, manage triggers, and make sandboxed egress requests through an allowlist. - **WebSocket events** — streaming assistant deltas, tool/thinking activity, sub-agent roster updates, memory suggestions, trigger updates, and chat messages. +External systems can create a bundle-backed session and immediately prompt its Prime agent: + +```bash +curl -u "$INGRESS_USERNAME:$INGRESS_PASSWORD" \ + -H "content-type: application/json" \ + -d '{"bundleId":"tangle-oss","prompt":"Investigate the latest failed run"}' \ + https://tangent.example.com/api/session-launches +``` + +The endpoint returns `201 Created` with `{ "sessionId": "..." }`. Configure machine +authentication for this path at the deployment ingress or service proxy; shared credentials +must not be embedded in browser code. + State lives in two places: session **metadata** in SQLite (`sessions`, `sessionAssets`, `sessionAgents` tables), and per-session **data** on disk — artifacts, uploads, memory files, and append-only JSONL chat logs under each session's folder. Schema changes are managed with diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 821d556..300f4b4 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -19,6 +19,7 @@ import { createInternalMemoryRouter } from "./routes/internalMemory.ts"; import { createInternalSessionRouter } from "./routes/internalSession.ts"; import { createInternalTriggersRouter } from "./routes/internalTriggers.ts"; import { createMeRouter } from "./routes/me.ts"; +import { createSessionLaunchesRouter } from "./routes/sessionLaunches.ts"; import { createSessionsRouter } from "./routes/sessions/index.ts"; import { createAgentEventHandler, @@ -99,6 +100,15 @@ app.use( "/api/sessions", createSessionsRouter(store, pi, triggers, triggerEngine, agentBundleStore), ); +app.use( + "/api/session-launches", + createSessionLaunchesRouter({ + store, + pi, + triggerEngine, + agentBundleStore, + }), +); app.use("/api/agent-bundles", createAgentBundlesRouter(agentBundleStore)); app.use("/api/global-memory", createGlobalMemoryRouter(memory)); // Returns the current user, derived from the Oktasso JWT cookie. diff --git a/apps/server/src/routes/sessionLaunches.test.ts b/apps/server/src/routes/sessionLaunches.test.ts new file mode 100644 index 0000000..7ff2a12 --- /dev/null +++ b/apps/server/src/routes/sessionLaunches.test.ts @@ -0,0 +1,135 @@ +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { createServer } from "node:http"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { test } from "node:test"; + +import type { Session } from "@tangent/shared/contracts.ts"; +import express from "express"; +import { strToU8, zipSync } from "fflate"; + +import { errorHandler } from "../middleware/errorHandler.ts"; +import type { PiAgentManager } from "../pi/piAgentManager.ts"; +import type { TriggerEngine } from "../pi/triggers/triggerEngine.ts"; +import type { AgentBundleStore } from "../store/agentBundleStore.ts"; +import type { SessionStore } from "../store/sessionStore.ts"; +import { createSessionLaunchesRouter } from "./sessionLaunches.ts"; + +const BUNDLE = Buffer.from( + zipSync({ + "tangent.yaml": strToU8(`schemaVersion: 1 +id: test-bundle +name: Test Bundle +version: 1.0.0 +prime: + systemPrompt: prompts/prime.md + tools: [] +`), + "prompts/prime.md": strToU8("You are Prime."), + }), +); + +function launchDependencies(rootPath: string, prompts: string[]) { + const session: Session = { + id: "session-1", + name: "Session 1", + rootPath, + status: "created", + archived: false, + createdAt: "2026-08-12T00:00:00.000Z", + updatedAt: "2026-08-12T00:00:00.000Z", + }; + const store = { + createSession: async () => session, + attachConfig: async (_id, config) => ({ ...session, config }), + appendMessage: async () => {}, + deleteSession: async () => true, + } satisfies Pick< + SessionStore, + "createSession" | "attachConfig" | "appendMessage" | "deleteSession" + >; + const pi = { + ensure: () => {}, + prompt: (_sessionId, _rootPath, prompt) => prompts.push(prompt), + dispose: () => {}, + } satisfies Pick; + const triggerEngine = { + seed: () => {}, + dispose: () => {}, + } satisfies Pick; + const agentBundleStore = { + readBundle: async () => BUNDLE, + } satisfies Pick; + + return { store, pi, triggerEngine, agentBundleStore }; +} + +async function startApp() { + const parent = mkdtempSync(path.join(tmpdir(), "session-launches-")); + const rootPath = path.join(parent, "session-1"); + mkdirSync(rootPath); + const prompts: string[] = []; + const app = express(); + app.use(express.json()); + app.use( + "/api/session-launches", + createSessionLaunchesRouter(launchDependencies(rootPath, prompts)), + ); + app.use(errorHandler); + + const server = createServer(app); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Test server did not bind to a TCP port"); + } + + return { + url: `http://127.0.0.1:${address.port}/api/session-launches`, + prompts, + close: async () => { + await new Promise((resolve) => server.close(() => resolve())); + rmSync(parent, { recursive: true, force: true }); + }, + }; +} + +async function post(url: string, body: unknown): Promise { + return fetch(url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +test("launches and prompts a session", async () => { + const app = await startApp(); + try { + const response = await post(app.url, { + bundleId: "test-bundle", + prompt: "Investigate the failure", + }); + + assert.equal(response.status, 201); + assert.deepEqual(await response.json(), { sessionId: "session-1" }); + assert.deepEqual(app.prompts, ["Investigate the failure"]); + } finally { + await app.close(); + } +}); + +test("rejects malformed launch requests", async () => { + const app = await startApp(); + try { + const response = await post(app.url, { + bundleId: "test-bundle", + prompt: " ", + }); + + assert.equal(response.status, 400); + assert.deepEqual(app.prompts, []); + } finally { + await app.close(); + } +}); diff --git a/apps/server/src/routes/sessionLaunches.ts b/apps/server/src/routes/sessionLaunches.ts new file mode 100644 index 0000000..c1dcbdf --- /dev/null +++ b/apps/server/src/routes/sessionLaunches.ts @@ -0,0 +1,59 @@ +import type { + LaunchSessionRequest, + LaunchSessionResponse, +} from "@tangent/shared/contracts.ts"; +import { Router } from "express"; +import { z } from "zod"; + +import { getValidated, validate } from "../middleware/validate.ts"; +import type { PiAgentManager } from "../pi/piAgentManager.ts"; +import type { TriggerEngine } from "../pi/triggers/triggerEngine.ts"; +import type { AgentBundleStore } from "../store/agentBundleStore.ts"; +import type { SessionStore } from "../store/sessionStore.ts"; +import { + provisionSession, + SessionProvisioningError, +} from "./sessions/provisionSession.ts"; + +const launchSessionSchema = z + .object({ + bundleId: z.string().trim().min(1), + prompt: z.string().trim().min(1), + }) + .strict(); + +interface SessionLaunchDependencies { + store: Pick< + SessionStore, + "createSession" | "attachConfig" | "appendMessage" | "deleteSession" + >; + pi: Pick; + triggerEngine: Pick; + agentBundleStore: Pick; +} + +export function createSessionLaunchesRouter( + dependencies: SessionLaunchDependencies, +): Router { + const router = Router(); + router.post( + "/", + validate({ body: launchSessionSchema }), + async (req, res) => { + const input = getValidated(req).body; + + try { + const session = await provisionSession(dependencies, input); + const response: LaunchSessionResponse = { sessionId: session.id }; + res.status(201).json(response); + } catch (error) { + if (error instanceof SessionProvisioningError) { + res.status(error.status).json({ error: error.message }); + return; + } + throw error; + } + }, + ); + return router; +} diff --git a/apps/server/src/routes/sessions/handlers.ts b/apps/server/src/routes/sessions/handlers.ts index 6f957bd..c209240 100644 --- a/apps/server/src/routes/sessions/handlers.ts +++ b/apps/server/src/routes/sessions/handlers.ts @@ -1,4 +1,4 @@ -import { randomBytes, randomUUID } from "node:crypto"; +import { randomBytes } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; @@ -6,11 +6,8 @@ import type { Attachment, Session, SessionActivity, - SessionConfigMeta, UploadFilesResponse, - UserIdentity, } from "@tangent/shared/contracts.ts"; -import { PI_AGENT } from "@tangent/shared/contracts.ts"; import type { Request, Response } from "express"; import multer from "multer"; @@ -20,14 +17,16 @@ import { SESSIONS_ROOT, UPLOADS_DIRNAME, } from "../../config.ts"; -import { installBundle } from "../../pi/config/bundleLoader.ts"; import type { PiAgentManager } from "../../pi/piAgentManager.ts"; import type { TriggerEngine } from "../../pi/triggers/triggerEngine.ts"; -import { PRIME_AGENT_ID } from "../../pi/types.ts"; import type { AgentBundleStore } from "../../store/agentBundleStore.ts"; import { readActivity } from "../../store/chatLog.ts"; import type { SessionStore } from "../../store/sessionStore.ts"; import { injectPageBridge } from "./pageBridge.ts"; +import { + provisionSession, + SessionProvisioningError, +} from "./provisionSession.ts"; import type { CreateSessionInput, SessionParams, @@ -131,68 +130,7 @@ function serveArtifact( }); } -/** - * Provisions a new session from an uploaded Configuration Bundle: installs it - * into the session root, records its metadata, and spawns Prime with the - * resolved per-session config. On an invalid bundle the just-created session is - * removed so a failed upload leaves nothing half-provisioned. - */ -async function createSessionFromBundle( - store: SessionStore, - pi: PiAgentManager, - triggerEngine: TriggerEngine, - sessionId: string, - rootPath: string, - zipBuffer: Buffer, - user: UserIdentity | undefined, - res: Response, -): Promise { - try { - const { manifest, config } = await installBundle(zipBuffer, rootPath); - const meta: SessionConfigMeta = { - id: manifest.id, - name: manifest.name, - version: manifest.version, - icon: manifest.icon, - }; - const withConfig = await store.attachConfig(sessionId, meta); - - // Seed the bundle's declared triggers and arm any schedules. - triggerEngine.seed(sessionId, rootPath, manifest.triggers); - - // Pre-seed Prime's first message so the bundle's agent "speaks first" - // (e.g. renders a welcome card). It replays via `chat:history` on join and - // renders any `tangent-ui:*` card because the bundle id is already attached. - if (config.welcomeMessage) { - await store.appendMessage({ - id: randomUUID(), - sessionId, - conversationId: PRIME_AGENT_ID, - author: PI_AGENT, - content: config.welcomeMessage, - createdAt: new Date().toISOString(), - }); - } - - pi.ensure(sessionId, rootPath, config, undefined, user); - res.status(201).json({ session: withConfig }); - } catch (err) { - await store.deleteSession(sessionId); - res.status(400).json({ error: (err as Error).message }); - } -} - -async function resolveCreateBundle( - body: CreateSessionInput, - agentBundleStore: AgentBundleStore, -): Promise { - return (await agentBundleStore.readBundle(body.bundleId)) ?? "not-found"; -} - -/** - * Handles `POST /api/sessions`. Sessions are created from a saved marketplace - * agent bundle so every session carries bundle config metadata. - */ +/** Handles `POST /api/sessions`. */ export async function handleCreateSession( store: SessionStore, pi: PiAgentManager, @@ -202,29 +140,22 @@ export async function handleCreateSession( body: CreateSessionInput, res: Response, ): Promise { - // Resolve any bundle before creating the session so a bad id fails without - // leaving an empty session behind. - const zipBuffer = await resolveCreateBundle(body, agentBundleStore); - if (zipBuffer === "not-found") { - res.status(404).json({ error: "Agent bundle not found" }); - return; + try { + const session = await provisionSession( + { store, pi, triggerEngine, agentBundleStore }, + { + ...body, + user: resolveUserIdentity(req.headers.cookie) ?? undefined, + }, + ); + res.status(201).json({ session }); + } catch (error) { + if (error instanceof SessionProvisioningError) { + res.status(error.status).json({ error: error.message }); + return; + } + throw error; } - - // Resolve the creator's identity from their Oktasso JWT cookie so every agent - // spawned for the session knows who it's helping. - const user = resolveUserIdentity(req.headers.cookie) ?? undefined; - const session = await store.createSession({ name: body.name, user }); - - await createSessionFromBundle( - store, - pi, - triggerEngine, - session.id, - session.rootPath, - zipBuffer, - user, - res, - ); } /** diff --git a/apps/server/src/routes/sessions/provisionSession.test.ts b/apps/server/src/routes/sessions/provisionSession.test.ts new file mode 100644 index 0000000..e70ab77 --- /dev/null +++ b/apps/server/src/routes/sessions/provisionSession.test.ts @@ -0,0 +1,159 @@ +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { test } from "node:test"; + +import type { ChatMessage, Session } from "@tangent/shared/contracts.ts"; +import { strToU8, zipSync } from "fflate"; + +import type { PiAgentManager } from "../../pi/piAgentManager.ts"; +import type { TriggerEngine } from "../../pi/triggers/triggerEngine.ts"; +import type { AgentBundleStore } from "../../store/agentBundleStore.ts"; +import type { SessionStore } from "../../store/sessionStore.ts"; +import { + provisionSession, + SessionProvisioningError, +} from "./provisionSession.ts"; + +const VALID_BUNDLE = Buffer.from( + zipSync({ + "tangent.yaml": strToU8(`schemaVersion: 1 +id: test-bundle +name: Test Bundle +version: 1.0.0 +prime: + systemPrompt: prompts/prime.md + welcomeMessage: prompts/welcome.md + tools: [] +`), + "prompts/prime.md": strToU8("You are Prime."), + "prompts/welcome.md": strToU8("Welcome"), + }), +); + +function sessionAt(rootPath: string): Session { + return { + id: "session-1", + name: "Session 1", + rootPath, + status: "created", + archived: false, + createdAt: "2026-08-12T00:00:00.000Z", + updatedAt: "2026-08-12T00:00:00.000Z", + }; +} + +function fixture(bundle: Buffer | null = VALID_BUNDLE) { + const parent = mkdtempSync(path.join(tmpdir(), "provision-session-")); + const rootPath = path.join(parent, "session-1"); + mkdirSync(rootPath); + const session = sessionAt(rootPath); + const messages: ChatMessage[] = []; + const calls: string[] = []; + + const store = { + createSession: async () => { + calls.push("create"); + return session; + }, + attachConfig: async (_id, config) => { + calls.push("configure"); + return { ...session, config }; + }, + appendMessage: async (message) => { + calls.push(`message:${message.author.id}`); + messages.push(message); + }, + deleteSession: async () => { + calls.push("delete"); + return true; + }, + } satisfies Pick< + SessionStore, + "createSession" | "attachConfig" | "appendMessage" | "deleteSession" + >; + const pi = { + ensure: () => calls.push("ensure"), + prompt: () => calls.push("prompt"), + dispose: () => calls.push("dispose"), + } satisfies Pick; + const triggerEngine = { + seed: () => calls.push("seed"), + dispose: () => calls.push("dispose-triggers"), + } satisfies Pick; + const agentBundleStore = { + readBundle: async () => bundle ?? undefined, + } satisfies Pick; + + return { + dependencies: { store, pi, triggerEngine, agentBundleStore }, + rootPath, + messages, + calls, + cleanup: () => rmSync(parent, { recursive: true, force: true }), + }; +} + +test("provisions a bundle session and sends its initial prompt", async () => { + const context = fixture(); + try { + const session = await provisionSession(context.dependencies, { + bundleId: "test-bundle", + prompt: "Investigate the failure", + }); + + assert.equal(session.config?.id, "test-bundle"); + assert.deepEqual( + context.messages.map(({ author, content }) => [author.id, content]), + [ + ["prime", "Welcome"], + ["api-user", "Investigate the failure"], + ], + ); + assert.deepEqual(context.calls, [ + "create", + "configure", + "seed", + "message:prime", + "ensure", + "message:api-user", + "prompt", + ]); + } finally { + context.cleanup(); + } +}); + +test("rejects an unknown bundle before creating a session", async () => { + const context = fixture(null); + try { + await assert.rejects( + () => provisionSession(context.dependencies, { bundleId: "missing" }), + (error: unknown) => + error instanceof SessionProvisioningError && error.status === 404, + ); + assert.deepEqual(context.calls, []); + } finally { + context.cleanup(); + } +}); + +test("rolls back a session when its bundle is invalid", async () => { + const context = fixture(Buffer.from("not a zip")); + try { + await assert.rejects( + () => provisionSession(context.dependencies, { bundleId: "invalid" }), + (error: unknown) => + error instanceof SessionProvisioningError && error.status === 400, + ); + assert.deepEqual(context.calls, [ + "create", + "dispose", + "dispose-triggers", + "delete", + ]); + } finally { + context.cleanup(); + } +}); diff --git a/apps/server/src/routes/sessions/provisionSession.ts b/apps/server/src/routes/sessions/provisionSession.ts new file mode 100644 index 0000000..d33cd94 --- /dev/null +++ b/apps/server/src/routes/sessions/provisionSession.ts @@ -0,0 +1,160 @@ +import { randomUUID } from "node:crypto"; +import { rm } from "node:fs/promises"; + +import { + type ChatAuthor, + PI_AGENT, + type Session, + type SessionConfigMeta, + type UserIdentity, +} from "@tangent/shared/contracts.ts"; + +import { installBundle } from "../../pi/config/bundleLoader.ts"; +import type { PiAgentManager } from "../../pi/piAgentManager.ts"; +import type { TriggerEngine } from "../../pi/triggers/triggerEngine.ts"; +import { PRIME_AGENT_ID } from "../../pi/types.ts"; +import type { AgentBundleStore } from "../../store/agentBundleStore.ts"; +import type { SessionStore } from "../../store/sessionStore.ts"; + +interface ProvisionSessionDependencies { + store: Pick< + SessionStore, + "createSession" | "attachConfig" | "appendMessage" | "deleteSession" + >; + pi: Pick; + triggerEngine: Pick; + agentBundleStore: Pick; +} + +export interface ProvisionSessionInput { + bundleId: string; + name?: string; + prompt?: string; + user?: UserIdentity; +} + +export class SessionProvisioningError extends Error { + readonly status: 400 | 404; + + constructor(message: string, status: 400 | 404) { + super(message); + this.status = status; + } +} + +function promptAuthor(user: UserIdentity | undefined): ChatAuthor { + if (!user) { + return { id: "api-user", kind: "human", name: "API" }; + } + + return { + id: user.email, + kind: "human", + name: user.first_name || user.email, + }; +} + +async function installSessionBundle(bundle: Buffer, rootPath: string) { + try { + return await installBundle(bundle, rootPath); + } catch (error) { + const message = error instanceof Error ? error.message : "Invalid bundle"; + throw new SessionProvisioningError(message, 400); + } +} + +async function appendMessage( + store: Pick, + sessionId: string, + author: ChatAuthor, + content: string, +): Promise { + await store.appendMessage({ + id: randomUUID(), + sessionId, + conversationId: PRIME_AGENT_ID, + author, + content, + createdAt: new Date().toISOString(), + }); +} + +async function configureSession( + dependencies: ProvisionSessionDependencies, + input: ProvisionSessionInput, + session: Session, + bundle: Buffer, +): Promise { + const { store, pi, triggerEngine } = dependencies; + const installed = await installSessionBundle(bundle, session.rootPath); + const configMeta: SessionConfigMeta = { + id: installed.manifest.id, + name: installed.manifest.name, + version: installed.manifest.version, + icon: installed.manifest.icon, + }; + const configuredSession = await store.attachConfig(session.id, configMeta); + if (!configuredSession) { + throw new Error("Unable to configure the created session"); + } + + triggerEngine.seed(session.id, session.rootPath, installed.manifest.triggers); + if (installed.config.welcomeMessage) { + await appendMessage( + store, + session.id, + PI_AGENT, + installed.config.welcomeMessage, + ); + } + + pi.ensure( + session.id, + session.rootPath, + installed.config, + undefined, + input.user, + ); + if (input.prompt) { + await appendMessage( + store, + session.id, + promptAuthor(input.user), + input.prompt, + ); + pi.prompt(session.id, session.rootPath, input.prompt); + } + + return configuredSession; +} + +async function rollbackSession( + dependencies: ProvisionSessionDependencies, + session: Session, +): Promise { + dependencies.pi.dispose(session.id); + dependencies.triggerEngine.dispose(session.id); + await dependencies.store.deleteSession(session.id); + await rm(session.rootPath, { recursive: true, force: true }); +} + +export async function provisionSession( + dependencies: ProvisionSessionDependencies, + input: ProvisionSessionInput, +): Promise { + const bundle = await dependencies.agentBundleStore.readBundle(input.bundleId); + if (!bundle) { + throw new SessionProvisioningError("Agent bundle not found", 404); + } + + const session = await dependencies.store.createSession({ + name: input.name, + user: input.user, + }); + try { + return await configureSession(dependencies, input, session, bundle); + } catch (error) { + await rollbackSession(dependencies, session); + throw error; + } +} diff --git a/packages/shared/src/contracts.ts b/packages/shared/src/contracts.ts index 650b71d..2402f4d 100644 --- a/packages/shared/src/contracts.ts +++ b/packages/shared/src/contracts.ts @@ -446,6 +446,15 @@ export interface CreateSessionRequest { bundleId: string; } +export interface LaunchSessionRequest { + bundleId: string; + prompt: string; +} + +export interface LaunchSessionResponse { + sessionId: string; +} + export interface UpdateSessionRequest { name?: string; archived?: boolean;