diff --git a/apps/desktop/src/main/channels.ts b/apps/desktop/src/main/channels.ts index 56dd940..ed365eb 100644 --- a/apps/desktop/src/main/channels.ts +++ b/apps/desktop/src/main/channels.ts @@ -33,6 +33,9 @@ export const CHANNELS = { findings: "check:findings", locate: "sources:locate", drop: "sources:drop", + /** What is sitting in `raw/_inbox/` (3.7), and taking it when asked. */ + inboxWaiting: "sources:inbox-waiting", + inboxDrain: "sources:inbox-drain", // The credential (8.3), the launcher (8.4), the content language (8.12) and // the run 6.3 starts. @@ -47,6 +50,20 @@ export const CHANNELS = { /** Main → renderer, for 8.10. */ changed: "project:changed", + /** Main → renderer, for the inbox doorway of 3.7. */ + inbox: "sources:inbox", } as const; export type Channel = (typeof CHANNELS)[keyof typeof CHANNELS]; + +/** + * The channels the main process pushes on, which therefore take no handler. + * + * Named as a set rather than checked one by one in `index.ts`: registering an + * `ipcMain.handle` for a push channel is harmless until the day something + * invokes it, and then it is a handler nobody wrote answering `undefined`. + */ +export const PUSH_CHANNELS: ReadonlySet = new Set([ + CHANNELS.changed, + CHANNELS.inbox, +]); diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 71bc6d4..2c428c9 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -1,11 +1,14 @@ import { app, BrowserWindow, ipcMain, shell } from "electron"; import { join, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; -import { CHANNELS, createApi, dispatch } from "./ipc.js"; +import { CHANNELS, createApi, dispatch, INBOX_STABILITY_MS } from "./ipc.js"; +import { PUSH_CHANNELS } from "./channels.js"; +import { asDropOutcome, inboxFailure } from "./ingest.js"; import { resolveProject } from "./project.js"; import { RecorderSession, resolveRecorder, spawnTransport } from "./recorder.js"; import { applyPackagedBinaries } from "./resources.js"; import { serveQueries } from "@open-wiki/access/socket"; +import { drainInbox, watchInbox, type InboxOutcome, type InboxWatcher } from "@open-wiki/access"; import { isOpenableExternally } from "../renderer/navigation.js"; import { watchProject } from "./watcher.js"; @@ -66,12 +69,53 @@ function createWindow(projectRoot: string | null): BrowserWindow { }, }; - const api = createApi({ projectRoot, recorder }); + // 3.7 — the doorway's watcher, which arrives asynchronously; see below. + let inbox: InboxWatcher | null = null; + let closed = false; + + // The window's own watcher once its initial scan has finished, so an explicit + // drain and an event cannot both read the same file and both try to register + // the same id. Standalone until then: a drain must still work in the seconds + // between the window opening and the scan completing. + const inboxDrain = (root: string): Promise => + inbox ? inbox.drain() : drainInbox(root, { stabilityMs: INBOX_STABILITY_MS }); + + const api = createApi({ + projectRoot, + recorder, + ...(projectRoot ? { inbox: { drain: () => inboxDrain(projectRoot) } } : {}), + }); for (const channel of Object.values(CHANNELS)) { - if (channel === CHANNELS.changed) continue; // main → renderer only + if (PUSH_CHANNELS.has(channel)) continue; // main → renderer only ipcMain.handle(channel, (_event, ...args: unknown[]) => dispatch(api, channel, args)); } + /** + * Tell the window something. + * + * **Buffered until the document has loaded.** `webContents.send` before that + * is dropped on the floor with no queue and no error, and the things pushed + * here are reports — a file that arrived, a watcher that died. A report that + * silently goes nowhere is the failure this channel exists to prevent. + */ + let loaded = false; + const waiting: Array<{ channel: string; payload: unknown }> = []; + const send = (channel: string, payload: unknown): void => { + if (window.isDestroyed()) return; + if (!loaded) { + waiting.push({ channel, payload }); + return; + } + window.webContents.send(channel, payload); + }; + window.webContents.once("did-finish-load", () => { + loaded = true; + for (const message of waiting) { + if (!window.isDestroyed()) window.webContents.send(message.channel, message.payload); + } + waiting.length = 0; + }); + // 9.14 — the CLI asks here rather than starting a process, when this // window already has the project open. Read and validate only; the socket // never carries a write. @@ -80,13 +124,54 @@ function createWindow(projectRoot: string | null): BrowserWindow { // 8.10 — whoever wrote it, the screen follows. A launcher window has no // project to watch. const watcher = projectRoot - ? watchProject(projectRoot, (change) => { - if (!window.isDestroyed()) window.webContents.send(CHANNELS.changed, change); - }) + ? watchProject(projectRoot, (change) => send(CHANNELS.changed, change)) : null; + // 3.7 — the doorway. An agent that fetched something writes it into + // `raw/_inbox/` with its own tools, and it becomes a source through the same + // registration a dropped file goes through. This is the process that holds + // the watcher open; until it existed the doorway only worked when something + // called `drainInbox` by hand. + // + // Started asynchronously — `watchInbox` waits for chokidar's initial scan, and + // a window that blocked on it would be a window that does not open. So the + // handle arrives late, and a window closed before it does has to close it + // anyway or the watcher outlives its window. + // + // **`ingestExisting: false`.** What is already in the doorway when a window + // opens is listed and left alone; only what arrives while it is open is taken + // on sight. `raw/` comes with a clone, so the alternative is a repository + // shipping `raw/_inbox/x.pdf` and this application parsing a stranger's bytes + // in the main process — and deleting the file out of the user's tree — before + // anybody clicked anything. + if (projectRoot) { + void watchInbox( + projectRoot, + { + onOutcome: (outcome) => send(CHANNELS.inbox, asDropOutcome(outcome)), + onError: (error) => send(CHANNELS.inbox, inboxFailure(error)), + }, + { ingestExisting: false }, + ) + .then((started) => { + // `return`, not `void`: a discarded promise here escapes the `.catch` + // below and becomes an unhandled rejection in the main process. + if (closed) return started.close(); + inbox = started; + return undefined; + }) + .catch((error: unknown) => { + send( + CHANNELS.inbox, + inboxFailure(error instanceof Error ? error : new Error(String(error))), + ); + }); + } + window.on("closed", () => { + closed = true; void watcher?.close(); + void inbox?.close(); queries?.close(); session?.dispose(); for (const channel of Object.values(CHANNELS)) ipcMain.removeHandler(channel); diff --git a/apps/desktop/src/main/ingest.ts b/apps/desktop/src/main/ingest.ts index 600ecb9..12d5a8d 100644 --- a/apps/desktop/src/main/ingest.ts +++ b/apps/desktop/src/main/ingest.ts @@ -5,6 +5,7 @@ import { uploadPdfSource, uploadTextSource, TakenIdError, + type InboxOutcome, } from "@open-wiki/access"; /** @@ -96,3 +97,34 @@ export async function ingestDrop( for (const path of paths) outcomes.push(await ingestFile(projectRoot, path)); return outcomes; } + +/** + * What the inbox watcher saw (plan 3.7), said the way a drop says it. + * + * The doorway and the drop zone are two ways into the same registration, so + * they are worth reporting through one shape: the window already knows how to + * show "three of four added, and here is the fourth". `removed` is dropped on + * the way through — whether the file left the doorway is the watcher's + * bookkeeping, and a reader who was not told the doorway exists cannot be told + * something stayed in it. + */ +export function asDropOutcome(outcome: InboxOutcome): DropOutcome { + return outcome.ok + ? { name: outcome.name, ok: true, id: outcome.id } + : { name: outcome.name, ok: false, reason: outcome.reason }; +} + +/** + * A doorway that stopped working, as an outcome (plan 3.7). + * + * Reported rather than logged, because a watcher that goes quiet is + * indistinguishable from an inbox nobody is using — and the failure it hides is + * material an agent believes it handed over. + */ +export function inboxFailure(error: Error): DropOutcome { + return { + name: "raw/_inbox", + ok: false, + reason: `the inbox stopped being watched: ${error.message}`, + }; +} diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts index ff4b8ae..cfc6ad1 100644 --- a/apps/desktop/src/main/ipc.ts +++ b/apps/desktop/src/main/ipc.ts @@ -17,7 +17,8 @@ import { type SaveInput, type SaveResult, } from "./edit.js"; -import { ingestDrop, type DropOutcome } from "./ingest.js"; +import { asDropOutcome, ingestDrop, type DropOutcome } from "./ingest.js"; +import { drainInbox, listInbox, type InboxOutcome } from "@open-wiki/access"; import { createProject, credentialState, @@ -39,6 +40,7 @@ import { locateCitation, sourceDetail, sourcesOfPage, + type PageSource, type SourceLocation, type SourceRow, } from "./sources.js"; @@ -82,10 +84,24 @@ export interface Deps { */ projectRoot: string | null; recorder?: RecorderControl; + /** The window's inbox watcher (3.7), when its initial scan has finished. */ + inbox?: InboxControl; /** Injected so a test does not depend on today's date. */ now?: () => Date; } +/** What a window offers of its inbox watcher — draining, and nothing else. */ +export interface InboxControl { + drain(): Promise; +} + +/** + * How long a file's size must hold steady before an explicit drain reads it. + * The same wait `watchInbox` applies, because a file half-copied is half a + * source whichever path reached it. + */ +export const INBOX_STABILITY_MS = 400; + /** What a window reports when nothing is being recorded. */ export const IDLE_STATUS: RecorderStatus = { state: "idle", @@ -131,11 +147,21 @@ export interface DesktopApi { undo(id: string): void; sourceDetail(id: string): SourceRow; - sourcesOfPage(slug: string): string[]; + sourcesOfPage(slug: string): PageSource[]; retitle(id: string, title: string): void; findings(): Finding[]; locate(id: string, fragment: string): SourceLocation; drop(paths: readonly string[]): Promise; + /** + * What is waiting in the doorway (plan 3.7), and taking it. + * + * **Asked for rather than pushed**, which is what makes the report reliable: + * a window reports live arrivals over `CHANNELS.inbox`, but what was already + * there when the window opened would be announced before the renderer had + * subscribed and vanish. The renderer asks instead, whenever it likes. + */ + inboxWaiting(): string[]; + inboxDrain(): Promise; credential(): CredentialState; saveCredential(input: SaveCredentialInput): Promise; @@ -217,6 +243,18 @@ export function createApi(deps: Deps): DesktopApi { findings: () => findings(root()), locate: (id, fragment) => locateCitation(root(), id, fragment), drop: (paths) => ingestDrop(root(), paths), + inboxWaiting: () => listInbox(root()), + // Through the window's watcher when there is one, so an explicit drain and + // an event cannot both read the same file and both try to register the same + // id. Standalone otherwise — a drain must still work in the window between + // opening and the watcher finishing its initial scan. + async inboxDrain() { + const projectRoot = root(); + const outcomes = deps.inbox + ? await deps.inbox.drain() + : await drainInbox(projectRoot, { stabilityMs: INBOX_STABILITY_MS }); + return outcomes.map(asDropOutcome); + }, credential: () => credentialState(root()), saveCredential: (input) => saveCredential(root(), input), @@ -312,6 +350,10 @@ export async function dispatch( // The renderer hands over paths Chromium gave it for a drop. Anything // that is not a string is not a path. return api.drop((Array.isArray(args[0]) ? args[0] : []).filter((p) => typeof p === "string")); + case CHANNELS.inboxWaiting: + return api.inboxWaiting(); + case CHANNELS.inboxDrain: + return api.inboxDrain(); default: throw new Error(`unknown channel "${channel}"`); diff --git a/apps/desktop/src/main/preload.ts b/apps/desktop/src/main/preload.ts index daf7e90..4674a2c 100644 --- a/apps/desktop/src/main/preload.ts +++ b/apps/desktop/src/main/preload.ts @@ -47,6 +47,8 @@ const api = { findings: () => ipcRenderer.invoke(CHANNELS.findings), locate: (id: string, fragment: string) => ipcRenderer.invoke(CHANNELS.locate, id, fragment), drop: (paths: readonly string[]) => ipcRenderer.invoke(CHANNELS.drop, paths), + inboxWaiting: () => ipcRenderer.invoke(CHANNELS.inboxWaiting), + inboxDrain: () => ipcRenderer.invoke(CHANNELS.inboxDrain), credential: () => ipcRenderer.invoke(CHANNELS.credential), saveCredential: (input: unknown) => ipcRenderer.invoke(CHANNELS.saveCredential, input), @@ -82,6 +84,13 @@ const api = { ipcRenderer.on(CHANNELS.changed, listener); return () => ipcRenderer.removeListener(CHANNELS.changed, listener); }, + + /** 3.7 — something arrived through `raw/_inbox/`, or the doorway broke. */ + onInbox: (handler: (outcome: unknown) => void) => { + const listener = (_event: unknown, outcome: unknown): void => handler(outcome); + ipcRenderer.on(CHANNELS.inbox, listener); + return () => ipcRenderer.removeListener(CHANNELS.inbox, listener); + }, }; contextBridge.exposeInMainWorld("ow", api); diff --git a/apps/desktop/src/main/sources.ts b/apps/desktop/src/main/sources.ts index 12361df..f8a3cd6 100644 --- a/apps/desktop/src/main/sources.ts +++ b/apps/desktop/src/main/sources.ts @@ -7,6 +7,7 @@ import { extractProvenanceLinks, listSourceStates, readWiki, + sourceExists, sourceState, type Finding, type SourceState, @@ -57,6 +58,43 @@ export function sourceDetail(projectRoot: string, id: string): SourceRow { return asRow(sourceState(projectRoot, id, citations.get(id) ?? [])); } +/** + * One source a page cites, as the page needs it on screen (plan 6.5). + * + * It carries what 6.4's rows carry in the other direction — a readable title + * and somewhere to click — rather than the bare id the citation spells. A page + * listing `arquitetura-fenix.pdf` and a page listing "Fenix architecture, v3" + * are the same page; only one of them is worth reading. + */ +export interface PageSource { + /** The id as cited. */ + id: string; + /** The source's title, or the id itself when there is nothing to read it from. */ + title: string; + /** + * Null when the source cannot be described. The citation is shown anyway: a + * page pointing at a source nobody can open is exactly what 7.3 reports, and + * hiding it here would leave the reader believing the page is sourced. + */ + kind: SourceState["kind"] | null; + /** + * Why it could not be described, when `kind` is null. + * + * Absent and unreadable are **not the same finding and do not have the same + * fix** — one is a citation pointing at nothing, the other a source that is + * there with a manifest nobody can read. Saying "there is no source named x" + * about a directory the reader can see would send them looking for the wrong + * problem, and 7.3 would be reporting something else about the same id. + */ + reason?: string; + /** + * Where clicking opens it — the start of a recording, the first page of a + * document. The fragment goes back through `locateCitation` (8.6), so the + * panel that opens is the same one a provenance link in the prose opens. + */ + fragment: string; +} + /** * Which sources a page came from (plan 6.5) — the inverse of 6.4. * @@ -64,7 +102,7 @@ export function sourceDetail(projectRoot: string, id: string): SourceRow { * mirrors the body's citations into the field and a page written before that * ran has them only in the body. */ -export function sourcesOfPage(projectRoot: string, slug: string): string[] { +export function sourcesOfPage(projectRoot: string, slug: string): PageSource[] { const page = readWiki(projectRoot).find((p) => p.slug === slug); if (!page) return []; const front = page.frontmatter?.["sources"]; @@ -76,7 +114,41 @@ export function sourcesOfPage(projectRoot: string, slug: string): string[] { const id = link.replace(/^(src|rec):\/\//, "").split("#")[0]; if (id) ids.add(id); } - return [...ids].sort(); + return [...ids].sort().map((id) => describeSource(projectRoot, id)); +} + +/** + * A cited id, described well enough to render and to open. + * + * A source that is not there is described as itself rather than thrown over: + * one broken citation on a page must not take the whole list with it, which is + * the same reason the drop reports per file (3.5). + */ +function describeSource(projectRoot: string, id: string): PageSource { + try { + const state = sourceState(projectRoot, id); + return { + id, + title: state.title, + kind: state.kind, + // `0:00` is the anchor `text.md` writes for the first passage of a + // recording (4.13), and `p1` the one `pdf.ts` writes for a first page — + // so both go through `locateCitation` as an ordinary citation would. + fragment: state.kind === "recording" ? "0:00" : "p1", + }; + } catch (e) { + return { + id, + title: id, + kind: null, + fragment: "p1", + reason: sourceExists(projectRoot, id) + ? // It is there and could not be read — a malformed `manifest.json`, + // a permission error. The message names which. + `"${id}" is there but could not be read: ${e instanceof Error ? e.message : String(e)}` + : `there is no source named "${id}"`, + }; + } } /** The integrity findings, for the panel 7.6 asks for. */ diff --git a/apps/desktop/src/main/transcribe-run.ts b/apps/desktop/src/main/transcribe-run.ts index 154cf84..6a2d3a4 100644 --- a/apps/desktop/src/main/transcribe-run.ts +++ b/apps/desktop/src/main/transcribe-run.ts @@ -7,7 +7,6 @@ import { finishRecording, preprocessRecording, isTimeMap, - readJournal, resolveFfmpeg, spawnFfmpeg, transcribeRecording, @@ -154,16 +153,3 @@ async function mapFor(run: FfmpegRunner, dir: string): Promise { } return preprocessRecording(run, dir); } - -/** How far a recording got, for the button's label (plan 6.3). */ -export function transcriptionProgress( - projectRoot: string, - id: string, -): { done: number; total: number } | null { - const journal = readJournal(sourceDir(projectRoot, id)); - if (!journal) return null; - return { - done: journal.chunks.filter((c) => c.done).length, - total: journal.chunks.length, - }; -} diff --git a/apps/desktop/src/renderer/App.tsx b/apps/desktop/src/renderer/App.tsx index 5f34c3f..37c1af1 100644 --- a/apps/desktop/src/renderer/App.tsx +++ b/apps/desktop/src/renderer/App.tsx @@ -6,7 +6,7 @@ import { bridge, hasBridge } from "./bridge.js"; import { Editor } from "./Editor.js"; import { renderPageBody } from "./markdown.js"; import { History, linkTarget, type Location } from "./navigation.js"; -import { Findings, History as HistoryPanel, SourceAt } from "./Panels.js"; +import { Findings, History as HistoryPanel, PageSources, SourceAt } from "./Panels.js"; import { RecordingIndicator } from "./RecordingIndicator.js"; import { useRecording, type RecordingState } from "./recording.js"; import { Launcher } from "./Launcher.js"; @@ -120,6 +120,21 @@ export function App(): React.JSX.Element { }; }, [location.slug, refreshIndex, reload]); + // 3.7 — the doorway. A file an agent wrote into `raw/_inbox/` while this + // window was open becomes a source with nobody clicking anything, so the + // window reports it where a drop is reported: material the user did not + // initiate is still material they have to see arrive, or a refusal is silence. + // + // No `reloadKey` bump. The ingest writes under `raw/`, which 8.10's watcher + // already reports — and that path coalesces, where thirty files arriving here + // would be thirty un-coalesced walks of the whole project. + useEffect(() => { + if (!hasBridge()) return; + return bridge().onInbox((outcome) => { + setDropped((current) => append(current, outcome)); + }); + }, []); + useEffect(() => { if (location.view !== "wiki" || !location.slug) { setPage(null); @@ -190,7 +205,9 @@ export function App(): React.JSX.Element { void ow .drop(paths) .then((outcomes) => { - setDropped(outcomes); + // Appended, not replaced: an inbox arrival the user has not dismissed + // is a report, and a drop is no reason to discard it. + setDropped((current) => [...(current ?? []), ...outcomes]); setReloadKey((n) => n + 1); }) .catch((e: unknown) => setError(message(e))); @@ -252,6 +269,14 @@ export function App(): React.JSX.Element { {error ?

{error}

: null} {recordError ?

{recordError}

: null} {dropped ? setDropped(null)} /> : null} + { + setDropped((current) => [...(current ?? []), ...outcomes]); + setReloadKey((n) => n + 1); + }} + onError={setError} + /> {dragging ?

Drop files to add them as sources.

: null} {location.view === "wiki" && !location.slug ? ( @@ -271,6 +296,12 @@ export function App(): React.JSX.Element { onDelete={() => void deleteFlow(page.slug, visit, setError)} /> + {/* 6.5 — where this page came from, and a way into each source. */} + setOpenSource({ id, fragment })} + /> {/* Rendered with `html: false` and two token rules, so what reaches here is a closed set of tags this renderer produced. */}
Dismiss
    - {outcomes.map((outcome) => ( -
  • + {/* Keyed by position as well as name: the inbox (3.7) appends to this + list over time, and two files can carry one name across batches. */} + {outcomes.map((outcome, i) => ( +
  • {outcome.name} — {outcome.ok ? `added as ${outcome.id}` : outcome.reason}
  • ))} @@ -519,3 +552,87 @@ function Frontmatter({ page }: { page: PageView }): React.JSX.Element | null { function message(error: unknown): string { return error instanceof Error ? error.message : String(error); } + +/** + * Add an outcome, unless it repeats the one before it (plan 3.7). + * + * `watchInbox` deduplicates its own refusals, but a watcher error does not go + * through that — a directory raising EPERM raises it again and again, and an + * identical line appended thirty times is a banner nobody can read past. + */ +function append(current: DropOutcome[] | null, outcome: DropOutcome): DropOutcome[] { + const list = current ?? []; + const last = list[list.length - 1]; + if (last && sameOutcome(last, outcome)) return list; + return [...list, outcome]; +} + +function sameOutcome(a: DropOutcome, b: DropOutcome): boolean { + if (a.name !== b.name || a.ok !== b.ok) return false; + return a.ok && b.ok ? a.id === b.id : !a.ok && !b.ok && a.reason === b.reason; +} + +/** + * What is sitting in `raw/_inbox/` and was not taken on sight (plan 3.7). + * + * **Asked for, not pushed.** What was already in the doorway when the window + * opened would be announced before the renderer had subscribed and vanish, so + * the renderer asks — and it is left alone until somebody says to take it, + * because `raw/` arrives with a clone and a file that came out of `git clone` + * is not an agent handing something over. + */ +function InboxWaiting({ + reloadKey, + onTaken, + onError, +}: { + reloadKey: number; + onTaken: (outcomes: DropOutcome[]) => void; + onError: (message: string) => void; +}): React.JSX.Element | null { + const [names, setNames] = useState([]); + const [busy, setBusy] = useState(false); + + const load = useCallback(() => { + if (!hasBridge()) return; + void bridge() + .inboxWaiting() + .then(setNames) + .catch(() => setNames([])); + }, []); + + useEffect(load, [load, reloadKey]); + + const take = useCallback(async () => { + setBusy(true); + try { + onTaken(await bridge().inboxDrain()); + load(); + } catch (e) { + onError(message(e)); + } finally { + setBusy(false); + } + }, [load, onTaken, onError]); + + if (names.length === 0) return null; + + return ( +
    +
    + + {names.length} {names.length === 1 ? "file is" : "files are"} waiting in raw/_inbox + + + +
    +
      + {names.map((name) => ( +
    • {name}
    • + ))} +
    +
    + ); +} diff --git a/apps/desktop/src/renderer/Launcher.tsx b/apps/desktop/src/renderer/Launcher.tsx index 6c02adb..0e67229 100644 --- a/apps/desktop/src/renderer/Launcher.tsx +++ b/apps/desktop/src/renderer/Launcher.tsx @@ -1,6 +1,8 @@ import { useCallback, useEffect, useState } from "react"; +import type { Language } from "@open-wiki/access"; import type { KnownProject } from "../main/settings.js"; import { bridge } from "./bridge.js"; +import { DEFAULT_LANGUAGE, LANGUAGES } from "./languages.js"; /** * The launcher (plan 8.4) — what `ow` run outside a project opens instead of @@ -14,6 +16,7 @@ import { bridge } from "./bridge.js"; export function Launcher(): React.JSX.Element { const [projects, setProjects] = useState(null); const [error, setError] = useState(null); + const [creating, setCreating] = useState(false); const load = useCallback(() => { void bridge() @@ -24,19 +27,6 @@ export function Launcher(): React.JSX.Element { useEffect(load, [load]); - const create = useCallback(async () => { - const name = globalThis.prompt?.("A short name for this project")?.trim(); - if (!name) return; - const directory = globalThis.prompt?.("Where should it live?")?.trim(); - if (!directory) return; - try { - await bridge().createProject(name, directory, "en"); - load(); - } catch (e) { - setError(e instanceof Error ? e.message : String(e)); - } - }, [load]); - const forget = useCallback( async (name: string) => { // Only the entry. The directory is the user's. @@ -67,9 +57,23 @@ export function Launcher(): React.JSX.Element { ) : (

    Nothing here yet.

    )} -
    - -
    + + {creating ? ( + setCreating(false)} + onCreated={() => { + setCreating(false); + setError(null); + load(); + }} + onError={setError} + /> + ) : ( +
    + +
    + )} +

    Opening a project is ow inside its directory — the same way code .{" "} works. This list is a convenience, and it is a cache rather than the truth: a project that @@ -78,3 +82,106 @@ export function Launcher(): React.JSX.Element { ); } + +/** + * Creating a project (plan 8.4), and the one moment 8.12 asks the content + * language. + * + * **A form rather than a chain of `prompt()` calls.** Two of those reasons are + * worth stating: Electron does not implement `window.prompt`, so the chain that + * stood here answered nothing at all in the packaged application; and the + * language is a choice with three named options, which a text box cannot offer + * and a user cannot guess the spelling of. + * + * The default is English, per `adr:0008` — chosen here rather than assumed, so + * the setting a project is born with is one somebody looked at. It is not a + * decision to agonise over either: the settings screen changes it afterwards + * and regenerates `CLAUDE.md` when it does. + */ +function NewProject({ + onCancel, + onCreated, + onError, +}: { + onCancel: () => void; + onCreated: () => void; + onError: (message: string) => void; +}): React.JSX.Element { + const [name, setName] = useState(""); + const [directory, setDirectory] = useState(""); + const [language, setLanguage] = useState(DEFAULT_LANGUAGE); + const [busy, setBusy] = useState(false); + + const create = useCallback(async () => { + const trimmedName = name.trim(); + const trimmedDirectory = directory.trim(); + if (!trimmedName || !trimmedDirectory) { + onError("a project needs a name and a directory"); + return; + } + setBusy(true); + try { + // Through the scaffolder of 2.1, the same one `ow init` and the first run + // use — so a project is the same project whichever door it came through. + await bridge().createProject(trimmedName, trimmedDirectory, language); + onCreated(); + } catch (e) { + onError(e instanceof Error ? e.message : String(e)); + } finally { + setBusy(false); + } + }, [name, directory, language, onCreated, onError]); + + return ( +

    +

    New project

    + + +
    + Content language +

    + What transcription is told to expect, and what the generated CLAUDE.md tells the agent to + write pages in. The schema itself stays English, and this is changeable later. +

    +
    + {LANGUAGES.map((option) => ( + + ))} +
    +
    +
    + + +
    +
    + ); +} diff --git a/apps/desktop/src/renderer/Panels.tsx b/apps/desktop/src/renderer/Panels.tsx index e9e773c..99db238 100644 --- a/apps/desktop/src/renderer/Panels.tsx +++ b/apps/desktop/src/renderer/Panels.tsx @@ -1,13 +1,85 @@ import { useCallback, useEffect, useState } from "react"; import type { Finding, Operation } from "@open-wiki/access"; -import type { SourceLocation } from "../main/sources.js"; +import type { PageSource, SourceLocation } from "../main/sources.js"; import { bridge } from "./bridge.js"; /** - * The three panels that hang off the main view: the checks (7.6), the - * operation history (8.11), and what a provenance link opens (8.6). + * The panels that hang off the main view: the checks (7.6), the operation + * history (8.11), what a provenance link opens (8.6), and where the open page + * came from (6.5). */ +/** + * Which sources the open page came from (plan 6.5) — the inverse of the sources + * screen's "cited by" (6.4). + * + * It sits on the page itself rather than behind a button, because the question + * it answers is the one a reader has while they are reading. Clicking one opens + * the same panel a provenance link in the prose opens, at the source's start. + * + * A citation whose source is not there is **shown as broken, not hidden** — the + * same choice 8.5 makes for a wikilink that does not resolve. Dropping it would + * leave the reader believing the page is sourced, which is the one wrong answer + * available here. + */ +export function PageSources({ + slug, + reloadKey, + onOpen, +}: { + slug: string; + reloadKey: number; + onOpen: (id: string, fragment: string) => void; +}): React.JSX.Element | null { + const [sources, setSources] = useState(null); + + useEffect(() => { + // **Cleared first.** The component survives navigation — same instance, no + // `key` — so without this the previous page's sources stay on screen under + // the new page's title and body until the walk over the wiki returns. For a + // component whose whole job is saying where the page in front of you came + // from, attributing one page's provenance to another is the one wrong + // answer available. + setSources(null); + // Guarded as well, and against a different failure: a slow answer for the + // page we have left arriving after the fast one for the page we are on. + let live = true; + void bridge() + .sourcesOfPage(slug) + .then((found) => { + if (live) setSources(found); + }) + .catch(() => { + if (live) setSources([]); + }); + return () => { + live = false; + }; + }, [slug, reloadKey]); + + if (!sources || sources.length === 0) return null; + + return ( +

    + From{" "} + {sources.map((source, i) => ( + + {i > 0 ? ", " : ""} + {source.kind === null ? ( + + {source.id} + + ) : ( + onOpen(source.id, source.fragment)}> + {source.title} + + )} + + ))} +

    + ); +} + /** * The integrity findings (plan 7.6). * diff --git a/apps/desktop/src/renderer/Settings.tsx b/apps/desktop/src/renderer/Settings.tsx index e6f27ca..88beb98 100644 --- a/apps/desktop/src/renderer/Settings.tsx +++ b/apps/desktop/src/renderer/Settings.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from "react"; import type { Language } from "@open-wiki/access"; import type { CredentialState } from "../main/settings.js"; import { bridge } from "./bridge.js"; +import { LANGUAGES } from "./languages.js"; /** * The settings screen: the transcription credential (plan 8.3) and the content @@ -13,12 +14,6 @@ import { bridge } from "./bridge.js"; * DOM of a window that renders markdown an agent wrote. */ -const LANGUAGES: Array<{ value: Language; label: string }> = [ - { value: "en", label: "English" }, - { value: "pt-BR", label: "Brazilian Portuguese" }, - { value: "es", label: "Spanish" }, -]; - export function Settings(): React.JSX.Element { const [credential, setCredential] = useState(null); const [language, setLanguageState] = useState(null); diff --git a/apps/desktop/src/renderer/bridge.ts b/apps/desktop/src/renderer/bridge.ts index cb290d1..01aadba 100644 --- a/apps/desktop/src/renderer/bridge.ts +++ b/apps/desktop/src/renderer/bridge.ts @@ -9,7 +9,7 @@ import type { KnownProject, SaveCredentialInput, } from "../main/settings.js"; -import type { SourceLocation, SourceRow } from "../main/sources.js"; +import type { PageSource, SourceLocation, SourceRow } from "../main/sources.js"; import type { TranscribeOutcome } from "../main/transcribe-run.js"; import type { ProjectChange } from "../main/watcher.js"; @@ -39,11 +39,14 @@ export interface OwBridge { undo(id: string): Promise; sourceDetail(id: string): Promise; - sourcesOfPage(slug: string): Promise; + sourcesOfPage(slug: string): Promise; retitle(id: string, title: string): Promise; findings(): Promise; locate(id: string, fragment: string): Promise; drop(paths: readonly string[]): Promise; + /** 3.7 — what is sitting in the doorway, and taking it when asked. */ + inboxWaiting(): Promise; + inboxDrain(): Promise; credential(): Promise; saveCredential(input: SaveCredentialInput): Promise; language(): Promise; @@ -57,6 +60,9 @@ export interface OwBridge { pathForFile(file: File): string; onChanged(handler: (change: ProjectChange) => void): () => void; + + /** 3.7 — a file that arrived through `raw/_inbox/`, reported as a drop is. */ + onInbox(handler: (outcome: DropOutcome) => void): () => void; } declare global { diff --git a/apps/desktop/src/renderer/languages.ts b/apps/desktop/src/renderer/languages.ts new file mode 100644 index 0000000..6e42ce9 --- /dev/null +++ b/apps/desktop/src/renderer/languages.ts @@ -0,0 +1,20 @@ +import type { Language } from "@open-wiki/access"; + +/** + * The content languages 8.12 ships, in one place. + * + * Shared because two screens ask the same question — the launcher at onboarding, + * the settings screen afterwards — and a list written out twice becomes two + * answers to one question the moment a fourth language is added. + * + * The values are the `Language` union itself, so a language added to the setting + * and forgotten here is a compile error rather than a picker missing an option. + */ +export const LANGUAGES: ReadonlyArray<{ value: Language; label: string }> = [ + { value: "en", label: "English" }, + { value: "pt-BR", label: "Brazilian Portuguese" }, + { value: "es", label: "Spanish" }, +]; + +/** What a project starts in when nobody chose — `adr:0008`. */ +export const DEFAULT_LANGUAGE: Language = "en"; diff --git a/apps/desktop/src/renderer/tokens.css b/apps/desktop/src/renderer/tokens.css index efd3af0..cb04c2b 100644 --- a/apps/desktop/src/renderer/tokens.css +++ b/apps/desktop/src/renderer/tokens.css @@ -488,3 +488,45 @@ a:hover, outline: 2px dashed var(--accent); outline-offset: -6px; } + +/* --- Creating a project (8.4), and the language it is born in (8.12). + The fieldset is reset rather than styled: a browser's default is a raised + border drawn for a light document, and on this surface it reads as a box + somebody forgot to finish. */ +.launcher__new { + display: grid; + gap: var(--space-3); + background: var(--surface-2); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: var(--space-3); +} + +.launcher__new label { + display: grid; + gap: var(--space-1); + font-size: var(--text-xs); + color: var(--ink-1); +} + +.launcher__languages { + border: 0; + margin: 0; + padding: 0; + display: grid; + gap: var(--space-1); +} + +.launcher__languages legend { + font-size: var(--text-xs); + color: var(--ink-1); + padding: 0; +} + +/* A radio and its words are one target, and they sit on one line — the grid + above is for the stacked text fields, not for these. */ +.launcher__languages label { + display: flex; + align-items: center; + gap: var(--space-1); +} diff --git a/apps/desktop/tests/sources.spec.ts b/apps/desktop/tests/sources.spec.ts index 4e191f4..fff9dc1 100644 --- a/apps/desktop/tests/sources.spec.ts +++ b/apps/desktop/tests/sources.spec.ts @@ -1,9 +1,17 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { CHANNELS, createApi, dispatch } from "../src/main/ipc.js"; -import { ingestDrop, ingestFile, recognises, recognisedExtensions } from "../src/main/ingest.js"; +import { PUSH_CHANNELS } from "../src/main/channels.js"; +import { + asDropOutcome, + inboxFailure, + ingestDrop, + ingestFile, + recognises, + recognisedExtensions, +} from "../src/main/ingest.js"; import { findings, locateCitation, @@ -24,13 +32,23 @@ afterEach(() => rmSync(root, { recursive: true, force: true })); function source( id: string, - over: { kind?: "file" | "recording"; text?: boolean; original?: string } = {}, + over: { + kind?: "file" | "recording"; + text?: boolean; + original?: string; + title?: string; + } = {}, ): void { const dir = join(root, "raw", id); mkdirSync(dir, { recursive: true }); writeFileSync( join(dir, "manifest.json"), - JSON.stringify({ id, title: id, kind: over.kind ?? "file", original: over.original ?? "" }), + JSON.stringify({ + id, + title: over.title ?? id, + kind: over.kind ?? "file", + original: over.original ?? "", + }), "utf8", ); if (over.text !== false) writeFileSync(join(dir, "text.md"), "# text\n", "utf8"); @@ -113,7 +131,7 @@ describe("sourcesOfPage (6.5)", () => { source("a.pdf"); source("weekly", { kind: "recording" }); page("fenix", "see src://a.pdf#p1 and rec://weekly#14:32\n"); - expect(sourcesOfPage(root, "fenix")).toEqual(["a.pdf", "weekly"]); + expect(sourcesOfPage(root, "fenix").map((s) => s.id)).toEqual(["a.pdf", "weekly"]); }); it("reads the field as well as the prose", () => { @@ -121,7 +139,63 @@ describe("sourcesOfPage (6.5)", () => { // has its citations only in one of the two. source("a.pdf"); page("fenix", "no citations in the body\n", ["src://a.pdf#p1"]); - expect(sourcesOfPage(root, "fenix")).toEqual(["a.pdf"]); + expect(sourcesOfPage(root, "fenix").map((s) => s.id)).toEqual(["a.pdf"]); + }); + + it("carries the title, so a page says what it came from and not only its id", () => { + source("a.pdf", { title: "Fenix architecture, v3" }); + page("fenix", "see src://a.pdf#p1\n"); + expect(sourcesOfPage(root, "fenix")[0]).toMatchObject({ + id: "a.pdf", + title: "Fenix architecture, v3", + kind: "file", + }); + }); + + it("opens each kind at its own start — a first page, a first instant", () => { + // **Asserted through `locateCitation`, not against the constant.** The + // fragment is only worth anything if it resolves, and asserting `"0:00"` + // would faithfully encode the wrong string if the string were wrong: any + // fragment `parseInstant` rejects sends a recording down the *document* + // branch, where it resolves to "has no file to open". + source("a.pdf", { original: "a.pdf" }); + writeFileSync(join(root, "raw", "a.pdf", "source.pdf"), "%PDF"); + source("weekly", { kind: "recording" }); + writeFileSync(join(root, "raw", "weekly", "mic.opus"), ""); + page("fenix", "see src://a.pdf#p1 and rec://weekly#14:32\n"); + + const [document, recording] = sourcesOfPage(root, "fenix"); + expect(locateCitation(root, document!.id, document!.fragment)).toMatchObject({ + kind: "document", + page: 1, + }); + expect(locateCitation(root, recording!.id, recording!.fragment)).toMatchObject({ + kind: "audio", + seconds: 0, + }); + }); + + it("reports a citation whose source is gone, rather than dropping it", () => { + // Hiding it would leave the reader believing the page is sourced, which is + // the one wrong answer available here — and 7.3 reports the same citation + // as a finding. + page("fenix", "see src://vanished.pdf#p1\n"); + const [missing] = sourcesOfPage(root, "fenix"); + expect(missing).toMatchObject({ id: "vanished.pdf", kind: null }); + expect(missing?.reason).toContain("there is no source"); + }); + + it("tells a source that cannot be read from one that is not there", () => { + // They have different fixes, so they cannot share a message. Saying "there + // is no source named x" about a directory the reader can see sends them + // looking for the wrong problem. + mkdirSync(join(root, "raw", "broken.pdf"), { recursive: true }); + writeFileSync(join(root, "raw", "broken.pdf", "manifest.json"), '{"title":{}}', "utf8"); + page("fenix", "see src://broken.pdf#p1\n"); + const [unreadable] = sourcesOfPage(root, "fenix"); + expect(unreadable).toMatchObject({ id: "broken.pdf", kind: null }); + expect(unreadable?.reason).toContain("could not be read"); + expect(unreadable?.reason).not.toContain("there is no source"); }); it("is empty for a page that cites nothing, and for a page that is not there", () => { @@ -255,6 +329,65 @@ describe("dropping files onto the window (3.5)", () => { }); }); +describe("the inbox doorway, as the window reports it (3.7)", () => { + it("says an arrival the way a drop says it", () => { + expect( + asDropOutcome({ ok: true, name: "notes.md", format: "text", id: "notes.md", removed: true }), + ).toEqual({ + name: "notes.md", + ok: true, + id: "notes.md", + }); + }); + + it("carries the refusal's own reason, not a generic one", () => { + // The reason is the whole value of reporting a refusal: "could not ingest" + // tells the person nothing they can act on, and a file refused in silence + // is material an agent believes it handed over. + expect( + asDropOutcome({ + ok: false, + name: "big.pdf", + format: null, + reason: "over the size limit for a source", + removed: false, + }), + ).toEqual({ name: "big.pdf", ok: false, reason: "over the size limit for a source" }); + }); + + it("reports a doorway that stopped working, because a quiet watcher looks like an empty inbox", () => { + const outcome = inboxFailure(new Error("EPERM: operation not permitted")); + expect(outcome.ok).toBe(false); + expect(outcome.ok === false && outcome.reason).toContain("EPERM"); + }); + + it("lists what is waiting without taking it", async () => { + // What is already in the doorway when a window opens is listed and left + // alone: `raw/` arrives with a clone, so ingesting on sight would parse a + // stranger's bytes and delete the file out of the user's tree with nobody + // having clicked anything. + mkdirSync(join(root, "raw", "_inbox"), { recursive: true }); + writeFileSync(join(root, "raw", "_inbox", "notes.md"), "# notes\n", "utf8"); + const api = createApi({ projectRoot: root }); + + expect(api.inboxWaiting()).toEqual(["notes.md"]); + // Still there, and still not a source. + expect(existsSync(join(root, "raw", "_inbox", "notes.md"))).toBe(true); + expect(sourceRows(root)).toEqual([]); + }); + + it("takes it when asked, and then it is gone from the doorway", async () => { + mkdirSync(join(root, "raw", "_inbox"), { recursive: true }); + writeFileSync(join(root, "raw", "_inbox", "notes.md"), "# notes\n", "utf8"); + const api = createApi({ projectRoot: root }); + + const outcomes = await api.inboxDrain(); + expect(outcomes).toEqual([{ name: "notes.md", ok: true, id: "notes.md" }]); + expect(api.inboxWaiting()).toEqual([]); + expect(sourceRows(root).map((r) => r.id)).toEqual(["notes.md"]); + }); +}); + describe("the widened IPC surface (6.x, 7.6, 8.6 to 8.11)", () => { it("routes every channel it declares", async () => { source("a.pdf"); @@ -267,7 +400,9 @@ describe("the widened IPC surface (6.x, 7.6, 8.6 to 8.11)", () => { await expect(dispatch(api, CHANNELS.sourceDetail, ["a.pdf"])).resolves.toMatchObject({ id: "a.pdf", }); - await expect(dispatch(api, CHANNELS.sourcesOfPage, ["fenix"])).resolves.toEqual(["a.pdf"]); + await expect(dispatch(api, CHANNELS.sourcesOfPage, ["fenix"])).resolves.toMatchObject([ + { id: "a.pdf" }, + ]); await expect(dispatch(api, CHANNELS.findings, [])).resolves.toBeInstanceOf(Array); await expect(dispatch(api, CHANNELS.locate, ["a.pdf", "p1"])).resolves.toMatchObject({ kind: "missing", @@ -275,10 +410,19 @@ describe("the widened IPC surface (6.x, 7.6, 8.6 to 8.11)", () => { await expect(dispatch(api, CHANNELS.history, [])).resolves.toBeInstanceOf(Array); }); + it("names exactly the channels the main process pushes on", () => { + // The loop below skips `PUSH_CHANNELS`, which is the same set `index.ts` + // uses to decide what gets no `ipcMain.handle`. Trusting it in both places + // means a channel wrongly added to it is skipped twice over: no handler + // registered, no dispatch case exercised, test green, and the renderer's + // `invoke` hanging against a channel nobody answers. This pins it. + expect([...PUSH_CHANNELS].sort()).toEqual([CHANNELS.changed, CHANNELS.inbox].sort()); + }); + it("handles every channel it declares, with no gaps", async () => { const api = createApi({ projectRoot: root }); for (const channel of Object.values(CHANNELS)) { - if (channel === CHANNELS.changed) continue; // main → renderer only + if (PUSH_CHANNELS.has(channel)) continue; // main → renderer only // `createProject` actually scaffolds. Calling every channel with // arbitrary arguments once left a real project directory behind in the // repository — a test with side effects is a test that edits the thing diff --git a/packages/access/src/index.ts b/packages/access/src/index.ts index 4cbd871..9d9a439 100644 --- a/packages/access/src/index.ts +++ b/packages/access/src/index.ts @@ -73,10 +73,12 @@ export { formatDenial } from "./gate/errors.js"; // Sources (group 3) export { readManifest, + parseManifest, listSources, sourceExists, TakenIdError, MissingSourceError, + InvalidManifestError, type SourceManifest, type SourceKind, } from "./sources/manifest.js"; @@ -112,6 +114,7 @@ export { watchInbox, ensureInbox, inboxPath, + listInbox, INBOX, type InboxOutcome, type InboxWatcher, diff --git a/packages/access/src/read.ts b/packages/access/src/read.ts index dc05f1a..69cd85b 100644 --- a/packages/access/src/read.ts +++ b/packages/access/src/read.ts @@ -59,9 +59,11 @@ export { resolveProvenance, extractProvenanceLinks } from "./store/provenance.js export { isStoreOnlyChange, pagesEqual, STORE_MANAGED_FIELDS } from "./store/staleness.js"; export { readManifest, + parseManifest, listSources, sourceExists, MissingSourceError, + InvalidManifestError, type SourceManifest, type SourceKind, } from "./sources/manifest.js"; diff --git a/packages/access/src/sources/inbox.ts b/packages/access/src/sources/inbox.ts index d7bcd25..5cce09c 100644 --- a/packages/access/src/sources/inbox.ts +++ b/packages/access/src/sources/inbox.ts @@ -19,8 +19,10 @@ import { MAX_SOURCE_BYTES, ingestSource, type IngestOutcome } from "./upload.js" * reported. It is the user's file, and moving or deleting it to keep the * doorway tidy would lose material that nothing else holds a copy of. * - * Nothing wires the watcher up yet — group 8's shell is what will run it. Until - * then `drainInbox` is the whole doorway, callable by hand. + * The desktop application holds the watcher open for as long as a project window + * is open (`apps/desktop/src/main/index.ts`), reporting each outcome where a + * drop is reported. `drainInbox` remains callable on its own, which is what a + * process with no window — a test, a script — uses. */ export { INBOX }; @@ -204,6 +206,37 @@ export interface WatchInboxOptions { */ stabilityThreshold?: number; pollInterval?: number; + /** + * Ingest what is **already sitting in the doorway** when watching starts. + * Defaults to true, which is what a script draining a directory wants. + * + * The desktop application passes `false`, and the reason is a threat rather + * than a preference. `raw/` arrives with a `git clone`, so a repository can + * ship `raw/_inbox/x.pdf`; with initial ingestion on, opening that project + * would parse a stranger's bytes in the privileged main process and delete + * the file out of the user's working tree, with no click anywhere. The + * doorway exists for an agent handing something over **during a session** — + * that is what an event is — and a directory that came out of a clone is not + * that. What is already there is listed instead, and taken only when asked. + */ + ingestExisting?: boolean; +} + +/** + * The names sitting in the doorway right now. + * + * A read, so a caller that does not ingest on sight can still say what is + * waiting. Bare filenames, never paths: the inbox is flat and the caller has no + * business reaching below it. + */ +export function listInbox(projectRoot: string): string[] { + const dir = inboxPath(projectRoot); + if (!existsSync(dir)) return []; + try { + return readdirSync(dir).sort(); + } catch { + return []; + } } /** @@ -296,7 +329,9 @@ export async function watchInbox( const watcher = watch(dir, { depth: 0, // the doorway is flat - ignoreInitial: false, // whatever is already sitting there is work to do + // What is already sitting there is work to do — unless the caller said it + // would rather be told than have it done, which is `ingestExisting`. + ignoreInitial: options.ingestExisting === false, awaitWriteFinish: { stabilityThreshold: stabilityMs, pollInterval: options.pollInterval ?? 100, diff --git a/packages/access/src/sources/manifest.ts b/packages/access/src/sources/manifest.ts index 81db50c..30b75de 100644 --- a/packages/access/src/sources/manifest.ts +++ b/packages/access/src/sources/manifest.ts @@ -47,6 +47,53 @@ export class MissingSourceError extends Error { } } +export class InvalidManifestError extends Error { + constructor( + public readonly id: string, + detail: string, + ) { + super(`the manifest of "${id}" is not one: ${detail}`); + this.name = "InvalidManifestError"; + } +} + +/** + * Parse a manifest, checking its shape rather than asserting it. + * + * `manifest.json` is a file in a project directory, so it **arrives with a + * clone** — it is not something this application necessarily wrote. `JSON.parse` + * returns `any` and casting it to `SourceManifest` checks nothing, so a `title` + * that is an object reached the screen as a React child and blanked the whole + * window: there is no error boundary, and every page citing that source went + * with it. A refusal naming the source is recoverable; a blank window is not. + * + * **The id comes from the directory, never from the file.** `adr:0011` freezes an + * id as the directory name, so a manifest claiming a different one is claiming + * something it does not get to decide. + */ +export function parseManifest(id: string, text: string): SourceManifest { + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + throw new InvalidManifestError(id, "it does not parse as JSON"); + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw new InvalidManifestError(id, "it is not a JSON object"); + } + const record = parsed as Record; + const title = record["title"]; + const kind = record["kind"]; + if (typeof title !== "string") { + throw new InvalidManifestError(id, "`title` is missing or is not a string"); + } + if (kind !== "file" && kind !== "recording") { + throw new InvalidManifestError(id, '`kind` is neither "file" nor "recording"'); + } + const original = record["original"]; + return { id, title, kind, original: typeof original === "string" ? original : "" }; +} + /** * The confined path of a source's manifest; throws if the id escapes `raw/`. * @@ -61,11 +108,14 @@ function manifestPath(projectRoot: string, id: string): string { return assertWithin(rawDir, join(rawDir, id, "manifest.json")); } -/** Read a source's manifest. Throws `MissingSourceError` if it is not there. */ +/** + * Read a source's manifest. Throws `MissingSourceError` if it is not there, and + * `InvalidManifestError` if what is there is not a manifest. + */ export function readManifest(projectRoot: string, id: string): SourceManifest { const file = manifestPath(projectRoot, id); if (!existsSync(file)) throw new MissingSourceError(id); - return JSON.parse(readFileSync(file, "utf8")); + return parseManifest(id, readFileSync(file, "utf8")); } /** True when a source directory with this id exists under `raw/`. */ diff --git a/packages/access/tests/paths.spec.ts b/packages/access/tests/paths.spec.ts index bcd61d7..6c216ce 100644 --- a/packages/access/tests/paths.spec.ts +++ b/packages/access/tests/paths.spec.ts @@ -56,17 +56,20 @@ describe("isWithin / assertWithin", () => { expect(() => assertWithin(root, outside)).toThrow(OutsideProjectError); }); - it("refuses a symlink inside the project that points outside", () => { + it("refuses a symlink inside the project that points outside", (ctx) => { const outside = join(dirname(root), "secret.md"); writeFileSync(outside, "x"); const link = join(root, "wiki", "link.md"); try { symlinkSync(outside, link); - } catch { - // Some Windows accounts lack the symlink privilege; that is a different - // failure from the containment logic and should not fail this test. - rmSync(root, { recursive: true, force: true }); + } catch (err) { + // Creating a symlink is privileged on most Windows accounts, and that is + // a different failure from the containment logic. **Reported as a skip, + // never as a pass**: a test that silently returns green is one nobody + // knows stopped running, and this is the check standing between a + // citation and a file anywhere on disk. rmSync(outside, { force: true }); + ctx.skip(`symlink creation unavailable: ${err instanceof Error ? err.message : err}`); return; } try { @@ -77,16 +80,21 @@ describe("isWithin / assertWithin", () => { } }); - it("refuses a Windows directory junction pointing outside, without privilege", () => { + it("refuses a Windows directory junction pointing outside, without privilege", (ctx) => { const outside = join(dirname(root), "junction-target"); mkdirSync(outside, { recursive: true }); const junction = join(root, "wiki", "junction"); - // A junction needs no privilege on Windows and is not a symlink. try { symlinkSync(outside, junction, "junction"); - } catch { - rmSync(root, { recursive: true, force: true }); + } catch (err) { rmSync(outside, { recursive: true, force: true }); + // **On Windows this is a failure, not a skip.** A junction needs no + // privilege there — that is the entire reason this case exists beside the + // symlink one — so an account that cannot create one is telling us + // something is wrong with the test, not with the account. Elsewhere the + // call is emulated as a symlink and may genuinely be unavailable. + if (process.platform === "win32") throw err; + ctx.skip(`junction creation unavailable: ${err instanceof Error ? err.message : err}`); return; } try { diff --git a/packages/access/tests/sources-inbox.spec.ts b/packages/access/tests/sources-inbox.spec.ts index ee2a8a1..a7cf8d6 100644 --- a/packages/access/tests/sources-inbox.spec.ts +++ b/packages/access/tests/sources-inbox.spec.ts @@ -20,6 +20,7 @@ import { drainInbox, ensureInbox, inboxPath, + listInbox, watchInbox, MAX_SOURCE_BYTES, type InboxOutcome, @@ -71,6 +72,22 @@ describe("the raw/_inbox doorway (3.7)", () => { beforeEach(() => (root = tempProject())); afterEach(() => rmSync(root, { recursive: true, force: true })); + describe("listInbox", () => { + it("names what is waiting, without taking any of it", () => { + ensureInbox(root); + writeFileSync(join(root, "raw", INBOX, "b.md"), "# B\n"); + writeFileSync(join(root, "raw", INBOX, "a.md"), "# A\n"); + // Sorted, so a caller reports a stable order rather than the + // filesystem's. + expect(listInbox(root)).toEqual(["a.md", "b.md"]); + expect(listSources(root)).toEqual([]); + }); + + it("is empty when there is no doorway yet", () => { + expect(listInbox(root)).toEqual([]); + }); + }); + describe("inboxPath / ensureInbox", () => { it("is raw/_inbox inside the project", () => { expect(inboxPath(root)).toBe(join(root, "raw", INBOX)); @@ -242,6 +259,53 @@ describe("the raw/_inbox doorway (3.7)", () => { } }); + it("leaves what was already there alone when told to, and still takes what arrives", async () => { + // `ingestExisting: false` is what the desktop application passes, and the + // reason is a threat rather than a preference: `raw/` arrives with a + // clone, so a repository can ship `raw/_inbox/x.pdf`. Ingesting on sight + // would parse a stranger's bytes in the privileged main process and + // delete the file out of the user's tree with nobody having clicked. + writeFileSync(join(root, "raw", INBOX, "cloned.md"), "# Cloned\n"); + const seen: InboxOutcome[] = []; + const watcher = await watchInbox( + root, + { onOutcome: (o) => seen.push(o) }, + { stabilityThreshold: 150, pollInterval: 25, ingestExisting: false }, + ); + try { + // What arrives afterwards is an agent handing something over, which is + // what the doorway is for — and it still works. + writeFileSync(join(root, "raw", INBOX, "handed-over.md"), "# Handed over\n"); + await until(() => seen.length > 0); + expect(seen.map((o) => o.name)).toEqual(["handed-over.md"]); + + // The cloned one is untouched: still in the doorway, still not a source. + expect(existsSync(join(root, "raw", INBOX, "cloned.md"))).toBe(true); + expect(listSources(root)).not.toContain("cloned.md"); + } finally { + await watcher.close(); + } + }); + + it("drains on request what it would not take on sight", async () => { + // Left alone is not lost. `drain()` is the explicit act, and it is what + // the window's "Add them" button reaches. + writeFileSync(join(root, "raw", INBOX, "cloned.md"), "# Cloned\n"); + const watcher = await watchInbox( + root, + { onOutcome: () => undefined }, + { stabilityThreshold: 20, pollInterval: 25, ingestExisting: false }, + ); + try { + const outcomes = await watcher.drain(); + expect(outcomes).toHaveLength(1); + expect(outcomes[0]).toMatchObject({ ok: true, id: "cloned.md" }); + expect(listInbox(root)).toEqual([]); + } finally { + await watcher.close(); + } + }); + it( "does not ingest a file that is still being written", { timeout: WATCH_TIMEOUT }, diff --git a/packages/access/tests/sources-manifest.spec.ts b/packages/access/tests/sources-manifest.spec.ts index d62afb7..cf578fc 100644 --- a/packages/access/tests/sources-manifest.spec.ts +++ b/packages/access/tests/sources-manifest.spec.ts @@ -4,9 +4,11 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { readManifest, + parseManifest, listSources, sourceExists, TakenIdError, + InvalidManifestError, type SourceManifest, } from "../src/sources/manifest.js"; import { registerSource } from "../src/sources/register.js"; @@ -142,3 +144,44 @@ describe("readManifest / listSources / sourceExists (3.1 read side)", () => { expect(ids).not.toContain("loose.txt"); }); }); + +describe("parseManifest — a manifest arrives with a clone, so its shape is checked", () => { + it("accepts a well-formed manifest", () => { + expect( + parseManifest("a.pdf", '{"id":"a.pdf","title":"A","kind":"file","original":"a.pdf"}'), + ).toEqual({ id: "a.pdf", title: "A", kind: "file", original: "a.pdf" }); + }); + + it("refuses a title that is not a string, rather than passing it on", () => { + // This is the one that mattered: `JSON.parse` returns `any`, the cast + // checked nothing, and an object title reached the screen as a React child + // and blanked the whole window — taking every page citing that source. + expect(() => parseManifest("a.pdf", '{"title":{},"kind":"file"}')).toThrow( + InvalidManifestError, + ); + expect(() => parseManifest("a.pdf", '{"kind":"file"}')).toThrow(InvalidManifestError); + }); + + it("refuses a kind that is neither of the two", () => { + expect(() => parseManifest("a.pdf", '{"title":"A","kind":"video"}')).toThrow( + InvalidManifestError, + ); + }); + + it("refuses what does not parse, and what parses to the wrong thing", () => { + for (const text of ["not json", "[]", "null", '"a string"']) { + expect(() => parseManifest("a.pdf", text), text).toThrow(InvalidManifestError); + } + }); + + it("takes the id from the directory and never from the file", () => { + // `adr:0011` freezes an id as the directory name, so a manifest claiming a + // different one is claiming something it does not get to decide. + expect(parseManifest("a.pdf", '{"id":"../elsewhere","title":"A","kind":"file"}').id) // + .toBe("a.pdf"); + }); + + it("defaults a missing original rather than refusing — a recording has none", () => { + expect(parseManifest("weekly", '{"title":"W","kind":"recording"}').original).toBe(""); + }); +}); diff --git a/plans/open-wiki.md b/plans/open-wiki.md index c38f301..880031b 100644 --- a/plans/open-wiki.md +++ b/plans/open-wiki.md @@ -123,7 +123,11 @@ fenix/ a project — usually a repository the user alre - The drop reports **what was recognised and what was not** — the second half of the task. A partial success reported as success is how a source silently never arrives, and a name already taken is reported as itself, because `adr:0011` chose that refusal deliberately. - [x] 3.6 (TDD) Derive the id: lowercase, accents folded, anything outside `[a-z0-9]` collapsed to one `-`, and refuse a filename already taken in this project instead of inventing a suffix - [x] 3.7 (Unit) Watch `raw/_inbox/` and ingest what lands there through the same path as 3.1 — the way an agent hands over material it fetched, now that no MCP tool ingests. The inbox is the one mutable thing under `raw/`: it is a doorway, emptied by ingestion, and it is not a source, so nothing enumerates it, cites it or reports it uncited - - The watcher is built and tested but **nothing runs it yet** — 8.2's shell is what will hold it open. Until then the doorway works through `drainInbox`, called by hand. A reader should not take the ticked box to mean a file dropped into `raw/_inbox/` is picked up by a running process today. + - **Now wired.** 8.2's shell holds the watcher open for the life of a project window (`apps/desktop/src/main/index.ts`), and each arrival is reported where a drop is reported — the doorway and the drop zone are two ways into one registration, so they say the same thing. It starts asynchronously, because `watchInbox` waits for chokidar's initial scan and a window that blocked on it would be a window that does not open; a window closed before the handle arrives closes it anyway, or the watcher outlives its window. + - A doorway that **stops working is reported, not logged**. A watcher gone quiet is indistinguishable from an inbox nobody is using, and what it silently drops is material an agent believes it handed over. + - **What is already in the doorway when a window opens is listed, never taken** — `ingestExisting: false`, which the desktop passes and nothing else does. A security review caught what wiring the watcher had quietly changed: `raw/` arrives with a `git clone`, so a repository can ship `raw/_inbox/x.pdf`, and ingesting on sight would parse a stranger's bytes in the privileged main process and delete the file out of the user's tree before anybody clicked anything. The doorway is for an agent handing something over **during a session** — that is what an event is — and a file that came out of a clone is not that. + - Left alone is not lost: the window says what is waiting and offers to take it. That report is **asked for rather than pushed**, which is what makes it reliable — anything announced at startup is announced before the renderer has subscribed and vanishes, since `webContents.send` has no queue. Live arrivals stay pushed, because by then the window is listening. Everything the main process does push is buffered until the document has loaded, for the same reason. + - `drainInbox` stays callable on its own. That is what a process with no window — a test, a script — uses, and it is what the "Add them" button reaches through the watcher's own queue, so an explicit drain and an event never read the same file twice. ## 4 — Sources: audio recording @@ -227,6 +231,9 @@ which is 9.5's problem. - [x] 6.4 (Unit) Show, for a source, which pages cite it, and navigate from there to the page - [x] 6.5 (Unit) Show, for a page, which sources it came from — the inverse path of the previous one - Reads the page’s prose as well as its `sources` field: 5.5 mirrors one into the other, and a page written before that ran has its citations in only one of the two. + - **On the page itself**, under the frontmatter, because the question it answers is the one a reader has while they are reading — provenance behind a button nobody presses may as well not have been recorded. The backend, its IPC channel and its tests all existed before the surface did, which made this read as done from every angle except the user's. + - It carries the **title and somewhere to click**, not the bare id the citation spells. Clicking opens the panel a provenance link in the prose opens (8.6), at the source's own start — `p1` for a document, `0:00` for a recording, which are the anchors `pdf.ts` and 4.13 actually write. A fragment of the wrong shape resolves to nothing while reading perfectly reasonably. + - A citation whose source is gone is **shown as broken, not dropped** — the same choice 8.5 makes for an unresolvable wikilink, and the same citation 7.3 reports. Hiding it would leave the reader believing the page is sourced, which is the one wrong answer available here. - [x] 6.6 (Unit) Highlight a source sitting in `raw/` that no page cites, which is the case that disappears from view on its own - [x] 6.7 (Unit) Correct a source's title without moving its directory or touching a citation — the freeze in `adr:0011-sources-are-named-by-what-they-are` is only bearable if the readable name stays editable @@ -297,6 +304,9 @@ of record. - [x] 8.12 (Unit) Choose the content language at onboarding and change it afterwards — English by default, Brazilian Portuguese and Spanish alongside it — held in the project settings of 2.7 and reaching exactly two places: the transcription hint of 4.15, and the generated `CLAUDE.md` of 9.4, which is regenerated on change because it is generated and the skills are not - `CLAUDE.md` is regenerated on change, because it is generated and carries the language; the skills are not and are left alone, which is the distinction 9.4 draws. - The generator moved from the CLI into `@open-wiki/access`, beside `scaffoldSkills`: 9.3 and 9.4 are one act, and a copy in the CLI would have meant the desktop application either reaching into it or growing a second generator that drifts. + - **The onboarding half was missing and is now there.** Changing the language afterwards worked, and `ow init --language` asked, but the launcher passed a hardcoded `"en"` — so a project created through the application was born in a language nobody chose, and the one screen the task names by name was the one that did not ask. + - It is a **form, not a chain of `prompt()` calls**, and two of the reasons are worth keeping: Electron does not implement `window.prompt`, so the chain that stood there answered nothing in a packaged build; and a choice between three named options is not something a text box can offer or a user can guess the spelling of. + - The three languages live in one module both screens import. A list written out twice becomes two answers to one question the moment a fourth language is added, and the values are the `Language` union itself, so a language added to the setting and forgotten in the picker is a compile error. ## 9 — The CLI, MCP and the agent's contract diff --git a/scripts/fetch-ffmpeg.mjs b/scripts/fetch-ffmpeg.mjs index 7d69e07..fe39d3d 100644 --- a/scripts/fetch-ffmpeg.mjs +++ b/scripts/fetch-ffmpeg.mjs @@ -7,12 +7,20 @@ * (`docs/stack.md`). This script is what makes that real: it downloads a pinned * build, refuses it if its SHA256 does not match, and extracts only `ffmpeg.exe`. * - * The URL and the hash are pinned below. Bumping ffmpeg is a deliberate act: - * change both, run the script, commit nothing under `vendor/ffmpeg/` (it is - * gitignored). Both can be overridden from the environment for CI: + * **The hash is supplied, never defaulted.** The URL below is a rolling one — + * `ffmpeg-release-essentials.zip` is whatever gyan.dev published most recently — + * so a hash baked in beside it would be wrong the day upstream releases, and the + * only way to keep it green would be to stop checking. So the expected digest + * comes from the environment, the release workflow reads it from `vars` and + * fails loudly when it is unset, and this script refuses to download anything + * without one: * * FFMPEG_URL=... FFMPEG_SHA256=... node scripts/fetch-ffmpeg.mjs * + * To pin a build rather than track the latest, point `FFMPEG_URL` at a versioned + * package and record its digest alongside — the pair is what makes the pin, and + * either one on its own verifies nothing. + * * Windows-only on purpose — it is the only platform the product supports. */ import { createHash } from "node:crypto"; @@ -24,8 +32,9 @@ import { fileURLToPath } from "node:url"; const here = fileURLToPath(import.meta.url); const repoRoot = resolve(here, "..", ".."); -// Pin both. The essentials build carries the encoders Opus needs; the hash is -// what stops a tampered or half-download from shipping inside the installer. +// The essentials build carries the encoders Opus needs. The digest has no +// default on purpose: it is what stops a tampered or half-download from shipping +// inside the installer, and a default would be a value nobody checked. const FFMPEG_URL = process.env["FFMPEG_URL"] ?? "https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip"; const FFMPEG_SHA256 = process.env["FFMPEG_SHA256"] ?? ""; @@ -54,9 +63,19 @@ async function download(url, dest) { function extract(zip, dest) { // Windows 10+ ships bsdtar, which reads zip. Avoids a JS unzip dependency. - const result = spawnSync("tar", ["-xf", zip, "-C", dest, "--strip-components=1"], { - stdio: "inherit", - }); + // + // **Two components, and only the one member.** The archive is laid out as + // `ffmpeg--essentials_build/bin/ffmpeg.exe`, so stripping one leaf + // leaves `bin/ffmpeg.exe` and the check below fails — which is what happened, + // undetected, because nothing in this repository had ever run this script + // against a real download. Naming the member is also what makes the docstring + // above true: the zip carries ~110 MB of documentation and presets the + // installer has no use for. + const result = spawnSync( + "tar", + ["-xf", zip, "-C", dest, "--strip-components=2", "*/bin/ffmpeg.exe"], + { stdio: "inherit" }, + ); if (result.status !== 0) fail(`tar extraction failed (exit ${result.status})`); if (!existsSync(exePath)) fail(`extraction produced no ${exePath}`); }