tmux-ide 2.9.0-beta.19 → 2.9.0-beta.21
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/bin/cli.js +1979 -783
- package/package.json +4 -2
- package/packages/contracts/src/__tests__/daemon-wire.test.ts +32 -0
- package/packages/contracts/src/daemon-events.ts +12 -12
- package/packages/contracts/src/daemon-wire.ts +30 -0
- package/packages/daemon/dist/command-center/agent-status-watch.js +9 -2
- package/packages/daemon/dist/command-center/diagnostics.js +65 -0
- package/packages/daemon/dist/command-center/log-stream.js +9 -0
- package/packages/daemon/dist/command-center/resources/fleet-preview-route.js +22 -6
- package/packages/daemon/dist/command-center/server.js +7 -0
- package/packages/daemon/dist/doctor.js +62 -0
- package/packages/daemon/dist/lib/__tests__/installed-recovery-fixture.js +400 -0
- package/packages/daemon/dist/lib/app-config.js +4 -2
- package/packages/daemon/dist/lib/canonical-daemon.js +1 -0
- package/packages/daemon/dist/lib/daemon-embed.js +24 -0
- package/packages/daemon/dist/lib/daemon-provenance.js +575 -0
- package/packages/daemon/dist/lib/headless-daemon.js +1 -0
- package/packages/daemon/dist/lib/log-sanitize.js +199 -0
- package/packages/daemon/dist/lib/log.js +123 -13
- package/packages/daemon/dist/lib/soak-diagnostics.js +124 -0
- package/packages/daemon/dist/lib/soak-verdict.js +472 -0
- package/packages/daemon/dist/lib/terminal-host-color.js +33 -0
- package/packages/daemon/dist/lib/tmux-external-interaction-observer.js +187 -39
- package/packages/daemon/dist/lib/tmux-interaction-retention.js +21 -0
- package/packages/daemon/dist/lib/workspace-promotion.js +55 -37
- package/packages/daemon/dist/terminal/mirror/session-channel.js +2 -1
- package/packages/daemon/dist/tui/mirror/automatic-contrast.js +161 -0
- package/packages/daemon/dist/tui/mirror/open-tui-workspace-runtime-port.js +9 -3
- package/packages/daemon/dist/tui/mirror/pane-surface.jsx +7 -5
- package/packages/daemon/dist/tui/mirror/resize-transaction.js +48 -20
- package/packages/daemon/dist/tui/mirror/runtime/application-appearance-owner.js +40 -6
- package/packages/daemon/dist/tui/mirror/runtime/application-fleet-preview.js +4 -1
- package/packages/daemon/dist/tui/mirror/runtime/application-machine-sidebar.jsx +63 -42
- package/packages/daemon/dist/tui/mirror/runtime/application-terminal-interaction-controller.js +138 -67
- package/packages/daemon/dist/tui/mirror/runtime/application-terminal-palette-owner.js +44 -28
- package/packages/daemon/dist/tui/mirror/runtime/application-terminal-workspace.jsx +49 -11
- package/packages/daemon/dist/tui/mirror/runtime/semantic-shell-viewport-resize.js +140 -2
- package/packages/daemon/dist/tui/mirror/runtime/workspace-terminal-fast-lane.js +3 -1
- package/packages/daemon/dist/tui/mirror/semantic-pane-render-source.js +2 -1
- package/packages/daemon/dist/tui/mirror/theme.js +4 -31
- package/packages/daemon/dist/tui/mirror/workspace/terminal-pane-header.jsx +15 -8
- package/packages/daemon/dist/tui/team/wait-receipts.js +112 -33
- package/packages/daemon/src/command-center/agent-status-watch.ts +8 -2
- package/packages/daemon/src/command-center/diagnostics.ts +75 -0
- package/packages/daemon/src/command-center/log-stream.ts +8 -0
- package/packages/daemon/src/command-center/resources/fleet-preview-route.ts +27 -5
- package/packages/daemon/src/command-center/server.ts +8 -0
- package/packages/daemon/src/doctor.ts +70 -0
- package/packages/daemon/src/lib/app-config.ts +11 -3
- package/packages/daemon/src/lib/canonical-daemon.ts +1 -0
- package/packages/daemon/src/lib/daemon-embed.ts +30 -0
- package/packages/daemon/src/lib/daemon-provenance.ts +769 -0
- package/packages/daemon/src/lib/fleet-preview-model.ts +1 -0
- package/packages/daemon/src/lib/headless-daemon.ts +1 -0
- package/packages/daemon/src/lib/log-sanitize.ts +248 -0
- package/packages/daemon/src/lib/log.ts +165 -14
- package/packages/daemon/src/lib/soak-diagnostics.ts +183 -0
- package/packages/daemon/src/lib/soak-verdict.ts +785 -0
- package/packages/daemon/src/lib/terminal-host-color.ts +43 -0
- package/packages/daemon/src/lib/tmux-external-interaction-observer.ts +233 -37
- package/packages/daemon/src/lib/tmux-interaction-retention.ts +24 -0
- package/packages/daemon/src/lib/workspace-promotion.ts +74 -45
- package/packages/daemon/src/terminal/mirror/session-channel.ts +2 -1
- package/packages/daemon/src/tui/mirror/automatic-contrast.ts +180 -0
- package/packages/daemon/src/tui/mirror/open-tui-workspace-runtime-port.ts +26 -5
- package/packages/daemon/src/tui/mirror/pane-surface.tsx +9 -8
- package/packages/daemon/src/tui/mirror/resize-transaction.ts +46 -22
- package/packages/daemon/src/tui/mirror/runtime/application-appearance-owner.ts +45 -6
- package/packages/daemon/src/tui/mirror/runtime/application-fleet-preview.ts +4 -0
- package/packages/daemon/src/tui/mirror/runtime/application-fleet-switcher.tsx +2 -1
- package/packages/daemon/src/tui/mirror/runtime/application-machine-sidebar.tsx +99 -75
- package/packages/daemon/src/tui/mirror/runtime/application-palette-preview.tsx +13 -3
- package/packages/daemon/src/tui/mirror/runtime/application-reference-sheet.tsx +125 -0
- package/packages/daemon/src/tui/mirror/runtime/application-root-v2.tsx +2 -1
- package/packages/daemon/src/tui/mirror/runtime/application-shell-overlays.tsx +199 -157
- package/packages/daemon/src/tui/mirror/runtime/application-shell-view.tsx +2 -0
- package/packages/daemon/src/tui/mirror/runtime/application-terminal-interaction-controller.ts +148 -69
- package/packages/daemon/src/tui/mirror/runtime/application-terminal-palette-owner.ts +55 -28
- package/packages/daemon/src/tui/mirror/runtime/application-terminal-workspace.tsx +59 -17
- package/packages/daemon/src/tui/mirror/runtime/semantic-shell-viewport-resize.ts +151 -3
- package/packages/daemon/src/tui/mirror/runtime/workspace-terminal-fast-lane.ts +3 -1
- package/packages/daemon/src/tui/mirror/semantic-pane-render-source.ts +2 -1
- package/packages/daemon/src/tui/mirror/theme.ts +4 -39
- package/packages/daemon/src/tui/mirror/workspace/terminal-pane-header.tsx +21 -8
- package/packages/daemon/src/tui/team/wait-receipts.ts +115 -40
- package/packages/daemon-client/src/terminal-fast-lane.test.ts +18 -0
- package/packages/daemon-client/src/terminal-fast-lane.ts +7 -2
- package/packages/tmux-bridge/src/index.ts +2 -0
- package/packages/tmux-bridge/src/runner.test.ts +41 -0
- package/packages/tmux-bridge/src/runner.ts +41 -2
|
@@ -0,0 +1,575 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Daemon identity and log provenance.
|
|
3
|
+
*
|
|
4
|
+
* Two halves:
|
|
5
|
+
*
|
|
6
|
+
* 1. `captureDaemonProvenance` — run by the daemon at startup. It records how
|
|
7
|
+
* the process was launched (headless vs embedded, which supervisor), and
|
|
8
|
+
* where its stdout/stderr ACTUALLY go (file path + dev/ino, tty, pipe,
|
|
9
|
+
* socket, /dev/null). The result is stamped into the daemon record so a
|
|
10
|
+
* later triage derives the live log destination from the record instead of
|
|
11
|
+
* guessing file names. It never throws: anything it cannot determine
|
|
12
|
+
* degrades to `unknown` plus a warning.
|
|
13
|
+
*
|
|
14
|
+
* 2. `buildDaemonProvenanceReport` (pure) + `collectDaemonProvenanceReport`
|
|
15
|
+
* (io) — the credential-free triage report behind `tmux-ide daemon info`
|
|
16
|
+
* and the doctor row. Discovered log files are labeled `current` only when
|
|
17
|
+
* they are the live destination; everything else is `historical` (with the
|
|
18
|
+
* pid they belong to when recorded) or `unattributed`. Files are only ever
|
|
19
|
+
* read, never modified.
|
|
20
|
+
*/
|
|
21
|
+
import { execFileSync } from "node:child_process";
|
|
22
|
+
import { closeSync, fstatSync, lstatSync, openSync, readdirSync, readlinkSync, readSync, realpathSync, statSync, } from "node:fs";
|
|
23
|
+
import { join, resolve } from "node:path";
|
|
24
|
+
import { getCanonicalDaemonInfoPath, inspectCanonicalDaemonInfo, } from "./canonical-daemon.js";
|
|
25
|
+
import { redactText } from "./log-sanitize.js";
|
|
26
|
+
import { resolveRuntimeNamespace } from "./runtime-namespace.js";
|
|
27
|
+
/**
|
|
28
|
+
* PURE — classify the supervising process from launch facts. Environment
|
|
29
|
+
* evidence wins over the reservation id hint; an explicit reservation without
|
|
30
|
+
* environment evidence still counts as that supervisor because the operator
|
|
31
|
+
* asserted it when reserving the namespace.
|
|
32
|
+
*/
|
|
33
|
+
export function classifySupervisor(facts) {
|
|
34
|
+
if (facts.launcher === "embedded")
|
|
35
|
+
return { kind: "embedded", evidence: "startEmbeddedDaemon host" };
|
|
36
|
+
const { env, platform, parentPid, supervisionId } = facts;
|
|
37
|
+
if (env.INVOCATION_ID || env.JOURNAL_STREAM || env.SYSTEMD_EXEC_PID) {
|
|
38
|
+
const markers = ["INVOCATION_ID", "JOURNAL_STREAM", "SYSTEMD_EXEC_PID"].filter((k) => env[k]);
|
|
39
|
+
return { kind: "systemd", evidence: `env ${markers.join(",")}` };
|
|
40
|
+
}
|
|
41
|
+
if (platform === "darwin") {
|
|
42
|
+
const xpc = env.XPC_SERVICE_NAME;
|
|
43
|
+
if (xpc && xpc !== "0" && !xpc.startsWith("application.")) {
|
|
44
|
+
return { kind: "launchd", evidence: `XPC_SERVICE_NAME=${xpc}` };
|
|
45
|
+
}
|
|
46
|
+
if (parentPid === 1 && supervisionId)
|
|
47
|
+
return { kind: "launchd", evidence: "parent pid 1" };
|
|
48
|
+
}
|
|
49
|
+
if (supervisionId) {
|
|
50
|
+
if (/\.(service|socket|timer)$/u.test(supervisionId)) {
|
|
51
|
+
return { kind: "systemd", evidence: `reservation ${supervisionId}` };
|
|
52
|
+
}
|
|
53
|
+
if (platform === "darwin")
|
|
54
|
+
return { kind: "launchd", evidence: `reservation ${supervisionId}` };
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
kind: "manual",
|
|
58
|
+
evidence: parentPid === 1 ? "detached (parent pid 1)" : "foreground or detached shell",
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
/** PURE — describe one stdio stream from its fstat and (optional) resolved path. */
|
|
62
|
+
export function describeStream(stat, path, isTTY) {
|
|
63
|
+
if (!stat)
|
|
64
|
+
return { kind: "unknown", detail: "fstat failed" };
|
|
65
|
+
if (isTTY)
|
|
66
|
+
return { kind: "tty", ...(path ? { path } : {}) };
|
|
67
|
+
if (stat.isFile) {
|
|
68
|
+
return {
|
|
69
|
+
kind: "file",
|
|
70
|
+
...(path ? { path } : {}),
|
|
71
|
+
dev: Number(stat.dev),
|
|
72
|
+
ino: Number(stat.ino),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
if (stat.isFIFO)
|
|
76
|
+
return { kind: "pipe", ...(path ? { path } : {}) };
|
|
77
|
+
if (stat.isSocket)
|
|
78
|
+
return { kind: "socket", ...(path ? { path } : {}) };
|
|
79
|
+
if (stat.isCharacterDevice) {
|
|
80
|
+
if (!path || path === "/dev/null")
|
|
81
|
+
return { kind: "null", path: "/dev/null" };
|
|
82
|
+
return { kind: "unknown", path, detail: "character device" };
|
|
83
|
+
}
|
|
84
|
+
return { kind: "unknown", detail: "unrecognized stream type" };
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Resolve where a file descriptor points. Linux answers via /proc; macOS has
|
|
88
|
+
* no /proc, so `lsof` (present on every stock install) is consulted once per
|
|
89
|
+
* fd with a short timeout. Returns undefined rather than throwing.
|
|
90
|
+
*/
|
|
91
|
+
export const resolveFdPath = (fd, pid, platform) => {
|
|
92
|
+
if (platform === "linux") {
|
|
93
|
+
try {
|
|
94
|
+
return readlinkSync(`/proc/${pid}/fd/${fd}`);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (platform === "darwin") {
|
|
101
|
+
try {
|
|
102
|
+
const out = execFileSync("lsof", ["-a", "-p", String(pid), "-d", String(fd), "-F", "fn"], {
|
|
103
|
+
encoding: "utf8",
|
|
104
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
105
|
+
timeout: 2_000,
|
|
106
|
+
});
|
|
107
|
+
const lines = out.split("\n");
|
|
108
|
+
const index = lines.indexOf(`f${fd}`);
|
|
109
|
+
if (index === -1)
|
|
110
|
+
return undefined;
|
|
111
|
+
const name = lines.slice(index + 1).find((line) => line.startsWith("n"));
|
|
112
|
+
return name ? name.slice(1) : undefined;
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return undefined;
|
|
119
|
+
};
|
|
120
|
+
function defaultNullDevice() {
|
|
121
|
+
try {
|
|
122
|
+
const stat = statSync("/dev/null");
|
|
123
|
+
return { dev: Number(stat.dev), ino: Number(stat.ino) };
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
function defaultFstat(fd) {
|
|
130
|
+
const stat = fstatSync(fd);
|
|
131
|
+
return {
|
|
132
|
+
isFile: stat.isFile(),
|
|
133
|
+
isFIFO: stat.isFIFO(),
|
|
134
|
+
isSocket: stat.isSocket(),
|
|
135
|
+
isCharacterDevice: stat.isCharacterDevice(),
|
|
136
|
+
dev: stat.dev,
|
|
137
|
+
ino: stat.ino,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
function defaultIsTTY(fd) {
|
|
141
|
+
return fd === 1
|
|
142
|
+
? process.stdout.isTTY === true
|
|
143
|
+
: fd === 2
|
|
144
|
+
? process.stderr.isTTY === true
|
|
145
|
+
: false;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Capture launch + log-destination provenance for the current process.
|
|
149
|
+
* Never throws; partial knowledge is reported with warnings so startup can
|
|
150
|
+
* continue and the report can say what it could not determine.
|
|
151
|
+
*/
|
|
152
|
+
export function captureDaemonProvenance(options) {
|
|
153
|
+
const warnings = [];
|
|
154
|
+
const env = options.env ?? process.env;
|
|
155
|
+
const platform = options.platform ?? process.platform;
|
|
156
|
+
const parentPid = options.parentPid ?? process.ppid;
|
|
157
|
+
const pid = options.pid ?? process.pid;
|
|
158
|
+
const fdPath = options.fdPath ?? resolveFdPath;
|
|
159
|
+
const fstat = options.fstat ?? defaultFstat;
|
|
160
|
+
const isTTY = options.isTTY ?? defaultIsTTY;
|
|
161
|
+
const nullDevice = options.nullDevice ?? defaultNullDevice;
|
|
162
|
+
const supervisor = classifySupervisor({
|
|
163
|
+
launcher: options.launcher,
|
|
164
|
+
platform,
|
|
165
|
+
env,
|
|
166
|
+
parentPid,
|
|
167
|
+
...(options.supervisionId ? { supervisionId: options.supervisionId } : {}),
|
|
168
|
+
});
|
|
169
|
+
const capture = (fd, name) => {
|
|
170
|
+
let stat;
|
|
171
|
+
try {
|
|
172
|
+
stat = fstat(fd);
|
|
173
|
+
}
|
|
174
|
+
catch (error) {
|
|
175
|
+
warnings.push(`${name}: fstat failed (${errorMessage(error)})`);
|
|
176
|
+
return { kind: "unknown", detail: "fstat failed" };
|
|
177
|
+
}
|
|
178
|
+
let path;
|
|
179
|
+
let tty = false;
|
|
180
|
+
try {
|
|
181
|
+
tty = isTTY(fd);
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
// treat as non-tty
|
|
185
|
+
}
|
|
186
|
+
if (tty)
|
|
187
|
+
return describeStream(stat, undefined, true);
|
|
188
|
+
if (stat.isCharacterDevice) {
|
|
189
|
+
// Cheap and exact: /dev/null is identified by inode, no path lookup.
|
|
190
|
+
const devNull = nullDevice();
|
|
191
|
+
if (devNull && devNull.dev === Number(stat.dev) && devNull.ino === Number(stat.ino)) {
|
|
192
|
+
return { kind: "null", path: "/dev/null" };
|
|
193
|
+
}
|
|
194
|
+
return { kind: "unknown", detail: "character device" };
|
|
195
|
+
}
|
|
196
|
+
if (stat.isFile) {
|
|
197
|
+
// The only case that needs a path lookup (lsof on macOS, /proc on Linux).
|
|
198
|
+
try {
|
|
199
|
+
path = fdPath(fd, pid, platform);
|
|
200
|
+
}
|
|
201
|
+
catch (error) {
|
|
202
|
+
warnings.push(`${name}: path resolution failed (${errorMessage(error)})`);
|
|
203
|
+
}
|
|
204
|
+
if (!path)
|
|
205
|
+
warnings.push(`${name}: regular file but its path could not be resolved`);
|
|
206
|
+
}
|
|
207
|
+
return describeStream(stat, path, false);
|
|
208
|
+
};
|
|
209
|
+
const stdout = capture(1, "stdout");
|
|
210
|
+
const stderr = capture(2, "stderr");
|
|
211
|
+
return {
|
|
212
|
+
launcher: options.launcher,
|
|
213
|
+
supervisor: supervisor.kind,
|
|
214
|
+
parentPid,
|
|
215
|
+
stdout,
|
|
216
|
+
stderr,
|
|
217
|
+
...(warnings.length ? { warnings: warnings.slice(0, 8).map((w) => w.slice(0, 256)) } : {}),
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
function errorMessage(error) {
|
|
221
|
+
return redactText(error instanceof Error ? error.message : String(error)).slice(0, 160);
|
|
222
|
+
}
|
|
223
|
+
/** PURE — human description of a stream destination. */
|
|
224
|
+
export function describeLogDestination(stream, supervisor) {
|
|
225
|
+
if (!stream)
|
|
226
|
+
return "unknown (record predates provenance stamping)";
|
|
227
|
+
switch (stream.kind) {
|
|
228
|
+
case "file":
|
|
229
|
+
return stream.path
|
|
230
|
+
? `file ${stream.path}`
|
|
231
|
+
: `file (unresolved path, dev ${stream.dev} ino ${stream.ino})`;
|
|
232
|
+
case "tty":
|
|
233
|
+
return stream.path ? `terminal ${stream.path}` : "terminal";
|
|
234
|
+
case "pipe":
|
|
235
|
+
return supervisor === "embedded"
|
|
236
|
+
? "pipe to the embedding host"
|
|
237
|
+
: "pipe to the parent process";
|
|
238
|
+
case "socket":
|
|
239
|
+
return supervisor === "systemd" ? "systemd journal (socket; use journalctl)" : "socket";
|
|
240
|
+
case "null":
|
|
241
|
+
return "discarded (/dev/null)";
|
|
242
|
+
default:
|
|
243
|
+
return stream.detail ? `unknown (${stream.detail})` : "unknown";
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
function sameFile(stream, file) {
|
|
247
|
+
if (!stream || stream.kind !== "file")
|
|
248
|
+
return false;
|
|
249
|
+
if (stream.dev !== undefined && stream.ino !== undefined) {
|
|
250
|
+
return stream.dev === file.dev && stream.ino === file.ino;
|
|
251
|
+
}
|
|
252
|
+
return stream.path !== undefined && stream.path === file.path;
|
|
253
|
+
}
|
|
254
|
+
function stripCredentials(info) {
|
|
255
|
+
// Explicit copy so a future record field named like a credential still has
|
|
256
|
+
// to be added here deliberately.
|
|
257
|
+
const rest = { ...info };
|
|
258
|
+
delete rest.authToken;
|
|
259
|
+
return rest;
|
|
260
|
+
}
|
|
261
|
+
function classifyFile(file, daemon, pidLiveness) {
|
|
262
|
+
const liveness = file.pid !== undefined ? pidLiveness(file.pid) : undefined;
|
|
263
|
+
const withLiveness = (status, reason) => ({
|
|
264
|
+
...file,
|
|
265
|
+
status,
|
|
266
|
+
reason,
|
|
267
|
+
...(liveness ? { pidLiveness: liveness } : {}),
|
|
268
|
+
});
|
|
269
|
+
if (daemon && daemon.liveness !== "dead") {
|
|
270
|
+
if (sameFile(daemon.logs.stdout, file) || sameFile(daemon.logs.stderr, file)) {
|
|
271
|
+
return withLiveness("current", `live destination of daemon pid ${daemon.pid}`);
|
|
272
|
+
}
|
|
273
|
+
if (file.pid !== undefined && file.pid !== daemon.pid) {
|
|
274
|
+
return withLiveness("historical", liveness === "dead"
|
|
275
|
+
? `written by pid ${file.pid}, which is no longer running; the current daemon is pid ${daemon.pid}`
|
|
276
|
+
: `written by pid ${file.pid}, not the current daemon pid ${daemon.pid}`);
|
|
277
|
+
}
|
|
278
|
+
if (file.pid === daemon.pid) {
|
|
279
|
+
return withLiveness("historical", `mentions the current pid ${daemon.pid} but is not its live log destination`);
|
|
280
|
+
}
|
|
281
|
+
if (Date.parse(file.lastWriteAt) < Date.parse(daemon.startedAt)) {
|
|
282
|
+
return withLiveness("historical", `last written before the current daemon started (${daemon.startedAt})`);
|
|
283
|
+
}
|
|
284
|
+
return withLiveness("unattributed", "no daemon identity recorded in the file and it is not the live destination");
|
|
285
|
+
}
|
|
286
|
+
if (file.pid !== undefined) {
|
|
287
|
+
return withLiveness("historical", liveness === "dead"
|
|
288
|
+
? `written by pid ${file.pid}, which is no longer running`
|
|
289
|
+
: `written by pid ${file.pid}; no live canonical daemon record`);
|
|
290
|
+
}
|
|
291
|
+
return withLiveness("unattributed", "no daemon identity recorded in the file");
|
|
292
|
+
}
|
|
293
|
+
/** PURE — assemble the credential-free report from observed facts. */
|
|
294
|
+
export function buildDaemonProvenanceReport(input) {
|
|
295
|
+
const warnings = [...(input.warnings ?? [])];
|
|
296
|
+
const generatedAt = (input.now ?? (() => new Date()))().toISOString();
|
|
297
|
+
const { recordState } = input;
|
|
298
|
+
let daemon = null;
|
|
299
|
+
let status;
|
|
300
|
+
let record;
|
|
301
|
+
if (recordState.status === "missing") {
|
|
302
|
+
status = "not-running";
|
|
303
|
+
record = { status: "missing" };
|
|
304
|
+
}
|
|
305
|
+
else if (recordState.status === "reserved") {
|
|
306
|
+
// A supervisor reservation without a published owner: no daemon, and the
|
|
307
|
+
// namespace is held for that supervisor.
|
|
308
|
+
status = "not-running";
|
|
309
|
+
record = {
|
|
310
|
+
status: "reserved",
|
|
311
|
+
supervisionId: recordState.reservation.supervisionId,
|
|
312
|
+
reservedAt: recordState.reservation.reservedAt,
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
else if (recordState.status === "invalid") {
|
|
316
|
+
status = "record-invalid";
|
|
317
|
+
record = {
|
|
318
|
+
status: "invalid",
|
|
319
|
+
reason: recordState.reason,
|
|
320
|
+
detail: redactText(recordState.detail),
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
else {
|
|
324
|
+
record = { status: "valid" };
|
|
325
|
+
const info = stripCredentials(recordState.info);
|
|
326
|
+
const liveness = input.pidLiveness(info.pid);
|
|
327
|
+
const provenance = info.provenance ?? null;
|
|
328
|
+
status = liveness === "dead" ? "stale-record" : "running";
|
|
329
|
+
daemon = {
|
|
330
|
+
instanceId: info.instanceId,
|
|
331
|
+
productVersion: info.productVersion,
|
|
332
|
+
protocolVersion: info.protocolVersion,
|
|
333
|
+
pid: info.pid,
|
|
334
|
+
port: info.port,
|
|
335
|
+
bindHostname: info.bindHostname,
|
|
336
|
+
startedAt: info.startedAt,
|
|
337
|
+
supervisionId: info.supervisionId ?? null,
|
|
338
|
+
liveness,
|
|
339
|
+
launcher: provenance?.launcher ?? null,
|
|
340
|
+
supervisor: provenance?.supervisor ?? null,
|
|
341
|
+
parentPid: provenance?.parentPid ?? null,
|
|
342
|
+
provenanceRecorded: provenance !== null,
|
|
343
|
+
logs: { stdout: provenance?.stdout ?? null, stderr: provenance?.stderr ?? null },
|
|
344
|
+
logDestination: describeLogDestination(provenance?.stdout ?? null, provenance?.supervisor ?? null),
|
|
345
|
+
provenanceWarnings: provenance?.warnings ?? [],
|
|
346
|
+
};
|
|
347
|
+
if (!provenance)
|
|
348
|
+
warnings.push("daemon record predates provenance stamping; log destination unknown");
|
|
349
|
+
if (liveness === "dead")
|
|
350
|
+
warnings.push(`daemon record names pid ${info.pid}, which is not running (stale record)`);
|
|
351
|
+
if (liveness === "unknown")
|
|
352
|
+
warnings.push(`liveness of pid ${info.pid} could not be determined`);
|
|
353
|
+
}
|
|
354
|
+
const logFiles = input.files
|
|
355
|
+
.map((file) => classifyFile(file, daemon, input.pidLiveness))
|
|
356
|
+
.sort((a, b) => Date.parse(b.lastWriteAt) - Date.parse(a.lastWriteAt));
|
|
357
|
+
return { generatedAt, status, namespace: input.namespace, record, daemon, logFiles, warnings };
|
|
358
|
+
}
|
|
359
|
+
/** PURE — human rendering of the report for the non-JSON CLI path. */
|
|
360
|
+
export function formatDaemonProvenanceReport(report) {
|
|
361
|
+
const lines = [];
|
|
362
|
+
const d = report.daemon;
|
|
363
|
+
switch (report.status) {
|
|
364
|
+
case "running":
|
|
365
|
+
lines.push(`Canonical daemon: running (pid ${d.pid}, v${d.productVersion})`);
|
|
366
|
+
break;
|
|
367
|
+
case "stale-record":
|
|
368
|
+
lines.push(`Canonical daemon: not running (stale record names pid ${d.pid}, v${d.productVersion})`);
|
|
369
|
+
break;
|
|
370
|
+
case "not-running":
|
|
371
|
+
lines.push(report.record.status === "reserved"
|
|
372
|
+
? `Canonical daemon: not running (namespace reserved for supervisor ${report.record.supervisionId})`
|
|
373
|
+
: "Canonical daemon: not running (no record)");
|
|
374
|
+
break;
|
|
375
|
+
case "record-invalid":
|
|
376
|
+
lines.push(`Canonical daemon: record invalid (${report.record.status === "invalid" ? report.record.reason : "unknown"})`);
|
|
377
|
+
break;
|
|
378
|
+
}
|
|
379
|
+
lines.push(` record: ${report.namespace.recordPath}`);
|
|
380
|
+
if (d) {
|
|
381
|
+
lines.push(` instance: ${d.instanceId}`);
|
|
382
|
+
lines.push(` started: ${d.startedAt} protocol ${d.protocolVersion} ${d.bindHostname}:${d.port}`);
|
|
383
|
+
lines.push(` supervisor: ${d.supervisor ?? "unknown"}${d.supervisionId ? ` (${d.supervisionId})` : ""}` +
|
|
384
|
+
`${d.launcher ? `, launcher ${d.launcher}` : ""}${d.parentPid !== null ? `, parent pid ${d.parentPid}` : ""}`);
|
|
385
|
+
lines.push(` logs: stdout → ${d.logDestination}`);
|
|
386
|
+
if (d.logs.stderr && JSON.stringify(d.logs.stderr) !== JSON.stringify(d.logs.stdout)) {
|
|
387
|
+
lines.push(` stderr → ${describeLogDestination(d.logs.stderr, d.supervisor)}`);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
if (report.logFiles.length) {
|
|
391
|
+
lines.push("Log files:");
|
|
392
|
+
for (const file of report.logFiles) {
|
|
393
|
+
const who = file.pid !== undefined
|
|
394
|
+
? ` pid ${file.pid}${file.pidLiveness ? ` (${file.pidLiveness})` : ""}`
|
|
395
|
+
: "";
|
|
396
|
+
lines.push(` [${file.status}] ${file.path} — ${file.bytes} bytes, last write ${file.lastWriteAt}${who}`);
|
|
397
|
+
lines.push(` ${file.reason}`);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
else {
|
|
401
|
+
lines.push("Log files: none discovered");
|
|
402
|
+
}
|
|
403
|
+
for (const warning of report.warnings)
|
|
404
|
+
lines.push(`warning: ${warning}`);
|
|
405
|
+
return lines;
|
|
406
|
+
}
|
|
407
|
+
// ---------------------------------------------------------------------------
|
|
408
|
+
// Observation (io)
|
|
409
|
+
// ---------------------------------------------------------------------------
|
|
410
|
+
const LOG_FILE_NAME = /\.(log|out|err)$/iu;
|
|
411
|
+
const MAX_DISCOVERED_FILES = 64;
|
|
412
|
+
const READ_WINDOW_BYTES = 64 * 1024;
|
|
413
|
+
const PID_MARKERS = [/\(pid (\d{1,10})\)/gu, /"pid":\s*(\d{1,10})/gu];
|
|
414
|
+
const INSTANCE_MARKER = /"instanceId":\s*"([0-9a-f-]{36})"/giu;
|
|
415
|
+
const VERSION_MARKER = /"(?:version|productVersion)":\s*"([^"]{1,64})"/gu;
|
|
416
|
+
function lastMatch(pattern, text) {
|
|
417
|
+
let found;
|
|
418
|
+
pattern.lastIndex = 0;
|
|
419
|
+
for (const match of text.matchAll(pattern))
|
|
420
|
+
found = match[1];
|
|
421
|
+
return found;
|
|
422
|
+
}
|
|
423
|
+
/** PURE — extract the last daemon identity markers from a bounded text window. */
|
|
424
|
+
export function extractLogIdentity(text) {
|
|
425
|
+
const out = {};
|
|
426
|
+
let lastPidIndex = -1;
|
|
427
|
+
for (const pattern of PID_MARKERS) {
|
|
428
|
+
pattern.lastIndex = 0;
|
|
429
|
+
for (const match of text.matchAll(pattern)) {
|
|
430
|
+
if (match.index !== undefined && match.index > lastPidIndex) {
|
|
431
|
+
lastPidIndex = match.index;
|
|
432
|
+
out.pid = Number(match[1]);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
const instanceId = lastMatch(INSTANCE_MARKER, text);
|
|
437
|
+
if (instanceId)
|
|
438
|
+
out.instanceId = instanceId.toLowerCase();
|
|
439
|
+
const version = lastMatch(VERSION_MARKER, text);
|
|
440
|
+
if (version)
|
|
441
|
+
out.productVersion = version;
|
|
442
|
+
return out;
|
|
443
|
+
}
|
|
444
|
+
function readWindow(path, size) {
|
|
445
|
+
const fd = openSync(path, "r");
|
|
446
|
+
try {
|
|
447
|
+
const head = Buffer.alloc(Math.min(size, READ_WINDOW_BYTES));
|
|
448
|
+
const headRead = readSync(fd, head, 0, head.length, 0);
|
|
449
|
+
if (size <= READ_WINDOW_BYTES)
|
|
450
|
+
return head.subarray(0, headRead).toString("utf8");
|
|
451
|
+
const tail = Buffer.alloc(READ_WINDOW_BYTES);
|
|
452
|
+
const tailRead = readSync(fd, tail, 0, tail.length, size - READ_WINDOW_BYTES);
|
|
453
|
+
return `${head.subarray(0, headRead).toString("utf8")}\n${tail.subarray(0, tailRead).toString("utf8")}`;
|
|
454
|
+
}
|
|
455
|
+
finally {
|
|
456
|
+
closeSync(fd);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
/** Observe one candidate log file read-only; read errors degrade to `readError`. */
|
|
460
|
+
export function observeLogFile(path) {
|
|
461
|
+
let stat;
|
|
462
|
+
try {
|
|
463
|
+
stat = lstatSync(path);
|
|
464
|
+
}
|
|
465
|
+
catch {
|
|
466
|
+
return null;
|
|
467
|
+
}
|
|
468
|
+
if (!stat.isFile())
|
|
469
|
+
return null;
|
|
470
|
+
const base = {
|
|
471
|
+
path,
|
|
472
|
+
bytes: stat.size,
|
|
473
|
+
lastWriteAt: stat.mtime.toISOString(),
|
|
474
|
+
dev: Number(stat.dev),
|
|
475
|
+
ino: Number(stat.ino),
|
|
476
|
+
};
|
|
477
|
+
try {
|
|
478
|
+
const identity = extractLogIdentity(readWindow(path, stat.size));
|
|
479
|
+
return { ...base, ...identity };
|
|
480
|
+
}
|
|
481
|
+
catch (error) {
|
|
482
|
+
return { ...base, readError: errorMessage(error) };
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
/** Discover candidate log files non-recursively; never throws. */
|
|
486
|
+
export function discoverLogFiles(options) {
|
|
487
|
+
const seen = new Set();
|
|
488
|
+
const files = [];
|
|
489
|
+
const warnings = [];
|
|
490
|
+
const consider = (path) => {
|
|
491
|
+
let key = path;
|
|
492
|
+
try {
|
|
493
|
+
key = realpathSync(path);
|
|
494
|
+
}
|
|
495
|
+
catch {
|
|
496
|
+
// keep the given path as the identity
|
|
497
|
+
}
|
|
498
|
+
if (seen.has(key) || files.length >= MAX_DISCOVERED_FILES)
|
|
499
|
+
return;
|
|
500
|
+
seen.add(key);
|
|
501
|
+
const observed = observeLogFile(path);
|
|
502
|
+
if (observed)
|
|
503
|
+
files.push(observed);
|
|
504
|
+
};
|
|
505
|
+
for (const dir of options.directories) {
|
|
506
|
+
let entries;
|
|
507
|
+
try {
|
|
508
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
509
|
+
}
|
|
510
|
+
catch (error) {
|
|
511
|
+
const code = error.code;
|
|
512
|
+
if (code !== "ENOENT")
|
|
513
|
+
warnings.push(`cannot list ${dir} (${errorMessage(error)})`);
|
|
514
|
+
continue;
|
|
515
|
+
}
|
|
516
|
+
for (const entry of entries) {
|
|
517
|
+
if (!entry.isFile() || !LOG_FILE_NAME.test(entry.name))
|
|
518
|
+
continue;
|
|
519
|
+
consider(join(dir, entry.name));
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
for (const path of options.extraPaths ?? [])
|
|
523
|
+
consider(resolve(path));
|
|
524
|
+
if (files.length >= MAX_DISCOVERED_FILES)
|
|
525
|
+
warnings.push(`log discovery capped at ${MAX_DISCOVERED_FILES} files`);
|
|
526
|
+
return { files, warnings };
|
|
527
|
+
}
|
|
528
|
+
export function pidLivenessProbe(pid) {
|
|
529
|
+
try {
|
|
530
|
+
process.kill(pid, 0);
|
|
531
|
+
return "alive";
|
|
532
|
+
}
|
|
533
|
+
catch (error) {
|
|
534
|
+
const code = error.code;
|
|
535
|
+
if (code === "ESRCH")
|
|
536
|
+
return "dead";
|
|
537
|
+
if (code === "EPERM")
|
|
538
|
+
return "alive";
|
|
539
|
+
return "unknown";
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
/** io — inspect the record, discover log files, and build the report. */
|
|
543
|
+
export function collectDaemonProvenanceReport(options = {}) {
|
|
544
|
+
const namespace = resolveRuntimeNamespace();
|
|
545
|
+
const recordPath = getCanonicalDaemonInfoPath();
|
|
546
|
+
const recordState = options.recordState ?? inspectCanonicalDaemonInfo();
|
|
547
|
+
const extraPaths = [];
|
|
548
|
+
if (recordState.status === "valid") {
|
|
549
|
+
for (const stream of [
|
|
550
|
+
recordState.info.provenance?.stdout,
|
|
551
|
+
recordState.info.provenance?.stderr,
|
|
552
|
+
]) {
|
|
553
|
+
if (stream?.kind === "file" && stream.path)
|
|
554
|
+
extraPaths.push(stream.path);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
const directories = [
|
|
558
|
+
...new Set([namespace.logsDir, namespace.daemonInfoDir, namespace.stateHome]),
|
|
559
|
+
];
|
|
560
|
+
const discovered = discoverLogFiles({ directories, extraPaths });
|
|
561
|
+
return buildDaemonProvenanceReport({
|
|
562
|
+
namespace: {
|
|
563
|
+
mode: namespace.mode,
|
|
564
|
+
stateHome: namespace.stateHome,
|
|
565
|
+
daemonInfoDir: namespace.daemonInfoDir,
|
|
566
|
+
logsDir: namespace.logsDir,
|
|
567
|
+
recordPath,
|
|
568
|
+
},
|
|
569
|
+
recordState,
|
|
570
|
+
files: discovered.files,
|
|
571
|
+
pidLiveness: options.pidLiveness ?? pidLivenessProbe,
|
|
572
|
+
...(options.now ? { now: options.now } : {}),
|
|
573
|
+
warnings: discovered.warnings,
|
|
574
|
+
});
|
|
575
|
+
}
|
|
@@ -231,6 +231,7 @@ async function runHeadlessDaemonGeneration(options, deps, restoreTmuxWorkspaces,
|
|
|
231
231
|
for (let startAttempt = 0; startAttempt < 2 && !handle; startAttempt += 1) {
|
|
232
232
|
try {
|
|
233
233
|
handle = await deps.startEmbeddedDaemon({
|
|
234
|
+
launcher: "headless",
|
|
234
235
|
...(options.supervisionId
|
|
235
236
|
? { supervisionId: options.supervisionId, predecessor: lifecycle.predecessor }
|
|
236
237
|
: {}),
|