pmx-canvas 0.2.7 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +148 -1
- package/Readme.md +13 -11
- package/dist/canvas/index.js +2 -2
- package/dist/json-render/index.css +1 -1
- package/dist/types/cli/daemon.d.ts +74 -0
- package/dist/types/cli/watch.d.ts +2 -2
- package/dist/types/client/canvas/CanvasViewport.d.ts +1 -1
- package/dist/types/client/canvas/CommandPalette.d.ts +1 -1
- package/dist/types/client/canvas/Minimap.d.ts +1 -1
- package/dist/types/client/nodes/FileNode.d.ts +1 -1
- package/dist/types/client/nodes/ImageNode.d.ts +1 -1
- package/dist/types/client/nodes/InlineFormatBar.d.ts +1 -1
- package/dist/types/client/nodes/MarkdownNode.d.ts +1 -1
- package/dist/types/client/nodes/PromptNode.d.ts +1 -1
- package/dist/types/client/nodes/ResponseNode.d.ts +1 -1
- package/dist/types/mcp/canvas-access.d.ts +0 -7
- package/dist/types/server/ax-state-manager.d.ts +3 -2
- package/dist/types/server/bundled-skills.d.ts +3 -3
- package/dist/types/server/canvas-schema.d.ts +1 -1
- package/dist/types/server/canvas-state.d.ts +2 -0
- package/dist/types/server/ext-app-lookup.d.ts +1 -3
- package/dist/types/server/html-primitives.d.ts +1 -1
- package/dist/types/server/index.d.ts +4 -4
- package/dist/types/server/operations/composites.d.ts +29 -23
- package/dist/types/server/operations/http.d.ts +2 -1
- package/dist/types/server/operations/index.d.ts +1 -1
- package/dist/types/server/operations/ops/ax-read.d.ts +2 -0
- package/dist/types/server/operations/ops/canvas-wire.d.ts +2 -0
- package/dist/types/server/operations/ops/ext-app.d.ts +2 -0
- package/dist/types/shared/ax-intent.d.ts +1 -1
- package/docs/RELEASE.md +23 -8
- package/docs/ax-host-adapter-contract.md +7 -7
- package/docs/ax-state-contract.md +7 -6
- package/docs/bun-webview-integration.md +5 -5
- package/docs/cli.md +1 -1
- package/docs/http-api.md +35 -3
- package/docs/mcp.md +121 -100
- package/docs/node-types.md +45 -35
- package/docs/plans/plan-006-mcp-tool-consolidation.md +3 -1
- package/docs/plans/plan-008-registry-finish.md +2 -0
- package/docs/plans/plan-009-tech-debt-backlog.md +51 -0
- package/docs/screenshot.png +0 -0
- package/docs/tech-debt-assessment-2026-06.md +1 -1
- package/docs/tech-debt-assessment-2026-07.md +135 -0
- package/package.json +5 -2
- package/skills/data-analysis/SKILL.md +3 -3
- package/skills/frontend-design/SKILL.md +3 -2
- package/skills/json-render-mcp/SKILL.md +4 -3
- package/skills/playwright-cli/SKILL.md +1 -1
- package/skills/pmx-canvas/SKILL.md +12 -7
- package/skills/pmx-canvas/evals/evals.json +2 -2
- package/skills/pmx-canvas/references/ax-html-control-surface.md +3 -1
- package/skills/pmx-canvas/references/excalidraw-diagram-authoring.md +2 -2
- package/skills/pmx-canvas/references/full-reference.md +58 -56
- package/skills/pmx-canvas/references/html-primitives.md +3 -3
- package/skills/tufte-viz/SKILL.md +5 -4
- package/skills/web-artifacts-builder/SKILL.md +4 -4
- package/src/cli/agent.ts +1861 -1548
- package/src/cli/daemon.ts +460 -0
- package/src/cli/index.ts +63 -326
- package/src/cli/watch.ts +2 -10
- package/src/client/App.tsx +48 -46
- package/src/client/canvas/AttentionHistory.tsx +11 -1
- package/src/client/canvas/CanvasNode.tsx +41 -29
- package/src/client/canvas/CanvasViewport.tsx +101 -66
- package/src/client/canvas/CommandPalette.tsx +61 -27
- package/src/client/canvas/ContextMenu.tsx +13 -20
- package/src/client/canvas/ContextPinBar.tsx +1 -5
- package/src/client/canvas/ContextPinHud.tsx +1 -6
- package/src/client/canvas/DockedNode.tsx +4 -4
- package/src/client/canvas/EdgeLayer.tsx +37 -36
- package/src/client/canvas/ExpandedNodeOverlay.tsx +69 -45
- package/src/client/canvas/FocusFieldLayer.tsx +20 -22
- package/src/client/canvas/IntentLayer.tsx +31 -14
- package/src/client/canvas/Minimap.tsx +11 -16
- package/src/client/canvas/SelectionBar.tsx +4 -11
- package/src/client/canvas/ShortcutOverlay.tsx +3 -1
- package/src/client/canvas/SnapshotPanel.tsx +77 -95
- package/src/client/canvas/auto-fit.ts +15 -14
- package/src/client/canvas/snap-guides.ts +12 -12
- package/src/client/canvas/use-node-resize.ts +1 -5
- package/src/client/canvas/use-pan-zoom.ts +25 -26
- package/src/client/ext-app/bridge.ts +3 -12
- package/src/client/icons.tsx +63 -20
- package/src/client/nodes/ContextNode.tsx +14 -25
- package/src/client/nodes/ExtAppFrame.tsx +60 -39
- package/src/client/nodes/FileNode.tsx +74 -62
- package/src/client/nodes/GroupNode.tsx +4 -6
- package/src/client/nodes/HtmlNode.tsx +76 -46
- package/src/client/nodes/ImageNode.tsx +18 -27
- package/src/client/nodes/InlineFormatBar.tsx +4 -21
- package/src/client/nodes/InlineMarkdownEditor.tsx +0 -1
- package/src/client/nodes/LedgerNode.tsx +10 -4
- package/src/client/nodes/MarkdownNode.tsx +3 -10
- package/src/client/nodes/McpAppNode.tsx +26 -22
- package/src/client/nodes/MdFormatBar.tsx +10 -7
- package/src/client/nodes/PromptNode.tsx +23 -51
- package/src/client/nodes/ResponseNode.tsx +3 -13
- package/src/client/nodes/StatusNode.tsx +5 -9
- package/src/client/nodes/StatusSummary.tsx +2 -8
- package/src/client/nodes/WebpageNode.tsx +20 -14
- package/src/client/nodes/iframe-document-url.ts +25 -16
- package/src/client/nodes/image-warnings.ts +1 -7
- package/src/client/nodes/md-format.ts +20 -5
- package/src/client/state/attention-bridge.ts +4 -9
- package/src/client/state/attention-store.ts +1 -7
- package/src/client/state/canvas-store.ts +52 -36
- package/src/client/state/intent-bridge.ts +176 -112
- package/src/client/state/intent-store.ts +4 -1
- package/src/client/state/sse-bridge.ts +53 -70
- package/src/json-render/catalog.ts +12 -16
- package/src/json-render/charts/components.tsx +16 -20
- package/src/json-render/charts/extra-components.tsx +8 -16
- package/src/json-render/charts/extra-definitions.ts +1 -2
- package/src/json-render/charts/tufte-components.tsx +37 -20
- package/src/json-render/charts/tufte-definitions.ts +8 -2
- package/src/json-render/renderer/index.tsx +42 -22
- package/src/json-render/schema.ts +6 -3
- package/src/json-render/server.ts +33 -39
- package/src/mcp/canvas-access.ts +40 -121
- package/src/mcp/server.ts +206 -271
- package/src/server/agent-context.ts +63 -36
- package/src/server/ax-context.ts +7 -5
- package/src/server/ax-interaction.ts +176 -43
- package/src/server/ax-state-manager.ts +185 -41
- package/src/server/ax-state.ts +142 -47
- package/src/server/bundled-skills.ts +3 -3
- package/src/server/canvas-db.ts +213 -95
- package/src/server/canvas-operations.ts +177 -120
- package/src/server/canvas-provenance.ts +1 -4
- package/src/server/canvas-schema.ts +500 -102
- package/src/server/canvas-serialization.ts +27 -35
- package/src/server/canvas-state.ts +155 -58
- package/src/server/chart-template.ts +4 -5
- package/src/server/code-graph.ts +20 -7
- package/src/server/diagram-presets.ts +28 -29
- package/src/server/ext-app-lookup.ts +4 -20
- package/src/server/html-node-summary.ts +19 -10
- package/src/server/html-primitives.ts +326 -97
- package/src/server/html-surface.ts +6 -9
- package/src/server/image-source.ts +6 -4
- package/src/server/index.ts +321 -218
- package/src/server/intent-registry.ts +3 -6
- package/src/server/mcp-app-candidate.ts +5 -10
- package/src/server/mcp-app-host.ts +14 -38
- package/src/server/mcp-app-runtime.ts +12 -20
- package/src/server/mutation-history.ts +15 -5
- package/src/server/operations/composites.ts +38 -40
- package/src/server/operations/http.ts +5 -5
- package/src/server/operations/index.ts +7 -1
- package/src/server/operations/invoker.ts +4 -3
- package/src/server/operations/mcp.ts +29 -36
- package/src/server/operations/ops/annotation.ts +122 -10
- package/src/server/operations/ops/app.ts +102 -77
- package/src/server/operations/ops/ax-await.ts +17 -10
- package/src/server/operations/ops/ax-read.ts +347 -0
- package/src/server/operations/ops/ax-shared.ts +2 -7
- package/src/server/operations/ops/ax-state.ts +32 -14
- package/src/server/operations/ops/ax-timeline.ts +32 -19
- package/src/server/operations/ops/ax-work.ts +54 -37
- package/src/server/operations/ops/batch.ts +39 -14
- package/src/server/operations/ops/canvas-wire.ts +91 -0
- package/src/server/operations/ops/edges.ts +37 -25
- package/src/server/operations/ops/ext-app.ts +346 -0
- package/src/server/operations/ops/groups.ts +49 -20
- package/src/server/operations/ops/intent.ts +18 -12
- package/src/server/operations/ops/json-render.ts +239 -98
- package/src/server/operations/ops/nodes.ts +300 -111
- package/src/server/operations/ops/query.ts +62 -33
- package/src/server/operations/ops/snapshots.ts +35 -26
- package/src/server/operations/ops/validate.ts +2 -1
- package/src/server/operations/ops/viewport.ts +60 -16
- package/src/server/operations/ops/webview.ts +44 -18
- package/src/server/operations/registry.ts +2 -3
- package/src/server/operations/types.ts +7 -5
- package/src/server/operations/webview-runner.ts +1 -3
- package/src/server/placement.ts +8 -18
- package/src/server/server.ts +121 -1012
- package/src/server/spatial-analysis.ts +39 -25
- package/src/server/trace-manager.ts +3 -8
- package/src/server/web-artifacts.ts +23 -27
- package/src/server/webpage-node.ts +5 -13
- package/src/shared/auto-arrange.ts +12 -5
- package/src/shared/ax-intent.ts +1 -1
- package/src/shared/content-height-reporter.ts +8 -6
- package/src/shared/ext-app-tool-result.ts +2 -6
- package/src/shared/placement.ts +1 -4
- package/src/shared/semantic-attention.ts +39 -37
- package/src/shared/surface.ts +8 -4
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Daemon lifecycle for `pmx-canvas serve --daemon` / `serve status` / `serve stop`.
|
|
3
|
+
*
|
|
4
|
+
* Invariants:
|
|
5
|
+
* - The pid file doubles as the spawn lock: it is created exclusively BEFORE
|
|
6
|
+
* the child spawns and holds the child pid immediately after, so a daemon
|
|
7
|
+
* is addressable by `serve stop` from the moment it exists — even if it
|
|
8
|
+
* never becomes healthy. A failed startup kills the child and removes the
|
|
9
|
+
* file: no orphans.
|
|
10
|
+
* - The daemon child binds its requested port strictly (`PmxCanvas.start`
|
|
11
|
+
* pins `allowPortFallback: false`), so the health URL and the port-keyed
|
|
12
|
+
* pid/log file names are always accurate.
|
|
13
|
+
* - Pid liveness is cross-checked against the process command line, so a
|
|
14
|
+
* recycled pid (or an EPERM-protected foreign process) reads as stale
|
|
15
|
+
* instead of alive.
|
|
16
|
+
* - A responsive /health that reports a different workspace (or none) is a
|
|
17
|
+
* port conflict, not an "already running" success.
|
|
18
|
+
*/
|
|
19
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
20
|
+
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
21
|
+
import { dirname, resolve } from 'node:path';
|
|
22
|
+
|
|
23
|
+
export function outputJson(data: unknown): void {
|
|
24
|
+
console.log(JSON.stringify(data, null, 2));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function readPidFile(path: string): number | null {
|
|
28
|
+
try {
|
|
29
|
+
if (!existsSync(path)) return null;
|
|
30
|
+
const raw = readFileSync(path, 'utf-8').trim();
|
|
31
|
+
if (!raw) return null;
|
|
32
|
+
const pid = Number(raw);
|
|
33
|
+
return Number.isInteger(pid) && pid > 0 ? pid : null;
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function isProcessRunning(pid: number): boolean {
|
|
40
|
+
try {
|
|
41
|
+
process.kill(pid, 0);
|
|
42
|
+
return true;
|
|
43
|
+
} catch (error) {
|
|
44
|
+
if (error && typeof error === 'object' && 'code' in error) {
|
|
45
|
+
return (error as NodeJS.ErrnoException).code === 'EPERM';
|
|
46
|
+
}
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Whether pid's command line contains `needle`. Returns null when `ps` cannot
|
|
53
|
+
* answer (no such process, unsupported platform) — callers fall back to the
|
|
54
|
+
* plain liveness signal.
|
|
55
|
+
*/
|
|
56
|
+
export function processCommandMatches(pid: number, needle: string): boolean | null {
|
|
57
|
+
if (!Number.isInteger(pid) || pid <= 0) return null;
|
|
58
|
+
try {
|
|
59
|
+
const command = execFileSync('ps', ['-o', 'command=', '-p', String(pid)], {
|
|
60
|
+
encoding: 'utf-8',
|
|
61
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
62
|
+
}).trim();
|
|
63
|
+
if (!command) return null;
|
|
64
|
+
return command.includes(needle);
|
|
65
|
+
} catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Liveness with a PID-recycling guard: the pid must be alive AND its command
|
|
72
|
+
* line must still look like our daemon (unless `ps` is unavailable, in which
|
|
73
|
+
* case the plain liveness signal wins).
|
|
74
|
+
*/
|
|
75
|
+
export function isOwnDaemonProcess(pid: number, entryNeedle: string): boolean {
|
|
76
|
+
if (!isProcessRunning(pid)) return false;
|
|
77
|
+
return processCommandMatches(pid, entryNeedle) !== false;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function removePidFile(path: string): void {
|
|
81
|
+
try {
|
|
82
|
+
rmSync(path, { force: true });
|
|
83
|
+
} catch {
|
|
84
|
+
// Ignore cleanup failures for stale pid files.
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface HealthStatus {
|
|
89
|
+
responsive: boolean;
|
|
90
|
+
workspace: string | null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function readHealthStatus(url: string): Promise<HealthStatus> {
|
|
94
|
+
try {
|
|
95
|
+
const response = await fetch(url);
|
|
96
|
+
if (!response.ok) return { responsive: false, workspace: null };
|
|
97
|
+
const payload = (await response.json().catch(() => null)) as unknown;
|
|
98
|
+
const workspace =
|
|
99
|
+
payload && typeof payload === 'object' && 'workspace' in payload && typeof payload.workspace === 'string'
|
|
100
|
+
? payload.workspace
|
|
101
|
+
: null;
|
|
102
|
+
return { responsive: true, workspace };
|
|
103
|
+
} catch {
|
|
104
|
+
return { responsive: false, workspace: null };
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function isHealthy(url: string): Promise<boolean> {
|
|
109
|
+
return (await readHealthStatus(url)).responsive;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function readLogTail(path: string, maxLines = 20): string | null {
|
|
113
|
+
try {
|
|
114
|
+
if (!existsSync(path)) return null;
|
|
115
|
+
const lines = readFileSync(path, 'utf-8').trim().split('\n');
|
|
116
|
+
return lines.slice(-maxLines).join('\n') || null;
|
|
117
|
+
} catch {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function waitForHealth(
|
|
123
|
+
healthUrl: string,
|
|
124
|
+
timeoutMs: number,
|
|
125
|
+
getExitMessage: () => string | null,
|
|
126
|
+
): Promise<{ ok: true } | { ok: false; reason: string }> {
|
|
127
|
+
const deadline = Date.now() + timeoutMs;
|
|
128
|
+
while (Date.now() < deadline) {
|
|
129
|
+
if (await isHealthy(healthUrl)) {
|
|
130
|
+
return { ok: true };
|
|
131
|
+
}
|
|
132
|
+
const exitMessage = getExitMessage();
|
|
133
|
+
if (exitMessage) {
|
|
134
|
+
return { ok: false, reason: exitMessage };
|
|
135
|
+
}
|
|
136
|
+
await Bun.sleep(250);
|
|
137
|
+
}
|
|
138
|
+
return { ok: false, reason: `Timed out waiting for ${healthUrl}` };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export async function waitForShutdown(healthUrl: string, timeoutMs: number, isAlive: () => boolean): Promise<boolean> {
|
|
142
|
+
const deadline = Date.now() + timeoutMs;
|
|
143
|
+
while (Date.now() < deadline) {
|
|
144
|
+
const responsive = await isHealthy(healthUrl);
|
|
145
|
+
if (!responsive && !isAlive()) {
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
await Bun.sleep(250);
|
|
149
|
+
}
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Workspace the daemon child will bind — mirrors startCanvasServer's resolution. */
|
|
154
|
+
export function resolveExpectedWorkspace(): string {
|
|
155
|
+
const env = process.env.PMX_CANVAS_WORKSPACE_ROOT?.trim();
|
|
156
|
+
return resolve(env || process.cwd());
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export type PrecheckVerdict = 'not-running' | 'already-running' | 'foreign-port-owner';
|
|
160
|
+
|
|
161
|
+
/** Pure decision for the pre-spawn health probe. */
|
|
162
|
+
export function classifyPrecheck(health: HealthStatus, expectedWorkspace: string): PrecheckVerdict {
|
|
163
|
+
if (!health.responsive) return 'not-running';
|
|
164
|
+
if (health.workspace && resolve(health.workspace) === expectedWorkspace) return 'already-running';
|
|
165
|
+
return 'foreign-port-owner';
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export type LockResult = { ok: true } | { ok: false; holderPid: number | null; reason: 'running' | 'starting' };
|
|
169
|
+
|
|
170
|
+
/** How long an empty lock file is presumed to belong to a concurrent starter. */
|
|
171
|
+
const LOCK_FRESH_MS = 30_000;
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Exclusive pid-file creation IS the spawn lock. A held file with a live
|
|
175
|
+
* daemon pid means 'running'; a fresh empty file means another starter is
|
|
176
|
+
* mid-spawn ('starting'); anything stale is reclaimed once.
|
|
177
|
+
*/
|
|
178
|
+
export function acquireDaemonLock(pidFile: string, entryNeedle: string): LockResult {
|
|
179
|
+
mkdirSync(dirname(pidFile), { recursive: true });
|
|
180
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
181
|
+
try {
|
|
182
|
+
closeSync(openSync(pidFile, 'wx'));
|
|
183
|
+
return { ok: true };
|
|
184
|
+
} catch {
|
|
185
|
+
const holderPid = readPidFile(pidFile);
|
|
186
|
+
if (holderPid && isOwnDaemonProcess(holderPid, entryNeedle)) {
|
|
187
|
+
return { ok: false, holderPid, reason: 'running' };
|
|
188
|
+
}
|
|
189
|
+
if (!holderPid && existsSync(pidFile)) {
|
|
190
|
+
try {
|
|
191
|
+
if (Date.now() - statSync(pidFile).mtimeMs < LOCK_FRESH_MS) {
|
|
192
|
+
return { ok: false, holderPid: null, reason: 'starting' };
|
|
193
|
+
}
|
|
194
|
+
} catch {
|
|
195
|
+
return { ok: false, holderPid: null, reason: 'starting' };
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
removePidFile(pidFile);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return { ok: false, holderPid: readPidFile(pidFile), reason: 'starting' };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* True when the pid file is a concurrent starter's spawn lock: an EMPTY file
|
|
206
|
+
* (acquireDaemonLock creates it before writing the child pid) younger than
|
|
207
|
+
* LOCK_FRESH_MS. `serve status` must NOT delete such a file — clearing it
|
|
208
|
+
* mid-spawn would defeat the spawn lock and let a racing starter double-spawn.
|
|
209
|
+
* Mirrors the fresh-lock protection acquireDaemonLock applies. A stat failure
|
|
210
|
+
* biases toward preserving the lock.
|
|
211
|
+
*/
|
|
212
|
+
export function isFreshEmptyLock(pidFile: string): boolean {
|
|
213
|
+
if (!existsSync(pidFile) || readPidFile(pidFile) !== null) return false;
|
|
214
|
+
try {
|
|
215
|
+
return Date.now() - statSync(pidFile).mtimeMs < LOCK_FRESH_MS;
|
|
216
|
+
} catch {
|
|
217
|
+
return true;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export interface DaemonPaths {
|
|
222
|
+
port: number;
|
|
223
|
+
logFile: string;
|
|
224
|
+
pidFile: string;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export async function startDaemonMode(
|
|
228
|
+
options: DaemonPaths & {
|
|
229
|
+
baseArgs: string[];
|
|
230
|
+
waitMs: number;
|
|
231
|
+
entry: string;
|
|
232
|
+
},
|
|
233
|
+
): Promise<void> {
|
|
234
|
+
const healthUrl = `http://localhost:${options.port}/health`;
|
|
235
|
+
const workbenchUrl = `http://localhost:${options.port}/workbench`;
|
|
236
|
+
const expectedWorkspace = resolveExpectedWorkspace();
|
|
237
|
+
const health = await readHealthStatus(healthUrl);
|
|
238
|
+
const verdict = classifyPrecheck(health, expectedWorkspace);
|
|
239
|
+
|
|
240
|
+
if (verdict === 'already-running') {
|
|
241
|
+
outputJson({
|
|
242
|
+
ok: true,
|
|
243
|
+
daemon: true,
|
|
244
|
+
alreadyRunning: true,
|
|
245
|
+
pid: readPidFile(options.pidFile),
|
|
246
|
+
workspace: health.workspace,
|
|
247
|
+
url: workbenchUrl,
|
|
248
|
+
healthUrl,
|
|
249
|
+
logFile: options.logFile,
|
|
250
|
+
pidFile: options.pidFile,
|
|
251
|
+
});
|
|
252
|
+
process.exit(0);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (verdict === 'foreign-port-owner') {
|
|
256
|
+
outputJson({
|
|
257
|
+
ok: false,
|
|
258
|
+
daemon: true,
|
|
259
|
+
error: health.workspace
|
|
260
|
+
? `Port ${options.port} is already serving workspace ${health.workspace}; not attaching.`
|
|
261
|
+
: `Port ${options.port} is already in use by another application; not attaching.`,
|
|
262
|
+
hint: 'Pass --port=<other> for a separate daemon, or stop the process holding this port.',
|
|
263
|
+
url: workbenchUrl,
|
|
264
|
+
healthUrl,
|
|
265
|
+
logFile: options.logFile,
|
|
266
|
+
pidFile: options.pidFile,
|
|
267
|
+
});
|
|
268
|
+
process.exit(1);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const lock = acquireDaemonLock(options.pidFile, options.entry);
|
|
272
|
+
if (!lock.ok) {
|
|
273
|
+
outputJson({
|
|
274
|
+
ok: false,
|
|
275
|
+
daemon: true,
|
|
276
|
+
error:
|
|
277
|
+
lock.reason === 'running'
|
|
278
|
+
? `A daemon (pid ${lock.holderPid}) already owns ${options.pidFile}.`
|
|
279
|
+
: `Another \`serve --daemon\` start appears to be in progress (${options.pidFile} is locked).`,
|
|
280
|
+
hint: 'Use `pmx-canvas serve status` / `pmx-canvas serve stop`, or remove the pid file if it is stale.',
|
|
281
|
+
url: workbenchUrl,
|
|
282
|
+
healthUrl,
|
|
283
|
+
logFile: options.logFile,
|
|
284
|
+
pidFile: options.pidFile,
|
|
285
|
+
});
|
|
286
|
+
process.exit(1);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
mkdirSync(dirname(options.logFile), { recursive: true });
|
|
290
|
+
const logFd = openSync(options.logFile, 'a');
|
|
291
|
+
const childArgs = options.baseArgs.includes('--no-open') ? options.baseArgs : [...options.baseArgs, '--no-open'];
|
|
292
|
+
const child = spawn(process.execPath, ['run', options.entry, ...childArgs], {
|
|
293
|
+
cwd: process.cwd(),
|
|
294
|
+
detached: true,
|
|
295
|
+
env: process.env,
|
|
296
|
+
stdio: ['ignore', logFd, logFd],
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
// The pid lands in the lock file immediately: the daemon must be stoppable
|
|
300
|
+
// via `serve stop` even if it never becomes healthy.
|
|
301
|
+
writeFileSync(options.pidFile, `${child.pid}\n`, 'utf-8');
|
|
302
|
+
|
|
303
|
+
let exitMessage: string | null = null;
|
|
304
|
+
child.once('exit', (code, signal) => {
|
|
305
|
+
exitMessage = signal ? `Daemon exited via signal ${signal}` : `Daemon exited with code ${code ?? 'unknown'}`;
|
|
306
|
+
});
|
|
307
|
+
child.unref();
|
|
308
|
+
|
|
309
|
+
const healthResult = await waitForHealth(healthUrl, options.waitMs, () => exitMessage);
|
|
310
|
+
if (!healthResult.ok) {
|
|
311
|
+
if (child.pid && !exitMessage) {
|
|
312
|
+
try {
|
|
313
|
+
process.kill(child.pid, 'SIGTERM');
|
|
314
|
+
} catch {
|
|
315
|
+
// The child exited between the health timeout and the kill.
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
removePidFile(options.pidFile);
|
|
319
|
+
const logTail = readLogTail(options.logFile);
|
|
320
|
+
const details = logTail ? `${healthResult.reason}\n\nRecent log output:\n${logTail}` : healthResult.reason;
|
|
321
|
+
console.error(details);
|
|
322
|
+
process.exit(1);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
outputJson({
|
|
326
|
+
ok: true,
|
|
327
|
+
daemon: true,
|
|
328
|
+
pid: child.pid,
|
|
329
|
+
workspace: expectedWorkspace,
|
|
330
|
+
url: workbenchUrl,
|
|
331
|
+
healthUrl,
|
|
332
|
+
logFile: options.logFile,
|
|
333
|
+
pidFile: options.pidFile,
|
|
334
|
+
});
|
|
335
|
+
process.exit(0);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export async function showServeStatus(options: DaemonPaths & { entry: string }): Promise<void> {
|
|
339
|
+
const healthUrl = `http://localhost:${options.port}/health`;
|
|
340
|
+
const url = `http://localhost:${options.port}/workbench`;
|
|
341
|
+
const pid = readPidFile(options.pidFile);
|
|
342
|
+
const pidRunning = pid ? isOwnDaemonProcess(pid, options.entry) : false;
|
|
343
|
+
const health = await readHealthStatus(healthUrl);
|
|
344
|
+
const responsive = health.responsive;
|
|
345
|
+
const running = responsive || pidRunning;
|
|
346
|
+
// Clean up a stale pid file, but never a concurrent starter's fresh empty
|
|
347
|
+
// spawn lock (that would let a racing `serve --daemon` double-spawn).
|
|
348
|
+
if (!running && existsSync(options.pidFile) && !pidRunning && !isFreshEmptyLock(options.pidFile)) {
|
|
349
|
+
removePidFile(options.pidFile);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
outputJson({
|
|
353
|
+
ok: true,
|
|
354
|
+
daemon: true,
|
|
355
|
+
running,
|
|
356
|
+
responsive,
|
|
357
|
+
workspace: health.workspace,
|
|
358
|
+
pid,
|
|
359
|
+
pidRunning,
|
|
360
|
+
url,
|
|
361
|
+
healthUrl,
|
|
362
|
+
logFile: options.logFile,
|
|
363
|
+
pidFile: options.pidFile,
|
|
364
|
+
pidFileExists: existsSync(options.pidFile),
|
|
365
|
+
});
|
|
366
|
+
process.exit(0);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
export async function stopServeDaemon(
|
|
370
|
+
options: DaemonPaths & {
|
|
371
|
+
waitMs: number;
|
|
372
|
+
entry: string;
|
|
373
|
+
},
|
|
374
|
+
): Promise<void> {
|
|
375
|
+
const healthUrl = `http://localhost:${options.port}/health`;
|
|
376
|
+
const url = `http://localhost:${options.port}/workbench`;
|
|
377
|
+
const pid = readPidFile(options.pidFile);
|
|
378
|
+
const responsive = await isHealthy(healthUrl);
|
|
379
|
+
|
|
380
|
+
if (!pid) {
|
|
381
|
+
if (!responsive) {
|
|
382
|
+
removePidFile(options.pidFile);
|
|
383
|
+
outputJson({
|
|
384
|
+
ok: true,
|
|
385
|
+
daemon: true,
|
|
386
|
+
stopped: false,
|
|
387
|
+
running: false,
|
|
388
|
+
reason: 'No running daemon found.',
|
|
389
|
+
url,
|
|
390
|
+
healthUrl,
|
|
391
|
+
logFile: options.logFile,
|
|
392
|
+
pidFile: options.pidFile,
|
|
393
|
+
});
|
|
394
|
+
process.exit(0);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
outputJson({
|
|
398
|
+
ok: false,
|
|
399
|
+
daemon: true,
|
|
400
|
+
error: `Server on port ${options.port} is responsive, but no pid file was found at ${options.pidFile}.`,
|
|
401
|
+
hint: 'Restart with `pmx-canvas serve --daemon` or provide the correct --pid-file.',
|
|
402
|
+
url,
|
|
403
|
+
healthUrl,
|
|
404
|
+
logFile: options.logFile,
|
|
405
|
+
pidFile: options.pidFile,
|
|
406
|
+
});
|
|
407
|
+
process.exit(1);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
if (!isOwnDaemonProcess(pid, options.entry)) {
|
|
411
|
+
removePidFile(options.pidFile);
|
|
412
|
+
outputJson({
|
|
413
|
+
ok: true,
|
|
414
|
+
daemon: true,
|
|
415
|
+
stopped: false,
|
|
416
|
+
running: responsive,
|
|
417
|
+
reason: `Removed stale pid file for ${pid}.`,
|
|
418
|
+
pid,
|
|
419
|
+
url,
|
|
420
|
+
healthUrl,
|
|
421
|
+
logFile: options.logFile,
|
|
422
|
+
pidFile: options.pidFile,
|
|
423
|
+
});
|
|
424
|
+
process.exit(0);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
process.kill(pid, 'SIGTERM');
|
|
428
|
+
const stopped = await waitForShutdown(healthUrl, options.waitMs, () => isOwnDaemonProcess(pid, options.entry));
|
|
429
|
+
const stillResponsive = await isHealthy(healthUrl);
|
|
430
|
+
const pidRunning = isOwnDaemonProcess(pid, options.entry);
|
|
431
|
+
if (stopped || (!stillResponsive && !pidRunning)) {
|
|
432
|
+
removePidFile(options.pidFile);
|
|
433
|
+
outputJson({
|
|
434
|
+
ok: true,
|
|
435
|
+
daemon: true,
|
|
436
|
+
stopped: true,
|
|
437
|
+
pid,
|
|
438
|
+
url,
|
|
439
|
+
healthUrl,
|
|
440
|
+
logFile: options.logFile,
|
|
441
|
+
pidFile: options.pidFile,
|
|
442
|
+
});
|
|
443
|
+
process.exit(0);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
outputJson({
|
|
447
|
+
ok: false,
|
|
448
|
+
daemon: true,
|
|
449
|
+
stopped: false,
|
|
450
|
+
error: `Timed out waiting for daemon ${pid} to stop.`,
|
|
451
|
+
pid,
|
|
452
|
+
responsive: stillResponsive,
|
|
453
|
+
pidRunning,
|
|
454
|
+
url,
|
|
455
|
+
healthUrl,
|
|
456
|
+
logFile: options.logFile,
|
|
457
|
+
pidFile: options.pidFile,
|
|
458
|
+
});
|
|
459
|
+
process.exit(1);
|
|
460
|
+
}
|