@triflux/remote 10.1.0 → 10.25.1
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/hub/pipe.mjs +239 -121
- package/hub/public/dashboard.html +61 -0
- package/hub/server.mjs +2165 -708
- package/hub/store-adapter.mjs +59 -26
- package/hub/store.mjs +356 -153
- package/hub/team/agent-map.json +26 -7
- package/hub/team/ansi.mjs +245 -146
- package/hub/team/backend.mjs +84 -17
- package/hub/team/build-worker-prompt.mjs +42 -0
- package/hub/team/check-mcp-hub.mjs +49 -0
- package/hub/team/claude-daemon-control.mjs +219 -0
- package/hub/team/claude-native-bridge.mjs +784 -0
- package/hub/team/claude-session-projection.mjs +65 -0
- package/hub/team/cli/commands/attach.mjs +57 -37
- package/hub/team/cli/commands/control.mjs +29 -8
- package/hub/team/cli/commands/debug.mjs +95 -74
- package/hub/team/cli/commands/focus.mjs +80 -53
- package/hub/team/cli/commands/interrupt.mjs +18 -6
- package/hub/team/cli/commands/kill.mjs +16 -5
- package/hub/team/cli/commands/list.mjs +31 -24
- package/hub/team/cli/commands/send.mjs +19 -6
- package/hub/team/cli/commands/start/index.mjs +154 -33
- package/hub/team/cli/commands/start/parse-args.mjs +89 -12
- package/hub/team/cli/commands/start/start-headless.mjs +125 -37
- package/hub/team/cli/commands/start/start-in-process.mjs +50 -40
- package/hub/team/cli/commands/start/start-mux.mjs +122 -73
- package/hub/team/cli/commands/start/start-wt.mjs +86 -69
- package/hub/team/cli/commands/status.mjs +43 -14
- package/hub/team/cli/commands/stop.mjs +11 -4
- package/hub/team/cli/commands/task.mjs +8 -3
- package/hub/team/cli/commands/tasks.mjs +13 -13
- package/hub/team/cli/index.mjs +2 -2
- package/hub/team/cli/manifest.mjs +38 -8
- package/hub/team/cli/render.mjs +52 -30
- package/hub/team/cli/services/attach-fallback.mjs +74 -54
- package/hub/team/cli/services/hub-client.mjs +44 -16
- package/hub/team/cli/services/member-selector.mjs +37 -30
- package/hub/team/cli/services/native-control.mjs +144 -116
- package/hub/team/cli/services/runtime-mode.mjs +55 -1
- package/hub/team/cli/services/state-store.mjs +66 -9
- package/hub/team/cli/services/task-model.mjs +40 -30
- package/hub/team/codex-review.mjs +521 -0
- package/hub/team/conductor-mesh-bridge.mjs +25 -24
- package/hub/team/conductor-registry.mjs +121 -0
- package/hub/team/conductor.mjs +1379 -671
- package/hub/team/consensus-meta.mjs +176 -0
- package/hub/team/dashboard-anchor.mjs +4 -5
- package/hub/team/dashboard-layout.mjs +3 -1
- package/hub/team/dashboard-open.mjs +20 -118
- package/hub/team/dashboard.mjs +76 -28
- package/hub/team/event-log.mjs +18 -10
- package/hub/team/execution-mode.mjs +241 -0
- package/hub/team/extract-completion-payload.mjs +110 -0
- package/hub/team/git-preflight.mjs +286 -0
- package/hub/team/handoff.mjs +49 -18
- package/hub/team/headless-bridge-session.mjs +17 -0
- package/hub/team/headless.mjs +2065 -1156
- package/hub/team/health-probe.mjs +232 -69
- package/hub/team/launcher-template.mjs +23 -13
- package/hub/team/lead-control.mjs +91 -4
- package/hub/team/mcp-selector.mjs +145 -0
- package/hub/team/native-supervisor.mjs +90 -26
- package/hub/team/native.mjs +74 -35
- package/hub/team/nativeProxy.mjs +184 -113
- package/hub/team/notify.mjs +147 -78
- package/hub/team/orchestrator.mjs +173 -161
- package/hub/team/pane.mjs +43 -15
- package/hub/team/process-cleanup.mjs +69 -25
- package/hub/team/psmux.mjs +1020 -279
- package/hub/team/recovery-store.mjs +149 -0
- package/hub/team/remote-probe.mjs +68 -52
- package/hub/team/remote-session.mjs +131 -59
- package/hub/team/remote-watcher.mjs +61 -33
- package/hub/team/retry-state-machine.mjs +267 -0
- package/hub/team/routing.mjs +51 -25
- package/hub/team/runtime-strategy.mjs +200 -0
- package/hub/team/schemas/worker-completion.json +39 -0
- package/hub/team/sentinel-capture.mjs +127 -0
- package/hub/team/session-sync.mjs +153 -6
- package/hub/team/session.mjs +605 -611
- package/hub/team/shared.mjs +13 -13
- package/hub/team/staleState.mjs +72 -30
- package/hub/team/swarm-cli.mjs +390 -0
- package/hub/team/swarm-hypervisor.mjs +1522 -193
- package/hub/team/swarm-intent.mjs +216 -0
- package/hub/team/swarm-locks.mjs +140 -25
- package/hub/team/swarm-planner.mjs +121 -21
- package/hub/team/swarm-preflight.mjs +317 -0
- package/hub/team/swarm-reconciler.mjs +128 -23
- package/hub/team/synapse-cli.mjs +458 -0
- package/hub/team/synapse-http.mjs +60 -0
- package/hub/team/synapse-registry.mjs +329 -0
- package/hub/team/terminal-opener.mjs +216 -0
- package/hub/team/tui-core.mjs +288 -0
- package/hub/team/tui-lite.mjs +234 -146
- package/hub/team/tui-remote-adapter.mjs +18 -34
- package/hub/team/tui-synapse.mjs +223 -0
- package/hub/team/tui-viewer.mjs +104 -44
- package/hub/team/tui-widgets.mjs +262 -0
- package/hub/team/tui.mjs +927 -437
- package/hub/team/worker-completion-validator.mjs +84 -0
- package/hub/team/worker-sandbox.mjs +93 -0
- package/hub/team/worker-signal.mjs +263 -0
- package/hub/team/worker-signal.types.d.ts +52 -0
- package/hub/team/worktree-lifecycle.mjs +517 -63
- package/hub/team/wt-manager.mjs +297 -26
- package/hub/tools.mjs +474 -252
- package/hub/tray.mjs +411 -368
- package/hub/workers/claude-worker.mjs +162 -118
- package/hub/workers/codex-app-server-worker.mjs +1015 -0
- package/hub/workers/codex-mcp.mjs +724 -507
- package/hub/workers/delegator-mcp.mjs +662 -335
- package/hub/workers/factory.mjs +100 -21
- package/hub/workers/gemini-worker.mjs +179 -86
- package/hub/workers/interface.mjs +23 -2
- package/hub/workers/lib/jsonrpc-stdio.mjs +463 -0
- package/hub/workers/worker-utils.mjs +22 -16
- package/package.json +4 -3
- package/scripts/lib/agent-json.mjs +27 -0
- package/scripts/lib/async.mjs +174 -0
- package/scripts/lib/claudemd-scanner.mjs +223 -0
- package/scripts/lib/cli-agy.mjs +62 -0
- package/scripts/lib/cli-claude.mjs +78 -0
- package/scripts/lib/cli-codex.mjs +199 -0
- package/scripts/lib/cli-gemini.mjs +67 -0
- package/scripts/lib/codex-recovery.sh +50 -0
- package/scripts/lib/context.mjs +67 -0
- package/scripts/lib/cross-review-utils.mjs +59 -0
- package/scripts/lib/doctor-env-checks.mjs +121 -0
- package/scripts/lib/dynamic-route-cli.mjs +107 -0
- package/scripts/lib/env-probe.mjs +269 -0
- package/scripts/lib/env.mjs +14 -0
- package/scripts/lib/gemini-profiles.mjs +119 -0
- package/scripts/lib/handoff.mjs +187 -0
- package/scripts/lib/hook-utils.mjs +16 -0
- package/scripts/lib/hub.mjs +48 -0
- package/scripts/lib/keyword-rules.mjs +195 -0
- package/scripts/lib/logger.mjs +105 -0
- package/scripts/lib/mcp-filter.mjs +883 -0
- package/scripts/lib/mcp-gateway-servers.mjs +70 -0
- package/scripts/lib/mcp-guard-engine.mjs +2120 -0
- package/scripts/lib/mcp-health.mjs +773 -0
- package/scripts/lib/mcp-manifest.mjs +89 -0
- package/scripts/lib/mcp-server-catalog.mjs +355 -0
- package/scripts/lib/pid.mjs +63 -0
- package/scripts/lib/process-utils.mjs +26 -0
- package/scripts/lib/psmux-info.mjs +179 -0
- package/scripts/lib/quota.mjs +47 -0
- package/scripts/lib/remote-spawn-transfer.mjs +226 -0
- package/scripts/lib/skill-state.mjs +220 -0
- package/scripts/lib/skill-template.mjs +375 -0
- package/scripts/lib/team.mjs +75 -0
- package/scripts/lib/timeout.mjs +66 -0
- package/scripts/lib/tmp.mjs +26 -0
- 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,93 @@
|
|
|
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
|
+
|
|
75
|
+
const winParts =
|
|
76
|
+
windowsHomeParts(home) ||
|
|
77
|
+
(process.platform === "win32"
|
|
78
|
+
? {
|
|
79
|
+
drive: win32.parse(home).root.replace(/[\\/]$/, "") || "C:",
|
|
80
|
+
path:
|
|
81
|
+
win32
|
|
82
|
+
.relative(win32.parse(home).root, home)
|
|
83
|
+
.replace(/\//g, "\\")
|
|
84
|
+
.replace(/^/, "\\") || "\\",
|
|
85
|
+
}
|
|
86
|
+
: null);
|
|
87
|
+
if (winParts) {
|
|
88
|
+
overlay.HOMEDRIVE = winParts.drive;
|
|
89
|
+
overlay.HOMEPATH = winParts.path;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return { env: overlay, root: home, home, disabled: false };
|
|
93
|
+
}
|
|
@@ -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
|
+
}
|