From 1c0662603d2b136600b39ab21dacaa6f0d16b876 Mon Sep 17 00:00:00 2001 From: mbeaulne Date: Tue, 11 Aug 2026 13:47:14 -0400 Subject: [PATCH] Keep external launch documentation vendor-neutral --- README.md | 17 ++ apps/server/src/index.ts | 16 +- .../server/src/routes/sessionLaunches.test.ts | 143 +++++++++++++ apps/server/src/routes/sessionLaunches.ts | 62 ++++++ .../src/routes/sessions/createSession.test.ts | 60 +++--- apps/server/src/routes/sessions/handlers.ts | 121 +++-------- apps/server/src/routes/sessions/index.ts | 21 +- .../sessions/sessionProvisioner.test.ts | 197 ++++++++++++++++++ .../src/routes/sessions/sessionProvisioner.ts | 179 ++++++++++++++++ docs/server/external-session-launches.md | 54 +++++ packages/shared/src/contracts.ts | 11 + 11 files changed, 743 insertions(+), 138 deletions(-) create mode 100644 apps/server/src/routes/sessionLaunches.test.ts create mode 100644 apps/server/src/routes/sessionLaunches.ts create mode 100644 apps/server/src/routes/sessions/sessionProvisioner.test.ts create mode 100644 apps/server/src/routes/sessions/sessionProvisioner.ts create mode 100644 docs/server/external-session-launches.md diff --git a/README.md b/README.md index 30bfd11..a5fccf1 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,23 @@ Socket.IO rooms. - **WebSocket events** — streaming assistant deltas, tool/thinking activity, sub-agent roster updates, memory suggestions, trigger updates, and chat messages. +### External session creation + +External tools can create a bundle-backed session and immediately send its first message to +Prime through the dedicated `POST /api/session-launches` endpoint. + +```bash +curl -X POST https://tangent.example.com/api/session-launches \ + -u "$TANGENT_USERNAME:$TANGENT_PASSWORD" \ + -H 'content-type: application/json' \ + -d '{"bundleId":"tangle-oss","prompt":"Investigate the latest failed run"}' +``` + +The endpoint returns `201 Created` with the new session. Basic Auth is configured at the +deployment ingress, not inside Express. See +[`docs/server/external-session-launches.md`](docs/server/external-session-launches.md) for the +full contract and deployment requirements. + 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..5a0a0da 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -19,7 +19,9 @@ 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 { DefaultSessionProvisioner } from "./routes/sessions/sessionProvisioner.ts"; import { createAgentEventHandler, createAgentMessageHandler, @@ -78,6 +80,12 @@ const pi = new PiAgentManager( // Drives schedule timers and callback firings, delivering prompts to Prime. const triggerEngine = new TriggerEngine(io, store, pi, triggers); +const sessionProvisioner = new DefaultSessionProvisioner( + store, + pi, + triggerEngine, + agentBundleStore, +); app.get("/api/health", (req, res) => { const cookies = Object.fromEntries( @@ -97,7 +105,13 @@ app.get("/api/health", (req, res) => { app.use( "/api/sessions", - createSessionsRouter(store, pi, triggers, triggerEngine, agentBundleStore), + createSessionsRouter(store, pi, triggers, triggerEngine, sessionProvisioner), +); +// External automation entry point. Deployments authenticate this path at their +// ingress or service proxy before forwarding requests to Tangent Shell. +app.use( + "/api/session-launches", + createSessionLaunchesRouter(sessionProvisioner), ); app.use("/api/agent-bundles", createAgentBundlesRouter(agentBundleStore)); app.use("/api/global-memory", createGlobalMemoryRouter(memory)); diff --git a/apps/server/src/routes/sessionLaunches.test.ts b/apps/server/src/routes/sessionLaunches.test.ts new file mode 100644 index 0000000..0c15458 --- /dev/null +++ b/apps/server/src/routes/sessionLaunches.test.ts @@ -0,0 +1,143 @@ +import assert from "node:assert/strict"; +import { createServer } from "node:http"; +import { test } from "node:test"; + +import type { + LaunchSessionResponse, + Session, +} from "@tangent/shared/contracts.ts"; +import express from "express"; + +import { errorHandler } from "../middleware/errorHandler.ts"; +import { + createSessionLaunchesRouter, + launchSessionSchema, +} from "./sessionLaunches.ts"; +import { + AgentBundleNotFoundError, + InvalidAgentBundleError, + type ProvisionSessionInput, + type SessionProvisioner, +} from "./sessions/sessionProvisioner.ts"; + +function session(): Session { + return { + id: "session-1", + name: "Session 1", + rootPath: "/tmp/session-1", + status: "created", + archived: false, + createdAt: "2026-08-11T00:00:00.000Z", + updatedAt: "2026-08-11T00:00:00.000Z", + }; +} + +async function startApp(provisioner: SessionProvisioner) { + const app = express(); + app.use(express.json()); + app.use("/api/session-launches", createSessionLaunchesRouter(provisioner)); + 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`, + close: () => new Promise((resolve) => server.close(() => resolve())), + }; +} + +function fakeProvisioner( + create: (input: ProvisionSessionInput) => Promise, +): SessionProvisioner { + return { create }; +} + +async function post(url: string, body: unknown): Promise { + return fetch(url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +test("launch schema requires exactly bundleId and prompt", () => { + assert.equal( + launchSessionSchema.safeParse({ bundleId: "bundle", prompt: "Run" }) + .success, + true, + ); + assert.equal( + launchSessionSchema.safeParse({ bundleId: "bundle" }).success, + false, + ); + assert.equal( + launchSessionSchema.safeParse({ + bundleId: "bundle", + prompt: "Run", + name: "Unexpected", + }).success, + false, + ); +}); + +test("launch endpoint creates and prompts a session", async () => { + let received: ProvisionSessionInput | undefined; + const provisioner = fakeProvisioner(async (input) => { + received = input; + return session(); + }); + const app = await startApp(provisioner); + + try { + const response = await post(app.url, { + bundleId: "tangle-oss", + prompt: "Investigate the latest failed run", + }); + + assert.equal(response.status, 201); + assert.deepEqual(await response.json(), { + sessionId: "session-1", + } satisfies LaunchSessionResponse); + assert.equal(received?.bundleId, "tangle-oss"); + assert.equal(received?.prompt, "Investigate the latest failed run"); + } finally { + await app.close(); + } +}); + +test("launch endpoint rejects malformed requests", async () => { + const provisioner = fakeProvisioner(async () => session()); + const app = await startApp(provisioner); + + try { + const response = await post(app.url, { bundleId: "tangle-oss" }); + assert.equal(response.status, 400); + } finally { + await app.close(); + } +}); + +test("launch endpoint reports missing and invalid bundles", async () => { + const missingApp = await startApp( + fakeProvisioner(async () => { + throw new AgentBundleNotFoundError("Agent bundle not found"); + }), + ); + const invalidApp = await startApp( + fakeProvisioner(async () => { + throw new InvalidAgentBundleError("Invalid manifest"); + }), + ); + + try { + const body = { bundleId: "bundle", prompt: "Run" }; + assert.equal((await post(missingApp.url, body)).status, 404); + assert.equal((await post(invalidApp.url, body)).status, 400); + } finally { + await missingApp.close(); + await invalidApp.close(); + } +}); diff --git a/apps/server/src/routes/sessionLaunches.ts b/apps/server/src/routes/sessionLaunches.ts new file mode 100644 index 0000000..97d368e --- /dev/null +++ b/apps/server/src/routes/sessionLaunches.ts @@ -0,0 +1,62 @@ +import type { + LaunchSessionRequest, + LaunchSessionResponse, +} from "@tangent/shared/contracts.ts"; +import { type Request, type Response, Router } from "express"; +import { z } from "zod"; + +import { resolveUserIdentity } from "../auth/identity.ts"; +import { getValidated, validate } from "../middleware/validate.ts"; +import { + AgentBundleNotFoundError, + InvalidAgentBundleError, + type SessionProvisioner, +} from "./sessions/sessionProvisioner.ts"; + +export const launchSessionSchema = z + .object({ + bundleId: z.string().trim().min(1), + prompt: z.string().trim().min(1), + }) + .strict(); + +async function handleLaunchSession( + provisioner: SessionProvisioner, + req: Request, + res: Response, +): Promise { + const input = getValidated(req).body; + + try { + const session = await provisioner.create({ + ...input, + user: resolveUserIdentity(req.headers.cookie) ?? undefined, + }); + const response: LaunchSessionResponse = { + sessionId: session.id, + }; + res.status(201).json(response); + } catch (error) { + if (error instanceof AgentBundleNotFoundError) { + res.status(404).json({ error: error.message }); + return; + } + if (error instanceof InvalidAgentBundleError) { + res.status(400).json({ error: error.message }); + return; + } + throw error; + } +} + +export function createSessionLaunchesRouter( + provisioner: SessionProvisioner, +): Router { + const router = Router(); + router.post( + "/", + validate({ body: launchSessionSchema }), + (req: Request, res: Response) => handleLaunchSession(provisioner, req, res), + ); + return router; +} diff --git a/apps/server/src/routes/sessions/createSession.test.ts b/apps/server/src/routes/sessions/createSession.test.ts index 91fb427..0464d92 100644 --- a/apps/server/src/routes/sessions/createSession.test.ts +++ b/apps/server/src/routes/sessions/createSession.test.ts @@ -1,14 +1,13 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import type { Request, Response } from "express"; - -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 { handleCreateSession } from "./handlers.ts"; import { createSessionSchema } from "./schemas.ts"; +import { + AgentBundleNotFoundError, + InvalidAgentBundleError, + type SessionProvisioner, +} from "./sessionProvisioner.ts"; class TestResponse { statusCode = 200; @@ -25,6 +24,14 @@ class TestResponse { } } +function rejectingProvisioner(error: Error): SessionProvisioner { + return { + create: async () => { + throw error; + }, + }; +} + test("createSessionSchema rejects blank create requests", () => { assert.equal(createSessionSchema.safeParse({}).success, false); assert.equal(createSessionSchema.safeParse({ bundleId: "" }).success, false); @@ -35,34 +42,31 @@ test("createSessionSchema rejects blank create requests", () => { }); test("handleCreateSession returns 404 for unknown bundle ids", async () => { - let createSessionCalled = false; - let requestedBundleId: string | undefined; - const store = { - createSession: async () => { - createSessionCalled = true; - throw new Error("createSession should not be called"); - }, - } as unknown as SessionStore; - const agentBundleStore = { - readBundle: async (id: string) => { - requestedBundleId = id; - return undefined; - }, - } as AgentBundleStore; const response = new TestResponse(); await handleCreateSession( - store, - {} as PiAgentManager, - {} as TriggerEngine, - agentBundleStore, - { headers: {} } as Request, + rejectingProvisioner( + new AgentBundleNotFoundError("Agent bundle not found"), + ), + { headers: {} }, { bundleId: "missing-bundle" }, - response as unknown as Response, + response, ); - assert.equal(requestedBundleId, "missing-bundle"); - assert.equal(createSessionCalled, false); assert.equal(response.statusCode, 404); assert.deepEqual(response.body, { error: "Agent bundle not found" }); }); + +test("handleCreateSession returns 400 for invalid bundles", async () => { + const response = new TestResponse(); + + await handleCreateSession( + rejectingProvisioner(new InvalidAgentBundleError("Invalid manifest")), + { headers: {} }, + { bundleId: "broken-bundle" }, + response, + ); + + assert.equal(response.statusCode, 400); + assert.deepEqual(response.body, { error: "Invalid manifest" }); +}); diff --git a/apps/server/src/routes/sessions/handlers.ts b/apps/server/src/routes/sessions/handlers.ts index 6f957bd..58169f5 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,11 +17,8 @@ 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"; @@ -33,6 +27,11 @@ import type { SessionParams, UpdateSessionInput, } from "./schemas.ts"; +import { + AgentBundleNotFoundError, + InvalidAgentBundleError, + type SessionProvisioner, +} from "./sessionProvisioner.ts"; import { isUnsafeId, isWithin, @@ -131,100 +130,36 @@ 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. */ export async function handleCreateSession( - store: SessionStore, - pi: PiAgentManager, - triggerEngine: TriggerEngine, - agentBundleStore: AgentBundleStore, - req: Request, + provisioner: SessionProvisioner, + req: Pick, body: CreateSessionInput, - res: Response, + res: { + status(code: number): { json(body: unknown): unknown }; + json(body: unknown): unknown; + }, ): 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 provisioner.create({ + ...body, + user: resolveUserIdentity(req.headers.cookie) ?? undefined, + }); + res.status(201).json({ session }); + } catch (error) { + if (error instanceof AgentBundleNotFoundError) { + res.status(404).json({ error: error.message }); + return; + } + if (error instanceof InvalidAgentBundleError) { + res.status(400).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/index.ts b/apps/server/src/routes/sessions/index.ts index f421a11..3054789 100644 --- a/apps/server/src/routes/sessions/index.ts +++ b/apps/server/src/routes/sessions/index.ts @@ -4,7 +4,6 @@ import { getValidated, validate } from "../../middleware/validate.ts"; import type { PiAgentManager } from "../../pi/piAgentManager.ts"; import type { TriggerEngine } from "../../pi/triggers/triggerEngine.ts"; import type { TriggerManager } from "../../pi/triggers/triggerManager.ts"; -import type { AgentBundleStore } from "../../store/agentBundleStore.ts"; import type { SessionStore } from "../../store/sessionStore.ts"; import { createArtifactFileHandler, @@ -27,15 +26,14 @@ import { sessionParamsSchema, updateSessionSchema, } from "./schemas.ts"; +import type { SessionProvisioner } from "./sessionProvisioner.ts"; import { registerTriggerRoutes } from "./triggers.ts"; /** Registers the session collection routes (`GET /` list, `POST /` create). */ function registerSessionCollectionRoutes( router: Router, store: SessionStore, - pi: PiAgentManager, - triggerEngine: TriggerEngine, - agentBundleStore: AgentBundleStore, + provisioner: SessionProvisioner, ): void { router.get("/", (req: Request, res: Response) => handleListSessions(store, req, res), @@ -46,10 +44,7 @@ function registerSessionCollectionRoutes( validate({ body: createSessionSchema }), (req: Request, res: Response) => handleCreateSession( - store, - pi, - triggerEngine, - agentBundleStore, + provisioner, req, getValidated(req).body, res, @@ -133,17 +128,11 @@ export function createSessionsRouter( pi: PiAgentManager, triggers: TriggerManager, triggerEngine: TriggerEngine, - agentBundleStore: AgentBundleStore, + provisioner: SessionProvisioner, ): Router { const router = Router(); - registerSessionCollectionRoutes( - router, - store, - pi, - triggerEngine, - agentBundleStore, - ); + registerSessionCollectionRoutes(router, store, provisioner); registerSessionItemRoutes(router, store, pi, triggerEngine); registerSessionActivityRoutes(router, store); registerTriggerRoutes(router, store, triggers, triggerEngine); diff --git a/apps/server/src/routes/sessions/sessionProvisioner.test.ts b/apps/server/src/routes/sessions/sessionProvisioner.test.ts new file mode 100644 index 0000000..ddf0fd0 --- /dev/null +++ b/apps/server/src/routes/sessions/sessionProvisioner.test.ts @@ -0,0 +1,197 @@ +import assert from "node:assert/strict"; +import { existsSync, 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 type { ResolvedSessionConfig } from "../../pi/agentConfig.ts"; +import type { InstalledBundle } from "../../pi/config/bundleLoader.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 { + AgentBundleNotFoundError, + DefaultSessionProvisioner, + InvalidAgentBundleError, + type SessionProvisioner, +} from "./sessionProvisioner.ts"; + +const ZIP = Buffer.from("bundle"); + +function installedBundle(): InstalledBundle { + return { + manifest: { + schemaVersion: 1, + id: "test-bundle", + name: "Test Bundle", + version: "1.0.0", + prime: { systemPrompt: "prompts/prime.md" }, + }, + config: { + prime: { + tools: [], + appendSystemPrompt: "", + }, + subagentDefaults: {}, + templates: new Map(), + skillPaths: [], + workflowPaths: [], + extensionPaths: [], + welcomeMessage: "Welcome", + } satisfies ResolvedSessionConfig, + }; +} + +function sessionAt(rootPath: string): Session { + return { + id: "session-1", + name: "Session 1", + rootPath, + status: "created", + archived: false, + createdAt: "2026-08-10T00:00:00.000Z", + updatedAt: "2026-08-10T00:00:00.000Z", + }; +} + +interface Fixture { + provisioner: SessionProvisioner; + session: Session; + messages: ChatMessage[]; + calls: string[]; + cleanup(): void; +} + +interface FixtureOptions { + bundle?: Buffer | null; + install?: () => Promise; +} + +function fixture(options?: FixtureOptions): Fixture { + const parent = mkdtempSync(path.join(tmpdir(), "session-provisioner-")); + const rootPath = path.join(parent, "session-1"); + mkdirSync(rootPath, { recursive: true }); + const session = sessionAt(rootPath); + const messages: ChatMessage[] = []; + const calls: string[] = []; + + const store = { + createSession: async () => { + calls.push("create"); + return session; + }, + attachConfig: async (_id: string, config: Session["config"]) => { + calls.push("attach-config"); + return { ...session, config }; + }, + appendMessage: async (message: ChatMessage) => { + calls.push(`append:${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 bundles = { + readBundle: async () => + options?.bundle === null ? undefined : (options?.bundle ?? ZIP), + } satisfies Pick; + const installer = async () => + options?.install ? options.install() : installedBundle(); + + return { + provisioner: new DefaultSessionProvisioner( + store, + pi, + triggerEngine, + bundles, + installer, + ), + session, + messages, + calls, + cleanup: () => rmSync(parent, { recursive: true, force: true }), + }; +} + +test("provisions a bundle session and dispatches the initial prompt", async () => { + const ctx = fixture(); + try { + const session = await ctx.provisioner.create({ + bundleId: "test-bundle", + prompt: "Investigate the failure", + }); + + assert.equal(session.config?.id, "test-bundle"); + assert.deepEqual( + ctx.messages.map((message) => [message.author.id, message.content]), + [ + ["prime", "Welcome"], + ["external-user", "Investigate the failure"], + ], + ); + assert.deepEqual(ctx.calls, [ + "create", + "attach-config", + "seed", + "append:prime", + "ensure", + "append:external-user", + "prompt", + ]); + } finally { + ctx.cleanup(); + } +}); + +test("removes all state and files when provisioning fails", async () => { + const ctx = fixture({ + install: async () => { + throw new Error("bad bundle"); + }, + }); + try { + await assert.rejects( + () => ctx.provisioner.create({ bundleId: "test-bundle" }), + InvalidAgentBundleError, + ); + + assert.equal(existsSync(ctx.session.rootPath), false); + assert.deepEqual(ctx.calls, [ + "create", + "dispose", + "dispose-triggers", + "delete", + ]); + } finally { + ctx.cleanup(); + } +}); + +test("rejects an unknown bundle before creating a session", async () => { + const ctx = fixture({ bundle: null }); + try { + await assert.rejects( + () => ctx.provisioner.create({ bundleId: "missing" }), + AgentBundleNotFoundError, + ); + } finally { + ctx.cleanup(); + } +}); diff --git a/apps/server/src/routes/sessions/sessionProvisioner.ts b/apps/server/src/routes/sessions/sessionProvisioner.ts new file mode 100644 index 0000000..5109b5c --- /dev/null +++ b/apps/server/src/routes/sessions/sessionProvisioner.ts @@ -0,0 +1,179 @@ +import { randomUUID } from "node:crypto"; +import { rm } from "node:fs/promises"; + +import type { + ChatAuthor, + Session, + SessionConfigMeta, + UserIdentity, +} from "@tangent/shared/contracts.ts"; +import { PI_AGENT } from "@tangent/shared/contracts.ts"; + +import { installBundle } from "../../pi/config/bundleLoader.ts"; +import type { PiAgentManager } from "../../pi/piAgentManager.ts"; +import { PRIME_AGENT_ID } 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"; + +export class AgentBundleNotFoundError extends Error {} +export class InvalidAgentBundleError extends Error {} + +export interface ProvisionSessionInput { + bundleId: string; + name?: string; + prompt?: string; + user?: UserIdentity; +} + +export interface SessionProvisioner { + create(input: ProvisionSessionInput): Promise; +} + +type ProvisioningStore = Pick< + SessionStore, + "createSession" | "attachConfig" | "appendMessage" | "deleteSession" +>; +type ProvisioningAgentManager = Pick< + PiAgentManager, + "ensure" | "prompt" | "dispose" +>; +type ProvisioningTriggerEngine = Pick; +type ProvisioningBundleStore = Pick; + +type BundleInstallResult = Awaited>; +type BundleInstaller = ( + zipBuffer: Buffer, + rootPath: string, +) => Promise; + +function humanAuthor(user: UserIdentity | undefined): ChatAuthor { + if (!user) { + return { + id: "external-user", + kind: "human", + name: "External request", + }; + } + return { + id: user.email, + kind: "human", + name: user.first_name || user.email, + }; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +/** Creates and fully provisions bundle-backed sessions for every entry point. */ +export class DefaultSessionProvisioner implements SessionProvisioner { + private readonly store: ProvisioningStore; + private readonly pi: ProvisioningAgentManager; + private readonly triggerEngine: ProvisioningTriggerEngine; + private readonly agentBundleStore: ProvisioningBundleStore; + private readonly installer: BundleInstaller; + + constructor( + store: ProvisioningStore, + pi: ProvisioningAgentManager, + triggerEngine: ProvisioningTriggerEngine, + agentBundleStore: ProvisioningBundleStore, + installer: BundleInstaller = installBundle, + ) { + this.store = store; + this.pi = pi; + this.triggerEngine = triggerEngine; + this.agentBundleStore = agentBundleStore; + this.installer = installer; + } + + async create(input: ProvisionSessionInput): Promise { + const zipBuffer = await this.agentBundleStore.readBundle(input.bundleId); + if (!zipBuffer) + throw new AgentBundleNotFoundError("Agent bundle not found"); + + const session = await this.store.createSession({ + name: input.name, + user: input.user, + }); + + try { + const { manifest, config } = await this.install( + zipBuffer, + session.rootPath, + ); + const meta: SessionConfigMeta = { + id: manifest.id, + name: manifest.name, + version: manifest.version, + icon: manifest.icon, + }; + const configured = await this.store.attachConfig(session.id, meta); + if (!configured) throw new Error("Created session disappeared"); + + this.triggerEngine.seed(session.id, session.rootPath, manifest.triggers); + await this.appendWelcomeMessage(session.id, config.welcomeMessage); + + this.pi.ensure( + session.id, + session.rootPath, + config, + undefined, + input.user, + ); + await this.deliverPrompt(configured, input.prompt, input.user); + return configured; + } catch (error) { + await this.rollback(session); + throw error; + } + } + + private async install(zipBuffer: Buffer, rootPath: string) { + try { + return await this.installer(zipBuffer, rootPath); + } catch (error) { + throw new InvalidAgentBundleError(errorMessage(error)); + } + } + + private async appendWelcomeMessage( + sessionId: string, + welcomeMessage: string | undefined, + ): Promise { + if (!welcomeMessage) return; + await this.store.appendMessage({ + id: randomUUID(), + sessionId, + conversationId: PRIME_AGENT_ID, + author: PI_AGENT, + content: welcomeMessage, + createdAt: new Date().toISOString(), + }); + } + + private async deliverPrompt( + session: Session, + prompt: string | undefined, + user: UserIdentity | undefined, + ): Promise { + if (!prompt) return; + await this.store.appendMessage({ + id: randomUUID(), + sessionId: session.id, + conversationId: PRIME_AGENT_ID, + author: humanAuthor(user), + content: prompt, + createdAt: new Date().toISOString(), + }); + this.pi.prompt(session.id, session.rootPath, prompt); + } + + private async rollback(session: Session): Promise { + this.pi.dispose(session.id); + this.triggerEngine.dispose(session.id); + await this.store.deleteSession(session.id); + await rm(session.rootPath, { recursive: true, force: true }); + } +} diff --git a/docs/server/external-session-launches.md b/docs/server/external-session-launches.md new file mode 100644 index 0000000..0ab8787 --- /dev/null +++ b/docs/server/external-session-launches.md @@ -0,0 +1,54 @@ +# External session launches + +`POST /api/session-launches` is the automation boundary for external systems. It creates +a session from an installed Agent Bundle, persists the supplied prompt as the first human +message, starts Prime, and dispatches the prompt. + +The interactive UI continues to use `POST /api/sessions`; keeping the routes separate allows +deployments to apply machine authentication only to the external launch path. + +## Request + +```http +POST /api/session-launches +Content-Type: application/json + +{ + "bundleId": "tangle-oss", + "prompt": "Investigate the latest failed run" +} +``` + +Both body fields are required. + +## Response + +New launch (`201 Created`): + +```json +{ + "sessionId": "..." +} +``` + +The caller can link a user to `/sessions/` on the same Tangent Shell +deployment. + +Other responses: + +- `400 Bad Request` — malformed body or invalid bundle. +- `404 Not Found` — the requested bundle is not installed. +- `500 Internal Server Error` — an unexpected provisioning failure. Failed launches roll back + the session database row, agent process, triggers, chat files, and workspace directory. + +## Authentication and deployment + +Authentication belongs at the ingress or service proxy. Configure the launch path with a +machine-authentication mechanism such as Basic Auth; do not store or compare ingress +credentials in Tangent Shell. + +The caller supplies `Authorization: Basic ...` using its provisioned ingress credential, +along with any additional headers required by the hosting environment. Credentials and +related ingress changes belong in deployment configuration, not this application. Because a +browser must not contain a shared machine password, a trusted backend or service shim should +make this request rather than client-side JavaScript calling Tangent Shell directly. diff --git a/packages/shared/src/contracts.ts b/packages/shared/src/contracts.ts index 650b71d..d309320 100644 --- a/packages/shared/src/contracts.ts +++ b/packages/shared/src/contracts.ts @@ -446,6 +446,17 @@ export interface CreateSessionRequest { bundleId: string; } +/** Request accepted by the externally callable session-launch API. */ +export interface LaunchSessionRequest { + bundleId: string; + prompt: string; +} + +/** Response from an external session launch. */ +export interface LaunchSessionResponse { + sessionId: string; +} + export interface UpdateSessionRequest { name?: string; archived?: boolean;