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 `
\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/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": {
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 `
\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..379916e092 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)),
- )
-
- 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 },
+ HttpClientRequest.bodyJson(body),
+ Effect.flatMap((r) => httpOk.execute(r)),
+ Effect.flatMap(HttpClientResponse.schemaBodyJson(CreatedSchema)),
)
-
- 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,49 @@ 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(
- HttpClientRequest.setHeaders(req.headers),
- HttpClientRequest.bodyJson({ secret: share.secret }),
- Effect.flatMap((r) => httpOk.execute(r)),
+ const req = yield* authorized()
+ 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) {
+ 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),
)
})