@reticlehq/server 3.3.0 → 3.4.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/LICENSE-ENTERPRISE +1 -1
- package/README.md +3 -3
- package/SKILL.md +12 -14
- package/dist/chromium-launch-options.d.ts +4 -3
- package/dist/chromium-launch-options.js +6 -2
- package/dist/command/cli/adhoc-suite.d.ts +22 -0
- package/dist/command/cli/adhoc-suite.js +90 -19
- package/dist/command/cli/adhoc-verdict.d.ts +35 -0
- package/dist/command/cli/adhoc-verdict.js +90 -3
- package/dist/command/cli/answers/gate-hook-message.d.ts +6 -0
- package/dist/command/cli/answers/gate-hook-message.js +6 -0
- package/dist/command/cli/auth/cloud-keystore.d.ts +0 -2
- package/dist/command/cli/auth/cloud-keystore.js +0 -3
- package/dist/command/cli/auth/cloud-session.d.ts +4 -0
- package/dist/command/cli/auth/cloud-session.js +2 -0
- package/dist/command/cli/cli-doctor.js +7 -5
- package/dist/command/cli/cli-flow-commands.d.ts +3 -6
- package/dist/command/cli/cli-flow-commands.js +67 -4
- package/dist/command/cli/cli-parse.d.ts +8 -1
- package/dist/command/cli/cli-parse.js +21 -3
- package/dist/command/cli/cli-update-commands.js +8 -1
- package/dist/command/cli/cli-verify.d.ts +24 -3
- package/dist/command/cli/cli-verify.js +54 -11
- package/dist/command/cli/cloud-cli.js +5 -2
- package/dist/command/cli/cloud-kit.js +3 -5
- package/dist/command/cli/cloud-login.js +21 -3
- package/dist/command/cli/demo/demo-page.js +6 -3
- package/dist/command/cli/doctor/browser/chromium-hint.d.ts +5 -0
- package/dist/command/cli/doctor/browser/chromium-hint.js +5 -0
- package/dist/command/cli/launch/cli-launch.d.ts +8 -0
- package/dist/command/cli/launch/cli-launch.js +21 -9
- package/dist/command/cli/launch/sdk-version-match.d.ts +84 -0
- package/dist/command/cli/launch/sdk-version-match.js +206 -0
- package/dist/command/cli/lifecycle/daemon-lifecycle.js +2 -16
- package/dist/command/cli/ports/resolve/cli-port.d.ts +25 -0
- package/dist/command/cli/ports/resolve/cli-port.js +125 -15
- package/dist/command/cli/setup-mcp-cli.js +13 -13
- package/dist/command/cli/tutorial.d.ts +2 -2
- package/dist/command/cli/tutorial.js +21 -7
- package/dist/command/cli.js +62 -10
- package/dist/command/daemon/binding/daemon-status-probe.d.ts +14 -2
- package/dist/command/daemon/binding/daemon-status-probe.js +34 -6
- package/dist/command/daemon/daemon-resolve.d.ts +9 -0
- package/dist/command/daemon/daemon-resolve.js +1 -1
- package/dist/command/daemon/lifetime/daemon-usefulness.d.ts +7 -1
- package/dist/command/daemon/lifetime/daemon-usefulness.js +30 -7
- package/dist/command/dev/stale-issue-guard.d.ts +3 -4
- package/dist/command/dev/stale-issue-guard.js +8 -8
- package/dist/command/setup/bringup/connect-lease.d.ts +17 -0
- package/dist/command/setup/bringup/connect-lease.js +38 -0
- package/dist/command/setup/bringup/dev-server-files.d.ts +68 -0
- package/dist/command/setup/bringup/dev-server-files.js +131 -0
- package/dist/command/setup/bringup/dev-server-log-cap.d.ts +35 -0
- package/dist/command/setup/bringup/dev-server-log-cap.js +121 -0
- package/dist/command/setup/bringup/ensure-daemon.d.ts +65 -4
- package/dist/command/setup/bringup/ensure-daemon.js +135 -16
- package/dist/command/setup/bringup/static-page-server.d.ts +35 -0
- package/dist/command/setup/bringup/static-page-server.js +151 -0
- package/dist/command/setup/desktop-shape.d.ts +4 -0
- package/dist/command/setup/desktop-shape.js +18 -2
- package/dist/command/setup/init/init-port.d.ts +15 -0
- package/dist/command/setup/init/init-port.js +44 -0
- package/dist/command/setup/init/init-runtime.d.ts +1 -1
- package/dist/command/setup/init/init-runtime.js +39 -2
- package/dist/command/setup/node-effects.d.ts +27 -14
- package/dist/command/setup/node-effects.js +80 -44
- package/dist/command/setup/probe/loopback-probe-urls.d.ts +7 -0
- package/dist/command/setup/probe/loopback-probe-urls.js +40 -0
- package/dist/command/setup/probe/page-probe.d.ts +13 -2
- package/dist/command/setup/probe/page-probe.js +18 -3
- package/dist/command/setup/run-setup.d.ts +26 -1
- package/dist/command/setup/run-setup.js +96 -13
- package/dist/command/setup/session-pick.d.ts +13 -0
- package/dist/command/setup/session-pick.js +20 -0
- package/dist/command/setup/setup-command.d.ts +2 -14
- package/dist/command/setup/setup-command.js +105 -31
- package/dist/command/setup/setup-mcp.js +3 -3
- package/dist/command/setup/terminal/wait-progress.d.ts +8 -0
- package/dist/command/setup/terminal/wait-progress.js +17 -0
- package/dist/features/crawl/crawl-empty.js +1 -1
- package/dist/features/crawl/crawl-tools.d.ts +6 -0
- package/dist/features/crawl/crawl-tools.js +57 -0
- package/dist/features/crawl/crawl.js +171 -157
- package/dist/features/exhaust/app-coverage.d.ts +33 -0
- package/dist/features/exhaust/app-coverage.js +31 -0
- package/dist/features/exhaust/code-coverage.d.ts +47 -0
- package/dist/features/exhaust/code-coverage.js +77 -0
- package/dist/features/exhaust/exhaust-run.d.ts +33 -0
- package/dist/features/exhaust/exhaust-run.js +82 -0
- package/dist/features/exhaust/explorer.d.ts +103 -0
- package/dist/features/exhaust/explorer.js +195 -0
- package/dist/features/exhaust/failure-branches.d.ts +33 -0
- package/dist/features/exhaust/failure-branches.js +92 -0
- package/dist/features/exhaust/ledger.d.ts +202 -0
- package/dist/features/exhaust/ledger.js +252 -0
- package/dist/features/exhaust/session-fold.d.ts +31 -0
- package/dist/features/exhaust/session-fold.js +79 -0
- package/dist/features/exhaust/session-port.d.ts +36 -0
- package/dist/features/exhaust/session-port.js +102 -0
- package/dist/features/harness/drive-report.d.ts +0 -9
- package/dist/features/harness/drive-report.js +18 -0
- package/dist/features/harness/driver.js +4 -7
- package/dist/features/harness/fill-values.d.ts +6 -0
- package/dist/features/harness/fill-values.js +3 -0
- package/dist/features/harness/jev-driver.js +30 -13
- package/dist/features/harness/openai-driver.js +4 -7
- package/dist/features/harness/platform-config.js +5 -6
- package/dist/index.d.ts +9 -0
- package/dist/index.js +40 -11
- package/dist/judgement/domain/domain-model.js +3 -1
- package/dist/judgement/oracles/self-instrument.js +4 -2
- package/dist/judgement/runs/artifact/replay-mapping.d.ts +2 -2
- package/dist/judgement/runs/artifact/replay-mapping.js +44 -9
- package/dist/judgement/runs/verification-sync.d.ts +3 -1
- package/dist/judgement/runs/verification-sync.js +1 -1
- package/dist/language/flows/change/affected.d.ts +9 -0
- package/dist/language/flows/change/affected.js +35 -0
- package/dist/language/flows/change/gate.d.ts +20 -0
- package/dist/language/flows/change/gate.js +12 -1
- package/dist/language/flows/flow-author.d.ts +7 -0
- package/dist/language/flows/flow-author.js +19 -0
- package/dist/language/flows/flow-expect-grammar.js +1 -1
- package/dist/language/flows/flow-journey.d.ts +54 -0
- package/dist/language/flows/flow-journey.js +119 -0
- package/dist/language/flows/flow-replay-run.d.ts +2 -3
- package/dist/language/flows/flow-replay-run.js +13 -47
- package/dist/language/flows/flow-replay.d.ts +22 -8
- package/dist/language/flows/flow-replay.js +55 -9
- package/dist/language/flows/flow-result.d.ts +2 -0
- package/dist/language/flows/flow-result.js +25 -0
- package/dist/language/flows/flow-tools.js +17 -5
- package/dist/language/flows/flows.d.ts +10 -1
- package/dist/language/flows/flows.js +90 -14
- package/dist/language/flows/recording/replay-halt.js +3 -1
- package/dist/language/flows/recording/tape/recordings.d.ts +30 -1
- package/dist/language/flows/recording/tape/recordings.js +55 -1
- package/dist/language/flows/replay.d.ts +19 -0
- package/dist/language/flows/replay.js +42 -6
- package/dist/language/flows/verify-change-tools.js +5 -2
- package/dist/launch-chromium.d.ts +80 -0
- package/dist/launch-chromium.js +218 -0
- package/dist/memory/cloud/cloud-config.d.ts +11 -2
- package/dist/memory/cloud/cloud-config.js +47 -7
- package/dist/memory/cloud/cloud-sync.d.ts +4 -1
- package/dist/memory/cloud/cloud-sync.js +6 -9
- package/dist/memory/cloud/harness-offer.js +5 -6
- package/dist/memory/cloud/harness-switch.js +5 -6
- package/dist/memory/cloud/sync-cycle.d.ts +68 -3
- package/dist/memory/cloud/sync-cycle.js +298 -55
- package/dist/memory/cloud/sync-daemon.js +24 -20
- package/dist/memory/cloud/sync-disk.js +16 -2
- package/dist/memory/intent/intent-shard.d.ts +10 -32
- package/dist/memory/intent/intent-shard.js +0 -10
- package/dist/memory/journal/drive-flow.d.ts +18 -13
- package/dist/memory/journal/drive-flow.js +45 -37
- package/dist/memory/journal/on-disk/workspace-tiers.js +2 -0
- package/dist/memory/journal/session-end.d.ts +9 -1
- package/dist/memory/journal/session-end.js +24 -12
- package/dist/memory/project/dir/reticle-dir.d.ts +2 -0
- package/dist/memory/project/dir/reticle-dir.js +1 -0
- package/dist/memory/project/project-tools.js +3 -1
- package/dist/portal/bridge/auth-failure-reason.d.ts +1 -1
- package/dist/portal/bridge/auth-failure-reason.js +4 -2
- package/dist/portal/bridge/bridge.d.ts +8 -0
- package/dist/portal/bridge/bridge.js +43 -2
- package/dist/portal/input/js-coverage.d.ts +25 -0
- package/dist/portal/input/js-coverage.js +30 -0
- package/dist/portal/input/real-input.d.ts +11 -0
- package/dist/portal/input/real-input.js +29 -4
- package/dist/portal/pool/playwright-launcher.d.ts +6 -0
- package/dist/portal/pool/playwright-launcher.js +20 -13
- package/dist/portal/session/dev-server/dev-command.d.ts +2 -2
- package/dist/portal/session/dev-server/dev-command.js +30 -12
- package/dist/portal/session/dev-server/dev-server-probe.js +17 -1
- package/dist/portal/session/dev-server/route-status-probe.d.ts +24 -0
- package/dist/portal/session/dev-server/route-status-probe.js +57 -0
- package/dist/portal/session/facts/observed-state.d.ts +11 -0
- package/dist/portal/session/facts/observed-state.js +34 -0
- package/dist/portal/session/no-session-diagnosis.d.ts +29 -0
- package/dist/portal/session/no-session-diagnosis.js +62 -5
- package/dist/portal/session/no-session-next-action.d.ts +13 -0
- package/dist/portal/session/no-session-next-action.js +48 -9
- package/dist/portal/session/no-session-watch.d.ts +18 -0
- package/dist/portal/session/no-session-watch.js +88 -2
- package/dist/portal/session/session-health.js +3 -2
- package/dist/portal/session/session-manager.d.ts +20 -6
- package/dist/portal/session/session-manager.js +19 -3
- package/dist/portal/session/session-reaper.d.ts +28 -0
- package/dist/portal/session/session-reaper.js +49 -1
- package/dist/portal/session/session.d.ts +5 -0
- package/dist/portal/session/session.js +15 -19
- package/dist/surface/http-server.d.ts +2 -1
- package/dist/surface/http-server.js +4 -2
- package/dist/surface/mcp/mcp-proxy.d.ts +2 -2
- package/dist/surface/mcp/mcp-proxy.js +2 -2
- package/dist/surface/mcp/mcp.js +4 -2
- package/dist/surface/mcp/proxy/proxy-daemon-probe.d.ts +13 -1
- package/dist/surface/mcp/proxy/proxy-daemon-probe.js +27 -2
- package/dist/surface/mcp/server-instructions.js +9 -7
- package/dist/surface/tools/act/after-match.d.ts +17 -0
- package/dist/surface/tools/act/after-match.js +30 -0
- package/dist/surface/tools/act/durable.d.ts +69 -0
- package/dist/surface/tools/act/durable.js +139 -0
- package/dist/surface/tools/act/navigation/navigate-arrival.d.ts +13 -6
- package/dist/surface/tools/act/navigation/navigate-arrival.js +47 -10
- package/dist/surface/tools/act/navigation/navigate-result.d.ts +1 -12
- package/dist/surface/tools/act/navigation/navigate-result.js +21 -3
- package/dist/surface/tools/act/proved-controls.d.ts +9 -0
- package/dist/surface/tools/act/proved-controls.js +11 -0
- package/dist/surface/tools/act/sequence-grade.d.ts +5 -0
- package/dist/surface/tools/act/sequence-grade.js +32 -1
- package/dist/surface/tools/act-capsule.d.ts +7 -0
- package/dist/surface/tools/act-capsule.js +46 -1
- package/dist/surface/tools/act-sequence-tool.js +25 -8
- package/dist/surface/tools/act-tools.js +26 -50
- package/dist/surface/tools/assert/assert-grade.js +12 -1
- package/dist/surface/tools/assert/assert-verdict.js +2 -2
- package/dist/surface/tools/browser-tools.js +14 -2
- package/dist/surface/tools/coverage-tools.js +27 -1
- package/dist/surface/tools/error-recovery.js +4 -4
- package/dist/surface/tools/feedback-invite.js +6 -4
- package/dist/surface/tools/harness-explore.d.ts +3 -2
- package/dist/surface/tools/harness-explore.js +7 -6
- package/dist/surface/tools/lease-tools.js +15 -6
- package/dist/surface/tools/live-call-text.js +4 -1
- package/dist/surface/tools/observe-tools.js +9 -1
- package/dist/surface/tools/tools.js +4 -0
- package/dist/telemetry/argument-shape.d.ts +0 -2
- package/dist/telemetry/argument-shape.js +0 -4
- package/dist/telemetry/error-class.d.ts +2 -3
- package/dist/telemetry/error-class.js +2 -3
- package/dist/telemetry/harness-drive.d.ts +0 -2
- package/dist/telemetry/harness-drive.js +0 -4
- package/dist/wire-journal.d.ts +3 -0
- package/dist/wire-journal.js +4 -0
- package/dist/workspace-packages.d.ts +0 -2
- package/dist/workspace-packages.js +1 -9
- package/docs/README.md +4 -2
- package/docs/actions.mdx +3 -3
- package/docs/agent-cheatsheet.md +4 -4
- package/docs/architecture.md +6 -6
- package/docs/best-practices.mdx +1 -1
- package/docs/capabilities.mdx +32 -1
- package/docs/cli/cloud.mdx +14 -4
- package/docs/cli/doctor.mdx +4 -0
- package/docs/cli/feedback.mdx +2 -2
- package/docs/cli/gate.mdx +9 -4
- package/docs/cli/update.mdx +10 -0
- package/docs/cli.mdx +9 -0
- package/docs/desktop.mdx +14 -10
- package/docs/environment.mdx +87 -0
- package/docs/faq.mdx +18 -16
- package/docs/first-drive.md +5 -13
- package/docs/flows.md +21 -2
- package/docs/frameworks.mdx +29 -10
- package/docs/getting-started.md +3 -3
- package/docs/http-transport.md +6 -3
- package/docs/human-control.md +2 -2
- package/docs/index.mdx +16 -6
- package/docs/install-agentic.mdx +1 -1
- package/docs/install-manual.mdx +1 -1
- package/docs/integration-patterns.md +3 -3
- package/docs/multi-agent-testing.md +1 -1
- package/docs/packages/babel-plugin.mdx +1 -1
- package/docs/packages/browser.mdx +2 -2
- package/docs/packages/react.mdx +2 -2
- package/docs/packages/tauri.mdx +1 -1
- package/docs/packages/vite-plugin.mdx +2 -2
- package/docs/packages.mdx +9 -9
- package/docs/predicates.mdx +50 -5
- package/docs/quickstart.mdx +2 -2
- package/docs/recipes.mdx +2 -2
- package/docs/state-management.mdx +2 -2
- package/docs/system-map.md +1 -1
- package/docs/telemetry.md +1 -1
- package/docs/token-efficiency.md +3 -3
- package/docs/tools/act-and-wait.mdx +1 -0
- package/docs/tools/act-sequence.mdx +1 -1
- package/docs/tools/console.mdx +1 -1
- package/docs/tools/inspect.mdx +1 -1
- package/docs/tools/network.mdx +1 -1
- package/docs/tools/query.mdx +1 -1
- package/docs/tools/sessions.mdx +1 -1
- package/docs/tools/snapshot.mdx +1 -1
- package/docs/tools/state.mdx +1 -1
- package/docs/tools/wait-for.mdx +1 -1
- package/docs/usage.md +7 -6
- package/docs/use-cases.mdx +157 -0
- package/docs/vs-screenshots.mdx +3 -3
- package/docs/what-is-recorded.md +2 -2
- package/docs/what-is-reticle.mdx +17 -20
- package/docs/why-reticle.mdx +14 -21
- package/package.json +10 -9
- package/dist/judgement/capsule/minimize.d.ts +0 -12
- package/dist/judgement/capsule/minimize.js +0 -22
- package/dist/judgement/phenomena/phenomena.d.ts +0 -27
- package/dist/judgement/phenomena/phenomena.js +0 -101
- package/dist/language/flows/flow-report.d.ts +0 -22
- package/dist/language/flows/flow-report.js +0 -106
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What `init` leaves in the state home about a dev server it handed over: the log, its one rotated
|
|
3
|
+
* predecessor, and a record of where the server is.
|
|
4
|
+
*
|
|
5
|
+
* All keyed on the app directory, beside the daemon logs and so outside the project: no bundler
|
|
6
|
+
* watcher ever sees them change (a `.reticle/` write inside the app once put Vite into a continuous
|
|
7
|
+
* full-reload loop), and two projects never share a file.
|
|
8
|
+
*/
|
|
9
|
+
import { z } from 'zod';
|
|
10
|
+
export declare function devServerLogName(cwd: string): string;
|
|
11
|
+
/** Where a handed-over server is: enough to find it again and to prove it is still that server. */
|
|
12
|
+
declare const HandedOverSchema: z.ZodObject<{
|
|
13
|
+
pid: z.ZodNumber;
|
|
14
|
+
url: z.ZodString;
|
|
15
|
+
appDir: z.ZodString;
|
|
16
|
+
}, "strip", z.ZodTypeAny, {
|
|
17
|
+
url: string;
|
|
18
|
+
pid: number;
|
|
19
|
+
appDir: string;
|
|
20
|
+
}, {
|
|
21
|
+
url: string;
|
|
22
|
+
pid: number;
|
|
23
|
+
appDir: string;
|
|
24
|
+
}>;
|
|
25
|
+
export type HandedOver = z.infer<typeof HandedOverSchema>;
|
|
26
|
+
/**
|
|
27
|
+
* Written when `init` leaves a server running, so the next `init` can attach to it.
|
|
28
|
+
*
|
|
29
|
+
* Only Vite announces itself (through its plugin). Re-running init while a Next or Angular server
|
|
30
|
+
* it had started was still up spawned a second `dev`, which failed with "port 3000 is already in use
|
|
31
|
+
* — something else is serving there" — the something being the server init itself had left.
|
|
32
|
+
*/
|
|
33
|
+
export declare function recordHandOver(dir: string, record: HandedOver): void;
|
|
34
|
+
/**
|
|
35
|
+
* The url of the server `init` handed over for `appDir`, when that process is alive AND the url
|
|
36
|
+
* still answers — both, because a recycled pid proves nothing and a url answering proves only that
|
|
37
|
+
* SOMETHING is there. Undefined otherwise, and the caller starts one.
|
|
38
|
+
*/
|
|
39
|
+
export declare function handedOverUrl(dir: string, appDir: string, deps: {
|
|
40
|
+
alive: (pid: number) => boolean;
|
|
41
|
+
answers: (url: string) => Promise<boolean>;
|
|
42
|
+
}): Promise<string | undefined>;
|
|
43
|
+
/**
|
|
44
|
+
* Stop the server `init` handed over for `appDir`, and forget it. True when one was stopped.
|
|
45
|
+
*
|
|
46
|
+
* For a port move: Next reads the daemon url into `NEXT_PUBLIC_RETICLE_URL` once, when `next dev`
|
|
47
|
+
* starts, so attaching to the running server after `init --port <new>` left the page dialling the
|
|
48
|
+
* old port — which the same run had just stopped. Only a server this record proves init started is
|
|
49
|
+
* touched; one the user started is theirs to restart.
|
|
50
|
+
*/
|
|
51
|
+
export declare function stopHandedOver(dir: string, appDir: string, deps: {
|
|
52
|
+
alive: (pid: number) => boolean;
|
|
53
|
+
kill: (pid: number) => void;
|
|
54
|
+
}): boolean;
|
|
55
|
+
/**
|
|
56
|
+
* Remove what servers that are gone left behind: their logs, rotated logs and records.
|
|
57
|
+
*
|
|
58
|
+
* Every run wrote a log per project and nothing ever deleted one, so the state home kept a file for
|
|
59
|
+
* every app `init` had ever started. A record whose pid is dead takes its logs with it; a log with no
|
|
60
|
+
* record at all goes once it is old. `keep` is the app about to start, whose files are replaced
|
|
61
|
+
* anyway.
|
|
62
|
+
*/
|
|
63
|
+
export declare function sweepDeadDevServers(dir: string, deps: {
|
|
64
|
+
alive: (pid: number) => boolean;
|
|
65
|
+
now: number;
|
|
66
|
+
keep?: string;
|
|
67
|
+
}): void;
|
|
68
|
+
export {};
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What `init` leaves in the state home about a dev server it handed over: the log, its one rotated
|
|
3
|
+
* predecessor, and a record of where the server is.
|
|
4
|
+
*
|
|
5
|
+
* All keyed on the app directory, beside the daemon logs and so outside the project: no bundler
|
|
6
|
+
* watcher ever sees them change (a `.reticle/` write inside the app once put Vite into a continuous
|
|
7
|
+
* full-reload loop), and two projects never share a file.
|
|
8
|
+
*/
|
|
9
|
+
import { createHash } from 'node:crypto';
|
|
10
|
+
import { readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import { z } from 'zod';
|
|
13
|
+
import { ROTATED_SUFFIX } from './dev-server-log-cap.js';
|
|
14
|
+
const DEV_SERVER_PREFIX = 'dev-server-';
|
|
15
|
+
const LOG_SUFFIX = '.log';
|
|
16
|
+
const RECORD_SUFFIX = '.json';
|
|
17
|
+
/**
|
|
18
|
+
* A log with no record is from a build before records existed, so there is no pid to ask. Anything
|
|
19
|
+
* this old is from a server long gone; a live one that went this quiet loses a file it was not using.
|
|
20
|
+
*/
|
|
21
|
+
const UNRECORDED_LOG_MAX_AGE_MS = 24 * 60 * 60_000;
|
|
22
|
+
const OWN_FILE = /^dev-server-([0-9a-f]{12})\.(?:log|log\.1|json)$/;
|
|
23
|
+
function keyOf(cwd) {
|
|
24
|
+
return createHash('sha256').update(cwd).digest('hex').slice(0, 12);
|
|
25
|
+
}
|
|
26
|
+
export function devServerLogName(cwd) {
|
|
27
|
+
return `${DEV_SERVER_PREFIX}${keyOf(cwd)}${LOG_SUFFIX}`;
|
|
28
|
+
}
|
|
29
|
+
function recordName(cwd) {
|
|
30
|
+
return `${DEV_SERVER_PREFIX}${keyOf(cwd)}${RECORD_SUFFIX}`;
|
|
31
|
+
}
|
|
32
|
+
/** Where a handed-over server is: enough to find it again and to prove it is still that server. */
|
|
33
|
+
const HandedOverSchema = z.object({
|
|
34
|
+
pid: z.number().int().positive(),
|
|
35
|
+
url: z.string().min(1),
|
|
36
|
+
appDir: z.string().min(1),
|
|
37
|
+
});
|
|
38
|
+
/**
|
|
39
|
+
* Written when `init` leaves a server running, so the next `init` can attach to it.
|
|
40
|
+
*
|
|
41
|
+
* Only Vite announces itself (through its plugin). Re-running init while a Next or Angular server
|
|
42
|
+
* it had started was still up spawned a second `dev`, which failed with "port 3000 is already in use
|
|
43
|
+
* — something else is serving there" — the something being the server init itself had left.
|
|
44
|
+
*/
|
|
45
|
+
export function recordHandOver(dir, record) {
|
|
46
|
+
try {
|
|
47
|
+
writeFileSync(join(dir, recordName(record.appDir)), JSON.stringify(record));
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
// Without the record the next run starts a server as it always did. Never fail a handover on it.
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function readRecord(path) {
|
|
54
|
+
try {
|
|
55
|
+
const parsed = HandedOverSchema.safeParse(JSON.parse(readFileSync(path, 'utf8')));
|
|
56
|
+
return parsed.success ? parsed.data : undefined;
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* The url of the server `init` handed over for `appDir`, when that process is alive AND the url
|
|
64
|
+
* still answers — both, because a recycled pid proves nothing and a url answering proves only that
|
|
65
|
+
* SOMETHING is there. Undefined otherwise, and the caller starts one.
|
|
66
|
+
*/
|
|
67
|
+
export async function handedOverUrl(dir, appDir, deps) {
|
|
68
|
+
const record = readRecord(join(dir, recordName(appDir)));
|
|
69
|
+
if (undefined === record || record.appDir !== appDir || !deps.alive(record.pid))
|
|
70
|
+
return undefined;
|
|
71
|
+
return (await deps.answers(record.url)) ? record.url : undefined;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Stop the server `init` handed over for `appDir`, and forget it. True when one was stopped.
|
|
75
|
+
*
|
|
76
|
+
* For a port move: Next reads the daemon url into `NEXT_PUBLIC_RETICLE_URL` once, when `next dev`
|
|
77
|
+
* starts, so attaching to the running server after `init --port <new>` left the page dialling the
|
|
78
|
+
* old port — which the same run had just stopped. Only a server this record proves init started is
|
|
79
|
+
* touched; one the user started is theirs to restart.
|
|
80
|
+
*/
|
|
81
|
+
export function stopHandedOver(dir, appDir, deps) {
|
|
82
|
+
const path = join(dir, recordName(appDir));
|
|
83
|
+
const record = readRecord(path);
|
|
84
|
+
if (undefined === record || record.appDir !== appDir || !deps.alive(record.pid))
|
|
85
|
+
return false;
|
|
86
|
+
deps.kill(record.pid);
|
|
87
|
+
rmSync(path, { force: true });
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Remove what servers that are gone left behind: their logs, rotated logs and records.
|
|
92
|
+
*
|
|
93
|
+
* Every run wrote a log per project and nothing ever deleted one, so the state home kept a file for
|
|
94
|
+
* every app `init` had ever started. A record whose pid is dead takes its logs with it; a log with no
|
|
95
|
+
* record at all goes once it is old. `keep` is the app about to start, whose files are replaced
|
|
96
|
+
* anyway.
|
|
97
|
+
*/
|
|
98
|
+
export function sweepDeadDevServers(dir, deps) {
|
|
99
|
+
let files;
|
|
100
|
+
try {
|
|
101
|
+
files = readdirSync(dir);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const keepKey = undefined === deps.keep ? undefined : keyOf(deps.keep);
|
|
107
|
+
// Exactly the names this module writes, so nothing else in the state home can match.
|
|
108
|
+
const keys = new Set(files.map((f) => OWN_FILE.exec(f)?.[1]).filter((k) => undefined !== k));
|
|
109
|
+
for (const key of keys) {
|
|
110
|
+
if (key === keepKey)
|
|
111
|
+
continue;
|
|
112
|
+
const log = join(dir, `${DEV_SERVER_PREFIX}${key}${LOG_SUFFIX}`);
|
|
113
|
+
const recordPath = join(dir, `${DEV_SERVER_PREFIX}${key}${RECORD_SUFFIX}`);
|
|
114
|
+
const record = readRecord(recordPath);
|
|
115
|
+
const dead = undefined === record
|
|
116
|
+
? deps.now - lastWrittenAt(log) > UNRECORDED_LOG_MAX_AGE_MS
|
|
117
|
+
: !deps.alive(record.pid);
|
|
118
|
+
if (!dead)
|
|
119
|
+
continue;
|
|
120
|
+
for (const path of [log, `${log}${ROTATED_SUFFIX}`, recordPath])
|
|
121
|
+
rmSync(path, { force: true });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
function lastWrittenAt(path) {
|
|
125
|
+
try {
|
|
126
|
+
return statSync(path).mtimeMs;
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return 0;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The dev server's log, with a ceiling — and the small process that enforces it.
|
|
3
|
+
*
|
|
4
|
+
* The handed-over dev server wrote straight into a file descriptor, which is what keeps it alive once
|
|
5
|
+
* `init` exits (a pipe whose reader has gone is an EPIPE on the next write). But a descriptor nobody
|
|
6
|
+
* reads has no ceiling: an upstream echo loop — TanStack devtools and Vite 8 forwarding each other's
|
|
7
|
+
* console lines — grew `~/.reticle/dev-server-<hash>.log` to 2.9 GB in about five minutes.
|
|
8
|
+
*
|
|
9
|
+
* So the server's output goes through a pipe again, and the reader on the other end is THIS script,
|
|
10
|
+
* run as its own process in the server's process group rather than inside `init`. It lives exactly
|
|
11
|
+
* as long as the server's output does: it exits when the pipes close, never before, so the EPIPE
|
|
12
|
+
* that killed handed-over servers cannot come back. It ignores the signals a group stop sends, for
|
|
13
|
+
* the same reason — the server gets them too, and may still be writing on its way out.
|
|
14
|
+
*
|
|
15
|
+
* The bound is rotation, not truncation: past the cap the file is renamed to `<log>.1` (replacing
|
|
16
|
+
* the previous one) and a fresh file started, so disk use stays under twice the cap while the most
|
|
17
|
+
* recent output — the part anybody reads — is always there.
|
|
18
|
+
*
|
|
19
|
+
* Why not truncate from the daemon on a timer: the daemon idles out on its own schedule and a
|
|
20
|
+
* handed-over server does not, so the one bound that matters would stop being enforced exactly when
|
|
21
|
+
* nobody is watching. A reader that shares the server's lifetime cannot be outlived by it.
|
|
22
|
+
*/
|
|
23
|
+
/** Per file. The log and its one rotated predecessor together never exceed twice this. */
|
|
24
|
+
export declare const DEV_SERVER_LOG_CAP_BYTES: number;
|
|
25
|
+
/** The single predecessor a rotation keeps. */
|
|
26
|
+
export declare const ROTATED_SUFFIX = ".1";
|
|
27
|
+
/** Appends to `path`, rotating it to `path.1` whenever the next write would pass `capBytes`. */
|
|
28
|
+
export declare class BoundedLog {
|
|
29
|
+
#private;
|
|
30
|
+
private readonly path;
|
|
31
|
+
private readonly capBytes;
|
|
32
|
+
constructor(path: string, capBytes?: number);
|
|
33
|
+
write(chunk: Buffer): void;
|
|
34
|
+
close(): void;
|
|
35
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The dev server's log, with a ceiling — and the small process that enforces it.
|
|
3
|
+
*
|
|
4
|
+
* The handed-over dev server wrote straight into a file descriptor, which is what keeps it alive once
|
|
5
|
+
* `init` exits (a pipe whose reader has gone is an EPIPE on the next write). But a descriptor nobody
|
|
6
|
+
* reads has no ceiling: an upstream echo loop — TanStack devtools and Vite 8 forwarding each other's
|
|
7
|
+
* console lines — grew `~/.reticle/dev-server-<hash>.log` to 2.9 GB in about five minutes.
|
|
8
|
+
*
|
|
9
|
+
* So the server's output goes through a pipe again, and the reader on the other end is THIS script,
|
|
10
|
+
* run as its own process in the server's process group rather than inside `init`. It lives exactly
|
|
11
|
+
* as long as the server's output does: it exits when the pipes close, never before, so the EPIPE
|
|
12
|
+
* that killed handed-over servers cannot come back. It ignores the signals a group stop sends, for
|
|
13
|
+
* the same reason — the server gets them too, and may still be writing on its way out.
|
|
14
|
+
*
|
|
15
|
+
* The bound is rotation, not truncation: past the cap the file is renamed to `<log>.1` (replacing
|
|
16
|
+
* the previous one) and a fresh file started, so disk use stays under twice the cap while the most
|
|
17
|
+
* recent output — the part anybody reads — is always there.
|
|
18
|
+
*
|
|
19
|
+
* Why not truncate from the daemon on a timer: the daemon idles out on its own schedule and a
|
|
20
|
+
* handed-over server does not, so the one bound that matters would stop being enforced exactly when
|
|
21
|
+
* nobody is watching. A reader that shares the server's lifetime cannot be outlived by it.
|
|
22
|
+
*/
|
|
23
|
+
import { spawn } from 'node:child_process';
|
|
24
|
+
import { closeSync, openSync, renameSync, writeSync } from 'node:fs';
|
|
25
|
+
import { pathToFileURL } from 'node:url';
|
|
26
|
+
/** Per file. The log and its one rotated predecessor together never exceed twice this. */
|
|
27
|
+
export const DEV_SERVER_LOG_CAP_BYTES = 8 * 1024 * 1024;
|
|
28
|
+
const APPEND = 'a';
|
|
29
|
+
const TRUNCATE = 'w';
|
|
30
|
+
/** The single predecessor a rotation keeps. */
|
|
31
|
+
export const ROTATED_SUFFIX = '.1';
|
|
32
|
+
/** Appends to `path`, rotating it to `path.1` whenever the next write would pass `capBytes`. */
|
|
33
|
+
export class BoundedLog {
|
|
34
|
+
path;
|
|
35
|
+
capBytes;
|
|
36
|
+
/** Undefined only between a rotation's close and a reopen that failed; the next write retries. */
|
|
37
|
+
#fd;
|
|
38
|
+
#size = 0;
|
|
39
|
+
constructor(path, capBytes = DEV_SERVER_LOG_CAP_BYTES) {
|
|
40
|
+
this.path = path;
|
|
41
|
+
this.capBytes = capBytes;
|
|
42
|
+
this.#fd = openSync(path, APPEND);
|
|
43
|
+
}
|
|
44
|
+
write(chunk) {
|
|
45
|
+
try {
|
|
46
|
+
if (this.#fd === undefined || (0 < this.#size && this.#size + chunk.length > this.capBytes)) {
|
|
47
|
+
this.#rotate();
|
|
48
|
+
}
|
|
49
|
+
const fd = this.#fd;
|
|
50
|
+
if (fd === undefined)
|
|
51
|
+
return;
|
|
52
|
+
// A single chunk bigger than the cap is kept only in its tail: it is still bounded.
|
|
53
|
+
const kept = chunk.length > this.capBytes ? chunk.subarray(chunk.length - this.capBytes) : chunk;
|
|
54
|
+
writeSync(fd, kept);
|
|
55
|
+
this.#size += kept.length;
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
// Disk full, a removed state dir: the output is lost, the server is not. Never throw here.
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
close() {
|
|
62
|
+
const fd = this.#fd;
|
|
63
|
+
this.#fd = undefined;
|
|
64
|
+
if (fd === undefined)
|
|
65
|
+
return;
|
|
66
|
+
try {
|
|
67
|
+
closeSync(fd);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
/* already closed */
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Close, rename to the predecessor, reopen. A rename that fails (the rotated name is taken, or on
|
|
75
|
+
* Windows something holds the file) truncates in place instead: the predecessor is lost, the bound
|
|
76
|
+
* and the newest output are not. It used to leave the descriptor closed, and every later write
|
|
77
|
+
* vanished inside `write`'s catch.
|
|
78
|
+
*/
|
|
79
|
+
#rotate() {
|
|
80
|
+
this.close();
|
|
81
|
+
let flags = APPEND;
|
|
82
|
+
try {
|
|
83
|
+
renameSync(this.path, `${this.path}${ROTATED_SUFFIX}`);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
flags = TRUNCATE;
|
|
87
|
+
}
|
|
88
|
+
this.#fd = openSync(this.path, flags);
|
|
89
|
+
this.#size = 0;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/** Arguments are `<log path> <cap bytes> <shell command>`, the way `OwnedDevServer` spawns this. */
|
|
93
|
+
function main(argv) {
|
|
94
|
+
const [logPath, cap, command] = argv;
|
|
95
|
+
if (undefined === logPath || undefined === command)
|
|
96
|
+
process.exit(2);
|
|
97
|
+
const log = new BoundedLog(logPath, Number(cap) || DEV_SERVER_LOG_CAP_BYTES);
|
|
98
|
+
// Stopping the group signals both of us. The server decides when it is done; this outlives it.
|
|
99
|
+
for (const signal of ['SIGTERM', 'SIGINT', 'SIGHUP'])
|
|
100
|
+
process.on(signal, () => { });
|
|
101
|
+
const child = spawn(command, {
|
|
102
|
+
shell: true,
|
|
103
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
104
|
+
env: process.env,
|
|
105
|
+
});
|
|
106
|
+
child.stdout.on('data', (chunk) => log.write(chunk));
|
|
107
|
+
child.stderr.on('data', (chunk) => log.write(chunk));
|
|
108
|
+
// `close`, not `exit`: every byte the server wrote is read before this process goes.
|
|
109
|
+
child.on('close', (code) => {
|
|
110
|
+
log.close();
|
|
111
|
+
process.exit(code ?? 1);
|
|
112
|
+
});
|
|
113
|
+
child.on('error', () => {
|
|
114
|
+
log.close();
|
|
115
|
+
process.exit(1);
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
const entry = process.argv[1];
|
|
119
|
+
if (undefined !== entry && import.meta.url === pathToFileURL(entry).href) {
|
|
120
|
+
main(process.argv.slice(2));
|
|
121
|
+
}
|
|
@@ -8,12 +8,35 @@
|
|
|
8
8
|
* dial and setup ends with "the SDK is in the page and never dialled the bridge" — the SDK being
|
|
9
9
|
* blamed for the absence of the thing it was dialling.
|
|
10
10
|
*
|
|
11
|
-
* Idempotent by construction:
|
|
12
|
-
*
|
|
11
|
+
* Idempotent by construction: a daemon that speaks our wire contract is adopted. One that does NOT
|
|
12
|
+
* is not usable, however healthy its `/status`: an older Reticle turns away every hello from the page
|
|
13
|
+
* init just instrumented ("bridge refused the connection: invalid message"), and init used to adopt
|
|
14
|
+
* it anyway, wait out its budget, and blame the page. So a skewed daemon nobody is using is replaced
|
|
15
|
+
* (stopped by its LISTENER, never `lsof -ti`, which also kills the agent's MCP proxy), and one with
|
|
16
|
+
* live sessions is left alone with the exact command to run once they are done.
|
|
17
|
+
*
|
|
18
|
+
* A stranger on the port that is not a Reticle daemon at all is refused before this runs; see
|
|
19
|
+
* `bridge-port.ts`.
|
|
13
20
|
*/
|
|
21
|
+
import type { DaemonRegistryEntry } from '@reticlehq/core/artifacts';
|
|
14
22
|
interface EnsureDaemonDeps {
|
|
23
|
+
/**
|
|
24
|
+
* Whether a daemon's `/status` body says it speaks a different wire contract from this build.
|
|
25
|
+
* Injected: the judge is `daemonSkew` in cli-launch, the same rule `reticle mcp` warns with.
|
|
26
|
+
*/
|
|
27
|
+
readonly skewed: (status: unknown) => boolean;
|
|
15
28
|
readonly usable: (port: number) => Promise<boolean>;
|
|
29
|
+
/** The daemon's `/status` body, or undefined. */
|
|
30
|
+
readonly status: (port: number) => Promise<unknown>;
|
|
31
|
+
/** Stop the daemon on the port. True once the port is free. */
|
|
32
|
+
readonly replace: (port: number) => Promise<boolean>;
|
|
16
33
|
readonly spawn: (port: number) => boolean;
|
|
34
|
+
/**
|
|
35
|
+
* A live process already owns this port's pid file — a daemon mid-start that this spawn lost the
|
|
36
|
+
* lock to. That is exactly what `spawn` returning false means on a fresh HOME where the editor's
|
|
37
|
+
* `reticle mcp` got there first, and it is a reason to wait, not to give up.
|
|
38
|
+
*/
|
|
39
|
+
readonly starting: (port: number) => boolean;
|
|
17
40
|
readonly waitReady: (port: number) => Promise<unknown>;
|
|
18
41
|
readonly scriptPath: string | undefined;
|
|
19
42
|
}
|
|
@@ -21,10 +44,48 @@ export declare const EnsureDaemon: {
|
|
|
21
44
|
/** Something usable was already there. */
|
|
22
45
|
readonly ADOPTED: "adopted";
|
|
23
46
|
readonly STARTED: "started";
|
|
47
|
+
/** A daemon on another wire contract, serving nobody, was stopped and ours started in its place. */
|
|
48
|
+
readonly REPLACED: "replaced";
|
|
49
|
+
/** A daemon on another wire contract is serving live sessions, so it was left alone. */
|
|
50
|
+
readonly SKEWED_IN_USE: "skewed-in-use";
|
|
24
51
|
/** Could not be started, and the caller has to say so rather than blame the page. */
|
|
25
52
|
readonly UNAVAILABLE: "unavailable";
|
|
26
53
|
};
|
|
27
54
|
export type EnsureDaemon = (typeof EnsureDaemon)[keyof typeof EnsureDaemon];
|
|
28
|
-
|
|
29
|
-
|
|
55
|
+
interface EnsureDaemonResult {
|
|
56
|
+
readonly state: EnsureDaemon;
|
|
57
|
+
/** One line for the person watching, when the state needs one. */
|
|
58
|
+
readonly message?: string;
|
|
59
|
+
}
|
|
60
|
+
export declare function ensureDaemon(port: number, deps: EnsureDaemonDeps): Promise<EnsureDaemonResult>;
|
|
61
|
+
interface RetireDeps {
|
|
62
|
+
/** Every LIVE daemon in the registry. */
|
|
63
|
+
readonly registered: () => readonly DaemonRegistryEntry[];
|
|
64
|
+
readonly status: (port: number) => Promise<unknown>;
|
|
65
|
+
/** Stop the daemon on the port by its listener. True once the port is free. */
|
|
66
|
+
readonly stop: (port: number) => Promise<boolean>;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Stop this project's daemons on any port but `port`, the one init is about to use. Returns the
|
|
70
|
+
* ports stopped.
|
|
71
|
+
*
|
|
72
|
+
* `init --port <new>` started a daemon on the new port and left the old one serving: the build
|
|
73
|
+
* plugin found both, dialled the old one, and init — waiting on the new one — exited 1 with
|
|
74
|
+
* "connected to a DIFFERENT Reticle daemon". A daemon another project's tab is still on is left
|
|
75
|
+
* alone, because stopping it would take that app down to fix this one. Stopped by its listener
|
|
76
|
+
* (`runKill` without force), never `lsof -ti`, which also kills the agent's MCP proxy.
|
|
77
|
+
*/
|
|
78
|
+
export declare function retireMovedDaemons(projectId: string | undefined, port: number, deps: RetireDeps): Promise<number[]>;
|
|
79
|
+
/** The real registry, `/status` and listener-only stop. */
|
|
80
|
+
export declare function nodeRetireDeps(): RetireDeps;
|
|
81
|
+
/**
|
|
82
|
+
* Hold the daemon on `port` until the returned release is called, by renewing a hold on its
|
|
83
|
+
* `/status` well inside the hold window.
|
|
84
|
+
*
|
|
85
|
+
* `init` starts the daemon and can then wait on the app for longer than the idle grace — a cold
|
|
86
|
+
* desktop build, a slow first compile. None of that counted as activity, and the daemon init had
|
|
87
|
+
* just started idled out (`reticle_daemon_idle_exit`) while init was still waiting on it.
|
|
88
|
+
*/
|
|
89
|
+
export declare function holdDaemon(port: number): () => void;
|
|
90
|
+
export declare function nodeEnsureDaemonDeps(skewed: (status: unknown) => boolean): EnsureDaemonDeps;
|
|
30
91
|
export {};
|
|
@@ -8,51 +8,170 @@
|
|
|
8
8
|
* dial and setup ends with "the SDK is in the page and never dialled the bridge" — the SDK being
|
|
9
9
|
* blamed for the absence of the thing it was dialling.
|
|
10
10
|
*
|
|
11
|
-
* Idempotent by construction:
|
|
12
|
-
*
|
|
11
|
+
* Idempotent by construction: a daemon that speaks our wire contract is adopted. One that does NOT
|
|
12
|
+
* is not usable, however healthy its `/status`: an older Reticle turns away every hello from the page
|
|
13
|
+
* init just instrumented ("bridge refused the connection: invalid message"), and init used to adopt
|
|
14
|
+
* it anyway, wait out its budget, and blame the page. So a skewed daemon nobody is using is replaced
|
|
15
|
+
* (stopped by its LISTENER, never `lsof -ti`, which also kills the agent's MCP proxy), and one with
|
|
16
|
+
* live sessions is left alone with the exact command to run once they are done.
|
|
17
|
+
*
|
|
18
|
+
* A stranger on the port that is not a Reticle daemon at all is refused before this runs; see
|
|
19
|
+
* `bridge-port.ts`.
|
|
13
20
|
*/
|
|
14
21
|
import { log } from '../../../log.js';
|
|
15
|
-
import { spawnDaemon } from '../../daemon/daemon.js';
|
|
22
|
+
import { isAlive, readPid, reticleStateHome, spawnDaemon } from '../../daemon/daemon.js';
|
|
23
|
+
import { readDaemonRegistry } from '../../daemon/daemon-resolve.js';
|
|
24
|
+
import { LOOPBACK_HOST, STATUS_HOLD_MS } from '@reticlehq/core';
|
|
16
25
|
import { probePresence, presenceIsUsable } from '../../daemon/binding/port-presence.js';
|
|
17
|
-
import {
|
|
26
|
+
import { probeDaemon, waitForDaemonBind } from '../../../surface/mcp/mcp-proxy.js';
|
|
18
27
|
import { daemonSpawnArgs } from '../../cli/daemon-start-options.js';
|
|
19
|
-
import { fetchStatus } from '../../daemon/binding/daemon-status-probe.js';
|
|
28
|
+
import { fetchStatus, summarizeStatus } from '../../daemon/binding/daemon-status-probe.js';
|
|
29
|
+
import { runKill } from '../../cli/cli-kill.js';
|
|
20
30
|
export const EnsureDaemon = {
|
|
21
31
|
/** Something usable was already there. */
|
|
22
32
|
ADOPTED: 'adopted',
|
|
23
33
|
STARTED: 'started',
|
|
34
|
+
/** A daemon on another wire contract, serving nobody, was stopped and ours started in its place. */
|
|
35
|
+
REPLACED: 'replaced',
|
|
36
|
+
/** A daemon on another wire contract is serving live sessions, so it was left alone. */
|
|
37
|
+
SKEWED_IN_USE: 'skewed-in-use',
|
|
24
38
|
/** Could not be started, and the caller has to say so rather than blame the page. */
|
|
25
39
|
UNAVAILABLE: 'unavailable',
|
|
26
40
|
};
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
41
|
+
/** A string field off the /status body, or undefined on a daemon too old to report it. */
|
|
42
|
+
function statusString(payload, key) {
|
|
43
|
+
if ('object' !== typeof payload || null === payload)
|
|
44
|
+
return undefined;
|
|
45
|
+
const value = Reflect.get(payload, key);
|
|
46
|
+
return 'string' === typeof value && 0 < value.length ? value : undefined;
|
|
47
|
+
}
|
|
48
|
+
async function spawnAndWait(port, deps) {
|
|
30
49
|
if (undefined === deps.scriptPath)
|
|
31
|
-
return
|
|
32
|
-
|
|
33
|
-
|
|
50
|
+
return false;
|
|
51
|
+
// A lost spawn race is not a failed start: `init` printed "could not start the Reticle daemon"
|
|
52
|
+
// over a daemon that bound seconds later. Only a spawn that failed with nobody else starting one
|
|
53
|
+
// is a reason to stop without waiting.
|
|
54
|
+
if (!deps.spawn(port) && !deps.starting(port))
|
|
55
|
+
return false;
|
|
34
56
|
try {
|
|
35
57
|
// Readiness means the port ACCEPTS, not that a child was spawned: a readiness signal that
|
|
36
58
|
// precedes readiness is worse than none, because what waits on it stops waiting too early.
|
|
37
59
|
await deps.waitReady(port);
|
|
38
60
|
}
|
|
39
61
|
catch {
|
|
40
|
-
return
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
return deps.usable(port);
|
|
65
|
+
}
|
|
66
|
+
export async function ensureDaemon(port, deps) {
|
|
67
|
+
if (await deps.usable(port)) {
|
|
68
|
+
const status = await deps.status(port);
|
|
69
|
+
if (!deps.skewed(status))
|
|
70
|
+
return { state: EnsureDaemon.ADOPTED };
|
|
71
|
+
const theirs = statusString(status, 'version') ?? 'an older build';
|
|
72
|
+
const { sessionCount } = summarizeStatus(status);
|
|
73
|
+
if (0 < sessionCount) {
|
|
74
|
+
return {
|
|
75
|
+
state: EnsureDaemon.SKEWED_IN_USE,
|
|
76
|
+
message: `The Reticle daemon on port ${String(port)} is ${theirs}, which speaks a different wire ` +
|
|
77
|
+
`contract than this init, so it will refuse your app — and ` +
|
|
78
|
+
`${String(sessionCount)} session(s) are still using it, so init will not stop it. When ` +
|
|
79
|
+
`they are done, run \`npx @reticlehq/server kill --port ${String(port)}\` and re-run init.`,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
if (!(await deps.replace(port)))
|
|
83
|
+
return { state: EnsureDaemon.UNAVAILABLE };
|
|
84
|
+
return (await spawnAndWait(port, deps))
|
|
85
|
+
? {
|
|
86
|
+
state: EnsureDaemon.REPLACED,
|
|
87
|
+
message: `replaced the idle Reticle daemon on port ${String(port)} (${theirs}), which speaks a ` +
|
|
88
|
+
`different wire contract and would have refused your app, with a current one.`,
|
|
89
|
+
}
|
|
90
|
+
: { state: EnsureDaemon.UNAVAILABLE };
|
|
91
|
+
}
|
|
92
|
+
return (await spawnAndWait(port, deps))
|
|
93
|
+
? { state: EnsureDaemon.STARTED }
|
|
94
|
+
: { state: EnsureDaemon.UNAVAILABLE };
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Stop this project's daemons on any port but `port`, the one init is about to use. Returns the
|
|
98
|
+
* ports stopped.
|
|
99
|
+
*
|
|
100
|
+
* `init --port <new>` started a daemon on the new port and left the old one serving: the build
|
|
101
|
+
* plugin found both, dialled the old one, and init — waiting on the new one — exited 1 with
|
|
102
|
+
* "connected to a DIFFERENT Reticle daemon". A daemon another project's tab is still on is left
|
|
103
|
+
* alone, because stopping it would take that app down to fix this one. Stopped by its listener
|
|
104
|
+
* (`runKill` without force), never `lsof -ti`, which also kills the agent's MCP proxy.
|
|
105
|
+
*/
|
|
106
|
+
export async function retireMovedDaemons(projectId, port, deps) {
|
|
107
|
+
if (undefined === projectId || 0 === projectId.length)
|
|
108
|
+
return [];
|
|
109
|
+
const stopped = [];
|
|
110
|
+
for (const entry of deps.registered()) {
|
|
111
|
+
if (entry.projectId !== projectId || entry.port === port)
|
|
112
|
+
continue;
|
|
113
|
+
const { sessions } = summarizeStatus(await deps.status(entry.port));
|
|
114
|
+
const shared = sessions.some((s) => undefined !== s.projectId && projectId !== s.projectId);
|
|
115
|
+
if (shared)
|
|
116
|
+
continue;
|
|
117
|
+
if (await deps.stop(entry.port))
|
|
118
|
+
stopped.push(entry.port);
|
|
41
119
|
}
|
|
42
|
-
return
|
|
120
|
+
return stopped;
|
|
121
|
+
}
|
|
122
|
+
/** The real registry, `/status` and listener-only stop. */
|
|
123
|
+
export function nodeRetireDeps() {
|
|
124
|
+
return {
|
|
125
|
+
registered: () => readDaemonRegistry(reticleStateHome()).filter((e) => isAlive(e.pid)),
|
|
126
|
+
status: (port) => fetchStatus(port),
|
|
127
|
+
stop: (port) => runKill(port, false),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Hold the daemon on `port` until the returned release is called, by renewing a hold on its
|
|
132
|
+
* `/status` well inside the hold window.
|
|
133
|
+
*
|
|
134
|
+
* `init` starts the daemon and can then wait on the app for longer than the idle grace — a cold
|
|
135
|
+
* desktop build, a slow first compile. None of that counted as activity, and the daemon init had
|
|
136
|
+
* just started idled out (`reticle_daemon_idle_exit`) while init was still waiting on it.
|
|
137
|
+
*/
|
|
138
|
+
export function holdDaemon(port) {
|
|
139
|
+
const renew = () => void fetchStatus(port, LOOPBACK_HOST, true);
|
|
140
|
+
renew();
|
|
141
|
+
const timer = setInterval(renew, STATUS_HOLD_MS / 3);
|
|
142
|
+
timer.unref();
|
|
143
|
+
return () => clearInterval(timer);
|
|
43
144
|
}
|
|
44
|
-
export function nodeEnsureDaemonDeps() {
|
|
145
|
+
export function nodeEnsureDaemonDeps(skewed) {
|
|
45
146
|
return {
|
|
147
|
+
skewed,
|
|
46
148
|
usable: async (port) => presenceIsUsable(await probePresence(port, { tcpOpen: probeDaemon, status: fetchStatus })),
|
|
149
|
+
status: (port) => fetchStatus(port),
|
|
150
|
+
// `reticle kill` without `--force`: it signals only the LISTENER, and only one that answers
|
|
151
|
+
// /status, so the agent's MCP proxy on the same port survives.
|
|
152
|
+
replace: (port) => runKill(port, false),
|
|
47
153
|
spawn: (port) => {
|
|
48
154
|
const scriptPath = process.argv[1];
|
|
49
155
|
if (undefined === scriptPath)
|
|
50
156
|
return false;
|
|
51
157
|
const started = spawnDaemon(process.execPath, scriptPath, daemonSpawnArgs({ port, headless: true, http: false }), port);
|
|
52
|
-
log
|
|
158
|
+
// For a log or a pipe. On a terminal it was a JSON line in the middle of `init`'s output, read
|
|
159
|
+
// by a person, about a step the next lines already report in words.
|
|
160
|
+
if (true !== process.stderr.isTTY)
|
|
161
|
+
log('reticle_setup_daemon_started', { port, started });
|
|
53
162
|
return started;
|
|
54
163
|
},
|
|
55
|
-
|
|
164
|
+
starting: (port) => {
|
|
165
|
+
const pid = readPid(port);
|
|
166
|
+
return null !== pid && isAlive(pid);
|
|
167
|
+
},
|
|
168
|
+
// The same wait `reticle restart` does: poll until `/status` answers as a daemon, bounded. A
|
|
169
|
+
// cold daemon on a fresh HOME outlived the proxy's shorter reconnect budget this used to borrow.
|
|
170
|
+
waitReady: async (port) => {
|
|
171
|
+
if (!(await waitForDaemonBind(port)))
|
|
172
|
+
throw new Error(`no daemon bound :${String(port)}`);
|
|
173
|
+
return undefined;
|
|
174
|
+
},
|
|
56
175
|
scriptPath: process.argv[1],
|
|
57
176
|
};
|
|
58
177
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The dev server `init` starts for a plain HTML page that has none of its own.
|
|
3
|
+
*
|
|
4
|
+
* A directory holding only `index.html` (and a script or two) used to be wired correctly — init
|
|
5
|
+
* writes the connect snippet into the page — and then stop at "No dev command … start the app
|
|
6
|
+
* yourself and pass --url". The one thing left between that user and a connected session was a web
|
|
7
|
+
* server, which is the one thing a page like that never had. So init starts this one, the same way it
|
|
8
|
+
* starts `npm run dev`, and the rest of setup (wait, open, prove the connect) runs unchanged.
|
|
9
|
+
*
|
|
10
|
+
* It is run as its own process (`node static-page-server.js --dir <app>`), not inside init, because it
|
|
11
|
+
* has to outlive init exactly like any handed-over dev server. It imports nothing but Node, so the
|
|
12
|
+
* built file runs on its own.
|
|
13
|
+
*
|
|
14
|
+
* Loopback only, on both families: a page served to the LAN would publish the pairing token the
|
|
15
|
+
* snippet carries.
|
|
16
|
+
*/
|
|
17
|
+
export declare const STATIC_PAGE_INDEX = "index.html";
|
|
18
|
+
/** Where the search for a free port starts when none is given — clear of Vite's 5173 run. */
|
|
19
|
+
export declare const STATIC_PAGE_FIRST_PORT = 5500;
|
|
20
|
+
export interface StaticPageServer {
|
|
21
|
+
readonly url: string;
|
|
22
|
+
close(): Promise<void>;
|
|
23
|
+
}
|
|
24
|
+
export declare function startStaticPageServer(opts: {
|
|
25
|
+
root: string;
|
|
26
|
+
port: number;
|
|
27
|
+
}): Promise<StaticPageServer>;
|
|
28
|
+
/**
|
|
29
|
+
* The command setup runs for a page with no dev server of its own, or undefined when this is not
|
|
30
|
+
* that page. Quoted so an app path with a space survives the shell.
|
|
31
|
+
*/
|
|
32
|
+
export declare function staticPageDevCommand(opts: {
|
|
33
|
+
appDir: string;
|
|
34
|
+
isStaticPage: boolean;
|
|
35
|
+
}): string | undefined;
|