diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx index 92c6b41e3..b47d80680 100644 --- a/packages/app/src/context/platform.tsx +++ b/packages/app/src/context/platform.tsx @@ -95,6 +95,16 @@ export type Platform = { /** Save file picker dialog (desktop only) */ saveFilePickerDialog?(opts?: SaveFilePickerOptions): Promise + /** + * Export a session to a local JSON file (desktop only). + * Main process fetches the internal export route, opens save dialog, writes file. + */ + exportSession?( + sessionID: string, + directory: string, + defaultName?: string, + ): Promise<{ ok: true; path: string } | { ok: false; error: string }> + /** Storage mechanism, defaults to localStorage */ storage?: (name?: string) => SyncStorage | AsyncStorage diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index e4a39527a..af343d9cf 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -697,6 +697,10 @@ export const dict = { "session.share.copy.copied": "Copied", "session.share.copy.copyLink": "Copy link", + "session.export.action.export": "Export session log", + "session.export.success": "Session exported", + "session.export.error.failed": "Export failed", + "lsp.tooltip.none": "No LSP servers", "lsp.label.connected": "{{count}} LSP", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index 27542fd99..a5e42c0be 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -633,6 +633,10 @@ export const dict = { "session.share.copy.copied": "已复制", "session.share.copy.copyLink": "复制链接", + "session.export.action.export": "导出会话日志", + "session.export.success": "会话已导出", + "session.export.error.failed": "导出失败", + "lsp.tooltip.none": "没有 LSP 服务器", "lsp.label.connected": "{{count}} LSP", diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index 2ebe4ea72..6b81658dc 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -12,12 +12,10 @@ import { InlineInput } from "@opencode-ai/ui/inline-input" import { Spinner } from "@opencode-ai/ui/spinner" import { SessionTurn } from "@opencode-ai/ui/session-turn" import { ScrollView } from "@opencode-ai/ui/scroll-view" -import { TextField } from "@opencode-ai/ui/text-field" import type { AssistantMessage, Message as MessageType, Part, TextPart, UserMessage } from "@opencode-ai/sdk/v2" import { showToast } from "@opencode-ai/ui/toast" import { Binary } from "@opencode-ai/util/binary" import { getFilename } from "@opencode-ai/util/path" -import { Popover as KobaltePopover } from "@kobalte/core/popover" import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture" import { isSessionRunning } from "@/pages/session/session-running-state" import { SessionContextUsage } from "@/components/session-context-usage" @@ -25,8 +23,8 @@ import { useDialog } from "@opencode-ai/ui/context/dialog" import { createResizeObserver } from "@solid-primitives/resize-observer" import { useLanguage } from "@/context/language" import { useSessionKey } from "@/pages/session/session-layout" -import { useGlobalSDK } from "@/context/global-sdk" import { usePlatform } from "@/context/platform" +import { useServer } from "@/context/server" import { useSettings } from "@/context/settings" import { useSDK } from "@/context/sdk" import { useSync } from "@/context/sync" @@ -234,7 +232,6 @@ export function MessageTimeline(props: { let touchGesture: number | undefined const navigate = useNavigate() - const globalSDK = useGlobalSDK() const sdk = useSDK() const sync = useSync() const settings = useSettings() @@ -242,6 +239,11 @@ export function MessageTimeline(props: { const language = useLanguage() const { params, sessionKey } = useSessionKey() const platform = usePlatform() + const server = useServer() + // Export hits the embedded sidecar via main-process IPC. When the user has switched the + // active server to a remote HTTP/SSH target, the sidecar holds different data than the UI; + // hide the action rather than ship a misleading export. + const exportAvailable = createMemo(() => !!platform.exportSession && server.current?.type === "sidecar") const rendered = createMemo(() => props.renderedUserMessages.map((message) => message.id)) const sessionID = createMemo(() => params.id) @@ -307,8 +309,6 @@ export function MessageTimeline(props: { }) const titleValue = createMemo(() => info()?.title) const titleLabel = createMemo(() => sessionTitle(titleValue())) - const shareUrl = createMemo(() => info()?.share?.url) - const shareEnabled = createMemo(() => sync.data.config.share !== "disabled") const parentID = createMemo(() => info()?.parentID) const parent = createMemo(() => { const id = parentID() @@ -350,14 +350,9 @@ export function MessageTimeline(props: { editing: false, menuOpen: false, pendingRename: false, - pendingShare: false, }) let titleRef: HTMLInputElement | undefined - const [share, setShare] = createStore({ - open: false, - dismiss: null as "escape" | "outside" | null, - }) const [bar, setBar] = createStore({ ms: pace(640), }) @@ -373,12 +368,6 @@ export function MessageTimeline(props: { }, ) - const viewShare = () => { - const url = shareUrl() - if (!url) return - platform.openLink(url) - } - const errorMessage = (err: unknown) => { if (err && typeof err === "object" && "data" in err) { const data = (err as { data?: { message?: string } }).data @@ -388,20 +377,6 @@ export function MessageTimeline(props: { return language.t("common.requestFailed") } - const shareMutation = useMutation(() => ({ - mutationFn: (id: string) => globalSDK.client.session.share({ sessionID: id, directory: sdk.directory }), - onError: (err) => { - console.error("Failed to share session", err) - }, - })) - - const unshareMutation = useMutation(() => ({ - mutationFn: (id: string) => globalSDK.client.session.unshare({ sessionID: id, directory: sdk.directory }), - onError: (err) => { - console.error("Failed to unshare session", err) - }, - })) - const titleMutation = useMutation(() => ({ mutationFn: (input: { id: string; title: string }) => sdk.client.session.update({ sessionID: input.id, title: input.title }), @@ -422,18 +397,43 @@ export function MessageTimeline(props: { }, })) - const shareSession = () => { + const onExport = async () => { const id = sessionID() - if (!id || shareMutation.isPending) return - if (!shareEnabled()) return - shareMutation.mutate(id) - } - - const unshareSession = () => { - const id = sessionID() - if (!id || unshareMutation.isPending) return - if (!shareEnabled()) return - unshareMutation.mutate(id) + if (!id || !platform.exportSession) return + + // Build a slug-based default filename. Falls back to id suffix if slug is missing. + const slugSource = info()?.slug ?? id + // Allow Unicode letters/numbers (CJK titles work) but strip filesystem-hostile chars. + // If sanitization produces an empty/dash-only string, fall back to the id suffix. + const sanitized = slugSource.replace(/[\\/:*?"<>|]/g, "-").slice(0, 32) + const slug = /[\p{L}\p{N}]/u.test(sanitized) ? sanitized : id.slice(-8) + const stamp = new Date().toISOString().replace(/[:T]/g, "-").replace(/\..+$/, "") + const defaultName = `pawwork-session-${slug}-${stamp}.json` + + let result: { ok: true; path: string } | { ok: false; error: string } + try { + result = await platform.exportSession(id, sdk.directory, defaultName) + } catch (err) { + showToast({ + title: language.t("session.export.error.failed"), + description: errorMessage(err), + variant: "error", + }) + return + } + if (!result.ok) { + if (result.error === "cancelled") return + showToast({ + title: language.t("session.export.error.failed"), + description: result.error, + variant: "error", + }) + return + } + showToast({ + title: language.t("session.export.success"), + description: result.path, + }) } createEffect( @@ -445,7 +445,6 @@ export function MessageTimeline(props: { editing: false, menuOpen: false, pendingRename: false, - pendingShare: false, }), { defer: true }, ), @@ -835,11 +834,8 @@ export function MessageTimeline(props: { icon="dot-grid" variant="ghost" class="size-6 rounded-md data-[expanded]:bg-surface-base-active" - classList={{ - "bg-surface-base-active": share.open || title.pendingShare, - }} aria-label={language.t("common.moreOptions")} - aria-expanded={title.menuOpen || share.open || title.pendingShare} + aria-expanded={title.menuOpen} ref={(el: HTMLButtonElement) => { more = el }} @@ -852,14 +848,6 @@ export function MessageTimeline(props: { event.preventDefault() setTitle("pendingRename", false) openTitleEditor() - return - } - if (title.pendingShare) { - event.preventDefault() - requestAnimationFrame(() => { - setShare({ open: true, dismiss: null }) - setTitle("pendingShare", false) - }) } }} > @@ -871,14 +859,15 @@ export function MessageTimeline(props: { > {language.t("common.rename")} - + { - setTitle({ pendingShare: true, menuOpen: false }) + setTitle("menuOpen", false) + void onExport() }} > - {language.t("session.share.action.share")} + {language.t("session.export.action.export")} @@ -894,104 +883,6 @@ export function MessageTimeline(props: { - - more} - placement="bottom-end" - gutter={4} - modal={false} - onOpenChange={(open) => { - if (open) setShare("dismiss", null) - setShare("open", open) - }} - > - - { - setShare({ dismiss: "escape", open: false }) - event.preventDefault() - event.stopPropagation() - }} - onPointerDownOutside={() => { - setShare({ dismiss: "outside", open: false }) - }} - onFocusOutside={() => { - setShare({ dismiss: "outside", open: false }) - }} - onCloseAutoFocus={(event) => { - if (share.dismiss === "outside") event.preventDefault() - setShare("dismiss", null) - }} - > -
-
-
- {language.t("session.share.popover.title")} -
-
- {shareUrl() - ? language.t("session.share.popover.description.shared") - : language.t("session.share.popover.description.unshared")} -
-
-
- - {shareMutation.isPending - ? language.t("session.share.action.publishing") - : language.t("session.share.action.publish")} - - } - > -
- -
- - -
-
-
-
-
-
-
-
)} diff --git a/packages/desktop-electron/src/main/index.ts b/packages/desktop-electron/src/main/index.ts index 89e2eeba9..3016e19fc 100644 --- a/packages/desktop-electron/src/main/index.ts +++ b/packages/desktop-electron/src/main/index.ts @@ -496,6 +496,7 @@ registerIpcHandlers({ initEmitter.off("step", listener) } }, + getServerReadyData: () => serverReady.promise, getDefaultServerUrl: () => getDefaultServerUrl(), setDefaultServerUrl: (url) => setDefaultServerUrl(url), getWslConfig: () => Promise.resolve(getWslConfig()), diff --git a/packages/desktop-electron/src/main/ipc.ts b/packages/desktop-electron/src/main/ipc.ts index 4acd3c11b..4bc2deca1 100644 --- a/packages/desktop-electron/src/main/ipc.ts +++ b/packages/desktop-electron/src/main/ipc.ts @@ -17,6 +17,7 @@ import type { } from "../preload/types" import { attachmentPathMime } from "./attachment-mime" import { getStore } from "./store" +import { fetchExport } from "./server-client" const pickerFilters = (ext?: string[]) => { if (!ext || ext.length === 0) return undefined @@ -38,6 +39,7 @@ function normalizeAttachmentPath(filepath: unknown) { type Deps = { killSidecar: () => void awaitInitialization: (sendStep: (step: InitStep) => void) => Promise + getServerReadyData: () => Promise getDefaultServerUrl: () => Promise | string | null setDefaultServerUrl: (url: string | null) => Promise | void getWslConfig: () => Promise @@ -233,6 +235,33 @@ export function registerIpcHandlers(deps: Deps) { }, ) + ipcMain.handle( + "export-session", + async (_event: IpcMainInvokeEvent, sessionID: string, directory: string, defaultName?: string) => { + if (typeof sessionID !== "string" || typeof directory !== "string") { + return { ok: false, error: "invalid_args" } as const + } + const server = await deps.getServerReadyData() + const fetched = await fetchExport(server, directory, sessionID) + if (!fetched.ok) return fetched + + const fallbackStamp = new Date().toISOString().replace(/[:T]/g, "-").replace(/\..+$/, "") + const result = await dialog.showSaveDialog({ + title: "Export session log", + defaultPath: defaultName ?? `pawwork-session-${sessionID.slice(-8)}-${fallbackStamp}.json`, + filters: [{ name: "JSON", extensions: ["json"] }], + }) + if (result.canceled || !result.filePath) return { ok: false, error: "cancelled" } as const + + try { + await fs.writeFile(result.filePath, fetched.body, "utf8") + return { ok: true, path: result.filePath } as const + } catch (err) { + return { ok: false, error: (err as Error).message } as const + } + }, + ) + ipcMain.on("open-link", (_event: IpcMainEvent, url: string) => { void shell.openExternal(url) }) diff --git a/packages/desktop-electron/src/main/server-client.ts b/packages/desktop-electron/src/main/server-client.ts new file mode 100644 index 000000000..4edb7720d --- /dev/null +++ b/packages/desktop-electron/src/main/server-client.ts @@ -0,0 +1,52 @@ +import type { ServerReadyData } from "../preload/types" + +export type InternalFetchError = { ok: false; error: string } +export type InternalFetchOk = { ok: true; body: string } + +export function buildExportUrl(server: Pick, directory: string, sessionID: string) { + // Locked from pre-flight: directory is a QUERY PARAM (consumed by instance middleware), + // NOT a path segment. The full URL is `/session//export?directory=`. + const base = server.url.replace(/\/$/, "") + const url = new URL(`${base}/session/${encodeURIComponent(sessionID)}/export`) + url.searchParams.set("directory", directory) + return url.toString() +} + +export function buildAuthHeader(server: Pick): Record { + if (server.username || server.password) { + // Fallback `"opencode"` username when only password is set matches main/index.ts:170 + // (the existing internal-fetch path) so dev installs that omit username still authenticate. + return { + Authorization: + "Basic " + Buffer.from(`${server.username ?? "opencode"}:${server.password ?? ""}`).toString("base64"), + } + } + return {} +} + +// Same 10s ceiling as the existing feedback/session-export internal fetch in main/index.ts; +// if the embedded server (or any future remote target) stalls, the renderer surfaces a real +// error toast instead of an indefinitely-pending Promise. +const FETCH_TIMEOUT_MS = 10_000 + +export async function fetchExport( + server: ServerReadyData, + directory: string, + sessionID: string, +): Promise { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS) + try { + const res = await fetch(buildExportUrl(server, directory, sessionID), { + headers: buildAuthHeader(server), + signal: controller.signal, + }) + if (!res.ok) return { ok: false, error: `server_${res.status}` } + return { ok: true, body: await res.text() } + } catch (err) { + const message = (err as Error).name === "AbortError" ? "timeout" : (err as Error).message + return { ok: false, error: message } + } finally { + clearTimeout(timer) + } +} diff --git a/packages/desktop-electron/src/preload/index.ts b/packages/desktop-electron/src/preload/index.ts index d0a3855b5..5c173f69e 100644 --- a/packages/desktop-electron/src/preload/index.ts +++ b/packages/desktop-electron/src/preload/index.ts @@ -59,6 +59,8 @@ const api: ElectronAPI = { openFilePicker: (opts) => ipcRenderer.invoke("open-file-picker", opts), readFileDataUrl: (path, mime) => ipcRenderer.invoke("read-file-data-url", path, mime), saveFilePicker: (opts) => ipcRenderer.invoke("save-file-picker", opts), + exportSession: (sessionID, directory, defaultName) => + ipcRenderer.invoke("export-session", sessionID, directory, defaultName), openLink: (url) => ipcRenderer.send("open-link", url), openPath: (path, app) => ipcRenderer.invoke("open-path", path, app), showItemInFolder: (path) => ipcRenderer.invoke("show-item-in-folder", path), diff --git a/packages/desktop-electron/src/preload/types.ts b/packages/desktop-electron/src/preload/types.ts index ed377d486..9842b4bf1 100644 --- a/packages/desktop-electron/src/preload/types.ts +++ b/packages/desktop-electron/src/preload/types.ts @@ -74,6 +74,11 @@ export type ElectronAPI = { }) => Promise readFileDataUrl: (path: string, mime: string) => Promise saveFilePicker: (opts?: { title?: string; defaultPath?: string }) => Promise + exportSession: ( + sessionID: string, + directory: string, + defaultName?: string, + ) => Promise<{ ok: true; path: string } | { ok: false; error: string }> openLink: (url: string) => void openPath: (path: string, app?: string) => Promise showItemInFolder: (path: string) => Promise diff --git a/packages/desktop-electron/src/renderer/index.tsx b/packages/desktop-electron/src/renderer/index.tsx index b01ace0fe..26453b87c 100644 --- a/packages/desktop-electron/src/renderer/index.tsx +++ b/packages/desktop-electron/src/renderer/index.tsx @@ -175,6 +175,10 @@ const createPlatform = (): Platform => { return handleWslPicker(result) }, + exportSession(sessionID, directory, defaultName) { + return window.api.exportSession(sessionID, directory, defaultName) + }, + openLink(url: string) { window.api.openLink(url) }, diff --git a/packages/opencode/src/cli/cmd/export.ts b/packages/opencode/src/cli/cmd/export.ts index 8d37805b1..f83a72898 100644 --- a/packages/opencode/src/cli/cmd/export.ts +++ b/packages/opencode/src/cli/cmd/export.ts @@ -1,6 +1,5 @@ import type { Argv } from "yargs" import { Session } from "../../session" -import { MessageV2 } from "../../session/message-v2" import { SessionID } from "../../session/schema" import { cmd } from "./cmd" import { bootstrap } from "../bootstrap" @@ -8,272 +7,7 @@ import { UI } from "../ui" import * as prompts from "@clack/prompts" import { EOL } from "os" import { AppRuntime } from "@/effect/app-runtime" - -function redact(kind: string, id: string, value: string) { - return value.trim() ? `[redacted:${kind}:${id}]` : value -} - -function data(kind: string, id: string, value: Record | undefined) { - if (!value) return value - return Object.keys(value).length ? { redacted: `${kind}:${id}` } : value -} - -function span(id: string, value: { value: string; start: number; end: number }) { - return { - ...value, - value: redact("file-text", id, value.value), - } -} - -function diff(kind: string, diffs: { file: string; patch: string }[] | undefined) { - return diffs?.map((item, i) => ({ - ...item, - file: redact(`${kind}-file`, String(i), item.file), - patch: redact(`${kind}-patch`, String(i), item.patch), - })) -} - -function source(part: MessageV2.FilePart) { - if (!part.source) return part.source - if (part.source.type === "symbol") { - return { - ...part.source, - path: redact("file-path", part.id, part.source.path), - name: redact("file-symbol", part.id, part.source.name), - text: span(part.id, part.source.text), - } - } - if (part.source.type === "resource") { - return { - ...part.source, - clientName: redact("file-client", part.id, part.source.clientName), - uri: redact("file-uri", part.id, part.source.uri), - text: span(part.id, part.source.text), - } - } - return { - ...part.source, - path: redact("file-path", part.id, part.source.path), - text: span(part.id, part.source.text), - } -} - -function filepart(part: MessageV2.FilePart): MessageV2.FilePart { - return { - ...part, - url: redact("file-url", part.id, part.url), - filename: part.filename === undefined ? undefined : redact("file-name", part.id, part.filename), - source: source(part), - } -} - -function errorData(kind: string, id: string, value: Record | undefined) { - if (!value) return value - return { - ...value, - message: typeof value.message === "string" ? redact(`${kind}-message`, id, value.message) : value.message, - responseBody: - typeof value.responseBody === "string" ? redact(`${kind}-body`, id, value.responseBody) : value.responseBody, - responseHeaders: data(`${kind}-headers`, id, value.responseHeaders as Record | undefined), - metadata: data(`${kind}-metadata`, id, value.metadata as Record | undefined), - } -} - -function namedError }>(kind: string, id: string, error: T): T -function namedError }>( - kind: string, - id: string, - error: T | undefined, -): T | undefined -function namedError }>(kind: string, id: string, error: T | undefined) { - if (!error) return error - return { - ...error, - data: errorData(kind, id, error.data), - } -} - -function part(part: MessageV2.Part): MessageV2.Part { - switch (part.type) { - case "text": - return { - ...part, - text: redact("text", part.id, part.text), - metadata: data("text-metadata", part.id, part.metadata), - } - case "reasoning": - return { - ...part, - text: redact("reasoning", part.id, part.text), - metadata: data("reasoning-metadata", part.id, part.metadata), - } - case "file": - return filepart(part) - case "subtask": - return { - ...part, - prompt: redact("subtask-prompt", part.id, part.prompt), - description: redact("subtask-description", part.id, part.description), - command: part.command === undefined ? undefined : redact("subtask-command", part.id, part.command), - } - case "tool": - switch (part.state.status) { - case "pending": - return { - ...part, - metadata: data("tool-metadata", part.id, part.metadata), - state: { - ...part.state, - input: data("tool-input", part.id, part.state.input) ?? part.state.input, - raw: redact("tool-raw", part.id, part.state.raw), - }, - } - case "running": - return { - ...part, - metadata: data("tool-metadata", part.id, part.metadata), - state: { - ...part.state, - input: data("tool-input", part.id, part.state.input) ?? part.state.input, - title: part.state.title === undefined ? undefined : redact("tool-title", part.id, part.state.title), - metadata: data("tool-state-metadata", part.id, part.state.metadata), - }, - } - case "completed": - return { - ...part, - metadata: data("tool-metadata", part.id, part.metadata), - state: { - ...part.state, - input: data("tool-input", part.id, part.state.input) ?? part.state.input, - output: redact("tool-output", part.id, part.state.output), - title: redact("tool-title", part.id, part.state.title), - metadata: data("tool-state-metadata", part.id, part.state.metadata) ?? part.state.metadata, - attachments: part.state.attachments?.map(filepart), - }, - } - case "error": - return { - ...part, - metadata: data("tool-metadata", part.id, part.metadata), - state: { - ...part.state, - input: data("tool-input", part.id, part.state.input) ?? part.state.input, - error: redact("tool-error", part.id, part.state.error), - metadata: data("tool-state-metadata", part.id, part.state.metadata) ?? part.state.metadata, - }, - } - } - case "patch": - return { - ...part, - hash: redact("patch", part.id, part.hash), - files: part.files.map((item: string, i: number) => redact("patch-file", `${part.id}-${i}`, item)), - } - case "snapshot": - return { - ...part, - snapshot: redact("snapshot", part.id, part.snapshot), - } - case "step-start": - return { - ...part, - snapshot: part.snapshot === undefined ? undefined : redact("snapshot", part.id, part.snapshot), - } - case "step-finish": - return { - ...part, - snapshot: part.snapshot === undefined ? undefined : redact("snapshot", part.id, part.snapshot), - } - case "agent": - return { - ...part, - source: !part.source - ? part.source - : { - ...part.source, - value: redact("agent-source", part.id, part.source.value), - }, - } - case "retry": - return { - ...part, - error: namedError("retry-error", part.id, part.error), - } - default: - return part - } -} - -const partFn = part - -export function sanitize(data: { info: Session.Info; messages: MessageV2.WithParts[] }) { - return { - info: { - ...data.info, - title: redact("session-title", data.info.id, data.info.title), - directory: redact("session-directory", data.info.id, data.info.directory), - share: !data.info.share - ? data.info.share - : { - ...data.info.share, - url: redact("session-share", data.info.id, data.info.share.url), - }, - summary: !data.info.summary - ? data.info.summary - : { - ...data.info.summary, - diffs: diff("session-diff", data.info.summary.diffs), - }, - revert: !data.info.revert - ? data.info.revert - : { - ...data.info.revert, - snapshot: - data.info.revert.snapshot === undefined - ? undefined - : redact("revert-snapshot", data.info.id, data.info.revert.snapshot), - diff: - data.info.revert.diff === undefined - ? undefined - : redact("revert-diff", data.info.id, data.info.revert.diff), - }, - }, - messages: data.messages.map((msg) => ({ - info: - msg.info.role === "user" - ? { - ...msg.info, - system: msg.info.system === undefined ? undefined : redact("system", msg.info.id, msg.info.system), - summary: !msg.info.summary - ? msg.info.summary - : { - ...msg.info.summary, - title: - msg.info.summary.title === undefined - ? undefined - : redact("summary-title", msg.info.id, msg.info.summary.title), - body: - msg.info.summary.body === undefined - ? undefined - : redact("summary-body", msg.info.id, msg.info.summary.body), - diffs: diff("message-diff", msg.info.summary.diffs), - }, - } - : { - ...msg.info, - path: { - cwd: redact("cwd", msg.info.id, msg.info.path.cwd), - root: redact("root", msg.info.id, msg.info.path.root), - }, - structured: - msg.info.structured === undefined ? undefined : { redacted: `assistant-structured:${msg.info.id}` }, - error: namedError("assistant-error", msg.info.id, msg.info.error), - }, - parts: msg.parts.map(partFn), - })), - } -} +import { Export } from "../../session/export" export const ExportCommand = cmd({ command: "export [sessionID]", @@ -340,17 +74,9 @@ export const ExportCommand = cmd({ } try { - const sessionInfo = await AppRuntime.runPromise(Session.Service.use((svc) => svc.get(sessionID!))) - const messages = await AppRuntime.runPromise( - Session.Service.use((svc) => svc.messages({ sessionID: sessionInfo.id })), - ) - - const exportData = { - info: sessionInfo, - messages, - } - - process.stdout.write(JSON.stringify(args.sanitize ? sanitize(exportData) : exportData, null, 2)) + const result = await AppRuntime.runPromise(Export.session(sessionID!)) + const final = args.sanitize ? Export.sanitizeSnapshot(result) : result + process.stdout.write(JSON.stringify(final, null, 2)) process.stdout.write(EOL) } catch { UI.error(`Session not found: ${sessionID!}`) diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index f0e6a2306..e3739050b 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -46,6 +46,7 @@ import { Pty } from "@/pty" import { Installation } from "@/installation" import { ShareNext } from "@/share/share-next" import { SessionShare } from "@/share/session" +import { ShareRuntime } from "@/share/runtime" import { memoMap } from "./memo-map" export const AppLayer = Layer.mergeAll( @@ -94,6 +95,7 @@ export const AppLayer = Layer.mergeAll( Installation.defaultLayer, ShareNext.defaultLayer, SessionShare.defaultLayer, + ShareRuntime.cloudShareGateDefaultLayer, ) const rt = ManagedRuntime.make(AppLayer, { memoMap }) diff --git a/packages/opencode/src/server/instance/session.ts b/packages/opencode/src/server/instance/session.ts index 75eda7e49..aa1f6ed6f 100644 --- a/packages/opencode/src/server/instance/session.ts +++ b/packages/opencode/src/server/instance/session.ts @@ -10,6 +10,9 @@ import { SessionRunState } from "@/session/run-state" import { SessionCompaction } from "../../session/compaction" import { SessionRevert } from "../../session/revert" import { SessionShare } from "@/share/session" +import { Export } from "@/session/export" +import { ShareRuntime } from "@/share/runtime" +import { NotFoundError } from "@/storage/db" import { SessionStatus } from "@/session/status" import { SessionSummary } from "@/session/summary" import { Todo } from "../../session/todo" @@ -439,6 +442,15 @@ export const SessionRoutes = lazy(() => ), async (c) => { const sessionID = c.req.valid("param").sessionID + const enabled = await AppRuntime.runPromise( + Effect.gen(function* () { + const gate = yield* ShareRuntime.CloudShareGate + return gate.isEnabled() + }), + ) + if (!enabled) { + return c.json({ error: "cloud_share_disabled" }, 410) + } const share = await SessionShare.share(sessionID) const session = await Session.get(sessionID) return c.json({ @@ -447,6 +459,41 @@ export const SessionRoutes = lazy(() => }) }, ) + .get( + "/:sessionID/export", + describeRoute({ + summary: "Export session log", + description: + "Export the full root session tree as a single JSON document for local debugging. " + + "If a child session id is provided, climbs to the topmost ancestor and exports the whole tree.", + operationId: "session.export", + responses: { + 200: { + description: "Successfully exported session", + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + sessionID: SessionID.zod, + }), + ), + async (c) => { + const sessionID = c.req.valid("param").sessionID + try { + const result = await AppRuntime.runPromise(Export.session(sessionID)) + return c.json(result) + } catch (err) { + // Session.get throws NotFoundError; matches the typed pattern used in middleware.ts. + if (err instanceof NotFoundError) { + return c.json({ error: "session_not_found", sessionID }, 404) + } + throw err + } + }, + ) .get( "/:sessionID/diff", describeRoute({ @@ -543,6 +590,15 @@ export const SessionRoutes = lazy(() => ), async (c) => { const sessionID = c.req.valid("param").sessionID + const enabled = await AppRuntime.runPromise( + Effect.gen(function* () { + const gate = yield* ShareRuntime.CloudShareGate + return gate.isEnabled() + }), + ) + if (!enabled) { + return c.json({ error: "cloud_share_disabled" }, 410) + } await SessionShare.unshare(sessionID) const session = await Session.get(sessionID) return c.json({ diff --git a/packages/opencode/src/session/export.ts b/packages/opencode/src/session/export.ts new file mode 100644 index 000000000..da2a61d2a --- /dev/null +++ b/packages/opencode/src/session/export.ts @@ -0,0 +1,606 @@ +import os from "node:os" +import path from "node:path" +import fs from "node:fs/promises" +import crypto from "node:crypto" +import { fileURLToPath } from "node:url" + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +import { Effect } from "effect" +import { Runtime } from "@opencode-ai/shared/runtime" +import { Session } from "." +import type { SessionID } from "./schema" +import { MessageV2 } from "./message-v2" +import type { Snapshot as SnapshotMod } from "../snapshot" +import { Installation } from "../installation" +import { Provider } from "../provider/provider" +import { ProviderID, ModelID } from "../provider/schema" +import { Instance } from "../project/instance" +import { Global } from "../global" + +export function getRuntimeNamespace(): "pawwork" | "opencode" { + return Runtime.isPawWork() ? "pawwork" : "opencode" +} + +async function hashFile(p: string) { + try { + const buf = await fs.readFile(p) + return "sha256:" + crypto.createHash("sha256").update(buf).digest("hex") + } catch { + return undefined + } +} + +function redactDataUrl(url: string): { mime: string; size_bytes: number; sha256: string } | null { + // RFC 2397 allows zero-or-more `;param=value` segments between mime and the optional `;base64` flag, + // e.g. `data:text/plain;charset=utf-8;base64,...`. Lazy params group lets `;base64` still anchor. + const match = /^data:([^;,]+)((?:;[^;,]+)*?)(;base64)?,(.*)$/s.exec(url) + if (!match) return null + const [, mime, , isBase64, payload] = match + const buf = isBase64 ? Buffer.from(payload, "base64") : Buffer.from(payload, "utf8") + return { + mime, + size_bytes: buf.byteLength, + sha256: "sha256:" + crypto.createHash("sha256").update(buf).digest("hex"), + } +} + +export function redactPart( + part: MessageV2.Part, + ctx: { count: { omitted: number } }, +): MessageV2.Part { + if (part.type === "file") { + const r = redactDataUrl(part.url) + if (!r) return part + ctx.count.omitted++ + return { + ...part, + url: "", + metadata: { ...(part.metadata ?? {}), redacted_binary: r }, + } + } + if (part.type === "tool" && part.state.status === "completed" && part.state.attachments) { + let mutated = false + const attachments = part.state.attachments.map((a) => { + const r = redactDataUrl(a.url) + if (!r) return a + mutated = true + ctx.count.omitted++ + return { ...a, url: "", metadata: { ...(a.metadata ?? {}), redacted_binary: r } } + }) + return mutated ? { ...part, state: { ...part.state, attachments } } : part + } + return part +} + +function extractReasonFromCause(cause: unknown): string { + // Cause shape in Effect 4.x: { reasons: Array<{ _tag, error?, defect?, ... }> } + // We only need a reason string for diagnostics — best-effort extraction without depending + // on a stable Cause API surface (Cause.failureOption was removed in this version). + const reasons = (cause as { reasons?: unknown[] } | undefined)?.reasons ?? [] + for (const r of reasons as Array<{ _tag?: string; error?: unknown; defect?: unknown }>) { + const payload = r.error ?? r.defect + if (typeof payload === "string") return payload + const p = payload as { _tag?: string; message?: string } | undefined + if (p?.message) return p.message + if (p?._tag) return p._tag + } + return "unknown" +} + +export namespace Export { + export type Tree = { + info: Omit + had_cloud_share: boolean + diffs: SnapshotMod.FileDiff[] + messages: MessageV2.WithParts[] + children: Tree[] + } + + export type ModelRefEntry = + | { providerID: string; modelID: string; resolved: true } + | { providerID: string; modelID: string; resolved: false; unresolved_reason: string } + + export type InstructionSource = { + kind: string + path?: string + url?: string + hash?: string + hash_unavailable?: true + } + + export type Snapshot = { + schema_version: 1 + format: "pawwork-session-export" + exported_at: number + root_session_id: SessionID + runtime_context: { + app_version: string + build_channel?: string + runtime_namespace: "pawwork" | "opencode" + platform: NodeJS.Platform + os_version: string + locale: string + timezone: string + instruction_sources: InstructionSource[] + model_refs: Record + stats: { + session_count: number + message_count: number + part_count: number + omitted_attachment_count: number + } + } + diagnostics: Record + session: Tree + } + + type NodeData = { + node: Tree + childInfos: Session.Info[] + } + + const climbToRoot = Effect.fn("Export.climbToRoot")(function* (svc: Session.Interface, id: SessionID) { + let current: Session.Info = yield* svc.get(id) + while (current.parentID) { + current = yield* svc.get(current.parentID) + } + return current + }) + + const buildNode = Effect.fn("Export.buildNode")(function* ( + svc: Session.Interface, + info: Session.Info, + ctx: { count: { omitted: number } }, + ) { + const messages = yield* svc.messages({ sessionID: info.id }) + const diffs = yield* svc.diff(info.id) + const children = yield* svc.children(info.id) + const sorted = [...children].sort((a, b) => { + if (a.time.created !== b.time.created) return a.time.created - b.time.created + return a.id.localeCompare(b.id) + }) + const { share, ...infoWithoutShare } = info as Session.Info & { share?: unknown } + const redactedMessages = messages.map((m) => ({ ...m, parts: m.parts.map((p) => redactPart(p, ctx)) })) + const node: Tree = { + info: infoWithoutShare as Omit, + had_cloud_share: !!(share as { url?: string } | undefined)?.url, + diffs, + messages: redactedMessages, + children: [], + } + const data: NodeData = { node, childInfos: sorted } + return data + }) + + const exportTree = Effect.fn("Export.exportTree")(function* ( + svc: Session.Interface, + root: Session.Info, + ctx: { count: { omitted: number } }, + ) { + const rootData = yield* buildNode(svc, root, ctx) + const queue: NodeData[] = [rootData] + let head = 0 + while (head < queue.length) { + const cur = queue[head++] + for (const childInfo of cur.childInfos) { + const childData = yield* buildNode(svc, childInfo, ctx) + cur.node.children.push(childData.node) + queue.push(childData) + } + } + return rootData.node + }) + + function countStats(tree: Tree, omitted_attachment_count: number) { + let session_count = 0 + let message_count = 0 + let part_count = 0 + function walk(node: Tree) { + session_count++ + message_count += node.messages.length + for (const m of node.messages) part_count += m.parts.length + for (const c of node.children) walk(c) + } + walk(tree) + return { session_count, message_count, part_count, omitted_attachment_count } + } + + const collectInstructionSources = Effect.fn("Export.instructionSources")(function* () { + const sources: InstructionSource[] = [] + let worktree: string | undefined + try { + worktree = Instance.worktree + } catch { + worktree = undefined + } + const candidates: Array<{ kind: string; file: string }> = [ + { kind: "global", file: path.join(Global.Path.config, "AGENTS.md") }, + ...(worktree ? [{ kind: "project", file: path.join(worktree, "AGENTS.md") }] : []), + // Bundled pawwork prompt — present in the repo at packages/opencode/src/session/prompt/pawwork.txt. + // hashFile silently returns undefined and the entry is skipped if the file is missing. + { kind: "bundled", file: path.join(__dirname, "prompt", "pawwork.txt") }, + ] + for (const c of candidates) { + const hash = yield* Effect.promise(() => hashFile(c.file)) + if (hash) sources.push({ kind: c.kind, path: c.file, hash }) + } + return sources.sort((a, b) => { + if (a.kind !== b.kind) return a.kind.localeCompare(b.kind) + return (a.path ?? a.url ?? "").localeCompare(b.path ?? b.url ?? "") + }) + }) + + // Exported so it can be unit-tested with synthesized Tree fixtures. + export const collectModelRefs = Effect.fn("Export.modelRefs")(function* (tree: Tree) { + const provider = yield* Provider.Service + const seen = new Map() + function walk(node: Tree) { + for (const m of node.messages) { + if (m.info.role !== "user") continue + const ref = m.info.model + const key = `${ref.providerID}/${ref.modelID}` + if (!seen.has(key)) seen.set(key, { providerID: ref.providerID, modelID: ref.modelID }) + } + for (const c of node.children) walk(c) + } + walk(tree) + const refs: Record = {} + for (const [key, ref] of [...seen.entries()].sort(([a], [b]) => a.localeCompare(b))) { + const entry = yield* Effect.matchCause( + provider.getModel(ProviderID.make(ref.providerID), ModelID.make(ref.modelID)), + { + onSuccess: (): ModelRefEntry => ({ + providerID: ref.providerID, + modelID: ref.modelID, + resolved: true, + }), + onFailure: (cause): ModelRefEntry => { + // The provider throws ModelNotFoundError inside Effect.gen → arrives as a defect. + // matchCause handles both typed failures and defects; reach into cause.reasons to extract. + const reason = extractReasonFromCause(cause) + return { + providerID: ref.providerID, + modelID: ref.modelID, + resolved: false, + unresolved_reason: reason, + } + }, + }, + ) + refs[key] = entry + } + return refs + }) + + export const session = Effect.fn("Export.session")(function* (anyID: SessionID) { + const svc = yield* Session.Service + const root = yield* climbToRoot(svc, anyID) + const ctx = { count: { omitted: 0 } } + const tree = yield* exportTree(svc, root, ctx) + const instruction_sources = yield* collectInstructionSources() + const model_refs = yield* collectModelRefs(tree) + return { + schema_version: 1 as const, + format: "pawwork-session-export" as const, + exported_at: Date.now(), + root_session_id: root.id, + runtime_context: { + app_version: Installation.VERSION, + ...(Installation.CHANNEL ? { build_channel: Installation.CHANNEL } : {}), + runtime_namespace: getRuntimeNamespace(), + platform: process.platform, + os_version: os.release(), + locale: Intl.DateTimeFormat().resolvedOptions().locale, + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + instruction_sources, + model_refs, + stats: countStats(tree, ctx.count.omitted), + }, + diagnostics: {}, + session: tree, + } satisfies Snapshot + }) + + // ----- Sanitize helpers (moved from cli/cmd/export.ts so CLI + future surfaces share them) ----- + + function redact(kind: string, id: string, value: string) { + return value.trim() ? `[redacted:${kind}:${id}]` : value + } + + function dataField(kind: string, id: string, value: Record | undefined) { + if (!value) return value + return Object.keys(value).length ? { redacted: `${kind}:${id}` } : value + } + + function span(id: string, value: { value: string; start: number; end: number }) { + return { + ...value, + value: redact("file-text", id, value.value), + } + } + + function diff(kind: string, diffs: { file: string; patch: string }[] | undefined) { + return diffs?.map((item, i) => ({ + ...item, + file: redact(`${kind}-file`, String(i), item.file), + patch: redact(`${kind}-patch`, String(i), item.patch), + })) + } + + function source(part: MessageV2.FilePart) { + if (!part.source) return part.source + if (part.source.type === "symbol") { + return { + ...part.source, + path: redact("file-path", part.id, part.source.path), + name: redact("file-symbol", part.id, part.source.name), + text: span(part.id, part.source.text), + } + } + if (part.source.type === "resource") { + return { + ...part.source, + clientName: redact("file-client", part.id, part.source.clientName), + uri: redact("file-uri", part.id, part.source.uri), + text: span(part.id, part.source.text), + } + } + return { + ...part.source, + path: redact("file-path", part.id, part.source.path), + text: span(part.id, part.source.text), + } + } + + function filepart(part: MessageV2.FilePart): MessageV2.FilePart { + return { + ...part, + url: redact("file-url", part.id, part.url), + filename: part.filename === undefined ? undefined : redact("file-name", part.id, part.filename), + source: source(part), + } + } + + function errorData(kind: string, id: string, value: Record | undefined) { + if (!value) return value + return { + ...value, + message: typeof value.message === "string" ? redact(`${kind}-message`, id, value.message) : value.message, + responseBody: + typeof value.responseBody === "string" ? redact(`${kind}-body`, id, value.responseBody) : value.responseBody, + responseHeaders: dataField(`${kind}-headers`, id, value.responseHeaders as Record | undefined), + metadata: dataField(`${kind}-metadata`, id, value.metadata as Record | undefined), + } + } + + function namedError }>(kind: string, id: string, error: T): T + function namedError }>( + kind: string, + id: string, + error: T | undefined, + ): T | undefined + function namedError }>(kind: string, id: string, error: T | undefined) { + if (!error) return error + return { + ...error, + data: errorData(kind, id, error.data), + } + } + + function part(part: MessageV2.Part): MessageV2.Part { + switch (part.type) { + case "text": + return { + ...part, + text: redact("text", part.id, part.text), + metadata: dataField("text-metadata", part.id, part.metadata), + } + case "reasoning": + return { + ...part, + text: redact("reasoning", part.id, part.text), + metadata: dataField("reasoning-metadata", part.id, part.metadata), + } + case "file": + return filepart(part) + case "subtask": + return { + ...part, + prompt: redact("subtask-prompt", part.id, part.prompt), + description: redact("subtask-description", part.id, part.description), + command: part.command === undefined ? undefined : redact("subtask-command", part.id, part.command), + } + case "tool": + switch (part.state.status) { + case "pending": + return { + ...part, + metadata: dataField("tool-metadata", part.id, part.metadata), + state: { + ...part.state, + input: dataField("tool-input", part.id, part.state.input) ?? part.state.input, + raw: redact("tool-raw", part.id, part.state.raw), + }, + } + case "running": + return { + ...part, + metadata: dataField("tool-metadata", part.id, part.metadata), + state: { + ...part.state, + input: dataField("tool-input", part.id, part.state.input) ?? part.state.input, + title: part.state.title === undefined ? undefined : redact("tool-title", part.id, part.state.title), + metadata: dataField("tool-state-metadata", part.id, part.state.metadata), + }, + } + case "completed": + return { + ...part, + metadata: dataField("tool-metadata", part.id, part.metadata), + state: { + ...part.state, + input: dataField("tool-input", part.id, part.state.input) ?? part.state.input, + output: redact("tool-output", part.id, part.state.output), + title: redact("tool-title", part.id, part.state.title), + metadata: dataField("tool-state-metadata", part.id, part.state.metadata) ?? part.state.metadata, + attachments: part.state.attachments?.map(filepart), + }, + } + case "error": + return { + ...part, + metadata: dataField("tool-metadata", part.id, part.metadata), + state: { + ...part.state, + input: dataField("tool-input", part.id, part.state.input) ?? part.state.input, + error: redact("tool-error", part.id, part.state.error), + metadata: dataField("tool-state-metadata", part.id, part.state.metadata) ?? part.state.metadata, + }, + } + } + case "patch": + return { + ...part, + hash: redact("patch", part.id, part.hash), + files: part.files.map((item: string, i: number) => redact("patch-file", `${part.id}-${i}`, item)), + } + case "snapshot": + return { + ...part, + snapshot: redact("snapshot", part.id, part.snapshot), + } + case "step-start": + return { + ...part, + snapshot: part.snapshot === undefined ? undefined : redact("snapshot", part.id, part.snapshot), + } + case "step-finish": + return { + ...part, + snapshot: part.snapshot === undefined ? undefined : redact("snapshot", part.id, part.snapshot), + } + case "agent": + return { + ...part, + source: !part.source + ? part.source + : { + ...part.source, + value: redact("agent-source", part.id, part.source.value), + }, + } + case "retry": + return { + ...part, + error: namedError("retry-error", part.id, part.error), + } + default: + return part + } + } + + const partFn = part + + export function sanitize(data: { info: Session.Info; messages: MessageV2.WithParts[] }) { + return { + info: { + ...data.info, + title: redact("session-title", data.info.id, data.info.title), + directory: redact("session-directory", data.info.id, data.info.directory), + share: !data.info.share + ? data.info.share + : { + ...data.info.share, + url: redact("session-share", data.info.id, data.info.share.url), + }, + summary: !data.info.summary + ? data.info.summary + : { + ...data.info.summary, + diffs: diff("session-diff", data.info.summary.diffs), + }, + revert: !data.info.revert + ? data.info.revert + : { + ...data.info.revert, + snapshot: + data.info.revert.snapshot === undefined + ? undefined + : redact("revert-snapshot", data.info.id, data.info.revert.snapshot), + diff: + data.info.revert.diff === undefined + ? undefined + : redact("revert-diff", data.info.id, data.info.revert.diff), + }, + }, + messages: data.messages.map((msg) => ({ + info: + msg.info.role === "user" + ? { + ...msg.info, + system: msg.info.system === undefined ? undefined : redact("system", msg.info.id, msg.info.system), + summary: !msg.info.summary + ? msg.info.summary + : { + ...msg.info.summary, + title: + msg.info.summary.title === undefined + ? undefined + : redact("summary-title", msg.info.id, msg.info.summary.title), + body: + msg.info.summary.body === undefined + ? undefined + : redact("summary-body", msg.info.id, msg.info.summary.body), + diffs: diff("message-diff", msg.info.summary.diffs), + }, + } + : { + ...msg.info, + path: { + cwd: redact("cwd", msg.info.id, msg.info.path.cwd), + root: redact("root", msg.info.id, msg.info.path.root), + }, + structured: + msg.info.structured === undefined ? undefined : { redacted: `assistant-structured:${msg.info.id}` }, + error: namedError("assistant-error", msg.info.id, msg.info.error), + }, + parts: msg.parts.map(partFn), + })), + } + } + + export function sanitizeTree(node: Tree): Tree { + const out = sanitize({ info: node.info as Session.Info, messages: node.messages }) + // Sanitize replaces sensitive strings with redaction markers but preserves structural shape; + // the inferred type narrows summary.diffs in ways the strict MessageV2 schema rejects, so cast + // at this boundary instead of weakening every helper signature in the pipeline. + // Tree.diffs carries raw file paths + source patches; redact via the existing diff() helper. + const sanitizedDiffs = (diff("tree-diff", node.diffs) ?? []) as Tree["diffs"] + return { + ...node, + info: out.info as Omit, + diffs: sanitizedDiffs, + messages: out.messages as MessageV2.WithParts[], + children: node.children.map(sanitizeTree), + } + } + + // Snapshot-level sanitize. Wraps sanitizeTree (the conversation tree) AND redacts top-level + // runtime_context fields that may carry user-machine paths (instruction_sources). Other + // runtime_context fields (app_version, build_channel, locale, timezone, model_refs, stats) + // are not user-identifying and are kept verbatim. + export function sanitizeSnapshot(snap: Snapshot): Snapshot { + return { + ...snap, + runtime_context: { + ...snap.runtime_context, + instruction_sources: snap.runtime_context.instruction_sources.map((s, i) => ({ + ...s, + path: s.path === undefined ? undefined : redact("instruction-path", String(i), s.path), + url: s.url === undefined ? undefined : redact("instruction-url", String(i), s.url), + })), + }, + session: sanitizeTree(snap.session), + } + } +} diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index af264efaa..c2c882514 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -184,6 +184,7 @@ export const FilePart = PartBase.extend({ filename: z.string().optional(), url: z.string(), source: FilePartSource.optional(), + metadata: z.record(z.string(), z.any()).optional(), }).meta({ ref: "FilePart", }) diff --git a/packages/opencode/src/share/runtime.ts b/packages/opencode/src/share/runtime.ts new file mode 100644 index 000000000..85c19a410 --- /dev/null +++ b/packages/opencode/src/share/runtime.ts @@ -0,0 +1,31 @@ +import { Context, Data, Effect, Layer } from "effect" +import { Runtime } from "@opencode-ai/shared/runtime" + +export namespace ShareRuntime { + export class CloudShareGate extends Context.Service boolean }>()( + "@pawwork/CloudShareGate", + ) {} + + export const cloudShareGateDefaultLayer = Layer.succeed(CloudShareGate, { + isEnabled: () => !Runtime.isPawWork(), + }) + + // Typed Effect failure (NOT a thrown Error). Using Data.TaggedError + Effect.fail produces + // a typed failure in the Cause; throwing inside Effect.sync would produce a Cause.Die (defect) + // and lose the typed-error contract that callers / tests rely on. + export class CloudShareDisabled extends Data.TaggedError("CloudShareDisabled")<{ + readonly message: string + }> {} + + export const cloudShareDisabled = () => + new CloudShareDisabled({ + message: "Cloud share is disabled in PawWork. Use Export session log instead.", + }) + + // Returns an Effect; callers `yield* ensureEnabled` to surface the typed failure. + export const ensureEnabled = Effect.gen(function* () { + const gate = yield* CloudShareGate + if (gate.isEnabled()) return + return yield* Effect.fail(cloudShareDisabled()) + }) +} diff --git a/packages/opencode/src/share/session.ts b/packages/opencode/src/share/session.ts index 3f35d3947..57433989b 100644 --- a/packages/opencode/src/share/session.ts +++ b/packages/opencode/src/share/session.ts @@ -7,6 +7,7 @@ import { Effect, Layer, Scope, Context } from "effect" import { Config } from "../config/config" import { Flag } from "../flag/flag" import { ShareNext } from "./share-next" +import { ShareRuntime } from "./runtime" export namespace SessionShare { export interface Interface { @@ -23,9 +24,18 @@ export namespace SessionShare { const cfg = yield* Config.Service const session = yield* Session.Service const shareNext = yield* ShareNext.Service + const gate = yield* ShareRuntime.CloudShareGate const scope = yield* Scope.Scope + // Local closure mirrors ShareRuntime.ensureEnabled — kept here so the captured `gate` + // reference doesn't leak the CloudShareGate requirement into share/unshare/create's R type. + // If you change the failure semantics here, mirror the change in runtime.ts. + const ensureEnabled = Effect.suspend(() => + gate.isEnabled() ? Effect.void : Effect.fail(ShareRuntime.cloudShareDisabled()), + ) + const share = Effect.fn("SessionShare.share")(function* (sessionID: SessionID) { + yield* ensureEnabled const conf = yield* cfg.get() if (conf.share === "disabled") throw new Error("Sharing is disabled in configuration") const result = yield* shareNext.create(sessionID) @@ -36,6 +46,7 @@ export namespace SessionShare { }) const unshare = Effect.fn("SessionShare.unshare")(function* (sessionID: SessionID) { + yield* ensureEnabled yield* shareNext.remove(sessionID) yield* Effect.sync(() => SyncEvent.run(Session.Event.Updated, { sessionID, info: { share: { url: null } } })) }) @@ -43,6 +54,7 @@ export namespace SessionShare { const create = Effect.fn("SessionShare.create")(function* (input?: Parameters[0]) { const result = yield* session.create(input) if (result.parentID) return result + if (!gate.isEnabled()) return result const conf = yield* cfg.get() if (!(Flag.OPENCODE_AUTO_SHARE || conf.share === "auto")) return result yield* share(result.id).pipe(Effect.ignore, Effect.forkIn(scope)) @@ -57,6 +69,7 @@ export namespace SessionShare { Layer.provide(ShareNext.defaultLayer), Layer.provide(Session.defaultLayer), Layer.provide(Config.defaultLayer), + Layer.provide(ShareRuntime.cloudShareGateDefaultLayer), ) const { runPromise } = makeRuntime(Service, defaultLayer) diff --git a/packages/opencode/src/share/share-next.ts b/packages/opencode/src/share/share-next.ts index ad247f546..9bb871544 100644 --- a/packages/opencode/src/share/share-next.ts +++ b/packages/opencode/src/share/share-next.ts @@ -13,6 +13,7 @@ import { Database, eq } from "@/storage/db" import { Config } from "@/config/config" import { Log } from "@/util/log" import { SessionShareTable } from "./share.sql" +import { ShareRuntime } from "./runtime" export namespace ShareNext { const log = Log.create({ service: "share-next" }) @@ -115,6 +116,7 @@ export namespace ShareNext { const httpOk = HttpClient.filterStatusOk(http) const provider = yield* Provider.Service const session = yield* Session.Service + const gate = yield* ShareRuntime.CloudShareGate function sync(sessionID: SessionID, data: Data[]): Effect.Effect { return Effect.gen(function* () { @@ -156,7 +158,7 @@ export namespace ShareNext { ), ) - if (disabled) return cache + if (disabled || !gate.isEnabled()) return cache const watch = ( def: D, @@ -288,7 +290,11 @@ export namespace ShareNext { }) const create = Effect.fn("ShareNext.create")(function* (sessionID: SessionID) { + // OPENCODE_DISABLE_SHARE keeps a benign empty stub for opencode users who opt out; + // the gate (PawWork runtime) raises a typed failure so direct callers can't mistake the + // disabled return for a successful share with a blank URL. if (disabled) return { id: "", url: "", secret: "" } + if (!gate.isEnabled()) return yield* Effect.fail(ShareRuntime.cloudShareDisabled()) log.info("creating share", { sessionID }) const req = yield* request() const result = yield* HttpClientRequest.post(`${req.baseUrl}${req.api.create}`).pipe( @@ -320,7 +326,9 @@ export namespace ShareNext { }) const remove = Effect.fn("ShareNext.remove")(function* (sessionID: SessionID) { + // See ShareNext.create — opencode opt-out is silent; PawWork gate raises a typed failure. if (disabled) return + if (!gate.isEnabled()) return yield* Effect.fail(ShareRuntime.cloudShareDisabled()) log.info("removing share", { sessionID }) const share = yield* get(sessionID) if (!share) return @@ -346,5 +354,6 @@ export namespace ShareNext { Layer.provide(FetchHttpClient.layer), Layer.provide(Provider.defaultLayer), Layer.provide(Session.defaultLayer), + Layer.provide(ShareRuntime.cloudShareGateDefaultLayer), ) } diff --git a/packages/opencode/test/cli/export.test.ts b/packages/opencode/test/cli/export.test.ts index 4e768db6c..307af6c94 100644 --- a/packages/opencode/test/cli/export.test.ts +++ b/packages/opencode/test/cli/export.test.ts @@ -1,6 +1,8 @@ import { describe, expect, test } from "bun:test" import yargs from "yargs/yargs" -import { ExportCommand, sanitize } from "../../src/cli/cmd/export" +import { ExportCommand } from "../../src/cli/cmd/export" +import { Export } from "../../src/session/export" +const { sanitize } = Export describe("cli export command", () => { test("registers the sanitize option", () => { diff --git a/packages/opencode/test/session/export.test.ts b/packages/opencode/test/session/export.test.ts new file mode 100644 index 000000000..62eb4a1dc --- /dev/null +++ b/packages/opencode/test/session/export.test.ts @@ -0,0 +1,371 @@ +import { describe, expect, test } from "bun:test" +import path from "path" +import { Instance } from "../../src/project/instance" +import { Session as SessionNs } from "../../src/session" +import { MessageV2 } from "../../src/session/message-v2" +import { MessageID, PartID, SessionID } from "../../src/session/schema" +import { Log } from "../../src/util/log" +import { AppRuntime } from "../../src/effect/app-runtime" +import { Export, getRuntimeNamespace, redactPart } from "../../src/session/export" + +const projectRoot = path.join(__dirname, "../..") +void Log.init({ print: false }) + +describe("Export.session", () => { + test("getRuntimeNamespace returns 'pawwork' or 'opencode'", () => { + expect(["pawwork", "opencode"]).toContain(getRuntimeNamespace()) + }) + + test("exports a single root session with empty messages and stub runtime_context", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const created = await SessionNs.create({ title: "test session" }) + try { + // Precondition: this test is the "single root, no climb" contract — Task 2 adds climb. + expect(created.parentID).toBeUndefined() + + const result = await AppRuntime.runPromise(Export.session(created.id)) + + expect(result.schema_version).toBe(1) + expect(result.format).toBe("pawwork-session-export") + expect(typeof result.exported_at).toBe("number") + expect(result.root_session_id).toBe(created.id) + expect(result.session.info.id).toBe(created.id) + expect(result.session.info.title).toBe("test session") + // info.share is stripped from the export + expect((result.session.info as { share?: unknown }).share).toBeUndefined() + expect(result.session.had_cloud_share).toBe(false) + expect(result.session.messages).toEqual([]) + expect(result.session.diffs).toEqual([]) + expect(result.session.children).toEqual([]) + expect(result.runtime_context.runtime_namespace).toBe(getRuntimeNamespace()) + expect(result.runtime_context.stats.session_count).toBe(1) + expect(result.runtime_context.stats.message_count).toBe(0) + expect(result.diagnostics).toEqual({}) + } finally { + await SessionNs.remove(created.id) + } + }, + }) + }) + + test("climbs to root when given a child session id", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const root = await SessionNs.create({ title: "root" }) + const child = await SessionNs.create({ parentID: root.id, title: "child" }) + try { + const result = await AppRuntime.runPromise(Export.session(child.id)) + + expect(result.root_session_id).toBe(root.id) + expect(result.session.info.id).toBe(root.id) + expect(result.session.children).toHaveLength(1) + expect(result.session.children[0].info.id).toBe(child.id) + expect(result.runtime_context.stats.session_count).toBe(2) + } finally { + await SessionNs.remove(root.id) + } + }, + }) + }) + + test("orders children deterministically by time.created then id", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const root = await SessionNs.create({ title: "root" }) + const a = await SessionNs.create({ parentID: root.id, title: "a" }) + // Force a measurable time gap so the test does not depend on intra-millisecond create timing + // and does not bottom out on tie-break against monotonic-descending SessionID, which would + // make the assertion tautological. + await new Promise((r) => setTimeout(r, 10)) + const b = await SessionNs.create({ parentID: root.id, title: "b" }) + try { + // Independent verification: a was created first → a.time.created < b.time.created + expect(a.time.created).toBeLessThan(b.time.created) + + const result = await AppRuntime.runPromise(Export.session(root.id)) + const ids = result.session.children.map((c) => c.info.id) + + // Hard-coded expected order based on creation sequence, not derived from result's own sort. + expect(ids).toEqual([a.id, b.id]) + } finally { + await SessionNs.remove(root.id) + } + }, + }) + }) + + test("ties break by id.localeCompare when time.created is equal (synthesized fixture)", () => { + // Pure-function test on the sort comparator, not against real session creation, + // so this assertion is independently verifiable and does not depend on timing. + const cmp = (x: { time: { created: number }; id: string }, y: typeof x) => { + if (x.time.created !== y.time.created) return x.time.created - y.time.created + return x.id.localeCompare(y.id) + } + const items = [ + { id: "ses_b", time: { created: 100 } }, + { id: "ses_a", time: { created: 100 } }, + ] + expect([...items].sort(cmp).map((s) => s.id)).toEqual(["ses_a", "ses_b"]) + }) + + test("includes runtime_context with platform, locale, timezone, and best-effort instruction_sources", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const root = await SessionNs.create({ title: "x" }) + try { + const result = await AppRuntime.runPromise(Export.session(root.id)) + + expect(result.runtime_context.platform).toBe(process.platform) + expect(result.runtime_context.app_version).toBeTruthy() + expect(typeof result.runtime_context.timezone).toBe("string") + expect(typeof result.runtime_context.locale).toBe("string") + expect(Array.isArray(result.runtime_context.instruction_sources)).toBe(true) + expect(result.runtime_context.model_refs).toEqual({}) + // Sort invariant: stable kind then path/url (both keys, not just primary). + const sources = result.runtime_context.instruction_sources + const sortedCopy = [...sources].sort((a, b) => { + if (a.kind !== b.kind) return a.kind.localeCompare(b.kind) + return (a.path ?? a.url ?? "").localeCompare(b.path ?? b.url ?? "") + }) + expect(sources).toEqual(sortedCopy) + } finally { + await SessionNs.remove(root.id) + } + }, + }) + }) + + test("collectModelRefs marks unknown providers as unresolved with a reason", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const root = await SessionNs.create({ title: "modelRefsFixture" }) + try { + const userMessage: MessageV2.WithParts = { + info: { + id: MessageID.ascending(), + sessionID: root.id, + role: "user", + time: { created: Date.now() }, + agent: "user", + model: { providerID: "nonexistent-provider", modelID: "fake-model-7b" }, + tools: {}, + } as MessageV2.User, + parts: [], + } + const fakeTree: Export.Tree = { + info: root, + had_cloud_share: false, + diffs: [], + messages: [userMessage], + children: [], + } + const refs = await AppRuntime.runPromise(Export.collectModelRefs(fakeTree)) + const entry = refs["nonexistent-provider/fake-model-7b"] + expect(entry).toBeDefined() + expect(entry.resolved).toBe(false) + if (!entry.resolved) { + expect(entry.unresolved_reason).toBeTruthy() + } + } finally { + await SessionNs.remove(root.id) + } + }, + }) + }) +}) + +describe("redactPart", () => { + test("replaces data: url in a file part with empty string and adds redacted_binary metadata", () => { + const ctx = { count: { omitted: 0 } } + const part: MessageV2.FilePart = { + id: PartID.make("prt_test"), + messageID: MessageID.make("msg_test"), + sessionID: SessionID.make("ses_test"), + type: "file", + url: "data:image/png;base64,iVBORw0KGgo=", + mime: "image/png", + filename: "x.png", + } + + const out = redactPart(part, ctx) + if (out.type !== "file") throw new Error("type narrowing") + expect(out.url).toBe("") + expect(out.metadata?.redacted_binary).toMatchObject({ + mime: "image/png", + size_bytes: expect.any(Number), + sha256: expect.stringMatching(/^sha256:[0-9a-f]{64}$/), + }) + expect(ctx.count.omitted).toBe(1) + }) + + test("leaves non-data: url untouched", () => { + const ctx = { count: { omitted: 0 } } + const part: MessageV2.FilePart = { + id: PartID.make("prt_test"), + messageID: MessageID.make("msg_test"), + sessionID: SessionID.make("ses_test"), + type: "file", + url: "https://example.com/x.png", + mime: "image/png", + } + + const out = redactPart(part, ctx) + if (out.type !== "file") throw new Error("type narrowing") + expect(out.url).toBe("https://example.com/x.png") + expect(out.metadata?.redacted_binary).toBeUndefined() + expect(ctx.count.omitted).toBe(0) + }) + + test("redacts data: url with extra parameters between mime and base64 (RFC 2397 compliance)", () => { + const ctx = { count: { omitted: 0 } } + const part: MessageV2.FilePart = { + id: PartID.make("prt_test"), + messageID: MessageID.make("msg_test"), + sessionID: SessionID.make("ses_test"), + type: "file", + // Real-world data URL with charset between mime and base64. + url: "data:image/png;charset=utf-8;base64,iVBORw0KGgo=", + mime: "image/png", + } + + const out = redactPart(part, ctx) + if (out.type !== "file") throw new Error("type narrowing") + expect(out.url).toBe("") + expect(out.metadata?.redacted_binary).toMatchObject({ + mime: "image/png", + size_bytes: expect.any(Number), + sha256: expect.stringMatching(/^sha256:[0-9a-f]{64}$/), + }) + expect(ctx.count.omitted).toBe(1) + }) + + test("sanitizeSnapshot redacts node.diffs file/patch on every tree node", () => { + const fakeSnapshot: Export.Snapshot = { + schema_version: 1, + format: "pawwork-session-export", + exported_at: 0, + root_session_id: SessionID.make("ses_x"), + runtime_context: { + app_version: "test", + runtime_namespace: "pawwork", + platform: "darwin", + os_version: "0", + locale: "en-US", + timezone: "UTC", + instruction_sources: [], + model_refs: {}, + stats: { session_count: 0, message_count: 0, part_count: 0, omitted_attachment_count: 0 }, + }, + diagnostics: {}, + session: { + info: { id: SessionID.make("ses_x"), title: "t", directory: "/dir" } as never, + had_cloud_share: false, + diffs: [ + { file: "/Users/secret/code.ts", patch: "@@ -1 +1 @@\n-secret\n+leak", additions: 1, deletions: 1 }, + ], + messages: [], + children: [ + { + info: { id: SessionID.make("ses_y"), title: "child", directory: "/dir" } as never, + had_cloud_share: false, + diffs: [ + { file: "/Users/secret/child.ts", patch: "child secret", additions: 0, deletions: 0 }, + ], + messages: [], + children: [], + }, + ], + }, + } + + const sanitized = Export.sanitizeSnapshot(fakeSnapshot) + // Root and child diffs both scrubbed. + expect(sanitized.session.diffs[0].file).toBe("[redacted:tree-diff-file:0]") + expect(sanitized.session.diffs[0].patch).toBe("[redacted:tree-diff-patch:0]") + expect(sanitized.session.children[0].diffs[0].file).toBe("[redacted:tree-diff-file:0]") + expect(sanitized.session.children[0].diffs[0].patch).toBe("[redacted:tree-diff-patch:0]") + }) + + test("sanitizeSnapshot redacts instruction_sources paths in runtime_context", () => { + const fakeSnapshot: Export.Snapshot = { + schema_version: 1, + format: "pawwork-session-export", + exported_at: 0, + root_session_id: SessionID.make("ses_x"), + runtime_context: { + app_version: "test", + runtime_namespace: "pawwork", + platform: "darwin", + os_version: "0", + locale: "en-US", + timezone: "UTC", + instruction_sources: [ + { kind: "global", path: "/Users/secret/.config/AGENTS.md", hash: "sha256:abc" }, + { kind: "remote", url: "https://example.com/secret-instructions" }, + ], + model_refs: {}, + stats: { session_count: 0, message_count: 0, part_count: 0, omitted_attachment_count: 0 }, + }, + diagnostics: {}, + session: { + info: { id: SessionID.make("ses_x"), title: "t", directory: "/dir" } as never, + had_cloud_share: false, + diffs: [], + messages: [], + children: [], + }, + } + + const sanitized = Export.sanitizeSnapshot(fakeSnapshot) + const sources = sanitized.runtime_context.instruction_sources + expect(sources[0].path).toBe("[redacted:instruction-path:0]") + expect(sources[1].url).toBe("[redacted:instruction-url:1]") + // hash + kind + structural fields preserved + expect(sources[0].kind).toBe("global") + expect(sources[0].hash).toBe("sha256:abc") + }) + + test("redacts data: url inside completed tool attachments", () => { + const ctx = { count: { omitted: 0 } } + const part: MessageV2.ToolPart = { + id: PartID.make("prt_tool_fixture"), + messageID: MessageID.make("msg_fixture"), + sessionID: SessionID.make("ses_fixture"), + type: "tool", + callID: "call_1", + tool: "read", + state: { + status: "completed", + input: {}, + output: "", + title: "fixture", + metadata: {}, + time: { start: 0, end: 1 }, + attachments: [ + { + id: PartID.make("att_fixture"), + messageID: MessageID.make("msg_fixture"), + sessionID: SessionID.make("ses_fixture"), + type: "file", + url: "data:image/jpeg;base64,/9j/4AAQ", + mime: "image/jpeg", + filename: "fixture.bin", + }, + ], + }, + } + + const out = redactPart(part, ctx) + if (out.type !== "tool" || out.state.status !== "completed") throw new Error("type narrowing") + const attachments = out.state.attachments ?? [] + expect(attachments[0].url).toBe("") + expect(attachments[0].metadata?.redacted_binary).toBeDefined() + expect(ctx.count.omitted).toBe(1) + }) +}) diff --git a/packages/opencode/test/share/session-pawwork-fail-closed.test.ts b/packages/opencode/test/share/session-pawwork-fail-closed.test.ts new file mode 100644 index 000000000..c7866f4bd --- /dev/null +++ b/packages/opencode/test/share/session-pawwork-fail-closed.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, test } from "bun:test" +import path from "path" +import { Effect, Cause, Layer, Option } from "effect" +import { Instance } from "../../src/project/instance" +import { Session as SessionNs } from "../../src/session" +import { SessionShare } from "../../src/share/session" +import { ShareNext } from "../../src/share/share-next" +import { ShareRuntime } from "../../src/share/runtime" +import { Config } from "../../src/config/config" +import { Log } from "../../src/util/log" + +const projectRoot = path.join(__dirname, "../..") +void Log.init({ print: false }) + +// Test layer: rebuilds SessionShare's full default chain except CloudShareGate is swappable. +function sessionShareTestLayer(opts: { gate: { isEnabled: () => boolean } }) { + const gateLayer = Layer.succeed(ShareRuntime.CloudShareGate, opts.gate) + return SessionShare.layer.pipe( + Layer.provide(ShareNext.defaultLayer), + Layer.provide(SessionNs.defaultLayer), + Layer.provide(Config.defaultLayer), + Layer.provide(gateLayer), + ) +} + +describe("PawWork runtime cloud share fail-closed", () => { + test("SessionShare.share fails with typed CloudShareDisabled when gate returns false", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const ses = await SessionNs.create({ title: "fail-closed test" }) + try { + const disabledLayer = sessionShareTestLayer({ gate: { isEnabled: () => false } }) + + const program = SessionShare.Service.use((svc) => svc.share(ses.id)) + const exit = await Effect.runPromiseExit(program.pipe(Effect.provide(disabledLayer))) + + expect(exit._tag).toBe("Failure") + if (exit._tag !== "Failure") return + // Cause.findFail returns Result; check .reasons directly. + const reasons = (exit.cause as unknown as { reasons?: ReadonlyArray<{ error?: unknown }> }).reasons ?? [] + const failed = reasons.find((r) => r.error instanceof ShareRuntime.CloudShareDisabled) + expect(failed).toBeDefined() + if (failed) { + const err = failed.error as ShareRuntime.CloudShareDisabled + expect(err._tag).toBe("CloudShareDisabled") + } + } finally { + await SessionNs.remove(ses.id) + } + }, + }) + }) + + test("SessionShare.share succeeds path is preserved when gate returns true", async () => { + // Sanity: with enabled gate the typed failure must NOT be raised. Actual share publication + // would hit opncd.ai which we don't want in tests, so we just confirm the gate doesn't + // short-circuit by verifying the failure (if any) is NOT CloudShareDisabled. + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const ses = await SessionNs.create({ title: "gate-enabled test" }) + try { + const enabledLayer = sessionShareTestLayer({ gate: { isEnabled: () => true } }) + + const program = SessionShare.Service.use((svc) => svc.share(ses.id)) + const exit = await Effect.runPromiseExit(program.pipe(Effect.provide(enabledLayer))) + + // Either succeeds (unlikely without a real cloud account) OR fails for some reason + // OTHER than CloudShareDisabled. The negative assertion proves the gate is not engaging. + if (exit._tag === "Failure") { + const reasons = (exit.cause as unknown as { reasons?: ReadonlyArray<{ error?: unknown }> }).reasons ?? [] + const wronglyDisabled = reasons.find((r) => r.error instanceof ShareRuntime.CloudShareDisabled) + expect(wronglyDisabled).toBeUndefined() + } + } finally { + await SessionNs.remove(ses.id) + } + }, + }) + }) +}) + +// Suppress unused-import warning for Cause/Option which are exported from this test surface +// to make the failure-extraction pattern reusable by other share tests later. +void Cause +void Option diff --git a/packages/opencode/test/share/share-next.test.ts b/packages/opencode/test/share/share-next.test.ts index fd230f545..0cd043467 100644 --- a/packages/opencode/test/share/share-next.test.ts +++ b/packages/opencode/test/share/share-next.test.ts @@ -13,6 +13,7 @@ import { Provider } from "../../src/provider/provider" import { Session } from "../../src/session" import type { SessionID } from "../../src/session/schema" import { ShareNext } from "../../src/share/share-next" +import { ShareRuntime } from "../../src/share/runtime" import { Storage } from "../../src/storage/storage" import { SessionShareTable } from "../../src/share/share.sql" import { Database, eq } from "../../src/storage/db" @@ -48,6 +49,7 @@ function live(client: HttpClient.HttpClient) { Layer.provide(http), Layer.provide(Provider.defaultLayer), Layer.provide(Session.defaultLayer), + Layer.provide(ShareRuntime.cloudShareGateDefaultLayer), ) } @@ -66,6 +68,7 @@ function wired(client: HttpClient.HttpClient) { Layer.provide(Config.defaultLayer), Layer.provide(http), Layer.provide(Provider.defaultLayer), + Layer.provide(ShareRuntime.cloudShareGateDefaultLayer), ) } @@ -235,6 +238,35 @@ describe("ShareNext", () => { ), ) + it.live("create fails closed and issues no HTTP when CloudShareGate is disabled", () => + provideTmpdirInstance(() => + Effect.gen(function* () { + const session = yield* Session.Service.use((svc) => svc.create({ title: "test" })) + // The HttpClient die() ensures any actual HTTP attempt would fail loudly with a defect; + // the gate must short-circuit before that point. + const client = none + const disabledGate = Layer.succeed(ShareRuntime.CloudShareGate, { isEnabled: () => false }) + + const exit = yield* ShareNext.Service.use((svc) => Effect.exit(svc.create(session.id))).pipe( + Effect.provide( + ShareNext.layer.pipe( + Layer.provide(Bus.layer), + Layer.provide(Account.layer.pipe(Layer.provide(AccountRepo.layer), Layer.provide(Layer.succeed(HttpClient.HttpClient, client)))), + Layer.provide(Config.defaultLayer), + Layer.provide(Layer.succeed(HttpClient.HttpClient, client)), + Layer.provide(Provider.defaultLayer), + Layer.provide(Session.defaultLayer), + Layer.provide(disabledGate), + ), + ), + ) + + expect(Exit.isFailure(exit)).toBe(true) + expect(share(session.id)).toBeUndefined() + }), + ), + ) + it.live("ShareNext coalesces rapid diff events into one delayed sync with latest data", () => provideTmpdirInstance( () => {