diff --git a/apps/web/server/api-rewrites.json b/apps/web/server/api-rewrites.json index 2c6f1d4a46..3a05548cbc 100644 --- a/apps/web/server/api-rewrites.json +++ b/apps/web/server/api-rewrites.json @@ -395,10 +395,18 @@ "src": "/api/issues", "dest": "/api/default.js?route=issues" }, + { + "src": "/api/live/agent-inbox", + "dest": "/api/default.js?route=live/agent-inbox" + }, { "src": "/api/live/inbox", "dest": "/api/default.js?route=live/inbox" }, + { + "src": "/api/live/task-inbox", + "dest": "/api/default.js?route=live/task-inbox" + }, { "src": "/api/mac-commands", "dest": "/api/default.js?route=mac-commands" diff --git a/apps/web/server/routes/live/agent-inbox.ts b/apps/web/server/routes/live/agent-inbox.ts new file mode 100644 index 0000000000..47fe0afd87 --- /dev/null +++ b/apps/web/server/routes/live/agent-inbox.ts @@ -0,0 +1,6 @@ +import { resolveHostedUserId } from "@luke/service/auth/bearer"; +import { liveApp } from "@luke/service/live/live-app"; +import { routeFromHttpRouter } from "../../route-effect.js"; + +/** One agent's inbox under `?id=`, sealed for following over Neon Realtime (GET). The logic lives in `packages/service/src/live/live-app.ts`; this file only hands it the deployment's real seams. */ +export default routeFromHttpRouter(liveApp({ resolveUserId: resolveHostedUserId })); diff --git a/apps/web/server/routes/live/task-inbox.ts b/apps/web/server/routes/live/task-inbox.ts new file mode 100644 index 0000000000..ffb9978a2c --- /dev/null +++ b/apps/web/server/routes/live/task-inbox.ts @@ -0,0 +1,6 @@ +import { resolveHostedUserId } from "@luke/service/auth/bearer"; +import { liveApp } from "@luke/service/live/live-app"; +import { routeFromHttpRouter } from "../../route-effect.js"; + +/** One task's inbox under `?id=`, sealed for following over Neon Realtime (GET). The logic lives in `packages/service/src/live/live-app.ts`; this file only hands it the deployment's real seams. */ +export default routeFromHttpRouter(liveApp({ resolveUserId: resolveHostedUserId })); diff --git a/apps/web/tests/plan-comments.test.ts b/apps/web/tests/plan-comments.test.ts index 9fa421128e..896da05e6f 100644 --- a/apps/web/tests/plan-comments.test.ts +++ b/apps/web/tests/plan-comments.test.ts @@ -41,6 +41,7 @@ import { CONNECTOR_ASKED_NOTHING, integrationConnectOf, } from "@luke/service/testing/integration-connect"; +import { quietLiveWake } from "@luke/service/testing/live-wake"; import { unparsedWire } from "@luke/wire"; import { readEither } from "@luke/wire/effect"; import { Clock, type Context, Effect, Layer, Option, Result, Stream } from "effect"; @@ -125,6 +126,7 @@ const answer = (routes: "comments" | "plans", request: Request) => NO_GITHUB, modelCatalogOf([]), integrationConnectOf(() => CONNECTOR_ASKED_NOTHING), + quietLiveWake, ); let response: Response; if (routes === "comments") { diff --git a/packages/database/src/core/live-statement.ts b/packages/database/src/core/live-statement.ts index bdd17baa44..e733c155c3 100644 --- a/packages/database/src/core/live-statement.ts +++ b/packages/database/src/core/live-statement.ts @@ -10,8 +10,10 @@ import { Option, Schema } from "effect"; * selects every column under its wire key (`liveSelection`), and an instant * as the epoch milliseconds the wire carries, so a row as Realtime delivers * it decodes under the same schema the held read answers with. Realtime takes - * raw SQL with `$n` placeholders and its values beside it, which is what - * Drizzle renders a statement to (`liveStatementOf`). + * the Drizzle select itself through its Drizzle adapter, which renders it to + * SQL with `$n` placeholders and its values beside it and refuses one whose + * selected key is not the name Postgres answers the column under + * (`liveStatementOf`). * * Note that a live read is deterministic, as Realtime requires: no `now()`, * no window measured from the clock. A read that needs one bounds it on the @@ -21,15 +23,25 @@ import { Option, Schema } from "effect"; /** One value of a live statement, as Realtime takes it: Postgres infers its type from where it stands. */ type LiveParameter = string | number | boolean | null; -/** A live statement: its SQL and the values its placeholders name, in order. */ +/** + * A Drizzle select as Realtime's Drizzle adapter seals it: the rows it is + * typed to answer, and the SQL and values it renders to. + */ +export interface LiveSelect { + readonly _: { readonly result: readonly unknown[] }; + toSQL(): { readonly sql: string; readonly params: readonly unknown[] }; +} + +/** + * A live statement: its SQL and the values its placeholders name, in order, + * and the Drizzle select it was rendered from, which is what is sealed + * (`drizzleAdapter()`); a statement written as text alone, as a test's + * stand-in is, carries none and is sealed as that text. + */ export interface LiveStatement { readonly sql: string; readonly params: readonly LiveParameter[]; -} - -/** What a Drizzle statement renders to, whichever builder made it. */ -interface RenderableStatement { - toSQL(): { readonly sql: string; readonly params: readonly unknown[] }; + readonly query?: LiveSelect; } /** @@ -80,10 +92,12 @@ const LiveValuesSchema = Schema.Array( * The statement as Realtime takes it; none where it names a value Realtime * cannot carry, which a caller answers as the read not being live. */ -export function liveStatementOf(statement: RenderableStatement): Option.Option { +export function liveStatementOf(statement: LiveSelect): Option.Option { const { sql: text, params } = statement.toSQL(); return Option.map(Schema.decodeUnknownOption(LiveValuesSchema)(params), (values) => ({ sql: text, params: values.map((value) => (value instanceof Date ? value.toISOString() : value)), + // Note that a select binding an instant is sealed as its text, because the adapter encodes no `Date`. + ...(values.some((value) => value instanceof Date) ? undefined : { query: statement }), })); } diff --git a/packages/database/src/models/__tests__/mac-command-store.test.ts b/packages/database/src/models/__tests__/mac-command-store.test.ts index 64f3cd217a..2a659445d9 100644 --- a/packages/database/src/models/__tests__/mac-command-store.test.ts +++ b/packages/database/src/models/__tests__/mac-command-store.test.ts @@ -3,12 +3,14 @@ import { randomUUID } from "node:crypto"; import { it } from "@effect/vitest"; import type { MacCommandResult } from "@luke/features/mac-command-wire"; import { Clock, Effect, Option } from "effect"; +import { SqlClient } from "effect/sql"; import { Database } from "../../core/query.js"; import { user } from "../../schemas/auth.js"; import { getTestDB } from "../../testing/index.js"; import { claimMacCommands, macCommandResult, + macCommandSettledLiveStatement, queueMacCommand, settleMacCommand, } from "../mac-command-store.js"; @@ -77,4 +79,25 @@ it.layer(getTestDB())("the Mac command store", (it) => { assert.deepEqual(yield* macCommandResult(other, queued.id), Option.none()); }), ); + it.effect( + "the statement Realtime wakes a waiting call on says the command settled once it does, and reads nothing of another account's", + () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const { owner, other } = yield* openAccounts; + const since = yield* now; + const queued = command(); + yield* queueMacCommand(owner, queued); + const rows = (userId: string) => { + const statement = Option.getOrThrow(macCommandSettledLiveStatement(userId, queued.id)); + return sql.unsafe(statement.sql, [...statement.params]); + }; + + assert.deepEqual(yield* rows(owner), [{ id: queued.id, settled: false }]); + yield* claimMacCommands(owner, since); + assert.ok(yield* settleMacCommand(owner, queued.id, RESULT)); + assert.deepEqual(yield* rows(owner), [{ id: queued.id, settled: true }]); + assert.deepEqual(yield* rows(other), []); + }), + ); }); diff --git a/packages/database/src/models/__tests__/plan-comments.test.ts b/packages/database/src/models/__tests__/plan-comments.test.ts index a5f26b5954..3493ec6278 100644 --- a/packages/database/src/models/__tests__/plan-comments.test.ts +++ b/packages/database/src/models/__tests__/plan-comments.test.ts @@ -12,7 +12,8 @@ import { type PlanCommentStartRequest, type PlanCommentThread, } from "@luke/features/plan-comment-wire"; -import { Effect, Option } from "effect"; +import { Effect, Option, Schema } from "effect"; +import { SqlClient } from "effect/sql"; import { Database } from "../../core/query.js"; import { user } from "../../schemas/auth.js"; import { getTestDB, joinWorkspace, openWorkspace } from "../../testing/index.js"; @@ -22,6 +23,8 @@ import { PLAN_COMMENT_OUTCOME, type PlanCommentOutcome, type PlanCommentWriterRef, + planCommentStamps, + planCommentStampsLiveStatement, readPlanCommentThreads, startPlanCommentThread, } from "../plan-comments.js"; @@ -158,6 +161,37 @@ it.layer(getTestDB())("a plan's comment threads", (it) => { }), ); + it.effect( + "the statement Realtime wakes a held read of the threads on delivers where each thread stands as the held read's stamps say, to a member and to no one else", + () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const { owner, teammate, outsider, taskId } = yield* openSharedTask; + threadOf( + yield* startPlanCommentThread( + member(owner), + taskId, + startOn(PLAN_TEXT, "teammate", "Or a guest?"), + ), + ); + const StampRows = Schema.Array( + Schema.Struct({ + id: Schema.String, + revision: Schema.Number, + status: Schema.String, + updatedAt: Schema.Number, + }), + ); + for (const reader of [owner, teammate, outsider]) { + const statement = Option.getOrThrow(planCommentStampsLiveStatement(reader, taskId)); + const rows = yield* Schema.decodeUnknownEffect(StampRows)( + yield* sql.unsafe(statement.sql, [...statement.params]), + ); + assert.deepEqual(rows, yield* planCommentStamps(reader, taskId)); + } + }), + ); + it.effect("a thread sent again under its key is started once", () => Effect.gen(function* () { const { owner, taskId } = yield* openSharedTask; diff --git a/packages/database/src/models/board-store.ts b/packages/database/src/models/board-store.ts index 5acbfcf5bc..017e9a4228 100644 --- a/packages/database/src/models/board-store.ts +++ b/packages/database/src/models/board-store.ts @@ -5,10 +5,11 @@ import { type DrawingElement, drawingElementsSchema, } from "@luke/features/board-wire"; -import { eq, sql } from "drizzle-orm"; +import { eq, isNotNull, sql } from "drizzle-orm"; import { DateTime, Effect, Option, Schema } from "effect"; import { SqlClient, SqlSchema } from "effect/sql"; -import { Database, type DatabaseServices } from "../core/query.js"; +import { type LiveStatement, liveSelection, liveStatementOf } from "../core/live-statement.js"; +import { Database, type DatabaseServices, queryBuilder } from "../core/query.js"; import { task, taskBoard } from "../schemas/task.js"; import { TaskArchived, type TaskStoreEffect, type TaskStoreFailure } from "./task-store.js"; import { reachableTask } from "./workspace-store.js"; @@ -134,6 +135,32 @@ const findBoardImage = SqlSchema.findOneOption({ ), }); +/** + * Where the plan's board stands for `readBoardImage`, written for Neon + * Realtime to wake a look on: the drawing numbers and whether an image is + * kept, never the image itself. None where it names a value Realtime cannot + * carry. + */ +export function boardImageLiveStatement( + userId: string, + taskId: string, +): Option.Option { + return liveStatementOf( + queryBuilder + .select( + liveSelection({ + taskId: task.id, + appliedDrawing: taskBoard.appliedDrawing, + drawingNumber: taskBoard.drawingNumber, + imaged: isNotNull(taskBoard.image), + }), + ) + .from(task) + .leftJoin(taskBoard, eq(taskBoard.taskId, task.id)) + .where(reachableTask(userId, taskId)), + ); +} + /** Luke's drawing written whole, as the next number. */ function upsertDrawing(drawing: readonly DrawingElement[]) { return SqlSchema.findOne({ diff --git a/packages/database/src/models/mac-command-store.ts b/packages/database/src/models/mac-command-store.ts index 49fbdfc6c3..e66aa7b44c 100644 --- a/packages/database/src/models/mac-command-store.ts +++ b/packages/database/src/models/mac-command-store.ts @@ -6,7 +6,8 @@ import { import { and, asc, eq, gte, isNotNull, isNull, type SQL } from "drizzle-orm"; import { DateTime, Effect, Option, Schema } from "effect"; import { SqlSchema } from "effect/sql"; -import { Database } from "../core/query.js"; +import { type LiveStatement, liveSelection, liveStatementOf } from "../core/live-statement.js"; +import { Database, queryBuilder } from "../core/query.js"; import { macCommand, macCommandListener } from "../schemas/mac-command.js"; import type { TaskStoreEffect } from "./task-store.js"; @@ -142,6 +143,23 @@ const findResult = SqlSchema.findOneOption({ ), }); +/** + * Whether the account's command is settled, written for Neon Realtime to + * wake the call waiting on its result: its id and whether a result is kept, + * never the result itself. None where it names a value Realtime cannot carry. + */ +export function macCommandSettledLiveStatement( + userId: string, + id: string, +): Option.Option { + return liveStatementOf( + queryBuilder + .select(liveSelection({ id: macCommand.id, settled: isNotNull(macCommand.result) })) + .from(macCommand) + .where(and(eq(macCommand.id, id), eq(macCommand.userId, userId))), + ); +} + const deleteCommand = SqlSchema.void({ Request: CommandKeySchema, execute: ({ userId, id }) => diff --git a/packages/database/src/models/plan-comments.ts b/packages/database/src/models/plan-comments.ts index 2d65d7a44e..e98a3d6688 100644 --- a/packages/database/src/models/plan-comments.ts +++ b/packages/database/src/models/plan-comments.ts @@ -23,7 +23,8 @@ import { planSections } from "@luke/features/plan-document"; import { and, asc, eq, inArray, isNull, sql } from "drizzle-orm"; import { DateTime, Effect, Option, Schema } from "effect"; import { SqlClient, SqlSchema } from "effect/sql"; -import { Database } from "../core/query.js"; +import { type LiveStatement, liveSelection, liveStatementOf } from "../core/live-statement.js"; +import { Database, queryBuilder } from "../core/query.js"; import { user } from "../schemas/auth.js"; import { planComment, planCommentThread } from "../schemas/plan-comment.js"; import { task } from "../schemas/task.js"; @@ -223,6 +224,33 @@ const findStamps = SqlSchema.findAll({ ), }); +/** + * Where each of the plan's threads stands, as `planCommentStamps` reads it, + * written for Neon Realtime to wake a held read on. None where it names a + * value Realtime cannot carry. + */ +export function planCommentStampsLiveStatement( + userId: string, + taskId: string, +): Option.Option { + return liveStatementOf( + queryBuilder + .select( + liveSelection( + { + id: planCommentThread.id, + revision: planCommentThread.revision, + status: planCommentThread.status, + }, + { updatedAt: planCommentThread.updatedAt }, + ), + ) + .from(planCommentThread) + .innerJoin(task, eq(task.id, planCommentThread.taskId)) + .where(and(eq(planCommentThread.taskId, taskId), taskReachableBy(userId))), + ); +} + const countThreads = SqlSchema.findOne({ Request: Schema.Struct({ taskId: Schema.String }), Result: Schema.Struct({ count: Schema.Number }), diff --git a/packages/database/src/testing/__tests__/live-immutability.test.ts b/packages/database/src/testing/__tests__/live-immutability.test.ts new file mode 100644 index 0000000000..9cee00d8c6 --- /dev/null +++ b/packages/database/src/testing/__tests__/live-immutability.test.ts @@ -0,0 +1,94 @@ +import assert from "node:assert/strict"; +import { it } from "@effect/vitest"; +import { and, eq, sql } from "drizzle-orm"; +import { Effect, Option } from "effect"; +import { type LiveSelect, liveSelection, liveStatementOf } from "../../core/live-statement.js"; +import { queryBuilder } from "../../core/query.js"; +import { user } from "../../schemas/auth.js"; +import { mutableFunctionsOf } from "../live-immutability.js"; +import { getTestDB } from "../sql-client.js"; + +/** + * The probe reads what a statement calls as Postgres records it, so a + * statement Realtime would refuse is named by every function that is not + * immutable, an aggregate's and an operator's among them, and one it would + * take by none. + */ + +/** The live statement a select renders to, which every probe here can carry. */ +function statementOf(select: LiveSelect) { + return Option.getOrThrow(liveStatementOf(select)); +} + +it.layer(getTestDB())("a live statement's immutability", (it) => { + it.effect( + "a statement of columns, instants read at UTC, and a scalar read calls nothing mutable", + () => + Effect.gen(function* () { + const statement = statementOf( + queryBuilder + .select( + liveSelection( + { + id: user.id, + named: sql`${user.name} <> ${"nobody"}`, + others: sql`(select count(*)::int from ${user} as other where other.id <> ${user.id})`, + }, + { createdAt: user.createdAt }, + ), + ) + .from(user) + .where(and(eq(user.id, "user-1"), eq(user.emailVerified, true))), + ); + assert.deepEqual(yield* mutableFunctionsOf(statement), []); + }), + ); + + it.effect( + "a statement building JSON, extracting from a timestamptz, or adding to one is named by each call", + () => + Effect.gen(function* () { + const statement = statementOf( + queryBuilder + .select({ + files: + sql`(select coalesce(jsonb_agg(jsonb_build_object('id', other.id)), '[]'::jsonb) from ${user} as other)`.as( + "files", + ), + at: sql`floor(extract(epoch from ${user.createdAt}) * 1000)::float8`.as("at"), + later: sql`${user.updatedAt} > ${user.createdAt} + interval '1 day'`.as("later"), + }) + .from(user) + .where(eq(user.id, "user-1")), + ); + assert.deepEqual(yield* mutableFunctionsOf(statement), [ + '"extract"(text,timestamp with time zone) s', + "jsonb_agg(anyelement) s", + 'jsonb_build_object("any") s', + "timestamptz_pl_interval(timestamp with time zone,interval) s", + ]); + }), + ); + + it.effect( + "a cast through text is named by the stable input or output function it calls, an instant's either way, and a uuid's to text by none", + () => + Effect.gen(function* () { + const statement = statementOf( + queryBuilder + .select({ + latest: + sql`(select max(other.created_at)::text from ${user} as other)::timestamptz`.as( + "latest", + ), + id: sql`${user.id}::uuid::text`.as("id"), + }) + .from(user), + ); + assert.deepEqual(yield* mutableFunctionsOf(statement), [ + "timestamptz_in(cstring,oid,integer) s", + "timestamptz_out(timestamp with time zone) s", + ]); + }), + ); +}); diff --git a/packages/database/src/testing/index.ts b/packages/database/src/testing/index.ts index b0d21a8b78..03c0d56bd8 100644 --- a/packages/database/src/testing/index.ts +++ b/packages/database/src/testing/index.ts @@ -1,4 +1,5 @@ export * from "./hosted-store-database.js"; +export * from "./live-immutability.js"; export * from "./migrations.js"; export * from "./sql-client.js"; export * from "./store-rows.js"; diff --git a/packages/database/src/testing/live-immutability.ts b/packages/database/src/testing/live-immutability.ts new file mode 100644 index 0000000000..9c0e02d516 --- /dev/null +++ b/packages/database/src/testing/live-immutability.ts @@ -0,0 +1,176 @@ +import { Effect, Predicate } from "effect"; +import { SqlClient } from "effect/sql"; +import type { SqlError } from "effect/sql/SqlError"; + +/** + * live-immutability.ts -- the functions a live statement calls that Postgres does not mark immutable, which Neon Realtime refuses. + * + * Realtime refuses a statement that calls any function, operator, or + * aggregate whose `provolatile` is not `i` ("unsupported query: scalar + * expression uses a mutable function"), while plain Postgres and PGlite run + * the same statement without a word. So the statement is made a view, its + * placeholders written as literals, and what the view's rule calls is read + * from the rule's own tree in `pg_rewrite`: every function, aggregate, and + * window function it names, and the function behind every operator, each + * with its volatility, its subqueries included. Note that `pg_depend` would + * not do, since Postgres records no dependency on a built-in, which every + * function Realtime could refuse is. + * + * A cast through a type's text form (`CoerceViaIO`, such as + * `max(at)::text` or `text::timestamptz`) names no function in the tree, so + * each is read from the tree itself: the input function of the type it casts + * to, and the output function of the type it casts from, where the node it + * casts says its type, a scalar subquery's by its one column. A cast from + * a type the tree does not say is answered as one, since its output function + * cannot be read. A literal cast (`'-infinity'::timestamptz`) is folded to a + * constant first and calls nothing. + */ + +/** A statement as Realtime takes it: SQL with `$n` placeholders, and their values in order. */ +export interface ImmutabilityProbe { + readonly sql: string; + readonly params: readonly (string | number | boolean | null)[]; +} + +/** The view a probe is made, for as long as it is read. */ +const PROBE_VIEW = "luke_live_immutability_probe"; + +/** The field each node a cast may read from says its type in, at its own depth. */ +const TYPE_FIELD = new Map([ + ["VAR", "vartype"], + ["CONST", "consttype"], + ["PARAM", "paramtype"], + ["FUNCEXPR", "funcresulttype"], + ["OPEXPR", "opresulttype"], + ["AGGREF", "aggtype"], + ["WINDOWFUNC", "wintype"], + ["COERCEVIAIO", "resulttype"], + ["RELABELTYPE", "resulttype"], + ["CASE", "casetype"], + ["COALESCEEXPR", "coalescetype"], + ["MINMAXEXPR", "minmaxtype"], +]); + +/** What a statement's casts through text read: the types cast to, the types cast from, and how many casts' source type the tree does not say. */ +interface TextCasts { + readonly to: readonly number[]; + readonly from: readonly number[]; + readonly unread: number; +} + +/** The node starting at `start`, a `{`, through its matching `}`. */ +function nodeAt(tree: string, start: number): string { + let depth = 0; + for (let at = start; at < tree.length; at++) { + if (tree[at] === "{") depth++; + if (tree[at] === "}") depth--; + if (depth === 0) return tree.slice(start, at + 1); + } + return tree.slice(start); +} + +/** A node with every node nested in it left out, so a field read is its own. */ +function ownFields(node: string): string { + let depth = 0; + let own = ""; + for (const character of node) { + if (character === "{") depth++; + if (depth <= 1) own += character; + if (character === "}") depth--; + } + return own; +} + +/** The type a node says it answers, where it says one; a scalar subquery's is its one column's. */ +function typeOf(node: string): number | undefined { + const name = /^\{(\w+)/.exec(node)?.[1]; + if (name === "SUBLINK") { + const column = node.indexOf("{TARGETENTRY :expr "); + const expression = column === -1 ? -1 : node.indexOf("{", column + 1); + return expression === -1 ? undefined : typeOf(nodeAt(node, expression)); + } + const field = name === undefined ? undefined : TYPE_FIELD.get(name); + if (field === undefined) return undefined; + const read = new RegExp(`:${field} (\\d+)`).exec(ownFields(node))?.[1]; + return read === undefined ? undefined : Number(read); +} + +/** Every cast through text in a rule's tree. */ +function textCastsOf(tree: string): TextCasts { + const to: number[] = []; + const from: number[] = []; + let unread = 0; + for ( + let at = tree.indexOf("{COERCEVIAIO"); + at !== -1; + at = tree.indexOf("{COERCEVIAIO", at + 1) + ) { + const cast = nodeAt(tree, at); + const target = typeOf(cast); + if (target !== undefined) to.push(target); + const argument = cast.indexOf("{", cast.indexOf(":arg ")); + const source = argument === -1 ? undefined : typeOf(nodeAt(cast, argument)); + if (source === undefined) unread++; + else from.push(source); + } + return { to, from, unread }; +} + +/** A value written as the literal it stands for: text left untyped, so Postgres types it from where it stands, as it types a placeholder. */ +function literalOf(value: string | number | boolean | null): string { + return Predicate.isString(value) ? `'${value.replaceAll("'", "''")}'` : String(value); +} + +/** The statement with each placeholder written as its value; one naming no value is left for Postgres to refuse. */ +function inlined(probe: ImmutabilityProbe): string { + return probe.sql.replace(/\$(\d+)\b/g, (placeholder, position: string) => { + const value = probe.params[Number(position) - 1]; + return value === undefined ? placeholder : literalOf(value); + }); +} + +/** + * Every function, aggregate, and operator's function the statement calls + * that is not immutable, as `name(arguments) volatility`, sorted; empty + * where Realtime would take the statement. + */ +export function mutableFunctionsOf( + probe: ImmutabilityProbe, +): Effect.Effect { + return Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql.unsafe(`create or replace temporary view ${PROBE_VIEW} as ${inlined(probe)}`); + const rows = yield* sql.unsafe<{ readonly called: string }>( + `with rule as ( + select ev_action::text as tree from pg_rewrite where ev_class = '${PROBE_VIEW}'::regclass + ), called as ( + select called[2]::oid as proc + from rule, regexp_matches(tree, ':(funcid|aggfnoid|winfnoid) (\\d+)', 'g') as called + union + select operator.oprcode::oid + from rule, regexp_matches(tree, ':opno (\\d+)', 'g') as called + join pg_operator as operator on operator.oid = called[1]::oid + ) + select distinct p.oid::regprocedure::text || ' ' || p.provolatile::text as called + from called join pg_proc as p on p.oid = called.proc + where p.provolatile <> 'i' + order by 1`, + ); + const trees = yield* sql.unsafe<{ readonly tree: string }>( + `select ev_action::text as tree from pg_rewrite where ev_class = '${PROBE_VIEW}'::regclass`, + ); + const casts = textCastsOf(trees.map((row) => row.tree).join("")); + const castsCall = yield* sql.unsafe<{ readonly called: string }>( + `select distinct p.oid::regprocedure::text || ' ' || p.provolatile::text as called + from pg_type as t join pg_proc as p + on (t.oid = any('{${casts.to.join(",")}}'::oid[]) and p.oid = t.typinput::oid) + or (t.oid = any('{${casts.from.join(",")}}'::oid[]) and p.oid = t.typoutput::oid) + where p.provolatile <> 'i' + order by 1`, + ); + yield* sql.unsafe(`drop view ${PROBE_VIEW}`); + const unread = + casts.unread === 0 ? [] : ["a cast through text from a type the tree does not say"]; + return [...new Set([...rows, ...castsCall].map((row) => row.called))].toSorted().concat(unread); + }); +} diff --git a/packages/features/fixtures/json-schema/live-wire-scopeInboxLiveAnswerSchema.json b/packages/features/fixtures/json-schema/live-wire-scopeInboxLiveAnswerSchema.json new file mode 100644 index 0000000000..4b7fd8ee50 --- /dev/null +++ b/packages/features/fixtures/json-schema/live-wire-scopeInboxLiveAnswerSchema.json @@ -0,0 +1,87 @@ +{ + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "queries": { + "type": "object", + "properties": { + "items": { + "type": "object", + "properties": { + "capability": { + "type": "string" + }, + "queryFingerprint": { + "type": "string" + }, + "expiresAt": { + "type": "number", + "minimum": 0 + } + }, + "required": [ + "capability", + "queryFingerprint", + "expiresAt" + ], + "additionalProperties": false + }, + "messages": { + "type": "object", + "properties": { + "capability": { + "type": "string" + }, + "queryFingerprint": { + "type": "string" + }, + "expiresAt": { + "type": "number", + "minimum": 0 + } + }, + "required": [ + "capability", + "queryFingerprint", + "expiresAt" + ], + "additionalProperties": false + }, + "files": { + "type": "object", + "properties": { + "capability": { + "type": "string" + }, + "queryFingerprint": { + "type": "string" + }, + "expiresAt": { + "type": "number", + "minimum": 0 + } + }, + "required": [ + "capability", + "queryFingerprint", + "expiresAt" + ], + "additionalProperties": false + } + }, + "required": [ + "items", + "messages", + "files" + ], + "additionalProperties": false + } + }, + "required": [ + "url", + "queries" + ], + "additionalProperties": false +} diff --git a/packages/features/fixtures/json-schema/live-wire-scopeInboxLiveRowsSchema.json b/packages/features/fixtures/json-schema/live-wire-scopeInboxLiveRowsSchema.json new file mode 100644 index 0000000000..c74fee5b78 --- /dev/null +++ b/packages/features/fixtures/json-schema/live-wire-scopeInboxLiveRowsSchema.json @@ -0,0 +1,1701 @@ +{ + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 36 + }, + "agentId": { + "type": "string", + "minLength": 1, + "maxLength": 36 + }, + "key": { + "type": "string" + }, + "revision": { + "type": "number", + "minimum": 0 + }, + "version": { + "type": "number", + "minimum": 0 + }, + "title": { + "type": "string" + }, + "blocks": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "markdown" + ] + }, + "text": { + "type": "string", + "minLength": 1, + "maxLength": 8000 + } + }, + "required": [ + "type", + "text" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "link" + ] + }, + "url": { + "type": "string", + "maxLength": 2000 + }, + "label": { + "type": "string", + "minLength": 1, + "maxLength": 160 + } + }, + "required": [ + "type", + "url", + "label" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "code" + ] + }, + "repo": { + "type": "string", + "maxLength": 140 + }, + "path": { + "type": "string", + "minLength": 1, + "maxLength": 1024 + }, + "startLine": { + "type": "integer", + "minimum": 1 + }, + "endLine": { + "type": "integer", + "minimum": 1 + }, + "sha": { + "type": "string", + "minLength": 40, + "maxLength": 40 + } + }, + "required": [ + "type", + "repo", + "path", + "startLine", + "endLine", + "sha" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "image" + ] + }, + "attachmentId": { + "type": "string", + "minLength": 1, + "maxLength": 36 + }, + "alt": { + "type": "string", + "minLength": 1, + "maxLength": 300 + } + }, + "required": [ + "type", + "attachmentId", + "alt" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } + ] + } + }, + "response": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "choice" + ] + }, + "options": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { + "type": "string", + "minLength": 1, + "maxLength": 160 + }, + "detail": { + "type": "string", + "maxLength": 600 + } + }, + "required": [ + "label" + ], + "additionalProperties": false + }, + "minItems": 2, + "maxItems": 6 + }, + "multiple": { + "type": "boolean" + }, + "recommended": { + "type": "string" + } + }, + "required": [ + "type", + "options" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "approval" + ] + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "text" + ] + }, + "placeholder": { + "type": "string", + "maxLength": 160 + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "task" + ] + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "form" + ] + }, + "fields": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "text", + "number", + "select", + "boolean" + ] + }, + "label": { + "type": "string", + "minLength": 1, + "maxLength": 160 + }, + "required": { + "type": "boolean" + }, + "options": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 160 + }, + "minItems": 2, + "maxItems": 6 + }, + "recommended": { + "type": "string" + } + }, + "required": [ + "type", + "label" + ], + "additionalProperties": false + }, + "minItems": 1, + "maxItems": 8 + } + }, + "required": [ + "type", + "fields" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "none" + ] + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } + ] + }, + "needsReply": { + "type": "boolean" + }, + "status": { + "type": "string", + "enum": [ + "open", + "answered", + "closed" + ] + }, + "startedBy": { + "type": "string" + }, + "answer": { + "anyOf": [ + { + "type": "object", + "properties": { + "reply": { + "type": "string", + "enum": [ + "chose", + "approved", + "declined", + "answered", + "done", + "not-done", + "submitted", + "acknowledged" + ] + }, + "choices": { + "type": "array", + "items": { + "type": "string" + }, + "maxItems": 6 + }, + "values": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { + "type": "string", + "minLength": 1, + "maxLength": 160 + }, + "value": { + "anyOf": [ + { + "type": "string", + "maxLength": 1000 + }, + { + "type": "number" + }, + { + "type": "boolean" + } + ] + } + }, + "required": [ + "label", + "value" + ], + "additionalProperties": false + }, + "maxItems": 8 + }, + "note": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "reply" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "answerFiles": { + "type": "array", + "items": { + "type": "object", + "properties": { + "attachmentId": { + "type": "string", + "minLength": 1, + "maxLength": 36 + }, + "name": { + "type": "string" + }, + "mediaType": { + "type": "string" + } + }, + "required": [ + "attachmentId", + "name", + "mediaType" + ], + "additionalProperties": false + } + }, + "answeredBy": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "closedReason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "closedBy": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "messages": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 36 + }, + "author": { + "type": "string", + "enum": [ + "member", + "agent" + ] + }, + "member": { + "type": "object", + "properties": { + "userId": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "userId", + "name" + ], + "additionalProperties": false + }, + "text": { + "type": "string" + }, + "files": { + "type": "array", + "items": { + "type": "object", + "properties": { + "attachmentId": { + "type": "string", + "minLength": 1, + "maxLength": 36 + }, + "name": { + "type": "string" + }, + "mediaType": { + "type": "string" + } + }, + "required": [ + "attachmentId", + "name", + "mediaType" + ], + "additionalProperties": false + } + }, + "ask": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "decide" + ] + }, + "options": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { + "type": "string", + "minLength": 1, + "maxLength": 160 + }, + "detail": { + "type": "string", + "maxLength": 600 + } + }, + "required": [ + "label" + ], + "additionalProperties": false + }, + "minItems": 2, + "maxItems": 6 + }, + "multiple": { + "type": "boolean" + }, + "recommended": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "open", + "answered", + "replaced", + "withdrawn" + ] + }, + "withdrawnReason": { + "type": "string" + } + }, + "required": [ + "type", + "options", + "status" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "do" + ] + }, + "steps": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 600 + }, + "maxItems": 8 + }, + "status": { + "type": "string", + "enum": [ + "open", + "answered", + "replaced", + "withdrawn" + ] + }, + "withdrawnReason": { + "type": "string" + } + }, + "required": [ + "type", + "steps", + "status" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "answer" + ] + }, + "fields": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "text", + "number", + "select", + "boolean" + ] + }, + "label": { + "type": "string", + "minLength": 1, + "maxLength": 160 + }, + "required": { + "type": "boolean" + }, + "options": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 160 + }, + "minItems": 2, + "maxItems": 6 + }, + "recommended": { + "type": "string" + } + }, + "required": [ + "type", + "label" + ], + "additionalProperties": false + }, + "minItems": 1, + "maxItems": 8 + }, + "status": { + "type": "string", + "enum": [ + "open", + "answered", + "replaced", + "withdrawn" + ] + }, + "withdrawnReason": { + "type": "string" + } + }, + "required": [ + "type", + "status" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "open", + "answered", + "replaced", + "withdrawn" + ] + }, + "withdrawnReason": { + "type": "string" + } + }, + "required": [ + "type", + "status" + ], + "additionalProperties": false + } + ] + }, + "answers": { + "type": "object", + "properties": { + "ask": { + "type": "string", + "minLength": 1, + "maxLength": 36 + }, + "reply": { + "type": "string", + "enum": [ + "chose", + "approved", + "declined", + "answered", + "done", + "not-done", + "submitted", + "acknowledged" + ] + }, + "choices": { + "type": "array", + "items": { + "type": "string" + }, + "maxItems": 6 + }, + "values": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { + "type": "string", + "minLength": 1, + "maxLength": 160 + }, + "value": { + "anyOf": [ + { + "type": "string", + "maxLength": 1000 + }, + { + "type": "number" + }, + { + "type": "boolean" + } + ] + } + }, + "required": [ + "label", + "value" + ], + "additionalProperties": false + }, + "maxItems": 8 + }, + "steps": { + "type": "array", + "items": { + "type": "integer", + "minimum": 0 + }, + "maxItems": 8 + } + }, + "required": [ + "ask", + "reply" + ], + "additionalProperties": false + }, + "createdAt": { + "type": "number", + "minimum": 0 + } + }, + "required": [ + "id", + "author", + "text", + "createdAt" + ], + "additionalProperties": false + } + }, + "thread": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 36 + }, + "author": { + "type": "string", + "enum": [ + "member", + "agent" + ] + }, + "member": { + "type": "object", + "properties": { + "userId": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "userId", + "name" + ], + "additionalProperties": false + }, + "text": { + "type": "string" + }, + "files": { + "type": "array", + "items": { + "type": "object", + "properties": { + "attachmentId": { + "type": "string", + "minLength": 1, + "maxLength": 36 + }, + "name": { + "type": "string" + }, + "mediaType": { + "type": "string" + } + }, + "required": [ + "attachmentId", + "name", + "mediaType" + ], + "additionalProperties": false + } + }, + "ask": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "decide" + ] + }, + "options": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { + "type": "string", + "minLength": 1, + "maxLength": 160 + }, + "detail": { + "type": "string", + "maxLength": 600 + } + }, + "required": [ + "label" + ], + "additionalProperties": false + }, + "minItems": 2, + "maxItems": 6 + }, + "multiple": { + "type": "boolean" + }, + "recommended": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "open", + "answered", + "replaced", + "withdrawn" + ] + }, + "withdrawnReason": { + "type": "string" + } + }, + "required": [ + "type", + "options", + "status" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "do" + ] + }, + "steps": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 600 + }, + "maxItems": 8 + }, + "status": { + "type": "string", + "enum": [ + "open", + "answered", + "replaced", + "withdrawn" + ] + }, + "withdrawnReason": { + "type": "string" + } + }, + "required": [ + "type", + "steps", + "status" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "answer" + ] + }, + "fields": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "text", + "number", + "select", + "boolean" + ] + }, + "label": { + "type": "string", + "minLength": 1, + "maxLength": 160 + }, + "required": { + "type": "boolean" + }, + "options": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 160 + }, + "minItems": 2, + "maxItems": 6 + }, + "recommended": { + "type": "string" + } + }, + "required": [ + "type", + "label" + ], + "additionalProperties": false + }, + "minItems": 1, + "maxItems": 8 + }, + "status": { + "type": "string", + "enum": [ + "open", + "answered", + "replaced", + "withdrawn" + ] + }, + "withdrawnReason": { + "type": "string" + } + }, + "required": [ + "type", + "status" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "open", + "answered", + "replaced", + "withdrawn" + ] + }, + "withdrawnReason": { + "type": "string" + } + }, + "required": [ + "type", + "status" + ], + "additionalProperties": false + } + ] + }, + "answers": { + "type": "object", + "properties": { + "ask": { + "type": "string", + "minLength": 1, + "maxLength": 36 + }, + "reply": { + "type": "string", + "enum": [ + "chose", + "approved", + "declined", + "answered", + "done", + "not-done", + "submitted", + "acknowledged" + ] + }, + "choices": { + "type": "array", + "items": { + "type": "string" + }, + "maxItems": 6 + }, + "values": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { + "type": "string", + "minLength": 1, + "maxLength": 160 + }, + "value": { + "anyOf": [ + { + "type": "string", + "maxLength": 1000 + }, + { + "type": "number" + }, + { + "type": "boolean" + } + ] + } + }, + "required": [ + "label", + "value" + ], + "additionalProperties": false + }, + "maxItems": 8 + }, + "steps": { + "type": "array", + "items": { + "type": "integer", + "minimum": 0 + }, + "maxItems": 8 + } + }, + "required": [ + "ask", + "reply" + ], + "additionalProperties": false + }, + "createdAt": { + "type": "number", + "minimum": 0 + } + }, + "required": [ + "id", + "author", + "text", + "createdAt" + ], + "additionalProperties": false + } + }, + "postedAt": { + "type": "number", + "minimum": 0 + }, + "settledAt": { + "anyOf": [ + { + "type": "number", + "minimum": 0 + }, + { + "type": "null" + } + ] + }, + "updatedAt": { + "type": "number", + "minimum": 0 + }, + "unread": { + "type": "boolean" + }, + "startedByName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "answeredByName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "closedByName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "openAskMessageId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "agentId", + "key", + "revision", + "version", + "title", + "blocks", + "response", + "needsReply", + "status", + "startedBy", + "answer", + "answeredBy", + "closedReason", + "closedBy", + "postedAt", + "settledAt", + "updatedAt", + "unread", + "startedByName", + "answeredByName", + "closedByName", + "openAskMessageId" + ], + "additionalProperties": false + } + }, + "messages": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 36 + }, + "itemId": { + "type": "string" + }, + "author": { + "type": "string", + "enum": [ + "member", + "agent" + ] + }, + "userId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "text": { + "type": "string" + }, + "ask": { + "anyOf": [ + { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "decide" + ] + }, + "options": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { + "type": "string", + "minLength": 1, + "maxLength": 160 + }, + "detail": { + "type": "string", + "maxLength": 600 + } + }, + "required": [ + "label" + ], + "additionalProperties": false + }, + "minItems": 2, + "maxItems": 6 + }, + "multiple": { + "type": "boolean" + }, + "recommended": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "open", + "answered", + "replaced", + "withdrawn" + ] + }, + "withdrawnReason": { + "type": "string" + } + }, + "required": [ + "type", + "options", + "status" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "do" + ] + }, + "steps": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 600 + }, + "maxItems": 8 + }, + "status": { + "type": "string", + "enum": [ + "open", + "answered", + "replaced", + "withdrawn" + ] + }, + "withdrawnReason": { + "type": "string" + } + }, + "required": [ + "type", + "steps", + "status" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "answer" + ] + }, + "fields": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "text", + "number", + "select", + "boolean" + ] + }, + "label": { + "type": "string", + "minLength": 1, + "maxLength": 160 + }, + "required": { + "type": "boolean" + }, + "options": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 160 + }, + "minItems": 2, + "maxItems": 6 + }, + "recommended": { + "type": "string" + } + }, + "required": [ + "type", + "label" + ], + "additionalProperties": false + }, + "minItems": 1, + "maxItems": 8 + }, + "status": { + "type": "string", + "enum": [ + "open", + "answered", + "replaced", + "withdrawn" + ] + }, + "withdrawnReason": { + "type": "string" + } + }, + "required": [ + "type", + "status" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "open", + "answered", + "replaced", + "withdrawn" + ] + }, + "withdrawnReason": { + "type": "string" + } + }, + "required": [ + "type", + "status" + ], + "additionalProperties": false + } + ] + }, + { + "type": "null" + } + ] + }, + "answers": { + "anyOf": [ + { + "type": "object", + "properties": { + "ask": { + "type": "string", + "minLength": 1, + "maxLength": 36 + }, + "reply": { + "type": "string", + "enum": [ + "chose", + "approved", + "declined", + "answered", + "done", + "not-done", + "submitted", + "acknowledged" + ] + }, + "choices": { + "type": "array", + "items": { + "type": "string" + }, + "maxItems": 6 + }, + "values": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { + "type": "string", + "minLength": 1, + "maxLength": 160 + }, + "value": { + "anyOf": [ + { + "type": "string", + "maxLength": 1000 + }, + { + "type": "number" + }, + { + "type": "boolean" + } + ] + } + }, + "required": [ + "label", + "value" + ], + "additionalProperties": false + }, + "maxItems": 8 + }, + "steps": { + "type": "array", + "items": { + "type": "integer", + "minimum": 0 + }, + "maxItems": 8 + } + }, + "required": [ + "ask", + "reply" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "createdAt": { + "type": "number", + "minimum": 0 + } + }, + "required": [ + "id", + "itemId", + "author", + "userId", + "name", + "text", + "ask", + "answers", + "createdAt" + ], + "additionalProperties": false + } + }, + "files": { + "type": "array", + "items": { + "type": "object", + "properties": { + "attachmentId": { + "type": "string", + "minLength": 1, + "maxLength": 36 + }, + "name": { + "type": "string" + }, + "mediaType": { + "type": "string" + }, + "answerOf": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "messageId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "position": { + "anyOf": [ + { + "type": "number", + "minimum": 0 + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "attachmentId", + "name", + "mediaType", + "answerOf", + "messageId", + "position" + ], + "additionalProperties": false + } + } + }, + "required": [ + "items", + "messages", + "files" + ], + "additionalProperties": false +} diff --git a/packages/features/src/coding-agent-client.test.ts b/packages/features/src/coding-agent-client.test.ts index 7c68b0a7f2..759040f889 100644 --- a/packages/features/src/coding-agent-client.test.ts +++ b/packages/features/src/coding-agent-client.test.ts @@ -224,22 +224,6 @@ it.effect( }), ); -it.effect( - "reads one agent's inbox past a cursor at the agent's own path, held as a plan's is", - () => - Effect.gen(function* () { - const api = fakeCloudApi({ - [`GET /api/agents/${AGENT_ID}/inbox`]: { - answer: () => ({ items: [], cursor: "3" }), - }, - }); - - const read = yield* Effect.provide(client().agentInbox(AGENT_ID, "0"), api.layer); - - assert.deepEqual(read, { items: [], cursor: "3" }); - }), -); - it.effect( "writes a message on an inbox item's thread at the item's own path, its words trimmed, answers the item as it then stands, names a refusal as a message's, and sends nothing that carries neither words nor files", () => diff --git a/packages/features/src/coding-agent-client.ts b/packages/features/src/coding-agent-client.ts index 0ec40dc6eb..3045f4f2bb 100644 --- a/packages/features/src/coding-agent-client.ts +++ b/packages/features/src/coding-agent-client.ts @@ -31,7 +31,6 @@ import { type CodingAgentDefaultAnswer, type CodingAgentGitHubImageView, type CodingAgentInboxAnsweredView, - type CodingAgentInboxAnswerView, type CodingAgentInboxAttachmentView, type CodingAgentInboxMessagedView, type CodingAgentInboxReadAllView, @@ -103,7 +102,6 @@ import { type InboxReadAllRequest, inboxAnsweredSchema, inboxAnswerRequestSchema, - inboxAnswerSchema, inboxCloseRequestSchema, inboxImageTypeSchema, inboxMessagedSchema, @@ -112,7 +110,12 @@ import { inboxReadAllSchema, inboxReadStateSchema, } from "./inbox-wire.js"; -import { type AccountInboxLiveAnswer, accountInboxLiveAnswerSchema } from "./live-wire.js"; +import { + type AccountInboxLiveAnswer, + accountInboxLiveAnswerSchema, + type ScopeInboxLiveAnswer, + scopeInboxLiveAnswerSchema, +} from "./live-wire.js"; import { codingAgentDefaultWriteSchema, type ModelChoice, @@ -130,7 +133,6 @@ import { type RepositoryUnreached, repositoryNotReachableSchema } from "./reposi import { agentChangesPath, agentGitHubImagePath, - agentInboxPath, agentOverviewChecksPath, agentOverviewConversationPath, agentOverviewPullRequestPath, @@ -148,7 +150,6 @@ import { inboxItemReadPath, inboxMessagesPath, taskAgentsPath, - taskInboxPath, } from "./service-paths.js"; import { HOSTED_API_ERROR, @@ -176,9 +177,6 @@ import type { TaskAgentRefusal } from "./task-wire.js"; * the default and the keys. */ -/** How long a held read or a dictated clip may stand: the service's hold or the model's hearing, and room for the answer to travel after it. */ -const HELD_READ_TIMEOUT_MS = 35_000; - /** * How long a Start may stand: a Start from another agent copies its work, * summarizing what does not fit, before the new agent's session opens, @@ -351,7 +349,6 @@ function defaultOf(read: AccountPreferencesAnswerWire | Failed): CodingAgentDefa */ export class HostedCodingAgentClient { readonly #call: AccountCallEffects; - readonly #held: AccountCallEffects; readonly #start: AccountCallEffects; readonly #hosted: HostedClient; @@ -368,11 +365,6 @@ export class HostedCodingAgentClient { credential, requestTimeoutMs: options.requestTimeoutMs, }); - this.#held = accountCall({ - baseUrl: options.serviceBaseUrl, - credential, - requestTimeoutMs: HELD_READ_TIMEOUT_MS, - }); this.#start = accountCall({ baseUrl: options.serviceBaseUrl, credential, @@ -793,28 +785,6 @@ export class HostedCodingAgentClient { ); } - /** One task's inbox past the cursor: every item its agents posted, held by the service while nothing changed. */ - inbox( - taskId: string, - after: string, - ): Effect.Effect { - return Effect.flatMap( - this.#held.send({ method: HTTP_METHOD.GET, path: taskInboxPath(taskId, after) }), - (answer) => readAnswer(answer, inboxAnswerSchema, ROW_REFUSALS), - ); - } - - /** One agent's inbox past the cursor, an agent with no task as much as one on a task: every item it posted, held by the service while nothing changed. */ - agentInbox( - agentId: string, - after: string, - ): Effect.Effect { - return Effect.flatMap( - this.#held.send({ method: HTTP_METHOD.GET, path: agentInboxPath(agentId, after) }), - (answer) => readAnswer(answer, inboxAnswerSchema, ROW_REFUSALS), - ); - } - /** A workspace's chat past the cursor: what a person in it sees, held by the service while nothing moved. */ chat( workspaceId: string, @@ -872,6 +842,36 @@ export class HostedCodingAgentClient { ); } + /** + * One plan's inbox sealed for following over Neon Realtime, answered at + * once: not found where the account reaches no such plan, unanswered + * for anything else, which the caller answers by asking again. + */ + taskInboxLive( + taskId: string, + ): Effect.Effect { + return Effect.flatMap( + this.#call.send({ + method: HTTP_METHOD.GET, + path: `${HOSTED_SERVICE_PATH.LIVE_TASK_INBOX}?${new URLSearchParams({ id: taskId })}`, + }), + (answer) => readAnswer(answer, scopeInboxLiveAnswerSchema, ROW_REFUSALS), + ); + } + + /** One agent's inbox sealed for following over Neon Realtime, on the terms a plan's is. */ + agentInboxLive( + agentId: string, + ): Effect.Effect { + return Effect.flatMap( + this.#call.send({ + method: HTTP_METHOD.GET, + path: `${HOSTED_SERVICE_PATH.LIVE_AGENT_INBOX}?${new URLSearchParams({ id: agentId })}`, + }), + (answer) => readAnswer(answer, scopeInboxLiveAnswerSchema, ROW_REFUSALS), + ); + } + /** * One picture a tool answered an agent, or one of its workers where * `callId` names the delegation call, fetched under the account: its diff --git a/packages/features/src/hosted-wire-schemas.test.ts b/packages/features/src/hosted-wire-schemas.test.ts index 9d17785913..cdc1553bbc 100644 --- a/packages/features/src/hosted-wire-schemas.test.ts +++ b/packages/features/src/hosted-wire-schemas.test.ts @@ -66,6 +66,8 @@ const EFFECT_MODULE_SCHEMAS = { "live-wire": { sealedLiveQuerySchema: liveWire.sealedLiveQuerySchema, accountInboxLiveAnswerSchema: liveWire.accountInboxLiveAnswerSchema, + scopeInboxLiveAnswerSchema: liveWire.scopeInboxLiveAnswerSchema, + scopeInboxLiveRowsSchema: liveWire.scopeInboxLiveRowsSchema, } satisfies RecordedEffectJsonSchemas, "board-wire": { boardElementSchema: boardWire.boardElementSchema, diff --git a/packages/features/src/inbox-wire.ts b/packages/features/src/inbox-wire.ts index 6ce9bfbe88..617d398535 100644 --- a/packages/features/src/inbox-wire.ts +++ b/packages/features/src/inbox-wire.ts @@ -1,5 +1,5 @@ import { isWireBoolean, isWireNumber, isWireString } from "@luke/wire"; -import { Schema as EffectSchema } from "effect"; +import { Schema as EffectSchema, Equal } from "effect"; import { CODING_AGENT_ATTACHMENT_TYPE, CODING_AGENT_BOUNDS, @@ -1267,3 +1267,67 @@ export function inboxReplyAnswers( return undefined; } } + +/** An ask as a message keeps it without where it stands, where this build knows its type; nothing for a type a newer deployment wrote. */ +export function knownAsk(ask: InboxReadAsk): InboxAsk | undefined { + const { status: _status, withdrawnReason: _reason, ...kept } = ask; + return EffectSchema.is(inboxAskSchema)(kept) ? kept : undefined; +} + +/** + * Whether the item's response still stands for the ask: the ask a response + * maps to is the ask itself, a to-do any `do` ask, whose steps its blocks + * carry. A post from before version 5 rewrites the response and not the + * thread, so an ask it posted over stands for it no longer. + */ +function askStandsFor(response: InboxResponse, ask: InboxAsk): boolean { + const asked = inboxResponseAsk(response); + if (asked === undefined || asked.type !== ask.type) return false; + return asked.type === INBOX_ASK.DO || Equal.equals(asked, ask); +} + +/** Whether the ask held open is still the one the item asks: an ask, or a response, of a type a newer deployment wrote is taken at its word. */ +export function askStillAsked(response: InboxReadResponse, ask: InboxReadAsk): boolean { + const known = knownAsk(ask); + return known === undefined || !knownResponse(response) || askStandsFor(response, known); +} + +/** Where an item stands, as each ask of its thread is read against it. */ +export interface InboxAskStanding { + readonly status: InboxStatus; + readonly response: InboxReadResponse; + readonly openAskMessageId: string | null; + readonly closedReason: string | null; +} + +/** + * An ask as a read carries it: as it was kept, unless it was kept open and + * the item no longer holds it open, which a deployment from before version + * 5 leaves by closing the item without a word to the ask; then withdrawn + * with the item's reason where the item closed on it, answered where the + * item was answered on it, and replaced where the item holds another or, + * posted over from before version 5, asks something else. + * Note that an ask withdrawn with no reason is a member's close without an + * answer, the item's `closedBy` naming the member while the close stands, + * because an agent's close always says why: a reader tells the two closes + * apart by the reason alone, as it tells a closed item's. + */ +export function standingAsk( + ask: InboxReadAsk, + messageId: string, + item: InboxAskStanding, +): InboxReadAsk { + if (ask.status !== INBOX_ASK_STATUS.OPEN) return ask; + const replaced = { ...ask, status: INBOX_ASK_STATUS.REPLACED }; + if (messageId !== item.openAskMessageId) return replaced; + switch (item.status) { + case INBOX_STATUS.OPEN: + return askStillAsked(item.response, ask) ? ask : replaced; + case INBOX_STATUS.ANSWERED: + return { ...ask, status: INBOX_ASK_STATUS.ANSWERED }; + case INBOX_STATUS.CLOSED: + return item.closedReason === null + ? { ...ask, status: INBOX_ASK_STATUS.WITHDRAWN } + : { ...ask, status: INBOX_ASK_STATUS.WITHDRAWN, withdrawnReason: item.closedReason }; + } +} diff --git a/packages/features/src/live-wire.ts b/packages/features/src/live-wire.ts index 773af7af90..aea26f44a4 100644 --- a/packages/features/src/live-wire.ts +++ b/packages/features/src/live-wire.ts @@ -13,6 +13,20 @@ import { accountInboxTaskSchema, accountTasklessEntrySchema, } from "./account-inbox-wire.js"; +import { + INBOX_BOUNDS, + INBOX_STATUS, + type InboxAskStanding, + type InboxItem, + type InboxMessage, + inboxAnswerFileSchema, + inboxAnswersSchema, + inboxItemSchema, + inboxMessageSchema, + inboxReadAskSchema, + inboxReplySchema, + standingAsk, +} from "./inbox-wire.js"; import { countedNumber } from "./service-wire.js"; /** @@ -219,3 +233,203 @@ export function nextFailedRunExpiry( .filter((expiry) => expiry > now); return expiries.length === 0 ? undefined : Math.min(...expiries); } + +/** + * One plan's inbox, or one agent's, sealed for following: the address, and + * its open items and its latest settled ones as one statement, as the held + * read reads them, so an item settling moves in one change, with their + * threads' latest messages and the files their answers and messages + * carried as two more, which the reader joins to them. The service answers + * `not-found` for a plan or agent the account does not reach, and + * `unavailable` where its deployment's Realtime is misconfigured, which the + * desktop answers by asking again. + */ +export const scopeInboxLiveAnswerSchema = EffectSchema.Struct({ + url: EffectSchema.String, + queries: EffectSchema.Struct({ + items: sealedLiveQuerySchema, + messages: sealedLiveQuerySchema, + files: sealedLiveQuerySchema, + }), +}); + +export type ScopeInboxLiveAnswer = typeof scopeInboxLiveAnswerSchema.Type; + +/** One message of an item's thread as its row carries it: its item, and the member by id and name, either null where none stands. */ +const inboxLiveMessageSchema = EffectSchema.Struct({ + id: inboxMessageSchema.fields.id, + itemId: EffectSchema.String, + author: inboxMessageSchema.fields.author, + userId: EffectSchema.NullOr(EffectSchema.String), + name: EffectSchema.NullOr(EffectSchema.String), + text: EffectSchema.String, + ask: EffectSchema.NullOr(inboxReadAskSchema), + answers: EffectSchema.NullOr(inboxAnswersSchema), + createdAt: countedNumber, +}); + +/** One file as its row carries it: the item whose answer carried it, or the message that did, and where it stands among them. */ +const inboxLiveFileSchema = EffectSchema.Struct({ + ...inboxAnswerFileSchema.fields, + answerOf: EffectSchema.NullOr(EffectSchema.String), + messageId: EffectSchema.NullOr(EffectSchema.String), + position: EffectSchema.NullOr(countedNumber), +}); + +/** One item as its row delivers it: what an item leaves out where it holds nothing is null. */ +const inboxLiveRowSchema = EffectSchema.Struct({ + ...inboxItemSchema.fields, + startedBy: EffectSchema.String, + startedByName: EffectSchema.NullOr(EffectSchema.String), + answer: EffectSchema.NullOr(inboxReplySchema), + answeredBy: EffectSchema.NullOr(EffectSchema.String), + answeredByName: EffectSchema.NullOr(EffectSchema.String), + closedReason: EffectSchema.NullOr(EffectSchema.String), + closedBy: EffectSchema.NullOr(EffectSchema.String), + closedByName: EffectSchema.NullOr(EffectSchema.String), + openAskMessageId: EffectSchema.NullOr(EffectSchema.String), + settledAt: EffectSchema.NullOr(countedNumber), + unread: EffectSchema.Boolean, +}); + +/** A scope's rows as Realtime delivers them, decoded at the boundary before anything reads them. */ +export const scopeInboxLiveRowsSchema = EffectSchema.Struct({ + items: EffectSchema.Array(inboxLiveRowSchema), + messages: EffectSchema.Array(inboxLiveMessageSchema), + files: EffectSchema.Array(inboxLiveFileSchema), +}); + +type InboxLiveRows = typeof scopeInboxLiveRowsSchema.Type; +type InboxLiveRow = typeof inboxLiveRowSchema.Type; +type InboxLiveMessage = typeof inboxLiveMessageSchema.Type; +type InboxLiveFile = typeof inboxLiveFileSchema.Type; + +/** The rows grouped under a key each names, a row naming none left out. */ +function groupedBy( + rows: readonly Row[], + keyOf: (row: Row) => string | null, +): ReadonlyMap { + const groups = new Map(); + for (const row of rows) { + const key = keyOf(row); + if (key === null) continue; + groups.set(key, [...(groups.get(key) ?? []), row]); + } + return groups; +} + +/** Files in the order they were attached, each as the wire names it, as the held read lists them. */ +function filesInOrder(files: readonly InboxLiveFile[] | undefined) { + return (files ?? []) + .toSorted((one, other) => (one.position ?? Infinity) - (other.position ?? Infinity)) + .map(({ attachmentId, name, mediaType }) => ({ attachmentId, name, mediaType })); +} + +/** A message as the wire carries it, the member named where their account stands, no files where it carried none, and its ask as it stands on the item, as the held read writes it. */ +function inboxMessageOfLiveRow( + row: InboxLiveMessage, + filesOf: ReadonlyMap, + item: InboxAskStanding, +): InboxMessage { + const files = filesInOrder(filesOf.get(row.id)); + return { + id: row.id, + author: row.author, + ...(row.userId === null || row.name === null + ? undefined + : { member: { userId: row.userId, name: row.name } }), + text: row.text, + ...(files.length === 0 ? undefined : { files }), + ...(row.ask === null ? undefined : { ask: standingAsk(row.ask, row.id, item) }), + ...(row.answers === null ? undefined : { answers: row.answers }), + createdAt: row.createdAt, + }; +} + +/** + * An item's two threads from its delivered messages, oldest first, as the + * held read carries them: the latest `MAX_MESSAGES_READ` that neither ask + * nor answer, and the whole thread, its latest messages with its first and + * the one whose ask it holds open however many came since. + */ +function threadsOf(messages: readonly InboxLiveMessage[], openAskMessageId: string | null) { + const ordered = messages.toSorted(threadOrder); + const bound = INBOX_BOUNDS.MAX_MESSAGES_READ; + const plain = ordered.filter((each) => each.ask === null && each.answers === null).slice(-bound); + const latest = new Set(ordered.slice(-bound).map((each) => each.id)); + const firstId = ordered[0]?.id; + const thread = ordered.filter( + (each) => latest.has(each.id) || each.id === firstId || each.id === openAskMessageId, + ); + return { plain, thread }; +} + +/** A thread's messages oldest first, as the held read orders them. */ +function threadOrder(one: InboxLiveMessage, other: InboxLiveMessage): number { + if (one.createdAt !== other.createdAt) return one.createdAt - other.createdAt; + return one.id < other.id ? -1 : one.id > other.id ? 1 : 0; +} + +/** A row as the wire carries its item, its threads and files joined to it, each absent field left out rather than null, as the held read writes it. */ +function inboxItemOfLiveRow( + row: InboxLiveRow, + threads: ReadonlyMap, + answerFilesOf: ReadonlyMap, + messageFilesOf: ReadonlyMap, +): InboxItem { + const { + startedBy, + startedByName, + answer, + answeredBy, + answeredByName, + closedReason, + closedBy, + closedByName, + openAskMessageId, + settledAt, + ...item + } = row; + const answerFiles = filesInOrder(answerFilesOf.get(row.id)); + const { plain, thread } = threadsOf(threads.get(row.id) ?? [], openAskMessageId); + const message = (each: InboxLiveMessage) => + inboxMessageOfLiveRow(each, messageFilesOf, { + status: row.status, + response: row.response, + openAskMessageId, + closedReason, + }); + return { + ...item, + ...(startedByName === null + ? undefined + : { startedBy: { userId: startedBy, name: startedByName } }), + ...(answer === null ? undefined : { answer }), + ...(answerFiles.length === 0 ? undefined : { answerFiles }), + ...(answeredBy === null || answeredByName === null + ? undefined + : { answeredBy: { userId: answeredBy, name: answeredByName } }), + ...(closedReason === null ? undefined : { closedReason }), + ...(closedBy === null || closedByName === null + ? undefined + : { closedBy: { userId: closedBy, name: closedByName } }), + ...(plain.length === 0 ? undefined : { messages: plain.map(message) }), + ...(thread.length === 0 ? undefined : { thread: thread.map(message) }), + ...(settledAt === null ? undefined : { settledAt }), + }; +} + +/** The scope's items as the held read answers them: every open one, then the latest settled, each the latest changed first. */ +export function inboxOfLiveRows(rows: InboxLiveRows): readonly InboxItem[] { + const ordered = latestFirst((item: InboxItem) => item.id); + const threads = groupedBy(rows.messages, (message) => message.itemId); + const answerFilesOf = groupedBy(rows.files, (file) => file.answerOf); + const messageFilesOf = groupedBy(rows.files, (file) => file.messageId); + const items = rows.items.map((row) => + inboxItemOfLiveRow(row, threads, answerFilesOf, messageFilesOf), + ); + return [ + ...items.filter((item) => item.status === INBOX_STATUS.OPEN).toSorted(ordered), + ...items.filter((item) => item.status !== INBOX_STATUS.OPEN).toSorted(ordered), + ]; +} diff --git a/packages/features/src/service-paths.ts b/packages/features/src/service-paths.ts index 6b66c6c9a4..a17549772b 100644 --- a/packages/features/src/service-paths.ts +++ b/packages/features/src/service-paths.ts @@ -71,6 +71,10 @@ export const HOSTED_SERVICE_PATH = { * Realtime. `live-wire.ts` declares the answer. */ LIVE_INBOX: "/api/live/inbox", + /** One task's inbox under `?id=`, sealed for following over Neon Realtime (GET): its open and latest settled items, or `not-found` or `unavailable`. */ + LIVE_TASK_INBOX: "/api/live/task-inbox", + /** One agent's inbox under `?id=`, sealed for following over Neon Realtime (GET), as a task's is. */ + LIVE_AGENT_INBOX: "/api/live/agent-inbox", /** * A workspace's agents, on a task or with none, newest started first and * the archived ones left out unless the query asks for them, narrowed to @@ -351,24 +355,6 @@ export function taskAgentsPath(taskId: string): string { return `${taskPath(taskId)}/agents`; } -/** - * One task's inbox: every item its coding agents posted for the developer, - * past a cursor (GET), held open while nothing changed, as an agent's - * transcript is. `inbox-wire.ts` declares the answer. - */ -export function taskInboxPath(taskId: string, after?: string): string { - const path = `${taskPath(taskId)}/inbox`; - if (after === undefined) return path; - const query = new URLSearchParams({ after }); - return `${path}?${query}`; -} - -/** One agent's inbox past a cursor (GET), held open while nothing changed as a task's is, an agent with no task as much as one on a task; the same answer as `taskInboxPath`'s read. */ -export function agentInboxPath(agentId: string, after: string): string { - const query = new URLSearchParams({ after }); - return `/api/agents/${encodeURIComponent(agentId)}/inbox?${query}`; -} - /** One item of a task's inbox, answered by the developer (POST) against the revision they saw; `inbox-wire.ts` declares the request and the answer. */ export function inboxItemPath(itemId: string): string { return `/api/inbox/${encodeURIComponent(itemId)}`; diff --git a/packages/host/src/account-inbox-follow.ts b/packages/host/src/account-inbox-follow.ts index 07dc35c414..740050ac6b 100644 --- a/packages/host/src/account-inbox-follow.ts +++ b/packages/host/src/account-inbox-follow.ts @@ -5,65 +5,28 @@ import { accountInboxLiveRowSchemas, accountInboxOfLiveRows, nextFailedRunExpiry, - type SealedLiveQueryWire, } from "@luke/features/live-wire"; -import { Cause, Data, Duration, Effect, Option, Queue, Schema, Stream } from "effect"; +import { Duration, Effect, Option, Schema, Stream } from "effect"; +import { LiveBroken, type LiveClient, liveRows } from "./live-follow.js"; /** * account-inbox-follow.ts -- what waits on the developer across the account, followed over Neon Realtime as Neon's guide says to. * * The sealed statements are fetched from the service ("Fetch from an API - * endpoint"), subscribed to at the address the answer names ("Subscribe to - * the sealed query"), and renewed ten seconds before they expire, a failed - * fetch asked again a second later ("Renew the sealed query"). Every part's - * rows are decoded where they arrive, and once every part has its first - * result each change publishes the account inbox's answer whole, as the held - * read answered it. The sealed statements stay here, where the account's own - * bearer is: only the answer built from the rows is published. + * endpoint") and followed (`live-follow.ts`); every part's rows are decoded + * where they arrive, and each change publishes the account inbox's answer + * whole, as the held read answered it, and again as a failed run ages out + * of the last week with no row moving. The sealed statements stay here, + * where the account's own bearer is: only the answer built from the rows is + * published. * * A seal the service did not answer is asked for again, and a subscription * that stops for good started again, after `LIVE_RESTART`. */ -/** How long before a sealed statement expires it is renewed, as Neon's own bindings renew it. */ -const RENEW_EARLY = Duration.seconds(10); -/** How soon a renewal whose fetch failed is asked again, as Neon's own bindings ask. */ -const RENEW_RETRY = Duration.seconds(1); /** How long after a seal went unanswered, or a subscription stopped for good, it is asked again. */ const LIVE_RESTART = Duration.seconds(5); -/** How often a follower holding no failed run looks again for one to age out, since one may arrive meanwhile. */ -const EXPIRY_RECHECK = Duration.hours(1); - -/** A subscription that stopped for good: Realtime said so, or a row did not decode. */ -class LiveBroken extends Data.TaggedError("LiveBroken")<{ readonly reason: string }> {} - -/** One part's subscription, the slice of Realtime's client this reads. */ -interface LiveSubscription { - onChange( - listener: (snapshot: { - readonly status: string; - readonly data: readonly unknown[] | undefined; - }) => void, - ): () => void; - renew(query: SealedLiveQueryWire): Promise; - unsubscribe(): void; -} - -/** One socket to Realtime, every part's subscription on it. */ -export interface LiveClient { - subscribe(query: SealedLiveQueryWire): LiveSubscription; - close(): void; -} - -/** - * Realtime's own client, at the address the service named, loaded only when - * the account is first followed, so its protocol's validators stay out of the - * web app's first load. - */ -export const openRealtimeClient = (url: string): Promise => - import("@neon/realtime/client").then(({ createRealtimeClient }) => createRealtimeClient({ url })); - export interface AccountInboxFollowDependencies { /** The sealed statements, fetched from the service; a failure where it answered none. */ readonly live: () => Effect.Effect; @@ -75,168 +38,61 @@ export interface AccountInboxFollowDependencies { readonly publish: (answer: AccountInboxAnswer) => void; } -/** The earliest any of the answer's sealed statements expires, in epoch milliseconds. */ -function earliestExpiry(answer: AccountInboxLiveAnswer): number { - return Math.min(...Object.values(answer.queries).map((query) => query.expiresAt)); -} - -/** The rows as every part last delivered them, or nothing before each has its first result. */ -interface PartRows { - items?: AccountInboxLiveRows["items"]; - planless?: AccountInboxLiveRows["planless"]; - plans?: AccountInboxLiveRows["plans"]; - automations?: AccountInboxLiveRows["automations"]; - failedRuns?: AccountInboxLiveRows["failedRuns"]; - chatAsks?: AccountInboxLiveRows["chatAsks"]; - chatNotices?: AccountInboxLiveRows["chatNotices"]; -} +/** Every part's rows as the desktop decodes them, at this boundary. */ +const LiveRowsSchema = Schema.Struct({ + items: Schema.Array(accountInboxLiveRowSchemas.items), + planless: Schema.Array(accountInboxLiveRowSchemas.planless), + plans: Schema.Array(accountInboxLiveRowSchemas.plans), + automations: Schema.Array(accountInboxLiveRowSchemas.automations), + failedRuns: Schema.Array(accountInboxLiveRowSchemas.failedRuns), + chatAsks: Schema.Array(accountInboxLiveRowSchemas.chatAsks), + chatNotices: Schema.Array(accountInboxLiveRowSchemas.chatNotices), +}); -function whole(rows: PartRows): Option.Option { - const { items, planless, plans, automations, failedRuns, chatAsks, chatNotices } = rows; - return items && planless && plans && automations && failedRuns && chatAsks && chatNotices - ? Option.some({ items, planless, plans, automations, failedRuns, chatAsks, chatNotices }) - : Option.none(); +/** + * The answers one delivery of rows makes: the answer now, then again each + * time one of its failed runs ages out of the last week, until the next + * delivery takes its place. + */ +function answersOf( + dependencies: AccountInboxFollowDependencies, + read: AccountInboxLiveRows, +): Stream.Stream { + const answer = () => accountInboxOfLiveRows(read, dependencies.now()); + return Stream.unfold(true, (first) => { + if (first) return Effect.succeed([answer(), false] as const); + const now = dependencies.now(); + const next = nextFailedRunExpiry(read.failedRuns, now); + return next === undefined + ? Effect.succeed(undefined) + : Effect.andThen( + Effect.sleep(Duration.millis(next - now + 1)), + Effect.sync(() => [answer(), false] as const), + ); + }); } /** * The answers the live rows make, for as long as the subscriptions stand: - * each part subscribed to, its rows decoded as they arrive, and every seal - * renewed before it expires. It fails where a subscription stops for good. + * each part's rows decoded as they arrive. It fails where a subscription + * stops for good or a row does not decode. */ function liveAnswers( dependencies: AccountInboxFollowDependencies, first: AccountInboxLiveAnswer, ): Stream.Stream { - return Stream.callback((queue) => - Effect.gen(function* () { - const client = yield* Effect.acquireRelease( - Effect.tryPromise({ - try: async () => dependencies.openLive(first.url), - catch: () => new LiveBroken({ reason: "Realtime's client did not load" }), - }), - (opened) => Effect.sync(() => opened.close()), - ); - const rows: PartRows = {}; - const broken = (reason: string) => - Queue.failCauseUnsafe(queue, Cause.fail(new LiveBroken({ reason }))); - const changed = () => { - const read = whole(rows); - if (Option.isSome(read)) - Queue.offerUnsafe(queue, accountInboxOfLiveRows(read.value, dependencies.now())); - }; - /** One part followed: its rows decoded as they arrive and kept under its key. */ - const follow = ( - query: SealedLiveQueryWire, - schema: Schema.Codec, - keep: (decoded: readonly Row[]) => void, - ): LiveSubscription => { - const subscription = client.subscribe(query); - const decode = Schema.decodeUnknownOption(Schema.Array(schema)); - subscription.onChange((snapshot) => { - if (snapshot.status === "error") { - broken("the subscription stopped"); - return; - } - if (snapshot.data === undefined) return; - const decoded = decode(snapshot.data); - if (Option.isNone(decoded)) { - broken("a row did not decode"); - return; - } - keep(decoded.value); - changed(); - }); - return subscription; - }; - const subscriptions = yield* Effect.acquireRelease( - Effect.sync(() => ({ - items: follow(first.queries.items, accountInboxLiveRowSchemas.items, (read) => { - rows.items = read; - }), - planless: follow(first.queries.planless, accountInboxLiveRowSchemas.planless, (read) => { - rows.planless = read; - }), - plans: follow(first.queries.plans, accountInboxLiveRowSchemas.plans, (read) => { - rows.plans = read; - }), - automations: follow( - first.queries.automations, - accountInboxLiveRowSchemas.automations, - (read) => { - rows.automations = read; - }, - ), - failedRuns: follow( - first.queries.failedRuns, - accountInboxLiveRowSchemas.failedRuns, - (read) => { - rows.failedRuns = read; - }, - ), - chatAsks: follow(first.queries.chatAsks, accountInboxLiveRowSchemas.chatAsks, (read) => { - rows.chatAsks = read; - }), - chatNotices: follow( - first.queries.chatNotices, - accountInboxLiveRowSchemas.chatNotices, - (read) => { - rows.chatNotices = read; - }, - ), - })), - (standing) => - Effect.sync(() => { - for (const subscription of Object.values(standing)) subscription.unsubscribe(); - }), - ); - // A failed run ages out of the last week with no row moving, so the answer is published again as it does. - yield* Effect.forkScoped( - Effect.forever( - Effect.suspend(() => { - const now = dependencies.now(); - const next = - rows.failedRuns === undefined ? undefined : nextFailedRunExpiry(rows.failedRuns, now); - return next === undefined - ? Effect.sleep(EXPIRY_RECHECK) - : Effect.andThen(Effect.sleep(Duration.millis(next - now + 1)), Effect.sync(changed)); - }), - ), - ); - let expiresAt = earliestExpiry(first); - const renewal = Effect.gen(function* () { - const wait = expiresAt - Duration.toMillis(RENEW_EARLY) - dependencies.now(); - yield* Effect.sleep(Duration.millis(Math.max(0, wait))); - let next = yield* dependencies.live(); - while ("failure" in next) { - yield* Effect.sleep(RENEW_RETRY); - next = yield* dependencies.live(); - } - const renewed = next; - yield* Effect.forEach( - [ - "items", - "planless", - "plans", - "automations", - "failedRuns", - "chatAsks", - "chatNotices", - ] as const, - (part) => - Effect.tryPromise({ - try: () => subscriptions[part].renew(renewed.queries[part]), - catch: () => new LiveBroken({ reason: "a renewal was refused" }), - }), - { discard: true }, - ); - expiresAt = earliestExpiry(renewed); - }); - yield* Effect.forkScoped( - Effect.forever(renewal).pipe( - Effect.catch((failure) => Effect.sync(() => broken(failure.reason))), - ), - ); - }), + const decode = Schema.decodeUnknownOption(LiveRowsSchema); + return liveRows( + { openLive: dependencies.openLive, reseal: dependencies.live, now: dependencies.now }, + first, + ).pipe( + Stream.mapEffect((rows) => + Option.match(decode(rows), { + onNone: () => Effect.fail(new LiveBroken({ reason: "a row did not decode" })), + onSome: (read) => Effect.succeed(read), + }), + ), + Stream.switchMap((read) => answersOf(dependencies, read)), ); } diff --git a/packages/host/src/compose-coding-agents.test.ts b/packages/host/src/compose-coding-agents.test.ts index 90a4b2dbe4..da9d2688b1 100644 --- a/packages/host/src/compose-coding-agents.test.ts +++ b/packages/host/src/compose-coding-agents.test.ts @@ -30,7 +30,7 @@ import { type InboxItem, } from "@luke/features/inbox-wire"; import { INTEGRATION_PROVIDER } from "@luke/features/integration-wire"; -import type { AccountInboxLiveAnswer } from "@luke/features/live-wire"; +import type { AccountInboxLiveAnswer, ScopeInboxLiveAnswer } from "@luke/features/live-wire"; import { MODEL_PROVIDER } from "@luke/features/models-wire"; import { DEFAULT_PLAN_TEMPLATE, @@ -53,12 +53,12 @@ import { type GatewayEventKind, type GatewayMethod, } from "@luke/gateway"; -import type { WireRecord, WireValue } from "@luke/wire"; +import { isRecord, isWireString, type WireRecord, type WireValue } from "@luke/wire"; import { noNetwork } from "@luke/wire/testing"; -import { Duration, Effect, Schema } from "effect"; +import { Duration, Effect, Fiber, Schema } from "effect"; import { TestClock } from "effect/testing"; -import type { LiveClient } from "./account-inbox-follow.js"; import { type CodingAgentClient, composeCodingAgents } from "./compose-coding-agents.js"; +import type { LiveClient } from "./live-follow.js"; /** One revision of the template as the service lists it. */ const TEMPLATE_REVISION: PlanTemplateRevisionLine = { @@ -225,6 +225,7 @@ const context = { client: { clientId: "desktop", role: GATEWAY_CLIENT_ROLE.OPERA function fakeService() { const asked: string[] = []; let live: AccountInboxLiveAnswer | undefined; + let scope: ScopeInboxLiveAnswer | { readonly failure: "not-found" } | undefined; let cursor = CODING_AGENT_CURSOR_START; const client: CodingAgentClient = { models: () => @@ -312,11 +313,6 @@ function fakeService() { asked.push(`page:${agentId}`); return { agent: PAGE }; }), - agentInbox: (agentId, after) => - Effect.sync(() => { - asked.push(`agent-inbox:${agentId}:${after}`); - return { items: [ITEM], cursor: "8" }; - }), pullRequest: (agentId) => Effect.sync(() => { asked.push(`pull-request:${agentId}`); @@ -418,11 +414,6 @@ function fakeService() { asked.push(`transcribe:${clip.audio}:${clip.keywords.join(",")}`); return { text: "Run the tests" }; }), - inbox: (taskId, after) => - Effect.sync(() => { - asked.push(`inbox:${taskId}:${after}`); - return { items: [ITEM], cursor: "7" }; - }), inboxAttachment: (itemId, attachmentId) => Effect.sync(() => { asked.push(`attachment:${itemId}:${attachmentId}`); @@ -509,6 +500,16 @@ function fakeService() { asked.push(`chat-attachment:${workspaceId}:${messageId}:${attachmentId}`); return { contentType: INBOX_IMAGE_TYPE.PNG, base64: "iVBORw0KGgo=" }; }), + taskInboxLive: (taskId) => + Effect.sync(() => { + asked.push(`plan-inbox-live:${taskId}`); + return scope ?? { failure: CODING_AGENT_CALL_FAILURE.UNANSWERED }; + }), + agentInboxLive: (agentId) => + Effect.sync(() => { + asked.push(`agent-inbox-live:${agentId}`); + return scope ?? { failure: CODING_AGENT_CALL_FAILURE.UNANSWERED }; + }), }; return { client, @@ -517,6 +518,10 @@ function fakeService() { seal: (next: AccountInboxLiveAnswer | undefined) => { live = next; }, + /** What the service seals for following a plan's or an agent's inbox, or that the account reaches none; none answers unanswered. */ + sealScope: (next: ScopeInboxLiveAnswer | { readonly failure: "not-found" } | undefined) => { + scope = next; + }, }; } @@ -551,6 +556,7 @@ function subject(options: { signedIn?: boolean; sendsNetwork?: boolean } = {}) { call, asked: service.asked, seal: service.seal, + sealScope: service.sealScope, realtime, inboxes, reset: composer.reset, @@ -747,7 +753,7 @@ it.effect("each method asks the service once and answers what it said, whole", ( agentId: AGENT_ID, after: INBOX_CURSOR_START, }), - { items: [ITEM], cursor: "8" }, + { failure: CODING_AGENT_CALL_FAILURE.UNANSWERED }, ); assert.deepEqual( yield* call(GATEWAY_METHOD.CODING_AGENTS_PULL_REQUEST, { agentId: AGENT_ID }), @@ -801,7 +807,7 @@ it.effect("each method asks the service once and answers what it said, whole", ( taskId: TASK_ID, after: INBOX_CURSOR_START, }), - { items: [ITEM], cursor: "7" }, + { failure: CODING_AGENT_CALL_FAILURE.UNANSWERED }, ); assert.deepEqual( yield* call(GATEWAY_METHOD.CODING_AGENTS_INBOX_ATTACHMENT, { @@ -925,7 +931,7 @@ it.effect("each method asks the service once and answers what it said, whole", ( `archive:${AGENT_ID}:true`, `pin:${AGENT_ID}:true`, `page:${AGENT_ID}`, - `agent-inbox:${AGENT_ID}:${INBOX_CURSOR_START}`, + `agent-inbox-live:${AGENT_ID}`, `pull-request:${AGENT_ID}`, `changes:${AGENT_ID}`, `changes:${AGENT_ID}#11`, @@ -936,7 +942,7 @@ it.effect("each method asks the service once and answers what it said, whole", ( `github-image:${AGENT_ID}:https://camo.githubusercontent.com/abc`, `pull-request-action:${AGENT_ID}#11:${PULL_REQUEST_ACTION.READY_FOR_REVIEW}`, `pull-request-viewed:${AGENT_ID}`, - `inbox:${TASK_ID}:${INBOX_CURSOR_START}`, + `plan-inbox-live:${TASK_ID}`, `attachment:${ITEM.id}:${ATTACHMENT_ID}`, `picture:${AGENT_ID}:${PICTURE_ID}:call-worker`, `answer:${ITEM.id}:2:${INBOX_REPLY.DECLINED}:Not before the freeze lifts.:answer-1`, @@ -1004,7 +1010,7 @@ it.effect( ); /** Every step the follower has to take, taken: it runs on a fiber of its own. */ -const settled = Effect.repeat(Effect.yieldNow, { times: 20 }); +const settled = Effect.repeat(Effect.yieldNow, { times: 100 }); /** A second item, changed after `WAITING`, which the answer lists first. */ const NEWER: AccountInboxEntry = { @@ -1181,6 +1187,16 @@ it.effect( assert.deepEqual(asked, ["account-inbox-live", "account-inbox-live"]); realtime.deliver("items", [{ id: "not an item" }]); + for (const part of [ + "planless", + "plans", + "automations", + "failedRuns", + "chatAsks", + "chatNotices", + ]) { + realtime.deliver(part, []); + } yield* settled; assert.deepEqual(realtime.closed, [SEALED.url]); yield* TestClock.adjust("5 seconds"); @@ -1220,3 +1236,159 @@ it.effect( assert.deepEqual(live.realtime.closed, [SEALED.url, SEALED.url]); }), ); + +const SEALED_SCOPE: ScopeInboxLiveAnswer = { + url: "wss://realtime.example/v1", + queries: { items: sealed("items"), messages: sealed("messages"), files: sealed("files") }, +}; + +/** That item as its row delivers it, what it leaves out held as null. */ +/** That item as a reader who has read it reads it live. */ +const LIVE_ITEM: InboxItem = { ...ITEM, unread: false }; + +const ITEM_ROW = { + ...LIVE_ITEM, + startedBy: "user-dana", + startedByName: null, + answer: null, + answeredBy: null, + answeredByName: null, + closedReason: null, + closedBy: null, + closedByName: null, + openAskMessageId: null, + settledAt: null, +}; + +it.effect( + "a plan's inbox is answered from its Realtime subscription: at once once its rows land, then as soon as they move past the reader's cursor, and unchanged when the hold ends", + () => + Effect.gen(function* () { + const { call, asked, sealScope, realtime } = yield* subject(); + sealScope(SEALED_SCOPE); + const first = yield* Effect.forkChild( + call(GATEWAY_METHOD.CODING_AGENTS_INBOX, { taskId: TASK_ID, after: INBOX_CURSOR_START }), + ); + yield* settled; + assert.deepEqual(realtime.opened, [SEALED_SCOPE.url]); + realtime.deliver("messages", []); + realtime.deliver("files", []); + realtime.deliver("items", [ITEM_ROW]); + yield* settled; + const answered = yield* Fiber.join(first); + assert.ok(isRecord(answered) && isWireString(answered.cursor)); + assert.deepEqual(answered.items, [LIVE_ITEM]); + + // Read again from that cursor, it holds until the items move. + const next = yield* Effect.forkChild( + call(GATEWAY_METHOD.CODING_AGENTS_INBOX, { taskId: TASK_ID, after: answered.cursor }), + ); + yield* settled; + const closed = { + ...ITEM_ROW, + revision: 3, + status: INBOX_STATUS.CLOSED, + closedReason: "Done", + }; + realtime.deliver("items", [closed]); + yield* settled; + const moved = yield* Fiber.join(next); + assert.ok(isRecord(moved) && isWireString(moved.cursor)); + assert.notEqual(moved.cursor, answered.cursor); + assert.deepEqual(moved.items, [ + { ...LIVE_ITEM, revision: 3, status: INBOX_STATUS.CLOSED, closedReason: "Done" }, + ]); + + const unchanged = yield* Effect.forkChild( + call(GATEWAY_METHOD.CODING_AGENTS_INBOX, { taskId: TASK_ID, after: moved.cursor }), + ); + yield* settled; + yield* TestClock.adjust("20 seconds"); + assert.deepEqual(yield* Fiber.join(unchanged), moved); + // One subscription served every read. + assert.deepEqual(asked, [`plan-inbox-live:${TASK_ID}`]); + assert.deepEqual(realtime.opened, [SEALED_SCOPE.url]); + }), +); + +it.effect( + "a plan's inbox read past its cursor wakes at once when the reader marks an item unread, though nothing else on it moved", + () => + Effect.gen(function* () { + const { call, sealScope, realtime } = yield* subject(); + sealScope(SEALED_SCOPE); + const first = yield* Effect.forkChild( + call(GATEWAY_METHOD.CODING_AGENTS_INBOX, { taskId: TASK_ID, after: INBOX_CURSOR_START }), + ); + yield* settled; + realtime.deliver("messages", []); + realtime.deliver("files", []); + realtime.deliver("items", [ITEM_ROW]); + yield* settled; + const answered = yield* Fiber.join(first); + assert.ok(isRecord(answered) && isWireString(answered.cursor)); + + const next = yield* Effect.forkChild( + call(GATEWAY_METHOD.CODING_AGENTS_INBOX, { taskId: TASK_ID, after: answered.cursor }), + ); + yield* settled; + realtime.deliver("items", [{ ...ITEM_ROW, unread: true }]); + yield* settled; + const moved = yield* Fiber.join(next); + assert.ok(isRecord(moved)); + assert.notEqual(moved.cursor, answered.cursor); + assert.deepEqual(moved.items, [{ ...LIVE_ITEM, unread: true }]); + }), +); + +it.effect( + "an agent's inbox the account does not reach is not found, one the service seals nothing for is unanswered, and either is asked for again on the next read", + () => + Effect.gen(function* () { + const { call, asked, sealScope, realtime } = yield* subject(); + const read = () => + call(GATEWAY_METHOD.CODING_AGENTS_AGENT_INBOX, { + agentId: AGENT_ID, + after: INBOX_CURSOR_START, + }); + sealScope({ failure: CODING_AGENT_CALL_FAILURE.NOT_FOUND }); + assert.deepEqual(yield* read(), { failure: CODING_AGENT_CALL_FAILURE.NOT_FOUND }); + sealScope(undefined); + assert.deepEqual(yield* read(), { failure: CODING_AGENT_CALL_FAILURE.UNANSWERED }); + assert.deepEqual(asked, [`agent-inbox-live:${AGENT_ID}`, `agent-inbox-live:${AGENT_ID}`]); + assert.deepEqual(realtime.opened, []); + }), +); + +it.effect( + "a plan's inbox whose subscription stops is unanswered, and the next read subscribes again", + () => + Effect.gen(function* () { + const { call, sealScope, realtime } = yield* subject(); + sealScope(SEALED_SCOPE); + const read = () => + Effect.forkChild( + call(GATEWAY_METHOD.CODING_AGENTS_INBOX, { taskId: TASK_ID, after: INBOX_CURSOR_START }), + ); + const broken = yield* read(); + yield* settled; + realtime.deliver("messages", []); + realtime.deliver("files", []); + realtime.deliver("items", [{ id: "not an item" }]); + yield* settled; + assert.deepEqual(yield* Fiber.join(broken), { + failure: CODING_AGENT_CALL_FAILURE.UNANSWERED, + }); + + const again = yield* read(); + yield* settled; + realtime.deliver("messages", []); + realtime.deliver("files", []); + realtime.deliver("items", [ITEM_ROW]); + yield* settled; + const answered = yield* Fiber.join(again); + assert.ok(isRecord(answered)); + assert.deepEqual(answered.items, [LIVE_ITEM]); + assert.deepEqual(realtime.opened, [SEALED_SCOPE.url, SEALED_SCOPE.url]); + }), +); diff --git a/packages/host/src/compose-coding-agents.ts b/packages/host/src/compose-coding-agents.ts index 016b0d860f..9097e5fbe9 100644 --- a/packages/host/src/compose-coding-agents.ts +++ b/packages/host/src/compose-coding-agents.ts @@ -74,13 +74,15 @@ import { } from "@luke/gateway"; import { unparsedWire } from "@luke/wire"; import { readEither } from "@luke/wire/effect"; -import { Effect, FiberHandle, Result, type Schema, type Scope } from "effect"; +import { Effect, FiberHandle, RcMap, Result, type Schema, type Scope } from "effect"; import * as HttpClient from "effect/http/HttpClient"; -import { followAccountInbox, type LiveClient, openRealtimeClient } from "./account-inbox-follow.js"; +import { followAccountInbox } from "./account-inbox-follow.js"; import type { AccountComposer } from "./compose-account.js"; import type { Composer } from "./composer.js"; import type { HostKernel } from "./host-kernel.js"; +import { type LiveClient, openRealtimeClient } from "./live-follow.js"; import type { RunMode } from "./run-mode.js"; +import { inboxPast, type ScopeInboxes, scopeInboxes } from "./scope-inbox-follow.js"; /** * compose-coding-agents.ts -- a task's coding agents, asked of the service on the window's behalf: the models, the account's default, its own provider keys, and its plan template, the agents, one started, one's transcript, one messaged, one's model changed, one stopped, what one published, a clip dictated into an agent's box heard as words, the task's inbox, one item of it answered, what waits across the account, and the workspace's chat, read and written. @@ -121,7 +123,6 @@ export type CodingAgentClient = Pick< | "pin" | "stop" | "agent" - | "agentInbox" | "pullRequest" | "changes" | "overviewPullRequest" @@ -140,7 +141,6 @@ export type CodingAgentClient = Pick< | "readPlanTemplateRevision" | "restorePlanTemplate" | "transcribeDictation" - | "inbox" | "answerInbox" | "closeInbox" | "messageInbox" @@ -148,6 +148,8 @@ export type CodingAgentClient = Pick< | "markInboxItemUnread" | "markInboxRead" | "accountInboxLive" + | "taskInboxLive" + | "agentInboxLive" | "inboxAttachment" | "agentPicture" | "chat" @@ -164,36 +166,58 @@ export interface CodingAgentsDependencies { } export interface CodingAgentsComposer extends Composer { - /** The account inbox let go, as a sign-out lets it go, whoever followed it. */ + /** The account inbox let go, and every plan's and agent's inbox subscription, as a sign-out lets them go. */ readonly reset: Effect.Effect; } const UNANSWERED = { failure: CODING_AGENT_CALL_FAILURE.UNANSWERED } as const; +/** Whether the service may be asked now: a run that sends, behind an open account gate. */ +function gateOf({ kernel, account }: CodingAgentsDependencies): () => boolean { + return () => kernel.runMode.sendsNetwork && account.capabilitiesActive(); +} + +/** The service asked behind the gate, over the host's own HTTP client. */ +function askedOf(dependencies: CodingAgentsDependencies, http: HttpClient.HttpClient) { + const gate = gateOf(dependencies); + return ( + call: Effect.Effect, + ): Effect.Effect => + gate() ? Effect.provideService(call, HttpClient.HttpClient, http) : Effect.succeed(UNANSWERED); +} + export function composeCodingAgents( dependencies: CodingAgentsDependencies, ): Effect.Effect { return Effect.gen(function* () { const http = yield* HttpClient.HttpClient; const follower = yield* FiberHandle.make(); - return composed(dependencies, http, follower); + const asked = askedOf(dependencies, http); + const { client } = dependencies; + const inboxes = yield* scopeInboxes({ + seal: (scope) => { + const [kind, id] = scope.split(":", 2); + return asked( + kind === "plan" ? client.taskInboxLive(id ?? "") : client.agentInboxLive(id ?? ""), + ); + }, + openLive: dependencies.openLive ?? openRealtimeClient, + now: dependencies.kernel.now, + }); + return composed(dependencies, http, follower, inboxes); }); } -/** The composer's one table, built from its seams; nothing here starts anything but the account inbox's follower, when main asks for it. */ +/** The composer's one table, built from its seams; nothing here starts anything but the account inbox's follower, when main asks for it, and a scope's inbox subscription, while a window reads it. */ function composed( dependencies: CodingAgentsDependencies, http: HttpClient.HttpClient, follower: FiberHandle.FiberHandle, + inboxes: ScopeInboxes, ): CodingAgentsComposer { - const { kernel, account, client } = dependencies; - const gate = () => kernel.runMode.sendsNetwork && account.capabilitiesActive(); - - /** The service asked behind the gate, over the host's own HTTP client. */ - const asked = ( - call: Effect.Effect, - ): Effect.Effect => - gate() ? Effect.provideService(call, HttpClient.HttpClient, http) : Effect.succeed(UNANSWERED); + const { kernel, client } = dependencies; + const gate = gateOf(dependencies); + const asked = askedOf(dependencies, http); /** The params read against the method's own schema, or the invalid-params refusal worded. */ const read = ( @@ -518,7 +542,7 @@ function composed( params, "reading a task's inbox names the task and a cursor", ); - const answer = yield* asked(client.inbox(taskId, after)); + const answer = gate() ? yield* inboxPast(inboxes, `plan:${taskId}`, after) : UNANSWERED; return carried(answer); }), [GATEWAY_METHOD.CODING_AGENTS_AGENT_INBOX]: (params) => @@ -528,7 +552,7 @@ function composed( params, "reading an agent's inbox names the agent and a cursor", ); - const answer = yield* asked(client.agentInbox(agentId, after)); + const answer = gate() ? yield* inboxPast(inboxes, `agent:${agentId}`, after) : UNANSWERED; return carried(answer); }), [GATEWAY_METHOD.CODING_AGENTS_INBOX_ATTACHMENT]: (params) => @@ -650,5 +674,10 @@ function composed( Effect.as(FiberHandle.clear(follower), {}), }; - return { methods, lifetime: Effect.void, reset: FiberHandle.clear(follower) }; + const reset = Effect.gen(function* () { + yield* FiberHandle.clear(follower); + for (const scope of yield* RcMap.keys(inboxes)) yield* RcMap.invalidate(inboxes, scope); + }); + + return { methods, lifetime: Effect.void, reset }; } diff --git a/packages/host/src/live-follow.ts b/packages/host/src/live-follow.ts new file mode 100644 index 0000000000..69faaaaba4 --- /dev/null +++ b/packages/host/src/live-follow.ts @@ -0,0 +1,147 @@ +import type { SealedLiveQueryWire } from "@luke/features/live-wire"; +import { Cause, Data, Duration, Effect, Queue, Stream } from "effect"; + +/** + * live-follow.ts -- a read the host follows over Neon Realtime, as Neon's guide says to: sealed statements subscribed to at the address the service names, and renewed before they expire. + * + * Each read is one or more parts, one sealed statement each, all on one + * socket ("Subscribe to the sealed query"). Once every part has its first + * result, every change of any part is the parts' rows whole, undecoded, for + * the caller to decode at this boundary. Every seal is renewed ten seconds + * before the earliest expires, a fetch that failed asked again a second + * later, as Neon's own bindings renew ("Renew the sealed query"). The sealed + * statements stay here, where the account's own bearer is; only what the + * caller builds from the rows leaves. + */ + +/** How long before a sealed statement expires it is renewed, as Neon's own bindings renew it. */ +const RENEW_EARLY = Duration.seconds(10); +/** How soon a renewal whose fetch failed is asked again, as Neon's own bindings ask. */ +const RENEW_RETRY = Duration.seconds(1); + +/** A subscription that stopped for good: Realtime's client did not load, Realtime said so, a renewal was refused, or a row did not decode. */ +export class LiveBroken extends Data.TaggedError("LiveBroken")<{ readonly reason: string }> {} + +/** One part's subscription, the slice of Realtime's client this reads. */ +interface LiveSubscription { + onChange( + listener: (snapshot: { + readonly status: string; + readonly data: readonly unknown[] | undefined; + }) => void, + ): () => void; + renew(query: SealedLiveQueryWire): Promise; + unsubscribe(): void; +} + +/** One socket to Realtime, every part's subscription on it. */ +export interface LiveClient { + subscribe(query: SealedLiveQueryWire): LiveSubscription; + close(): void; +} + +/** + * Realtime's own client, at the address the service named, loaded only when + * a read is first followed, so its protocol's validators stay out of the + * web app's first load. + */ +export const openRealtimeClient = (url: string): Promise => + import("@neon/realtime/client").then(({ createRealtimeClient }) => createRealtimeClient({ url })); + +/** A read sealed for following: where to subscribe, and each part's sealed statement. */ +export interface SealedRead { + readonly url: string; + readonly queries: Readonly>; +} + +export interface LiveFollowDependencies { + /** Realtime's client at the address the service named, loaded where it must be. */ + readonly openLive: (url: string) => LiveClient | PromiseLike; + /** The read sealed again, for renewing; a failure where the service answered none. */ + readonly reseal: () => Effect.Effect | { readonly failure: string }>; + /** Now, by the host's own clock, which renewals are timed by. */ + readonly now: () => number; +} + +/** The earliest any of the read's sealed statements expires, in epoch milliseconds. */ +function earliestExpiry(read: SealedRead): number { + return Math.min( + ...Object.values(read.queries).map((query) => query.expiresAt), + ); +} + +/** + * Every part's rows, whole and undecoded, each time any part changes once + * every part has its first result, for as long as the subscriptions stand. + * It fails where a subscription stops for good or a renewal is refused. + */ +export function liveRows( + dependencies: LiveFollowDependencies, + first: SealedRead, +): Stream.Stream>, LiveBroken> { + const parts = Object.keys(first.queries).filter((part): part is Part => part in first.queries); + return Stream.callback>, LiveBroken>((queue) => + Effect.gen(function* () { + const client = yield* Effect.acquireRelease( + Effect.tryPromise({ + try: async () => dependencies.openLive(first.url), + catch: () => new LiveBroken({ reason: "Realtime's client did not load" }), + }), + (opened) => Effect.sync(() => opened.close()), + ); + const rows = new Map(); + const broken = (reason: string) => + Queue.failCauseUnsafe(queue, Cause.fail(new LiveBroken({ reason }))); + const subscriptions = yield* Effect.acquireRelease( + Effect.sync(() => + parts.map((part) => { + const subscription = client.subscribe(first.queries[part]); + subscription.onChange((snapshot) => { + if (snapshot.status === "error") { + broken("the subscription stopped"); + return; + } + if (snapshot.data === undefined) return; + rows.set(part, snapshot.data); + if (rows.size < parts.length) return; + const whole = Object.fromEntries(rows); + // SAFETY: `rows` holds every part once its size is the parts', and only parts. + Queue.offerUnsafe(queue, whole as Readonly>); + }); + return [part, subscription] as const; + }), + ), + (standing) => + Effect.sync(() => { + for (const [, subscription] of standing) subscription.unsubscribe(); + }), + ); + let expiresAt = earliestExpiry(first); + const renewal = Effect.gen(function* () { + const wait = expiresAt - Duration.toMillis(RENEW_EARLY) - dependencies.now(); + yield* Effect.sleep(Duration.millis(Math.max(0, wait))); + let next = yield* dependencies.reseal(); + while ("failure" in next) { + yield* Effect.sleep(RENEW_RETRY); + next = yield* dependencies.reseal(); + } + const renewed = next; + yield* Effect.forEach( + subscriptions, + ([part, subscription]) => + Effect.tryPromise({ + try: () => subscription.renew(renewed.queries[part]), + catch: () => new LiveBroken({ reason: "a renewal was refused" }), + }), + { discard: true }, + ); + expiresAt = earliestExpiry(renewed); + }); + yield* Effect.forkScoped( + Effect.forever(renewal).pipe( + Effect.catch((failure) => Effect.sync(() => broken(failure.reason))), + ), + ); + }), + ); +} diff --git a/packages/host/src/scope-inbox-follow.ts b/packages/host/src/scope-inbox-follow.ts new file mode 100644 index 0000000000..51b2f439fc --- /dev/null +++ b/packages/host/src/scope-inbox-follow.ts @@ -0,0 +1,180 @@ +import { + CODING_AGENT_CALL_FAILURE, + type CodingAgentInboxAnswerView, +} from "@luke/features/coding-agent-view"; +import type { InboxItem } from "@luke/features/inbox-wire"; +import { + inboxOfLiveRows, + type ScopeInboxLiveAnswer, + scopeInboxLiveRowsSchema, +} from "@luke/features/live-wire"; +import { + Duration, + Effect, + Option, + RcMap, + Schema, + type Scope, + Stream, + SubscriptionRef, +} from "effect"; +import { Hex } from "effect/encoding"; +import { LiveBroken, type LiveClient, liveRows } from "./live-follow.js"; + +/** + * scope-inbox-follow.ts -- a plan's inbox, or one agent's, read by the window as before but answered from a Neon Realtime subscription the host holds while the window reads it. + * + * The window still asks with the cursor it was last answered, and is + * answered as soon as the inbox moves past it, or with the inbox unchanged + * once the hold ends, as the service's held read answered it. What changed + * is where the answer comes from: the host subscribes to the scope's sealed + * statements (`live-follow.ts`) the first time a window asks, keeps the + * items as each change lands, and lets the subscription go a while after + * the last read let go of it. Nothing asks the service on a clock. A scope + * the account does not reach is not found, as the held read said; a seal + * the service did not answer, or a subscription that stopped, is answered + * unanswered and let go, so the window's next read subscribes again. + */ + +/** How long a read is held while nothing moved past its cursor, as the service held it. */ +const HOLD = Duration.seconds(20); +/** How long a subscription no read holds is kept, so the window's next read finds it standing. */ +const IDLE = Duration.seconds(30); + +/** Which inbox: one plan's, or one agent's. */ +export type InboxScopeKey = `plan:${string}` | `agent:${string}`; + +/** Where a scope's items stand as the subscription last delivered them, and the cursor they digest to. */ +interface LiveInbox { + readonly items: readonly InboxItem[]; + readonly cursor: string; +} + +/** What a scope's read is answered from. */ +type Standing = + | { readonly kind: "live"; readonly inbox: SubscriptionRef.SubscriptionRef } + | { readonly kind: "refused"; readonly failure: CodingAgentCallFailure }; + +/** Why a scope's read is refused: not found, or unanswered. */ +type CodingAgentCallFailure = + | typeof CODING_AGENT_CALL_FAILURE.NOT_FOUND + | typeof CODING_AGENT_CALL_FAILURE.UNANSWERED; + +/** A subscription before its first rows, with them, or stopped for good. */ +type LiveState = + | { readonly kind: "waiting" } + | { readonly kind: "standing"; readonly inbox: LiveInbox } + | { readonly kind: "broken" }; + +/** + * A digest of where every item stands, and whether it is unread for the + * reader: any change to any of them, or the reader marking one read or + * unread, moves it, as the service's stamps do. Note that it is Web Crypto's + * SHA-256, since the host loads where no `node:` module does. + */ +function cursorOf(items: readonly InboxItem[]): Effect.Effect { + const standing = items + .map( + (item) => [item.id, item.revision, item.status, item.updatedAt, item.unread ?? null] as const, + ) + .toSorted(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); + return Effect.map( + Effect.promise(() => + crypto.subtle.digest("SHA-256", new TextEncoder().encode(JSON.stringify(standing))), + ), + (digest) => `live-${Hex.encode(new Uint8Array(digest)).slice(0, 32)}`, + ); +} + +export interface ScopeInboxDependencies { + /** The scope sealed for following; not found, or a failure where the service sealed none. */ + readonly seal: ( + scope: InboxScopeKey, + ) => Effect.Effect; + /** Realtime's client at the address the service named, loaded where it must be. */ + readonly openLive: (url: string) => LiveClient | PromiseLike; + readonly now: () => number; +} + +/** The subscriptions the windows' reads share, each held while read and a while after. */ +export type ScopeInboxes = RcMap.RcMap; + +/** The map a composer holds for as long as it stands. */ +export function scopeInboxes( + dependencies: ScopeInboxDependencies, +): Effect.Effect { + const decode = Schema.decodeUnknownOption(scopeInboxLiveRowsSchema); + return RcMap.make({ + idleTimeToLive: IDLE, + lookup: (scope: InboxScopeKey) => + Effect.gen(function* () { + const first = yield* dependencies.seal(scope); + if ("failure" in first) { + const failure = + first.failure === CODING_AGENT_CALL_FAILURE.NOT_FOUND + ? CODING_AGENT_CALL_FAILURE.NOT_FOUND + : CODING_AGENT_CALL_FAILURE.UNANSWERED; + return { kind: "refused", failure } as const; + } + const inbox = yield* SubscriptionRef.make({ kind: "waiting" }); + const rows = liveRows( + { + openLive: dependencies.openLive, + reseal: () => dependencies.seal(scope), + now: dependencies.now, + }, + first, + ); + yield* Effect.forkScoped( + Stream.runForEach(rows, (read) => + Option.match(decode(read), { + onNone: () => Effect.fail(new LiveBroken({ reason: "a row did not decode" })), + onSome: (decoded) => { + const items = inboxOfLiveRows(decoded); + return Effect.flatMap(cursorOf(items), (cursor) => + SubscriptionRef.set(inbox, { kind: "standing", inbox: { items, cursor } }), + ); + }, + }), + ).pipe(Effect.catch(() => SubscriptionRef.set(inbox, { kind: "broken" }))), + ); + return { kind: "live", inbox } as const; + }), + }); +} + +/** + * The scope's inbox past the reader's cursor: at once where it moved, or + * once it does within the hold, or unchanged once the hold ends. A scope + * refused, or whose subscription stopped or had no rows within the hold, + * is answered why and let go, so the next read subscribes again. + */ +export function inboxPast( + inboxes: ScopeInboxes, + scope: InboxScopeKey, + after: string, +): Effect.Effect { + return Effect.scoped( + Effect.gen(function* () { + const standing = yield* RcMap.get(inboxes, scope); + if (standing.kind === "refused") { + yield* RcMap.invalidate(inboxes, scope); + return { failure: standing.failure }; + } + const moved = yield* SubscriptionRef.changes(standing.inbox).pipe( + Stream.filter( + (state) => + state.kind === "broken" || (state.kind === "standing" && state.inbox.cursor !== after), + ), + Stream.runHead, + Effect.timeoutOption(HOLD), + Effect.map(Option.flatten), + ); + const state = Option.isSome(moved) ? moved.value : yield* SubscriptionRef.get(standing.inbox); + if (state.kind === "standing") + return { items: state.inbox.items, cursor: state.inbox.cursor }; + yield* RcMap.invalidate(inboxes, scope); + return { failure: CODING_AGENT_CALL_FAILURE.UNANSWERED }; + }), + ); +} diff --git a/packages/service/package.json b/packages/service/package.json index f7e05a92ca..6e88269f4e 100644 --- a/packages/service/package.json +++ b/packages/service/package.json @@ -219,6 +219,7 @@ "./testing/integration-connect": "./src/testing/integration-connect.js", "./testing/linux-shell-tools": "./src/testing/linux-shell-tools.js", "./testing/live-events": "./src/testing/live-events.js", + "./testing/live-wake": "./src/testing/live-wake.js", "./testing/task-contents": "./src/testing/task-contents.js", "./testing/plan-documents": "./src/testing/plan-documents.js", "./testing/repository-sandbox": "./src/testing/repository-sandbox.js", @@ -263,6 +264,7 @@ "@luke/voice": "workspace:*", "@luke/wire": "workspace:*", "@neon/realtime": "0.1.1", + "@neon/realtime-drizzle": "0.1.0", "@orpc/contract": "catalog:", "@orpc/experimental-effect": "catalog:", "@orpc/openapi": "catalog:", diff --git a/packages/service/src/agents/coder-host/bounds.ts b/packages/service/src/agents/coder-host/bounds.ts index e5622b2184..c3c42bc3b5 100644 --- a/packages/service/src/agents/coder-host/bounds.ts +++ b/packages/service/src/agents/coder-host/bounds.ts @@ -61,13 +61,6 @@ export const CODER = { MESSAGES_HOLD: Duration.seconds(20), /** How often a held transcript read looks for a new message. */ MESSAGES_POLL: Duration.millis(500), - /** - * How often a held read of what waits across the account looks for a - * change. Slower than `MESSAGES_POLL`, because a desktop holds that read - * for as long as it is signed in, and an item heard a moment later costs - * the developer nothing. - */ - ACCOUNT_INBOX_POLL: Duration.seconds(2), /** * How long a title call may take before its agent or worker goes untitled * (`title.ts`), which is also how long after a delegation a read of an diff --git a/packages/service/src/agents/coder-host/coder-host.test.ts b/packages/service/src/agents/coder-host/coder-host.test.ts index 667ef88f8c..28c6cd4028 100644 --- a/packages/service/src/agents/coder-host/coder-host.test.ts +++ b/packages/service/src/agents/coder-host/coder-host.test.ts @@ -60,6 +60,7 @@ import { modelCatalogOf, type OfferedModel } from "../../providers/model-catalog import { PICTURE_LOOK_STATUS } from "../../tasks/picture-look.js"; import { stampedEveEvent } from "../../testing/eve-events.js"; import { githubReaching, openGithubUser } from "../../testing/github-app-fake.js"; +import { quietLiveWake } from "../../testing/live-wake.js"; import { BRAIN_HOST_ATTRIBUTE, BRAIN_HOST_TURN } from "../brain-host/bounds.js"; import { memoryRelayState } from "../brain-host/relay.js"; import { CODER_REFUSAL } from "./bounds.js"; @@ -400,7 +401,7 @@ const turnRow = (id: string) => rows[0] === undefined ? undefined : Schema.decodeUnknownSync(TurnRowSchema)(rows[0]), ); -it.layer(Layer.mergeAll(getTestDB(), noNetwork))("the coding-agent host", (it) => { +it.layer(Layer.mergeAll(getTestDB(), noNetwork, quietLiveWake))("the coding-agent host", (it) => { it.effect( "a conversation that is no agent's is refused a model, an agent, and a prompt alike", () => diff --git a/packages/service/src/agents/coder-host/inbox-page.ts b/packages/service/src/agents/coder-host/inbox-page.ts index 8518f7a73d..b860dc1e9d 100644 --- a/packages/service/src/agents/coder-host/inbox-page.ts +++ b/packages/service/src/agents/coder-host/inbox-page.ts @@ -1,12 +1,17 @@ -import { ownFailedAutomationRuns } from "@luke/database/models/automation-store"; +import type { LiveStatement } from "@luke/database/core/live-statement"; +import { + ownFailedAutomationRuns, + ownFailedRunsLiveStatement, +} from "@luke/database/models/automation-store"; import type { AccountFailedRun, AccountInboxAnswer } from "@luke/features/account-inbox-wire"; import { AUTOMATION_RUN_STATUS } from "@luke/features/automation-wire"; import type { InboxAnswer } from "@luke/features/inbox-wire"; import { FAILED_RUN_WINDOW_MILLIS } from "@luke/features/live-wire"; -import { Clock, Duration, Effect } from "effect"; +import { Clock, Duration, Effect, Option } from "effect"; import { cursorOf, heldUntilMoved as heldOpen } from "../../http/held-read.js"; import { accountInboxAutomations, + accountInboxLiveStatements, accountInboxTasks, accountTasklessItems, accountWaitingItems, @@ -14,8 +19,10 @@ import { type InboxStamp, type InboxStoreEffect, memberAgentInboxItems, + memberAgentInboxLiveStatements, memberAgentInboxStamps, taskInboxItems, + taskInboxLiveStatements, taskInboxStamps, } from "../../inbox/inbox-store.js"; import { CODER } from "./bounds.js"; @@ -27,8 +34,8 @@ import { chatWaitingEntries, chatWaitingStamps } from "./chat-waiting.js"; * The cursor is a digest of where every item stands (`http/held-read.ts`), * so a post, a close, an answer, and a new item each move it, and a reader * that holds it hears the next change once. A read whose cursor is - * still the inbox's is held, looked at again every `MESSAGES_POLL`, each - * look reading where the items stand and none of their words, and let + * still the inbox's is held, looked at again as Realtime says its rows + * changed (`http/held-read.ts`), each look reading where the items stand and none of their words, and let * go at `MESSAGES_HOLD` as a transcript read is (`transcript.ts`), so the * Inbox tab hears an item the moment it lands without asking on a clock of * its own; a read past the hold answers the inbox unchanged, and the reader @@ -40,9 +47,7 @@ import { chatWaitingEntries, chatWaitingStamps } from "./chat-waiting.js"; * an item too, settled ones included, read once per answer rather than on * each look: a task's list moves with its open items, since every item * opens before it settles, and what moves it alone, such as an archive, is - * answered at the hold's end at the latest. Note that it looks every - * `ACCOUNT_INBOX_POLL` rather than `MESSAGES_POLL`, because a desktop holds - * it for as long as it is signed in rather than while a tab is on screen. + * answered at the hold's end at the latest. * A reader that asks is listed the archived tasks holding items beside the * rest, though none of their items waits on it. * The open items of agents with no task, and the automations holding them, @@ -68,9 +73,14 @@ type InboxCursorStamp = Pick & { function heldUntilMoved( stamps: InboxStoreEffect, after: string, - poll: Duration.Duration, + live: Option.Option, ): InboxStoreEffect { - return heldOpen(stamps, after, poll, CODER.MESSAGES_HOLD); + return heldOpen( + stamps, + after, + CODER.MESSAGES_HOLD, + Option.getOrElse(live, () => []), + ); } /** The newest run of each of the account's own automations that failed to start in the window, as its owner is told of it. */ @@ -99,7 +109,11 @@ export function agentInboxPast( agentId: string, after: string, ): InboxStoreEffect { - return heldUntilMoved(memberAgentInboxStamps(userId, agentId), after, CODER.MESSAGES_POLL).pipe( + return heldUntilMoved( + memberAgentInboxStamps(userId, agentId), + after, + Option.map(memberAgentInboxLiveStatements(userId, agentId), ({ items }) => [items]), + ).pipe( Effect.andThen(memberAgentInboxItems(userId, agentId)), Effect.map((items) => ({ items, cursor: cursorOf(items) })), ); @@ -111,7 +125,11 @@ export function taskInboxPast( taskId: string, after: string, ): InboxStoreEffect { - return heldUntilMoved(taskInboxStamps(userId, taskId), after, CODER.MESSAGES_POLL).pipe( + return heldUntilMoved( + taskInboxStamps(userId, taskId), + after, + Option.map(taskInboxLiveStatements(userId, taskId), ({ items }) => [items]), + ).pipe( Effect.andThen(taskInboxItems(userId, taskId)), Effect.map((items) => ({ items, cursor: cursorOf(items) })), ); @@ -161,7 +179,18 @@ export function accountInboxPast( readAt, }; }); - return heldUntilMoved(accountStamps(userId), after, CODER.ACCOUNT_INBOX_POLL).pipe( - Effect.andThen(read), + const live = Option.zipWith( + accountInboxLiveStatements(userId), + ownFailedRunsLiveStatement(userId), + (parts, failedRuns) => [ + parts.items, + parts.planless, + parts.plans, + parts.automations, + failedRuns, + parts.chatAsks, + parts.chatNotices, + ], ); + return heldUntilMoved(accountStamps(userId), after, live).pipe(Effect.andThen(read)); } diff --git a/packages/service/src/agents/coder-host/inbox-store.test.ts b/packages/service/src/agents/coder-host/inbox-store.test.ts index adb9dea67b..de0f4204f6 100644 --- a/packages/service/src/agents/coder-host/inbox-store.test.ts +++ b/packages/service/src/agents/coder-host/inbox-store.test.ts @@ -47,10 +47,15 @@ import { type InboxImageBlock, type InboxPost, } from "@luke/features/inbox-wire"; -import { accountInboxLiveRowSchemas, accountInboxOfLiveRows } from "@luke/features/live-wire"; +import { + accountInboxLiveRowSchemas, + accountInboxOfLiveRows, + inboxOfLiveRows, + scopeInboxLiveRowsSchema, +} from "@luke/features/live-wire"; import { EXPERIMENT } from "@luke/settings"; import { eq } from "drizzle-orm"; -import { DateTime, Duration, Effect, Option, Result, Schema } from "effect"; +import { DateTime, Duration, Effect, Layer, Option, Result, Schema } from "effect"; import { SqlClient } from "effect/sql"; import { TestClock } from "effect/testing"; import { @@ -66,16 +71,20 @@ import { markInboxItemRead, markInboxItemUnread, markInboxRead, + memberAgentInboxItems, + memberAgentInboxLiveStatements, postInboxItem, postInboxMessage, readInboxAttachment, readInboxItem, replyInboxItem, taskInboxItems, + taskInboxLiveStatements, withdrawInboxAnswer, withdrawInboxClose, withdrawInboxMessage, } from "../../inbox/inbox-store.js"; +import { quietLiveWake } from "../../testing/live-wake.js"; import { accountInboxPast, taskInboxPast } from "./inbox-page.js"; /** @@ -210,7 +219,82 @@ const liveAnswerOf = (userId: string, readAt: number) => ); }); -it.layer(getTestDB())("the inbox store", (it) => { +it.layer(Layer.merge(getTestDB(), quietLiveWake))("the inbox store", (it) => { + it.effect( + "the live statements of a plan's inbox and an agent's deliver the items their held reads answer, open, answered, and closed alike, to a member and to no one else", + () => + Effect.gen(function* () { + const { agent, workspaceId } = yield* openAgentIn; + const teammate = yield* openUser; + yield* joinWorkspace(workspaceId, teammate); + const outsider = yield* openUser; + yield* openWorkspace(outsider); + const posted = yield* postInboxItem(agent, SETTINGS); + assert.ok(Result.isSuccess(posted)); + const open = yield* postInboxItem(agent, { ...SETTINGS, key: "open" }); + assert.ok(Result.isSuccess(open)); + assert.ok(Result.isSuccess(yield* postInboxItem(agent, { ...SETTINGS, key: "closed" }))); + assert.ok( + Option.isSome( + yield* answerInboxItem(teammate, posted.success.id, 1, DONE, randomUUID(), [ + attached("first.txt", "text/plain", TRACE), + attached("second.txt", "text/plain", TRACE), + ]), + ), + ); + assert.ok(Option.isSome(yield* closeInboxItem(agent, "closed", "Not needed after all"))); + // The teammate's message on the open item, with a file, leaves it read for them and unread for its starter. + assert.ok( + Option.isSome( + yield* postInboxMessage(teammate, open.success.id, { + text: "Here is the trace.", + clientKey: randomUUID(), + files: [attached("trace.txt", "text/plain", TRACE)], + }), + ), + ); + + for (const reader of [agent.userId, teammate, outsider]) { + const plan = Option.getOrThrow(taskInboxLiveStatements(reader, agent.taskId ?? "")); + const ofAgent = Option.getOrThrow(memberAgentInboxLiveStatements(reader, agent.agentId)); + const liveOf = (statements: typeof plan) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const rows = yield* Effect.all({ + items: sql.unsafe(statements.items.sql, [...statements.items.params]), + messages: sql.unsafe(statements.messages.sql, [...statements.messages.params]), + files: sql.unsafe(statements.files.sql, [...statements.files.params]), + }); + return inboxOfLiveRows( + yield* Schema.decodeUnknownEffect(scopeInboxLiveRowsSchema)(rows), + ); + }); + assert.deepEqual(yield* liveOf(plan), yield* taskInboxItems(reader, agent.taskId ?? "")); + assert.deepEqual( + yield* liveOf(ofAgent), + yield* memberAgentInboxItems(reader, agent.agentId), + ); + } + const read = yield* taskInboxItems(teammate, agent.taskId ?? ""); + const openOf = (items: typeof read) => items.find((item) => item.id === open.success.id); + assert.equal(openOf(read)?.unread, false, "the teammate wrote on it last"); + assert.equal(openOf(yield* taskInboxItems(agent.userId, agent.taskId ?? ""))?.unread, true); + assert.deepEqual( + openOf(read)?.messages?.map((message) => [message.text, message.files?.length]), + [["Here is the trace.", 1]], + ); + assert.deepEqual( + read.find((item) => item.id === posted.success.id)?.answerFiles?.map((file) => file.name), + ["first.txt", "second.txt"], + ); + assert.deepEqual(read.map((item) => item.status).toSorted(), [ + INBOX_STATUS.ANSWERED, + INBOX_STATUS.CLOSED, + INBOX_STATUS.OPEN, + ]); + }), + ); + it.effect( "the live statements of the account's read deliver the rows its held read answers, under the keys the wire reads them by, to every member it answers and to no one else", () => @@ -992,7 +1076,7 @@ const postedId = (agent: InboxAgent, key: string) => return posted.success.id; }); -it.layer(getTestDB())("an inbox item's read state", (it) => { +it.layer(Layer.merge(getTestDB(), quietLiveWake))("an inbox item's read state", (it) => { it.effect( "a new item is unread for every member until each reads it, and unread again for a reader once anything happens on it after their read", () => diff --git a/packages/service/src/agents/coding-agents-app.test.ts b/packages/service/src/agents/coding-agents-app.test.ts index 2d3b53abf5..e6327ae1fe 100644 --- a/packages/service/src/agents/coding-agents-app.test.ts +++ b/packages/service/src/agents/coding-agents-app.test.ts @@ -234,6 +234,7 @@ import { standingGithubRow, } from "../testing/github-app-fake.js"; import { CONNECTOR_ASKED_NOTHING, integrationConnectOf } from "../testing/integration-connect.js"; +import { quietLiveWake } from "../testing/live-wake.js"; import { fixtureOidcToken, SOURCE_SANDBOX, @@ -547,6 +548,7 @@ const standing = ( modelCatalogOf(CATALOG), integrationConnectOf(() => CONNECTOR_ASKED_NOTHING), ANALYTICS.layer, + quietLiveWake, ); // Every account behind a bearer is signed in on a browser of its own too, whose cookie names it. const sessions = new Map( @@ -835,7 +837,7 @@ function reachRequests(sent: readonly SentToGitHub[], installationId: number) { }; } -it.layer(getTestDB())("the coding-agent routes", (it) => { +it.layer(Layer.merge(getTestDB(), quietLiveWake))("the coding-agent routes", (it) => { it.effect( "a Start whose prompt mentions a page the store cannot read is refused before any agent is made, so a retry under its key starts afresh", () => @@ -1867,7 +1869,7 @@ function linesAnswering(pending: readonly PendingNode[]): (call: GraphqlCall) => return reviewsAnswering(pending); } -it.layer(getTestDB())("changing an agent's pull request", (it) => { +it.layer(Layer.merge(getTestDB(), quietLiveWake))("changing an agent's pull request", (it) => { /** The owner's plan with an agent that published a pull request, and the other account where the test makes it a member. */ const published = (options: { readonly answer?: (call: GraphqlCall) => Response; @@ -3026,196 +3028,204 @@ function changeAs(bearer: string, agentId: string, body: WireBoundaryInput): Req return request(AGENT_PULL_REQUEST_ACTION, bearer, { method: "POST", id: agentId, body }); } -it.layer(getTestDB())("asking reviews and labelling an agent's pull request", (it) => { - const published = (repository: RepositoryScript, otherToken = "ghu_teammate") => - Effect.gen(function* () { - const opened = yield* openTask({ github: repository, otherToken }); - yield* joinWorkspace(opened.task.workspaceId, opened.other); - const { agent } = yield* startedAgent(opened.owner, opened.task.id, opened.ask); - yield* updateCodingAgentBranch(opened.owner, agent.id, PUBLISHED.branch); - return { ...opened, agent }; - }); - - it.effect( - "a teammate asks a review of a team, asks one of a user no longer, puts a label on and takes one off, each a REST write on the teammate's own token", - () => +it.layer(Layer.merge(getTestDB(), quietLiveWake))( + "asking reviews and labelling an agent's pull request", + (it) => { + const published = (repository: RepositoryScript, otherToken = "ghu_teammate") => Effect.gen(function* () { - const writes: RestWrite[] = []; - const { other, agent, ask } = yield* published(peopleAndLabelsAnswering(writes)); - const pull = `/repos/${RELAY.owner}/${RELAY.name}/pulls/${PUBLISHED.number}`; - const issue = `/repos/${RELAY.owner}/${RELAY.name}/issues/${PUBLISHED.number}`; + const opened = yield* openTask({ github: repository, otherToken }); + yield* joinWorkspace(opened.task.workspaceId, opened.other); + const { agent } = yield* startedAgent(opened.owner, opened.task.id, opened.ask); + yield* updateCodingAgentBranch(opened.owner, agent.id, PUBLISHED.branch); + return { ...opened, agent }; + }); - const answers = [ - yield* ask( - changeAs(other, agent.id, { - action: "request-review", - reviewer: { kind: "team", name: "reviewers" }, - }), - ), - yield* ask( - changeAs(other, agent.id, { - action: "remove-review-request", - reviewer: { kind: "user", name: "priyan" }, - }), - ), - yield* ask(changeAs(other, agent.id, { action: "add-label", label: "bug" })), - yield* ask(changeAs(other, agent.id, { action: "remove-label", label: "needs review" })), - ]; + it.effect( + "a teammate asks a review of a team, asks one of a user no longer, puts a label on and takes one off, each a REST write on the teammate's own token", + () => + Effect.gen(function* () { + const writes: RestWrite[] = []; + const { other, agent, ask } = yield* published(peopleAndLabelsAnswering(writes)); + const pull = `/repos/${RELAY.owner}/${RELAY.name}/pulls/${PUBLISHED.number}`; + const issue = `/repos/${RELAY.owner}/${RELAY.name}/issues/${PUBLISHED.number}`; + + const answers = [ + yield* ask( + changeAs(other, agent.id, { + action: "request-review", + reviewer: { kind: "team", name: "reviewers" }, + }), + ), + yield* ask( + changeAs(other, agent.id, { + action: "remove-review-request", + reviewer: { kind: "user", name: "priyan" }, + }), + ), + yield* ask(changeAs(other, agent.id, { action: "add-label", label: "bug" })), + yield* ask( + changeAs(other, agent.id, { action: "remove-label", label: "needs review" }), + ), + ]; - assert.ok( - answers.every((answered) => answered.status === HOSTED_HTTP_STATUS.OK), - "every change is answered", - ); - assert.deepEqual( - answers.map((answered) => answered.body), - answers.map(() => ({ outcome: "done" })), - ); - assert.deepEqual( - writes.map(({ method, path, body }) => ({ method, path, body })), - [ - { - method: "POST", - path: `${pull}/requested_reviewers`, - body: { team_reviewers: ["reviewers"] }, - }, - { - method: "DELETE", - path: `${pull}/requested_reviewers`, - body: { reviewers: ["priyan"] }, - }, - { method: "POST", path: `${issue}/labels`, body: { labels: ["bug"] } }, - { method: "DELETE", path: `${issue}/labels/needs review`, body: undefined }, - ], - ); - assert.ok(writes.every((write) => write.authorization === "Bearer ghu_teammate")); - }), - ); + assert.ok( + answers.every((answered) => answered.status === HOSTED_HTTP_STATUS.OK), + "every change is answered", + ); + assert.deepEqual( + answers.map((answered) => answered.body), + answers.map(() => ({ outcome: "done" })), + ); + assert.deepEqual( + writes.map(({ method, path, body }) => ({ method, path, body })), + [ + { + method: "POST", + path: `${pull}/requested_reviewers`, + body: { team_reviewers: ["reviewers"] }, + }, + { + method: "DELETE", + path: `${pull}/requested_reviewers`, + body: { reviewers: ["priyan"] }, + }, + { method: "POST", path: `${issue}/labels`, body: { labels: ["bug"] } }, + { method: "DELETE", path: `${issue}/labels/needs review`, body: undefined }, + ], + ); + assert.ok(writes.every((write) => write.authorization === "Bearer ghu_teammate")); + }), + ); - it.effect( - "a review GitHub will not ask is answered in its words, and a reviewer the wire does not name is refused before GitHub", - () => - Effect.gen(function* () { - const writes: RestWrite[] = []; - const { other, agent, ask } = yield* published( - peopleAndLabelsAnswering(writes, () => - Response.json( - { message: "Reviews may only be requested from collaborators." }, - { status: 422 }, + it.effect( + "a review GitHub will not ask is answered in its words, and a reviewer the wire does not name is refused before GitHub", + () => + Effect.gen(function* () { + const writes: RestWrite[] = []; + const { other, agent, ask } = yield* published( + peopleAndLabelsAnswering(writes, () => + Response.json( + { message: "Reviews may only be requested from collaborators." }, + { status: 422 }, + ), ), - ), - ); + ); - assert.deepEqual( - yield* ask( - changeAs(other, agent.id, { - action: "request-review", - reviewer: { kind: "user", name: "stranger" }, - }), - ), - { - status: HOSTED_HTTP_STATUS.OK, - body: { - outcome: "refused", - words: "Reviews may only be requested from collaborators.", + assert.deepEqual( + yield* ask( + changeAs(other, agent.id, { + action: "request-review", + reviewer: { kind: "user", name: "stranger" }, + }), + ), + { + status: HOSTED_HTTP_STATUS.OK, + body: { + outcome: "refused", + words: "Reviews may only be requested from collaborators.", + }, }, - }, - ); - assert.deepEqual( - yield* ask( - changeAs(other, agent.id, { - action: "request-review", - reviewer: { kind: "everyone", name: "all" }, - }), - ), - refusal(HOSTED_HTTP_STATUS.BAD_REQUEST, HOSTED_API_ERROR.INVALID_REQUEST), - ); - assert.equal(writes.length, 1); - }), - ); -}); + ); + assert.deepEqual( + yield* ask( + changeAs(other, agent.id, { + action: "request-review", + reviewer: { kind: "everyone", name: "all" }, + }), + ), + refusal(HOSTED_HTTP_STATUS.BAD_REQUEST, HOSTED_API_ERROR.INVALID_REQUEST), + ); + assert.equal(writes.length, 1); + }), + ); + }, +); const AGENT_REVIEW_OPTIONS = "/api/agents/review-options"; -it.layer(getTestDB())("what the reviewer and label pickers offer", (it) => { - it.effect( - "the repository's labels, collaborators named against the workspace, and teams, read on the App's token; teams it may not read list none", - () => - Effect.gen(function* () { - const repository = `/repos/${RELAY.owner}/${RELAY.name}`; - const asked: SentToGitHub[] = []; - const { owner, task, ask } = yield* openTask({ - github: (sent) => { - const path = new URL(sent.url).pathname; - if (path.startsWith(repository)) asked.push(sent); - switch (path) { - case `${repository}/labels`: - return Response.json([{ name: "bug", color: "d73a4a" }]); - case `${repository}/collaborators`: - return Response.json([ - { - login: "priyan", - id: 1002, - type: "User", - avatar_url: "https://avatars.githubusercontent.com/u/1002?v=4", - }, - ]); - case `${repository}/teams`: - return Response.json( - { message: "Resource not accessible by integration" }, - { status: 403 }, - ); - default: - return publishedPullRequest(sent); - } - }, - }); - const { agent } = yield* startedAgent(owner, task.id, ask); +it.layer(Layer.merge(getTestDB(), quietLiveWake))( + "what the reviewer and label pickers offer", + (it) => { + it.effect( + "the repository's labels, collaborators named against the workspace, and teams, read on the App's token; teams it may not read list none", + () => + Effect.gen(function* () { + const repository = `/repos/${RELAY.owner}/${RELAY.name}`; + const asked: SentToGitHub[] = []; + const { owner, task, ask } = yield* openTask({ + github: (sent) => { + const path = new URL(sent.url).pathname; + if (path.startsWith(repository)) asked.push(sent); + switch (path) { + case `${repository}/labels`: + return Response.json([{ name: "bug", color: "d73a4a" }]); + case `${repository}/collaborators`: + return Response.json([ + { + login: "priyan", + id: 1002, + type: "User", + avatar_url: "https://avatars.githubusercontent.com/u/1002?v=4", + }, + ]); + case `${repository}/teams`: + return Response.json( + { message: "Resource not accessible by integration" }, + { status: 403 }, + ); + default: + return publishedPullRequest(sent); + } + }, + }); + const { agent } = yield* startedAgent(owner, task.id, ask); - const answered = yield* ask(request(AGENT_REVIEW_OPTIONS, owner, { id: agent.id })); + const answered = yield* ask(request(AGENT_REVIEW_OPTIONS, owner, { id: agent.id })); - assert.deepEqual(answered, { - status: HOSTED_HTTP_STATUS.OK, - body: { - labels: [{ name: "bug", color: "d73a4a" }], - users: [ - { - login: "priyan", - bot: false, - avatarUrl: "https://avatars.githubusercontent.com/u/1002?v=4", - }, - ], - teams: [], - }, - }); - assert.ok( - asked.every((sent) => !sent.headers.get("authorization")?.includes("ghu_")), - "the repository is read on an installation token, never a member's", - ); - }), - ); + assert.deepEqual(answered, { + status: HOSTED_HTTP_STATUS.OK, + body: { + labels: [{ name: "bug", color: "d73a4a" }], + users: [ + { + login: "priyan", + bot: false, + avatarUrl: "https://avatars.githubusercontent.com/u/1002?v=4", + }, + ], + teams: [], + }, + }); + assert.ok( + asked.every((sent) => !sent.headers.get("authorization")?.includes("ghu_")), + "the repository is read on an installation token, never a member's", + ); + }), + ); - it.effect( - "a listing GitHub did not answer fails the read rather than offering an empty list, so the picker reads it again", - () => - Effect.gen(function* () { - const repository = `/repos/${RELAY.owner}/${RELAY.name}`; - const { owner, task, ask } = yield* openTask({ - github: (sent) => - new URL(sent.url).pathname === `${repository}/labels` - ? Response.json({ message: "Service unavailable" }, { status: 503 }) - : new URL(sent.url).pathname.startsWith(`${repository}/`) && - !new URL(sent.url).pathname.startsWith(`${repository}/pulls`) - ? Response.json([]) - : publishedPullRequest(sent), - }); - const { agent } = yield* startedAgent(owner, task.id, ask); + it.effect( + "a listing GitHub did not answer fails the read rather than offering an empty list, so the picker reads it again", + () => + Effect.gen(function* () { + const repository = `/repos/${RELAY.owner}/${RELAY.name}`; + const { owner, task, ask } = yield* openTask({ + github: (sent) => + new URL(sent.url).pathname === `${repository}/labels` + ? Response.json({ message: "Service unavailable" }, { status: 503 }) + : new URL(sent.url).pathname.startsWith(`${repository}/`) && + !new URL(sent.url).pathname.startsWith(`${repository}/pulls`) + ? Response.json([]) + : publishedPullRequest(sent), + }); + const { agent } = yield* startedAgent(owner, task.id, ask); - const answered = yield* ask(request(AGENT_REVIEW_OPTIONS, owner, { id: agent.id })); + const answered = yield* ask(request(AGENT_REVIEW_OPTIONS, owner, { id: agent.id })); - assert.notEqual(answered.status, HOSTED_HTTP_STATUS.OK); - }), - ); -}); + assert.notEqual(answered.status, HOSTED_HTTP_STATUS.OK); + }), + ); + }, +); /** A message to the agent as the bearer given, with the body given. */ function messageAs(bearer: string | undefined, agentId: string, body: WireBoundaryInput): Request { @@ -3234,7 +3244,7 @@ function userTexts(page: { }); } -it.layer(getTestDB())("an agent's kept pull request", (it) => { +it.layer(Layer.merge(getTestDB(), quietLiveWake))("an agent's kept pull request", (it) => { /** One file as GitHub lists a pull request's files. */ const githubFile = (filename: string, additions: number, deletions: number) => ({ filename, @@ -3412,7 +3422,7 @@ it.layer(getTestDB())("an agent's kept pull request", (it) => { ); }); -it.layer(getTestDB())("messaging a coding agent", (it) => { +it.layer(Layer.merge(getTestDB(), quietLiveWake))("messaging a coding agent", (it) => { /** An agent started on the owner's task, its session claimed, and its first turn where asked. */ const startedAgent = ( eve: ReturnType, @@ -4871,7 +4881,7 @@ const INBOX_READ = "/api/inbox/read"; /** The first bytes of a PNG, as a screenshot's file starts. */ const PNG = Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13]); -it.layer(getTestDB())("the developer's inbox", (it) => { +it.layer(Layer.merge(getTestDB(), quietLiveWake))("the developer's inbox", (it) => { /** The owner's task with one agent started on it, its session claimed so a message reaches it, and the inbox as each side sees it. */ const openInbox = (eve = fakeEve()) => Effect.gen(function* () { @@ -5676,7 +5686,7 @@ const picture = (mark: number) => ({ height: 720, }); -it.layer(getTestDB())("an agent's pictures", (it) => { +it.layer(Layer.merge(getTestDB(), quietLiveWake))("an agent's pictures", (it) => { it.effect( "a picture a tool answered an agent or its worker is served as its bytes, not to be sniffed, not to be cached, and never to run as a page, to whoever may read the agent, and through its own transcript alone", () => @@ -5748,613 +5758,624 @@ it.layer(getTestDB())("an agent's pictures", (it) => { ); }); -it.layer(getTestDB())("telling a chat message to the agents it addresses", (it) => { - /** - * The owner's plan with two agents on it, each session claimed so a notice - * reaches it, their chat members and the owner's, the plan's channel, and - * the door a post goes through, as the owner posts. - */ - const openChat = (eve = fakeEve(), secondByTeammate = false) => - Effect.gen(function* () { - const opened = yield* openTask({ eve }); - const workspace = opened.task.workspaceId; - if (secondByTeammate) yield* joinWorkspace(workspace, opened.other); - // Chat is an experiment each starter turns on; the tests below are of its telling. - for (const starter of [opened.owner, opened.other]) { - yield* writeAccountPreferences(starter, { experiments: { [EXPERIMENT.CHAT]: true } }); - } - const agents = []; - for (const at of [0, 1]) { - const starter = at === 1 && secondByTeammate ? opened.other : opened.owner; - const started = yield* startedAgent(starter, opened.task.id, opened.ask); - const sessionId = eve.opened[at]?.sessionId ?? ""; - assert.equal( - yield* claimRuntimeSession(started.target, sessionId, yield* DateTime.nowAsDate), - true, - ); - const member = Option.getOrThrow( +it.layer(Layer.merge(getTestDB(), quietLiveWake))( + "telling a chat message to the agents it addresses", + (it) => { + /** + * The owner's plan with two agents on it, each session claimed so a notice + * reaches it, their chat members and the owner's, the plan's channel, and + * the door a post goes through, as the owner posts. + */ + const openChat = (eve = fakeEve(), secondByTeammate = false) => + Effect.gen(function* () { + const opened = yield* openTask({ eve }); + const workspace = opened.task.workspaceId; + if (secondByTeammate) yield* joinWorkspace(workspace, opened.other); + // Chat is an experiment each starter turns on; the tests below are of its telling. + for (const starter of [opened.owner, opened.other]) { + yield* writeAccountPreferences(starter, { experiments: { [EXPERIMENT.CHAT]: true } }); + } + const agents = []; + for (const at of [0, 1]) { + const starter = at === 1 && secondByTeammate ? opened.other : opened.owner; + const started = yield* startedAgent(starter, opened.task.id, opened.ask); + const sessionId = eve.opened[at]?.sessionId ?? ""; + assert.equal( + yield* claimRuntimeSession(started.target, sessionId, yield* DateTime.nowAsDate), + true, + ); + const member = Option.getOrThrow( + yield* ensureMember(opened.owner, workspace, { + kind: MEMBER_KIND.AGENT, + agentId: started.agent.id, + }), + ); + agents.push({ ...started, sessionId, member }); + } + const [first, second] = agents; + assert.ok(first && second); + const person = Option.getOrThrow( yield* ensureMember(opened.owner, workspace, { - kind: MEMBER_KIND.AGENT, - agentId: started.agent.id, + kind: MEMBER_KIND.HUMAN, + userId: opened.owner, }), ); - agents.push({ ...started, sessionId, member }); - } - const [first, second] = agents; - assert.ok(first && second); - const person = Option.getOrThrow( - yield* ensureMember(opened.owner, workspace, { - kind: MEMBER_KIND.HUMAN, + const channel = Option.getOrThrow(yield* ensureTaskChannel(opened.owner, opened.task.id)); + const writer = yield* storeWriter({ tools: CODER_TOOL_SET }); + const telling: ChatTelling = { + eveFor: (starter) => eve.eve(`Bearer ${starter}`), + writer: Effect.succeed(writer), userId: opened.owner, - }), - ); - const channel = Option.getOrThrow(yield* ensureTaskChannel(opened.owner, opened.task.id)); - const writer = yield* storeWriter({ tools: CODER_TOOL_SET }); - const telling: ChatTelling = { - eveFor: (starter) => eve.eve(`Bearer ${starter}`), - writer: Effect.succeed(writer), - userId: opened.owner, - organizationId: workspace, - doors: HELD_DOORS, - }; - /** A post by the member given, its mentions read off its words, through the door. */ - const post = ( - author: { readonly id: string }, - text: string, - to: { readonly channelId: string } | { readonly replyTo: number } = { - channelId: channel.id, - }, - clientKey: string = randomUUID(), - ) => - postChat(telling, { - authorMemberId: author.id, - to, - blocks: [{ type: CHAT_BLOCK.MARKDOWN, text }], - mentions: mentionedHandles(text), - clientKey, - }).pipe(Effect.provide(opened.github.layer)); - /** The user rows of an agent's conversation, as the store keeps them. */ - const rowsOf = (target: { readonly userId: string; readonly conversationId: string }) => - Effect.map( - listMessagesPast(target.userId, target.conversationId, CODER_TOOL_SET, CURSOR_START, 50), - (page) => - (page.read.ok ? page.read.value : []).filter( - (record) => record.message.role === MESSAGE_ROLE.USER, + organizationId: workspace, + doors: HELD_DOORS, + }; + /** A post by the member given, its mentions read off its words, through the door. */ + const post = ( + author: { readonly id: string }, + text: string, + to: { readonly channelId: string } | { readonly replyTo: number } = { + channelId: channel.id, + }, + clientKey: string = randomUUID(), + ) => + postChat(telling, { + authorMemberId: author.id, + to, + blocks: [{ type: CHAT_BLOCK.MARKDOWN, text }], + mentions: mentionedHandles(text), + clientKey, + }).pipe(Effect.provide(opened.github.layer)); + /** The user rows of an agent's conversation, as the store keeps them. */ + const rowsOf = (target: { readonly userId: string; readonly conversationId: string }) => + Effect.map( + listMessagesPast( + target.userId, + target.conversationId, + CODER_TOOL_SET, + CURSOR_START, + 50, ), - ); - return { ...opened, workspace, first, second, person, channel, telling, post, rowsOf }; - }); + (page) => + (page.read.ok ? page.read.value : []).filter( + (record) => record.message.role === MESSAGE_ROLE.USER, + ), + ); + return { ...opened, workspace, first, second, person, channel, telling, post, rowsOf }; + }); - it.effect( - "a post whose pictures cannot be kept is not posted at all, so no reader ever sees an image block without its bytes, and nobody is told", - () => - Effect.gen(function* () { - const eve = fakeEve(); - const chat = yield* openChat(eve); - const attachmentId = randomUUID(); - // One byte past a chat picture's bound, which the store's own check refuses as it writes the picture. - const bytes = new Uint8Array(CHAT_BOUNDS.MAX_IMAGE_BYTES + 1); - bytes.set(PNG); - const refused = yield* Effect.flip( - postChat( - chat.telling, + it.effect( + "a post whose pictures cannot be kept is not posted at all, so no reader ever sees an image block without its bytes, and nobody is told", + () => + Effect.gen(function* () { + const eve = fakeEve(); + const chat = yield* openChat(eve); + const attachmentId = randomUUID(); + // One byte past a chat picture's bound, which the store's own check refuses as it writes the picture. + const bytes = new Uint8Array(CHAT_BOUNDS.MAX_IMAGE_BYTES + 1); + bytes.set(PNG); + const refused = yield* Effect.flip( + postChat( + chat.telling, + { + authorMemberId: chat.person.id, + to: { channelId: chat.channel.id }, + blocks: [ + { type: CHAT_BLOCK.MARKDOWN, text: "@agent-1 the new email." }, + { type: CHAT_BLOCK.IMAGE, attachmentId, alt: "email.png" }, + ], + mentions: ["agent-1"], + clientKey: randomUUID(), + }, + [{ id: attachmentId, contentType: CHAT_IMAGE_TYPE.PNG, bytes }], + ).pipe(Effect.provide(chat.github.layer)), + ); + assert.deepEqual(refused, HOSTED_REFUSAL.UNAVAILABLE); + const kept = Option.getOrThrow( + yield* history(chat.owner, chat.workspace, chat.person.id, { + channelId: chat.channel.id, + }), + ); + assert.deepEqual(kept, [], "the message went with its pictures"); + assert.equal(eve.sent.length, 0); + }), + ); + + it.effect( + "a DM steers the other agent once as a chat-marked line of its own, a second while the first is unread is held, and one after it reads is told", + () => + Effect.gen(function* () { + const eve = fakeEve(); + const chat = yield* openChat(eve); + const dm = Option.getOrThrow( + yield* findOrMakeDm( + chat.owner, + chat.workspace, + chat.first.member.id, + chat.second.member.id, + ), + ); + const key = randomUUID(); + const sent = yield* chat.post( + chat.first.member, + "The invite table is mine.", + { channelId: dm.id }, + key, + ); + assert.deepEqual(sent.receipt, { told: ["agent-2"], held: [], left: [], unresolved: [] }); + assert.deepEqual( + eve.sent.map((each) => each.sessionId), + [chat.second.sessionId], + ); + // The notice is the only line the second agent's conversation holds: no turn of its has run. + const [notice] = yield* chat.rowsOf(chat.second.target); + assert.ok(notice?.message.role === MESSAGE_ROLE.USER); + assert.deepEqual(notice.message.metadata, { + author: MESSAGE_AUTHOR.AGENT, + channel: MESSAGE_CHANNEL.CHAT, + chat_seq: sent.message.seq, + from: "agent-1", + }); + // The window reads the notice back as the words posted alone, its head and closing lines its own. + const noticeLine = notice.message.parts.flatMap((part) => + part.type === "text" ? [part.text] : [], + ); + assert.deepEqual(noticeLine.map(chatNoticeWords), ["The invite table is mine."]); + assert.deepEqual( + (yield* lineSendersOf(chat.second.target, [notice.message.id])).get(notice.message.id), + // An agent's notice names it by its handle, and by its member's agent and the model that agent posted under. { - authorMemberId: chat.person.id, - to: { channelId: chat.channel.id }, - blocks: [ - { type: CHAT_BLOCK.MARKDOWN, text: "@agent-1 the new email." }, - { type: CHAT_BLOCK.IMAGE, attachmentId, alt: "email.png" }, - ], - mentions: ["agent-1"], - clientKey: randomUUID(), + kind: MESSAGE_SENDER.AGENT, + name: "agent-1", + model: chat.first.agent.model, + agent: { id: chat.first.agent.id, taskId: chat.first.agent.planId ?? undefined }, }, - [{ id: attachmentId, contentType: CHAT_IMAGE_TYPE.PNG, bytes }], - ).pipe(Effect.provide(chat.github.layer)), - ); - assert.deepEqual(refused, HOSTED_REFUSAL.UNAVAILABLE); - const kept = Option.getOrThrow( - yield* history(chat.owner, chat.workspace, chat.person.id, { - channelId: chat.channel.id, - }), - ); - assert.deepEqual(kept, [], "the message went with its pictures"); - assert.equal(eve.sent.length, 0); - }), - ); + ); + // The notice keeps the model its agent posted under, whatever the agent runs on since. + yield* Database.use((db) => + db + .update(codingAgent) + .set({ model: "openai/gpt-6.1-sol" }) + .where(eq(codingAgent.id, chat.first.agent.id)), + ); + assert.deepEqual( + (yield* lineSendersOf(chat.second.target, [notice.message.id])).get(notice.message.id), + { + kind: MESSAGE_SENDER.AGENT, + name: "agent-1", + model: chat.first.agent.model, + agent: { id: chat.first.agent.id, taskId: chat.first.agent.planId ?? undefined }, + }, + ); + const again = yield* chat.post( + chat.first.member, + "The invite table is mine.", + { channelId: dm.id }, + key, + ); + assert.equal(again.repeated, true); + assert.equal(eve.sent.length, 1); + + const held = yield* chat.post(chat.first.member, "And the emails.", { channelId: dm.id }); + assert.deepEqual(held.receipt.held, ["agent-2"]); + assert.equal(eve.sent.length, 1); + yield* unreadFor(chat.owner, chat.workspace, chat.second.member.id); + const told = yield* chat.post(chat.first.member, "Done now.", { channelId: dm.id }); + assert.deepEqual(told.receipt.told, ["agent-2"]); + assert.equal(eve.sent.length, 2); + }), + ); - it.effect( - "a DM steers the other agent once as a chat-marked line of its own, a second while the first is unread is held, and one after it reads is told", - () => - Effect.gen(function* () { - const eve = fakeEve(); - const chat = yield* openChat(eve); - const dm = Option.getOrThrow( - yield* findOrMakeDm( - chat.owner, - chat.workspace, - chat.first.member.id, - chat.second.member.id, - ), - ); - const key = randomUUID(); - const sent = yield* chat.post( - chat.first.member, - "The invite table is mine.", - { channelId: dm.id }, - key, - ); - assert.deepEqual(sent.receipt, { told: ["agent-2"], held: [], left: [], unresolved: [] }); - assert.deepEqual( - eve.sent.map((each) => each.sessionId), - [chat.second.sessionId], - ); - // The notice is the only line the second agent's conversation holds: no turn of its has run. - const [notice] = yield* chat.rowsOf(chat.second.target); - assert.ok(notice?.message.role === MESSAGE_ROLE.USER); - assert.deepEqual(notice.message.metadata, { - author: MESSAGE_AUTHOR.AGENT, - channel: MESSAGE_CHANNEL.CHAT, - chat_seq: sent.message.seq, - from: "agent-1", - }); - // The window reads the notice back as the words posted alone, its head and closing lines its own. - const noticeLine = notice.message.parts.flatMap((part) => - part.type === "text" ? [part.text] : [], - ); - assert.deepEqual(noticeLine.map(chatNoticeWords), ["The invite table is mine."]); - assert.deepEqual( - (yield* lineSendersOf(chat.second.target, [notice.message.id])).get(notice.message.id), - // An agent's notice names it by its handle, and by its member's agent and the model that agent posted under. - { - kind: MESSAGE_SENDER.AGENT, - name: "agent-1", - model: chat.first.agent.model, - agent: { id: chat.first.agent.id, taskId: chat.first.agent.planId ?? undefined }, - }, - ); - // The notice keeps the model its agent posted under, whatever the agent runs on since. - yield* Database.use((db) => - db - .update(codingAgent) - .set({ model: "openai/gpt-6.1-sol" }) - .where(eq(codingAgent.id, chat.first.agent.id)), - ); - assert.deepEqual( - (yield* lineSendersOf(chat.second.target, [notice.message.id])).get(notice.message.id), - { - kind: MESSAGE_SENDER.AGENT, - name: "agent-1", - model: chat.first.agent.model, - agent: { id: chat.first.agent.id, taskId: chat.first.agent.planId ?? undefined }, - }, - ); - const again = yield* chat.post( - chat.first.member, - "The invite table is mine.", - { channelId: dm.id }, - key, - ); - assert.equal(again.repeated, true); - assert.equal(eve.sent.length, 1); - - const held = yield* chat.post(chat.first.member, "And the emails.", { channelId: dm.id }); - assert.deepEqual(held.receipt.held, ["agent-2"]); - assert.equal(eve.sent.length, 1); - yield* unreadFor(chat.owner, chat.workspace, chat.second.member.id); - const told = yield* chat.post(chat.first.member, "Done now.", { channelId: dm.id }); - assert.deepEqual(told.receipt.told, ["agent-2"]); - assert.equal(eve.sent.length, 2); - }), - ); - - it.effect( - "an account with Chat off reads nothing waiting in chat, a message addressed to its person among it", - () => - Effect.gen(function* () { - const chat = yield* openChat(); - yield* chooseChat(chat.owner, false); - const telling = { ...chat.telling, doors: CHAT_DOORS }; - const dm = Option.getOrThrow( - yield* findOrMakeDm(chat.owner, chat.workspace, chat.first.member.id, chat.person.id), - ); - const before = yield* accountInboxPast(chat.owner, INBOX_CURSOR_START); - yield* postChat(telling, { - authorMemberId: chat.first.member.id, - to: { channelId: dm.id }, - blocks: [{ type: CHAT_BLOCK.MARKDOWN, text: "Can I take the invite file too?" }], - mentions: [], - clientKey: randomUUID(), - }).pipe(Effect.provide(chat.github.layer)); - const after = yield* accountInboxPast(chat.owner, INBOX_CURSOR_START); - assert.deepEqual(after.chat, []); - assert.equal(after.cursor, before.cursor, "nothing in chat moves the read"); - }), - ); - - it.effect( - "a person a message addresses is told through their account's read once, a second before they read is held, and the read carries who and where until they read past it, with none of its words", - () => - Effect.gen(function* () { - const chat = yield* openChat(); - yield* chooseChat(chat.owner, true); - const telling = { ...chat.telling, doors: CHAT_DOORS }; - const dm = Option.getOrThrow( - yield* findOrMakeDm(chat.owner, chat.workspace, chat.first.member.id, chat.person.id), - ); - const say = (text: string) => - postChat(telling, { + it.effect( + "an account with Chat off reads nothing waiting in chat, a message addressed to its person among it", + () => + Effect.gen(function* () { + const chat = yield* openChat(); + yield* chooseChat(chat.owner, false); + const telling = { ...chat.telling, doors: CHAT_DOORS }; + const dm = Option.getOrThrow( + yield* findOrMakeDm(chat.owner, chat.workspace, chat.first.member.id, chat.person.id), + ); + const before = yield* accountInboxPast(chat.owner, INBOX_CURSOR_START); + yield* postChat(telling, { authorMemberId: chat.first.member.id, to: { channelId: dm.id }, - blocks: [{ type: CHAT_BLOCK.MARKDOWN, text }], + blocks: [{ type: CHAT_BLOCK.MARKDOWN, text: "Can I take the invite file too?" }], mentions: [], clientKey: randomUUID(), }).pipe(Effect.provide(chat.github.layer)); - const before = yield* accountInboxPast(chat.owner, INBOX_CURSOR_START); - assert.deepEqual(before.chat, []); - - const first = yield* say("Can I take the invite file too?"); - assert.deepEqual(first.receipt.told, [chat.person.handle]); - const second = yield* say("Or should I wait for #214?"); - assert.deepEqual(second.receipt.held, [chat.person.handle]); - - const waiting = yield* accountInboxPast(chat.owner, before.cursor); - assert.notEqual(waiting.cursor, before.cursor); - assert.deepEqual( - waiting.chat?.map(({ seq, reason, from, workspaceId }) => ({ - seq, - reason, - from, - workspaceId, - })), - [ - { - seq: first.message.seq, - reason: ACCOUNT_CHAT_REASON.DIRECT, - from: "agent-1", - workspaceId: chat.workspace, - }, - ], - ); - assert.ok(!JSON.stringify(waiting.chat).includes("invite file")); + const after = yield* accountInboxPast(chat.owner, INBOX_CURSOR_START); + assert.deepEqual(after.chat, []); + assert.equal(after.cursor, before.cursor, "nothing in chat moves the read"); + }), + ); - yield* readTo( - chat.owner, - chat.workspace, - chat.person.id, - second.message.channelId, - second.message.seq, - ); - const read = yield* accountInboxPast(chat.owner, waiting.cursor); - assert.deepEqual(read.chat, []); - const third = yield* say("Going ahead."); - assert.deepEqual(third.receipt.told, [chat.person.handle]); - }), - ); + it.effect( + "a person a message addresses is told through their account's read once, a second before they read is held, and the read carries who and where until they read past it, with none of its words", + () => + Effect.gen(function* () { + const chat = yield* openChat(); + yield* chooseChat(chat.owner, true); + const telling = { ...chat.telling, doors: CHAT_DOORS }; + const dm = Option.getOrThrow( + yield* findOrMakeDm(chat.owner, chat.workspace, chat.first.member.id, chat.person.id), + ); + const say = (text: string) => + postChat(telling, { + authorMemberId: chat.first.member.id, + to: { channelId: dm.id }, + blocks: [{ type: CHAT_BLOCK.MARKDOWN, text }], + mentions: [], + clientKey: randomUUID(), + }).pipe(Effect.provide(chat.github.layer)); + const before = yield* accountInboxPast(chat.owner, INBOX_CURSOR_START); + assert.deepEqual(before.chat, []); - it.effect( - "a plain post steers nobody, a mention steers the agent it names, and Luke, a person, and a handle naming no one are told no agent's way", - () => - Effect.gen(function* () { - const eve = fakeEve(); - const chat = yield* openChat(eve); - const plain = yield* chat.post(chat.first.member, "Starting on the emails."); - assert.deepEqual(plain.receipt, { told: [], held: [], left: [], unresolved: [] }); - assert.equal(eve.sent.length, 0); + const first = yield* say("Can I take the invite file too?"); + assert.deepEqual(first.receipt.told, [chat.person.handle]); + const second = yield* say("Or should I wait for #214?"); + assert.deepEqual(second.receipt.held, [chat.person.handle]); - const mentioned = yield* chat.post( - chat.first.member, - `@agent-2 take the table; @luke and @${chat.person.handle} FYI; @nobody`, - ); - assert.deepEqual(mentioned.receipt, { - told: ["agent-2"], - held: ["luke", chat.person.handle], - left: [], - unresolved: ["nobody"], - }); - assert.deepEqual( - eve.sent.map((each) => each.sessionId), - [chat.second.sessionId], - ); - }), - ); + const waiting = yield* accountInboxPast(chat.owner, before.cursor); + assert.notEqual(waiting.cursor, before.cursor); + assert.deepEqual( + waiting.chat?.map(({ seq, reason, from, workspaceId }) => ({ + seq, + reason, + from, + workspaceId, + })), + [ + { + seq: first.message.seq, + reason: ACCOUNT_CHAT_REASON.DIRECT, + from: "agent-1", + workspaceId: chat.workspace, + }, + ], + ); + assert.ok(!JSON.stringify(waiting.chat).includes("invite file")); - it.effect( - "a thread reply tells the root's author and the earlier repliers, never its own author", - () => - Effect.gen(function* () { - const eve = fakeEve(); - const chat = yield* openChat(eve); - const root = yield* chat.post(chat.first.member, "Who owns the invite table?"); - const reply = yield* chat.post(chat.person, "agent-2 does.", { replyTo: root.message.seq }); - assert.deepEqual(reply.receipt.told, ["agent-1"]); - const answer = yield* chat.post(chat.second.member, "Confirmed.", { - replyTo: root.message.seq, - }); - assert.deepEqual( - [answer.receipt.told, answer.receipt.held], - [[], ["agent-1", chat.person.handle]], - ); - assert.deepEqual( - eve.sent.map((each) => each.sessionId), - [chat.first.sessionId], - ); - }), - ); + yield* readTo( + chat.owner, + chat.workspace, + chat.person.id, + second.message.channelId, + second.message.seq, + ); + const read = yield* accountInboxPast(chat.owner, waiting.cursor); + assert.deepEqual(read.chat, []); + const third = yield* say("Going ahead."); + assert.deepEqual(third.receipt.told, [chat.person.handle]); + }), + ); - it.effect( - "an archived agent is said to have left and steered nothing, and is told again once brought back; one whose session eve no longer runs is stamped as having left for good", - () => - Effect.gen(function* () { - const eve = fakeEve(); - const chat = yield* openChat(eve); - yield* archiveCodingAgent(chat.owner, chat.second.agent.id, true); - const archived = yield* chat.post(chat.first.member, "@agent-2 still there?"); - assert.deepEqual(archived.receipt.left, ["agent-2"]); - assert.equal(eve.sent.length, 0); - yield* archiveCodingAgent(chat.owner, chat.second.agent.id, false); - const back = yield* chat.post(chat.first.member, "@agent-2 welcome back."); - assert.deepEqual(back.receipt.told, ["agent-2"]); - assert.equal(eve.sent.length, 1); - assert.deepEqual( - (yield* roster(chat.owner, chat.workspace)).filter((row) => row.leftAt !== null), - [], - ); - }), - ); + it.effect( + "a plain post steers nobody, a mention steers the agent it names, and Luke, a person, and a handle naming no one are told no agent's way", + () => + Effect.gen(function* () { + const eve = fakeEve(); + const chat = yield* openChat(eve); + const plain = yield* chat.post(chat.first.member, "Starting on the emails."); + assert.deepEqual(plain.receipt, { told: [], held: [], left: [], unresolved: [] }); + assert.equal(eve.sent.length, 0); + + const mentioned = yield* chat.post( + chat.first.member, + `@agent-2 take the table; @luke and @${chat.person.handle} FYI; @nobody`, + ); + assert.deepEqual(mentioned.receipt, { + told: ["agent-2"], + held: ["luke", chat.person.handle], + left: [], + unresolved: ["nobody"], + }); + assert.deepEqual( + eve.sent.map((each) => each.sessionId), + [chat.second.sessionId], + ); + }), + ); - it.effect( - "an agent whose session eve no longer runs is stamped as having left, and posts nothing more", - () => - Effect.gen(function* () { - const eve = fakeEve({ sends: EVE_SEND_OUTCOME.RETIRED }); - const chat = yield* openChat(eve); - const toSecond = yield* chat.post(chat.person, "@agent-2 are you?"); - assert.deepEqual(toSecond.receipt.left, ["agent-2"]); - const left = (yield* roster(chat.owner, chat.workspace)).filter( - (row) => row.leftAt !== null, - ); - assert.deepEqual( - left.map((row) => row.handle), - ["agent-2"], - ); - const silenced = yield* chat.post(chat.second.member, "Still here.").pipe(Effect.result); - assert.ok(Result.isFailure(silenced)); - }), - ); + it.effect( + "a thread reply tells the root's author and the earlier repliers, never its own author", + () => + Effect.gen(function* () { + const eve = fakeEve(); + const chat = yield* openChat(eve); + const root = yield* chat.post(chat.first.member, "Who owns the invite table?"); + const reply = yield* chat.post(chat.person, "agent-2 does.", { + replyTo: root.message.seq, + }); + assert.deepEqual(reply.receipt.told, ["agent-1"]); + const answer = yield* chat.post(chat.second.member, "Confirmed.", { + replyTo: root.message.seq, + }); + assert.deepEqual( + [answer.receipt.told, answer.receipt.held], + [[], ["agent-1", chat.person.handle]], + ); + assert.deepEqual( + eve.sent.map((each) => each.sessionId), + [chat.first.sessionId], + ); + }), + ); - it.effect( - "a DM tells its other member alone, a mention in it naming no one else, so its words reach no one who may not read them", - () => - Effect.gen(function* () { - const eve = fakeEve(); - const chat = yield* openChat(eve); - const dm = Option.getOrThrow( - yield* findOrMakeDm(chat.owner, chat.workspace, chat.first.member.id, chat.person.id), - ); - const sent = yield* chat.post(chat.first.member, "@agent-2 is slow, between us.", { - channelId: dm.id, - }); - assert.deepEqual(sent.receipt, { - told: [], - held: [chat.person.handle], - left: [], - unresolved: [], - }); - assert.equal(eve.sent.length, 0); - }), - ); + it.effect( + "an archived agent is said to have left and steered nothing, and is told again once brought back; one whose session eve no longer runs is stamped as having left for good", + () => + Effect.gen(function* () { + const eve = fakeEve(); + const chat = yield* openChat(eve); + yield* archiveCodingAgent(chat.owner, chat.second.agent.id, true); + const archived = yield* chat.post(chat.first.member, "@agent-2 still there?"); + assert.deepEqual(archived.receipt.left, ["agent-2"]); + assert.equal(eve.sent.length, 0); + yield* archiveCodingAgent(chat.owner, chat.second.agent.id, false); + const back = yield* chat.post(chat.first.member, "@agent-2 welcome back."); + assert.deepEqual(back.receipt.told, ["agent-2"]); + assert.equal(eve.sent.length, 1); + assert.deepEqual( + (yield* roster(chat.owner, chat.workspace)).filter((row) => row.leftAt !== null), + [], + ); + }), + ); - it.effect( - "a post sent again under its key finishes a telling the first send never made, and steers no one twice", - () => - Effect.gen(function* () { - const eve = fakeEve(); - const chat = yield* openChat(eve); - const key = randomUUID(); - // The first send landed and was never told, as a send that failed after its write leaves it. - yield* postMessage(chat.owner, chat.workspace, { - authorMemberId: chat.first.member.id, - to: { channelId: chat.channel.id }, - blocks: [{ type: CHAT_BLOCK.MARKDOWN, text: "@agent-2 the table is mine." }], - mentions: ["agent-2"], - clientKey: key, - }); - const retried = yield* chat.post( - chat.first.member, - "@agent-2 the table is mine.", - { channelId: chat.channel.id }, - key, - ); - assert.deepEqual([retried.repeated, retried.receipt.told], [true, ["agent-2"]]); - const again = yield* chat.post( - chat.first.member, - "@agent-2 the table is mine.", - { channelId: chat.channel.id }, - key, - ); - assert.deepEqual(again.receipt.held, ["agent-2"]); - assert.equal(eve.sent.length, 1); - }), - ); + it.effect( + "an agent whose session eve no longer runs is stamped as having left, and posts nothing more", + () => + Effect.gen(function* () { + const eve = fakeEve({ sends: EVE_SEND_OUTCOME.RETIRED }); + const chat = yield* openChat(eve); + const toSecond = yield* chat.post(chat.person, "@agent-2 are you?"); + assert.deepEqual(toSecond.receipt.left, ["agent-2"]); + const left = (yield* roster(chat.owner, chat.workspace)).filter( + (row) => row.leftAt !== null, + ); + assert.deepEqual( + left.map((row) => row.handle), + ["agent-2"], + ); + const silenced = yield* chat.post(chat.second.member, "Still here.").pipe(Effect.result); + assert.ok(Result.isFailure(silenced)); + }), + ); - it.effect("an ask posted again under its key is told again with what it now asks", () => - Effect.gen(function* () { - const eve = fakeEve(); - const chat = yield* openChat(eve); - const asked = (text: string) => + it.effect( + "a DM tells its other member alone, a mention in it naming no one else, so its words reach no one who may not read them", + () => Effect.gen(function* () { - const posted = Result.getOrThrow( - yield* postAsk(chat.owner, chat.workspace, { - authorMemberId: chat.first.member.id, - to: { channelId: chat.channel.id }, - blocks: [{ type: CHAT_BLOCK.MARKDOWN, text }], - mentions: ["agent-2"], - clientKey: randomUUID(), - key: "table-owner", - ask: { response: { type: CHAT_ASK.TEXT } }, - }), + const eve = fakeEve(); + const chat = yield* openChat(eve); + const dm = Option.getOrThrow( + yield* findOrMakeDm(chat.owner, chat.workspace, chat.first.member.id, chat.person.id), + ); + const sent = yield* chat.post(chat.first.member, "@agent-2 is slow, between us.", { + channelId: dm.id, + }); + assert.deepEqual(sent.receipt, { + told: [], + held: [chat.person.handle], + left: [], + unresolved: [], + }); + assert.equal(eve.sent.length, 0); + }), + ); + + it.effect( + "a post sent again under its key finishes a telling the first send never made, and steers no one twice", + () => + Effect.gen(function* () { + const eve = fakeEve(); + const chat = yield* openChat(eve); + const key = randomUUID(); + // The first send landed and was never told, as a send that failed after its write leaves it. + yield* postMessage(chat.owner, chat.workspace, { + authorMemberId: chat.first.member.id, + to: { channelId: chat.channel.id }, + blocks: [{ type: CHAT_BLOCK.MARKDOWN, text: "@agent-2 the table is mine." }], + mentions: ["agent-2"], + clientKey: key, + }); + const retried = yield* chat.post( + chat.first.member, + "@agent-2 the table is mine.", + { channelId: chat.channel.id }, + key, ); - return yield* tellMessage(chat.telling, posted.message, posted.unresolved).pipe( - Effect.provide(chat.github.layer), + assert.deepEqual([retried.repeated, retried.receipt.told], [true, ["agent-2"]]); + const again = yield* chat.post( + chat.first.member, + "@agent-2 the table is mine.", + { channelId: chat.channel.id }, + key, ); - }); - assert.deepEqual((yield* asked("@agent-2 who owns the table?")).told, ["agent-2"]); - yield* unreadFor(chat.owner, chat.workspace, chat.second.member.id); - assert.deepEqual((yield* asked("@agent-2 who owns the table, now?")).told, ["agent-2"]); - assert.deepEqual( - eve.sent.map((each) => String(each.message.message).includes("now?")), - [false, true], - ); - }), - ); + assert.deepEqual(again.receipt.held, ["agent-2"]); + assert.equal(eve.sent.length, 1); + }), + ); - it.effect( - "a post past a notice's bound is cut in its words alone, so the notice stays within a message's bound and ends on its closing lines whole, an ask's among them", - () => + it.effect("an ask posted again under its key is told again with what it now asks", () => Effect.gen(function* () { const eve = fakeEve(); const chat = yield* openChat(eve); - // Three blocks a little short of their bound each run past what one notice may carry. - const block = (fill: string) => `@agent-2 ${fill.repeat(7_900)}`; - const blocks = ["a", "b", "c"].map((fill) => ({ - type: CHAT_BLOCK.MARKDOWN, - text: block(fill), - })); - const told = (ask: boolean) => + const asked = (text: string) => Effect.gen(function* () { - const post = { - authorMemberId: chat.first.member.id, - to: { channelId: chat.channel.id }, - blocks, - mentions: ["agent-2"], - clientKey: randomUUID(), - }; - const seq = ask - ? yield* Effect.gen(function* () { - const posted = Result.getOrThrow( - yield* postAsk(chat.owner, chat.workspace, { - ...post, - key: "long-ask", - ask: { response: { type: CHAT_ASK.TEXT } }, - }), - ); - yield* tellMessage(chat.telling, posted.message, posted.unresolved).pipe( - Effect.provide(chat.github.layer), - ); - return posted.message.seq; - }) - : (yield* postChat(chat.telling, post).pipe(Effect.provide(chat.github.layer))) - .message.seq; - yield* unreadFor(chat.owner, chat.workspace, chat.second.member.id); - return { seq, text: String(eve.sent.at(-1)?.message.message) }; + const posted = Result.getOrThrow( + yield* postAsk(chat.owner, chat.workspace, { + authorMemberId: chat.first.member.id, + to: { channelId: chat.channel.id }, + blocks: [{ type: CHAT_BLOCK.MARKDOWN, text }], + mentions: ["agent-2"], + clientKey: randomUUID(), + key: "table-owner", + ask: { response: { type: CHAT_ASK.TEXT } }, + }), + ); + return yield* tellMessage(chat.telling, posted.message, posted.unresolved).pipe( + Effect.provide(chat.github.layer), + ); }); - for (const ask of [false, true]) { - const { seq, text } = yield* told(ask); - assert.ok(text.length <= CODING_AGENT_BOUNDS.MAX_MESSAGE_CHARS, String(text.length)); - const closings = [ - ...(ask ? [chatNoticeAsksLine(CHAT_ASK.TEXT)] : []), - chatNoticeReplyLine(seq), - ].join("\n\n"); - assert.ok(text.endsWith(`\n\n${closings}`), text.slice(-200)); - const words = chatNoticeWords(text); - assert.ok(words.startsWith(block("a")), "the words are cut from their end"); - assert.equal(words.includes("Reply with"), false); - } - }), - ); - - it.effect( - "a notice to an agent a teammate started is minted for that teammate, whose principal its turn runs under, whoever posted", - () => - Effect.gen(function* () { - const eve = fakeEve(); - const chat = yield* openChat(eve, true); - const told = yield* chat.post(chat.first.member, "@agent-2 the table is mine."); - assert.deepEqual(told.receipt.told, ["agent-2"]); + assert.deepEqual((yield* asked("@agent-2 who owns the table?")).told, ["agent-2"]); + yield* unreadFor(chat.owner, chat.workspace, chat.second.member.id); + assert.deepEqual((yield* asked("@agent-2 who owns the table, now?")).told, ["agent-2"]); assert.deepEqual( - eve.sent.map((each) => [each.authorization, each.sessionId]), - [[`Bearer ${chat.other}`, chat.second.sessionId]], + eve.sent.map((each) => String(each.message.message).includes("now?")), + [false, true], ); - const back = yield* chat.post(chat.second.member, "@agent-1 noted."); - assert.deepEqual(back.receipt.told, ["agent-1"]); - assert.deepEqual(eve.sent[1]?.authorization, `Bearer ${chat.owner}`); }), - ); - - it.effect( - "an agent whose starter left the workspace is away: said to have left, steered nothing, stamped nothing, and told again once its starter is back", - () => - Effect.gen(function* () { - const eve = fakeEve(); - const chat = yield* openChat(eve, true); - const db = yield* Database; - yield* db - .delete(member) - .where(and(eq(member.organizationId, chat.workspace), eq(member.userId, chat.other))); - const away = yield* chat.post(chat.person, "@agent-2 please carry on."); - assert.deepEqual(away.receipt.left, ["agent-2"]); - assert.equal(eve.sent.length, 0); - assert.deepEqual( - (yield* roster(chat.owner, chat.workspace)).filter((row) => row.leftAt !== null), - [], - ); + ); - yield* joinWorkspace(chat.workspace, chat.other); - const back = yield* chat.post(chat.person, "@agent-2 welcome back."); - assert.deepEqual(back.receipt.told, ["agent-2"]); - assert.deepEqual( - eve.sent.map((each) => each.authorization), - [`Bearer ${chat.other}`], - ); - }), - ); + it.effect( + "a post past a notice's bound is cut in its words alone, so the notice stays within a message's bound and ends on its closing lines whole, an ask's among them", + () => + Effect.gen(function* () { + const eve = fakeEve(); + const chat = yield* openChat(eve); + // Three blocks a little short of their bound each run past what one notice may carry. + const block = (fill: string) => `@agent-2 ${fill.repeat(7_900)}`; + const blocks = ["a", "b", "c"].map((fill) => ({ + type: CHAT_BLOCK.MARKDOWN, + text: block(fill), + })); + const told = (ask: boolean) => + Effect.gen(function* () { + const post = { + authorMemberId: chat.first.member.id, + to: { channelId: chat.channel.id }, + blocks, + mentions: ["agent-2"], + clientKey: randomUUID(), + }; + const seq = ask + ? yield* Effect.gen(function* () { + const posted = Result.getOrThrow( + yield* postAsk(chat.owner, chat.workspace, { + ...post, + key: "long-ask", + ask: { response: { type: CHAT_ASK.TEXT } }, + }), + ); + yield* tellMessage(chat.telling, posted.message, posted.unresolved).pipe( + Effect.provide(chat.github.layer), + ); + return posted.message.seq; + }) + : (yield* postChat(chat.telling, post).pipe(Effect.provide(chat.github.layer))) + .message.seq; + yield* unreadFor(chat.owner, chat.workspace, chat.second.member.id); + return { seq, text: String(eve.sent.at(-1)?.message.message) }; + }); + for (const ask of [false, true]) { + const { seq, text } = yield* told(ask); + assert.ok(text.length <= CODING_AGENT_BOUNDS.MAX_MESSAGE_CHARS, String(text.length)); + const closings = [ + ...(ask ? [chatNoticeAsksLine(CHAT_ASK.TEXT)] : []), + chatNoticeReplyLine(seq), + ].join("\n\n"); + assert.ok(text.endsWith(`\n\n${closings}`), text.slice(-200)); + const words = chatNoticeWords(text); + assert.ok(words.startsWith(block("a")), "the words are cut from their end"); + assert.equal(words.includes("Reply with"), false); + } + }), + ); - it.effect("an ask only answered since it was told steers no one again", () => - Effect.gen(function* () { - const eve = fakeEve(); - const chat = yield* openChat(eve); - const posted = Result.getOrThrow( - yield* postAsk(chat.owner, chat.workspace, { - authorMemberId: chat.first.member.id, - to: { channelId: chat.channel.id }, - blocks: [{ type: CHAT_BLOCK.MARKDOWN, text: "@agent-2 who owns the table?" }], - mentions: ["agent-2"], - clientKey: randomUUID(), - key: "table-owner", - ask: { response: { type: CHAT_ASK.TEXT } }, + it.effect( + "a notice to an agent a teammate started is minted for that teammate, whose principal its turn runs under, whoever posted", + () => + Effect.gen(function* () { + const eve = fakeEve(); + const chat = yield* openChat(eve, true); + const told = yield* chat.post(chat.first.member, "@agent-2 the table is mine."); + assert.deepEqual(told.receipt.told, ["agent-2"]); + assert.deepEqual( + eve.sent.map((each) => [each.authorization, each.sessionId]), + [[`Bearer ${chat.other}`, chat.second.sessionId]], + ); + const back = yield* chat.post(chat.second.member, "@agent-1 noted."); + assert.deepEqual(back.receipt.told, ["agent-1"]); + assert.deepEqual(eve.sent[1]?.authorization, `Bearer ${chat.owner}`); }), - ); - const tell = (message: typeof posted.message) => - tellMessage(chat.telling, message, []).pipe(Effect.provide(chat.github.layer)); - yield* tell(posted.message); - yield* unreadFor(chat.owner, chat.workspace, chat.second.member.id); - const answered = Result.getOrThrow( - yield* answerAsk(chat.owner, chat.workspace, { - answererMemberId: chat.person.id, - seq: posted.message.seq, - revision: 1, - reply: { reply: CHAT_REPLY.ANSWERED, note: "agent-1 does." }, - clientKey: randomUUID(), + ); + + it.effect( + "an agent whose starter left the workspace is away: said to have left, steered nothing, stamped nothing, and told again once its starter is back", + () => + Effect.gen(function* () { + const eve = fakeEve(); + const chat = yield* openChat(eve, true); + const db = yield* Database; + yield* db + .delete(member) + .where(and(eq(member.organizationId, chat.workspace), eq(member.userId, chat.other))); + const away = yield* chat.post(chat.person, "@agent-2 please carry on."); + assert.deepEqual(away.receipt.left, ["agent-2"]); + assert.equal(eve.sent.length, 0); + assert.deepEqual( + (yield* roster(chat.owner, chat.workspace)).filter((row) => row.leftAt !== null), + [], + ); + + yield* joinWorkspace(chat.workspace, chat.other); + const back = yield* chat.post(chat.person, "@agent-2 welcome back."); + assert.deepEqual(back.receipt.told, ["agent-2"]); + assert.deepEqual( + eve.sent.map((each) => each.authorization), + [`Bearer ${chat.other}`], + ); }), - ); - yield* tell(answered.ask); - assert.equal(eve.sent.length, 1); - }), - ); + ); - it.effect( - "an agent whose starter has not turned Chat on is steered nothing, however it is addressed", - () => + it.effect("an ask only answered since it was told steers no one again", () => Effect.gen(function* () { const eve = fakeEve(); - const chat = yield* openChat(eve, true); - yield* writeAccountPreferences(chat.other, { experiments: { [EXPERIMENT.CHAT]: false } }); - const told = yield* chat.post(chat.person, "@agent-2 please carry on."); - assert.deepEqual([told.receipt.told, told.receipt.held], [[], ["agent-2"]]); - assert.equal(eve.sent.length, 0); + const chat = yield* openChat(eve); + const posted = Result.getOrThrow( + yield* postAsk(chat.owner, chat.workspace, { + authorMemberId: chat.first.member.id, + to: { channelId: chat.channel.id }, + blocks: [{ type: CHAT_BLOCK.MARKDOWN, text: "@agent-2 who owns the table?" }], + mentions: ["agent-2"], + clientKey: randomUUID(), + key: "table-owner", + ask: { response: { type: CHAT_ASK.TEXT } }, + }), + ); + const tell = (message: typeof posted.message) => + tellMessage(chat.telling, message, []).pipe(Effect.provide(chat.github.layer)); + yield* tell(posted.message); + yield* unreadFor(chat.owner, chat.workspace, chat.second.member.id); + const answered = Result.getOrThrow( + yield* answerAsk(chat.owner, chat.workspace, { + answererMemberId: chat.person.id, + seq: posted.message.seq, + revision: 1, + reply: { reply: CHAT_REPLY.ANSWERED, note: "agent-1 does." }, + clientKey: randomUUID(), + }), + ); + yield* tell(answered.ask); + assert.equal(eve.sent.length, 1); }), - ); -}); + ); + + it.effect( + "an agent whose starter has not turned Chat on is steered nothing, however it is addressed", + () => + Effect.gen(function* () { + const eve = fakeEve(); + const chat = yield* openChat(eve, true); + yield* writeAccountPreferences(chat.other, { experiments: { [EXPERIMENT.CHAT]: false } }); + const told = yield* chat.post(chat.person, "@agent-2 please carry on."); + assert.deepEqual([told.receipt.told, told.receipt.held], [[], ["agent-2"]]); + assert.equal(eve.sent.length, 0); + }), + ); + }, +); /** A sandbox door no post with words alone opens: one that fails if it is. */ const noSandbox: InboxSandboxDoor = () => Promise.reject(new Error("the sandbox was opened")); @@ -6391,7 +6412,7 @@ const QUEUE_ASK = { }, } as const; -it.layer(getTestDB())("the coding agent's chat tools", (it) => { +it.layer(Layer.merge(getTestDB(), quietLiveWake))("the coding agent's chat tools", (it) => { /** * The owner's plan with two agents on it, each session claimed so a notice * reaches it, and each agent's tools as its own calls run them: a post @@ -7167,7 +7188,7 @@ function openedParts(eve: ReturnType) { return { text: message, parts: firstMessageParts(message) }; } -it.layer(getTestDB())("starting an agent from another", (it) => { +it.layer(Layer.merge(getTestDB(), quietLiveWake))("starting an agent from another", (it) => { /** A source agent on the owner's plan, on a choice and servers of its own, with a transcript of two turns, a branch, and a pull request, its GitHub answering as given. */ const sourceAgentOn = (github?: RepositoryScript, eve?: ReturnType) => Effect.gen(function* () { @@ -8583,7 +8604,7 @@ it.layer(getTestDB())("starting an agent from another", (it) => { const chooseChat = (userId: string, on: boolean) => writeAccountPreferences(userId, { experiments: { [EXPERIMENT.CHAT]: on } }); -it.layer(getTestDB())("a person's chat from the window", (it) => { +it.layer(Layer.merge(getTestDB(), quietLiveWake))("a person's chat from the window", (it) => { /** * The owner's plan with one agent on it, its session claimed so a notice * reaches it, its chat member, the plan's channel, and the chat's writes diff --git a/packages/service/src/agents/mac-command.ts b/packages/service/src/agents/mac-command.ts index 5c8f94d23d..42da37a6a5 100644 --- a/packages/service/src/agents/mac-command.ts +++ b/packages/service/src/agents/mac-command.ts @@ -3,6 +3,7 @@ import type { DatabaseServices } from "@luke/database/core/query"; import { claimMacCommands, macCommandResult, + macCommandSettledLiveStatement, macCommandsWaiting, macListeningSince, markMacListening, @@ -19,6 +20,7 @@ import { import type { UnparsedWireValue } from "@luke/wire"; import { describeWire, readEither } from "@luke/wire/effect"; import { Clock, Duration, Effect, Option, Result, Schedule, Schema } from "effect"; +import { lookedUntil } from "../http/held-read.js"; import { AGENT_TOOL } from "./agent-runtime/tools.js"; import { COMMAND_DESCRIPTION } from "./command-description.js"; @@ -44,7 +46,7 @@ import { COMMAND_DESCRIPTION } from "./command-description.js"; const MAC_COMMAND_SERVICE = { /** How long the Mac's read is held while nothing waits for it. */ HOLD: Duration.seconds(20), - /** How often a held read, and a call waiting on its result, looks again. */ + /** How often the Mac's held read looks again. */ POLL: Duration.millis(500), /** How lately a Mac must have read for a call to count it listening: one hold, and room for the next read to arrive. */ LISTENER_FRESH: Duration.seconds(45), @@ -153,13 +155,10 @@ function awaitedResult( Duration.seconds(queued.timeoutSeconds), MAC_COMMAND_SERVICE.ANSWER_GRACE, ); - return macCommandResult(userId, queued.id).pipe( - Effect.repeat({ - schedule: Schedule.spaced(MAC_COMMAND_SERVICE.POLL), - until: Option.isSome, - }), - Effect.timeoutOption(deadline), - Effect.map(Option.flatten), + return lookedUntil( + macCommandResult(userId, queued.id), + deadline, + Option.toArray(macCommandSettledLiveStatement(userId, queued.id)), ); } diff --git a/packages/service/src/http/held-read.ts b/packages/service/src/http/held-read.ts index c6a8b3205f..9d98aa6944 100644 --- a/packages/service/src/http/held-read.ts +++ b/packages/service/src/http/held-read.ts @@ -1,19 +1,30 @@ import { createHash } from "node:crypto"; -import { type Duration, Effect, Schedule } from "effect"; +import type { LiveStatement } from "@luke/database/core/live-statement"; +import { Duration, Effect, Option } from "effect"; +import { LiveWake } from "../live/live-wake.js"; /** * held-read.ts -- a read the desktop holds open while nothing it reads changed since its cursor: the cursor, and the hold. * * The cursor is a digest of where every row a read covers stands, so any * change to any of them moves it, and a reader that holds it hears the next - * change once. A read whose cursor is still the rows' is held, looked at - * again every poll, each look reading where the rows stand and none of - * their words, and let go at the hold's end, when it answers the rows - * unchanged and the reader asks again. The Inbox and a plan's comments are - * read this way, so a window hears a teammate's or an agent's change the - * moment it lands without asking on a clock of its own. + * change once. A read whose cursor is still the rows' is held, and let go + * at the hold's end, when it answers the rows unchanged and the reader asks + * again. The Inbox and a plan's comments are read this way, so a window + * hears a teammate's or an agent's change the moment it lands without + * asking on a clock of its own. + * + * A hold watches statements covering the rows its looks read over Neon + * Realtime (`live/live-wake.ts`), and looks again as they change, each look + * reading where the rows stand and none of their words, with a look every + * `LIVE_SAFETY_POLL` beside, so a change the watch missed is heard late + * rather than never. Any wait of the service's on a row is held the same + * way (`lookedUntil`). */ +/** How often a watched wait looks though nothing told it to: what a change the watch missed waits at most. */ +const LIVE_SAFETY_POLL = Duration.seconds(5); + /** How many characters of the digest a cursor carries: far more than any two readings of one plan's rows could share by chance. */ const CURSOR_CHARS = 32; @@ -47,6 +58,29 @@ export function cursorOf(stamps: readonly CursorStamp[]): string { return createHash("sha256").update(JSON.stringify(standing)).digest("hex").slice(0, CURSOR_CHARS); } +/** + * Looks until a look finds something, or the hold ends: between looks it + * waits for the next change of the `live` statements, or the safety beat. + * None past the hold. + */ +export function lookedUntil( + look: Effect.Effect, E, R>, + hold: Duration.Input, + live: readonly LiveStatement[] = [], +): Effect.Effect, E, R> { + return Effect.scoped( + Effect.gen(function* () { + const wake = yield* LiveWake; + const watch = yield* wake.watch(live); + while (true) { + const found = yield* look; + if (Option.isSome(found)) return found; + yield* Effect.raceFirst(watch.changed, Effect.sleep(LIVE_SAFETY_POLL)); + } + }), + ).pipe(Effect.timeoutOption(hold), Effect.map(Option.flatten)); +} + /** * Waits until the stamps move past the reader's cursor, or the hold ends: * each look reads where the rows stand and none of their words, and the @@ -55,13 +89,11 @@ export function cursorOf(stamps: readonly CursorStamp[]): string { export function heldUntilMoved( stamps: Effect.Effect, after: string, - poll: Duration.Duration, hold: Duration.Duration, + live: readonly LiveStatement[] = [], ): Effect.Effect { - const moved = Effect.map(stamps, (standing) => cursorOf(standing) !== after); - return moved.pipe( - Effect.repeat({ schedule: Schedule.spaced(poll), until: (changed) => changed }), - Effect.timeoutOption(hold), - Effect.asVoid, + const moved = Effect.map(stamps, (standing) => + cursorOf(standing) === after ? Option.none() : Option.some(true), ); + return Effect.asVoid(lookedUntil(moved, hold, live)); } diff --git a/packages/service/src/inbox/inbox-store.ts b/packages/service/src/inbox/inbox-store.ts index 1278c666d8..a2b7c74046 100644 --- a/packages/service/src/inbox/inbox-store.ts +++ b/packages/service/src/inbox/inbox-store.ts @@ -1,6 +1,5 @@ import { randomUUID } from "node:crypto"; import { posix } from "node:path"; -import { isDeepStrictEqual } from "node:util"; import { LIVE_COUNT, type LiveStatement, @@ -48,6 +47,7 @@ import { } from "@luke/features/coding-agent-wire"; import { type InboxItemPlace, inboxAttachmentAddress } from "@luke/features/inbox-attachment-link"; import { + askStillAsked, INBOX_APPROVAL_OPTION, INBOX_ASK, INBOX_ASK_STATUS, @@ -60,25 +60,26 @@ import { INBOX_WIRE_VERSION, type InboxAnswers, type InboxAsk, + type InboxAskStanding, type InboxBlock, type InboxImageType, type InboxItem, type InboxMessage, type InboxPost, type InboxPostBlock, - type InboxReadAsk, type InboxReply, type InboxResponse, inboxAnswersRefusal, inboxAnswersSchema, inboxAskResponse, - inboxAskSchema, inboxBlockSchema, inboxReadAskSchema, inboxReplyAnswers, inboxReplySchema, inboxResponseAsk, inboxResponseSchema, + knownAsk, + standingAsk, } from "@luke/features/inbox-wire"; import { LINK_PLACE } from "@luke/features/luke-link"; import { insideCode } from "@luke/features/mention-link"; @@ -89,6 +90,7 @@ import { desc, eq, getTableName, + inArray, isNotNull, isNull, max, @@ -97,7 +99,7 @@ import { type SQL, sql, } from "drizzle-orm"; -import type { AnyPgColumn, AnyPgTable } from "drizzle-orm/pg-core"; +import { type AnyPgColumn, type AnyPgTable, alias } from "drizzle-orm/pg-core"; import { DateTime, Effect, Option, Result, Schema } from "effect"; import { SqlClient, SqlSchema } from "effect/sql"; @@ -414,62 +416,8 @@ const MessageRowSchema = Schema.Struct({ type MessageRow = typeof MessageRowSchema.Type; -/** Where an item stands, as each ask of its thread is read against it. */ -interface AskStanding { - readonly status: InboxItem["status"]; - readonly response: InboxResponse; - readonly openAskMessageId: string | null; - readonly closedReason: string | null; -} - -/** - * Whether the item's response still stands for the ask: the ask a response - * maps to is the ask itself, a to-do any `do` ask, whose steps its blocks - * carry. A post from before version 5 rewrites the response and not the - * thread, so an ask it posted over stands for it no longer. - */ -function askStandsFor(response: InboxResponse, ask: InboxAsk): boolean { - const asked = inboxResponseAsk(response); - if (asked === undefined || asked.type !== ask.type) return false; - return asked.type === INBOX_ASK.DO || isDeepStrictEqual(asked, ask); -} - -/** Whether the ask held open is still the one the item asks: an ask of a type a newer deployment wrote is taken at its word. */ -function askStillAsked(response: InboxResponse, ask: InboxReadAsk): boolean { - const known = knownAsk(ask); - return known === undefined || askStandsFor(response, known); -} - -/** - * An ask as a read carries it: as it was kept, unless it was kept open and - * the item no longer holds it open, which a deployment from before version - * 5 leaves by closing the item without a word to the ask; then withdrawn - * with the item's reason where the item closed on it, answered where the - * item was answered on it, and replaced where the item holds another or, - * posted over from before version 5, asks something else. - * Note that an ask withdrawn with no reason is a member's close without an - * answer, the item's `closedBy` naming the member while the close stands, - * because an agent's close always says why: a reader tells the two closes - * apart by the reason alone, as it tells a closed item's. - */ -function standingAsk(ask: InboxReadAsk, messageId: string, item: AskStanding): InboxReadAsk { - if (ask.status !== INBOX_ASK_STATUS.OPEN) return ask; - const replaced = { ...ask, status: INBOX_ASK_STATUS.REPLACED }; - if (messageId !== item.openAskMessageId) return replaced; - switch (item.status) { - case INBOX_STATUS.OPEN: - return askStillAsked(item.response, ask) ? ask : replaced; - case INBOX_STATUS.ANSWERED: - return { ...ask, status: INBOX_ASK_STATUS.ANSWERED }; - case INBOX_STATUS.CLOSED: - return item.closedReason === null - ? { ...ask, status: INBOX_ASK_STATUS.WITHDRAWN } - : { ...ask, status: INBOX_ASK_STATUS.WITHDRAWN, withdrawnReason: item.closedReason }; - } -} - /** A message as the wire carries it: the member named where their account stands, no files where it carried none, and its ask as it stands on the item. */ -function storedMessage(row: MessageRow, item: AskStanding): InboxMessage { +function storedMessage(row: MessageRow, item: InboxAskStanding): InboxMessage { return { id: row.id, author: row.author, @@ -1416,6 +1364,187 @@ export function accountInboxLiveStatements( ); } +/** + * The statements a plan's or an agent's inbox is subscribed to live: its + * open items and its latest settled ones, as one result, and the threads + * and files they carry, each its own. + */ +export interface ScopeInboxLiveStatements { + readonly items: LiveStatement; + readonly messages: LiveStatement; + readonly files: LiveStatement; +} + +/** A scope's thread read under its own name, so a message's latest siblings are read beside it. */ +const sibling = alias(inboxMessage, "sibling"); + +/** + * A scope's items written for Neon Realtime: the same rows, joins, bound, + * and rule of who may read them as `scopeItems` with `MAX_ITEMS_READ`, and + * the same columns, whether each is unread for the reader among them, each + * column under the key `live-wire.ts` reads it by and each instant in epoch + * milliseconds. The open items and the latest settled ones are one + * statement, so an item settling moves from one to the other in one change + * rather than leaving the reader a moment holding neither. The order is the + * reader's to keep, since Realtime keeps none. + * + * Note that each item's thread, the messages either of a read's threads + * draws (`messagesOf`'s `LATEST_PLAIN_MESSAGES` and `THREAD_MESSAGES`), and + * the files its answer and its messages carried are statements of their own, + * which the reader joins to the items, rather than lists the item's row + * aggregates, because `jsonb_agg` and `jsonb_build_object` are only stable + * and Realtime refuses any function that is not immutable. + */ +function scopeLiveStatements(scope: InboxScope): Option.Option { + const latestSettled = queryBuilder + .select({ id: inboxItem.id }) + .from(inboxItem) + .where(and(inScope(scope), ne(inboxItem.status, INBOX_STATUS.OPEN))) + .orderBy(...LATEST_FIRST) + .limit(INBOX_BOUNDS.MAX_ITEMS_READ); + const read = and( + inScope(scope), + or(eq(inboxItem.status, INBOX_STATUS.OPEN), inArray(inboxItem.id, latestSettled)), + ); + const itemIds = queryBuilder.select({ id: inboxItem.id }).from(inboxItem).where(read); + const ofItem = eq(sibling.itemId, qualified(inboxMessage, inboxMessage.itemId)); + const latestOf = (plain: boolean) => + queryBuilder + .select({ id: sibling.id }) + .from(sibling) + .where(plain ? and(ofItem, isNull(sibling.ask), isNull(sibling.answers)) : ofItem) + .orderBy(desc(sibling.createdAt), desc(sibling.id)) + .limit(INBOX_BOUNDS.MAX_MESSAGES_READ); + const first = queryBuilder + .select({ id: sibling.id }) + .from(sibling) + .where(ofItem) + .orderBy(sibling.createdAt, sibling.id) + .limit(1); + // The messages either thread draws: the latest of all and of those that neither ask nor answer, the first, and the one whose ask is held open. + const latestMessage = and( + inArray(inboxMessage.itemId, itemIds), + or( + inArray(MESSAGE.id, latestOf(false)), + inArray(MESSAGE.id, latestOf(true)), + inArray(MESSAGE.id, first), + inArray( + MESSAGE.id, + queryBuilder.select({ id: inboxItem.openAskMessageId }).from(inboxItem).where(read), + ), + ), + ); + // The message that answered an item's ask, under the answer's key, whose files are the answer's. + const answering = queryBuilder + .select({ id: inboxMessage.id }) + .from(inboxMessage) + .innerJoin(inboxItem, eq(inboxItem.id, inboxMessage.itemId)) + .where( + and(read, eq(inboxMessage.clientKey, inboxItem.answerKey), isNotNull(inboxMessage.answers)), + ); + const items = liveStatementOf( + queryBuilder + .select( + liveSelection( + { + id: inboxItem.id, + agentId: inboxItem.agentId, + key: inboxItem.key, + revision: inboxItem.revision, + version: inboxItem.version, + title: inboxItem.title, + blocks: inboxItem.blocks, + response: inboxItem.response, + needsReply: inboxItem.needsReply, + status: inboxItem.status, + startedBy: inboxItem.userId, + startedByName: INBOX_ITEM_COLUMNS.startedByName, + answer: inboxItem.answer, + answeredBy: inboxItem.answeredBy, + answeredByName: INBOX_ITEM_COLUMNS.answeredByName, + closedReason: inboxItem.closedReason, + closedBy: inboxItem.closedBy, + closedByName: INBOX_ITEM_COLUMNS.closedByName, + openAskMessageId: inboxItem.openAskMessageId, + unread: unreadFor(scope.userId), + }, + { + postedAt: inboxItem.postedAt, + updatedAt: inboxItem.updatedAt, + // As `SETTLED_AT` reads it, the instant read at UTC as every live instant is. + settledAt: sql`coalesce(${inboxItem.settledAt}, case when ${SETTLED} then ${inboxItem.updatedAt} end)`, + }, + ), + ) + .from(inboxItem) + .where(read), + ); + const messages = liveStatementOf( + queryBuilder + .select( + liveSelection( + { + id: MESSAGE.id, + itemId: qualified(inboxMessage, inboxMessage.itemId), + author: MESSAGE.author, + userId: MESSAGE.userId, + name: sql`(select "name" from ${user} where ${user.id} = ${MESSAGE.userId})`, + text: MESSAGE.text, + ask: MESSAGE.ask, + answers: MESSAGE.answers, + }, + { createdAt: MESSAGE.createdAt }, + ), + ) + .from(inboxMessage) + .where(latestMessage), + ); + const files = liveStatementOf( + queryBuilder + .select( + liveSelection({ + attachmentId: attachment.id, + answerOf: sql`case when ${attachment.kind} = ${INBOX_ATTACHMENT_KIND.ANSWER} or ${attachment.inboxMessageId} in (${answering}) then ${attachment.inboxItemId} end`, + messageId: attachment.inboxMessageId, + name: attachment.name, + mediaType: attachment.contentType, + position: attachment.position, + }), + ) + .from(attachment) + .where( + or( + and( + eq(attachment.kind, INBOX_ATTACHMENT_KIND.ANSWER), + inArray(attachment.inboxItemId, itemIds), + ), + inArray( + attachment.inboxMessageId, + queryBuilder.select({ id: MESSAGE.id }).from(inboxMessage).where(latestMessage), + ), + inArray(attachment.inboxMessageId, answering), + ), + ), + ); + return Option.all({ items, messages, files }); +} + +/** One plan's inbox written for Neon Realtime, as `taskInboxItems` reads it. */ +export function taskInboxLiveStatements( + userId: string, + taskId: string, +): Option.Option { + return scopeLiveStatements({ userId, scope: INBOX_SCOPE.TASK, id: taskId }); +} + +/** One agent's inbox written for Neon Realtime, as `memberAgentInboxItems` reads it. */ +export function memberAgentInboxLiveStatements( + userId: string, + agentId: string, +): Option.Option { + return scopeLiveStatements({ userId, scope: INBOX_SCOPE.AGENT_OF_WORKSPACE, id: agentId }); +} + /** The scope's items: every open one, then the latest settled ones up to the limit. */ function scopeItems( scope: InboxScope, @@ -2562,12 +2691,6 @@ function bytesOf(file: CodingAgentAttachment): Uint8Array { return new Uint8Array(Buffer.from(file.data, "base64")); } -/** An ask as a message keeps it without where it stands, where this build knows its type; nothing for a type a newer deployment wrote. */ -function knownAsk(ask: InboxReadAsk): InboxAsk | undefined { - const { status: _status, withdrawnReason: _reason, ...kept } = ask; - return Schema.is(inboxAskSchema)(kept) ? kept : undefined; -} - /** A note bounded as an answer's note on the item, which a desktop before version 5 reads it as; words past the bound end in an ellipsis. */ function boundedNote(words: string): string { const max = INBOX_BOUNDS.MAX_NOTE_CHARS; diff --git a/packages/service/src/live/live-app.test.ts b/packages/service/src/live/live-app.test.ts index 17c2e0a1fd..48b1f45d5c 100644 --- a/packages/service/src/live/live-app.test.ts +++ b/packages/service/src/live/live-app.test.ts @@ -1,10 +1,16 @@ import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; import { it } from "@effect/vitest"; import type { LiveStatement } from "@luke/database/core/live-statement"; -import { accountInboxLiveAnswerSchema } from "@luke/features/live-wire"; +import { Database } from "@luke/database/core/query"; +import { createCodingAgent } from "@luke/database/models/coding-agent-store"; +import { user } from "@luke/database/schemas/auth"; +import { getTestDB, startTask } from "@luke/database/testing"; +import { accountInboxLiveAnswerSchema, scopeInboxLiveAnswerSchema } from "@luke/features/live-wire"; import type { WireBoundaryInput } from "@luke/wire"; import { Effect, Layer, Option, Schema } from "effect"; import { HttpRouter } from "effect/http"; +import { SqlClient } from "effect/sql"; import { HOSTED_API_ERROR, HOSTED_HTTP_STATUS } from "../http/http.js"; import { liveApp } from "./live-app.js"; import { type LiveSealer, LiveSealing, SealRefused } from "./realtime.js"; @@ -24,6 +30,14 @@ const ORIGIN = "https://luke.test"; const LIVE_INBOX = "/api/live/inbox"; const REALTIME_URL = "wss://realtime.example/v1"; +/** A new account, as the store holds one. */ +const openUser = Effect.gen(function* () { + const db = yield* Database; + const userId = `user-${randomUUID()}`; + yield* db.insert(user).values({ id: userId, name: "Test User", email: `${userId}@luke.test` }); + return userId; +}); + /** A sealer that seals a statement as its values, so whose read it is shows. */ const SCRIPTED: LiveSealer = { url: REALTIME_URL, @@ -35,13 +49,19 @@ const SCRIPTED: LiveSealer = { }), }; -/** The group answering one request as `bearer`, over the deployment's Realtime where it has one. */ +/** The group answering one request as `bearer`, over the deployment's Realtime where it has one, at the path asked. */ const ask = (options: { readonly bearer?: string; readonly method?: string; + readonly path?: string; readonly sealing: Option.Option; }) => Effect.gen(function* () { + const services = Layer.mergeAll( + Layer.succeed(LiveSealing, options.sealing), + Layer.succeed(SqlClient.SqlClient, yield* SqlClient.SqlClient), + Layer.succeed(Database, yield* Database), + ); const { handler, dispose } = HttpRouter.toWebHandler( liveApp({ resolveUserId: (incoming) => { @@ -50,12 +70,12 @@ const ask = (options: { bearer.startsWith("Bearer ") ? Option.some(bearer.slice(7)) : Option.none(), ); }, - }).pipe(HttpRouter.provideRequest(Layer.succeed(LiveSealing, options.sealing))), + }).pipe(HttpRouter.provideRequest(services)), { disableLogger: true }, ); const response = yield* Effect.promise(() => handler( - new Request(new URL(LIVE_INBOX, ORIGIN), { + new Request(new URL(options.path ?? LIVE_INBOX, ORIGIN), { method: options.method ?? "GET", headers: options.bearer === undefined ? {} : { authorization: `Bearer ${options.bearer}` }, @@ -68,67 +88,110 @@ const ask = (options: { return { status: response.status, body }; }); -it.effect("a request without a bearer is refused, and one by another method is not taken", () => - Effect.gen(function* () { - assert.deepEqual(yield* ask({ sealing: Option.some(SCRIPTED) }), { - status: HOSTED_HTTP_STATUS.UNAUTHORIZED, - body: { error: HOSTED_API_ERROR.INVALID_TOKEN }, - }); - assert.deepEqual( - yield* ask({ bearer: "user-dana", method: "POST", sealing: Option.some(SCRIPTED) }), - { - status: HOSTED_HTTP_STATUS.METHOD_NOT_ALLOWED, - body: { error: HOSTED_API_ERROR.METHOD_NOT_ALLOWED }, - }, - ); - }), -); - -it.effect("a deployment whose Realtime is misconfigured answers unavailable", () => - Effect.gen(function* () { - assert.deepEqual(yield* ask({ bearer: "user-dana", sealing: Option.none() }), { - status: HOSTED_HTTP_STATUS.SERVICE_UNAVAILABLE, - body: { error: HOSTED_API_ERROR.UNAVAILABLE }, - }); - }), -); - -it.effect( - "every part of the account inbox is sealed for the bearer's own account, with the address to subscribe at, and a seal refused answers unavailable", - () => +it.layer(getTestDB())("the live reads", (it) => { + it.effect("a request without a bearer is refused, and one by another method is not taken", () => Effect.gen(function* () { - const dana = yield* ask({ bearer: "user-dana", sealing: Option.some(SCRIPTED) }); - const eli = yield* ask({ bearer: "user-eli", sealing: Option.some(SCRIPTED) }); - - assert.equal(dana.status, HOSTED_HTTP_STATUS.OK); - const answer = Schema.decodeUnknownSync(accountInboxLiveAnswerSchema)(dana.body); - assert.equal(answer.url, REALTIME_URL); - const parts = Object.entries(answer.queries); - assert.deepEqual(parts.map(([part]) => part).toSorted(), [ - "automations", - "chatAsks", - "chatNotices", - "failedRuns", - "items", - "planless", - "plans", - ]); - for (const [part, sealed] of parts) { - assert.ok(sealed.capability.includes('"user-dana"'), `${part} is read for the bearer`); - assert.ok(!sealed.capability.includes("user-eli"), `${part} is read for no one else`); - } - const theirs = Schema.decodeUnknownSync(accountInboxLiveAnswerSchema)(eli.body); - assert.ok( - Object.values(theirs.queries).every((sealed) => sealed.capability.includes('"user-eli"')), + assert.deepEqual(yield* ask({ sealing: Option.some(SCRIPTED) }), { + status: HOSTED_HTTP_STATUS.UNAUTHORIZED, + body: { error: HOSTED_API_ERROR.INVALID_TOKEN }, + }); + assert.deepEqual( + yield* ask({ bearer: "user-dana", method: "POST", sealing: Option.some(SCRIPTED) }), + { + status: HOSTED_HTTP_STATUS.METHOD_NOT_ALLOWED, + body: { error: HOSTED_API_ERROR.METHOD_NOT_ALLOWED }, + }, ); + }), + ); - const refusing: LiveSealer = { - url: REALTIME_URL, - seal: () => Effect.fail(new SealRefused({ reason: "refused" })), - }; - assert.deepEqual(yield* ask({ bearer: "user-dana", sealing: Option.some(refusing) }), { + it.effect("a deployment whose Realtime is misconfigured answers unavailable", () => + Effect.gen(function* () { + assert.deepEqual(yield* ask({ bearer: "user-dana", sealing: Option.none() }), { status: HOSTED_HTTP_STATUS.SERVICE_UNAVAILABLE, body: { error: HOSTED_API_ERROR.UNAVAILABLE }, }); }), -); + ); + + it.effect( + "every part of the account inbox is sealed for the bearer's own account, with the address to subscribe at, and a seal refused answers unavailable", + () => + Effect.gen(function* () { + const dana = yield* ask({ bearer: "user-dana", sealing: Option.some(SCRIPTED) }); + const eli = yield* ask({ bearer: "user-eli", sealing: Option.some(SCRIPTED) }); + + assert.equal(dana.status, HOSTED_HTTP_STATUS.OK); + const answer = Schema.decodeUnknownSync(accountInboxLiveAnswerSchema)(dana.body); + assert.equal(answer.url, REALTIME_URL); + const parts = Object.entries(answer.queries); + assert.deepEqual(parts.map(([part]) => part).toSorted(), [ + "automations", + "chatAsks", + "chatNotices", + "failedRuns", + "items", + "planless", + "plans", + ]); + for (const [part, sealed] of parts) { + assert.ok(sealed.capability.includes('"user-dana"'), `${part} is read for the bearer`); + assert.ok(!sealed.capability.includes("user-eli"), `${part} is read for no one else`); + } + const theirs = Schema.decodeUnknownSync(accountInboxLiveAnswerSchema)(eli.body); + assert.ok( + Object.values(theirs.queries).every((sealed) => sealed.capability.includes('"user-eli"')), + ); + + const refusing: LiveSealer = { + url: REALTIME_URL, + seal: () => Effect.fail(new SealRefused({ reason: "refused" })), + }; + assert.deepEqual(yield* ask({ bearer: "user-dana", sealing: Option.some(refusing) }), { + status: HOSTED_HTTP_STATUS.SERVICE_UNAVAILABLE, + body: { error: HOSTED_API_ERROR.UNAVAILABLE }, + }); + }), + ); + + it.effect( + "a plan's inbox, or an agent's, is sealed as its open and latest settled items for an account that reaches it, and not found for one that does not", + () => + Effect.gen(function* () { + const owner = yield* openUser; + const stranger = yield* openUser; + const task = yield* startTask(owner, { name: "Teammate invitations" }); + const started = yield* createCodingAgent(owner, { + taskId: task.id, + idempotencyKey: randomUUID(), + model: "anthropic/claude-opus-5.5", + effort: "high", + repository: "acme/relay", + planSnapshot: "# Teammate invitations\n", + prompt: null, + }); + assert.ok(Option.isSome(started)); + const agentId = started.value.agent.id; + const taskPath = `/api/live/task-inbox?id=${task.id}`; + const agentPath = `/api/live/agent-inbox?id=${agentId}`; + + for (const path of [taskPath, agentPath]) { + const sealed = yield* ask({ bearer: owner, path, sealing: Option.some(SCRIPTED) }); + assert.equal(sealed.status, HOSTED_HTTP_STATUS.OK); + const answer = Schema.decodeUnknownSync(scopeInboxLiveAnswerSchema)(sealed.body); + assert.equal(answer.url, REALTIME_URL); + for (const query of Object.values(answer.queries)) { + assert.ok(query.capability.includes(`"${owner}"`), "read for the bearer"); + } + assert.deepEqual(yield* ask({ bearer: stranger, path, sealing: Option.some(SCRIPTED) }), { + status: HOSTED_HTTP_STATUS.NOT_FOUND, + body: { error: HOSTED_API_ERROR.NOT_FOUND }, + }); + assert.deepEqual(yield* ask({ bearer: owner, path, sealing: Option.none() }), { + status: HOSTED_HTTP_STATUS.SERVICE_UNAVAILABLE, + body: { error: HOSTED_API_ERROR.UNAVAILABLE }, + }); + } + }), + ); +}); diff --git a/packages/service/src/live/live-app.ts b/packages/service/src/live/live-app.ts index fafcf93195..2155b5b9a0 100644 --- a/packages/service/src/live/live-app.ts +++ b/packages/service/src/live/live-app.ts @@ -1,7 +1,10 @@ +import type { DatabaseServices } from "@luke/database/core/query"; import { ownFailedRunsLiveStatement } from "@luke/database/models/automation-store"; +import { readTask } from "@luke/database/models/task-store"; import { HOSTED_SERVICE_PATH } from "@luke/features"; import { Effect, Layer, Option } from "effect"; import { HttpRouter, HttpServerRequest, type HttpServerResponse } from "effect/http"; +import { reachedAgent, resolvedUserId, uuidOf } from "../agents/agent-reach.js"; import { HOSTED_HTTP_STATUS } from "../http/http.js"; import { HOSTED_REFUSAL, @@ -9,11 +12,17 @@ import { hostedJsonResponse, hostedNotFoundRoute, hostedRefusing, + hostedStoreOrUnavailable, type UserIdResolver, } from "../http/http-effect.js"; -import { accountInboxLiveStatements } from "../inbox/inbox-store.js"; +import { + accountInboxLiveStatements, + memberAgentInboxLiveStatements, + type ScopeInboxLiveStatements, + taskInboxLiveStatements, +} from "../inbox/inbox-store.js"; import { ANY_METHOD, type WebRoutes } from "../route.js"; -import { LiveSealing } from "./realtime.js"; +import { type LiveSealer, LiveSealing } from "./realtime.js"; /** * live-app.ts -- the reads the desktop follows over Neon Realtime, sealed for the account the bearer names. @@ -26,11 +35,21 @@ import { LiveSealing } from "./realtime.js"; * subscription reads that account's rows and no other's, and stays bound to * the same rule of who may read them as the rows change. A deployment whose * Realtime is misconfigured answers `unavailable`, and the desktop asks - * again a moment later. Nothing here reads a row: the store is read by Realtime. + * again a moment later. + * + * `GET /api/live/task-inbox?id=` and `GET /api/live/agent-inbox?id=` answer + * one plan's inbox, or one agent's, the same way: its open items and its + * latest settled ones as one statement, their threads and files as two + * more, after the plan or the agent is read as the account, + * so one it does not reach is not found, as its held read answers. + * Nothing else here reads a row: the store is read by Realtime. */ const HTTP_METHOD = { GET: "GET" } as const; +/** The query a scope's endpoint names its plan or agent under. */ +const LIVE_QUERY = { ID: "id" } as const; + export interface LiveAppSeams { resolveUserId: UserIdResolver; } @@ -73,14 +92,78 @@ const accountInboxLiveEndpoint = /* @__PURE__ */ Effect.fn("web/accountInboxLive }, ); +/** A scope's statements sealed, with the address to subscribe at; unavailable where they could not be. */ +const sealedScope = ( + sealing: Option.Option, + statements: Option.Option, +): Effect.Effect => + Effect.gen(function* () { + if (Option.isNone(sealing) || Option.isNone(statements)) { + return yield* Effect.fail(HOSTED_REFUSAL.UNAVAILABLE); + } + const { seal, url } = sealing.value; + const queries = yield* Effect.all({ + items: seal(statements.value.items), + messages: seal(statements.value.messages), + files: seal(statements.value.files), + }).pipe(Effect.mapError(() => HOSTED_REFUSAL.UNAVAILABLE)); + return hostedJsonResponse(HOSTED_HTTP_STATUS.OK, { url, queries }); + }); + +/** The GET a scope's endpoint takes, as the web request it is. */ +const getRequest = Effect.gen(function* () { + const incoming = yield* HttpServerRequest.HttpServerRequest; + if (incoming.method !== HTTP_METHOD.GET) { + return yield* Effect.fail(HOSTED_REFUSAL.METHOD_NOT_ALLOWED); + } + return yield* Effect.orDie(HttpServerRequest.toWeb(incoming)); +}); + +const taskInboxLiveEndpoint = /* @__PURE__ */ Effect.fn("web/taskInboxLiveEndpoint")(function* ( + seams: LiveAppSeams, +): Effect.fn.Return< + HttpServerResponse.HttpServerResponse, + HostedRefusal, + HttpServerRequest.HttpServerRequest | DatabaseServices +> { + const request = yield* getRequest; + const taskId = yield* uuidOf(request, LIVE_QUERY.ID); + const userId = yield* resolvedUserId(seams, request); + const task = yield* hostedStoreOrUnavailable(readTask(userId, taskId)); + if (Option.isNone(task)) return yield* Effect.fail(HOSTED_REFUSAL.NOT_FOUND); + return yield* sealedScope(yield* LiveSealing, taskInboxLiveStatements(userId, taskId)); +}); + +const agentInboxLiveEndpoint = /* @__PURE__ */ Effect.fn("web/agentInboxLiveEndpoint")(function* ( + seams: LiveAppSeams, +): Effect.fn.Return< + HttpServerResponse.HttpServerResponse, + HostedRefusal, + HttpServerRequest.HttpServerRequest | DatabaseServices +> { + const request = yield* getRequest; + const { userId, agent } = yield* reachedAgent(seams, request); + return yield* sealedScope(yield* LiveSealing, memberAgentInboxLiveStatements(userId, agent.id)); +}); + /** The group: each live read's path, and the hosted vocabulary's own refusal for any other. */ -export function liveApp(seams: LiveAppSeams): WebRoutes { +export function liveApp(seams: LiveAppSeams): WebRoutes { return Layer.mergeAll( HttpRouter.add( ANY_METHOD, HOSTED_SERVICE_PATH.LIVE_INBOX, hostedRefusing(accountInboxLiveEndpoint(seams)), ), + HttpRouter.add( + ANY_METHOD, + HOSTED_SERVICE_PATH.LIVE_TASK_INBOX, + hostedRefusing(taskInboxLiveEndpoint(seams)), + ), + HttpRouter.add( + ANY_METHOD, + HOSTED_SERVICE_PATH.LIVE_AGENT_INBOX, + hostedRefusing(agentInboxLiveEndpoint(seams)), + ), hostedNotFoundRoute, ); } diff --git a/packages/service/src/live/live-statements.test.ts b/packages/service/src/live/live-statements.test.ts new file mode 100644 index 0000000000..22c0d4109a --- /dev/null +++ b/packages/service/src/live/live-statements.test.ts @@ -0,0 +1,92 @@ +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import { it } from "@effect/vitest"; +import { ownFailedRunsLiveStatement } from "@luke/database/models/automation-store"; +import { getTestDB, mutableFunctionsOf } from "@luke/database/testing"; +import { ConfigProvider, Effect, Layer, Option } from "effect"; +import { + accountInboxLiveStatements, + memberAgentInboxLiveStatements, + taskInboxLiveStatements, +} from "../inbox/inbox-store.js"; +import { LiveSealing, liveSealingFromEnvironment, REALTIME_ENVIRONMENT } from "./realtime.js"; + +/** + * Every statement the live endpoints seal calls only functions Postgres + * marks immutable, since Neon Realtime refuses any other ("scalar expression + * uses a mutable function") where Postgres runs it without a word. + */ + +const USER_ID = "user-live-statements"; + +/** A deployment's Realtime over a synthetic secret in the shape its SDK reads, so no credential is written down. */ +const SEALING_ENVIRONMENT = { + DATABASE_URL: "postgresql://luke:synthetic@db.example/neondb?sslmode=require", + [REALTIME_ENVIRONMENT.URL]: "wss://realtime.example/v1", + [REALTIME_ENVIRONMENT.SECRET]: `nrt_live_1${Buffer.alloc(32).toString("base64url")}`, +}; + +/** Every live statement the endpoints seal, by the part it answers. */ +function liveStatements() { + const account = Option.getOrThrow(accountInboxLiveStatements(USER_ID)); + const task = Option.getOrThrow(taskInboxLiveStatements(USER_ID, randomUUID())); + const agent = Option.getOrThrow(memberAgentInboxLiveStatements(USER_ID, randomUUID())); + return { + ...Object.fromEntries( + Object.entries(account).map(([part, query]) => [`account.${part}`, query]), + ), + "account.failedRuns": Option.getOrThrow(ownFailedRunsLiveStatement(USER_ID)), + ...Object.fromEntries(Object.entries(task).map(([part, query]) => [`task.${part}`, query])), + ...Object.fromEntries(Object.entries(agent).map(([part, query]) => [`agent.${part}`, query])), + }; +} + +it.layer(getTestDB())("the live statements", (it) => { + it.effect( + "every statement a live endpoint seals calls only immutable functions, as Realtime requires", + () => + Effect.gen(function* () { + const mutable = yield* Effect.forEach( + Object.entries(liveStatements()), + ([part, statement]) => + Effect.map(mutableFunctionsOf(statement), (called) => [part, called] as const), + ); + assert.deepEqual( + mutable.filter(([, called]) => called.length > 0), + [], + ); + }), + ); + + it.effect( + "every statement a live endpoint seals is a Drizzle select Realtime's Drizzle adapter seals, each key the name Postgres answers it under", + () => + Effect.gen(function* () { + const sealing = yield* Effect.gen(function* () { + return yield* LiveSealing; + }).pipe( + Effect.provide( + liveSealingFromEnvironment.pipe( + Layer.provide(ConfigProvider.layer(ConfigProvider.fromUnknown(SEALING_ENVIRONMENT))), + ), + ), + ); + assert.ok(Option.isSome(sealing)); + const { seal } = sealing.value; + const refused = yield* Effect.forEach( + Object.entries(liveStatements()), + ([part, statement]): Effect.Effect => + statement.query === undefined + ? Effect.succeed(`${part}: no Drizzle select`) + : Effect.match(seal(statement), { + onFailure: () => `${part}: refused`, + onSuccess: () => undefined, + }), + ); + assert.deepEqual( + refused.filter((entry) => entry !== undefined), + [], + ); + }), + ); +}); diff --git a/packages/service/src/live/live-wake.test.ts b/packages/service/src/live/live-wake.test.ts new file mode 100644 index 0000000000..c61e83a84d --- /dev/null +++ b/packages/service/src/live/live-wake.test.ts @@ -0,0 +1,220 @@ +import assert from "node:assert/strict"; +import { it } from "@effect/vitest"; +import { Effect, Exit, Fiber, Option } from "effect"; +import { TestClock } from "effect/testing"; +import { lookedUntil } from "../http/held-read.js"; +import { + type LiveSubscriber, + LiveWake, + type LiveWatch, + realtimeServers, + realtimeWake, +} from "./live-wake.js"; + +/** + * A wait on a row, woken by Neon Realtime: it looks again as soon as the + * rows change, and on a safety beat beside; with no wake provided it fails + * rather than polling; and the wake over Realtime's direct server subscribes + * to each statement for the length of the wait, subscribes again where one + * moves to error, on a new server where the connection failed, and lets go + * after. + * + * Statements and rows are synthetic; Realtime is a script. + */ + +const STATEMENT = { sql: "select 1 as one where $1 = $1", params: ["user-dana"] } as const; + +/** Every step the wait has to take, taken. */ +const settled = Effect.repeat(Effect.yieldNow, { times: 50 }); + +/** A look that finds nothing until `found` is set, counting each time it looks. */ +function scriptedLook() { + let found: string | undefined; + let looks = 0; + return { + look: Effect.sync(() => { + looks += 1; + return Option.fromNullishOr(found); + }), + find: (value: string) => { + found = value; + }, + looks: () => looks, + }; +} + +/** A watch a test changes by hand. */ +function scriptedWatch() { + let wake: (() => void) | undefined; + const watch: LiveWatch = { + changed: Effect.callback((resume) => { + wake = () => resume(Effect.void); + }), + }; + return { + wake: { watch: () => Effect.succeed(watch) }, + change: () => wake?.(), + }; +} + +it.effect("with no wake provided a wait fails rather than looking on a clock", () => + Effect.gen(function* () { + const script = scriptedLook(); + const exit = yield* Effect.exit(lookedUntil(script.look, "10 seconds", [STATEMENT])); + assert.ok(Exit.isFailure(exit)); + assert.equal(script.looks(), 0); + }), +); + +it.effect( + "watched, a wait looks again the moment its rows change rather than on its poll, and on the safety beat beside", + () => + Effect.gen(function* () { + const script = scriptedLook(); + const watch = scriptedWatch(); + const waiting = yield* Effect.forkChild( + lookedUntil(script.look, "1 minute", [STATEMENT]).pipe( + Effect.provideService(LiveWake, watch.wake), + ), + ); + yield* settled; + assert.equal(script.looks(), 1); + yield* TestClock.adjust("5 seconds"); + assert.equal(script.looks(), 2, "the safety beat looks though nothing said the rows changed"); + script.find("settled"); + watch.change(); + yield* settled; + assert.deepEqual(yield* Fiber.join(waiting), Option.some("settled")); + assert.equal(script.looks(), 3); + }), +); + +/** Realtime's direct servers as a script: each one opened, its subscriptions by name, and which are let go. */ +function scriptedRealtime() { + const subscriptions: { + readonly name: string; + readonly update: Set<() => void>; + readonly state: Set<(state: { readonly status: string }) => void>; + }[] = []; + const unsubscribed: string[] = []; + const closed: number[] = []; + let opened = 0; + let refuse = false; + /** Servers whose connection failed, on which a subscription is in error the moment it is made. */ + const failed = new Set(); + const open = (): LiveSubscriber => { + const server = opened++; + return { + subscribe: () => { + if (refuse) return Promise.reject(new Error("refused")); + const name = `server-${server}/query-${subscriptions.length}`; + const update = new Set<() => void>(); + const state = new Set<(state: { readonly status: string }) => void>(); + subscriptions.push({ name, update, state }); + const status = failed.has(server) ? "error" : "live"; + return Promise.resolve({ + onUpdate: (listener) => { + update.add(listener); + return () => void update.delete(listener); + }, + onStateChange: (listener) => { + state.add(listener); + return () => void state.delete(listener); + }, + getState: () => ({ status }), + unsubscribe: () => void unsubscribed.push(name), + }); + }, + close: () => void closed.push(server), + }; + }; + const standing = () => subscriptions.filter(({ name }) => !unsubscribed.includes(name)); + return { + open, + closed, + unsubscribed, + names: () => subscriptions.map(({ name }) => name), + update: () => { + for (const { update } of standing()) for (const listener of update) listener(); + }, + /** Every standing subscription moves to `error`, and with `connection`, its server can no longer subscribe. */ + fail: (connection: boolean) => { + if (connection) failed.add(opened - 1); + for (const { state } of standing()) + for (const listener of state) listener({ status: "error" }); + }, + refuse: () => { + refuse = true; + }, + }; +} + +it.effect( + "the wake over Realtime subscribes to each statement for the length of a wait, wakes it on any update, and lets go after; a refused subscription leaves the wait on its safety beat", + () => + Effect.gen(function* () { + const realtime = scriptedRealtime(); + const wake = realtimeWake(yield* realtimeServers(realtime.open)); + yield* Effect.scoped( + Effect.gen(function* () { + const watch = yield* wake.watch([STATEMENT, STATEMENT]); + assert.deepEqual(realtime.names(), ["server-0/query-0", "server-0/query-1"]); + const changed = yield* Effect.forkChild(watch.changed); + yield* settled; + realtime.update(); + yield* Fiber.join(changed); + // A change no wait took yet is taken by the next one at once. + realtime.update(); + yield* watch.changed; + }), + ); + assert.deepEqual(realtime.unsubscribed.toSorted(), ["server-0/query-0", "server-0/query-1"]); + + realtime.refuse(); + const script = scriptedLook(); + const waiting = yield* Effect.forkChild( + lookedUntil(script.look, "1 minute", [STATEMENT]).pipe( + Effect.provideService(LiveWake, wake), + ), + ); + yield* settled; + assert.equal(script.looks(), 1); + yield* TestClock.adjust("5 seconds"); + assert.equal(script.looks(), 2); + yield* Fiber.interrupt(waiting); + }), +); + +it.effect( + "a subscription that moves to error is made again and wakes the wait; where the connection failed, it is made again on a new server and the old one closed", + () => + Effect.scoped( + Effect.gen(function* () { + const realtime = scriptedRealtime(); + const wake = realtimeWake(yield* realtimeServers(realtime.open)); + const watch = yield* wake.watch([STATEMENT]); + assert.deepEqual(realtime.names(), ["server-0/query-0"]); + + realtime.fail(false); + yield* watch.changed; + yield* settled; + assert.deepEqual(realtime.names(), ["server-0/query-0", "server-0/query-1"]); + assert.deepEqual(realtime.closed, []); + + realtime.fail(true); + yield* watch.changed; + yield* settled; + assert.deepEqual(realtime.names(), [ + "server-0/query-0", + "server-0/query-1", + "server-0/query-2", + "server-1/query-3", + ]); + assert.deepEqual(realtime.closed, [0]); + const changed = yield* Effect.forkChild(watch.changed); + yield* settled; + realtime.update(); + yield* Fiber.join(changed); + }), + ), +); diff --git a/packages/service/src/live/live-wake.ts b/packages/service/src/live/live-wake.ts new file mode 100644 index 0000000000..731a17f295 --- /dev/null +++ b/packages/service/src/live/live-wake.ts @@ -0,0 +1,243 @@ +import type { LiveStatement } from "@luke/database/core/live-statement"; +import { createRealtime, type RawSqlQuery, rawSql } from "@neon/realtime/server"; +import { Config, Context, Effect, Layer, Option, Redacted, type Scope } from "effect"; +import { databaseUrl } from "../db/index.js"; +import { databaseNameOf, REALTIME_ENVIRONMENT } from "./realtime.js"; + +/** + * live-wake.ts -- what a held read, or any wait of the service's on a row, waits on between its looks: a Neon Realtime subscription to its own statements. + * + * Realtime never answers a read. It only says that the rows a held read's + * statements cover changed, and the read then looks again with its own + * statement, so what the desktop is answered is what the store says and + * nothing the subscription carried. A watch that misses a change, or one + * Realtime refused or stopped, costs the read a look on its safety beat. + */ + +/** What a held read waits on, for the length of one hold. */ +export interface LiveWatch { + /** Completes at the first change since it last completed; never while nothing changes. */ + readonly changed: Effect.Effect; +} + +export interface LiveWakeShape { + /** A watch on the statements, for as long as the scope stands. */ + readonly watch: ( + statements: readonly LiveStatement[], + ) => Effect.Effect; +} + +/** The watch that hears nothing: a wait on no statements, or one Realtime refused, looks on its safety beat alone. */ +const QUIET: LiveWatch = { changed: Effect.never }; + +/** The wake where Realtime is not configured, which refuses every watch loudly rather than looking on a clock. */ +const UNCONFIGURED: LiveWakeShape = { + watch: () => Effect.die(new Error("live-wake: Neon Realtime is not configured")), +}; + +/** + * The wake a held read waits on: Realtime, where the web runtime provides + * `liveWakeFromEnvironment`, and a scripted one in a test. With none + * provided, every watch dies, so a missing wake is heard rather than + * polled around. + */ +export const LiveWake = Context.Reference("@luke/service/LiveWake", { + defaultValue: () => UNCONFIGURED, +}); + +/** One statement's subscription, the slice of Realtime's raw subscription a watch reads. */ +interface LiveSubscription { + onUpdate(listener: () => void): () => void; + onStateChange(listener: (state: { readonly status: string }) => void): () => void; + getState(): { readonly status: string }; + unsubscribe(): void; +} + +/** The subscription calls a watch makes, the slice of Realtime's direct server it uses. */ +export interface LiveSubscriber { + subscribe( + query: RawSqlQuery, + options: { readonly materialize: false }, + ): Promise; + close(): void; +} + +/** + * Where one watch stands, as Realtime's listeners reach it: whether a change + * landed that no wait has taken yet, and the one wait parked on it, if any. + * Plain state rather than an Effect primitive, because a listener runs on + * Realtime's own callbacks, outside any fiber. + */ +function changeSignal() { + let pending = false; + let waiter: (() => void) | undefined; + return { + fire: () => { + const parked = waiter; + waiter = undefined; + if (parked === undefined) pending = true; + else parked(); + }, + watch: { + changed: Effect.callback((resume) => { + if (pending) { + pending = false; + resume(Effect.void); + return; + } + waiter = () => resume(Effect.void); + return Effect.sync(() => { + waiter = undefined; + }); + }), + } satisfies LiveWatch, + }; +} + +/** + * The direct server every watch subscribes on, replaced once its connection + * failed: Neon's docs say a client whose connection failed cannot reconnect, + * so a new one is made before subscribing again. `failed` is the server a + * watch saw fail, so the first watch to see it replaces it and the rest + * subscribe on the replacement. + */ +export interface StandingServer { + readonly current: () => LiveSubscriber; + readonly replace: (failed: LiveSubscriber) => LiveSubscriber; + readonly close: () => void; +} + +function standingServer(open: () => LiveSubscriber): StandingServer { + let server = open(); + return { + current: () => server, + replace: (failed) => { + if (server !== failed) return server; + failed.close(); + server = open(); + return server; + }, + close: () => server.close(), + }; +} + +/** + * One statement followed for a watch, as Neon's docs say a long-running + * worker follows one: an error is final, so the watch watches for it with + * `onStateChange` and subscribes again, and wakes the read, which may have + * missed a change while the subscription stood in error. A subscription + * that is in error the moment it is made is one on a connection that + * failed, so it is made again on a new server. Answers the way to let go + * once the first subscription stands; refused where the first is. + */ +async function followed( + servers: StandingServer, + statement: LiveStatement, + fire: () => void, +): Promise<() => void> { + let stopped = false; + let current: LiveSubscription | undefined; + const subscribe = async (on: LiveSubscriber, mayReplace: boolean): Promise => { + const subscription = await on.subscribe(rawSql(statement.sql, statement.params), { + materialize: false, + }); + if (stopped) { + subscription.unsubscribe(); + return; + } + if (subscription.getState().status === "error") { + subscription.unsubscribe(); + if (mayReplace) await subscribe(servers.replace(on), false); + return; + } + current = subscription; + subscription.onUpdate(fire); + subscription.onStateChange((state) => { + if (state.status !== "error" || current !== subscription) return; + current = undefined; + subscription.unsubscribe(); + fire(); + void subscribe(servers.current(), true).catch(() => undefined); + }); + }; + await subscribe(servers.current(), true); + return () => { + stopped = true; + current?.unsubscribe(); + current = undefined; + }; +} + +/** + * The wake over a Realtime direct server: each watch subscribes to its + * statements as raw updates, and every update of any of them is a change. + * Note that the first update of each is its whole result, which reads as a + * change too, so a change that landed between the subscription and the + * read's first look is looked for once more rather than missed. A + * subscription that moves to `error` is made again (`followed`); one + * refused outright, or one that could not be made again, leaves the read on + * its safety beat (`LIVE_SAFETY_POLL` in `http/held-read.ts`), which is + * the backstop for every way a watch can go quiet, so that beat stays. + */ +export function realtimeWake(servers: StandingServer): LiveWakeShape { + return { + watch: (statements) => + Effect.gen(function* () { + if (statements.length === 0) return QUIET; + const signal = changeSignal(); + const subscribed = yield* Effect.forEach(statements, (statement) => + Effect.acquireRelease( + Effect.tryPromise(() => followed(servers, statement, signal.fire)), + (letGo) => Effect.sync(letGo), + ), + ).pipe(Effect.option); + if (Option.isNone(subscribed)) { + yield* Effect.logWarning("live-wake: a watch was refused; the read looks on its beat"); + return QUIET; + } + return signal.watch; + }), + }; +} + +/** The servers a wake subscribes on, each one `open` makes, the standing one closed with the scope. */ +export const realtimeServers = ( + open: () => LiveSubscriber, +): Effect.Effect => + Effect.acquireRelease( + Effect.try(() => standingServer(open)), + (servers) => Effect.sync(() => servers.close()), + ); + +/** + * The wake as the deployment's environment configures it: Realtime, over one + * direct server and one socket the instance's every held read shares, closed + * with the runtime and replaced where its connection failed. Where a variable or the database name is missing, or + * Realtime refuses its configuration, it is said in the log and every watch + * dies, since a held read has nothing else to wait on. + */ +export const liveWakeFromEnvironment = Layer.effect( + LiveWake, + Effect.gen(function* () { + const read = yield* Config.all({ + url: Config.option(Config.String(REALTIME_ENVIRONMENT.URL)), + secret: Config.option(Config.Redacted(REALTIME_ENVIRONMENT.SECRET)), + database: Config.option(databaseUrl), + }); + const url = Option.getOrUndefined(read.url)?.trim(); + const secret = Option.getOrUndefined(read.secret); + const database = Option.flatMap(read.database, databaseNameOf); + if (!url || secret === undefined || Option.isNone(database)) { + yield* Effect.logError("live-wake: Neon Realtime is not configured; held reads fail"); + return UNCONFIGURED; + } + const servers = yield* realtimeServers(() => + createRealtime({ url, secret: Redacted.value(secret).trim(), db: database.value }), + ).pipe(Effect.option); + if (Option.isNone(servers)) { + yield* Effect.logError("live-wake: Realtime refused its configuration; held reads fail"); + return UNCONFIGURED; + } + return realtimeWake(servers.value); + }), +); diff --git a/packages/service/src/live/realtime.test.ts b/packages/service/src/live/realtime.test.ts index 341d0e3ce3..63382c8978 100644 --- a/packages/service/src/live/realtime.test.ts +++ b/packages/service/src/live/realtime.test.ts @@ -1,6 +1,10 @@ import assert from "node:assert/strict"; import { it } from "@effect/vitest"; -import { ConfigProvider, Effect, Layer, Option } from "effect"; +import { liveSelection, liveStatementOf } from "@luke/database/core/live-statement"; +import { queryBuilder } from "@luke/database/core/query"; +import { inboxItem } from "@luke/database/schemas/inbox"; +import { eq } from "drizzle-orm"; +import { ConfigProvider, Effect, Layer, Option, Result } from "effect"; import { LiveSealing, liveSealingFromEnvironment, REALTIME_ENVIRONMENT } from "./realtime.js"; /** @@ -82,3 +86,28 @@ it.effect( assert.notEqual(other.queryFingerprint, sealed.queryFingerprint); }), ); + +it.effect( + "a Drizzle select is sealed through Realtime's Drizzle adapter, which refuses one whose key is not the name Postgres answers it under", + () => + Effect.gen(function* () { + const sealing = yield* sealingOver({ + DATABASE_URL, + [REALTIME_ENVIRONMENT.URL]: REALTIME_URL, + [REALTIME_ENVIRONMENT.SECRET]: SYNTHETIC_SECRET, + }); + assert.ok(Option.isSome(sealing)); + const select = (columns: Parameters[0]) => + Option.getOrThrow( + liveStatementOf( + queryBuilder.select(columns).from(inboxItem).where(eq(inboxItem.userId, "user-dana")), + ), + ); + const aliased = yield* Effect.result( + sealing.value.seal(select(liveSelection({ itemId: inboxItem.id }))), + ); + assert.ok(Result.isSuccess(aliased)); + const renamed = yield* Effect.result(sealing.value.seal(select({ itemId: inboxItem.id }))); + assert.ok(Result.isFailure(renamed)); + }), +); diff --git a/packages/service/src/live/realtime.ts b/packages/service/src/live/realtime.ts index 33988d385f..e32236063b 100644 --- a/packages/service/src/live/realtime.ts +++ b/packages/service/src/live/realtime.ts @@ -1,6 +1,7 @@ import type { LiveStatement } from "@luke/database/core/live-statement"; import type { SealedLiveQueryWire } from "@luke/features/live-wire"; import { createRealtime, rawSql } from "@neon/realtime/server"; +import { drizzleAdapter } from "@neon/realtime-drizzle"; import { Config, Context, Data, Effect, Layer, Option, Redacted } from "effect"; import { databaseUrl } from "../db/index.js"; @@ -9,7 +10,9 @@ import { databaseUrl } from "../db/index.js"; * * A live read is sealed here, for the account the request's bearer names, * as Neon's own guide does it ("Sealing authorized queries"): the statement - * is built on the server with the account's id in it and sealed, so the + * is built on the server with the account's id in it and its Drizzle select + * sealed through Neon's Drizzle adapter (`drizzleAdapter()`), which refuses + * one whose selected key is not the name Postgres answers it under, so the * desktop can subscribe to it and can neither read nor change it. The * secret never leaves this service. A sealed read is itself a short-lived * bearer, so it travels in an answer body alone, never a URL or a log. @@ -42,7 +45,7 @@ export const LiveSealing = Context.Reference>( ); /** The database a connection string names, which Realtime binds each sealed read to. */ -function databaseNameOf(url: Redacted.Redacted): Option.Option { +export function databaseNameOf(url: Redacted.Redacted): Option.Option { try { const name = decodeURIComponent(new URL(Redacted.value(url)).pathname.slice(1)); return name === "" ? Option.none() : Option.some(name); @@ -59,14 +62,22 @@ function liveSealer(options: { }): Effect.Effect { return Effect.map( Effect.try({ - try: () => createRealtime({ secret: Redacted.value(options.secret), db: options.database }), + try: () => + createRealtime({ + secret: Redacted.value(options.secret), + db: options.database, + adapter: drizzleAdapter(), + }), catch: () => new SealRefused({ reason: "Realtime refused its secret" }), }), (realtime) => ({ url: options.url, seal: (statement) => Effect.tryPromise({ - try: () => realtime.seal({ query: rawSql(statement.sql, statement.params) }), + try: () => + realtime.seal({ + query: statement.query ?? rawSql(statement.sql, statement.params), + }), catch: () => new SealRefused({ reason: "Realtime refused to seal a statement" }), }).pipe( Effect.map(({ capability, queryFingerprint, expiresAt }) => ({ diff --git a/packages/service/src/runtime.ts b/packages/service/src/runtime.ts index 3f5d99742d..ef5a77d1b2 100644 --- a/packages/service/src/runtime.ts +++ b/packages/service/src/runtime.ts @@ -17,6 +17,7 @@ import { integrationConnectFromEnvironment, workspaceLinearConnectFromEnvironment, } from "./integrations/integration-connect.js"; +import { liveWakeFromEnvironment } from "./live/live-wake.js"; import { liveSealingFromEnvironment } from "./live/realtime.js"; import { sentryLogger } from "./observability/sentry.js"; import { modelCatalogLayer } from "./providers/model-catalog.js"; @@ -64,7 +65,10 @@ const webLogger = Logger.layer([Logger.consoleJson, Logger.tracerLogger, sentryL * `LiveSealing` is the one place Neon Realtime's address and secret are read, * so a live read is sealed under the same secret on every route * (`live/realtime.ts`); without both it stands as none, and every live read - * answers unavailable. + * answers unavailable. `LiveWake` is the same Realtime seen from the service's + * side: one socket per instance, which every held read and every wait on a + * row watches its rows over rather than looking on a clock + * (`live/live-wake.ts`); without Realtime each fails. * * `ModelCatalog` is here for the same reason: its read of AI Gateway's * public catalog is cached on the service, so one instance reads the catalog @@ -86,6 +90,7 @@ export const webServices = Layer.mergeAll( workspaceLinearConnectFromEnvironment, webLogger, liveSealingFromEnvironment, + liveWakeFromEnvironment, modelCatalogLayer.pipe(Layer.provide(Layer.merge(FetchHttpClient.layer, hostedEnvironment))), productAnalyticsFromEnvironment.pipe( Layer.provide(Layer.merge(FetchHttpClient.layer, hostedEnvironment)), diff --git a/packages/service/src/tasks/board-look.test.ts b/packages/service/src/tasks/board-look.test.ts index c594463532..8b5e0a9d29 100644 --- a/packages/service/src/tasks/board-look.test.ts +++ b/packages/service/src/tasks/board-look.test.ts @@ -2,13 +2,19 @@ import assert from "node:assert/strict"; import { randomUUID } from "node:crypto"; import { it } from "@effect/vitest"; import { Database } from "@luke/database/core/query"; -import { writeDrawing, writeScene } from "@luke/database/models/board-store"; +import { + boardImageLiveStatement, + writeDrawing, + writeScene, +} from "@luke/database/models/board-store"; import { user } from "@luke/database/schemas/auth"; import { getTestDB, startTask } from "@luke/database/testing"; import { BOARD_ELEMENT_TYPE } from "@luke/features/board-vocabulary"; import { unparsedWire } from "@luke/wire"; -import { Effect } from "effect"; +import { Effect, Layer, Option } from "effect"; +import { SqlClient } from "effect/sql"; import { driven, forkClockDriven } from "../testing/clock-driven.js"; +import { quietLiveWake } from "../testing/live-wake.js"; import { BOARD_LOOK_WAIT, LOOK_AT_BOARD_STATUS, runLookAtBoard } from "./board-look.js"; /** @@ -36,7 +42,7 @@ const openTask = Effect.gen(function* () { return { userId, taskId: started.id, binding: { userId, taskId: started.id } }; }); -it.layer(getTestDB())("look_at_board", (it) => { +it.layer(Layer.merge(getTestDB(), quietLiveWake))("look_at_board", (it) => { it.effect("another account's plan shows nothing", () => Effect.gen(function* () { const { userId, taskId } = yield* openTask; @@ -52,4 +58,27 @@ it.layer(getTestDB())("look_at_board", (it) => { assert.equal(answered.status, LOOK_AT_BOARD_STATUS.NOT_LOOKED); }), ); + it.effect( + "the statement Realtime wakes a look on moves as Luke draws and the Mac saves the scene, carries no image, and reads nothing of another account's plan", + () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const { userId, taskId } = yield* openTask; + const stranger = (yield* openTask).userId; + const rows = (reader: string) => { + const statement = Option.getOrThrow(boardImageLiveStatement(reader, taskId)); + return sql.unsafe(statement.sql, [...statement.params]); + }; + const before = yield* rows(userId); + yield* writeDrawing(userId, taskId, BOX); + const drawn = yield* rows(userId); + yield* writeScene(userId, taskId, SCENE, 1, IMAGE); + const saved = yield* rows(userId); + + assert.notDeepEqual(drawn, before); + assert.notDeepEqual(saved, drawn); + assert.ok(!JSON.stringify(saved).includes(IMAGE)); + assert.deepEqual(yield* rows(stranger), []); + }), + ); }); diff --git a/packages/service/src/tasks/board-look.ts b/packages/service/src/tasks/board-look.ts index 41650521ca..bd30daf01f 100644 --- a/packages/service/src/tasks/board-look.ts +++ b/packages/service/src/tasks/board-look.ts @@ -1,9 +1,10 @@ import type { DatabaseServices } from "@luke/database/core/query"; -import { readBoardImage } from "@luke/database/models/board-store"; +import { boardImageLiveStatement, readBoardImage } from "@luke/database/models/board-store"; import { LOOK_AT_BOARD_TOOL_NAME } from "@luke/features/board-vocabulary"; import { isRecord, type UnparsedWireValue, unparsedWire } from "@luke/wire"; -import { Duration, Effect, Option, Schedule, Schema } from "effect"; +import { Duration, Effect, Option, Schema } from "effect"; import type { ToolModelOutput } from "eve/tools"; +import { lookedUntil } from "../http/held-read.js"; import type { PlanDocumentBinding } from "./plan-notes.js"; /** @@ -43,9 +44,8 @@ export const LOOK_AT_BOARD_STATUS = { NOT_LOOKED: "not-looked", } as const; -/** How long a look waits for the Mac's image of the latest drawing, and how often it reads. */ +/** How long a look waits for the Mac's image of the latest drawing. */ export const BOARD_LOOK_WAIT = { - POLL: Duration.millis(250), DEADLINE: Duration.seconds(8), } as const; @@ -92,10 +92,10 @@ export function runLookAtBoard( reason: LOOK_AT_BOARD_REFUSAL.UNREADABLE, }); } - const awaited = readBoardImage(binding.userId, binding.taskId).pipe( - Effect.repeat({ schedule: Schedule.spaced(BOARD_LOOK_WAIT.POLL), until: Option.isSome }), - Effect.timeoutOption(BOARD_LOOK_WAIT.DEADLINE), - Effect.map(Option.flatten), + const awaited = lookedUntil( + readBoardImage(binding.userId, binding.taskId), + BOARD_LOOK_WAIT.DEADLINE, + Option.toArray(boardImageLiveStatement(binding.userId, binding.taskId)), ); return awaited.pipe( Effect.map( diff --git a/packages/service/src/tasks/plan-comments-app.ts b/packages/service/src/tasks/plan-comments-app.ts index 6120408bc5..d586a7088b 100644 --- a/packages/service/src/tasks/plan-comments-app.ts +++ b/packages/service/src/tasks/plan-comments-app.ts @@ -4,6 +4,7 @@ import { PLAN_COMMENT_OUTCOME, type PlanCommentOutcome, planCommentStamps, + planCommentStampsLiveStatement, readPlanCommentThreads, startPlanCommentThread, withdrawPlanCommentAsk, @@ -157,8 +158,8 @@ function planCommentsPast( return heldUntilMoved( planCommentStamps(userId, taskId), after, - CODER.MESSAGES_POLL, CODER.MESSAGES_HOLD, + Option.toArray(planCommentStampsLiveStatement(userId, taskId)), ).pipe( Effect.andThen(readPlanCommentThreads(userId, taskId)), Effect.map(Option.map((threads) => ({ threads, cursor: cursorOf(threads) }))), diff --git a/packages/service/src/testing/live-wake.ts b/packages/service/src/testing/live-wake.ts new file mode 100644 index 0000000000..4d67ac494d --- /dev/null +++ b/packages/service/src/testing/live-wake.ts @@ -0,0 +1,13 @@ +// live-wake.ts -- the wake a suite's held reads wait on: Realtime scripted to hear no change, so a wait looks on its safety beat alone. +import { Effect, Layer } from "effect"; +import { LiveWake, type LiveWatch } from "../live/live-wake.js"; + +/** A watch that hears nothing. */ +const QUIET: LiveWatch = { changed: Effect.never }; + +/** + * The wake a suite provides where a held read or a wait on a row runs, since + * the service polls nothing without one: every watch quiet, so a wait looks + * at once and then on its safety beat, which a suite drives with its clock. + */ +export const quietLiveWake = Layer.succeed(LiveWake, { watch: () => Effect.succeed(QUIET) }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ecf75ebb91..f7d152b6b6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1120,6 +1120,9 @@ importers: '@neon/realtime': specifier: 0.1.1 version: 0.1.1(ws@8.21.3) + '@neon/realtime-drizzle': + specifier: 0.1.0 + version: 0.1.0(@neon/realtime@0.1.1)(drizzle-orm@1.0.0-rc.4) '@orpc/contract': specifier: 'catalog:' version: 2.0.0-beta.44 @@ -2595,6 +2598,16 @@ packages: '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4 '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4 + '@neon/realtime-drizzle@0.1.0': + resolution: {integrity: sha512-JZ4g5Zg2klNxF7QPxUKU77d8i+IEtX3fQ83oDoG61/DUi8jPZrTuZGhQyZuspaYTb+tPMYhITBpaRd1qAr0Xtg==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@neon/realtime': ^0.1.0 + drizzle-orm: '>=0.45.2 <0.46.0' + peerDependenciesMeta: + drizzle-orm: + optional: true + '@neon/realtime@0.1.1': resolution: {integrity: sha512-YbTaKcq7Vmaw05oEu/1HNbxrtLeHaofnPHptd6QPGZj2M1uZJRJKKrjb+ShH0GpR5fpBlp8S4iZcTW3IsnIoqg==} engines: {node: '>=20.19.0'} @@ -9230,6 +9243,12 @@ snapshots: '@tybys/wasm-util': 0.10.3 optional: true + '@neon/realtime-drizzle@0.1.0(@neon/realtime@0.1.1)(drizzle-orm@1.0.0-rc.4)': + dependencies: + '@neon/realtime': 0.1.1(ws@8.21.3) + optionalDependencies: + drizzle-orm: 1.0.0-rc.4(patch_hash=7eca027493ea68b0777e0306d382bf89cdd6217b8c3377c6d44e7911303f9f5b)(@effect/sql-pg@4.0.4)(@effect/sql-pglite@4.0.4)(@electric-sql/pglite@0.5.8)(@opentelemetry/api@1.9.1)(@types/pg@8.21.0)(effect@4.0.4)(pg@8.23.0)(zod@4.5.4) + '@neon/realtime@0.1.1(ws@8.21.3)': dependencies: postgres-array: 3.0.4 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index a7de57706a..4a31f23801 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -97,6 +97,16 @@ patchedDependencies: # the map. eve@0.74.0: patches/eve@0.74.0.patch +# `@neon/realtime-drizzle` 0.1.0 declares `drizzle-orm` `>=0.45.2 <0.46.0` as +# its peer, but its adapter reads only what a select compiles to and the +# fields it selected, which 1.0.0-rc.4 builds the same way: sealing a +# `liveSelection` read through it was checked to match our SQL and values +# (`packages/service/src/live/realtime.test.ts`). Delete this when a release +# declares the 1.0 line. +peerDependencyRules: + allowedVersions: + "@neon/realtime-drizzle>drizzle-orm": 1.0.0-rc.4 + # A dependency is keyed by its peers' names alone, not by the peers' own peer # graphs, so one `vitest` serves every workspace. Keyed in full, a peer's own # optional peers can split `vitest` in two, and `@effect/vitest` then reads diff --git a/vercel.json b/vercel.json index 6aae398632..c7663c9201 100644 --- a/vercel.json +++ b/vercel.json @@ -409,10 +409,18 @@ "src": "/api/issues", "dest": "/api/default.js?route=issues" }, + { + "src": "/api/live/agent-inbox", + "dest": "/api/default.js?route=live/agent-inbox" + }, { "src": "/api/live/inbox", "dest": "/api/default.js?route=live/inbox" }, + { + "src": "/api/live/task-inbox", + "dest": "/api/default.js?route=live/task-inbox" + }, { "src": "/api/mac-commands", "dest": "/api/default.js?route=mac-commands"