Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions apps/web/server/api-rewrites.json
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,14 @@
"src": "/api/inbox/([^/]+)/messages",
"dest": "/api/default.js?route=inbox/messages&id=$1"
},
{
"src": "/api/inbox/([^/]+)/read",
"dest": "/api/default.js?route=inbox/read&id=$1"
},
{
"src": "/api/inbox/read",
"dest": "/api/default.js?route=inbox/read"
},
{
"src": "/api/inbox/([^/]+)",
"dest": "/api/default.js?route=inbox/item&id=$1"
Expand Down
12 changes: 10 additions & 2 deletions apps/web/server/function-rewrites.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,16 @@ export interface Rewrite {
readonly dest: string;
}

/** Rewrites that read a path segment into the route's query, so a route sees `?id=` as it was written to. */
/**
* Rewrites that read a path segment into the route's query, so a route sees
* `?id=` as it was written to, and the few exact paths that must stand ahead
* of a segment rewrite whose pattern would otherwise take them, which carry
* no query of their own.
*/
interface SegmentRewrite {
readonly src: string;
readonly route: string;
readonly query: string;
readonly query?: string;
}

const SEGMENT_REWRITES: readonly SegmentRewrite[] = [
Expand Down Expand Up @@ -160,6 +165,9 @@ const SEGMENT_REWRITES: readonly SegmentRewrite[] = [
query: "id=$1&attachment=$2",
},
{ src: "/api/inbox/([^/]+)/messages", route: "inbox/messages", query: "id=$1" },
{ src: "/api/inbox/([^/]+)/read", route: "inbox/read", query: "id=$1" },
// Every item of the Inbox marked read, ahead of the one item's rewrite that would read `read` as an id.
{ src: "/api/inbox/read", route: "inbox/read" },
{ src: "/api/inbox/([^/]+)", route: "inbox/item", query: "id=$1" },
{ src: "/api/workspaces/([^/]+)/photo", route: "workspaces/photo", query: "id=$1" },
{ src: "/api/automations/([^/]+)/webhook", route: "automations/webhook", query: "id=$1" },
Expand Down
9 changes: 9 additions & 0 deletions apps/web/server/routes/inbox/read.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { deploymentEveOrigin } from "@luke/service/agents/brain-host/eve-origin";
import { codingAgentsApp } from "@luke/service/agents/coding-agents-app";
import { resolveHostedUserId } from "@luke/service/auth/bearer";
import { routeFromHttpRouter } from "../../route-effect.js";

/** A member's own read state on the Inbox: one item marked read (PUT) or unread (DELETE), or, where the path names none, every item their Inbox lists marked read (PUT). The logic lives in `packages/service/src/agents/coding-agents-app.ts`; this file only hands it the deployment's real seams. */
export default routeFromHttpRouter(
codingAgentsApp({ resolveUserId: resolveHostedUserId, eveOrigin: deploymentEveOrigin }),
);
104 changes: 104 additions & 0 deletions apps/web/tests/inbox-item-read-migration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import assert from "node:assert/strict";
import { randomUUID } from "node:crypto";
import { NodeServices } from "@effect/platform-node";
import { it } from "@effect/vitest";
import { MIGRATIONS_FOLDER, runWebMigrations } from "@luke/database/core/migrator";
import { Database } from "@luke/database/core/query";
import { createCodingAgent } from "@luke/database/models/coding-agent-store";
import { user } from "@luke/database/schemas/auth";
import { joinWorkspace, startTask, unmigratedPgliteSqlClient } from "@luke/database/testing";
import { INBOX_RESPONSE } from "@luke/features/inbox-wire";
import { postInboxItem, readInboxItem } from "@luke/service/inbox/inbox-store";
import { Effect, Layer, Option, Result } from "effect";
import { FileSystem } from "effect/FileSystem";
import { Path } from "effect/Path";
import * as SqlClient from "effect/sql/SqlClient";

/**
* inbox-item-read-migration.test.ts -- the migration that keeps a member's read state on an inbox item, over items that stood before it.
*
* Over a database migrated up to it, holding an item an agent posted: once
* it runs, that item reads as read for its starter and a teammate alike,
* with no marker of theirs written, while an item posted after it reads
* unread for both; and a second run changes nothing. Synthetic accounts
* throughout.
*/

const MIGRATION = "20261018153718_inbox_item_read";

/** A migrations folder holding every migration named before this one, and none after. */
const foldersBefore = Effect.gen(function* () {
const fileSystem = yield* FileSystem;
const path = yield* Path;
const folder = yield* fileSystem.makeTempDirectoryScoped({ prefix: "luke-before-inbox-read-" });
const names = (yield* fileSystem.readDirectory(MIGRATIONS_FOLDER)).filter(
(name) => name.localeCompare(MIGRATION) < 0,
);
for (const name of names) {
yield* fileSystem.copy(path.join(MIGRATIONS_FOLDER, name), path.join(folder, name));
}
return folder;
});

const openUser = (id: string) =>
Database.use((db) => db.insert(user).values({ id, name: id, email: `${id}@luke.test` }));

it.effect(
"an item that stood before unread was kept reads as read for every member, and one posted after it as unread",
() =>
Effect.scoped(
Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;
yield* runWebMigrations(yield* foldersBefore);
yield* openUser("dean");
yield* openUser("charles");
const task = yield* startTask("dean", { name: "Teammate invitations" });
yield* joinWorkspace(task.workspaceId, "charles");
const started = Option.getOrThrow(
yield* createCodingAgent("dean", {
taskId: task.id,
idempotencyKey: randomUUID(),
model: "anthropic/claude-opus-5.5",
effort: "high",
repository: "acme/relay",
planSnapshot: "# Teammate invitations\n",
prompt: null,
}),
);
const standing = randomUUID();
yield* sql`insert into inbox_item (id, user_id, task_id, organization_id, agent_id, key, revision, version, title, blocks, response, needs_reply, status, posted_at, updated_at)
values (${standing}, 'dean', ${task.id}, ${task.workspaceId}, ${started.agent.id}, 'branch-protection', 1, 4, 'Require a review on main', '[]'::jsonb, '{"type":"task"}'::jsonb, false, 'open', '2026-10-17T09:00:00Z', '2026-10-17T09:00:00Z')`;

yield* runWebMigrations();
yield* sql`delete from __drizzle_migrations where name = ${MIGRATION}`;
yield* runWebMigrations();

const posted = yield* postInboxItem(
{ userId: "dean", taskId: task.id, agentId: started.agent.id },
{
key: "invite-copy",
title: "What should it say?",
response: { type: INBOX_RESPONSE.TEXT },
},
);
assert.ok(Result.isSuccess(posted));
const unread = (reader: string, itemId: string) =>
Effect.map(
readInboxItem(reader, itemId),
(found) => Option.getOrThrow(found).item.unread,
);
assert.deepEqual(
[yield* unread("dean", standing), yield* unread("charles", standing)],
[false, false],
);
assert.deepEqual(
[yield* unread("dean", posted.success.id), yield* unread("charles", posted.success.id)],
[true, true],
);
const markers = yield* sql<{
readonly count: number;
}>`select count(*)::int as count from inbox_item_read`;
assert.deepEqual(markers, [{ count: 0 }], "no marker was written for the items standing");
}),
).pipe(Effect.provide(Layer.provideMerge(unmigratedPgliteSqlClient, NodeServices.layer))),
);
1 change: 1 addition & 0 deletions apps/web/tests/storage-schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ const DECLARED_TABLES = [
"hosted_usage",
"inbox_attachment",
"inbox_item",
"inbox_item_read",
"inbox_message",
"integration_connection",
"invitation",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
CREATE TABLE IF NOT EXISTS "inbox_item_read" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"item_id" uuid NOT NULL,
"user_id" text NOT NULL,
"read_through" timestamp with time zone,
"marked_unread" boolean DEFAULT false NOT NULL
);
--> statement-breakpoint
-- No backfill: `now()` is stable, so this default is evaluated once and kept in the catalog as the value of every row standing, with no row of the table rewritten. Every item that stands when unread ships reads as read through that instant, for every member, until it changes again.
ALTER TABLE "inbox_item" ADD COLUMN IF NOT EXISTS "read_through" timestamp with time zone DEFAULT now();--> statement-breakpoint
-- The default is dropped in the same transaction, so an item posted from here on holds null and reads unread until a member reads it (`inbox-store.ts`). The standing rows keep the value the catalog stamped them with.
ALTER TABLE "inbox_item" ALTER COLUMN "read_through" DROP DEFAULT;--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "inbox_item_read_item_user" ON "inbox_item_read" ("item_id","user_id");--> statement-breakpoint
ALTER TABLE "inbox_item_read" DROP CONSTRAINT IF EXISTS "inbox_item_read_item_id_inbox_item_id_fkey";--> statement-breakpoint
ALTER TABLE "inbox_item_read" ADD CONSTRAINT "inbox_item_read_item_id_inbox_item_id_fkey" FOREIGN KEY ("item_id") REFERENCES "inbox_item"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "inbox_item_read" DROP CONSTRAINT IF EXISTS "inbox_item_read_user_id_user_id_fkey";--> statement-breakpoint
ALTER TABLE "inbox_item_read" ADD CONSTRAINT "inbox_item_read_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE;
Loading
Loading