mixdog 0.9.94 → 0.9.96
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/LICENSES/Apache-2.0.txt +201 -0
- package/LICENSES/MIT.txt +56 -0
- package/LICENSES/codex-NOTICE.txt +6 -0
- package/NOTICE.md +109 -0
- package/package.json +26 -13
- package/scripts/.tmp-coverage-report.mjs +96 -0
- package/scripts/build-tui.mjs +17 -1
- package/scripts/fixtures/patch-replay-corpus.json +98 -0
- package/scripts/lib/isolated-root-cleanup.mjs +19 -0
- package/scripts/run-suite.mjs +100 -0
- package/src/defaults/mixdog-config.template.json +5 -8
- package/src/lib/rules-builder.cjs +4 -3
- package/src/output-styles/detailed.md +13 -15
- package/src/output-styles/extreme-minimal.md +7 -11
- package/src/output-styles/minimal.md +4 -7
- package/src/output-styles/simple.md +11 -13
- package/src/rules/agent/30-explorer.md +7 -4
- package/src/rules/lead/01-general.md +1 -2
- package/src/rules/lead/lead-brief.md +4 -5
- package/src/rules/lead/lead-tool.md +3 -2
- package/src/rules/shared/01-tool.md +20 -35
- package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +18 -2
- package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +31 -1
- package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +2 -2
- package/src/runtime/agent/orchestrator/agent-runtime/maintenance-route.mjs +6 -17
- package/src/runtime/agent/orchestrator/agent-trace-format.mjs +13 -2
- package/src/runtime/agent/orchestrator/agent-trace-io.mjs +4 -0
- package/src/runtime/agent/orchestrator/agent-trace.mjs +29 -0
- package/src/runtime/agent/orchestrator/config.mjs +246 -67
- package/src/runtime/agent/orchestrator/mcp/client.mjs +29 -14
- package/src/runtime/agent/orchestrator/mcp/reconnect-singleflight.mjs +23 -0
- package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +113 -14
- package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +97 -7
- package/src/runtime/agent/orchestrator/providers/anthropic-model-resolve.mjs +11 -4
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +2 -3
- package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +5 -1
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/codex-client-meta.mjs +4 -6
- package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +32 -11
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +89 -2
- package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +12 -1
- package/src/runtime/agent/orchestrator/providers/lib/stream-outcome.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/openai-codex-metadata.mjs +8 -8
- package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +19 -1
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +144 -39
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +29 -16
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +2 -2
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +15 -1
- package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +14 -16
- package/src/runtime/agent/orchestrator/providers/openai-transport-policy.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/openai-ws-headers.mjs +5 -4
- package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +13 -14
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +2 -3
- package/src/runtime/agent/orchestrator/providers/registry.mjs +51 -4
- package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +82 -22
- package/src/runtime/agent/orchestrator/providers/stream-json-pool.mjs +291 -0
- package/src/runtime/agent/orchestrator/providers/stream-json-worker.mjs +21 -0
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +40 -36
- package/src/runtime/agent/orchestrator/session/cache/read-cache.mjs +7 -0
- package/src/runtime/agent/orchestrator/session/context-compaction-policy.mjs +10 -3
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +10 -11
- package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +5 -33
- package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +3 -3
- package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +0 -14
- package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +13 -0
- package/src/runtime/agent/orchestrator/session/loop/stop-hooks.mjs +9 -8
- package/src/runtime/agent/orchestrator/session/loop/termination.mjs +2 -2
- package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +0 -24
- package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +1 -27
- package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +6 -10
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +12 -4
- package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +168 -83
- package/src/runtime/agent/orchestrator/session/manager/runtime-loaders.mjs +4 -0
- package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +3 -0
- package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +31 -1
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +8 -1
- package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +34 -18
- package/src/runtime/agent/orchestrator/session/manager.mjs +2 -0
- package/src/runtime/agent/orchestrator/session/result-classification.mjs +65 -0
- package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +89 -11
- package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +12 -0
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +6 -39
- package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +5 -6
- package/src/runtime/agent/orchestrator/stall-policy.mjs +3 -4
- package/src/runtime/agent/orchestrator/tools/bash-session.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +56 -0
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +124 -19
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +8 -9
- package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/builtin/device-paths.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-context-expander.mjs +7 -16
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +10 -26
- package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +14 -0
- package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +4 -4
- package/src/runtime/agent/orchestrator/tools/builtin/read-image-resize.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/builtin/read-image.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +3 -3
- package/src/runtime/agent/orchestrator/tools/builtin/read-snapshot-runtime.mjs +2 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-special-files.mjs +3 -3
- package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +1 -23
- package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +24 -10
- package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +7 -0
- package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +20 -43
- package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +76 -3
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +13 -1
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-spawn.mjs +10 -5
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +38 -13
- package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +30 -0
- package/src/runtime/agent/orchestrator/tools/builtin/snapshot-store.mjs +98 -0
- package/src/runtime/agent/orchestrator/tools/builtin.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +8 -6
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +138 -25
- package/src/runtime/agent/orchestrator/tools/code-graph/project-root.mjs +47 -2
- package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +6 -15
- package/src/runtime/agent/orchestrator/tools/code-graph/trusted-roots.mjs +3 -1
- package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/env-scrub.mjs +9 -2
- package/src/runtime/agent/orchestrator/tools/lib/pwsh-standby-pool.mjs +83 -13
- package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +40 -2
- package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +57 -2
- package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +177 -16
- package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +21 -1
- package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +217 -25
- package/src/runtime/agent/orchestrator/tools/patch-manifest.json +10 -10
- package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +14 -15
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +60 -8
- package/src/runtime/agent/orchestrator/tools/shell-exec-output.mjs +2 -2
- package/src/runtime/channels/backends/discord.mjs +5 -14
- package/src/runtime/channels/backends/telegram.mjs +0 -5
- package/src/runtime/channels/lib/config.mjs +2 -2
- package/src/runtime/channels/lib/inbound-handler.mjs +1 -2
- package/src/runtime/channels/lib/output-forwarder.mjs +24 -5
- package/src/runtime/channels/lib/scheduler.mjs +3 -3
- package/src/runtime/channels/lib/worker-main.mjs +1 -1
- package/src/runtime/media/renditions.mjs +21 -2
- package/src/runtime/memory/index.mjs +5 -38
- package/src/runtime/memory/lib/agent-ipc.mjs +145 -81
- package/src/runtime/memory/lib/compact-vector-cache.mjs +88 -0
- package/src/runtime/memory/lib/core-memory-store.mjs +18 -1
- package/src/runtime/memory/lib/cycle-llm-adapters.mjs +12 -33
- package/src/runtime/memory/lib/embedding-provider.mjs +47 -26
- package/src/runtime/memory/lib/embedding-worker.mjs +30 -34
- package/src/runtime/memory/lib/http-router.mjs +8 -0
- package/src/runtime/memory/lib/ko-morph.mjs +3 -20
- package/src/runtime/memory/lib/memory-action-handlers.mjs +2 -1
- package/src/runtime/memory/lib/memory-cycle1.mjs +1 -0
- package/src/runtime/memory/lib/memory-cycle2-gate.mjs +4 -2
- package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +1 -0
- package/src/runtime/memory/lib/memory-cycle3.mjs +3 -1
- package/src/runtime/memory/lib/pg/process.mjs +32 -9
- package/src/runtime/memory/tool-defs.mjs +7 -11
- package/src/runtime/search/tool-defs.mjs +2 -2
- package/src/runtime/shared/agent-route-config.mjs +119 -0
- package/src/runtime/shared/atomic-file.mjs +133 -4
- package/src/runtime/shared/background-tasks.mjs +6 -3
- package/src/runtime/shared/channel-notification-routing.mjs +2 -2
- package/src/runtime/shared/child-guardian.mjs +204 -108
- package/src/runtime/shared/child-spawn-gate.mjs +60 -31
- package/src/runtime/shared/config.mjs +130 -44
- package/src/runtime/shared/resource-admission.mjs +146 -58
- package/src/runtime/shared/stream-progress.mjs +6 -0
- package/src/runtime/shared/tool-status.mjs +10 -1
- package/src/runtime/shared/tool-surface.mjs +2 -7
- package/src/runtime/shared/turn-snapshot.mjs +411 -21
- package/src/runtime/shared/turn-worktree-snapshot.mjs +552 -0
- package/src/session-runtime/config-helpers.mjs +39 -37
- package/src/session-runtime/context-status.mjs +9 -3
- package/src/session-runtime/lifecycle-api.mjs +35 -5
- package/src/session-runtime/mcp-glue.mjs +11 -6
- package/src/session-runtime/model-route-api.mjs +7 -5
- package/src/session-runtime/provider-auth-api.mjs +0 -7
- package/src/session-runtime/provider-models.mjs +94 -43
- package/src/session-runtime/runtime-core.mjs +57 -19
- package/src/session-runtime/runtime-tunables.mjs +4 -0
- package/src/session-runtime/self-update.mjs +33 -4
- package/src/session-runtime/session-lifecycle.mjs +11 -2
- package/src/session-runtime/session-text.mjs +2 -1
- package/src/session-runtime/session-turn-api.mjs +139 -23
- package/src/session-runtime/settings-api.mjs +5 -17
- package/src/session-runtime/tool-defs.mjs +3 -3
- package/src/session-runtime/workflow-agents-api.mjs +55 -81
- package/src/session-runtime/workflow.mjs +25 -28
- package/src/standalone/agent-dispatch-broker.mjs +198 -0
- package/src/standalone/agent-tool/helpers.mjs +2 -2
- package/src/standalone/agent-tool/spawn-flow.mjs +2 -0
- package/src/standalone/agent-tool/spawn-preset.mjs +11 -21
- package/src/standalone/agent-tool/tool-def.mjs +0 -14
- package/src/standalone/agent-tool.mjs +4 -4
- package/src/standalone/backend-daemon.mjs +631 -0
- package/src/standalone/channel-admin.mjs +12 -12
- package/src/standalone/channel-daemon-client.mjs +17 -1
- package/src/standalone/channel-daemon-transport.mjs +231 -9
- package/src/standalone/channel-worker.mjs +3 -2
- package/src/standalone/engine-daemon-client.mjs +1069 -0
- package/src/standalone/engine-daemon-protocol.mjs +32 -0
- package/src/standalone/engine-daemon-service.mjs +1108 -0
- package/src/standalone/engine-daemon-transport.mjs +794 -0
- package/src/standalone/explore-tool.mjs +187 -70
- package/src/standalone/fair-call-scheduler.mjs +264 -0
- package/src/standalone/session-protocol.mjs +188 -0
- package/src/tui/App.jsx +92 -64
- package/src/tui/app/app-format.mjs +4 -2
- package/src/tui/app/app-view.jsx +10 -2
- package/src/tui/app/channel-pickers.mjs +7 -6
- package/src/tui/app/core-memory-picker.mjs +20 -20
- package/src/tui/app/doctor.mjs +5 -11
- package/src/tui/app/extension-pickers.mjs +20 -18
- package/src/tui/app/maintenance-pickers.mjs +27 -27
- package/src/tui/app/message-selector.mjs +103 -0
- package/src/tui/app/onboarding-steps.mjs +24 -20
- package/src/tui/app/project-picker.mjs +80 -56
- package/src/tui/app/prompt-submit.mjs +59 -43
- package/src/tui/app/resume-picker.mjs +2 -2
- package/src/tui/app/route-pickers.mjs +16 -9
- package/src/tui/app/settings-picker.mjs +60 -65
- package/src/tui/app/slash-dispatch.mjs +32 -28
- package/src/tui/app/transcript-window.mjs +57 -0
- package/src/tui/app/usage-context-panels.mjs +22 -7
- package/src/tui/app/use-mouse-input.mjs +25 -3
- package/src/tui/app/use-prompt-draft-flow.mjs +5 -7
- package/src/tui/app/use-prompt-handlers.mjs +86 -21
- package/src/tui/app/use-prompt-hint.mjs +3 -2
- package/src/tui/app/use-prompt-queue-history.mjs +39 -17
- package/src/tui/app/use-transcript-scroll.mjs +39 -10
- package/src/tui/app/use-transcript-window.mjs +62 -14
- package/src/tui/app/use-welcome-prompt-hint.mjs +2 -2
- package/src/tui/components/PromptInput.jsx +81 -38
- package/src/tui/components/Spinner.jsx +89 -95
- package/src/tui/components/TextEntryPanel.jsx +14 -0
- package/src/tui/components/ToolExecution.jsx +2 -2
- package/src/tui/components/prompt-input/edit-helpers.mjs +9 -11
- package/src/tui/components/prompt-input/escape-policy.mjs +42 -0
- package/src/tui/components/prompt-input/immediate-render.mjs +0 -10
- package/src/tui/components/prompt-input/interrupt-policy.mjs +10 -0
- package/src/tui/components/prompt-input/restore-policy.mjs +10 -0
- package/src/tui/dist/index.mjs +1694 -10633
- package/src/tui/engine/agent-job-feed.mjs +2 -2
- package/src/tui/engine/live-share.mjs +110 -3
- package/src/tui/engine/session-api-ext.mjs +24 -5
- package/src/tui/engine/session-api.mjs +165 -55
- package/src/tui/engine/session-flow.mjs +50 -5
- package/src/tui/engine/tool-card-results.mjs +10 -2
- package/src/tui/engine/tool-result-text.mjs +10 -0
- package/src/tui/engine/turn.mjs +11 -64
- package/src/tui/engine-local-session.mjs +1124 -0
- package/src/tui/engine.mjs +16 -1065
- package/src/tui/index.jsx +41 -4
- package/src/tui/markdown/stream-fence.mjs +1 -1
- package/src/tui/spinner-meta.mjs +80 -0
- package/src/tui/spinner-verbs.mjs +83 -0
- package/src/ui/statusline-segments.mjs +43 -10
- package/src/ui/statusline.mjs +10 -1
- package/scripts/tmp-cdp-errors.mjs +0 -41
- package/scripts/tmp-cdp-inspect.mjs +0 -41
- package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +0 -176
- package/src/standalone/channel-daemon.mjs +0 -226
|
@@ -0,0 +1,794 @@
|
|
|
1
|
+
// Machine-global ENGINE daemon — HTTP + SSE transport (server side).
|
|
2
|
+
//
|
|
3
|
+
// Sibling of channel-daemon-transport.mjs, deliberately separate: the channels
|
|
4
|
+
// transport routes notifies to ONE pointer client (remote ownership), while
|
|
5
|
+
// engine frames are shared state — every attached client (terminal TUI and the
|
|
6
|
+
// desktop app at the same time) must observe the same snapshot stream. Mixing
|
|
7
|
+
// the two routing rules into one server would put the pointer semantics on a
|
|
8
|
+
// path that must never target a single client.
|
|
9
|
+
//
|
|
10
|
+
// This module owns ONLY the transport (sockets, client registry, frame fan-out,
|
|
11
|
+
// discovery file, lifecycle). The engine pool is injected via `handleCall`, so
|
|
12
|
+
// the same transport is exercised by the real daemon entry AND by the smoke
|
|
13
|
+
// harness with a stub engine factory (no provider, no model download).
|
|
14
|
+
import http from 'node:http';
|
|
15
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
16
|
+
import { rmSync } from 'node:fs';
|
|
17
|
+
import { writeJsonAtomicSync } from '../runtime/shared/atomic-file.mjs';
|
|
18
|
+
import { sendJson, sendError } from '../runtime/memory/lib/http-wire.mjs';
|
|
19
|
+
import { ENGINE_DAEMON_PROTOCOL, engineRuntimeVersion } from './engine-daemon-protocol.mjs';
|
|
20
|
+
import { createFairCallScheduler } from './fair-call-scheduler.mjs';
|
|
21
|
+
|
|
22
|
+
// A loopback front door still buffers whatever a client sends before it can be
|
|
23
|
+
// parsed, so the body has an explicit ceiling instead of the client's memory.
|
|
24
|
+
const MAX_BODY_BYTES = Math.max(1, Number(process.env.MIXDOG_ENGINE_DAEMON_MAX_BODY_MB) || 32)
|
|
25
|
+
* 1024 * 1024;
|
|
26
|
+
|
|
27
|
+
function readLimitedBody(req, {
|
|
28
|
+
reserve = () => true,
|
|
29
|
+
release = () => {},
|
|
30
|
+
} = {}) {
|
|
31
|
+
return new Promise((resolve, reject) => {
|
|
32
|
+
let settled = false;
|
|
33
|
+
let reserved = 0;
|
|
34
|
+
const releaseReserved = () => {
|
|
35
|
+
if (reserved <= 0) return;
|
|
36
|
+
release(reserved);
|
|
37
|
+
reserved = 0;
|
|
38
|
+
};
|
|
39
|
+
const fail = (message, statusCode) => {
|
|
40
|
+
if (settled) return;
|
|
41
|
+
settled = true;
|
|
42
|
+
releaseReserved();
|
|
43
|
+
const error = new Error(message);
|
|
44
|
+
error.statusCode = statusCode;
|
|
45
|
+
try { req.destroy(); } catch {}
|
|
46
|
+
reject(error);
|
|
47
|
+
};
|
|
48
|
+
const declared = Number(req.headers['content-length']);
|
|
49
|
+
if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) {
|
|
50
|
+
fail('request body too large', 413);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const chunks = [];
|
|
54
|
+
let size = 0;
|
|
55
|
+
req.on('data', (chunk) => {
|
|
56
|
+
if (settled) return;
|
|
57
|
+
size += chunk.length;
|
|
58
|
+
if (size > MAX_BODY_BYTES) { fail('request body too large', 413); return; }
|
|
59
|
+
if (!reserve(chunk.length)) { fail('daemon request memory budget is busy', 503); return; }
|
|
60
|
+
reserved += chunk.length;
|
|
61
|
+
chunks.push(chunk);
|
|
62
|
+
});
|
|
63
|
+
req.on('end', () => {
|
|
64
|
+
if (settled) return;
|
|
65
|
+
settled = true;
|
|
66
|
+
try {
|
|
67
|
+
const raw = Buffer.concat(chunks).toString('utf8').trim();
|
|
68
|
+
if (!raw) { resolve({}); return; }
|
|
69
|
+
resolve(JSON.parse(raw));
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
const err = new Error(`invalid JSON body: ${error.message}`);
|
|
73
|
+
err.statusCode = 400;
|
|
74
|
+
reject(err);
|
|
75
|
+
}
|
|
76
|
+
finally { releaseReserved(); }
|
|
77
|
+
});
|
|
78
|
+
req.on('error', (error) => {
|
|
79
|
+
if (!settled) {
|
|
80
|
+
settled = true;
|
|
81
|
+
releaseReserved();
|
|
82
|
+
reject(error);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Identity of a call's PAYLOAD. A retry repeats it exactly; a caller-supplied
|
|
89
|
+
* submission id that happens to be reused carries a different one. */
|
|
90
|
+
function callSignature(name, args) {
|
|
91
|
+
try {
|
|
92
|
+
return createHash('sha1').update(`${name}\u0000${JSON.stringify(args ?? {})}`).digest('hex');
|
|
93
|
+
} catch { return null; }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function parsePid(value) {
|
|
97
|
+
const n = Number(value);
|
|
98
|
+
return Number.isInteger(n) && n > 0 ? n : null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function isPidAlive(pid) {
|
|
102
|
+
const n = parsePid(pid);
|
|
103
|
+
if (!n) return false;
|
|
104
|
+
try { process.kill(n, 0); return true; }
|
|
105
|
+
catch (error) { return error?.code === 'EPERM'; }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// A stalled reader collapses the backlog to one frame per key. That is right
|
|
109
|
+
// for SNAPSHOTS (the newest state is the whole truth) and wrong for a BYTE
|
|
110
|
+
// STREAM: dropping PTY output leaves a terminal permanently corrupted. Same-key
|
|
111
|
+
// terminal frames therefore concatenate, bounded so a wedged reader can never
|
|
112
|
+
// grow the daemon's heap without limit.
|
|
113
|
+
const TERMINAL_BACKLOG_MAX_CHARS = 256 * 1024;
|
|
114
|
+
const SSE_PENDING_MAX_BYTES = Math.max(
|
|
115
|
+
256 * 1024,
|
|
116
|
+
(Number(process.env.MIXDOG_ENGINE_DAEMON_SSE_PENDING_MB) || 8) * 1024 * 1024,
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
function terminalDataFrame(frame) {
|
|
120
|
+
return frame?.type === 'desktop-event'
|
|
121
|
+
&& frame.message?.kind === 'backend-event'
|
|
122
|
+
&& frame.message?.name === 'terminal-data'
|
|
123
|
+
&& typeof frame.message?.value?.data === 'string'
|
|
124
|
+
? frame
|
|
125
|
+
: null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function mergePendingFrame(existing, frame, json) {
|
|
129
|
+
const previous = existing ? terminalDataFrame(existing.frame) : null;
|
|
130
|
+
const next = terminalDataFrame(frame);
|
|
131
|
+
if (!previous || !next) return { frame, json };
|
|
132
|
+
const joined = `${previous.message.value.data}${next.message.value.data}`;
|
|
133
|
+
const data = joined.length > TERMINAL_BACKLOG_MAX_CHARS
|
|
134
|
+
? joined.slice(joined.length - TERMINAL_BACKLOG_MAX_CHARS)
|
|
135
|
+
: joined;
|
|
136
|
+
const merged = {
|
|
137
|
+
...next,
|
|
138
|
+
message: { ...next.message, value: { ...next.message.value, data } },
|
|
139
|
+
};
|
|
140
|
+
return { frame: merged, json: JSON.stringify(merged) };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function createEngineDaemonTransport({
|
|
144
|
+
handleCall,
|
|
145
|
+
discoveryPath,
|
|
146
|
+
serverToken = randomUUID(),
|
|
147
|
+
log = () => {},
|
|
148
|
+
clientGraceMs = 10_000,
|
|
149
|
+
sweepMs = 5_000,
|
|
150
|
+
onClientsEmpty = null,
|
|
151
|
+
onClientRegistered = null,
|
|
152
|
+
onClientDropped = null,
|
|
153
|
+
getStatus = () => ({}),
|
|
154
|
+
} = {}) {
|
|
155
|
+
if (typeof handleCall !== 'function') throw new Error('handleCall is required');
|
|
156
|
+
|
|
157
|
+
// token -> { token, leadPid, cwd, lifecycle, sse, pending, lastSeen }
|
|
158
|
+
const clients = new Map();
|
|
159
|
+
let boundPort = null;
|
|
160
|
+
let server = null;
|
|
161
|
+
let graceTimer = null;
|
|
162
|
+
let sweepTimer = null;
|
|
163
|
+
let everHadLifecycleClient = false;
|
|
164
|
+
let closed = false;
|
|
165
|
+
const BODY_INFLIGHT_MAX_BYTES = Math.max(
|
|
166
|
+
MAX_BODY_BYTES,
|
|
167
|
+
(Number(process.env.MIXDOG_ENGINE_DAEMON_BODY_INFLIGHT_MB) || 64) * 1024 * 1024,
|
|
168
|
+
);
|
|
169
|
+
let bodyBytesInFlight = 0;
|
|
170
|
+
const readBody = (req) => readLimitedBody(req, {
|
|
171
|
+
reserve(bytes) {
|
|
172
|
+
if (bodyBytesInFlight + bytes > BODY_INFLIGHT_MAX_BYTES) return false;
|
|
173
|
+
bodyBytesInFlight += bytes;
|
|
174
|
+
return true;
|
|
175
|
+
},
|
|
176
|
+
release(bytes) {
|
|
177
|
+
bodyBytesInFlight = Math.max(0, bodyBytesInFlight - bytes);
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
// Idempotency cache: a transport retry of the SAME callId must never run a
|
|
181
|
+
// second engine mutation (submit/abort are not idempotent).
|
|
182
|
+
const callCache = new Map();
|
|
183
|
+
let callCacheBytes = 0;
|
|
184
|
+
const CALL_CACHE_TTL_MS = 60_000;
|
|
185
|
+
// Bound the dedup table: a call that never settles would otherwise pin its
|
|
186
|
+
// entry for the daemon's whole life.
|
|
187
|
+
const CALL_CACHE_MAX = Math.max(512, Number(process.env.MIXDOG_ENGINE_DAEMON_CALL_CACHE) || 4096);
|
|
188
|
+
const CALL_CACHE_MAX_BYTES = Math.max(
|
|
189
|
+
1024 * 1024,
|
|
190
|
+
(Number(process.env.MIXDOG_ENGINE_DAEMON_CALL_CACHE_MB) || 8) * 1024 * 1024,
|
|
191
|
+
);
|
|
192
|
+
// A burst of panes used to enqueue every handleCall() as a microtask. Node
|
|
193
|
+
// drains the whole microtask queue before returning to HTTP, so enough cheap
|
|
194
|
+
// calls could starve /health and /client/register even though no explicit
|
|
195
|
+
// mutex existed. Admit one call per event-loop turn while allowing admitted
|
|
196
|
+
// async calls to overlap; control-plane routes never enter this queue.
|
|
197
|
+
// No default concurrency gate: independent session work starts in parallel.
|
|
198
|
+
// The scheduler still launches one item per check phase so synchronous setup
|
|
199
|
+
// cannot monopolize the HTTP/control-plane turn. Operators may explicitly
|
|
200
|
+
// set finite lane limits for constrained hosts.
|
|
201
|
+
const configuredLaneLimit = (name) => {
|
|
202
|
+
const parsed = Math.floor(Number(process.env[name]));
|
|
203
|
+
return Number.isFinite(parsed) && parsed >= 1 ? parsed : Infinity;
|
|
204
|
+
};
|
|
205
|
+
const CALL_QUEUE_MAX = Math.max(1024, Number(process.env.MIXDOG_ENGINE_DAEMON_CALL_QUEUE) || 1024);
|
|
206
|
+
const CRITICAL_CALLS = new Set([
|
|
207
|
+
'session.abort',
|
|
208
|
+
'session.approve',
|
|
209
|
+
'session.unsubscribe',
|
|
210
|
+
'desktop.control',
|
|
211
|
+
'desktop.unsubscribe',
|
|
212
|
+
]);
|
|
213
|
+
const INTERACTIVE_CALLS = new Set([
|
|
214
|
+
'session.create',
|
|
215
|
+
'session.read',
|
|
216
|
+
'session.subscribe',
|
|
217
|
+
'session.submit',
|
|
218
|
+
'session.configure',
|
|
219
|
+
'project.list',
|
|
220
|
+
'project.inspect',
|
|
221
|
+
'project.add',
|
|
222
|
+
'project.touch',
|
|
223
|
+
'project.rename',
|
|
224
|
+
'project.remove',
|
|
225
|
+
'project.ensureDirectory',
|
|
226
|
+
'desktop.init',
|
|
227
|
+
]);
|
|
228
|
+
const INTERACTIVE_DESKTOP_METHODS = new Set([
|
|
229
|
+
'termEnsure',
|
|
230
|
+
'termWrite',
|
|
231
|
+
'termResize',
|
|
232
|
+
'termDispose',
|
|
233
|
+
'termProfiles',
|
|
234
|
+
]);
|
|
235
|
+
const normalCalls = createFairCallScheduler({
|
|
236
|
+
name: 'engine daemon call',
|
|
237
|
+
activeMax: configuredLaneLimit('MIXDOG_ENGINE_DAEMON_ACTIVE_CALLS'),
|
|
238
|
+
queueMax: CALL_QUEUE_MAX,
|
|
239
|
+
minOwnerQueue: Math.max(8, Math.floor(CALL_QUEUE_MAX / 16)),
|
|
240
|
+
dispatchBurst: 1,
|
|
241
|
+
yieldUnbounded: true,
|
|
242
|
+
});
|
|
243
|
+
const criticalCalls = createFairCallScheduler({
|
|
244
|
+
name: 'engine daemon critical call',
|
|
245
|
+
activeMax: configuredLaneLimit('MIXDOG_ENGINE_DAEMON_URGENT_RESERVE'),
|
|
246
|
+
queueMax: Math.max(32, Math.min(256, CALL_QUEUE_MAX)),
|
|
247
|
+
minOwnerQueue: 8,
|
|
248
|
+
});
|
|
249
|
+
const interactiveCalls = createFairCallScheduler({
|
|
250
|
+
name: 'engine daemon interactive call',
|
|
251
|
+
activeMax: configuredLaneLimit('MIXDOG_ENGINE_DAEMON_INTERACTIVE_RESERVE'),
|
|
252
|
+
queueMax: Math.max(64, Math.min(512, CALL_QUEUE_MAX)),
|
|
253
|
+
minOwnerQueue: 8,
|
|
254
|
+
dispatchBurst: 1,
|
|
255
|
+
yieldUnbounded: true,
|
|
256
|
+
});
|
|
257
|
+
// A timed-out registration may already have committed server-side. Replaying
|
|
258
|
+
// the same registrationId returns that token instead of leaking a second
|
|
259
|
+
// client record and another lifecycle reference.
|
|
260
|
+
const registrationReplays = new Map();
|
|
261
|
+
const REGISTRATION_REPLAY_TTL_MS = 30_000;
|
|
262
|
+
const REGISTRATION_REPLAY_MAX = 256;
|
|
263
|
+
|
|
264
|
+
function deleteCallCacheEntry(key, record = callCache.get(key)) {
|
|
265
|
+
if (!record || callCache.get(key) !== record) return false;
|
|
266
|
+
callCache.delete(key);
|
|
267
|
+
callCacheBytes = Math.max(0, callCacheBytes - (record.bytes || 0));
|
|
268
|
+
return true;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function estimateRetainedBytes(value, limit = CALL_CACHE_MAX_BYTES + 1, seen = new Set()) {
|
|
272
|
+
if (typeof value === 'string') return Math.min(limit, value.length * 2 + 16);
|
|
273
|
+
if (typeof value === 'number' || typeof value === 'bigint') return 8;
|
|
274
|
+
if (typeof value === 'boolean' || value == null) return 4;
|
|
275
|
+
if (typeof value !== 'object' || seen.has(value)) return 0;
|
|
276
|
+
seen.add(value);
|
|
277
|
+
let bytes = Array.isArray(value) ? 32 : 64;
|
|
278
|
+
const entries = Array.isArray(value) ? value : Object.entries(value);
|
|
279
|
+
for (const entry of entries) {
|
|
280
|
+
if (Array.isArray(value)) bytes += 8 + estimateRetainedBytes(entry, limit - bytes, seen);
|
|
281
|
+
else bytes += String(entry[0]).length * 2 + 16
|
|
282
|
+
+ estimateRetainedBytes(entry[1], limit - bytes, seen);
|
|
283
|
+
if (bytes >= limit) break;
|
|
284
|
+
}
|
|
285
|
+
seen.delete(value);
|
|
286
|
+
return Math.min(limit, bytes);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function pruneCallCache() {
|
|
290
|
+
while (callCache.size > CALL_CACHE_MAX || callCacheBytes > CALL_CACHE_MAX_BYTES) {
|
|
291
|
+
let removed = false;
|
|
292
|
+
for (const [key, record] of callCache) {
|
|
293
|
+
// Never lose the dedup identity of an in-flight mutation.
|
|
294
|
+
if (!record.settled) continue;
|
|
295
|
+
deleteCallCacheEntry(key, record);
|
|
296
|
+
removed = true;
|
|
297
|
+
break;
|
|
298
|
+
}
|
|
299
|
+
if (!removed) return;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function callLane(name, args = {}) {
|
|
304
|
+
if (CRITICAL_CALLS.has(name)) return 'critical';
|
|
305
|
+
if (INTERACTIVE_CALLS.has(name)) return 'interactive';
|
|
306
|
+
if (name === 'desktop.invoke') {
|
|
307
|
+
const adapterMethod = String(args?.method || '');
|
|
308
|
+
const backendMethod = adapterMethod === 'backendInvoke'
|
|
309
|
+
? String(args?.args?.[0] || '')
|
|
310
|
+
: adapterMethod;
|
|
311
|
+
if (INTERACTIVE_DESKTOP_METHODS.has(backendMethod)) return 'interactive';
|
|
312
|
+
}
|
|
313
|
+
return 'normal';
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function dispatchCall(ownerKey, run, { lane = 'normal', signal = null } = {}) {
|
|
317
|
+
const scheduler = lane === 'critical'
|
|
318
|
+
? criticalCalls
|
|
319
|
+
: lane === 'interactive'
|
|
320
|
+
? interactiveCalls
|
|
321
|
+
: normalCalls;
|
|
322
|
+
return scheduler.enqueue(ownerKey, run, { signal });
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function nowMs() { return Date.now(); }
|
|
326
|
+
|
|
327
|
+
function lifecycleClientCount() {
|
|
328
|
+
let count = 0;
|
|
329
|
+
for (const client of clients.values()) {
|
|
330
|
+
if (client.lifecycle) count += 1;
|
|
331
|
+
}
|
|
332
|
+
return count;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function cancelGrace() {
|
|
336
|
+
if (graceTimer) { clearTimeout(graceTimer); graceTimer = null; }
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function maybeArmGrace(reason) {
|
|
340
|
+
if (closed || !everHadLifecycleClient || typeof onClientsEmpty !== 'function') return;
|
|
341
|
+
if (lifecycleClientCount() > 0) return;
|
|
342
|
+
// Never re-arm an ALREADY armed grace: the 5s sweep also calls this, and
|
|
343
|
+
// cancel+rearm on every tick pushed the 10s deadline out forever — the
|
|
344
|
+
// daemon could never self-shut down through the engine front door.
|
|
345
|
+
if (graceTimer) return;
|
|
346
|
+
graceTimer = setTimeout(() => {
|
|
347
|
+
graceTimer = null;
|
|
348
|
+
if (closed || lifecycleClientCount() > 0) return;
|
|
349
|
+
log(`no clients remain (${reason}) — signalling shutdown`);
|
|
350
|
+
try { onClientsEmpty(); } catch {}
|
|
351
|
+
}, clientGraceMs);
|
|
352
|
+
graceTimer.unref?.();
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function removeClientRecord(token) {
|
|
356
|
+
const c = clients.get(token);
|
|
357
|
+
if (!c) return;
|
|
358
|
+
try { c.sse?.end?.(); } catch {}
|
|
359
|
+
clients.delete(token);
|
|
360
|
+
for (const [registrationId, replay] of registrationReplays) {
|
|
361
|
+
if (replay.token === token) {
|
|
362
|
+
clearTimeout(replay.timer);
|
|
363
|
+
registrationReplays.delete(registrationId);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function dropClient(token, reason) {
|
|
369
|
+
if (!clients.has(token)) return;
|
|
370
|
+
removeClientRecord(token);
|
|
371
|
+
// The engine pool refcounts VIEWS by client token: a client that is gone
|
|
372
|
+
// must stop holding engines open (and must stop being counted as the
|
|
373
|
+
// reason another client's engine survives).
|
|
374
|
+
if (typeof onClientDropped === 'function') {
|
|
375
|
+
try { onClientDropped(token, reason); } catch {}
|
|
376
|
+
}
|
|
377
|
+
log(`client dropped token=${token} (${reason})`);
|
|
378
|
+
maybeArmGrace(reason);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function startSweep() {
|
|
382
|
+
if (sweepTimer || closed) return;
|
|
383
|
+
sweepTimer = setInterval(() => {
|
|
384
|
+
for (const [token, c] of [...clients]) {
|
|
385
|
+
// A client whose owning process died can never read its stream again.
|
|
386
|
+
if (c.leadPid && !isPidAlive(c.leadPid)) dropClient(token, 'lead pid gone');
|
|
387
|
+
}
|
|
388
|
+
maybeArmGrace('sweep');
|
|
389
|
+
}, sweepMs);
|
|
390
|
+
sweepTimer.unref?.();
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function registerClient({ leadPid, cwd, lifecycle = true, registrationId = null } = {}) {
|
|
394
|
+
const pid = parsePid(leadPid) || 0;
|
|
395
|
+
const ownsLifecycle = lifecycle !== false;
|
|
396
|
+
const replayId = registrationId ? String(registrationId).slice(0, 200) : null;
|
|
397
|
+
const replay = replayId ? registrationReplays.get(replayId) : null;
|
|
398
|
+
if (replay) {
|
|
399
|
+
const sameIdentity = replay.leadPid === pid
|
|
400
|
+
&& replay.cwd === (cwd || null)
|
|
401
|
+
&& replay.lifecycle === ownsLifecycle;
|
|
402
|
+
if (sameIdentity && clients.has(replay.token)) {
|
|
403
|
+
log(`client registration replay token=${replay.token} lead=${pid}`);
|
|
404
|
+
return replay.token;
|
|
405
|
+
}
|
|
406
|
+
if (clients.has(replay.token)) {
|
|
407
|
+
const error = new Error('registration replay identity mismatch');
|
|
408
|
+
error.statusCode = 409;
|
|
409
|
+
throw error;
|
|
410
|
+
}
|
|
411
|
+
clearTimeout(replay.timer);
|
|
412
|
+
registrationReplays.delete(replayId);
|
|
413
|
+
}
|
|
414
|
+
const token = randomUUID();
|
|
415
|
+
clients.set(token, {
|
|
416
|
+
token,
|
|
417
|
+
leadPid: pid,
|
|
418
|
+
cwd: cwd || null,
|
|
419
|
+
lifecycle: ownsLifecycle,
|
|
420
|
+
sse: null,
|
|
421
|
+
// True while the socket asked us to stop writing (see writeFrame).
|
|
422
|
+
paused: false,
|
|
423
|
+
// Frames are latest-wins per key while a client has no stream: a
|
|
424
|
+
// reconnecting viewer wants the CURRENT snapshot, never a backlog.
|
|
425
|
+
pending: new Map(),
|
|
426
|
+
pendingBytes: 0,
|
|
427
|
+
lastSeen: nowMs(),
|
|
428
|
+
});
|
|
429
|
+
if (ownsLifecycle) {
|
|
430
|
+
everHadLifecycleClient = true;
|
|
431
|
+
cancelGrace();
|
|
432
|
+
}
|
|
433
|
+
startSweep();
|
|
434
|
+
log(`client registered token=${token} lead=${pid} cwd=${cwd || '-'} lifecycle=${ownsLifecycle}`);
|
|
435
|
+
if (typeof onClientRegistered === 'function') {
|
|
436
|
+
try { onClientRegistered({ token, leadPid: pid, cwd: cwd || null, lifecycle: ownsLifecycle }); } catch {}
|
|
437
|
+
}
|
|
438
|
+
if (replayId) {
|
|
439
|
+
while (registrationReplays.size >= REGISTRATION_REPLAY_MAX) {
|
|
440
|
+
const oldest = registrationReplays.keys().next();
|
|
441
|
+
if (oldest.done) break;
|
|
442
|
+
const prior = registrationReplays.get(oldest.value);
|
|
443
|
+
clearTimeout(prior?.timer);
|
|
444
|
+
registrationReplays.delete(oldest.value);
|
|
445
|
+
}
|
|
446
|
+
const timer = setTimeout(() => registrationReplays.delete(replayId), REGISTRATION_REPLAY_TTL_MS);
|
|
447
|
+
timer.unref?.();
|
|
448
|
+
registrationReplays.set(replayId, {
|
|
449
|
+
token,
|
|
450
|
+
leadPid: pid,
|
|
451
|
+
cwd: cwd || null,
|
|
452
|
+
lifecycle: ownsLifecycle,
|
|
453
|
+
timer,
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
return token;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** Drain the latest-wins backlog into a stream that reported room again. */
|
|
460
|
+
function flushPending(client) {
|
|
461
|
+
while (client.sse && !client.paused && client.pending.size > 0) {
|
|
462
|
+
const [key, entry] = client.pending.entries().next().value;
|
|
463
|
+
client.pending.delete(key);
|
|
464
|
+
client.pendingBytes = Math.max(0, client.pendingBytes - (entry.bytes || 0));
|
|
465
|
+
try {
|
|
466
|
+
if (client.sse.write(`data: ${entry.json}\n\n`) === false) client.paused = true;
|
|
467
|
+
} catch {
|
|
468
|
+
client.sse = null;
|
|
469
|
+
client.pending.set(key, entry);
|
|
470
|
+
client.pendingBytes += entry.bytes || 0;
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function pendingEntry(frame, json) {
|
|
477
|
+
return { frame, json, bytes: Buffer.byteLength(json) + 8 };
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function resyncEntry(frame) {
|
|
481
|
+
if (frame?.type !== 'session-state' || !frame.sessionId) return null;
|
|
482
|
+
const marker = {
|
|
483
|
+
type: 'session-state',
|
|
484
|
+
key: frame.key,
|
|
485
|
+
sessionId: frame.sessionId,
|
|
486
|
+
revision: -1,
|
|
487
|
+
baseRevision: -2,
|
|
488
|
+
patch: { set: {}, remove: [], itemsAppend: null },
|
|
489
|
+
resyncRequired: true,
|
|
490
|
+
};
|
|
491
|
+
return pendingEntry(marker, JSON.stringify(marker));
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function setPending(client, key, entry) {
|
|
495
|
+
const previous = client.pending.get(key);
|
|
496
|
+
if (previous) client.pendingBytes = Math.max(0, client.pendingBytes - (previous.bytes || 0));
|
|
497
|
+
client.pending.delete(key);
|
|
498
|
+
client.pending.set(key, entry);
|
|
499
|
+
client.pendingBytes += entry.bytes || 0;
|
|
500
|
+
while (client.pendingBytes > SSE_PENDING_MAX_BYTES && client.pending.size > 0) {
|
|
501
|
+
const [oldestKey, oldest] = client.pending.entries().next().value;
|
|
502
|
+
const marker = resyncEntry(oldest.frame);
|
|
503
|
+
if (marker && marker.bytes < oldest.bytes) {
|
|
504
|
+
client.pending.set(oldestKey, marker);
|
|
505
|
+
client.pendingBytes += marker.bytes - oldest.bytes;
|
|
506
|
+
continue;
|
|
507
|
+
}
|
|
508
|
+
client.pending.delete(oldestKey);
|
|
509
|
+
client.pendingBytes = Math.max(0, client.pendingBytes - (oldest.bytes || 0));
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function writeFrame(client, frame, json) {
|
|
514
|
+
const key = frame.key || `${frame.type}:${frame.sessionId || frame.desktopId || ''}`;
|
|
515
|
+
// A stalled reader must never make the daemon buffer a whole stream in
|
|
516
|
+
// Node's socket queue: while the socket is full the backlog collapses to
|
|
517
|
+
// one frame per key, exactly like a client with no stream at all.
|
|
518
|
+
if (!client.sse || client.paused) {
|
|
519
|
+
const merged = mergePendingFrame(client.pending.get(key), frame, json);
|
|
520
|
+
setPending(client, key, pendingEntry(merged.frame, merged.json));
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
try {
|
|
524
|
+
if (client.sse.write(`data: ${json}\n\n`) === false) client.paused = true;
|
|
525
|
+
} catch {
|
|
526
|
+
client.sse = null;
|
|
527
|
+
const merged = mergePendingFrame(client.pending.get(key), frame, json);
|
|
528
|
+
setPending(client, key, pendingEntry(merged.frame, merged.json));
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/** Session and desktop state reaches only subscribed client tokens. Calls
|
|
533
|
+
* without a target set retain the transport-level broadcast used by
|
|
534
|
+
* diagnostics and compatibility tests. */
|
|
535
|
+
function broadcast(frame, targetTokens = null) {
|
|
536
|
+
const json = JSON.stringify(frame);
|
|
537
|
+
if (targetTokens) {
|
|
538
|
+
for (const token of targetTokens) {
|
|
539
|
+
const client = clients.get(String(token || ''));
|
|
540
|
+
if (client) writeFrame(client, frame, json);
|
|
541
|
+
}
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
for (const client of clients.values()) writeFrame(client, frame, json);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function attachSse(token, res) {
|
|
548
|
+
const c = clients.get(token);
|
|
549
|
+
if (!c) return false;
|
|
550
|
+
// Interactive lane: PTY output and keystroke echoes are small writes, and
|
|
551
|
+
// Nagle on a loopback socket adds a full delayed-ACK round trip to each.
|
|
552
|
+
try { res.socket?.setNoDelay(true); } catch { /* transport default stands */ }
|
|
553
|
+
res.writeHead(200, {
|
|
554
|
+
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
555
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
556
|
+
Connection: 'keep-alive',
|
|
557
|
+
'X-Accel-Buffering': 'no',
|
|
558
|
+
});
|
|
559
|
+
res.write(': attached\n\n');
|
|
560
|
+
c.sse = res;
|
|
561
|
+
c.paused = false;
|
|
562
|
+
c.lastSeen = nowMs();
|
|
563
|
+
// The stream proves the client received its token; response-loss replay is
|
|
564
|
+
// no longer needed for this registration.
|
|
565
|
+
for (const [registrationId, replay] of registrationReplays) {
|
|
566
|
+
if (replay.token === token) {
|
|
567
|
+
clearTimeout(replay.timer);
|
|
568
|
+
registrationReplays.delete(registrationId);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
res.on('drain', () => {
|
|
572
|
+
if (c.sse !== res) return;
|
|
573
|
+
c.paused = false;
|
|
574
|
+
flushPending(c);
|
|
575
|
+
});
|
|
576
|
+
flushPending(c);
|
|
577
|
+
const ka = setInterval(() => {
|
|
578
|
+
try { res.write(': ka\n\n'); } catch {}
|
|
579
|
+
}, 15_000);
|
|
580
|
+
ka.unref?.();
|
|
581
|
+
const cleanup = () => {
|
|
582
|
+
clearInterval(ka);
|
|
583
|
+
if (c.sse === res) { c.sse = null; c.paused = false; }
|
|
584
|
+
// Stream loss alone never drops the client: a desktop reload or a TUI
|
|
585
|
+
// resize can bounce the stream while the engine keeps running.
|
|
586
|
+
maybeArmGrace('sse closed');
|
|
587
|
+
};
|
|
588
|
+
res.on('close', cleanup);
|
|
589
|
+
res.on('error', cleanup);
|
|
590
|
+
return true;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
async function handleRequest(req, res) {
|
|
594
|
+
const url = new URL(req.url, 'http://127.0.0.1');
|
|
595
|
+
const pathName = url.pathname;
|
|
596
|
+
try {
|
|
597
|
+
if (req.method === 'GET' && pathName === '/health') {
|
|
598
|
+
// Identity travels with EVERY health probe: a view negotiates version
|
|
599
|
+
// skew before it attaches, so an embedder can never forget to publish
|
|
600
|
+
// it and leave clients guessing.
|
|
601
|
+
sendJson(res, {
|
|
602
|
+
status: 'ok',
|
|
603
|
+
pid: process.pid,
|
|
604
|
+
clients: lifecycleClientCount(),
|
|
605
|
+
connections: clients.size,
|
|
606
|
+
protocol: ENGINE_DAEMON_PROTOCOL,
|
|
607
|
+
version: engineRuntimeVersion(),
|
|
608
|
+
...getStatus(),
|
|
609
|
+
activeCalls: normalCalls.active + interactiveCalls.active + criticalCalls.active,
|
|
610
|
+
queuedCalls: normalCalls.queued + interactiveCalls.queued + criticalCalls.queued,
|
|
611
|
+
queuedUrgentCalls: criticalCalls.queued,
|
|
612
|
+
queuedInteractiveCalls: interactiveCalls.queued,
|
|
613
|
+
callOwners: normalCalls.snapshot().owners,
|
|
614
|
+
callQueues: {
|
|
615
|
+
critical: criticalCalls.snapshot(),
|
|
616
|
+
interactive: interactiveCalls.snapshot(),
|
|
617
|
+
normal: normalCalls.snapshot(),
|
|
618
|
+
},
|
|
619
|
+
transportMemory: {
|
|
620
|
+
bodyBytesInFlight,
|
|
621
|
+
bodyBytesMax: BODY_INFLIGHT_MAX_BYTES,
|
|
622
|
+
callCacheEntries: callCache.size,
|
|
623
|
+
callCacheBytes,
|
|
624
|
+
callCacheMaxBytes: CALL_CACHE_MAX_BYTES,
|
|
625
|
+
ssePendingBytes: [...clients.values()]
|
|
626
|
+
.reduce((sum, client) => sum + (client.pendingBytes || 0), 0),
|
|
627
|
+
},
|
|
628
|
+
});
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
const token = req.headers['x-mixdog-daemon-token'] || url.searchParams.get('server_token');
|
|
632
|
+
if (token !== serverToken) { sendError(res, 'forbidden', 403); return; }
|
|
633
|
+
|
|
634
|
+
if (req.method === 'POST' && pathName === '/client/register') {
|
|
635
|
+
const body = await readBody(req);
|
|
636
|
+
const clientToken = registerClient({
|
|
637
|
+
leadPid: body.leadPid,
|
|
638
|
+
cwd: body.cwd,
|
|
639
|
+
lifecycle: body.lifecycle !== false,
|
|
640
|
+
registrationId: body.registrationId,
|
|
641
|
+
});
|
|
642
|
+
sendJson(res, { token: clientToken, pid: process.pid });
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
if (req.method === 'POST' && pathName === '/client/deregister') {
|
|
646
|
+
const body = await readBody(req);
|
|
647
|
+
if (body.token) dropClient(String(body.token), 'deregister');
|
|
648
|
+
sendJson(res, { ok: true });
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
if (req.method === 'GET' && pathName === '/events') {
|
|
652
|
+
const clientToken = url.searchParams.get('token');
|
|
653
|
+
if (!attachSse(clientToken, res)) { sendError(res, 'unknown client token', 404); return; }
|
|
654
|
+
return; // stream stays open
|
|
655
|
+
}
|
|
656
|
+
if (req.method === 'POST' && pathName === '/call') {
|
|
657
|
+
const body = await readBody(req);
|
|
658
|
+
const clientToken = body.token ? String(body.token) : null;
|
|
659
|
+
const c = clientToken ? clients.get(clientToken) : null;
|
|
660
|
+
if (!c) { sendError(res, 'unknown client token', 404); return; }
|
|
661
|
+
c.lastSeen = nowMs();
|
|
662
|
+
const name = String(body.name || '');
|
|
663
|
+
const callId = body.callId ? String(body.callId) : null;
|
|
664
|
+
const addressedSessionId = String(body.args?.sessionId || '').trim();
|
|
665
|
+
const ownerKey = addressedSessionId
|
|
666
|
+
? `session:${addressedSessionId}`
|
|
667
|
+
: c.leadPid
|
|
668
|
+
? `pid:${c.leadPid}`
|
|
669
|
+
: `client:${clientToken}`;
|
|
670
|
+
// Scheduling fairness follows the addressed session, but idempotency
|
|
671
|
+
// belongs to the CALLING PROCESS. Two clients legitimately issuing the
|
|
672
|
+
// same callId against one shared session must not dedupe each other.
|
|
673
|
+
const cacheOwnerKey = c.leadPid
|
|
674
|
+
? `pid:${c.leadPid}`
|
|
675
|
+
: `client:${clientToken}`;
|
|
676
|
+
const cacheKey = callId ? `${cacheOwnerKey}\u0000${callId}` : null;
|
|
677
|
+
// A retry is the SAME payload under the same id. A different payload
|
|
678
|
+
// that reuses an id (submission ids are caller-supplied) is a NEW call
|
|
679
|
+
// and must never be answered out of another call's result.
|
|
680
|
+
const signature = callId ? callSignature(name, body.args) : null;
|
|
681
|
+
const cached = cacheKey ? callCache.get(cacheKey) : null;
|
|
682
|
+
let dispatch;
|
|
683
|
+
if (cached && (!cached.signature || !signature || cached.signature === signature)) {
|
|
684
|
+
dispatch = cached.promise;
|
|
685
|
+
} else {
|
|
686
|
+
dispatch = dispatchCall(
|
|
687
|
+
ownerKey,
|
|
688
|
+
() => handleCall(name, body.args || {}, {
|
|
689
|
+
clientToken,
|
|
690
|
+
leadPid: c.leadPid ?? null,
|
|
691
|
+
cwd: c.cwd ?? null,
|
|
692
|
+
}),
|
|
693
|
+
{ lane: callLane(name, body.args || {}) },
|
|
694
|
+
);
|
|
695
|
+
if (cacheKey) {
|
|
696
|
+
const record = {
|
|
697
|
+
promise: dispatch, at: nowMs(), signature, settled: false, bytes: 0,
|
|
698
|
+
};
|
|
699
|
+
callCache.set(cacheKey, record);
|
|
700
|
+
pruneCallCache();
|
|
701
|
+
// TTL starts at SETTLE: a long-running submit must not expire its
|
|
702
|
+
// dedup entry mid-flight and let a retry run the turn twice.
|
|
703
|
+
dispatch.then((result) => {
|
|
704
|
+
record.bytes = estimateRetainedBytes(result);
|
|
705
|
+
callCacheBytes += record.bytes;
|
|
706
|
+
}, () => {}).then(() => {
|
|
707
|
+
record.settled = true;
|
|
708
|
+
pruneCallCache();
|
|
709
|
+
const t = setTimeout(() => {
|
|
710
|
+
deleteCallCacheEntry(cacheKey, record);
|
|
711
|
+
}, CALL_CACHE_TTL_MS);
|
|
712
|
+
t.unref?.();
|
|
713
|
+
});
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
try {
|
|
717
|
+
const result = await dispatch;
|
|
718
|
+
sendJson(res, { result });
|
|
719
|
+
} catch (err) {
|
|
720
|
+
// Engine errors travel as a 200 {error} envelope so the client can
|
|
721
|
+
// tell a failed CALL from a dead TRANSPORT (which must re-attach).
|
|
722
|
+
sendJson(res, { error: err?.message || String(err) }, 200);
|
|
723
|
+
}
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
if (req.method === 'POST' && pathName === '/shutdown') {
|
|
727
|
+
sendJson(res, { ok: true });
|
|
728
|
+
if (typeof onClientsEmpty === 'function') { try { onClientsEmpty(); } catch {} }
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
sendError(res, 'not found', 404);
|
|
732
|
+
} catch (err) {
|
|
733
|
+
try { sendError(res, err?.message || String(err), err?.statusCode || 500); } catch {}
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function writeDiscovery() {
|
|
738
|
+
if (!discoveryPath) return;
|
|
739
|
+
try {
|
|
740
|
+
writeJsonAtomicSync(discoveryPath, {
|
|
741
|
+
pid: process.pid,
|
|
742
|
+
port: boundPort,
|
|
743
|
+
token: serverToken,
|
|
744
|
+
startedAt: Date.now(),
|
|
745
|
+
}, { compact: true });
|
|
746
|
+
} catch (err) {
|
|
747
|
+
log(`discovery write failed: ${err?.message || err}`);
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
function start() {
|
|
752
|
+
return new Promise((resolve, reject) => {
|
|
753
|
+
server = http.createServer(handleRequest);
|
|
754
|
+
server.on('error', reject);
|
|
755
|
+
// 127.0.0.1 ONLY — the engine daemon executes tools; it must never be
|
|
756
|
+
// reachable off-box.
|
|
757
|
+
server.listen(0, '127.0.0.1', () => {
|
|
758
|
+
server.removeListener('error', reject);
|
|
759
|
+
boundPort = server.address().port;
|
|
760
|
+
server.on('error', (err) => log(`server error: ${err?.message || err}`));
|
|
761
|
+
writeDiscovery();
|
|
762
|
+
log(`engine daemon transport listening on 127.0.0.1:${boundPort} pid=${process.pid}`);
|
|
763
|
+
resolve({ port: boundPort, token: serverToken });
|
|
764
|
+
});
|
|
765
|
+
});
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
async function stop() {
|
|
769
|
+
closed = true;
|
|
770
|
+
cancelGrace();
|
|
771
|
+
if (sweepTimer) { clearInterval(sweepTimer); sweepTimer = null; }
|
|
772
|
+
normalCalls.close('engine daemon transport is closed');
|
|
773
|
+
interactiveCalls.close('engine daemon transport is closed');
|
|
774
|
+
criticalCalls.close('engine daemon transport is closed');
|
|
775
|
+
for (const replay of registrationReplays.values()) clearTimeout(replay.timer);
|
|
776
|
+
registrationReplays.clear();
|
|
777
|
+
for (const token of [...clients.keys()]) removeClientRecord(token);
|
|
778
|
+
if (discoveryPath) { try { rmSync(discoveryPath, { force: true }); } catch {} }
|
|
779
|
+
await new Promise((resolve) => {
|
|
780
|
+
if (!server) { resolve(); return; }
|
|
781
|
+
server.close(() => resolve());
|
|
782
|
+
server = null;
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
return {
|
|
787
|
+
start,
|
|
788
|
+
stop,
|
|
789
|
+
broadcast,
|
|
790
|
+
get port() { return boundPort; },
|
|
791
|
+
get clientCount() { return lifecycleClientCount(); },
|
|
792
|
+
get connectionCount() { return clients.size; },
|
|
793
|
+
};
|
|
794
|
+
}
|