@triflux/remote 10.1.0 → 10.26.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.
Files changed (162) hide show
  1. package/hub/pipe.mjs +239 -121
  2. package/hub/public/dashboard.html +61 -0
  3. package/hub/server.mjs +2179 -709
  4. package/hub/store-adapter.mjs +59 -26
  5. package/hub/store.mjs +356 -153
  6. package/hub/team/agent-map.json +26 -7
  7. package/hub/team/ansi.mjs +245 -146
  8. package/hub/team/backend.mjs +78 -17
  9. package/hub/team/build-worker-prompt.mjs +73 -0
  10. package/hub/team/check-mcp-hub.mjs +49 -0
  11. package/hub/team/claude-daemon-control.mjs +1130 -0
  12. package/hub/team/claude-native-bridge.mjs +1011 -0
  13. package/hub/team/claude-session-projection.mjs +74 -0
  14. package/hub/team/cli/commands/attach.mjs +57 -37
  15. package/hub/team/cli/commands/control.mjs +29 -8
  16. package/hub/team/cli/commands/debug.mjs +95 -74
  17. package/hub/team/cli/commands/focus.mjs +80 -53
  18. package/hub/team/cli/commands/interrupt.mjs +18 -6
  19. package/hub/team/cli/commands/kill.mjs +16 -5
  20. package/hub/team/cli/commands/list.mjs +31 -24
  21. package/hub/team/cli/commands/send.mjs +19 -6
  22. package/hub/team/cli/commands/start/index.mjs +151 -33
  23. package/hub/team/cli/commands/start/parse-args.mjs +97 -13
  24. package/hub/team/cli/commands/start/start-headless.mjs +155 -37
  25. package/hub/team/cli/commands/start/start-in-process.mjs +50 -40
  26. package/hub/team/cli/commands/start/start-mux.mjs +122 -73
  27. package/hub/team/cli/commands/start/start-wt.mjs +86 -69
  28. package/hub/team/cli/commands/status.mjs +43 -14
  29. package/hub/team/cli/commands/stop.mjs +11 -4
  30. package/hub/team/cli/commands/task.mjs +8 -3
  31. package/hub/team/cli/commands/tasks.mjs +13 -13
  32. package/hub/team/cli/help.mjs +1 -1
  33. package/hub/team/cli/index.mjs +2 -2
  34. package/hub/team/cli/manifest.mjs +38 -8
  35. package/hub/team/cli/render.mjs +52 -30
  36. package/hub/team/cli/services/attach-fallback.mjs +74 -54
  37. package/hub/team/cli/services/hub-client.mjs +44 -16
  38. package/hub/team/cli/services/member-selector.mjs +37 -30
  39. package/hub/team/cli/services/native-control.mjs +144 -116
  40. package/hub/team/cli/services/runtime-mode.mjs +61 -4
  41. package/hub/team/cli/services/state-store.mjs +66 -9
  42. package/hub/team/cli/services/task-model.mjs +40 -30
  43. package/hub/team/codex-review.mjs +521 -0
  44. package/hub/team/conductor-mesh-bridge.mjs +25 -24
  45. package/hub/team/conductor-registry.mjs +121 -0
  46. package/hub/team/conductor.mjs +1379 -671
  47. package/hub/team/consensus-meta.mjs +176 -0
  48. package/hub/team/dashboard-anchor.mjs +4 -5
  49. package/hub/team/dashboard-layout.mjs +3 -1
  50. package/hub/team/dashboard-open.mjs +20 -118
  51. package/hub/team/dashboard.mjs +76 -28
  52. package/hub/team/event-log.mjs +18 -10
  53. package/hub/team/execution-mode.mjs +256 -0
  54. package/hub/team/extract-completion-payload.mjs +110 -0
  55. package/hub/team/git-preflight.mjs +286 -0
  56. package/hub/team/handoff.mjs +49 -18
  57. package/hub/team/headless-bridge-session.mjs +17 -0
  58. package/hub/team/headless.mjs +2213 -1156
  59. package/hub/team/health-probe.mjs +232 -69
  60. package/hub/team/interactive-native-launcher.mjs +106 -0
  61. package/hub/team/interactive-native-worker.mjs +80 -0
  62. package/hub/team/interactive-tui-transport.mjs +465 -0
  63. package/hub/team/launcher-template.mjs +49 -17
  64. package/hub/team/lead-control.mjs +91 -4
  65. package/hub/team/mcp-selector.mjs +145 -0
  66. package/hub/team/native-supervisor.mjs +90 -26
  67. package/hub/team/native.mjs +120 -42
  68. package/hub/team/nativeProxy.mjs +184 -113
  69. package/hub/team/notify.mjs +147 -78
  70. package/hub/team/orchestrator.mjs +173 -161
  71. package/hub/team/pane.mjs +43 -20
  72. package/hub/team/process-cleanup.mjs +69 -25
  73. package/hub/team/psmux.mjs +1022 -281
  74. package/hub/team/recovery-store.mjs +149 -0
  75. package/hub/team/remote-probe.mjs +68 -52
  76. package/hub/team/remote-session.mjs +131 -59
  77. package/hub/team/remote-watcher.mjs +61 -33
  78. package/hub/team/retry-state-machine.mjs +336 -0
  79. package/hub/team/routing.mjs +51 -25
  80. package/hub/team/runtime-strategy.mjs +200 -0
  81. package/hub/team/schemas/worker-completion.json +39 -0
  82. package/hub/team/sentinel-capture.mjs +127 -0
  83. package/hub/team/session-sync.mjs +153 -6
  84. package/hub/team/session.mjs +574 -611
  85. package/hub/team/shared.mjs +13 -13
  86. package/hub/team/staleState.mjs +72 -30
  87. package/hub/team/swarm-cli.mjs +390 -0
  88. package/hub/team/swarm-hypervisor.mjs +1544 -193
  89. package/hub/team/swarm-intent.mjs +216 -0
  90. package/hub/team/swarm-locks.mjs +140 -25
  91. package/hub/team/swarm-planner.mjs +121 -21
  92. package/hub/team/swarm-preflight.mjs +319 -0
  93. package/hub/team/swarm-reconciler.mjs +128 -23
  94. package/hub/team/synapse-cli.mjs +458 -0
  95. package/hub/team/synapse-http.mjs +60 -0
  96. package/hub/team/synapse-registry.mjs +329 -0
  97. package/hub/team/terminal-opener.mjs +216 -0
  98. package/hub/team/tui-core.mjs +292 -0
  99. package/hub/team/tui-lite.mjs +237 -148
  100. package/hub/team/tui-remote-adapter.mjs +18 -34
  101. package/hub/team/tui-synapse.mjs +223 -0
  102. package/hub/team/tui-viewer.mjs +108 -48
  103. package/hub/team/tui-widgets.mjs +262 -0
  104. package/hub/team/tui.mjs +931 -439
  105. package/hub/team/worker-completion-validator.mjs +84 -0
  106. package/hub/team/worker-sandbox.mjs +97 -0
  107. package/hub/team/worker-signal.mjs +263 -0
  108. package/hub/team/worker-signal.types.d.ts +52 -0
  109. package/hub/team/worktree-lifecycle.mjs +517 -63
  110. package/hub/team/wt-manager.mjs +297 -26
  111. package/hub/tools.mjs +477 -252
  112. package/hub/tray.mjs +411 -368
  113. package/hub/workers/antigravity-route-worker.mjs +169 -0
  114. package/hub/workers/claude-worker.mjs +162 -118
  115. package/hub/workers/codex-app-server-worker.mjs +1015 -0
  116. package/hub/workers/codex-mcp.mjs +724 -507
  117. package/hub/workers/delegator-mcp.mjs +570 -499
  118. package/hub/workers/factory.mjs +103 -21
  119. package/hub/workers/gemini-worker.mjs +126 -284
  120. package/hub/workers/interface.mjs +24 -2
  121. package/hub/workers/lib/jsonrpc-stdio.mjs +463 -0
  122. package/hub/workers/worker-utils.mjs +23 -17
  123. package/package.json +4 -3
  124. package/scripts/lib/agent-json.mjs +27 -0
  125. package/scripts/lib/async.mjs +174 -0
  126. package/scripts/lib/claudemd-scanner.mjs +223 -0
  127. package/scripts/lib/cli-agy.mjs +62 -0
  128. package/scripts/lib/cli-claude.mjs +78 -0
  129. package/scripts/lib/cli-codex.mjs +199 -0
  130. package/scripts/lib/cli-gemini.mjs +37 -0
  131. package/scripts/lib/codex-recovery.sh +50 -0
  132. package/scripts/lib/context.mjs +67 -0
  133. package/scripts/lib/cross-review-utils.mjs +59 -0
  134. package/scripts/lib/doctor-env-checks.mjs +121 -0
  135. package/scripts/lib/dynamic-route-cli.mjs +107 -0
  136. package/scripts/lib/env-probe.mjs +269 -0
  137. package/scripts/lib/env.mjs +14 -0
  138. package/scripts/lib/gemini-profiles.mjs +119 -0
  139. package/scripts/lib/handoff.mjs +187 -0
  140. package/scripts/lib/hook-utils.mjs +16 -0
  141. package/scripts/lib/hub.mjs +48 -0
  142. package/scripts/lib/keyword-rules.mjs +195 -0
  143. package/scripts/lib/logger.mjs +105 -0
  144. package/scripts/lib/mcp-filter.mjs +883 -0
  145. package/scripts/lib/mcp-gateway-health-check.mjs +159 -0
  146. package/scripts/lib/mcp-gateway-servers.mjs +70 -0
  147. package/scripts/lib/mcp-gateway-wrapper-check.mjs +35 -0
  148. package/scripts/lib/mcp-guard-engine.mjs +2120 -0
  149. package/scripts/lib/mcp-health.mjs +773 -0
  150. package/scripts/lib/mcp-manifest.mjs +89 -0
  151. package/scripts/lib/mcp-server-catalog.mjs +355 -0
  152. package/scripts/lib/pid.mjs +63 -0
  153. package/scripts/lib/process-utils.mjs +26 -0
  154. package/scripts/lib/psmux-info.mjs +224 -0
  155. package/scripts/lib/quota.mjs +47 -0
  156. package/scripts/lib/remote-spawn-transfer.mjs +226 -0
  157. package/scripts/lib/skill-state.mjs +220 -0
  158. package/scripts/lib/skill-template.mjs +375 -0
  159. package/scripts/lib/team.mjs +75 -0
  160. package/scripts/lib/timeout.mjs +66 -0
  161. package/scripts/lib/tmp.mjs +26 -0
  162. package/scripts/lib/toml.mjs +113 -0
