@mulmoclaude/core 0.11.0 → 0.12.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/dist/remote-host/index.cjs +2 -2
- package/dist/remote-host/index.cjs.map +1 -1
- package/dist/remote-host/index.d.ts +1 -1
- package/dist/remote-host/index.js +2 -2
- package/dist/remote-host/index.js.map +1 -1
- package/dist/remote-host/server/hostRunner.d.ts +1 -1
- package/dist/remote-host/server/index.cjs +22 -8
- package/dist/remote-host/server/index.cjs.map +1 -1
- package/dist/remote-host/server/index.js +22 -8
- package/dist/remote-host/server/index.js.map +1 -1
- package/dist/remote-host/server/lifecycle.d.ts +1 -1
- package/package.json +1 -1
|
@@ -3,11 +3,11 @@ let firebase_firestore = require("firebase/firestore");
|
|
|
3
3
|
//#region src/remote-host/index.ts
|
|
4
4
|
var isExpired = (command, now) => typeof command.expiresAt === "number" && now >= command.expiresAt;
|
|
5
5
|
var byCreatedAt = (left, right) => (left.createdAt ?? 0) - (right.createdAt ?? 0);
|
|
6
|
-
var REMOTE_HOST_PROTOCOL_VERSION =
|
|
6
|
+
var REMOTE_HOST_PROTOCOL_VERSION = 2;
|
|
7
7
|
var buildHostPresence = (channel, handlers, online) => ({
|
|
8
8
|
online,
|
|
9
9
|
hostId: channel.hostId,
|
|
10
|
-
protocolVersion:
|
|
10
|
+
protocolVersion: 2,
|
|
11
11
|
capabilities: Object.keys(handlers)
|
|
12
12
|
});
|
|
13
13
|
var commandsCollection = (firestore, channel) => (0, firebase_firestore.collection)(firestore, "users", channel.uid, "hosts", channel.hostId, "commands");
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.cjs","names":[],"sources":["../../src/remote-host/index.ts"],"sourcesContent":["// 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\";\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// 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/feat-remote-offline-queue.md.\n createdAt?: number; // enqueue time —
|
|
1
|
+
{"version":3,"file":"index.cjs","names":[],"sources":["../../src/remote-host/index.ts"],"sourcesContent":["// 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\";\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// 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/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"],"mappings":";;;AA6DA,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,GAAA,mBAAA,WAAA,CAC5C,WAAW,SAAS,QAAQ,KAAK,SAAS,QAAQ,QAAQ,UAAU;AAIjF,IAAa,WAAW,WAAsB,aAAA,GAAA,mBAAA,IAAA,CACxC,WAAW,SAAS,QAAQ,KAAK,SAAS,QAAQ,MAAM"}
|
|
@@ -27,7 +27,7 @@ export declare const isExpired: (command: Pick<Command, "expiresAt">, now: numbe
|
|
|
27
27
|
export declare const byCreatedAt: (left: Pick<Command, "createdAt">, right: Pick<Command, "createdAt">) => number;
|
|
28
28
|
export type CommandHandler = (params: JsonObject) => JsonValue | Promise<JsonValue>;
|
|
29
29
|
export type CommandHandlers = Record<string, CommandHandler>;
|
|
30
|
-
export declare const REMOTE_HOST_PROTOCOL_VERSION =
|
|
30
|
+
export declare const REMOTE_HOST_PROTOCOL_VERSION = 2;
|
|
31
31
|
export interface HostPresence {
|
|
32
32
|
online: boolean;
|
|
33
33
|
hostId: string;
|
|
@@ -2,11 +2,11 @@ import { collection, doc } from "firebase/firestore";
|
|
|
2
2
|
//#region src/remote-host/index.ts
|
|
3
3
|
var isExpired = (command, now) => typeof command.expiresAt === "number" && now >= command.expiresAt;
|
|
4
4
|
var byCreatedAt = (left, right) => (left.createdAt ?? 0) - (right.createdAt ?? 0);
|
|
5
|
-
var REMOTE_HOST_PROTOCOL_VERSION =
|
|
5
|
+
var REMOTE_HOST_PROTOCOL_VERSION = 2;
|
|
6
6
|
var buildHostPresence = (channel, handlers, online) => ({
|
|
7
7
|
online,
|
|
8
8
|
hostId: channel.hostId,
|
|
9
|
-
protocolVersion:
|
|
9
|
+
protocolVersion: 2,
|
|
10
10
|
capabilities: Object.keys(handlers)
|
|
11
11
|
});
|
|
12
12
|
var commandsCollection = (firestore, channel) => collection(firestore, "users", channel.uid, "hosts", channel.hostId, "commands");
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","names":[],"sources":["../../src/remote-host/index.ts"],"sourcesContent":["// 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\";\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// 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/feat-remote-offline-queue.md.\n createdAt?: number; // enqueue time —
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../../src/remote-host/index.ts"],"sourcesContent":["// 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\";\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// 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/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"],"mappings":";;AA6DA,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"}
|
|
@@ -8,7 +8,7 @@ export interface HostEvent {
|
|
|
8
8
|
export interface HostRunnerOptions {
|
|
9
9
|
onEvent?: (event: HostEvent) => void;
|
|
10
10
|
onClosed?: () => void;
|
|
11
|
-
onExpire?: (command: Command) => void | Promise<void>;
|
|
11
|
+
onExpire?: (command: Command, uid: string) => void | Promise<void>;
|
|
12
12
|
heartbeatMs?: number;
|
|
13
13
|
}
|
|
14
14
|
export declare const startHostRunner: (firestore: Firestore, channel: Channel, handlers: CommandHandlers, options?: HostRunnerOptions) => (() => void);
|
|
@@ -49,9 +49,9 @@ var runHandler = async (ref, claim, handler) => {
|
|
|
49
49
|
};
|
|
50
50
|
}
|
|
51
51
|
};
|
|
52
|
-
var expireCommand = async (ref, command, options) => {
|
|
52
|
+
var expireCommand = async (ref, command, options, uid) => {
|
|
53
53
|
try {
|
|
54
|
-
await options.onExpire?.(command);
|
|
54
|
+
await options.onExpire?.(command, uid);
|
|
55
55
|
} catch (error) {
|
|
56
56
|
options.onEvent?.({
|
|
57
57
|
phase: "error",
|
|
@@ -59,19 +59,26 @@ var expireCommand = async (ref, command, options) => {
|
|
|
59
59
|
message: `onExpire failed: ${require_errorMessage.errorMessage(error)}`
|
|
60
60
|
});
|
|
61
61
|
}
|
|
62
|
-
await (0, firebase_firestore.deleteDoc)(ref).catch(
|
|
62
|
+
await (0, firebase_firestore.deleteDoc)(ref).catch((error) => {
|
|
63
|
+
options.onEvent?.({
|
|
64
|
+
phase: "error",
|
|
65
|
+
method: command.method,
|
|
66
|
+
message: `expire delete failed: ${require_errorMessage.errorMessage(error)}`
|
|
67
|
+
});
|
|
68
|
+
});
|
|
63
69
|
options.onEvent?.({
|
|
64
70
|
phase: "done",
|
|
65
71
|
method: command.method,
|
|
66
72
|
message: "expired"
|
|
67
73
|
});
|
|
68
74
|
};
|
|
69
|
-
var processCommand = async (
|
|
75
|
+
var processCommand = async (ctx, ref, command, now) => {
|
|
76
|
+
const { handlers, options } = ctx;
|
|
70
77
|
if (require_remote_host_index.isExpired(command, now)) {
|
|
71
|
-
await expireCommand(ref, command, options);
|
|
78
|
+
await expireCommand(ref, command, options, ctx.uid);
|
|
72
79
|
return;
|
|
73
80
|
}
|
|
74
|
-
const claim = await claimCommand(firestore, ref);
|
|
81
|
+
const claim = await claimCommand(ctx.firestore, ref);
|
|
75
82
|
if (!claim) return;
|
|
76
83
|
options.onEvent?.({
|
|
77
84
|
phase: "received",
|
|
@@ -100,13 +107,20 @@ var startHostRunner = (firestore, channel, handlers, options = {}) => {
|
|
|
100
107
|
};
|
|
101
108
|
announce();
|
|
102
109
|
const beat = setInterval(announce, options.heartbeatMs ?? DEFAULT_HEARTBEAT_MS);
|
|
103
|
-
const
|
|
110
|
+
const queuedCommands = (0, firebase_firestore.query)(require_remote_host_index.commandsCollection(firestore, channel), (0, firebase_firestore.where)("status", "==", "queued"));
|
|
111
|
+
const ctx = {
|
|
112
|
+
firestore,
|
|
113
|
+
handlers,
|
|
114
|
+
options,
|
|
115
|
+
uid: channel.uid
|
|
116
|
+
};
|
|
117
|
+
const unsubscribe = (0, firebase_firestore.onSnapshot)(queuedCommands, (snapshot) => {
|
|
104
118
|
const now = Date.now();
|
|
105
119
|
snapshot.docChanges().filter((change) => change.type === "added").map((change) => ({
|
|
106
120
|
ref: change.doc.ref,
|
|
107
121
|
command: change.doc.data()
|
|
108
122
|
})).sort((left, right) => require_remote_host_index.byCreatedAt(left.command, right.command)).forEach(({ ref, command }) => {
|
|
109
|
-
processCommand(
|
|
123
|
+
processCommand(ctx, ref, command, now).catch(noop$1);
|
|
110
124
|
});
|
|
111
125
|
}, (error) => {
|
|
112
126
|
options.onEvent?.({
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.cjs","names":[],"sources":["../../../src/remote-host/server/hostRunner.ts","../../../src/remote-host/server/lifecycle.ts","../../../src/remote-host/server/auth.ts","../../../src/remote-host/server/firebase.ts"],"sourcesContent":["// Host side of the command channel: claim queued commands, run handlers, write\n// results back, and announce presence via heartbeat.\n//\n// Extracted into core from MulmoClaude's server/remoteHost/hostRunner.ts (itself\n// ported from ../mulmoserver). The only signature change vs. that copy: the\n// `firestore` instance is a parameter (each host supplies its own Firebase init),\n// and the heartbeat interval is an option (defaults to one minute).\nimport { DocumentReference, Firestore, deleteDoc, onSnapshot, query, runTransaction, serverTimestamp, setDoc, updateDoc, where } from \"firebase/firestore\";\n\nimport { errorMessage } from \"../../collection/core/errorMessage.js\";\nimport {\n Channel,\n Command,\n CommandHandler,\n CommandHandlers,\n JsonObject,\n buildHostPresence,\n byCreatedAt,\n commandsCollection,\n hostDoc,\n isExpired,\n} from \"../index.js\";\n\nconst DEFAULT_HEARTBEAT_MS = 60_000;\n\nexport interface HostEvent {\n phase: \"received\" | \"done\" | \"error\";\n method: string;\n message?: string;\n}\n\nexport interface HostRunnerOptions {\n onEvent?: (event: HostEvent) => void;\n // Called once when the listener dies fatally (after presence has been set\n // offline), so the lifecycle owner can reconcile its own state — e.g. clear\n // the runner handle so status() no longer reports connected. NOT called on a\n // normal stop().\n onClosed?: () => void;\n // Called when a command is dropped for being past its `expiresAt`, BEFORE the\n // doc is deleted, so the host can clean up out-of-band resources the command\n // referenced (e.g. staged attachment uploads in Storage). Best-effort: a throw\n // is logged via onEvent and does NOT block the doc deletion. Absent ⇒ the\n // expired doc is simply deleted with no extra cleanup.\n onExpire?: (command: Command) => void | Promise<void>;\n // Presence heartbeat interval; defaults to one minute.\n heartbeatMs?: number;\n}\n\ninterface Claim {\n method: string;\n params: JsonObject;\n}\n\nconst noop = () => undefined;\n\n// The remote may have deleted the doc on timeout, so ignore write-after-delete.\nconst writeError = (ref: DocumentReference, code: string, message: string) =>\n updateDoc(ref, { status: \"error\", error: { code, message }, updatedAt: serverTimestamp() }).catch(noop);\n\n// Atomically move a command queued -> processing so it is handled exactly once.\n// Returns the method/params to run, or null if another handler already took it.\nconst claimCommand = (firestore: Firestore, ref: DocumentReference): Promise<Claim | null> =>\n runTransaction(firestore, async (txn) => {\n const data = (await txn.get(ref)).data() as Command | undefined;\n if (!data || data.status !== \"queued\") {\n return null;\n }\n txn.update(ref, { status: \"processing\", updatedAt: serverTimestamp() });\n return { method: data.method, params: data.params ?? {} };\n });\n\nconst runHandler = async (ref: DocumentReference, claim: Claim, handler: CommandHandler): Promise<HostEvent> => {\n try {\n const result = await handler(claim.params);\n await updateDoc(ref, { status: \"done\", result: result ?? null, updatedAt: serverTimestamp() });\n return { phase: \"done\", method: claim.method };\n } catch (error) {\n const message = errorMessage(error);\n await writeError(ref, \"handler_error\", message);\n return { phase: \"error\", method: claim.method, message };\n }\n};\n\n// A command past its deadline is removed entirely rather than run: give the host\n// a chance to clean up out-of-band resources (staged attachments), then delete\n// the doc so it is neither reprocessed nor left as a stale error. Both steps are\n// best-effort/idempotent, so a snapshot replay surfacing the same expired doc\n// twice is harmless (no claim transaction needed — see plan edge #3).\nconst expireCommand = async (ref: DocumentReference, command: Command, options: HostRunnerOptions) => {\n try {\n await options.onExpire?.(command);\n } catch (error) {\n options.onEvent?.({ phase: \"error\", method: command.method, message: `onExpire failed: ${errorMessage(error)}` });\n }\n await deleteDoc(ref).catch(noop);\n options.onEvent?.({ phase: \"done\", method: command.method, message: \"expired\" });\n};\n\nconst processCommand = async (\n firestore: Firestore,\n ref: DocumentReference,\n command: Command,\n handlers: CommandHandlers,\n options: HostRunnerOptions,\n now: number,\n) => {\n // Drop an expired command before claiming it — it must never reach a handler.\n if (isExpired(command, now)) {\n await expireCommand(ref, command, options);\n return;\n }\n const claim = await claimCommand(firestore, ref);\n if (!claim) {\n return;\n }\n options.onEvent?.({ phase: \"received\", method: claim.method });\n const handler: CommandHandler | undefined = handlers[claim.method];\n if (!handler) {\n await writeError(ref, \"unknown_method\", `No handler for method: ${claim.method}`);\n options.onEvent?.({ phase: \"error\", method: claim.method, message: \"unknown method\" });\n return;\n }\n options.onEvent?.(await runHandler(ref, claim, handler));\n};\n\n// startHostRunner subscribes to queued commands for the given channel and runs\n// each one through the supplied handler table. It also announces presence (a\n// heartbeat on users/{uid}/hosts/{hostId}) so the remote can tell it is online.\n// Returns a stop function that goes offline and detaches the listener.\nexport const startHostRunner = (firestore: Firestore, channel: Channel, handlers: CommandHandlers, options: HostRunnerOptions = {}): (() => void) => {\n const presence = hostDoc(firestore, channel);\n // Advertise online/offline + the capability set (method names + protocol\n // version) on the same doc the remote already listens to for presence.\n const writePresence = (online: boolean) => setDoc(presence, { ...buildHostPresence(channel, handlers, online), updatedAt: serverTimestamp() }).catch(noop);\n const announce = () => {\n writePresence(true);\n };\n announce();\n const beat = setInterval(announce, options.heartbeatMs ?? DEFAULT_HEARTBEAT_MS);\n\n const queuedCommands = query(commandsCollection(firestore, channel), where(\"status\", \"==\", \"queued\"));\n const unsubscribe = onSnapshot(\n queuedCommands,\n (snapshot) => {\n const now = Date.now();\n // Replay a drained batch oldest-first. We sort in memory rather than\n // orderBy(\"createdAt\") on the query because a Firestore orderBy silently\n // EXCLUDES docs missing the ordered field — which would drop every\n // pre-offline-queue command (no createdAt) from the queue entirely.\n const added = snapshot\n .docChanges()\n .filter((change) => change.type === \"added\")\n .map((change) => ({ ref: change.doc.ref, command: change.doc.data() as Command }))\n .sort((left, right) => byCreatedAt(left.command, right.command));\n added.forEach(({ ref, command }) => {\n processCommand(firestore, ref, command, handlers, options, now).catch(noop);\n });\n },\n (error) => {\n options.onEvent?.({ phase: \"error\", method: \"listen\", message: error.message });\n // A Firestore onSnapshot error terminates the listener and it does not\n // recover on its own. Stop advertising presence (clear the heartbeat +\n // write online:false) so remotes see the host as offline instead of a\n // live host that silently consumes no commands.\n clearInterval(beat);\n writePresence(false);\n options.onClosed?.();\n },\n );\n\n return () => {\n clearInterval(beat);\n writePresence(false);\n unsubscribe();\n };\n};\n","// Remote-host lifecycle: wire a Firebase session to the Firestore host runner so\n// connecting starts the command loop + presence heartbeat, and disconnecting\n// stops both.\n//\n// Extracted into core from MulmoClaude's server/remoteHost/index.ts. The factory\n// takes injected collaborators — real hosts bind them to Firebase; tests pass\n// fakes to exercise the invariants (non-destructive connect, serialized\n// transitions, status/liveness reconciliation on fatal listener death) without a\n// network. `hostId` and the logger are injected too, so this file imports no\n// Firebase and no host logger and stays trivially unit-testable.\n//\n// Single-account, single-host per instance, in-memory session: a host restart\n// drops the session and needs a re-connect.\nimport type { Channel, Command, CommandHandlers } from \"../index.js\";\nimport type { HostRunnerOptions } from \"./hostRunner.js\";\n\nexport interface RemoteHostStatus {\n connected: boolean;\n uid: string | null;\n}\n\n// Minimal logger the factory calls; each host adapts its own logger to this\n// shape (or omits it to run silently, as the tests do).\nexport interface RemoteHostLogger {\n info: (msg: string) => void;\n warn: (msg: string) => void;\n debug: (msg: string) => void;\n}\n\n// Injectable collaborators — a real host binds these to Firebase + its own\n// hostId; tests pass fakes to exercise the lifecycle without a network.\n// `startRunner` is the host's `startHostRunner` pre-bound with its Firestore\n// instance, so this module needs no Firebase of its own.\nexport interface RemoteHostDeps {\n hostId: string;\n signIn: (idToken: string) => Promise<string>;\n signOut: () => Promise<void>;\n currentUid: () => string | null;\n startRunner: (channel: Channel, handlers: CommandHandlers, options: HostRunnerOptions) => () => void;\n handlers: CommandHandlers;\n // Optional host-specific cleanup for a command the runner drops as expired\n // (e.g. delete its staged attachment uploads). Threaded verbatim into the\n // runner's `onExpire`; absent ⇒ an expired doc is just deleted.\n onExpire?: (command: Command) => void | Promise<void>;\n log?: RemoteHostLogger;\n}\n\nexport interface RemoteHostLifecycle {\n connect: (idToken: string) => Promise<RemoteHostStatus>;\n disconnect: () => Promise<RemoteHostStatus>;\n status: () => RemoteHostStatus;\n}\n\nconst noop = () => undefined;\nconst silentLogger: RemoteHostLogger = { info: noop, warn: noop, debug: noop };\n\nexport const createRemoteHost = (deps: RemoteHostDeps): RemoteHostLifecycle => {\n const log = deps.log ?? silentLogger;\n\n // The running host runner's stop() handle, or null when disconnected. Keeps\n // the single-host invariant — one runner at a time.\n let stopRunner: (() => void) | null = null;\n\n // Serialize connect/disconnect so overlapping requests can't both mutate\n // stopRunner (which would leak a second runner) or race auth against teardown.\n // Each transition runs only after the previous one settles; a failed\n // transition does not block the next (both handlers run the next op).\n let transition: Promise<unknown> = Promise.resolve();\n\n const serialize = <T>(operation: () => Promise<T>): Promise<T> => {\n const next = transition.then(operation, operation);\n transition = next.then(noop, noop);\n return next;\n };\n\n const stopIfRunning = () => {\n if (stopRunner) {\n stopRunner();\n stopRunner = null;\n }\n };\n\n const status = (): RemoteHostStatus => ({ connected: stopRunner !== null, uid: deps.currentUid() });\n\n const startRunner = (uid: string) => {\n const runner = deps.startRunner({ uid, hostId: deps.hostId }, deps.handlers, {\n onEvent: (event) => log.debug(`host event: ${event.phase} ${event.method}`),\n onExpire: deps.onExpire,\n // The listener died fatally (heartbeat already stopped + offline written);\n // clear the handle so status() stops reporting connected — but only if it\n // still points at THIS runner (a later reconnect may have replaced it).\n onClosed: () => {\n if (stopRunner === runner) {\n stopRunner = null;\n log.warn(\"host runner listener died; marked disconnected\");\n }\n },\n });\n return runner;\n };\n\n const connect = (idToken: string): Promise<RemoteHostStatus> =>\n serialize(async () => {\n // Authenticate BEFORE any teardown, so a failed connect (expired/rejected\n // token) leaves an existing healthy session untouched instead of dropping it.\n const uid = await deps.signIn(idToken);\n stopIfRunning();\n stopRunner = startRunner(uid);\n log.info(`connected as ${uid}, host runner started (hostId=${deps.hostId})`);\n return status();\n });\n\n const disconnect = (): Promise<RemoteHostStatus> =>\n serialize(async () => {\n stopIfRunning();\n await deps.signOut();\n log.info(\"disconnected, host runner stopped\");\n return status();\n });\n\n return { connect, disconnect, status };\n};\n","// Firebase credential exchange for the remote-host runner.\n//\n// The host authenticates to Firestore *as the user* (Option B) using the\n// Firebase JS SDK's signInWithCredential with a browser-minted Google OAuth ID\n// token — no Admin SDK, no project service account. Security rules keep the host\n// scoped to that user's own users/{uid}/… subtree.\n//\n// Extracted into core from MulmoClaude's server/remoteHost/auth.ts. The `auth`\n// instance is a parameter so each host binds its own Firebase init; the factory\n// returns the low-level credential primitives (the connect/disconnect lifecycle\n// that starts/stops the host runner lives in createRemoteHost).\nimport { Auth, GoogleAuthProvider, signInWithCredential, signOut } from \"firebase/auth\";\n\nexport interface RemoteHostAuth {\n // Establish the Firebase session from a browser-minted Google OAuth ID token.\n // The token is used once here; the JS SDK then holds its own refresh token for\n // the process lifetime. Resolves to the authenticated uid.\n signInHost: (idToken: string) => Promise<string>;\n // Tear down the Firebase session (in-memory persistence → re-login on restart).\n signOutHost: () => Promise<void>;\n // The currently signed-in uid, or null when disconnected.\n currentUid: () => string | null;\n}\n\nexport const createRemoteHostAuth = (auth: Auth): RemoteHostAuth => ({\n signInHost: async (idToken: string): Promise<string> => {\n const credential = GoogleAuthProvider.credential(idToken);\n const userCredential = await signInWithCredential(auth, credential);\n return userCredential.user.uid;\n },\n signOutHost: (): Promise<void> => signOut(auth),\n currentUid: (): string | null => auth.currentUser?.uid ?? null,\n});\n","// Firebase init for a remote-host runner.\n//\n// A host acts as a *host*: it signs in to Firebase as the user (via\n// signInWithCredential, see auth.ts) and listens to that user's command queue in\n// Firestore. The modular firebase/firestore + firebase/auth SDKs run in Node, so\n// this mirrors a browser init but also exposes Firestore (default database,\n// which must be in Native mode) and Storage.\n//\n// Extracted into core from MulmoClaude's server/remoteHost/firebase.ts. The\n// public web config is a parameter so each host supplies its own (both hosts\n// reuse the shared mulmoserver project).\nimport { FirebaseApp, FirebaseOptions, initializeApp } from \"firebase/app\";\nimport { Auth, getAuth } from \"firebase/auth\";\nimport { Firestore, getFirestore } from \"firebase/firestore\";\nimport { FirebaseStorage, getStorage } from \"firebase/storage\";\n\nexport interface RemoteHostFirebase {\n app: FirebaseApp;\n auth: Auth;\n firestore: Firestore;\n // Storage carries the full-res attachment bytes the command channel can't (a\n // Firestore command doc caps at ~1 MiB). The host, signed in as the user,\n // pulls each staged upload from `users/{uid}/uploads/{id}` and deletes it\n // after ingest.\n storage: FirebaseStorage;\n}\n\nexport const createRemoteHostFirebase = (config: FirebaseOptions): RemoteHostFirebase => {\n const app = initializeApp(config);\n return { app, auth: getAuth(app), firestore: getFirestore(app), storage: getStorage(app) };\n};\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.cjs","names":[],"sources":["../../../src/remote-host/server/hostRunner.ts","../../../src/remote-host/server/lifecycle.ts","../../../src/remote-host/server/auth.ts","../../../src/remote-host/server/firebase.ts"],"sourcesContent":["// Host side of the command channel: claim queued commands, run handlers, write\n// results back, and announce presence via heartbeat.\n//\n// Extracted into core from MulmoClaude's server/remoteHost/hostRunner.ts (itself\n// ported from ../mulmoserver). The only signature change vs. that copy: the\n// `firestore` instance is a parameter (each host supplies its own Firebase init),\n// and the heartbeat interval is an option (defaults to one minute).\nimport { DocumentReference, Firestore, deleteDoc, onSnapshot, query, runTransaction, serverTimestamp, setDoc, updateDoc, where } from \"firebase/firestore\";\n\nimport { errorMessage } from \"../../collection/core/errorMessage.js\";\nimport {\n Channel,\n Command,\n CommandHandler,\n CommandHandlers,\n JsonObject,\n buildHostPresence,\n byCreatedAt,\n commandsCollection,\n hostDoc,\n isExpired,\n} from \"../index.js\";\n\nconst DEFAULT_HEARTBEAT_MS = 60_000;\n\nexport interface HostEvent {\n phase: \"received\" | \"done\" | \"error\";\n method: string;\n message?: string;\n}\n\nexport interface HostRunnerOptions {\n onEvent?: (event: HostEvent) => void;\n // Called once when the listener dies fatally (after presence has been set\n // offline), so the lifecycle owner can reconcile its own state — e.g. clear\n // the runner handle so status() no longer reports connected. NOT called on a\n // normal stop().\n onClosed?: () => void;\n // Called when a command is dropped for being past its `expiresAt`, BEFORE the\n // doc is deleted, so the host can clean up out-of-band resources the command\n // referenced (e.g. staged attachment uploads in Storage). `uid` is THIS runner's\n // session uid (channel.uid) — passed in rather than read from a global so a\n // concurrent reconnect as a different account can't point cleanup at the wrong\n // user's Storage path. Best-effort: a throw is logged via onEvent and does NOT\n // block the doc deletion. Absent ⇒ the expired doc is simply deleted.\n onExpire?: (command: Command, uid: string) => void | Promise<void>;\n // Presence heartbeat interval; defaults to one minute.\n heartbeatMs?: number;\n}\n\ninterface Claim {\n method: string;\n params: JsonObject;\n}\n\nconst noop = () => undefined;\n\n// The remote may have deleted the doc on timeout, so ignore write-after-delete.\nconst writeError = (ref: DocumentReference, code: string, message: string) =>\n updateDoc(ref, { status: \"error\", error: { code, message }, updatedAt: serverTimestamp() }).catch(noop);\n\n// Atomically move a command queued -> processing so it is handled exactly once.\n// Returns the method/params to run, or null if another handler already took it.\nconst claimCommand = (firestore: Firestore, ref: DocumentReference): Promise<Claim | null> =>\n runTransaction(firestore, async (txn) => {\n const data = (await txn.get(ref)).data() as Command | undefined;\n if (!data || data.status !== \"queued\") {\n return null;\n }\n txn.update(ref, { status: \"processing\", updatedAt: serverTimestamp() });\n return { method: data.method, params: data.params ?? {} };\n });\n\nconst runHandler = async (ref: DocumentReference, claim: Claim, handler: CommandHandler): Promise<HostEvent> => {\n try {\n const result = await handler(claim.params);\n await updateDoc(ref, { status: \"done\", result: result ?? null, updatedAt: serverTimestamp() });\n return { phase: \"done\", method: claim.method };\n } catch (error) {\n const message = errorMessage(error);\n await writeError(ref, \"handler_error\", message);\n return { phase: \"error\", method: claim.method, message };\n }\n};\n\n// A command past its deadline is removed entirely rather than run: give the host\n// a chance to clean up out-of-band resources (staged attachments), then delete\n// the doc so it is neither reprocessed nor left as a stale error. Both steps are\n// best-effort/idempotent, so a snapshot replay surfacing the same expired doc\n// twice is harmless (no claim transaction needed — see plan edge #3).\nconst expireCommand = async (ref: DocumentReference, command: Command, options: HostRunnerOptions, uid: string) => {\n try {\n await options.onExpire?.(command, uid);\n } catch (error) {\n options.onEvent?.({ phase: \"error\", method: command.method, message: `onExpire failed: ${errorMessage(error)}` });\n }\n // Surface a delete failure (permissions / transient network) the same way the\n // onExpire failure above is surfaced — otherwise the expired doc lingers as\n // \"queued\" with no signal as to why cleanup didn't happen.\n await deleteDoc(ref).catch((error) => {\n options.onEvent?.({ phase: \"error\", method: command.method, message: `expire delete failed: ${errorMessage(error)}` });\n });\n options.onEvent?.({ phase: \"done\", method: command.method, message: \"expired\" });\n};\n\n// Per-runner constants bundled into one context so processCommand stays under the\n// max-params cap: firestore, the handler table, options, and the session uid are\n// all fixed for the runner's lifetime; only ref/command/now vary per command.\ninterface RunnerContext {\n firestore: Firestore;\n handlers: CommandHandlers;\n options: HostRunnerOptions;\n uid: string;\n}\n\nconst processCommand = async (ctx: RunnerContext, ref: DocumentReference, command: Command, now: number) => {\n const { handlers, options } = ctx;\n // Drop an expired command before claiming it — it must never reach a handler.\n if (isExpired(command, now)) {\n await expireCommand(ref, command, options, ctx.uid);\n return;\n }\n const claim = await claimCommand(ctx.firestore, ref);\n if (!claim) {\n return;\n }\n options.onEvent?.({ phase: \"received\", method: claim.method });\n const handler: CommandHandler | undefined = handlers[claim.method];\n if (!handler) {\n await writeError(ref, \"unknown_method\", `No handler for method: ${claim.method}`);\n options.onEvent?.({ phase: \"error\", method: claim.method, message: \"unknown method\" });\n return;\n }\n options.onEvent?.(await runHandler(ref, claim, handler));\n};\n\n// startHostRunner subscribes to queued commands for the given channel and runs\n// each one through the supplied handler table. It also announces presence (a\n// heartbeat on users/{uid}/hosts/{hostId}) so the remote can tell it is online.\n// Returns a stop function that goes offline and detaches the listener.\nexport const startHostRunner = (firestore: Firestore, channel: Channel, handlers: CommandHandlers, options: HostRunnerOptions = {}): (() => void) => {\n const presence = hostDoc(firestore, channel);\n // Advertise online/offline + the capability set (method names + protocol\n // version) on the same doc the remote already listens to for presence.\n const writePresence = (online: boolean) => setDoc(presence, { ...buildHostPresence(channel, handlers, online), updatedAt: serverTimestamp() }).catch(noop);\n const announce = () => {\n writePresence(true);\n };\n announce();\n const beat = setInterval(announce, options.heartbeatMs ?? DEFAULT_HEARTBEAT_MS);\n\n const queuedCommands = query(commandsCollection(firestore, channel), where(\"status\", \"==\", \"queued\"));\n const ctx: RunnerContext = { firestore, handlers, options, uid: channel.uid };\n const unsubscribe = onSnapshot(\n queuedCommands,\n (snapshot) => {\n const now = Date.now();\n // Best-effort oldest-first DISPATCH only. Commands are processed\n // concurrently (not awaited in turn) and out-of-order completion is fine by\n // design — chat is asynchronous — so this sort just biases which command\n // starts first; it is not an ordering guarantee. We still sort in memory\n // rather than orderBy(\"createdAt\") on the query because a Firestore orderBy\n // silently EXCLUDES docs missing the field — which would drop every\n // pre-offline-queue command (no createdAt) from the queue entirely.\n const added = snapshot\n .docChanges()\n .filter((change) => change.type === \"added\")\n .map((change) => ({ ref: change.doc.ref, command: change.doc.data() as Command }))\n .sort((left, right) => byCreatedAt(left.command, right.command));\n added.forEach(({ ref, command }) => {\n processCommand(ctx, ref, command, now).catch(noop);\n });\n },\n (error) => {\n options.onEvent?.({ phase: \"error\", method: \"listen\", message: error.message });\n // A Firestore onSnapshot error terminates the listener and it does not\n // recover on its own. Stop advertising presence (clear the heartbeat +\n // write online:false) so remotes see the host as offline instead of a\n // live host that silently consumes no commands.\n clearInterval(beat);\n writePresence(false);\n options.onClosed?.();\n },\n );\n\n return () => {\n clearInterval(beat);\n writePresence(false);\n unsubscribe();\n };\n};\n","// Remote-host lifecycle: wire a Firebase session to the Firestore host runner so\n// connecting starts the command loop + presence heartbeat, and disconnecting\n// stops both.\n//\n// Extracted into core from MulmoClaude's server/remoteHost/index.ts. The factory\n// takes injected collaborators — real hosts bind them to Firebase; tests pass\n// fakes to exercise the invariants (non-destructive connect, serialized\n// transitions, status/liveness reconciliation on fatal listener death) without a\n// network. `hostId` and the logger are injected too, so this file imports no\n// Firebase and no host logger and stays trivially unit-testable.\n//\n// Single-account, single-host per instance, in-memory session: a host restart\n// drops the session and needs a re-connect.\nimport type { Channel, Command, CommandHandlers } from \"../index.js\";\nimport type { HostRunnerOptions } from \"./hostRunner.js\";\n\nexport interface RemoteHostStatus {\n connected: boolean;\n uid: string | null;\n}\n\n// Minimal logger the factory calls; each host adapts its own logger to this\n// shape (or omits it to run silently, as the tests do).\nexport interface RemoteHostLogger {\n info: (msg: string) => void;\n warn: (msg: string) => void;\n debug: (msg: string) => void;\n}\n\n// Injectable collaborators — a real host binds these to Firebase + its own\n// hostId; tests pass fakes to exercise the lifecycle without a network.\n// `startRunner` is the host's `startHostRunner` pre-bound with its Firestore\n// instance, so this module needs no Firebase of its own.\nexport interface RemoteHostDeps {\n hostId: string;\n signIn: (idToken: string) => Promise<string>;\n signOut: () => Promise<void>;\n currentUid: () => string | null;\n startRunner: (channel: Channel, handlers: CommandHandlers, options: HostRunnerOptions) => () => void;\n handlers: CommandHandlers;\n // Optional host-specific cleanup for a command the runner drops as expired\n // (e.g. delete its staged attachment uploads). Threaded verbatim into the\n // runner's `onExpire`, which supplies the session `uid` so cleanup targets the\n // right user's Storage path even across a reconnect; absent ⇒ an expired doc is\n // just deleted.\n onExpire?: (command: Command, uid: string) => void | Promise<void>;\n log?: RemoteHostLogger;\n}\n\nexport interface RemoteHostLifecycle {\n connect: (idToken: string) => Promise<RemoteHostStatus>;\n disconnect: () => Promise<RemoteHostStatus>;\n status: () => RemoteHostStatus;\n}\n\nconst noop = () => undefined;\nconst silentLogger: RemoteHostLogger = { info: noop, warn: noop, debug: noop };\n\nexport const createRemoteHost = (deps: RemoteHostDeps): RemoteHostLifecycle => {\n const log = deps.log ?? silentLogger;\n\n // The running host runner's stop() handle, or null when disconnected. Keeps\n // the single-host invariant — one runner at a time.\n let stopRunner: (() => void) | null = null;\n\n // Serialize connect/disconnect so overlapping requests can't both mutate\n // stopRunner (which would leak a second runner) or race auth against teardown.\n // Each transition runs only after the previous one settles; a failed\n // transition does not block the next (both handlers run the next op).\n let transition: Promise<unknown> = Promise.resolve();\n\n const serialize = <T>(operation: () => Promise<T>): Promise<T> => {\n const next = transition.then(operation, operation);\n transition = next.then(noop, noop);\n return next;\n };\n\n const stopIfRunning = () => {\n if (stopRunner) {\n stopRunner();\n stopRunner = null;\n }\n };\n\n const status = (): RemoteHostStatus => ({ connected: stopRunner !== null, uid: deps.currentUid() });\n\n const startRunner = (uid: string) => {\n const runner = deps.startRunner({ uid, hostId: deps.hostId }, deps.handlers, {\n onEvent: (event) => log.debug(`host event: ${event.phase} ${event.method}`),\n onExpire: deps.onExpire,\n // The listener died fatally (heartbeat already stopped + offline written);\n // clear the handle so status() stops reporting connected — but only if it\n // still points at THIS runner (a later reconnect may have replaced it).\n onClosed: () => {\n if (stopRunner === runner) {\n stopRunner = null;\n log.warn(\"host runner listener died; marked disconnected\");\n }\n },\n });\n return runner;\n };\n\n const connect = (idToken: string): Promise<RemoteHostStatus> =>\n serialize(async () => {\n // Authenticate BEFORE any teardown, so a failed connect (expired/rejected\n // token) leaves an existing healthy session untouched instead of dropping it.\n const uid = await deps.signIn(idToken);\n stopIfRunning();\n stopRunner = startRunner(uid);\n log.info(`connected as ${uid}, host runner started (hostId=${deps.hostId})`);\n return status();\n });\n\n const disconnect = (): Promise<RemoteHostStatus> =>\n serialize(async () => {\n stopIfRunning();\n await deps.signOut();\n log.info(\"disconnected, host runner stopped\");\n return status();\n });\n\n return { connect, disconnect, status };\n};\n","// Firebase credential exchange for the remote-host runner.\n//\n// The host authenticates to Firestore *as the user* (Option B) using the\n// Firebase JS SDK's signInWithCredential with a browser-minted Google OAuth ID\n// token — no Admin SDK, no project service account. Security rules keep the host\n// scoped to that user's own users/{uid}/… subtree.\n//\n// Extracted into core from MulmoClaude's server/remoteHost/auth.ts. The `auth`\n// instance is a parameter so each host binds its own Firebase init; the factory\n// returns the low-level credential primitives (the connect/disconnect lifecycle\n// that starts/stops the host runner lives in createRemoteHost).\nimport { Auth, GoogleAuthProvider, signInWithCredential, signOut } from \"firebase/auth\";\n\nexport interface RemoteHostAuth {\n // Establish the Firebase session from a browser-minted Google OAuth ID token.\n // The token is used once here; the JS SDK then holds its own refresh token for\n // the process lifetime. Resolves to the authenticated uid.\n signInHost: (idToken: string) => Promise<string>;\n // Tear down the Firebase session (in-memory persistence → re-login on restart).\n signOutHost: () => Promise<void>;\n // The currently signed-in uid, or null when disconnected.\n currentUid: () => string | null;\n}\n\nexport const createRemoteHostAuth = (auth: Auth): RemoteHostAuth => ({\n signInHost: async (idToken: string): Promise<string> => {\n const credential = GoogleAuthProvider.credential(idToken);\n const userCredential = await signInWithCredential(auth, credential);\n return userCredential.user.uid;\n },\n signOutHost: (): Promise<void> => signOut(auth),\n currentUid: (): string | null => auth.currentUser?.uid ?? null,\n});\n","// Firebase init for a remote-host runner.\n//\n// A host acts as a *host*: it signs in to Firebase as the user (via\n// signInWithCredential, see auth.ts) and listens to that user's command queue in\n// Firestore. The modular firebase/firestore + firebase/auth SDKs run in Node, so\n// this mirrors a browser init but also exposes Firestore (default database,\n// which must be in Native mode) and Storage.\n//\n// Extracted into core from MulmoClaude's server/remoteHost/firebase.ts. The\n// public web config is a parameter so each host supplies its own (both hosts\n// reuse the shared mulmoserver project).\nimport { FirebaseApp, FirebaseOptions, initializeApp } from \"firebase/app\";\nimport { Auth, getAuth } from \"firebase/auth\";\nimport { Firestore, getFirestore } from \"firebase/firestore\";\nimport { FirebaseStorage, getStorage } from \"firebase/storage\";\n\nexport interface RemoteHostFirebase {\n app: FirebaseApp;\n auth: Auth;\n firestore: Firestore;\n // Storage carries the full-res attachment bytes the command channel can't (a\n // Firestore command doc caps at ~1 MiB). The host, signed in as the user,\n // pulls each staged upload from `users/{uid}/uploads/{id}` and deletes it\n // after ingest.\n storage: FirebaseStorage;\n}\n\nexport const createRemoteHostFirebase = (config: FirebaseOptions): RemoteHostFirebase => {\n const app = initializeApp(config);\n return { app, auth: getAuth(app), firestore: getFirestore(app), storage: getStorage(app) };\n};\n"],"mappings":"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"}
|
|
@@ -48,9 +48,9 @@ var runHandler = async (ref, claim, handler) => {
|
|
|
48
48
|
};
|
|
49
49
|
}
|
|
50
50
|
};
|
|
51
|
-
var expireCommand = async (ref, command, options) => {
|
|
51
|
+
var expireCommand = async (ref, command, options, uid) => {
|
|
52
52
|
try {
|
|
53
|
-
await options.onExpire?.(command);
|
|
53
|
+
await options.onExpire?.(command, uid);
|
|
54
54
|
} catch (error) {
|
|
55
55
|
options.onEvent?.({
|
|
56
56
|
phase: "error",
|
|
@@ -58,19 +58,26 @@ var expireCommand = async (ref, command, options) => {
|
|
|
58
58
|
message: `onExpire failed: ${errorMessage(error)}`
|
|
59
59
|
});
|
|
60
60
|
}
|
|
61
|
-
await deleteDoc(ref).catch(
|
|
61
|
+
await deleteDoc(ref).catch((error) => {
|
|
62
|
+
options.onEvent?.({
|
|
63
|
+
phase: "error",
|
|
64
|
+
method: command.method,
|
|
65
|
+
message: `expire delete failed: ${errorMessage(error)}`
|
|
66
|
+
});
|
|
67
|
+
});
|
|
62
68
|
options.onEvent?.({
|
|
63
69
|
phase: "done",
|
|
64
70
|
method: command.method,
|
|
65
71
|
message: "expired"
|
|
66
72
|
});
|
|
67
73
|
};
|
|
68
|
-
var processCommand = async (
|
|
74
|
+
var processCommand = async (ctx, ref, command, now) => {
|
|
75
|
+
const { handlers, options } = ctx;
|
|
69
76
|
if (isExpired(command, now)) {
|
|
70
|
-
await expireCommand(ref, command, options);
|
|
77
|
+
await expireCommand(ref, command, options, ctx.uid);
|
|
71
78
|
return;
|
|
72
79
|
}
|
|
73
|
-
const claim = await claimCommand(firestore, ref);
|
|
80
|
+
const claim = await claimCommand(ctx.firestore, ref);
|
|
74
81
|
if (!claim) return;
|
|
75
82
|
options.onEvent?.({
|
|
76
83
|
phase: "received",
|
|
@@ -99,13 +106,20 @@ var startHostRunner = (firestore, channel, handlers, options = {}) => {
|
|
|
99
106
|
};
|
|
100
107
|
announce();
|
|
101
108
|
const beat = setInterval(announce, options.heartbeatMs ?? DEFAULT_HEARTBEAT_MS);
|
|
102
|
-
const
|
|
109
|
+
const queuedCommands = query(commandsCollection(firestore, channel), where("status", "==", "queued"));
|
|
110
|
+
const ctx = {
|
|
111
|
+
firestore,
|
|
112
|
+
handlers,
|
|
113
|
+
options,
|
|
114
|
+
uid: channel.uid
|
|
115
|
+
};
|
|
116
|
+
const unsubscribe = onSnapshot(queuedCommands, (snapshot) => {
|
|
103
117
|
const now = Date.now();
|
|
104
118
|
snapshot.docChanges().filter((change) => change.type === "added").map((change) => ({
|
|
105
119
|
ref: change.doc.ref,
|
|
106
120
|
command: change.doc.data()
|
|
107
121
|
})).sort((left, right) => byCreatedAt(left.command, right.command)).forEach(({ ref, command }) => {
|
|
108
|
-
processCommand(
|
|
122
|
+
processCommand(ctx, ref, command, now).catch(noop$1);
|
|
109
123
|
});
|
|
110
124
|
}, (error) => {
|
|
111
125
|
options.onEvent?.({
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","names":[],"sources":["../../../src/remote-host/server/hostRunner.ts","../../../src/remote-host/server/lifecycle.ts","../../../src/remote-host/server/auth.ts","../../../src/remote-host/server/firebase.ts"],"sourcesContent":["// Host side of the command channel: claim queued commands, run handlers, write\n// results back, and announce presence via heartbeat.\n//\n// Extracted into core from MulmoClaude's server/remoteHost/hostRunner.ts (itself\n// ported from ../mulmoserver). The only signature change vs. that copy: the\n// `firestore` instance is a parameter (each host supplies its own Firebase init),\n// and the heartbeat interval is an option (defaults to one minute).\nimport { DocumentReference, Firestore, deleteDoc, onSnapshot, query, runTransaction, serverTimestamp, setDoc, updateDoc, where } from \"firebase/firestore\";\n\nimport { errorMessage } from \"../../collection/core/errorMessage.js\";\nimport {\n Channel,\n Command,\n CommandHandler,\n CommandHandlers,\n JsonObject,\n buildHostPresence,\n byCreatedAt,\n commandsCollection,\n hostDoc,\n isExpired,\n} from \"../index.js\";\n\nconst DEFAULT_HEARTBEAT_MS = 60_000;\n\nexport interface HostEvent {\n phase: \"received\" | \"done\" | \"error\";\n method: string;\n message?: string;\n}\n\nexport interface HostRunnerOptions {\n onEvent?: (event: HostEvent) => void;\n // Called once when the listener dies fatally (after presence has been set\n // offline), so the lifecycle owner can reconcile its own state — e.g. clear\n // the runner handle so status() no longer reports connected. NOT called on a\n // normal stop().\n onClosed?: () => void;\n // Called when a command is dropped for being past its `expiresAt`, BEFORE the\n // doc is deleted, so the host can clean up out-of-band resources the command\n // referenced (e.g. staged attachment uploads in Storage). Best-effort: a throw\n // is logged via onEvent and does NOT block the doc deletion. Absent ⇒ the\n // expired doc is simply deleted with no extra cleanup.\n onExpire?: (command: Command) => void | Promise<void>;\n // Presence heartbeat interval; defaults to one minute.\n heartbeatMs?: number;\n}\n\ninterface Claim {\n method: string;\n params: JsonObject;\n}\n\nconst noop = () => undefined;\n\n// The remote may have deleted the doc on timeout, so ignore write-after-delete.\nconst writeError = (ref: DocumentReference, code: string, message: string) =>\n updateDoc(ref, { status: \"error\", error: { code, message }, updatedAt: serverTimestamp() }).catch(noop);\n\n// Atomically move a command queued -> processing so it is handled exactly once.\n// Returns the method/params to run, or null if another handler already took it.\nconst claimCommand = (firestore: Firestore, ref: DocumentReference): Promise<Claim | null> =>\n runTransaction(firestore, async (txn) => {\n const data = (await txn.get(ref)).data() as Command | undefined;\n if (!data || data.status !== \"queued\") {\n return null;\n }\n txn.update(ref, { status: \"processing\", updatedAt: serverTimestamp() });\n return { method: data.method, params: data.params ?? {} };\n });\n\nconst runHandler = async (ref: DocumentReference, claim: Claim, handler: CommandHandler): Promise<HostEvent> => {\n try {\n const result = await handler(claim.params);\n await updateDoc(ref, { status: \"done\", result: result ?? null, updatedAt: serverTimestamp() });\n return { phase: \"done\", method: claim.method };\n } catch (error) {\n const message = errorMessage(error);\n await writeError(ref, \"handler_error\", message);\n return { phase: \"error\", method: claim.method, message };\n }\n};\n\n// A command past its deadline is removed entirely rather than run: give the host\n// a chance to clean up out-of-band resources (staged attachments), then delete\n// the doc so it is neither reprocessed nor left as a stale error. Both steps are\n// best-effort/idempotent, so a snapshot replay surfacing the same expired doc\n// twice is harmless (no claim transaction needed — see plan edge #3).\nconst expireCommand = async (ref: DocumentReference, command: Command, options: HostRunnerOptions) => {\n try {\n await options.onExpire?.(command);\n } catch (error) {\n options.onEvent?.({ phase: \"error\", method: command.method, message: `onExpire failed: ${errorMessage(error)}` });\n }\n await deleteDoc(ref).catch(noop);\n options.onEvent?.({ phase: \"done\", method: command.method, message: \"expired\" });\n};\n\nconst processCommand = async (\n firestore: Firestore,\n ref: DocumentReference,\n command: Command,\n handlers: CommandHandlers,\n options: HostRunnerOptions,\n now: number,\n) => {\n // Drop an expired command before claiming it — it must never reach a handler.\n if (isExpired(command, now)) {\n await expireCommand(ref, command, options);\n return;\n }\n const claim = await claimCommand(firestore, ref);\n if (!claim) {\n return;\n }\n options.onEvent?.({ phase: \"received\", method: claim.method });\n const handler: CommandHandler | undefined = handlers[claim.method];\n if (!handler) {\n await writeError(ref, \"unknown_method\", `No handler for method: ${claim.method}`);\n options.onEvent?.({ phase: \"error\", method: claim.method, message: \"unknown method\" });\n return;\n }\n options.onEvent?.(await runHandler(ref, claim, handler));\n};\n\n// startHostRunner subscribes to queued commands for the given channel and runs\n// each one through the supplied handler table. It also announces presence (a\n// heartbeat on users/{uid}/hosts/{hostId}) so the remote can tell it is online.\n// Returns a stop function that goes offline and detaches the listener.\nexport const startHostRunner = (firestore: Firestore, channel: Channel, handlers: CommandHandlers, options: HostRunnerOptions = {}): (() => void) => {\n const presence = hostDoc(firestore, channel);\n // Advertise online/offline + the capability set (method names + protocol\n // version) on the same doc the remote already listens to for presence.\n const writePresence = (online: boolean) => setDoc(presence, { ...buildHostPresence(channel, handlers, online), updatedAt: serverTimestamp() }).catch(noop);\n const announce = () => {\n writePresence(true);\n };\n announce();\n const beat = setInterval(announce, options.heartbeatMs ?? DEFAULT_HEARTBEAT_MS);\n\n const queuedCommands = query(commandsCollection(firestore, channel), where(\"status\", \"==\", \"queued\"));\n const unsubscribe = onSnapshot(\n queuedCommands,\n (snapshot) => {\n const now = Date.now();\n // Replay a drained batch oldest-first. We sort in memory rather than\n // orderBy(\"createdAt\") on the query because a Firestore orderBy silently\n // EXCLUDES docs missing the ordered field — which would drop every\n // pre-offline-queue command (no createdAt) from the queue entirely.\n const added = snapshot\n .docChanges()\n .filter((change) => change.type === \"added\")\n .map((change) => ({ ref: change.doc.ref, command: change.doc.data() as Command }))\n .sort((left, right) => byCreatedAt(left.command, right.command));\n added.forEach(({ ref, command }) => {\n processCommand(firestore, ref, command, handlers, options, now).catch(noop);\n });\n },\n (error) => {\n options.onEvent?.({ phase: \"error\", method: \"listen\", message: error.message });\n // A Firestore onSnapshot error terminates the listener and it does not\n // recover on its own. Stop advertising presence (clear the heartbeat +\n // write online:false) so remotes see the host as offline instead of a\n // live host that silently consumes no commands.\n clearInterval(beat);\n writePresence(false);\n options.onClosed?.();\n },\n );\n\n return () => {\n clearInterval(beat);\n writePresence(false);\n unsubscribe();\n };\n};\n","// Remote-host lifecycle: wire a Firebase session to the Firestore host runner so\n// connecting starts the command loop + presence heartbeat, and disconnecting\n// stops both.\n//\n// Extracted into core from MulmoClaude's server/remoteHost/index.ts. The factory\n// takes injected collaborators — real hosts bind them to Firebase; tests pass\n// fakes to exercise the invariants (non-destructive connect, serialized\n// transitions, status/liveness reconciliation on fatal listener death) without a\n// network. `hostId` and the logger are injected too, so this file imports no\n// Firebase and no host logger and stays trivially unit-testable.\n//\n// Single-account, single-host per instance, in-memory session: a host restart\n// drops the session and needs a re-connect.\nimport type { Channel, Command, CommandHandlers } from \"../index.js\";\nimport type { HostRunnerOptions } from \"./hostRunner.js\";\n\nexport interface RemoteHostStatus {\n connected: boolean;\n uid: string | null;\n}\n\n// Minimal logger the factory calls; each host adapts its own logger to this\n// shape (or omits it to run silently, as the tests do).\nexport interface RemoteHostLogger {\n info: (msg: string) => void;\n warn: (msg: string) => void;\n debug: (msg: string) => void;\n}\n\n// Injectable collaborators — a real host binds these to Firebase + its own\n// hostId; tests pass fakes to exercise the lifecycle without a network.\n// `startRunner` is the host's `startHostRunner` pre-bound with its Firestore\n// instance, so this module needs no Firebase of its own.\nexport interface RemoteHostDeps {\n hostId: string;\n signIn: (idToken: string) => Promise<string>;\n signOut: () => Promise<void>;\n currentUid: () => string | null;\n startRunner: (channel: Channel, handlers: CommandHandlers, options: HostRunnerOptions) => () => void;\n handlers: CommandHandlers;\n // Optional host-specific cleanup for a command the runner drops as expired\n // (e.g. delete its staged attachment uploads). Threaded verbatim into the\n // runner's `onExpire`; absent ⇒ an expired doc is just deleted.\n onExpire?: (command: Command) => void | Promise<void>;\n log?: RemoteHostLogger;\n}\n\nexport interface RemoteHostLifecycle {\n connect: (idToken: string) => Promise<RemoteHostStatus>;\n disconnect: () => Promise<RemoteHostStatus>;\n status: () => RemoteHostStatus;\n}\n\nconst noop = () => undefined;\nconst silentLogger: RemoteHostLogger = { info: noop, warn: noop, debug: noop };\n\nexport const createRemoteHost = (deps: RemoteHostDeps): RemoteHostLifecycle => {\n const log = deps.log ?? silentLogger;\n\n // The running host runner's stop() handle, or null when disconnected. Keeps\n // the single-host invariant — one runner at a time.\n let stopRunner: (() => void) | null = null;\n\n // Serialize connect/disconnect so overlapping requests can't both mutate\n // stopRunner (which would leak a second runner) or race auth against teardown.\n // Each transition runs only after the previous one settles; a failed\n // transition does not block the next (both handlers run the next op).\n let transition: Promise<unknown> = Promise.resolve();\n\n const serialize = <T>(operation: () => Promise<T>): Promise<T> => {\n const next = transition.then(operation, operation);\n transition = next.then(noop, noop);\n return next;\n };\n\n const stopIfRunning = () => {\n if (stopRunner) {\n stopRunner();\n stopRunner = null;\n }\n };\n\n const status = (): RemoteHostStatus => ({ connected: stopRunner !== null, uid: deps.currentUid() });\n\n const startRunner = (uid: string) => {\n const runner = deps.startRunner({ uid, hostId: deps.hostId }, deps.handlers, {\n onEvent: (event) => log.debug(`host event: ${event.phase} ${event.method}`),\n onExpire: deps.onExpire,\n // The listener died fatally (heartbeat already stopped + offline written);\n // clear the handle so status() stops reporting connected — but only if it\n // still points at THIS runner (a later reconnect may have replaced it).\n onClosed: () => {\n if (stopRunner === runner) {\n stopRunner = null;\n log.warn(\"host runner listener died; marked disconnected\");\n }\n },\n });\n return runner;\n };\n\n const connect = (idToken: string): Promise<RemoteHostStatus> =>\n serialize(async () => {\n // Authenticate BEFORE any teardown, so a failed connect (expired/rejected\n // token) leaves an existing healthy session untouched instead of dropping it.\n const uid = await deps.signIn(idToken);\n stopIfRunning();\n stopRunner = startRunner(uid);\n log.info(`connected as ${uid}, host runner started (hostId=${deps.hostId})`);\n return status();\n });\n\n const disconnect = (): Promise<RemoteHostStatus> =>\n serialize(async () => {\n stopIfRunning();\n await deps.signOut();\n log.info(\"disconnected, host runner stopped\");\n return status();\n });\n\n return { connect, disconnect, status };\n};\n","// Firebase credential exchange for the remote-host runner.\n//\n// The host authenticates to Firestore *as the user* (Option B) using the\n// Firebase JS SDK's signInWithCredential with a browser-minted Google OAuth ID\n// token — no Admin SDK, no project service account. Security rules keep the host\n// scoped to that user's own users/{uid}/… subtree.\n//\n// Extracted into core from MulmoClaude's server/remoteHost/auth.ts. The `auth`\n// instance is a parameter so each host binds its own Firebase init; the factory\n// returns the low-level credential primitives (the connect/disconnect lifecycle\n// that starts/stops the host runner lives in createRemoteHost).\nimport { Auth, GoogleAuthProvider, signInWithCredential, signOut } from \"firebase/auth\";\n\nexport interface RemoteHostAuth {\n // Establish the Firebase session from a browser-minted Google OAuth ID token.\n // The token is used once here; the JS SDK then holds its own refresh token for\n // the process lifetime. Resolves to the authenticated uid.\n signInHost: (idToken: string) => Promise<string>;\n // Tear down the Firebase session (in-memory persistence → re-login on restart).\n signOutHost: () => Promise<void>;\n // The currently signed-in uid, or null when disconnected.\n currentUid: () => string | null;\n}\n\nexport const createRemoteHostAuth = (auth: Auth): RemoteHostAuth => ({\n signInHost: async (idToken: string): Promise<string> => {\n const credential = GoogleAuthProvider.credential(idToken);\n const userCredential = await signInWithCredential(auth, credential);\n return userCredential.user.uid;\n },\n signOutHost: (): Promise<void> => signOut(auth),\n currentUid: (): string | null => auth.currentUser?.uid ?? null,\n});\n","// Firebase init for a remote-host runner.\n//\n// A host acts as a *host*: it signs in to Firebase as the user (via\n// signInWithCredential, see auth.ts) and listens to that user's command queue in\n// Firestore. The modular firebase/firestore + firebase/auth SDKs run in Node, so\n// this mirrors a browser init but also exposes Firestore (default database,\n// which must be in Native mode) and Storage.\n//\n// Extracted into core from MulmoClaude's server/remoteHost/firebase.ts. The\n// public web config is a parameter so each host supplies its own (both hosts\n// reuse the shared mulmoserver project).\nimport { FirebaseApp, FirebaseOptions, initializeApp } from \"firebase/app\";\nimport { Auth, getAuth } from \"firebase/auth\";\nimport { Firestore, getFirestore } from \"firebase/firestore\";\nimport { FirebaseStorage, getStorage } from \"firebase/storage\";\n\nexport interface RemoteHostFirebase {\n app: FirebaseApp;\n auth: Auth;\n firestore: Firestore;\n // Storage carries the full-res attachment bytes the command channel can't (a\n // Firestore command doc caps at ~1 MiB). The host, signed in as the user,\n // pulls each staged upload from `users/{uid}/uploads/{id}` and deletes it\n // after ingest.\n storage: FirebaseStorage;\n}\n\nexport const createRemoteHostFirebase = (config: FirebaseOptions): RemoteHostFirebase => {\n const app = initializeApp(config);\n return { app, auth: getAuth(app), firestore: getFirestore(app), storage: getStorage(app) };\n};\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../../../src/remote-host/server/hostRunner.ts","../../../src/remote-host/server/lifecycle.ts","../../../src/remote-host/server/auth.ts","../../../src/remote-host/server/firebase.ts"],"sourcesContent":["// Host side of the command channel: claim queued commands, run handlers, write\n// results back, and announce presence via heartbeat.\n//\n// Extracted into core from MulmoClaude's server/remoteHost/hostRunner.ts (itself\n// ported from ../mulmoserver). The only signature change vs. that copy: the\n// `firestore` instance is a parameter (each host supplies its own Firebase init),\n// and the heartbeat interval is an option (defaults to one minute).\nimport { DocumentReference, Firestore, deleteDoc, onSnapshot, query, runTransaction, serverTimestamp, setDoc, updateDoc, where } from \"firebase/firestore\";\n\nimport { errorMessage } from \"../../collection/core/errorMessage.js\";\nimport {\n Channel,\n Command,\n CommandHandler,\n CommandHandlers,\n JsonObject,\n buildHostPresence,\n byCreatedAt,\n commandsCollection,\n hostDoc,\n isExpired,\n} from \"../index.js\";\n\nconst DEFAULT_HEARTBEAT_MS = 60_000;\n\nexport interface HostEvent {\n phase: \"received\" | \"done\" | \"error\";\n method: string;\n message?: string;\n}\n\nexport interface HostRunnerOptions {\n onEvent?: (event: HostEvent) => void;\n // Called once when the listener dies fatally (after presence has been set\n // offline), so the lifecycle owner can reconcile its own state — e.g. clear\n // the runner handle so status() no longer reports connected. NOT called on a\n // normal stop().\n onClosed?: () => void;\n // Called when a command is dropped for being past its `expiresAt`, BEFORE the\n // doc is deleted, so the host can clean up out-of-band resources the command\n // referenced (e.g. staged attachment uploads in Storage). `uid` is THIS runner's\n // session uid (channel.uid) — passed in rather than read from a global so a\n // concurrent reconnect as a different account can't point cleanup at the wrong\n // user's Storage path. Best-effort: a throw is logged via onEvent and does NOT\n // block the doc deletion. Absent ⇒ the expired doc is simply deleted.\n onExpire?: (command: Command, uid: string) => void | Promise<void>;\n // Presence heartbeat interval; defaults to one minute.\n heartbeatMs?: number;\n}\n\ninterface Claim {\n method: string;\n params: JsonObject;\n}\n\nconst noop = () => undefined;\n\n// The remote may have deleted the doc on timeout, so ignore write-after-delete.\nconst writeError = (ref: DocumentReference, code: string, message: string) =>\n updateDoc(ref, { status: \"error\", error: { code, message }, updatedAt: serverTimestamp() }).catch(noop);\n\n// Atomically move a command queued -> processing so it is handled exactly once.\n// Returns the method/params to run, or null if another handler already took it.\nconst claimCommand = (firestore: Firestore, ref: DocumentReference): Promise<Claim | null> =>\n runTransaction(firestore, async (txn) => {\n const data = (await txn.get(ref)).data() as Command | undefined;\n if (!data || data.status !== \"queued\") {\n return null;\n }\n txn.update(ref, { status: \"processing\", updatedAt: serverTimestamp() });\n return { method: data.method, params: data.params ?? {} };\n });\n\nconst runHandler = async (ref: DocumentReference, claim: Claim, handler: CommandHandler): Promise<HostEvent> => {\n try {\n const result = await handler(claim.params);\n await updateDoc(ref, { status: \"done\", result: result ?? null, updatedAt: serverTimestamp() });\n return { phase: \"done\", method: claim.method };\n } catch (error) {\n const message = errorMessage(error);\n await writeError(ref, \"handler_error\", message);\n return { phase: \"error\", method: claim.method, message };\n }\n};\n\n// A command past its deadline is removed entirely rather than run: give the host\n// a chance to clean up out-of-band resources (staged attachments), then delete\n// the doc so it is neither reprocessed nor left as a stale error. Both steps are\n// best-effort/idempotent, so a snapshot replay surfacing the same expired doc\n// twice is harmless (no claim transaction needed — see plan edge #3).\nconst expireCommand = async (ref: DocumentReference, command: Command, options: HostRunnerOptions, uid: string) => {\n try {\n await options.onExpire?.(command, uid);\n } catch (error) {\n options.onEvent?.({ phase: \"error\", method: command.method, message: `onExpire failed: ${errorMessage(error)}` });\n }\n // Surface a delete failure (permissions / transient network) the same way the\n // onExpire failure above is surfaced — otherwise the expired doc lingers as\n // \"queued\" with no signal as to why cleanup didn't happen.\n await deleteDoc(ref).catch((error) => {\n options.onEvent?.({ phase: \"error\", method: command.method, message: `expire delete failed: ${errorMessage(error)}` });\n });\n options.onEvent?.({ phase: \"done\", method: command.method, message: \"expired\" });\n};\n\n// Per-runner constants bundled into one context so processCommand stays under the\n// max-params cap: firestore, the handler table, options, and the session uid are\n// all fixed for the runner's lifetime; only ref/command/now vary per command.\ninterface RunnerContext {\n firestore: Firestore;\n handlers: CommandHandlers;\n options: HostRunnerOptions;\n uid: string;\n}\n\nconst processCommand = async (ctx: RunnerContext, ref: DocumentReference, command: Command, now: number) => {\n const { handlers, options } = ctx;\n // Drop an expired command before claiming it — it must never reach a handler.\n if (isExpired(command, now)) {\n await expireCommand(ref, command, options, ctx.uid);\n return;\n }\n const claim = await claimCommand(ctx.firestore, ref);\n if (!claim) {\n return;\n }\n options.onEvent?.({ phase: \"received\", method: claim.method });\n const handler: CommandHandler | undefined = handlers[claim.method];\n if (!handler) {\n await writeError(ref, \"unknown_method\", `No handler for method: ${claim.method}`);\n options.onEvent?.({ phase: \"error\", method: claim.method, message: \"unknown method\" });\n return;\n }\n options.onEvent?.(await runHandler(ref, claim, handler));\n};\n\n// startHostRunner subscribes to queued commands for the given channel and runs\n// each one through the supplied handler table. It also announces presence (a\n// heartbeat on users/{uid}/hosts/{hostId}) so the remote can tell it is online.\n// Returns a stop function that goes offline and detaches the listener.\nexport const startHostRunner = (firestore: Firestore, channel: Channel, handlers: CommandHandlers, options: HostRunnerOptions = {}): (() => void) => {\n const presence = hostDoc(firestore, channel);\n // Advertise online/offline + the capability set (method names + protocol\n // version) on the same doc the remote already listens to for presence.\n const writePresence = (online: boolean) => setDoc(presence, { ...buildHostPresence(channel, handlers, online), updatedAt: serverTimestamp() }).catch(noop);\n const announce = () => {\n writePresence(true);\n };\n announce();\n const beat = setInterval(announce, options.heartbeatMs ?? DEFAULT_HEARTBEAT_MS);\n\n const queuedCommands = query(commandsCollection(firestore, channel), where(\"status\", \"==\", \"queued\"));\n const ctx: RunnerContext = { firestore, handlers, options, uid: channel.uid };\n const unsubscribe = onSnapshot(\n queuedCommands,\n (snapshot) => {\n const now = Date.now();\n // Best-effort oldest-first DISPATCH only. Commands are processed\n // concurrently (not awaited in turn) and out-of-order completion is fine by\n // design — chat is asynchronous — so this sort just biases which command\n // starts first; it is not an ordering guarantee. We still sort in memory\n // rather than orderBy(\"createdAt\") on the query because a Firestore orderBy\n // silently EXCLUDES docs missing the field — which would drop every\n // pre-offline-queue command (no createdAt) from the queue entirely.\n const added = snapshot\n .docChanges()\n .filter((change) => change.type === \"added\")\n .map((change) => ({ ref: change.doc.ref, command: change.doc.data() as Command }))\n .sort((left, right) => byCreatedAt(left.command, right.command));\n added.forEach(({ ref, command }) => {\n processCommand(ctx, ref, command, now).catch(noop);\n });\n },\n (error) => {\n options.onEvent?.({ phase: \"error\", method: \"listen\", message: error.message });\n // A Firestore onSnapshot error terminates the listener and it does not\n // recover on its own. Stop advertising presence (clear the heartbeat +\n // write online:false) so remotes see the host as offline instead of a\n // live host that silently consumes no commands.\n clearInterval(beat);\n writePresence(false);\n options.onClosed?.();\n },\n );\n\n return () => {\n clearInterval(beat);\n writePresence(false);\n unsubscribe();\n };\n};\n","// Remote-host lifecycle: wire a Firebase session to the Firestore host runner so\n// connecting starts the command loop + presence heartbeat, and disconnecting\n// stops both.\n//\n// Extracted into core from MulmoClaude's server/remoteHost/index.ts. The factory\n// takes injected collaborators — real hosts bind them to Firebase; tests pass\n// fakes to exercise the invariants (non-destructive connect, serialized\n// transitions, status/liveness reconciliation on fatal listener death) without a\n// network. `hostId` and the logger are injected too, so this file imports no\n// Firebase and no host logger and stays trivially unit-testable.\n//\n// Single-account, single-host per instance, in-memory session: a host restart\n// drops the session and needs a re-connect.\nimport type { Channel, Command, CommandHandlers } from \"../index.js\";\nimport type { HostRunnerOptions } from \"./hostRunner.js\";\n\nexport interface RemoteHostStatus {\n connected: boolean;\n uid: string | null;\n}\n\n// Minimal logger the factory calls; each host adapts its own logger to this\n// shape (or omits it to run silently, as the tests do).\nexport interface RemoteHostLogger {\n info: (msg: string) => void;\n warn: (msg: string) => void;\n debug: (msg: string) => void;\n}\n\n// Injectable collaborators — a real host binds these to Firebase + its own\n// hostId; tests pass fakes to exercise the lifecycle without a network.\n// `startRunner` is the host's `startHostRunner` pre-bound with its Firestore\n// instance, so this module needs no Firebase of its own.\nexport interface RemoteHostDeps {\n hostId: string;\n signIn: (idToken: string) => Promise<string>;\n signOut: () => Promise<void>;\n currentUid: () => string | null;\n startRunner: (channel: Channel, handlers: CommandHandlers, options: HostRunnerOptions) => () => void;\n handlers: CommandHandlers;\n // Optional host-specific cleanup for a command the runner drops as expired\n // (e.g. delete its staged attachment uploads). Threaded verbatim into the\n // runner's `onExpire`, which supplies the session `uid` so cleanup targets the\n // right user's Storage path even across a reconnect; absent ⇒ an expired doc is\n // just deleted.\n onExpire?: (command: Command, uid: string) => void | Promise<void>;\n log?: RemoteHostLogger;\n}\n\nexport interface RemoteHostLifecycle {\n connect: (idToken: string) => Promise<RemoteHostStatus>;\n disconnect: () => Promise<RemoteHostStatus>;\n status: () => RemoteHostStatus;\n}\n\nconst noop = () => undefined;\nconst silentLogger: RemoteHostLogger = { info: noop, warn: noop, debug: noop };\n\nexport const createRemoteHost = (deps: RemoteHostDeps): RemoteHostLifecycle => {\n const log = deps.log ?? silentLogger;\n\n // The running host runner's stop() handle, or null when disconnected. Keeps\n // the single-host invariant — one runner at a time.\n let stopRunner: (() => void) | null = null;\n\n // Serialize connect/disconnect so overlapping requests can't both mutate\n // stopRunner (which would leak a second runner) or race auth against teardown.\n // Each transition runs only after the previous one settles; a failed\n // transition does not block the next (both handlers run the next op).\n let transition: Promise<unknown> = Promise.resolve();\n\n const serialize = <T>(operation: () => Promise<T>): Promise<T> => {\n const next = transition.then(operation, operation);\n transition = next.then(noop, noop);\n return next;\n };\n\n const stopIfRunning = () => {\n if (stopRunner) {\n stopRunner();\n stopRunner = null;\n }\n };\n\n const status = (): RemoteHostStatus => ({ connected: stopRunner !== null, uid: deps.currentUid() });\n\n const startRunner = (uid: string) => {\n const runner = deps.startRunner({ uid, hostId: deps.hostId }, deps.handlers, {\n onEvent: (event) => log.debug(`host event: ${event.phase} ${event.method}`),\n onExpire: deps.onExpire,\n // The listener died fatally (heartbeat already stopped + offline written);\n // clear the handle so status() stops reporting connected — but only if it\n // still points at THIS runner (a later reconnect may have replaced it).\n onClosed: () => {\n if (stopRunner === runner) {\n stopRunner = null;\n log.warn(\"host runner listener died; marked disconnected\");\n }\n },\n });\n return runner;\n };\n\n const connect = (idToken: string): Promise<RemoteHostStatus> =>\n serialize(async () => {\n // Authenticate BEFORE any teardown, so a failed connect (expired/rejected\n // token) leaves an existing healthy session untouched instead of dropping it.\n const uid = await deps.signIn(idToken);\n stopIfRunning();\n stopRunner = startRunner(uid);\n log.info(`connected as ${uid}, host runner started (hostId=${deps.hostId})`);\n return status();\n });\n\n const disconnect = (): Promise<RemoteHostStatus> =>\n serialize(async () => {\n stopIfRunning();\n await deps.signOut();\n log.info(\"disconnected, host runner stopped\");\n return status();\n });\n\n return { connect, disconnect, status };\n};\n","// Firebase credential exchange for the remote-host runner.\n//\n// The host authenticates to Firestore *as the user* (Option B) using the\n// Firebase JS SDK's signInWithCredential with a browser-minted Google OAuth ID\n// token — no Admin SDK, no project service account. Security rules keep the host\n// scoped to that user's own users/{uid}/… subtree.\n//\n// Extracted into core from MulmoClaude's server/remoteHost/auth.ts. The `auth`\n// instance is a parameter so each host binds its own Firebase init; the factory\n// returns the low-level credential primitives (the connect/disconnect lifecycle\n// that starts/stops the host runner lives in createRemoteHost).\nimport { Auth, GoogleAuthProvider, signInWithCredential, signOut } from \"firebase/auth\";\n\nexport interface RemoteHostAuth {\n // Establish the Firebase session from a browser-minted Google OAuth ID token.\n // The token is used once here; the JS SDK then holds its own refresh token for\n // the process lifetime. Resolves to the authenticated uid.\n signInHost: (idToken: string) => Promise<string>;\n // Tear down the Firebase session (in-memory persistence → re-login on restart).\n signOutHost: () => Promise<void>;\n // The currently signed-in uid, or null when disconnected.\n currentUid: () => string | null;\n}\n\nexport const createRemoteHostAuth = (auth: Auth): RemoteHostAuth => ({\n signInHost: async (idToken: string): Promise<string> => {\n const credential = GoogleAuthProvider.credential(idToken);\n const userCredential = await signInWithCredential(auth, credential);\n return userCredential.user.uid;\n },\n signOutHost: (): Promise<void> => signOut(auth),\n currentUid: (): string | null => auth.currentUser?.uid ?? null,\n});\n","// Firebase init for a remote-host runner.\n//\n// A host acts as a *host*: it signs in to Firebase as the user (via\n// signInWithCredential, see auth.ts) and listens to that user's command queue in\n// Firestore. The modular firebase/firestore + firebase/auth SDKs run in Node, so\n// this mirrors a browser init but also exposes Firestore (default database,\n// which must be in Native mode) and Storage.\n//\n// Extracted into core from MulmoClaude's server/remoteHost/firebase.ts. The\n// public web config is a parameter so each host supplies its own (both hosts\n// reuse the shared mulmoserver project).\nimport { FirebaseApp, FirebaseOptions, initializeApp } from \"firebase/app\";\nimport { Auth, getAuth } from \"firebase/auth\";\nimport { Firestore, getFirestore } from \"firebase/firestore\";\nimport { FirebaseStorage, getStorage } from \"firebase/storage\";\n\nexport interface RemoteHostFirebase {\n app: FirebaseApp;\n auth: Auth;\n firestore: Firestore;\n // Storage carries the full-res attachment bytes the command channel can't (a\n // Firestore command doc caps at ~1 MiB). The host, signed in as the user,\n // pulls each staged upload from `users/{uid}/uploads/{id}` and deletes it\n // after ingest.\n storage: FirebaseStorage;\n}\n\nexport const createRemoteHostFirebase = (config: FirebaseOptions): RemoteHostFirebase => {\n const app = initializeApp(config);\n return { app, auth: getAuth(app), firestore: getFirestore(app), storage: getStorage(app) };\n};\n"],"mappings":"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"}
|
|
@@ -16,7 +16,7 @@ export interface RemoteHostDeps {
|
|
|
16
16
|
currentUid: () => string | null;
|
|
17
17
|
startRunner: (channel: Channel, handlers: CommandHandlers, options: HostRunnerOptions) => () => void;
|
|
18
18
|
handlers: CommandHandlers;
|
|
19
|
-
onExpire?: (command: Command) => void | Promise<void>;
|
|
19
|
+
onExpire?: (command: Command, uid: string) => void | Promise<void>;
|
|
20
20
|
log?: RemoteHostLogger;
|
|
21
21
|
}
|
|
22
22
|
export interface RemoteHostLifecycle {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mulmoclaude/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "Shared server-side core for MulmoClaude and MulmoTerminal — the always-shipped-together subsystems consolidated behind subpath exports so the two hosts can't drift. Server-only except the browser-safe ./whisper/client, ./workspace-setup/slug, ./translation/client, ./remote-view and ./remote-host entries. All host specifics are injected.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|