From 4875753d9caede91936e26f8ec2852d057291335 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 13:10:25 +0000 Subject: [PATCH 1/7] feat(memory): remember appends dated notes instead of replacing memory The remember tool rewrote the bot's whole MEMORY.md with each call, so every new fact erased the earlier ones unless the model re-sent the full document. - MemoryCommitRequest gains mode "append" (the store joins the note onto the current content inside the same serializable transaction and still writes a revision row) and every commit is capped at 16 KB per document. - remember appends a dated bullet ("- YYYY-MM-DD: ...", the user's local date) by default and rewrites only with mode "replace"; a full document returns an error that tells the bot to condense it. Retries of the same tool call stay single appends through the existing effect record. - Scripted runtime memory entries can append too; the scripted "remember" script now appends instead of overwriting the bot's memory. - When memory does not fit the run's 32 KB budget, the prompt keeps a document's newest lines instead of its oldest. - memory.update maps an oversized save to BAD_REQUEST. - docs/PRODUCT.md records that UserFact is the cross-space user model and the space MEMORY.md stays space notes (no migration). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u --- apps/api/src/router.ts | 38 +++++--- apps/web/e2e/knowledge-panel.spec.ts | 30 ++++++ docs/PRODUCT.md | 13 +++ packages/adapter-kit/src/index.ts | 1 + packages/adapter-kit/src/memory.ts | 13 +++ packages/adapter-kit/src/types.ts | 4 +- packages/adapters/src/builtin-tools.ts | 10 +- packages/adapters/src/current-time.test.ts | 11 ++- packages/adapters/src/current-time.ts | 8 ++ .../src/executor-effect-idempotency.test.ts | 8 ++ packages/adapters/src/executor.ts | 26 ++--- packages/adapters/src/memory-context.test.ts | 30 ++++++ packages/adapters/src/memory-context.ts | 37 ++++++- packages/adapters/src/remember.test.ts | 96 +++++++++++++++++++ packages/adapters/src/remember.ts | 55 +++++++++++ packages/adapters/src/scripted-runtime.ts | 2 +- packages/memory/src/commit.postgres.test.ts | 79 +++++++++++++++ packages/memory/src/index.test.ts | 17 +++- packages/memory/src/index.ts | 32 ++++++- packages/testkit/src/journeys.test.ts | 4 + 20 files changed, 472 insertions(+), 42 deletions(-) create mode 100644 packages/adapter-kit/src/memory.ts create mode 100644 packages/adapters/src/remember.test.ts create mode 100644 packages/adapters/src/remember.ts diff --git a/apps/api/src/router.ts b/apps/api/src/router.ts index 2baf21e..11ec389 100644 --- a/apps/api/src/router.ts +++ b/apps/api/src/router.ts @@ -11,6 +11,7 @@ import type { } from "@rakazo/adapter-kit"; import { computerControlExpireJobKey, + MemoryDocumentTooLargeError, messagingDeliverJob, routineJobKey, routineWakeupJob, @@ -2427,21 +2428,28 @@ export function createRouter(deps: RouterDeps) { }, }); if (!doc) throw new IsolationError(); - const updated = await deps.memory.commit( - { - scope: doc.scope as "bot" | "user", - botId: doc.botId ?? undefined, - path: doc.path, - content: input.content, - }, - { - operationId: "mem", - traceId: "mem", - spaceId: context.actor.spaceId, - userId: context.actor.userId, - signal: new AbortController().signal, - }, - ); + const updated = await deps.memory + .commit( + { + scope: doc.scope as "bot" | "user", + botId: doc.botId ?? undefined, + path: doc.path, + content: input.content, + }, + { + operationId: "mem", + traceId: "mem", + spaceId: context.actor.spaceId, + userId: context.actor.userId, + signal: new AbortController().signal, + }, + ) + .catch((error: unknown) => { + if (error instanceof MemoryDocumentTooLargeError) { + throw new ORPCError("BAD_REQUEST", { message: error.message }); + } + throw error; + }); return { id: updated.id, scope: doc.scope as "bot" | "user", diff --git a/apps/web/e2e/knowledge-panel.spec.ts b/apps/web/e2e/knowledge-panel.spec.ts index a504c9d..3ab2d19 100644 --- a/apps/web/e2e/knowledge-panel.spec.ts +++ b/apps/web/e2e/knowledge-panel.spec.ts @@ -114,6 +114,36 @@ test("memory and skills are readable and editable in the app", async ({ page }, await expect(botDocEditor).toHaveValue(new RegExp(botMarker)); await botMemory.getByRole("button", { name: "Cancel", exact: true }).click(); + // Remembering appends a dated note and keeps the edit above. + await rpc(page, "threads/send", { + botId: activeBotId(page), + text: `remember that the tester ${stamp} prefers tea`, + }); + await expect + .poll( + async () => + ( + await rpc(page, "memory/list", { + botId: activeBotId(page), + scope: "bot", + }) + ).find((doc) => doc.path === "MEMORY.md")?.content, + { timeout: 60_000 }, + ) + .toMatch( + new RegExp( + `^# Chief\\n\\n${botMarker}\\n\\n- \\d{4}-\\d{2}-\\d{2}: .*tester ${stamp} prefers tea\\n$`, + ), + ); + await expect + .poll( + async () => + (await rpc<{ run?: unknown }>(page, "threads/get", { botId: activeBotId(page) })).run ?? + null, + { timeout: 60_000 }, + ) + .toBeNull(); + // Skills: create one through the editor, reopen it, edit, then delete it. // Builtin catalog is currently empty; user skills still cover create/edit/delete. await knowledge.getByRole("tab", { name: "Skills", exact: true }).click(); diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index 8c69d87..4577151 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -127,6 +127,19 @@ Called from `Onboarding.tsx` after first bot ensure. Seeded in user `MEMORY.md` (`user-relationship-contract.ts`). Persona copy and prompts follow. Details: `docs/OO-RELATIONSHIP.md`. +## Memory and the user model + +| Store | Holds | Written by | +|-------|-------|------------| +| `UserProfile` + `UserFact` (Settings → About you) | The user model: who the user is, across spaces; a fact can be limited to chosen spaces | `update_user_profile`, onboarding, chat consolidation, the user | +| User-scoped `MEMORY.md` (Settings → Memory) | Space notes shared by the bots of one space | The user, onboarding harvest | +| Bot-scoped `MEMORY.md` (bot → Knowledge) | One bot's notes in one space | `remember` appends dated notes; `mode: "replace"` rewrites | + +Space notes are not migrated into facts: facts are short, consented statements about the user, +space notes are free-form context for one space. A memory document holds at most 16 KB, so a +bot's notes and the space notes both reach the prompt; when it is full, `remember` asks the bot +to condense it. + ## Capability vision — ambient & video (future roadmap) **This PR does not build surveillance.** Current Gesamterfahrung stays **Stage + sound + calm onboarding** (`VOICE.md`). The following is Ingo's long-term direction — optional layers only. diff --git a/packages/adapter-kit/src/index.ts b/packages/adapter-kit/src/index.ts index 43f7898..ac00d1b 100644 --- a/packages/adapter-kit/src/index.ts +++ b/packages/adapter-kit/src/index.ts @@ -1,5 +1,6 @@ export * from "./background-jobs.js"; export * from "./cloud-agents.js"; export * from "./interfaces.js"; +export * from "./memory.js"; export * from "./registry.js"; export * from "./types.js"; diff --git a/packages/adapter-kit/src/memory.ts b/packages/adapter-kit/src/memory.ts new file mode 100644 index 0000000..9e1ccd3 --- /dev/null +++ b/packages/adapter-kit/src/memory.ts @@ -0,0 +1,13 @@ +/** + * Largest Markdown memory document a commit may store, in UTF-8 bytes: half of what a run reads, + * so a bot's document and the space's shared one both reach the prompt. + */ +export const MEMORY_DOCUMENT_MAX_BYTES = 16 * 1024; + +/** A commit would grow a memory document past MEMORY_DOCUMENT_MAX_BYTES. */ +export class MemoryDocumentTooLargeError extends Error { + constructor(readonly path: string) { + super(`${path} would exceed ${MEMORY_DOCUMENT_MAX_BYTES / 1024} KB`); + this.name = "MemoryDocumentTooLargeError"; + } +} diff --git a/packages/adapter-kit/src/types.ts b/packages/adapter-kit/src/types.ts index d77cb5e..8ca259f 100644 --- a/packages/adapter-kit/src/types.ts +++ b/packages/adapter-kit/src/types.ts @@ -250,6 +250,8 @@ export interface MemoryCommitRequest { botId?: string; path: string; content: string; + /** "replace" (default) stores `content` as the whole document; "append" adds it at the end. */ + mode?: "replace" | "append"; sourceRunId?: string; sourceThreadId?: string; } @@ -435,7 +437,7 @@ export interface ScriptedTurn { ask?: { text: string; detail?: string; actions?: Array<{ id: string; label: string }> }; takeover?: { reason: string }; files?: Array<{ path: string; content: string }>; - memory?: Array<{ scope: "bot" | "user"; path: string; content: string }>; + memory?: Array>; complete?: boolean; } diff --git a/packages/adapters/src/builtin-tools.ts b/packages/adapters/src/builtin-tools.ts index 0ff52fa..8502640 100644 --- a/packages/adapters/src/builtin-tools.ts +++ b/packages/adapters/src/builtin-tools.ts @@ -378,12 +378,18 @@ export const builtinAgentTools: ConnectorTool[] = [ }, { name: "remember", - description: "Store a durable fact in this bot's explicit memory.", + description: + "Add a durable note to this bot's memory document; it is appended with today's date. Use mode replace only to rewrite the whole document, e.g. to condense it when it is full.", inputSchema: { type: "object", properties: { content: { type: "string" }, - path: { type: "string" }, + path: { type: "string", description: "Memory document, default MEMORY.md." }, + mode: { + type: "string", + enum: ["append", "replace"], + description: "append (default) adds a note; replace sets the whole document to content.", + }, }, required: ["content"], }, diff --git a/packages/adapters/src/current-time.test.ts b/packages/adapters/src/current-time.test.ts index 55d2676..88aaee2 100644 --- a/packages/adapters/src/current-time.test.ts +++ b/packages/adapters/src/current-time.test.ts @@ -1,6 +1,6 @@ import type { PrismaClient } from "@rakazo/db"; import { describe, expect, it, vi } from "vitest"; -import { formatCurrentTimeInstruction, loadUserTimeZone } from "./current-time.js"; +import { formatCurrentTimeInstruction, formatLocalDate, loadUserTimeZone } from "./current-time.js"; describe("formatCurrentTimeInstruction", () => { it("anchors the present moment in UTC with the weekday and no sub-second noise", () => { @@ -93,3 +93,12 @@ describe("loadUserTimeZone", () => { ).resolves.toBeUndefined(); }); }); + +describe("formatLocalDate", () => { + it("is the user's local date, or the UTC date without a known zone", () => { + const now = new Date("2026-01-01T03:00:00Z"); + expect(formatLocalDate("America/Los_Angeles", now)).toBe("2025-12-31"); + expect(formatLocalDate("Not/AZone", now)).toBe("2026-01-01"); + expect(formatLocalDate(null, now)).toBe("2026-01-01"); + }); +}); diff --git a/packages/adapters/src/current-time.ts b/packages/adapters/src/current-time.ts index e1dd058..ad57e41 100644 --- a/packages/adapters/src/current-time.ts +++ b/packages/adapters/src/current-time.ts @@ -27,6 +27,14 @@ export function formatCurrentTimeInstruction( ].join(" "); } +/** Today's date as YYYY-MM-DD in the user's time zone, or in UTC when none is known. */ +export function formatLocalDate( + timeZone: string | null | undefined, + now: Date = new Date(), +): string { + return zonedClock(now, knownTimeZone(timeZone) ?? "UTC").local.slice(0, 10); +} + /** The user's time zone from their profile when this runtime knows it. */ export async function loadUserTimeZone( prisma: Pick, diff --git a/packages/adapters/src/executor-effect-idempotency.test.ts b/packages/adapters/src/executor-effect-idempotency.test.ts index 374494a..9901102 100644 --- a/packages/adapters/src/executor-effect-idempotency.test.ts +++ b/packages/adapters/src/executor-effect-idempotency.test.ts @@ -319,6 +319,14 @@ describe("mutating tool effect idempotency keys", () => { ]); await f.run(); expect(f.memoryCommit).toHaveBeenCalledOnce(); + // remember appends, so only the effect record keeps a retry from adding the note twice. + expect(f.memoryCommit).toHaveBeenCalledWith( + expect.objectContaining({ + mode: "append", + content: expect.stringMatching(/^- \d{4}-\d{2}-\d{2}: durable fact$/), + }), + expect.anything(), + ); expect(f.effects).toHaveLength(1); f.setCalls([ diff --git a/packages/adapters/src/executor.ts b/packages/adapters/src/executor.ts index 5cb9181..7181880 100644 --- a/packages/adapters/src/executor.ts +++ b/packages/adapters/src/executor.ts @@ -207,7 +207,7 @@ import { observationToolResult, parseComputerActions } from "./computer-tools.js import { checkpointRunComputerWorkspace } from "./computer-workspace.js"; import { sanitizeConnectorError } from "./connector-safety.js"; import { createProject } from "./create-project.js"; -import { formatCurrentTimeInstruction } from "./current-time.js"; +import { formatCurrentTimeInstruction, formatLocalDate } from "./current-time.js"; import { delegateTask, loadDelegatedTaskInstruction, @@ -319,6 +319,7 @@ import { tryRecoverProseHandoff, workshopFallbackAfterBlockedHandoff, } from "./prose-handoff-recovery.js"; +import { datedMemoryNote, rememberFromTool } from "./remember.js"; import type { RemoteTransportDependencies } from "./remote-mcp.js"; import { loadReplyContext, messageToAgentHistoryText } from "./reply-context.js"; import { @@ -2546,18 +2547,15 @@ export function createRunExecutor(deps: ExecutorDeps) { }, finish); } if (name === "remember") { - await deps.memory.commit( - { - scope: "bot", + return finish( + await rememberFromTool(deps.memory, context, { botId: bot.id, - path: String(args.path ?? "MEMORY.md"), - content: String(args.content ?? ""), - sourceRunId: runId, - sourceThreadId: thread.id, - }, - context, + runId, + threadId: thread.id, + date: formatLocalDate(userContext.timeZone), + args, + }), ); - return finish({ ok: true }); } if (name === "update_user_profile") { return finish( @@ -4240,7 +4238,11 @@ export function createRunExecutor(deps: ExecutorDeps) { scope: mem.scope, botId: mem.scope === "bot" ? bot.id : undefined, path: mem.path, - content: mem.content, + content: + mem.mode === "append" + ? datedMemoryNote(mem.content, formatLocalDate(userContext.timeZone)) + : mem.content, + mode: mem.mode, sourceRunId: runId, sourceThreadId: thread.id, }, diff --git a/packages/adapters/src/memory-context.test.ts b/packages/adapters/src/memory-context.test.ts index 3df4d13..93bcd21 100644 --- a/packages/adapters/src/memory-context.test.ts +++ b/packages/adapters/src/memory-context.test.ts @@ -51,6 +51,36 @@ describe("agent memory context", () => { expect(result?.endsWith("")).toBe(true); }); + it("keeps the newest notes of a document that does not fit, from a line start", async () => { + const notes = Array.from( + { length: 40 }, + (_, index) => `- 2026-10-0${index % 9}: note ${index}`, + ); + const read = vi.fn(async ({ scope }: { scope: "bot" | "user" }) => + snapshot( + scope === "bot" + ? [ + document( + "bot", + "MEMORY.md", + `# Chief\n${notes.join("\n")}\n`, + 41, + "2026-10-03T12:00:00.000Z", + ), + ] + : [], + ), + ); + + const result = await loadAgentMemoryContext(storeWith(read), "bot-1", context, 600); + + expect(Buffer.byteLength(result ?? "", "utf8")).toBeLessThanOrEqual(600); + expect(result).toContain("## bot: MEMORY.md (revision 41)\n[earlier notes omitted]\n- "); + expect(result).toContain("note 39\n\n"); + expect(result).not.toContain("# Chief"); + expect(result).not.toContain("note 0\n"); + }); + it("omits the memory block when neither scope has documents", async () => { const read = vi.fn(async () => snapshot([])); diff --git a/packages/adapters/src/memory-context.ts b/packages/adapters/src/memory-context.ts index 1912225..e53d266 100644 --- a/packages/adapters/src/memory-context.ts +++ b/packages/adapters/src/memory-context.ts @@ -1,6 +1,7 @@ import type { AdapterContext, MemorySnapshot, MemoryStore } from "@rakazo/adapter-kit"; const MAX_AGENT_MEMORY_BYTES = 32 * 1024; +const OMITTED_MARKER = "[earlier notes omitted]\n"; type ScopedMemoryDocument = MemorySnapshot["documents"][number] & { scope: "bot" | "user"; @@ -45,10 +46,14 @@ export async function loadAgentMemoryContext( sections.push(heading); remainingBytes -= headingBytes; - const content = truncateUtf8(document.content, remainingBytes); - sections.push(content); - remainingBytes -= byteLength(content); - if (content !== document.content) break; + if (byteLength(document.content) <= remainingBytes) { + sections.push(document.content); + remainingBytes -= byteLength(document.content); + continue; + } + // Notes are appended, so the end of a document is its newest part. + sections.push(newestLines(document.content, remainingBytes)); + break; } return `${preamble}${sections.join("")}${closing}`; @@ -58,6 +63,30 @@ function byteLength(value: string): number { return Buffer.byteLength(value, "utf8"); } +/** The end of `content` within `maxBytes`, from a line start, after an omission marker. */ +function newestLines(content: string, maxBytes: number): string { + const markerBytes = byteLength(OMITTED_MARKER); + if (maxBytes <= markerBytes) return ""; + const tail = tailUtf8(content, maxBytes - markerBytes); + const startsLine = content.charAt(content.length - tail.length - 1) === "\n"; + const newline = tail.indexOf("\n"); + const partialFirstLine = !startsLine && newline >= 0 && newline < tail.length - 1; + return `${OMITTED_MARKER}${partialFirstLine ? tail.slice(newline + 1) : tail}`; +} + +function tailUtf8(value: string, maxBytes: number): string { + const characters = Array.from(value); + let bytes = 0; + let start = characters.length; + while (start > 0) { + const characterBytes = byteLength(characters[start - 1]!); + if (bytes + characterBytes > maxBytes) break; + bytes += characterBytes; + start -= 1; + } + return characters.slice(start).join(""); +} + function truncateUtf8(value: string, maxBytes: number): string { if (maxBytes <= 0) return ""; const characters: string[] = []; diff --git a/packages/adapters/src/remember.test.ts b/packages/adapters/src/remember.test.ts new file mode 100644 index 0000000..05a86d0 --- /dev/null +++ b/packages/adapters/src/remember.test.ts @@ -0,0 +1,96 @@ +import type { AdapterContext, MemoryCommitRequest } from "@rakazo/adapter-kit"; +import { MemoryDocumentTooLargeError } from "@rakazo/adapter-kit"; +import { describe, expect, it, vi } from "vitest"; +import { datedMemoryNote, rememberFromTool } from "./remember.js"; + +const context: AdapterContext = { + operationId: "run-1", + traceId: "run-1", + spaceId: "space-1", + userId: "user-1", + signal: new AbortController().signal, +}; + +function remember(args: Record, commit = vi.fn(async () => ({}) as never)) { + return { + commit, + result: rememberFromTool({ commit }, context, { + botId: "bot-1", + runId: "run-1", + threadId: "thread-1", + date: "2026-10-03", + args, + }), + }; +} + +describe("datedMemoryNote", () => { + it("is one dated bullet whose later lines stay inside it", () => { + expect(datedMemoryNote(" Prefers tea ", "2026-10-03")).toBe("- 2026-10-03: Prefers tea"); + expect(datedMemoryNote("Trip plan\nBerlin first\n\nthen Vienna", "2026-10-03")).toBe( + "- 2026-10-03: Trip plan\n Berlin first\n\n then Vienna", + ); + }); +}); + +describe("rememberFromTool", () => { + it("appends a dated note to MEMORY.md by default", async () => { + const { commit, result } = remember({ content: "Prefers tea" }); + await expect(result).resolves.toEqual({ ok: true }); + expect(commit).toHaveBeenCalledWith( + { + scope: "bot", + botId: "bot-1", + path: "MEMORY.md", + content: "- 2026-10-03: Prefers tea", + mode: "append", + sourceRunId: "run-1", + sourceThreadId: "thread-1", + } satisfies MemoryCommitRequest, + context, + ); + }); + + it("rewrites the whole document only when asked to", async () => { + const { commit, result } = remember({ + content: "# Chief\n\n- Prefers tea\n", + path: "notes.md", + mode: "replace", + }); + await expect(result).resolves.toEqual({ ok: true }); + expect(commit).toHaveBeenCalledWith( + expect.objectContaining({ + path: "notes.md", + content: "# Chief\n\n- Prefers tea\n", + mode: "replace", + }), + context, + ); + }); + + it("refuses an empty note without writing", async () => { + const { commit, result } = remember({ content: " " }); + await expect(result).resolves.toEqual({ error: "content is empty" }); + expect(commit).not.toHaveBeenCalled(); + }); + + it("tells the bot to condense a full document", async () => { + const commit = vi.fn(async () => { + throw new MemoryDocumentTooLargeError("MEMORY.md"); + }); + const { result } = remember({ content: "One more fact" }, commit); + await expect(result).resolves.toEqual({ + error: + 'MEMORY.md is full (16 KB). Condense it with mode "replace", then save this note again.', + }); + }); + + it("lets other storage failures fail the call", async () => { + const commit = vi.fn(async () => { + throw new Error("database unavailable"); + }); + await expect(remember({ content: "fact" }, commit).result).rejects.toThrow( + "database unavailable", + ); + }); +}); diff --git a/packages/adapters/src/remember.ts b/packages/adapters/src/remember.ts new file mode 100644 index 0000000..7f411ac --- /dev/null +++ b/packages/adapters/src/remember.ts @@ -0,0 +1,55 @@ +import type { AdapterContext, MemoryStore } from "@rakazo/adapter-kit"; +import { MEMORY_DOCUMENT_MAX_BYTES, MemoryDocumentTooLargeError } from "@rakazo/adapter-kit"; + +const DEFAULT_MEMORY_PATH = "MEMORY.md"; + +/** One dated Markdown bullet; later lines are indented so the note stays a single list item. */ +export function datedMemoryNote(content: string, date: string): string { + const [first = "", ...rest] = content.trim().split(/\r?\n/); + return [`- ${date}: ${first}`, ...rest.map((line) => (line.trim() ? ` ${line}` : ""))].join( + "\n", + ); +} + +/** + * The builtin `remember` tool: appends a dated note to the bot's memory document, or rewrites the + * whole document with `mode: "replace"`. Retries of the same call are replayed from its effect + * record by the executor, so a note is appended once. + */ +export async function rememberFromTool( + memory: Pick, + context: AdapterContext, + input: { + botId: string; + runId: string; + threadId: string; + /** The user's local date, YYYY-MM-DD. */ + date: string; + args: Record; + }, +): Promise<{ ok: true } | { error: string }> { + const replace = input.args.mode === "replace"; + const content = String(input.args.content ?? ""); + const path = String(input.args.path ?? "").trim() || DEFAULT_MEMORY_PATH; + if (!replace && !content.trim()) return { error: "content is empty" }; + try { + await memory.commit( + { + scope: "bot", + botId: input.botId, + path, + content: replace ? content : datedMemoryNote(content, input.date), + mode: replace ? "replace" : "append", + sourceRunId: input.runId, + sourceThreadId: input.threadId, + }, + context, + ); + } catch (error) { + if (!(error instanceof MemoryDocumentTooLargeError)) throw error; + return { + error: `${path} is full (${MEMORY_DOCUMENT_MAX_BYTES / 1024} KB). Condense it with mode "replace", then save this note again.`, + }; + } + return { ok: true }; +} diff --git a/packages/adapters/src/scripted-runtime.ts b/packages/adapters/src/scripted-runtime.ts index 6a19544..b75121f 100644 --- a/packages/adapters/src/scripted-runtime.ts +++ b/packages/adapters/src/scripted-runtime.ts @@ -481,7 +481,7 @@ export function inferScript( return [ { assistant: "noted — i will keep that in memory.", - memory: [{ scope: "bot", path: "MEMORY.md", content: `# Memory\n\n- ${prompt}\n` }], + memory: [{ scope: "bot", path: "MEMORY.md", content: prompt, mode: "append" }], complete: true, }, ]; diff --git a/packages/memory/src/commit.postgres.test.ts b/packages/memory/src/commit.postgres.test.ts index 9fdecfc..c6f07d1 100644 --- a/packages/memory/src/commit.postgres.test.ts +++ b/packages/memory/src/commit.postgres.test.ts @@ -1,4 +1,5 @@ import type { AdapterContext, MemoryCommitRequest } from "@rakazo/adapter-kit"; +import { MEMORY_DOCUMENT_MAX_BYTES, MemoryDocumentTooLargeError } from "@rakazo/adapter-kit"; import { createDb, type PrismaClient } from "@rakazo/db"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { MarkdownMemoryStore } from "./index.js"; @@ -193,6 +194,84 @@ describePostgres("memory commits (PostgreSQL)", () => { }, ); + it("appends to the current content with a revision per note, creating a missing document", async () => { + const path = "appended.md"; + const append = (content: string): MemoryCommitRequest => ({ + ...request(path, content), + mode: "append", + }); + const first = await store.commit(append("- first"), context); + expect(first).toMatchObject({ revision: 1, content: "- first\n" }); + await store.commit(append("- second"), context); + const current = await document(path); + expect(current).toMatchObject({ revision: 2, content: "- first\n- second\n" }); + expect(current.revisions.map(({ revision, content }) => ({ revision, content }))).toEqual([ + { revision: 1, content: "- first\n" }, + { revision: 2, content: "- first\n- second\n" }, + ]); + + // A replace still rewrites the whole document. + await store.commit(request(path, "# Condensed\n"), context); + await store.commit(append("- third"), context); + expect((await document(path)).content).toBe("# Condensed\n\n- third\n"); + }); + + it("keeps both notes when two appends overlap", async () => { + const path = "concurrent-append.md"; + await store.commit(request(path, "# Notes\n"), context); + let reads = 0; + let release!: () => void; + const bothRead = new Promise((resolve) => { + release = resolve; + }); + const concurrentStore = new MarkdownMemoryStore( + prisma.$extends({ + query: { + memoryDocument: { + async findFirst({ args, query }) { + const result = await query(args); + reads += 1; + if (reads === 2) release(); + if (reads <= 2) await bothRead; + return result; + }, + }, + }, + }) as PrismaClient, + ); + + const results = await Promise.allSettled([ + concurrentStore.commit({ ...request(path, "- a"), mode: "append" }, context), + concurrentStore.commit({ ...request(path, "- b"), mode: "append" }, context), + ]); + + expect(results.map((result) => result.status)).toEqual(["fulfilled", "fulfilled"]); + const current = await document(path); + expect(current.revision).toBe(3); + expect(current.content).toMatch(/^# Notes\n\n- [ab]\n- [ab]\n$/); + expect(current.content).toContain("- a\n"); + expect(current.content).toContain("- b\n"); + }); + + it("refuses a document past the size limit without writing anything", async () => { + const path = "full.md"; + const nearlyFull = `${"x".repeat(MEMORY_DOCUMENT_MAX_BYTES - 10)}\n`; + await store.commit(request(path, nearlyFull), context); + + await expect( + store.commit({ ...request(path, "- one note too many"), mode: "append" }, context), + ).rejects.toBeInstanceOf(MemoryDocumentTooLargeError); + await expect( + store.commit(request(path, "é".repeat(MEMORY_DOCUMENT_MAX_BYTES / 2 + 1)), context), + ).rejects.toBeInstanceOf(MemoryDocumentTooLargeError); + const current = await document(path); + expect(current).toMatchObject({ revision: 1, content: nearlyFull }); + expect(current.revisions).toHaveLength(1); + + await store.commit({ ...request(path, "- ok"), mode: "append" }, context); + expect((await document(path)).revision).toBe(2); + }); + it("bounds conflict retries, preserves only committed saves, and allows a later retry", async () => { const path = "exhausted.md"; await store.commit(request(path, "initial"), context); diff --git a/packages/memory/src/index.test.ts b/packages/memory/src/index.test.ts index 92f77fe..7cc98fc 100644 --- a/packages/memory/src/index.test.ts +++ b/packages/memory/src/index.test.ts @@ -1,6 +1,6 @@ import type { AdapterContext } from "@rakazo/adapter-kit"; import { describe, expect, it, vi } from "vitest"; -import { MarkdownMemoryStore } from "./index.js"; +import { appendMarkdown, MarkdownMemoryStore } from "./index.js"; const context: AdapterContext = { operationId: "read-memory", @@ -47,3 +47,18 @@ describe("memory store contract shape", () => { }); }); }); + +describe("appendMarkdown", () => { + it("adds the note below a heading or paragraph, and right below a list", () => { + expect(appendMarkdown("# Chief\n\n", "- 2026-10-03: Prefers tea")).toBe( + "# Chief\n\n- 2026-10-03: Prefers tea\n", + ); + expect(appendMarkdown("- a\n- b", "- c")).toBe("- a\n- b\n- c\n"); + expect(appendMarkdown("- a\n more about a\n", "- c")).toBe("- a\n more about a\n- c\n"); + expect(appendMarkdown("", "- first")).toBe("- first\n"); + }); + + it("leaves the document unchanged for an empty note", () => { + expect(appendMarkdown("# Chief\n", " ")).toBe("# Chief\n"); + }); +}); diff --git a/packages/memory/src/index.ts b/packages/memory/src/index.ts index 937c00e..14931c1 100644 --- a/packages/memory/src/index.ts +++ b/packages/memory/src/index.ts @@ -10,6 +10,7 @@ import type { MemoryStore, PortableFile, } from "@rakazo/adapter-kit"; +import { MEMORY_DOCUMENT_MAX_BYTES, MemoryDocumentTooLargeError } from "@rakazo/adapter-kit"; import { Prisma, type PrismaClient, withTransactionRetry } from "@rakazo/db"; export class MarkdownMemoryStore implements MemoryStore { @@ -69,8 +70,8 @@ export class MarkdownMemoryStore implements MemoryStore { } async commit(request: MemoryCommitRequest, context: AdapterContext): Promise { - // A save replaces the current content and appends its history atomically. - // Conflicts retry the whole transaction so revision numbers use fresh state. + // A save replaces (or appends to) the current content and appends its history atomically. + // Conflicts retry the whole transaction so revision numbers and appends use fresh state. return withTransactionRetry(() => this.prisma.$transaction( async (tx) => { @@ -83,10 +84,17 @@ export class MarkdownMemoryStore implements MemoryStore { path: request.path, }, }); + const content = + request.mode === "append" + ? appendMarkdown(existing?.content ?? "", request.content) + : request.content; + if (Buffer.byteLength(content, "utf8") > MEMORY_DOCUMENT_MAX_BYTES) { + throw new MemoryDocumentTooLargeError(request.path); + } const doc = existing ? await tx.memoryDocument.update({ where: { id: existing.id }, - data: { content: request.content, revision: existing.revision + 1 }, + data: { content, revision: existing.revision + 1 }, }) : await tx.memoryDocument.create({ data: { @@ -95,14 +103,14 @@ export class MarkdownMemoryStore implements MemoryStore { botId: request.botId, scope: request.scope, path: request.path, - content: request.content, + content, }, }); await tx.memoryRevision.create({ data: { documentId: doc.id, revision: doc.revision, - content: request.content, + content, sourceRunId: request.sourceRunId, sourceThreadId: request.sourceThreadId, }, @@ -147,6 +155,20 @@ export class MarkdownMemoryStore implements MemoryStore { } } +/** + * `addition` after the existing content, ending with a newline: right below a list, so notes stay + * one list, and after a blank line below anything else, such as a heading. + */ +export function appendMarkdown(existing: string, addition: string): string { + const base = existing.trimEnd(); + const next = addition.trim(); + if (!next) return existing; + if (!base) return `${next}\n`; + const lastLine = base.slice(base.lastIndexOf("\n") + 1); + const inList = /^\s*(?:[-*+]|\d+\.)\s|^\s{2,}\S/.test(lastLine); + return `${base}${inList ? "\n" : "\n\n"}${next}\n`; +} + function snippet(content: string, q: string): string { const idx = content.toLowerCase().indexOf(q); if (idx < 0) return content.slice(0, 140); diff --git a/packages/testkit/src/journeys.test.ts b/packages/testkit/src/journeys.test.ts index f5ae027..f236434 100644 --- a/packages/testkit/src/journeys.test.ts +++ b/packages/testkit/src/journeys.test.ts @@ -382,6 +382,10 @@ describeJourneys("required product journeys", () => { botId: coder.id, }); expect(coderMem.some((m) => m.content.toLowerCase().includes("rust"))).toBe(true); + // Remembering appends a dated note below the seeded heading instead of replacing it. + expect(coderMem.map((m) => m.content)).toContainEqual( + expect.stringMatching(/^# Coder\n\n- \d{4}-\d{2}-\d{2}: remember that coder prefers rust\n$/), + ); const dedicated = await rpc(app, ada, "bots/setComputer", { botId: coder.id, mode: "dedicated", From 84dff4d84616ade6331ea7ffae51dc6f132f5539 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 13:27:38 +0000 Subject: [PATCH 2/7] refactor(memory): derive the run's memory budget from the document limit The prompt budget was a separate 32 KB constant; it is now twice the per-document limit, so a full bot document and a full space document keep fitting if either number changes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u --- packages/adapters/src/memory-context.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/adapters/src/memory-context.ts b/packages/adapters/src/memory-context.ts index e53d266..901ba7e 100644 --- a/packages/adapters/src/memory-context.ts +++ b/packages/adapters/src/memory-context.ts @@ -1,6 +1,8 @@ import type { AdapterContext, MemorySnapshot, MemoryStore } from "@rakazo/adapter-kit"; +import { MEMORY_DOCUMENT_MAX_BYTES } from "@rakazo/adapter-kit"; -const MAX_AGENT_MEMORY_BYTES = 32 * 1024; +/** Room for a full bot document and a full space document. */ +const MAX_AGENT_MEMORY_BYTES = 2 * MEMORY_DOCUMENT_MAX_BYTES; const OMITTED_MARKER = "[earlier notes omitted]\n"; type ScopedMemoryDocument = MemorySnapshot["documents"][number] & { From ddf13adf08010cc49b2529bb8a04419f1a34a7f3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 13:27:38 +0000 Subject: [PATCH 3/7] feat(profile): edit facts in place in About you About you could only confirm or delete what the bots know about the user, so a fact that was slightly wrong or filed under the wrong category had to be deleted and taught again, and a fact could not be limited to some spaces from the app even though the profile supports it. - Each fact gets an Edit button that turns its row into an inline editor: wording, category, and (when the user has more than one space, or the fact is already limited) which spaces it shows in. Escape or Cancel leaves it unchanged; only changed fields are sent through profile.update editFacts. - A limited fact names its spaces next to its source. - The repository validates edits like new facts: a wording whose normalized form is empty or equals another fact of the user is refused, all edits of one update apply atomically, and the stored embedding is only dropped when the wording really changes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u --- apps/web/e2e/user-profile-edit.spec.ts | 74 ++++++++++ apps/web/src/components/UserFactEditor.tsx | 129 ++++++++++++++++++ .../src/components/UserProfileSettings.tsx | 118 ++++++++++++---- apps/web/src/lib/user-profile-view.test.ts | 44 ++++++ apps/web/src/lib/user-profile-view.ts | 57 +++++++- packages/db/src/user-profile.postgres.test.ts | 60 ++++++++ packages/db/src/user-profile.ts | 70 ++++++---- .../testkit/src/user-profile.postgres.test.ts | 41 ++++++ 8 files changed, 537 insertions(+), 56 deletions(-) create mode 100644 apps/web/e2e/user-profile-edit.spec.ts create mode 100644 apps/web/src/components/UserFactEditor.tsx diff --git a/apps/web/e2e/user-profile-edit.spec.ts b/apps/web/e2e/user-profile-edit.spec.ts new file mode 100644 index 0000000..612ab13 --- /dev/null +++ b/apps/web/e2e/user-profile-edit.spec.ts @@ -0,0 +1,74 @@ +/** + * E2E: Settings → About you edits a fact in place: its wording, category and the spaces it + * shows in. + */ +import { expect, test } from "@playwright/test"; +import type { UserProfileState } from "@rakazo/contracts"; +import { captureScreenshot, completeOnboarding, openUserSettings, rpc, signup } from "./helpers"; + +test("a fact's wording, category and spaces are edited in place", async ({ page }, testInfo) => { + const stamp = Date.now(); + await signup(page, `profile-edit-${stamp}@rakazo.test`, "password12", "Edit Tester"); + await completeOnboarding(page); + const side = await rpc<{ id: string }>(page, "spaces/create", { name: "Side project" }); + await rpc(page, "profile/update", { + addFacts: [{ category: "preferences", value: "Prefers tea" }], + }); + + const settings = await openUserSettings(page, "profile"); + const profile = settings.getByTestId("user-profile-settings"); + const tea = profile.getByTestId("user-fact").filter({ hasText: "Prefers tea" }); + await tea.getByRole("button", { name: "Edit" }).click(); + + const editor = profile.getByTestId("user-fact-editor"); + const wording = editor.getByRole("textbox", { name: "Fact" }); + await expect(wording).toBeFocused(); + await wording.fill("Prefers green tea"); + await editor.getByRole("combobox", { name: "Category" }).selectOption("habits"); + await editor.getByRole("button", { name: "All spaces" }).click(); + await page.getByRole("menuitemcheckbox", { name: "Side project" }).click(); + await expect(page.getByRole("menuitemcheckbox", { name: "All spaces" })).not.toBeChecked(); + await page.keyboard.press("Escape"); + await expect(editor.getByRole("button", { name: "Side project" })).toBeVisible(); + await captureScreenshot(page, testInfo, "user-profile-edit"); + + await editor.getByRole("button", { name: "Save", exact: true }).click(); + await expect(editor).toHaveCount(0); + const edited = profile + .getByTestId("user-facts-habits") + .getByTestId("user-fact") + .filter({ hasText: "Prefers green tea" }); + await expect(edited).toContainText("Side project"); + await expect(tea).toHaveCount(0); + const teaFact = async () => + (await rpc(page, "profile/get", null)).facts.find((fact) => + fact.value.includes("tea"), + ); + expect(await teaFact()).toMatchObject({ + value: "Prefers green tea", + category: "habits", + allSpaces: false, + spaceIds: [side.id], + }); + + // Escape leaves the fact as it was; showing it everywhere again clears the limit. + await edited.getByRole("button", { name: "Edit" }).click(); + await editor.getByRole("textbox", { name: "Fact" }).fill("Discarded"); + await page.keyboard.press("Escape"); + await expect(editor).toHaveCount(0); + await expect(edited).toContainText("Prefers green tea"); + await expect(settings).toBeVisible(); + + await edited.getByRole("button", { name: "Edit" }).click(); + await editor.getByRole("button", { name: "Side project" }).click(); + await page.getByRole("menuitemcheckbox", { name: "All spaces" }).click(); + await page.keyboard.press("Escape"); + await editor.getByRole("button", { name: "Save", exact: true }).click(); + await expect(editor).toHaveCount(0); + await expect(edited).not.toContainText("Side project"); + expect(await teaFact()).toMatchObject({ + value: "Prefers green tea", + allSpaces: true, + spaceIds: [], + }); +}); diff --git a/apps/web/src/components/UserFactEditor.tsx b/apps/web/src/components/UserFactEditor.tsx new file mode 100644 index 0000000..a7b7e9a --- /dev/null +++ b/apps/web/src/components/UserFactEditor.tsx @@ -0,0 +1,129 @@ +import { Trans, useLingui } from "@lingui/react/macro"; +import type { UserFact, UserFactCategory } from "@rakazo/contracts"; +import { USER_FACT_CATEGORIES, USER_FACT_VALUE_MAX_LENGTH } from "@rakazo/contracts"; +import { + Button, + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuSeparator, + DropdownMenuTrigger, + Input, + NativeSelect, + NativeSelectOption, +} from "@rakazo/ui-web"; +import { ChevronDownIcon } from "lucide-react"; +import { useState } from "react"; +import type { UserFactDraft } from "../lib/user-profile-view"; +import { factSpaceNames, toggleFactSpace, userFactDraft } from "../lib/user-profile-view"; + +/** Edits one fact in place: its wording, category and, with more than one space, where it shows. */ +export function UserFactEditor({ + fact, + spaces, + categoryLabels, + busy, + onSave, + onCancel, +}: { + fact: UserFact; + /** The user's spaces; the visibility control appears with more than one. */ + spaces: ReadonlyArray<{ id: string; name: string }>; + categoryLabels: Record; + busy: boolean; + onSave: (draft: UserFactDraft) => void; + onCancel: () => void; +}) { + const { t } = useLingui(); + const [draft, setDraft] = useState(() => userFactDraft(fact)); + const allSpaces = t`All spaces`; + const showSpaces = spaces.length > 1 || draft.spaceIds !== null; + + return ( +
  • +
    { + event.preventDefault(); + if (draft.value.trim()) onSave(draft); + }} + onKeyDown={(event) => { + // The spaces menu renders in a portal; its Escape only closes the menu. + if (event.key === "Escape" && event.currentTarget.contains(event.target as Node)) { + event.stopPropagation(); + onCancel(); + } + }} + > + setDraft({ ...draft, value: event.target.value })} + /> +
    + + setDraft({ ...draft, category: event.target.value as UserFactCategory }) + } + > + {USER_FACT_CATEGORIES.map((category) => ( + + {categoryLabels[category]} + + ))} + + {showSpaces ? ( + + } + > + + {factSpaceNames(draft.spaceIds, spaces).join(", ") || allSpaces} + + + + setDraft({ ...draft, spaceIds: null })} + > + {allSpaces} + + + {spaces.map((space) => ( + + setDraft({ + ...draft, + spaceIds: toggleFactSpace(draft.spaceIds, space.id, checked), + }) + } + > + + {space.name} + + + ))} + + + ) : null} +
    + + +
    +
    +
    +
  • + ); +} diff --git a/apps/web/src/components/UserProfileSettings.tsx b/apps/web/src/components/UserProfileSettings.tsx index 6a8e2e3..3811a9a 100644 --- a/apps/web/src/components/UserProfileSettings.tsx +++ b/apps/web/src/components/UserProfileSettings.tsx @@ -10,7 +10,7 @@ import { AlertDialogTitle, Button, } from "@rakazo/ui-web"; -import { XIcon } from "lucide-react"; +import { PencilIcon, XIcon } from "lucide-react"; import type { ReactNode } from "react"; import { useEffect, useId, useState } from "react"; import { getActiveUiLocale } from "../lib/i18n"; @@ -20,9 +20,12 @@ import { CLEARED_PROFILE_FIELD, displayLanguageTag, displayRegionTag, + factSpaceNames, filledProfileFields, groupUserFacts, + userFactEdit, } from "../lib/user-profile-view"; +import { UserFactEditor } from "./UserFactEditor"; function Row({ value, @@ -51,14 +54,30 @@ function Row({ ); } -/** Everything the user's bots know about the user, with consent and forget controls. */ +/** Everything the user's bots know about the user, with edit, consent and forget controls. */ export function UserProfileSettings() { const { t } = useLingui(); const [state, setState] = useState(null); + const [spaces, setSpaces] = useState>([]); + const [editingId, setEditingId] = useState(null); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [forgetOpen, setForgetOpen] = useState(false); + useEffect(() => { + let cancelled = false; + // Only names a fact's spaces; without them the profile still works. + rpc.spaces + .list() + .then((navigation) => { + if (!cancelled) setSpaces(navigation.spaces.map(({ id, name }) => ({ id, name }))); + }) + .catch(() => undefined); + return () => { + cancelled = true; + }; + }, []); + useEffect(() => { let cancelled = false; rpc.profile @@ -74,14 +93,16 @@ export function UserProfileSettings() { }; }, [t]); - async function apply(change: () => Promise) { - if (busy) return; + async function apply(change: () => Promise): Promise { + if (busy) return false; setBusy(true); setError(null); try { setState(await change()); + return true; } catch (err) { setError(err instanceof Error ? err.message : t`Couldn't save`); + return false; } finally { setBusy(false); } @@ -178,36 +199,75 @@ export function UserProfileSettings() { {categoryLabels[group.category]}
      - {group.facts.map((fact) => ( - ( - <> - {fact.status === "pending" ? ( + {group.facts.map((fact) => + fact.id === editingId ? ( + setEditingId(null)} + onSave={(draft) => { + const edit = userFactEdit(fact, draft); + if (!edit) { + setEditingId(null); + return; + } + void apply(() => rpc.profile.update({ editFacts: [edit] })).then((saved) => { + if (saved) setEditingId(null); + }); + }} + /> + ) : ( + ( + <> + {fact.status === "pending" ? ( + + ) : null} - ) : null} - {deleteButton( - describedBy, - () => void apply(() => rpc.profile.forget({ factIds: [fact.id] })), - )} - - )} - /> - ))} + {deleteButton( + describedBy, + () => void apply(() => rpc.profile.forget({ factIds: [fact.id] })), + )} + + )} + /> + ), + )}
    ))} diff --git a/apps/web/src/lib/user-profile-view.test.ts b/apps/web/src/lib/user-profile-view.test.ts index 0843590..0f774c6 100644 --- a/apps/web/src/lib/user-profile-view.test.ts +++ b/apps/web/src/lib/user-profile-view.test.ts @@ -3,8 +3,12 @@ import { describe, expect, it } from "vitest"; import { displayLanguageTag, displayRegionTag, + factSpaceNames, filledProfileFields, groupUserFacts, + toggleFactSpace, + userFactDraft, + userFactEdit, } from "./user-profile-view"; function fact(overrides: Partial & Pick): UserFact { @@ -74,3 +78,43 @@ describe("user profile view", () => { expect(displayRegionTag("not a tag", "en")).toBe("not a tag"); }); }); + +describe("user fact editing", () => { + const tea = fact({ id: "tea", value: "Prefers tea", category: "preferences" }); + const limited = fact({ id: "lin", allSpaces: false, spaceIds: ["space-b", "space-a"] }); + + it("sends only what changed, or nothing", () => { + expect(userFactEdit(tea, userFactDraft(tea))).toBeNull(); + expect(userFactEdit(tea, { ...userFactDraft(tea), value: " Prefers tea " })).toBeNull(); + expect( + userFactEdit(limited, { ...userFactDraft(limited), spaceIds: ["space-a", "space-b"] }), + ).toBeNull(); + expect( + userFactEdit(tea, { value: " Prefers green tea ", category: "habits", spaceIds: null }), + ).toEqual({ id: "tea", value: "Prefers green tea", category: "habits" }); + expect(userFactEdit(tea, { ...userFactDraft(tea), spaceIds: ["space-a"] })).toEqual({ + id: "tea", + spaceIds: ["space-a"], + }); + expect(userFactEdit(limited, { ...userFactDraft(limited), spaceIds: null })).toEqual({ + id: "lin", + spaceIds: null, + }); + }); + + it("limits a fact to checked spaces and shows it everywhere when none is left", () => { + expect(toggleFactSpace(null, "space-a", true)).toEqual(["space-a"]); + expect(toggleFactSpace(["space-a"], "space-b", true)).toEqual(["space-a", "space-b"]); + expect(toggleFactSpace(["space-a", "space-b"], "space-a", false)).toEqual(["space-b"]); + expect(toggleFactSpace(["space-a"], "space-a", false)).toBeNull(); + }); + + it("names the spaces of a limited fact", () => { + const spaces = [ + { id: "space-a", name: "Home" }, + { id: "space-b", name: "Side project" }, + ]; + expect(factSpaceNames(["space-b", "gone"], spaces)).toEqual(["Side project"]); + expect(factSpaceNames(null, spaces)).toEqual([]); + }); +}); diff --git a/apps/web/src/lib/user-profile-view.ts b/apps/web/src/lib/user-profile-view.ts index 97877dc..623e9be 100644 --- a/apps/web/src/lib/user-profile-view.ts +++ b/apps/web/src/lib/user-profile-view.ts @@ -1,6 +1,15 @@ -import type { UserFact, UserFactCategory, UserProfile } from "@rakazo/contracts"; +import type { UpdateUserProfile, UserFact, UserFactCategory, UserProfile } from "@rakazo/contracts"; import { USER_FACT_CATEGORIES } from "@rakazo/contracts"; +/** What the fact editor holds: null spaces means every space. */ +export interface UserFactDraft { + value: string; + category: UserFactCategory; + spaceIds: string[] | null; +} + +type UserFactEdit = NonNullable[number]; + export type ProfileField = "timeZone" | "language" | "locale" | "communicationStyle" | "summary"; const PROFILE_FIELDS: ProfileField[] = [ @@ -75,3 +84,49 @@ export function displayRegionTag(tag: string, uiLocale: string): string { return tag; } } + +export function userFactDraft(fact: UserFact): UserFactDraft { + return { + value: fact.value, + category: fact.category, + spaceIds: fact.allSpaces ? null : fact.spaceIds, + }; +} + +/** The `editFacts` entry for what changed in the draft, or null when nothing did. */ +export function userFactEdit(fact: UserFact, draft: UserFactDraft): UserFactEdit | null { + const edit: UserFactEdit = { id: fact.id }; + const value = draft.value.trim(); + if (value !== fact.value) edit.value = value; + if (draft.category !== fact.category) edit.category = draft.category; + const before = userFactDraft(fact).spaceIds; + const sameSpaces = + before === null || draft.spaceIds === null + ? before === draft.spaceIds + : before.length === draft.spaceIds.length && + draft.spaceIds.every((spaceId) => before.includes(spaceId)); + if (!sameSpaces) edit.spaceIds = draft.spaceIds; + return Object.keys(edit).length > 1 ? edit : null; +} + +/** Checks or unchecks one space; unchecking the last one shows the fact everywhere again. */ +export function toggleFactSpace( + spaceIds: string[] | null, + spaceId: string, + checked: boolean, +): string[] | null { + const current = spaceIds ?? []; + const next = checked + ? [...current.filter((id) => id !== spaceId), spaceId] + : current.filter((id) => id !== spaceId); + return next.length > 0 ? next : null; +} + +/** Names of the spaces a limited fact shows in, in the order of `spaces`; unknown ids are skipped. */ +export function factSpaceNames( + spaceIds: string[] | null, + spaces: ReadonlyArray<{ id: string; name: string }>, +): string[] { + if (!spaceIds) return []; + return spaces.filter((space) => spaceIds.includes(space.id)).map((space) => space.name); +} diff --git a/packages/db/src/user-profile.postgres.test.ts b/packages/db/src/user-profile.postgres.test.ts index 03e10b0..d1c218c 100644 --- a/packages/db/src/user-profile.postgres.test.ts +++ b/packages/db/src/user-profile.postgres.test.ts @@ -179,6 +179,66 @@ describePostgres("user profile (PostgreSQL)", () => { }); }); + it("edits a fact in place, refusing a wording another fact already has", async () => { + const { userId, homeSpaceId } = await newUser(); + const work = await createSpaceForMember(prisma, { + currentSpaceId: homeSpaceId, + userId, + name: "Work", + }); + const [tea, coffee] = await updateUserProfile(prisma, userId, { + addFacts: [ + { category: "preferences", value: "Prefers tea", source: "user" }, + { category: "preferences", value: "Drinks coffee at work", source: "conversation" }, + ], + }); + await prisma.userFact.update({ + where: { id: tea!.id }, + data: { embedding: [0.1, 0.2], embeddingModel: "test-embedding" }, + }); + + // Same wording, new category and visibility: the embedding still fits. + await updateUserProfile(prisma, userId, { + editFacts: [{ id: tea!.id, value: " Prefers tea ", category: "habits", spaceIds: [work.id] }], + }); + expect(await prisma.userFact.findUniqueOrThrow({ where: { id: tea!.id } })).toMatchObject({ + value: "Prefers tea", + category: "habits", + allSpaces: false, + embedding: [0.1, 0.2], + embeddingModel: "test-embedding", + }); + expect( + (await getUserProfileState(prisma, userId)).facts.find((fact) => fact.id === tea!.id), + ).toMatchObject({ allSpaces: false, spaceIds: [work.id] }); + + for (const value of ["“prefers TEA!”", "..."]) { + await expect( + updateUserProfile(prisma, userId, { + editFacts: [ + { id: coffee!.id, category: "work" }, + { id: coffee!.id, value }, + ], + }), + ).rejects.toBeInstanceOf(InvalidUserFactError); + } + // The whole update failed, the category change included. + expect(await prisma.userFact.findUniqueOrThrow({ where: { id: coffee!.id } })).toMatchObject({ + value: "Drinks coffee at work", + category: "preferences", + }); + + // A fact may take a wording that an earlier edit in the same update gave up. + await updateUserProfile(prisma, userId, { + editFacts: [ + { id: tea!.id, value: "Prefers green tea" }, + { id: coffee!.id, value: "prefers tea" }, + ], + }); + const values = (await getUserProfileState(prisma, userId)).facts.map((fact) => fact.value); + expect(values.sort()).toEqual(["Prefers green tea", "prefers tea"]); + }); + it("limits a fact to the user's own spaces", async () => { const owner = await newUser(); const other = await newUser("Grace"); diff --git a/packages/db/src/user-profile.ts b/packages/db/src/user-profile.ts index c8f80f3..da24418 100644 --- a/packages/db/src/user-profile.ts +++ b/packages/db/src/user-profile.ts @@ -250,36 +250,54 @@ async function addFacts( return results; } -async function editFact( +/** + * Applies the user's fact edits in order. A new wording must still say something and must not + * repeat another fact, compared the way new facts are. + */ +async function editFacts( tx: Prisma.TransactionClient, userId: string, - edit: UserFactEdit, + edits: UserFactEdit[], ): Promise { - const fact = await tx.userFact.findFirst({ - where: { id: edit.id, userId }, - select: { id: true }, + if (edits.length === 0) return; + const facts = await tx.userFact.findMany({ + where: { userId }, + select: { id: true, value: true }, }); - if (!fact) throw new UserFactNotFoundError(); - const data: Prisma.UserFactUpdateInput = {}; - if (edit.category !== undefined) { - data.category = edit.category; - data.sensitive = isSensitiveUserFactCategory(edit.category); + const values = new Map(facts.map((fact) => [fact.id, fact.value])); + for (const edit of edits) { + const current = values.get(edit.id); + if (current === undefined) throw new UserFactNotFoundError(); + const data: Prisma.UserFactUpdateInput = {}; + if (edit.category !== undefined) { + data.category = edit.category; + data.sensitive = isSensitiveUserFactCategory(edit.category); + } + if (edit.value !== undefined && edit.value.trim() !== current) { + const value = edit.value.trim(); + const key = normalizeUserFactValue(value); + if (!key) throw new InvalidUserFactError("Fact is empty"); + for (const [id, other] of values) { + if (id !== edit.id && normalizeUserFactValue(other) === key) { + throw new InvalidUserFactError("This fact already exists"); + } + } + values.set(edit.id, value); + data.value = value; + // The stored embedding described the old wording. + data.embedding = []; + data.embeddingModel = null; + } + if (edit.spaceIds !== undefined) { + const spaces = visibleSpacesOf(edit.spaceIds); + data.allSpaces = spaces === null; + data.visibleSpaces = { + deleteMany: {}, + ...(spaces ? { create: spaces.map((spaceId) => ({ spaceId })) } : {}), + }; + } + if (Object.keys(data).length > 0) await tx.userFact.update({ where: { id: edit.id }, data }); } - if (edit.value !== undefined) { - data.value = edit.value.trim(); - // The stored embedding described the old wording. - data.embedding = []; - data.embeddingModel = null; - } - if (edit.spaceIds !== undefined) { - const spaces = visibleSpacesOf(edit.spaceIds); - data.allSpaces = spaces === null; - data.visibleSpaces = { - deleteMany: {}, - ...(spaces ? { create: spaces.map((spaceId) => ({ spaceId })) } : {}), - }; - } - await tx.userFact.update({ where: { id: fact.id }, data }); } async function confirmFacts( @@ -341,7 +359,7 @@ export async function updateUserProfile( }); } const added = await addFacts(tx, userId, update.addFacts ?? []); - for (const edit of update.editFacts ?? []) await editFact(tx, userId, edit); + await editFacts(tx, userId, update.editFacts ?? []); await confirmFacts(tx, userId, update.confirmFactIds ?? []); return added; }); diff --git a/packages/testkit/src/user-profile.postgres.test.ts b/packages/testkit/src/user-profile.postgres.test.ts index 11b3ede..462e461 100644 --- a/packages/testkit/src/user-profile.postgres.test.ts +++ b/packages/testkit/src/user-profile.postgres.test.ts @@ -161,6 +161,47 @@ describeWithDatabase("user profile through the API and the main bot", () => { expect(forgotten.facts).toEqual([]); expect(forgotten.profile).toMatchObject({ timeZone: null, language: null, summary: "" }); }); + + it("edits a fact's wording, category and spaces for its owner only", async () => { + const cookie = await signup(app, `profile-edit-${stamp}@rakazo.test`, "Profile Edit"); + const intruder = await signup(app, `profile-edit-other-${stamp}@rakazo.test`, "Intruder"); + const side = await rpc<{ id: string }>(app, cookie, "spaces/create", { name: "Side" }); + const elsewhere = await rpc<{ id: string }>(app, intruder, "spaces/create", { name: "Theirs" }); + const added = await rpc(app, cookie, "profile/update", { + addFacts: [ + { category: "preferences", value: "Prefers tea" }, + { category: "work", value: "Works late" }, + ], + }); + const tea = added.facts.find((fact) => fact.value === "Prefers tea")!; + + const edited = await rpc(app, cookie, "profile/update", { + editFacts: [ + { id: tea.id, value: "Prefers green tea", category: "habits", spaceIds: [side.id] }, + ], + }); + expect(edited.facts.find((fact) => fact.id === tea.id)).toMatchObject({ + value: "Prefers green tea", + category: "habits", + allSpaces: false, + spaceIds: [side.id], + }); + + const edit = (body: unknown, who = cookie) => raw(app, who, "profile/update", body); + expect((await edit({ editFacts: [{ id: tea.id, value: "works late!" }] })).status).toBe(400); + expect((await edit({ editFacts: [{ id: tea.id, spaceIds: [elsewhere.id] }] })).status).toBe( + 400, + ); + expect((await edit({ editFacts: [{ id: tea.id, value: "Stolen" }] }, intruder)).status).toBe( + 404, + ); + expect((await rpc(app, intruder, "profile/get")).facts).toEqual([]); + expect( + (await rpc(app, cookie, "profile/get")).facts.find( + (fact) => fact.id === tea.id, + ), + ).toMatchObject({ value: "Prefers green tea", spaceIds: [side.id] }); + }); }); async function signup(app: App, email: string, name: string) { From e13dbe5ffaa7255586908b507afd6d2f21bcd6e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 13:27:39 +0000 Subject: [PATCH 4/7] feat(inbox): list and answer items from all of the user's spaces The inbox in the main conversation only showed asks, approvals, takeovers and unread threads of the current space. The main conversation lives in the home space, so anything a bot needed in a project space never reached it. - inbox.list now covers every space the user belongs to (except spaces being deleted), still only the user's own bots, runs and threads. Items from another space carry that space's id and name. - The web inbox shows the space name next to the bot for those items, answers them with that space's header so the existing thread procedures check membership and ownership for that space, and opens their thread in it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u --- apps/api/src/inbox.test.ts | 36 ++++++++++ apps/api/src/inbox.ts | 47 ++++++++++-- apps/web/e2e/inbox.spec.ts | 69 ++++++++++++++++++ apps/web/src/components/MainInbox.test.tsx | 79 ++++++++++++++++---- apps/web/src/components/MainInbox.tsx | 60 +++++++++++----- apps/web/src/pages/Shell.tsx | 7 +- packages/contracts/src/inbox.ts | 9 ++- packages/contracts/src/rpc.ts | 2 +- packages/testkit/src/authorization.test.ts | 10 +++ packages/testkit/src/inbox.test.ts | 83 +++++++++++++++++++--- 10 files changed, 351 insertions(+), 51 deletions(-) diff --git a/apps/api/src/inbox.test.ts b/apps/api/src/inbox.test.ts index 08360a1..f5e754d 100644 --- a/apps/api/src/inbox.test.ts +++ b/apps/api/src/inbox.test.ts @@ -18,9 +18,11 @@ function run( status: "waiting_input" | "waiting_takeover", thread: Partial = {}, bot = scout, + spaceId = "space-home", ): InboxRunRow { return { id, + spaceId, status, updatedAt: at(0), bot, @@ -48,8 +50,10 @@ const approval = { function unreadThread( bot: InboxUnreadThreadRow["bot"], latest?: { id: string; text: string; minute: number }, + spaceId = "space-home", ): InboxUnreadThreadRow { return { + spaceId, createdAt: at(0), bot, messages: latest @@ -227,4 +231,36 @@ describe("buildInboxItems", () => { }); expect(items[2]).toMatchObject({ messageId: null, text: "", createdAt: at(0).toISOString() }); }); + + it("names the space of items from the user's other spaces", () => { + const side = { id: "space-side", name: "Side project" }; + const items = buildInboxItems({ + runs: [ + run("run-home", "waiting_input"), + run("run-side", "waiting_input", {}, writer, side.id), + run("takeover-side", "waiting_takeover", {}, writer, side.id), + ], + messages: [ + message("ask-home", "run-home", [question], 1), + message("ask-side", "run-side", [question], 2), + ], + unreadThreads: [ + unreadThread( + { id: "bot-quiet", name: "Quiet", color: "#3EC5A8" }, + { id: "quiet-latest", text: "Done", minute: 3 }, + side.id, + ), + ], + otherSpaces: new Map([[side.id, side]]), + }); + + expect(items.map((item) => [item.kind, item.space?.name ?? null])).toEqual([ + ["takeover", "Side project"], + ["ask", null], + ["ask", "Side project"], + ["unread", "Side project"], + ]); + expect(items[1]).not.toHaveProperty("space"); + expect(items[2]).toMatchObject({ space: side, target: { botId: writer.id } }); + }); }); diff --git a/apps/api/src/inbox.ts b/apps/api/src/inbox.ts index 549d5a6..23162b6 100644 --- a/apps/api/src/inbox.ts +++ b/apps/api/src/inbox.ts @@ -4,7 +4,7 @@ import { isApprovalAskBlock } from "@rakazo/core"; import type { PrismaClient } from "@rakazo/db"; import { promptSnippet } from "./runs.js"; -/** Caps per query; a space rarely holds more waiting runs or unread threads than this. */ +/** Caps per query; the user's spaces rarely hold more waiting runs or unread threads than this. */ const INBOX_RUN_LIMIT = 50; const INBOX_UNREAD_LIMIT = 50; const INBOX_PREVIEW_LENGTH = 280; @@ -12,9 +12,11 @@ const INBOX_PREVIEW_LENGTH = 280; type AskBlock = Extract; type ComputerBlock = Extract; type InboxBot = { id: string; name: string; color: string }; +type InboxSpace = NonNullable; export interface InboxRunRow { id: string; + spaceId: string; status: string; updatedAt: Date; bot: InboxBot; @@ -33,6 +35,7 @@ export interface InboxMessageRow { } export interface InboxUnreadThreadRow { + spaceId: string; createdAt: Date; bot: InboxBot | null; /** The newest bot message, if any. */ @@ -95,14 +98,20 @@ function byCreatedAt(left: InboxItem, right: InboxItem): number { /** * Turns waiting runs, their ask and takeover messages, and unread threads into inbox items: * everything that waits on the user first, then unread threads not already covered, each - * oldest first. + * oldest first. Items from another space name it. */ export function buildInboxItems(input: { runs: InboxRunRow[]; /** Ask and takeover messages of those runs, newest first. */ messages: InboxMessageRow[]; unreadThreads: InboxUnreadThreadRow[]; + /** The user's other spaces by id; rows of any space not listed belong to the current one. */ + otherSpaces?: ReadonlyMap; }): InboxItem[] { + const spaceOf = (spaceId: string) => { + const space = input.otherSpaces?.get(spaceId); + return space ? { space } : {}; + }; const messagesByRun = new Map(); for (const message of input.messages) { if (!message.runId) continue; @@ -122,6 +131,7 @@ export function buildInboxItems(input: { kind: isApprovalAskBlock(pending.block) ? "approval" : "ask", bot: run.bot, target, + ...spaceOf(run.spaceId), runId: run.id, messageId: pending.message.id, ask: pending.block, @@ -135,6 +145,7 @@ export function buildInboxItems(input: { kind: "takeover", bot: run.bot, target: { botId: run.bot.id }, + ...spaceOf(run.spaceId), runId: run.id, messageId: request?.message.id ?? null, text: request?.block.text ?? "", @@ -154,6 +165,7 @@ export function buildInboxItems(input: { kind: "unread", bot: thread.bot, target, + ...spaceOf(thread.spaceId), messageId: latest?.id ?? null, text: previewText(latest?.blocks), createdAt: (latest?.createdAt ?? thread.createdAt).toISOString(), @@ -165,13 +177,34 @@ export function buildInboxItems(input: { const botSelect = { id: true, name: true, color: true } as const; -/** The current space's inbox: only the actor's own bots, runs and threads. */ +/** + * The other spaces whose items the inbox shows: every space the user belongs to, except spaces + * being deleted. + */ +async function otherInboxSpaces( + prisma: PrismaClient, + actor: Actor, +): Promise> { + const memberships = await prisma.spaceMember.findMany({ + where: { userId: actor.userId, spaceId: { not: actor.spaceId }, space: { deletingAt: null } }, + select: { space: { select: { id: true, name: true } } }, + }); + return new Map(memberships.map(({ space }) => [space.id, space])); +} + +/** + * The inbox across the user's spaces: only the actor's own bots, runs and threads. Items are + * answered and opened in their own space, where the thread procedures check membership and + * ownership again. + */ export async function listInbox(prisma: PrismaClient, actor: Actor): Promise { + const otherSpaces = await otherInboxSpaces(prisma, actor); + const spaceIds = [actor.spaceId, ...otherSpaces.keys()]; const ownBot = { userId: actor.userId, archivedAt: null }; const [runs, unreadThreads] = await Promise.all([ prisma.run.findMany({ where: { - spaceId: actor.spaceId, + spaceId: { in: spaceIds }, userId: actor.userId, status: { in: ["waiting_input", "waiting_takeover"] }, bot: ownBot, @@ -179,6 +212,7 @@ export async function listInbox(prisma: PrismaClient, actor: Actor): Promise { + const stamp = Date.now(); + await signup(page, `inbox-cross-${stamp}@rakazo.test`, "password12", "Inbox Cross"); + await page.waitForURL(/\/(onboarding|app)/, { timeout: 30_000 }); + const helper = await createBot(page, "Helper"); + const side = await rpc<{ id: string }>(page, "spaces/create", { name: "Side project" }); + const scout = await rpcInSpace<{ id: string }>(page, side.id, "bots/create", { + name: "Scout", + title: "", + description: "", + notifyOnFinish: true, + computerMode: "team", + }); + await rpcInSpace(page, side.id, "threads/send", { + botId: scout.id, + text: "ask me which city to use", + }); + const runInSide = async () => + ( + await rpcInSpace<{ run?: { status: string } | null }>(page, side.id, "threads/get", { + botId: scout.id, + }) + ).run ?? null; + await expect + .poll(async () => (await runInSide())?.status ?? null, { timeout: 60_000 }) + .toBe("waiting_input"); + + await openMainConversation(page, helper.id); + const inbox = page.getByRole("region", { name: "Needs you" }); + const ask = inbox.locator("[data-kind='ask']"); + await expect(ask).toContainText("Scout", { timeout: 30_000 }); + await expect(ask.getByTestId("main-inbox-space")).toHaveText("· Side project"); + await captureScreenshot(page, testInfo, "inbox-cross-space"); + + await ask.getByRole("button", { name: "Edit first" }).click(); + await ask.getByRole("textbox", { name: "Answer" }).fill("Paris"); + await ask.getByRole("button", { name: "Send answer" }).click(); + await expect(ask).toHaveCount(0); + await expect.poll(runInSide, { timeout: 60_000 }).toBeNull(); + + // The reply arrives as unread from the other space and opens there. + const reply = inbox.locator("[data-kind='unread']").filter({ hasText: "Scout" }); + await expect(reply).toContainText("Paris", { timeout: 45_000 }); + await expect(reply.getByTestId("main-inbox-space")).toHaveText("· Side project"); + await reply.getByRole("button").click(); + await page.waitForURL(new RegExp(`/app/${scout.id}$`)); + await expect(page.getByText("Answered: Paris", { exact: true })).toBeVisible({ timeout: 30_000 }); + expect(await page.evaluate(() => window.localStorage.getItem("rakazo:space-id"))).toBe(side.id); +}); + +async function rpcInSpace( + page: Page, + spaceId: string, + procedure: string, + body: unknown, +): Promise { + const response = await page.request.post(`/rpc/${procedure}`, { + data: { json: body }, + headers: { "x-rakazo-space-id": spaceId }, + }); + const parsed = (await response.json()) as { json?: T; error?: { message?: string } }; + if (!response.ok() || parsed.error) { + throw new Error(`${procedure} ${response.status()}: ${parsed.error?.message ?? "failed"}`); + } + return parsed.json as T; +} + async function createBot(page: Page, name: string) { return rpc<{ id: string }>(page, "bots/create", { name, diff --git a/apps/web/src/components/MainInbox.test.tsx b/apps/web/src/components/MainInbox.test.tsx index 0d6175d..c208258 100644 --- a/apps/web/src/components/MainInbox.test.tsx +++ b/apps/web/src/components/MainInbox.test.tsx @@ -79,6 +79,22 @@ const unread: InboxItem = { text: "Draft ready for review", createdAt, }; +const side = { id: "space-side", name: "Side project" }; +const askInOtherSpace: InboxItem = { + kind: "ask", + bot: writer, + target: { botId: writer.id }, + space: side, + runId: "run-side", + messageId: "msg-side", + ask: { kind: "ask", text: "Which title fits?", status: "pending" }, + createdAt, +}; +const groupAskInOtherSpace: InboxItem = { + ...approvalInGroup, + space: side, + messageId: "msg-side-group", +}; const idleBots = [ { id: scout.id, status: "idle", unread: false, preview: "" }, @@ -89,10 +105,18 @@ let container: HTMLDivElement; let root: Root; const onOpenBot = vi.fn(); const onOpenGroup = vi.fn(); +const onOpenInSpace = vi.fn(); function render(bots = idleBots) { return act(async () => - root.render(), + root.render( + , + ), ); } @@ -111,6 +135,7 @@ beforeEach(() => { api.markRead.mockReset().mockResolvedValue({ ok: true }); onOpenBot.mockReset(); onOpenGroup.mockReset(); + onOpenInSpace.mockReset(); container = document.createElement("div"); document.body.append(container); root = createRoot(container); @@ -154,12 +179,10 @@ it("answers through the thread the ask came from and drops only that item", asyn itemOf("approval").querySelector("[data-answer]")?.click(), ); - expect(api.answer).toHaveBeenCalledWith({ - groupId: "group-ops", - runId: "run-approval", - messageId: "msg-approval", - answer: "Paris", - }); + expect(api.answer).toHaveBeenCalledWith( + { groupId: "group-ops", runId: "run-approval", messageId: "msg-approval", answer: "Paris" }, + { context: {} }, + ); expect(api.markRead).not.toHaveBeenCalled(); expect(api.list).toHaveBeenCalledTimes(2); expect(container.querySelectorAll("[data-testid='main-inbox-item']")).toHaveLength(1); @@ -173,7 +196,7 @@ it("marks the bot's own thread read before answering, so the question does not r await act(async () => itemOf("ask").querySelector("[data-answer]")?.click()); - expect(api.markRead).toHaveBeenCalledWith({ botId: scout.id }); + expect(api.markRead).toHaveBeenCalledWith({ botId: scout.id }, { context: {} }); expect(api.markRead.mock.invocationCallOrder[0]).toBeLessThan( api.answer.mock.invocationCallOrder[0]!, ); @@ -187,12 +210,10 @@ it("refreshes after a failed answer so a stale ask disappears", async () => { await act(async () => itemOf("ask").querySelector("[data-answer]")?.click()); - expect(api.answer).toHaveBeenCalledWith({ - botId: scout.id, - runId: "run-ask", - messageId: "msg-ask", - answer: "Paris", - }); + expect(api.answer).toHaveBeenCalledWith( + { botId: scout.id, runId: "run-ask", messageId: "msg-ask", answer: "Paris" }, + { context: {} }, + ); expect(container.querySelector("[data-testid='main-inbox']")).toBeNull(); }); @@ -208,6 +229,36 @@ it("opens the bot's thread for takeovers and unread threads, and the source thre expect(onOpenGroup.mock.calls).toEqual([["group-ops"]]); }); +it("names another space's items and answers and opens them in that space", async () => { + api.list.mockResolvedValueOnce({ items: [askInOtherSpace, askInOwnThread] }); + api.list.mockResolvedValue({ items: [askInOwnThread, groupAskInOtherSpace] }); + api.answer.mockResolvedValue({ ok: true }); + await render(); + + const items = container.querySelectorAll("[data-testid='main-inbox-item']"); + expect(items[0]?.querySelector("[data-testid='main-inbox-space']")?.textContent).toBe( + "· Side project", + ); + expect(items[1]?.querySelector("[data-testid='main-inbox-space']")).toBeNull(); + + await act(async () => items[0]?.querySelector("[data-answer]")?.click()); + expect(api.markRead).toHaveBeenCalledWith( + { botId: writer.id }, + { context: { spaceId: side.id } }, + ); + expect(api.answer).toHaveBeenCalledWith( + { botId: writer.id, runId: "run-side", messageId: "msg-side", answer: "Paris" }, + { context: { spaceId: side.id } }, + ); + + const [own, group] = container.querySelectorAll("[data-testid='main-inbox-item']"); + await act(async () => group?.querySelector("button")?.click()); + await act(async () => own?.querySelector("button")?.click()); + expect(onOpenInSpace.mock.calls).toEqual([[side.id, "/app/g/group-ops"]]); + expect(onOpenBot.mock.calls).toEqual([[scout.id]]); + expect(onOpenGroup).not.toHaveBeenCalled(); +}); + it("refreshes when a bot's state changes, not on every render", async () => { api.list.mockResolvedValue({ items: [] }); await render(); diff --git a/apps/web/src/components/MainInbox.tsx b/apps/web/src/components/MainInbox.tsx index 5f3007b..8ea65bd 100644 --- a/apps/web/src/components/MainInbox.tsx +++ b/apps/web/src/components/MainInbox.tsx @@ -12,20 +12,28 @@ const INBOX_POLL_MS = 15_000; type PendingAskItem = Extract; +/** Thread procedures for an item run in the item's own space. */ +function itemContext(item: InboxItem) { + return { context: item.space ? { spaceId: item.space.id } : {} }; +} + /** - * What the user's bots need from them outside the main conversation, with each bot as the - * sender: asks and approvals are answered in place, takeovers and unread threads open the - * bot's thread. + * What the user's bots in all of their spaces need from them outside the main conversation, with + * each bot as the sender: asks and approvals are answered in place, takeovers and unread threads + * open the bot's thread. */ export function MainInbox({ bots, onOpenBot, onOpenGroup, + onOpenInSpace, }: { /** The space's bots; a change in their status, unread flag or preview refreshes the inbox. */ bots: readonly Pick[]; onOpenBot: (botId: string) => void; onOpenGroup: (groupId: string) => void; + /** Opens an app path, such as a thread, in another of the user's spaces. */ + onOpenInSpace: (spaceId: string, path: string) => void; }) { const { t } = useLingui(); const [items, setItems] = useState([]); @@ -59,11 +67,16 @@ export function MainInbox({ }, [refresh]); const open = useCallback( - (target: InboxThreadTarget) => { - if ("botId" in target) onOpenBot(target.botId); + ({ target, space }: { target: InboxThreadTarget; space?: InboxItem["space"] }) => { + if (space) { + onOpenInSpace( + space.id, + "botId" in target ? `/app/${target.botId}` : `/app/g/${target.groupId}`, + ); + } else if ("botId" in target) onOpenBot(target.botId); else onOpenGroup(target.groupId); }, - [onOpenBot, onOpenGroup], + [onOpenBot, onOpenGroup, onOpenInSpace], ); const answer = useCallback( @@ -72,14 +85,12 @@ export function MainInbox({ // Answering reads the ask, so the bot's thread should not come back as unread with the // same question. Mark it first: the reply to this answer marks it unread again. if ("botId" in item.target) { - await rpc.threads.markRead(item.target).catch(() => undefined); + await rpc.threads.markRead(item.target, itemContext(item)).catch(() => undefined); } - await rpc.threads.answer({ - ...item.target, - runId: item.runId, - messageId: item.messageId, - answer: text, - }); + await rpc.threads.answer( + { ...item.target, runId: item.runId, messageId: item.messageId, answer: text }, + itemContext(item), + ); const key = inboxItemKey(item); setItems((current) => current.filter((entry) => inboxItemKey(entry) !== key)); } finally { @@ -105,10 +116,10 @@ export function MainInbox({
    open(item.target)} + onClick={() => open(item)} className="flex w-full items-start gap-2.5 rounded-xl px-2 py-1.5 text-start hover:bg-accent" > - + {item.text ? ( {bot.name} + {space ? ( + + + {space.name} + + ) : null} ); } diff --git a/apps/web/src/pages/Shell.tsx b/apps/web/src/pages/Shell.tsx index bf25ff9..a3b1e3a 100644 --- a/apps/web/src/pages/Shell.tsx +++ b/apps/web/src/pages/Shell.tsx @@ -4637,7 +4637,12 @@ export function ShellPage() { )} {activeGroup?.kind === "main" && !showProjectTimeline ? ( - + ) : null} {recordingSkill ? (
    diff --git a/packages/contracts/src/inbox.ts b/packages/contracts/src/inbox.ts index 7894cd2..3bd318d 100644 --- a/packages/contracts/src/inbox.ts +++ b/packages/contracts/src/inbox.ts @@ -13,6 +13,11 @@ const InboxItemBase = z.object({ /** The bot that needs the user, shown as the sender. */ bot: z.object({ id: Id, name: z.string(), color: z.string() }), target: InboxThreadTargetSchema, + /** + * Set when the item belongs to another of the user's spaces: call the thread procedures and + * open the thread in that space. + */ + space: z.object({ id: Id, name: z.string() }).optional(), createdAt: z.string(), }); @@ -24,8 +29,8 @@ const PendingAskItem = InboxItemBase.extend({ }); /** - * What a bot needs from the user outside the main conversation: an answer, an approval, the - * computer screen, or a look at an unread thread. + * What a bot needs from the user outside the main conversation, in any of the user's spaces: an + * answer, an approval, the computer screen, or a look at an unread thread. */ export const InboxItemSchema = z.discriminatedUnion("kind", [ PendingAskItem.extend({ kind: z.literal("ask") }), diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 6640d4c..0d11a12 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -815,7 +815,7 @@ export const appContract = { .input(z.object({ threadId: Id.optional(), status: DelegatedTaskStatusSchema.optional() })) .output(z.array(DelegatedTaskSchema)), }, - /** Asks, approvals, takeovers and unread threads outside the main conversation. */ + /** Asks, approvals, takeovers and unread threads outside the main conversation, in all spaces. */ inbox: { list: oc.output(InboxListOutputSchema), }, diff --git a/packages/testkit/src/authorization.test.ts b/packages/testkit/src/authorization.test.ts index ce1e785..a4490cb 100644 --- a/packages/testkit/src/authorization.test.ts +++ b/packages/testkit/src/authorization.test.ts @@ -712,6 +712,16 @@ describeWithDatabase("API authorization and resource isolation", () => { .items, ).toContainEqual(expect.objectContaining({ kind: "ask", runId: run.id })); expect(await rpc(app, member, "inbox/list", {}, sharedSpaceId)).toEqual({ items: [] }); + // From another of the member's spaces the inbox spans the shared one too, still without + // the owner's runs. + const memberSpace = await rpc<{ id: string }>( + app, + member, + "spaces/create", + { name: "Member side" }, + sharedSpaceId, + ); + expect(await rpc(app, member, "inbox/list", {}, memberSpace.id)).toEqual({ items: [] }); }); it("prevents one shared-space member from hijacking another member's Team Computer takeover", async () => { diff --git a/packages/testkit/src/inbox.test.ts b/packages/testkit/src/inbox.test.ts index 2fe3d09..22dfc2f 100644 --- a/packages/testkit/src/inbox.test.ts +++ b/packages/testkit/src/inbox.test.ts @@ -94,7 +94,7 @@ describeInbox("inbox.list", () => { expect(await listInbox(app, cookie)).toEqual([]); }); - it("collects approvals, takeovers and unread threads of this space outside the main conversation", async () => { + it("collects approvals, takeovers and unread threads of every space outside the main conversation", async () => { const cookie = await signup(app, `inbox-mixed-${stamp}@rakazo.test`, "Inbox Mixed"); const me = await rpc<{ userId: string; spaceId: string }>(app, cookie, "me"); const scout = await createBot(app, cookie, "Scout"); @@ -187,6 +187,14 @@ describeInbox("inbox.list", () => { text: "Sign in to continue.", createdAt: expect.any(String), }, + expect.objectContaining({ + kind: "ask", + bot: expect.objectContaining({ id: remote.id }), + target: { botId: remote.id }, + space: { id: remoteSpace.id, name: "Remote" }, + runId: remoteAsk.runId, + messageId: remoteAsk.messageId, + }), expect.objectContaining({ kind: "unread", bot: expect.objectContaining({ id: scout.id }), @@ -196,16 +204,74 @@ describeInbox("inbox.list", () => { ]); // The Pilot thread is unread too, but its takeover already covers it. expect(items.filter((item) => item.bot.id === pilot.id)).toHaveLength(1); + // Items of the current space carry no space. + expect(items.filter((item) => item.space).map((item) => item.bot.id)).toEqual([remote.id]); - expect(await listInbox(app, cookie, remoteSpace.id)).toEqual([ + // From the other space, the same items with the sides swapped. + const fromRemote = await listInbox(app, cookie, remoteSpace.id); + expect(fromRemote.map((item) => [item.kind, item.space?.id ?? null])).toEqual([ + ["approval", me.spaceId], + ["takeover", me.spaceId], + ["ask", null], + ["unread", me.spaceId], + ]); + }); + + it("answers an item from another space only in that space and only for its owner", async () => { + const cookie = await signup(app, `inbox-cross-${stamp}@rakazo.test`, "Inbox Cross"); + const intruder = await signup(app, `inbox-intruder-${stamp}@rakazo.test`, "Inbox Intruder"); + const me = await rpc<{ spaceId: string }>(app, cookie, "me"); + const side = await rpc<{ id: string }>(app, cookie, "spaces/create", { name: "Side" }); + const scout = await createBot(app, cookie, "Scout", side.id); + + await rpc( + app, + cookie, + "threads/send", + { botId: scout.id, text: "ask me which city to use" }, + side.id, + ); + await waitForThread( + app, + cookie, + scout.id, + (snapshot) => snapshot.run?.status === "waiting_input", + side.id, + ); + + const [item] = await listInbox(app, cookie, me.spaceId); + if (item?.kind !== "ask") throw new Error("expected an ask item"); + expect(item).toMatchObject({ + space: { id: side.id, name: "Side" }, + target: { botId: scout.id }, + }); + const answer = { + ...item.target, + runId: item.runId, + messageId: item.messageId, + answer: "Paris", + }; + + // The thread is not in the current space, and the intruder belongs to neither space. + await expect(rpc(app, cookie, "threads/answer", answer, me.spaceId)).rejects.toThrow(); + await expect(rpc(app, intruder, "threads/answer", answer, side.id)).rejects.toThrow(); + await expect(rpc(app, intruder, "threads/answer", answer)).rejects.toThrow(); + expect(await listInbox(app, intruder)).toEqual([]); + expect((await listInbox(app, cookie, me.spaceId)).map((entry) => entry.kind)).toEqual(["ask"]); + + await rpc(app, cookie, "threads/answer", answer, item.space?.id); + await waitForThread(app, cookie, scout.id, (snapshot) => !snapshot.run, side.id); + expect(await listInbox(app, cookie, me.spaceId)).toEqual([ expect.objectContaining({ - kind: "ask", - bot: expect.objectContaining({ id: remote.id }), - target: { botId: remote.id }, - runId: remoteAsk.runId, - messageId: remoteAsk.messageId, + kind: "unread", + target: { botId: scout.id }, + space: { id: side.id, name: "Side" }, + text: expect.stringContaining("Paris"), }), ]); + + await rpc(app, cookie, "threads/markRead", { botId: scout.id }, side.id); + expect(await listInbox(app, cookie, me.spaceId)).toEqual([]); }); }); @@ -269,11 +335,12 @@ async function waitForThread( cookie: string, botId: string, done: (snapshot: ThreadSnapshot) => boolean, + spaceId?: string, ): Promise { const start = Date.now(); let last: ThreadSnapshot | null = null; while (Date.now() - start < 20_000) { - last = await rpc(app, cookie, "threads/get", { botId }); + last = await rpc(app, cookie, "threads/get", { botId }, spaceId); if (done(last)) return last; await new Promise((resolve) => setTimeout(resolve, 100)); } From 7a58bc7ba99be996a961cf93f225cdfe9b9554d6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 13:42:24 +0000 Subject: [PATCH 5/7] fix(profile): keep the fact editor's Save label visible Small buttons in ui-web merge their text-ui-label size over the default variant's text color, so the small Save button rendered ink on ink. The editor now uses default-size controls, with the category select at the same height. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u --- apps/web/src/components/UserFactEditor.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/UserFactEditor.tsx b/apps/web/src/components/UserFactEditor.tsx index a7b7e9a..17306e1 100644 --- a/apps/web/src/components/UserFactEditor.tsx +++ b/apps/web/src/components/UserFactEditor.tsx @@ -64,7 +64,7 @@ export function UserFactEditor({ />
    @@ -80,7 +80,7 @@ export function UserFactEditor({ {showSpaces ? ( } + render={ -
    From 4191a4b6e637d51c308a4a317fb03db33379beeb Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 13:58:29 +0000 Subject: [PATCH 6/7] fix(core): load child_process normally in the git checkout reader readGitCheckout loaded node:child_process through require(), which does not exist in the ESM API runtime. Linking a project space to a folder that is a git checkout therefore failed after the space was created. The lazy load is no longer needed: the reader is only reachable through the server-only "@rakazo/core/git-checkout" subpath, never through the package entry the web app bundles. Import spawn at the top instead. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u --- packages/core/src/git-checkout.ts | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/packages/core/src/git-checkout.ts b/packages/core/src/git-checkout.ts index ccf1ff7..899d2ac 100644 --- a/packages/core/src/git-checkout.ts +++ b/packages/core/src/git-checkout.ts @@ -1,19 +1,10 @@ +// Server only: reached through the "@rakazo/core/git-checkout" subpath, never the browser entry. +import { spawn } from "node:child_process"; import { access } from "node:fs/promises"; import path from "node:path"; import { gitStatusArgv, gitUntrackedFilesArgv, parseGitNameOnly } from "./compose-update.js"; import { DEFAULT_UPDATE_REMOTE, parseGitStatusPorcelain } from "./self-update.js"; -// Lazy import to avoid bundling `node:child_process` in browser bundles. -// `defaultGitRunner` is server-only; lazy-loading ensures tree-shaking works. -type SpawnFn = typeof import("node:child_process").spawn; -let _spawn: SpawnFn | undefined; -const getSpawn = (): SpawnFn => { - if (_spawn === undefined) { - _spawn = require("node:child_process").spawn as SpawnFn; - } - return _spawn; -}; - export interface GitCheckoutStatus { present: boolean; remoteUrl: string | null; @@ -32,7 +23,6 @@ export type GitRunner = (args: string[], cwd: string) => Promise { return new Promise((resolve) => { - const spawn = getSpawn(); const child = spawn("git", args, { cwd, env: process.env }); let output = ""; child.stdout.on("data", (chunk: Buffer | string) => { From 2c91a80f691f5098cf48057900daca1b0b27baf6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 13:58:39 +0000 Subject: [PATCH 7/7] feat(onboarding): ask before discovering projects and let the user choose The onboarding scan started on its own, discarded what it found, claimed to read email and calendars, simulated progress for everyone who could not scan, and its project creation always failed (wrong Space fields and a space lookup by title across tenants). Discovery now asks first and creates nothing without a choice: - The scan step appears only for the deployment owner when ONBOARDING_SCAN_HOME is set; everyone else skips it. The simulated scan is gone. - It shows the folder and asks. Only directory listings are read: git projects up to four levels deep, their languages and a few tools (Docker, GitHub Actions, Terraform). Symlinks are never followed and each chosen folder is resolved again before use, so nothing outside the folder is reached. Results stay with the account that scanned. - Found projects are a checklist (none preselected); found languages and tools are facts (preselected). "Add" creates one project space per chosen project with createSpaceForMember and wireProjectWorkspace in the user's own organization, reusing the user's own space already linked to that folder, and saves the chosen facts as onboarding facts under the profile lock and fact limit. - When a connection becomes connected (direct connect, authorization round trip, or catalog reconciliation), "Uses " is remembered as a tools fact from the connector. Revoking keeps it. The E2E harness now points ONBOARDING_SCAN_HOME at a fixture folder and starts without a deployment owner, so owner-only onboarding steps appear only in specs that take the seat through /__e2e/deployment-owner. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u --- .env.example | 6 +- apps/api/src/connected-app-facts.ts | 50 + apps/api/src/onboarding-scan.test.ts | 208 ++- apps/api/src/onboarding-scan.ts | 1134 ++++++----------- apps/api/src/router.ts | 49 +- apps/web/e2e/onboarding-discovery.spec.ts | 76 ++ .../onboarding/OnboardingScanProgress.tsx | 186 --- .../onboarding/OnboardingScanStep.tsx | 250 ++++ apps/web/src/pages/Onboarding.tsx | 18 +- packages/contracts/src/index.ts | 1 + packages/contracts/src/onboarding-scan.ts | 49 + packages/contracts/src/rpc.ts | 22 +- packages/testkit/src/authorization.test.ts | 25 +- packages/testkit/src/cli/harness.ts | 59 +- .../src/onboarding-discovery.postgres.test.ts | 363 ++++++ .../testkit/src/onboarding-scan-fixture.ts | 35 + 16 files changed, 1494 insertions(+), 1037 deletions(-) create mode 100644 apps/api/src/connected-app-facts.ts create mode 100644 apps/web/e2e/onboarding-discovery.spec.ts delete mode 100644 apps/web/src/components/onboarding/OnboardingScanProgress.tsx create mode 100644 apps/web/src/components/onboarding/OnboardingScanStep.tsx create mode 100644 packages/contracts/src/onboarding-scan.ts create mode 100644 packages/testkit/src/onboarding-discovery.postgres.test.ts create mode 100644 packages/testkit/src/onboarding-scan-fixture.ts diff --git a/.env.example b/.env.example index 5d588c8..8ebb743 100644 --- a/.env.example +++ b/.env.example @@ -25,9 +25,9 @@ PUBLIC_POSTHOG_KEY= PUBLIC_POSTHOG_HOST=https://us.i.posthog.com SIGNUPS_ENABLED=true SIGNUP_ALLOWLIST= -# First-run discovery. Leave unset and the onboarding scan never touches the host filesystem. -# Set a home-style directory (its Projects, Documents, src, ... subfolders are scanned) to let the -# deployment owner's own onboarding discover local projects. Other accounts never scan the host. +# First-run discovery. Leave unset and onboarding never looks at the host filesystem. +# Set a folder (for example a home folder) and the deployment owner's onboarding offers to list the +# git projects in it, reading file and folder names only. Other accounts never see the step. # ONBOARDING_SCAN_HOME= ENCRYPTION_KEY=replace-with-64-random-hex-characters DATA_DIR=./data diff --git a/apps/api/src/connected-app-facts.ts b/apps/api/src/connected-app-facts.ts new file mode 100644 index 0000000..5b83abe --- /dev/null +++ b/apps/api/src/connected-app-facts.ts @@ -0,0 +1,50 @@ +import { FEATURED_CONNECTOR_LABELS, matchFeaturedConnectorId } from "@rakazo/core"; +import type { PrismaClient } from "@rakazo/db"; +import { updateUserProfile } from "@rakazo/db"; +import { getLogger } from "@rakazo/logging"; + +/** + * The app's own name: a featured app's label, otherwise the label of the user's first account for + * it (later accounts may be named "Gmail 2" or renamed). + */ +async function connectedAppName( + prisma: PrismaClient, + connection: { userId: string; connectorId: string; provider: string }, +): Promise { + const featured = matchFeaturedConnectorId(connection.provider); + if (featured) return FEATURED_CONNECTOR_LABELS[featured]; + const first = await prisma.connection.findFirst({ + where: { + userId: connection.userId, + connectorId: connection.connectorId, + provider: connection.provider, + }, + orderBy: [{ createdAt: "asc" }, { id: "asc" }], + select: { displayName: true }, + }); + const name = first?.displayName.trim().slice(0, 80); + return name || null; +} + +/** + * Remembers "Uses " in the user's profile when one of their connections becomes connected. + * Revoking the connection keeps the fact; the user removes it in About you. Best effort: the + * connection itself has already succeeded, so a full profile or a database error only logs. + */ +export async function recordConnectedApp( + prisma: PrismaClient, + connection: { userId: string; connectorId: string; provider: string }, +): Promise { + try { + const name = await connectedAppName(prisma, connection); + if (!name) return; + await updateUserProfile(prisma, connection.userId, { + addFacts: [{ category: "tools", value: `Uses ${name}`, source: "connector" }], + }); + } catch (error) { + getLogger().error("connected app fact was not recorded", error, { + connectorId: connection.connectorId, + provider: connection.provider, + }); + } +} diff --git a/apps/api/src/onboarding-scan.test.ts b/apps/api/src/onboarding-scan.test.ts index ea2e625..9cbafd9 100644 --- a/apps/api/src/onboarding-scan.test.ts +++ b/apps/api/src/onboarding-scan.test.ts @@ -1,64 +1,192 @@ -import { mkdir, mkdtemp, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { getScanStatus, startOnboardingScan } from "./onboarding-scan.js"; +import path from "node:path"; +import type { PrismaClient } from "@rakazo/db"; +import { afterAll, describe, expect, it, vi } from "vitest"; +import { + applyOnboardingScan, + discoverOnboardingScan, + onboardingScanFolder, + onboardingScanStatus, + startOnboardingScan, +} from "./onboarding-scan.js"; -const owner = { userId: "user-owner", isDeploymentOwner: true }; -const member = { userId: "user-member", isDeploymentOwner: false }; +const owner = { userId: "user-owner", spaceId: "space-owner", isDeploymentOwner: true }; +const member = { userId: "user-member", spaceId: "space-member", isDeploymentOwner: false }; +const roots: string[] = []; -async function scanHomeWithOneFile(): Promise { - const home = await mkdtemp(join(tmpdir(), "evoo-scan-")); - await mkdir(join(home, "Projects")); - await writeFile(join(home, "Projects", "notes.ts"), "export const x = 1;\n"); - return home; +/** Fails the test if the scan writes anything before its checks pass. */ +const untouchedPrisma = new Proxy( + {}, + { + get() { + throw new Error("the database must not be touched"); + }, + }, +) as PrismaClient; + +async function tempDir(): Promise { + const dir = await realpath(await mkdtemp(path.join(tmpdir(), "evoo-scan-"))); + roots.push(dir); + return dir; +} + +async function files(root: string, entries: string[]): Promise { + for (const entry of entries) { + const file = path.join(root, entry); + await mkdir(path.dirname(file), { recursive: true }); + await writeFile(file, "x\n"); + } +} + +async function project(root: string, relative: string, entries: string[]): Promise { + await mkdir(path.join(root, relative, ".git"), { recursive: true }); + await files(path.join(root, relative), entries); } async function completed(scanId: string, userId: string) { return await vi.waitFor( - async () => { - const status = await getScanStatus(scanId, userId); - if (status.status !== "complete" && status.status !== "failed") throw new Error("pending"); + () => { + const status = onboardingScanStatus(scanId, userId); + if (status.status === "scanning") throw new Error("pending"); return status; }, - { timeout: 10_000, interval: 50 }, + { timeout: 10_000, interval: 20 }, ); } +afterAll(async () => { + await Promise.all(roots.map((root) => rm(root, { recursive: true, force: true }))); +}); + describe("onboarding scan", () => { - afterEach(() => { - vi.unstubAllEnvs(); + it("lists git projects with their languages and tools and never follows a symlink", async () => { + const home = await tempDir(); + const outside = await tempDir(); + // A home folder tracked by git is not itself a project. + await mkdir(path.join(home, ".git")); + await project(home, "code/atlas", [ + "src/a.ts", + "src/b.ts", + "src/c.ts", + "src/d.tsx", + "scripts/build.js", + "Dockerfile", + ".github/workflows/ci.yml", + "infra/main.tf", + "src/odd.constructor", + "node_modules/dep/index.js", + "node_modules/dep/more.js", + ]); + await project(home, "code/garden", ["main.py"]); + await project(home, "deep/a/b/edge", ["tool.py"]); + await project(home, "deep/a/b/c/too-deep", ["main.go"]); + await project(home, ".hidden/secret", ["main.go"]); + await project(home, "notes/node_modules/vendored", ["main.go"]); + await files(home, ["notes/todo.md"]); + await project(outside, "escaped", ["main.rs"]); + await symlink(path.join(outside, "escaped"), path.join(home, "linked")); + await symlink(outside, path.join(home, "code/atlas/src/outside")); + + const findings = await discoverOnboardingScan(home); + + expect(findings.projects).toEqual([ + { + id: "project-0", + name: "atlas", + path: "code/atlas", + languages: ["TypeScript", "JavaScript"], + tools: ["Docker", "GitHub Actions", "Terraform"], + }, + { id: "project-1", name: "garden", path: "code/garden", languages: ["Python"], tools: [] }, + { id: "project-2", name: "edge", path: "deep/a/b/edge", languages: ["Python"], tools: [] }, + ]); + expect(findings.facts).toEqual([ + { id: "language:Python", label: "Python", category: "work", value: "Codes in Python" }, + { + id: "language:JavaScript", + label: "JavaScript", + category: "work", + value: "Codes in JavaScript", + }, + { + id: "language:TypeScript", + label: "TypeScript", + category: "work", + value: "Codes in TypeScript", + }, + { id: "tool:Docker", label: "Docker", category: "tools", value: "Uses Docker" }, + { + id: "tool:GitHub Actions", + label: "GitHub Actions", + category: "tools", + value: "Uses GitHub Actions", + }, + { id: "tool:Terraform", label: "Terraform", category: "tools", value: "Uses Terraform" }, + ]); }); - it("hides a scan from every account except the one that started it", async () => { - const { scanId } = await startOnboardingScan({ actor: member }, {}); - await expect(getScanStatus(scanId, owner.userId)).rejects.toThrow(/Scan not found/); - await expect(getScanStatus(scanId, member.userId)).resolves.toMatchObject({ - status: expect.any(String), - }); - await expect(getScanStatus("scan_missing", member.userId)).rejects.toThrow(/Scan not found/); + it("offers a scan folder only to the deployment owner and only when it exists", async () => { + const home = await tempDir(); + await files(home, ["file.txt"]); + await expect(onboardingScanFolder(member, home)).resolves.toBeNull(); + await expect(onboardingScanFolder(owner, undefined)).resolves.toBeNull(); + await expect(onboardingScanFolder(owner, path.join(home, "missing"))).resolves.toBeNull(); + await expect(onboardingScanFolder(owner, path.join(home, "file.txt"))).resolves.toBeNull(); + await expect(onboardingScanFolder(owner, home)).resolves.toBe(home); + await expect(startOnboardingScan(member, home)).rejects.toMatchObject({ code: "FORBIDDEN" }); }); - it("never reads the host without a configured scan home", async () => { - const { scanId } = await startOnboardingScan({ actor: owner }, {}); + it("keeps a scan with the account that started it and returns only relative paths", async () => { + const home = await tempDir(); + await project(home, "code/atlas", ["index.ts"]); + const { scanId } = await startOnboardingScan(owner, home); + const status = await completed(scanId, owner.userId); - expect(status.status).toBe("complete"); - expect(status.result).toMatchObject({ localRepos: [], fileSummary: { totalFiles: 0 } }); + expect(status).toEqual({ + status: "complete", + projects: [{ id: "project-0", name: "atlas", path: "code/atlas" }], + facts: [{ id: "language:TypeScript", label: "TypeScript" }], + }); + expect(JSON.stringify(status)).not.toContain(home); + expect(() => onboardingScanStatus(scanId, member.userId)).toThrow( + expect.objectContaining({ code: "NOT_FOUND" }), + ); + expect(() => onboardingScanStatus("missing-scan", owner.userId)).toThrow( + expect.objectContaining({ code: "NOT_FOUND" }), + ); + await expect( + applyOnboardingScan(untouchedPrisma, member, home, { + scanId, + projectIds: ["project-0"], + factIds: [], + }), + ).rejects.toMatchObject({ code: "NOT_FOUND" }); }); - it("lets only the deployment owner discover the configured scan home", async () => { - // The shared test setup pins the emulated runtime, which also keeps the scan simulated. - vi.stubEnv("AGENT_RUNTIME", ""); - vi.stubEnv("SANDBOX_PROVIDER", ""); - const scanHome = await scanHomeWithOneFile(); + it("checks ownership, the folder and every chosen project again before writing", async () => { + const home = await tempDir(); + const outside = await tempDir(); + await project(home, "code/atlas", ["index.ts"]); + await project(home, "code/garden", ["main.py"]); + await project(outside, "elsewhere", ["main.rs"]); + const { scanId } = await startOnboardingScan(owner, home); + await completed(scanId, owner.userId); + const choice = { scanId, projectIds: ["project-1"], factIds: ["language:Python"] }; - const asMember = await startOnboardingScan({ actor: member, scanHome }, {}); - const memberStatus = await completed(asMember.scanId, member.userId); - expect(memberStatus.result).toMatchObject({ fileSummary: { totalFiles: 0 } }); + await expect( + applyOnboardingScan(untouchedPrisma, { ...owner, isDeploymentOwner: false }, home, choice), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + await expect( + applyOnboardingScan(untouchedPrisma, owner, outside, choice), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); - const asOwner = await startOnboardingScan({ actor: owner, scanHome }, {}); - const ownerStatus = await completed(asOwner.scanId, owner.userId); - expect(ownerStatus.status).toBe("complete"); - expect(ownerStatus.result).toMatchObject({ fileSummary: { totalFiles: 1 } }); + // The chosen project became a symlink out of the scan folder after the scan. + await rm(path.join(home, "code/garden"), { recursive: true }); + await symlink(path.join(outside, "elsewhere"), path.join(home, "code/garden")); + await expect(applyOnboardingScan(untouchedPrisma, owner, home, choice)).rejects.toMatchObject({ + code: "BAD_REQUEST", + message: "That folder does not exist or is not readable.", + }); }); }); diff --git a/apps/api/src/onboarding-scan.ts b/apps/api/src/onboarding-scan.ts index 8d1c786..504cc84 100644 --- a/apps/api/src/onboarding-scan.ts +++ b/apps/api/src/onboarding-scan.ts @@ -1,815 +1,435 @@ -// Onboarding scan — real filesystem scan, Git analysis, and profile compilation. -// Runs synchronously on the host machine (no sandbox needed). -import { spawn } from "node:child_process"; -import { access, readdir, stat } from "node:fs/promises"; -import { dirname, join } from "node:path"; -import type { Actor } from "@rakazo/contracts"; +// Onboarding discovery. With the deployment owner's consent, it lists the code projects (git +// checkouts) in the configured scan folder and the languages and tools they use. It reads +// directory listings only, never file contents, and never follows a symlink, so nothing outside +// the folder is reached. Results stay with the account that started the scan, and nothing is +// created until the owner picks projects and facts from them. +import { randomUUID } from "node:crypto"; +import type { Dirent } from "node:fs"; +import { readdir, realpath, stat } from "node:fs/promises"; +import path from "node:path"; +import { ORPCError } from "@orpc/server"; +import type { + Actor, + OnboardingScanApplyInput, + OnboardingScanApplyResult, + OnboardingScanStatus, + UserFactCategory, +} from "@rakazo/contracts"; +import { ONBOARDING_SCAN_MAX_FACTS, ONBOARDING_SCAN_MAX_PROJECTS } from "@rakazo/contracts"; +import type { PrismaClient } from "@rakazo/db"; +import { + createSpaceForMember, + InvalidProjectWorkspacePathError, + InvalidSpaceNameError, + InvalidUserFactError, + SpaceLimitError, + updateUserProfile, + wireProjectWorkspace, +} from "@rakazo/db"; -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- +/** Project roots are looked for at most this deep below the scan folder. */ +const PROJECT_DEPTH = 4; +/** Bounds the walk of a large home folder. */ +const MAX_DIRECTORIES = 5_000; +/** Bounds the language count inside one project. */ +const MAX_FILES_PER_PROJECT = 5_000; +const MAX_FILE_DEPTH = 8; +const MAX_LANGUAGES_PER_PROJECT = 3; +const SCAN_TTL_MS = 60 * 60_000; -interface ScanResult { - localRepos: RepoInfo[]; - projectFolders: ProjectFolder[]; - fileSummary: FileSummary; - profileSummary: ProfileSummary; - agentBriefing: string; - createdProjects: CreatedProject[]; - subAgentRoles: SubAgentRole[]; -} +const SKIPPED_DIRECTORIES = new Set([ + "node_modules", + "dist", + "build", + "out", + "target", + "vendor", + "venv", + "__pycache__", + "Library", +]); -interface RepoInfo { - path: string; +const LANGUAGES = new Map( + Object.entries({ + ts: "TypeScript", + tsx: "TypeScript", + mts: "TypeScript", + cts: "TypeScript", + js: "JavaScript", + jsx: "JavaScript", + mjs: "JavaScript", + cjs: "JavaScript", + py: "Python", + go: "Go", + rs: "Rust", + java: "Java", + kt: "Kotlin", + kts: "Kotlin", + swift: "Swift", + rb: "Ruby", + php: "PHP", + c: "C", + h: "C", + cc: "C++", + cpp: "C++", + cxx: "C++", + hpp: "C++", + cs: "C#", + dart: "Dart", + ex: "Elixir", + exs: "Elixir", + scala: "Scala", + lua: "Lua", + }), +); + +const COMPOSE_FILES = new Set([ + "docker-compose.yml", + "docker-compose.yaml", + "compose.yml", + "compose.yaml", +]); + +interface FoundProject { + id: string; name: string; - remote: string | null; - branch: string; - lastCommit: string | null; - languages: Record; - sizeBytes: number; -} - -interface ProjectFolder { + /** Relative to the scan folder. */ path: string; - name: string; - type: "git" | "workspace" | "documents" | "media" | "music" | "code"; - fileCount: number; - modifiedAt: string; -} - -interface FileSummary { - totalFiles: number; - totalSizeBytes: number; - byType: Record; - byExtension: Record; -} - -interface ProfileSummary { - interests: string[]; - skills: string[]; - workStyle: string; - communication: string; languages: string[]; - techStack: string[]; + tools: string[]; } -interface CreatedProject { +interface FoundFact { id: string; - name: string; - spaceId: string; - description: string; + label: string; + category: UserFactCategory; + /** Stored as data for the user's bots, so it stays a plain English statement. */ + value: string; } -interface ScanRecord { +export interface OnboardingScanFindings { + projects: FoundProject[]; + facts: FoundFact[]; +} + +interface ScanRecord extends OnboardingScanFindings { id: string; - botId: string; ownerUserId: string; - status: "scanning" | "connecting" | "building" | "briefing" | "complete" | "failed"; - progress: number; - message: string; - result: string; - startedAt: Date; - completedAt: Date | null; + /** Real path of the scanned folder. */ + home: string; + status: "scanning" | "complete" | "failed"; + finishedAt: number | null; + applying: boolean; } -// --------------------------------------------------------------------------- -// Registry (in-memory, 1h TTL) -// --------------------------------------------------------------------------- +/** Per API process; a scan only lives as long as the onboarding that started it. */ +const scans = new Map(); -const scanRegistry = new Map(); - -const emptyScanResult = (): ScanResult => ({ - localRepos: [], - projectFolders: [], - fileSummary: { totalFiles: 0, totalSizeBytes: 0, byType: {}, byExtension: {} }, - profileSummary: { - interests: [], - skills: [], - workStyle: "structured", - communication: "direct", - languages: [], - techStack: [], - }, - agentBriefing: "Profile ready", - createdProjects: [], - subAgentRoles: [], -}); - -async function runSimulatedScan(record: ScanRecord) { - const phases: Array<{ status: ScanRecord["status"]; progress: number; message: string }> = [ - { status: "scanning", progress: 25, message: "Scanning local files and repositories..." }, - { status: "connecting", progress: 40, message: "Connecting to your services..." }, - { status: "building", progress: 60, message: "Building your profile..." }, - { status: "briefing", progress: 80, message: "Briefing your agent team..." }, - ]; - for (const phase of phases) { - record.status = phase.status; - record.progress = phase.progress; - record.message = phase.message; - await new Promise((resolve) => setTimeout(resolve, 400)); - } - record.status = "complete"; - record.progress = 100; - record.message = "Scan complete — profile ready"; - record.result = JSON.stringify(emptyScanResult()); - record.completedAt = new Date(); +/** Code-point order, so results do not depend on the server's locale. */ +function compareText(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; } -export interface OnboardingScanRequest { - actor: Pick; - botId?: string; - /** Home-style directory to discover. Unset keeps the scan off the host filesystem. */ - scanHome?: string; +/** Most frequent first, then by name. */ +function byCount([leftName, left]: [string, number], [rightName, right]: [string, number]): number { + return right - left || compareText(leftName, rightName); } -export async function startOnboardingScan( - { actor, botId, scanHome }: OnboardingScanRequest, - prisma: any, -): Promise<{ scanId: string }> { - const scanId = `scan_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; - const record: ScanRecord = { - id: scanId, - botId: botId ?? "", - ownerUserId: actor.userId, - status: "scanning", - progress: 0, - message: "Scanning your computer...", - result: "[]", - startedAt: new Date(), - completedAt: null, - }; - scanRegistry.set(scanId, record); - - // Run async - void (async () => { - try { - // The host is the deployment owner's machine: only their onboarding may discover it, and - // only inside the directory the deployment explicitly configured. - if ( - !scanHome || - !actor.isDeploymentOwner || - process.env.AGENT_RUNTIME === "scripted" || - process.env.SANDBOX_PROVIDER === "fake" - ) { - await runSimulatedScan(record); - return; - } - const result = await executeFullScan(scanHome, botId ?? "", prisma); - record.status = "complete"; - record.progress = 100; - record.message = "Scan complete — profile ready"; - record.result = JSON.stringify(result); - record.completedAt = new Date(); - } catch (error) { - record.status = "failed"; - record.message = error instanceof Error ? error.message : "Scan failed"; - record.completedAt = new Date(); - } - })(); - - return { scanId }; -} - -export async function getScanStatus( - scanId: string, - userId: string, -): Promise<{ - status: ScanRecord["status"]; - progress: number; - message: string; - result?: unknown; -}> { - const record = scanRegistry.get(scanId); - if (!record || record.ownerUserId !== userId) throw new Error("Scan not found"); - const result = record.result ? JSON.parse(record.result) : undefined; - return { status: record.status, progress: record.progress, message: record.message, result }; -} - -// --------------------------------------------------------------------------- -// Full scan pipeline -// --------------------------------------------------------------------------- - -async function executeFullScan(homeDir: string, _botId: string, prisma: any): Promise { - // Phase 1: Local file scan - const [fileSummary, projectFolders] = await scanFiles(homeDir); - - // Phase 2: Git repository discovery - const localRepos = await discoverGitRepos(homeDir); - - // Phase 3: External service analysis (GitHub, etc.) - // Phase 4: Profile compilation from findings - const profileSummary = compileProfile(localRepos, fileSummary, projectFolders); - - // Phase 6: Project folder creation - const createdProjects = await createProjectFolders(localRepos, prisma); - - // Phase 7: Sub-agent role creation - const subAgentRoles = generateSubAgentRoles(profileSummary, localRepos); - - // Phase 5: Agent briefing (includes sub-agent roles) - const agentBriefing = generateBriefing(localRepos, profileSummary, fileSummary, subAgentRoles); - - return { - localRepos, - projectFolders, - fileSummary, - profileSummary, - agentBriefing, - createdProjects, - subAgentRoles, - }; -} - -// --------------------------------------------------------------------------- -// Phase 1: File scan -// --------------------------------------------------------------------------- - -async function scanFiles(homeDir: string): Promise<[FileSummary, ProjectFolder[]]> { - const summary: FileSummary = { totalFiles: 0, totalSizeBytes: 0, byType: {}, byExtension: {} }; - const folders: ProjectFolder[] = []; - - // Scan common directories - const scanDirs = [ - "Projects", - "Documents", - "Pictures", - "Music", - "Downloads", - "Desktop", - "src", - "code", - "work", - "data", - "repos", - "dev", - "workspace", - ]; - - for (const subDir of scanDirs) { - const dirPath = join(homeDir, subDir); - try { - await access(dirPath); - const stats = await scanDirectory(dirPath, homeDir, subDir); - summary.totalFiles += stats.totalFiles; - summary.totalSizeBytes += stats.totalSizeBytes; - Object.assign(summary.byType, stats.byType); - Object.assign(summary.byExtension, stats.byExtension); - - if (stats.totalFiles > 0) { - folders.push({ - path: dirPath, - name: subDir, - type: classifyFolderType(subDir), - fileCount: stats.totalFiles, - modifiedAt: new Date().toISOString(), - }); - } - } catch { - /* directory doesn't exist, skip */ - } - } - - return [summary, folders]; -} - -interface DirStats { - totalFiles: number; - totalSizeBytes: number; - byType: Record; - byExtension: Record; -} - -async function scanDirectory( - dirPath: string, - _homeDir: string, - _relativePath: string, -): Promise { - const stats: DirStats = { totalFiles: 0, totalSizeBytes: 0, byType: {}, byExtension: {} }; - +async function listDirectory(directory: string): Promise { try { - const entries = await readdir(dirPath, { withFileTypes: true }); + return (await readdir(directory, { withFileTypes: true })).sort((left, right) => + compareText(left.name, right.name), + ); + } catch { + return []; + } +} +/** isDirectory() is false for a symlink, so links (and anything they point to) are never walked. */ +function walkable(entry: Dirent): boolean { + return entry.isDirectory() && !entry.name.startsWith(".") && !SKIPPED_DIRECTORIES.has(entry.name); +} + +async function findProjectRoots(home: string): Promise { + const roots: string[] = []; + let visited = 0; + let level = [home]; + // Breadth first, so shallow projects win when a limit is reached. The scan folder itself is + // never a project: a home folder tracked by git still holds the projects below it. + for (let depth = 0; depth <= PROJECT_DEPTH && level.length > 0; depth += 1) { + const next: string[] = []; + for (const directory of level) { + if (roots.length >= ONBOARDING_SCAN_MAX_PROJECTS || visited >= MAX_DIRECTORIES) return roots; + visited += 1; + const entries = await listDirectory(directory); + if (depth > 0 && entries.some((entry) => entry.name === ".git")) { + roots.push(directory); + continue; + } + for (const entry of entries) { + if (walkable(entry)) next.push(path.join(directory, entry.name)); + } + } + level = next; + } + return roots; +} + +async function analyzeProject(root: string): Promise<{ languages: string[]; tools: string[] }> { + const counts = new Map(); + const tools = new Set(); + let files = 0; + const rootEntries = await listDirectory(root); + if (rootEntries.some((entry) => entry.name === ".github" && entry.isDirectory())) { + const github = await listDirectory(path.join(root, ".github")); + if (github.some((entry) => entry.name === "workflows" && entry.isDirectory())) { + tools.add("GitHub Actions"); + } + } + const visit = async (directory: string, depth: number, entries: Dirent[]): Promise => { for (const entry of entries) { - const fullPath = join(dirPath, entry.name); - - if (entry.isDirectory()) { - // Skip node_modules, .git, etc. - if (entry.name === "node_modules" || entry.name === ".git" || entry.name === ".venv") - continue; - const subStats = await scanDirectory(fullPath, _homeDir, _relativePath); - stats.totalFiles += subStats.totalFiles; - stats.totalSizeBytes += subStats.totalSizeBytes; - Object.assign(stats.byType, subStats.byType); - Object.assign(stats.byExtension, subStats.byExtension); - } else if (entry.isFile()) { - try { - const fileStat = await stat(fullPath); - stats.totalFiles++; - stats.totalSizeBytes += fileStat.size; - - // Classify by type - const type = classifyFileType(entry.name); - stats.byType[type] = (stats.byType[type] || 0) + 1; - - // Classify by extension - const ext = entry.name.split(".").pop()?.toLowerCase() || "none"; - stats.byExtension[ext] = (stats.byExtension[ext] || 0) + 1; - } catch { - /* permission denied */ + if (files >= MAX_FILES_PER_PROJECT) return; + if (entry.isFile()) { + files += 1; + const name = entry.name.toLowerCase(); + const extension = name.includes(".") ? name.slice(name.lastIndexOf(".") + 1) : ""; + const language = LANGUAGES.get(extension); + if (language) counts.set(language, (counts.get(language) ?? 0) + 1); + if (name === "dockerfile" || name.startsWith("dockerfile.") || COMPOSE_FILES.has(name)) { + tools.add("Docker"); } + if (extension === "tf") tools.add("Terraform"); + } else if (depth < MAX_FILE_DEPTH && walkable(entry)) { + const child = path.join(directory, entry.name); + await visit(child, depth + 1, await listDirectory(child)); } } - } catch { - /* permission denied */ - } - - return stats; + }; + await visit(root, 0, rootEntries); + const total = [...counts.values()].reduce((sum, count) => sum + count, 0); + const languages = [...counts.entries()] + .filter(([, count]) => count >= total * 0.1) + .sort(byCount) + .slice(0, MAX_LANGUAGES_PER_PROJECT) + .map(([language]) => language); + return { languages, tools: [...tools].sort() }; } -function classifyFileType(filename: string): string { - const ext = filename.split(".").pop()?.toLowerCase() || ""; - const codeExts = [ - "ts", - "tsx", - "js", - "jsx", - "py", - "go", - "rs", - "java", - "c", - "cpp", - "h", - "rb", - "php", - "swift", - "kt", - ]; - const docExts = ["md", "txt", "doc", "docx", "pdf", "rst", "tex"]; - const imageExts = ["png", "jpg", "jpeg", "gif", "svg", "ico", "webp", "bmp"]; - const musicExts = ["mp3", "wav", "flac", "aac", "ogg", "m4a", "wma"]; - - if (codeExts.includes(ext)) return "code"; - if (docExts.includes(ext)) return "documents"; - if (imageExts.includes(ext)) return "images"; - if (musicExts.includes(ext)) return "music"; - if (["json", "yaml", "yml", "toml", "cfg", "ini", "env", "sh", "bash", "zsh"].includes(ext)) - return "config"; - return "other"; +/** Labels used by the most projects first, then by name. */ +function rankLabels(lists: string[][]): string[] { + const counts = new Map(); + for (const list of lists) + for (const label of list) counts.set(label, (counts.get(label) ?? 0) + 1); + return [...counts.entries()].sort(byCount).map(([label]) => label); } -function classifyFolderType(name: string): ProjectFolder["type"] { - const lower = name.toLowerCase(); - if (lower.includes("music")) return "music"; - if (lower.includes("picture") || lower.includes("image") || lower.includes("photo")) - return "media"; - if (lower.includes("document") || lower.includes("doc")) return "documents"; - if ( - lower.includes("project") || - lower.includes("code") || - lower.includes("src") || - lower.includes("dev") || - lower.includes("workspace") || - lower.includes("repo") - ) - return "code"; - return "workspace"; +/** Lists the projects below a real (symlink-free) folder path, and what they use. */ +export async function discoverOnboardingScan(home: string): Promise { + const roots = await findProjectRoots(home); + const analyzed = await Promise.all( + roots.map(async (root) => ({ root, ...(await analyzeProject(root)) })), + ); + const projects = analyzed + .map(({ root, languages, tools }) => ({ + name: path.basename(root), + path: path.relative(home, root), + languages, + tools, + })) + .sort((left, right) => compareText(left.path, right.path)) + .map((project, index) => ({ id: `project-${index}`, ...project })); + const facts: FoundFact[] = [ + ...rankLabels(projects.map((project) => project.languages)).map((label) => ({ + id: `language:${label}`, + label, + category: "work" as const, + value: `Codes in ${label}`, + })), + ...rankLabels(projects.map((project) => project.tools)).map((label) => ({ + id: `tool:${label}`, + label, + category: "tools" as const, + value: `Uses ${label}`, + })), + ].slice(0, ONBOARDING_SCAN_MAX_FACTS); + return { projects, facts }; } -// --------------------------------------------------------------------------- -// Phase 2: Git repository discovery -// --------------------------------------------------------------------------- - -async function discoverGitRepos(homeDir: string): Promise { - const repos: RepoInfo[] = []; - +/** + * The scan reads the server's filesystem, so only the deployment owner may scan, and only the + * folder the deployment configured. Null hides the step. + */ +export async function onboardingScanFolder( + actor: Pick, + scanHome: string | undefined, +): Promise { + if (!actor.isDeploymentOwner || !scanHome) return null; try { - const gitDirs = await findGitDirectories(homeDir); - - for (const repoDir of gitDirs) { - try { - const repo = await analyzeGitRepo(repoDir, homeDir); - if (repo) repos.push(repo); - } catch { - /* skip broken repos */ - } - } - } catch { - /* git not available */ - } - - return repos; -} - -async function findGitDirectories(dir: string, depth: number = 0): Promise { - if (depth > 4) return []; // Limit search depth - const results: string[] = []; - - try { - const entries = await readdir(dir, { withFileTypes: true }); - for (const entry of entries) { - const fullPath = join(dir, entry.name); - if (entry.isDirectory()) { - if (entry.name === ".git") { - results.push(dirname(fullPath)); - } else if ( - entry.name !== "node_modules" && - entry.name !== ".git" && - entry.name !== ".venv" && - entry.name !== "dist" - ) { - results.push(...(await findGitDirectories(fullPath, depth + 1))); - } - } - } - } catch { - /* permission denied */ - } - - return results; -} - -async function analyzeGitRepo(repoDir: string, _homeDir: string): Promise { - try { - const name = repoDir.split("/").pop() || ""; - - // Get remote URL - const remoteResult = await spawnPromise("git", ["remote", "get-url", "origin"], { - cwd: repoDir, - }).catch(() => ({ stdout: "" })); - const remote = remoteResult.stdout.trim() || null; - - // Get current branch - const branchResult = await spawnPromise("git", ["branch", "--show-current"], { - cwd: repoDir, - }).catch(() => ({ stdout: "" })); - const branch = branchResult.stdout.trim() || "unknown"; - - // Get last commit - const commitResult = await spawnPromise("git", ["log", "-1", "--format=%H"], { - cwd: repoDir, - }).catch(() => ({ stdout: "" })); - const lastCommit = commitResult.stdout.trim() || null; - - // Get language stats via git ls-files + wc - const languages = await getRepoLanguages(repoDir); - - // Get directory size - const sizeResult = await spawnPromise("du", ["-sb", repoDir]).catch(() => ({ stdout: "0" })); - const sizeBytes = parseInt(sizeResult.stdout.split("\t")[0] || "0", 10); - - return { path: repoDir, name, remote, branch, lastCommit, languages, sizeBytes }; + const home = await realpath(scanHome); + return (await stat(home)).isDirectory() ? home : null; } catch { return null; } } -async function getRepoLanguages(repoDir: string): Promise> { - const langs: Record = {}; - try { - const { stdout } = await spawnPromise("git", ["ls-files"], { cwd: repoDir }).catch(() => ({ - stdout: "", - })); - const files = stdout.trim().split("\n").filter(Boolean); - for (const file of files) { - const ext = file.split(".").pop()?.toLowerCase() || ""; - const langMap: Record = { - ts: "TypeScript", - tsx: "TypeScript", - js: "JavaScript", - jsx: "JavaScript", - py: "Python", - go: "Go", - rs: "Rust", - java: "Java", - c: "C", - cpp: "C++", - rb: "Ruby", - php: "PHP", - swift: "Swift", - kt: "Kotlin", - }; - const lang = langMap[ext] || ext || "unknown"; - langs[lang] = (langs[lang] || 0) + 1; - } - } catch { - /* no languages */ +function forgetExpiredScans(now: number): void { + for (const [id, scan] of scans) { + if (scan.finishedAt !== null && now - scan.finishedAt > SCAN_TTL_MS) scans.delete(id); } - return langs; } -// --------------------------------------------------------------------------- -// Phase 3: Profile compilation -// --------------------------------------------------------------------------- - -function compileProfile( - repos: RepoInfo[], - fileSummary: FileSummary, - _folders: ProjectFolder[], -): ProfileSummary { - const interests: string[] = []; - const skills: string[] = []; - const techStack: string[] = []; - const languageSet = new Set(); - - // Extract tech stack from repos - for (const repo of repos) { - for (const lang of Object.keys(repo.languages)) { - techStack.push(lang); - languageSet.add(lang); - } - } - - // Extract interests from file types - for (const [type, count] of Object.entries(fileSummary.byType)) { - if (count > 5) { - interests.push(`${type} (${count} files)`); - } - } - - // Derive skills from languages - const langToSkill: Record = { - TypeScript: "TypeScript/JavaScript", - Python: "Python", - Go: "Go", - Rust: "Rust", - Java: "Java", - C: "C/C++", - "C++": "C/C++", - Ruby: "Ruby", - Swift: "Swift/Kotlin", - PHP: "PHP", +export async function startOnboardingScan( + actor: Pick, + scanHome: string | undefined, +): Promise<{ scanId: string }> { + const home = await onboardingScanFolder(actor, scanHome); + if (!home) throw new ORPCError("FORBIDDEN"); + forgetExpiredScans(Date.now()); + const running = [...scans.values()].find( + (scan) => scan.ownerUserId === actor.userId && scan.status === "scanning", + ); + if (running) return { scanId: running.id }; + const scan: ScanRecord = { + id: randomUUID(), + ownerUserId: actor.userId, + home, + status: "scanning", + projects: [], + facts: [], + finishedAt: null, + applying: false, }; - for (const lang of techStack) { - const skill = langToSkill[lang]; - if (skill && !skills.includes(skill)) skills.push(skill); - } + scans.set(scan.id, scan); + void discoverOnboardingScan(home) + .then((findings) => { + scan.projects = findings.projects; + scan.facts = findings.facts; + scan.status = "complete"; + }) + .catch(() => { + scan.status = "failed"; + }) + .finally(() => { + scan.finishedAt = Date.now(); + }); + return { scanId: scan.id }; +} - // Deduplicate - const uniqueInterests = [...new Set(interests)].slice(0, 10); - const uniqueSkills = [...new Set(skills)].slice(0, 15); - const uniqueTech = [...new Set(techStack)].sort(); +/** Another account's scan reads as missing. */ +function ownedScan(scanId: string, userId: string): ScanRecord { + const scan = scans.get(scanId); + if (!scan || scan.ownerUserId !== userId) throw new ORPCError("NOT_FOUND"); + return scan; +} +export function onboardingScanStatus(scanId: string, userId: string): OnboardingScanStatus { + const scan = ownedScan(scanId, userId); + if (scan.status !== "complete") return { status: scan.status }; return { - interests: uniqueInterests, - skills: uniqueSkills, - workStyle: - uniqueTech.length > 3 - ? "multi-language" - : uniqueTech[0] - ? `${uniqueTech[0]}-focused` - : "general", - communication: "structured", - languages: Array.from(languageSet), - techStack: uniqueTech, + status: "complete", + projects: scan.projects.map(({ id, name, path }) => ({ id, name, path })), + facts: scan.facts.map(({ id, label }) => ({ id, label })), }; } -// --------------------------------------------------------------------------- -// Phase 4: Agent briefing -// --------------------------------------------------------------------------- +const MISSING_FOLDER = "That folder does not exist or is not readable."; -function generateBriefing( - repos: RepoInfo[], - profile: ProfileSummary, - fileSummary: FileSummary, - subAgentRoles: SubAgentRole[], -): string { - const lines: string[] = []; - lines.push(`# User Profile Briefing`); - lines.push(``); - lines.push(`## Technical Profile`); - lines.push(`- **Tech Stack:** ${profile.techStack.join(", ") || "N/A"}`); - lines.push(`- **Languages:** ${profile.languages.join(", ") || "N/A"}`); - lines.push(`- **Work Style:** ${profile.workStyle}`); - lines.push(``); - lines.push(`## Local Repositories (${repos.length} found)`); - for (const repo of repos) { - lines.push( - `- **${repo.name}** — ${repo.branch}, ${Object.values(repo.languages).reduce((a, b) => a + b, 0)} files, ${repo.remote || "no remote"}`, - ); +/** Resolves a found project again, so a folder swapped for a symlink cannot leave the scan folder. */ +async function confinedProjectFolder(home: string, relative: string): Promise { + let folder: string; + try { + folder = await realpath(path.join(home, relative)); + } catch { + throw new InvalidProjectWorkspacePathError(MISSING_FOLDER); } - lines.push(``); - lines.push(`## File System Summary`); - lines.push(`- **Total Files:** ${fileSummary.totalFiles.toLocaleString()}`); - lines.push(`- **Total Size:** ${(fileSummary.totalSizeBytes / (1024 * 1024)).toFixed(0)} MB`); - lines.push( - `- **Categories:** ${Object.entries(fileSummary.byType) - .map(([t, c]) => `${t}(${c})`) - .join(", ")}`, - ); - lines.push(``); - lines.push(`## User Interests`); - lines.push(`- ${profile.interests.slice(0, 5).join("\n- ")}`); - lines.push(``); - lines.push(`## User Skills`); - lines.push(`- ${profile.skills.slice(0, 5).join("\n- ")}`); - lines.push(``); - lines.push(`## Assigned Specialist Roles`); - for (const role of subAgentRoles) { - lines.push(`### ${role.name}`); - lines.push(`- **Role:** ${role.role}`); - lines.push(`- **Description:** ${role.description}`); - lines.push(`- **Instructions:** ${role.instructions}`); - lines.push(`- **Skills:** ${role.skills.join(", ")}`); - lines.push(""); + const inside = path.relative(home, folder); + if (!inside || inside === ".." || inside.startsWith(`..${path.sep}`) || path.isAbsolute(inside)) { + throw new InvalidProjectWorkspacePathError(MISSING_FOLDER); } - lines.push(`---`); - lines.push( - `This profile should be used to tailor all future interactions, project recommendations, and agent assignments.`, - ); - return lines.join("\n"); + return folder; } -// --------------------------------------------------------------------------- -// Phase 5: Project folder creation -// --------------------------------------------------------------------------- +async function projectSpace( + prisma: PrismaClient, + actor: Pick, + name: string, + folder: string, +): Promise<{ id: string; name: string }> { + // Applying twice, or onboarding again, reuses the user's own space linked to this folder. + const existing = await prisma.space.findFirst({ + where: { + workspacePath: folder, + deletingAt: null, + memberships: { some: { userId: actor.userId } }, + }, + select: { id: true, name: true }, + orderBy: { createdAt: "asc" }, + }); + if (existing) return existing; + const space = await createSpaceForMember(prisma, { + currentSpaceId: actor.spaceId, + userId: actor.userId, + name: name.slice(0, 60), + }); + await wireProjectWorkspace(prisma, { spaceId: space.id, workspacePath: folder }); + return space; +} -async function createProjectFolders(repos: RepoInfo[], prisma: any): Promise { - const created: CreatedProject[] = []; +function toRpcError(error: unknown): unknown { + if ( + error instanceof SpaceLimitError || + error instanceof InvalidSpaceNameError || + error instanceof InvalidProjectWorkspacePathError || + error instanceof InvalidUserFactError + ) { + return new ORPCError("BAD_REQUEST", { message: error.message }); + } + return error; +} - for (const repo of repos) { - try { - // Check if space already exists - const existing = await prisma.space.findFirst({ where: { title: repo.name } }); - if (existing) { - created.push({ - id: existing.id, - name: repo.name, - spaceId: existing.id, - description: repo.remote || repo.path, - }); - continue; - } - // Create new space - const space = await prisma.space.create({ - data: { title: repo.name, path: repo.path }, - }); - created.push({ - id: space.id, - name: repo.name, - spaceId: space.id, - description: repo.remote || repo.path, - }); - } catch { - // Space might already exist or DB unavailable — skip +/** Creates a project space per chosen project and saves the chosen facts to the profile. */ +export async function applyOnboardingScan( + prisma: PrismaClient, + actor: Pick, + scanHome: string | undefined, + input: OnboardingScanApplyInput, +): Promise { + const scan = ownedScan(input.scanId, actor.userId); + if (scan.status !== "complete" || scan.applying) throw new ORPCError("CONFLICT"); + scan.applying = true; + try { + // Ownership or the configured folder can change after the scan; check again before writing. + if ((await onboardingScanFolder(actor, scanHome)) !== scan.home) { + throw new ORPCError("FORBIDDEN"); } + const projectIds = new Set(input.projectIds); + const factIds = new Set(input.factIds); + const projects = scan.projects.filter((project) => projectIds.has(project.id)); + const facts = scan.facts.filter((fact) => factIds.has(fact.id)); + // Every chosen folder is checked before anything is written. + const chosen = await Promise.all( + projects.map(async (project) => ({ + name: project.name, + folder: await confinedProjectFolder(scan.home, project.path), + })), + ); + const added = + facts.length > 0 + ? await updateUserProfile(prisma, actor.userId, { + addFacts: facts.map(({ category, value }) => ({ + category, + value, + source: "onboarding" as const, + })), + }) + : []; + const spaces: Array<{ id: string; name: string }> = []; + for (const { name, folder } of chosen) + spaces.push(await projectSpace(prisma, actor, name, folder)); + return { spaces, factsAdded: added.filter((fact) => fact.created).length }; + } catch (error) { + throw toRpcError(error); + } finally { + scan.applying = false; } - - return created; } - -// --------------------------------------------------------------------------- -// Phase 6: Sub-agent role creation -// --------------------------------------------------------------------------- - -interface SubAgentRole { - name: string; - role: string; - description: string; - instructions: string; - skills: string[]; -} - -function generateSubAgentRoles(profile: ProfileSummary, repos: RepoInfo[]): SubAgentRole[] { - const roles: SubAgentRole[] = []; - const hasCode = profile.languages?.length > 0; - const hasDesign = profile.interests?.some( - (i) => i.toLowerCase().includes("design") || i.toLowerCase().includes("ui"), - ); - const hasData = profile.interests?.some( - (i) => - i.toLowerCase().includes("data") || - i.toLowerCase().includes("analytics") || - i.toLowerCase().includes("database"), - ); - const hasDevOps = profile.interests?.some( - (i) => - i.toLowerCase().includes("infra") || - i.toLowerCase().includes("ops") || - i.toLowerCase().includes("deploy"), - ); - const hasMusic = profile.interests?.some((i) => i.toLowerCase().includes("music")); - const hasWriting = profile.interests?.some( - (i) => i.toLowerCase().includes("write") || i.toLowerCase().includes("blog"), - ); - - if (hasCode) { - const primaryLang = profile.languages?.[0] || "TypeScript"; - roles.push({ - name: "Code Engineer", - role: "software-engineer", - description: `Specializes in ${primaryLang} development and code review`, - instructions: `You are the Code Engineer specialist. Your expertise is in ${profile.languages?.join(", ") || "software development"}. Review code, write tests, debug issues, and implement features. You have access to the user's local repositories and can run commands to build and test code.`, - skills: profile.skills.filter( - (s) => s.toLowerCase().includes("code") || s.toLowerCase().includes("program"), - ), - }); - } - - if (hasDesign) { - roles.push({ - name: "UI/UX Designer", - role: "ui-designer", - description: "Creates polished interfaces and design systems", - instructions: - "You are the UI/UX Designer specialist. Focus on creating beautiful, accessible interfaces. Review designs, suggest improvements, and ensure consistency across the user's projects.", - skills: ["design", "UI/UX"], - }); - } - - if (hasData) { - roles.push({ - name: "Data Analyst", - role: "data-analyst", - description: "Analyzes data, creates reports, and extracts insights", - instructions: - "You are the Data Analyst specialist. Process data from the user's projects, create visualizations, generate reports, and provide actionable insights.", - skills: ["data analysis", "statistics"], - }); - } - - if (hasDevOps) { - roles.push({ - name: "DevOps Engineer", - role: "devops-engineer", - description: "Manages infrastructure, deployments, and CI/CD", - instructions: - "You are the DevOps Engineer specialist. Handle deployments, manage infrastructure, configure CI/CD pipelines, and ensure system reliability.", - skills: ["DevOps", "infrastructure"], - }); - } - - if (hasMusic) { - roles.push({ - name: "Music Producer", - role: "music-producer", - description: "Assists with music production and audio engineering", - instructions: - "You are the Music Producer specialist. Help with music composition, audio engineering, mixing, and mastering. You understand the user's music projects and can provide expert advice.", - skills: ["music production", "audio engineering"], - }); - } - - if (hasWriting) { - roles.push({ - name: "Content Writer", - role: "content-writer", - description: "Creates and edits written content", - instructions: - "You are the Content Writer specialist. Write blog posts, documentation, articles, and other content for the user's projects and personal use.", - skills: ["writing", "content creation"], - }); - } - - // Always include a general assistant - roles.unshift({ - name: "General Assistant", - role: "general-assistant", - description: "Handles general tasks and coordinates other specialists", - instructions: `You are the General Assistant and team coordinator. Your role is to handle general queries, coordinate with other specialists (${ - roles - .slice(1) - .map((r) => r.name) - .join(", ") || "none yet" - }), and manage the user's daily workflow. You have access to all tools and can delegate tasks to specialists when needed.`, - skills: ["task management", "coordination"], - }); - - return roles; -} - -// --------------------------------------------------------------------------- -// Utility -// --------------------------------------------------------------------------- - -function spawnPromise( - cmd: string, - args: string[], - options?: { cwd?: string }, -): Promise<{ stdout: string; stderr: string }> { - return new Promise((resolve, reject) => { - const child = spawn(cmd, args, { ...options, timeout: 15000 }); - let stdout = ""; - let stderr = ""; - child.stdout?.on("data", (d: Buffer) => { - stdout += d.toString(); - }); - child.stderr?.on("data", (d: Buffer) => { - stderr += d.toString(); - }); - child.on("error", () => reject(new Error("spawn failed"))); - child.on("close", (code) => { - if (code === 0 || code === null) resolve({ stdout, stderr }); - else reject(new Error(`exit ${code}`)); - }); - }); -} - -// Keep old registry cleanup -setInterval(() => { - const cutoff = Date.now() - 3600000; - for (const [id, r] of scanRegistry) { - if (r.completedAt && r.completedAt.getTime() < cutoff) scanRegistry.delete(id); - } -}, 60000); diff --git a/apps/api/src/router.ts b/apps/api/src/router.ts index 2baf21e..2af5c9a 100644 --- a/apps/api/src/router.ts +++ b/apps/api/src/router.ts @@ -157,6 +157,7 @@ import { resolveBusyBotName, toComputerStatus, } from "./computer-status.js"; +import { recordConnectedApp } from "./connected-app-facts.js"; import { listInbox } from "./inbox.js"; import { searchIntegrationCatalog } from "./integration-catalog.js"; import { @@ -180,7 +181,12 @@ import { promptFocus, startOnboarding, } from "./onboarding.js"; -import { getScanStatus, startOnboardingScan } from "./onboarding-scan.js"; +import { + applyOnboardingScan, + onboardingScanFolder, + onboardingScanStatus, + startOnboardingScan, +} from "./onboarding-scan.js"; import { listSpaceRuns } from "./runs.js"; import { addScreenProxyCapability } from "./screen-proxy.js"; import { querySpaceSearch } from "./search.js"; @@ -285,6 +291,11 @@ async function reconcilePendingConnections( : []), ]; if (updates.length > 0) await prisma.$transaction(updates); + const connected = new Set(sync.connectIds); + for (const row of rows) { + if (row.status !== "pending" || !connected.has(row.id)) continue; + await recordConnectedApp(prisma, { userId: owner.userId, connectorId, provider: row.provider }); + } } /** Serialize begin/revoke for one user+provider so slug-wide remote deletes cannot race a new connect. */ @@ -3434,16 +3445,18 @@ export function createRouter(deps: RouterDeps) { ); return { ok: true as const }; }), - scan: authed.onboarding.scan.handler(async ({ context, input }) => { - const { scanId } = await startOnboardingScan( - { actor: context.actor, botId: input.botId, scanHome: deps.env.onboardingScanHome }, - deps.prisma, - ); - return { scanId }; - }), - scanStatus: authed.onboarding.scanStatus.handler(async ({ context, input }) => { - return await getScanStatus(input.scanId, context.actor.userId); - }), + scanTarget: authed.onboarding.scanTarget.handler(async ({ context }) => ({ + folder: await onboardingScanFolder(context.actor, deps.env.onboardingScanHome), + })), + scan: authed.onboarding.scan.handler(({ context }) => + startOnboardingScan(context.actor, deps.env.onboardingScanHome), + ), + scanStatus: authed.onboarding.scanStatus.handler(async ({ context, input }) => + onboardingScanStatus(input.scanId, context.actor.userId), + ), + applyScan: authed.onboarding.applyScan.handler(({ context, input }) => + applyOnboardingScan(deps.prisma, context.actor, deps.env.onboardingScanHome, input), + ), }, integrationSetup: { get: authed.integrationSetup.get.handler(async ({ context }) => { @@ -3692,6 +3705,13 @@ export function createRouter(deps: RouterDeps) { } throw new IsolationError(); } + if (!auth.authorizationUrl) { + await recordConnectedApp(deps.prisma, { + userId: context.actor.userId, + connectorId: input.connectorId, + provider: input.provider, + }); + } return { connectionId: row.id, authorizationUrl: auth.authorizationUrl }; } catch (error) { if (error instanceof IsolationError) throw error; @@ -3997,6 +4017,13 @@ export function createRouter(deps: RouterDeps) { }, { timeout: 60_000 }, ); + if (row.status === "connected") { + await recordConnectedApp(deps.prisma, { + userId: context.actor.userId, + connectorId: row.connectorId, + provider: row.provider, + }); + } } return { id: row.id, diff --git a/apps/web/e2e/onboarding-discovery.spec.ts b/apps/web/e2e/onboarding-discovery.spec.ts new file mode 100644 index 0000000..0f639bc --- /dev/null +++ b/apps/web/e2e/onboarding-discovery.spec.ts @@ -0,0 +1,76 @@ +/** + * E2E: the deployment owner's onboarding asks before scanning the fixture home folder the + * harness configures, then adds only the projects and facts the user picks. + */ +import { expect, type Page, test } from "@playwright/test"; +import type { SpaceNavigation, UserProfileState } from "@rakazo/contracts"; +import { captureScreenshot, rpc, signup } from "./helpers"; + +const api = process.env.API_URL ?? "http://127.0.0.1:3110"; + +/** Advances whichever wizard step is showing until the scan step is reached. */ +async function walkToScanStep(page: Page) { + const surface = page.locator('[data-rakazo-surface="onboarding"]'); + await expect(async () => { + if ((await surface.getAttribute("data-onboarding-step")) === "scan") return; + const skip = page.getByRole("button", { name: /^Skip/ }).first(); + const next = (await skip.isVisible()) + ? skip + : page.getByRole("button", { name: "Continue", exact: true }); + await next.click({ timeout: 2_000 }); + await expect(surface).toHaveAttribute("data-onboarding-step", "scan", { timeout: 1_000 }); + }).toPass({ timeout: 60_000 }); +} + +test("asks before looking for projects and adds only what the user picks", async ({ + page, +}, testInfo) => { + const email = `discovery-${Date.now()}@rakazo.test`; + await signup(page, email, "password12", "Discovery"); + await page.waitForURL(/\/onboarding/, { timeout: 20_000 }); + // The harness starts without a deployment owner; this spec takes the seat for its run. + const owner = await page.request.post(`${api}/__e2e/deployment-owner`, { data: { email } }); + expect(owner.ok()).toBe(true); + const { previousOwnerUserId } = (await owner.json()) as { previousOwnerUserId: string | null }; + try { + await page.goto("/onboarding", { waitUntil: "domcontentloaded" }); + await walkToScanStep(page); + + await expect(page.getByRole("heading", { name: "Look for projects here?" })).toBeVisible(); + await expect(page.getByText(/rakazo-e2e-scan-home-/)).toBeVisible(); + await captureScreenshot(page, testInfo, "onboarding-discovery-consent"); + await page.getByRole("button", { name: "Look", exact: true }).click(); + + const projects = page.getByRole("group", { name: "Add as projects" }); + await expect(projects.getByRole("checkbox")).toHaveCount(2); + const atlas = projects.getByRole("checkbox", { name: /atlas/ }); + await expect(atlas).not.toBeChecked(); + await expect(projects.getByRole("checkbox", { name: /garden/ })).not.toBeChecked(); + await atlas.check(); + const facts = page.getByRole("group", { name: "Add to About you" }); + await expect(facts.getByRole("checkbox", { name: "TypeScript" })).toBeChecked(); + await facts.getByRole("checkbox", { name: "Python" }).uncheck(); + await captureScreenshot(page, testInfo, "onboarding-discovery-results"); + await page.getByRole("button", { name: "Add", exact: true }).click(); + await page.waitForURL(/\/app\/g\/[^/]+$/, { timeout: 30_000 }); + + const navigation = await rpc(page, "spaces/list", {}); + const names = navigation.spaces.map((space) => space.name); + expect(names).toContain("atlas"); + expect(names).not.toContain("garden"); + expect(navigation.spaces.find((space) => space.name === "atlas")?.workspace?.path).toMatch( + /code\/atlas$/, + ); + const values = (await rpc(page, "profile/get", {})).facts.map( + (fact) => fact.value, + ); + expect(values).toEqual( + expect.arrayContaining(["Codes in TypeScript", "Uses Docker", "Uses GitHub Actions"]), + ); + expect(values).not.toContain("Codes in Python"); + } finally { + await page.request.post(`${api}/__e2e/deployment-owner`, { + data: { userId: previousOwnerUserId }, + }); + } +}); diff --git a/apps/web/src/components/onboarding/OnboardingScanProgress.tsx b/apps/web/src/components/onboarding/OnboardingScanProgress.tsx deleted file mode 100644 index 16c41b4..0000000 --- a/apps/web/src/components/onboarding/OnboardingScanProgress.tsx +++ /dev/null @@ -1,186 +0,0 @@ -import { useEffect, useState } from "react"; -import { rpc } from "../../lib/rpc"; - -interface ScanStatus { - status: "scanning" | "connecting" | "building" | "briefing" | "complete" | "failed"; - progress: number; - message: string; - result?: unknown; -} - -const PHASES = [ - { - key: "scanning" as const, - label: "Scanning your computer...", - sublabel: "Files, folders, git repositories", - }, - { - key: "connecting" as const, - label: "Connecting to your services...", - sublabel: "GitHub, email, calendar", - }, - { - key: "building" as const, - label: "Building your profile...", - sublabel: "Interests, skills, preferences", - }, - { - key: "briefing" as const, - label: "Setting up your agent team...", - sublabel: "Project folders, role assignments", - }, -] as const; - -export function OnboardingScanProgress({ - onComplete, - botId, -}: { - onComplete: () => void; - botId?: string; -}) { - const [status, setStatus] = useState(null); - const [error, setError] = useState(null); - const [, setScanId] = useState(null); - - useEffect(() => { - let cancelled = false; - let pollingInterval: ReturnType | null = null; - - const run = async () => { - try { - const scanResult = await rpc.onboarding.scan(botId ? { botId } : {}); - if (cancelled) return; - setScanId(scanResult.scanId); - - // Poll for status - const poll = async () => { - try { - const s = await rpc.onboarding.scanStatus({ scanId: scanResult.scanId }); - if (cancelled) return; - setStatus(s); - if (s.status === "complete") { - onComplete(); - } else if (s.status === "failed") { - setError(s.message); - } - } catch { - // poll error — keep trying - } - }; - - // First poll immediately, then every 2s - await poll(); - pollingInterval = setInterval(poll, 2000); - } catch (err) { - if (!cancelled) setError(err instanceof Error ? err.message : "Scan failed"); - } - }; - - run(); - return () => { - cancelled = true; - if (pollingInterval) clearInterval(pollingInterval); - }; - }, [botId, onComplete]); - - if (error) { - return ( -
    -

    {error}

    - -
    - ); - } - - if (!status) { - return ( -
    -
    -

    Preparing your profile...

    -
    - ); - } - - const currentPhaseIndex = PHASES.findIndex((p) => p.key === status.status); - const isComplete = status.status === "complete"; - - return ( -
    -

    - {isComplete ? "All set!" : "Getting to know you..."} -

    -

    - {isComplete - ? "Your profile and agent team are ready." - : "We're scanning your computer and services to personalize your experience."} -

    - -
    - {PHASES.map((phase, idx) => { - const isActive = idx === currentPhaseIndex && !isComplete; - const isDone = idx < currentPhaseIndex || isComplete; - return ( -
    -
    - {isDone ? ( - - ✓ - - ) : isActive ? ( - - ● - - ) : ( - - ○ - - )} -
    -
    -

    - {phase.label} -

    -

    {phase.sublabel}

    -
    - {isActive && ( -
    -
    -
    -
    -
    - )} -
    - ); - })} -
    - - {isComplete && ( -
    - -
    - )} -
    - ); -} diff --git a/apps/web/src/components/onboarding/OnboardingScanStep.tsx b/apps/web/src/components/onboarding/OnboardingScanStep.tsx new file mode 100644 index 0000000..1b700c8 --- /dev/null +++ b/apps/web/src/components/onboarding/OnboardingScanStep.tsx @@ -0,0 +1,250 @@ +import { Trans, useLingui } from "@lingui/react/macro"; +import type { OnboardingScanFact, OnboardingScanProject } from "@rakazo/contracts"; +import { Button, Checkbox } from "@rakazo/ui-web"; +import { useEffect, useId, useRef, useState } from "react"; +import { rpc } from "../../lib/rpc"; +import { LoadingState } from "../ai/primitives"; + +const POLL_MS = 1_000; + +interface Found { + projects: OnboardingScanProject[]; + facts: OnboardingScanFact[]; +} + +function toggled(ids: string[], id: string, on: boolean): string[] { + return on ? [...ids, id] : ids.filter((entry) => entry !== id); +} + +/** + * Asks before looking at the server's scan folder, then lets the user choose which found projects + * become project spaces and which languages and tools go to About you. Nothing is created without + * a choice. Skips itself when this account cannot scan. + */ +export function OnboardingScanStep({ onDone }: { onDone: () => void }) { + const { t } = useLingui(); + const ids = useId(); + const done = useRef(onDone); + done.current = onDone; + const [folder, setFolder] = useState(null); + const [looking, setLooking] = useState(false); + const [scanId, setScanId] = useState(null); + const [found, setFound] = useState(null); + const [failed, setFailed] = useState(false); + const [projectIds, setProjectIds] = useState([]); + const [factIds, setFactIds] = useState([]); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + rpc.onboarding + .scanTarget() + .then((target) => { + if (cancelled) return; + if (target.folder) setFolder(target.folder); + else done.current(); + }) + .catch(() => { + if (!cancelled) done.current(); + }); + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + if (!scanId) return; + let cancelled = false; + let timer: ReturnType | undefined; + const poll = () => { + rpc.onboarding + .scanStatus({ scanId }) + .then((status) => { + if (cancelled) return; + if (status.status === "scanning") { + timer = setTimeout(poll, POLL_MS); + } else if (status.status === "failed") { + setFailed(true); + } else { + setFound({ projects: status.projects, facts: status.facts }); + setFactIds(status.facts.map((fact) => fact.id)); + } + }) + .catch(() => { + if (!cancelled) setFailed(true); + }); + }; + poll(); + return () => { + cancelled = true; + clearTimeout(timer); + }; + }, [scanId]); + + async function look() { + setLooking(true); + try { + setScanId((await rpc.onboarding.scan()).scanId); + } catch { + setFailed(true); + } + } + + async function add() { + if (!scanId) return; + setSaving(true); + setError(null); + try { + await rpc.onboarding.applyScan({ scanId, projectIds, factIds }); + done.current(); + } catch (reason) { + setError(reason instanceof Error ? reason.message : String(reason)); + setSaving(false); + } + } + + const continueButton = ( +
    + +
    + ); + + if (failed) { + return ( +
    +

    + Could not look in this folder. +

    + {continueButton} +
    + ); + } + + if (found && found.projects.length === 0) { + return ( +
    +

    + No projects found +

    + {continueButton} +
    + ); + } + + if (found) { + return ( +
    +
    + +

    + Add as projects +

    +
    +
    + {found.projects.map((project) => ( + + ))} +
    +
    + + {found.facts.length > 0 ? ( +
    + + Add to About you + +
    + {found.facts.map((fact) => ( + + ))} +
    +
    + ) : null} + + {error ?

    {error}

    : null} + +
    + + +
    +
    + ); + } + + if (looking) { + return ( +
    + +
    + ); + } + + if (!folder) return null; + + return ( +
    +

    + Look for projects here? +

    +

    + {folder} +

    +

    + Only file and folder names are read. +

    +
    + + +
    +
    + ); +} diff --git a/apps/web/src/pages/Onboarding.tsx b/apps/web/src/pages/Onboarding.tsx index 0423eaa..75b9533 100644 --- a/apps/web/src/pages/Onboarding.tsx +++ b/apps/web/src/pages/Onboarding.tsx @@ -9,7 +9,7 @@ import { type OnboardingEmojiConfig, OnboardingOoDesigner, } from "../components/onboarding/OnboardingOoDesigner"; -import { OnboardingScanProgress } from "../components/onboarding/OnboardingScanProgress"; +import { OnboardingScanStep } from "../components/onboarding/OnboardingScanStep"; import { KOKORO_LOCAL_TOKEN, OnboardingVoiceStep, @@ -115,7 +115,6 @@ export function OnboardingPage() { const [profilePersisted, setProfilePersisted] = useState(false); const [crewReady, setCrewReady] = useState(false); const [_helpVibe, setHelpVibe] = useState(null); - const [scanBotId, setScanBotId] = useState(null); const [emojiConfig, setEmojiConfig] = useState(defaultEmojiConfig); const [prefs, setPrefs] = useState(defaultPreferences()); @@ -141,14 +140,6 @@ export function OnboardingPage() { if (cancelled) return; setBootstrapMe(me); setHelpVibe(inferHelpVibeHints(collectLocalProfileSignals(me))); - // Get bot ID for scan phase - try { - const botList = await rpc.bots.list(); - const mainBot = botList.find((b) => b.kind === "main"); - if (mainBot && !cancelled) setScanBotId(mainBot.id); - } catch { - /* best effort */ - } setIntegrationSetup(integrations); // The first step renders before this resolves; never pull a user back who moved on. if (!me.needsModel && integrations?.needsSetup) { @@ -513,12 +504,7 @@ export function OnboardingPage() {
    ) : null} - {step === "scan" ? ( - setStep("bot")} - botId={scanBotId ?? undefined} - /> - ) : null} + {step === "scan" ? setStep("bot")} /> : null} {step === "bot" && error ? (
    diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 3865e1b..af399a3 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -10,6 +10,7 @@ export * from "./integration-settings.js"; export * from "./local-settings.js"; export * from "./mcp.js"; export * from "./model-tiers.js"; +export * from "./onboarding-scan.js"; export * from "./openai-compatible-ui.js"; export * from "./project-goals.js"; export * from "./project-sessions.js"; diff --git a/packages/contracts/src/onboarding-scan.ts b/packages/contracts/src/onboarding-scan.ts new file mode 100644 index 0000000..97faa35 --- /dev/null +++ b/packages/contracts/src/onboarding-scan.ts @@ -0,0 +1,49 @@ +import * as z from "zod"; +import { Id } from "./ids.js"; + +/** Upper bounds on one scan's choices; the server lists at most this many projects and facts. */ +export const ONBOARDING_SCAN_MAX_PROJECTS = 30; +export const ONBOARDING_SCAN_MAX_FACTS = 20; + +/** The folder a consented scan would read, or null when this account cannot scan. */ +export const OnboardingScanTargetSchema = z.object({ folder: z.string().nullable() }); +export type OnboardingScanTarget = z.infer; + +export const OnboardingScanProjectSchema = z.object({ + id: z.string(), + name: z.string(), + /** Relative to the scanned folder. */ + path: z.string(), +}); +export type OnboardingScanProject = z.infer; + +export const OnboardingScanFactSchema = z.object({ + id: z.string(), + /** A language or tool name, e.g. "TypeScript". */ + label: z.string(), +}); +export type OnboardingScanFact = z.infer; + +export const OnboardingScanStatusSchema = z.discriminatedUnion("status", [ + z.object({ status: z.literal("scanning") }), + z.object({ status: z.literal("failed") }), + z.object({ + status: z.literal("complete"), + projects: z.array(OnboardingScanProjectSchema), + facts: z.array(OnboardingScanFactSchema), + }), +]); +export type OnboardingScanStatus = z.infer; + +export const OnboardingScanApplyInputSchema = z.object({ + scanId: z.string(), + projectIds: z.array(z.string()).max(ONBOARDING_SCAN_MAX_PROJECTS), + factIds: z.array(z.string()).max(ONBOARDING_SCAN_MAX_FACTS), +}); +export type OnboardingScanApplyInput = z.infer; + +export const OnboardingScanApplyResultSchema = z.object({ + spaces: z.array(z.object({ id: Id, name: z.string() })), + factsAdded: z.number().int().nonnegative(), +}); +export type OnboardingScanApplyResult = z.infer; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 6640d4c..48506ba 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -83,6 +83,12 @@ import { IntegrationSetupStateSchema, } from "./integration-settings.js"; import { ModelTierMappingSchema, SetModelTierInput } from "./model-tiers.js"; +import { + OnboardingScanApplyInputSchema, + OnboardingScanApplyResultSchema, + OnboardingScanStatusSchema, + OnboardingScanTargetSchema, +} from "./onboarding-scan.js"; import { CreateProjectGoalInput, ProjectGoalSchema, @@ -676,15 +682,13 @@ export const appContract = { z.object({ botId: Id, provider: z.string(), connectorId: z.string().default("composio") }), ) .output(z.object({ ok: z.literal(true) })), - scan: oc.input(z.object({ botId: Id.optional() })).output(z.object({ scanId: z.string() })), - scanStatus: oc.input(z.object({ scanId: z.string() })).output( - z.object({ - status: z.enum(["scanning", "connecting", "building", "briefing", "complete", "failed"]), - progress: z.number(), - message: z.string(), - result: z.any().optional(), - }), - ), + /** The folder a scan would read; null when this account cannot scan, so the step is skipped. */ + scanTarget: oc.output(OnboardingScanTargetSchema), + /** Start scanning that folder. Only called after the user agreed. */ + scan: oc.output(z.object({ scanId: z.string() })), + scanStatus: oc.input(z.object({ scanId: z.string() })).output(OnboardingScanStatusSchema), + /** Create a project space per chosen project and save the chosen facts. */ + applyScan: oc.input(OnboardingScanApplyInputSchema).output(OnboardingScanApplyResultSchema), }, integrationSetup: { get: oc.output(IntegrationSetupStateSchema), diff --git a/packages/testkit/src/authorization.test.ts b/packages/testkit/src/authorization.test.ts index ce1e785..21ffadd 100644 --- a/packages/testkit/src/authorization.test.ts +++ b/packages/testkit/src/authorization.test.ts @@ -556,19 +556,18 @@ describeWithDatabase("API authorization and resource isolation", () => { ).toMatchObject({ content: "owner-only-memory" }); // The user profile is per user: another user's fact ids and spaces are out of reach. - const ownerProfile = await rpc<{ facts: Array<{ id: string; status: string }> }>( - app, - owner, - "profile/update", - { - addFacts: [ - { category: "work", value: "Owner works nights" }, - { category: "health", value: "Owner health note", source: "onboarding" }, - ], - }, - ); - const ownerFactIds = ownerProfile.facts.map((fact) => fact.id); - expect(ownerProfile.facts.map((fact) => fact.status).sort()).toEqual(["active", "pending"]); + const ownerProfile = await rpc<{ + facts: Array<{ id: string; status: string; source: string }>; + }>(app, owner, "profile/update", { + addFacts: [ + { category: "work", value: "Owner works nights" }, + { category: "health", value: "Owner health note", source: "onboarding" }, + ], + }); + // Connecting an app above already remembered it; this check is about the facts added here. + const ownerFacts = ownerProfile.facts.filter((fact) => fact.source !== "connector"); + const ownerFactIds = ownerFacts.map((fact) => fact.id); + expect(ownerFacts.map((fact) => fact.status).sort()).toEqual(["active", "pending"]); const profileCalls = [ ["profile/update", { confirmFactIds: ownerFactIds }], ["profile/update", { editFacts: [{ id: ownerFactIds[0], value: "Stolen" }] }], diff --git a/packages/testkit/src/cli/harness.ts b/packages/testkit/src/cli/harness.ts index 27415a4..5305a76 100644 --- a/packages/testkit/src/cli/harness.ts +++ b/packages/testkit/src/cli/harness.ts @@ -1,9 +1,11 @@ import { execSync } from "node:child_process"; -import { mkdir, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; import path from "node:path"; import { loadRootEnv } from "@rakazo/core/node/load-root-env"; import { PostgreSqlContainer } from "@testcontainers/postgresql"; import type { createApp } from "../../../../apps/api/src/app.ts"; +import { writeOnboardingScanFixture } from "../onboarding-scan-fixture.js"; import { runProcess } from "./process.js"; loadRootEnv(); @@ -106,6 +108,7 @@ async function main() { "packages/testkit/src/bot-secrets.test.ts", "packages/testkit/src/delegation.postgres.test.ts", "packages/testkit/src/user-profile.postgres.test.ts", + "packages/testkit/src/onboarding-discovery.postgres.test.ts", "packages/db/src/space-membership.postgres.test.ts", "packages/db/src/main-agent.postgres.test.ts", "packages/db/src/user-profile.postgres.test.ts", @@ -180,6 +183,10 @@ async function main() { { fetch: thirdParties.fetch, resolveHostname: thirdParties.resolveHostname }, ); const email = new EmailEmulator(); + // A fixture home folder, so the deployment owner's onboarding offers the real project scan. + const scanHome = await mkdtemp(path.join(tmpdir(), "rakazo-e2e-scan-home-")); + await writeOnboardingScanFixture(scanHome); + process.env.ONBOARDING_SCAN_HOME = scanHome; const handles = await createApp({ databaseUrl, prisma: undefined, @@ -192,13 +199,30 @@ async function main() { }, integrationsCatalogUrl: "https://catalog.example.test/", }); + // No spec user owns the deployment unless it asks (POST /__e2e/deployment-owner), so + // owner-only onboarding steps appear only in the specs that test them, whatever runs first. + await handles.prisma.deploymentSettings.upsert({ + where: { id: "default" }, + create: { + id: "default", + ownerUserId: E2E_NO_OWNER, + signupsEnabled: true, + signupAllowlist: "", + signupPolicyInitialized: true, + }, + update: { ownerUserId: E2E_NO_OWNER }, + }); let activeRequests = 0; const requestWaiters = new Set<() => void>(); const server = serve({ fetch: async (request) => { - if (new URL(request.url).pathname === "/__e2e/emails") { + const { pathname } = new URL(request.url); + if (pathname === "/__e2e/emails") { return Response.json(email.sent, { headers: { "cache-control": "no-store" } }); } + if (pathname === "/__e2e/deployment-owner" && request.method === "POST") { + return Response.json(await setDeploymentOwner(handles, await request.json())); + } activeRequests += 1; try { return await handles.app.fetch(request); @@ -266,6 +290,7 @@ async function main() { await new Promise((resolve) => requestWaiters.add(resolve)); } await handles.stop().catch(() => undefined); + await rm(scanHome, { recursive: true, force: true }).catch(() => undefined); for (let index = 0; index < computers.length; index += 4) { const results = await Promise.allSettled( computers.slice(index, index + 4).map((computer) => @@ -304,6 +329,9 @@ async function main() { type AppHandles = Awaited>; +/** Matches no user, so the first signup does not claim the deployment. */ +const E2E_NO_OWNER = "e2e-no-owner"; + async function managedComputers(handles: AppHandles) { if (!["e2b", "daytona", "box"].includes(sandboxProvider)) return []; return handles.prisma.computer.findMany({ @@ -312,6 +340,33 @@ async function managedComputers(handles: AppHandles) { }); } +/** + * Lets a spec act as the deployment owner (by email) for owner-only steps, then restore the + * previous owner (by id). Returns the owner it replaced. + */ +async function setDeploymentOwner( + handles: AppHandles, + input: { email?: string; userId?: string | null }, +): Promise<{ previousOwnerUserId: string | null }> { + const previous = await handles.prisma.deploymentSettings.findUnique({ + where: { id: "default" }, + select: { ownerUserId: true }, + }); + const ownerUserId = input.email + ? ( + await handles.prisma.user.findUniqueOrThrow({ + where: { email: input.email }, + select: { id: true }, + }) + ).id + : (input.userId ?? E2E_NO_OWNER); + await handles.prisma.deploymentSettings.update({ + where: { id: "default" }, + data: { ownerUserId }, + }); + return { previousOwnerUserId: previous?.ownerUserId ?? null }; +} + async function writeSummary(reportDir: string, summary: Record) { await writeFile( path.join(reportDir, "summary.json"), diff --git a/packages/testkit/src/onboarding-discovery.postgres.test.ts b/packages/testkit/src/onboarding-discovery.postgres.test.ts new file mode 100644 index 0000000..671131b --- /dev/null +++ b/packages/testkit/src/onboarding-discovery.postgres.test.ts @@ -0,0 +1,363 @@ +import { mkdtempSync, realpathSync, rmSync, symlinkSync } from "node:fs"; +import { mkdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { + ComposioEmulator, + PipedreamConnector, + ThirdPartyConnectorEmulator, +} from "@rakazo/adapters"; +import type { + OnboardingScanApplyResult, + OnboardingScanStatus, + SpaceNavigation, + UserProfileState, +} from "@rakazo/contracts"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import type { createApp } from "../../../apps/api/src/app.ts"; +import { sessionCookieHeader } from "./index.js"; +import { writeOnboardingScanFixture } from "./onboarding-scan-fixture.js"; + +type App = { request: (input: string, init?: RequestInit) => Response | Promise }; +type AppHandles = Awaited>; +type Actor = { userId: string; spaceId: string }; + +process.env.WAKEUP_DRIVER = "memory"; +process.env.SANDBOX_PROVIDER = "fake"; +process.env.AGENT_RUNTIME = "scripted"; + +const hasDb = process.env.VERIFY_DATABASE === "1" && Boolean(process.env.DATABASE_URL); +const describeWithDatabase = hasDb ? describe : describe.skip; +const TEST_ENCRYPTION_KEY = "offline-discovery-test-encryption-key"; + +describeWithDatabase("onboarding discovery through the API", () => { + let handles: AppHandles; + let app: App; + let composio: ComposioEmulator; + const stamp = `${Date.now()}-${Math.random().toString(36).slice(2)}`; + const dataDir = mkdtempSync(path.join(tmpdir(), "rakazo-discovery-")); + const home = realpathSync(mkdtempSync(path.join(tmpdir(), "rakazo-scan-home-"))); + const outside = realpathSync(mkdtempSync(path.join(tmpdir(), "rakazo-scan-outside-"))); + + beforeAll(async () => { + await writeOnboardingScanFixture(home); + const { createApp } = await import("../../../apps/api/src/app.ts"); + composio = new ComposioEmulator(); + const thirdParties = new ThirdPartyConnectorEmulator(); + handles = await createApp({ + databaseUrl: process.env.DATABASE_URL!, + dataDir, + sandboxProvider: "fake", + agentRuntime: "scripted", + wakeupDriver: "memory", + signupsEnabled: "true", + encryptionKey: TEST_ENCRYPTION_KEY, + onboardingScanHome: home, + composio, + pipedream: new PipedreamConnector( + { + clientId: "fake-client-id", + clientSecret: "fake-client-secret", + projectId: "fake-project-id", + environment: "development", + identitySecret: TEST_ENCRYPTION_KEY, + }, + { fetch: thirdParties.fetch, resolveHostname: thirdParties.resolveHostname }, + ), + remoteConnectors: { + fetch: thirdParties.fetch, + resolveHostname: thirdParties.resolveHostname, + }, + }); + app = handles.app; + }); + + afterAll(async () => { + await handles?.stop(); + for (const dir of [dataDir, home, outside]) rmSync(dir, { recursive: true, force: true }); + }); + + async function account(label: string): Promise<{ cookie: string; actor: Actor }> { + const cookie = await signup(app, `${label}-${stamp}@rakazo.test`, label); + return { cookie, actor: await rpc(app, cookie, "me") }; + } + + async function makeDeploymentOwner(actor: Actor): Promise { + await handles.prisma.deploymentSettings.update({ + where: { id: "default" }, + data: { ownerUserId: actor.userId }, + }); + } + + async function finishedScan(cookie: string) { + const { scanId } = await rpc<{ scanId: string }>(app, cookie, "onboarding/scan"); + const status = await vi.waitFor( + async () => { + const current = await rpc(app, cookie, "onboarding/scanStatus", { + scanId, + }); + if (current.status === "scanning") throw new Error("still scanning"); + return current; + }, + { timeout: 10_000, interval: 50 }, + ); + if (status.status !== "complete") throw new Error("scan failed"); + return { scanId, ...status }; + } + + it("offers the scan only to the deployment owner and keeps it with them", async () => { + const owner = await account("discovery-owner"); + const other = await account("discovery-other"); + await makeDeploymentOwner(owner.actor); + + await expect(rpc(app, other.cookie, "onboarding/scanTarget")).resolves.toEqual({ + folder: null, + }); + expect((await raw(app, other.cookie, "onboarding/scan")).status).toBe(403); + await expect(rpc(app, owner.cookie, "onboarding/scanTarget")).resolves.toEqual({ + folder: home, + }); + + const scan = await finishedScan(owner.cookie); + expect(scan.projects).toEqual([ + { id: "project-0", name: "atlas", path: "code/atlas" }, + { id: "project-1", name: "garden", path: "code/garden" }, + ]); + expect(scan.facts.map((fact) => fact.label)).toEqual([ + "Python", + "TypeScript", + "Docker", + "GitHub Actions", + ]); + expect( + (await raw(app, other.cookie, "onboarding/scanStatus", { scanId: scan.scanId })).status, + ).toBe(404); + expect( + ( + await raw(app, other.cookie, "onboarding/applyScan", { + scanId: scan.scanId, + projectIds: ["project-0"], + factIds: ["language:Python"], + }) + ).status, + ).toBe(404); + await expect(rpc(app, other.cookie, "profile/get")).resolves.toMatchObject({ + facts: [], + }); + }); + + it("creates the chosen projects in the owner's own spaces and saves only the chosen facts", async () => { + const owner = await account("discovery-creator"); + const intruder = await account("discovery-intruder"); + await makeDeploymentOwner(owner.actor); + const atlasFolder = path.join(home, "code/atlas"); + // Another tenant already linked a space with the same name to the same folder. + const foreign = await rpc<{ id: string }>(app, intruder.cookie, "spaces/create", { + name: "atlas", + workspacePath: atlasFolder, + }); + + const scan = await finishedScan(owner.cookie); + const choice = { + scanId: scan.scanId, + projectIds: ["project-0"], + factIds: ["language:TypeScript", "tool:Docker"], + }; + const applied = await rpc( + app, + owner.cookie, + "onboarding/applyScan", + choice, + ); + + expect(applied.factsAdded).toBe(2); + expect(applied.spaces).toEqual([{ id: expect.any(String), name: "atlas" }]); + const created = applied.spaces[0]!.id; + expect(created).not.toBe(foreign.id); + const ownerOrg = await handles.prisma.space.findUniqueOrThrow({ + where: { id: owner.actor.spaceId }, + select: { organizationId: true }, + }); + await expect( + handles.prisma.space.findUniqueOrThrow({ + where: { id: created }, + select: { organizationId: true, workspacePath: true }, + }), + ).resolves.toEqual({ organizationId: ownerOrg.organizationId, workspacePath: atlasFolder }); + await expect( + handles.prisma.spaceMember.findMany({ + where: { spaceId: { in: [created, foreign.id] } }, + select: { spaceId: true, userId: true, role: true }, + orderBy: { spaceId: "asc" }, + }), + ).resolves.toEqual( + expect.arrayContaining([ + { spaceId: created, userId: owner.actor.userId, role: "owner" }, + { spaceId: foreign.id, userId: intruder.actor.userId, role: "owner" }, + ]), + ); + expect( + await handles.prisma.spaceMember.count({ where: { spaceId: { in: [created, foreign.id] } } }), + ).toBe(2); + await expect( + handles.prisma.memoryDocument.count({ + where: { spaceId: created, userId: owner.actor.userId, scope: "user" }, + }), + ).resolves.toBe(1); + + const ownerSpaces = await rpc(app, owner.cookie, "spaces/list"); + expect(ownerSpaces.spaces).toContainEqual( + expect.objectContaining({ + id: created, + name: "atlas", + workspace: expect.objectContaining({ path: atlasFolder }), + }), + ); + expect(ownerSpaces.spaces.map((space) => space.id)).not.toContain(foreign.id); + const intruderSpaces = await rpc(app, intruder.cookie, "spaces/list"); + expect(intruderSpaces.spaces.map((space) => space.id)).not.toContain(created); + + const profile = await rpc(app, owner.cookie, "profile/get"); + expect( + profile.facts.map(({ category, value, source }) => ({ category, value, source })), + ).toEqual( + expect.arrayContaining([ + { category: "work", value: "Codes in TypeScript", source: "onboarding" }, + { category: "tools", value: "Uses Docker", source: "onboarding" }, + ]), + ); + expect(profile.facts).toHaveLength(2); + await expect(rpc(app, intruder.cookie, "profile/get")).resolves.toMatchObject( + { facts: [] }, + ); + + // Applying again reuses the space and the facts. + await expect( + rpc(app, owner.cookie, "onboarding/applyScan", choice), + ).resolves.toEqual({ spaces: [{ id: created, name: "atlas" }], factsAdded: 0 }); + expect(await handles.prisma.spaceMember.count({ where: { userId: owner.actor.userId } })).toBe( + 2, + ); + }); + + it("refuses a chosen project that left the scan folder and writes nothing", async () => { + const owner = await account("discovery-escape"); + await makeDeploymentOwner(owner.actor); + const scan = await finishedScan(owner.cookie); + await mkdir(path.join(outside, "elsewhere", ".git"), { recursive: true }); + await rm(path.join(home, "code/garden"), { recursive: true, force: true }); + symlinkSync(path.join(outside, "elsewhere"), path.join(home, "code/garden")); + + const response = await raw(app, owner.cookie, "onboarding/applyScan", { + scanId: scan.scanId, + projectIds: ["project-1"], + factIds: ["language:Python"], + }); + expect(response.status).toBe(400); + expect(await handles.prisma.spaceMember.count({ where: { userId: owner.actor.userId } })).toBe( + 1, + ); + await expect(rpc(app, owner.cookie, "profile/get")).resolves.toMatchObject({ + facts: [], + }); + }); + + it("remembers each connected app once as a tools fact and keeps it after revoking", async () => { + const user = await account("discovery-apps"); + const other = await account("discovery-apps-other"); + // A featured app is named by its label, whatever the account is called. + const gmail = await rpc<{ connectionId: string }>(app, user.cookie, "connections/begin", { + connectorId: "composio", + provider: "GMAIL", + displayName: "Personal", + }); + // Other apps take the first account's name; a second account adds nothing. + for (const displayName of ["GitHub", "GitHub 2"]) { + await rpc(app, user.cookie, "connections/begin", { + connectorId: "composio", + provider: "GITHUB", + displayName, + }); + } + // Connected through the authorization round trip. + const linear = await rpc<{ connectionId: string; authorizationUrl: string | null }>( + app, + user.cookie, + "connections/begin", + { connectorId: "pipedream", provider: "linear", displayName: "Linear" }, + ); + expect(linear.authorizationUrl).not.toBeNull(); + await expect( + rpc<{ status: string }>(app, user.cookie, "connections/complete", { + connectionId: linear.connectionId, + }), + ).resolves.toMatchObject({ status: "connected" }); + // Connected remotely, then noticed when the catalog reconciles a pending row. + await handles.prisma.connection.create({ + data: { + spaceId: user.actor.spaceId, + userId: user.actor.userId, + provider: "SLACK", + displayName: "SLACK", + status: "pending", + }, + }); + await composio.begin( + { provider: "SLACK", redirectUrl: "http://127.0.0.1.invalid/callback" }, + { + operationId: "discovery-test", + traceId: "discovery-test", + spaceId: user.actor.spaceId, + userId: user.actor.userId, + signal: new AbortController().signal, + }, + ); + await rpc(app, user.cookie, "connections/catalog", { connectorId: "composio" }); + await rpc(app, user.cookie, "connections/revoke", { connectionId: gmail.connectionId }); + + const profile = await rpc(app, user.cookie, "profile/get"); + expect( + profile.facts + .map(({ category, value, source }) => ({ category, value, source })) + .sort((left, right) => left.value.localeCompare(right.value)), + ).toEqual([ + { category: "tools", value: "Uses GitHub", source: "connector" }, + { category: "tools", value: "Uses Gmail", source: "connector" }, + { category: "tools", value: "Uses Linear", source: "connector" }, + { category: "tools", value: "Uses Slack", source: "connector" }, + ]); + await expect(rpc(app, other.cookie, "profile/get")).resolves.toMatchObject({ + facts: [], + }); + }); +}); + +async function signup(app: App, email: string, name: string) { + const response = await app.request("/api/auth/sign-up/email", { + method: "POST", + headers: { "content-type": "application/json", origin: "http://127.0.0.1:5173" }, + body: JSON.stringify({ email, password: "password12", name }), + }); + if (response.status >= 400) { + throw new Error(`signup failed ${response.status}: ${await response.text()}`); + } + return sessionCookieHeader(response); +} + +async function raw(app: App, cookie: string, procedure: string, body: unknown = {}) { + return app.request(`/rpc/${procedure}`, { + method: "POST", + headers: { "content-type": "application/json", cookie, origin: "http://127.0.0.1:5173" }, + body: JSON.stringify({ json: body }), + }); +} + +async function rpc(app: App, cookie: string, procedure: string, body: unknown = {}) { + const response = await raw(app, cookie, procedure, body); + const text = await response.text(); + const payload = JSON.parse(text) as { json?: T; error?: { message?: string } }; + if (response.status >= 400 || payload.error) { + throw new Error(`${procedure} ${response.status}: ${payload.error?.message ?? text}`); + } + return payload.json as T; +} diff --git a/packages/testkit/src/onboarding-scan-fixture.ts b/packages/testkit/src/onboarding-scan-fixture.ts new file mode 100644 index 0000000..f79e884 --- /dev/null +++ b/packages/testkit/src/onboarding-scan-fixture.ts @@ -0,0 +1,35 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; + +/** An empty but valid git directory, so git commands stop here instead of searching upwards. */ +async function writeGitDirectory(project: string): Promise { + await mkdir(path.join(project, ".git", "objects"), { recursive: true }); + await mkdir(path.join(project, ".git", "refs"), { recursive: true }); + await writeFile(path.join(project, ".git", "HEAD"), "ref: refs/heads/main\n"); +} + +async function writeFiles(root: string, files: Record): Promise { + for (const [relative, content] of Object.entries(files)) { + const file = path.join(root, relative); + await mkdir(path.dirname(file), { recursive: true }); + await writeFile(file, content); + } +} + +/** + * A home folder for the onboarding scan: two projects ("code/atlas" in TypeScript with Docker and + * GitHub Actions, "code/garden" in Python) and a notes folder that is not a project. + */ +export async function writeOnboardingScanFixture(home: string): Promise { + await writeFiles(home, { + "code/atlas/src/index.ts": "export const atlas = 1;\n", + "code/atlas/src/app.tsx": "export const App = () => null;\n", + "code/atlas/Dockerfile": "FROM scratch\n", + "code/atlas/.github/workflows/ci.yml": "on: push\n", + "code/garden/main.py": "print('garden')\n", + "code/garden/util.py": "VALUE = 1\n", + "notes/todo.md": "- water the plants\n", + }); + await writeGitDirectory(path.join(home, "code/atlas")); + await writeGitDirectory(path.join(home, "code/garden")); +}