From 7f8d2800044ec283d24c03c7d94cf033fae45dae Mon Sep 17 00:00:00 2001 From: Janghoon Lee <44862514+savagemanage@users.noreply.github.com> Date: Mon, 19 Oct 2026 13:50:18 +0000 Subject: [PATCH 1/3] feat(share): share sessions and files to Redrob Chat Session share now POSTs an immutable transcript snapshot (kind=transcript, source=code) to the Redrob Chat API with the Redrob Console key as a bearer token, and unshare calls DELETE /share/:id. The live sync to opencode's share service is gone, and the GitHub footers no longer request opencode's social-cards image. Base URL: chat.url in config, REDROB_CHAT_API_URL overrides, production default https://chat-prod.redrob.ai. New: redrob share [--lines a-b] [--title] shares a file or snippet as kind=artifact. --- github/index.ts | 17 +- packages/core/src/flag/flag.ts | 2 + packages/core/src/v1/config/config.ts | 7 + packages/redrob/src/cli/cmd/github.handler.ts | 16 +- packages/redrob/src/cli/cmd/share.ts | 63 ++++ packages/redrob/src/index.ts | 2 + packages/redrob/src/share/share-next.ts | 329 ++++++++---------- packages/redrob/test/share/share-next.test.ts | 237 +++---------- 8 files changed, 264 insertions(+), 409 deletions(-) create mode 100644 packages/redrob/src/cli/cmd/share.ts diff --git a/github/index.ts b/github/index.ts index 564bb196ae..f7c0dd17f7 100644 --- a/github/index.ts +++ b/github/index.ts @@ -853,21 +853,10 @@ async function createPR(base: string, branch: string, title: string, body: strin }) return pr.data.number } -function footer(opts?: { image?: boolean }) { - const { providerID, modelID } = useEnvModel() - - const image = (() => { - if (!shareUrl) return "" - if (!opts?.image) return "" - - const titleAlt = encodeURIComponent(session.title.substring(0, 50)) - const title64 = Buffer.from(session.title.substring(0, 700), "utf8").toString("base64") - const shareId = shareUrl.split("/").pop() - - return `${titleAlt}\n` - })() +// Shares live on Redrob Chat; the opencode social-cards image is no longer requested. +function footer(_opts?: { image?: boolean }) { const shareLink = shareUrl ? `[Redrob Code session](${shareUrl})  |  ` : "" - return `\n\n${image}${shareLink}[github run](${useEnvRunUrl()})` + return `\n\n${shareLink}[github run](${useEnvRunUrl()})` } async function fetchRepo() { diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts index e4dc288133..6cc8661093 100644 --- a/packages/core/src/flag/flag.ts +++ b/packages/core/src/flag/flag.ts @@ -29,6 +29,8 @@ export const Flag = { * still wants a key it recognises. */ REDROB_CONSOLE_URL: process.env["REDROB_CONSOLE_URL"], + /** Point sharing at a different Redrob Chat API (staging, local). Wins over `chat.url` in config. */ + REDROB_CHAT_API_URL: process.env["REDROB_CHAT_API_URL"], REDROB_CONFIG: process.env["REDROB_CONFIG"], REDROB_CONFIG_CONTENT: process.env["REDROB_CONFIG_CONTENT"], REDROB_DISABLE_AUTOUPDATE: truthy("REDROB_DISABLE_AUTOUPDATE"), diff --git a/packages/core/src/v1/config/config.ts b/packages/core/src/v1/config/config.ts index 972597005e..c9b87c23db 100644 --- a/packages/core/src/v1/config/config.ts +++ b/packages/core/src/v1/config/config.ts @@ -133,6 +133,13 @@ export const Info = Schema.Struct({ enterprise: Schema.optional( Schema.Struct({ url: Schema.optional(Schema.String).annotate({ description: "Enterprise URL" }) }), ), + chat: Schema.optional( + Schema.Struct({ + url: Schema.optional(Schema.String).annotate({ + description: "Redrob Chat API base URL used for sharing (default https://chat-prod.redrob.ai)", + }), + }), + ), tool_output: Schema.optional( Schema.Struct({ max_lines: Schema.optional(PositiveInt).annotate({ diff --git a/packages/redrob/src/cli/cmd/github.handler.ts b/packages/redrob/src/cli/cmd/github.handler.ts index 58af47a3df..ac5535d1d4 100644 --- a/packages/redrob/src/cli/cmd/github.handler.ts +++ b/packages/redrob/src/cli/cmd/github.handler.ts @@ -1220,19 +1220,11 @@ export const githubRun = Effect.fn("Cli.github.run")(function* (args: { event?: } } - function footer(opts?: { image?: boolean }) { - const image = (() => { - if (!shareUrl) return "" - if (!opts?.image) return "" - - const titleAlt = encodeURIComponent(session.title.substring(0, 50)) - const title64 = Buffer.from(session.title.substring(0, 700), "utf8").toString("base64") - const shareId = shareUrl.split("/").pop() - - return `${titleAlt}\n` - })() + // The share card image was rendered by opencode's social-cards service from the session title. Shares + // now live on Redrob Chat, so the footer carries the plain link and nothing is sent to that service. + function footer(_opts?: { image?: boolean }) { const shareLink = shareUrl ? `[redrob session](${shareUrl})  |  ` : "" - return `\n\n${image}${shareLink}[github run](${runUrl})` + return `\n\n${shareLink}[github run](${runUrl})` } async function fetchRepo() { diff --git a/packages/redrob/src/cli/cmd/share.ts b/packages/redrob/src/cli/cmd/share.ts new file mode 100644 index 0000000000..eece5956e4 --- /dev/null +++ b/packages/redrob/src/cli/cmd/share.ts @@ -0,0 +1,63 @@ +import path from "path" +import { Effect } from "effect" +import { effectCmd, fail } from "../effect-cmd" +import { ShareNext } from "@/share/share-next" +import { UI } from "../ui" + +/** Redrob Chat refuses bodies over 2 MB; checked here so the user gets a clear message, not a 413. */ +const MAX_BYTES = 2 * 1024 * 1024 + +function format(file: string): { format: ShareNext.ArtifactInput["format"]; language?: string } { + const ext = path.extname(file).slice(1).toLowerCase() + if (ext === "md" || ext === "markdown" || ext === "mdx") return { format: "markdown" } + if (ext === "html" || ext === "htm") return { format: "html" } + if (ext === "json") return { format: "json" } + return { format: "code", language: ext || undefined } +} + +/** `--lines 10-40` (1-based, inclusive) or `--lines 12` for one line. */ +function slice(text: string, lines?: string) { + if (!lines) return text + const match = lines.match(/^(\d+)(?:-(\d+))?$/) + if (!match) return undefined + const start = Number(match[1]) + const end = match[2] ? Number(match[2]) : start + if (start < 1 || end < start) return undefined + return text.split("\n").slice(start - 1, end).join("\n") +} + +export const ShareCommand = effectCmd({ + command: "share ", + describe: "share a file or snippet to Redrob Chat", + builder: (yargs) => + yargs + .positional("file", { + describe: "file to share", + type: "string", + demandOption: true, + }) + .option("lines", { + describe: "only share these lines, e.g. 10-40", + type: "string", + }) + .option("title", { + describe: "title shown on the share page", + type: "string", + }), + handler: Effect.fn("Cli.share")(function* (args) { + const file = path.resolve(args.file) + const text = yield* Effect.tryPromise(() => Bun.file(file).text()).pipe( + Effect.catch(() => fail(`Cannot read ${args.file}`)), + ) + const body = slice(text, args.lines) + if (body === undefined) return yield* fail(`Invalid --lines value: ${args.lines}`) + if (Buffer.byteLength(body, "utf8") > MAX_BYTES) return yield* fail("File is larger than 2 MB") + + const name = path.basename(file) + const title = args.title ?? (args.lines ? `${name}:${args.lines}` : name) + const result = yield* ShareNext.Service.use((svc) => svc.artifact({ title, body, ...format(file) })).pipe( + Effect.catch((error) => fail(error instanceof Error ? error.message : String(error))), + ) + UI.println(UI.Style.TEXT_INFO_BOLD + "~ " + result.url + UI.Style.TEXT_NORMAL) + }), +}) diff --git a/packages/redrob/src/index.ts b/packages/redrob/src/index.ts index b58c948c8f..04b52521aa 100644 --- a/packages/redrob/src/index.ts +++ b/packages/redrob/src/index.ts @@ -22,6 +22,7 @@ import { McpCommand } from "./cli/cmd/mcp" import { GithubCommand } from "./cli/cmd/github" import { ExportCommand } from "./cli/cmd/export" import { ImportCommand } from "./cli/cmd/import" +import { ShareCommand } from "./cli/cmd/share" import { AttachCommand } from "./cli/cmd/attach" import { TuiThreadCommand } from "./cli/cmd/tui" import { AcpCommand } from "./cli/cmd/acp" @@ -102,6 +103,7 @@ const cli = yargs(args) .command(StatsCommand) .command(ExportCommand) .command(ImportCommand) + .command(ShareCommand) .command(GithubCommand) .command(PrCommand) .command(SessionCommand) diff --git a/packages/redrob/src/share/share-next.ts b/packages/redrob/src/share/share-next.ts index f08b124abf..2d042d9599 100644 --- a/packages/redrob/src/share/share-next.ts +++ b/packages/redrob/src/share/share-next.ts @@ -1,30 +1,39 @@ import { LayerNode } from "@redrob-code/core/effect/layer-node" import { httpClient } from "@redrob-code/core/effect/app-node-platform" -import type * as SDK from "@redrob-labs/sdk/v2" import { serviceUse } from "@redrob-code/core/effect/service-use" -import { Effect, Exit, Layer, Option, Schema, Scope, Context, Stream } from "effect" -import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" -import { Account } from "@/account/account" +import { Effect, Exit, Layer, Schema, Scope, Context } from "effect" +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { EventV2Bridge } from "@/event-v2-bridge" import { InstanceState } from "@/effect/instance-state" import { Provider } from "@/provider/provider" import { Session } from "@/session/session" -import { MessageV2 } from "@/session/message-v2" import type { SessionID } from "@/session/schema" +import type { SessionV1 } from "@redrob-code/core/v1/session" import { Database } from "@redrob-code/core/database/database" import { eq } from "drizzle-orm" import { Config } from "@/config/config" import { SessionShareTable } from "@redrob-code/core/share/sql" import { ProviderV2 } from "@redrob-code/core/provider" -import { ModelV2 } from "@redrob-code/core/model" import { EventV2 } from "@redrob-code/core/event" +import { Flag } from "@redrob-code/core/flag/flag" + +/* + Sharing goes to Redrob Chat, not to opencode's share service. + + A share is an immutable snapshot (`POST /share` on the Redrob Chat API, kind "transcript", source "code"): + the transcript is sent once, when the user shares, and nothing is streamed afterwards. Unshare is + `DELETE /share/:id`. The caller is identified by the same Redrob Console API key the `redrob` provider + spends, sent as a bearer token; there is no second account and no share secret. +*/ const disabled = process.env["REDROB_DISABLE_SHARE"] === "true" || process.env["REDROB_DISABLE_SHARE"] === "1" +/** Production Redrob Chat API. Overridden by `chat.url` in config, then by `REDROB_CHAT_API_URL`. */ +export const DEFAULT_CHAT_API_URL = "https://chat-prod.redrob.ai" + export type Api = { create: string - sync: (shareID: string) => string remove: (shareID: string) => string data: (shareID: string) => string } @@ -35,84 +44,89 @@ export type Req = { baseUrl: string } -const ShareSchema = Schema.Struct({ +const CreatedSchema = Schema.Struct({ id: Schema.String, url: Schema.String, - secret: Schema.String, }) -export type Share = typeof ShareSchema.Type + +/** `secret` is kept for the stored row's shape; Redrob Chat shares carry none, so it is always "". */ +export type Share = { id: string; url: string; secret: string } + +export type ArtifactInput = { + title: string + format: "markdown" | "html" | "code" | "json" + language?: string + body: string +} type State = { - queue: Map> scope: Scope.Closeable shared: Map } -type Data = - | { - type: "session" - data: SDK.Session - } - | { - type: "message" - data: SDK.Message - } - | { - type: "part" - data: SDK.Part - } - | { - type: "session_diff" - data: SDK.SnapshotFileDiff[] - } - | { - type: "model" - data: SDK.Model[] - } - export interface Interface { readonly init: () => Effect.Effect readonly url: () => Effect.Effect readonly request: () => Effect.Effect readonly create: (sessionID: SessionID) => Effect.Effect readonly remove: (sessionID: SessionID) => Effect.Effect + /** Share one file or snippet as a standalone artifact. Not tied to a session, so nothing is persisted. */ + readonly artifact: (input: ArtifactInput) => Effect.Effect<{ id: string; url: string }, unknown> } export class Service extends Context.Service()("@redrob/ShareNext") {} export const use = serviceUse(Service) -function api(resource: string): Api { - return { - create: `/api/${resource}`, - sync: (shareID) => `/api/${resource}/${shareID}/sync`, - remove: (shareID) => `/api/${resource}/${shareID}`, - data: (shareID) => `/api/${resource}/${shareID}/data`, - } +const chatApi: Api = { + create: "/share", + remove: (shareID) => `/share/${encodeURIComponent(shareID)}`, + data: (shareID) => `/share/${encodeURIComponent(shareID)}`, } -const legacyApi = api("share") -const consoleApi = api("shares") - -function key(item: Data) { - switch (item.type) { - case "session": - return "session" - case "message": - return `message/${item.data.id}` - case "part": - return `part/${item.data.messageID}/${item.data.id}` - case "session_diff": - return "session_diff" - case "model": - return "model" +/** Contract limit on the server side; trimming here would silently drop history, so the oldest go first. */ +const MAX_MESSAGES = 500 + +/* + One line per tool call: the tool name and its title (for file tools that is the path). Inputs and + outputs are left out on purpose, so a share never carries file contents the conversation did not show. +*/ +function toolLine(part: SessionV1.ToolPart) { + const state = part.state + const title = state.status === "completed" ? state.title : "" + const suffix = state.status === "error" ? " (failed)" : "" + return `> **${part.tool}**${title ? ` ${title}` : ""}${suffix}` +} + +function transcript(messages: SessionV1.WithParts[]) { + const out: Array<{ role: "user" | "assistant"; content: string }> = [] + for (const msg of messages) { + const lines: string[] = [] + for (const part of msg.parts) { + if (part.type === "text") { + if (part.ignored || part.synthetic || !part.text.trim()) continue + lines.push(part.text) + continue + } + if (part.type === "tool") { + lines.push(toolLine(part)) + continue + } + if (part.type === "file") { + lines.push(`> attached ${part.filename ?? part.mime}`) + continue + } + } + const content = lines.join("\n\n").trim() + if (!content) continue + out.push({ role: msg.info.role, content }) } + return out.slice(-MAX_MESSAGES) } const layer = Layer.effect( Service, Effect.gen(function* () { - const account = yield* Account.Service const events = yield* EventV2Bridge.Service const cfg = yield* Config.Service const { db } = yield* Database.Service @@ -121,44 +135,12 @@ const layer = Layer.effect( const provider = yield* Provider.Service const session = yield* Session.Service - function sync(sessionID: SessionID, data: Data[]) { - return Effect.gen(function* () { - if (disabled) return - const share = yield* getCached(sessionID) - if (!share) return - - const s = yield* InstanceState.get(state) - const existing = s.queue.get(sessionID) - if (existing) { - for (const item of data) { - existing.set(key(item), item) - } - return - } - - const next = new Map(data.map((item) => [key(item), item])) - s.queue.set(sessionID, next) - yield* flush(sessionID).pipe( - Effect.delay(1000), - Effect.catchCause((cause) => Effect.logError("share flush failed", { sessionID: sessionID, cause: cause })), - Effect.forkIn(s.scope), - ) - }) - } - const state: InstanceState.InstanceState = yield* InstanceState.make( Effect.fn("ShareNext.state")(function* (_ctx) { - const cache: State = { queue: new Map(), scope: yield* Scope.make(), shared: new Map() } + const cache: State = { scope: yield* Scope.make(), shared: new Map() } yield* Effect.addFinalizer(() => - Scope.close(cache.scope, Exit.void).pipe( - Effect.andThen( - Effect.sync(() => { - cache.queue.clear() - cache.shared.clear() - }), - ), - ), + Scope.close(cache.scope, Exit.void).pipe(Effect.andThen(Effect.sync(() => cache.shared.clear()))), ) if (disabled) return cache @@ -176,49 +158,39 @@ const layer = Layer.effect( ) }) - yield* watch(Session.Event.Updated, (data) => - Effect.gen(function* () { - const info = data.info - yield* sync(info.id, [{ type: "session", data: structuredClone(info) as SDK.Session }]) - }), - ) - yield* watch(MessageV2.Event.Updated, (data) => - Effect.gen(function* () { - const info = data.info - yield* sync(info.sessionID, [{ type: "message", data: structuredClone(info) as SDK.Message }]) - if (info.role !== "user") return - const model = yield* provider.getModel(info.model.providerID, info.model.modelID) - yield* sync(info.sessionID, [{ type: "model", data: [model] }]) - }), - ) - yield* watch(MessageV2.Event.PartUpdated, (data) => - sync(data.part.sessionID, [{ type: "part", data: structuredClone(data.part) as SDK.Part }]), - ) - yield* watch(Session.Event.Diff, (data) => - sync(data.sessionID, [{ type: "session_diff", data: structuredClone(data.diff) as SDK.SnapshotFileDiff[] }]), - ) + // A deleted session takes its share link down with it. yield* watch(Session.Event.Deleted, (data) => remove(data.sessionID)) return cache }), ) + /** The Console key the `redrob` provider resolved (env, auth store, config, or launcher). */ + const consoleKey = Effect.fn("ShareNext.consoleKey")(function* () { + const providers = yield* provider.list() + const info = providers[ProviderV2.ID.redrob] + const key = info?.key ?? (info?.options?.["apiKey"] as string | undefined) + if (!key || key === "public") return undefined + return key + }) + const request = Effect.fn("ShareNext.request")(function* () { + const conf = yield* cfg.get() + const baseUrl = (Flag.REDROB_CHAT_API_URL ?? conf.chat?.url ?? DEFAULT_CHAT_API_URL).replace(/\/+$/, "") const headers: Record = {} - const active = yield* account.active() - if (Option.isNone(active) || !active.value.active_org_id) { - const baseUrl = (yield* cfg.get()).enterprise?.url ?? "https://opncd.ai" - return { headers, api: legacyApi, baseUrl } satisfies Req - } + const key = yield* consoleKey() + if (key) headers.authorization = `Bearer ${key}` + return { headers, api: chatApi, baseUrl } satisfies Req + }) - const token = yield* account.token(active.value.id) - if (Option.isNone(token)) { - throw new Error("No active account token available for sharing") + const authorized = Effect.fn("ShareNext.authorized")(function* () { + const req = yield* request() + if (!req.headers.authorization) { + return yield* Effect.fail( + new Error("Sharing needs a Redrob Console key. Connect with `redrob providers login` first."), + ) } - - headers.authorization = `Bearer ${token.value}` - headers["x-org-id"] = active.value.active_org_id - return { headers, api: consoleApi, baseUrl: active.value.url } satisfies Req + return req }) const get = Effect.fnUntraced(function* (sessionID: SessionID) { @@ -244,58 +216,14 @@ const layer = Layer.effect( return share }) - const flush = Effect.fn("ShareNext.flush")(function* (sessionID: SessionID) { - if (disabled) return - const s = yield* InstanceState.get(state) - const queued = s.queue.get(sessionID) - if (!queued) return - - s.queue.delete(sessionID) - - const share = yield* getCached(sessionID) - if (!share) return - - const req = yield* request() - const res = yield* HttpClientRequest.post(`${req.baseUrl}${req.api.sync(share.id)}`).pipe( + const post = Effect.fn("ShareNext.post")(function* (body: Record) { + const req = yield* authorized() + return yield* HttpClientRequest.post(`${req.baseUrl}${req.api.create}`).pipe( HttpClientRequest.setHeaders(req.headers), - HttpClientRequest.bodyJson({ secret: share.secret, data: Array.from(queued.values()) }), - Effect.flatMap((r) => http.execute(r)), + HttpClientRequest.bodyJson(body), + Effect.flatMap((r) => httpOk.execute(r)), + Effect.flatMap(HttpClientResponse.schemaBodyJson(CreatedSchema)), ) - - if (res.status >= 400) { - yield* Effect.logWarning("failed to sync share", { - sessionID: sessionID, - shareID: share.id, - status: res.status, - }) - } - }) - - const full = Effect.fn("ShareNext.full")(function* (sessionID: SessionID) { - yield* Effect.logInfo("full sync", { sessionID: sessionID }) - const info = yield* session.get(sessionID) - const diffs = yield* session.diff(sessionID) - const messages = yield* session.messages({ sessionID }) - const models = yield* Effect.forEach( - Array.from( - new Map( - messages - .filter((msg) => msg.info.role === "user") - .map((msg) => (msg.info as SDK.UserMessage).model) - .map((item) => [`${item.providerID}/${item.modelID}`, item] as const), - ).values(), - ), - (item) => provider.getModel(ProviderV2.ID.make(item.providerID), ModelV2.ID.make(item.modelID)), - { concurrency: 8 }, - ) - - yield* sync(sessionID, [ - { type: "session", data: info }, - ...messages.map((item) => ({ type: "message" as const, data: item.info })), - ...messages.flatMap((item) => item.parts.map((part) => ({ type: "part" as const, data: part }))), - { type: "session_diff", data: diffs }, - { type: "model", data: models }, - ]) }) const init = Effect.fn("ShareNext.init")(function* () { @@ -310,13 +238,16 @@ const layer = Layer.effect( const create = Effect.fn("ShareNext.create")(function* (sessionID: SessionID) { if (disabled) return { id: "", url: "", secret: "" } yield* Effect.logInfo("creating share", { sessionID: sessionID }) - const req = yield* request() - const result = yield* HttpClientRequest.post(`${req.baseUrl}${req.api.create}`).pipe( - HttpClientRequest.setHeaders(req.headers), - HttpClientRequest.bodyJson({ sessionID }), - Effect.flatMap((r) => httpOk.execute(r)), - Effect.flatMap(HttpClientResponse.schemaBodyJson(ShareSchema)), - ) + const info = yield* session.get(sessionID) + const messages = yield* session.messages({ sessionID }) + const created = yield* post({ + kind: "transcript", + source: "code", + title: (info.title || "Redrob Code session").slice(0, 200), + visibility: "unlisted", + messages: transcript(messages), + }) + const result: Share = { id: created.id, url: created.url, secret: "" } yield* db .insert(SessionShareTable) .values({ session_id: sessionID, id: result.id, secret: result.secret, url: result.url }) @@ -328,10 +259,6 @@ const layer = Layer.effect( .pipe(Effect.orDie) const s = yield* InstanceState.get(state) s.shared.set(sessionID, result) - yield* full(sessionID).pipe( - Effect.catchCause((cause) => Effect.logError("share full sync failed", { sessionID: sessionID, cause: cause })), - Effect.forkIn(s.scope), - ) return result }) @@ -342,30 +269,48 @@ const layer = Layer.effect( const share = yield* getCached(sessionID) if (!share) { s.shared.delete(sessionID) - s.queue.delete(sessionID) return } - const req = yield* request() - yield* HttpClientRequest.delete(`${req.baseUrl}${req.api.remove(share.id)}`).pipe( + const req = yield* authorized() + const res = yield* HttpClientRequest.delete(`${req.baseUrl}${req.api.remove(share.id)}`).pipe( HttpClientRequest.setHeaders(req.headers), - HttpClientRequest.bodyJson({ secret: share.secret }), - Effect.flatMap((r) => httpOk.execute(r)), + Effect.flatMap((r) => http.execute(r)), ) + // 404: the link is already gone on the server, which is the state unshare asks for. + if (res.status >= 400 && res.status !== 404) { + return yield* Effect.fail(new Error(`Redrob Chat refused to remove the share (${res.status})`)) + } yield* db.delete(SessionShareTable).where(eq(SessionShareTable.session_id, sessionID)).run().pipe(Effect.orDie) s.shared.delete(sessionID) - s.queue.delete(sessionID) }) - return Service.of({ init, url, request, create, remove }) + const artifact = Effect.fn("ShareNext.artifact")(function* (input: ArtifactInput) { + if (disabled) return yield* Effect.fail(new Error("Sharing is disabled (REDROB_DISABLE_SHARE)")) + const conf = yield* cfg.get() + if (conf.share === "disabled") return yield* Effect.fail(new Error("Sharing is disabled in configuration")) + return yield* post({ + kind: "artifact", + source: "code", + title: input.title.slice(0, 200), + visibility: "unlisted", + artifact: { + format: input.format, + ...(input.language ? { language: input.language } : {}), + body: input.body, + }, + }) + }) + + return Service.of({ init, url, request, create, remove, artifact }) }), ) export const node = LayerNode.make({ service: Service, layer: layer, - deps: () => [Account.node, EventV2Bridge.node, Config.node, Database.node, httpClient, Provider.node, Session.node], + deps: () => [EventV2Bridge.node, Config.node, Database.node, httpClient, Provider.node, Session.node], }) export * as ShareNext from "./share-next" diff --git a/packages/redrob/test/share/share-next.test.ts b/packages/redrob/test/share/share-next.test.ts index a01394506a..4f06def976 100644 --- a/packages/redrob/test/share/share-next.test.ts +++ b/packages/redrob/test/share/share-next.test.ts @@ -1,13 +1,11 @@ -import { beforeEach, describe, expect } from "bun:test" -import { Effect, Exit, Layer, Option } from "effect" +import { afterEach, beforeEach, describe, expect } from "bun:test" +import { Effect, Exit, Layer } from "effect" import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { LayerNode } from "@redrob-code/core/effect/layer-node" import { httpClient } from "@redrob-code/core/effect/app-node-platform" import { CrossSpawnSpawner } from "@redrob-code/core/cross-spawn-spawner" import { SessionProjector } from "@redrob-code/core/session/projector" -import { AccessToken, AccountID, OrgID } from "../../src/account/schema" -import { AccountRepo } from "../../src/account/repo" import { EventV2Bridge } from "../../src/event-v2-bridge" import { Session } from "@/session/session" import type { SessionID } from "../../src/session/schema" @@ -17,7 +15,7 @@ import { Database } from "@redrob-code/core/database/database" import { eq } from "drizzle-orm" import { provideTmpdirInstance } from "../fixture/fixture" import { resetDatabase } from "../fixture/db" -import { pollWithTimeout, testEffect } from "../lib/effect" +import { testEffect } from "../lib/effect" const env = LayerNode.compile(LayerNode.group([CrossSpawnSpawner.node])) const it = testEffect(env) @@ -35,20 +33,13 @@ const none = HttpClient.make(() => Effect.die("unexpected http call")) function requestLayer(client: HttpClient.HttpClient) { const replacement = [httpClient, Layer.succeed(HttpClient.HttpClient, client)] as const - return LayerNode.compile(LayerNode.group([ShareNext.node, AccountRepo.node]), [replacement]) + return LayerNode.compile(LayerNode.group([ShareNext.node]), [replacement]) } function integrationLayer(client: HttpClient.HttpClient) { const replacement = [httpClient, Layer.succeed(HttpClient.HttpClient, client)] as const return LayerNode.compile( - LayerNode.group([ - ShareNext.node, - EventV2Bridge.node, - Session.node, - SessionProjector.node, - AccountRepo.node, - Database.node, - ]), + LayerNode.group([ShareNext.node, EventV2Bridge.node, Session.node, SessionProjector.node, Database.node]), [replacement], ) } @@ -64,131 +55,93 @@ const share = (id: SessionID) => .pipe(Effect.orDie) }) -const seed = (url: string, org?: string) => - AccountRepo.Service.use((repo) => - repo.persistAccount({ - id: AccountID.make("account-1"), - email: "user@example.com", - url, - accessToken: AccessToken.make("st_test_token"), - orgID: org ? Option.some(OrgID.make(org)) : Option.none(), - }), - ) +const chat = { config: { chat: { url: "https://chat.example.com/" } } } +const previousKey = process.env["REDROB_API_KEY"] beforeEach(async () => { await resetDatabase() + process.env["REDROB_API_KEY"] = "rk_test_key" +}) + +afterEach(() => { + if (previousKey === undefined) delete process.env["REDROB_API_KEY"] + else process.env["REDROB_API_KEY"] = previousKey }) describe("ShareNext", () => { - it.live("request uses legacy share API without active org account", () => + it.live("request targets the Redrob Chat API with the console key", () => provideTmpdirInstance( () => ShareNext.Service.use((svc) => Effect.gen(function* () { const req = yield* svc.request() - expect(req.api.create).toBe("/api/share") - expect(req.api.sync("shr_123")).toBe("/api/share/shr_123/sync") - expect(req.api.remove("shr_123")).toBe("/api/share/shr_123") - expect(req.api.data("shr_123")).toBe("/api/share/shr_123/data") - expect(req.baseUrl).toBe("https://legacy-share.example.com") - expect(req.headers).toEqual({}) + expect(req.baseUrl).toBe("https://chat.example.com") + expect(req.api.create).toBe("/share") + expect(req.api.remove("shr_123")).toBe("/share/shr_123") + expect(req.api.data("shr_123")).toBe("/share/shr_123") + expect(req.headers).toEqual({ authorization: "Bearer rk_test_key" }) }), ).pipe(Effect.provide(requestLayer(none))), - { config: { enterprise: { url: "https://legacy-share.example.com" } } }, + chat, ), ) - it.live("request uses default URL when no enterprise config", () => + it.live("request defaults to production Redrob Chat", () => provideTmpdirInstance(() => ShareNext.Service.use((svc) => Effect.gen(function* () { const req = yield* svc.request() - - expect(req.baseUrl).toBe("https://opncd.ai") - expect(req.api.create).toBe("/api/share") - expect(req.headers).toEqual({}) + expect(req.baseUrl).toBe(ShareNext.DEFAULT_CHAT_API_URL) }), ).pipe(Effect.provide(requestLayer(none))), ), ) - it.live("request uses org share API with auth headers when account is active", () => - provideTmpdirInstance(() => - Effect.gen(function* () { - yield* seed("https://control.example.com", "org-1") - - const req = yield* ShareNext.use.request() - - expect(req.api.create).toBe("/api/shares") - expect(req.api.sync("shr_123")).toBe("/api/shares/shr_123/sync") - expect(req.api.remove("shr_123")).toBe("/api/shares/shr_123") - expect(req.api.data("shr_123")).toBe("/api/shares/shr_123/data") - expect(req.baseUrl).toBe("https://control.example.com") - expect(req.headers).toEqual({ - authorization: "Bearer st_test_token", - "x-org-id": "org-1", - }) - }).pipe(Effect.provide(requestLayer(none))), - ), - ) - - it.live("create posts share, persists it, and returns the result", () => + it.live("create posts a transcript snapshot, persists it, and returns the result", () => provideTmpdirInstance( () => { - const createRequests: HttpClientRequest.HttpClientRequest[] = [] + const seen: HttpClientRequest.HttpClientRequest[] = [] const client = HttpClient.make((req) => { - if (req.url.endsWith("/api/share")) { - createRequests.push(req) - return Effect.succeed( - json(req, { - id: "shr_abc", - url: "https://legacy-share.example.com/share/abc", - secret: "sec_123", - }), - ) - } - return Effect.succeed(json(req, { ok: true })) + seen.push(req) + return Effect.succeed(json(req, { id: "shr_abcdefghijklmnop", url: "https://chat.example.com/s/abc" }, 201)) }) return Effect.gen(function* () { const session = yield* (yield* Session.Service).create({ title: "test" }) const result = yield* (yield* ShareNext.Service).create(session.id) - expect(result.id).toBe("shr_abc") - expect(result.url).toBe("https://legacy-share.example.com/share/abc") - expect(result.secret).toBe("sec_123") + expect(result.id).toBe("shr_abcdefghijklmnop") + expect(result.url).toBe("https://chat.example.com/s/abc") const row = yield* share(session.id) - expect(row?.id).toBe("shr_abc") - expect(row?.url).toBe("https://legacy-share.example.com/share/abc") - expect(row?.secret).toBe("sec_123") + expect(row?.url).toBe("https://chat.example.com/s/abc") - expect(createRequests).toHaveLength(1) - expect(createRequests[0].method).toBe("POST") - expect(createRequests[0].url).toBe("https://legacy-share.example.com/api/share") + expect(seen).toHaveLength(1) + expect(seen[0].method).toBe("POST") + expect(seen[0].url).toBe("https://chat.example.com/share") + expect(seen[0].headers["authorization"]).toBe("Bearer rk_test_key") + const body = seen[0].body._tag === "Uint8Array" ? JSON.parse(new TextDecoder().decode(seen[0].body.body)) : {} + expect(body.kind).toBe("transcript") + expect(body.source).toBe("code") + expect(body.title).toBe("test") + expect(Array.isArray(body.messages)).toBe(true) }).pipe(Effect.provide(integrationLayer(client))) }, - { config: { enterprise: { url: "https://legacy-share.example.com" } } }, + chat, ), ) - it.live("remove deletes the persisted share and calls the delete endpoint", () => + it.live("remove deletes the persisted share and calls DELETE /share/:id", () => provideTmpdirInstance( () => { const seen: HttpClientRequest.HttpClientRequest[] = [] const client = HttpClient.make((req) => { seen.push(req) if (req.method === "POST") { - return Effect.succeed( - json(req, { - id: "shr_abc", - url: "https://legacy-share.example.com/share/abc", - secret: "sec_123", - }), - ) + return Effect.succeed(json(req, { id: "shr_abc", url: "https://chat.example.com/s/shr_abc" }, 201)) } - return Effect.succeed(HttpClientResponse.fromWeb(req, new Response(null, { status: 200 }))) + return Effect.succeed(HttpClientResponse.fromWeb(req, new Response(null, { status: 204 }))) }) return Effect.gen(function* () { const session = yield* (yield* Session.Service).create({ title: "test" }) @@ -199,12 +152,12 @@ describe("ShareNext", () => { expect(yield* share(session.id)).toBeUndefined() expect(seen.map((req) => [req.method, req.url])).toEqual([ - ["POST", "https://legacy-share.example.com/api/share"], - ["DELETE", "https://legacy-share.example.com/api/share/shr_abc"], + ["POST", "https://chat.example.com/share"], + ["DELETE", "https://chat.example.com/share/shr_abc"], ]) }).pipe(Effect.provide(integrationLayer(client))) }, - { config: { enterprise: { url: "https://legacy-share.example.com" } } }, + chat, ), ) @@ -219,104 +172,6 @@ describe("ShareNext", () => { expect(Exit.isFailure(exit)).toBe(true) expect(yield* share(session.id)).toBeUndefined() }).pipe(Effect.provide(integrationLayer(client))) - }), - ) - - it.live("ShareNext coalesces rapid diff events into one delayed sync with latest data", () => - provideTmpdirInstance( - () => { - const seen: Array<{ url: string; body: string }> = [] - const client = HttpClient.make((req) => { - if (req.url.endsWith("/sync") && req.body._tag === "Uint8Array") { - seen.push({ url: req.url, body: new TextDecoder().decode(req.body.body) }) - } - return Effect.succeed(json(req, { ok: true })) - }) - - return Effect.gen(function* () { - const events = yield* EventV2Bridge.Service - const share = yield* ShareNext.Service - const session = yield* Session.Service - - const info = yield* session.create({ title: "first" }) - yield* share.init() - yield* Effect.sleep(50) - const { db } = yield* Database.Service - yield* db - .insert(SessionShareTable) - .values({ - session_id: info.id, - id: "shr_abc", - url: "https://legacy-share.example.com/share/abc", - secret: "sec_123", - }) - .run() - .pipe(Effect.orDie) - - yield* events.publish(Session.Event.Diff, { - sessionID: info.id, - diff: [ - { - file: "a.ts", - patch: - "Index: a.ts\n===================================================================\n--- a.ts\t\n+++ a.ts\t\n@@ -1,1 +1,1 @@\n-one\n\\ No newline at end of file\n+two\n\\ No newline at end of file\n", - additions: 1, - deletions: 1, - status: "modified", - }, - ], - }) - yield* events.publish(Session.Event.Diff, { - sessionID: info.id, - diff: [ - { - file: "b.ts", - patch: - "Index: b.ts\n===================================================================\n--- b.ts\t\n+++ b.ts\t\n@@ -1,1 +1,1 @@\n-old\n\\ No newline at end of file\n+new\n\\ No newline at end of file\n", - additions: 2, - deletions: 0, - status: "modified", - }, - ], - }) - yield* pollWithTimeout( - Effect.sync(() => (seen.length === 1 ? true : undefined)), - "timed out waiting for share sync", - "5 seconds", - ) - - expect(seen).toHaveLength(1) - expect(seen[0].url).toBe("https://legacy-share.example.com/api/share/shr_abc/sync") - - const body = JSON.parse(seen[0].body) as { - secret: string - data: Array<{ - type: string - data: Array<{ - file: string - patch: string - additions: number - deletions: number - status?: string - }> - }> - } - expect(body.secret).toBe("sec_123") - expect(body.data).toHaveLength(1) - expect(body.data[0].type).toBe("session_diff") - expect(body.data[0].data).toEqual([ - { - file: "b.ts", - patch: - "Index: b.ts\n===================================================================\n--- b.ts\t\n+++ b.ts\t\n@@ -1,1 +1,1 @@\n-old\n\\ No newline at end of file\n+new\n\\ No newline at end of file\n", - additions: 2, - deletions: 0, - status: "modified", - }, - ]) - }).pipe(Effect.provide(integrationLayer(client))) - }, - { config: { enterprise: { url: "https://legacy-share.example.com" } } }, - ), + }, chat), ) }) From 1e406d41480d1421e01954967d45e028a6ba687d Mon Sep 17 00:00:00 2001 From: Janghoon Lee <44862514+savagemanage@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:05:11 +0000 Subject: [PATCH 2/3] fix(share): run the DELETE request through the http client HttpClientRequest.delete returns a request, not an effect; execute it directly. --- packages/redrob/src/share/share-next.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/redrob/src/share/share-next.ts b/packages/redrob/src/share/share-next.ts index 2d042d9599..379916e092 100644 --- a/packages/redrob/src/share/share-next.ts +++ b/packages/redrob/src/share/share-next.ts @@ -273,9 +273,10 @@ const layer = Layer.effect( } const req = yield* authorized() - const res = yield* HttpClientRequest.delete(`${req.baseUrl}${req.api.remove(share.id)}`).pipe( - HttpClientRequest.setHeaders(req.headers), - Effect.flatMap((r) => http.execute(r)), + const res = yield* http.execute( + HttpClientRequest.delete(`${req.baseUrl}${req.api.remove(share.id)}`).pipe( + HttpClientRequest.setHeaders(req.headers), + ), ) // 404: the link is already gone on the server, which is the state unshare asks for. if (res.status >= 400 && res.status !== 404) { From 0a0d6351d286bd29809dbe08375def1aa29f27a1 Mon Sep 17 00:00:00 2001 From: Janghoon Lee <44862514+its-janghoon@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:13:24 +0000 Subject: [PATCH 3/3] chore(schema): regenerate config schema for share.chat.url --- packages/redrob/schema/config.json | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/redrob/schema/config.json b/packages/redrob/schema/config.json index 21c515721f..5674cb46dd 100644 --- a/packages/redrob/schema/config.json +++ b/packages/redrob/schema/config.json @@ -1202,6 +1202,16 @@ }, "additionalProperties": false }, + "chat": { + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "Redrob Chat API base URL used for sharing (default https://chat-prod.redrob.ai)" + } + }, + "additionalProperties": false + }, "tool_output": { "type": "object", "properties": {