{"version":3,"sources":["../src/tasks.ts"],"sourcesContent":["// Task invocation — apps invoking apps (UI_AS_APPS_SPEC §5.7). The\n// `startActivityForResult` pattern: one app invokes another by TASK CONTRACT\n// (never by app name — the user's override picks the bound app), passes typed\n// params, and awaits a typed result. The callee runs in a host-owned overlay\n// under ITS OWN grants — data crosses, your authority does not (§5.7).\n//\n// Two roles:\n//  - CALLER: `invokeTask(task, params)` (Recipe B — a deferred reply the host\n//    holds open until the callee finishes). Delegate a file with `capFile(...)`:\n//    the host resolves it against YOUR grants and mints an attenuated chroot.\n//  - CALLEE: read `useTaskInput()`, then `completeTask(result)` / `cancelTask()`.\n//\n// OFF-HOST (plain `vite dev`, node/jsdom tests — no sandbox, no host transport):\n// importing this module is always safe (R3-421 — no subpath may throw at import\n// time), and the surface degrades instead of crashing: `getTaskInput()` /\n// `useTaskInput()` stay `null` forever (you are never a task callee without a\n// host), `completeTask` / `cancelTask` are no-ops, and `invokeTask` rejects with\n// a \"no host transport\" error. Under the @immediately-run/dev-fs substrate the\n// transport resolves but no task ever arrives — same observable behaviour.\n// The no-op is scoped to the ABSENCE of a host: when there IS one, a failed send\n// throws instead of leaving the caller to hang out its deadline.\nimport { useEffect, useState } from 'react';\nimport { protocolRequest, sendMessage, addListener } from './sandboxUtils';\nimport { throwOnRefusal } from './protocolRefusal';\nimport { transport } from './hostTransport';\nimport { PROTOCOL_TASK, REQUEST_TASK_INPUT, TASK_CANCEL, TASK_COMPLETE, TASK_INPUT } from './generated/protocol';\nimport { SCHEMES } from './protocolSchemes';\n\n// ── caller side ─────────────────────────────────────────────────────────────\n\n/** A delegated FILE capability marker for a task param (§5.7). */\nexport interface FileCap {\n  $cap: 'file';\n  mountId: string;\n  relPath: string;\n  mode: 'ro' | 'rw';\n}\n\n/**\n * Build a delegated file reference for a task param. The host resolves it against\n * YOUR OWN grants and mints an attenuated, task-scoped chroot for the callee — you\n * can only delegate a path you already hold (attenuation only, never escalation).\n *\n *   file: capFile({ mountId: 'space:abc', relPath: 'photos/cat.jpg' }, { mode: 'rw' })\n */\nexport const capFile = (ref: { mountId: string; relPath: string }, opts: { mode: 'ro' | 'rw' }): FileCap => ({\n  $cap: 'file',\n  mountId: ref.mountId,\n  relPath: ref.relPath,\n  mode: opts.mode,\n});\n\n/** A delegated DIRECTORY capability marker for a task param (D2). Like {@link FileCap}\n *  but `relPath` names a DIRECTORY: the host chroots the callee AT that directory\n *  (the whole subtree). Used for the `pick-file` `roots` — one chroot per root. */\nexport interface DirCap {\n  $cap: 'dir';\n  mountId: string;\n  relPath: string;\n  mode: 'ro' | 'rw';\n}\n\n/**\n * Build a delegated DIRECTORY reference for a task param (the directory analogue of\n * {@link capFile}). The host resolves it against YOUR OWN grants and mints an\n * attenuated, task-scoped chroot of that directory for the callee — you can only\n * delegate a directory you already hold (attenuation only, never escalation):\n *\n *   roots: [capDir({ mountId: 'space:abc', relPath: 'boards' }, { mode: 'rw' })]\n */\nexport const capDir = (ref: { mountId: string; relPath: string }, opts: { mode: 'ro' | 'rw' }): DirCap => ({\n  $cap: 'dir',\n  mountId: ref.mountId,\n  relPath: ref.relPath,\n  mode: opts.mode,\n});\n\n/**\n * Invoke another app via a task contract and await its typed result (Recipe B).\n * Rejects with a machine `.code` on refusal: `cancelled` (user dismissed the\n * overlay), `timeout` (§5.7.1 liveness), `forbidden` (missing `task:invoke`, or a\n * file delegation you don't hold), `not-declared` (the task is missing from your\n * `immediately.run.invokes`), `no-such-task` (no app is bound to that contract),\n * `task-cycle`/`task-depth-exceeded`/`task-version-mismatch`, or `invalid-params`\n * (result failed the contract schema). **Read `err.message`** — since R3-418 the host\n * says which capability, which declaration or which param was at fault; the code alone\n * does not distinguish them.\n *\n * Two things must BOTH be true before any invoke is admitted, and they are separate:\n *  1. your `package.json` `immediately.run.invokes` lists the task (else `not-declared`);\n *  2. your app holds the `task:invoke` capability — declare it in\n *     `immediately.run.capabilities` so the user can grant it (else `forbidden`).\n * Declaring the invoke does not imply the capability.\n *\n * ── `pick-file`: `roots` is required (SPACES_UI_SPEC §4.1) ───────────────────\n * ```ts\n * import { invokeTask, capDir, requestMount } from '@immediately-run/sdk';\n *\n * const mount = await requestMount();                    // the user picks a space\n * // `invokeTask` returns `unknown` — name the result type, or the destructuring\n * // below does not compile.\n * const { root, path } = await invokeTask<{ root: number; path: string }>('pick-file', {\n *   // `SandboxMount.id` is optional (absent on the primary repo mount) — fall back to\n *   // `path`, which is always present and is what the host resolves against.\n *   roots: [capDir({ mountId: mount.id ?? mount.path, relPath: 'boards' }, { mode: 'ro' })],\n * });\n * ```\n * - `roots` is a NON-EMPTY `DirCap[]` of directories you **already hold**. The result\n *   names its target as `{ root, path }` where `root` INDEXES `params.roots`, so\n *   `invokeTask('pick-file', {})` can never produce a valid result and is rejected.\n *   There is no rootless \"pick from any of my spaces\" mode by design: enumerating the\n *   user's spaces is the host's powerbox (`requestMount()`), and composing the two —\n *   as above — is that flow. See SPACES_UI_SPEC R-SPACES-5/R-SPACES-12.\n * - Each `capDir` is attenuated against your own grant. **`{ mode: 'rw' }` over a space\n *   the user granted `ro` is refused `forbidden`** — the commonest failure, because the\n *   mount *was* granted and the path *is* inside it. Pass the mode you actually hold;\n *   `ro` is right for a plain pick.\n * - The `requestMount()` grant is DURABLE and survives a cancelled pick: the user\n *   consented to the mount in host chrome, separately from the pick (SPACES_UI_SPEC §4.1).\n *   Reuse it for later picks rather than re-requesting.\n *\n * Off-host (plain `vite dev` — no host transport) it rejects with a plain\n * \"no host transport\" error: there is no host to resolve the task binding.\n */\nexport const invokeTask = async <R = unknown>(task: string, params: Record<string, unknown> = {}): Promise<R> => {\n  const res = await protocolRequest(SCHEMES[PROTOCOL_TASK], 'invoke', [{ task, params }]);\n  // The one refusal unwrap (R6, R3-816): an absent/malformed reply and a coded\n  // refusal both throw, the task named in the fallback message.\n  throwOnRefusal(res, `task '${task}' failed`);\n  return res.data as R;\n};\n\n// ── the host-provided CAPTURE contracts (R3-425) ────────────────────────────\n\n/** Bytes captured by the host, as `capturePhoto()` / `captureAudio()` return them.\n *  `bytes` is a transferable `ArrayBuffer` — write it to a space, decode it, upload\n *  it. You never receive a `MediaStream` or a `MediaStreamTrack`, and that is not an\n *  omission: a track cannot be transferred into an app frame at all\n *  (`DataCloneError`), and the frame's opaque origin cannot open a device itself\n *  (`getUserMedia` throws `SecurityError: Invalid security origin`). */\nexport interface CaptureResult {\n  bytes: ArrayBuffer;\n  /** e.g. `image/jpeg`, `audio/webm`. Chosen by the HOST, not by you. */\n  mimeType: string;\n  /** How long the device was open, in ms. */\n  durationMs: number;\n}\n\n/** Optional hints for a capture. They deliberately name NOTHING — no device id, no\n *  camera index — because the host chooses everything about how the capture is\n *  performed. That is what lets the grant be a plain on/off instead of a per-call\n *  target list the way `net:fetch` needs. */\nexport interface CaptureOptions {\n  /** Which way the camera should face, if the device has a choice. A HINT: the host\n   *  may ignore it. Ignored for audio. */\n  facing?: 'user' | 'environment';\n}\n\n/**\n * Take one photo, or record one audio clip, through the HOST's capture UI\n * (`BROWSER_CAPABILITIES_SPEC` §3 grade 1).\n *\n * The host opens the device at its own origin, draws the viewfinder, and hands you\n * the bytes when the user presses Done. **You are never in the loop while the device\n * is live** — that is the point of the design, not a limitation of it:\n *\n * - The capture UI is drawn by the host, so a user can trust it the way they trust\n *   the sign-in dialog. Do not build a lookalike; imitating host chrome is spoofing.\n * - While the device is open the host shows a persistent \"camera on\" / \"microphone\n *   on\" indicator in its own chrome, for the whole session. You cannot hide it and\n *   should not try to.\n * - If the user cancels, this rejects `cancelled` and **nothing was recorded**. There\n *   is no partial result — do not write error handling that hopes for one.\n *\n * ```ts\n * import { capturePhoto, requestMount, openFs } from '@immediately-run/sdk';\n *\n * try {\n *   const { bytes, mimeType } = await capturePhoto({ facing: 'environment' });\n *   const mount = await requestMount();          // the user picks a space to save into\n *   const fs = openFs(mount);                    // writeFile is a METHOD on the MountFs\n *   await fs.mkdir('photos', { recursive: true }); // writeFile does not create parents\n *   await fs.writeFile(`photos/latest.${mimeType === 'image/png' ? 'png' : 'jpg'}`, new Uint8Array(bytes));\n * } catch (e) {\n *   if ((e as { code?: string }).code === 'cancelled') return; // the user said no\n *   throw e;\n * }\n * ```\n * (The extension follows `mimeType` because the HOST chooses the format — do not assume\n * JPEG. `openAppFs()` is the variant that writes to your own app space with no pick.)\n *\n * Two things must BOTH be true, exactly as for any other task:\n *  1. `immediately.run.invokes` lists `capture-photo` (or `capture-audio`);\n *  2. your app holds `task:invoke` **and** `device:camera` (or `device:microphone`)\n *     — declare them in `immediately.run.capabilities` so the user can grant them.\n *\n * Rejects with `.code`: `cancelled` (the user dismissed the capture — nothing was\n * recorded), `forbidden` (you lack the capability; `message: 'browser-denied'` means\n * the BROWSER refused immediately.run itself, which your consent cannot fix),\n * `unavailable` (no such device on this machine, or it would not start),\n * `unsupported` (this host cannot capture), `not-declared`, `timeout`.\n *\n * *Zero-platform alternative, still worth knowing:* `<input type=\"file\"\n * accept=\"image/*\" capture=\"environment\">` opens the OS camera from inside the\n * sandbox (file choosers are not permission-gated). It needs no capability and no\n * host support; it also gives you no indicator and no host-drawn surface.\n */\nexport const capturePhoto = (options: CaptureOptions = {}): Promise<CaptureResult> =>\n  invokeTask<CaptureResult>('capture-photo', { ...options });\n\n/** Record one audio clip through the host's capture UI. See {@link capturePhoto} —\n *  every rule there applies verbatim, with `device:microphone` and `capture-audio`. */\nexport const captureAudio = (options: CaptureOptions = {}): Promise<CaptureResult> =>\n  invokeTask<CaptureResult>('capture-audio', { ...options });\n\n// ── callee side ─────────────────────────────────────────────────────────────\n\n/** The params this app was invoked with as a task callee. */\nexport interface TaskInput {\n  task: string;\n  params: Record<string, unknown>;\n}\n\nlet latestInput: TaskInput | null = null;\nconst inputListeners = new Set<(i: TaskInput) => void>();\nlet inputListenerRegistered = false;\n\n/**\n * Whether two deliveries carry the same input, compared by their JSON image. The host does\n * not check that params are JSON; the hosts' senders build them from strings and plain\n * objects, for which the image is faithful. A value JSON cannot serialise at all (it\n * throws) is treated as a change; a value JSON writes lossily (a `Map`, a `Blob`) is not\n * told apart, so two inputs differing only there compare equal.\n */\nconst sameTaskInput = (a: TaskInput, b: TaskInput): boolean => {\n  if (a.task !== b.task) return false;\n  try {\n    return JSON.stringify(a.params) === JSON.stringify(b.params);\n  } catch {\n    return false;\n  }\n};\n\n// Register the `task-input` listener IF a host transport is reachable; otherwise do\n// nothing (and let the next call retry — the same pattern as pushChannel's `start`).\n// Split out so both the module-eval attempt below and the first-use call sites share\n// one idempotent path.\n//\n// Registration then pulls the input once with `request-task-input`, the way `mounts`\n// pulls with `request-mounts`: the host answers a callee frame with its current\n// `task-input` and answers nothing to any other frame. So the input arrives whenever\n// this listener comes to exist, and a host send that landed before it is not lost.\n//\n// The poll has its own latch, set only once a send returns. The listener registering\n// proves there is a host, so a send that then throws is a real failure, not the off-host\n// case: it is reported once and tried again on the next read, because a poll that never\n// goes out leaves a callee looking exactly like an app that is not one.\nlet inputPolled = false;\nlet inputPollFailureReported = false;\nconst ensureInputListener = (): void => {\n  if (!inputListenerRegistered) {\n    try {\n      addListener(TASK_INPUT, (m: { task: string; params?: Record<string, unknown> }) => {\n        const next: TaskInput = { task: m.task, params: m.params ?? {} };\n        // The host sends the same input several times (at mount, past each compile, and\n        // in answer to the poll). A repeat keeps the object the app already has, so an\n        // effect keyed on the input does not run again for a value that did not change.\n        if (latestInput && sameTaskInput(latestInput, next)) return;\n        latestInput = next;\n        inputListeners.forEach((l) => l(next));\n      });\n    } catch {\n      return; // off-host: the transport resolver threw — no host to listen on\n    }\n    inputListenerRegistered = true;\n  }\n  if (inputPolled) return;\n  try {\n    sendMessage(REQUEST_TASK_INPUT);\n    inputPolled = true;\n  } catch (err) {\n    if (!inputPollFailureReported) {\n      inputPollFailureReported = true;\n      console.warn('[immediately.run] the task-input poll could not be sent; it is retried on the next read.', err);\n    }\n  }\n};\n\n// Register eagerly when a host transport is already present at module evaluation, and\n// lazily on first use otherwise, so importing this module off-host (plain `vite dev`)\n// never throws. Either way the registration polls, so against a host that answers the\n// poll its timing no longer decides whether the input is seen; against an older host the\n// eager registration is still what catches the send made at mount.\nensureInputListener();\n\n/** The task params this app was invoked with, or null if it isn't a task callee.\n *  Off-host (plain `vite dev`) this is always `null` — there is no host to invoke\n *  this app as a callee. The object changes only when the task or its params change:\n *  the host may deliver the same input more than once, and a repeat returns the same\n *  object. */\nexport const getTaskInput = (): TaskInput | null => {\n  ensureInputListener();\n  return latestInput;\n};\n\n/**\n * Is a host transport reachable from this realm at all?\n *\n * The R3-421 off-host no-op below must mean EXACTLY \"there is nobody to answer\" —\n * plain `vite dev`, node, jsdom. A blanket try/catch around the send meant it also\n * swallowed a genuine failure against a REAL host (`DataCloneError` because the\n * result holds a DOM node, a function or a live class instance, being the usual\n * one): the host was then never told the task finished, and the caller's\n * `invokeTask` hung to its deadline with no diagnostic anywhere. Absence is\n * therefore decided BEFORE the send, and the send itself is left to throw.\n */\nconst hostReachable = (): boolean => {\n  try {\n    transport();\n    return true;\n  } catch {\n    return false; // no injected bundler bus and no §4 discovery global — off-host\n  }\n};\n\n/**\n * Finish the task, returning a result to the caller. The host validates it against\n * the contract's result schema before resolving the caller (`invalid-params` on\n * violation), then tears down this overlay.\n *\n * `result` must be STRUCTURED-CLONEABLE — it crosses a frame boundary. A DOM node, a\n * function, a class instance with methods, a `MediaStreamTrack`: all `DataCloneError`.\n * **That throws**, deliberately and loudly: the alternative is a caller left hanging to\n * its `invokeTask` deadline while this app believes it answered.\n *\n * Off-host (plain `vite dev` — no host transport) this is a no-op: there is no caller\n * to answer. That case, and only that case, is silent.\n */\nexport const completeTask = (result: unknown): void => {\n  if (!hostReachable()) return; // off-host: no caller to answer — documented no-op\n  // On-host, a failure here is REAL and the caller is waiting on this exact message.\n  sendMessage(TASK_COMPLETE, { result });\n};\n\n/** Abort the task; the caller's `invokeTask` rejects with `cancelled`.\n *  Off-host (plain `vite dev` — no host transport) this is a no-op; on-host a failed\n *  send throws rather than leaving the caller waiting, exactly as {@link completeTask}. */\nexport const cancelTask = (): void => {\n  if (!hostReachable()) return; // off-host: no caller to answer — documented no-op\n  sendMessage(TASK_CANCEL, {});\n};\n\n/** React hook: the task input for this callee, re-rendering when it arrives.\n *  Off-host (plain `vite dev`) it stays `null` forever — render the non-callee\n *  state rather than waiting on it. A repeated delivery of the same input is not\n *  announced: the value's identity changes only when the task or its params do. */\nexport const useTaskInput = (): TaskInput | null => {\n  const [input, setInput] = useState<TaskInput | null>(getTaskInput);\n  useEffect(() => {\n    const l = (i: TaskInput) => setInput(i);\n    inputListeners.add(l);\n    if (latestInput) setInput(latestInput);\n    return () => {\n      inputListeners.delete(l);\n    };\n  }, []);\n  return input;\n};\n"],"mappings":"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","names":[]}