trekoon 0.4.1 → 0.4.2
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/.agents/skills/trekoon/SKILL.md +20 -577
- package/.agents/skills/trekoon/reference/execution-with-team.md +21 -9
- package/.agents/skills/trekoon/reference/execution.md +246 -7
- package/.agents/skills/trekoon/reference/planning.md +138 -1
- package/.agents/skills/trekoon/reference/status-machine.md +21 -0
- package/.agents/skills/trekoon/reference/sync.md +129 -0
- package/README.md +8 -1
- package/docs/ai-agents.md +17 -2
- package/docs/commands.md +147 -3
- package/docs/machine-contracts.md +123 -0
- package/docs/quickstart.md +52 -0
- package/package.json +1 -1
- package/src/board/assets/app.js +45 -13
- package/src/board/assets/components/Component.js +22 -8
- package/src/board/assets/components/Workspace.js +9 -3
- package/src/board/assets/components/helpers.js +4 -0
- package/src/board/assets/runtime/delegation.js +8 -0
- package/src/board/assets/runtime/focus-trap.js +48 -0
- package/src/board/assets/state/actions.js +42 -4
- package/src/board/assets/state/api.js +284 -11
- package/src/board/assets/state/store.js +79 -11
- package/src/board/assets/state/url.js +10 -0
- package/src/board/assets/state/utils.js +2 -1
- package/src/board/event-bus.ts +72 -0
- package/src/board/routes.ts +412 -33
- package/src/board/server.ts +77 -8
- package/src/board/wal-watcher.ts +302 -0
- package/src/commands/board.ts +52 -17
- package/src/commands/epic.ts +7 -9
- package/src/commands/error-utils.ts +54 -1
- package/src/commands/help.ts +69 -4
- package/src/commands/migrate.ts +153 -24
- package/src/commands/quickstart.ts +7 -0
- package/src/commands/subtask.ts +71 -10
- package/src/commands/suggest.ts +6 -13
- package/src/commands/task.ts +137 -88
- package/src/domain/batch-validation.ts +329 -0
- package/src/domain/cascade-planner.ts +412 -0
- package/src/domain/dependency-rules.ts +15 -0
- package/src/domain/mutation-service.ts +828 -192
- package/src/domain/search.ts +113 -0
- package/src/domain/tracker-domain.ts +150 -680
- package/src/domain/types.ts +53 -2
- package/src/index.ts +37 -0
- package/src/runtime/cli-shell.ts +44 -0
- package/src/runtime/daemon.ts +639 -0
- package/src/storage/backup.ts +166 -0
- package/src/storage/database.ts +261 -4
- package/src/storage/migrations.ts +422 -20
- package/src/storage/path.ts +8 -0
- package/src/storage/schema.ts +5 -1
- package/src/sync/event-writes.ts +38 -11
- package/src/sync/git-context.ts +226 -8
- package/src/sync/service.ts +650 -147
|
@@ -0,0 +1,639 @@
|
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, unlinkSync, statSync } from "node:fs";
|
|
2
|
+
import { connect, createServer, type Server, type Socket } from "node:net";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
import { redactStack, safeErrorMessage } from "../commands/error-utils";
|
|
5
|
+
import { closeCachedDatabases } from "../storage/database";
|
|
6
|
+
import { resolveStoragePaths } from "../storage/path";
|
|
7
|
+
|
|
8
|
+
import { executeShell, parseInvocation, renderShellResult } from "./cli-shell";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Daemon spike (experimental).
|
|
12
|
+
*
|
|
13
|
+
* Runs the trekoon CLI inside a long-lived Bun process listening on a Unix
|
|
14
|
+
* domain socket. Clients submit `{argv, cwd}` payloads; the server runs
|
|
15
|
+
* the same `executeShell` pipeline as the one-shot CLI and returns
|
|
16
|
+
* `{stdout, stderr, exitCode}`.
|
|
17
|
+
*
|
|
18
|
+
* Environment variables are intentionally NOT part of the request contract.
|
|
19
|
+
* The daemon process owns its own environment (set at `trekoon serve`
|
|
20
|
+
* startup). Forwarding the client environment would (a) leak secrets across
|
|
21
|
+
* the socket, (b) require the server to apply them, which it does not, and
|
|
22
|
+
* (c) muddy the equivalence claim with the one-shot CLI. Per-call envs are
|
|
23
|
+
* not supported — narrow the equivalence claim instead.
|
|
24
|
+
*
|
|
25
|
+
* Status: NOT the default path. Activated via `TREKOON_DAEMON=1` or the
|
|
26
|
+
* `--daemon` flag. The one-shot CLI behavior is unchanged when the daemon is
|
|
27
|
+
* unused or unreachable.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
export interface DaemonRequest {
|
|
31
|
+
readonly argv: readonly string[];
|
|
32
|
+
readonly cwd: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface DaemonResponse {
|
|
36
|
+
readonly stdout: string;
|
|
37
|
+
readonly stderr: string;
|
|
38
|
+
readonly exitCode: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const REQUEST_TERMINATOR = "\n";
|
|
42
|
+
const MAX_REQUEST_BYTES = 1_000_000;
|
|
43
|
+
/** Maximum bytes buffered across all open server sockets at once. */
|
|
44
|
+
const MAX_TOTAL_BUFFERED_BYTES = 8 * MAX_REQUEST_BYTES;
|
|
45
|
+
/** Default cap on concurrent open server-side sockets. */
|
|
46
|
+
const DEFAULT_MAX_CONNECTIONS = 32;
|
|
47
|
+
/** Idle/incomplete-request timeout for a server-side socket. */
|
|
48
|
+
const SERVER_SOCKET_IDLE_MS = 5_000;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Pre-write transport failure: the daemon socket was unreachable or the
|
|
52
|
+
* request never made it onto the wire. Safe to fall back to in-process
|
|
53
|
+
* dispatch — no server-side mutation could have run.
|
|
54
|
+
*/
|
|
55
|
+
export class PreWriteTransportError extends Error {
|
|
56
|
+
public override readonly cause: unknown;
|
|
57
|
+
public constructor(message: string, cause?: unknown) {
|
|
58
|
+
super(message);
|
|
59
|
+
this.name = "PreWriteTransportError";
|
|
60
|
+
this.cause = cause;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Post-write transport failure: the request bytes were already flushed to
|
|
66
|
+
* the daemon socket when the failure occurred. The server may have
|
|
67
|
+
* committed the mutation. The CLI must NOT silently re-run the command in
|
|
68
|
+
* process — exit non-zero so the caller can decide.
|
|
69
|
+
*/
|
|
70
|
+
export class PostWriteError extends Error {
|
|
71
|
+
public override readonly cause: unknown;
|
|
72
|
+
public constructor(message: string, cause?: unknown) {
|
|
73
|
+
super(message);
|
|
74
|
+
this.name = "PostWriteError";
|
|
75
|
+
this.cause = cause;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Resolve the canonical Unix socket path for the given working directory.
|
|
81
|
+
* The socket lives next to the SQLite database under `.trekoon/`.
|
|
82
|
+
*/
|
|
83
|
+
export function resolveDaemonSocketPath(cwd: string = process.cwd()): string {
|
|
84
|
+
const paths = resolveStoragePaths(cwd);
|
|
85
|
+
return `${paths.storageDir}/daemon.sock`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function safeUnlink(path: string): void {
|
|
89
|
+
try {
|
|
90
|
+
unlinkSync(path);
|
|
91
|
+
} catch (error: unknown) {
|
|
92
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
93
|
+
throw error;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function isPreWriteTransportCode(code: string | undefined): boolean {
|
|
99
|
+
return (
|
|
100
|
+
code === "ENOENT"
|
|
101
|
+
|| code === "ECONNREFUSED"
|
|
102
|
+
|| code === "EACCES"
|
|
103
|
+
|| code === "EPERM"
|
|
104
|
+
|| code === "ETIMEDOUT"
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function debugLog(prefix: string, payload: unknown): void {
|
|
109
|
+
if (process.env.TREKOON_DEBUG === "1") {
|
|
110
|
+
// eslint-disable-next-line no-console
|
|
111
|
+
console.error(prefix, payload);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (payload instanceof Error) {
|
|
115
|
+
const message: string = safeErrorMessage(payload, "unknown error");
|
|
116
|
+
// eslint-disable-next-line no-console
|
|
117
|
+
console.error(`${prefix} ${message}`);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
// eslint-disable-next-line no-console
|
|
121
|
+
console.error(prefix);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Execute a single daemon request through the in-process CLI shell.
|
|
126
|
+
* Exported for direct unit testing without a socket round-trip.
|
|
127
|
+
*/
|
|
128
|
+
export async function executeDaemonRequest(request: DaemonRequest): Promise<DaemonResponse> {
|
|
129
|
+
const argv: readonly string[] = request.argv;
|
|
130
|
+
const cwd: string = request.cwd;
|
|
131
|
+
const parsed = parseInvocation(argv, { stdoutIsTTY: false });
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
const result = await executeShell(parsed, cwd);
|
|
135
|
+
const rendered: string = renderShellResult(result, parsed.mode, parsed.compatibilityMode, {
|
|
136
|
+
compact: parsed.compact,
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
if (result.ok) {
|
|
140
|
+
return {
|
|
141
|
+
stdout: `${rendered}\n`,
|
|
142
|
+
stderr: "",
|
|
143
|
+
exitCode: 0,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
stdout: "",
|
|
149
|
+
stderr: `${rendered}\n`,
|
|
150
|
+
exitCode: 1,
|
|
151
|
+
};
|
|
152
|
+
} catch (error: unknown) {
|
|
153
|
+
// Never include the stack in the response envelope: stacks contain
|
|
154
|
+
// absolute filesystem paths and may carry secret-bearing error text.
|
|
155
|
+
// The stack is still surfaced locally on the daemon's stderr for
|
|
156
|
+
// operator-side debugging, with secrets redacted unless TREKOON_DEBUG=1.
|
|
157
|
+
if (error instanceof Error && typeof error.stack === "string") {
|
|
158
|
+
const stack: string = process.env.TREKOON_DEBUG === "1"
|
|
159
|
+
? error.stack
|
|
160
|
+
: redactStack(error.stack);
|
|
161
|
+
// eslint-disable-next-line no-console
|
|
162
|
+
console.error("[trekoon daemon] dispatch failure:", stack);
|
|
163
|
+
} else {
|
|
164
|
+
// eslint-disable-next-line no-console
|
|
165
|
+
console.error("[trekoon daemon] dispatch failure:", error);
|
|
166
|
+
}
|
|
167
|
+
const sanitized: string = safeErrorMessage(error, "unknown error");
|
|
168
|
+
return {
|
|
169
|
+
stdout: "",
|
|
170
|
+
stderr: `Daemon dispatch failure: ${sanitized}\n`,
|
|
171
|
+
exitCode: 1,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export interface DaemonServerHandle {
|
|
177
|
+
readonly socketPath: string;
|
|
178
|
+
readonly server: Server;
|
|
179
|
+
close(): Promise<void>;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export interface StartDaemonOptions {
|
|
183
|
+
readonly socketPath?: string;
|
|
184
|
+
readonly cwd?: string;
|
|
185
|
+
/** Suppress stdout banner; used by tests. */
|
|
186
|
+
readonly silent?: boolean;
|
|
187
|
+
/** Override the default concurrent connection cap. */
|
|
188
|
+
readonly maxConnections?: number;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Start the daemon server. Binds the Unix socket with mode 0o600 and ensures
|
|
193
|
+
* the parent directory has mode 0o700. Returns a handle for graceful
|
|
194
|
+
* shutdown. The CLI dispatch is run via `executeDaemonRequest`.
|
|
195
|
+
*/
|
|
196
|
+
export async function startDaemonServer(options: StartDaemonOptions = {}): Promise<DaemonServerHandle> {
|
|
197
|
+
// Flip the in-process DB cache flag so subsequent openTrekoonDatabase calls
|
|
198
|
+
// reuse a held-open connection. The default one-shot CLI never sets this.
|
|
199
|
+
process.env.TREKOON_DAEMON_INPROCESS = "1";
|
|
200
|
+
|
|
201
|
+
const cwd: string = options.cwd ?? process.cwd();
|
|
202
|
+
const socketPath: string = options.socketPath ?? resolveDaemonSocketPath(cwd);
|
|
203
|
+
const socketDir: string = dirname(socketPath);
|
|
204
|
+
const maxConnections: number = options.maxConnections ?? DEFAULT_MAX_CONNECTIONS;
|
|
205
|
+
|
|
206
|
+
if (!existsSync(socketDir)) {
|
|
207
|
+
mkdirSync(socketDir, { recursive: true, mode: 0o700 });
|
|
208
|
+
}
|
|
209
|
+
// Tighten parent dir perms even if it pre-existed.
|
|
210
|
+
try {
|
|
211
|
+
chmodSync(socketDir, 0o700);
|
|
212
|
+
} catch {
|
|
213
|
+
// best effort; not all filesystems honour this
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Stale socket from a previous crashed run.
|
|
217
|
+
safeUnlink(socketPath);
|
|
218
|
+
|
|
219
|
+
// Tracks open server-side sockets and the bytes each one currently has
|
|
220
|
+
// buffered. Used to enforce both the per-server connection cap and a
|
|
221
|
+
// global upper bound on memory pressure across all in-flight requests.
|
|
222
|
+
const liveSockets: Set<Socket> = new Set<Socket>();
|
|
223
|
+
let totalBufferedBytes = 0;
|
|
224
|
+
|
|
225
|
+
const server: Server = createServer((socket: Socket): void => {
|
|
226
|
+
if (liveSockets.size >= maxConnections) {
|
|
227
|
+
try {
|
|
228
|
+
socket.write(
|
|
229
|
+
`${JSON.stringify({
|
|
230
|
+
stdout: "",
|
|
231
|
+
stderr: "Daemon: daemon_busy (too many concurrent connections)\n",
|
|
232
|
+
exitCode: 1,
|
|
233
|
+
})}\n`,
|
|
234
|
+
);
|
|
235
|
+
} catch {
|
|
236
|
+
// best effort
|
|
237
|
+
}
|
|
238
|
+
socket.end();
|
|
239
|
+
socket.destroy();
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
liveSockets.add(socket);
|
|
244
|
+
let buffer = "";
|
|
245
|
+
let aborted = false;
|
|
246
|
+
let perSocketBytes = 0;
|
|
247
|
+
|
|
248
|
+
socket.setEncoding("utf8");
|
|
249
|
+
// Reject sockets that connect, never send a terminator, and sit idle —
|
|
250
|
+
// these accumulate file descriptors and buffer memory otherwise.
|
|
251
|
+
socket.setTimeout(SERVER_SOCKET_IDLE_MS);
|
|
252
|
+
|
|
253
|
+
const releaseBuffered = (): void => {
|
|
254
|
+
totalBufferedBytes -= perSocketBytes;
|
|
255
|
+
perSocketBytes = 0;
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
const onClose = (): void => {
|
|
259
|
+
liveSockets.delete(socket);
|
|
260
|
+
releaseBuffered();
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
socket.on("timeout", (): void => {
|
|
264
|
+
if (aborted) {
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
aborted = true;
|
|
268
|
+
try {
|
|
269
|
+
socket.write(
|
|
270
|
+
`${JSON.stringify({
|
|
271
|
+
stdout: "",
|
|
272
|
+
stderr: "Daemon: incomplete request (socket idle timeout)\n",
|
|
273
|
+
exitCode: 1,
|
|
274
|
+
})}\n`,
|
|
275
|
+
);
|
|
276
|
+
} catch {
|
|
277
|
+
// best effort
|
|
278
|
+
}
|
|
279
|
+
socket.end();
|
|
280
|
+
socket.destroy();
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
socket.on("data", (chunk: string): void => {
|
|
284
|
+
if (aborted) {
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
const chunkBytes: number = Buffer.byteLength(chunk, "utf8");
|
|
288
|
+
buffer += chunk;
|
|
289
|
+
perSocketBytes += chunkBytes;
|
|
290
|
+
totalBufferedBytes += chunkBytes;
|
|
291
|
+
|
|
292
|
+
if (buffer.length > MAX_REQUEST_BYTES || totalBufferedBytes > MAX_TOTAL_BUFFERED_BYTES) {
|
|
293
|
+
aborted = true;
|
|
294
|
+
const reason: string = totalBufferedBytes > MAX_TOTAL_BUFFERED_BYTES
|
|
295
|
+
? "daemon_busy (server buffer pressure)"
|
|
296
|
+
: "request exceeded max bytes";
|
|
297
|
+
try {
|
|
298
|
+
socket.write(
|
|
299
|
+
`${JSON.stringify({
|
|
300
|
+
stdout: "",
|
|
301
|
+
stderr: `Daemon: ${reason}\n`,
|
|
302
|
+
exitCode: 1,
|
|
303
|
+
})}\n`,
|
|
304
|
+
);
|
|
305
|
+
} catch {
|
|
306
|
+
// best effort
|
|
307
|
+
}
|
|
308
|
+
socket.end();
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const terminatorIndex: number = buffer.indexOf(REQUEST_TERMINATOR);
|
|
313
|
+
if (terminatorIndex < 0) {
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
aborted = true;
|
|
318
|
+
const payload: string = buffer.slice(0, terminatorIndex);
|
|
319
|
+
buffer = "";
|
|
320
|
+
releaseBuffered();
|
|
321
|
+
// Once we have a complete request the idle timer is no longer
|
|
322
|
+
// meaningful; the dispatcher controls the lifetime from here.
|
|
323
|
+
socket.setTimeout(0);
|
|
324
|
+
|
|
325
|
+
void handlePayload(payload, socket);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
socket.on("error", (error: Error): void => {
|
|
329
|
+
// Surface as a debug-level redacted log; never as a bare empty handler.
|
|
330
|
+
debugLog("[trekoon daemon] socket error:", error);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
socket.on("close", onClose);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
await new Promise<void>((resolve, reject): void => {
|
|
337
|
+
const onError = (error: Error): void => {
|
|
338
|
+
server.removeListener("listening", onListening);
|
|
339
|
+
reject(error);
|
|
340
|
+
};
|
|
341
|
+
const onListening = (): void => {
|
|
342
|
+
server.removeListener("error", onError);
|
|
343
|
+
resolve();
|
|
344
|
+
};
|
|
345
|
+
server.once("error", onError);
|
|
346
|
+
server.once("listening", onListening);
|
|
347
|
+
server.listen(socketPath);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
// Owner-only mode is enforced by the pre-created 0o700 parent directory
|
|
351
|
+
// plus this post-listen chmod. We deliberately do NOT wrap server.listen
|
|
352
|
+
// in a process.umask() override: that mutates global process state for
|
|
353
|
+
// every concurrent operation and was the source of an audit-flagged
|
|
354
|
+
// race when other code allocates fds during startup.
|
|
355
|
+
try {
|
|
356
|
+
chmodSync(socketPath, 0o600);
|
|
357
|
+
} catch {
|
|
358
|
+
// best effort
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (!options.silent) {
|
|
362
|
+
process.stdout.write(`trekoon daemon listening on ${socketPath}\n`);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const handle: DaemonServerHandle = {
|
|
366
|
+
socketPath,
|
|
367
|
+
server,
|
|
368
|
+
close: (): Promise<void> =>
|
|
369
|
+
new Promise<void>((resolve): void => {
|
|
370
|
+
// Force-close any sockets still parked on the connection cap path so
|
|
371
|
+
// server.close() resolves promptly during test teardown.
|
|
372
|
+
for (const sock of liveSockets) {
|
|
373
|
+
try {
|
|
374
|
+
sock.destroy();
|
|
375
|
+
} catch {
|
|
376
|
+
// best effort
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
liveSockets.clear();
|
|
380
|
+
totalBufferedBytes = 0;
|
|
381
|
+
server.close((): void => {
|
|
382
|
+
safeUnlink(socketPath);
|
|
383
|
+
closeCachedDatabases();
|
|
384
|
+
delete process.env.TREKOON_DAEMON_INPROCESS;
|
|
385
|
+
resolve();
|
|
386
|
+
});
|
|
387
|
+
}),
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
return handle;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
async function handlePayload(payload: string, socket: Socket): Promise<void> {
|
|
394
|
+
let response: DaemonResponse;
|
|
395
|
+
try {
|
|
396
|
+
const parsed: unknown = JSON.parse(payload);
|
|
397
|
+
if (!isDaemonRequest(parsed)) {
|
|
398
|
+
response = {
|
|
399
|
+
stdout: "",
|
|
400
|
+
stderr: "Daemon: invalid request payload\n",
|
|
401
|
+
exitCode: 1,
|
|
402
|
+
};
|
|
403
|
+
} else {
|
|
404
|
+
response = await executeDaemonRequest(parsed);
|
|
405
|
+
}
|
|
406
|
+
} catch (error: unknown) {
|
|
407
|
+
// Sanitize and never include a stack — the wire envelope must not leak
|
|
408
|
+
// filesystem paths or secret-bearing error text. The stack stays on the
|
|
409
|
+
// daemon's local stderr for operator debugging, redacted unless
|
|
410
|
+
// TREKOON_DEBUG=1 explicitly opts in to raw output.
|
|
411
|
+
if (error instanceof Error && typeof error.stack === "string") {
|
|
412
|
+
const stack: string = process.env.TREKOON_DEBUG === "1"
|
|
413
|
+
? error.stack
|
|
414
|
+
: redactStack(error.stack);
|
|
415
|
+
// eslint-disable-next-line no-console
|
|
416
|
+
console.error("[trekoon daemon] payload parse error:", stack);
|
|
417
|
+
}
|
|
418
|
+
const sanitized: string = safeErrorMessage(error, "invalid payload");
|
|
419
|
+
response = {
|
|
420
|
+
stdout: "",
|
|
421
|
+
stderr: `Daemon: payload parse error: ${sanitized}\n`,
|
|
422
|
+
exitCode: 1,
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const serialized: string = `${JSON.stringify(response)}\n`;
|
|
427
|
+
socket.write(serialized, (): void => {
|
|
428
|
+
socket.end();
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function isDaemonRequest(value: unknown): value is DaemonRequest {
|
|
433
|
+
if (!value || typeof value !== "object") {
|
|
434
|
+
return false;
|
|
435
|
+
}
|
|
436
|
+
const candidate = value as Record<string, unknown>;
|
|
437
|
+
if (!Array.isArray(candidate.argv)) {
|
|
438
|
+
return false;
|
|
439
|
+
}
|
|
440
|
+
if (!candidate.argv.every((entry): boolean => typeof entry === "string")) {
|
|
441
|
+
return false;
|
|
442
|
+
}
|
|
443
|
+
if (typeof candidate.cwd !== "string") {
|
|
444
|
+
return false;
|
|
445
|
+
}
|
|
446
|
+
// env is no longer part of the request contract; ignore it if present from
|
|
447
|
+
// older clients rather than failing the request.
|
|
448
|
+
return true;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
export interface DaemonClientResult extends DaemonResponse {
|
|
452
|
+
readonly transport: "daemon";
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Send a single request to a running daemon. Resolves with the parsed
|
|
457
|
+
* response. Throws `PreWriteTransportError` for connect-time failures (the
|
|
458
|
+
* caller may safely fall back to in-process dispatch) and `PostWriteError`
|
|
459
|
+
* once the request bytes have been flushed (the caller MUST surface the
|
|
460
|
+
* failure rather than retrying — the daemon may have already committed).
|
|
461
|
+
*/
|
|
462
|
+
export async function sendDaemonRequest(
|
|
463
|
+
socketPath: string,
|
|
464
|
+
request: DaemonRequest,
|
|
465
|
+
timeoutMs: number = 30_000,
|
|
466
|
+
): Promise<DaemonClientResult> {
|
|
467
|
+
return new Promise<DaemonClientResult>((resolve, reject): void => {
|
|
468
|
+
const socket: Socket = connect(socketPath);
|
|
469
|
+
let buffer = "";
|
|
470
|
+
let settled = false;
|
|
471
|
+
// Flips to true the moment the request bytes are flushed to the kernel
|
|
472
|
+
// socket buffer. Any subsequent error/timeout MUST surface as
|
|
473
|
+
// PostWriteError because the daemon may have already executed the
|
|
474
|
+
// mutation.
|
|
475
|
+
let postWrite = false;
|
|
476
|
+
|
|
477
|
+
const fail = (error: unknown): void => {
|
|
478
|
+
if (settled) {
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
settled = true;
|
|
482
|
+
clearTimeout(timer);
|
|
483
|
+
try {
|
|
484
|
+
socket.destroy();
|
|
485
|
+
} catch {
|
|
486
|
+
// best effort
|
|
487
|
+
}
|
|
488
|
+
if (postWrite) {
|
|
489
|
+
const cause: unknown = error;
|
|
490
|
+
const message: string = error instanceof Error ? error.message : String(error);
|
|
491
|
+
reject(new PostWriteError(`daemon may have committed; do not retry: ${message}`, cause));
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
if (error instanceof Error) {
|
|
495
|
+
const code: string | undefined = (error as NodeJS.ErrnoException).code;
|
|
496
|
+
if (isPreWriteTransportCode(code)) {
|
|
497
|
+
reject(new PreWriteTransportError(error.message, error));
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
reject(new PreWriteTransportError(error.message, error));
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
reject(new PreWriteTransportError(String(error), error));
|
|
504
|
+
};
|
|
505
|
+
|
|
506
|
+
const timer = setTimeout((): void => {
|
|
507
|
+
if (settled) {
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
// Build the timeout error before we settle so the post/pre-write
|
|
511
|
+
// classifier picks up the current `postWrite` flag.
|
|
512
|
+
const timeoutError = new Error(`daemon request timed out after ${timeoutMs}ms`);
|
|
513
|
+
fail(timeoutError);
|
|
514
|
+
}, timeoutMs);
|
|
515
|
+
|
|
516
|
+
socket.setEncoding("utf8");
|
|
517
|
+
|
|
518
|
+
socket.on("connect", (): void => {
|
|
519
|
+
const wireBytes: string = `${JSON.stringify(request)}${REQUEST_TERMINATOR}`;
|
|
520
|
+
socket.write(wireBytes, (writeError?: Error | null): void => {
|
|
521
|
+
if (writeError) {
|
|
522
|
+
fail(writeError);
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
// The bytes are buffered into the kernel socket; from this point
|
|
526
|
+
// on the daemon may execute the request. Any error/timeout that
|
|
527
|
+
// follows must be classified as PostWriteError.
|
|
528
|
+
postWrite = true;
|
|
529
|
+
});
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
socket.on("data", (chunk: string): void => {
|
|
533
|
+
buffer += chunk;
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
socket.on("error", (error: Error): void => {
|
|
537
|
+
fail(error);
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
socket.on("end", (): void => {
|
|
541
|
+
if (settled) {
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
settled = true;
|
|
545
|
+
clearTimeout(timer);
|
|
546
|
+
try {
|
|
547
|
+
const trimmed: string = buffer.trim();
|
|
548
|
+
const parsed: unknown = JSON.parse(trimmed);
|
|
549
|
+
if (!isDaemonResponse(parsed)) {
|
|
550
|
+
// We did receive a (malformed) response, so the request was
|
|
551
|
+
// post-write — surface as PostWriteError so the CLI does not
|
|
552
|
+
// silently re-run.
|
|
553
|
+
reject(new PostWriteError("daemon returned malformed response"));
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
resolve({ ...parsed, transport: "daemon" });
|
|
557
|
+
} catch (error: unknown) {
|
|
558
|
+
const message: string = error instanceof Error ? error.message : String(error);
|
|
559
|
+
reject(new PostWriteError(`daemon response parse error: ${message}`, error));
|
|
560
|
+
}
|
|
561
|
+
});
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function isDaemonResponse(value: unknown): value is DaemonResponse {
|
|
566
|
+
if (!value || typeof value !== "object") {
|
|
567
|
+
return false;
|
|
568
|
+
}
|
|
569
|
+
const candidate = value as Record<string, unknown>;
|
|
570
|
+
return (
|
|
571
|
+
typeof candidate.stdout === "string"
|
|
572
|
+
&& typeof candidate.stderr === "string"
|
|
573
|
+
&& typeof candidate.exitCode === "number"
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* Heuristic check that a daemon socket is live: file exists, looks like a
|
|
579
|
+
* socket, and its directory is on the local filesystem. We do NOT round-trip
|
|
580
|
+
* a ping here — the client retries the real request and falls back on error.
|
|
581
|
+
*/
|
|
582
|
+
export function isDaemonSocketPresent(socketPath: string): boolean {
|
|
583
|
+
try {
|
|
584
|
+
const stats = statSync(socketPath);
|
|
585
|
+
return stats.isSocket();
|
|
586
|
+
} catch {
|
|
587
|
+
return false;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* Run the daemon in the foreground until SIGINT/SIGTERM. Used by
|
|
593
|
+
* `trekoon serve`.
|
|
594
|
+
*/
|
|
595
|
+
export async function runDaemonForeground(options: StartDaemonOptions = {}): Promise<void> {
|
|
596
|
+
const handle = await startDaemonServer(options);
|
|
597
|
+
|
|
598
|
+
if (!options.silent) {
|
|
599
|
+
process.stdout.write("Press Ctrl-C to stop. (experimental spike)\n");
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
await new Promise<void>((resolve): void => {
|
|
603
|
+
const shutdown = (): void => {
|
|
604
|
+
void handle.close().then((): void => {
|
|
605
|
+
resolve();
|
|
606
|
+
});
|
|
607
|
+
};
|
|
608
|
+
process.once("SIGINT", shutdown);
|
|
609
|
+
process.once("SIGTERM", shutdown);
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* Try to dispatch the invocation through the daemon. Returns the rendered
|
|
615
|
+
* response on success, or `null` ONLY when the failure happened pre-write
|
|
616
|
+
* (no bytes left this process). Post-write failures are rethrown as
|
|
617
|
+
* `PostWriteError` so the caller can exit non-zero rather than
|
|
618
|
+
* re-executing in-process — the daemon may have committed.
|
|
619
|
+
*/
|
|
620
|
+
export async function tryDaemonDispatch(argv: readonly string[]): Promise<DaemonClientResult | null> {
|
|
621
|
+
const cwd: string = process.cwd();
|
|
622
|
+
const socketPath: string = resolveDaemonSocketPath(cwd);
|
|
623
|
+
if (!isDaemonSocketPresent(socketPath)) {
|
|
624
|
+
return null;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
try {
|
|
628
|
+
// The daemon owns its own environment (set at `trekoon serve` startup);
|
|
629
|
+
// client env is deliberately NOT forwarded over the socket.
|
|
630
|
+
return await sendDaemonRequest(socketPath, { argv: [...argv], cwd });
|
|
631
|
+
} catch (error: unknown) {
|
|
632
|
+
if (error instanceof PreWriteTransportError) {
|
|
633
|
+
return null;
|
|
634
|
+
}
|
|
635
|
+
// PostWriteError or any unclassified failure must surface so src/index.ts
|
|
636
|
+
// can refuse to silently re-run the command.
|
|
637
|
+
throw error;
|
|
638
|
+
}
|
|
639
|
+
}
|