claws-code 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/commands/claws-auto.md +90 -0
- package/.claude/commands/claws-bin.md +28 -0
- package/.claude/commands/claws-cleanup.md +28 -0
- package/.claude/commands/claws-do.md +82 -0
- package/.claude/commands/claws-fix.md +40 -0
- package/.claude/commands/claws-goal.md +111 -0
- package/.claude/commands/claws-help.md +54 -0
- package/.claude/commands/claws-plan.md +103 -0
- package/.claude/commands/claws-report.md +29 -0
- package/.claude/commands/claws-status.md +37 -0
- package/.claude/commands/claws-update.md +32 -0
- package/.claude/commands/claws.md +64 -0
- package/.claude/rules/claws-default-behavior.md +76 -0
- package/.claude/settings.json +112 -0
- package/.claude/settings.local.json +19 -0
- package/.claude/skills/claws-auto-engine/SKILL.md +97 -0
- package/.claude/skills/claws-goal-tracker/SKILL.md +106 -0
- package/.claude/skills/claws-prompt-templates/SKILL.md +203 -0
- package/.claude/skills/claws-wave-lead/SKILL.md +126 -0
- package/.claude/skills/claws-wave-subworker/SKILL.md +60 -0
- package/CHANGELOG.md +1949 -0
- package/LICENSE +21 -0
- package/README.md +420 -0
- package/bin/cli.js +84 -0
- package/cli.js +223 -0
- package/docs/ARCHITECTURE.md +511 -0
- package/docs/event-protocol.md +588 -0
- package/docs/features.md +562 -0
- package/docs/guide.md +891 -0
- package/docs/index.html +716 -0
- package/docs/protocol.md +323 -0
- package/extension/.vscodeignore +15 -0
- package/extension/CHANGELOG.md +1906 -0
- package/extension/LICENSE +21 -0
- package/extension/README.md +137 -0
- package/extension/docs/features.md +424 -0
- package/extension/docs/protocol.md +197 -0
- package/extension/esbuild.mjs +25 -0
- package/extension/icon.png +0 -0
- package/extension/native/.metadata.json +10 -0
- package/extension/native/node-pty/LICENSE +69 -0
- package/extension/native/node-pty/README.md +165 -0
- package/extension/native/node-pty/lib/conpty_console_list_agent.js +16 -0
- package/extension/native/node-pty/lib/conpty_console_list_agent.js.map +1 -0
- package/extension/native/node-pty/lib/eventEmitter2.js +47 -0
- package/extension/native/node-pty/lib/eventEmitter2.js.map +1 -0
- package/extension/native/node-pty/lib/index.js +52 -0
- package/extension/native/node-pty/lib/index.js.map +1 -0
- package/extension/native/node-pty/lib/interfaces.js +7 -0
- package/extension/native/node-pty/lib/interfaces.js.map +1 -0
- package/extension/native/node-pty/lib/shared/conout.js +11 -0
- package/extension/native/node-pty/lib/shared/conout.js.map +1 -0
- package/extension/native/node-pty/lib/terminal.js +190 -0
- package/extension/native/node-pty/lib/terminal.js.map +1 -0
- package/extension/native/node-pty/lib/types.js +7 -0
- package/extension/native/node-pty/lib/types.js.map +1 -0
- package/extension/native/node-pty/lib/unixTerminal.js +346 -0
- package/extension/native/node-pty/lib/unixTerminal.js.map +1 -0
- package/extension/native/node-pty/lib/utils.js +39 -0
- package/extension/native/node-pty/lib/utils.js.map +1 -0
- package/extension/native/node-pty/lib/windowsConoutConnection.js +125 -0
- package/extension/native/node-pty/lib/windowsConoutConnection.js.map +1 -0
- package/extension/native/node-pty/lib/windowsPtyAgent.js +320 -0
- package/extension/native/node-pty/lib/windowsPtyAgent.js.map +1 -0
- package/extension/native/node-pty/lib/windowsTerminal.js +199 -0
- package/extension/native/node-pty/lib/windowsTerminal.js.map +1 -0
- package/extension/native/node-pty/lib/worker/conoutSocketWorker.js +22 -0
- package/extension/native/node-pty/lib/worker/conoutSocketWorker.js.map +1 -0
- package/extension/native/node-pty/package.json +64 -0
- package/extension/native/node-pty/prebuilds/darwin-arm64/pty.node +0 -0
- package/extension/native/node-pty/prebuilds/darwin-arm64/spawn-helper +0 -0
- package/extension/native/node-pty/prebuilds/darwin-x64/pty.node +0 -0
- package/extension/native/node-pty/prebuilds/darwin-x64/spawn-helper +0 -0
- package/extension/native/node-pty/prebuilds/win32-arm64/conpty/OpenConsole.exe +0 -0
- package/extension/native/node-pty/prebuilds/win32-arm64/conpty/conpty.dll +0 -0
- package/extension/native/node-pty/prebuilds/win32-arm64/conpty.node +0 -0
- package/extension/native/node-pty/prebuilds/win32-arm64/conpty_console_list.node +0 -0
- package/extension/native/node-pty/prebuilds/win32-arm64/pty.node +0 -0
- package/extension/native/node-pty/prebuilds/win32-arm64/winpty-agent.exe +0 -0
- package/extension/native/node-pty/prebuilds/win32-arm64/winpty.dll +0 -0
- package/extension/native/node-pty/prebuilds/win32-x64/conpty/OpenConsole.exe +0 -0
- package/extension/native/node-pty/prebuilds/win32-x64/conpty/conpty.dll +0 -0
- package/extension/native/node-pty/prebuilds/win32-x64/conpty.node +0 -0
- package/extension/native/node-pty/prebuilds/win32-x64/conpty_console_list.node +0 -0
- package/extension/native/node-pty/prebuilds/win32-x64/pty.node +0 -0
- package/extension/native/node-pty/prebuilds/win32-x64/winpty-agent.exe +0 -0
- package/extension/native/node-pty/prebuilds/win32-x64/winpty.dll +0 -0
- package/extension/package-lock.json +605 -0
- package/extension/package.json +343 -0
- package/extension/scripts/bundle-native.mjs +104 -0
- package/extension/scripts/deploy-dev.mjs +60 -0
- package/extension/src/ansi-strip.ts +52 -0
- package/extension/src/backends/vscode/claws-pty.ts +483 -0
- package/extension/src/backends/vscode/status-bar.ts +99 -0
- package/extension/src/backends/vscode/vscode-backend.ts +282 -0
- package/extension/src/capture-store.ts +125 -0
- package/extension/src/event-log.ts +629 -0
- package/extension/src/event-schemas.ts +478 -0
- package/extension/src/extension.js +492 -0
- package/extension/src/extension.ts +873 -0
- package/extension/src/lifecycle-engine.ts +60 -0
- package/extension/src/lifecycle-rules.ts +171 -0
- package/extension/src/lifecycle-store.ts +506 -0
- package/extension/src/peer-registry.ts +176 -0
- package/extension/src/pipeline-registry.ts +82 -0
- package/extension/src/platform.ts +64 -0
- package/extension/src/protocol.ts +532 -0
- package/extension/src/server-config.ts +98 -0
- package/extension/src/server.ts +2210 -0
- package/extension/src/task-registry.ts +51 -0
- package/extension/src/terminal-backend.ts +211 -0
- package/extension/src/terminal-manager.ts +395 -0
- package/extension/src/topic-registry.ts +70 -0
- package/extension/src/topic-utils.ts +46 -0
- package/extension/src/transport.ts +45 -0
- package/extension/src/uninstall-cleanup.ts +232 -0
- package/extension/src/wave-registry.ts +314 -0
- package/extension/src/websocket-transport.ts +153 -0
- package/extension/tsconfig.json +23 -0
- package/lib/capabilities.js +145 -0
- package/lib/dry-run.js +43 -0
- package/lib/install.js +1018 -0
- package/lib/mcp-setup.js +92 -0
- package/lib/platform.js +240 -0
- package/lib/preflight.js +152 -0
- package/lib/shell-hook.js +343 -0
- package/lib/uninstall.js +162 -0
- package/lib/verify.js +166 -0
- package/mcp_server.js +3529 -0
- package/package.json +48 -0
- package/rules/claws-default-behavior.md +72 -0
- package/scripts/_helpers/atomic-file.mjs +137 -0
- package/scripts/_helpers/fix-repair.js +64 -0
- package/scripts/_helpers/json-safe.mjs +218 -0
- package/scripts/bump-version.sh +84 -0
- package/scripts/codegen/gen-docs.mjs +61 -0
- package/scripts/codegen/gen-json-schema.mjs +62 -0
- package/scripts/codegen/gen-mcp-tools.mjs +358 -0
- package/scripts/codegen/gen-types.mjs +172 -0
- package/scripts/codegen/index.mjs +42 -0
- package/scripts/dev-hooks/check-extension-dirs.js +77 -0
- package/scripts/dev-hooks/check-open-claws-terminals.js +70 -0
- package/scripts/dev-hooks/check-stale-main.js +55 -0
- package/scripts/dev-hooks/check-tag-pushed.js +51 -0
- package/scripts/dev-hooks/check-tag-vs-main.js +56 -0
- package/scripts/dev-vsix-install.sh +60 -0
- package/scripts/fix.sh +702 -0
- package/scripts/gen-client-types.mjs +81 -0
- package/scripts/git-hooks/pre-commit +31 -0
- package/scripts/hooks/lifecycle-state.js +61 -0
- package/scripts/hooks/package.json +4 -0
- package/scripts/hooks/post-tool-use-claws.js +292 -0
- package/scripts/hooks/pre-bash-no-verify-block.js +72 -0
- package/scripts/hooks/pre-tool-use-claws.js +206 -0
- package/scripts/hooks/session-start-claws.js +97 -0
- package/scripts/hooks/stop-claws.js +88 -0
- package/scripts/inject-claude-md.js +205 -0
- package/scripts/inject-dev-hooks.js +96 -0
- package/scripts/inject-global-claude-md.js +140 -0
- package/scripts/inject-settings-hooks.js +370 -0
- package/scripts/install.ps1 +146 -0
- package/scripts/install.sh +1729 -0
- package/scripts/monitor-arm-watch.js +155 -0
- package/scripts/rebuild-node-pty.sh +245 -0
- package/scripts/report.sh +232 -0
- package/scripts/shell-hook.fish +164 -0
- package/scripts/shell-hook.ps1 +33 -0
- package/scripts/shell-hook.sh +232 -0
- package/scripts/stream-events.js +399 -0
- package/scripts/terminal-wrapper.sh +36 -0
- package/scripts/test-enforcement.sh +132 -0
- package/scripts/test-install.sh +174 -0
- package/scripts/test-installer-parity.sh +135 -0
- package/scripts/test-template-enforcement.sh +76 -0
- package/scripts/uninstall.sh +143 -0
- package/scripts/update.sh +337 -0
- package/scripts/verify-release.sh +323 -0
- package/scripts/verify-wrapped.sh +194 -0
- package/templates/CLAUDE.global.md +135 -0
- package/templates/CLAUDE.project.md +37 -0
|
@@ -0,0 +1,2210 @@
|
|
|
1
|
+
import * as net from 'net';
|
|
2
|
+
import * as fs from 'fs';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import { randomUUID, createHmac, timingSafeEqual } from 'crypto';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { CaptureStore } from './capture-store';
|
|
7
|
+
import { TerminalBackend } from './terminal-backend';
|
|
8
|
+
import { ClawsRequest, ClawsResponse, HistoryEvent, PROTOCOL_VERSION, PROTOCOL_VERSION_V2, SubWorkerRole } from './protocol';
|
|
9
|
+
import { WaveRegistry } from './wave-registry';
|
|
10
|
+
import {
|
|
11
|
+
ServerConfigProvider,
|
|
12
|
+
defaultServerConfig,
|
|
13
|
+
} from './server-config';
|
|
14
|
+
import { PeerConnection, DisconnectedPeer, ClawsRole, allocPeerId, fingerprintPeer, matchTopic, PeerRegistry } from './peer-registry';
|
|
15
|
+
import { TaskRecord, allocTaskId } from './task-registry';
|
|
16
|
+
import { LifecycleStore } from './lifecycle-store';
|
|
17
|
+
import { canTransition, explainIllegalTransition, canReflect } from './lifecycle-rules';
|
|
18
|
+
import { LifecycleEngine } from './lifecycle-engine';
|
|
19
|
+
import { EnvelopeV1, SCHEMA_BY_NAME, VehicleStateName } from './event-schemas';
|
|
20
|
+
import { schemaForTopic } from './topic-registry';
|
|
21
|
+
import { EventLogWriter, EventLogReader, parseCursor } from './event-log';
|
|
22
|
+
import { PipelineRegistry } from './pipeline-registry';
|
|
23
|
+
import { WebSocketTransport } from './websocket-transport';
|
|
24
|
+
import { getServerEndpoint, isNamedPipe } from './transport';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Per-connection context threaded into `handle()`. Holds the raw socket
|
|
28
|
+
* plus closures for reading/writing the peerId and negotiated protocol
|
|
29
|
+
* captured in the `handleConnection` local scope.
|
|
30
|
+
*/
|
|
31
|
+
interface ConnCtx {
|
|
32
|
+
socket: net.Socket;
|
|
33
|
+
getPeerId(): string | null;
|
|
34
|
+
setPeerId(id: string): void;
|
|
35
|
+
getNegotiatedProtocol(): string;
|
|
36
|
+
setNegotiatedProtocol(p: string): void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const MAX_READLOG_BYTES = 512 * 1024;
|
|
40
|
+
const DEFAULT_SOCKET_REL = '.claws/claws.sock';
|
|
41
|
+
const MAX_LINE_BYTES = 1024 * 1024;
|
|
42
|
+
/** L18 AUTH — maximum token age before it is rejected as stale (5 minutes). */
|
|
43
|
+
const AUTH_MAX_TOKEN_AGE_MS = 5 * 60 * 1000;
|
|
44
|
+
// How long to wait for an existing socket to respond before declaring it
|
|
45
|
+
// stale. 250ms is a live-server SLA on localhost — a real server answers in
|
|
46
|
+
// single-digit ms; no answer means nobody's there.
|
|
47
|
+
const STALE_PROBE_TIMEOUT_MS = 250;
|
|
48
|
+
const SHELL_BASENAMES = new Set([
|
|
49
|
+
'bash', 'zsh', 'fish', 'sh', 'dash', 'tcsh', 'csh', 'ksh', '-bash', '-zsh', '-sh',
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
function classifyContentType(basename: string | null): string {
|
|
53
|
+
if (!basename) return 'unknown';
|
|
54
|
+
const name = path.basename(basename).toLowerCase();
|
|
55
|
+
if (SHELL_BASENAMES.has(name) || name.startsWith('bash') || name.startsWith('zsh') || name.startsWith('sh')) return 'shell';
|
|
56
|
+
if (name.startsWith('python')) return 'python';
|
|
57
|
+
if (name === 'node' || name === 'nodejs') return 'node';
|
|
58
|
+
if (name === 'vim' || name === 'nvim' || name === 'vi') return 'vim';
|
|
59
|
+
if (name === 'htop' || name === 'top') return 'htop';
|
|
60
|
+
if (name.includes('claude')) return 'claude';
|
|
61
|
+
return 'unknown';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Lightweight snapshot of extension state used by the `introspect` command.
|
|
67
|
+
* The extension passes in an accessor that returns this shape — the server
|
|
68
|
+
* has no direct vscode dependency for introspect data.
|
|
69
|
+
*/
|
|
70
|
+
export interface IntrospectSnapshot {
|
|
71
|
+
extensionVersion: string;
|
|
72
|
+
nodePty: { loaded: boolean; loadedFrom?: string | null; error?: string };
|
|
73
|
+
servers: Array<{ workspace: string; socket: string | null }>;
|
|
74
|
+
terminals: number;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export type IntrospectProvider = () => IntrospectSnapshot;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Extra non-interface capabilities that VsCodeBackend (and future backends)
|
|
81
|
+
* may expose. Server calls these via optional-chaining — no runtime error if absent.
|
|
82
|
+
*/
|
|
83
|
+
interface ExtendedBackend extends TerminalBackend {
|
|
84
|
+
liveTerminalIds?(): Set<string>;
|
|
85
|
+
readonly terminalCount?: number;
|
|
86
|
+
setStateChangeCallback?(cb: (id: string, from: VehicleStateName | null, to: VehicleStateName) => void): void;
|
|
87
|
+
setContentChangeCallback?(cb: (id: string, pid: number | null, basename: string | null) => void): void;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface ServerOptions {
|
|
91
|
+
workspaceRoot: string;
|
|
92
|
+
socketRel: string;
|
|
93
|
+
captureStore: CaptureStore;
|
|
94
|
+
backend: TerminalBackend;
|
|
95
|
+
logger: (msg: string) => void;
|
|
96
|
+
history: HistoryEvent[];
|
|
97
|
+
/**
|
|
98
|
+
* Optional live-config reader. If omitted the server uses hard-coded
|
|
99
|
+
* defaults (180s exec timeout, 100 poll limit). The extension wires this
|
|
100
|
+
* up to `vscode.workspace.getConfiguration('claws')` so the values react
|
|
101
|
+
* to `settings.json` edits without a reload.
|
|
102
|
+
*/
|
|
103
|
+
getConfig?: ServerConfigProvider;
|
|
104
|
+
/**
|
|
105
|
+
* Optional provider that returns a structured snapshot of extension + host
|
|
106
|
+
* state — powers the `introspect` command and feeds the in-UI health-check
|
|
107
|
+
* so both paths render the same data.
|
|
108
|
+
*/
|
|
109
|
+
getIntrospect?: IntrospectProvider;
|
|
110
|
+
/** Optional hook to trigger a VS Code window reload (wired in extension.ts). */
|
|
111
|
+
reloadWindow?: () => void;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export class ClawsServer {
|
|
115
|
+
private server: net.Server | null = null;
|
|
116
|
+
private socketPath: string | null = null;
|
|
117
|
+
private startError: Error | null = null;
|
|
118
|
+
private readonly startedAt: number = Date.now();
|
|
119
|
+
/** Client versions we've already warned about — one warning per run per version. */
|
|
120
|
+
private readonly versionWarned = new Set<string>();
|
|
121
|
+
/** claws/2 peer registry. Keyed by peerId. Cleared on stop(). */
|
|
122
|
+
private readonly peers = new Map<string, PeerConnection>();
|
|
123
|
+
/** Back-reference from raw socket → peerId, used during connection teardown. */
|
|
124
|
+
private readonly socketToPeer = new WeakMap<net.Socket, string>();
|
|
125
|
+
/** Monotonic peerId counter (the wire id itself is "p_" + hex). */
|
|
126
|
+
private peerSeq = 0;
|
|
127
|
+
/** topicPattern string → set of subscribing peerIds. Used by publish fan-out. */
|
|
128
|
+
private readonly subscriptionIndex = new Map<string, Set<string>>();
|
|
129
|
+
/** Monotonic subscriptionId counter. */
|
|
130
|
+
private subSeq = 0;
|
|
131
|
+
/** claws/2 task registry. Keyed by taskId. Cleared on stop(). */
|
|
132
|
+
private readonly tasks = new Map<string, TaskRecord>();
|
|
133
|
+
/** Monotonic taskId counter (wire id is "t_" + zero-padded 3 digits). */
|
|
134
|
+
private taskSeq = 0;
|
|
135
|
+
/** Monotonic sequence number stamped into [CLAWS_CMD] broadcast text for idempotency. */
|
|
136
|
+
private broadcastSeq = 0;
|
|
137
|
+
/**
|
|
138
|
+
* Tombstones for fingerprinted peers that have disconnected. Keyed by fingerprint.
|
|
139
|
+
* On reconnect with the same instanceNonce, subscriptions and tasks are restored
|
|
140
|
+
* without requiring re-assignment. Cleared on stop().
|
|
141
|
+
*/
|
|
142
|
+
private readonly disconnectedPeers = new Map<string, DisconnectedPeer>();
|
|
143
|
+
/** Set of peerIds whose socket is currently under backpressure (write returned false). */
|
|
144
|
+
private readonly pausedPeers = new Set<string>();
|
|
145
|
+
/** BUG-21 fix: per-peer outbound queue for frames that arrive during backpressure.
|
|
146
|
+
* Bounded at MAX_PENDING_FRAMES to prevent unbounded memory growth. */
|
|
147
|
+
private readonly pendingFrames = new Map<string, string[]>();
|
|
148
|
+
private static readonly MAX_PENDING_FRAMES = 500;
|
|
149
|
+
/** Dropped push-frame counts per peerId during backpressure windows. */
|
|
150
|
+
private readonly droppedFrames = new Map<string, number>();
|
|
151
|
+
/** Per-peer rate-limit bucket: {count, windowStart} reset every 1000ms. */
|
|
152
|
+
private readonly publishRateTracker = new Map<string, { count: number; windowStart: number }>();
|
|
153
|
+
/** Accumulated rate-limit rejections per peer since last heartbeat. */
|
|
154
|
+
private readonly peerRateLimitHits = new Map<string, number>();
|
|
155
|
+
/** Total publish count since last heartbeat (resets each heartbeat cycle). */
|
|
156
|
+
private publishCountSinceHeartbeat = 0;
|
|
157
|
+
/**
|
|
158
|
+
* Count of publish handlers currently in-flight (passed rate + admission checks
|
|
159
|
+
* but not yet responded). Incremented synchronously before any await so concurrent
|
|
160
|
+
* handlers see an accurate backlog count at admission-control check time.
|
|
161
|
+
*/
|
|
162
|
+
private serverInFlight = 0;
|
|
163
|
+
/** Server-owned lifecycle state. Gate checks and lifecycle.* commands use this. */
|
|
164
|
+
private readonly lifecycleStore: LifecycleStore;
|
|
165
|
+
/** Auto-advance engine: subscribes to worker state changes, self-progresses phases. */
|
|
166
|
+
private readonly lifecycleEngine: LifecycleEngine;
|
|
167
|
+
/** Wave army registry — tracks active waves, sub-worker heartbeats, and violation detection. */
|
|
168
|
+
private readonly waveRegistry: WaveRegistry;
|
|
169
|
+
/** Monotonic sequence counter for deliver-cmd frames. */
|
|
170
|
+
private cmdSeq = 0;
|
|
171
|
+
/** Idempotency map: idempotencyKey → {seq, targetPeerId}. Prevents re-delivery on retry. */
|
|
172
|
+
private readonly cmdIdempotencyMap = new Map<string, { seq: number; targetPeerId: string }>();
|
|
173
|
+
/** Delivery record: seq → {targetPeerId, from, cmdTopic}. Needed to fan-out cmd.ack to orchestrator. */
|
|
174
|
+
private readonly cmdDeliveryMap = new Map<number, { targetPeerId: string; from: string; cmdTopic: string }>();
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* L16 TYPED-RPC correlation map. Keyed by requestId (UUID). Each entry holds the
|
|
178
|
+
* resolve callback for the pending `rpc.call` handler and a timeout timer. Entries
|
|
179
|
+
* are deleted on response receipt or timeout — no unbounded growth.
|
|
180
|
+
*/
|
|
181
|
+
private readonly rpcPending = new Map<string, {
|
|
182
|
+
resolve: (res: ClawsResponse) => void;
|
|
183
|
+
callerPeerId: string;
|
|
184
|
+
timer: ReturnType<typeof setTimeout>;
|
|
185
|
+
}>();
|
|
186
|
+
|
|
187
|
+
private readonly eventLog = new EventLogWriter();
|
|
188
|
+
private heartbeatTimer: NodeJS.Timeout | null = null;
|
|
189
|
+
/** LH-9: TTL watchdog interval — scans expired workers every 30s. */
|
|
190
|
+
private ttlWatchdogTimer: NodeJS.Timeout | null = null;
|
|
191
|
+
/** L11 Pipeline composition registry. */
|
|
192
|
+
private readonly pipelineRegistry = new PipelineRegistry();
|
|
193
|
+
/**
|
|
194
|
+
* L18 AUTH — consumed nonce set. Nonces are single-use; a second hello
|
|
195
|
+
* with an already-seen nonce is rejected as a replay attack. Cleared on stop().
|
|
196
|
+
*/
|
|
197
|
+
private readonly usedNonces = new Set<string>();
|
|
198
|
+
/** L19 TRANSPORT-X — optional WebSocket transport alongside the Unix socket. */
|
|
199
|
+
private readonly wsTransport = new WebSocketTransport();
|
|
200
|
+
/** Bug-6 Layer 2 — tracks Monitor peers that declared monitorCorrelationId at hello time. */
|
|
201
|
+
private readonly peerRegistry = new PeerRegistry();
|
|
202
|
+
/** Tracks whether each terminal was created wrapped — populated from backend 'terminal:created' events. */
|
|
203
|
+
private readonly terminalWrapped = new Map<string, boolean>();
|
|
204
|
+
|
|
205
|
+
constructor(private readonly opts: ServerOptions) {
|
|
206
|
+
this.lifecycleStore = new LifecycleStore(opts.workspaceRoot);
|
|
207
|
+
// LH-9 1D: reconcile against live terminals AFTER loadFromDisk has
|
|
208
|
+
// populated state from JSON. The backend's liveTerminalIds() reflects
|
|
209
|
+
// whatever terminals survived the last extension reload. Anything in
|
|
210
|
+
// spawned_workers that isn't live is a stale entry and gets marked closed.
|
|
211
|
+
try {
|
|
212
|
+
const extBackend = opts.backend as ExtendedBackend;
|
|
213
|
+
const liveIds = extBackend.liveTerminalIds?.() ?? new Set<string>();
|
|
214
|
+
const { workersClosed, monitorsDropped } = this.lifecycleStore.reconcileWithLiveTerminals(liveIds);
|
|
215
|
+
if (workersClosed.length > 0 || monitorsDropped.length > 0) {
|
|
216
|
+
opts.logger(`[claws] lifecycle reconcile on boot: ${workersClosed.length} stale worker(s) closed: ${workersClosed.join(', ')}; ${monitorsDropped.length} orphan monitor(s) dropped: ${monitorsDropped.join(', ')}`);
|
|
217
|
+
}
|
|
218
|
+
} catch (err) {
|
|
219
|
+
opts.logger(`[claws] lifecycle reconcile failed (non-fatal): ${(err as Error).message}`);
|
|
220
|
+
}
|
|
221
|
+
// LH-9: tap CaptureStore so every PTY byte refreshes the worker's
|
|
222
|
+
// last_activity_at. markActivity is a no-op for non-lifecycle terminals
|
|
223
|
+
// and self-throttles disk flushes (>5s gap).
|
|
224
|
+
opts.captureStore.setOnAppend((id, _bytes) => {
|
|
225
|
+
try { this.lifecycleStore.markActivity(String(id)); } catch { /* non-fatal */ }
|
|
226
|
+
});
|
|
227
|
+
this.lifecycleEngine = new LifecycleEngine({
|
|
228
|
+
store: this.lifecycleStore,
|
|
229
|
+
emitEvent: (topic, payload) => this.emitServerEvent(topic, payload),
|
|
230
|
+
logger: opts.logger,
|
|
231
|
+
});
|
|
232
|
+
this.waveRegistry = new WaveRegistry(
|
|
233
|
+
(waveId, role, silentMs) => {
|
|
234
|
+
void this.emitSystemEvent(`wave.${waveId}.violation`, {
|
|
235
|
+
waveId,
|
|
236
|
+
subWorker: role,
|
|
237
|
+
silentMs,
|
|
238
|
+
ts: new Date().toISOString(),
|
|
239
|
+
});
|
|
240
|
+
const { terminalId } = this.waveRegistry.markSubWorkerAutoClosed(waveId, role);
|
|
241
|
+
if (terminalId) {
|
|
242
|
+
void opts.backend.closeTerminal(terminalId, 'wave_violation');
|
|
243
|
+
}
|
|
244
|
+
},
|
|
245
|
+
(waveId, subWorkerCount) => {
|
|
246
|
+
void this.emitSystemEvent(`wave.${waveId}.violation`, {
|
|
247
|
+
waveId,
|
|
248
|
+
kind: 'silent_lead_with_active_subs',
|
|
249
|
+
subWorkerCount,
|
|
250
|
+
ts: new Date().toISOString(),
|
|
251
|
+
});
|
|
252
|
+
},
|
|
253
|
+
);
|
|
254
|
+
opts.backend.on('terminal:created', (ev) => {
|
|
255
|
+
this.terminalWrapped.set(ev.id, ev.wrapped);
|
|
256
|
+
});
|
|
257
|
+
const extBackend = opts.backend as ExtendedBackend;
|
|
258
|
+
extBackend.setStateChangeCallback?.((id, from, to) => {
|
|
259
|
+
const payload = { terminalId: id, from, to, ts: new Date().toISOString() };
|
|
260
|
+
void this.emitSystemEvent(`vehicle.${id}.state`, payload);
|
|
261
|
+
});
|
|
262
|
+
extBackend.setContentChangeCallback?.((id, pid, basename) => {
|
|
263
|
+
const payload = {
|
|
264
|
+
terminalId: id,
|
|
265
|
+
contentType: classifyContentType(basename),
|
|
266
|
+
foregroundPid: pid,
|
|
267
|
+
basename: basename ?? null,
|
|
268
|
+
detectedAt: new Date().toISOString(),
|
|
269
|
+
confidence: pid !== null ? ('high' as const) : ('low' as const),
|
|
270
|
+
};
|
|
271
|
+
void this.emitSystemEvent(`vehicle.${id}.content`, payload);
|
|
272
|
+
});
|
|
273
|
+
// AC-1: translate backend terminal:ready → system.terminal.ready bus event.
|
|
274
|
+
opts.backend.on('terminal:ready', (ev) => {
|
|
275
|
+
void this.emitSystemEvent('system.terminal.ready', {
|
|
276
|
+
terminal_id: ev.id,
|
|
277
|
+
correlation_id: ev.correlationId,
|
|
278
|
+
ts: new Date().toISOString(),
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
// AF-AC Phase 1: process exited before VS Code UI teardown — deterministic close signal.
|
|
283
|
+
opts.backend.on('terminal:process_exited', (ev) => {
|
|
284
|
+
void this.emitSystemEvent('system.worker.process_exited', {
|
|
285
|
+
terminal_id: ev.id,
|
|
286
|
+
correlation_id: ev.correlationId,
|
|
287
|
+
exit_code: ev.exitCode,
|
|
288
|
+
exited_at: new Date().toISOString(),
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
opts.backend.on('terminal:closed', (ev) => {
|
|
293
|
+
const { id, origin } = ev;
|
|
294
|
+
// LH-10: look up correlation_id from lifecycle state so per-worker Monitors
|
|
295
|
+
// (filtered on correlation_id) can self-exit on terminal close — Monitor closure parity.
|
|
296
|
+
const correlationId = this.lifecycleStore.snapshot()
|
|
297
|
+
?.spawned_workers.find(w => w.id === String(id))?.correlation_id;
|
|
298
|
+
void this.emitSystemEvent('system.terminal.closed', {
|
|
299
|
+
terminal_id: id,
|
|
300
|
+
close_origin: origin,
|
|
301
|
+
closed_at: new Date().toISOString(),
|
|
302
|
+
...(correlationId ? { correlation_id: correlationId } : {}),
|
|
303
|
+
});
|
|
304
|
+
// Only emit system.worker.terminated for wrapped terminals (they host Claude workers).
|
|
305
|
+
// BUG-7 Option A: terminal_id is session-local. correlation_id is globally unique.
|
|
306
|
+
const wasWrapped = this.terminalWrapped.get(String(id)) ?? false;
|
|
307
|
+
this.terminalWrapped.delete(String(id));
|
|
308
|
+
if (wasWrapped) {
|
|
309
|
+
void this.emitSystemEvent('system.worker.terminated', {
|
|
310
|
+
terminal_id: id,
|
|
311
|
+
terminated_at: new Date().toISOString(),
|
|
312
|
+
...(correlationId ? { correlation_id: correlationId } : {}),
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
// LH-9 1A: every close path funnels through this event. Mark the
|
|
316
|
+
// worker closed in lifecycle so .claws/lifecycle-state.json never drifts.
|
|
317
|
+
try {
|
|
318
|
+
const updated = this.lifecycleStore.markWorkerStatus(String(id), 'closed');
|
|
319
|
+
if (updated) this.lifecycleEngine.onWorkerEvent('terminal-close-callback:' + id);
|
|
320
|
+
this.lifecycleStore.removeMonitorByTerminalId(String(id)); // LH-10
|
|
321
|
+
} catch (_err) { /* non-fatal */ }
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Milliseconds since this server instance was constructed. */
|
|
326
|
+
uptimeMs(): number {
|
|
327
|
+
return Date.now() - this.startedAt;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* L18 AUTH — validate a hello token against the configured shared secret.
|
|
332
|
+
* Returns null on success, or an error string ('auth:required'|'auth:invalid').
|
|
333
|
+
*
|
|
334
|
+
* Token = HMAC-SHA256(secret, `${peerName}:${role}:${nonce}:${timestamp}`).
|
|
335
|
+
* Checks: token present, timestamp not stale, nonce not reused, HMAC correct.
|
|
336
|
+
*/
|
|
337
|
+
private validateAuthToken(r: import('./protocol').HelloRequest): string | null {
|
|
338
|
+
const cfg = this.getConfig().auth;
|
|
339
|
+
if (!cfg?.enabled) return null;
|
|
340
|
+
|
|
341
|
+
if (!r.token || !r.nonce || r.timestamp === undefined) {
|
|
342
|
+
return 'auth:required';
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Reject stale tokens (replay window).
|
|
346
|
+
const age = Date.now() - r.timestamp;
|
|
347
|
+
if (age < 0 || age > AUTH_MAX_TOKEN_AGE_MS) {
|
|
348
|
+
return 'auth:invalid';
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Reject replayed nonces.
|
|
352
|
+
if (this.usedNonces.has(r.nonce)) {
|
|
353
|
+
return 'auth:invalid';
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Load and validate HMAC.
|
|
357
|
+
let secret: string;
|
|
358
|
+
try {
|
|
359
|
+
const tokenPath = path.isAbsolute(cfg.tokenPath)
|
|
360
|
+
? cfg.tokenPath
|
|
361
|
+
: path.join(this.opts.workspaceRoot, cfg.tokenPath);
|
|
362
|
+
secret = fs.readFileSync(tokenPath, 'utf8').trim();
|
|
363
|
+
} catch {
|
|
364
|
+
// Token file missing or unreadable — auth is misconfigured, reject all.
|
|
365
|
+
return 'auth:invalid';
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const expected = createHmac('sha256', secret)
|
|
369
|
+
.update(`${r.peerName}:${r.role}:${r.nonce}:${r.timestamp}`)
|
|
370
|
+
.digest('hex');
|
|
371
|
+
|
|
372
|
+
let valid = false;
|
|
373
|
+
try {
|
|
374
|
+
valid = timingSafeEqual(Buffer.from(r.token, 'hex'), Buffer.from(expected, 'hex'));
|
|
375
|
+
} catch {
|
|
376
|
+
// Mismatched buffer lengths (malformed token) → not equal.
|
|
377
|
+
valid = false;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (!valid) return 'auth:invalid';
|
|
381
|
+
|
|
382
|
+
// Consume the nonce — prevents replay on a second connection.
|
|
383
|
+
this.usedNonces.add(r.nonce);
|
|
384
|
+
return null;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Appends an event to the log and fans it out to subscribers. Skips both
|
|
389
|
+
* steps if the event log is degraded. Errors are swallowed so callers
|
|
390
|
+
* (heartbeat timer, task handlers) never crash the extension.
|
|
391
|
+
*/
|
|
392
|
+
private async emitSystemEvent(topic: string, payload: unknown): Promise<void> {
|
|
393
|
+
if (this.eventLog.isDegraded) return;
|
|
394
|
+
try {
|
|
395
|
+
const result = await this.eventLog.append({
|
|
396
|
+
topic,
|
|
397
|
+
from: 'server',
|
|
398
|
+
ts_server: new Date().toISOString(),
|
|
399
|
+
payload,
|
|
400
|
+
});
|
|
401
|
+
const sequence = result.sequence >= 0 ? result.sequence : undefined;
|
|
402
|
+
this.fanOut(topic, 'server', payload, false, sequence);
|
|
403
|
+
} catch {
|
|
404
|
+
// heartbeat failures must never crash the extension
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Begin listening on the Unix socket. This method is "fire-and-forget"
|
|
410
|
+
* from the caller's perspective but internally runs an async stale-socket
|
|
411
|
+
* probe before bind — on collision with a live server it logs to the
|
|
412
|
+
* diagnostic channel and stashes `startError` for later inspection via
|
|
413
|
+
* `getStartError()`. The caller may also `await start()` directly to wait
|
|
414
|
+
* on bind completion.
|
|
415
|
+
*/
|
|
416
|
+
start(): Promise<void> {
|
|
417
|
+
if (process.platform === 'win32') {
|
|
418
|
+
// Named pipes are kernel objects — use the computed pipe name derived
|
|
419
|
+
// from the workspace root. The socketRel config option is Unix-only.
|
|
420
|
+
this.socketPath = getServerEndpoint(this.opts.workspaceRoot);
|
|
421
|
+
} else {
|
|
422
|
+
const socketRel = this.opts.socketRel || DEFAULT_SOCKET_REL;
|
|
423
|
+
this.socketPath = path.join(this.opts.workspaceRoot, socketRel);
|
|
424
|
+
fs.mkdirSync(path.dirname(this.socketPath), { recursive: true });
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// NB: this promise resolves on success OR failure — failure is captured
|
|
428
|
+
// in `startError` for the caller to inspect. Returning a never-rejecting
|
|
429
|
+
// promise keeps fire-and-forget callers (`srv.start()`) safe from
|
|
430
|
+
// unhandledRejection noise.
|
|
431
|
+
return this.prepareSocket(this.socketPath)
|
|
432
|
+
.then(() => this.eventLog.open(this.opts.workspaceRoot).catch((err: unknown) => {
|
|
433
|
+
// Event log is non-fatal: log a warning and continue in degraded mode.
|
|
434
|
+
this.opts.logger(`[claws] event log disabled at startup: ${String(err)}`);
|
|
435
|
+
}))
|
|
436
|
+
.then(() => {
|
|
437
|
+
// Startup compaction: merge tiny segments left from previous runs.
|
|
438
|
+
if (this.getConfig().eventLog.compact) {
|
|
439
|
+
return this.eventLog.compact().catch(() => { /* non-fatal */ });
|
|
440
|
+
}
|
|
441
|
+
return undefined;
|
|
442
|
+
})
|
|
443
|
+
.then(() => this.bind(this.socketPath!))
|
|
444
|
+
.then(() => {
|
|
445
|
+
// Bug-12 investigation: enable peer-registry trace log.
|
|
446
|
+
this.peerRegistry.setTraceLogPath(path.join(this.opts.workspaceRoot, '.claws', 'peer-registry-trace.log'));
|
|
447
|
+
})
|
|
448
|
+
.then(() => {
|
|
449
|
+
const intervalMs = this.getConfig().heartbeatIntervalMs;
|
|
450
|
+
if (intervalMs > 0) {
|
|
451
|
+
this.heartbeatTimer = setInterval(() => {
|
|
452
|
+
void this.emitSystemEvent('system.heartbeat', {
|
|
453
|
+
uptimeMs: this.uptimeMs(),
|
|
454
|
+
peers: this.peers.size,
|
|
455
|
+
terminals: (this.opts.backend as ExtendedBackend).terminalCount ?? 0,
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
// L13: emit system.metrics with throughput + queue depth snapshot.
|
|
459
|
+
const intervalSec = Math.max(1, intervalMs / 1000);
|
|
460
|
+
const publishRate = this.publishCountSinceHeartbeat / intervalSec;
|
|
461
|
+
this.publishCountSinceHeartbeat = 0;
|
|
462
|
+
void this.emitSystemEvent('system.metrics', {
|
|
463
|
+
publishRate_per_sec: publishRate,
|
|
464
|
+
queueDepth: this.serverInFlight,
|
|
465
|
+
peerCount: this.peers.size,
|
|
466
|
+
eventLogLastSeq: this.eventLog.lastSequence,
|
|
467
|
+
uptimeMs: this.uptimeMs(),
|
|
468
|
+
ts: new Date().toISOString(),
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
// L13: emit per-peer metrics for peers with drops or rate-limit hits.
|
|
472
|
+
for (const peer of this.peers.values()) {
|
|
473
|
+
const dropped = this.droppedFrames.get(peer.peerId) ?? 0;
|
|
474
|
+
const rateLimitHits = this.peerRateLimitHits.get(peer.peerId) ?? 0;
|
|
475
|
+
const bucket = this.publishRateTracker.get(peer.peerId);
|
|
476
|
+
const publishCount = bucket?.count ?? 0;
|
|
477
|
+
if (dropped > 0 || rateLimitHits > 0) {
|
|
478
|
+
void this.emitSystemEvent(`system.peer.metrics.${peer.peerId}`, {
|
|
479
|
+
peerId: peer.peerId,
|
|
480
|
+
peerName: peer.peerName,
|
|
481
|
+
droppedFrames: dropped,
|
|
482
|
+
rateLimitHits,
|
|
483
|
+
publishCount,
|
|
484
|
+
ts: new Date().toISOString(),
|
|
485
|
+
});
|
|
486
|
+
this.peerRateLimitHits.delete(peer.peerId);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// Bug-15: emit per-terminal alive events so stream-events.js --keep-alive-on
|
|
491
|
+
// can confirm liveness without depending on worker hello or shell-integration
|
|
492
|
+
// transitions. Fires every heartbeat cycle (default 60 s); the 120 s stale
|
|
493
|
+
// threshold gives a 2× safety margin.
|
|
494
|
+
try {
|
|
495
|
+
const liveIds = (this.opts.backend as ExtendedBackend).liveTerminalIds?.() ?? new Set<string>();
|
|
496
|
+
for (const id of liveIds) {
|
|
497
|
+
void this.emitSystemEvent(`system.terminal.${id}.alive`, {
|
|
498
|
+
terminal_id: String(id),
|
|
499
|
+
ts: new Date().toISOString(),
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
} catch (e) {
|
|
503
|
+
this.opts.logger(`[claws] terminal.alive emit failed: ${String(e)}`);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// Retention: delete segments older than the configured threshold.
|
|
507
|
+
const retentionDays = this.getConfig().eventLog.retentionDays;
|
|
508
|
+
if (retentionDays > 0) {
|
|
509
|
+
void this.eventLog.runRetention(retentionDays).catch(() => { /* non-fatal */ });
|
|
510
|
+
}
|
|
511
|
+
}, intervalMs);
|
|
512
|
+
}
|
|
513
|
+
})
|
|
514
|
+
.then(() => {
|
|
515
|
+
// LH-9: TTL watchdog. Scans spawned_workers every 30s and closes any
|
|
516
|
+
// that have exceeded their idle window (default 10min) or hard
|
|
517
|
+
// ceiling (default 4h). The close call funnels through tm.close,
|
|
518
|
+
// which fires the close-callback, which marks lifecycle closed.
|
|
519
|
+
// No state drift possible — single chokepoint.
|
|
520
|
+
const TTL_SCAN_INTERVAL_MS = 30_000;
|
|
521
|
+
this.ttlWatchdogTimer = setInterval(() => {
|
|
522
|
+
try {
|
|
523
|
+
const expired = this.lifecycleStore.findExpiredWorkers();
|
|
524
|
+
for (const { id, reason } of expired) {
|
|
525
|
+
this.opts.logger(`[claws/ttl] worker ${id} expired (${reason}) — closing`);
|
|
526
|
+
try {
|
|
527
|
+
void this.opts.backend.closeTerminal(id, 'orchestrator');
|
|
528
|
+
} catch (err) {
|
|
529
|
+
this.opts.logger(`[claws/ttl] close ${id} failed: ${(err as Error).message}`);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
} catch (err) {
|
|
533
|
+
this.opts.logger(`[claws/ttl] watchdog scan failed: ${(err as Error).message}`);
|
|
534
|
+
}
|
|
535
|
+
}, TTL_SCAN_INTERVAL_MS);
|
|
536
|
+
if (typeof this.ttlWatchdogTimer.unref === 'function') {
|
|
537
|
+
this.ttlWatchdogTimer.unref();
|
|
538
|
+
}
|
|
539
|
+
})
|
|
540
|
+
.then(() => {
|
|
541
|
+
// L19 TRANSPORT-X — start WebSocket server alongside Unix socket if enabled.
|
|
542
|
+
const wsCfg = this.getConfig().webSocket;
|
|
543
|
+
if (wsCfg?.enabled) {
|
|
544
|
+
return this.wsTransport.start({
|
|
545
|
+
port: wsCfg.port,
|
|
546
|
+
certPath: wsCfg.certPath || undefined,
|
|
547
|
+
keyPath: wsCfg.keyPath || undefined,
|
|
548
|
+
logger: this.opts.logger,
|
|
549
|
+
onConnection: (socket) => this.handleConnection(socket),
|
|
550
|
+
}).catch((err: unknown) => {
|
|
551
|
+
// WebSocket failure is non-fatal — Unix socket still works.
|
|
552
|
+
this.opts.logger(`[claws/ws] failed to start: ${String(err)}`);
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
return undefined;
|
|
556
|
+
})
|
|
557
|
+
.catch((err) => {
|
|
558
|
+
this.startError = err instanceof Error ? err : new Error(String(err));
|
|
559
|
+
this.opts.logger(`[claws] server start failed: ${this.startError.message}`);
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/** Null unless a previous start() rejected. */
|
|
564
|
+
getStartError(): Error | null {
|
|
565
|
+
return this.startError;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
stop(): void {
|
|
569
|
+
if (this.heartbeatTimer !== null) {
|
|
570
|
+
clearInterval(this.heartbeatTimer);
|
|
571
|
+
this.heartbeatTimer = null;
|
|
572
|
+
}
|
|
573
|
+
if (this.ttlWatchdogTimer !== null) {
|
|
574
|
+
clearInterval(this.ttlWatchdogTimer);
|
|
575
|
+
this.ttlWatchdogTimer = null;
|
|
576
|
+
}
|
|
577
|
+
// LH-9: detach activity sink so server stop doesn't leak references.
|
|
578
|
+
this.opts.captureStore.setOnAppend(null);
|
|
579
|
+
this.waveRegistry.dispose();
|
|
580
|
+
// L19 TRANSPORT-X — stop WebSocket server if running.
|
|
581
|
+
this.wsTransport.stop();
|
|
582
|
+
// Best-effort flush: manifest is written synchronously inside close(); the
|
|
583
|
+
// stream.end() drain is async but VS Code deactivation gives it time.
|
|
584
|
+
this.eventLog.close().catch(() => { /* best-effort */ });
|
|
585
|
+
try { this.server?.close(); } catch { /* ignore */ }
|
|
586
|
+
try { if (this.socketPath && !isNamedPipe(this.socketPath)) fs.unlinkSync(this.socketPath); } catch { /* ignore */ }
|
|
587
|
+
this.server = null;
|
|
588
|
+
this.peers.clear();
|
|
589
|
+
this.subscriptionIndex.clear();
|
|
590
|
+
this.subSeq = 0;
|
|
591
|
+
this.tasks.clear();
|
|
592
|
+
this.taskSeq = 0;
|
|
593
|
+
this.usedNonces.clear();
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
getSocketPath(): string | null { return this.socketPath; }
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* Stale-socket check + unlink. If another live server is already bound to
|
|
600
|
+
* this path, reject loudly — silently stealing the socket is how two
|
|
601
|
+
* VS Code windows race each other into client confusion.
|
|
602
|
+
*/
|
|
603
|
+
private async prepareSocket(sockPath: string): Promise<void> {
|
|
604
|
+
if (isNamedPipe(sockPath)) {
|
|
605
|
+
// Named pipes are kernel objects — no filesystem cleanup needed.
|
|
606
|
+
// Probe by connecting: if something answers, another server is live.
|
|
607
|
+
const occupied = await this.probeSocket(sockPath);
|
|
608
|
+
if (occupied) {
|
|
609
|
+
throw new Error(
|
|
610
|
+
`refusing to start: another server is already listening on ${sockPath}. ` +
|
|
611
|
+
`Close the other VS Code window.`,
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
if (!fs.existsSync(sockPath)) return;
|
|
617
|
+
const occupied = await this.probeSocket(sockPath);
|
|
618
|
+
if (occupied) {
|
|
619
|
+
throw new Error(
|
|
620
|
+
`refusing to start: another server is already listening on ${sockPath}. ` +
|
|
621
|
+
`Close the other VS Code window or delete the socket manually.`,
|
|
622
|
+
);
|
|
623
|
+
}
|
|
624
|
+
try { fs.unlinkSync(sockPath); } catch (err) {
|
|
625
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
626
|
+
if (code !== 'ENOENT') {
|
|
627
|
+
throw new Error(`unable to remove stale socket ${sockPath}: ${(err as Error).message}`);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
private probeSocket(sockPath: string): Promise<boolean> {
|
|
633
|
+
return new Promise<boolean>((resolve) => {
|
|
634
|
+
const client = net.createConnection(sockPath);
|
|
635
|
+
const finish = (alive: boolean): void => {
|
|
636
|
+
try { client.destroy(); } catch { /* ignore */ }
|
|
637
|
+
resolve(alive);
|
|
638
|
+
};
|
|
639
|
+
client.once('connect', () => finish(true));
|
|
640
|
+
client.once('error', (err: NodeJS.ErrnoException) => {
|
|
641
|
+
// ECONNREFUSED = socket file exists but no one is accept()ing.
|
|
642
|
+
// ENOENT = file disappeared between stat and connect.
|
|
643
|
+
// Anything else (EACCES, ENOTSOCK) = corrupted path — treat as
|
|
644
|
+
// stale so we don't get stuck in a hard-refuse loop on a bad FS.
|
|
645
|
+
if (err.code === 'ECONNREFUSED' || err.code === 'ENOENT') return finish(false);
|
|
646
|
+
return finish(false);
|
|
647
|
+
});
|
|
648
|
+
setTimeout(() => finish(false), STALE_PROBE_TIMEOUT_MS);
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
private bind(sockPath: string): Promise<void> {
|
|
653
|
+
return new Promise<void>((resolve, reject) => {
|
|
654
|
+
this.server = net.createServer((socket) => this.handleConnection(socket));
|
|
655
|
+
|
|
656
|
+
const namedPipe = isNamedPipe(sockPath);
|
|
657
|
+
// Restrict file mode from birth by tightening umask around the bind.
|
|
658
|
+
// On macOS & Linux net.Server.listen creates the inode with
|
|
659
|
+
// (0o777 & ~umask), so umask(0o077) yields 0700 on the socket — good
|
|
660
|
+
// enough to prevent other-user access. We belt-and-brace with an
|
|
661
|
+
// explicit chmod in the listen callback in case VS Code's umask is
|
|
662
|
+
// unusual under Electron.
|
|
663
|
+
// Named pipes (Windows) are kernel objects — umask and chmod are no-ops.
|
|
664
|
+
const prevUmask = namedPipe ? null : process.umask(0o077);
|
|
665
|
+
try {
|
|
666
|
+
this.server.once('listening', () => {
|
|
667
|
+
if (!namedPipe) {
|
|
668
|
+
try { fs.chmodSync(sockPath, 0o600); } catch { /* ignore */ }
|
|
669
|
+
}
|
|
670
|
+
this.opts.logger(`[claws] listening on ${sockPath}`);
|
|
671
|
+
resolve();
|
|
672
|
+
});
|
|
673
|
+
this.server.once('error', (err) => {
|
|
674
|
+
this.opts.logger(`[server error] ${err}`);
|
|
675
|
+
reject(err);
|
|
676
|
+
});
|
|
677
|
+
this.server.listen(sockPath);
|
|
678
|
+
} finally {
|
|
679
|
+
if (prevUmask !== null) process.umask(prevUmask);
|
|
680
|
+
}
|
|
681
|
+
});
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
private handleConnection(socket: net.Socket): void {
|
|
685
|
+
let buf = '';
|
|
686
|
+
// Per-connection state. `_peerId` is set by the `hello` handler once
|
|
687
|
+
// the peer registers; remains null for plain claws/1 clients. The
|
|
688
|
+
// negotiated protocol starts at 'claws/1' and is upgraded to 'claws/2'
|
|
689
|
+
// on a successful hello handshake.
|
|
690
|
+
let _peerId: string | null = null;
|
|
691
|
+
let _protocol = 'claws/1';
|
|
692
|
+
const ctx: ConnCtx = {
|
|
693
|
+
socket,
|
|
694
|
+
getPeerId: () => _peerId,
|
|
695
|
+
setPeerId: (id) => { _peerId = id; },
|
|
696
|
+
getNegotiatedProtocol: () => _protocol,
|
|
697
|
+
setNegotiatedProtocol: (p) => { _protocol = p; },
|
|
698
|
+
};
|
|
699
|
+
socket.on('data', (data) => {
|
|
700
|
+
buf += data.toString('utf8');
|
|
701
|
+
if (buf.length > MAX_LINE_BYTES) {
|
|
702
|
+
try {
|
|
703
|
+
socket.write(this.encode(undefined, { ok: false, error: 'request too large' }) + '\n');
|
|
704
|
+
} catch { /* ignore */ }
|
|
705
|
+
this.opts.logger(`[socket] closing — line buffer exceeded ${MAX_LINE_BYTES} bytes`);
|
|
706
|
+
try { socket.destroy(); } catch { /* ignore */ }
|
|
707
|
+
buf = '';
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
let idx: number;
|
|
711
|
+
while ((idx = buf.indexOf('\n')) !== -1) {
|
|
712
|
+
const line = buf.slice(0, idx);
|
|
713
|
+
buf = buf.slice(idx + 1);
|
|
714
|
+
if (!line.trim()) continue;
|
|
715
|
+
let req: ClawsRequest;
|
|
716
|
+
try {
|
|
717
|
+
req = JSON.parse(line);
|
|
718
|
+
} catch {
|
|
719
|
+
socket.write(this.encode(undefined, { ok: false, error: 'bad json' }) + '\n');
|
|
720
|
+
continue;
|
|
721
|
+
}
|
|
722
|
+
// Protocol tag check. Absent = claws/1. Both v1 and v2 are accepted
|
|
723
|
+
// on the wire; v2-only commands (hello/ping/publish/etc) enforce
|
|
724
|
+
// stricter protocol requirements inside their individual handlers.
|
|
725
|
+
const SUPPORTED_PROTOCOLS = ['claws/1', 'claws/2'];
|
|
726
|
+
if (req.protocol && !SUPPORTED_PROTOCOLS.includes(req.protocol)) {
|
|
727
|
+
socket.write(this.encode(req.id, { ok: false, error: 'incompatible protocol version' }) + '\n');
|
|
728
|
+
continue;
|
|
729
|
+
}
|
|
730
|
+
// Client-version drift detection — warn once per version per run.
|
|
731
|
+
const asAny = req as ClawsRequest & { clientVersion?: string; clientName?: string };
|
|
732
|
+
if (asAny.clientVersion) this.maybeWarnClientVersion(asAny.clientVersion, asAny.clientName);
|
|
733
|
+
this.handle(req, ctx).then((resp) => {
|
|
734
|
+
socket.write(this.encode(req.id, resp) + '\n');
|
|
735
|
+
}).catch((err) => {
|
|
736
|
+
socket.write(this.encode(req.id, {
|
|
737
|
+
ok: false,
|
|
738
|
+
error: String((err && err.message) || err),
|
|
739
|
+
}) + '\n');
|
|
740
|
+
});
|
|
741
|
+
}
|
|
742
|
+
});
|
|
743
|
+
socket.on('error', (err) => this.opts.logger(`[socket error] ${err}`));
|
|
744
|
+
socket.on('close', () => this.handleDisconnect(socket));
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/**
|
|
748
|
+
* Tear down all claws/2 bookkeeping for a socket that has closed. Plain
|
|
749
|
+
* claws/1 connections were never registered and are a no-op. Removes the
|
|
750
|
+
* peer from `peers` and prunes the subscription index.
|
|
751
|
+
*/
|
|
752
|
+
private handleDisconnect(socket: net.Socket): void {
|
|
753
|
+
const peerId = this.socketToPeer.get(socket);
|
|
754
|
+
if (!peerId) return;
|
|
755
|
+
const peer = this.peers.get(peerId);
|
|
756
|
+
this.peers.delete(peerId);
|
|
757
|
+
if (peer) {
|
|
758
|
+
// For fingerprinted peers, save a tombstone so subscriptions and tasks
|
|
759
|
+
// can be restored on reconnect. For non-fingerprinted peers, clean up
|
|
760
|
+
// the subscription index immediately.
|
|
761
|
+
if (peer.fingerprint) {
|
|
762
|
+
this.disconnectedPeers.set(peer.fingerprint, {
|
|
763
|
+
peerId: peer.peerId,
|
|
764
|
+
fingerprint: peer.fingerprint,
|
|
765
|
+
role: peer.role,
|
|
766
|
+
peerName: peer.peerName,
|
|
767
|
+
capabilities: peer.capabilities,
|
|
768
|
+
subscriptions: new Map(peer.subscriptions),
|
|
769
|
+
disconnectedAt: Date.now(),
|
|
770
|
+
});
|
|
771
|
+
// Remove from subscriptionIndex but keep tasks alive — they can be
|
|
772
|
+
// re-bound when the peer reconnects with the same nonce.
|
|
773
|
+
for (const pattern of peer.subscriptions.values()) {
|
|
774
|
+
const set = this.subscriptionIndex.get(pattern);
|
|
775
|
+
if (set) { set.delete(peerId); if (set.size === 0) this.subscriptionIndex.delete(pattern); }
|
|
776
|
+
}
|
|
777
|
+
} else {
|
|
778
|
+
for (const pattern of peer.subscriptions.values()) {
|
|
779
|
+
const set = this.subscriptionIndex.get(pattern);
|
|
780
|
+
if (set) { set.delete(peerId); if (set.size === 0) this.subscriptionIndex.delete(pattern); }
|
|
781
|
+
}
|
|
782
|
+
// Fail tasks only for non-fingerprinted peers. Fingerprinted peers
|
|
783
|
+
// may reconnect and continue their tasks.
|
|
784
|
+
const now = Date.now();
|
|
785
|
+
for (const task of this.tasks.values()) {
|
|
786
|
+
if (task.assignee === peerId && ['pending', 'running', 'blocked'].includes(task.status)) {
|
|
787
|
+
task.status = 'failed';
|
|
788
|
+
task.note = 'assignee disconnected';
|
|
789
|
+
task.updatedAt = now;
|
|
790
|
+
this.emitServerEvent('task.completed', {
|
|
791
|
+
taskId: task.taskId, status: 'failed', result: null,
|
|
792
|
+
}).catch(() => { /* best-effort — never block disconnect */ });
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
// Bug-6 Layer 2: remove any armedCorrelations claim held by this peer.
|
|
798
|
+
this.peerRegistry.removeMonitorClaim(peerId);
|
|
799
|
+
this.peerRegistry.notifyUnregister(peerId, 'socket-close');
|
|
800
|
+
// Notify wave registry so it can cancel violation timers for this peer.
|
|
801
|
+
if (peerId) this.waveRegistry.handlePeerDisconnect(peerId);
|
|
802
|
+
// Clean up backpressure state for the disconnected peer.
|
|
803
|
+
this.pausedPeers.delete(peerId);
|
|
804
|
+
this.pendingFrames.delete(peerId);
|
|
805
|
+
this.droppedFrames.delete(peerId);
|
|
806
|
+
// Close may fire after extension deactivate has torn down the output
|
|
807
|
+
// channel; guard the logger so a teardown log line never crashes node.
|
|
808
|
+
try { this.opts.logger(`[claws/2] peer disconnected: ${peerId}`); } catch { /* ignore */ }
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
// Emit a response frame. Includes `id` (legacy correlation key), `rid`
|
|
812
|
+
// (guaranteed-unshadowed request id), and `protocol` tag. `rid` is
|
|
813
|
+
// forced at the end so body cannot shadow it. `protocol` defaults to
|
|
814
|
+
// claws/1 but body may override (e.g. the `hello` handler tags its
|
|
815
|
+
// reply with claws/2 so the client can confirm negotiation).
|
|
816
|
+
private encode(reqId: number | string | undefined, body: ClawsResponse | Record<string, unknown>): string {
|
|
817
|
+
return JSON.stringify({
|
|
818
|
+
id: reqId,
|
|
819
|
+
protocol: PROTOCOL_VERSION,
|
|
820
|
+
...body,
|
|
821
|
+
rid: reqId,
|
|
822
|
+
});
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/**
|
|
826
|
+
* Sends an unsolicited push frame to a peer socket.
|
|
827
|
+
* Push frames intentionally omit `rid` so clients can distinguish them
|
|
828
|
+
* from responses (a frame with `rid` is a response; without is a push).
|
|
829
|
+
*/
|
|
830
|
+
private pushFrame(
|
|
831
|
+
socket: net.Socket, topic: string, from: string, payload: unknown, sequence?: number,
|
|
832
|
+
): void {
|
|
833
|
+
const targetPeerId = this.socketToPeer.get(socket);
|
|
834
|
+
const frame = JSON.stringify({
|
|
835
|
+
push: 'message',
|
|
836
|
+
protocol: PROTOCOL_VERSION_V2,
|
|
837
|
+
topic,
|
|
838
|
+
from,
|
|
839
|
+
payload,
|
|
840
|
+
sentAt: Date.now(),
|
|
841
|
+
...(sequence !== undefined ? { sequence } : {}),
|
|
842
|
+
}) + '\n';
|
|
843
|
+
|
|
844
|
+
// BUG-21 fix: queue frames during backpressure instead of dropping them.
|
|
845
|
+
// wave.*.complete and other one-shot signals must not be silently lost.
|
|
846
|
+
if (targetPeerId && this.pausedPeers.has(targetPeerId)) {
|
|
847
|
+
const queue = this.pendingFrames.get(targetPeerId) ?? [];
|
|
848
|
+
if (queue.length < ClawsServer.MAX_PENDING_FRAMES) {
|
|
849
|
+
queue.push(frame);
|
|
850
|
+
this.pendingFrames.set(targetPeerId, queue);
|
|
851
|
+
} else {
|
|
852
|
+
this.droppedFrames.set(targetPeerId, (this.droppedFrames.get(targetPeerId) ?? 0) + 1);
|
|
853
|
+
}
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
try {
|
|
858
|
+
const drained = socket.write(frame);
|
|
859
|
+
if (!drained && targetPeerId && !this.pausedPeers.has(targetPeerId)) {
|
|
860
|
+
this.pausedPeers.add(targetPeerId);
|
|
861
|
+
this.opts.logger(`[claws/2] backpressure on push to ${targetPeerId}; pausing`);
|
|
862
|
+
socket.once('drain', () => {
|
|
863
|
+
this.pausedPeers.delete(targetPeerId);
|
|
864
|
+
// Flush frames that arrived during the backpressure window.
|
|
865
|
+
const queued = this.pendingFrames.get(targetPeerId) ?? [];
|
|
866
|
+
this.pendingFrames.delete(targetPeerId);
|
|
867
|
+
for (const qf of queued) {
|
|
868
|
+
try { socket.write(qf); } catch { /* socket may have closed */ }
|
|
869
|
+
}
|
|
870
|
+
const dropped = this.droppedFrames.get(targetPeerId) ?? 0;
|
|
871
|
+
if (dropped > 0) {
|
|
872
|
+
if (dropped >= 100) {
|
|
873
|
+
this.opts.logger(`[claws/2] drain for ${targetPeerId}; ${dropped} frames dropped (queue full)`);
|
|
874
|
+
} else {
|
|
875
|
+
this.opts.logger(`[claws/2] drain for ${targetPeerId}; ${dropped} frames dropped`);
|
|
876
|
+
}
|
|
877
|
+
this.droppedFrames.delete(targetPeerId);
|
|
878
|
+
}
|
|
879
|
+
if (queued.length > 0) {
|
|
880
|
+
this.opts.logger(`[claws/2] drain for ${targetPeerId}; flushed ${queued.length} queued frames`);
|
|
881
|
+
}
|
|
882
|
+
});
|
|
883
|
+
}
|
|
884
|
+
} catch (err) {
|
|
885
|
+
this.opts.logger(`[claws/2] push write failed for ${from}: ${err}`);
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/**
|
|
890
|
+
* Delivers a published message to all peers subscribed to a matching pattern.
|
|
891
|
+
* Returns the count of peers that received the message.
|
|
892
|
+
*/
|
|
893
|
+
private fanOut(
|
|
894
|
+
topic: string, from: string, payload: unknown, echo: boolean, sequence?: number,
|
|
895
|
+
): number {
|
|
896
|
+
let count = 0;
|
|
897
|
+
for (const [pattern, peerIds] of this.subscriptionIndex) {
|
|
898
|
+
if (!matchTopic(topic, pattern)) continue;
|
|
899
|
+
for (const peerId of peerIds) {
|
|
900
|
+
if (!echo && peerId === from) continue;
|
|
901
|
+
const peer = this.peers.get(peerId);
|
|
902
|
+
if (!peer) continue;
|
|
903
|
+
this.pushFrame(peer.socket, topic, from, payload, sequence);
|
|
904
|
+
count++;
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
return count;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
/**
|
|
911
|
+
* Durably append a server-originated event to the event log, then fan it out
|
|
912
|
+
* to subscribers. Mirrors the publish handler's persist-then-fanout contract
|
|
913
|
+
* for events the server emits on its own behalf (task.*, system.malformed.*).
|
|
914
|
+
*
|
|
915
|
+
* Degraded mode (sequence === -1): skips sequence in the push frame.
|
|
916
|
+
* Real I/O error: falls back to fanOut without sequence so delivery still happens.
|
|
917
|
+
*/
|
|
918
|
+
private async emitServerEvent(topic: string, payload: unknown): Promise<void> {
|
|
919
|
+
let sequence: number | undefined;
|
|
920
|
+
try {
|
|
921
|
+
const logResult = await this.eventLog.append({
|
|
922
|
+
topic,
|
|
923
|
+
from: 'server',
|
|
924
|
+
ts_server: new Date().toISOString(),
|
|
925
|
+
payload,
|
|
926
|
+
});
|
|
927
|
+
sequence = logResult.sequence >= 0 ? logResult.sequence : undefined;
|
|
928
|
+
} catch {
|
|
929
|
+
// Real I/O error — fall through with no sequence so fan-out still fires.
|
|
930
|
+
}
|
|
931
|
+
this.fanOut(topic, 'server', payload, false, sequence);
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
private async replayFromCursor(
|
|
935
|
+
cursor: string,
|
|
936
|
+
topicPattern: string,
|
|
937
|
+
subId: string,
|
|
938
|
+
socket: net.Socket,
|
|
939
|
+
): Promise<void> {
|
|
940
|
+
const reader = new EventLogReader(this.opts.workspaceRoot);
|
|
941
|
+
let count = 0;
|
|
942
|
+
try {
|
|
943
|
+
for await (const record of reader.scanFrom(cursor, topicPattern)) {
|
|
944
|
+
if (socket.destroyed) return;
|
|
945
|
+
const frame = JSON.stringify({
|
|
946
|
+
push: 'message',
|
|
947
|
+
protocol: PROTOCOL_VERSION_V2,
|
|
948
|
+
topic: record.topic,
|
|
949
|
+
from: record.from ?? 'server',
|
|
950
|
+
payload: record.payload,
|
|
951
|
+
sentAt: Date.now(),
|
|
952
|
+
replayed: true,
|
|
953
|
+
...(record.sequence !== undefined ? { sequence: record.sequence } : {}),
|
|
954
|
+
}) + '\n';
|
|
955
|
+
socket.write(frame);
|
|
956
|
+
count++;
|
|
957
|
+
}
|
|
958
|
+
} catch { /* I/O error during replay — fall through */ }
|
|
959
|
+
if (socket.destroyed) return;
|
|
960
|
+
socket.write(JSON.stringify({
|
|
961
|
+
push: 'caught-up',
|
|
962
|
+
protocol: PROTOCOL_VERSION_V2,
|
|
963
|
+
subscriptionId: subId,
|
|
964
|
+
replayedCount: count,
|
|
965
|
+
resumeCursor: this.eventLog.currentCursor(),
|
|
966
|
+
}) + '\n');
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
private getConfig() {
|
|
970
|
+
return this.opts.getConfig ? this.opts.getConfig() : defaultServerConfig;
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
/**
|
|
974
|
+
* Compare a reported client version against the current extension version
|
|
975
|
+
* (via the introspect provider) and log a one-shot warning on drift ≥ 1
|
|
976
|
+
* minor release. Exact match and unknown-extension-version are silent.
|
|
977
|
+
*/
|
|
978
|
+
private maybeWarnClientVersion(clientVersion: string, clientName?: string): void {
|
|
979
|
+
if (!this.opts.getIntrospect) return;
|
|
980
|
+
if (this.versionWarned.has(clientVersion)) return;
|
|
981
|
+
const extVersion = this.opts.getIntrospect().extensionVersion;
|
|
982
|
+
if (!extVersion || extVersion === '0.4.x') return;
|
|
983
|
+
if (clientVersion === extVersion) return;
|
|
984
|
+
const drift = compareMinorDrift(clientVersion, extVersion);
|
|
985
|
+
if (drift >= 1) {
|
|
986
|
+
this.versionWarned.add(clientVersion);
|
|
987
|
+
const who = clientName ? ` ${clientName}` : '';
|
|
988
|
+
this.opts.logger(
|
|
989
|
+
`[claws] MCP server${who} version ${clientVersion} < extension version ${extVersion} — consider /claws-update`,
|
|
990
|
+
);
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
/** True if a root (non-wave) orchestrator is already registered. */
|
|
995
|
+
private hasRootOrchestrator(): boolean {
|
|
996
|
+
// AE-1.2: a "root" orchestrator is one with no waveId AND no correlation_id.
|
|
997
|
+
// Wave-orchestrators (waveId) and nested orchestrators (correlation_id, e.g. a
|
|
998
|
+
// worker's child mcp_server.js that eager-hellos with the worker terminal's
|
|
999
|
+
// corr_id per AE-1) are NOT roots — they coexist with the user's primary
|
|
1000
|
+
// orchestrator. Without this exclusion, a stale nested orchestrator can
|
|
1001
|
+
// occupy the root slot forever, rejecting every subsequent root hello as
|
|
1002
|
+
// "root orchestrator already registered" and breaking event-driven boot.
|
|
1003
|
+
for (const p of this.peers.values()) {
|
|
1004
|
+
if (p.role === 'orchestrator' && !p.waveId && !p.correlationId) return true;
|
|
1005
|
+
}
|
|
1006
|
+
return false;
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
/** Allocate the next peerId for this server instance. */
|
|
1010
|
+
private allocPeerId(): string { return allocPeerId(++this.peerSeq); }
|
|
1011
|
+
|
|
1012
|
+
/**
|
|
1013
|
+
* Reject a request if the peer hasn't completed `hello` or is not in
|
|
1014
|
+
* one of the accepted roles. Returns null when the peer is allowed to
|
|
1015
|
+
* proceed, or a ready-to-send error response otherwise. Unused by the
|
|
1016
|
+
* W3 handshake but the v2 handlers that follow (publish, task dispatch)
|
|
1017
|
+
* will rely on this gate.
|
|
1018
|
+
*/
|
|
1019
|
+
private requireRole(ctx: ConnCtx, roles: ClawsRole[]): ClawsResponse | null {
|
|
1020
|
+
const pid = ctx.getPeerId();
|
|
1021
|
+
if (!pid) return { ok: false, error: 'call hello first' };
|
|
1022
|
+
const peer = this.peers.get(pid);
|
|
1023
|
+
if (!peer) return { ok: false, error: 'peer unknown' };
|
|
1024
|
+
if (!roles.includes(peer.role)) return { ok: false, error: `requires role: ${roles.join('|')}` };
|
|
1025
|
+
return null;
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
private async handle(req: ClawsRequest, ctx: ConnCtx): Promise<ClawsResponse> {
|
|
1029
|
+
const { cmd } = req;
|
|
1030
|
+
const backend = this.opts.backend;
|
|
1031
|
+
|
|
1032
|
+
if (cmd === 'list') {
|
|
1033
|
+
return { ok: true, terminals: await backend.listTerminals() };
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
if (cmd === 'create') {
|
|
1037
|
+
if (!this.lifecycleStore.hasPlan()) {
|
|
1038
|
+
return {
|
|
1039
|
+
ok: false,
|
|
1040
|
+
error: 'lifecycle:plan-required',
|
|
1041
|
+
message: '[LIFECYCLE GATE] No PLAN logged. Call mcp__claws__claws_lifecycle_plan first.',
|
|
1042
|
+
};
|
|
1043
|
+
}
|
|
1044
|
+
const r = req as ClawsRequest & {
|
|
1045
|
+
name?: string; cwd?: string; wrapped?: boolean; shellPath?: string;
|
|
1046
|
+
env?: Record<string, string>; show?: boolean; preserveFocus?: boolean;
|
|
1047
|
+
// BUG-09: explicit wave affiliation for dispatch_subworker path (fallback when peer has no waveId)
|
|
1048
|
+
waveId?: string; waveRole?: string;
|
|
1049
|
+
correlation_id?: string;
|
|
1050
|
+
};
|
|
1051
|
+
// Wave affiliation from the calling peer's stored waveId (registered via hello).
|
|
1052
|
+
const callerPeerId3 = ctx.getPeerId();
|
|
1053
|
+
const callerPeer3 = callerPeerId3 ? this.peers.get(callerPeerId3) : undefined;
|
|
1054
|
+
const callerWaveId = callerPeer3?.waveId ?? r.waveId;
|
|
1055
|
+
const callerRole = (callerPeer3?.subWorkerRole ?? r.waveRole) as SubWorkerRole | undefined;
|
|
1056
|
+
|
|
1057
|
+
// AC-1: accept correlation_id; validate it's a non-empty string if present.
|
|
1058
|
+
const corrIdForCreate = typeof r.correlation_id === 'string' && r.correlation_id.length > 0
|
|
1059
|
+
? r.correlation_id
|
|
1060
|
+
: undefined;
|
|
1061
|
+
|
|
1062
|
+
const { id, logPath } = await backend.createTerminal({
|
|
1063
|
+
name: r.name,
|
|
1064
|
+
cwd: r.cwd,
|
|
1065
|
+
wrapped: r.wrapped,
|
|
1066
|
+
shellPath: r.shellPath,
|
|
1067
|
+
env: r.env,
|
|
1068
|
+
correlationId: corrIdForCreate,
|
|
1069
|
+
});
|
|
1070
|
+
if (callerWaveId) this.waveRegistry.trackTerminal(callerWaveId, String(id), callerRole);
|
|
1071
|
+
return { ok: true, id, logPath: logPath ?? null, wrapped: r.wrapped === true };
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
if (cmd === 'show') {
|
|
1075
|
+
const r = req as ClawsRequest & { id: string | number; preserveFocus?: boolean };
|
|
1076
|
+
await backend.focusTerminal?.(String(r.id));
|
|
1077
|
+
return { ok: true };
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
if (cmd === 'send') {
|
|
1081
|
+
const r = req as ClawsRequest & {
|
|
1082
|
+
id: string | number; text?: string; newline?: boolean;
|
|
1083
|
+
show?: boolean; paste?: boolean;
|
|
1084
|
+
};
|
|
1085
|
+
const id = String(r.id);
|
|
1086
|
+
if (r.show !== false) await backend.focusTerminal?.(id);
|
|
1087
|
+
const text = r.text ?? '';
|
|
1088
|
+
const newline = r.newline !== false;
|
|
1089
|
+
const preTotalSize = this.opts.captureStore.read(id, 0, 0, false).totalSize;
|
|
1090
|
+
await backend.sendText(id, text, { newline, paste: r.paste === true });
|
|
1091
|
+
if (r.paste === true && text.length > 0) {
|
|
1092
|
+
const targetSize = preTotalSize + text.length;
|
|
1093
|
+
let ticks = 0;
|
|
1094
|
+
const watcher = setInterval(() => {
|
|
1095
|
+
ticks++;
|
|
1096
|
+
const cur = this.opts.captureStore.read(id, 0, 0, false).totalSize;
|
|
1097
|
+
if (cur >= targetSize || ticks >= 20) {
|
|
1098
|
+
clearInterval(watcher);
|
|
1099
|
+
if (cur >= targetSize) {
|
|
1100
|
+
void this.emitSystemEvent('system.terminal.paste_complete', {
|
|
1101
|
+
terminalId: id, totalSize: cur, ts: new Date().toISOString(),
|
|
1102
|
+
});
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
}, 100);
|
|
1106
|
+
}
|
|
1107
|
+
return { ok: true, mode: 'wrapped' };
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
if (cmd === 'exec') {
|
|
1111
|
+
const r = req as ClawsRequest & {
|
|
1112
|
+
id: string | number; command: string; timeoutMs?: number; show?: boolean;
|
|
1113
|
+
};
|
|
1114
|
+
const id = String(r.id);
|
|
1115
|
+
if (r.show !== false) await backend.focusTerminal?.(id);
|
|
1116
|
+
const startedAt = new Date().toISOString();
|
|
1117
|
+
void this.emitSystemEvent(`command.${id}.start`, {
|
|
1118
|
+
terminalId: id,
|
|
1119
|
+
command: r.command,
|
|
1120
|
+
startedAt,
|
|
1121
|
+
});
|
|
1122
|
+
if (!backend.execCommand) {
|
|
1123
|
+
// Backend has no exec support — fall back to sendText (degraded, no output capture).
|
|
1124
|
+
await backend.sendText(id, r.command, { newline: true, paste: false });
|
|
1125
|
+
void this.emitSystemEvent(`command.${id}.end`, {
|
|
1126
|
+
terminalId: id,
|
|
1127
|
+
command: r.command,
|
|
1128
|
+
exitCode: null,
|
|
1129
|
+
durationMs: 0,
|
|
1130
|
+
degraded: true,
|
|
1131
|
+
endedAt: new Date().toISOString(),
|
|
1132
|
+
});
|
|
1133
|
+
return {
|
|
1134
|
+
ok: true,
|
|
1135
|
+
degraded: true,
|
|
1136
|
+
note: 'backend has no execCommand; command sent via sendText — use readLog on wrapped terminals',
|
|
1137
|
+
};
|
|
1138
|
+
}
|
|
1139
|
+
const timeoutMs = r.timeoutMs || this.getConfig().execTimeoutMs;
|
|
1140
|
+
const { exitCode } = await backend.execCommand(id, r.command, timeoutMs);
|
|
1141
|
+
void this.emitSystemEvent(`command.${id}.end`, {
|
|
1142
|
+
terminalId: id,
|
|
1143
|
+
command: r.command,
|
|
1144
|
+
exitCode,
|
|
1145
|
+
durationMs: Date.now() - new Date(startedAt).getTime(),
|
|
1146
|
+
endedAt: new Date().toISOString(),
|
|
1147
|
+
});
|
|
1148
|
+
return { ok: true, exitCode };
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
if (cmd === 'read') {
|
|
1152
|
+
const r = req as ClawsRequest & { id?: string | number; since?: number; limit?: number };
|
|
1153
|
+
const sinceSeq = r.since ?? 0;
|
|
1154
|
+
const limit = r.limit ?? 50;
|
|
1155
|
+
const filtered = this.opts.history.filter((ev) => {
|
|
1156
|
+
if (ev.seq <= sinceSeq) return false;
|
|
1157
|
+
if (r.id != null && ev.terminalId !== String(r.id)) return false;
|
|
1158
|
+
return true;
|
|
1159
|
+
});
|
|
1160
|
+
const slice = filtered.slice(-limit);
|
|
1161
|
+
return {
|
|
1162
|
+
ok: true,
|
|
1163
|
+
events: slice,
|
|
1164
|
+
cursor: slice.length ? slice[slice.length - 1].seq : sinceSeq,
|
|
1165
|
+
};
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
if (cmd === 'poll') {
|
|
1169
|
+
const r = req as ClawsRequest & { since?: number; limit?: number };
|
|
1170
|
+
const sinceSeq = r.since ?? 0;
|
|
1171
|
+
const all = this.opts.history.filter((ev) => ev.seq > sinceSeq);
|
|
1172
|
+
const configLimit = this.getConfig().pollLimit;
|
|
1173
|
+
// Client-requested limit is an upper bound only — it cannot exceed
|
|
1174
|
+
// the server's configured max, which exists so a buggy client asking
|
|
1175
|
+
// for limit:1e9 doesn't blow up the JSON serialiser.
|
|
1176
|
+
const limit = r.limit != null ? Math.min(r.limit, configLimit) : configLimit;
|
|
1177
|
+
const truncated = all.length > limit;
|
|
1178
|
+
const events = truncated ? all.slice(-limit) : all;
|
|
1179
|
+
return {
|
|
1180
|
+
ok: true,
|
|
1181
|
+
events,
|
|
1182
|
+
cursor: events.length ? events[events.length - 1].seq : sinceSeq,
|
|
1183
|
+
truncated,
|
|
1184
|
+
limit,
|
|
1185
|
+
};
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
if (cmd === 'close') {
|
|
1189
|
+
const r = req as ClawsRequest & { id: string | number; close_origin?: string };
|
|
1190
|
+
// Use caller-supplied close_origin so semantic accuracy flows through
|
|
1191
|
+
// (e.g. mcp_server.js watchers pass 'marker'/'error'/'timeout').
|
|
1192
|
+
const closeOrigin = (['marker','error','timeout','orchestrator','user','pub_complete','wave_violation','idle_timeout','ttl_max'] as const)
|
|
1193
|
+
.find(o => o === r.close_origin) ?? 'orchestrator';
|
|
1194
|
+
const idStr = String(r.id);
|
|
1195
|
+
// Check liveness before closing — used to return alreadyClosed for idempotency semantics.
|
|
1196
|
+
const extBackendForClose = backend as ExtendedBackend;
|
|
1197
|
+
const liveIdsForClose = extBackendForClose.liveTerminalIds?.() ?? new Set<string>();
|
|
1198
|
+
const wasAlive = liveIdsForClose.has(idStr);
|
|
1199
|
+
// LH-9 1B: mark lifecycle closed BEFORE attempting backend.closeTerminal so that an
|
|
1200
|
+
// already-gone terminal still produces a healed state record.
|
|
1201
|
+
try {
|
|
1202
|
+
const updated = this.lifecycleStore.markWorkerStatus(idStr, 'closed');
|
|
1203
|
+
if (updated) this.lifecycleEngine.onWorkerEvent('claws-close:' + idStr);
|
|
1204
|
+
} catch (_e) { /* non-fatal */ }
|
|
1205
|
+
await backend.closeTerminal(idStr, closeOrigin);
|
|
1206
|
+
return { ok: true, alreadyClosed: !wasAlive };
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
if (cmd === 'reload_window') {
|
|
1210
|
+
this.opts.reloadWindow?.();
|
|
1211
|
+
return { ok: true };
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
if (cmd === 'readLog') {
|
|
1215
|
+
const r = req as ClawsRequest & {
|
|
1216
|
+
id: string | number; offset?: number; limit?: number; strip?: boolean;
|
|
1217
|
+
};
|
|
1218
|
+
const idStr = String(r.id);
|
|
1219
|
+
const strip = r.strip !== false;
|
|
1220
|
+
const limit = Math.min(r.limit || MAX_READLOG_BYTES, MAX_READLOG_BYTES);
|
|
1221
|
+
try {
|
|
1222
|
+
const slice = await backend.readLog(idStr, r.offset, limit, strip);
|
|
1223
|
+
return {
|
|
1224
|
+
ok: true,
|
|
1225
|
+
bytes: slice.bytes,
|
|
1226
|
+
offset: slice.offset,
|
|
1227
|
+
nextOffset: slice.nextOffset,
|
|
1228
|
+
totalSize: slice.totalSize,
|
|
1229
|
+
truncated: slice.truncated,
|
|
1230
|
+
logPath: null,
|
|
1231
|
+
};
|
|
1232
|
+
} catch (err) {
|
|
1233
|
+
return { ok: false, error: `readLog failed: ${(err as Error).message}` };
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
if (cmd === 'introspect') {
|
|
1238
|
+
const snap = this.opts.getIntrospect ? this.opts.getIntrospect() : null;
|
|
1239
|
+
return {
|
|
1240
|
+
ok: true,
|
|
1241
|
+
protocol: PROTOCOL_VERSION,
|
|
1242
|
+
extensionVersion: snap?.extensionVersion ?? 'unknown',
|
|
1243
|
+
nodeVersion: process.version,
|
|
1244
|
+
electronAbi: Number(process.versions.modules),
|
|
1245
|
+
platform: `${process.platform}-${process.arch}`,
|
|
1246
|
+
nodePty: snap?.nodePty ?? { loaded: false },
|
|
1247
|
+
servers: snap?.servers ?? [{ workspace: this.opts.workspaceRoot, socket: this.socketPath }],
|
|
1248
|
+
terminals: snap?.terminals ?? 0,
|
|
1249
|
+
uptime_ms: this.uptimeMs(),
|
|
1250
|
+
};
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
if (cmd === 'hello') {
|
|
1254
|
+
const r = req as import('./protocol').HelloRequest;
|
|
1255
|
+
if (r.protocol !== 'claws/2') return { ok: false, error: 'hello requires protocol: claws/2' };
|
|
1256
|
+
|
|
1257
|
+
// L18 AUTH — validate token before any other checks.
|
|
1258
|
+
const authErr = this.validateAuthToken(r);
|
|
1259
|
+
if (authErr) return { ok: false, error: authErr };
|
|
1260
|
+
|
|
1261
|
+
// BUG-03: idempotent hello — same socket re-registering updates capabilities and re-uses peerId
|
|
1262
|
+
const existingPeerIdForSocket = ctx.getPeerId();
|
|
1263
|
+
if (existingPeerIdForSocket) {
|
|
1264
|
+
const existingPeer = this.peers.get(existingPeerIdForSocket);
|
|
1265
|
+
if (existingPeer) {
|
|
1266
|
+
if (r.capabilities !== undefined) {
|
|
1267
|
+
const caps = new Set<string>(r.capabilities);
|
|
1268
|
+
caps.add('push'); // T2/Q6: auto-grant — idempotent hello still ensures push
|
|
1269
|
+
existingPeer.capabilities = Array.from(caps);
|
|
1270
|
+
}
|
|
1271
|
+
if (r.waveId !== undefined) existingPeer.waveId = r.waveId;
|
|
1272
|
+
if (r.subWorkerRole !== undefined) existingPeer.subWorkerRole = r.subWorkerRole;
|
|
1273
|
+
existingPeer.lastSeen = Date.now();
|
|
1274
|
+
return {
|
|
1275
|
+
ok: true, peerId: existingPeerIdForSocket, protocol: PROTOCOL_VERSION_V2,
|
|
1276
|
+
serverCapabilities: ['push', 'broadcast', 'tasks'],
|
|
1277
|
+
rootOrchestratorPresent: this.hasRootOrchestrator(), restored: false, idempotent: true,
|
|
1278
|
+
};
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
// AE-1: exempt orchestrators carrying a correlation_id from the
|
|
1283
|
+
// rootOrchestrator gate. They are nested-by-terminal — a worker's child
|
|
1284
|
+
// mcp_server.js eager-hellos with the worker terminal's corr_id (see
|
|
1285
|
+
// mcp_server.js main() block guarded on process.env.CLAWS_TERMINAL_CORR_ID).
|
|
1286
|
+
// Without this exemption every such hello is rejected as a second root,
|
|
1287
|
+
// the corr_id is never stored, and system.peer.connected never fires —
|
|
1288
|
+
// breaking the event-driven boot detection chain.
|
|
1289
|
+
if (r.role === 'orchestrator'
|
|
1290
|
+
&& !r.waveId
|
|
1291
|
+
&& !(typeof r.correlation_id === 'string' && r.correlation_id.length > 0)
|
|
1292
|
+
&& this.hasRootOrchestrator()) {
|
|
1293
|
+
return { ok: false, error: 'root orchestrator already registered' };
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
// AC-1: validate correlation_id uniqueness — reject if already registered to a live peer.
|
|
1297
|
+
const corrIdForHello = typeof r.correlation_id === 'string' && r.correlation_id.length > 0
|
|
1298
|
+
? r.correlation_id
|
|
1299
|
+
: undefined;
|
|
1300
|
+
if (corrIdForHello) {
|
|
1301
|
+
for (const existingPeer of this.peers.values()) {
|
|
1302
|
+
if (existingPeer.correlationId === corrIdForHello) {
|
|
1303
|
+
return { ok: false, error: `correlation_id ${corrIdForHello} already registered to peer ${existingPeer.peerId}` };
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
// Compute stable fingerprint when instanceNonce is provided.
|
|
1309
|
+
const fingerprint = r.instanceNonce
|
|
1310
|
+
? fingerprintPeer(r.peerName ?? 'unnamed', r.role, r.instanceNonce)
|
|
1311
|
+
: undefined;
|
|
1312
|
+
const peerId = fingerprint ? `fp_${fingerprint}` : this.allocPeerId();
|
|
1313
|
+
// T2/Q6: auto-grant push to every registered peer — publish is a core capability.
|
|
1314
|
+
const capSet = new Set<string>(r.capabilities ?? []);
|
|
1315
|
+
capSet.add('push');
|
|
1316
|
+
const capabilities = Array.from(capSet);
|
|
1317
|
+
|
|
1318
|
+
// Check for a disconnected peer with the same fingerprint.
|
|
1319
|
+
const tombstone = fingerprint ? this.disconnectedPeers.get(fingerprint) : undefined;
|
|
1320
|
+
if (tombstone) this.disconnectedPeers.delete(fingerprint!);
|
|
1321
|
+
|
|
1322
|
+
// Subscriptions: restore from tombstone or start fresh.
|
|
1323
|
+
const subscriptions: Map<string, string> = tombstone
|
|
1324
|
+
? new Map(tombstone.subscriptions)
|
|
1325
|
+
: new Map();
|
|
1326
|
+
|
|
1327
|
+
const peer: PeerConnection = {
|
|
1328
|
+
peerId,
|
|
1329
|
+
role: r.role as ClawsRole,
|
|
1330
|
+
peerName: r.peerName ?? 'unnamed',
|
|
1331
|
+
terminalId: r.terminalId,
|
|
1332
|
+
capabilities,
|
|
1333
|
+
socket: ctx.socket,
|
|
1334
|
+
waveId: r.waveId,
|
|
1335
|
+
subWorkerRole: r.subWorkerRole,
|
|
1336
|
+
subscriptions,
|
|
1337
|
+
lastSeen: Date.now(),
|
|
1338
|
+
connectedAt: Date.now(),
|
|
1339
|
+
fingerprint,
|
|
1340
|
+
correlationId: corrIdForHello,
|
|
1341
|
+
};
|
|
1342
|
+
this.peers.set(peerId, peer);
|
|
1343
|
+
this.socketToPeer.set(ctx.socket, peerId);
|
|
1344
|
+
ctx.setPeerId(peerId);
|
|
1345
|
+
ctx.setNegotiatedProtocol('claws/2');
|
|
1346
|
+
this.peerRegistry.notifyRegister(peerId, peer.role, { fingerprint, monitorCorrelationId: r.monitorCorrelationId });
|
|
1347
|
+
|
|
1348
|
+
// Re-add restored subscriptions to the subscription index.
|
|
1349
|
+
if (tombstone) {
|
|
1350
|
+
for (const pattern of peer.subscriptions.values()) {
|
|
1351
|
+
if (!this.subscriptionIndex.has(pattern)) this.subscriptionIndex.set(pattern, new Set());
|
|
1352
|
+
this.subscriptionIndex.get(pattern)!.add(peerId);
|
|
1353
|
+
}
|
|
1354
|
+
// Re-bind any tasks that were assigned to this peerId while disconnected.
|
|
1355
|
+
for (const task of this.tasks.values()) {
|
|
1356
|
+
if (task.assignee === peerId && ['pending', 'running', 'blocked'].includes(task.status)) {
|
|
1357
|
+
task.updatedAt = Date.now();
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
this.opts.logger(`[claws/2] peer reconnected (restored): ${peerId} name=${peer.peerName} subs=${peer.subscriptions.size}`);
|
|
1361
|
+
} else {
|
|
1362
|
+
this.opts.logger(`[claws/2] peer registered: ${peerId} role=${peer.role} name=${peer.peerName}`);
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
// Auto-subscribe workers to their cmd channel (skip if already restored).
|
|
1366
|
+
if (peer.role === 'worker') {
|
|
1367
|
+
const cmdTopic = `cmd.${peerId}.**`;
|
|
1368
|
+
const alreadySubscribed = Array.from(peer.subscriptions.values()).includes(cmdTopic);
|
|
1369
|
+
if (!alreadySubscribed) {
|
|
1370
|
+
const subId = `s_${(++this.subSeq).toString(16).padStart(4, '0')}`;
|
|
1371
|
+
peer.subscriptions.set(subId, cmdTopic);
|
|
1372
|
+
if (!this.subscriptionIndex.has(cmdTopic)) this.subscriptionIndex.set(cmdTopic, new Set());
|
|
1373
|
+
this.subscriptionIndex.get(cmdTopic)!.add(peerId);
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
// If this peer is a wave sub-worker, record its heartbeat.
|
|
1377
|
+
if (r.waveId && r.subWorkerRole) {
|
|
1378
|
+
this.waveRegistry.recordHeartbeat(r.waveId, r.subWorkerRole as SubWorkerRole, peerId);
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
// Bug-6 Layer 2: if the peer declares a monitorCorrelationId, record the claim.
|
|
1382
|
+
if (r.monitorCorrelationId) {
|
|
1383
|
+
this.peerRegistry.recordMonitorClaim(peerId, r.monitorCorrelationId);
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
// AC-1: publish system.peer.connected when correlation_id is present.
|
|
1387
|
+
// Enables event-driven boot detection in mcp_server.js (Wave AC-2).
|
|
1388
|
+
if (corrIdForHello) {
|
|
1389
|
+
void this.emitSystemEvent('system.peer.connected', {
|
|
1390
|
+
peer_id: peerId,
|
|
1391
|
+
correlation_id: corrIdForHello,
|
|
1392
|
+
peer_name: peer.peerName,
|
|
1393
|
+
role: peer.role,
|
|
1394
|
+
ts: new Date().toISOString(),
|
|
1395
|
+
});
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
return {
|
|
1399
|
+
ok: true,
|
|
1400
|
+
peerId,
|
|
1401
|
+
protocol: PROTOCOL_VERSION_V2,
|
|
1402
|
+
serverCapabilities: ['push', 'broadcast', 'tasks'],
|
|
1403
|
+
rootOrchestratorPresent: this.hasRootOrchestrator(),
|
|
1404
|
+
restored: tombstone !== undefined,
|
|
1405
|
+
};
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
if (cmd === 'ping') {
|
|
1409
|
+
return { ok: true, serverTime: Date.now() };
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
if (cmd === 'subscribe') {
|
|
1413
|
+
const denied = this.requireRole(ctx, ['orchestrator', 'worker', 'observer']);
|
|
1414
|
+
if (denied) return denied;
|
|
1415
|
+
const r = req as import('./protocol').SubscribeRequest;
|
|
1416
|
+
if (!r.topic || typeof r.topic !== 'string') return { ok: false, error: 'topic required' };
|
|
1417
|
+
if (r.fromCursor !== undefined && parseCursor(r.fromCursor) === null) {
|
|
1418
|
+
return { ok: false, error: 'invalid cursor format' };
|
|
1419
|
+
}
|
|
1420
|
+
const peerId = ctx.getPeerId()!;
|
|
1421
|
+
const peer = this.peers.get(peerId)!;
|
|
1422
|
+
const subId = `s_${(++this.subSeq).toString(16).padStart(4, '0')}`;
|
|
1423
|
+
peer.subscriptions.set(subId, r.topic);
|
|
1424
|
+
if (!this.subscriptionIndex.has(r.topic)) this.subscriptionIndex.set(r.topic, new Set());
|
|
1425
|
+
this.subscriptionIndex.get(r.topic)!.add(peerId);
|
|
1426
|
+
if (r.fromCursor) {
|
|
1427
|
+
const cursor = r.fromCursor;
|
|
1428
|
+
const topicPattern = r.topic;
|
|
1429
|
+
const socket = ctx.socket;
|
|
1430
|
+
setImmediate(() => { void this.replayFromCursor(cursor, topicPattern, subId, socket); });
|
|
1431
|
+
}
|
|
1432
|
+
// Return the current event-log cursor so callers can detect what they may
|
|
1433
|
+
// have missed before this subscription was established (BUG-21 mitigation).
|
|
1434
|
+
return { ok: true, subscriptionId: subId, resumeCursor: this.eventLog.currentCursor() };
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
if (cmd === 'unsubscribe') {
|
|
1438
|
+
const denied = this.requireRole(ctx, ['orchestrator', 'worker', 'observer']);
|
|
1439
|
+
if (denied) return denied;
|
|
1440
|
+
const r = req as import('./protocol').UnsubscribeRequest;
|
|
1441
|
+
const peerId = ctx.getPeerId()!;
|
|
1442
|
+
const peer = this.peers.get(peerId)!;
|
|
1443
|
+
const pattern = peer.subscriptions.get(r.subscriptionId);
|
|
1444
|
+
if (!pattern) return { ok: false, error: 'subscription not found' };
|
|
1445
|
+
peer.subscriptions.delete(r.subscriptionId);
|
|
1446
|
+
const set = this.subscriptionIndex.get(pattern);
|
|
1447
|
+
if (set) { set.delete(peerId); if (set.size === 0) this.subscriptionIndex.delete(pattern); }
|
|
1448
|
+
return { ok: true };
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
if (cmd === 'publish') {
|
|
1452
|
+
const denied = this.requireRole(ctx, ['orchestrator', 'worker', 'observer']);
|
|
1453
|
+
if (denied) return denied;
|
|
1454
|
+
// BUG-03: removed requireCapability('publish') — roles already gate access; undocumented cap check blocked SDK-less workers
|
|
1455
|
+
const r = req as import('./protocol').PublishRequest;
|
|
1456
|
+
if (!r.topic || typeof r.topic !== 'string') return { ok: false, error: 'topic required' };
|
|
1457
|
+
const peerId = ctx.getPeerId()!;
|
|
1458
|
+
const cfg = this.getConfig();
|
|
1459
|
+
|
|
1460
|
+
// L14: Per-peer rate limiter — orchestrators are exempt so management
|
|
1461
|
+
// commands are never self-rate-limited during high-volume waves.
|
|
1462
|
+
const peerRole = this.peers.get(peerId)?.role;
|
|
1463
|
+
if (peerRole !== 'orchestrator') {
|
|
1464
|
+
const nowMs = Date.now();
|
|
1465
|
+
const bucket = this.publishRateTracker.get(peerId) ?? { count: 0, windowStart: nowMs };
|
|
1466
|
+
if (nowMs - bucket.windowStart >= 1000) { bucket.count = 0; bucket.windowStart = nowMs; }
|
|
1467
|
+
bucket.count++;
|
|
1468
|
+
this.publishRateTracker.set(peerId, bucket);
|
|
1469
|
+
if (bucket.count > cfg.maxPublishRateHz) {
|
|
1470
|
+
this.peerRateLimitHits.set(peerId, (this.peerRateLimitHits.get(peerId) ?? 0) + 1);
|
|
1471
|
+
return { ok: false, error: 'rate-limit-exceeded' };
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
// L14: Queue-depth admission control — serverInFlight is incremented
|
|
1476
|
+
// synchronously before any await so concurrent handlers see an accurate count.
|
|
1477
|
+
if (this.serverInFlight > cfg.maxQueueDepth) {
|
|
1478
|
+
return { ok: false, error: 'admission-control:backlog' };
|
|
1479
|
+
}
|
|
1480
|
+
this.serverInFlight++;
|
|
1481
|
+
this.publishCountSinceHeartbeat++;
|
|
1482
|
+
|
|
1483
|
+
try {
|
|
1484
|
+
const strict = cfg.strictEventValidation;
|
|
1485
|
+
const dataSchema = schemaForTopic(r.topic);
|
|
1486
|
+
// BUG-02: envelope is server-applied; auto-fill missing fields for SDK-less workers
|
|
1487
|
+
let effectivePayload: unknown = r.payload;
|
|
1488
|
+
if (dataSchema !== null) {
|
|
1489
|
+
const envelopeResult = EnvelopeV1.safeParse(r.payload);
|
|
1490
|
+
if (!envelopeResult.success) {
|
|
1491
|
+
const senderPeer = this.peers.get(peerId);
|
|
1492
|
+
effectivePayload = {
|
|
1493
|
+
v: 1, id: randomUUID(), from_peer: peerId,
|
|
1494
|
+
from_name: senderPeer?.peerName ?? peerId,
|
|
1495
|
+
ts_published: new Date().toISOString(), schema: 'claws/2', data: r.payload,
|
|
1496
|
+
};
|
|
1497
|
+
const innerResult = dataSchema.safeParse(r.payload);
|
|
1498
|
+
if (!innerResult.success) {
|
|
1499
|
+
this.opts.logger(`[claws/schema] malformed data from ${peerId} on ${r.topic}`);
|
|
1500
|
+
await this.emitServerEvent('system.malformed.received', {
|
|
1501
|
+
from: peerId, topic: r.topic, error: innerResult.error.issues,
|
|
1502
|
+
});
|
|
1503
|
+
if (strict) return { ok: false, error: 'payload:invalid', details: innerResult.error.issues };
|
|
1504
|
+
}
|
|
1505
|
+
} else {
|
|
1506
|
+
const dataResult = dataSchema.safeParse(envelopeResult.data.data);
|
|
1507
|
+
if (!dataResult.success) {
|
|
1508
|
+
this.opts.logger(`[claws/schema] malformed data from ${peerId} on ${r.topic}`);
|
|
1509
|
+
await this.emitServerEvent('system.malformed.received', {
|
|
1510
|
+
from: peerId, topic: r.topic, error: dataResult.error.issues,
|
|
1511
|
+
});
|
|
1512
|
+
if (strict) {
|
|
1513
|
+
return { ok: false, error: 'payload:invalid', details: dataResult.error.issues };
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
// Durably append to the event log before fan-out.
|
|
1520
|
+
// If append() throws (non-degraded I/O error), we refuse to publish so
|
|
1521
|
+
// the caller is not told ok:true for an event that was not persisted.
|
|
1522
|
+
// In degraded mode (log disabled at startup) append() returns sequence -1
|
|
1523
|
+
// without throwing and fan-out proceeds normally.
|
|
1524
|
+
let sequence: number | undefined;
|
|
1525
|
+
try {
|
|
1526
|
+
const logResult = await this.eventLog.append({
|
|
1527
|
+
topic: r.topic,
|
|
1528
|
+
from: peerId,
|
|
1529
|
+
ts_server: new Date().toISOString(),
|
|
1530
|
+
payload: effectivePayload,
|
|
1531
|
+
});
|
|
1532
|
+
sequence = logResult.sequence >= 0 ? logResult.sequence : undefined;
|
|
1533
|
+
} catch {
|
|
1534
|
+
return { ok: false, error: 'event-log:write-failed' };
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
const delivered = this.fanOut(r.topic, peerId, effectivePayload, r.echo ?? false, sequence);
|
|
1538
|
+
|
|
1539
|
+
// BUG-06: heartbeat publishes reset wave violation timers (not just hello-time recordHeartbeat)
|
|
1540
|
+
if (/^worker\.[^.]+\.heartbeat$/.test(r.topic)) {
|
|
1541
|
+
const hbPeer = this.peers.get(peerId);
|
|
1542
|
+
if (hbPeer?.waveId && hbPeer?.subWorkerRole) {
|
|
1543
|
+
this.waveRegistry.recordHeartbeat(hbPeer.waveId, hbPeer.subWorkerRole as SubWorkerRole, peerId);
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
// L16 TYPED-RPC: resolve any pending rpc.call waiting on this response topic.
|
|
1548
|
+
// Topic: rpc.response.<callerPeerId>.<requestId> — parts[3] is the requestId.
|
|
1549
|
+
if (r.topic.startsWith('rpc.response.')) {
|
|
1550
|
+
const parts = r.topic.split('.');
|
|
1551
|
+
if (parts.length >= 4) {
|
|
1552
|
+
const requestId = parts[parts.length - 1];
|
|
1553
|
+
const pending = this.rpcPending.get(requestId);
|
|
1554
|
+
if (pending) {
|
|
1555
|
+
clearTimeout(pending.timer);
|
|
1556
|
+
this.rpcPending.delete(requestId);
|
|
1557
|
+
pending.resolve({ ok: true, requestId, result: r.payload });
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
// L11 Pipeline: if topic matches output.<sourceId>.*, route output to sink terminals.
|
|
1563
|
+
const outputMatch = /^output\.([^.]+)\./.exec(r.topic);
|
|
1564
|
+
if (outputMatch) {
|
|
1565
|
+
const sourceTerminalId = outputMatch[1];
|
|
1566
|
+
const activePipelines = this.pipelineRegistry.findBySource(sourceTerminalId);
|
|
1567
|
+
for (const pipeline of activePipelines) {
|
|
1568
|
+
const sourceStep = pipeline.steps.find((s) => s.role === 'source');
|
|
1569
|
+
const sinkStep = pipeline.steps.find((s) => s.role === 'sink');
|
|
1570
|
+
if (!sourceStep || !sinkStep) continue;
|
|
1571
|
+
const payloadObj = typeof r.payload === 'object' && r.payload !== null
|
|
1572
|
+
? r.payload as Record<string, unknown>
|
|
1573
|
+
: {};
|
|
1574
|
+
const text = typeof payloadObj['text'] === 'string'
|
|
1575
|
+
? payloadObj['text']
|
|
1576
|
+
: JSON.stringify(r.payload);
|
|
1577
|
+
void this.opts.backend.sendText(String(sinkStep.terminalId), text, { newline: true, paste: false });
|
|
1578
|
+
void this.emitSystemEvent(`pipeline.${pipeline.pipelineId}.step.${sourceStep.stepId}`, {
|
|
1579
|
+
pipelineId: pipeline.pipelineId,
|
|
1580
|
+
stepId: sourceStep.stepId,
|
|
1581
|
+
role: 'source',
|
|
1582
|
+
terminalId: sourceTerminalId,
|
|
1583
|
+
state: 'active',
|
|
1584
|
+
ts: new Date().toISOString(),
|
|
1585
|
+
});
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
return { ok: true, deliveredTo: delivered };
|
|
1590
|
+
} finally {
|
|
1591
|
+
this.serverInFlight--;
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
if (cmd === 'broadcast') {
|
|
1596
|
+
const denied = this.requireRole(ctx, ['orchestrator']);
|
|
1597
|
+
if (denied) return denied;
|
|
1598
|
+
const r = req as import('./protocol').BroadcastRequest;
|
|
1599
|
+
const from = ctx.getPeerId()!;
|
|
1600
|
+
const targetRole = r.targetRole ?? 'worker';
|
|
1601
|
+
let injectText = r.text;
|
|
1602
|
+
if (r.inject && r.text.startsWith('[CLAWS_CMD ')) {
|
|
1603
|
+
this.broadcastSeq++;
|
|
1604
|
+
injectText = r.text.replace('[CLAWS_CMD ', `[CLAWS_CMD seq=${this.broadcastSeq} `);
|
|
1605
|
+
}
|
|
1606
|
+
let count = 0;
|
|
1607
|
+
for (const peer of this.peers.values()) {
|
|
1608
|
+
if (targetRole !== 'all' && peer.role !== targetRole) continue;
|
|
1609
|
+
this.pushFrame(peer.socket, 'system.broadcast', from, { text: injectText });
|
|
1610
|
+
count++;
|
|
1611
|
+
if (r.inject && peer.terminalId) {
|
|
1612
|
+
void this.opts.backend.sendText(String(peer.terminalId), injectText, { newline: true, paste: true });
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
return { ok: true, deliveredTo: count };
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
if (cmd === 'task.assign') {
|
|
1619
|
+
const denied = this.requireRole(ctx, ['orchestrator']);
|
|
1620
|
+
if (denied) return denied;
|
|
1621
|
+
const r = req as import('./protocol').TaskAssignRequest;
|
|
1622
|
+
if (!r.title || !r.assignee || !r.prompt) {
|
|
1623
|
+
return { ok: false, error: 'title, assignee, and prompt are required' };
|
|
1624
|
+
}
|
|
1625
|
+
if (!this.peers.has(r.assignee)) {
|
|
1626
|
+
return { ok: false, error: `assignee peer not found: ${r.assignee}` };
|
|
1627
|
+
}
|
|
1628
|
+
const taskId = allocTaskId(++this.taskSeq);
|
|
1629
|
+
const now = Date.now();
|
|
1630
|
+
const task: TaskRecord = {
|
|
1631
|
+
taskId,
|
|
1632
|
+
title: r.title,
|
|
1633
|
+
prompt: r.prompt,
|
|
1634
|
+
assignee: r.assignee,
|
|
1635
|
+
assignedBy: ctx.getPeerId()!,
|
|
1636
|
+
status: 'pending',
|
|
1637
|
+
assignedAt: now,
|
|
1638
|
+
updatedAt: now,
|
|
1639
|
+
timeoutMs: r.timeoutMs,
|
|
1640
|
+
};
|
|
1641
|
+
this.tasks.set(taskId, task);
|
|
1642
|
+
const deliver = r.deliver ?? 'publish';
|
|
1643
|
+
// Publish task.assigned.<assignee> so the worker learns about the task
|
|
1644
|
+
if (deliver === 'publish' || deliver === 'both') {
|
|
1645
|
+
await this.emitServerEvent(`task.assigned.${r.assignee}`, { ...task });
|
|
1646
|
+
}
|
|
1647
|
+
// Inject prompt into the worker's terminal if requested
|
|
1648
|
+
if (deliver === 'inject' || deliver === 'both') {
|
|
1649
|
+
const assigneePeer = this.peers.get(r.assignee);
|
|
1650
|
+
if (assigneePeer?.terminalId) {
|
|
1651
|
+
void this.opts.backend.sendText(String(assigneePeer.terminalId), r.prompt, { newline: true, paste: true });
|
|
1652
|
+
}
|
|
1653
|
+
}
|
|
1654
|
+
this.opts.logger(`[claws/2] task assigned: ${taskId} to ${r.assignee}`);
|
|
1655
|
+
return { ok: true, taskId, assignedAt: now };
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
if (cmd === 'task.update') {
|
|
1659
|
+
const denied = this.requireRole(ctx, ['worker']);
|
|
1660
|
+
if (denied) return denied;
|
|
1661
|
+
const r = req as import('./protocol').TaskUpdateRequest;
|
|
1662
|
+
const task = this.tasks.get(r.taskId);
|
|
1663
|
+
if (!task) return { ok: false, error: `task not found: ${r.taskId}` };
|
|
1664
|
+
if (task.assignee !== ctx.getPeerId()) return { ok: false, error: 'not your task' };
|
|
1665
|
+
if (['succeeded', 'failed', 'skipped'].includes(task.status)) {
|
|
1666
|
+
return { ok: false, error: 'task already completed' };
|
|
1667
|
+
}
|
|
1668
|
+
task.status = r.status;
|
|
1669
|
+
if (r.progressPct !== undefined) task.progressPct = r.progressPct;
|
|
1670
|
+
if (r.note !== undefined) task.note = r.note;
|
|
1671
|
+
task.updatedAt = Date.now();
|
|
1672
|
+
// Publish task.status for orchestrator subscribers
|
|
1673
|
+
await this.emitServerEvent('task.status', {
|
|
1674
|
+
taskId: task.taskId,
|
|
1675
|
+
assignee: task.assignee,
|
|
1676
|
+
status: task.status,
|
|
1677
|
+
progressPct: task.progressPct,
|
|
1678
|
+
note: task.note,
|
|
1679
|
+
});
|
|
1680
|
+
return { ok: true };
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
if (cmd === 'task.complete') {
|
|
1684
|
+
const denied = this.requireRole(ctx, ['worker']);
|
|
1685
|
+
if (denied) return denied;
|
|
1686
|
+
const r = req as import('./protocol').TaskCompleteRequest;
|
|
1687
|
+
const task = this.tasks.get(r.taskId);
|
|
1688
|
+
if (!task) return { ok: false, error: `task not found: ${r.taskId}` };
|
|
1689
|
+
if (task.assignee !== ctx.getPeerId()) return { ok: false, error: 'not your task' };
|
|
1690
|
+
// Idempotent: if already completed, return ok without re-firing a push
|
|
1691
|
+
if (['succeeded', 'failed', 'skipped'].includes(task.status)) return { ok: true };
|
|
1692
|
+
const now = Date.now();
|
|
1693
|
+
task.status = r.status;
|
|
1694
|
+
task.result = r.result;
|
|
1695
|
+
task.artifacts = r.artifacts;
|
|
1696
|
+
task.completedAt = now;
|
|
1697
|
+
task.updatedAt = now;
|
|
1698
|
+
await this.emitServerEvent('task.completed', {
|
|
1699
|
+
taskId: task.taskId,
|
|
1700
|
+
status: task.status,
|
|
1701
|
+
result: task.result,
|
|
1702
|
+
artifacts: task.artifacts,
|
|
1703
|
+
});
|
|
1704
|
+
this.opts.logger(`[claws/2] task completed: ${task.taskId} status=${task.status}`);
|
|
1705
|
+
return { ok: true };
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
if (cmd === 'task.cancel') {
|
|
1709
|
+
const denied = this.requireRole(ctx, ['orchestrator']);
|
|
1710
|
+
if (denied) return denied;
|
|
1711
|
+
const r = req as import('./protocol').TaskCancelRequest;
|
|
1712
|
+
const task = this.tasks.get(r.taskId);
|
|
1713
|
+
if (!task) return { ok: false, error: `task not found: ${r.taskId}` };
|
|
1714
|
+
task.cancelRequested = true;
|
|
1715
|
+
task.cancelReason = r.reason;
|
|
1716
|
+
task.updatedAt = Date.now();
|
|
1717
|
+
await this.emitServerEvent(`task.cancel_requested.${task.assignee}`, {
|
|
1718
|
+
taskId: task.taskId,
|
|
1719
|
+
reason: r.reason,
|
|
1720
|
+
});
|
|
1721
|
+
return { ok: true };
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
if (cmd === 'task.list') {
|
|
1725
|
+
const r = req as import('./protocol').TaskListRequest;
|
|
1726
|
+
let list = Array.from(this.tasks.values());
|
|
1727
|
+
if (r.assignee) list = list.filter((t) => t.assignee === r.assignee);
|
|
1728
|
+
if (r.status) list = list.filter((t) => t.status === r.status);
|
|
1729
|
+
if (r.since) list = list.filter((t) => t.updatedAt >= r.since!);
|
|
1730
|
+
return { ok: true, tasks: list };
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
if (cmd === 'lifecycle.plan') {
|
|
1734
|
+
const r = req as import('./protocol').LifecyclePlanRequest;
|
|
1735
|
+
if (!r.plan || !r.plan.trim()) {
|
|
1736
|
+
return { ok: false, error: 'lifecycle:plan-empty', message: 'plan text must be non-empty' };
|
|
1737
|
+
}
|
|
1738
|
+
if (!r.workerMode) {
|
|
1739
|
+
return { ok: false, error: 'lifecycle:worker-mode-required', message: 'workerMode required (single|fleet|army)' };
|
|
1740
|
+
}
|
|
1741
|
+
if (typeof r.expectedWorkers !== 'number' || r.expectedWorkers < 1) {
|
|
1742
|
+
return { ok: false, error: 'lifecycle:expected-workers-required', message: 'expectedWorkers must be positive integer' };
|
|
1743
|
+
}
|
|
1744
|
+
try {
|
|
1745
|
+
const existingState = this.lifecycleStore.snapshot();
|
|
1746
|
+
const isResettingFromReflect = existingState !== null && existingState.phase === 'REFLECT';
|
|
1747
|
+
const state = this.lifecycleStore.plan(r.plan, r.workerMode, r.expectedWorkers);
|
|
1748
|
+
const inActiveMission = existingState !== null
|
|
1749
|
+
&& existingState.phase !== 'SESSION-BOOT'
|
|
1750
|
+
&& existingState.phase !== 'REFLECT'
|
|
1751
|
+
&& existingState.phase !== 'SESSION-END';
|
|
1752
|
+
const idempotent = inActiveMission && !isResettingFromReflect;
|
|
1753
|
+
return { ok: true, state, idempotent };
|
|
1754
|
+
} catch (err) {
|
|
1755
|
+
const msg = (err as Error).message;
|
|
1756
|
+
const sepIdx = msg.indexOf(' — ');
|
|
1757
|
+
if (sepIdx !== -1) return { ok: false, error: msg.slice(0, sepIdx), message: msg.slice(sepIdx + 3) };
|
|
1758
|
+
return { ok: false, error: msg, message: msg };
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
if (cmd === 'lifecycle.advance') {
|
|
1763
|
+
const r = req as import('./protocol').LifecycleAdvanceRequest;
|
|
1764
|
+
const cur = this.lifecycleStore.snapshot();
|
|
1765
|
+
if (!cur) {
|
|
1766
|
+
return { ok: false, error: 'lifecycle:plan-required', message: 'no lifecycle state — call lifecycle.plan first' };
|
|
1767
|
+
}
|
|
1768
|
+
const to = r.to as import('./lifecycle-store').Phase;
|
|
1769
|
+
if (cur.phase === to) {
|
|
1770
|
+
return { ok: true, state: cur, idempotent: true };
|
|
1771
|
+
}
|
|
1772
|
+
// Validate transition via pure rules
|
|
1773
|
+
if (!canTransition(cur.phase, to)) {
|
|
1774
|
+
const reason = explainIllegalTransition(cur.phase, to);
|
|
1775
|
+
return { ok: false, error: 'lifecycle:invalid-transition', message: reason ?? `${cur.phase} → ${to} not allowed` };
|
|
1776
|
+
}
|
|
1777
|
+
// Gate-check REFLECT specifically (CLEANUP gate is enforced earlier when entering CLEANUP)
|
|
1778
|
+
if (to === 'REFLECT') {
|
|
1779
|
+
const gate = canReflect(cur);
|
|
1780
|
+
if (!gate.ok) {
|
|
1781
|
+
return { ok: false, error: 'lifecycle:reflect-gate', message: gate.reason };
|
|
1782
|
+
}
|
|
1783
|
+
}
|
|
1784
|
+
const state = this.lifecycleStore.setPhase(to);
|
|
1785
|
+
return { ok: true, state };
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
if (cmd === 'lifecycle.snapshot') {
|
|
1789
|
+
return { ok: true, state: this.lifecycleStore.snapshot() };
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
if (cmd === 'lifecycle.reflect') {
|
|
1793
|
+
const r = req as import('./protocol').LifecycleReflectRequest;
|
|
1794
|
+
if (!r.reflect || !r.reflect.trim()) {
|
|
1795
|
+
return { ok: false, error: 'lifecycle:reflect-empty', message: 'reflect text must be non-empty' };
|
|
1796
|
+
}
|
|
1797
|
+
const cur = this.lifecycleStore.snapshot();
|
|
1798
|
+
if (!cur) {
|
|
1799
|
+
return { ok: false, error: 'lifecycle:plan-required', message: 'no lifecycle state' };
|
|
1800
|
+
}
|
|
1801
|
+
// REFLECT must be reachable from current phase + reflect-gate must pass
|
|
1802
|
+
if (!canTransition(cur.phase, 'REFLECT')) {
|
|
1803
|
+
const reason = explainIllegalTransition(cur.phase, 'REFLECT');
|
|
1804
|
+
return { ok: false, error: 'lifecycle:invalid-transition', message: reason ?? `cannot REFLECT from ${cur.phase}` };
|
|
1805
|
+
}
|
|
1806
|
+
const gate = canReflect(cur);
|
|
1807
|
+
if (!gate.ok) {
|
|
1808
|
+
return { ok: false, error: 'lifecycle:reflect-gate', message: gate.reason };
|
|
1809
|
+
}
|
|
1810
|
+
const state = this.lifecycleStore.reflect(r.reflect);
|
|
1811
|
+
return { ok: true, state };
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
// ─── D+F: per-worker spawn + monitor registration (v0.7.10) ──────────────
|
|
1815
|
+
|
|
1816
|
+
if (cmd === 'lifecycle.register-spawn') {
|
|
1817
|
+
const r = req as import('./protocol').LifecycleRegisterSpawnRequest;
|
|
1818
|
+
if (!r.terminalId || !r.correlationId || !r.name) {
|
|
1819
|
+
return { ok: false, error: 'lifecycle:register-spawn-args', message: 'terminalId, correlationId, name required' };
|
|
1820
|
+
}
|
|
1821
|
+
try {
|
|
1822
|
+
const worker = this.lifecycleStore.registerSpawn(r.terminalId, r.correlationId, r.name);
|
|
1823
|
+
this.lifecycleEngine.onWorkerEvent('register-spawn');
|
|
1824
|
+
return { ok: true, worker };
|
|
1825
|
+
} catch (err) {
|
|
1826
|
+
const msg = (err as Error).message;
|
|
1827
|
+
const sepIdx = msg.indexOf(' — ');
|
|
1828
|
+
if (sepIdx !== -1) return { ok: false, error: msg.slice(0, sepIdx), message: msg.slice(sepIdx + 3) };
|
|
1829
|
+
return { ok: false, error: msg, message: msg };
|
|
1830
|
+
}
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1833
|
+
if (cmd === 'lifecycle.register-monitor') {
|
|
1834
|
+
const r = req as import('./protocol').LifecycleRegisterMonitorRequest;
|
|
1835
|
+
if (!r.terminalId || !r.correlationId || !r.command) {
|
|
1836
|
+
return { ok: false, error: 'lifecycle:register-monitor-args', message: 'terminalId, correlationId, command required' };
|
|
1837
|
+
}
|
|
1838
|
+
try {
|
|
1839
|
+
const monitor = this.lifecycleStore.registerMonitor(r.terminalId, r.correlationId, r.command);
|
|
1840
|
+
this.lifecycleEngine.onWorkerEvent('register-monitor');
|
|
1841
|
+
return { ok: true, monitor };
|
|
1842
|
+
} catch (err) {
|
|
1843
|
+
const msg = (err as Error).message;
|
|
1844
|
+
return { ok: false, error: msg, message: msg };
|
|
1845
|
+
}
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1848
|
+
if (cmd === 'lifecycle.mark-worker-status') {
|
|
1849
|
+
const r = req as import('./protocol').LifecycleMarkWorkerStatusRequest;
|
|
1850
|
+
if (!r.terminalId || !r.status) {
|
|
1851
|
+
return { ok: false, error: 'lifecycle:mark-status-args', message: 'terminalId, status required' };
|
|
1852
|
+
}
|
|
1853
|
+
const updated = this.lifecycleStore.markWorkerStatus(r.terminalId, r.status as import('./lifecycle-store').WorkerStatus);
|
|
1854
|
+
this.lifecycleEngine.onWorkerEvent('mark-worker-status:' + r.status);
|
|
1855
|
+
return { ok: true, worker: updated };
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1858
|
+
if (cmd === 'monitors.register-intent') {
|
|
1859
|
+
const r = req as import('./protocol').BaseRequest & { correlation_id?: string };
|
|
1860
|
+
if (typeof r.correlation_id !== 'string' || !r.correlation_id) {
|
|
1861
|
+
return { ok: false, error: 'monitors.register-intent:missing-correlation_id' };
|
|
1862
|
+
}
|
|
1863
|
+
this.peerRegistry.registerArmIntent(r.correlation_id);
|
|
1864
|
+
return { ok: true };
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
if (cmd === 'monitors.is-corr-armed') {
|
|
1868
|
+
const r = req as import('./protocol').BaseRequest & { correlation_id?: string };
|
|
1869
|
+
if (typeof r.correlation_id !== 'string' || !r.correlation_id) {
|
|
1870
|
+
return { ok: false, error: 'monitors.is-corr-armed:missing-correlation_id' };
|
|
1871
|
+
}
|
|
1872
|
+
const claimed = this.peerRegistry.isCorrIdArmed(r.correlation_id);
|
|
1873
|
+
const pending = this.peerRegistry.isCorrIdPending(r.correlation_id);
|
|
1874
|
+
const peerId = this.peerRegistry.getArmedPeerForCorrId(r.correlation_id) ?? null;
|
|
1875
|
+
return {
|
|
1876
|
+
ok: true,
|
|
1877
|
+
armed: claimed || pending, // backward-compat: either intent OR execution counts as 'armed'
|
|
1878
|
+
claimed,
|
|
1879
|
+
pending,
|
|
1880
|
+
peerId,
|
|
1881
|
+
};
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1884
|
+
// ── Wave army commands ──────────────────────────────────────────────────
|
|
1885
|
+
|
|
1886
|
+
if (cmd === 'wave.create') {
|
|
1887
|
+
const r = req as import('./protocol').WaveCreateRequest;
|
|
1888
|
+
if (!r.waveId) return { ok: false, error: 'wave.create:missing-waveId' };
|
|
1889
|
+
if (!Array.isArray(r.manifest) || r.manifest.length === 0) {
|
|
1890
|
+
return { ok: false, error: 'wave.create:missing-manifest' };
|
|
1891
|
+
}
|
|
1892
|
+
const peerId = ctx.getPeerId() ?? 'unknown';
|
|
1893
|
+
const wave = this.waveRegistry.createWave(
|
|
1894
|
+
r.waveId,
|
|
1895
|
+
Array.isArray(r.layers) ? r.layers : [],
|
|
1896
|
+
r.manifest as SubWorkerRole[],
|
|
1897
|
+
peerId,
|
|
1898
|
+
);
|
|
1899
|
+
void this.emitSystemEvent(`wave.${r.waveId}.lead.boot`, {
|
|
1900
|
+
waveId: r.waveId,
|
|
1901
|
+
peerName: this.peers.get(peerId)?.peerName ?? peerId,
|
|
1902
|
+
layers: wave.layers,
|
|
1903
|
+
manifest: r.manifest,
|
|
1904
|
+
started_at: new Date(wave.createdAt).toISOString(),
|
|
1905
|
+
});
|
|
1906
|
+
return { ok: true, waveId: wave.waveId, createdAt: wave.createdAt };
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1909
|
+
if (cmd === 'wave.status') {
|
|
1910
|
+
const r = req as import('./protocol').WaveStatusRequest;
|
|
1911
|
+
if (!r.waveId) return { ok: false, error: 'wave.status:missing-waveId' };
|
|
1912
|
+
const wave = this.waveRegistry.getWave(r.waveId);
|
|
1913
|
+
if (!wave) return { ok: false, error: `wave.status:not-found:${r.waveId}` };
|
|
1914
|
+
const subWorkers = [...wave.subWorkers.entries()].map(([role, entry]) => {
|
|
1915
|
+
const peerConn = entry.peerId ? this.peers.get(entry.peerId) : undefined;
|
|
1916
|
+
return {
|
|
1917
|
+
role,
|
|
1918
|
+
peerId: entry.peerId ?? null,
|
|
1919
|
+
peerName: peerConn?.peerName ?? null,
|
|
1920
|
+
terminalId: entry.terminalId ?? peerConn?.terminalId ?? null,
|
|
1921
|
+
lastHeartbeatMs: entry.lastHeartbeatMs,
|
|
1922
|
+
complete: entry.complete,
|
|
1923
|
+
};
|
|
1924
|
+
});
|
|
1925
|
+
const leadPeer = this.peers.get(wave.leadPeerId);
|
|
1926
|
+
return {
|
|
1927
|
+
ok: true,
|
|
1928
|
+
waveId: wave.waveId,
|
|
1929
|
+
layers: wave.layers,
|
|
1930
|
+
leadPeerId: wave.leadPeerId,
|
|
1931
|
+
leadPeerName: leadPeer?.peerName ?? null,
|
|
1932
|
+
leadTerminalId: leadPeer?.terminalId ?? null,
|
|
1933
|
+
// Nested lead tree (mission: claws_wave_status nested tree format)
|
|
1934
|
+
lead: {
|
|
1935
|
+
peerId: wave.leadPeerId,
|
|
1936
|
+
peerName: leadPeer?.peerName ?? null,
|
|
1937
|
+
terminalId: leadPeer?.terminalId ?? null,
|
|
1938
|
+
status: wave.complete ? 'complete' : 'active',
|
|
1939
|
+
lastSeenMs: leadPeer?.lastSeen ?? null,
|
|
1940
|
+
},
|
|
1941
|
+
subWorkers,
|
|
1942
|
+
subWorkerTerminals: wave.subWorkerTerminals,
|
|
1943
|
+
orphanedTerminals: wave.orphanedTerminals,
|
|
1944
|
+
harvestedAt: wave.harvestedAt ?? null,
|
|
1945
|
+
complete: wave.complete,
|
|
1946
|
+
createdAt: wave.createdAt,
|
|
1947
|
+
completedAt: wave.completedAt ?? null,
|
|
1948
|
+
summary: wave.summary ?? null,
|
|
1949
|
+
commits: wave.commits ?? [],
|
|
1950
|
+
regressionClean: wave.regressionClean ?? null,
|
|
1951
|
+
};
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
if (cmd === 'wave.complete') {
|
|
1955
|
+
const r = req as import('./protocol').WaveCompleteRequest;
|
|
1956
|
+
if (!r.waveId) return { ok: false, error: 'wave.complete:missing-waveId' };
|
|
1957
|
+
const peerId = ctx.getPeerId() ?? 'unknown';
|
|
1958
|
+
const wave = this.waveRegistry.getWave(r.waveId);
|
|
1959
|
+
if (!wave) return { ok: false, error: `wave.complete:not-found:${r.waveId}` };
|
|
1960
|
+
if (wave.leadPeerId !== peerId) {
|
|
1961
|
+
return { ok: false, error: 'wave.complete:not-lead — only the LEAD peer may complete a wave' };
|
|
1962
|
+
}
|
|
1963
|
+
const completed = this.waveRegistry.completeWave(
|
|
1964
|
+
r.waveId,
|
|
1965
|
+
r.summary,
|
|
1966
|
+
r.commits,
|
|
1967
|
+
r.regressionClean,
|
|
1968
|
+
);
|
|
1969
|
+
if (!completed) return { ok: false, error: `wave.complete:already-complete:${r.waveId}` };
|
|
1970
|
+
void this.emitSystemEvent(`wave.${r.waveId}.complete`, {
|
|
1971
|
+
waveId: r.waveId,
|
|
1972
|
+
status: 'ok',
|
|
1973
|
+
commits: r.commits ?? [],
|
|
1974
|
+
regression_clean: r.regressionClean ?? false,
|
|
1975
|
+
});
|
|
1976
|
+
|
|
1977
|
+
// HARVEST: close any sub-worker terminals registered to this wave
|
|
1978
|
+
const terminalIdsToClose = this.waveRegistry.harvestWave(r.waveId);
|
|
1979
|
+
const closedTerminals: string[] = [];
|
|
1980
|
+
const alreadyClosed: string[] = [];
|
|
1981
|
+
for (const tid of terminalIdsToClose) {
|
|
1982
|
+
try {
|
|
1983
|
+
await backend.closeTerminal(tid, 'orchestrator');
|
|
1984
|
+
closedTerminals.push(tid);
|
|
1985
|
+
} catch {
|
|
1986
|
+
alreadyClosed.push(tid);
|
|
1987
|
+
}
|
|
1988
|
+
}
|
|
1989
|
+
// Always emit harvested so orchestrators can confirm lifecycle closed.
|
|
1990
|
+
void this.emitSystemEvent(`wave.${r.waveId}.harvested`, {
|
|
1991
|
+
waveId: r.waveId,
|
|
1992
|
+
orphaned_count: terminalIdsToClose.length,
|
|
1993
|
+
closed_terminals: closedTerminals,
|
|
1994
|
+
already_closed: alreadyClosed,
|
|
1995
|
+
ts: new Date().toISOString(),
|
|
1996
|
+
});
|
|
1997
|
+
|
|
1998
|
+
return { ok: true, waveId: r.waveId, completedAt: completed.completedAt, harvested: closedTerminals.length };
|
|
1999
|
+
}
|
|
2000
|
+
|
|
2001
|
+
if (cmd === 'deliver-cmd') {
|
|
2002
|
+
const denied = this.requireRole(ctx, ['orchestrator']);
|
|
2003
|
+
if (denied) return denied;
|
|
2004
|
+
const r = req as import('./protocol').DeliverCmdRequest;
|
|
2005
|
+
if (!r.targetPeerId) return { ok: false, error: 'deliver-cmd:missing-targetPeerId' };
|
|
2006
|
+
if (!r.cmdTopic) return { ok: false, error: 'deliver-cmd:missing-cmdTopic' };
|
|
2007
|
+
if (!r.idempotencyKey) return { ok: false, error: 'deliver-cmd:missing-idempotencyKey' };
|
|
2008
|
+
const targetPeer = this.peers.get(r.targetPeerId);
|
|
2009
|
+
if (!targetPeer) return { ok: false, error: `deliver-cmd:target-not-found:${r.targetPeerId}` };
|
|
2010
|
+
const existing = this.cmdIdempotencyMap.get(r.idempotencyKey);
|
|
2011
|
+
if (existing) return { ok: true, duplicate: true, seq: existing.seq };
|
|
2012
|
+
const seq = ++this.cmdSeq;
|
|
2013
|
+
const from = ctx.getPeerId() ?? 'unknown';
|
|
2014
|
+
this.cmdIdempotencyMap.set(r.idempotencyKey, { seq, targetPeerId: r.targetPeerId });
|
|
2015
|
+
this.cmdDeliveryMap.set(seq, { targetPeerId: r.targetPeerId, from, cmdTopic: r.cmdTopic });
|
|
2016
|
+
try {
|
|
2017
|
+
await this.eventLog.append({
|
|
2018
|
+
schema: 'cmd-deliver-v1',
|
|
2019
|
+
topic: r.cmdTopic,
|
|
2020
|
+
peerId: from,
|
|
2021
|
+
peerName: ctx.getPeerId() ?? 'unknown',
|
|
2022
|
+
payload: { targetPeerId: r.targetPeerId, cmdTopic: r.cmdTopic, idempotencyKey: r.idempotencyKey, seq },
|
|
2023
|
+
});
|
|
2024
|
+
} catch { /* non-fatal: delivery continues even if log fails */ }
|
|
2025
|
+
this.pushFrame(targetPeer.socket, r.cmdTopic, from, r.payload, seq);
|
|
2026
|
+
return { ok: true, seq };
|
|
2027
|
+
}
|
|
2028
|
+
|
|
2029
|
+
if (cmd === 'cmd.ack') {
|
|
2030
|
+
const denied = this.requireRole(ctx, ['worker']);
|
|
2031
|
+
if (denied) return denied;
|
|
2032
|
+
const r = req as import('./protocol').CmdAckRequest;
|
|
2033
|
+
const workerPeerId = ctx.getPeerId() ?? 'unknown';
|
|
2034
|
+
const ackTopic = `cmd.${workerPeerId}.ack`;
|
|
2035
|
+
const ackPayload: Record<string, unknown> = { seq: r.seq, status: r.status, workerPeerId };
|
|
2036
|
+
if (r.correlation_id) ackPayload.correlation_id = r.correlation_id;
|
|
2037
|
+
let sequence: number | undefined;
|
|
2038
|
+
try {
|
|
2039
|
+
const logResult = await this.eventLog.append({
|
|
2040
|
+
schema: 'cmd-ack-v1',
|
|
2041
|
+
topic: ackTopic,
|
|
2042
|
+
peerId: workerPeerId,
|
|
2043
|
+
peerName: workerPeerId,
|
|
2044
|
+
payload: ackPayload,
|
|
2045
|
+
});
|
|
2046
|
+
sequence = logResult.sequence >= 0 ? logResult.sequence : undefined;
|
|
2047
|
+
} catch { /* non-fatal */ }
|
|
2048
|
+
this.fanOut(ackTopic, workerPeerId, ackPayload, false, sequence);
|
|
2049
|
+
return { ok: true };
|
|
2050
|
+
}
|
|
2051
|
+
|
|
2052
|
+
if (cmd === 'pipeline.create') {
|
|
2053
|
+
const denied = this.requireRole(ctx, ['orchestrator']);
|
|
2054
|
+
if (denied) return denied;
|
|
2055
|
+
const r = req as import('./protocol').PipelineCreateRequest;
|
|
2056
|
+
if (!Array.isArray(r.steps) || r.steps.length < 2) {
|
|
2057
|
+
return { ok: false, error: 'pipeline.create:steps-required (min 2 steps)' };
|
|
2058
|
+
}
|
|
2059
|
+
if (!r.steps.some((s) => s.role === 'source')) {
|
|
2060
|
+
return { ok: false, error: 'pipeline.create:source-step-required' };
|
|
2061
|
+
}
|
|
2062
|
+
if (!r.steps.some((s) => s.role === 'sink')) {
|
|
2063
|
+
return { ok: false, error: 'pipeline.create:sink-step-required' };
|
|
2064
|
+
}
|
|
2065
|
+
const pipeline = this.pipelineRegistry.create(r.name ?? 'pipeline', r.steps);
|
|
2066
|
+
await this.emitSystemEvent(`pipeline.${pipeline.pipelineId}.created`, {
|
|
2067
|
+
pipelineId: pipeline.pipelineId,
|
|
2068
|
+
name: pipeline.name,
|
|
2069
|
+
steps: pipeline.steps,
|
|
2070
|
+
state: pipeline.state,
|
|
2071
|
+
createdAt: pipeline.createdAt,
|
|
2072
|
+
});
|
|
2073
|
+
return { ok: true, pipelineId: pipeline.pipelineId, pipeline };
|
|
2074
|
+
}
|
|
2075
|
+
|
|
2076
|
+
if (cmd === 'pipeline.list') {
|
|
2077
|
+
return { ok: true, pipelines: this.pipelineRegistry.list() };
|
|
2078
|
+
}
|
|
2079
|
+
|
|
2080
|
+
if (cmd === 'pipeline.close') {
|
|
2081
|
+
const denied = this.requireRole(ctx, ['orchestrator']);
|
|
2082
|
+
if (denied) return denied;
|
|
2083
|
+
const r = req as import('./protocol').PipelineCloseRequest;
|
|
2084
|
+
if (!r.pipelineId) return { ok: false, error: 'pipeline.close:pipelineId-required' };
|
|
2085
|
+
const closed = this.pipelineRegistry.close(r.pipelineId);
|
|
2086
|
+
if (!closed) return { ok: false, error: `pipeline.close:not-found:${r.pipelineId}` };
|
|
2087
|
+
await this.emitSystemEvent(`pipeline.${r.pipelineId}.closed`, {
|
|
2088
|
+
pipelineId: r.pipelineId,
|
|
2089
|
+
state: 'closed',
|
|
2090
|
+
closedAt: closed.closedAt,
|
|
2091
|
+
steps: closed.steps,
|
|
2092
|
+
});
|
|
2093
|
+
return { ok: true, pipelineId: r.pipelineId };
|
|
2094
|
+
}
|
|
2095
|
+
|
|
2096
|
+
// ── L16 TYPED-RPC ───────────────────────────────────────────────────────
|
|
2097
|
+
|
|
2098
|
+
if (cmd === 'rpc.call') {
|
|
2099
|
+
const denied = this.requireRole(ctx, ['orchestrator', 'worker', 'observer']);
|
|
2100
|
+
if (denied) return denied;
|
|
2101
|
+
const r = req as import('./protocol').RpcCallRequest;
|
|
2102
|
+
if (!r.targetPeerId) return { ok: false, error: 'rpc.call:missing-targetPeerId' };
|
|
2103
|
+
if (!r.method) return { ok: false, error: 'rpc.call:missing-method' };
|
|
2104
|
+
|
|
2105
|
+
const targetPeer = this.peers.get(r.targetPeerId);
|
|
2106
|
+
if (!targetPeer) return { ok: false, error: `rpc.call:target-not-found:${r.targetPeerId}` };
|
|
2107
|
+
|
|
2108
|
+
const requestId = randomUUID();
|
|
2109
|
+
const callerPeerId = ctx.getPeerId()!;
|
|
2110
|
+
const timeoutMs = r.timeoutMs ?? 5000;
|
|
2111
|
+
|
|
2112
|
+
// Returns a Promise held open until the worker responds or times out.
|
|
2113
|
+
return new Promise<ClawsResponse>((resolve) => {
|
|
2114
|
+
const timer = setTimeout(() => {
|
|
2115
|
+
if (this.rpcPending.delete(requestId)) {
|
|
2116
|
+
resolve({ ok: false, error: 'rpc.call:timeout', requestId });
|
|
2117
|
+
}
|
|
2118
|
+
}, timeoutMs);
|
|
2119
|
+
|
|
2120
|
+
this.rpcPending.set(requestId, { resolve, callerPeerId, timer });
|
|
2121
|
+
|
|
2122
|
+
this.pushFrame(targetPeer.socket, `rpc.${r.targetPeerId}.request`, callerPeerId, {
|
|
2123
|
+
requestId,
|
|
2124
|
+
method: r.method,
|
|
2125
|
+
params: r.params ?? {},
|
|
2126
|
+
callerPeerId,
|
|
2127
|
+
});
|
|
2128
|
+
});
|
|
2129
|
+
}
|
|
2130
|
+
|
|
2131
|
+
// ── L7 Schema Registry ──────────────────────────────────────────────────
|
|
2132
|
+
|
|
2133
|
+
if (cmd === 'schema.list') {
|
|
2134
|
+
return { ok: true, schemas: Object.keys(SCHEMA_BY_NAME).sort() };
|
|
2135
|
+
}
|
|
2136
|
+
|
|
2137
|
+
if (cmd === 'schema.get') {
|
|
2138
|
+
const r = req as import('./protocol').SchemaGetRequest;
|
|
2139
|
+
if (!r.name) return { ok: false, error: 'schema.get:missing-name' };
|
|
2140
|
+
const schema = SCHEMA_BY_NAME[r.name];
|
|
2141
|
+
if (!schema) return { ok: false, error: `schema.get:not-found:${r.name}` };
|
|
2142
|
+
return { ok: true, name: r.name, schema: serializeZodSchema(schema) };
|
|
2143
|
+
}
|
|
2144
|
+
|
|
2145
|
+
return { ok: false, error: `unknown cmd: ${cmd}` };
|
|
2146
|
+
}
|
|
2147
|
+
}
|
|
2148
|
+
|
|
2149
|
+
/**
|
|
2150
|
+
* Serialize a Zod schema to a plain JSON-compatible object suitable for the
|
|
2151
|
+
* schema registry `schema.get` response. Covers the shapes used in
|
|
2152
|
+
* event-schemas.ts; unknown wrapper types fall back to `{ type: typeName }`.
|
|
2153
|
+
*/
|
|
2154
|
+
function serializeZodSchema(schema: z.ZodTypeAny): Record<string, unknown> {
|
|
2155
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
2156
|
+
const def = (schema as any)._def as Record<string, unknown>;
|
|
2157
|
+
const typeName = String(def.typeName ?? 'unknown');
|
|
2158
|
+
|
|
2159
|
+
switch (typeName) {
|
|
2160
|
+
case 'ZodObject': {
|
|
2161
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
2162
|
+
const shapeMap = (schema as any).shape as Record<string, z.ZodTypeAny>;
|
|
2163
|
+
const fields: Record<string, unknown> = {};
|
|
2164
|
+
for (const [k, v] of Object.entries(shapeMap)) {
|
|
2165
|
+
fields[k] = serializeZodSchema(v);
|
|
2166
|
+
}
|
|
2167
|
+
return { type: 'object', fields };
|
|
2168
|
+
}
|
|
2169
|
+
case 'ZodString': return { type: 'string' };
|
|
2170
|
+
case 'ZodNumber': return { type: 'number' };
|
|
2171
|
+
case 'ZodBoolean': return { type: 'boolean' };
|
|
2172
|
+
case 'ZodArray':
|
|
2173
|
+
return { type: 'array', items: serializeZodSchema(def.type as z.ZodTypeAny) };
|
|
2174
|
+
case 'ZodEnum':
|
|
2175
|
+
return { type: 'enum', values: def.values as string[] };
|
|
2176
|
+
case 'ZodOptional':
|
|
2177
|
+
return { ...serializeZodSchema(def.innerType as z.ZodTypeAny), optional: true };
|
|
2178
|
+
case 'ZodNullable':
|
|
2179
|
+
return { ...serializeZodSchema(def.innerType as z.ZodTypeAny), nullable: true };
|
|
2180
|
+
case 'ZodLiteral':
|
|
2181
|
+
return { type: 'literal', value: def.value as unknown };
|
|
2182
|
+
case 'ZodRecord':
|
|
2183
|
+
return { type: 'record', values: serializeZodSchema(def.valueType as z.ZodTypeAny) };
|
|
2184
|
+
case 'ZodUnknown': return { type: 'unknown' };
|
|
2185
|
+
default: return { type: typeName };
|
|
2186
|
+
}
|
|
2187
|
+
}
|
|
2188
|
+
|
|
2189
|
+
/**
|
|
2190
|
+
* Return the minor-version drift between two "major.minor.patch" strings.
|
|
2191
|
+
*
|
|
2192
|
+
* compareMinorDrift('0.4.0', '0.5.0') === 1
|
|
2193
|
+
* compareMinorDrift('0.5.1', '0.5.0') === -1 (client newer)
|
|
2194
|
+
* compareMinorDrift('1.0.0', '0.5.0') === 5 (crude, cross-major-bump)
|
|
2195
|
+
*
|
|
2196
|
+
* Non-semver strings return 0 (silently skip warning).
|
|
2197
|
+
*/
|
|
2198
|
+
export function compareMinorDrift(client: string, server: string): number {
|
|
2199
|
+
const parse = (s: string): [number, number] | null => {
|
|
2200
|
+
const m = /^(\d+)\.(\d+)\./.exec(s);
|
|
2201
|
+
if (!m) return null;
|
|
2202
|
+
return [parseInt(m[1], 10), parseInt(m[2], 10)];
|
|
2203
|
+
};
|
|
2204
|
+
const c = parse(client);
|
|
2205
|
+
const s = parse(server);
|
|
2206
|
+
if (!c || !s) return 0;
|
|
2207
|
+
// Crude drift: server-minor minus client-minor plus 10x major-drift. Good
|
|
2208
|
+
// enough to flag "client is 1+ minor releases behind".
|
|
2209
|
+
return (s[0] - c[0]) * 10 + (s[1] - c[1]);
|
|
2210
|
+
}
|