{"version":3,"sources":["../src/launch.ts"],"sourcesContent":["// Launch — the TO-RUN twin of `invokeTask` (STANDING_APP_LIFECYCLE_SPEC §1–§6).\n// Where `invokeTask` is FOR-RESULT (await one value, callee exits), `launch` is\n// TO-RUN (non-blocking, standing): it starts a bound program RUNNING in a region\n// and hands back a HANDLE, never a typed value (R-SAL-1). The launched app earns\n// its OWN grants — the only launcher→launched data path is the explicitly\n// delegated `capDir`/`capFile` in `input` (§5), attenuated + tainted + gated host-\n// side. The host owns the whole lifecycle (visibility, budget, revocation,\n// teardown); the launcher only OBSERVES via the handle.\n//\n// A caller that wants a typed value back uses `invokeTask` instead — the two are\n// siblings, not a replacement.\nimport { protocolRequest, sendMessage, addListener } from './sandboxUtils';\nimport { LAUNCH_DISMISS, LAUNCH_ENDED, PROTOCOL_LAUNCH } from './generated/protocol';\nimport { SCHEMES } from './protocolSchemes';\n\n/** Where a launched program runs (§6). `overlay` covers the caller's own region\n *  with opaque host chrome; `stage` replaces the focal app (the elevated into-\n *  stage surface, §7 — refused above the stage-principal ceiling). */\nexport type LaunchRegion = 'overlay' | 'stage';\n\n/**\n * What to launch (§3) — binding-resolved, NEVER a caller-named app. Exactly one of:\n *  - `entryPoint`: a sibling entry point of the caller's OWN repo (the mini-app\n *    case, `AGENT_AUTHORING §5`) — **rejected `forbidden` until program-identity\n *    `appKey` lands** (R-SAL-2a);\n *  - `task`: a task contract (`open-project`, …), resolved through the user-\n *    overridable `task.<name>` binding to whichever app the user bound (§3 kind 2).\n */\nexport interface LaunchTarget {\n  /** Kind 1 — a sibling entry point of the caller's own repo (mini-app overlay). */\n  entryPoint?: string;\n  /** Kind 2 — a task contract name (the host resolves the bound provider). */\n  task?: string;\n  /** Optional accepted contract version for a `task` target (semver, e.g. `^1`). */\n  version?: string;\n}\n\nexport interface LaunchOptions {\n  /** Where it runs (§6). */\n  region: LaunchRegion;\n  /**\n   * Delegations + plain data handed to the launched app. `$cap:'dir'|'file'`\n   * markers (`capDir`/`capFile`, exported from `./tasks`) are resolved against the\n   * launcher's OWN grants and minted as attenuated, `ro`-by-default chroots (§5) —\n   * you can only delegate what you already hold. Everything else is plain data.\n   */\n  input?: Record<string, unknown>;\n}\n\n/** The live state of a launch (§2). Terminal states (`dismissed`/`revoked`/\n *  `failed`) are reached identically for self-exit, user dismiss, and host revoke —\n *  the host debounces so the launcher gets no timing oracle (R-SAL-1 / §6.4). */\nexport type LaunchStatus = 'running' | 'dismissed' | 'revoked' | 'failed';\n\n/** Machine codes a refused `launch` resolves with (§8). */\nexport type LaunchErrorCode =\n  | 'forbidden' // undeclared target; sibling (pre-AA-01); self-resolving target; stage cap absent\n  | 'unsupported' // no provider bound / entry point absent / unknown contract version\n  | 'budget' // over an R-SAL-8 concurrency/rate limit\n  | 'revoked' // a delegated mount was revoked during the create-and-bind window\n  | 'cancelled' // the user dismissed the host launch affordance (into-stage only)\n  | 'invalid-params' // a malformed `capDir`/`capFile` (`..`/absolute/mode-escalation)\n  | 'unknown';\n\n/**\n * The control channel back to a launch — the ONLY thing a launcher gets (§2).\n * There is no typed return value (R-SAL-1); `status`/`onDismiss` are debounced so\n * they cannot time-distinguish a self-exit from a user dismiss.\n */\nexport interface LaunchHandle {\n  /** Host-assigned id for this launch. */\n  readonly launchId: string;\n  /** The current lifecycle state (§2). */\n  readonly status: LaunchStatus;\n  /** Ask the host to tear this launch down. Idempotent (double-dismiss is a no-op). */\n  dismiss(): void;\n  /**\n   * Observe the launch ending (self-exit / user dismiss / host revoke — fired\n   * identically, R-SAL-1). Returns an unsubscribe fn. Fires at most once; if the\n   * launch has already ended it fires on the next tick.\n   */\n  onDismiss(cb: () => void): () => void;\n}\n\ninterface LaunchEndedMessage {\n  launchId: string;\n  status: Exclude<LaunchStatus, 'running'>;\n}\n\n/** Live handles awaiting their terminal `launch-ended` message, keyed by launchId. */\nconst liveHandles = new Map<string, LaunchHandleImpl>();\n\n/**\n * Terminal statuses that arrived BEFORE their handle existed, keyed by launchId.\n *\n * The window is real: `launch()` registers the listener before it sends `create`,\n * but the handle only enters `liveHandles` once the create RESPONSE resolves. A\n * short-lived launch (or a revoke during binding) can end inside that gap, and a\n * dropped terminal message leaves the handle `running` for ever with `onDismiss`\n * never firing — the one failure a launcher cannot detect or recover from. So the\n * message is BUFFERED and applied when the handle lands.\n *\n * Bounded, because the host is not this module's to trust: entries are only kept\n * while a create is actually in flight, and no more than {@link MAX_PENDING_ENDED}\n * of them (oldest evicted). A `launch-ended` for an id we never created is still\n * ignored, exactly as before.\n */\nconst pendingEnded = new Map<string, Exclude<LaunchStatus, 'running'>>();\nconst MAX_PENDING_ENDED = 16;\n/** How many `launch()` calls are between their create request and its response. */\nlet createsInFlight = 0;\n\n// The host delivers ONE `launch-ended` message per launch when it tears down —\n// the SAME message shape for self-exit, dismiss, and revoke (the host debounces\n// so the timing is not an oracle, §6.4). We fan it out to the matching handle, or\n// buffer it for a handle that has not landed yet (above).\n//\n// Registered LAZILY, on the first `launch()` call, not at module evaluation\n// (R3-421 — no subpath may throw at import time off-host). First-use registration\n// loses nothing here: a `launch-ended` can only\n// ever follow a launch THIS module created, and `launch()` registers the listener\n// before it sends the create request — so the listener always exists before any\n// launchId it must match. That is a claim about the LISTENER only; the handle it\n// must reach can still be a response away, which is what `pendingEnded` covers.\nlet endedListenerRegistered = false;\nconst ensureEndedListener = (): void => {\n  if (endedListenerRegistered) return;\n  try {\n    addListener(LAUNCH_ENDED, (m: LaunchEndedMessage) => {\n      const h = liveHandles.get(m.launchId);\n      if (h) {\n        h._end(m.status);\n        return;\n      }\n      // No handle yet. Buffer only while a create could still produce one; anything\n      // else is a stale or unknown id and is ignored.\n      if (createsInFlight === 0) return;\n      if (pendingEnded.size >= MAX_PENDING_ENDED) {\n        const oldest = pendingEnded.keys().next();\n        if (!oldest.done) pendingEnded.delete(oldest.value);\n      }\n      pendingEnded.set(m.launchId, m.status);\n    });\n  } catch {\n    return; // off-host: no transport — the create request below will fail anyway\n  }\n  endedListenerRegistered = true;\n};\n\nclass LaunchHandleImpl implements LaunchHandle {\n  #status: LaunchStatus = 'running';\n  #dismissListeners = new Set<() => void>();\n  #ended = false;\n\n  constructor(readonly launchId: string) {\n    liveHandles.set(launchId, this);\n    // A terminal message that beat this handle into existence (see `pendingEnded`):\n    // apply it now, so the handle is born ended rather than stuck `running`.\n    const early = pendingEnded.get(launchId);\n    if (early !== undefined) {\n      pendingEnded.delete(launchId);\n      this._end(early);\n    }\n  }\n\n  get status(): LaunchStatus {\n    return this.#status;\n  }\n\n  dismiss(): void {\n    if (this.#ended) return;\n    // Fire-and-forget: the host owns teardown and answers with `launch-ended`,\n    // which drives `_end` (so status/onDismiss are host-authoritative, never\n    // optimistically local — a dismiss the host refuses would otherwise desync).\n    sendMessage(LAUNCH_DISMISS, { launchId: this.launchId });\n  }\n\n  onDismiss(cb: () => void): () => void {\n    if (this.#ended) {\n      // Already ended: fire on the next tick so the contract (\"returns an\n      // unsubscribe\") holds and the callback never runs synchronously mid-register.\n      queueMicrotask(cb);\n      return () => {};\n    }\n    this.#dismissListeners.add(cb);\n    return () => {\n      this.#dismissListeners.delete(cb);\n    };\n  }\n\n  /** Host-driven terminal transition — the only writer of `status`. Idempotent. */\n  _end(status: Exclude<LaunchStatus, 'running'>): void {\n    if (this.#ended) return;\n    this.#ended = true;\n    this.#status = status;\n    liveHandles.delete(this.launchId);\n    const listeners = [...this.#dismissListeners];\n    this.#dismissListeners.clear();\n    for (const l of listeners) {\n      try {\n        l();\n      } catch {\n        /* a launcher's own callback must never wedge teardown */\n      }\n    }\n  }\n}\n\n/**\n * Launch a bound program to RUN in a region (§2). Non-blocking: resolves once the\n * frame is created and bound, with a {@link LaunchHandle} — or a typed\n * `{ ok:false, code }` on refusal (§8), NEVER a throw for an ordinary refusal (so\n * a launcher branches on `code` without a try/catch). The launched app runs under\n * its OWN grants; the launcher's authority does not flow to it (R-SAL-4).\n *\n *   const h = await launch({ task: 'open-project' }, {\n *     region: 'stage',\n *     input: { dir: capDir({ mountId: 'space:abc', relPath: 'proj' }, { mode: 'ro' }) },\n *   });\n *   if ('ok' in h && h.ok === false) { ...handle h.code... }\n *   else { h.onDismiss(() => ...); }\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 run a launch in.\n */\nexport const launch = async (\n  target: LaunchTarget,\n  opts: LaunchOptions,\n): Promise<LaunchHandle | { ok: false; code: LaunchErrorCode }> => {\n  // Before the create request, so the terminal message can never beat the listener.\n  ensureEndedListener();\n  // The host wraps a successful handler return as `{ ok:true, data }` (the same\n  // Recipe-B framing `invokeTask` uses); a refusal is `{ ok:false, code }`.\n  createsInFlight += 1;\n  let res: { ok: true; data: { launchId: string } } | { ok: false; code?: LaunchErrorCode } | undefined;\n  try {\n    res = (await protocolRequest(SCHEMES[PROTOCOL_LAUNCH], 'create', [{ target, opts }])) as typeof res;\n  } finally {\n    createsInFlight -= 1;\n  }\n  // Once nothing is in flight, anything still buffered belongs to a launch that will\n  // never produce a handle — drop it rather than keep it for ever. Done AFTER the\n  // handle below is constructed (its constructor drains its own entry first), so the\n  // sweep can never eat the message this very call was waiting for.\n  const sweep = (): void => {\n    if (createsInFlight === 0) pendingEnded.clear();\n  };\n  if (!res || res.ok !== true || !res.data?.launchId) {\n    sweep();\n    const code = res && res.ok === false ? res.code ?? 'unknown' : 'unknown';\n    return { ok: false, code };\n  }\n  // The constructor drains a terminal message that arrived while `create` was in\n  // flight, so this handle can be returned already-ended rather than stuck `running`.\n  const handle = new LaunchHandleImpl(res.data.launchId);\n  sweep();\n  return handle;\n};\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAWA,0BAA0D;AAC1D,sBAA8D;AAC9D,6BAAwB;AAbxB;AA0FA,MAAM,cAAc,oBAAI,IAA8B;AAiBtD,MAAM,eAAe,oBAAI,IAA8C;AACvE,MAAM,oBAAoB;AAE1B,IAAI,kBAAkB;AActB,IAAI,0BAA0B;AAC9B,MAAM,sBAAsB,MAAY;AACtC,MAAI,wBAAyB;AAC7B,MAAI;AACF,yCAAY,8BAAc,CAAC,MAA0B;AACnD,YAAM,IAAI,YAAY,IAAI,EAAE,QAAQ;AACpC,UAAI,GAAG;AACL,UAAE,KAAK,EAAE,MAAM;AACf;AAAA,MACF;AAGA,UAAI,oBAAoB,EAAG;AAC3B,UAAI,aAAa,QAAQ,mBAAmB;AAC1C,cAAM,SAAS,aAAa,KAAK,EAAE,KAAK;AACxC,YAAI,CAAC,OAAO,KAAM,cAAa,OAAO,OAAO,KAAK;AAAA,MACpD;AACA,mBAAa,IAAI,EAAE,UAAU,EAAE,MAAM;AAAA,IACvC,CAAC;AAAA,EACH,QAAQ;AACN;AAAA,EACF;AACA,4BAA0B;AAC5B;AAEA,MAAM,iBAAyC;AAAA,EAK7C,YAAqB,UAAkB;AAAlB;AAJrB,gCAAwB;AACxB,0CAAoB,oBAAI,IAAgB;AACxC,+BAAS;AAGP,gBAAY,IAAI,UAAU,IAAI;AAG9B,UAAM,QAAQ,aAAa,IAAI,QAAQ;AACvC,QAAI,UAAU,QAAW;AACvB,mBAAa,OAAO,QAAQ;AAC5B,WAAK,KAAK,KAAK;AAAA,IACjB;AAAA,EACF;AAAA,EAEA,IAAI,SAAuB;AACzB,WAAO,mBAAK;AAAA,EACd;AAAA,EAEA,UAAgB;AACd,QAAI,mBAAK,QAAQ;AAIjB,yCAAY,gCAAgB,EAAE,UAAU,KAAK,SAAS,CAAC;AAAA,EACzD;AAAA,EAEA,UAAU,IAA4B;AACpC,QAAI,mBAAK,SAAQ;AAGf,qBAAe,EAAE;AACjB,aAAO,MAAM;AAAA,MAAC;AAAA,IAChB;AACA,uBAAK,mBAAkB,IAAI,EAAE;AAC7B,WAAO,MAAM;AACX,yBAAK,mBAAkB,OAAO,EAAE;AAAA,IAClC;AAAA,EACF;AAAA;AAAA,EAGA,KAAK,QAAgD;AACnD,QAAI,mBAAK,QAAQ;AACjB,uBAAK,QAAS;AACd,uBAAK,SAAU;AACf,gBAAY,OAAO,KAAK,QAAQ;AAChC,UAAM,YAAY,CAAC,GAAG,mBAAK,kBAAiB;AAC5C,uBAAK,mBAAkB,MAAM;AAC7B,eAAW,KAAK,WAAW;AACzB,UAAI;AACF,UAAE;AAAA,MACJ,QAAQ;AAAA,MAER;AAAA,IACF;AAAA,EACF;AACF;AAxDE;AACA;AACA;AAyEK,MAAM,SAAS,OACpB,QACA,SACiE;AAEjE,sBAAoB;AAGpB,qBAAmB;AACnB,MAAI;AACJ,MAAI;AACF,UAAO,UAAM,qCAAgB,+BAAQ,+BAAe,GAAG,UAAU,CAAC,EAAE,QAAQ,KAAK,CAAC,CAAC;AAAA,EACrF,UAAE;AACA,uBAAmB;AAAA,EACrB;AAKA,QAAM,QAAQ,MAAY;AACxB,QAAI,oBAAoB,EAAG,cAAa,MAAM;AAAA,EAChD;AACA,MAAI,CAAC,OAAO,IAAI,OAAO,QAAQ,CAAC,IAAI,MAAM,UAAU;AAClD,UAAM;AACN,UAAM,OAAO,OAAO,IAAI,OAAO,QAAQ,IAAI,QAAQ,YAAY;AAC/D,WAAO,EAAE,IAAI,OAAO,KAAK;AAAA,EAC3B;AAGA,QAAM,SAAS,IAAI,iBAAiB,IAAI,KAAK,QAAQ;AACrD,QAAM;AACN,SAAO;AACT;","names":[]}