@mulmoclaude/core 3.0.0 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/helps/collection-skills.md +72 -8
- package/assets/helps/custom-view.md +9 -1
- package/assets/helps/error-recovery.md +36 -0
- package/assets/helps/feeds.md +6 -4
- package/dist/calendarGrid-Csy2rpjp.js.map +1 -1
- package/dist/calendarGrid-p8K-7cuB.cjs.map +1 -1
- package/dist/collection/core/presentCollection.d.ts +49 -0
- package/dist/collection/core/schema.d.ts +16 -1
- package/dist/collection/index.cjs +56 -7
- package/dist/collection/index.cjs.map +1 -1
- package/dist/collection/index.js +53 -8
- package/dist/collection/index.js.map +1 -1
- package/dist/collection/registry/server/importWriter.d.ts +2 -1
- package/dist/collection/registry/server/index.cjs +31 -10
- package/dist/collection/registry/server/index.cjs.map +1 -1
- package/dist/collection/registry/server/index.js +30 -9
- package/dist/collection/registry/server/index.js.map +1 -1
- package/dist/collection/server/host.d.ts +34 -4
- package/dist/collection/server/index.cjs +4 -2
- package/dist/collection/server/index.d.ts +1 -1
- package/dist/collection/server/index.js +4 -3
- package/dist/collection/server/manageTool.d.ts +19 -0
- package/dist/collection/server/schemaDocs.d.ts +5 -1
- package/dist/collection-watchers/config.d.ts +15 -2
- package/dist/collection-watchers/index.cjs +310 -198
- package/dist/collection-watchers/index.cjs.map +1 -1
- package/dist/collection-watchers/index.js +310 -198
- package/dist/collection-watchers/index.js.map +1 -1
- package/dist/collection-watchers/reconciler.d.ts +1 -5
- package/dist/collection-watchers/watcher.d.ts +31 -26
- package/dist/{discovery-4Z-ZWFam.js → discovery-CC7kuwHf.js} +21 -7
- package/dist/discovery-CC7kuwHf.js.map +1 -0
- package/dist/{discovery-CtiZMlsX.cjs → discovery-CDOjfctD.cjs} +33 -7
- package/dist/discovery-CDOjfctD.cjs.map +1 -0
- package/dist/feeds/server/index.cjs +26 -6
- package/dist/feeds/server/index.cjs.map +1 -1
- package/dist/feeds/server/index.d.ts +1 -1
- package/dist/feeds/server/index.js +26 -7
- package/dist/feeds/server/index.js.map +1 -1
- package/dist/feeds/server/scheduledRefresh.d.ts +16 -1
- package/dist/files/index.cjs +6 -5
- package/dist/files/index.cjs.map +1 -1
- package/dist/files/index.d.ts +1 -0
- package/dist/files/index.js +2 -2
- package/dist/files/root.d.ts +15 -0
- package/dist/google/index.cjs +3 -3
- package/dist/google/index.cjs.map +1 -1
- package/dist/google/index.js +2 -2
- package/dist/remote-host/index.cjs +3 -1
- package/dist/remote-host/index.d.ts +30 -0
- package/dist/remote-host/index.js +2 -2
- package/dist/remote-host/server/index.cjs +38 -14
- package/dist/remote-host/server/index.cjs.map +1 -1
- package/dist/remote-host/server/index.js +38 -14
- package/dist/remote-host/server/index.js.map +1 -1
- package/dist/remote-host/server/monotonicClock.d.ts +3 -0
- package/dist/remote-host/server/presenceBeat.d.ts +3 -2
- package/dist/remote-host/server/resilientRunner.d.ts +6 -0
- package/dist/{remote-host-Dba4lF3Z.js → remote-host-CFnl1I-L.js} +35 -2
- package/dist/{remote-host-Dba4lF3Z.js.map → remote-host-CFnl1I-L.js.map} +1 -1
- package/dist/{remote-host-CtQjagPt.cjs → remote-host-EQPlccB9.cjs} +46 -1
- package/dist/{remote-host-CtQjagPt.cjs.map → remote-host-EQPlccB9.cjs.map} +1 -1
- package/dist/{atomic-DPpdrJzO.js → root-BMroU_mB.js} +21 -2
- package/dist/root-BMroU_mB.js.map +1 -0
- package/dist/{atomic-DhUk8uiM.cjs → root-rPH6FGDT.cjs} +26 -1
- package/dist/root-rPH6FGDT.cjs.map +1 -0
- package/dist/{server-CedIocRS.js → server-AA2kfDfk.js} +137 -42
- package/dist/server-AA2kfDfk.js.map +1 -0
- package/dist/{server-DOZFzx5l.cjs → server-WU35UYlF.cjs} +138 -43
- package/dist/server-WU35UYlF.cjs.map +1 -0
- package/package.json +1 -1
- package/dist/atomic-DPpdrJzO.js.map +0 -1
- package/dist/atomic-DhUk8uiM.cjs.map +0 -1
- package/dist/discovery-4Z-ZWFam.js.map +0 -1
- package/dist/discovery-CtiZMlsX.cjs.map +0 -1
- package/dist/server-CedIocRS.js.map +0 -1
- package/dist/server-DOZFzx5l.cjs.map +0 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"remote-host-Dba4lF3Z.js","names":[],"sources":["../src/remote-host/health.ts","../src/remote-host/index.ts"],"sourcesContent":["// Health of the remote-host command channel, as reported by the resilient runner\n// and rendered by a host's toolbar control. Browser-safe on purpose: the client\n// narrows the parsed HTTP payload with the same guard the server writes it from,\n// so the two sides cannot drift on the state names.\n//\n// online — the Firestore subscription is up; the phone can reach this host\n// reconnecting — it died and is being re-subscribed with backoff (self-healing)\n// offline — re-subscribing stopped helping, or nothing is connected at all;\n// recovering needs a re-auth from the browser's parked session\n//\n// Deliberately no UI wording here — how a state reads to a user is each host's\n// i18n, and core owning it would make the shared package a translation authority.\nexport const RUNNER_HEALTH_STATES = [\"online\", \"reconnecting\", \"offline\"] as const;\nexport type RunnerHealthState = (typeof RUNNER_HEALTH_STATES)[number];\n\nexport interface RunnerHealth {\n state: RunnerHealthState;\n /** Last channel error seen, for the popover and the log. Null before the first one. */\n lastError: string | null;\n /** ms epoch of the last state change, so the UI can say how long it has been down. */\n changedAt: number;\n}\n\nconst isRecord = (value: unknown): value is Record<string, unknown> => typeof value === \"object\" && value !== null;\n\nexport const isRunnerHealthState = (value: unknown): value is RunnerHealthState => RUNNER_HEALTH_STATES.some((state) => state === value);\n\n/** Narrows a parsed HTTP payload. The client renders whatever this accepts, so a\n * half-shaped health has to read as \"no health reported\" rather than as a state. */\nexport const isRunnerHealth = (value: unknown): value is RunnerHealth =>\n isRecord(value) &&\n isRunnerHealthState(value.state) &&\n (value.lastError === null || typeof value.lastError === \"string\") &&\n typeof value.changedAt === \"number\";\n","// Remote-host command-channel protocol — the browser-safe contract shared by a\n// host (MulmoClaude, MulmoTerminal) and the remote/mobile client (mulmoserver).\n//\n// A host signs in to Firebase as the user, listens to that user's per-host\n// command queue in Firestore, runs a handler, and writes the result back; the\n// remote writes commands and reads results via a real-time listener. This module\n// owns the wire types + the Firestore path helpers. It is the single source of\n// truth so the host runner and the client never drift on the protocol.\n//\n// Ported from ../mulmoserver/src/firestore/commandChannel.ts and the per-host\n// copy that lived in MulmoClaude's server/remoteHost/. The one change vs. those\n// copies: the path helpers take the `firestore` instance as a parameter (rather\n// than importing a module-level singleton) so a single extracted module serves\n// every host's own Firebase init. The hostId is host-specific (\"mulmoclaude\",\n// \"mulmoterminal\") and is supplied by each host — there is no discovery.\nimport { CollectionReference, DocumentData, DocumentReference, Firestore, collection, doc } from \"firebase/firestore\";\nimport { isRecord } from \"@mulmoclaude/common\";\n\n// JSON payloads carried by the command channel. Explicit JSON types keep the\n// channel typed without resorting to any/unknown.\nexport type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };\nexport type JsonObject = Record<string, JsonValue>;\n\n/** Structural JSON view of `T`, recursively.\n *\n * TypeScript gives an implicit index signature to type aliases and mapped\n * types but NOT to interfaces, so a payload assembled from domain interfaces\n * (`Shortcut`, `FeedSummary`, …) cannot satisfy `Record<string, JsonValue>`\n * structurally — even though it is plain JSON at runtime. Mapping over `T`\n * reconstructs it as an anonymous type, which does get that index signature.\n *\n * Recursive on purpose: a top-level-only map would still leave nested\n * interfaces (`{ shortcuts: Shortcut[] }`) unassignable, which is the case\n * every handler here actually has. */\n// The function branch must come BEFORE the object branch: a function IS an\n// object to TypeScript, so without it a function maps to `{}` and sails\n// through — the helper would accept a payload that serialises to nothing.\n// Verified: `toJsonObject({ callback: () => undefined })` compiled clean until\n// this branch existed (CodeRabbit, #2596).\nexport type Jsonify<T> = T extends JsonValue\n ? T\n : T extends (...args: never[]) => unknown\n ? never\n : T extends (infer U)[]\n ? Jsonify<U>[]\n : T extends object\n ? { [K in keyof T]: Jsonify<T[K]> }\n : never;\n\n/** Widen a JSON-shaped handler payload to the channel's `JsonObject`.\n *\n * Exists so the `Jsonify` reasoning above lives in ONE place. Before this,\n * eight remote-host handlers each carried their own `as unknown as JsonObject`\n * with the justification re-argued in eight slightly different comments —\n * which is how a rule stops being reviewable. */\nexport const toJsonObject = <T extends object>(payload: Jsonify<T>): JsonObject => payload as JsonObject;\n\nconst describeNonJson = (value: unknown): string => {\n if (typeof value === \"number\") return String(value);\n if (typeof value === \"object\") return \"a non-plain object\";\n return `a ${typeof value}`;\n};\n\n/** Anything carrying its own JSON form — `Date` above all — must be asked for\n * it rather than walked, because walking a `Date`'s own enumerable keys finds\n * none and flattens the timestamp to `{}`. This is the step `JSON.stringify`\n * performs before it recurses, and the channel used to get it for free. */\nconst hasToJson = (value: object): value is { toJSON: () => unknown } => \"toJSON\" in value && typeof value.toJSON === \"function\";\n\nconst jsonRepresentationOf = (value: object): unknown => (hasToJson(value) ? value.toJSON() : value);\n\n/** Rebuild `value` as JSON, or throw naming the property that cannot be. */\nfunction toJsonValue(value: unknown, path: string): JsonValue {\n if (Array.isArray(value)) return toJsonItems(value, path);\n if (isRecord(value)) {\n const represented = jsonRepresentationOf(value);\n if (represented !== value) return toJsonValue(represented, path);\n return toJsonEntries(value, path);\n }\n return toJsonScalar(value, path);\n}\n\n/** JSON's four scalar forms. Anything else — a function, a class instance, a\n * non-finite number — is what the channel cannot carry. */\nfunction toJsonScalar(value: unknown, path: string): JsonValue {\n if (value === null || typeof value === \"string\" || typeof value === \"boolean\") return value;\n if (typeof value === \"number\" && Number.isFinite(value)) return value;\n throw new Error(`${path} is ${describeNonJson(value)}, which JSON cannot represent`);\n}\n\nfunction toJsonItems(items: unknown[], path: string): JsonValue[] {\n // An absent element becomes `null`, matching `JSON.stringify` — an array has\n // to keep its length, so a hole cannot simply be dropped the way a key is.\n // `Array.from` rather than `map`, which SKIPS holes and would leave them in\n // the result: `JSON.stringify` renders a hole as null and hides that, but\n // `1 in arr` / `Object.keys` / `forEach` all still see the gap.\n return Array.from(items, (entry, index) => (entry === undefined ? null : toJsonValue(entry, `${path}[${index}]`)));\n}\n\nfunction toJsonEntries(record: Record<string, unknown>, path: string): JsonObject {\n const usable = Object.entries(record).filter(([, value]) => value !== undefined);\n return Object.fromEntries(usable.map(([key, value]) => [key, toJsonValue(value, `${path}.${key}`)]));\n}\n\n/** Runtime counterpart to `toJsonObject`, for payloads whose values are typed\n * `unknown` — a collection record, a projected view row — so no amount of\n * mapped-type work can PROVE them JSON.\n *\n * Walks the payload and rebuilds it from the values it actually inspected, so\n * the returned `JsonObject` is earned rather than asserted. Absent (`undefined`)\n * properties are dropped exactly as `JSON.stringify` drops them; anything the\n * channel could not carry — a function, a class instance, `NaN` — throws\n * naming its path, instead of reaching Firestore as a silently mangled write. */\nexport const coerceJsonObject = (payload: Record<string, unknown>): JsonObject => toJsonEntries(payload, \"payload\");\n\n// A channel routes commands to one specific host. Both sides agree on a\n// hardcoded hostId per use case (e.g. \"mulmoclaude\", \"mulmoterminal\"); there is\n// no discovery — the remote and host just share the id.\nexport interface Channel {\n uid: string;\n hostId: string;\n}\n\nexport type CommandStatus = \"queued\" | \"processing\" | \"done\" | \"error\";\n\nexport interface CommandError {\n code: string;\n message: string;\n}\n\n// One document in a channel's commands subcollection is one API-call-like\n// request. The remote (mobile) writes method/params; the host writes\n// result/error/status.\nexport interface Command {\n method: string;\n params: JsonObject;\n status: CommandStatus;\n result: JsonValue;\n error: CommandError | null;\n createdBy: \"remote\" | \"host\";\n // Offline-queue fields (all optional; absent ⇒ pre-offline-queue behaviour, so\n // this is backward-compatible with every deployed client). Epoch-millisecond\n // NUMBERS set by the remote at enqueue time — deliberately plain numbers, not\n // Firestore Timestamps, so `isExpired` / `byCreatedAt` stay pure + browser-safe\n // and unit-testable without a Firestore fake. Clock skew over a multi-day expiry\n // window is immaterial. See plans/done/feat-remote-offline-queue.md.\n createdAt?: number; // enqueue time — age/display + best-effort dispatch bias (NOT a strict order guarantee; chat is async)\n expiresAt?: number; // deadline; past it the host deletes the command + its staged attachments\n queuedOffline?: boolean; // emitted while the host was offline (gates the remote's attachment rollback)\n}\n\n// A command is expired once `now` reaches its remote-set deadline. Absent\n// `expiresAt` ⇒ it never expires (pre-offline-queue commands). Pure with an\n// injected `now` for deterministic tests; the runner passes `Date.now()`.\nexport const isExpired = (command: Pick<Command, \"expiresAt\">, now: number): boolean => typeof command.expiresAt === \"number\" && now >= command.expiresAt;\n\n// Best-effort dispatch bias for a drained batch: oldest enqueue first. This is\n// NOT an ordering guarantee — commands run concurrently and may complete out of\n// order (chat is asynchronous, by design); it only nudges which one starts first.\n// A command with no `createdAt` sorts as oldest (0) so it is never starved.\nexport const byCreatedAt = (left: Pick<Command, \"createdAt\">, right: Pick<Command, \"createdAt\">): number => (left.createdAt ?? 0) - (right.createdAt ?? 0);\n\nexport type CommandHandler = (params: JsonObject) => JsonValue | Promise<JsonValue>;\nexport type CommandHandlers = Record<string, CommandHandler>;\n\n// Bumped when the command-channel wire protocol changes in a way the remote must\n// gate on. Advertised in the presence doc so the remote can check compatibility\n// before issuing commands.\n//\n// v2: offline queueing. The host honours `expiresAt` (deletes an expired command\n// + its staged attachments instead of spawning a stale chat). A remote MUST see\n// protocolVersion >= 2 before queueing a startChat while the host is offline —\n// a v1 host silently ignores `expiresAt`, so a queued chat would spawn stale on\n// reconnect with its uploads never cleaned up.\nexport const REMOTE_HOST_PROTOCOL_VERSION = 2;\n\n// The presence doc's payload: online flag + a capability advertisement. Written\n// by the host on every heartbeat; the remote reads it from the presence listener\n// it already runs (no extra round trip, known the instant the host is online).\n// Browser-safe so the mobile client compiles against the same shape.\n// `updatedAt` (a Firestore serverTimestamp) is added by the runner at write time\n// and is intentionally not part of this capability contract.\nexport interface HostPresence {\n online: boolean;\n hostId: string;\n protocolVersion: number;\n // Method names the host serves — the keys of the live handler table.\n capabilities: string[];\n}\n\n// Build the presence payload from the live handler table. Capabilities are\n// `Object.keys(handlers)` so registering a handler is the ONLY step needed to\n// advertise it — there is no second list to keep in sync.\nexport const buildHostPresence = (channel: Channel, handlers: CommandHandlers, online: boolean): HostPresence => ({\n online,\n hostId: channel.hostId,\n protocolVersion: REMOTE_HOST_PROTOCOL_VERSION,\n capabilities: Object.keys(handlers),\n});\n\n// Per-host command queue: users/{uid}/hosts/{hostId}/commands.\nexport const commandsCollection = (firestore: Firestore, channel: Channel): CollectionReference<DocumentData> =>\n collection(firestore, \"users\", channel.uid, \"hosts\", channel.hostId, \"commands\");\n\n// Presence doc for a host: users/{uid}/hosts/{hostId}. The host heartbeats\n// { online, updatedAt } here; the remote reads it to know if the host is up.\nexport const hostDoc = (firestore: Firestore, channel: Channel): DocumentReference<DocumentData> =>\n doc(firestore, \"users\", channel.uid, \"hosts\", channel.hostId);\n\n// Channel health as the resilient runner reports it. Browser-safe alongside the\n// wire types because the control that renders it runs in the client.\nexport { RUNNER_HEALTH_STATES, isRunnerHealth, isRunnerHealthState } from \"./health.js\";\nexport type { RunnerHealth, RunnerHealthState } from \"./health.js\";\n"],"mappings":";;;AAYA,IAAa,uBAAuB;CAAC;CAAU;CAAgB;AAAS;AAWxE,IAAM,YAAY,UAAqD,OAAO,UAAU,YAAY,UAAU;AAE9G,IAAa,uBAAuB,UAA+C,qBAAqB,MAAM,UAAU,UAAU,KAAK;;;AAIvI,IAAa,kBAAkB,UAC7B,SAAS,KAAK,KACd,oBAAoB,MAAM,KAAK,MAC9B,MAAM,cAAc,QAAQ,OAAO,MAAM,cAAc,aACxD,OAAO,MAAM,cAAc;;;;;;;;;ACsB7B,IAAa,gBAAkC,YAAoC;AAEnF,IAAM,mBAAmB,UAA2B;CAClD,IAAI,OAAO,UAAU,UAAU,OAAO,OAAO,KAAK;CAClD,IAAI,OAAO,UAAU,UAAU,OAAO;CACtC,OAAO,KAAK,OAAO;AACrB;;;;;AAMA,IAAM,aAAa,UAAsD,YAAY,SAAS,OAAO,MAAM,WAAW;AAEtH,IAAM,wBAAwB,UAA4B,UAAU,KAAK,IAAI,MAAM,OAAO,IAAI;;AAG9F,SAAS,YAAY,OAAgB,MAAyB;CAC5D,IAAI,MAAM,QAAQ,KAAK,GAAG,OAAO,YAAY,OAAO,IAAI;CACxD,IAAI,WAAS,KAAK,GAAG;EACnB,MAAM,cAAc,qBAAqB,KAAK;EAC9C,IAAI,gBAAgB,OAAO,OAAO,YAAY,aAAa,IAAI;EAC/D,OAAO,cAAc,OAAO,IAAI;CAClC;CACA,OAAO,aAAa,OAAO,IAAI;AACjC;;;AAIA,SAAS,aAAa,OAAgB,MAAyB;CAC7D,IAAI,UAAU,QAAQ,OAAO,UAAU,YAAY,OAAO,UAAU,WAAW,OAAO;CACtF,IAAI,OAAO,UAAU,YAAY,OAAO,SAAS,KAAK,GAAG,OAAO;CAChE,MAAM,IAAI,MAAM,GAAG,KAAK,MAAM,gBAAgB,KAAK,EAAE,8BAA8B;AACrF;AAEA,SAAS,YAAY,OAAkB,MAA2B;CAMhE,OAAO,MAAM,KAAK,QAAQ,OAAO,UAAW,UAAU,KAAA,IAAY,OAAO,YAAY,OAAO,GAAG,KAAK,GAAG,MAAM,EAAE,CAAE;AACnH;AAEA,SAAS,cAAc,QAAiC,MAA0B;CAChF,MAAM,SAAS,OAAO,QAAQ,MAAM,CAAC,CAAC,QAAQ,GAAG,WAAW,UAAU,KAAA,CAAS;CAC/E,OAAO,OAAO,YAAY,OAAO,KAAK,CAAC,KAAK,WAAW,CAAC,KAAK,YAAY,OAAO,GAAG,KAAK,GAAG,KAAK,CAAC,CAAC,CAAC;AACrG;;;;;;;;;;AAWA,IAAa,oBAAoB,YAAiD,cAAc,SAAS,SAAS;AAyClH,IAAa,aAAa,SAAqC,QAAyB,OAAO,QAAQ,cAAc,YAAY,OAAO,QAAQ;AAMhJ,IAAa,eAAe,MAAkC,WAA+C,KAAK,aAAa,MAAM,MAAM,aAAa;AAcxJ,IAAa,+BAA+B;AAmB5C,IAAa,qBAAqB,SAAkB,UAA2B,YAAmC;CAChH;CACA,QAAQ,QAAQ;CAChB,iBAAA;CACA,cAAc,OAAO,KAAK,QAAQ;AACpC;AAGA,IAAa,sBAAsB,WAAsB,YACvD,WAAW,WAAW,SAAS,QAAQ,KAAK,SAAS,QAAQ,QAAQ,UAAU;AAIjF,IAAa,WAAW,WAAsB,YAC5C,IAAI,WAAW,SAAS,QAAQ,KAAK,SAAS,QAAQ,MAAM"}
|
|
1
|
+
{"version":3,"file":"remote-host-CFnl1I-L.js","names":[],"sources":["../src/remote-host/health.ts","../src/remote-host/index.ts"],"sourcesContent":["// Health of the remote-host command channel, as reported by the resilient runner\n// and rendered by a host's toolbar control. Browser-safe on purpose: the client\n// narrows the parsed HTTP payload with the same guard the server writes it from,\n// so the two sides cannot drift on the state names.\n//\n// online — the Firestore subscription is up; the phone can reach this host\n// reconnecting — it died and is being re-subscribed with backoff (self-healing)\n// offline — re-subscribing stopped helping, or nothing is connected at all;\n// recovering needs a re-auth from the browser's parked session\n//\n// Deliberately no UI wording here — how a state reads to a user is each host's\n// i18n, and core owning it would make the shared package a translation authority.\nexport const RUNNER_HEALTH_STATES = [\"online\", \"reconnecting\", \"offline\"] as const;\nexport type RunnerHealthState = (typeof RUNNER_HEALTH_STATES)[number];\n\nexport interface RunnerHealth {\n state: RunnerHealthState;\n /** Last channel error seen, for the popover and the log. Null before the first one. */\n lastError: string | null;\n /** ms epoch of the last state change, so the UI can say how long it has been down. */\n changedAt: number;\n}\n\nconst isRecord = (value: unknown): value is Record<string, unknown> => typeof value === \"object\" && value !== null;\n\nexport const isRunnerHealthState = (value: unknown): value is RunnerHealthState => RUNNER_HEALTH_STATES.some((state) => state === value);\n\n/** Narrows a parsed HTTP payload. The client renders whatever this accepts, so a\n * half-shaped health has to read as \"no health reported\" rather than as a state. */\nexport const isRunnerHealth = (value: unknown): value is RunnerHealth =>\n isRecord(value) &&\n isRunnerHealthState(value.state) &&\n (value.lastError === null || typeof value.lastError === \"string\") &&\n typeof value.changedAt === \"number\";\n","// Remote-host command-channel protocol — the browser-safe contract shared by a\n// host (MulmoClaude, MulmoTerminal) and the remote/mobile client (mulmoserver).\n//\n// A host signs in to Firebase as the user, listens to that user's per-host\n// command queue in Firestore, runs a handler, and writes the result back; the\n// remote writes commands and reads results via a real-time listener. This module\n// owns the wire types + the Firestore path helpers. It is the single source of\n// truth so the host runner and the client never drift on the protocol.\n//\n// Ported from ../mulmoserver/src/firestore/commandChannel.ts and the per-host\n// copy that lived in MulmoClaude's server/remoteHost/. The one change vs. those\n// copies: the path helpers take the `firestore` instance as a parameter (rather\n// than importing a module-level singleton) so a single extracted module serves\n// every host's own Firebase init. The hostId is host-specific (\"mulmoclaude\",\n// \"mulmoterminal\") and is supplied by each host — there is no discovery.\nimport { CollectionReference, DocumentData, DocumentReference, Firestore, collection, doc } from \"firebase/firestore\";\nimport { isRecord } from \"@mulmoclaude/common\";\n\n// JSON payloads carried by the command channel. Explicit JSON types keep the\n// channel typed without resorting to any/unknown.\nexport type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };\nexport type JsonObject = Record<string, JsonValue>;\n\n/** Structural JSON view of `T`, recursively.\n *\n * TypeScript gives an implicit index signature to type aliases and mapped\n * types but NOT to interfaces, so a payload assembled from domain interfaces\n * (`Shortcut`, `FeedSummary`, …) cannot satisfy `Record<string, JsonValue>`\n * structurally — even though it is plain JSON at runtime. Mapping over `T`\n * reconstructs it as an anonymous type, which does get that index signature.\n *\n * Recursive on purpose: a top-level-only map would still leave nested\n * interfaces (`{ shortcuts: Shortcut[] }`) unassignable, which is the case\n * every handler here actually has. */\n// The function branch must come BEFORE the object branch: a function IS an\n// object to TypeScript, so without it a function maps to `{}` and sails\n// through — the helper would accept a payload that serialises to nothing.\n// Verified: `toJsonObject({ callback: () => undefined })` compiled clean until\n// this branch existed (CodeRabbit, #2596).\nexport type Jsonify<T> = T extends JsonValue\n ? T\n : T extends (...args: never[]) => unknown\n ? never\n : T extends (infer U)[]\n ? Jsonify<U>[]\n : T extends object\n ? { [K in keyof T]: Jsonify<T[K]> }\n : never;\n\n/** Widen a JSON-shaped handler payload to the channel's `JsonObject`.\n *\n * Exists so the `Jsonify` reasoning above lives in ONE place. Before this,\n * eight remote-host handlers each carried their own `as unknown as JsonObject`\n * with the justification re-argued in eight slightly different comments —\n * which is how a rule stops being reviewable. */\nexport const toJsonObject = <T extends object>(payload: Jsonify<T>): JsonObject => payload as JsonObject;\n\nconst describeNonJson = (value: unknown): string => {\n if (typeof value === \"number\") return String(value);\n if (typeof value === \"object\") return \"a non-plain object\";\n return `a ${typeof value}`;\n};\n\n/** Anything carrying its own JSON form — `Date` above all — must be asked for\n * it rather than walked, because walking a `Date`'s own enumerable keys finds\n * none and flattens the timestamp to `{}`. This is the step `JSON.stringify`\n * performs before it recurses, and the channel used to get it for free. */\nconst hasToJson = (value: object): value is { toJSON: () => unknown } => \"toJSON\" in value && typeof value.toJSON === \"function\";\n\nconst jsonRepresentationOf = (value: object): unknown => (hasToJson(value) ? value.toJSON() : value);\n\n/** Rebuild `value` as JSON, or throw naming the property that cannot be. */\nfunction toJsonValue(value: unknown, path: string): JsonValue {\n if (Array.isArray(value)) return toJsonItems(value, path);\n if (isRecord(value)) {\n const represented = jsonRepresentationOf(value);\n if (represented !== value) return toJsonValue(represented, path);\n return toJsonEntries(value, path);\n }\n return toJsonScalar(value, path);\n}\n\n/** JSON's four scalar forms. Anything else — a function, a class instance, a\n * non-finite number — is what the channel cannot carry. */\nfunction toJsonScalar(value: unknown, path: string): JsonValue {\n if (value === null || typeof value === \"string\" || typeof value === \"boolean\") return value;\n if (typeof value === \"number\" && Number.isFinite(value)) return value;\n throw new Error(`${path} is ${describeNonJson(value)}, which JSON cannot represent`);\n}\n\nfunction toJsonItems(items: unknown[], path: string): JsonValue[] {\n // An absent element becomes `null`, matching `JSON.stringify` — an array has\n // to keep its length, so a hole cannot simply be dropped the way a key is.\n // `Array.from` rather than `map`, which SKIPS holes and would leave them in\n // the result: `JSON.stringify` renders a hole as null and hides that, but\n // `1 in arr` / `Object.keys` / `forEach` all still see the gap.\n return Array.from(items, (entry, index) => (entry === undefined ? null : toJsonValue(entry, `${path}[${index}]`)));\n}\n\nfunction toJsonEntries(record: Record<string, unknown>, path: string): JsonObject {\n const usable = Object.entries(record).filter(([, value]) => value !== undefined);\n return Object.fromEntries(usable.map(([key, value]) => [key, toJsonValue(value, `${path}.${key}`)]));\n}\n\n/** Runtime counterpart to `toJsonObject`, for payloads whose values are typed\n * `unknown` — a collection record, a projected view row — so no amount of\n * mapped-type work can PROVE them JSON.\n *\n * Walks the payload and rebuilds it from the values it actually inspected, so\n * the returned `JsonObject` is earned rather than asserted. Absent (`undefined`)\n * properties are dropped exactly as `JSON.stringify` drops them; anything the\n * channel could not carry — a function, a class instance, `NaN` — throws\n * naming its path, instead of reaching Firestore as a silently mangled write. */\nexport const coerceJsonObject = (payload: Record<string, unknown>): JsonObject => toJsonEntries(payload, \"payload\");\n\n// A channel routes commands to one specific host. Both sides agree on a\n// hardcoded hostId per use case (e.g. \"mulmoclaude\", \"mulmoterminal\"); there is\n// no discovery — the remote and host just share the id.\nexport interface Channel {\n uid: string;\n hostId: string;\n}\n\nexport type CommandStatus = \"queued\" | \"processing\" | \"done\" | \"error\";\n\nexport interface CommandError {\n code: string;\n message: string;\n}\n\n// One document in a channel's commands subcollection is one API-call-like\n// request. The remote (mobile) writes method/params; the host writes\n// result/error/status.\nexport interface Command {\n method: string;\n params: JsonObject;\n status: CommandStatus;\n result: JsonValue;\n error: CommandError | null;\n createdBy: \"remote\" | \"host\";\n // Offline-queue fields (all optional; absent ⇒ pre-offline-queue behaviour, so\n // this is backward-compatible with every deployed client). Epoch-millisecond\n // NUMBERS set by the remote at enqueue time — deliberately plain numbers, not\n // Firestore Timestamps, so `isExpired` / `byCreatedAt` stay pure + browser-safe\n // and unit-testable without a Firestore fake. Clock skew over a multi-day expiry\n // window is immaterial. See plans/done/feat-remote-offline-queue.md.\n createdAt?: number; // enqueue time — age/display + best-effort dispatch bias (NOT a strict order guarantee; chat is async)\n expiresAt?: number; // deadline; past it the host deletes the command + its staged attachments\n queuedOffline?: boolean; // emitted while the host was offline (gates the remote's attachment rollback)\n}\n\n// A command is expired once `now` reaches its remote-set deadline. Absent\n// `expiresAt` ⇒ it never expires (pre-offline-queue commands). Pure with an\n// injected `now` for deterministic tests; the runner passes `Date.now()`.\nexport const isExpired = (command: Pick<Command, \"expiresAt\">, now: number): boolean => typeof command.expiresAt === \"number\" && now >= command.expiresAt;\n\n// Best-effort dispatch bias for a drained batch: oldest enqueue first. This is\n// NOT an ordering guarantee — commands run concurrently and may complete out of\n// order (chat is asynchronous, by design); it only nudges which one starts first.\n// A command with no `createdAt` sorts as oldest (0) so it is never starved.\nexport const byCreatedAt = (left: Pick<Command, \"createdAt\">, right: Pick<Command, \"createdAt\">): number => (left.createdAt ?? 0) - (right.createdAt ?? 0);\n\nexport type CommandHandler = (params: JsonObject) => JsonValue | Promise<JsonValue>;\nexport type CommandHandlers = Record<string, CommandHandler>;\n\n/** The param name a collection-serving command uses to say WHICH project's\n * collections it means. Reserved and documented now, before a phone client\n * ships, because the parts that are hard to change later are the ones being\n * written today. Four rules go with it:\n *\n * 1. **It is an OPAQUE scope, never a path.** The phone is a genuinely remote\n * client; an absolute root in a command, an artifact or a token publishes\n * the user's home directory over the wire. Mint a project id host-side and\n * resolve it host-side.\n * 2. **The phone must be able to LEARN the list.** A picker needs\n * `{ id, label }` pairs from the host — a command of its own, or a field on\n * an existing listing. Designing the scope value now is what keeps that the\n * ONLY new thing when the feature lands.\n * 3. **Handlers RESOLVE a scope; they do not hard-code one.** Write each\n * collection handler as \"read the scope from params, defaulting to the\n * host's root\" rather than calling the workspace accessor inline. Today\n * every call resolves the default and behaves exactly as it does now; the\n * day the param arrives, no handler changes.\n * 4. **The artifact stays host-built.** A remote view's srcdoc, its inlined\n * image thumbnails and its token are assembled on the host, so the phone\n * never resolves a path itself. That is what makes (1) hold without\n * trusting the client. */\nexport const COMMAND_SCOPE_PARAM = \"project\";\n\n/** Read the opaque project scope off a command's params, or `undefined` for\n * \"the host's own root\" — which is what every command means today. A\n * non-string (or empty) value is treated as absent rather than as an error: a\n * scope the host cannot resolve must fall back to the default, never to a\n * guess. Hosts pass the result to their own id → root resolver; this module\n * deliberately never sees a path. */\nexport const readCommandScope = (params: JsonObject): string | undefined => {\n const raw = params[COMMAND_SCOPE_PARAM];\n return typeof raw === \"string\" && raw.trim().length > 0 ? raw.trim() : undefined;\n};\n\n// Bumped when the command-channel wire protocol changes in a way the remote must\n// gate on. Advertised in the presence doc so the remote can check compatibility\n// before issuing commands.\n//\n// v2: offline queueing. The host honours `expiresAt` (deletes an expired command\n// + its staged attachments instead of spawning a stale chat). A remote MUST see\n// protocolVersion >= 2 before queueing a startChat while the host is offline —\n// a v1 host silently ignores `expiresAt`, so a queued chat would spawn stale on\n// reconnect with its uploads never cleaned up.\nexport const REMOTE_HOST_PROTOCOL_VERSION = 2;\n\n// The presence doc's payload: online flag + a capability advertisement. Written\n// by the host on every heartbeat; the remote reads it from the presence listener\n// it already runs (no extra round trip, known the instant the host is online).\n// Browser-safe so the mobile client compiles against the same shape.\n// `updatedAt` (a Firestore serverTimestamp) is added by the runner at write time\n// and is intentionally not part of this capability contract.\nexport interface HostPresence {\n online: boolean;\n hostId: string;\n protocolVersion: number;\n // Method names the host serves — the keys of the live handler table.\n capabilities: string[];\n}\n\n// Build the presence payload from the live handler table. Capabilities are\n// `Object.keys(handlers)` so registering a handler is the ONLY step needed to\n// advertise it — there is no second list to keep in sync.\nexport const buildHostPresence = (channel: Channel, handlers: CommandHandlers, online: boolean): HostPresence => ({\n online,\n hostId: channel.hostId,\n protocolVersion: REMOTE_HOST_PROTOCOL_VERSION,\n capabilities: Object.keys(handlers),\n});\n\n// Per-host command queue: users/{uid}/hosts/{hostId}/commands.\nexport const commandsCollection = (firestore: Firestore, channel: Channel): CollectionReference<DocumentData> =>\n collection(firestore, \"users\", channel.uid, \"hosts\", channel.hostId, \"commands\");\n\n// Presence doc for a host: users/{uid}/hosts/{hostId}. The host heartbeats\n// { online, updatedAt } here; the remote reads it to know if the host is up.\nexport const hostDoc = (firestore: Firestore, channel: Channel): DocumentReference<DocumentData> =>\n doc(firestore, \"users\", channel.uid, \"hosts\", channel.hostId);\n\n// Channel health as the resilient runner reports it. Browser-safe alongside the\n// wire types because the control that renders it runs in the client.\nexport { RUNNER_HEALTH_STATES, isRunnerHealth, isRunnerHealthState } from \"./health.js\";\nexport type { RunnerHealth, RunnerHealthState } from \"./health.js\";\n"],"mappings":";;;AAYA,IAAa,uBAAuB;CAAC;CAAU;CAAgB;AAAS;AAWxE,IAAM,YAAY,UAAqD,OAAO,UAAU,YAAY,UAAU;AAE9G,IAAa,uBAAuB,UAA+C,qBAAqB,MAAM,UAAU,UAAU,KAAK;;;AAIvI,IAAa,kBAAkB,UAC7B,SAAS,KAAK,KACd,oBAAoB,MAAM,KAAK,MAC9B,MAAM,cAAc,QAAQ,OAAO,MAAM,cAAc,aACxD,OAAO,MAAM,cAAc;;;;;;;;;ACsB7B,IAAa,gBAAkC,YAAoC;AAEnF,IAAM,mBAAmB,UAA2B;CAClD,IAAI,OAAO,UAAU,UAAU,OAAO,OAAO,KAAK;CAClD,IAAI,OAAO,UAAU,UAAU,OAAO;CACtC,OAAO,KAAK,OAAO;AACrB;;;;;AAMA,IAAM,aAAa,UAAsD,YAAY,SAAS,OAAO,MAAM,WAAW;AAEtH,IAAM,wBAAwB,UAA4B,UAAU,KAAK,IAAI,MAAM,OAAO,IAAI;;AAG9F,SAAS,YAAY,OAAgB,MAAyB;CAC5D,IAAI,MAAM,QAAQ,KAAK,GAAG,OAAO,YAAY,OAAO,IAAI;CACxD,IAAI,WAAS,KAAK,GAAG;EACnB,MAAM,cAAc,qBAAqB,KAAK;EAC9C,IAAI,gBAAgB,OAAO,OAAO,YAAY,aAAa,IAAI;EAC/D,OAAO,cAAc,OAAO,IAAI;CAClC;CACA,OAAO,aAAa,OAAO,IAAI;AACjC;;;AAIA,SAAS,aAAa,OAAgB,MAAyB;CAC7D,IAAI,UAAU,QAAQ,OAAO,UAAU,YAAY,OAAO,UAAU,WAAW,OAAO;CACtF,IAAI,OAAO,UAAU,YAAY,OAAO,SAAS,KAAK,GAAG,OAAO;CAChE,MAAM,IAAI,MAAM,GAAG,KAAK,MAAM,gBAAgB,KAAK,EAAE,8BAA8B;AACrF;AAEA,SAAS,YAAY,OAAkB,MAA2B;CAMhE,OAAO,MAAM,KAAK,QAAQ,OAAO,UAAW,UAAU,KAAA,IAAY,OAAO,YAAY,OAAO,GAAG,KAAK,GAAG,MAAM,EAAE,CAAE;AACnH;AAEA,SAAS,cAAc,QAAiC,MAA0B;CAChF,MAAM,SAAS,OAAO,QAAQ,MAAM,CAAC,CAAC,QAAQ,GAAG,WAAW,UAAU,KAAA,CAAS;CAC/E,OAAO,OAAO,YAAY,OAAO,KAAK,CAAC,KAAK,WAAW,CAAC,KAAK,YAAY,OAAO,GAAG,KAAK,GAAG,KAAK,CAAC,CAAC,CAAC;AACrG;;;;;;;;;;AAWA,IAAa,oBAAoB,YAAiD,cAAc,SAAS,SAAS;AAyClH,IAAa,aAAa,SAAqC,QAAyB,OAAO,QAAQ,cAAc,YAAY,OAAO,QAAQ;AAMhJ,IAAa,eAAe,MAAkC,WAA+C,KAAK,aAAa,MAAM,MAAM,aAAa;;;;;;;;;;;;;;;;;;;;;;;AA2BxJ,IAAa,sBAAsB;;;;;;;AAQnC,IAAa,oBAAoB,WAA2C;CAC1E,MAAM,MAAM,OAAO;CACnB,OAAO,OAAO,QAAQ,YAAY,IAAI,KAAK,CAAC,CAAC,SAAS,IAAI,IAAI,KAAK,IAAI,KAAA;AACzE;AAWA,IAAa,+BAA+B;AAmB5C,IAAa,qBAAqB,SAAkB,UAA2B,YAAmC;CAChH;CACA,QAAQ,QAAQ;CAChB,iBAAA;CACA,cAAc,OAAO,KAAK,QAAQ;AACpC;AAGA,IAAa,sBAAsB,WAAsB,YACvD,WAAW,WAAW,SAAS,QAAQ,KAAK,SAAS,QAAQ,QAAQ,UAAU;AAIjF,IAAa,WAAW,WAAsB,YAC5C,IAAI,WAAW,SAAS,QAAQ,KAAK,SAAS,QAAQ,MAAM"}
|
|
@@ -67,6 +67,39 @@ function toJsonEntries(record, path) {
|
|
|
67
67
|
var coerceJsonObject = (payload) => toJsonEntries(payload, "payload");
|
|
68
68
|
var isExpired = (command, now) => typeof command.expiresAt === "number" && now >= command.expiresAt;
|
|
69
69
|
var byCreatedAt = (left, right) => (left.createdAt ?? 0) - (right.createdAt ?? 0);
|
|
70
|
+
/** The param name a collection-serving command uses to say WHICH project's
|
|
71
|
+
* collections it means. Reserved and documented now, before a phone client
|
|
72
|
+
* ships, because the parts that are hard to change later are the ones being
|
|
73
|
+
* written today. Four rules go with it:
|
|
74
|
+
*
|
|
75
|
+
* 1. **It is an OPAQUE scope, never a path.** The phone is a genuinely remote
|
|
76
|
+
* client; an absolute root in a command, an artifact or a token publishes
|
|
77
|
+
* the user's home directory over the wire. Mint a project id host-side and
|
|
78
|
+
* resolve it host-side.
|
|
79
|
+
* 2. **The phone must be able to LEARN the list.** A picker needs
|
|
80
|
+
* `{ id, label }` pairs from the host — a command of its own, or a field on
|
|
81
|
+
* an existing listing. Designing the scope value now is what keeps that the
|
|
82
|
+
* ONLY new thing when the feature lands.
|
|
83
|
+
* 3. **Handlers RESOLVE a scope; they do not hard-code one.** Write each
|
|
84
|
+
* collection handler as "read the scope from params, defaulting to the
|
|
85
|
+
* host's root" rather than calling the workspace accessor inline. Today
|
|
86
|
+
* every call resolves the default and behaves exactly as it does now; the
|
|
87
|
+
* day the param arrives, no handler changes.
|
|
88
|
+
* 4. **The artifact stays host-built.** A remote view's srcdoc, its inlined
|
|
89
|
+
* image thumbnails and its token are assembled on the host, so the phone
|
|
90
|
+
* never resolves a path itself. That is what makes (1) hold without
|
|
91
|
+
* trusting the client. */
|
|
92
|
+
var COMMAND_SCOPE_PARAM = "project";
|
|
93
|
+
/** Read the opaque project scope off a command's params, or `undefined` for
|
|
94
|
+
* "the host's own root" — which is what every command means today. A
|
|
95
|
+
* non-string (or empty) value is treated as absent rather than as an error: a
|
|
96
|
+
* scope the host cannot resolve must fall back to the default, never to a
|
|
97
|
+
* guess. Hosts pass the result to their own id → root resolver; this module
|
|
98
|
+
* deliberately never sees a path. */
|
|
99
|
+
var readCommandScope = (params) => {
|
|
100
|
+
const raw = params[COMMAND_SCOPE_PARAM];
|
|
101
|
+
return typeof raw === "string" && raw.trim().length > 0 ? raw.trim() : void 0;
|
|
102
|
+
};
|
|
70
103
|
var REMOTE_HOST_PROTOCOL_VERSION = 2;
|
|
71
104
|
var buildHostPresence = (channel, handlers, online) => ({
|
|
72
105
|
online,
|
|
@@ -77,6 +110,12 @@ var buildHostPresence = (channel, handlers, online) => ({
|
|
|
77
110
|
var commandsCollection = (firestore, channel) => (0, firebase_firestore.collection)(firestore, "users", channel.uid, "hosts", channel.hostId, "commands");
|
|
78
111
|
var hostDoc = (firestore, channel) => (0, firebase_firestore.doc)(firestore, "users", channel.uid, "hosts", channel.hostId);
|
|
79
112
|
//#endregion
|
|
113
|
+
Object.defineProperty(exports, "COMMAND_SCOPE_PARAM", {
|
|
114
|
+
enumerable: true,
|
|
115
|
+
get: function() {
|
|
116
|
+
return COMMAND_SCOPE_PARAM;
|
|
117
|
+
}
|
|
118
|
+
});
|
|
80
119
|
Object.defineProperty(exports, "REMOTE_HOST_PROTOCOL_VERSION", {
|
|
81
120
|
enumerable: true,
|
|
82
121
|
get: function() {
|
|
@@ -137,6 +176,12 @@ Object.defineProperty(exports, "isRunnerHealthState", {
|
|
|
137
176
|
return isRunnerHealthState;
|
|
138
177
|
}
|
|
139
178
|
});
|
|
179
|
+
Object.defineProperty(exports, "readCommandScope", {
|
|
180
|
+
enumerable: true,
|
|
181
|
+
get: function() {
|
|
182
|
+
return readCommandScope;
|
|
183
|
+
}
|
|
184
|
+
});
|
|
140
185
|
Object.defineProperty(exports, "toJsonObject", {
|
|
141
186
|
enumerable: true,
|
|
142
187
|
get: function() {
|
|
@@ -144,4 +189,4 @@ Object.defineProperty(exports, "toJsonObject", {
|
|
|
144
189
|
}
|
|
145
190
|
});
|
|
146
191
|
|
|
147
|
-
//# sourceMappingURL=remote-host-
|
|
192
|
+
//# sourceMappingURL=remote-host-EQPlccB9.cjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"remote-host-CtQjagPt.cjs","names":[],"sources":["../src/remote-host/health.ts","../src/remote-host/index.ts"],"sourcesContent":["// Health of the remote-host command channel, as reported by the resilient runner\n// and rendered by a host's toolbar control. Browser-safe on purpose: the client\n// narrows the parsed HTTP payload with the same guard the server writes it from,\n// so the two sides cannot drift on the state names.\n//\n// online — the Firestore subscription is up; the phone can reach this host\n// reconnecting — it died and is being re-subscribed with backoff (self-healing)\n// offline — re-subscribing stopped helping, or nothing is connected at all;\n// recovering needs a re-auth from the browser's parked session\n//\n// Deliberately no UI wording here — how a state reads to a user is each host's\n// i18n, and core owning it would make the shared package a translation authority.\nexport const RUNNER_HEALTH_STATES = [\"online\", \"reconnecting\", \"offline\"] as const;\nexport type RunnerHealthState = (typeof RUNNER_HEALTH_STATES)[number];\n\nexport interface RunnerHealth {\n state: RunnerHealthState;\n /** Last channel error seen, for the popover and the log. Null before the first one. */\n lastError: string | null;\n /** ms epoch of the last state change, so the UI can say how long it has been down. */\n changedAt: number;\n}\n\nconst isRecord = (value: unknown): value is Record<string, unknown> => typeof value === \"object\" && value !== null;\n\nexport const isRunnerHealthState = (value: unknown): value is RunnerHealthState => RUNNER_HEALTH_STATES.some((state) => state === value);\n\n/** Narrows a parsed HTTP payload. The client renders whatever this accepts, so a\n * half-shaped health has to read as \"no health reported\" rather than as a state. */\nexport const isRunnerHealth = (value: unknown): value is RunnerHealth =>\n isRecord(value) &&\n isRunnerHealthState(value.state) &&\n (value.lastError === null || typeof value.lastError === \"string\") &&\n typeof value.changedAt === \"number\";\n","// Remote-host command-channel protocol — the browser-safe contract shared by a\n// host (MulmoClaude, MulmoTerminal) and the remote/mobile client (mulmoserver).\n//\n// A host signs in to Firebase as the user, listens to that user's per-host\n// command queue in Firestore, runs a handler, and writes the result back; the\n// remote writes commands and reads results via a real-time listener. This module\n// owns the wire types + the Firestore path helpers. It is the single source of\n// truth so the host runner and the client never drift on the protocol.\n//\n// Ported from ../mulmoserver/src/firestore/commandChannel.ts and the per-host\n// copy that lived in MulmoClaude's server/remoteHost/. The one change vs. those\n// copies: the path helpers take the `firestore` instance as a parameter (rather\n// than importing a module-level singleton) so a single extracted module serves\n// every host's own Firebase init. The hostId is host-specific (\"mulmoclaude\",\n// \"mulmoterminal\") and is supplied by each host — there is no discovery.\nimport { CollectionReference, DocumentData, DocumentReference, Firestore, collection, doc } from \"firebase/firestore\";\nimport { isRecord } from \"@mulmoclaude/common\";\n\n// JSON payloads carried by the command channel. Explicit JSON types keep the\n// channel typed without resorting to any/unknown.\nexport type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };\nexport type JsonObject = Record<string, JsonValue>;\n\n/** Structural JSON view of `T`, recursively.\n *\n * TypeScript gives an implicit index signature to type aliases and mapped\n * types but NOT to interfaces, so a payload assembled from domain interfaces\n * (`Shortcut`, `FeedSummary`, …) cannot satisfy `Record<string, JsonValue>`\n * structurally — even though it is plain JSON at runtime. Mapping over `T`\n * reconstructs it as an anonymous type, which does get that index signature.\n *\n * Recursive on purpose: a top-level-only map would still leave nested\n * interfaces (`{ shortcuts: Shortcut[] }`) unassignable, which is the case\n * every handler here actually has. */\n// The function branch must come BEFORE the object branch: a function IS an\n// object to TypeScript, so without it a function maps to `{}` and sails\n// through — the helper would accept a payload that serialises to nothing.\n// Verified: `toJsonObject({ callback: () => undefined })` compiled clean until\n// this branch existed (CodeRabbit, #2596).\nexport type Jsonify<T> = T extends JsonValue\n ? T\n : T extends (...args: never[]) => unknown\n ? never\n : T extends (infer U)[]\n ? Jsonify<U>[]\n : T extends object\n ? { [K in keyof T]: Jsonify<T[K]> }\n : never;\n\n/** Widen a JSON-shaped handler payload to the channel's `JsonObject`.\n *\n * Exists so the `Jsonify` reasoning above lives in ONE place. Before this,\n * eight remote-host handlers each carried their own `as unknown as JsonObject`\n * with the justification re-argued in eight slightly different comments —\n * which is how a rule stops being reviewable. */\nexport const toJsonObject = <T extends object>(payload: Jsonify<T>): JsonObject => payload as JsonObject;\n\nconst describeNonJson = (value: unknown): string => {\n if (typeof value === \"number\") return String(value);\n if (typeof value === \"object\") return \"a non-plain object\";\n return `a ${typeof value}`;\n};\n\n/** Anything carrying its own JSON form — `Date` above all — must be asked for\n * it rather than walked, because walking a `Date`'s own enumerable keys finds\n * none and flattens the timestamp to `{}`. This is the step `JSON.stringify`\n * performs before it recurses, and the channel used to get it for free. */\nconst hasToJson = (value: object): value is { toJSON: () => unknown } => \"toJSON\" in value && typeof value.toJSON === \"function\";\n\nconst jsonRepresentationOf = (value: object): unknown => (hasToJson(value) ? value.toJSON() : value);\n\n/** Rebuild `value` as JSON, or throw naming the property that cannot be. */\nfunction toJsonValue(value: unknown, path: string): JsonValue {\n if (Array.isArray(value)) return toJsonItems(value, path);\n if (isRecord(value)) {\n const represented = jsonRepresentationOf(value);\n if (represented !== value) return toJsonValue(represented, path);\n return toJsonEntries(value, path);\n }\n return toJsonScalar(value, path);\n}\n\n/** JSON's four scalar forms. Anything else — a function, a class instance, a\n * non-finite number — is what the channel cannot carry. */\nfunction toJsonScalar(value: unknown, path: string): JsonValue {\n if (value === null || typeof value === \"string\" || typeof value === \"boolean\") return value;\n if (typeof value === \"number\" && Number.isFinite(value)) return value;\n throw new Error(`${path} is ${describeNonJson(value)}, which JSON cannot represent`);\n}\n\nfunction toJsonItems(items: unknown[], path: string): JsonValue[] {\n // An absent element becomes `null`, matching `JSON.stringify` — an array has\n // to keep its length, so a hole cannot simply be dropped the way a key is.\n // `Array.from` rather than `map`, which SKIPS holes and would leave them in\n // the result: `JSON.stringify` renders a hole as null and hides that, but\n // `1 in arr` / `Object.keys` / `forEach` all still see the gap.\n return Array.from(items, (entry, index) => (entry === undefined ? null : toJsonValue(entry, `${path}[${index}]`)));\n}\n\nfunction toJsonEntries(record: Record<string, unknown>, path: string): JsonObject {\n const usable = Object.entries(record).filter(([, value]) => value !== undefined);\n return Object.fromEntries(usable.map(([key, value]) => [key, toJsonValue(value, `${path}.${key}`)]));\n}\n\n/** Runtime counterpart to `toJsonObject`, for payloads whose values are typed\n * `unknown` — a collection record, a projected view row — so no amount of\n * mapped-type work can PROVE them JSON.\n *\n * Walks the payload and rebuilds it from the values it actually inspected, so\n * the returned `JsonObject` is earned rather than asserted. Absent (`undefined`)\n * properties are dropped exactly as `JSON.stringify` drops them; anything the\n * channel could not carry — a function, a class instance, `NaN` — throws\n * naming its path, instead of reaching Firestore as a silently mangled write. */\nexport const coerceJsonObject = (payload: Record<string, unknown>): JsonObject => toJsonEntries(payload, \"payload\");\n\n// A channel routes commands to one specific host. Both sides agree on a\n// hardcoded hostId per use case (e.g. \"mulmoclaude\", \"mulmoterminal\"); there is\n// no discovery — the remote and host just share the id.\nexport interface Channel {\n uid: string;\n hostId: string;\n}\n\nexport type CommandStatus = \"queued\" | \"processing\" | \"done\" | \"error\";\n\nexport interface CommandError {\n code: string;\n message: string;\n}\n\n// One document in a channel's commands subcollection is one API-call-like\n// request. The remote (mobile) writes method/params; the host writes\n// result/error/status.\nexport interface Command {\n method: string;\n params: JsonObject;\n status: CommandStatus;\n result: JsonValue;\n error: CommandError | null;\n createdBy: \"remote\" | \"host\";\n // Offline-queue fields (all optional; absent ⇒ pre-offline-queue behaviour, so\n // this is backward-compatible with every deployed client). Epoch-millisecond\n // NUMBERS set by the remote at enqueue time — deliberately plain numbers, not\n // Firestore Timestamps, so `isExpired` / `byCreatedAt` stay pure + browser-safe\n // and unit-testable without a Firestore fake. Clock skew over a multi-day expiry\n // window is immaterial. See plans/done/feat-remote-offline-queue.md.\n createdAt?: number; // enqueue time — age/display + best-effort dispatch bias (NOT a strict order guarantee; chat is async)\n expiresAt?: number; // deadline; past it the host deletes the command + its staged attachments\n queuedOffline?: boolean; // emitted while the host was offline (gates the remote's attachment rollback)\n}\n\n// A command is expired once `now` reaches its remote-set deadline. Absent\n// `expiresAt` ⇒ it never expires (pre-offline-queue commands). Pure with an\n// injected `now` for deterministic tests; the runner passes `Date.now()`.\nexport const isExpired = (command: Pick<Command, \"expiresAt\">, now: number): boolean => typeof command.expiresAt === \"number\" && now >= command.expiresAt;\n\n// Best-effort dispatch bias for a drained batch: oldest enqueue first. This is\n// NOT an ordering guarantee — commands run concurrently and may complete out of\n// order (chat is asynchronous, by design); it only nudges which one starts first.\n// A command with no `createdAt` sorts as oldest (0) so it is never starved.\nexport const byCreatedAt = (left: Pick<Command, \"createdAt\">, right: Pick<Command, \"createdAt\">): number => (left.createdAt ?? 0) - (right.createdAt ?? 0);\n\nexport type CommandHandler = (params: JsonObject) => JsonValue | Promise<JsonValue>;\nexport type CommandHandlers = Record<string, CommandHandler>;\n\n// Bumped when the command-channel wire protocol changes in a way the remote must\n// gate on. Advertised in the presence doc so the remote can check compatibility\n// before issuing commands.\n//\n// v2: offline queueing. The host honours `expiresAt` (deletes an expired command\n// + its staged attachments instead of spawning a stale chat). A remote MUST see\n// protocolVersion >= 2 before queueing a startChat while the host is offline —\n// a v1 host silently ignores `expiresAt`, so a queued chat would spawn stale on\n// reconnect with its uploads never cleaned up.\nexport const REMOTE_HOST_PROTOCOL_VERSION = 2;\n\n// The presence doc's payload: online flag + a capability advertisement. Written\n// by the host on every heartbeat; the remote reads it from the presence listener\n// it already runs (no extra round trip, known the instant the host is online).\n// Browser-safe so the mobile client compiles against the same shape.\n// `updatedAt` (a Firestore serverTimestamp) is added by the runner at write time\n// and is intentionally not part of this capability contract.\nexport interface HostPresence {\n online: boolean;\n hostId: string;\n protocolVersion: number;\n // Method names the host serves — the keys of the live handler table.\n capabilities: string[];\n}\n\n// Build the presence payload from the live handler table. Capabilities are\n// `Object.keys(handlers)` so registering a handler is the ONLY step needed to\n// advertise it — there is no second list to keep in sync.\nexport const buildHostPresence = (channel: Channel, handlers: CommandHandlers, online: boolean): HostPresence => ({\n online,\n hostId: channel.hostId,\n protocolVersion: REMOTE_HOST_PROTOCOL_VERSION,\n capabilities: Object.keys(handlers),\n});\n\n// Per-host command queue: users/{uid}/hosts/{hostId}/commands.\nexport const commandsCollection = (firestore: Firestore, channel: Channel): CollectionReference<DocumentData> =>\n collection(firestore, \"users\", channel.uid, \"hosts\", channel.hostId, \"commands\");\n\n// Presence doc for a host: users/{uid}/hosts/{hostId}. The host heartbeats\n// { online, updatedAt } here; the remote reads it to know if the host is up.\nexport const hostDoc = (firestore: Firestore, channel: Channel): DocumentReference<DocumentData> =>\n doc(firestore, \"users\", channel.uid, \"hosts\", channel.hostId);\n\n// Channel health as the resilient runner reports it. Browser-safe alongside the\n// wire types because the control that renders it runs in the client.\nexport { RUNNER_HEALTH_STATES, isRunnerHealth, isRunnerHealthState } from \"./health.js\";\nexport type { RunnerHealth, RunnerHealthState } from \"./health.js\";\n"],"mappings":";;;AAYA,IAAa,uBAAuB;CAAC;CAAU;CAAgB;AAAS;AAWxE,IAAM,YAAY,UAAqD,OAAO,UAAU,YAAY,UAAU;AAE9G,IAAa,uBAAuB,UAA+C,qBAAqB,MAAM,UAAU,UAAU,KAAK;;;AAIvI,IAAa,kBAAkB,UAC7B,SAAS,KAAK,KACd,oBAAoB,MAAM,KAAK,MAC9B,MAAM,cAAc,QAAQ,OAAO,MAAM,cAAc,aACxD,OAAO,MAAM,cAAc;;;;;;;;;ACsB7B,IAAa,gBAAkC,YAAoC;AAEnF,IAAM,mBAAmB,UAA2B;CAClD,IAAI,OAAO,UAAU,UAAU,OAAO,OAAO,KAAK;CAClD,IAAI,OAAO,UAAU,UAAU,OAAO;CACtC,OAAO,KAAK,OAAO;AACrB;;;;;AAMA,IAAM,aAAa,UAAsD,YAAY,SAAS,OAAO,MAAM,WAAW;AAEtH,IAAM,wBAAwB,UAA4B,UAAU,KAAK,IAAI,MAAM,OAAO,IAAI;;AAG9F,SAAS,YAAY,OAAgB,MAAyB;CAC5D,IAAI,MAAM,QAAQ,KAAK,GAAG,OAAO,YAAY,OAAO,IAAI;CACxD,IAAI,aAAA,SAAS,KAAK,GAAG;EACnB,MAAM,cAAc,qBAAqB,KAAK;EAC9C,IAAI,gBAAgB,OAAO,OAAO,YAAY,aAAa,IAAI;EAC/D,OAAO,cAAc,OAAO,IAAI;CAClC;CACA,OAAO,aAAa,OAAO,IAAI;AACjC;;;AAIA,SAAS,aAAa,OAAgB,MAAyB;CAC7D,IAAI,UAAU,QAAQ,OAAO,UAAU,YAAY,OAAO,UAAU,WAAW,OAAO;CACtF,IAAI,OAAO,UAAU,YAAY,OAAO,SAAS,KAAK,GAAG,OAAO;CAChE,MAAM,IAAI,MAAM,GAAG,KAAK,MAAM,gBAAgB,KAAK,EAAE,8BAA8B;AACrF;AAEA,SAAS,YAAY,OAAkB,MAA2B;CAMhE,OAAO,MAAM,KAAK,QAAQ,OAAO,UAAW,UAAU,KAAA,IAAY,OAAO,YAAY,OAAO,GAAG,KAAK,GAAG,MAAM,EAAE,CAAE;AACnH;AAEA,SAAS,cAAc,QAAiC,MAA0B;CAChF,MAAM,SAAS,OAAO,QAAQ,MAAM,CAAC,CAAC,QAAQ,GAAG,WAAW,UAAU,KAAA,CAAS;CAC/E,OAAO,OAAO,YAAY,OAAO,KAAK,CAAC,KAAK,WAAW,CAAC,KAAK,YAAY,OAAO,GAAG,KAAK,GAAG,KAAK,CAAC,CAAC,CAAC;AACrG;;;;;;;;;;AAWA,IAAa,oBAAoB,YAAiD,cAAc,SAAS,SAAS;AAyClH,IAAa,aAAa,SAAqC,QAAyB,OAAO,QAAQ,cAAc,YAAY,OAAO,QAAQ;AAMhJ,IAAa,eAAe,MAAkC,WAA+C,KAAK,aAAa,MAAM,MAAM,aAAa;AAcxJ,IAAa,+BAA+B;AAmB5C,IAAa,qBAAqB,SAAkB,UAA2B,YAAmC;CAChH;CACA,QAAQ,QAAQ;CAChB,iBAAA;CACA,cAAc,OAAO,KAAK,QAAQ;AACpC;AAGA,IAAa,sBAAsB,WAAsB,aAAA,GACvD,mBAAA,WAAA,CAAW,WAAW,SAAS,QAAQ,KAAK,SAAS,QAAQ,QAAQ,UAAU;AAIjF,IAAa,WAAW,WAAsB,aAAA,GAC5C,mBAAA,IAAA,CAAI,WAAW,SAAS,QAAQ,KAAK,SAAS,QAAQ,MAAM"}
|
|
1
|
+
{"version":3,"file":"remote-host-EQPlccB9.cjs","names":[],"sources":["../src/remote-host/health.ts","../src/remote-host/index.ts"],"sourcesContent":["// Health of the remote-host command channel, as reported by the resilient runner\n// and rendered by a host's toolbar control. Browser-safe on purpose: the client\n// narrows the parsed HTTP payload with the same guard the server writes it from,\n// so the two sides cannot drift on the state names.\n//\n// online — the Firestore subscription is up; the phone can reach this host\n// reconnecting — it died and is being re-subscribed with backoff (self-healing)\n// offline — re-subscribing stopped helping, or nothing is connected at all;\n// recovering needs a re-auth from the browser's parked session\n//\n// Deliberately no UI wording here — how a state reads to a user is each host's\n// i18n, and core owning it would make the shared package a translation authority.\nexport const RUNNER_HEALTH_STATES = [\"online\", \"reconnecting\", \"offline\"] as const;\nexport type RunnerHealthState = (typeof RUNNER_HEALTH_STATES)[number];\n\nexport interface RunnerHealth {\n state: RunnerHealthState;\n /** Last channel error seen, for the popover and the log. Null before the first one. */\n lastError: string | null;\n /** ms epoch of the last state change, so the UI can say how long it has been down. */\n changedAt: number;\n}\n\nconst isRecord = (value: unknown): value is Record<string, unknown> => typeof value === \"object\" && value !== null;\n\nexport const isRunnerHealthState = (value: unknown): value is RunnerHealthState => RUNNER_HEALTH_STATES.some((state) => state === value);\n\n/** Narrows a parsed HTTP payload. The client renders whatever this accepts, so a\n * half-shaped health has to read as \"no health reported\" rather than as a state. */\nexport const isRunnerHealth = (value: unknown): value is RunnerHealth =>\n isRecord(value) &&\n isRunnerHealthState(value.state) &&\n (value.lastError === null || typeof value.lastError === \"string\") &&\n typeof value.changedAt === \"number\";\n","// Remote-host command-channel protocol — the browser-safe contract shared by a\n// host (MulmoClaude, MulmoTerminal) and the remote/mobile client (mulmoserver).\n//\n// A host signs in to Firebase as the user, listens to that user's per-host\n// command queue in Firestore, runs a handler, and writes the result back; the\n// remote writes commands and reads results via a real-time listener. This module\n// owns the wire types + the Firestore path helpers. It is the single source of\n// truth so the host runner and the client never drift on the protocol.\n//\n// Ported from ../mulmoserver/src/firestore/commandChannel.ts and the per-host\n// copy that lived in MulmoClaude's server/remoteHost/. The one change vs. those\n// copies: the path helpers take the `firestore` instance as a parameter (rather\n// than importing a module-level singleton) so a single extracted module serves\n// every host's own Firebase init. The hostId is host-specific (\"mulmoclaude\",\n// \"mulmoterminal\") and is supplied by each host — there is no discovery.\nimport { CollectionReference, DocumentData, DocumentReference, Firestore, collection, doc } from \"firebase/firestore\";\nimport { isRecord } from \"@mulmoclaude/common\";\n\n// JSON payloads carried by the command channel. Explicit JSON types keep the\n// channel typed without resorting to any/unknown.\nexport type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };\nexport type JsonObject = Record<string, JsonValue>;\n\n/** Structural JSON view of `T`, recursively.\n *\n * TypeScript gives an implicit index signature to type aliases and mapped\n * types but NOT to interfaces, so a payload assembled from domain interfaces\n * (`Shortcut`, `FeedSummary`, …) cannot satisfy `Record<string, JsonValue>`\n * structurally — even though it is plain JSON at runtime. Mapping over `T`\n * reconstructs it as an anonymous type, which does get that index signature.\n *\n * Recursive on purpose: a top-level-only map would still leave nested\n * interfaces (`{ shortcuts: Shortcut[] }`) unassignable, which is the case\n * every handler here actually has. */\n// The function branch must come BEFORE the object branch: a function IS an\n// object to TypeScript, so without it a function maps to `{}` and sails\n// through — the helper would accept a payload that serialises to nothing.\n// Verified: `toJsonObject({ callback: () => undefined })` compiled clean until\n// this branch existed (CodeRabbit, #2596).\nexport type Jsonify<T> = T extends JsonValue\n ? T\n : T extends (...args: never[]) => unknown\n ? never\n : T extends (infer U)[]\n ? Jsonify<U>[]\n : T extends object\n ? { [K in keyof T]: Jsonify<T[K]> }\n : never;\n\n/** Widen a JSON-shaped handler payload to the channel's `JsonObject`.\n *\n * Exists so the `Jsonify` reasoning above lives in ONE place. Before this,\n * eight remote-host handlers each carried their own `as unknown as JsonObject`\n * with the justification re-argued in eight slightly different comments —\n * which is how a rule stops being reviewable. */\nexport const toJsonObject = <T extends object>(payload: Jsonify<T>): JsonObject => payload as JsonObject;\n\nconst describeNonJson = (value: unknown): string => {\n if (typeof value === \"number\") return String(value);\n if (typeof value === \"object\") return \"a non-plain object\";\n return `a ${typeof value}`;\n};\n\n/** Anything carrying its own JSON form — `Date` above all — must be asked for\n * it rather than walked, because walking a `Date`'s own enumerable keys finds\n * none and flattens the timestamp to `{}`. This is the step `JSON.stringify`\n * performs before it recurses, and the channel used to get it for free. */\nconst hasToJson = (value: object): value is { toJSON: () => unknown } => \"toJSON\" in value && typeof value.toJSON === \"function\";\n\nconst jsonRepresentationOf = (value: object): unknown => (hasToJson(value) ? value.toJSON() : value);\n\n/** Rebuild `value` as JSON, or throw naming the property that cannot be. */\nfunction toJsonValue(value: unknown, path: string): JsonValue {\n if (Array.isArray(value)) return toJsonItems(value, path);\n if (isRecord(value)) {\n const represented = jsonRepresentationOf(value);\n if (represented !== value) return toJsonValue(represented, path);\n return toJsonEntries(value, path);\n }\n return toJsonScalar(value, path);\n}\n\n/** JSON's four scalar forms. Anything else — a function, a class instance, a\n * non-finite number — is what the channel cannot carry. */\nfunction toJsonScalar(value: unknown, path: string): JsonValue {\n if (value === null || typeof value === \"string\" || typeof value === \"boolean\") return value;\n if (typeof value === \"number\" && Number.isFinite(value)) return value;\n throw new Error(`${path} is ${describeNonJson(value)}, which JSON cannot represent`);\n}\n\nfunction toJsonItems(items: unknown[], path: string): JsonValue[] {\n // An absent element becomes `null`, matching `JSON.stringify` — an array has\n // to keep its length, so a hole cannot simply be dropped the way a key is.\n // `Array.from` rather than `map`, which SKIPS holes and would leave them in\n // the result: `JSON.stringify` renders a hole as null and hides that, but\n // `1 in arr` / `Object.keys` / `forEach` all still see the gap.\n return Array.from(items, (entry, index) => (entry === undefined ? null : toJsonValue(entry, `${path}[${index}]`)));\n}\n\nfunction toJsonEntries(record: Record<string, unknown>, path: string): JsonObject {\n const usable = Object.entries(record).filter(([, value]) => value !== undefined);\n return Object.fromEntries(usable.map(([key, value]) => [key, toJsonValue(value, `${path}.${key}`)]));\n}\n\n/** Runtime counterpart to `toJsonObject`, for payloads whose values are typed\n * `unknown` — a collection record, a projected view row — so no amount of\n * mapped-type work can PROVE them JSON.\n *\n * Walks the payload and rebuilds it from the values it actually inspected, so\n * the returned `JsonObject` is earned rather than asserted. Absent (`undefined`)\n * properties are dropped exactly as `JSON.stringify` drops them; anything the\n * channel could not carry — a function, a class instance, `NaN` — throws\n * naming its path, instead of reaching Firestore as a silently mangled write. */\nexport const coerceJsonObject = (payload: Record<string, unknown>): JsonObject => toJsonEntries(payload, \"payload\");\n\n// A channel routes commands to one specific host. Both sides agree on a\n// hardcoded hostId per use case (e.g. \"mulmoclaude\", \"mulmoterminal\"); there is\n// no discovery — the remote and host just share the id.\nexport interface Channel {\n uid: string;\n hostId: string;\n}\n\nexport type CommandStatus = \"queued\" | \"processing\" | \"done\" | \"error\";\n\nexport interface CommandError {\n code: string;\n message: string;\n}\n\n// One document in a channel's commands subcollection is one API-call-like\n// request. The remote (mobile) writes method/params; the host writes\n// result/error/status.\nexport interface Command {\n method: string;\n params: JsonObject;\n status: CommandStatus;\n result: JsonValue;\n error: CommandError | null;\n createdBy: \"remote\" | \"host\";\n // Offline-queue fields (all optional; absent ⇒ pre-offline-queue behaviour, so\n // this is backward-compatible with every deployed client). Epoch-millisecond\n // NUMBERS set by the remote at enqueue time — deliberately plain numbers, not\n // Firestore Timestamps, so `isExpired` / `byCreatedAt` stay pure + browser-safe\n // and unit-testable without a Firestore fake. Clock skew over a multi-day expiry\n // window is immaterial. See plans/done/feat-remote-offline-queue.md.\n createdAt?: number; // enqueue time — age/display + best-effort dispatch bias (NOT a strict order guarantee; chat is async)\n expiresAt?: number; // deadline; past it the host deletes the command + its staged attachments\n queuedOffline?: boolean; // emitted while the host was offline (gates the remote's attachment rollback)\n}\n\n// A command is expired once `now` reaches its remote-set deadline. Absent\n// `expiresAt` ⇒ it never expires (pre-offline-queue commands). Pure with an\n// injected `now` for deterministic tests; the runner passes `Date.now()`.\nexport const isExpired = (command: Pick<Command, \"expiresAt\">, now: number): boolean => typeof command.expiresAt === \"number\" && now >= command.expiresAt;\n\n// Best-effort dispatch bias for a drained batch: oldest enqueue first. This is\n// NOT an ordering guarantee — commands run concurrently and may complete out of\n// order (chat is asynchronous, by design); it only nudges which one starts first.\n// A command with no `createdAt` sorts as oldest (0) so it is never starved.\nexport const byCreatedAt = (left: Pick<Command, \"createdAt\">, right: Pick<Command, \"createdAt\">): number => (left.createdAt ?? 0) - (right.createdAt ?? 0);\n\nexport type CommandHandler = (params: JsonObject) => JsonValue | Promise<JsonValue>;\nexport type CommandHandlers = Record<string, CommandHandler>;\n\n/** The param name a collection-serving command uses to say WHICH project's\n * collections it means. Reserved and documented now, before a phone client\n * ships, because the parts that are hard to change later are the ones being\n * written today. Four rules go with it:\n *\n * 1. **It is an OPAQUE scope, never a path.** The phone is a genuinely remote\n * client; an absolute root in a command, an artifact or a token publishes\n * the user's home directory over the wire. Mint a project id host-side and\n * resolve it host-side.\n * 2. **The phone must be able to LEARN the list.** A picker needs\n * `{ id, label }` pairs from the host — a command of its own, or a field on\n * an existing listing. Designing the scope value now is what keeps that the\n * ONLY new thing when the feature lands.\n * 3. **Handlers RESOLVE a scope; they do not hard-code one.** Write each\n * collection handler as \"read the scope from params, defaulting to the\n * host's root\" rather than calling the workspace accessor inline. Today\n * every call resolves the default and behaves exactly as it does now; the\n * day the param arrives, no handler changes.\n * 4. **The artifact stays host-built.** A remote view's srcdoc, its inlined\n * image thumbnails and its token are assembled on the host, so the phone\n * never resolves a path itself. That is what makes (1) hold without\n * trusting the client. */\nexport const COMMAND_SCOPE_PARAM = \"project\";\n\n/** Read the opaque project scope off a command's params, or `undefined` for\n * \"the host's own root\" — which is what every command means today. A\n * non-string (or empty) value is treated as absent rather than as an error: a\n * scope the host cannot resolve must fall back to the default, never to a\n * guess. Hosts pass the result to their own id → root resolver; this module\n * deliberately never sees a path. */\nexport const readCommandScope = (params: JsonObject): string | undefined => {\n const raw = params[COMMAND_SCOPE_PARAM];\n return typeof raw === \"string\" && raw.trim().length > 0 ? raw.trim() : undefined;\n};\n\n// Bumped when the command-channel wire protocol changes in a way the remote must\n// gate on. Advertised in the presence doc so the remote can check compatibility\n// before issuing commands.\n//\n// v2: offline queueing. The host honours `expiresAt` (deletes an expired command\n// + its staged attachments instead of spawning a stale chat). A remote MUST see\n// protocolVersion >= 2 before queueing a startChat while the host is offline —\n// a v1 host silently ignores `expiresAt`, so a queued chat would spawn stale on\n// reconnect with its uploads never cleaned up.\nexport const REMOTE_HOST_PROTOCOL_VERSION = 2;\n\n// The presence doc's payload: online flag + a capability advertisement. Written\n// by the host on every heartbeat; the remote reads it from the presence listener\n// it already runs (no extra round trip, known the instant the host is online).\n// Browser-safe so the mobile client compiles against the same shape.\n// `updatedAt` (a Firestore serverTimestamp) is added by the runner at write time\n// and is intentionally not part of this capability contract.\nexport interface HostPresence {\n online: boolean;\n hostId: string;\n protocolVersion: number;\n // Method names the host serves — the keys of the live handler table.\n capabilities: string[];\n}\n\n// Build the presence payload from the live handler table. Capabilities are\n// `Object.keys(handlers)` so registering a handler is the ONLY step needed to\n// advertise it — there is no second list to keep in sync.\nexport const buildHostPresence = (channel: Channel, handlers: CommandHandlers, online: boolean): HostPresence => ({\n online,\n hostId: channel.hostId,\n protocolVersion: REMOTE_HOST_PROTOCOL_VERSION,\n capabilities: Object.keys(handlers),\n});\n\n// Per-host command queue: users/{uid}/hosts/{hostId}/commands.\nexport const commandsCollection = (firestore: Firestore, channel: Channel): CollectionReference<DocumentData> =>\n collection(firestore, \"users\", channel.uid, \"hosts\", channel.hostId, \"commands\");\n\n// Presence doc for a host: users/{uid}/hosts/{hostId}. The host heartbeats\n// { online, updatedAt } here; the remote reads it to know if the host is up.\nexport const hostDoc = (firestore: Firestore, channel: Channel): DocumentReference<DocumentData> =>\n doc(firestore, \"users\", channel.uid, \"hosts\", channel.hostId);\n\n// Channel health as the resilient runner reports it. Browser-safe alongside the\n// wire types because the control that renders it runs in the client.\nexport { RUNNER_HEALTH_STATES, isRunnerHealth, isRunnerHealthState } from \"./health.js\";\nexport type { RunnerHealth, RunnerHealthState } from \"./health.js\";\n"],"mappings":";;;AAYA,IAAa,uBAAuB;CAAC;CAAU;CAAgB;AAAS;AAWxE,IAAM,YAAY,UAAqD,OAAO,UAAU,YAAY,UAAU;AAE9G,IAAa,uBAAuB,UAA+C,qBAAqB,MAAM,UAAU,UAAU,KAAK;;;AAIvI,IAAa,kBAAkB,UAC7B,SAAS,KAAK,KACd,oBAAoB,MAAM,KAAK,MAC9B,MAAM,cAAc,QAAQ,OAAO,MAAM,cAAc,aACxD,OAAO,MAAM,cAAc;;;;;;;;;ACsB7B,IAAa,gBAAkC,YAAoC;AAEnF,IAAM,mBAAmB,UAA2B;CAClD,IAAI,OAAO,UAAU,UAAU,OAAO,OAAO,KAAK;CAClD,IAAI,OAAO,UAAU,UAAU,OAAO;CACtC,OAAO,KAAK,OAAO;AACrB;;;;;AAMA,IAAM,aAAa,UAAsD,YAAY,SAAS,OAAO,MAAM,WAAW;AAEtH,IAAM,wBAAwB,UAA4B,UAAU,KAAK,IAAI,MAAM,OAAO,IAAI;;AAG9F,SAAS,YAAY,OAAgB,MAAyB;CAC5D,IAAI,MAAM,QAAQ,KAAK,GAAG,OAAO,YAAY,OAAO,IAAI;CACxD,IAAI,aAAA,SAAS,KAAK,GAAG;EACnB,MAAM,cAAc,qBAAqB,KAAK;EAC9C,IAAI,gBAAgB,OAAO,OAAO,YAAY,aAAa,IAAI;EAC/D,OAAO,cAAc,OAAO,IAAI;CAClC;CACA,OAAO,aAAa,OAAO,IAAI;AACjC;;;AAIA,SAAS,aAAa,OAAgB,MAAyB;CAC7D,IAAI,UAAU,QAAQ,OAAO,UAAU,YAAY,OAAO,UAAU,WAAW,OAAO;CACtF,IAAI,OAAO,UAAU,YAAY,OAAO,SAAS,KAAK,GAAG,OAAO;CAChE,MAAM,IAAI,MAAM,GAAG,KAAK,MAAM,gBAAgB,KAAK,EAAE,8BAA8B;AACrF;AAEA,SAAS,YAAY,OAAkB,MAA2B;CAMhE,OAAO,MAAM,KAAK,QAAQ,OAAO,UAAW,UAAU,KAAA,IAAY,OAAO,YAAY,OAAO,GAAG,KAAK,GAAG,MAAM,EAAE,CAAE;AACnH;AAEA,SAAS,cAAc,QAAiC,MAA0B;CAChF,MAAM,SAAS,OAAO,QAAQ,MAAM,CAAC,CAAC,QAAQ,GAAG,WAAW,UAAU,KAAA,CAAS;CAC/E,OAAO,OAAO,YAAY,OAAO,KAAK,CAAC,KAAK,WAAW,CAAC,KAAK,YAAY,OAAO,GAAG,KAAK,GAAG,KAAK,CAAC,CAAC,CAAC;AACrG;;;;;;;;;;AAWA,IAAa,oBAAoB,YAAiD,cAAc,SAAS,SAAS;AAyClH,IAAa,aAAa,SAAqC,QAAyB,OAAO,QAAQ,cAAc,YAAY,OAAO,QAAQ;AAMhJ,IAAa,eAAe,MAAkC,WAA+C,KAAK,aAAa,MAAM,MAAM,aAAa;;;;;;;;;;;;;;;;;;;;;;;AA2BxJ,IAAa,sBAAsB;;;;;;;AAQnC,IAAa,oBAAoB,WAA2C;CAC1E,MAAM,MAAM,OAAO;CACnB,OAAO,OAAO,QAAQ,YAAY,IAAI,KAAK,CAAC,CAAC,SAAS,IAAI,IAAI,KAAK,IAAI,KAAA;AACzE;AAWA,IAAa,+BAA+B;AAmB5C,IAAa,qBAAqB,SAAkB,UAA2B,YAAmC;CAChH;CACA,QAAQ,QAAQ;CAChB,iBAAA;CACA,cAAc,OAAO,KAAK,QAAQ;AACpC;AAGA,IAAa,sBAAsB,WAAsB,aAAA,GACvD,mBAAA,WAAA,CAAW,WAAW,SAAS,QAAQ,KAAK,SAAS,QAAQ,QAAQ,UAAU;AAIjF,IAAa,WAAW,WAAsB,aAAA,GAC5C,mBAAA,IAAA,CAAI,WAAW,SAAS,QAAQ,KAAK,SAAS,QAAQ,MAAM"}
|
|
@@ -84,6 +84,25 @@ function writeFileAtomicSync(filePath, content, opts = {}) {
|
|
|
84
84
|
}
|
|
85
85
|
}
|
|
86
86
|
//#endregion
|
|
87
|
-
|
|
87
|
+
//#region src/files/root.ts
|
|
88
|
+
/** The canonical form of a root, for every place a root becomes an IDENTITY
|
|
89
|
+
* rather than a path to read.
|
|
90
|
+
*
|
|
91
|
+
* `path.resolve` only — it collapses `.`/`..` and the trailing separator, so
|
|
92
|
+
* `/work/project` and `/work/project/` are one root instead of two watcher
|
|
93
|
+
* generations over the same tree, two bells per record, and two scheduled
|
|
94
|
+
* refresh jobs.
|
|
95
|
+
*
|
|
96
|
+
* Deliberately NOT `realpath`. Resolving symlinks is async (so it could not run
|
|
97
|
+
* on a synchronous claim path), it fails for a root that does not exist yet,
|
|
98
|
+
* and it would put a path the host never named into an id that is written to
|
|
99
|
+
* disk. The policy is therefore lexical: a host that hands the same tree under
|
|
100
|
+
* two different symlink spellings gets two roots, and it is the host's job to
|
|
101
|
+
* name a project the same way every time. */
|
|
102
|
+
function canonicalRoot(root) {
|
|
103
|
+
return path.resolve(root);
|
|
104
|
+
}
|
|
105
|
+
//#endregion
|
|
106
|
+
export { writeFileAtomic as n, writeFileAtomicSync as r, canonicalRoot as t };
|
|
88
107
|
|
|
89
|
-
//# sourceMappingURL=
|
|
108
|
+
//# sourceMappingURL=root-BMroU_mB.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"root-BMroU_mB.js","names":[],"sources":["../src/files/atomic.ts","../src/files/root.ts"],"sourcesContent":["// rename(2) is atomic on POSIX; Node's Windows fallback (copy+unlink) is still safer than truncating in place.\n// Readers always see either the old file or the new — never a half-written one.\n//\n// Single source of truth for atomic file writes across the whole monorepo\n// (host, core, plugins). Previously copy-pasted four times, so a Windows retry\n// fix landed in only one (#2399, precedent #2222).\n\nimport { mkdirSync, promises, renameSync, unlinkSync, writeFileSync } from \"node:fs\";\nimport path from \"node:path\";\nimport { randomBytes } from \"node:crypto\";\n\nexport interface WriteAtomicOptions {\n mode?: number;\n /** Give the staging file a random suffix so concurrent writers to the same\n * destination can't collide at the OS layer.\n *\n * Defaults to `true`, and opting out is almost always wrong. A shared\n * `${filePath}.tmp` means two writers of one file race: the second write\n * overwrites the first's staging file, or one rename/unlink pulls it out\n * from under the other, surfacing as `ENOENT … rename '<file>.tmp'`. That is\n * not theoretical — it fired in production on session meta, where ten\n * distinct callers (`setClaudeSessionId`, `backfillOrigin`,\n * `incrementUserQueryCount`, …) all write the same `<sessionId>.json`\n * (#2222).\n *\n * Pass `false` only when you specifically need the staging path to be\n * predictable (e.g. a single-writer token file, or a test that pre-creates\n * it to force a write failure). */\n uniqueTmp?: boolean;\n}\n\n// Unique staging names are the safe default: the cost is a random suffix, the\n// cost of the alternative is a lost update under concurrency (#2222).\nconst DEFAULT_UNIQUE_TMP = true;\n\nfunction tmpPathFor(filePath: string, uniqueTmp: boolean | undefined): string {\n return (uniqueTmp ?? DEFAULT_UNIQUE_TMP) ? `${filePath}.${randomBytes(6).toString(\"hex\")}.tmp` : `${filePath}.tmp`;\n}\n\n// On Windows, AV / Search Indexer / Defender briefly hold handles and rename trips EPERM/EBUSY/EACCES. Retry loop is\n// gated to Windows because POSIX EPERM means a real perm problem (read-only fs, sticky, cross-device) — retrying\n// just adds latency before the inevitable throw.\nconst IS_WINDOWS = process.platform === \"win32\";\nconst RENAME_RETRY_DELAYS_MS = [30, 100, 300] as const;\n\nfunction hasErrnoCode(err: unknown): err is { code: string } {\n return typeof err === \"object\" && err !== null && \"code\" in err && typeof err.code === \"string\";\n}\n\n// `isWindows` is a parameter (defaulting to the real platform) so the safety-critical decision is testable on any OS.\nexport function isTransientRenameError(err: unknown, isWindows: boolean = IS_WINDOWS): boolean {\n if (!isWindows || !hasErrnoCode(err)) return false;\n return err.code === \"EPERM\" || err.code === \"EBUSY\" || err.code === \"EACCES\";\n}\n\n// Injectable so a test can drive the retry path (fail-then-succeed rename, no-op sleep, isWindows=true) on any OS.\nexport interface RenameRetryDeps {\n rename: (fromPath: string, toPath: string) => Promise<void>;\n sleep: (millis: number) => Promise<void>;\n isWindows: boolean;\n}\n\nconst defaultRenameRetryDeps: RenameRetryDeps = {\n rename: (fromPath, toPath) => promises.rename(fromPath, toPath),\n sleep: (millis) => new Promise((resolve) => setTimeout(resolve, millis)),\n isWindows: IS_WINDOWS,\n};\n\nexport async function renameWithWindowsRetry(fromPath: string, toPath: string, deps: RenameRetryDeps = defaultRenameRetryDeps): Promise<void> {\n for (const delayMs of RENAME_RETRY_DELAYS_MS) {\n try {\n await deps.rename(fromPath, toPath);\n return;\n } catch (err) {\n if (!isTransientRenameError(err, deps.isWindows)) throw err;\n await deps.sleep(delayMs);\n }\n }\n // Final attempt — let any error propagate.\n await deps.rename(fromPath, toPath);\n}\n\n// Atomics.wait parks the thread instead of busy-spinning. Only on the Windows-rename retry path, total ≤ ~430ms.\nconst SYNC_SLEEP_BUF = new Int32Array(new SharedArrayBuffer(4));\nfunction sleepSync(millis: number): void {\n Atomics.wait(SYNC_SLEEP_BUF, 0, 0, millis);\n}\n\n// Deliberate async/sync twin of RenameRetryDeps: `sleep` blocks the thread instead of returning a Promise.\nexport interface RenameRetryDepsSync {\n rename: (fromPath: string, toPath: string) => void;\n sleep: (millis: number) => void;\n isWindows: boolean;\n}\n\nconst defaultRenameRetryDepsSync: RenameRetryDepsSync = {\n rename: (fromPath, toPath) => renameSync(fromPath, toPath),\n sleep: sleepSync,\n isWindows: IS_WINDOWS,\n};\n\nexport function renameSyncWithWindowsRetry(fromPath: string, toPath: string, deps: RenameRetryDepsSync = defaultRenameRetryDepsSync): void {\n for (const delayMs of RENAME_RETRY_DELAYS_MS) {\n try {\n deps.rename(fromPath, toPath);\n return;\n } catch (err) {\n if (!isTransientRenameError(err, deps.isWindows)) throw err;\n deps.sleep(delayMs);\n }\n }\n deps.rename(fromPath, toPath);\n}\n\n// Forcing utf-8 on a Uint8Array would re-encode the bytes — wrong for PNGs and other binary blobs.\nfunction writeOptionsFor(content: string | Uint8Array, mode: number | undefined): { encoding?: \"utf-8\" | undefined; mode?: number | undefined } {\n return typeof content === \"string\" ? { encoding: \"utf-8\", mode } : { mode };\n}\n\nexport async function writeFileAtomic(filePath: string, content: string | Uint8Array, opts: WriteAtomicOptions = {}): Promise<void> {\n const tmp = tmpPathFor(filePath, opts.uniqueTmp);\n await promises.mkdir(path.dirname(filePath), { recursive: true });\n try {\n await promises.writeFile(tmp, content, writeOptionsFor(content, opts.mode));\n await renameWithWindowsRetry(tmp, filePath);\n } catch (err) {\n await promises.unlink(tmp).catch(() => {});\n throw err;\n }\n}\n\nexport function writeFileAtomicSync(filePath: string, content: string | Uint8Array, opts: WriteAtomicOptions = {}): void {\n const tmp = tmpPathFor(filePath, opts.uniqueTmp);\n mkdirSync(path.dirname(filePath), { recursive: true });\n try {\n writeFileSync(tmp, content, writeOptionsFor(content, opts.mode));\n renameSyncWithWindowsRetry(tmp, filePath);\n } catch (err) {\n try {\n unlinkSync(tmp);\n } catch {\n // best-effort cleanup\n }\n throw err;\n }\n}\n","// The canonical form of a workspace / project root.\n//\n// Lives here rather than in the collection engine because a root is an IDENTITY\n// in several subsystems that do not depend on each other — a watcher generation\n// key, a change payload, a completion-bell id, a scheduled task id — and every\n// one of them has to agree on it or the same project registers twice.\n\nimport path from \"node:path\";\n\n/** The canonical form of a root, for every place a root becomes an IDENTITY\n * rather than a path to read.\n *\n * `path.resolve` only — it collapses `.`/`..` and the trailing separator, so\n * `/work/project` and `/work/project/` are one root instead of two watcher\n * generations over the same tree, two bells per record, and two scheduled\n * refresh jobs.\n *\n * Deliberately NOT `realpath`. Resolving symlinks is async (so it could not run\n * on a synchronous claim path), it fails for a root that does not exist yet,\n * and it would put a path the host never named into an id that is written to\n * disk. The policy is therefore lexical: a host that hands the same tree under\n * two different symlink spellings gets two roots, and it is the host's job to\n * name a project the same way every time. */\nexport function canonicalRoot(root: string): string {\n return path.resolve(root);\n}\n"],"mappings":"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"}
|
|
@@ -86,6 +86,31 @@ function writeFileAtomicSync(filePath, content, opts = {}) {
|
|
|
86
86
|
}
|
|
87
87
|
}
|
|
88
88
|
//#endregion
|
|
89
|
+
//#region src/files/root.ts
|
|
90
|
+
/** The canonical form of a root, for every place a root becomes an IDENTITY
|
|
91
|
+
* rather than a path to read.
|
|
92
|
+
*
|
|
93
|
+
* `path.resolve` only — it collapses `.`/`..` and the trailing separator, so
|
|
94
|
+
* `/work/project` and `/work/project/` are one root instead of two watcher
|
|
95
|
+
* generations over the same tree, two bells per record, and two scheduled
|
|
96
|
+
* refresh jobs.
|
|
97
|
+
*
|
|
98
|
+
* Deliberately NOT `realpath`. Resolving symlinks is async (so it could not run
|
|
99
|
+
* on a synchronous claim path), it fails for a root that does not exist yet,
|
|
100
|
+
* and it would put a path the host never named into an id that is written to
|
|
101
|
+
* disk. The policy is therefore lexical: a host that hands the same tree under
|
|
102
|
+
* two different symlink spellings gets two roots, and it is the host's job to
|
|
103
|
+
* name a project the same way every time. */
|
|
104
|
+
function canonicalRoot(root) {
|
|
105
|
+
return node_path.default.resolve(root);
|
|
106
|
+
}
|
|
107
|
+
//#endregion
|
|
108
|
+
Object.defineProperty(exports, "canonicalRoot", {
|
|
109
|
+
enumerable: true,
|
|
110
|
+
get: function() {
|
|
111
|
+
return canonicalRoot;
|
|
112
|
+
}
|
|
113
|
+
});
|
|
89
114
|
Object.defineProperty(exports, "writeFileAtomic", {
|
|
90
115
|
enumerable: true,
|
|
91
116
|
get: function() {
|
|
@@ -99,4 +124,4 @@ Object.defineProperty(exports, "writeFileAtomicSync", {
|
|
|
99
124
|
}
|
|
100
125
|
});
|
|
101
126
|
|
|
102
|
-
//# sourceMappingURL=
|
|
127
|
+
//# sourceMappingURL=root-rPH6FGDT.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"root-rPH6FGDT.cjs","names":[],"sources":["../src/files/atomic.ts","../src/files/root.ts"],"sourcesContent":["// rename(2) is atomic on POSIX; Node's Windows fallback (copy+unlink) is still safer than truncating in place.\n// Readers always see either the old file or the new — never a half-written one.\n//\n// Single source of truth for atomic file writes across the whole monorepo\n// (host, core, plugins). Previously copy-pasted four times, so a Windows retry\n// fix landed in only one (#2399, precedent #2222).\n\nimport { mkdirSync, promises, renameSync, unlinkSync, writeFileSync } from \"node:fs\";\nimport path from \"node:path\";\nimport { randomBytes } from \"node:crypto\";\n\nexport interface WriteAtomicOptions {\n mode?: number;\n /** Give the staging file a random suffix so concurrent writers to the same\n * destination can't collide at the OS layer.\n *\n * Defaults to `true`, and opting out is almost always wrong. A shared\n * `${filePath}.tmp` means two writers of one file race: the second write\n * overwrites the first's staging file, or one rename/unlink pulls it out\n * from under the other, surfacing as `ENOENT … rename '<file>.tmp'`. That is\n * not theoretical — it fired in production on session meta, where ten\n * distinct callers (`setClaudeSessionId`, `backfillOrigin`,\n * `incrementUserQueryCount`, …) all write the same `<sessionId>.json`\n * (#2222).\n *\n * Pass `false` only when you specifically need the staging path to be\n * predictable (e.g. a single-writer token file, or a test that pre-creates\n * it to force a write failure). */\n uniqueTmp?: boolean;\n}\n\n// Unique staging names are the safe default: the cost is a random suffix, the\n// cost of the alternative is a lost update under concurrency (#2222).\nconst DEFAULT_UNIQUE_TMP = true;\n\nfunction tmpPathFor(filePath: string, uniqueTmp: boolean | undefined): string {\n return (uniqueTmp ?? DEFAULT_UNIQUE_TMP) ? `${filePath}.${randomBytes(6).toString(\"hex\")}.tmp` : `${filePath}.tmp`;\n}\n\n// On Windows, AV / Search Indexer / Defender briefly hold handles and rename trips EPERM/EBUSY/EACCES. Retry loop is\n// gated to Windows because POSIX EPERM means a real perm problem (read-only fs, sticky, cross-device) — retrying\n// just adds latency before the inevitable throw.\nconst IS_WINDOWS = process.platform === \"win32\";\nconst RENAME_RETRY_DELAYS_MS = [30, 100, 300] as const;\n\nfunction hasErrnoCode(err: unknown): err is { code: string } {\n return typeof err === \"object\" && err !== null && \"code\" in err && typeof err.code === \"string\";\n}\n\n// `isWindows` is a parameter (defaulting to the real platform) so the safety-critical decision is testable on any OS.\nexport function isTransientRenameError(err: unknown, isWindows: boolean = IS_WINDOWS): boolean {\n if (!isWindows || !hasErrnoCode(err)) return false;\n return err.code === \"EPERM\" || err.code === \"EBUSY\" || err.code === \"EACCES\";\n}\n\n// Injectable so a test can drive the retry path (fail-then-succeed rename, no-op sleep, isWindows=true) on any OS.\nexport interface RenameRetryDeps {\n rename: (fromPath: string, toPath: string) => Promise<void>;\n sleep: (millis: number) => Promise<void>;\n isWindows: boolean;\n}\n\nconst defaultRenameRetryDeps: RenameRetryDeps = {\n rename: (fromPath, toPath) => promises.rename(fromPath, toPath),\n sleep: (millis) => new Promise((resolve) => setTimeout(resolve, millis)),\n isWindows: IS_WINDOWS,\n};\n\nexport async function renameWithWindowsRetry(fromPath: string, toPath: string, deps: RenameRetryDeps = defaultRenameRetryDeps): Promise<void> {\n for (const delayMs of RENAME_RETRY_DELAYS_MS) {\n try {\n await deps.rename(fromPath, toPath);\n return;\n } catch (err) {\n if (!isTransientRenameError(err, deps.isWindows)) throw err;\n await deps.sleep(delayMs);\n }\n }\n // Final attempt — let any error propagate.\n await deps.rename(fromPath, toPath);\n}\n\n// Atomics.wait parks the thread instead of busy-spinning. Only on the Windows-rename retry path, total ≤ ~430ms.\nconst SYNC_SLEEP_BUF = new Int32Array(new SharedArrayBuffer(4));\nfunction sleepSync(millis: number): void {\n Atomics.wait(SYNC_SLEEP_BUF, 0, 0, millis);\n}\n\n// Deliberate async/sync twin of RenameRetryDeps: `sleep` blocks the thread instead of returning a Promise.\nexport interface RenameRetryDepsSync {\n rename: (fromPath: string, toPath: string) => void;\n sleep: (millis: number) => void;\n isWindows: boolean;\n}\n\nconst defaultRenameRetryDepsSync: RenameRetryDepsSync = {\n rename: (fromPath, toPath) => renameSync(fromPath, toPath),\n sleep: sleepSync,\n isWindows: IS_WINDOWS,\n};\n\nexport function renameSyncWithWindowsRetry(fromPath: string, toPath: string, deps: RenameRetryDepsSync = defaultRenameRetryDepsSync): void {\n for (const delayMs of RENAME_RETRY_DELAYS_MS) {\n try {\n deps.rename(fromPath, toPath);\n return;\n } catch (err) {\n if (!isTransientRenameError(err, deps.isWindows)) throw err;\n deps.sleep(delayMs);\n }\n }\n deps.rename(fromPath, toPath);\n}\n\n// Forcing utf-8 on a Uint8Array would re-encode the bytes — wrong for PNGs and other binary blobs.\nfunction writeOptionsFor(content: string | Uint8Array, mode: number | undefined): { encoding?: \"utf-8\" | undefined; mode?: number | undefined } {\n return typeof content === \"string\" ? { encoding: \"utf-8\", mode } : { mode };\n}\n\nexport async function writeFileAtomic(filePath: string, content: string | Uint8Array, opts: WriteAtomicOptions = {}): Promise<void> {\n const tmp = tmpPathFor(filePath, opts.uniqueTmp);\n await promises.mkdir(path.dirname(filePath), { recursive: true });\n try {\n await promises.writeFile(tmp, content, writeOptionsFor(content, opts.mode));\n await renameWithWindowsRetry(tmp, filePath);\n } catch (err) {\n await promises.unlink(tmp).catch(() => {});\n throw err;\n }\n}\n\nexport function writeFileAtomicSync(filePath: string, content: string | Uint8Array, opts: WriteAtomicOptions = {}): void {\n const tmp = tmpPathFor(filePath, opts.uniqueTmp);\n mkdirSync(path.dirname(filePath), { recursive: true });\n try {\n writeFileSync(tmp, content, writeOptionsFor(content, opts.mode));\n renameSyncWithWindowsRetry(tmp, filePath);\n } catch (err) {\n try {\n unlinkSync(tmp);\n } catch {\n // best-effort cleanup\n }\n throw err;\n }\n}\n","// The canonical form of a workspace / project root.\n//\n// Lives here rather than in the collection engine because a root is an IDENTITY\n// in several subsystems that do not depend on each other — a watcher generation\n// key, a change payload, a completion-bell id, a scheduled task id — and every\n// one of them has to agree on it or the same project registers twice.\n\nimport path from \"node:path\";\n\n/** The canonical form of a root, for every place a root becomes an IDENTITY\n * rather than a path to read.\n *\n * `path.resolve` only — it collapses `.`/`..` and the trailing separator, so\n * `/work/project` and `/work/project/` are one root instead of two watcher\n * generations over the same tree, two bells per record, and two scheduled\n * refresh jobs.\n *\n * Deliberately NOT `realpath`. Resolving symlinks is async (so it could not run\n * on a synchronous claim path), it fails for a root that does not exist yet,\n * and it would put a path the host never named into an id that is written to\n * disk. The policy is therefore lexical: a host that hands the same tree under\n * two different symlink spellings gets two roots, and it is the host's job to\n * name a project the same way every time. */\nexport function canonicalRoot(root: string): string {\n return path.resolve(root);\n}\n"],"mappings":"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"}
|