Merge branch 'claude/phase2-coach' into p2/a1-onboarding
Brings in profile editing, the cross-space inbox and consent-first discovery. The wizard keeps both changes: no preferences step, and the new scan step without the main bot lookup. The harness lists both new onboarding Postgres suites. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
This commit is contained in:
commit
4fbd087ec8
@ -25,9 +25,9 @@ PUBLIC_POSTHOG_KEY=
|
|||||||
PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
|
PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
|
||||||
SIGNUPS_ENABLED=true
|
SIGNUPS_ENABLED=true
|
||||||
SIGNUP_ALLOWLIST=
|
SIGNUP_ALLOWLIST=
|
||||||
# First-run discovery. Leave unset and the onboarding scan never touches the host filesystem.
|
# First-run discovery. Leave unset and onboarding never looks at the host filesystem.
|
||||||
# Set a home-style directory (its Projects, Documents, src, ... subfolders are scanned) to let the
|
# Set a folder (for example a home folder) and the deployment owner's onboarding offers to list the
|
||||||
# deployment owner's own onboarding discover local projects. Other accounts never scan the host.
|
# git projects in it, reading file and folder names only. Other accounts never see the step.
|
||||||
# ONBOARDING_SCAN_HOME=
|
# ONBOARDING_SCAN_HOME=
|
||||||
ENCRYPTION_KEY=replace-with-64-random-hex-characters
|
ENCRYPTION_KEY=replace-with-64-random-hex-characters
|
||||||
DATA_DIR=./data
|
DATA_DIR=./data
|
||||||
|
|||||||
50
apps/api/src/connected-app-facts.ts
Normal file
50
apps/api/src/connected-app-facts.ts
Normal file
@ -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<string | null> {
|
||||||
|
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 <App>" 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<void> {
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -18,9 +18,11 @@ function run(
|
|||||||
status: "waiting_input" | "waiting_takeover",
|
status: "waiting_input" | "waiting_takeover",
|
||||||
thread: Partial<InboxRunRow["thread"]> = {},
|
thread: Partial<InboxRunRow["thread"]> = {},
|
||||||
bot = scout,
|
bot = scout,
|
||||||
|
spaceId = "space-home",
|
||||||
): InboxRunRow {
|
): InboxRunRow {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
|
spaceId,
|
||||||
status,
|
status,
|
||||||
updatedAt: at(0),
|
updatedAt: at(0),
|
||||||
bot,
|
bot,
|
||||||
@ -48,8 +50,10 @@ const approval = {
|
|||||||
function unreadThread(
|
function unreadThread(
|
||||||
bot: InboxUnreadThreadRow["bot"],
|
bot: InboxUnreadThreadRow["bot"],
|
||||||
latest?: { id: string; text: string; minute: number },
|
latest?: { id: string; text: string; minute: number },
|
||||||
|
spaceId = "space-home",
|
||||||
): InboxUnreadThreadRow {
|
): InboxUnreadThreadRow {
|
||||||
return {
|
return {
|
||||||
|
spaceId,
|
||||||
createdAt: at(0),
|
createdAt: at(0),
|
||||||
bot,
|
bot,
|
||||||
messages: latest
|
messages: latest
|
||||||
@ -227,4 +231,36 @@ describe("buildInboxItems", () => {
|
|||||||
});
|
});
|
||||||
expect(items[2]).toMatchObject({ messageId: null, text: "", createdAt: at(0).toISOString() });
|
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 } });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { isApprovalAskBlock } from "@rakazo/core";
|
|||||||
import type { PrismaClient } from "@rakazo/db";
|
import type { PrismaClient } from "@rakazo/db";
|
||||||
import { promptSnippet } from "./runs.js";
|
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_RUN_LIMIT = 50;
|
||||||
const INBOX_UNREAD_LIMIT = 50;
|
const INBOX_UNREAD_LIMIT = 50;
|
||||||
const INBOX_PREVIEW_LENGTH = 280;
|
const INBOX_PREVIEW_LENGTH = 280;
|
||||||
@ -12,9 +12,11 @@ const INBOX_PREVIEW_LENGTH = 280;
|
|||||||
type AskBlock = Extract<MessageBlock, { kind: "ask" }>;
|
type AskBlock = Extract<MessageBlock, { kind: "ask" }>;
|
||||||
type ComputerBlock = Extract<MessageBlock, { kind: "computer" }>;
|
type ComputerBlock = Extract<MessageBlock, { kind: "computer" }>;
|
||||||
type InboxBot = { id: string; name: string; color: string };
|
type InboxBot = { id: string; name: string; color: string };
|
||||||
|
type InboxSpace = NonNullable<InboxItem["space"]>;
|
||||||
|
|
||||||
export interface InboxRunRow {
|
export interface InboxRunRow {
|
||||||
id: string;
|
id: string;
|
||||||
|
spaceId: string;
|
||||||
status: string;
|
status: string;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
bot: InboxBot;
|
bot: InboxBot;
|
||||||
@ -33,6 +35,7 @@ export interface InboxMessageRow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface InboxUnreadThreadRow {
|
export interface InboxUnreadThreadRow {
|
||||||
|
spaceId: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
bot: InboxBot | null;
|
bot: InboxBot | null;
|
||||||
/** The newest bot message, if any. */
|
/** 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:
|
* 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
|
* 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: {
|
export function buildInboxItems(input: {
|
||||||
runs: InboxRunRow[];
|
runs: InboxRunRow[];
|
||||||
/** Ask and takeover messages of those runs, newest first. */
|
/** Ask and takeover messages of those runs, newest first. */
|
||||||
messages: InboxMessageRow[];
|
messages: InboxMessageRow[];
|
||||||
unreadThreads: InboxUnreadThreadRow[];
|
unreadThreads: InboxUnreadThreadRow[];
|
||||||
|
/** The user's other spaces by id; rows of any space not listed belong to the current one. */
|
||||||
|
otherSpaces?: ReadonlyMap<string, InboxSpace>;
|
||||||
}): InboxItem[] {
|
}): InboxItem[] {
|
||||||
|
const spaceOf = (spaceId: string) => {
|
||||||
|
const space = input.otherSpaces?.get(spaceId);
|
||||||
|
return space ? { space } : {};
|
||||||
|
};
|
||||||
const messagesByRun = new Map<string, InboxMessageRow[]>();
|
const messagesByRun = new Map<string, InboxMessageRow[]>();
|
||||||
for (const message of input.messages) {
|
for (const message of input.messages) {
|
||||||
if (!message.runId) continue;
|
if (!message.runId) continue;
|
||||||
@ -122,6 +131,7 @@ export function buildInboxItems(input: {
|
|||||||
kind: isApprovalAskBlock(pending.block) ? "approval" : "ask",
|
kind: isApprovalAskBlock(pending.block) ? "approval" : "ask",
|
||||||
bot: run.bot,
|
bot: run.bot,
|
||||||
target,
|
target,
|
||||||
|
...spaceOf(run.spaceId),
|
||||||
runId: run.id,
|
runId: run.id,
|
||||||
messageId: pending.message.id,
|
messageId: pending.message.id,
|
||||||
ask: pending.block,
|
ask: pending.block,
|
||||||
@ -135,6 +145,7 @@ export function buildInboxItems(input: {
|
|||||||
kind: "takeover",
|
kind: "takeover",
|
||||||
bot: run.bot,
|
bot: run.bot,
|
||||||
target: { botId: run.bot.id },
|
target: { botId: run.bot.id },
|
||||||
|
...spaceOf(run.spaceId),
|
||||||
runId: run.id,
|
runId: run.id,
|
||||||
messageId: request?.message.id ?? null,
|
messageId: request?.message.id ?? null,
|
||||||
text: request?.block.text ?? "",
|
text: request?.block.text ?? "",
|
||||||
@ -154,6 +165,7 @@ export function buildInboxItems(input: {
|
|||||||
kind: "unread",
|
kind: "unread",
|
||||||
bot: thread.bot,
|
bot: thread.bot,
|
||||||
target,
|
target,
|
||||||
|
...spaceOf(thread.spaceId),
|
||||||
messageId: latest?.id ?? null,
|
messageId: latest?.id ?? null,
|
||||||
text: previewText(latest?.blocks),
|
text: previewText(latest?.blocks),
|
||||||
createdAt: (latest?.createdAt ?? thread.createdAt).toISOString(),
|
createdAt: (latest?.createdAt ?? thread.createdAt).toISOString(),
|
||||||
@ -165,13 +177,34 @@ export function buildInboxItems(input: {
|
|||||||
|
|
||||||
const botSelect = { id: true, name: true, color: true } as const;
|
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<Map<string, InboxSpace>> {
|
||||||
|
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<InboxItem[]> {
|
export async function listInbox(prisma: PrismaClient, actor: Actor): Promise<InboxItem[]> {
|
||||||
|
const otherSpaces = await otherInboxSpaces(prisma, actor);
|
||||||
|
const spaceIds = [actor.spaceId, ...otherSpaces.keys()];
|
||||||
const ownBot = { userId: actor.userId, archivedAt: null };
|
const ownBot = { userId: actor.userId, archivedAt: null };
|
||||||
const [runs, unreadThreads] = await Promise.all([
|
const [runs, unreadThreads] = await Promise.all([
|
||||||
prisma.run.findMany({
|
prisma.run.findMany({
|
||||||
where: {
|
where: {
|
||||||
spaceId: actor.spaceId,
|
spaceId: { in: spaceIds },
|
||||||
userId: actor.userId,
|
userId: actor.userId,
|
||||||
status: { in: ["waiting_input", "waiting_takeover"] },
|
status: { in: ["waiting_input", "waiting_takeover"] },
|
||||||
bot: ownBot,
|
bot: ownBot,
|
||||||
@ -179,6 +212,7 @@ export async function listInbox(prisma: PrismaClient, actor: Actor): Promise<Inb
|
|||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
spaceId: true,
|
||||||
status: true,
|
status: true,
|
||||||
threadId: true,
|
threadId: true,
|
||||||
updatedAt: true,
|
updatedAt: true,
|
||||||
@ -197,12 +231,13 @@ export async function listInbox(prisma: PrismaClient, actor: Actor): Promise<Inb
|
|||||||
// Only specialists' own threads; group threads, the main conversation included, stay out.
|
// Only specialists' own threads; group threads, the main conversation included, stay out.
|
||||||
prisma.thread.findMany({
|
prisma.thread.findMany({
|
||||||
where: {
|
where: {
|
||||||
spaceId: actor.spaceId,
|
spaceId: { in: spaceIds },
|
||||||
userId: actor.userId,
|
userId: actor.userId,
|
||||||
unread: true,
|
unread: true,
|
||||||
bot: { ...ownBot, kind: "specialist" },
|
bot: { ...ownBot, kind: "specialist" },
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
|
spaceId: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
bot: { select: botSelect },
|
bot: { select: botSelect },
|
||||||
messages: {
|
messages: {
|
||||||
@ -232,5 +267,5 @@ export async function listInbox(prisma: PrismaClient, actor: Actor): Promise<Inb
|
|||||||
orderBy: { seq: "desc" },
|
orderBy: { seq: "desc" },
|
||||||
select: { id: true, runId: true, blocks: true, createdAt: true },
|
select: { id: true, runId: true, blocks: true, createdAt: true },
|
||||||
});
|
});
|
||||||
return buildInboxItems({ runs, messages, unreadThreads });
|
return buildInboxItems({ runs, messages, unreadThreads, otherSpaces });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import path from "node:path";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import type { PrismaClient } from "@rakazo/db";
|
||||||
import { getScanStatus, startOnboardingScan } from "./onboarding-scan.js";
|
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 owner = { userId: "user-owner", spaceId: "space-owner", isDeploymentOwner: true };
|
||||||
const member = { userId: "user-member", isDeploymentOwner: false };
|
const member = { userId: "user-member", spaceId: "space-member", isDeploymentOwner: false };
|
||||||
|
const roots: string[] = [];
|
||||||
|
|
||||||
async function scanHomeWithOneFile(): Promise<string> {
|
/** Fails the test if the scan writes anything before its checks pass. */
|
||||||
const home = await mkdtemp(join(tmpdir(), "evoo-scan-"));
|
const untouchedPrisma = new Proxy(
|
||||||
await mkdir(join(home, "Projects"));
|
{},
|
||||||
await writeFile(join(home, "Projects", "notes.ts"), "export const x = 1;\n");
|
{
|
||||||
return home;
|
get() {
|
||||||
|
throw new Error("the database must not be touched");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
) as PrismaClient;
|
||||||
|
|
||||||
|
async function tempDir(): Promise<string> {
|
||||||
|
const dir = await realpath(await mkdtemp(path.join(tmpdir(), "evoo-scan-")));
|
||||||
|
roots.push(dir);
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function files(root: string, entries: string[]): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
await mkdir(path.join(root, relative, ".git"), { recursive: true });
|
||||||
|
await files(path.join(root, relative), entries);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function completed(scanId: string, userId: string) {
|
async function completed(scanId: string, userId: string) {
|
||||||
return await vi.waitFor(
|
return await vi.waitFor(
|
||||||
async () => {
|
() => {
|
||||||
const status = await getScanStatus(scanId, userId);
|
const status = onboardingScanStatus(scanId, userId);
|
||||||
if (status.status !== "complete" && status.status !== "failed") throw new Error("pending");
|
if (status.status === "scanning") throw new Error("pending");
|
||||||
return status;
|
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", () => {
|
describe("onboarding scan", () => {
|
||||||
afterEach(() => {
|
it("lists git projects with their languages and tools and never follows a symlink", async () => {
|
||||||
vi.unstubAllEnvs();
|
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 () => {
|
it("offers a scan folder only to the deployment owner and only when it exists", async () => {
|
||||||
const { scanId } = await startOnboardingScan({ actor: member }, {});
|
const home = await tempDir();
|
||||||
await expect(getScanStatus(scanId, owner.userId)).rejects.toThrow(/Scan not found/);
|
await files(home, ["file.txt"]);
|
||||||
await expect(getScanStatus(scanId, member.userId)).resolves.toMatchObject({
|
await expect(onboardingScanFolder(member, home)).resolves.toBeNull();
|
||||||
status: expect.any(String),
|
await expect(onboardingScanFolder(owner, undefined)).resolves.toBeNull();
|
||||||
});
|
await expect(onboardingScanFolder(owner, path.join(home, "missing"))).resolves.toBeNull();
|
||||||
await expect(getScanStatus("scan_missing", member.userId)).rejects.toThrow(/Scan not found/);
|
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 () => {
|
it("keeps a scan with the account that started it and returns only relative paths", async () => {
|
||||||
const { scanId } = await startOnboardingScan({ actor: owner }, {});
|
const home = await tempDir();
|
||||||
|
await project(home, "code/atlas", ["index.ts"]);
|
||||||
|
const { scanId } = await startOnboardingScan(owner, home);
|
||||||
|
|
||||||
const status = await completed(scanId, owner.userId);
|
const status = await completed(scanId, owner.userId);
|
||||||
expect(status.status).toBe("complete");
|
expect(status).toEqual({
|
||||||
expect(status.result).toMatchObject({ localRepos: [], fileSummary: { totalFiles: 0 } });
|
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 () => {
|
it("checks ownership, the folder and every chosen project again before writing", async () => {
|
||||||
// The shared test setup pins the emulated runtime, which also keeps the scan simulated.
|
const home = await tempDir();
|
||||||
vi.stubEnv("AGENT_RUNTIME", "");
|
const outside = await tempDir();
|
||||||
vi.stubEnv("SANDBOX_PROVIDER", "");
|
await project(home, "code/atlas", ["index.ts"]);
|
||||||
const scanHome = await scanHomeWithOneFile();
|
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 }, {});
|
await expect(
|
||||||
const memberStatus = await completed(asMember.scanId, member.userId);
|
applyOnboardingScan(untouchedPrisma, { ...owner, isDeploymentOwner: false }, home, choice),
|
||||||
expect(memberStatus.result).toMatchObject({ fileSummary: { totalFiles: 0 } });
|
).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||||
|
await expect(
|
||||||
|
applyOnboardingScan(untouchedPrisma, owner, outside, choice),
|
||||||
|
).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||||
|
|
||||||
const asOwner = await startOnboardingScan({ actor: owner, scanHome }, {});
|
// The chosen project became a symlink out of the scan folder after the scan.
|
||||||
const ownerStatus = await completed(asOwner.scanId, owner.userId);
|
await rm(path.join(home, "code/garden"), { recursive: true });
|
||||||
expect(ownerStatus.status).toBe("complete");
|
await symlink(path.join(outside, "elsewhere"), path.join(home, "code/garden"));
|
||||||
expect(ownerStatus.result).toMatchObject({ fileSummary: { totalFiles: 1 } });
|
await expect(applyOnboardingScan(untouchedPrisma, owner, home, choice)).rejects.toMatchObject({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "That folder does not exist or is not readable.",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -11,6 +11,7 @@ import type {
|
|||||||
} from "@rakazo/adapter-kit";
|
} from "@rakazo/adapter-kit";
|
||||||
import {
|
import {
|
||||||
computerControlExpireJobKey,
|
computerControlExpireJobKey,
|
||||||
|
MemoryDocumentTooLargeError,
|
||||||
messagingDeliverJob,
|
messagingDeliverJob,
|
||||||
routineJobKey,
|
routineJobKey,
|
||||||
routineWakeupJob,
|
routineWakeupJob,
|
||||||
@ -163,6 +164,7 @@ import {
|
|||||||
resolveBusyBotName,
|
resolveBusyBotName,
|
||||||
toComputerStatus,
|
toComputerStatus,
|
||||||
} from "./computer-status.js";
|
} from "./computer-status.js";
|
||||||
|
import { recordConnectedApp } from "./connected-app-facts.js";
|
||||||
import { listInbox } from "./inbox.js";
|
import { listInbox } from "./inbox.js";
|
||||||
import { searchIntegrationCatalog } from "./integration-catalog.js";
|
import { searchIntegrationCatalog } from "./integration-catalog.js";
|
||||||
import {
|
import {
|
||||||
@ -186,7 +188,12 @@ import {
|
|||||||
promptFocus,
|
promptFocus,
|
||||||
startOnboarding,
|
startOnboarding,
|
||||||
} from "./onboarding.js";
|
} 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 { listSpaceRuns } from "./runs.js";
|
||||||
import { addScreenProxyCapability } from "./screen-proxy.js";
|
import { addScreenProxyCapability } from "./screen-proxy.js";
|
||||||
import { querySpaceSearch } from "./search.js";
|
import { querySpaceSearch } from "./search.js";
|
||||||
@ -291,6 +298,11 @@ async function reconcilePendingConnections(
|
|||||||
: []),
|
: []),
|
||||||
];
|
];
|
||||||
if (updates.length > 0) await prisma.$transaction(updates);
|
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. */
|
/** Serialize begin/revoke for one user+provider so slug-wide remote deletes cannot race a new connect. */
|
||||||
@ -2444,21 +2456,28 @@ export function createRouter(deps: RouterDeps) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!doc) throw new IsolationError();
|
if (!doc) throw new IsolationError();
|
||||||
const updated = await deps.memory.commit(
|
const updated = await deps.memory
|
||||||
{
|
.commit(
|
||||||
scope: doc.scope as "bot" | "user",
|
{
|
||||||
botId: doc.botId ?? undefined,
|
scope: doc.scope as "bot" | "user",
|
||||||
path: doc.path,
|
botId: doc.botId ?? undefined,
|
||||||
content: input.content,
|
path: doc.path,
|
||||||
},
|
content: input.content,
|
||||||
{
|
},
|
||||||
operationId: "mem",
|
{
|
||||||
traceId: "mem",
|
operationId: "mem",
|
||||||
spaceId: context.actor.spaceId,
|
traceId: "mem",
|
||||||
userId: context.actor.userId,
|
spaceId: context.actor.spaceId,
|
||||||
signal: new AbortController().signal,
|
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 {
|
return {
|
||||||
id: updated.id,
|
id: updated.id,
|
||||||
scope: doc.scope as "bot" | "user",
|
scope: doc.scope as "bot" | "user",
|
||||||
@ -3451,16 +3470,18 @@ export function createRouter(deps: RouterDeps) {
|
|||||||
);
|
);
|
||||||
return { ok: true as const };
|
return { ok: true as const };
|
||||||
}),
|
}),
|
||||||
scan: authed.onboarding.scan.handler(async ({ context, input }) => {
|
scanTarget: authed.onboarding.scanTarget.handler(async ({ context }) => ({
|
||||||
const { scanId } = await startOnboardingScan(
|
folder: await onboardingScanFolder(context.actor, deps.env.onboardingScanHome),
|
||||||
{ actor: context.actor, botId: input.botId, scanHome: deps.env.onboardingScanHome },
|
})),
|
||||||
deps.prisma,
|
scan: authed.onboarding.scan.handler(({ context }) =>
|
||||||
);
|
startOnboardingScan(context.actor, deps.env.onboardingScanHome),
|
||||||
return { scanId };
|
),
|
||||||
}),
|
scanStatus: authed.onboarding.scanStatus.handler(async ({ context, input }) =>
|
||||||
scanStatus: authed.onboarding.scanStatus.handler(async ({ context, input }) => {
|
onboardingScanStatus(input.scanId, context.actor.userId),
|
||||||
return await getScanStatus(input.scanId, context.actor.userId);
|
),
|
||||||
}),
|
applyScan: authed.onboarding.applyScan.handler(({ context, input }) =>
|
||||||
|
applyOnboardingScan(deps.prisma, context.actor, deps.env.onboardingScanHome, input),
|
||||||
|
),
|
||||||
},
|
},
|
||||||
integrationSetup: {
|
integrationSetup: {
|
||||||
get: authed.integrationSetup.get.handler(async ({ context }) => {
|
get: authed.integrationSetup.get.handler(async ({ context }) => {
|
||||||
@ -3709,6 +3730,13 @@ export function createRouter(deps: RouterDeps) {
|
|||||||
}
|
}
|
||||||
throw new IsolationError();
|
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 };
|
return { connectionId: row.id, authorizationUrl: auth.authorizationUrl };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof IsolationError) throw error;
|
if (error instanceof IsolationError) throw error;
|
||||||
@ -4014,6 +4042,13 @@ export function createRouter(deps: RouterDeps) {
|
|||||||
},
|
},
|
||||||
{ timeout: 60_000 },
|
{ timeout: 60_000 },
|
||||||
);
|
);
|
||||||
|
if (row.status === "connected") {
|
||||||
|
await recordConnectedApp(deps.prisma, {
|
||||||
|
userId: context.actor.userId,
|
||||||
|
connectorId: row.connectorId,
|
||||||
|
provider: row.provider,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
|
|||||||
@ -39,6 +39,75 @@ test("answers a specialist's question from the main conversation", async ({ page
|
|||||||
await expect(page.getByText("Answered: Paris", { exact: true })).toBeVisible({ timeout: 30_000 });
|
await expect(page.getByText("Answered: Paris", { exact: true })).toBeVisible({ timeout: 30_000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("answers a question from another space in the main conversation", async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
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<T = unknown>(
|
||||||
|
page: Page,
|
||||||
|
spaceId: string,
|
||||||
|
procedure: string,
|
||||||
|
body: unknown,
|
||||||
|
): Promise<T> {
|
||||||
|
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) {
|
async function createBot(page: Page, name: string) {
|
||||||
return rpc<{ id: string }>(page, "bots/create", {
|
return rpc<{ id: string }>(page, "bots/create", {
|
||||||
name,
|
name,
|
||||||
|
|||||||
@ -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 expect(botDocEditor).toHaveValue(new RegExp(botMarker));
|
||||||
await botMemory.getByRole("button", { name: "Cancel", exact: true }).click();
|
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<MemoryDocument[]>(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.
|
// Skills: create one through the editor, reopen it, edit, then delete it.
|
||||||
// Builtin catalog is currently empty; user skills still cover create/edit/delete.
|
// Builtin catalog is currently empty; user skills still cover create/edit/delete.
|
||||||
await knowledge.getByRole("tab", { name: "Skills", exact: true }).click();
|
await knowledge.getByRole("tab", { name: "Skills", exact: true }).click();
|
||||||
|
|||||||
76
apps/web/e2e/onboarding-discovery.spec.ts
Normal file
76
apps/web/e2e/onboarding-discovery.spec.ts
Normal file
@ -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<SpaceNavigation>(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<UserProfileState>(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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
74
apps/web/e2e/user-profile-edit.spec.ts
Normal file
74
apps/web/e2e/user-profile-edit.spec.ts
Normal file
@ -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<UserProfileState>(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: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -79,6 +79,22 @@ const unread: InboxItem = {
|
|||||||
text: "Draft ready for review",
|
text: "Draft ready for review",
|
||||||
createdAt,
|
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 = [
|
const idleBots = [
|
||||||
{ id: scout.id, status: "idle", unread: false, preview: "" },
|
{ id: scout.id, status: "idle", unread: false, preview: "" },
|
||||||
@ -89,10 +105,18 @@ let container: HTMLDivElement;
|
|||||||
let root: Root;
|
let root: Root;
|
||||||
const onOpenBot = vi.fn();
|
const onOpenBot = vi.fn();
|
||||||
const onOpenGroup = vi.fn();
|
const onOpenGroup = vi.fn();
|
||||||
|
const onOpenInSpace = vi.fn();
|
||||||
|
|
||||||
function render(bots = idleBots) {
|
function render(bots = idleBots) {
|
||||||
return act(async () =>
|
return act(async () =>
|
||||||
root.render(<MainInbox bots={bots} onOpenBot={onOpenBot} onOpenGroup={onOpenGroup} />),
|
root.render(
|
||||||
|
<MainInbox
|
||||||
|
bots={bots}
|
||||||
|
onOpenBot={onOpenBot}
|
||||||
|
onOpenGroup={onOpenGroup}
|
||||||
|
onOpenInSpace={onOpenInSpace}
|
||||||
|
/>,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -111,6 +135,7 @@ beforeEach(() => {
|
|||||||
api.markRead.mockReset().mockResolvedValue({ ok: true });
|
api.markRead.mockReset().mockResolvedValue({ ok: true });
|
||||||
onOpenBot.mockReset();
|
onOpenBot.mockReset();
|
||||||
onOpenGroup.mockReset();
|
onOpenGroup.mockReset();
|
||||||
|
onOpenInSpace.mockReset();
|
||||||
container = document.createElement("div");
|
container = document.createElement("div");
|
||||||
document.body.append(container);
|
document.body.append(container);
|
||||||
root = createRoot(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<HTMLButtonElement>("[data-answer]")?.click(),
|
itemOf("approval").querySelector<HTMLButtonElement>("[data-answer]")?.click(),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(api.answer).toHaveBeenCalledWith({
|
expect(api.answer).toHaveBeenCalledWith(
|
||||||
groupId: "group-ops",
|
{ groupId: "group-ops", runId: "run-approval", messageId: "msg-approval", answer: "Paris" },
|
||||||
runId: "run-approval",
|
{ context: {} },
|
||||||
messageId: "msg-approval",
|
);
|
||||||
answer: "Paris",
|
|
||||||
});
|
|
||||||
expect(api.markRead).not.toHaveBeenCalled();
|
expect(api.markRead).not.toHaveBeenCalled();
|
||||||
expect(api.list).toHaveBeenCalledTimes(2);
|
expect(api.list).toHaveBeenCalledTimes(2);
|
||||||
expect(container.querySelectorAll("[data-testid='main-inbox-item']")).toHaveLength(1);
|
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<HTMLButtonElement>("[data-answer]")?.click());
|
await act(async () => itemOf("ask").querySelector<HTMLButtonElement>("[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(
|
expect(api.markRead.mock.invocationCallOrder[0]).toBeLessThan(
|
||||||
api.answer.mock.invocationCallOrder[0]!,
|
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<HTMLButtonElement>("[data-answer]")?.click());
|
await act(async () => itemOf("ask").querySelector<HTMLButtonElement>("[data-answer]")?.click());
|
||||||
|
|
||||||
expect(api.answer).toHaveBeenCalledWith({
|
expect(api.answer).toHaveBeenCalledWith(
|
||||||
botId: scout.id,
|
{ botId: scout.id, runId: "run-ask", messageId: "msg-ask", answer: "Paris" },
|
||||||
runId: "run-ask",
|
{ context: {} },
|
||||||
messageId: "msg-ask",
|
);
|
||||||
answer: "Paris",
|
|
||||||
});
|
|
||||||
expect(container.querySelector("[data-testid='main-inbox']")).toBeNull();
|
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"]]);
|
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<HTMLElement>("[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<HTMLButtonElement>("[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<HTMLElement>("[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 () => {
|
it("refreshes when a bot's state changes, not on every render", async () => {
|
||||||
api.list.mockResolvedValue({ items: [] });
|
api.list.mockResolvedValue({ items: [] });
|
||||||
await render();
|
await render();
|
||||||
|
|||||||
@ -12,20 +12,28 @@ const INBOX_POLL_MS = 15_000;
|
|||||||
|
|
||||||
type PendingAskItem = Extract<InboxItem, { kind: "ask" | "approval" }>;
|
type PendingAskItem = Extract<InboxItem, { kind: "ask" | "approval" }>;
|
||||||
|
|
||||||
|
/** 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
|
* What the user's bots in all of their spaces need from them outside the main conversation, with
|
||||||
* sender: asks and approvals are answered in place, takeovers and unread threads open the
|
* each bot as the sender: asks and approvals are answered in place, takeovers and unread threads
|
||||||
* bot's thread.
|
* open the bot's thread.
|
||||||
*/
|
*/
|
||||||
export function MainInbox({
|
export function MainInbox({
|
||||||
bots,
|
bots,
|
||||||
onOpenBot,
|
onOpenBot,
|
||||||
onOpenGroup,
|
onOpenGroup,
|
||||||
|
onOpenInSpace,
|
||||||
}: {
|
}: {
|
||||||
/** The space's bots; a change in their status, unread flag or preview refreshes the inbox. */
|
/** The space's bots; a change in their status, unread flag or preview refreshes the inbox. */
|
||||||
bots: readonly Pick<Bot, "id" | "status" | "unread" | "preview">[];
|
bots: readonly Pick<Bot, "id" | "status" | "unread" | "preview">[];
|
||||||
onOpenBot: (botId: string) => void;
|
onOpenBot: (botId: string) => void;
|
||||||
onOpenGroup: (groupId: 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 { t } = useLingui();
|
||||||
const [items, setItems] = useState<InboxItem[]>([]);
|
const [items, setItems] = useState<InboxItem[]>([]);
|
||||||
@ -59,11 +67,16 @@ export function MainInbox({
|
|||||||
}, [refresh]);
|
}, [refresh]);
|
||||||
|
|
||||||
const open = useCallback(
|
const open = useCallback(
|
||||||
(target: InboxThreadTarget) => {
|
({ target, space }: { target: InboxThreadTarget; space?: InboxItem["space"] }) => {
|
||||||
if ("botId" in target) onOpenBot(target.botId);
|
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);
|
else onOpenGroup(target.groupId);
|
||||||
},
|
},
|
||||||
[onOpenBot, onOpenGroup],
|
[onOpenBot, onOpenGroup, onOpenInSpace],
|
||||||
);
|
);
|
||||||
|
|
||||||
const answer = useCallback(
|
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
|
// 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.
|
// same question. Mark it first: the reply to this answer marks it unread again.
|
||||||
if ("botId" in item.target) {
|
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({
|
await rpc.threads.answer(
|
||||||
...item.target,
|
{ ...item.target, runId: item.runId, messageId: item.messageId, answer: text },
|
||||||
runId: item.runId,
|
itemContext(item),
|
||||||
messageId: item.messageId,
|
);
|
||||||
answer: text,
|
|
||||||
});
|
|
||||||
const key = inboxItemKey(item);
|
const key = inboxItemKey(item);
|
||||||
setItems((current) => current.filter((entry) => inboxItemKey(entry) !== key));
|
setItems((current) => current.filter((entry) => inboxItemKey(entry) !== key));
|
||||||
} finally {
|
} finally {
|
||||||
@ -105,10 +116,10 @@ export function MainInbox({
|
|||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => open(item.target)}
|
onClick={() => open(item)}
|
||||||
className="w-fit rounded-md hover:underline"
|
className="w-fit rounded-md hover:underline"
|
||||||
>
|
>
|
||||||
<Sender bot={item.bot} />
|
<Sender bot={item.bot} space={item.space} />
|
||||||
</button>
|
</button>
|
||||||
<AskCard
|
<AskCard
|
||||||
block={item.ask}
|
block={item.ask}
|
||||||
@ -120,11 +131,11 @@ export function MainInbox({
|
|||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => 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"
|
className="flex w-full items-start gap-2.5 rounded-xl px-2 py-1.5 text-start hover:bg-accent"
|
||||||
>
|
>
|
||||||
<span className="min-w-0 flex-1">
|
<span className="min-w-0 flex-1">
|
||||||
<Sender bot={item.bot} />
|
<Sender bot={item.bot} space={item.space} />
|
||||||
{item.text ? (
|
{item.text ? (
|
||||||
<span
|
<span
|
||||||
className="mt-0.5 line-clamp-2 text-ui-body-sm text-muted-foreground"
|
className="mt-0.5 line-clamp-2 text-ui-body-sm text-muted-foreground"
|
||||||
@ -154,16 +165,27 @@ export function MainInbox({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Sender({ bot }: { bot: InboxItem["bot"] }) {
|
/** The bot, and the space it works in when that is not the current one. */
|
||||||
|
function Sender({ bot, space }: { bot: InboxItem["bot"]; space?: InboxItem["space"] }) {
|
||||||
const color = resolvePersonaColorDef(bot.id, parseBotAvatar(bot.color).color).light;
|
const color = resolvePersonaColorDef(bot.id, parseBotAvatar(bot.color).color).light;
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className="flex items-center gap-2 text-ui-body-sm font-semibold tracking-tight"
|
className="flex min-w-0 items-center gap-2 text-ui-body-sm font-semibold tracking-tight"
|
||||||
style={{ color }}
|
style={{ color }}
|
||||||
dir="auto"
|
dir="auto"
|
||||||
>
|
>
|
||||||
<BotAvatar color={bot.color} identity={bot.id} size={22} className="shrink-0" />
|
<BotAvatar color={bot.color} identity={bot.id} size={22} className="shrink-0" />
|
||||||
<span className="truncate">{bot.name}</span>
|
<span className="truncate">{bot.name}</span>
|
||||||
|
{space ? (
|
||||||
|
<span
|
||||||
|
data-testid="main-inbox-space"
|
||||||
|
className="truncate font-normal text-muted-foreground"
|
||||||
|
dir="auto"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">· </span>
|
||||||
|
{space.name}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
129
apps/web/src/components/UserFactEditor.tsx
Normal file
129
apps/web/src/components/UserFactEditor.tsx
Normal file
@ -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<UserFactCategory, string>;
|
||||||
|
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 (
|
||||||
|
<li data-testid="user-fact-editor">
|
||||||
|
<form
|
||||||
|
className="space-y-2"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
autoFocus
|
||||||
|
aria-label={t`Fact`}
|
||||||
|
value={draft.value}
|
||||||
|
maxLength={USER_FACT_VALUE_MAX_LENGTH}
|
||||||
|
onChange={(event) => setDraft({ ...draft, value: event.target.value })}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<NativeSelect
|
||||||
|
className="*:data-[slot=native-select]:h-control-md"
|
||||||
|
aria-label={t`Category`}
|
||||||
|
value={draft.category}
|
||||||
|
onChange={(event) =>
|
||||||
|
setDraft({ ...draft, category: event.target.value as UserFactCategory })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{USER_FACT_CATEGORIES.map((category) => (
|
||||||
|
<NativeSelectOption key={category} value={category}>
|
||||||
|
{categoryLabels[category]}
|
||||||
|
</NativeSelectOption>
|
||||||
|
))}
|
||||||
|
</NativeSelect>
|
||||||
|
{showSpaces ? (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
render={<Button type="button" variant="outline" className="max-w-56" />}
|
||||||
|
>
|
||||||
|
<span className="truncate">
|
||||||
|
{factSpaceNames(draft.spaceIds, spaces).join(", ") || allSpaces}
|
||||||
|
</span>
|
||||||
|
<ChevronDownIcon aria-hidden="true" />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent className="w-auto min-w-44">
|
||||||
|
<DropdownMenuCheckboxItem
|
||||||
|
checked={draft.spaceIds === null}
|
||||||
|
onCheckedChange={() => setDraft({ ...draft, spaceIds: null })}
|
||||||
|
>
|
||||||
|
{allSpaces}
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{spaces.map((space) => (
|
||||||
|
<DropdownMenuCheckboxItem
|
||||||
|
key={space.id}
|
||||||
|
checked={draft.spaceIds?.includes(space.id) ?? false}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setDraft({
|
||||||
|
...draft,
|
||||||
|
spaceIds: toggleFactSpace(draft.spaceIds, space.id, checked),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="truncate" dir="auto">
|
||||||
|
{space.name}
|
||||||
|
</span>
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
) : null}
|
||||||
|
<div className="ms-auto flex items-center gap-1">
|
||||||
|
<Button type="button" variant="ghost" disabled={busy} onClick={onCancel}>
|
||||||
|
<Trans>Cancel</Trans>
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={busy || !draft.value.trim()}>
|
||||||
|
<Trans>Save</Trans>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -10,7 +10,7 @@ import {
|
|||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
Button,
|
Button,
|
||||||
} from "@rakazo/ui-web";
|
} from "@rakazo/ui-web";
|
||||||
import { XIcon } from "lucide-react";
|
import { PencilIcon, XIcon } from "lucide-react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { useEffect, useId, useState } from "react";
|
import { useEffect, useId, useState } from "react";
|
||||||
import { getActiveUiLocale } from "../lib/i18n";
|
import { getActiveUiLocale } from "../lib/i18n";
|
||||||
@ -20,9 +20,12 @@ import {
|
|||||||
CLEARED_PROFILE_FIELD,
|
CLEARED_PROFILE_FIELD,
|
||||||
displayLanguageTag,
|
displayLanguageTag,
|
||||||
displayRegionTag,
|
displayRegionTag,
|
||||||
|
factSpaceNames,
|
||||||
filledProfileFields,
|
filledProfileFields,
|
||||||
groupUserFacts,
|
groupUserFacts,
|
||||||
|
userFactEdit,
|
||||||
} from "../lib/user-profile-view";
|
} from "../lib/user-profile-view";
|
||||||
|
import { UserFactEditor } from "./UserFactEditor";
|
||||||
|
|
||||||
function Row({
|
function Row({
|
||||||
value,
|
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() {
|
export function UserProfileSettings() {
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
const [state, setState] = useState<UserProfileState | null>(null);
|
const [state, setState] = useState<UserProfileState | null>(null);
|
||||||
|
const [spaces, setSpaces] = useState<Array<{ id: string; name: string }>>([]);
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [forgetOpen, setForgetOpen] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
rpc.profile
|
rpc.profile
|
||||||
@ -74,14 +93,16 @@ export function UserProfileSettings() {
|
|||||||
};
|
};
|
||||||
}, [t]);
|
}, [t]);
|
||||||
|
|
||||||
async function apply(change: () => Promise<UserProfileState>) {
|
async function apply(change: () => Promise<UserProfileState>): Promise<boolean> {
|
||||||
if (busy) return;
|
if (busy) return false;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
setState(await change());
|
setState(await change());
|
||||||
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : t`Couldn't save`);
|
setError(err instanceof Error ? err.message : t`Couldn't save`);
|
||||||
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
@ -178,36 +199,75 @@ export function UserProfileSettings() {
|
|||||||
{categoryLabels[group.category]}
|
{categoryLabels[group.category]}
|
||||||
</h3>
|
</h3>
|
||||||
<ul className="mt-3 space-y-3">
|
<ul className="mt-3 space-y-3">
|
||||||
{group.facts.map((fact) => (
|
{group.facts.map((fact) =>
|
||||||
<Row
|
fact.id === editingId ? (
|
||||||
key={fact.id}
|
<UserFactEditor
|
||||||
testId="user-fact"
|
key={fact.id}
|
||||||
status={fact.status}
|
fact={fact}
|
||||||
value={fact.value}
|
spaces={spaces}
|
||||||
detail={sourceLabels[fact.source]}
|
categoryLabels={categoryLabels}
|
||||||
actions={(describedBy) => (
|
busy={busy}
|
||||||
<>
|
onCancel={() => setEditingId(null)}
|
||||||
{fact.status === "pending" ? (
|
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);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Row
|
||||||
|
key={fact.id}
|
||||||
|
testId="user-fact"
|
||||||
|
status={fact.status}
|
||||||
|
value={fact.value}
|
||||||
|
detail={[
|
||||||
|
sourceLabels[fact.source],
|
||||||
|
factSpaceNames(fact.allSpaces ? null : fact.spaceIds, spaces).join(", "),
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ")}
|
||||||
|
actions={(describedBy) => (
|
||||||
|
<>
|
||||||
|
{fact.status === "pending" ? (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
aria-describedby={describedBy}
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() =>
|
||||||
|
void apply(() => rpc.profile.update({ confirmFactIds: [fact.id] }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Trans>Confirm</Trans>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="ghost"
|
||||||
size="sm"
|
size="icon-sm"
|
||||||
|
aria-label={t`Edit`}
|
||||||
aria-describedby={describedBy}
|
aria-describedby={describedBy}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onClick={() =>
|
onClick={() => {
|
||||||
void apply(() => rpc.profile.update({ confirmFactIds: [fact.id] }))
|
setError(null);
|
||||||
}
|
setEditingId(fact.id);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Trans>Confirm</Trans>
|
<PencilIcon />
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
{deleteButton(
|
||||||
{deleteButton(
|
describedBy,
|
||||||
describedBy,
|
() => void apply(() => rpc.profile.forget({ factIds: [fact.id] })),
|
||||||
() => void apply(() => rpc.profile.forget({ factIds: [fact.id] })),
|
)}
|
||||||
)}
|
</>
|
||||||
</>
|
)}
|
||||||
)}
|
/>
|
||||||
/>
|
),
|
||||||
))}
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -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<ScanStatus | null>(null);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [, setScanId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
let pollingInterval: ReturnType<typeof setInterval> | 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 (
|
|
||||||
<div className="mx-auto max-w-xl text-center">
|
|
||||||
<p className="text-destructive">{error}</p>
|
|
||||||
<button type="button" className="mt-4 rounded-lg border px-4 py-2" onClick={onComplete}>
|
|
||||||
Continue anyway
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!status) {
|
|
||||||
return (
|
|
||||||
<div className="mx-auto max-w-xl text-center">
|
|
||||||
<div className="mt-6 animate-spin rounded-full border-4 border-border border-t-primary h-8 w-8" />
|
|
||||||
<p className="mt-4 text-muted-foreground">Preparing your profile...</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentPhaseIndex = PHASES.findIndex((p) => p.key === status.status);
|
|
||||||
const isComplete = status.status === "complete";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx-auto max-w-xl">
|
|
||||||
<h2 className="text-xl font-semibold tracking-tight text-foreground text-center">
|
|
||||||
{isComplete ? "All set!" : "Getting to know you..."}
|
|
||||||
</h2>
|
|
||||||
<p className="mt-1 text-center text-sm text-muted-foreground">
|
|
||||||
{isComplete
|
|
||||||
? "Your profile and agent team are ready."
|
|
||||||
: "We're scanning your computer and services to personalize your experience."}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="mt-6 space-y-4">
|
|
||||||
{PHASES.map((phase, idx) => {
|
|
||||||
const isActive = idx === currentPhaseIndex && !isComplete;
|
|
||||||
const isDone = idx < currentPhaseIndex || isComplete;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={phase.key}
|
|
||||||
className={`flex items-center gap-3 rounded-xl border px-4 py-3 transition-all ${
|
|
||||||
isActive
|
|
||||||
? "border-primary bg-primary/5"
|
|
||||||
: isDone
|
|
||||||
? "border-emerald-500/30 bg-emerald-500/5"
|
|
||||||
: "border-border bg-card"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
{isDone ? (
|
|
||||||
<span className="grid size-6 items-center justify-center rounded-full bg-emerald-500/20 text-emerald-500 text-sm">
|
|
||||||
✓
|
|
||||||
</span>
|
|
||||||
) : isActive ? (
|
|
||||||
<span className="grid size-6 items-center justify-center rounded-full bg-primary/20 animate-pulse text-primary text-sm">
|
|
||||||
●
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="grid size-6 items-center justify-center rounded-full bg-muted/30 text-muted-foreground text-sm">
|
|
||||||
○
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<p
|
|
||||||
className={`text-sm font-medium ${isActive ? "text-foreground" : "text-muted-foreground"}`}
|
|
||||||
>
|
|
||||||
{phase.label}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-muted-foreground/60">{phase.sublabel}</p>
|
|
||||||
</div>
|
|
||||||
{isActive && (
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<div className="h-1 w-16 overflow-hidden rounded-full bg-muted/30">
|
|
||||||
<div
|
|
||||||
className="h-full rounded-full bg-primary transition-all duration-500"
|
|
||||||
style={{ width: `${status.progress}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isComplete && (
|
|
||||||
<div className="mt-8 text-center">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="rounded-xl bg-primary px-6 py-3 text-sm font-medium text-white"
|
|
||||||
onClick={onComplete}
|
|
||||||
>
|
|
||||||
Continue to your workspace →
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
250
apps/web/src/components/onboarding/OnboardingScanStep.tsx
Normal file
250
apps/web/src/components/onboarding/OnboardingScanStep.tsx
Normal file
@ -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<string | null>(null);
|
||||||
|
const [looking, setLooking] = useState(false);
|
||||||
|
const [scanId, setScanId] = useState<string | null>(null);
|
||||||
|
const [found, setFound] = useState<Found | null>(null);
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
const [projectIds, setProjectIds] = useState<string[]>([]);
|
||||||
|
const [factIds, setFactIds] = useState<string[]>([]);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(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<typeof setTimeout> | 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 = (
|
||||||
|
<div className="mt-8 flex justify-end">
|
||||||
|
<Button onClick={() => done.current()}>
|
||||||
|
<Trans>Continue</Trans>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (failed) {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-xl">
|
||||||
|
<p className="text-sm text-destructive">
|
||||||
|
<Trans>Could not look in this folder.</Trans>
|
||||||
|
</p>
|
||||||
|
{continueButton}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (found && found.projects.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-xl">
|
||||||
|
<h2 className="text-xl font-semibold tracking-tight text-foreground">
|
||||||
|
<Trans>No projects found</Trans>
|
||||||
|
</h2>
|
||||||
|
{continueButton}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (found) {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-xl">
|
||||||
|
<fieldset className="border-0 p-0">
|
||||||
|
<legend>
|
||||||
|
<h2 className="text-xl font-semibold tracking-tight text-foreground">
|
||||||
|
<Trans>Add as projects</Trans>
|
||||||
|
</h2>
|
||||||
|
</legend>
|
||||||
|
<div className="mt-3 max-h-72 space-y-1 overflow-y-auto">
|
||||||
|
{found.projects.map((project) => (
|
||||||
|
<label
|
||||||
|
key={project.id}
|
||||||
|
htmlFor={`${ids}-${project.id}`}
|
||||||
|
className="flex cursor-pointer items-start gap-3 rounded-lg px-2 py-2 hover:bg-muted"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
id={`${ids}-${project.id}`}
|
||||||
|
checked={projectIds.includes(project.id)}
|
||||||
|
disabled={saving}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setProjectIds((current) => toggled(current, project.id, checked))
|
||||||
|
}
|
||||||
|
className="mt-0.5"
|
||||||
|
/>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block text-sm font-medium text-foreground">{project.name}</span>
|
||||||
|
{project.path !== project.name ? (
|
||||||
|
<span className="block truncate font-mono text-xs text-muted-foreground">
|
||||||
|
{project.path}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
{found.facts.length > 0 ? (
|
||||||
|
<fieldset className="mt-6 border-0 p-0">
|
||||||
|
<legend className="text-sm font-medium text-foreground">
|
||||||
|
<Trans>Add to About you</Trans>
|
||||||
|
</legend>
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
{found.facts.map((fact) => (
|
||||||
|
<label
|
||||||
|
key={fact.id}
|
||||||
|
htmlFor={`${ids}-${fact.id}`}
|
||||||
|
className="flex cursor-pointer items-center gap-2 rounded-full border border-border px-3 py-1.5 text-sm text-foreground hover:bg-muted"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
id={`${ids}-${fact.id}`}
|
||||||
|
checked={factIds.includes(fact.id)}
|
||||||
|
disabled={saving}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setFactIds((current) => toggled(current, fact.id, checked))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{fact.label}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{error ? <p className="mt-4 text-sm text-destructive">{error}</p> : null}
|
||||||
|
|
||||||
|
<div className="mt-8 flex items-center justify-between">
|
||||||
|
<Button variant="ghost" size="sm" disabled={saving} onClick={() => done.current()}>
|
||||||
|
<Trans>Skip</Trans>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={saving || (projectIds.length === 0 && factIds.length === 0)}
|
||||||
|
onClick={() => void add()}
|
||||||
|
>
|
||||||
|
<Trans>Add</Trans>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (looking) {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-xl">
|
||||||
|
<LoadingState label={t`Looking`} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!folder) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-xl">
|
||||||
|
<h2 className="text-xl font-semibold tracking-tight text-foreground">
|
||||||
|
<Trans>Look for projects here?</Trans>
|
||||||
|
</h2>
|
||||||
|
<p className="mt-3 break-all rounded-lg bg-muted px-3 py-2 font-mono text-sm text-foreground">
|
||||||
|
{folder}
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
<Trans>Only file and folder names are read.</Trans>
|
||||||
|
</p>
|
||||||
|
<div className="mt-8 flex items-center justify-between">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => done.current()}>
|
||||||
|
<Trans>Skip</Trans>
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => void look()}>
|
||||||
|
<Trans>Look</Trans>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -3,8 +3,12 @@ import { describe, expect, it } from "vitest";
|
|||||||
import {
|
import {
|
||||||
displayLanguageTag,
|
displayLanguageTag,
|
||||||
displayRegionTag,
|
displayRegionTag,
|
||||||
|
factSpaceNames,
|
||||||
filledProfileFields,
|
filledProfileFields,
|
||||||
groupUserFacts,
|
groupUserFacts,
|
||||||
|
toggleFactSpace,
|
||||||
|
userFactDraft,
|
||||||
|
userFactEdit,
|
||||||
} from "./user-profile-view";
|
} from "./user-profile-view";
|
||||||
|
|
||||||
function fact(overrides: Partial<UserFact> & Pick<UserFact, "id">): UserFact {
|
function fact(overrides: Partial<UserFact> & Pick<UserFact, "id">): UserFact {
|
||||||
@ -74,3 +78,43 @@ describe("user profile view", () => {
|
|||||||
expect(displayRegionTag("not a tag", "en")).toBe("not a tag");
|
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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -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";
|
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<UpdateUserProfile["editFacts"]>[number];
|
||||||
|
|
||||||
export type ProfileField = "timeZone" | "language" | "locale" | "communicationStyle" | "summary";
|
export type ProfileField = "timeZone" | "language" | "locale" | "communicationStyle" | "summary";
|
||||||
|
|
||||||
const PROFILE_FIELDS: ProfileField[] = [
|
const PROFILE_FIELDS: ProfileField[] = [
|
||||||
@ -75,3 +84,49 @@ export function displayRegionTag(tag: string, uiLocale: string): string {
|
|||||||
return tag;
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import {
|
|||||||
type OnboardingEmojiConfig,
|
type OnboardingEmojiConfig,
|
||||||
OnboardingOoDesigner,
|
OnboardingOoDesigner,
|
||||||
} from "../components/onboarding/OnboardingOoDesigner";
|
} from "../components/onboarding/OnboardingOoDesigner";
|
||||||
import { OnboardingScanProgress } from "../components/onboarding/OnboardingScanProgress";
|
import { OnboardingScanStep } from "../components/onboarding/OnboardingScanStep";
|
||||||
import {
|
import {
|
||||||
KOKORO_LOCAL_TOKEN,
|
KOKORO_LOCAL_TOKEN,
|
||||||
OnboardingVoiceStep,
|
OnboardingVoiceStep,
|
||||||
@ -67,7 +67,6 @@ export function OnboardingPage() {
|
|||||||
const [bootstrapMe, setBootstrapMe] = useState<Awaited<ReturnType<typeof rpc.me>> | null>(null);
|
const [bootstrapMe, setBootstrapMe] = useState<Awaited<ReturnType<typeof rpc.me>> | null>(null);
|
||||||
const [profilePersisted, setProfilePersisted] = useState(false);
|
const [profilePersisted, setProfilePersisted] = useState(false);
|
||||||
const [crewReady, setCrewReady] = useState(false);
|
const [crewReady, setCrewReady] = useState(false);
|
||||||
const [scanBotId, setScanBotId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const [emojiConfig, setEmojiConfig] = useState<OnboardingEmojiConfig>(defaultEmojiConfig);
|
const [emojiConfig, setEmojiConfig] = useState<OnboardingEmojiConfig>(defaultEmojiConfig);
|
||||||
const [currentLocale, setCurrentLocale] = useState<string>(() => {
|
const [currentLocale, setCurrentLocale] = useState<string>(() => {
|
||||||
@ -91,14 +90,6 @@ export function OnboardingPage() {
|
|||||||
.then(async ([me, integrations]) => {
|
.then(async ([me, integrations]) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setBootstrapMe(me);
|
setBootstrapMe(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);
|
setIntegrationSetup(integrations);
|
||||||
// The first step renders before this resolves; never pull a user back who moved on.
|
// The first step renders before this resolves; never pull a user back who moved on.
|
||||||
if (!me.needsModel && integrations?.needsSetup) {
|
if (!me.needsModel && integrations?.needsSetup) {
|
||||||
@ -313,12 +304,7 @@ export function OnboardingPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{step === "scan" ? (
|
{step === "scan" ? <OnboardingScanStep onDone={() => setStep("bot")} /> : null}
|
||||||
<OnboardingScanProgress
|
|
||||||
onComplete={() => setStep("bot")}
|
|
||||||
botId={scanBotId ?? undefined}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{step === "bot" && error ? (
|
{step === "bot" && error ? (
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
|
|||||||
@ -4637,7 +4637,12 @@ export function ShellPage() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{activeGroup?.kind === "main" && !showProjectTimeline ? (
|
{activeGroup?.kind === "main" && !showProjectTimeline ? (
|
||||||
<MainInbox bots={bots} onOpenBot={openBot} onOpenGroup={openGroup} />
|
<MainInbox
|
||||||
|
bots={bots}
|
||||||
|
onOpenBot={openBot}
|
||||||
|
onOpenGroup={openGroup}
|
||||||
|
onOpenInSpace={openSpaceChat}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{recordingSkill ? (
|
{recordingSkill ? (
|
||||||
<div className="px-6 pb-2 text-center text-ui-body-sm text-destructive">
|
<div className="px-6 pb-2 text-center text-ui-body-sm text-destructive">
|
||||||
|
|||||||
@ -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`.
|
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)
|
## 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.
|
**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.
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
export * from "./background-jobs.js";
|
export * from "./background-jobs.js";
|
||||||
export * from "./cloud-agents.js";
|
export * from "./cloud-agents.js";
|
||||||
export * from "./interfaces.js";
|
export * from "./interfaces.js";
|
||||||
|
export * from "./memory.js";
|
||||||
export * from "./registry.js";
|
export * from "./registry.js";
|
||||||
export * from "./types.js";
|
export * from "./types.js";
|
||||||
|
|||||||
13
packages/adapter-kit/src/memory.ts
Normal file
13
packages/adapter-kit/src/memory.ts
Normal file
@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -250,6 +250,8 @@ export interface MemoryCommitRequest {
|
|||||||
botId?: string;
|
botId?: string;
|
||||||
path: string;
|
path: string;
|
||||||
content: string;
|
content: string;
|
||||||
|
/** "replace" (default) stores `content` as the whole document; "append" adds it at the end. */
|
||||||
|
mode?: "replace" | "append";
|
||||||
sourceRunId?: string;
|
sourceRunId?: string;
|
||||||
sourceThreadId?: string;
|
sourceThreadId?: string;
|
||||||
}
|
}
|
||||||
@ -435,7 +437,7 @@ export interface ScriptedTurn {
|
|||||||
ask?: { text: string; detail?: string; actions?: Array<{ id: string; label: string }> };
|
ask?: { text: string; detail?: string; actions?: Array<{ id: string; label: string }> };
|
||||||
takeover?: { reason: string };
|
takeover?: { reason: string };
|
||||||
files?: Array<{ path: string; content: string }>;
|
files?: Array<{ path: string; content: string }>;
|
||||||
memory?: Array<{ scope: "bot" | "user"; path: string; content: string }>;
|
memory?: Array<Pick<MemoryCommitRequest, "scope" | "path" | "content" | "mode">>;
|
||||||
complete?: boolean;
|
complete?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -378,12 +378,18 @@ export const builtinAgentTools: ConnectorTool[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "remember",
|
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: {
|
inputSchema: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
content: { type: "string" },
|
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"],
|
required: ["content"],
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import type { PrismaClient } from "@rakazo/db";
|
import type { PrismaClient } from "@rakazo/db";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { formatCurrentTimeInstruction, loadUserTimeZone } from "./current-time.js";
|
import { formatCurrentTimeInstruction, formatLocalDate, loadUserTimeZone } from "./current-time.js";
|
||||||
|
|
||||||
describe("formatCurrentTimeInstruction", () => {
|
describe("formatCurrentTimeInstruction", () => {
|
||||||
it("anchors the present moment in UTC with the weekday and no sub-second noise", () => {
|
it("anchors the present moment in UTC with the weekday and no sub-second noise", () => {
|
||||||
@ -93,3 +93,12 @@ describe("loadUserTimeZone", () => {
|
|||||||
).resolves.toBeUndefined();
|
).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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -27,6 +27,14 @@ export function formatCurrentTimeInstruction(
|
|||||||
].join(" ");
|
].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. */
|
/** The user's time zone from their profile when this runtime knows it. */
|
||||||
export async function loadUserTimeZone(
|
export async function loadUserTimeZone(
|
||||||
prisma: Pick<PrismaClient, "userProfile">,
|
prisma: Pick<PrismaClient, "userProfile">,
|
||||||
|
|||||||
@ -319,6 +319,14 @@ describe("mutating tool effect idempotency keys", () => {
|
|||||||
]);
|
]);
|
||||||
await f.run();
|
await f.run();
|
||||||
expect(f.memoryCommit).toHaveBeenCalledOnce();
|
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);
|
expect(f.effects).toHaveLength(1);
|
||||||
|
|
||||||
f.setCalls([
|
f.setCalls([
|
||||||
|
|||||||
@ -207,7 +207,7 @@ import { observationToolResult, parseComputerActions } from "./computer-tools.js
|
|||||||
import { checkpointRunComputerWorkspace } from "./computer-workspace.js";
|
import { checkpointRunComputerWorkspace } from "./computer-workspace.js";
|
||||||
import { sanitizeConnectorError } from "./connector-safety.js";
|
import { sanitizeConnectorError } from "./connector-safety.js";
|
||||||
import { createProject } from "./create-project.js";
|
import { createProject } from "./create-project.js";
|
||||||
import { formatCurrentTimeInstruction } from "./current-time.js";
|
import { formatCurrentTimeInstruction, formatLocalDate } from "./current-time.js";
|
||||||
import {
|
import {
|
||||||
delegateTask,
|
delegateTask,
|
||||||
loadDelegatedTaskInstruction,
|
loadDelegatedTaskInstruction,
|
||||||
@ -320,6 +320,7 @@ import {
|
|||||||
tryRecoverProseHandoff,
|
tryRecoverProseHandoff,
|
||||||
workshopFallbackAfterBlockedHandoff,
|
workshopFallbackAfterBlockedHandoff,
|
||||||
} from "./prose-handoff-recovery.js";
|
} from "./prose-handoff-recovery.js";
|
||||||
|
import { datedMemoryNote, rememberFromTool } from "./remember.js";
|
||||||
import type { RemoteTransportDependencies } from "./remote-mcp.js";
|
import type { RemoteTransportDependencies } from "./remote-mcp.js";
|
||||||
import { loadReplyContext, messageToAgentHistoryText } from "./reply-context.js";
|
import { loadReplyContext, messageToAgentHistoryText } from "./reply-context.js";
|
||||||
import {
|
import {
|
||||||
@ -2547,18 +2548,15 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
|||||||
}, finish);
|
}, finish);
|
||||||
}
|
}
|
||||||
if (name === "remember") {
|
if (name === "remember") {
|
||||||
await deps.memory.commit(
|
return finish(
|
||||||
{
|
await rememberFromTool(deps.memory, context, {
|
||||||
scope: "bot",
|
|
||||||
botId: bot.id,
|
botId: bot.id,
|
||||||
path: String(args.path ?? "MEMORY.md"),
|
runId,
|
||||||
content: String(args.content ?? ""),
|
threadId: thread.id,
|
||||||
sourceRunId: runId,
|
date: formatLocalDate(userContext.timeZone),
|
||||||
sourceThreadId: thread.id,
|
args,
|
||||||
},
|
}),
|
||||||
context,
|
|
||||||
);
|
);
|
||||||
return finish({ ok: true });
|
|
||||||
}
|
}
|
||||||
if (name === "update_user_profile") {
|
if (name === "update_user_profile") {
|
||||||
return finish(
|
return finish(
|
||||||
@ -4246,7 +4244,11 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
|||||||
scope: mem.scope,
|
scope: mem.scope,
|
||||||
botId: mem.scope === "bot" ? bot.id : undefined,
|
botId: mem.scope === "bot" ? bot.id : undefined,
|
||||||
path: mem.path,
|
path: mem.path,
|
||||||
content: mem.content,
|
content:
|
||||||
|
mem.mode === "append"
|
||||||
|
? datedMemoryNote(mem.content, formatLocalDate(userContext.timeZone))
|
||||||
|
: mem.content,
|
||||||
|
mode: mem.mode,
|
||||||
sourceRunId: runId,
|
sourceRunId: runId,
|
||||||
sourceThreadId: thread.id,
|
sourceThreadId: thread.id,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -51,6 +51,36 @@ describe("agent memory context", () => {
|
|||||||
expect(result?.endsWith("</durable_memory>")).toBe(true);
|
expect(result?.endsWith("</durable_memory>")).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</durable_memory>");
|
||||||
|
expect(result).not.toContain("# Chief");
|
||||||
|
expect(result).not.toContain("note 0\n");
|
||||||
|
});
|
||||||
|
|
||||||
it("omits the memory block when neither scope has documents", async () => {
|
it("omits the memory block when neither scope has documents", async () => {
|
||||||
const read = vi.fn(async () => snapshot([]));
|
const read = vi.fn(async () => snapshot([]));
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,9 @@
|
|||||||
import type { AdapterContext, MemorySnapshot, MemoryStore } from "@rakazo/adapter-kit";
|
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] & {
|
type ScopedMemoryDocument = MemorySnapshot["documents"][number] & {
|
||||||
scope: "bot" | "user";
|
scope: "bot" | "user";
|
||||||
@ -45,10 +48,14 @@ export async function loadAgentMemoryContext(
|
|||||||
sections.push(heading);
|
sections.push(heading);
|
||||||
remainingBytes -= headingBytes;
|
remainingBytes -= headingBytes;
|
||||||
|
|
||||||
const content = truncateUtf8(document.content, remainingBytes);
|
if (byteLength(document.content) <= remainingBytes) {
|
||||||
sections.push(content);
|
sections.push(document.content);
|
||||||
remainingBytes -= byteLength(content);
|
remainingBytes -= byteLength(document.content);
|
||||||
if (content !== document.content) break;
|
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}`;
|
return `${preamble}${sections.join("")}${closing}`;
|
||||||
@ -58,6 +65,30 @@ function byteLength(value: string): number {
|
|||||||
return Buffer.byteLength(value, "utf8");
|
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 {
|
function truncateUtf8(value: string, maxBytes: number): string {
|
||||||
if (maxBytes <= 0) return "";
|
if (maxBytes <= 0) return "";
|
||||||
const characters: string[] = [];
|
const characters: string[] = [];
|
||||||
|
|||||||
96
packages/adapters/src/remember.test.ts
Normal file
96
packages/adapters/src/remember.test.ts
Normal file
@ -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<string, unknown>, 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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
55
packages/adapters/src/remember.ts
Normal file
55
packages/adapters/src/remember.ts
Normal file
@ -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<MemoryStore, "commit">,
|
||||||
|
context: AdapterContext,
|
||||||
|
input: {
|
||||||
|
botId: string;
|
||||||
|
runId: string;
|
||||||
|
threadId: string;
|
||||||
|
/** The user's local date, YYYY-MM-DD. */
|
||||||
|
date: string;
|
||||||
|
args: Record<string, unknown>;
|
||||||
|
},
|
||||||
|
): 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 };
|
||||||
|
}
|
||||||
@ -481,7 +481,7 @@ export function inferScript(
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
assistant: "noted — i will keep that in memory.",
|
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,
|
complete: true,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@ -13,6 +13,11 @@ const InboxItemBase = z.object({
|
|||||||
/** The bot that needs the user, shown as the sender. */
|
/** The bot that needs the user, shown as the sender. */
|
||||||
bot: z.object({ id: Id, name: z.string(), color: z.string() }),
|
bot: z.object({ id: Id, name: z.string(), color: z.string() }),
|
||||||
target: InboxThreadTargetSchema,
|
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(),
|
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
|
* What a bot needs from the user outside the main conversation, in any of the user's spaces: an
|
||||||
* computer screen, or a look at an unread thread.
|
* answer, an approval, the computer screen, or a look at an unread thread.
|
||||||
*/
|
*/
|
||||||
export const InboxItemSchema = z.discriminatedUnion("kind", [
|
export const InboxItemSchema = z.discriminatedUnion("kind", [
|
||||||
PendingAskItem.extend({ kind: z.literal("ask") }),
|
PendingAskItem.extend({ kind: z.literal("ask") }),
|
||||||
|
|||||||
@ -13,6 +13,7 @@ export * from "./local-settings.js";
|
|||||||
export * from "./mcp.js";
|
export * from "./mcp.js";
|
||||||
export * from "./model-tiers.js";
|
export * from "./model-tiers.js";
|
||||||
export * from "./notifications.js";
|
export * from "./notifications.js";
|
||||||
|
export * from "./onboarding-scan.js";
|
||||||
export * from "./openai-compatible-ui.js";
|
export * from "./openai-compatible-ui.js";
|
||||||
export * from "./project-goals.js";
|
export * from "./project-goals.js";
|
||||||
export * from "./project-sessions.js";
|
export * from "./project-sessions.js";
|
||||||
|
|||||||
49
packages/contracts/src/onboarding-scan.ts
Normal file
49
packages/contracts/src/onboarding-scan.ts
Normal file
@ -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<typeof OnboardingScanTargetSchema>;
|
||||||
|
|
||||||
|
export const OnboardingScanProjectSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
/** Relative to the scanned folder. */
|
||||||
|
path: z.string(),
|
||||||
|
});
|
||||||
|
export type OnboardingScanProject = z.infer<typeof OnboardingScanProjectSchema>;
|
||||||
|
|
||||||
|
export const OnboardingScanFactSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
/** A language or tool name, e.g. "TypeScript". */
|
||||||
|
label: z.string(),
|
||||||
|
});
|
||||||
|
export type OnboardingScanFact = z.infer<typeof OnboardingScanFactSchema>;
|
||||||
|
|
||||||
|
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<typeof OnboardingScanStatusSchema>;
|
||||||
|
|
||||||
|
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<typeof OnboardingScanApplyInputSchema>;
|
||||||
|
|
||||||
|
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<typeof OnboardingScanApplyResultSchema>;
|
||||||
@ -91,6 +91,12 @@ import {
|
|||||||
WebPushEndpointSchema,
|
WebPushEndpointSchema,
|
||||||
WebPushSubscriptionInput,
|
WebPushSubscriptionInput,
|
||||||
} from "./notifications.js";
|
} from "./notifications.js";
|
||||||
|
import {
|
||||||
|
OnboardingScanApplyInputSchema,
|
||||||
|
OnboardingScanApplyResultSchema,
|
||||||
|
OnboardingScanStatusSchema,
|
||||||
|
OnboardingScanTargetSchema,
|
||||||
|
} from "./onboarding-scan.js";
|
||||||
import {
|
import {
|
||||||
CreateProjectGoalInput,
|
CreateProjectGoalInput,
|
||||||
ProjectGoalSchema,
|
ProjectGoalSchema,
|
||||||
@ -700,15 +706,13 @@ export const appContract = {
|
|||||||
z.object({ botId: Id, provider: z.string(), connectorId: z.string().default("composio") }),
|
z.object({ botId: Id, provider: z.string(), connectorId: z.string().default("composio") }),
|
||||||
)
|
)
|
||||||
.output(z.object({ ok: z.literal(true) })),
|
.output(z.object({ ok: z.literal(true) })),
|
||||||
scan: oc.input(z.object({ botId: Id.optional() })).output(z.object({ scanId: z.string() })),
|
/** The folder a scan would read; null when this account cannot scan, so the step is skipped. */
|
||||||
scanStatus: oc.input(z.object({ scanId: z.string() })).output(
|
scanTarget: oc.output(OnboardingScanTargetSchema),
|
||||||
z.object({
|
/** Start scanning that folder. Only called after the user agreed. */
|
||||||
status: z.enum(["scanning", "connecting", "building", "briefing", "complete", "failed"]),
|
scan: oc.output(z.object({ scanId: z.string() })),
|
||||||
progress: z.number(),
|
scanStatus: oc.input(z.object({ scanId: z.string() })).output(OnboardingScanStatusSchema),
|
||||||
message: z.string(),
|
/** Create a project space per chosen project and save the chosen facts. */
|
||||||
result: z.any().optional(),
|
applyScan: oc.input(OnboardingScanApplyInputSchema).output(OnboardingScanApplyResultSchema),
|
||||||
}),
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
integrationSetup: {
|
integrationSetup: {
|
||||||
get: oc.output(IntegrationSetupStateSchema),
|
get: oc.output(IntegrationSetupStateSchema),
|
||||||
@ -850,7 +854,7 @@ export const appContract = {
|
|||||||
.input(z.object({ threadId: Id.optional(), status: DelegatedTaskStatusSchema.optional() }))
|
.input(z.object({ threadId: Id.optional(), status: DelegatedTaskStatusSchema.optional() }))
|
||||||
.output(z.array(DelegatedTaskSchema)),
|
.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: {
|
inbox: {
|
||||||
list: oc.output(InboxListOutputSchema),
|
list: oc.output(InboxListOutputSchema),
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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 { access } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { gitStatusArgv, gitUntrackedFilesArgv, parseGitNameOnly } from "./compose-update.js";
|
import { gitStatusArgv, gitUntrackedFilesArgv, parseGitNameOnly } from "./compose-update.js";
|
||||||
import { DEFAULT_UPDATE_REMOTE, parseGitStatusPorcelain } from "./self-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 {
|
export interface GitCheckoutStatus {
|
||||||
present: boolean;
|
present: boolean;
|
||||||
remoteUrl: string | null;
|
remoteUrl: string | null;
|
||||||
@ -32,7 +23,6 @@ export type GitRunner = (args: string[], cwd: string) => Promise<GitRunnerResult
|
|||||||
|
|
||||||
export async function defaultGitRunner(args: string[], cwd: string): Promise<GitRunnerResult> {
|
export async function defaultGitRunner(args: string[], cwd: string): Promise<GitRunnerResult> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const spawn = getSpawn();
|
|
||||||
const child = spawn("git", args, { cwd, env: process.env });
|
const child = spawn("git", args, { cwd, env: process.env });
|
||||||
let output = "";
|
let output = "";
|
||||||
child.stdout.on("data", (chunk: Buffer | string) => {
|
child.stdout.on("data", (chunk: Buffer | string) => {
|
||||||
|
|||||||
@ -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 () => {
|
it("limits a fact to the user's own spaces", async () => {
|
||||||
const owner = await newUser();
|
const owner = await newUser();
|
||||||
const other = await newUser("Grace");
|
const other = await newUser("Grace");
|
||||||
|
|||||||
@ -250,36 +250,54 @@ async function addFacts(
|
|||||||
return results;
|
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,
|
tx: Prisma.TransactionClient,
|
||||||
userId: string,
|
userId: string,
|
||||||
edit: UserFactEdit,
|
edits: UserFactEdit[],
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const fact = await tx.userFact.findFirst({
|
if (edits.length === 0) return;
|
||||||
where: { id: edit.id, userId },
|
const facts = await tx.userFact.findMany({
|
||||||
select: { id: true },
|
where: { userId },
|
||||||
|
select: { id: true, value: true },
|
||||||
});
|
});
|
||||||
if (!fact) throw new UserFactNotFoundError();
|
const values = new Map(facts.map((fact) => [fact.id, fact.value]));
|
||||||
const data: Prisma.UserFactUpdateInput = {};
|
for (const edit of edits) {
|
||||||
if (edit.category !== undefined) {
|
const current = values.get(edit.id);
|
||||||
data.category = edit.category;
|
if (current === undefined) throw new UserFactNotFoundError();
|
||||||
data.sensitive = isSensitiveUserFactCategory(edit.category);
|
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(
|
async function confirmFacts(
|
||||||
@ -341,7 +359,7 @@ export async function updateUserProfile(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
const added = await addFacts(tx, userId, update.addFacts ?? []);
|
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 ?? []);
|
await confirmFacts(tx, userId, update.confirmFactIds ?? []);
|
||||||
return added;
|
return added;
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import type { AdapterContext, MemoryCommitRequest } from "@rakazo/adapter-kit";
|
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 { createDb, type PrismaClient } from "@rakazo/db";
|
||||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
import { MarkdownMemoryStore } from "./index.js";
|
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<void>((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 () => {
|
it("bounds conflict retries, preserves only committed saves, and allows a later retry", async () => {
|
||||||
const path = "exhausted.md";
|
const path = "exhausted.md";
|
||||||
await store.commit(request(path, "initial"), context);
|
await store.commit(request(path, "initial"), context);
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import type { AdapterContext } from "@rakazo/adapter-kit";
|
import type { AdapterContext } from "@rakazo/adapter-kit";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { MarkdownMemoryStore } from "./index.js";
|
import { appendMarkdown, MarkdownMemoryStore } from "./index.js";
|
||||||
|
|
||||||
const context: AdapterContext = {
|
const context: AdapterContext = {
|
||||||
operationId: "read-memory",
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import type {
|
|||||||
MemoryStore,
|
MemoryStore,
|
||||||
PortableFile,
|
PortableFile,
|
||||||
} from "@rakazo/adapter-kit";
|
} from "@rakazo/adapter-kit";
|
||||||
|
import { MEMORY_DOCUMENT_MAX_BYTES, MemoryDocumentTooLargeError } from "@rakazo/adapter-kit";
|
||||||
import { Prisma, type PrismaClient, withTransactionRetry } from "@rakazo/db";
|
import { Prisma, type PrismaClient, withTransactionRetry } from "@rakazo/db";
|
||||||
|
|
||||||
export class MarkdownMemoryStore implements MemoryStore {
|
export class MarkdownMemoryStore implements MemoryStore {
|
||||||
@ -69,8 +70,8 @@ export class MarkdownMemoryStore implements MemoryStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async commit(request: MemoryCommitRequest, context: AdapterContext): Promise<MemoryRevision> {
|
async commit(request: MemoryCommitRequest, context: AdapterContext): Promise<MemoryRevision> {
|
||||||
// A save replaces the current content and appends its history atomically.
|
// A save replaces (or appends to) the current content and appends its history atomically.
|
||||||
// Conflicts retry the whole transaction so revision numbers use fresh state.
|
// Conflicts retry the whole transaction so revision numbers and appends use fresh state.
|
||||||
return withTransactionRetry(() =>
|
return withTransactionRetry(() =>
|
||||||
this.prisma.$transaction(
|
this.prisma.$transaction(
|
||||||
async (tx) => {
|
async (tx) => {
|
||||||
@ -83,10 +84,17 @@ export class MarkdownMemoryStore implements MemoryStore {
|
|||||||
path: request.path,
|
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
|
const doc = existing
|
||||||
? await tx.memoryDocument.update({
|
? await tx.memoryDocument.update({
|
||||||
where: { id: existing.id },
|
where: { id: existing.id },
|
||||||
data: { content: request.content, revision: existing.revision + 1 },
|
data: { content, revision: existing.revision + 1 },
|
||||||
})
|
})
|
||||||
: await tx.memoryDocument.create({
|
: await tx.memoryDocument.create({
|
||||||
data: {
|
data: {
|
||||||
@ -95,14 +103,14 @@ export class MarkdownMemoryStore implements MemoryStore {
|
|||||||
botId: request.botId,
|
botId: request.botId,
|
||||||
scope: request.scope,
|
scope: request.scope,
|
||||||
path: request.path,
|
path: request.path,
|
||||||
content: request.content,
|
content,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await tx.memoryRevision.create({
|
await tx.memoryRevision.create({
|
||||||
data: {
|
data: {
|
||||||
documentId: doc.id,
|
documentId: doc.id,
|
||||||
revision: doc.revision,
|
revision: doc.revision,
|
||||||
content: request.content,
|
content,
|
||||||
sourceRunId: request.sourceRunId,
|
sourceRunId: request.sourceRunId,
|
||||||
sourceThreadId: request.sourceThreadId,
|
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 {
|
function snippet(content: string, q: string): string {
|
||||||
const idx = content.toLowerCase().indexOf(q);
|
const idx = content.toLowerCase().indexOf(q);
|
||||||
if (idx < 0) return content.slice(0, 140);
|
if (idx < 0) return content.slice(0, 140);
|
||||||
|
|||||||
@ -562,19 +562,18 @@ describeWithDatabase("API authorization and resource isolation", () => {
|
|||||||
).toMatchObject({ content: "owner-only-memory" });
|
).toMatchObject({ content: "owner-only-memory" });
|
||||||
|
|
||||||
// The user profile is per user: another user's fact ids and spaces are out of reach.
|
// 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 }> }>(
|
const ownerProfile = await rpc<{
|
||||||
app,
|
facts: Array<{ id: string; status: string; source: string }>;
|
||||||
owner,
|
}>(app, owner, "profile/update", {
|
||||||
"profile/update",
|
addFacts: [
|
||||||
{
|
{ category: "work", value: "Owner works nights" },
|
||||||
addFacts: [
|
{ category: "health", value: "Owner health note", source: "onboarding" },
|
||||||
{ 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);
|
||||||
const ownerFactIds = ownerProfile.facts.map((fact) => fact.id);
|
expect(ownerFacts.map((fact) => fact.status).sort()).toEqual(["active", "pending"]);
|
||||||
expect(ownerProfile.facts.map((fact) => fact.status).sort()).toEqual(["active", "pending"]);
|
|
||||||
const profileCalls = [
|
const profileCalls = [
|
||||||
["profile/update", { confirmFactIds: ownerFactIds }],
|
["profile/update", { confirmFactIds: ownerFactIds }],
|
||||||
["profile/update", { editFacts: [{ id: ownerFactIds[0], value: "Stolen" }] }],
|
["profile/update", { editFacts: [{ id: ownerFactIds[0], value: "Stolen" }] }],
|
||||||
@ -718,6 +717,16 @@ describeWithDatabase("API authorization and resource isolation", () => {
|
|||||||
.items,
|
.items,
|
||||||
).toContainEqual(expect.objectContaining({ kind: "ask", runId: run.id }));
|
).toContainEqual(expect.objectContaining({ kind: "ask", runId: run.id }));
|
||||||
expect(await rpc(app, member, "inbox/list", {}, sharedSpaceId)).toEqual({ items: [] });
|
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 () => {
|
it("prevents one shared-space member from hijacking another member's Team Computer takeover", async () => {
|
||||||
|
|||||||
@ -1,9 +1,11 @@
|
|||||||
import { execSync } from "node:child_process";
|
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 path from "node:path";
|
||||||
import { loadRootEnv } from "@rakazo/core/node/load-root-env";
|
import { loadRootEnv } from "@rakazo/core/node/load-root-env";
|
||||||
import { PostgreSqlContainer } from "@testcontainers/postgresql";
|
import { PostgreSqlContainer } from "@testcontainers/postgresql";
|
||||||
import type { createApp } from "../../../../apps/api/src/app.ts";
|
import type { createApp } from "../../../../apps/api/src/app.ts";
|
||||||
|
import { writeOnboardingScanFixture } from "../onboarding-scan-fixture.js";
|
||||||
import { runProcess } from "./process.js";
|
import { runProcess } from "./process.js";
|
||||||
|
|
||||||
loadRootEnv();
|
loadRootEnv();
|
||||||
@ -107,6 +109,7 @@ async function main() {
|
|||||||
"packages/testkit/src/delegation.postgres.test.ts",
|
"packages/testkit/src/delegation.postgres.test.ts",
|
||||||
"packages/testkit/src/user-profile.postgres.test.ts",
|
"packages/testkit/src/user-profile.postgres.test.ts",
|
||||||
"packages/testkit/src/onboarding-intake.postgres.test.ts",
|
"packages/testkit/src/onboarding-intake.postgres.test.ts",
|
||||||
|
"packages/testkit/src/onboarding-discovery.postgres.test.ts",
|
||||||
"packages/db/src/space-membership.postgres.test.ts",
|
"packages/db/src/space-membership.postgres.test.ts",
|
||||||
"packages/db/src/main-agent.postgres.test.ts",
|
"packages/db/src/main-agent.postgres.test.ts",
|
||||||
"packages/db/src/user-profile.postgres.test.ts",
|
"packages/db/src/user-profile.postgres.test.ts",
|
||||||
@ -181,6 +184,10 @@ async function main() {
|
|||||||
{ fetch: thirdParties.fetch, resolveHostname: thirdParties.resolveHostname },
|
{ fetch: thirdParties.fetch, resolveHostname: thirdParties.resolveHostname },
|
||||||
);
|
);
|
||||||
const email = new EmailEmulator();
|
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({
|
const handles = await createApp({
|
||||||
databaseUrl,
|
databaseUrl,
|
||||||
prisma: undefined,
|
prisma: undefined,
|
||||||
@ -193,13 +200,30 @@ async function main() {
|
|||||||
},
|
},
|
||||||
integrationsCatalogUrl: "https://catalog.example.test/",
|
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;
|
let activeRequests = 0;
|
||||||
const requestWaiters = new Set<() => void>();
|
const requestWaiters = new Set<() => void>();
|
||||||
const server = serve({
|
const server = serve({
|
||||||
fetch: async (request) => {
|
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" } });
|
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;
|
activeRequests += 1;
|
||||||
try {
|
try {
|
||||||
return await handles.app.fetch(request);
|
return await handles.app.fetch(request);
|
||||||
@ -267,6 +291,7 @@ async function main() {
|
|||||||
await new Promise<void>((resolve) => requestWaiters.add(resolve));
|
await new Promise<void>((resolve) => requestWaiters.add(resolve));
|
||||||
}
|
}
|
||||||
await handles.stop().catch(() => undefined);
|
await handles.stop().catch(() => undefined);
|
||||||
|
await rm(scanHome, { recursive: true, force: true }).catch(() => undefined);
|
||||||
for (let index = 0; index < computers.length; index += 4) {
|
for (let index = 0; index < computers.length; index += 4) {
|
||||||
const results = await Promise.allSettled(
|
const results = await Promise.allSettled(
|
||||||
computers.slice(index, index + 4).map((computer) =>
|
computers.slice(index, index + 4).map((computer) =>
|
||||||
@ -305,6 +330,9 @@ async function main() {
|
|||||||
|
|
||||||
type AppHandles = Awaited<ReturnType<typeof createApp>>;
|
type AppHandles = Awaited<ReturnType<typeof createApp>>;
|
||||||
|
|
||||||
|
/** Matches no user, so the first signup does not claim the deployment. */
|
||||||
|
const E2E_NO_OWNER = "e2e-no-owner";
|
||||||
|
|
||||||
async function managedComputers(handles: AppHandles) {
|
async function managedComputers(handles: AppHandles) {
|
||||||
if (!["e2b", "daytona", "box"].includes(sandboxProvider)) return [];
|
if (!["e2b", "daytona", "box"].includes(sandboxProvider)) return [];
|
||||||
return handles.prisma.computer.findMany({
|
return handles.prisma.computer.findMany({
|
||||||
@ -313,6 +341,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<string, unknown>) {
|
async function writeSummary(reportDir: string, summary: Record<string, unknown>) {
|
||||||
await writeFile(
|
await writeFile(
|
||||||
path.join(reportDir, "summary.json"),
|
path.join(reportDir, "summary.json"),
|
||||||
|
|||||||
@ -94,7 +94,7 @@ describeInbox("inbox.list", () => {
|
|||||||
expect(await listInbox(app, cookie)).toEqual([]);
|
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 cookie = await signup(app, `inbox-mixed-${stamp}@rakazo.test`, "Inbox Mixed");
|
||||||
const me = await rpc<{ userId: string; spaceId: string }>(app, cookie, "me");
|
const me = await rpc<{ userId: string; spaceId: string }>(app, cookie, "me");
|
||||||
const scout = await createBot(app, cookie, "Scout");
|
const scout = await createBot(app, cookie, "Scout");
|
||||||
@ -187,6 +187,14 @@ describeInbox("inbox.list", () => {
|
|||||||
text: "Sign in to continue.",
|
text: "Sign in to continue.",
|
||||||
createdAt: expect.any(String),
|
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({
|
expect.objectContaining({
|
||||||
kind: "unread",
|
kind: "unread",
|
||||||
bot: expect.objectContaining({ id: scout.id }),
|
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.
|
// The Pilot thread is unread too, but its takeover already covers it.
|
||||||
expect(items.filter((item) => item.bot.id === pilot.id)).toHaveLength(1);
|
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({
|
expect.objectContaining({
|
||||||
kind: "ask",
|
kind: "unread",
|
||||||
bot: expect.objectContaining({ id: remote.id }),
|
target: { botId: scout.id },
|
||||||
target: { botId: remote.id },
|
space: { id: side.id, name: "Side" },
|
||||||
runId: remoteAsk.runId,
|
text: expect.stringContaining("Paris"),
|
||||||
messageId: remoteAsk.messageId,
|
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
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,
|
cookie: string,
|
||||||
botId: string,
|
botId: string,
|
||||||
done: (snapshot: ThreadSnapshot) => boolean,
|
done: (snapshot: ThreadSnapshot) => boolean,
|
||||||
|
spaceId?: string,
|
||||||
): Promise<ThreadSnapshot> {
|
): Promise<ThreadSnapshot> {
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
let last: ThreadSnapshot | null = null;
|
let last: ThreadSnapshot | null = null;
|
||||||
while (Date.now() - start < 20_000) {
|
while (Date.now() - start < 20_000) {
|
||||||
last = await rpc<ThreadSnapshot>(app, cookie, "threads/get", { botId });
|
last = await rpc<ThreadSnapshot>(app, cookie, "threads/get", { botId }, spaceId);
|
||||||
if (done(last)) return last;
|
if (done(last)) return last;
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -382,6 +382,10 @@ describeJourneys("required product journeys", () => {
|
|||||||
botId: coder.id,
|
botId: coder.id,
|
||||||
});
|
});
|
||||||
expect(coderMem.some((m) => m.content.toLowerCase().includes("rust"))).toBe(true);
|
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<Bot>(app, ada, "bots/setComputer", {
|
const dedicated = await rpc<Bot>(app, ada, "bots/setComputer", {
|
||||||
botId: coder.id,
|
botId: coder.id,
|
||||||
mode: "dedicated",
|
mode: "dedicated",
|
||||||
|
|||||||
363
packages/testkit/src/onboarding-discovery.postgres.test.ts
Normal file
363
packages/testkit/src/onboarding-discovery.postgres.test.ts
Normal file
@ -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<Response> };
|
||||||
|
type AppHandles = Awaited<ReturnType<typeof createApp>>;
|
||||||
|
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<Actor>(app, cookie, "me") };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function makeDeploymentOwner(actor: Actor): Promise<void> {
|
||||||
|
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<OnboardingScanStatus>(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<UserProfileState>(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<OnboardingScanApplyResult>(
|
||||||
|
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<SpaceNavigation>(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<SpaceNavigation>(app, intruder.cookie, "spaces/list");
|
||||||
|
expect(intruderSpaces.spaces.map((space) => space.id)).not.toContain(created);
|
||||||
|
|
||||||
|
const profile = await rpc<UserProfileState>(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<UserProfileState>(app, intruder.cookie, "profile/get")).resolves.toMatchObject(
|
||||||
|
{ facts: [] },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Applying again reuses the space and the facts.
|
||||||
|
await expect(
|
||||||
|
rpc<OnboardingScanApplyResult>(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<UserProfileState>(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<UserProfileState>(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<UserProfileState>(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<T>(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;
|
||||||
|
}
|
||||||
35
packages/testkit/src/onboarding-scan-fixture.ts
Normal file
35
packages/testkit/src/onboarding-scan-fixture.ts
Normal file
@ -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<void> {
|
||||||
|
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<string, string>): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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"));
|
||||||
|
}
|
||||||
@ -161,6 +161,47 @@ describeWithDatabase("user profile through the API and the main bot", () => {
|
|||||||
expect(forgotten.facts).toEqual([]);
|
expect(forgotten.facts).toEqual([]);
|
||||||
expect(forgotten.profile).toMatchObject({ timeZone: null, language: null, summary: "" });
|
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<UserProfileState>(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<UserProfileState>(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<UserProfileState>(app, intruder, "profile/get")).facts).toEqual([]);
|
||||||
|
expect(
|
||||||
|
(await rpc<UserProfileState>(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) {
|
async function signup(app: App, email: string, name: string) {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user