@@ -0,0 +1,84 @@
1
+ // hub/team/worker-completion-validator.mjs — Enforce worker completion schema.
2
+ // Issue #115 Lane 1 / F7_worker_did_not_commit.
3
+ //
4
+ // A shard worker MUST emit a completion JSON with `status: "ok"` + a non-empty
5
+ // `commits_made` array. Reporting without committing is the #1 cause of lost
6
+ // swarm work — guarded here before integration trusts the worker.
7
+
8
+ import { readFileSync } from "node:fs";
9
+ import { dirname, join } from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+
12
+ import Ajv from "ajv";
13
+
14
+ const schemaPath = join(
15
+ dirname(fileURLToPath(import.meta.url)),
16
+ "schemas",
17
+ "worker-completion.json",
18
+ );
19
+ const schema = JSON.parse(readFileSync(schemaPath, "utf8"));
20
+
21
+ const ajv = new Ajv({ allErrors: true, strict: false });
22
+ const compiled = ajv.compile(schema);
23
+
24
+ /**
25
+ * Validate a worker completion payload against worker-completion.json.
26
+ * Returns `{ ok: true }` when the payload satisfies the schema, otherwise
27
+ * `{ ok: false, reason }` with a short human-readable reason string.
28
+ *
29
+ * @param {unknown} payload
30
+ * @returns {{ ok: boolean, reason?: string }}
31
+ */
32
+ export function validateWorkerCompletion(payload) {
33
+ if (payload === null || typeof payload !== "object") {
34
+ return { ok: false, reason: "payload_not_object" };
35
+ }
36
+
37
+ const valid = compiled(payload);
38
+ if (!valid) {
39
+ const firstError = compiled.errors?.[0];
40
+ const reason = formatError(firstError, payload);
41
+ return { ok: false, reason };
42
+ }
43
+
44
+ // BUG-G (#130): the schema's if-then only constrains commits_made when
45
+ // status='ok', so status='failed' payloads pass AJV vacuously. Without
46
+ // this guard the hypervisor F7 path treats a worker self-reported failure
47
+ // as a successful completion and integrates phantom shards.
48
+ if (payload.status === "failed") {
49
+ const detail =
50
+ typeof payload.reason === "string" && payload.reason.length > 0
51
+ ? payload.reason
52
+ : "unspecified";
53
+ return { ok: false, reason: `worker_self_reported_failure:${detail}` };
54
+ }
55
+
56
+ return { ok: true };
57
+ }
58
+
59
+ function formatError(err, payload) {
60
+ if (!err) return "schema_validation_failed";
61
+
62
+ // Map Ajv keywords to short, actionable reasons.
63
+ if (
64
+ err.keyword === "required" &&
65
+ err.params?.missingProperty === "commits_made"
66
+ ) {
67
+ return "missing_commits_made";
68
+ }
69
+ if (err.keyword === "minItems" && err.instancePath === "/commits_made") {
70
+ return "empty_commits_made";
71
+ }
72
+ if (err.keyword === "enum" && err.instancePath === "/status") {
73
+ return `invalid_status:${payload?.status ?? "undefined"}`;
74
+ }
75
+ if (err.keyword === "type" && err.instancePath === "/commits_made") {
76
+ return "commits_made_not_array";
77
+ }
78
+ if (err.keyword === "required" && err.params?.missingProperty === "status") {
79
+ return "missing_status";
80
+ }
81
+
82
+ const path = err.instancePath || "(root)";
83
+ return `${err.keyword}@${path}:${err.message}`;
84
+ }
@@ -0,0 +1,97 @@
1
+ // hub/team/worker-sandbox.mjs — local worker HOME/APPDATA isolation helpers
2
+ //
3
+ // Team/swarm workers run untrusted task-specific CLIs. Keep their mutable user
4
+ // state under the worker worktree instead of the operator's real home/appdata,
5
+ // while preserving explicitly brokered auth homes such as CODEX_HOME.
6
+
7
+ import { mkdirSync } from "node:fs";
8
+ import { join, resolve, win32 } from "node:path";
9
+
10
+ const SAFE_SEGMENT_RE = /[^a-zA-Z0-9._-]/g;
11
+
12
+ function safeSegment(value, fallback = "worker") {
13
+ const raw = String(value ?? "").trim();
14
+ const cleaned = raw.replace(SAFE_SEGMENT_RE, "_").replace(/^_+|_+$/g, "");
15
+ return cleaned || fallback;
16
+ }
17
+
18
+ function isDisabled(env = {}) {
19
+ return /^(0|false|no|off)$/i.test(String(env.TFX_WORKER_SANDBOX ?? ""));
20
+ }
21
+
22
+ function windowsHomeParts(home) {
23
+ const normalized = String(home || "").replace(/\//g, "\\");
24
+ const match = normalized.match(/^([a-zA-Z]:)(\\.*)$/);
25
+ if (!match) return null;
26
+ return { drive: match[1], path: match[2] || "\\" };
27
+ }
28
+
29
+ function mkdirAll(paths) {
30
+ for (const path of paths) mkdirSync(path, { recursive: true });
31
+ }
32
+
33
+ /**
34
+ * Build a deterministic sandbox home for a local team/swarm worker.
35
+ *
36
+ * @param {object} opts
37
+ * @param {string} [opts.cwd] Worktree/current working directory for the worker.
38
+ * @param {string} [opts.sessionId] Stable session/member id.
39
+ * @param {object} [opts.env] Existing env used for opt-out and explicit root.
40
+ * @param {boolean} [opts.create=true] Create directories eagerly.
41
+ * @returns {{env: object, root: string|null, home: string|null, disabled: boolean}}
42
+ */
43
+ export function buildWorkerSandboxEnv(opts = {}) {
44
+ const env = opts.env && typeof opts.env === "object" ? opts.env : {};
45
+ if (isDisabled(env)) {
46
+ return { env: {}, root: null, home: null, disabled: true };
47
+ }
48
+
49
+ const cwd = resolve(opts.cwd || process.cwd());
50
+ const sessionId = safeSegment(opts.sessionId, "worker");
51
+ const home = resolve(
52
+ env.TFX_WORKER_HOME || join(cwd, ".triflux", "worker-home", sessionId),
53
+ );
54
+ const appData = join(home, "AppData", "Roaming");
55
+ const localAppData = join(home, "AppData", "Local");
56
+ const xdgConfig = join(home, ".config");
57
+ const xdgCache = join(home, ".cache");
58
+ const xdgState = join(home, ".local", "state");
59
+
60
+ if (opts.create !== false) {
61
+ mkdirAll([home, appData, localAppData, xdgConfig, xdgCache, xdgState]);
62
+ }
63
+
64
+ const overlay = {
65
+ TFX_WORKER_HOME: home,
66
+ HOME: home,
67
+ USERPROFILE: home,
68
+ APPDATA: appData,
69
+ LOCALAPPDATA: localAppData,
70
+ XDG_CONFIG_HOME: xdgConfig,
71
+ XDG_CACHE_HOME: xdgCache,
72
+ XDG_STATE_HOME: xdgState,
73
+ };
74
+ const hostHome = env.HOME || env.USERPROFILE;
75
+ if (!env.CODEX_HOME && hostHome) {
76
+ overlay.CODEX_HOME = join(hostHome, ".codex");
77
+ }
78
+
79
+ const winParts =
80
+ windowsHomeParts(home) ||
81
+ (process.platform === "win32"
82
+ ? {
83
+ drive: win32.parse(home).root.replace(/[\\/]$/, "") || "C:",
84
+ path:
85
+ win32
86
+ .relative(win32.parse(home).root, home)
87
+ .replace(/\//g, "\\")
88
+ .replace(/^/, "\\") || "\\",
89
+ }
90
+ : null);
91
+ if (winParts) {
92
+ overlay.HOMEDRIVE = winParts.drive;
93
+ overlay.HOMEPATH = winParts.path;
94
+ }
95
+
96
+ return { env: overlay, root: home, home, disabled: false };
97
+ }
@@ -0,0 +1,263 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import * as nodeFs from "node:fs/promises";
3
+ import { basename, dirname, join } from "node:path";
4
+ import { setTimeout as delay } from "node:timers/promises";
5
+
6
+ const DEFAULT_TIMEOUT_MS = 600_000;
7
+ const DEFAULT_POLL_MS = 1_000;
8
+ const FALLBACK_RENAME_CODES = new Set(["EACCES", "EEXIST", "EPERM"]);
9
+ const TERMINAL_TYPES = new Set(["done", "error"]);
10
+
11
+ function assertSignalDir(signalDir) {
12
+ if (typeof signalDir !== "string" || !signalDir.trim()) {
13
+ throw new Error("WorkerSignalChannel requires a non-empty signalDir");
14
+ }
15
+ }
16
+
17
+ function assertShardId(shardId) {
18
+ if (typeof shardId !== "string" || !shardId.trim()) {
19
+ throw new Error("WorkerSignalChannel requires a non-empty shardId");
20
+ }
21
+ if (basename(shardId) !== shardId || shardId.includes("\\")) {
22
+ throw new Error(`Invalid shardId for signal file: ${shardId}`);
23
+ }
24
+ }
25
+
26
+ function objectValue(value, field) {
27
+ if (value == null) return {};
28
+ if (typeof value !== "object" || Array.isArray(value)) {
29
+ throw new Error(`${field} must be an object`);
30
+ }
31
+ return value;
32
+ }
33
+
34
+ async function readText(filePath, fs) {
35
+ try {
36
+ return await fs.readFile(filePath, "utf8");
37
+ } catch (error) {
38
+ if (error?.code === "ENOENT") return "";
39
+ throw error;
40
+ }
41
+ }
42
+
43
+ async function unlinkQuiet(filePath, fs) {
44
+ try {
45
+ await fs.unlink(filePath);
46
+ } catch (error) {
47
+ if (error?.code !== "ENOENT") throw error;
48
+ }
49
+ }
50
+
51
+ async function renameWithRollback(tempPath, targetPath, backupPath, fs) {
52
+ try {
53
+ await fs.rename(tempPath, targetPath);
54
+ return;
55
+ } catch (error) {
56
+ if (!FALLBACK_RENAME_CODES.has(error?.code)) throw error;
57
+ }
58
+
59
+ let backup = false;
60
+ try {
61
+ await fs.rename(targetPath, backupPath);
62
+ backup = true;
63
+ await fs.rename(tempPath, targetPath);
64
+ } catch (error) {
65
+ if (backup) {
66
+ try {
67
+ await fs.rename(backupPath, targetPath);
68
+ backup = false;
69
+ } catch (rollbackError) {
70
+ error.backupPath = backupPath;
71
+ error.rollbackError = rollbackError;
72
+ }
73
+ } else if (error?.code === "ENOENT") {
74
+ await fs.rename(tempPath, targetPath);
75
+ return;
76
+ }
77
+ throw error;
78
+ } finally {
79
+ if (backup) await unlinkQuiet(backupPath, fs);
80
+ }
81
+ }
82
+
83
+ /**
84
+ * @param {string} filePath
85
+ * @param {string} line
86
+ * @param {{fs?: typeof import("node:fs/promises")}} [options]
87
+ */
88
+ export async function appendSignalLineAtomic(filePath, line, options = {}) {
89
+ const fs = options.fs ?? nodeFs;
90
+ const dir = dirname(filePath);
91
+ const suffix = `${process.pid}.${Date.now()}.${randomUUID()}`;
92
+ const tempPath = join(dir, `.${basename(filePath)}.${suffix}.tmp`);
93
+ const backupPath = join(dir, `.${basename(filePath)}.${suffix}.bak`);
94
+
95
+ await fs.mkdir(dir, { recursive: true });
96
+ const current = await readText(filePath, fs);
97
+
98
+ try {
99
+ await fs.writeFile(tempPath, `${current}${line}`, {
100
+ encoding: "utf8",
101
+ mode: 0o600,
102
+ });
103
+ await renameWithRollback(tempPath, filePath, backupPath, fs);
104
+ } finally {
105
+ await unlinkQuiet(tempPath, fs);
106
+ }
107
+ }
108
+
109
+ export class WorkerSignalChannel {
110
+ #pendingWrite = Promise.resolve();
111
+
112
+ constructor({
113
+ shardId,
114
+ signalDir,
115
+ timeoutMs = DEFAULT_TIMEOUT_MS,
116
+ now,
117
+ } = {}) {
118
+ assertShardId(shardId);
119
+ assertSignalDir(signalDir);
120
+ this.shardId = shardId;
121
+ this.signalDir = signalDir;
122
+ this.timeoutMs = timeoutMs;
123
+ this.now = now ?? Date.now;
124
+ this.filePath = join(signalDir, `${shardId}.jsonl`);
125
+ }
126
+
127
+ start() {
128
+ return this.#write({ type: "start", shardId: this.shardId });
129
+ }
130
+
131
+ heartbeat(meta = {}) {
132
+ return this.#write({
133
+ type: "heartbeat",
134
+ shardId: this.shardId,
135
+ meta: objectValue(meta, "meta"),
136
+ });
137
+ }
138
+
139
+ done(payload) {
140
+ return this.#write({
141
+ type: "done",
142
+ shardId: this.shardId,
143
+ payload: objectValue(payload, "payload"),
144
+ });
145
+ }
146
+
147
+ error(reason, meta = {}) {
148
+ if (typeof reason !== "string" || !reason.trim()) {
149
+ throw new Error("error reason must be a non-empty string");
150
+ }
151
+ return this.#write({
152
+ type: "error",
153
+ shardId: this.shardId,
154
+ reason,
155
+ meta: objectValue(meta, "meta"),
156
+ });
157
+ }
158
+
159
+ async #write(signal) {
160
+ const payload = { ...signal, ts: new Date(this.now()).toISOString() };
161
+ const write = this.#pendingWrite.then(() =>
162
+ appendSignalLineAtomic(this.filePath, `${JSON.stringify(payload)}\n`),
163
+ );
164
+ this.#pendingWrite = write.catch(() => {});
165
+ return write;
166
+ }
167
+
168
+ static async listen({
169
+ signalDir,
170
+ onSignal,
171
+ abortSignal,
172
+ timeoutMs = DEFAULT_TIMEOUT_MS,
173
+ pollMs = DEFAULT_POLL_MS,
174
+ now = Date.now,
175
+ } = {}) {
176
+ assertSignalDir(signalDir);
177
+ if (typeof onSignal !== "function") {
178
+ throw new Error("WorkerSignalChannel.listen requires onSignal callback");
179
+ }
180
+
181
+ const state = {
182
+ lineCounts: new Map(),
183
+ lastLiveAt: new Map(),
184
+ staleShards: new Set(),
185
+ terminalShards: new Set(),
186
+ };
187
+
188
+ while (!abortSignal?.aborted) {
189
+ await scanSignalDir(signalDir, state, onSignal);
190
+ emitStaleSignals(state, timeoutMs, now, onSignal);
191
+ if (abortSignal?.aborted) break;
192
+ try {
193
+ await delay(pollMs, undefined, { signal: abortSignal });
194
+ } catch (error) {
195
+ if (error?.name !== "AbortError") throw error;
196
+ }
197
+ }
198
+ }
199
+ }
200
+
201
+ async function scanSignalDir(signalDir, state, onSignal) {
202
+ let entries;
203
+ try {
204
+ entries = await nodeFs.readdir(signalDir, { withFileTypes: true });
205
+ } catch (error) {
206
+ if (error?.code === "ENOENT") return;
207
+ throw error;
208
+ }
209
+
210
+ const files = entries
211
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl"))
212
+ .map((entry) => join(signalDir, entry.name))
213
+ .sort();
214
+ for (const filePath of files) await scanSignalFile(filePath, state, onSignal);
215
+ }
216
+
217
+ async function scanSignalFile(filePath, state, onSignal) {
218
+ const raw = await readText(filePath, nodeFs);
219
+ const lines = raw.split(/\r?\n/).filter(Boolean);
220
+ const previous = state.lineCounts.get(filePath) ?? 0;
221
+ for (const line of lines.slice(previous > lines.length ? 0 : previous)) {
222
+ const signal = JSON.parse(line);
223
+ onSignal(signal);
224
+ recordSignal(signal, state);
225
+ }
226
+ state.lineCounts.set(filePath, lines.length);
227
+ }
228
+
229
+ function recordSignal(signal, state) {
230
+ if (!signal?.shardId || !signal?.ts) return;
231
+ if (TERMINAL_TYPES.has(signal.type)) {
232
+ state.terminalShards.add(signal.shardId);
233
+ return;
234
+ }
235
+ if (signal.type === "start" || signal.type === "heartbeat") {
236
+ const ts = Date.parse(signal.ts);
237
+ if (Number.isFinite(ts)) {
238
+ state.lastLiveAt.set(signal.shardId, ts);
239
+ state.staleShards.delete(signal.shardId);
240
+ }
241
+ }
242
+ }
243
+
244
+ function emitStaleSignals(state, timeoutMs, now, onSignal) {
245
+ const current = now();
246
+ for (const [shardId, lastTs] of state.lastLiveAt.entries()) {
247
+ if (
248
+ state.terminalShards.has(shardId) ||
249
+ state.staleShards.has(shardId) ||
250
+ current - lastTs <= timeoutMs
251
+ ) {
252
+ continue;
253
+ }
254
+ state.staleShards.add(shardId);
255
+ onSignal({
256
+ type: "stale",
257
+ shardId,
258
+ ts: new Date(current).toISOString(),
259
+ lastTs: new Date(lastTs).toISOString(),
260
+ timeoutMs,
261
+ });
262
+ }
263
+ }
@@ -0,0 +1,52 @@
1
+ export type WorkerSignal =
2
+ | { type: "start"; shardId: string; ts: string }
3
+ | {
4
+ type: "heartbeat";
5
+ shardId: string;
6
+ ts: string;
7
+ meta?: Record<string, unknown>;
8
+ }
9
+ | {
10
+ type: "done";
11
+ shardId: string;
12
+ ts: string;
13
+ payload: Record<string, unknown>;
14
+ }
15
+ | {
16
+ type: "error";
17
+ shardId: string;
18
+ ts: string;
19
+ reason: string;
20
+ meta?: Record<string, unknown>;
21
+ };
22
+
23
+ export type WorkerSignalEvent =
24
+ | WorkerSignal
25
+ | {
26
+ type: "stale";
27
+ shardId: string;
28
+ ts: string;
29
+ lastTs: string;
30
+ timeoutMs: number;
31
+ };
32
+
33
+ export class WorkerSignalChannel {
34
+ constructor(options: {
35
+ shardId: string;
36
+ signalDir: string;
37
+ timeoutMs?: number;
38
+ now?: () => number;
39
+ });
40
+ start(): Promise<void>;
41
+ heartbeat(meta?: Record<string, unknown>): Promise<void>;
42
+ done(payload: Record<string, unknown>): Promise<void>;
43
+ error(reason: string, meta?: Record<string, unknown>): Promise<void>;
44
+ static listen(options: {
45
+ signalDir: string;
46
+ onSignal: (signal: WorkerSignalEvent) => void;
47
+ abortSignal?: AbortSignal;
48
+ timeoutMs?: number;
49
+ pollMs?: number;
50
+ now?: () => number;
51
+ }): Promise<void>;
52
+ }