rcf-lite 0.0.1 → 0.7.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 +288 -0
- package/LICENSE +202 -0
- package/README.md +92 -4
- package/bin/rcf-verify.js +122 -0
- package/bin/rcf.js +174 -0
- package/bin/view-supervisor-child.mjs +14 -0
- package/fixtures/canary-manifest.json +103 -0
- package/guidance/README.md +25 -0
- package/guidance/build-cycle-playbook.md +492 -0
- package/guidance/build-cycle.md +40 -0
- package/guidance/document-model.md +45 -0
- package/guidance/elicitation-playbook.md +442 -0
- package/guidance/harness-template.md +246 -0
- package/guidance/managed/README.md +63 -0
- package/guidance/managed/agent-instructions-block.hash +1 -0
- package/guidance/managed/agent-instructions-block.md +220 -0
- package/guidance/managed/legacy-fragment-hashes.json +22 -0
- package/guidance/manifest.json +21 -0
- package/guidance/overview.md +48 -0
- package/guidance/persona-programme.md +44 -0
- package/package.json +69 -8
- package/rcf/adrs/adr-001.json +25 -0
- package/rcf/adrs/adr-002.json +25 -0
- package/rcf/adrs/adr-003.json +25 -0
- package/rcf/adrs/adr-004.json +20 -0
- package/rcf/adrs/adr-005.json +20 -0
- package/rcf/adrs/adr-006.json +25 -0
- package/rcf/adrs/adr-007.json +25 -0
- package/rcf/adrs/adr-008.json +25 -0
- package/rcf/adrs/adr-009.json +25 -0
- package/rcf/build-sequence.json +11 -0
- package/rcf/code-nodes/cn-001.json +14 -0
- package/rcf/code-nodes/cn-002.json +14 -0
- package/rcf/code-nodes/cn-003.json +15 -0
- package/rcf/code-nodes/cn-004.json +17 -0
- package/rcf/code-nodes/cn-005.json +19 -0
- package/rcf/code-nodes/cn-006.json +17 -0
- package/rcf/code-nodes/cn-007.json +16 -0
- package/rcf/code-nodes/cn-008.json +19 -0
- package/rcf/code-nodes/cn-009.json +16 -0
- package/rcf/code-nodes/cn-010.json +18 -0
- package/rcf/code-nodes/cn-011.json +18 -0
- package/rcf/code-nodes/cn-012.json +18 -0
- package/rcf/code-nodes/cn-013.json +19 -0
- package/rcf/code-nodes/cn-014.json +16 -0
- package/rcf/code-nodes/cn-015.json +16 -0
- package/rcf/code-nodes/cn-016.json +16 -0
- package/rcf/code-nodes/cn-017.json +16 -0
- package/rcf/code-nodes/cn-018.json +15 -0
- package/rcf/code-nodes/cn-019.json +16 -0
- package/rcf/code-nodes/cn-020.json +16 -0
- package/rcf/code-nodes/cn-021.json +16 -0
- package/rcf/code-nodes/cn-022.json +16 -0
- package/rcf/code-nodes/cn-023.json +16 -0
- package/rcf/code-nodes/cn-024.json +16 -0
- package/rcf/code-nodes/cn-025.json +16 -0
- package/rcf/code-nodes/cn-026.json +16 -0
- package/rcf/code-nodes/cn-027.json +16 -0
- package/rcf/code-nodes/cn-028.json +16 -0
- package/rcf/code-nodes/cn-029.json +19 -0
- package/rcf/code-nodes/cn-030.json +14 -0
- package/rcf/code-nodes/cn-031.json +14 -0
- package/rcf/code-nodes/cn-032.json +14 -0
- package/rcf/code-nodes/cn-033.json +14 -0
- package/rcf/code-nodes/cn-034.json +14 -0
- package/rcf/code-nodes/cn-035.json +14 -0
- package/rcf/code-nodes/cn-036.json +14 -0
- package/rcf/code-nodes/cn-037.json +14 -0
- package/rcf/code-nodes/cn-038.json +14 -0
- package/rcf/code-nodes/cn-039.json +14 -0
- package/rcf/code-nodes/cn-040.json +14 -0
- package/rcf/code-nodes/cn-041.json +14 -0
- package/rcf/code-nodes/cn-042.json +14 -0
- package/rcf/code-nodes/cn-043.json +14 -0
- package/rcf/code-nodes/cn-044.json +14 -0
- package/rcf/code-nodes/cn-045.json +14 -0
- package/rcf/code-nodes/cn-046.json +14 -0
- package/rcf/code-nodes/cn-047.json +14 -0
- package/rcf/code-nodes/cn-048.json +14 -0
- package/rcf/code-nodes/cn-049.json +14 -0
- package/rcf/code-nodes/cn-050.json +14 -0
- package/rcf/code-nodes/cn-051.json +14 -0
- package/rcf/code-nodes/cn-052.json +14 -0
- package/rcf/code-nodes/cn-053.json +14 -0
- package/rcf/code-nodes/cn-054.json +14 -0
- package/rcf/code-nodes/cn-055.json +14 -0
- package/rcf/code-nodes/cn-056.json +14 -0
- package/rcf/code-nodes/cn-057.json +14 -0
- package/rcf/fbs/fbs-001.json +49 -0
- package/rcf/fbs/fbs-002.json +42 -0
- package/rcf/fbs/fbs-003.json +37 -0
- package/rcf/fbs/fbs-004.json +39 -0
- package/rcf/fbs/fbs-005.json +38 -0
- package/rcf/fbs/fbs-006.json +48 -0
- package/rcf/fbs/fbs-007.json +39 -0
- package/rcf/fbs/fbs-008.json +40 -0
- package/rcf/fbs/fbs-009.json +36 -0
- package/rcf/fbs/fbs-010.json +41 -0
- package/rcf/fbs/fbs-011.json +36 -0
- package/rcf/fbs/fbs-012.json +46 -0
- package/rcf/fbs/fbs-013.json +42 -0
- package/rcf/fbs/fbs-014.json +49 -0
- package/rcf/fbs/fbs-015.json +32 -0
- package/rcf/manifest.json +17 -0
- package/rcf/prd.json +47 -0
- package/rcf/requirements/req-001.json +19 -0
- package/rcf/requirements/req-002.json +19 -0
- package/rcf/requirements/req-003.json +19 -0
- package/rcf/requirements/req-004.json +19 -0
- package/rcf/requirements/req-005.json +19 -0
- package/rcf/requirements/req-006.json +19 -0
- package/rcf/requirements/req-007.json +19 -0
- package/rcf/requirements/req-008.json +19 -0
- package/rcf/requirements/req-009.json +19 -0
- package/rcf/tacs/tac-001.json +45 -0
- package/rcf/tacs/tac-002.json +109 -0
- package/rcf/tacs/tac-003.json +40 -0
- package/rcf/tacs/tac-004.json +51 -0
- package/rcf/tacs/tac-005.json +52 -0
- package/rcf/tacs/tac-006.json +104 -0
- package/rcf/tacs/tac-007.json +38 -0
- package/rcf/tacs/tac-008.json +51 -0
- package/rcf/tad.json +51 -0
- package/rcf/test-suites/PENDING.md +23 -0
- package/rcf/test-suites/ts-001.json +38 -0
- package/rcf/test-suites/ts-002.json +38 -0
- package/rcf/test-suites/ts-003.json +43 -0
- package/rcf/test-suites/ts-004.json +44 -0
- package/rcf/test-suites/ts-005.json +30 -0
- package/rcf/test-suites/ts-006.json +36 -0
- package/rcf/test-suites/ts-007.json +43 -0
- package/rcf/test-suites/ts-008.json +37 -0
- package/rcf/test-suites/ts-009.json +38 -0
- package/rcf/test-suites/ts-010.json +38 -0
- package/rcf/test-suites/ts-011.json +44 -0
- package/rcf/test-suites/ts-012.json +36 -0
- package/rcf/test-suites/ts-013.json +38 -0
- package/rcf/test-suites/ts-014.json +38 -0
- package/rcf/test-suites/ts-015.json +38 -0
- package/rcf/test-suites/ts-016.json +37 -0
- package/rcf/test-suites/ts-017.json +38 -0
- package/rcf/test-suites/ts-018.json +38 -0
- package/rcf/test-suites/ts-019.json +37 -0
- package/rcf/test-suites/ts-020.json +46 -0
- package/rcf/test-suites/ts-021.json +46 -0
- package/rcf/test-suites/ts-022.json +46 -0
- package/rcf/test-suites/ts-023.json +46 -0
- package/rcf/test-suites/ts-024.json +46 -0
- package/rcf/test-suites/ts-025.json +52 -0
- package/rcf/user-stories/us-101.json +40 -0
- package/rcf/user-stories/us-102.json +40 -0
- package/rcf/user-stories/us-201.json +40 -0
- package/rcf/user-stories/us-202.json +40 -0
- package/rcf/user-stories/us-203.json +32 -0
- package/rcf/user-stories/us-301.json +40 -0
- package/rcf/user-stories/us-302.json +40 -0
- package/rcf/user-stories/us-303.json +40 -0
- package/rcf/user-stories/us-304.json +40 -0
- package/rcf/user-stories/us-401.json +40 -0
- package/rcf/user-stories/us-402.json +40 -0
- package/rcf/user-stories/us-403.json +40 -0
- package/rcf/user-stories/us-501.json +40 -0
- package/rcf/user-stories/us-502.json +40 -0
- package/rcf/user-stories/us-503.json +40 -0
- package/rcf/user-stories/us-601.json +40 -0
- package/rcf/user-stories/us-602.json +40 -0
- package/rcf/user-stories/us-701.json +40 -0
- package/rcf/user-stories/us-702.json +40 -0
- package/rcf/user-stories/us-801.json +49 -0
- package/rcf/user-stories/us-802.json +49 -0
- package/rcf/user-stories/us-803.json +49 -0
- package/rcf/user-stories/us-804.json +49 -0
- package/rcf/user-stories/us-805.json +49 -0
- package/rcf/user-stories/us-901.json +40 -0
- package/src/.gitkeep +0 -0
- package/src/browser-verify/auth-smoke.js +109 -0
- package/src/browser-verify/index.js +29 -0
- package/src/browser-verify/invariants.js +336 -0
- package/src/browser-verify/manifest-writer.js +189 -0
- package/src/browser-verify/runner.js +171 -0
- package/src/build/bundle.js +198 -0
- package/src/build/formatters/json.js +18 -0
- package/src/build/formatters/markdown.js +406 -0
- package/src/build/index.js +14 -0
- package/src/build/mark.js +177 -0
- package/src/build/queue.js +285 -0
- package/src/cli/browser-verify.js +231 -0
- package/src/cli/build.js +584 -0
- package/src/cli/coverage.js +219 -0
- package/src/cli/create.js +355 -0
- package/src/cli/delete.js +127 -0
- package/src/cli/design.js +353 -0
- package/src/cli/doctor.js +548 -0
- package/src/cli/fbs.js +171 -0
- package/src/cli/finalise.js +377 -0
- package/src/cli/guidance.js +206 -0
- package/src/cli/help.js +156 -0
- package/src/cli/impact.js +119 -0
- package/src/cli/init.js +282 -0
- package/src/cli/intake.js +153 -0
- package/src/cli/link.js +128 -0
- package/src/cli/mcp.js +160 -0
- package/src/cli/preflight.js +220 -0
- package/src/cli/read.js +162 -0
- package/src/cli/req-baseline.js +269 -0
- package/src/cli/req-classify.js +135 -0
- package/src/cli/review.js +295 -0
- package/src/cli/test-suite.js +216 -0
- package/src/cli/trace.js +172 -0
- package/src/cli/ui-baseline.js +292 -0
- package/src/cli/ui-classify.js +108 -0
- package/src/cli/update.js +197 -0
- package/src/cli/validate.js +168 -0
- package/src/cli/view.js +452 -0
- package/src/core/baseline-catalog/data/auth.json +42 -0
- package/src/core/baseline-catalog/data/http-api.json +42 -0
- package/src/core/baseline-catalog/data/notifications.json +33 -0
- package/src/core/baseline-catalog/data/persistence.json +33 -0
- package/src/core/baseline-catalog/data/web-ui.json +60 -0
- package/src/core/baseline-catalog/index.js +121 -0
- package/src/core/errors/index.js +167 -0
- package/src/core/fixtures/register-canary/canary-prompt-01.json +18 -0
- package/src/core/fixtures/register-canary/canary-prompt-02.json +21 -0
- package/src/core/fixtures/register-canary/canary-prompt-03.json +17 -0
- package/src/core/isolation/index.js +60 -0
- package/src/core/mcp/framing.js +103 -0
- package/src/core/mcp/index.js +8 -0
- package/src/core/mcp/server.js +228 -0
- package/src/core/patterns/register-canary.js +209 -0
- package/src/core/patterns/req-shapes.js +158 -0
- package/src/core/patterns/services.js +358 -0
- package/src/core/patterns/ui-shapes.js +166 -0
- package/src/core/store/cn-resolve.js +134 -0
- package/src/core/store/derive-deps.js +93 -0
- package/src/core/store/ids.js +78 -0
- package/src/core/store/index.js +20 -0
- package/src/core/store/init.js +255 -0
- package/src/core/store/loader.js +211 -0
- package/src/core/store/tp-resolve.js +176 -0
- package/src/core/store/validator.js +191 -0
- package/src/core/store/walker.js +898 -0
- package/src/core/store/writer.js +1849 -0
- package/src/design/index.js +11 -0
- package/src/design/writer.js +271 -0
- package/src/finalise/detect.js +129 -0
- package/src/finalise/index.js +17 -0
- package/src/finalise/ingest.js +119 -0
- package/src/finalise/install.js +119 -0
- package/src/finalise/ship-without-verified.js +131 -0
- package/src/finalise/spawn.js +84 -0
- package/src/intake/fidelity.js +105 -0
- package/src/intake/index.js +6 -0
- package/src/intake/manifest-writer.js +100 -0
- package/src/intake/orchestrator.js +138 -0
- package/src/intake/validate.js +80 -0
- package/src/mcp/map-errors.js +131 -0
- package/src/mcp/prompts.js +49 -0
- package/src/mcp/resources.js +244 -0
- package/src/mcp/tools.js +1204 -0
- package/src/preflight/design-shapes.js +185 -0
- package/src/preflight/index.js +90 -0
- package/src/preflight/manifest-writer.js +264 -0
- package/src/preflight/scanner.js +206 -0
- package/src/preflight/secrets.js +134 -0
- package/src/preflight/session.js +246 -0
- package/src/query/attestation.js +285 -0
- package/src/query/coverage.js +308 -0
- package/src/query/formatters/json.js +21 -0
- package/src/query/formatters/mermaid.js +209 -0
- package/src/query/formatters/table.js +203 -0
- package/src/query/impact.js +173 -0
- package/src/query/index.js +9 -0
- package/src/query/trace.js +345 -0
- package/src/register-canary/fixture-loader.js +87 -0
- package/src/register-canary/index.js +10 -0
- package/src/register-canary/record-writer.js +132 -0
- package/src/register-canary/runner.js +156 -0
- package/src/req-baseline/gate.js +86 -0
- package/src/req-baseline/index.js +27 -0
- package/src/req-baseline/open-candidates.js +143 -0
- package/src/req-baseline/opt-out.js +195 -0
- package/src/req-baseline/sweep.js +230 -0
- package/src/req-detection/classifier.js +181 -0
- package/src/req-detection/index.js +9 -0
- package/src/req-detection/persist.js +55 -0
- package/src/review/index.js +325 -0
- package/src/review/mutation.js +117 -0
- package/src/review/ui-baseline-drift.js +138 -0
- package/src/server/index.js +178 -0
- package/src/server/routes.js +110 -0
- package/src/server/sse.js +118 -0
- package/src/setup/agent-setup.js +362 -0
- package/src/setup/identity-seed.js +104 -0
- package/src/setup/knowledge-seed.js +123 -0
- package/src/setup/managed-block.js +193 -0
- package/src/setup/managed-gitignore.js +166 -0
- package/src/setup/managed-markers.js +49 -0
- package/src/ui-baseline/defaults.js +119 -0
- package/src/ui-baseline/index.js +25 -0
- package/src/ui-baseline/manifest-writer.js +282 -0
- package/src/ui-baseline/session.js +178 -0
- package/src/ui-detection/classifier.js +192 -0
- package/src/verify/chain/index.js +190 -0
- package/src/verify/cli/cleanup.js +61 -0
- package/src/verify/cli/help.js +56 -0
- package/src/verify/cli/mcp.js +98 -0
- package/src/verify/cli/provision.js +71 -0
- package/src/verify/cli/report.js +71 -0
- package/src/verify/cli/run.js +155 -0
- package/src/verify/engine/brief.js +87 -0
- package/src/verify/engine/index.js +177 -0
- package/src/verify/engine/launcher.js +307 -0
- package/src/verify/mcp/tools.js +107 -0
- package/src/verify/profile/index.js +146 -0
- package/src/verify/provision/index.js +256 -0
- package/src/verify/report/index.js +139 -0
- package/src/verify/report/renderer.js +118 -0
- package/src/verify/verdict/index.js +246 -0
- package/src/view/doc-renderers/adr.js +44 -0
- package/src/view/doc-renderers/build-sequence.js +40 -0
- package/src/view/doc-renderers/fbs.js +128 -0
- package/src/view/doc-renderers/helpers.js +159 -0
- package/src/view/doc-renderers/index.js +12 -0
- package/src/view/doc-renderers/prd.js +45 -0
- package/src/view/doc-renderers/req.js +43 -0
- package/src/view/doc-renderers/tac.js +38 -0
- package/src/view/doc-renderers/tad.js +74 -0
- package/src/view/doc-renderers/test-suite.js +45 -0
- package/src/view/doc-renderers/user-story.js +63 -0
- package/src/view/html-page.js +462 -0
- package/src/view/index.js +63 -0
- package/src/view/live-client.js +338 -0
- package/src/view/mermaid-diagram.js +178 -0
- package/src/view/style.css +735 -0
- package/src/view/tree-model.js +152 -0
- package/src/view/vendored/mermaid.min.js +2607 -0
- package/src/view-supervisor/index.js +26 -0
- package/src/view-supervisor/logs.js +32 -0
- package/src/view-supervisor/manifest-writer.js +178 -0
- package/src/view-supervisor/persist-until.js +85 -0
- package/src/view-supervisor/supervisor.js +276 -0
- package/src/watch/index.js +152 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Public surface for the view-supervisor module (spec §9).
|
|
2
|
+
|
|
3
|
+
export {
|
|
4
|
+
startDetached,
|
|
5
|
+
stopDetached,
|
|
6
|
+
statusOfDetached,
|
|
7
|
+
runDetachedChild,
|
|
8
|
+
} from './supervisor.js';
|
|
9
|
+
|
|
10
|
+
export {
|
|
11
|
+
readViewServerRecord,
|
|
12
|
+
writeViewServerRecord,
|
|
13
|
+
clearViewServerRecord,
|
|
14
|
+
writePidFile,
|
|
15
|
+
readPidFile,
|
|
16
|
+
removePidFile,
|
|
17
|
+
DEFAULT_PID_PATH,
|
|
18
|
+
isPidAlive,
|
|
19
|
+
} from './manifest-writer.js';
|
|
20
|
+
|
|
21
|
+
export {
|
|
22
|
+
supervisorLogPath,
|
|
23
|
+
ensureLogDir,
|
|
24
|
+
writeLogLine,
|
|
25
|
+
readLogTail,
|
|
26
|
+
} from './logs.js';
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Supervisor log helpers (spec §9.2 `rcf view logs`).
|
|
2
|
+
|
|
3
|
+
import { appendFile, mkdir, readFile } from 'node:fs/promises';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @param {string} projectRoot
|
|
8
|
+
*/
|
|
9
|
+
export function supervisorLogPath(projectRoot) {
|
|
10
|
+
return join(projectRoot, '.rcf', 'view-server.log');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function ensureLogDir(projectRoot) {
|
|
14
|
+
const dir = dirname(supervisorLogPath(projectRoot));
|
|
15
|
+
await mkdir(dir, { recursive: true });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function writeLogLine(projectRoot, line) {
|
|
19
|
+
await ensureLogDir(projectRoot);
|
|
20
|
+
await appendFile(supervisorLogPath(projectRoot), `${new Date().toISOString()} ${line}\n`, 'utf8');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function readLogTail(projectRoot, tail = 200) {
|
|
24
|
+
try {
|
|
25
|
+
const raw = await readFile(supervisorLogPath(projectRoot), 'utf8');
|
|
26
|
+
const lines = raw.split('\n').filter(Boolean);
|
|
27
|
+
return lines.slice(-tail);
|
|
28
|
+
} catch (err) {
|
|
29
|
+
if (err.code === 'ENOENT') return [];
|
|
30
|
+
throw err;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// reviewSurface.viewServer manifest reader/writer (spec §3.6).
|
|
2
|
+
//
|
|
3
|
+
// Writes are atomic (.tmp → rename) and schema-validated before persist,
|
|
4
|
+
// mirroring the preflight and intake writers.
|
|
5
|
+
|
|
6
|
+
import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
|
|
7
|
+
import { dirname, join } from 'node:path';
|
|
8
|
+
import { existsSync } from 'node:fs';
|
|
9
|
+
|
|
10
|
+
import { rcfError } from '#core/errors';
|
|
11
|
+
import { validateDocument } from '#core/store';
|
|
12
|
+
|
|
13
|
+
export const DEFAULT_PID_PATH = '.rcf/view-server.pid';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The managed-gitignore entry the aggregator consumes (spec §9.3). The
|
|
17
|
+
* pid file and the supervisor log are host-local runtime artefacts; the
|
|
18
|
+
* chain never carries them.
|
|
19
|
+
*/
|
|
20
|
+
export const viewServerGitignoreEntry = Object.freeze({
|
|
21
|
+
path: '.rcf/view-server.pid',
|
|
22
|
+
owner: 'rcf view --detach (view-server pid file)',
|
|
23
|
+
since: '0.7.0',
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
export const viewServerLogGitignoreEntry = Object.freeze({
|
|
27
|
+
path: '.rcf/view-server.log',
|
|
28
|
+
owner: 'rcf view --detach (supervisor log)',
|
|
29
|
+
since: '0.7.0',
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Read the reviewSurface.viewServer record from the manifest, if any.
|
|
34
|
+
*
|
|
35
|
+
* @param {string} projectRoot
|
|
36
|
+
* @returns {Promise<object|null>}
|
|
37
|
+
*/
|
|
38
|
+
export async function readViewServerRecord(projectRoot) {
|
|
39
|
+
const abs = join(projectRoot, 'rcf', 'manifest.json');
|
|
40
|
+
try {
|
|
41
|
+
const raw = await readFile(abs, 'utf8');
|
|
42
|
+
const parsed = JSON.parse(raw);
|
|
43
|
+
return parsed?.reviewSurface?.viewServer ?? null;
|
|
44
|
+
} catch (err) {
|
|
45
|
+
if (err.code === 'ENOENT') return null;
|
|
46
|
+
throw err;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Persist a reviewSurface.viewServer block onto the manifest.
|
|
52
|
+
*
|
|
53
|
+
* @param {object} args
|
|
54
|
+
* @param {string} args.projectRoot
|
|
55
|
+
* @param {object} args.record
|
|
56
|
+
* @returns {Promise<{ record: object } | import('#core/errors').RcfError>}
|
|
57
|
+
*/
|
|
58
|
+
export async function writeViewServerRecord({ projectRoot, record }) {
|
|
59
|
+
const abs = join(projectRoot, 'rcf', 'manifest.json');
|
|
60
|
+
let manifest = {};
|
|
61
|
+
try {
|
|
62
|
+
const raw = await readFile(abs, 'utf8');
|
|
63
|
+
manifest = JSON.parse(raw);
|
|
64
|
+
} catch (err) {
|
|
65
|
+
if (err.code !== 'ENOENT') throw err;
|
|
66
|
+
}
|
|
67
|
+
const nextManifest = {
|
|
68
|
+
...manifest,
|
|
69
|
+
reviewSurface: {
|
|
70
|
+
...(manifest.reviewSurface ?? {}),
|
|
71
|
+
viewServer: record,
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
const validation = validateDocument({ doc: nextManifest, kind: 'manifest', filePath: 'rcf/manifest.json' });
|
|
75
|
+
if (validation) return validation;
|
|
76
|
+
try {
|
|
77
|
+
await mkdir(dirname(abs), { recursive: true });
|
|
78
|
+
const tmp = `${abs}.tmp`;
|
|
79
|
+
await writeFile(tmp, `${JSON.stringify(nextManifest, null, 2)}\n`, 'utf8');
|
|
80
|
+
try {
|
|
81
|
+
await rename(tmp, abs);
|
|
82
|
+
} catch (err) {
|
|
83
|
+
try { await unlink(tmp); } catch { /* ignore */ }
|
|
84
|
+
throw err;
|
|
85
|
+
}
|
|
86
|
+
} catch (err) {
|
|
87
|
+
return rcfError({
|
|
88
|
+
kind: 'ioFailure',
|
|
89
|
+
message: `view-supervisor: manifest write failed: ${err.message}`,
|
|
90
|
+
filePath: 'rcf/manifest.json',
|
|
91
|
+
stack: err.stack,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
return { record };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Clear the reviewSurface.viewServer field.
|
|
99
|
+
*
|
|
100
|
+
* @param {string} projectRoot
|
|
101
|
+
* @returns {Promise<void>}
|
|
102
|
+
*/
|
|
103
|
+
export async function clearViewServerRecord(projectRoot) {
|
|
104
|
+
const abs = join(projectRoot, 'rcf', 'manifest.json');
|
|
105
|
+
let manifest = {};
|
|
106
|
+
try {
|
|
107
|
+
const raw = await readFile(abs, 'utf8');
|
|
108
|
+
manifest = JSON.parse(raw);
|
|
109
|
+
} catch (err) {
|
|
110
|
+
if (err.code === 'ENOENT') return;
|
|
111
|
+
throw err;
|
|
112
|
+
}
|
|
113
|
+
if (!manifest.reviewSurface?.viewServer) return;
|
|
114
|
+
const nextReview = { ...(manifest.reviewSurface ?? {}) };
|
|
115
|
+
delete nextReview.viewServer;
|
|
116
|
+
const nextManifest = { ...manifest, reviewSurface: nextReview };
|
|
117
|
+
// If the reviewSurface object is empty, drop it entirely so validate
|
|
118
|
+
// does not carry an empty object.
|
|
119
|
+
if (Object.keys(nextReview).length === 0) delete nextManifest.reviewSurface;
|
|
120
|
+
const tmp = `${abs}.tmp`;
|
|
121
|
+
await writeFile(tmp, `${JSON.stringify(nextManifest, null, 2)}\n`, 'utf8');
|
|
122
|
+
try {
|
|
123
|
+
await rename(tmp, abs);
|
|
124
|
+
} catch (err) {
|
|
125
|
+
try { await unlink(tmp); } catch { /* ignore */ }
|
|
126
|
+
throw err;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Write a pid file at projectRoot/.rcf/view-server.pid. */
|
|
131
|
+
export async function writePidFile(projectRoot, pid) {
|
|
132
|
+
const abs = join(projectRoot, DEFAULT_PID_PATH);
|
|
133
|
+
await mkdir(dirname(abs), { recursive: true });
|
|
134
|
+
await writeFile(abs, `${pid}\n`, 'utf8');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Read the pid file, or null when absent. */
|
|
138
|
+
export async function readPidFile(projectRoot) {
|
|
139
|
+
const abs = join(projectRoot, DEFAULT_PID_PATH);
|
|
140
|
+
if (!existsSync(abs)) return null;
|
|
141
|
+
try {
|
|
142
|
+
const raw = await readFile(abs, 'utf8');
|
|
143
|
+
const n = Number.parseInt(raw.trim(), 10);
|
|
144
|
+
return Number.isFinite(n) && n > 0 ? n : null;
|
|
145
|
+
} catch {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Remove the pid file, ignoring ENOENT. */
|
|
151
|
+
export async function removePidFile(projectRoot) {
|
|
152
|
+
const abs = join(projectRoot, DEFAULT_PID_PATH);
|
|
153
|
+
try {
|
|
154
|
+
await unlink(abs);
|
|
155
|
+
} catch (err) {
|
|
156
|
+
if (err.code !== 'ENOENT') throw err;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Probe whether a pid is alive. `process.kill(pid, 0)` is the standard
|
|
162
|
+
* cross-platform (POSIX) predicate; throws ESRCH when the process is
|
|
163
|
+
* gone and EPERM when the process exists but the caller lacks permission
|
|
164
|
+
* (still "alive" from our perspective).
|
|
165
|
+
*
|
|
166
|
+
* @param {number} pid
|
|
167
|
+
* @returns {boolean}
|
|
168
|
+
*/
|
|
169
|
+
export function isPidAlive(pid) {
|
|
170
|
+
if (!Number.isFinite(pid) || pid <= 0) return false;
|
|
171
|
+
try {
|
|
172
|
+
process.kill(pid, 0);
|
|
173
|
+
return true;
|
|
174
|
+
} catch (err) {
|
|
175
|
+
if (err.code === 'EPERM') return true;
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// `rcf view start --persist-until <value>` argument parser (spec §9.2).
|
|
2
|
+
//
|
|
3
|
+
// The spec's sample invocation is `--persist-until 4h`; the shipping
|
|
4
|
+
// help text also names an ISO timestamp as the durable form. Accept
|
|
5
|
+
// both so the sample works AND cross-platform ISO timestamps still do.
|
|
6
|
+
//
|
|
7
|
+
// Duration grammar (minimal and documented):
|
|
8
|
+
//
|
|
9
|
+
// <duration> := <hours> | <minutes> | <hours><minutes>
|
|
10
|
+
// <hours> := /^\d+h$/ e.g. `4h`, `24h`
|
|
11
|
+
// <minutes> := /^\d+m$/ e.g. `30m`, `90m`
|
|
12
|
+
// combined := /^\d+h\d+m$/ e.g. `2h30m`
|
|
13
|
+
//
|
|
14
|
+
// Anything else (`4hrs`, `4 h`, `PT4H`, `4:00`, an empty component like
|
|
15
|
+
// `0h`, or a plain integer with no unit) is a usage error: the parser
|
|
16
|
+
// returns `{ ok: false, error: <message> }` and the CLI exits 2 with
|
|
17
|
+
// that message plus the grammar. The old behaviour - `Date.parse('4h')`
|
|
18
|
+
// returning NaN, no persist timer set, supervisor runs forever - is
|
|
19
|
+
// what this parser exists to prevent.
|
|
20
|
+
//
|
|
21
|
+
// ISO parsing: any string `Date.parse` accepts as a finite timestamp
|
|
22
|
+
// passes. We deliberately do NOT restrict to a strict ISO-8601 subset
|
|
23
|
+
// - the shipping help text says "ISO timestamp" and operators reach
|
|
24
|
+
// for `date -u +%Y-%m-%dT%H:%M:%SZ` and similar; Date.parse handles
|
|
25
|
+
// them all. A past timestamp is accepted (the supervisor will unwind
|
|
26
|
+
// on the next event loop turn); the parser is a shape check, not a
|
|
27
|
+
// policy check.
|
|
28
|
+
|
|
29
|
+
const DURATION_RE = /^(?:(\d+)h)?(?:(\d+)m)?$/;
|
|
30
|
+
|
|
31
|
+
const GRAMMAR_HINT = 'expected a duration (e.g. `4h`, `30m`, `2h30m`) or an ISO timestamp (e.g. `2026-07-31T18:00:00Z`)';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Parse a `--persist-until` argument into an ISO timestamp.
|
|
35
|
+
*
|
|
36
|
+
* @param {string} raw - argv value; must be a string
|
|
37
|
+
* @param {object} [opts]
|
|
38
|
+
* @param {number} [opts.nowMs] - injected clock for tests; defaults to Date.now()
|
|
39
|
+
* @returns {{ ok: true, iso: string, deadlineMs: number, source: 'duration'|'iso' }
|
|
40
|
+
* | { ok: false, error: string }}
|
|
41
|
+
*/
|
|
42
|
+
export function parsePersistUntil(raw, opts = {}) {
|
|
43
|
+
if (typeof raw !== 'string' || raw.length === 0) {
|
|
44
|
+
return { ok: false, error: `--persist-until: missing value; ${GRAMMAR_HINT}` };
|
|
45
|
+
}
|
|
46
|
+
const nowMs = typeof opts.nowMs === 'number' ? opts.nowMs : Date.now();
|
|
47
|
+
|
|
48
|
+
// Duration form. The regex `/^(?:(\d+)h)?(?:(\d+)m)?$/` also matches
|
|
49
|
+
// the empty string; guard against that plus `0h` / `0m` / `0h0m`
|
|
50
|
+
// (which the CLI accepting would make the supervisor unwind
|
|
51
|
+
// immediately, arguably useless).
|
|
52
|
+
const durationMatch = DURATION_RE.exec(raw);
|
|
53
|
+
const hasHours = durationMatch && durationMatch[1] !== undefined;
|
|
54
|
+
const hasMinutes = durationMatch && durationMatch[2] !== undefined;
|
|
55
|
+
if (durationMatch && (hasHours || hasMinutes)) {
|
|
56
|
+
const hours = hasHours ? Number.parseInt(durationMatch[1], 10) : 0;
|
|
57
|
+
const minutes = hasMinutes ? Number.parseInt(durationMatch[2], 10) : 0;
|
|
58
|
+
const ms = (hours * 60 * 60 * 1000) + (minutes * 60 * 1000);
|
|
59
|
+
if (ms <= 0) {
|
|
60
|
+
return { ok: false, error: `--persist-until: duration must be non-zero (got \`${raw}\`); ${GRAMMAR_HINT}` };
|
|
61
|
+
}
|
|
62
|
+
const deadlineMs = nowMs + ms;
|
|
63
|
+
return { ok: true, iso: new Date(deadlineMs).toISOString(), deadlineMs, source: 'duration' };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// A bare digit run is a duration-shaped mistake (someone typed `4`
|
|
67
|
+
// instead of `4h`). Date.parse would accept it as a year and the
|
|
68
|
+
// supervisor would happily set a timer for the far past; that is
|
|
69
|
+
// worse UX than a clean refusal.
|
|
70
|
+
if (/^\d+$/.test(raw)) {
|
|
71
|
+
return { ok: false, error: `--persist-until: bare integer \`${raw}\` has no unit; ${GRAMMAR_HINT}` };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ISO form. Date.parse is loose (accepts a variety of shapes); we
|
|
75
|
+
// only care that it lands on a finite timestamp. A partial ISO like
|
|
76
|
+
// `2026` still parses; that is a policy question we leave to the
|
|
77
|
+
// supervisor's timer semantics (a past deadline unwinds immediately,
|
|
78
|
+
// which is a clearer failure than silent no-op).
|
|
79
|
+
const parsedMs = Date.parse(raw);
|
|
80
|
+
if (Number.isFinite(parsedMs)) {
|
|
81
|
+
return { ok: true, iso: new Date(parsedMs).toISOString(), deadlineMs: parsedMs, source: 'iso' };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return { ok: false, error: `--persist-until: unrecognised value \`${raw}\`; ${GRAMMAR_HINT}` };
|
|
85
|
+
}
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
// View-server supervisor (spec §9.3).
|
|
2
|
+
//
|
|
3
|
+
// Detached mode: the parent CLI process forks a supervisor child via
|
|
4
|
+
// `child_process.spawn(..., { detached: true, stdio: 'ignore' })` then
|
|
5
|
+
// unref's; the parent returns immediately. The supervisor:
|
|
6
|
+
// 1. Writes its pid to .rcf/view-server.pid (gitignored).
|
|
7
|
+
// 2. Starts the view server on the requested port.
|
|
8
|
+
// 3. Writes the reviewSurface.viewServer manifest block.
|
|
9
|
+
// 4. Runs a heartbeat that bumps lastHeartbeatAt every 30 seconds
|
|
10
|
+
// (or the injected interval for tests).
|
|
11
|
+
// 5. Handles SIGTERM cleanly: server closed, manifest record cleared,
|
|
12
|
+
// pid file removed.
|
|
13
|
+
// 6. On the child server crashing, attempts one restart within 5s;
|
|
14
|
+
// a second crash within a minute exits the supervisor cleanly.
|
|
15
|
+
//
|
|
16
|
+
// Windows is out of scope per spec §9.3 / §16 O-6.
|
|
17
|
+
|
|
18
|
+
import { spawn } from 'node:child_process';
|
|
19
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
20
|
+
import { dirname, resolve } from 'node:path';
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
22
|
+
import { readFile } from 'node:fs/promises';
|
|
23
|
+
import process from 'node:process';
|
|
24
|
+
|
|
25
|
+
import { startServer } from '../server/index.js';
|
|
26
|
+
import {
|
|
27
|
+
writePidFile,
|
|
28
|
+
readPidFile,
|
|
29
|
+
removePidFile,
|
|
30
|
+
writeViewServerRecord,
|
|
31
|
+
readViewServerRecord,
|
|
32
|
+
clearViewServerRecord,
|
|
33
|
+
isPidAlive,
|
|
34
|
+
DEFAULT_PID_PATH,
|
|
35
|
+
} from './manifest-writer.js';
|
|
36
|
+
import { ensureLogDir, supervisorLogPath, writeLogLine } from './logs.js';
|
|
37
|
+
|
|
38
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
39
|
+
const CHILD_ENTRY_PATH = resolve(here, '..', '..', 'bin', 'view-supervisor-child.mjs');
|
|
40
|
+
|
|
41
|
+
const DEFAULT_HEARTBEAT_MS = 30_000;
|
|
42
|
+
const DEFAULT_STARTUP_TIMEOUT_MS = 15_000;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Fork a detached supervisor child.
|
|
46
|
+
*
|
|
47
|
+
* @param {object} args
|
|
48
|
+
* @param {string} args.projectRoot
|
|
49
|
+
* @param {number} args.port
|
|
50
|
+
* @param {string|null} [args.persistUntil] ISO timestamp; supervisor auto-stops
|
|
51
|
+
* @param {number} [args.heartbeatMs] for tests
|
|
52
|
+
* @param {number} [args.startupTimeoutMs] for tests
|
|
53
|
+
* @returns {Promise<{ pid: number, url: string, record: object }>}
|
|
54
|
+
*/
|
|
55
|
+
export async function startDetached({ projectRoot, port, persistUntil = null, heartbeatMs = DEFAULT_HEARTBEAT_MS, startupTimeoutMs = DEFAULT_STARTUP_TIMEOUT_MS }) {
|
|
56
|
+
await ensureLogDir(projectRoot);
|
|
57
|
+
|
|
58
|
+
// Refuse when a supervisor is already up.
|
|
59
|
+
const existing = await statusOfDetached(projectRoot);
|
|
60
|
+
if (existing.state === 'running') {
|
|
61
|
+
return { pid: existing.pid, url: existing.url, record: existing.record, alreadyRunning: true };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const child = spawn(process.execPath, [CHILD_ENTRY_PATH], {
|
|
65
|
+
detached: true,
|
|
66
|
+
stdio: 'ignore',
|
|
67
|
+
env: {
|
|
68
|
+
...process.env,
|
|
69
|
+
RCF_VIEW_SUPERVISOR_PROJECT_ROOT: projectRoot,
|
|
70
|
+
RCF_VIEW_SUPERVISOR_PORT: String(port),
|
|
71
|
+
RCF_VIEW_SUPERVISOR_PERSIST_UNTIL: persistUntil ?? '',
|
|
72
|
+
RCF_VIEW_SUPERVISOR_HEARTBEAT_MS: String(heartbeatMs),
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
if (typeof child.unref === 'function') child.unref();
|
|
76
|
+
const pid = child.pid;
|
|
77
|
+
if (typeof pid !== 'number') {
|
|
78
|
+
throw new Error('view-supervisor: spawn returned no pid');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Wait for the child to record itself in the manifest. Poll instead
|
|
82
|
+
// of piping the child's stdio (we intentionally detach).
|
|
83
|
+
const started = Date.now();
|
|
84
|
+
let record = null;
|
|
85
|
+
while (Date.now() - started < startupTimeoutMs) {
|
|
86
|
+
// eslint-disable-next-line no-await-in-loop
|
|
87
|
+
record = await readViewServerRecord(projectRoot);
|
|
88
|
+
if (record?.pid === pid) break;
|
|
89
|
+
// eslint-disable-next-line no-await-in-loop
|
|
90
|
+
await delay(100);
|
|
91
|
+
}
|
|
92
|
+
if (!record || record.pid !== pid) {
|
|
93
|
+
// Startup failed. Try to clean up the orphaned child.
|
|
94
|
+
try { process.kill(pid, 'SIGTERM'); } catch { /* ignore */ }
|
|
95
|
+
throw new Error(`view-supervisor: child ${pid} did not record itself in the manifest within ${startupTimeoutMs}ms`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Extract URL from healthCheckPath: 127.0.0.1:PORT/healthz -> the
|
|
99
|
+
// base URL is everything up to /healthz.
|
|
100
|
+
const url = record.healthCheckPath.replace(/\/healthz$/, '');
|
|
101
|
+
return { pid, url, record };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Stop a running detached supervisor. Idempotent.
|
|
106
|
+
*
|
|
107
|
+
* @param {string} projectRoot
|
|
108
|
+
* @returns {Promise<{ stopped: boolean, pid: number|null }>}
|
|
109
|
+
*/
|
|
110
|
+
export async function stopDetached(projectRoot) {
|
|
111
|
+
const pid = await readPidFile(projectRoot);
|
|
112
|
+
if (!pid) {
|
|
113
|
+
// Best-effort cleanup for the manifest record.
|
|
114
|
+
await clearViewServerRecord(projectRoot);
|
|
115
|
+
return { stopped: false, pid: null };
|
|
116
|
+
}
|
|
117
|
+
if (!isPidAlive(pid)) {
|
|
118
|
+
await clearViewServerRecord(projectRoot);
|
|
119
|
+
await removePidFile(projectRoot);
|
|
120
|
+
return { stopped: false, pid };
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
process.kill(pid, 'SIGTERM');
|
|
124
|
+
} catch (err) {
|
|
125
|
+
if (err.code !== 'ESRCH') throw err;
|
|
126
|
+
}
|
|
127
|
+
// Wait for the process to exit cleanly.
|
|
128
|
+
const deadline = Date.now() + 5000;
|
|
129
|
+
while (Date.now() < deadline) {
|
|
130
|
+
if (!isPidAlive(pid)) break;
|
|
131
|
+
// eslint-disable-next-line no-await-in-loop
|
|
132
|
+
await delay(100);
|
|
133
|
+
}
|
|
134
|
+
await clearViewServerRecord(projectRoot);
|
|
135
|
+
await removePidFile(projectRoot);
|
|
136
|
+
return { stopped: true, pid };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Report the current state of the detached supervisor.
|
|
141
|
+
*
|
|
142
|
+
* @param {string} projectRoot
|
|
143
|
+
* @param {object} [opts]
|
|
144
|
+
* @param {number} [opts.heartbeatMs]
|
|
145
|
+
* @returns {Promise<{ state: 'not-started'|'running'|'stale', pid: number|null, record: object|null, url: string|null, lastHeartbeatAt: string|null }>}
|
|
146
|
+
*/
|
|
147
|
+
export async function statusOfDetached(projectRoot, opts = {}) {
|
|
148
|
+
const heartbeatMs = opts.heartbeatMs ?? DEFAULT_HEARTBEAT_MS;
|
|
149
|
+
const record = await readViewServerRecord(projectRoot);
|
|
150
|
+
const pidFilePid = await readPidFile(projectRoot);
|
|
151
|
+
const pid = record?.pid ?? pidFilePid ?? null;
|
|
152
|
+
if (!record && !pidFilePid) {
|
|
153
|
+
return { state: 'not-started', pid: null, record: null, url: null, lastHeartbeatAt: null };
|
|
154
|
+
}
|
|
155
|
+
const alive = pid !== null && isPidAlive(pid);
|
|
156
|
+
const url = record?.healthCheckPath ? record.healthCheckPath.replace(/\/healthz$/, '') : null;
|
|
157
|
+
const lastHeartbeatAt = record?.lastHeartbeatAt ?? null;
|
|
158
|
+
const heartbeatFresh = lastHeartbeatAt
|
|
159
|
+
? (Date.now() - Date.parse(lastHeartbeatAt)) <= (heartbeatMs * 2)
|
|
160
|
+
: false;
|
|
161
|
+
if (alive && heartbeatFresh) {
|
|
162
|
+
return { state: 'running', pid, record, url, lastHeartbeatAt };
|
|
163
|
+
}
|
|
164
|
+
return { state: 'stale', pid, record, url, lastHeartbeatAt };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The supervisor loop. Runs inside the detached child; the caller is
|
|
169
|
+
* `bin/view-supervisor-child.mjs`. Reads config from env vars set by
|
|
170
|
+
* startDetached, brings up the view server, writes the manifest record,
|
|
171
|
+
* runs the heartbeat, and installs the shutdown handlers.
|
|
172
|
+
*
|
|
173
|
+
* @returns {Promise<void>}
|
|
174
|
+
*/
|
|
175
|
+
export async function runDetachedChild() {
|
|
176
|
+
const projectRoot = process.env.RCF_VIEW_SUPERVISOR_PROJECT_ROOT;
|
|
177
|
+
const port = Number.parseInt(process.env.RCF_VIEW_SUPERVISOR_PORT ?? '', 10);
|
|
178
|
+
const persistUntil = process.env.RCF_VIEW_SUPERVISOR_PERSIST_UNTIL || null;
|
|
179
|
+
const heartbeatMs = Number.parseInt(process.env.RCF_VIEW_SUPERVISOR_HEARTBEAT_MS ?? '', 10) || DEFAULT_HEARTBEAT_MS;
|
|
180
|
+
if (!projectRoot || !Number.isFinite(port)) {
|
|
181
|
+
await writeLogLine(projectRoot ?? '.', 'runDetachedChild: missing projectRoot or port');
|
|
182
|
+
process.exit(2);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
await writeLogLine(projectRoot, `supervisor starting on port ${port} (pid ${process.pid})`);
|
|
187
|
+
|
|
188
|
+
let server;
|
|
189
|
+
try {
|
|
190
|
+
server = await startServer({
|
|
191
|
+
projectRoot,
|
|
192
|
+
port,
|
|
193
|
+
log: (line) => { writeLogLine(projectRoot, `server: ${line}`).catch(() => {}); },
|
|
194
|
+
});
|
|
195
|
+
} catch (err) {
|
|
196
|
+
await writeLogLine(projectRoot, `supervisor: server start failed: ${err.message}`);
|
|
197
|
+
process.exit(1);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
await writePidFile(projectRoot, process.pid);
|
|
202
|
+
|
|
203
|
+
const socketPath = `.rcf/view-supervisor.${process.pid}.sock`;
|
|
204
|
+
const healthCheckPath = `${server.url.replace(/\/$/, '')}/healthz`;
|
|
205
|
+
|
|
206
|
+
let heartbeatTimer;
|
|
207
|
+
let persistTimer;
|
|
208
|
+
let stopping = false;
|
|
209
|
+
|
|
210
|
+
const writeRecord = async () => {
|
|
211
|
+
const record = {
|
|
212
|
+
mode: 'detached',
|
|
213
|
+
startedAt: new Date().toISOString(),
|
|
214
|
+
socketPath,
|
|
215
|
+
pid: process.pid,
|
|
216
|
+
healthCheckPath,
|
|
217
|
+
lastHeartbeatAt: new Date().toISOString(),
|
|
218
|
+
};
|
|
219
|
+
if (persistUntil) record.operatorRequestedPersistUntil = persistUntil;
|
|
220
|
+
const result = await writeViewServerRecord({ projectRoot, record });
|
|
221
|
+
if (result && result.kind && typeof result.message === 'string') {
|
|
222
|
+
await writeLogLine(projectRoot, `supervisor: manifest write failed: ${result.message}`);
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
const bumpHeartbeat = async () => {
|
|
227
|
+
const record = await readViewServerRecord(projectRoot);
|
|
228
|
+
if (!record) return;
|
|
229
|
+
const next = { ...record, lastHeartbeatAt: new Date().toISOString() };
|
|
230
|
+
await writeViewServerRecord({ projectRoot, record: next });
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
const shutdown = async (reason) => {
|
|
234
|
+
if (stopping) return;
|
|
235
|
+
stopping = true;
|
|
236
|
+
await writeLogLine(projectRoot, `supervisor: shutting down (${reason})`);
|
|
237
|
+
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
|
238
|
+
if (persistTimer) clearTimeout(persistTimer);
|
|
239
|
+
try {
|
|
240
|
+
await server.close();
|
|
241
|
+
} catch (err) {
|
|
242
|
+
await writeLogLine(projectRoot, `supervisor: server close error: ${err.message}`);
|
|
243
|
+
}
|
|
244
|
+
await clearViewServerRecord(projectRoot);
|
|
245
|
+
await removePidFile(projectRoot);
|
|
246
|
+
process.exit(0);
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
process.on('SIGTERM', () => { shutdown('SIGTERM'); });
|
|
250
|
+
process.on('SIGINT', () => { shutdown('SIGINT'); });
|
|
251
|
+
|
|
252
|
+
await writeRecord();
|
|
253
|
+
|
|
254
|
+
heartbeatTimer = setInterval(() => {
|
|
255
|
+
bumpHeartbeat().catch((err) => writeLogLine(projectRoot, `supervisor: heartbeat error: ${err.message}`).catch(() => {}));
|
|
256
|
+
}, heartbeatMs);
|
|
257
|
+
if (typeof heartbeatTimer.unref === 'function') heartbeatTimer.unref();
|
|
258
|
+
|
|
259
|
+
if (persistUntil) {
|
|
260
|
+
const deadline = Date.parse(persistUntil);
|
|
261
|
+
if (Number.isFinite(deadline)) {
|
|
262
|
+
const wait = Math.max(0, deadline - Date.now());
|
|
263
|
+
persistTimer = setTimeout(() => { shutdown('persistUntilExpired'); }, wait);
|
|
264
|
+
if (typeof persistTimer.unref === 'function') persistTimer.unref();
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
await writeLogLine(projectRoot, `supervisor: ready`);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Kept exports available so downstream tooling that pulls the supervisor
|
|
272
|
+
// path or config can do so without spelunking.
|
|
273
|
+
void CHILD_ENTRY_PATH;
|
|
274
|
+
void readFile;
|
|
275
|
+
void supervisorLogPath;
|
|
276
|
+
void DEFAULT_PID_PATH;
|