@reticlehq/server 3.1.0 → 3.3.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/SKILL.md +30 -28
- package/dist/command/cli/adhoc-suite.d.ts +19 -0
- package/dist/command/cli/adhoc-suite.js +88 -0
- package/dist/command/cli/adhoc-verdict.d.ts +23 -2
- package/dist/command/cli/adhoc-verdict.js +18 -10
- package/dist/command/cli/auth/device-flow.d.ts +2 -2
- package/dist/command/cli/cli-doctor.js +37 -5
- package/dist/command/cli/cli-flow-commands.d.ts +2 -1
- package/dist/command/cli/cli-kill.js +1 -1
- package/dist/command/cli/cli-parse-grammar.d.ts +7 -0
- package/dist/command/cli/cli-parse-grammar.js +7 -0
- package/dist/command/cli/cli-parse-verify.d.ts +2 -5
- package/dist/command/cli/cli-parse-verify.js +86 -13
- package/dist/command/cli/cli-parse.d.ts +8 -1
- package/dist/command/cli/cli-parse.js +38 -7
- package/dist/command/cli/cli-verify.d.ts +59 -0
- package/dist/command/cli/cli-verify.js +131 -19
- package/dist/command/cli/cloud-cli.js +7 -6
- package/dist/command/cli/cloud-kit.d.ts +0 -2
- package/dist/command/cli/cloud-kit.js +3 -7
- package/dist/command/cli/config/config-discovery.d.ts +12 -0
- package/dist/command/cli/config/config-discovery.js +19 -0
- package/dist/command/cli/doctor/doctor-rows.d.ts +12 -0
- package/dist/command/cli/doctor/doctor-rows.js +12 -0
- package/dist/command/cli/doctor/doctor-sessions-line.d.ts +2 -1
- package/dist/command/cli/drive/drive-command.js +1 -1
- package/dist/command/cli/launch/cli-launch.d.ts +3 -23
- package/dist/command/cli/launch/cli-launch.js +29 -70
- package/dist/command/cli/lifecycle/daemon-lifecycle.d.ts +33 -0
- package/dist/command/cli/lifecycle/daemon-lifecycle.js +198 -0
- package/dist/command/cli/mcp-command.js +5 -3
- package/dist/command/cli/ports/resolve/cli-port.d.ts +15 -1
- package/dist/command/cli/ports/resolve/cli-port.js +50 -2
- package/dist/command/cli/report-command.d.ts +16 -0
- package/dist/command/cli/report-command.js +97 -0
- package/dist/command/cli/status/status-lines.d.ts +28 -0
- package/dist/command/cli/status/status-lines.js +117 -0
- package/dist/command/cli.d.ts +1 -1
- package/dist/command/cli.js +29 -177
- package/dist/command/daemon/binding/daemon-status-probe.d.ts +32 -0
- package/dist/command/daemon/binding/daemon-status-probe.js +93 -0
- package/dist/command/daemon/binding/loopback-reach.d.ts +42 -0
- package/dist/command/daemon/binding/loopback-reach.js +63 -0
- package/dist/command/daemon/binding/port-presence.d.ts +1 -1
- package/dist/command/daemon/daemon-resilience.d.ts +13 -1
- package/dist/command/daemon/daemon-resilience.js +12 -1
- package/dist/command/daemon/daemon.d.ts +2 -11
- package/dist/command/daemon/daemon.js +8 -11
- package/dist/command/daemon/idle-shutdown.d.ts +2 -1
- package/dist/command/daemon/lifetime/heartbeat.d.ts +8 -0
- package/dist/command/daemon/lifetime/heartbeat.js +10 -0
- package/dist/command/daemon/lifetime/log-cap.d.ts +68 -0
- package/dist/command/daemon/lifetime/log-cap.js +115 -0
- package/dist/command/setup/agent-notice-memory.d.ts +16 -0
- package/dist/command/setup/agent-notice-memory.js +65 -0
- package/dist/command/setup/bringup/dev-server-wait.d.ts +1 -0
- package/dist/command/setup/bringup/dev-server-wait.js +16 -0
- package/dist/command/setup/bringup/ensure-daemon.js +1 -1
- package/dist/command/setup/init/init-host.js +8 -0
- package/dist/command/setup/init/init-runtime.js +13 -1
- package/dist/command/setup/node-effects.js +1 -1
- package/dist/command/setup/probe/page-probe.js +9 -2
- package/dist/command/setup/run-setup.d.ts +25 -7
- package/dist/command/setup/run-setup.js +51 -9
- package/dist/command/setup/setup-command.js +31 -3
- package/dist/command/setup/terminal/confirm.js +2 -1
- package/dist/command/update/updater.d.ts +27 -0
- package/dist/command/update/updater.js +35 -2
- package/dist/features/crawl/crawl-tools.js +3 -1
- package/dist/features/crawl/crawl.d.ts +2 -0
- package/dist/features/crawl/crawl.js +3 -1
- package/dist/features/harness/drive-report.d.ts +73 -0
- package/dist/features/harness/drive-report.js +196 -0
- package/dist/features/harness/driver.js +29 -8
- package/dist/features/harness/drivers.d.ts +19 -0
- package/dist/features/harness/drivers.js +19 -0
- package/dist/features/harness/fill-values.d.ts +67 -0
- package/dist/features/harness/fill-values.js +153 -0
- package/dist/features/harness/jev-driver.d.ts +188 -0
- package/dist/features/harness/jev-driver.js +661 -0
- package/dist/features/harness/openai-driver.d.ts +46 -0
- package/dist/features/harness/openai-driver.js +197 -0
- package/dist/features/harness/platform-config.d.ts +62 -0
- package/dist/features/harness/platform-config.js +97 -0
- package/dist/features/visual/visual-tools.js +26 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.js +80 -130
- package/dist/judgement/capsule/capsule-store.d.ts +31 -2
- package/dist/judgement/capsule/capsule-store.js +58 -6
- package/dist/judgement/capsule/capsule.js +5 -0
- package/dist/judgement/capsule/causal-summary.d.ts +13 -1
- package/dist/judgement/capsule/causal-summary.js +7 -0
- package/dist/judgement/domain/domain-model.js +38 -9
- package/dist/judgement/domain/domain-tools.js +4 -2
- package/dist/judgement/outcome/assertion-integrity.d.ts +3 -3
- package/dist/judgement/phenomena/phenomena.d.ts +2 -1
- package/dist/judgement/runs/artifact/gap-report.d.ts +17 -0
- package/dist/judgement/runs/artifact/gap-report.js +46 -0
- package/dist/judgement/runs/artifact/gap-summary.d.ts +58 -0
- package/dist/judgement/runs/artifact/gap-summary.js +85 -0
- package/dist/judgement/runs/artifact/render-report.js +4 -1
- package/dist/judgement/runs/artifact/to-artifact.d.ts +7 -2
- package/dist/judgement/runs/artifact/to-artifact.js +1 -1
- package/dist/judgement/runs/context-tools.js +21 -3
- package/dist/judgement/runs/drive-run.js +7 -2
- package/dist/judgement/runs/verification-sync.d.ts +2 -2
- package/dist/judgement/runs/verification-sync.js +9 -2
- package/dist/language/flows/affected-tools.js +6 -3
- package/dist/language/flows/annotate-notes/annotate.d.ts +0 -15
- package/dist/language/flows/annotate-notes/annotate.js +66 -18
- package/dist/language/flows/flow-anchor.d.ts +12 -0
- package/dist/language/flows/flow-anchor.js +20 -0
- package/dist/language/flows/flow-expect-grammar.d.ts +2 -2
- package/dist/language/flows/flow-expect-grammar.js +92 -11
- package/dist/language/flows/flow-intent.js +1 -1
- package/dist/language/flows/flow-learning.d.ts +3 -0
- package/dist/language/flows/flow-learning.js +39 -8
- package/dist/language/flows/flow-replay-run.d.ts +55 -10
- package/dist/language/flows/flow-replay-run.js +217 -58
- package/dist/language/flows/flow-replay-types.d.ts +9 -0
- package/dist/language/flows/flow-replay.d.ts +29 -11
- package/dist/language/flows/flow-replay.js +221 -203
- package/dist/language/flows/flow-result.d.ts +18 -1
- package/dist/language/flows/flow-result.js +18 -2
- package/dist/language/flows/flow-step-runners.d.ts +26 -10
- package/dist/language/flows/flow-step-runners.js +20 -4
- package/dist/language/flows/flow-store-for-session.d.ts +2 -1
- package/dist/language/flows/flow-success.d.ts +21 -6
- package/dist/language/flows/flow-success.js +50 -138
- package/dist/language/flows/flow-tools.js +8 -4
- package/dist/language/flows/flows.d.ts +15 -10
- package/dist/language/flows/flows.js +17 -4
- package/dist/language/flows/heal.d.ts +9 -1
- package/dist/language/flows/heal.js +24 -2
- package/dist/language/flows/learned-sources.d.ts +21 -0
- package/dist/language/flows/learned-sources.js +84 -0
- package/dist/language/flows/narrow-write.d.ts +4 -4
- package/dist/language/flows/recording/recording-backtrack.d.ts +0 -7
- package/dist/language/flows/recording/recording-backtrack.js +1 -1
- package/dist/language/flows/recording/replay-halt.d.ts +2 -1
- package/dist/language/flows/recording/tape/recordings.d.ts +2 -2
- package/dist/language/flows/replay.js +13 -9
- package/dist/language/flows/stores/annotation-store.d.ts +5 -5
- package/dist/language/flows/stores/assertion-tiers-store.js +2 -3
- package/dist/language/flows/stores/flake-store.js +2 -3
- package/dist/language/flows/suite/coverage.d.ts +1 -1
- package/dist/language/flows/suite/parallel-suite.d.ts +2 -1
- package/dist/language/flows/suite-selection.d.ts +15 -1
- package/dist/language/flows/suite-selection.js +1 -1
- package/dist/language/flows/verify-change-tools.js +21 -4
- package/dist/memory/cloud/account-state.d.ts +1 -1
- package/dist/memory/cloud/account-state.js +5 -4
- package/dist/memory/cloud/cloud-config.d.ts +9 -0
- package/dist/memory/cloud/cloud-config.js +9 -0
- package/dist/memory/cloud/cloud-sync.d.ts +7 -4
- package/dist/memory/cloud/cloud-sync.js +11 -6
- package/dist/memory/cloud/harness-config.d.ts +12 -0
- package/dist/memory/cloud/harness-config.js +58 -0
- package/dist/memory/cloud/harness-offer.d.ts +44 -0
- package/dist/memory/cloud/harness-offer.js +146 -0
- package/dist/memory/cloud/harness-switch.d.ts +11 -0
- package/dist/memory/cloud/harness-switch.js +53 -0
- package/dist/memory/cloud/sync-cycle.d.ts +12 -0
- package/dist/memory/cloud/sync-cycle.js +29 -4
- package/dist/memory/cloud/sync-daemon.d.ts +35 -2
- package/dist/memory/cloud/sync-daemon.js +45 -0
- package/dist/memory/cloud/sync-disk.js +11 -6
- package/dist/memory/impact/impact-recorder.d.ts +8 -0
- package/dist/memory/impact/impact-recorder.js +2 -1
- package/dist/memory/impact/impact-store.d.ts +5 -0
- package/dist/memory/impact/impact-store.js +25 -0
- package/dist/memory/intent/inline-intent.js +8 -3
- package/dist/memory/intent/intent-shard-store.d.ts +33 -47
- package/dist/memory/intent/intent-shard-store.js +253 -141
- package/dist/memory/intent/intent-shard.d.ts +22 -23
- package/dist/memory/intent/intent-shard.js +1 -1
- package/dist/memory/intent/intent-store.d.ts +13 -8
- package/dist/memory/intent/intent-store.js +46 -90
- package/dist/memory/intent/intent-tools.js +34 -23
- package/dist/memory/journal/ambient-store.js +2 -3
- package/dist/memory/journal/attach-journal.d.ts +11 -0
- package/dist/memory/journal/attach-journal.js +1 -1
- package/dist/memory/journal/deviation-report.js +4 -1
- package/dist/memory/journal/deviation-service.js +10 -2
- package/dist/memory/journal/drive-flow.d.ts +2 -2
- package/dist/memory/journal/drive-flow.js +21 -12
- package/dist/memory/journal/envelope-store.js +2 -3
- package/dist/memory/journal/journal-query.d.ts +35 -1
- package/dist/memory/journal/journal-query.js +46 -0
- package/dist/memory/journal/journal-recorder.d.ts +71 -2
- package/dist/memory/journal/journal-recorder.js +45 -7
- package/dist/memory/journal/on-disk/retain-policy.d.ts +34 -0
- package/dist/memory/journal/on-disk/retain-policy.js +44 -0
- package/dist/memory/journal/on-disk/retention.d.ts +91 -2
- package/dist/memory/journal/on-disk/retention.js +181 -11
- package/dist/memory/journal/on-disk/startup-maintenance.d.ts +42 -0
- package/dist/memory/journal/on-disk/startup-maintenance.js +14 -0
- package/dist/memory/journal/on-disk/workspace-gitignore.d.ts +0 -2
- package/dist/memory/journal/on-disk/workspace-gitignore.js +6 -38
- package/dist/memory/journal/on-disk/workspace-tiers.d.ts +52 -0
- package/dist/memory/journal/on-disk/workspace-tiers.js +88 -0
- package/dist/memory/journal/route-template.d.ts +22 -0
- package/dist/memory/journal/route-template.js +44 -0
- package/dist/memory/journal/self-observation.d.ts +3 -0
- package/dist/memory/journal/self-observation.js +67 -0
- package/dist/memory/journal/session-end.d.ts +17 -4
- package/dist/memory/journal/session-end.js +85 -7
- package/dist/memory/journal/session-journal.d.ts +122 -5
- package/dist/memory/journal/session-journal.js +489 -32
- package/dist/memory/project/artifact-root-resolver.d.ts +3 -0
- package/dist/memory/project/artifact-root-resolver.js +89 -0
- package/dist/memory/project/artifact-root.d.ts +39 -2
- package/dist/memory/project/artifact-root.js +58 -1
- package/dist/memory/project/baselines.d.ts +1 -1
- package/dist/memory/project/dir/fill-value-store.d.ts +32 -0
- package/dist/memory/project/dir/fill-value-store.js +88 -0
- package/dist/memory/project/dir/reticle-dir.d.ts +15 -5
- package/dist/memory/project/dir/reticle-dir.js +11 -0
- package/dist/memory/project/fs/fs-port.d.ts +22 -1
- package/dist/memory/project/fs/fs-port.js +72 -5
- package/dist/memory/project/fs/write-atomic.d.ts +26 -0
- package/dist/memory/project/fs/write-atomic.js +48 -0
- package/dist/memory/project/learned-routes.d.ts +16 -2
- package/dist/memory/project/learned-routes.js +26 -10
- package/dist/memory/project/project-for-root.d.ts +30 -0
- package/dist/memory/project/project-for-root.js +43 -0
- package/dist/memory/project/project-tools.js +10 -5
- package/dist/memory/project/session-root.d.ts +35 -6
- package/dist/memory/project/session-root.js +70 -8
- package/dist/memory/recall/prior/connection-memory.d.ts +6 -0
- package/dist/memory/recall/prior/connection-memory.js +8 -0
- package/dist/memory/recall/registered-projects.d.ts +2 -1
- package/dist/portal/bridge/auth-failure-reason.d.ts +9 -0
- package/dist/portal/bridge/auth-failure-reason.js +20 -3
- package/dist/portal/bridge/bridge.d.ts +6 -1
- package/dist/portal/bridge/bridge.js +36 -2
- package/dist/portal/input/real-input.js +2 -1
- package/dist/portal/pool/browser-pool.d.ts +10 -0
- package/dist/portal/pool/browser-pool.js +10 -0
- package/dist/portal/pool/playwright-launcher.js +1 -0
- package/dist/portal/pool/pool-contract.d.ts +2 -0
- package/dist/portal/pool/storage-seed.d.ts +2 -1
- package/dist/portal/pool/zero-install.d.ts +27 -0
- package/dist/portal/pool/zero-install.js +54 -0
- package/dist/portal/realm/web-realm.js +6 -0
- package/dist/portal/session/dev-server/dev-server-probe.d.ts +2 -1
- package/dist/portal/session/dev-server/served-document.d.ts +21 -0
- package/dist/portal/session/dev-server/served-document.js +57 -0
- package/dist/portal/session/facts/background-traffic.d.ts +9 -0
- package/dist/portal/session/facts/background-traffic.js +25 -0
- package/dist/portal/session/facts/session-replaced.d.ts +19 -0
- package/dist/portal/session/facts/session-replaced.js +22 -0
- package/dist/portal/session/live-control-tools.js +48 -3
- package/dist/portal/session/no-session-diagnosis.d.ts +1 -0
- package/dist/portal/session/no-session-diagnosis.js +67 -60
- package/dist/portal/session/no-session-next-action.js +3 -2
- package/dist/portal/session/no-session-watch.js +14 -10
- package/dist/portal/session/presence/attachment-history.d.ts +13 -2
- package/dist/portal/session/rebind.d.ts +18 -0
- package/dist/portal/session/rebind.js +38 -0
- package/dist/portal/session/session-health.d.ts +61 -0
- package/dist/portal/session/session-health.js +40 -1
- package/dist/portal/session/session-info.d.ts +6 -0
- package/dist/portal/session/session-manager.d.ts +13 -0
- package/dist/portal/session/session-manager.js +42 -3
- package/dist/portal/session/session-successor.d.ts +11 -0
- package/dist/portal/session/session-successor.js +14 -1
- package/dist/portal/session/session-verdict-facts.d.ts +28 -0
- package/dist/portal/session/session-verdict-facts.js +15 -0
- package/dist/portal/session/session.d.ts +26 -10
- package/dist/portal/session/session.js +48 -49
- package/dist/status-payload.d.ts +12 -1
- package/dist/status-payload.js +4 -1
- package/dist/surface/http-server.js +3 -5
- package/dist/surface/mcp/faults/mcp-outage.d.ts +2 -1
- package/dist/surface/mcp/faults/mcp-outage.js +20 -4
- package/dist/surface/mcp/mcp-proxy.d.ts +10 -1
- package/dist/surface/mcp/mcp-proxy.js +23 -12
- package/dist/surface/mcp/mcp.d.ts +10 -1
- package/dist/surface/mcp/mcp.js +59 -29
- package/dist/surface/mcp/proxy/proxy-daemon-probe.d.ts +15 -2
- package/dist/surface/mcp/proxy/proxy-daemon-probe.js +22 -5
- package/dist/surface/mcp/proxy/proxy-log.d.ts +5 -11
- package/dist/surface/mcp/proxy/proxy-log.js +7 -22
- package/dist/surface/mcp/proxy-idle-exit.d.ts +2 -1
- package/dist/surface/mcp/server-instructions.js +12 -1
- package/dist/surface/tools/act/act-preflight.js +6 -1
- package/dist/surface/tools/act/already-true.d.ts +40 -0
- package/dist/surface/tools/act/already-true.js +36 -0
- package/dist/surface/tools/act/navigation/navigate-arrival.d.ts +6 -0
- package/dist/surface/tools/act/navigation/navigate-arrival.js +13 -0
- package/dist/surface/tools/act/sequence-grade.js +13 -0
- package/dist/surface/tools/act/settle-in-flight.d.ts +6 -2
- package/dist/surface/tools/act/settle-in-flight.js +29 -6
- package/dist/surface/tools/act-capsule.js +49 -8
- package/dist/surface/tools/act-tools.js +25 -24
- package/dist/surface/tools/args/numeric-bounds.d.ts +15 -2
- package/dist/surface/tools/args/numeric-bounds.js +30 -3
- package/dist/surface/tools/assert/assert-grade.js +14 -2
- package/dist/surface/tools/assert/assert-verdict.js +74 -21
- package/dist/surface/tools/browser-tools.js +4 -3
- package/dist/surface/tools/coverage-tools.js +5 -2
- package/dist/surface/tools/dynamic-tools.js +43 -46
- package/dist/surface/tools/error-recovery.d.ts +6 -0
- package/dist/surface/tools/error-recovery.js +15 -0
- package/dist/surface/tools/explore-tools.js +80 -7
- package/dist/surface/tools/feedback-tools.d.ts +2 -1
- package/dist/surface/tools/feedback-tools.js +50 -3
- package/dist/surface/tools/first-sentence.d.ts +24 -0
- package/dist/surface/tools/first-sentence.js +0 -0
- package/dist/surface/tools/harness-explore.d.ts +125 -1
- package/dist/surface/tools/harness-explore.js +401 -15
- package/dist/surface/tools/harness-plan.d.ts +76 -0
- package/dist/surface/tools/harness-plan.js +156 -0
- package/dist/surface/tools/harness-toolset.js +12 -1
- package/dist/surface/tools/invoke-tool.js +82 -9
- package/dist/surface/tools/lease-hint.d.ts +8 -0
- package/dist/surface/tools/lease-hint.js +10 -0
- package/dist/surface/tools/lease-tools.d.ts +16 -28
- package/dist/surface/tools/lease-tools.js +53 -5
- package/dist/surface/tools/lineage-tools.js +1 -1
- package/dist/surface/tools/live-call-text.d.ts +6 -0
- package/dist/surface/tools/live-call-text.js +36 -2
- package/dist/surface/tools/observe-tools.js +69 -15
- package/dist/surface/tools/read/query-windowed.d.ts +1 -1
- package/dist/surface/tools/read/query-windowed.js +1 -1
- package/dist/surface/tools/read-tools.js +1 -2
- package/dist/surface/tools/real-input-attempt.d.ts +0 -5
- package/dist/surface/tools/real-input-attempt.js +1 -1
- package/dist/surface/tools/tool-kit.d.ts +23 -3
- package/dist/surface/tools/tool-kit.js +6 -1
- package/dist/surface/tools/tool-surface.js +17 -5
- package/dist/surface/tools/tools-helpers.d.ts +8 -7
- package/dist/surface/tools/tools-helpers.js +11 -13
- package/dist/surface/tools/tools.js +70 -30
- package/dist/telemetry/bug-found.js +32 -2
- package/dist/telemetry/feedback-context.js +3 -3
- package/dist/telemetry/feedback-local.d.ts +1 -0
- package/dist/telemetry/feedback-local.js +1 -3
- package/dist/telemetry/feedback.d.ts +1 -1
- package/dist/telemetry/harness-drive.d.ts +6 -0
- package/dist/telemetry/harness-drive.js +39 -0
- package/dist/telemetry/hud-metrics.d.ts +32 -0
- package/dist/telemetry/hud-metrics.js +95 -0
- package/dist/telemetry/mcp-connection.d.ts +14 -1
- package/dist/telemetry/mcp-connection.js +17 -2
- package/dist/telemetry/onboarding-firsts.d.ts +30 -1
- package/dist/telemetry/onboarding-firsts.js +54 -12
- package/dist/telemetry/session-metrics.d.ts +5 -0
- package/dist/telemetry/session-metrics.js +15 -1
- package/dist/telemetry/verification-of.js +31 -3
- package/dist/wire-journal.d.ts +31 -0
- package/dist/wire-journal.js +100 -0
- package/docs/README.md +1 -0
- package/docs/agent-cheatsheet.md +7 -3
- package/docs/autodrive.md +42 -2
- package/docs/benchmarks.md +1 -1
- package/docs/capabilities.mdx +1 -1
- package/docs/cli/cloud.mdx +11 -9
- package/docs/cli/doctor.mdx +1 -0
- package/docs/cli/init.mdx +1 -0
- package/docs/cli/report.mdx +44 -0
- package/docs/cli/status.mdx +35 -20
- package/docs/cli.mdx +8 -18
- package/docs/desktop.mdx +1 -1
- package/docs/faq.mdx +1 -1
- package/docs/flows.md +11 -0
- package/docs/frameworks.mdx +113 -1
- package/docs/install-agentic.mdx +36 -0
- package/docs/install-manual.mdx +1 -1
- package/docs/packages/tauri.mdx +1 -1
- package/docs/packages.mdx +1 -1
- package/docs/predicates.mdx +144 -4
- package/docs/system-map.md +6 -4
- package/docs/telemetry.md +2 -1
- package/docs/token-efficiency.md +2 -2
- package/docs/tools/overview.mdx +1 -1
- package/docs/tools/query.mdx +10 -10
- package/docs/troubleshooting.mdx +8 -2
- package/docs/usage.md +2 -2
- package/docs/what-is-recorded.md +62 -0
- package/package.json +5 -5
- package/dist/judgement/outcome/predicate-to-expect.d.ts +0 -44
- package/dist/judgement/outcome/predicate-to-expect.js +0 -148
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which `.reticle` a connected session's artifacts belong in — the IO half.
|
|
3
|
+
*
|
|
4
|
+
* `resolveArtifactRoot` and `unmatchedRoot` next door are pure on purpose; this is the file that
|
|
5
|
+
* goes to disk for them: the user-level project registry, config discovery, and the one question
|
|
6
|
+
* that decides whether the daemon may write into its own working directory at all.
|
|
7
|
+
*
|
|
8
|
+
* It lives here rather than in the daemon bootstrap because it is a subject, not wiring — and
|
|
9
|
+
* because the bootstrap is at its line cap, which is the cohesion signal that rule doing its job.
|
|
10
|
+
*/
|
|
11
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
12
|
+
import { dirname, join } from 'node:path';
|
|
13
|
+
import { homedir } from 'node:os';
|
|
14
|
+
import { PROJECT_REGISTRY_FILE, emptyProjectRegistry, parseProjectRegistry, } from '@reticlehq/core/artifacts';
|
|
15
|
+
import { ReticleDir } from '@reticlehq/core';
|
|
16
|
+
import { discoverProjectConfigs, hasProjectConfig, } from '../../command/cli/config/config-discovery.js';
|
|
17
|
+
import { log } from '../../log.js';
|
|
18
|
+
import { ArtifactRootReason, projectCandidatesFrom, resolveArtifactRoot, unmatchedRoot, } from './artifact-root.js';
|
|
19
|
+
/**
|
|
20
|
+
* Is the daemon's own directory a Reticle project, or is it a guest there?
|
|
21
|
+
*
|
|
22
|
+
* Answered ONCE per resolver rather than per session: a directory does not become a project between
|
|
23
|
+
* two tabs connecting, and this is the IO half `unmatchedRoot` deliberately does not do.
|
|
24
|
+
*/
|
|
25
|
+
function daemonSitsInAProject(daemonRoot) {
|
|
26
|
+
try {
|
|
27
|
+
// Either mark counts. `.reticle.json` means somebody ran `init` here; an existing `.reticle/`
|
|
28
|
+
// means Reticle has been invited into this tree before, including by a version that predates
|
|
29
|
+
// the config file. A directory with neither has never agreed to hold anybody's session data.
|
|
30
|
+
return existsSync(daemonRoot) || hasProjectConfig(dirname(daemonRoot));
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
// Unreadable is not a licence to write. Treat it as somebody else's directory.
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export function artifactRootResolver(daemonRoot) {
|
|
38
|
+
const daemonIsProject = daemonSitsInAProject(daemonRoot);
|
|
39
|
+
return (projectId, origin) => {
|
|
40
|
+
let registry = emptyProjectRegistry();
|
|
41
|
+
try {
|
|
42
|
+
const path = join(homedir(), ReticleDir.ROOT, PROJECT_REGISTRY_FILE);
|
|
43
|
+
registry = existsSync(path)
|
|
44
|
+
? parseProjectRegistry(JSON.parse(readFileSync(path, 'utf8')))
|
|
45
|
+
: registry;
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
// A cache that cannot be read is an empty cache, never an error: the daemon still resolves
|
|
49
|
+
// through discovery, and falls back to its own root exactly as it did before this existed.
|
|
50
|
+
}
|
|
51
|
+
let discovery = { found: [], searched: [] };
|
|
52
|
+
try {
|
|
53
|
+
discovery = discoverProjectConfigs(process.cwd());
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
// Same reasoning: a diagnostic search that throws must not take a tool call with it.
|
|
57
|
+
}
|
|
58
|
+
const resolved = resolveArtifactRoot({
|
|
59
|
+
projectId,
|
|
60
|
+
candidates: projectCandidatesFrom(discovery, registry),
|
|
61
|
+
daemonRoot,
|
|
62
|
+
});
|
|
63
|
+
if (resolved.reason === ArtifactRootReason.MATCHED_PROJECT)
|
|
64
|
+
return resolved;
|
|
65
|
+
// Could not name the project. The old code wrote into the daemon's directory anyway and said
|
|
66
|
+
// nothing, which put `.reticle/` — journals included — into repositories nobody had
|
|
67
|
+
// instrumented. Evidence still has to live somewhere, so it goes to the user's own
|
|
68
|
+
// `~/.reticle/unmatched/<projectId>` instead, and the daemon says so rather than leaving
|
|
69
|
+
// somebody to find the files.
|
|
70
|
+
const root = unmatchedRoot({
|
|
71
|
+
daemonRoot,
|
|
72
|
+
daemonIsProject,
|
|
73
|
+
home: homedir(),
|
|
74
|
+
...(projectId === undefined ? {} : { projectId }),
|
|
75
|
+
// Only reached when no project id survives the guard. Without it every app that never
|
|
76
|
+
// stamped one shares a single directory -- and that directory holds the durable half, so one
|
|
77
|
+
// app's learned expectations become another's.
|
|
78
|
+
...(origin === undefined ? {} : { origin }),
|
|
79
|
+
});
|
|
80
|
+
if (root !== daemonRoot) {
|
|
81
|
+
log('artifact_root_unmatched', {
|
|
82
|
+
projectId: projectId ?? null,
|
|
83
|
+
reason: resolved.reason,
|
|
84
|
+
root,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
return { ...resolved, root };
|
|
88
|
+
};
|
|
89
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type ProjectCandidate, type ProjectRegistry } from '@reticlehq/core';
|
|
1
|
+
import { type ProjectCandidate, type ProjectId, type ProjectRegistry } from '@reticlehq/core';
|
|
2
2
|
import type { ConfigDiscovery } from '../../command/cli/config/config-discovery.js';
|
|
3
3
|
/**
|
|
4
4
|
* Which `.reticle/` a session's artifacts belong in.
|
|
@@ -38,7 +38,7 @@ export declare const ArtifactRootReason: {
|
|
|
38
38
|
export type ArtifactRootReason = (typeof ArtifactRootReason)[keyof typeof ArtifactRootReason];
|
|
39
39
|
interface ArtifactRootQuery {
|
|
40
40
|
/** The connected session's HELLO projectId, when it sent one. */
|
|
41
|
-
projectId:
|
|
41
|
+
projectId: ProjectId | undefined;
|
|
42
42
|
/** Every project this machine knows about. Supplied, not gathered here — this stays pure. */
|
|
43
43
|
candidates: readonly ProjectCandidate[];
|
|
44
44
|
/** Where artifacts go when the project cannot be identified. Already a `.reticle` path. */
|
|
@@ -69,4 +69,41 @@ export declare function resolveArtifactRoot(query: ArtifactRootQuery): ArtifactR
|
|
|
69
69
|
* the one confirmed a moment ago is the better survivor.
|
|
70
70
|
*/
|
|
71
71
|
export declare function projectCandidatesFrom(discovery: ConfigDiscovery, registry: ProjectRegistry): ProjectCandidate[];
|
|
72
|
+
/** Where evidence goes when no project could be named and the daemon is a guest in this tree. */
|
|
73
|
+
export declare const UNMATCHED_SUBDIR = "unmatched";
|
|
74
|
+
/**
|
|
75
|
+
* The fallback root for a session whose project could not be resolved.
|
|
76
|
+
*
|
|
77
|
+
* Falling back to the daemon's own root was unconditional and silent, and that is how Reticle came
|
|
78
|
+
* to create `.reticle/` — session journals included, carrying URLs, request and response bodies and
|
|
79
|
+
* page text — in a user's BACKEND directory. Their editor starts the daemon there; the directory
|
|
80
|
+
* was never instrumented and never agreed to hold anybody's session data. They deleted it, and the
|
|
81
|
+
* next session wrote it again.
|
|
82
|
+
*
|
|
83
|
+
* Two cases, and only one of them was ever the intended behaviour:
|
|
84
|
+
*
|
|
85
|
+
* - the daemon is sitting IN a Reticle project (a developer who ran `reticle serve` in their own
|
|
86
|
+
* app). Its root is the right answer, and it is the case the old fallback was written for.
|
|
87
|
+
* - the daemon is a guest — no `.reticle.json`, no `.reticle/` already there. Then the evidence
|
|
88
|
+
* goes to the user's own `~/.reticle/unmatched/<projectId>`, which is Reticle's to write.
|
|
89
|
+
*
|
|
90
|
+
* It is never dropped. A verdict with nowhere to live is a worse failure than one in an unexpected
|
|
91
|
+
* place, and the caller says out loud where it went.
|
|
92
|
+
*
|
|
93
|
+
* `projectId` arrives in HELLO from the page, so it is untrusted input on a path join and is held
|
|
94
|
+
* to one safe segment — the same guard session ids and flow names already pass.
|
|
95
|
+
*/
|
|
96
|
+
export declare function unmatchedRoot(query: {
|
|
97
|
+
daemonRoot: string;
|
|
98
|
+
/** Whether the daemon's own directory is a Reticle project — an IO question, answered by the caller. */
|
|
99
|
+
daemonIsProject: boolean;
|
|
100
|
+
/** The user's home directory. Passed in rather than read, so this stays pure. */
|
|
101
|
+
home: string;
|
|
102
|
+
projectId?: ProjectId | undefined;
|
|
103
|
+
/**
|
|
104
|
+
* Where the session was served from, when it is known. Used ONLY when no project id survives the
|
|
105
|
+
* segment guard — a real id always wins, because it is an identity and this is a stand-in.
|
|
106
|
+
*/
|
|
107
|
+
origin?: string | undefined;
|
|
108
|
+
}): string;
|
|
72
109
|
export {};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { join } from 'node:path';
|
|
2
|
-
import { ReticleDir, projectCandidates, } from '@reticlehq/core';
|
|
2
|
+
import { SAFE_SEGMENT_PATTERN, ReticleDir, fnv1a, projectCandidates, } from '@reticlehq/core';
|
|
3
3
|
/**
|
|
4
4
|
* Which `.reticle/` a session's artifacts belong in.
|
|
5
5
|
*
|
|
@@ -98,3 +98,60 @@ export function projectCandidatesFrom(discovery, registry) {
|
|
|
98
98
|
: [{ projectId: config.projectId, directory: config.directory }]);
|
|
99
99
|
return [...discovered, ...projectCandidates(registry)];
|
|
100
100
|
}
|
|
101
|
+
/** Where evidence goes when no project could be named and the daemon is a guest in this tree. */
|
|
102
|
+
export const UNMATCHED_SUBDIR = 'unmatched';
|
|
103
|
+
/** What a project with NO identity at all is called on disk. Never blank, so the path is always real. */
|
|
104
|
+
const UNNAMED_PROJECT = 'unnamed';
|
|
105
|
+
/**
|
|
106
|
+
* The bucket for a project that could not name itself but was served from somewhere.
|
|
107
|
+
*
|
|
108
|
+
* `unnamed` used to take every one of these, which made it not a project's directory but the union
|
|
109
|
+
* of every project that ever failed to identify itself — sharing one `project.json`, one
|
|
110
|
+
* `envelopes.json`, one `flake.json` and one `assertion-tiers.json`. Those are the durable half:
|
|
111
|
+
* learned routes, per-route expectations, a quarantine ledger and an anti-downgrade floor. One
|
|
112
|
+
* app's floor silently becoming another app's floor is a wrong answer, not untidy disk.
|
|
113
|
+
*
|
|
114
|
+
* And it is the ORDINARY path, not an edge: a page that never stamped an id is an app instrumented
|
|
115
|
+
* without a build plugin, a page loaded before the plugin ran, or any tree where the daemon is a
|
|
116
|
+
* guest.
|
|
117
|
+
*
|
|
118
|
+
* The origin is the next-best identity available at that moment. It does not pretend to be a
|
|
119
|
+
* project id — two apps served on one port at different times still share a bucket — but that is a
|
|
120
|
+
* far smaller wrong than every unidentified app in the world sharing one.
|
|
121
|
+
*/
|
|
122
|
+
const ORIGIN_BUCKET_PREFIX = 'origin-';
|
|
123
|
+
/**
|
|
124
|
+
* The fallback root for a session whose project could not be resolved.
|
|
125
|
+
*
|
|
126
|
+
* Falling back to the daemon's own root was unconditional and silent, and that is how Reticle came
|
|
127
|
+
* to create `.reticle/` — session journals included, carrying URLs, request and response bodies and
|
|
128
|
+
* page text — in a user's BACKEND directory. Their editor starts the daemon there; the directory
|
|
129
|
+
* was never instrumented and never agreed to hold anybody's session data. They deleted it, and the
|
|
130
|
+
* next session wrote it again.
|
|
131
|
+
*
|
|
132
|
+
* Two cases, and only one of them was ever the intended behaviour:
|
|
133
|
+
*
|
|
134
|
+
* - the daemon is sitting IN a Reticle project (a developer who ran `reticle serve` in their own
|
|
135
|
+
* app). Its root is the right answer, and it is the case the old fallback was written for.
|
|
136
|
+
* - the daemon is a guest — no `.reticle.json`, no `.reticle/` already there. Then the evidence
|
|
137
|
+
* goes to the user's own `~/.reticle/unmatched/<projectId>`, which is Reticle's to write.
|
|
138
|
+
*
|
|
139
|
+
* It is never dropped. A verdict with nowhere to live is a worse failure than one in an unexpected
|
|
140
|
+
* place, and the caller says out loud where it went.
|
|
141
|
+
*
|
|
142
|
+
* `projectId` arrives in HELLO from the page, so it is untrusted input on a path join and is held
|
|
143
|
+
* to one safe segment — the same guard session ids and flow names already pass.
|
|
144
|
+
*/
|
|
145
|
+
export function unmatchedRoot(query) {
|
|
146
|
+
if (query.daemonIsProject)
|
|
147
|
+
return query.daemonRoot;
|
|
148
|
+
const id = query.projectId ?? '';
|
|
149
|
+
const named = SAFE_SEGMENT_PATTERN.test(id) && !id.includes('..');
|
|
150
|
+
return join(query.home, ReticleDir.ROOT, UNMATCHED_SUBDIR, named ? id : unnamedSegment(query.origin));
|
|
151
|
+
}
|
|
152
|
+
/** Hashed, not spelled: an origin carries a host and a port, and neither belongs in a path segment. */
|
|
153
|
+
function unnamedSegment(origin) {
|
|
154
|
+
if (origin === undefined || 0 === origin.trim().length)
|
|
155
|
+
return UNNAMED_PROJECT;
|
|
156
|
+
return `${ORIGIN_BUCKET_PREFIX}${fnv1a(origin.trim())}`;
|
|
157
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The values a drive types into this project's fields, kept so they are paid for once.
|
|
3
|
+
*
|
|
4
|
+
* A generated field value is not a model's opinion to be re-asked every run. It is a FIXTURE: the
|
|
5
|
+
* same label on the same app wants the same value tomorrow, a replay must send exactly what the
|
|
6
|
+
* recording sent or it is not a replay, and a human who dislikes "Acme Corp" should be able to open
|
|
7
|
+
* a file and write something else rather than argue with a model about it. So it lives beside the
|
|
8
|
+
* flows it belongs to, in git, in the project's own directory.
|
|
9
|
+
*
|
|
10
|
+
* Read once at the start of a drive and written once at the end: a drive that fills twenty fields
|
|
11
|
+
* must not write the file twenty times, and nothing else in the process is competing for it.
|
|
12
|
+
*
|
|
13
|
+
* Every failure is silent and answers "no values". A drive must never fail over a fixtures file,
|
|
14
|
+
* and the fallback -- the label heuristic -- is what every drive used before this existed.
|
|
15
|
+
*/
|
|
16
|
+
import type { FileSystemPort } from '../fs/fs-port.js';
|
|
17
|
+
/** What a drive holds while it runs: a read-through map plus whatever it learned. */
|
|
18
|
+
export interface FillValueStore {
|
|
19
|
+
get(label: string): string | undefined;
|
|
20
|
+
set(label: string, value: string): void;
|
|
21
|
+
/** Persist, if anything was learned. Silent on failure, like every read here. */
|
|
22
|
+
flush(): Promise<void>;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* A store that remembers within one drive and writes nothing.
|
|
26
|
+
*
|
|
27
|
+
* Used when there is no project directory to write to. A drive must never fail over its fixtures
|
|
28
|
+
* file, and that includes failing to FIND one: the values still de-duplicate inside the run, they
|
|
29
|
+
* are simply not carried into the next one.
|
|
30
|
+
*/
|
|
31
|
+
export declare function memoryFillValues(): FillValueStore;
|
|
32
|
+
export declare function openFillValues(fs: FileSystemPort, root: string | undefined): Promise<FillValueStore>;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The values a drive types into this project's fields, kept so they are paid for once.
|
|
3
|
+
*
|
|
4
|
+
* A generated field value is not a model's opinion to be re-asked every run. It is a FIXTURE: the
|
|
5
|
+
* same label on the same app wants the same value tomorrow, a replay must send exactly what the
|
|
6
|
+
* recording sent or it is not a replay, and a human who dislikes "Acme Corp" should be able to open
|
|
7
|
+
* a file and write something else rather than argue with a model about it. So it lives beside the
|
|
8
|
+
* flows it belongs to, in git, in the project's own directory.
|
|
9
|
+
*
|
|
10
|
+
* Read once at the start of a drive and written once at the end: a drive that fills twenty fields
|
|
11
|
+
* must not write the file twenty times, and nothing else in the process is competing for it.
|
|
12
|
+
*
|
|
13
|
+
* Every failure is silent and answers "no values". A drive must never fail over a fixtures file,
|
|
14
|
+
* and the fallback -- the label heuristic -- is what every drive used before this existed.
|
|
15
|
+
*/
|
|
16
|
+
import { ReticleDir } from '@reticlehq/core';
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
/** Stamped so a later format change can tell an old file from a broken one. */
|
|
19
|
+
const VERSION = 1;
|
|
20
|
+
/** A label is a UI string and can be anything; a value that is not a string is not usable. */
|
|
21
|
+
function parse(text) {
|
|
22
|
+
try {
|
|
23
|
+
const raw = JSON.parse(text);
|
|
24
|
+
if ('object' !== typeof raw || null === raw)
|
|
25
|
+
return {};
|
|
26
|
+
const values = raw.values;
|
|
27
|
+
if ('object' !== typeof values || null === values)
|
|
28
|
+
return {};
|
|
29
|
+
const out = {};
|
|
30
|
+
for (const [label, value] of Object.entries(values))
|
|
31
|
+
if ('string' === typeof value && 0 < value.length)
|
|
32
|
+
out[label] = value;
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return {};
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* A store that remembers within one drive and writes nothing.
|
|
41
|
+
*
|
|
42
|
+
* Used when there is no project directory to write to. A drive must never fail over its fixtures
|
|
43
|
+
* file, and that includes failing to FIND one: the values still de-duplicate inside the run, they
|
|
44
|
+
* are simply not carried into the next one.
|
|
45
|
+
*/
|
|
46
|
+
export function memoryFillValues() {
|
|
47
|
+
const values = new Map();
|
|
48
|
+
return {
|
|
49
|
+
get: (label) => values.get(label),
|
|
50
|
+
set: (label, value) => void values.set(label, value),
|
|
51
|
+
flush: () => Promise.resolve(),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
export async function openFillValues(fs, root) {
|
|
55
|
+
if (root === undefined || 0 === root.length)
|
|
56
|
+
return memoryFillValues();
|
|
57
|
+
const path = join(root, ReticleDir.FILL_VALUES_FILE);
|
|
58
|
+
let values = {};
|
|
59
|
+
try {
|
|
60
|
+
if (await fs.exists(path))
|
|
61
|
+
values = parse(await fs.readFile(path));
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
/* an unreadable fixtures file is the same as an absent one */
|
|
65
|
+
}
|
|
66
|
+
let learned = false;
|
|
67
|
+
return {
|
|
68
|
+
get: (label) => values[label],
|
|
69
|
+
set: (label, value) => {
|
|
70
|
+
if (values[label] === value)
|
|
71
|
+
return;
|
|
72
|
+
values[label] = value;
|
|
73
|
+
learned = true;
|
|
74
|
+
},
|
|
75
|
+
async flush() {
|
|
76
|
+
if (!learned)
|
|
77
|
+
return;
|
|
78
|
+
try {
|
|
79
|
+
await fs.mkdir(root);
|
|
80
|
+
const file = { version: VERSION, values };
|
|
81
|
+
await fs.writeFile(path, `${JSON.stringify(file, null, 2)}\n`);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
/* the drive already happened; losing the cache costs a regeneration, not a result */
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type FlowName, type SessionId } from '@reticlehq/core';
|
|
1
|
+
import { type ProjectId, type FlowName, type SessionId } from '@reticlehq/core';
|
|
2
2
|
import { ContractReadError, type CapabilitiesContract, type RunId } from '@reticlehq/core';
|
|
3
3
|
import type { FileSystemPort } from '../fs/fs-port.js';
|
|
4
4
|
/** Resolved absolute paths inside a `.reticle/` root. Pure: join only, no IO, no cwd. */
|
|
@@ -15,8 +15,10 @@ export interface ReticleDirPaths {
|
|
|
15
15
|
project: string;
|
|
16
16
|
/**.../.reticle/impact.json (what Reticle has done for this user, local only) */
|
|
17
17
|
impact: string;
|
|
18
|
-
/**.../.reticle/intent.json
|
|
18
|
+
/**.../.reticle/intent.json — the LEGACY single ledger, read until the first write migrates it into .reticle/intent/ */
|
|
19
19
|
intent: string;
|
|
20
|
+
/**.../.reticle/fill-values.json (what a drive types into each labelled field — git-checked) */
|
|
21
|
+
fillValues: string;
|
|
20
22
|
/**.../.reticle/visual (PNG baselines + diffs) */
|
|
21
23
|
visual: string;
|
|
22
24
|
/**.../.reticle/runs (verification-run artifacts) */
|
|
@@ -43,6 +45,14 @@ export declare function sessionDirPath(root: string, sessionId: SessionId): stri
|
|
|
43
45
|
export declare function journalEventsPath(root: string, sessionId: SessionId): string;
|
|
44
46
|
/** The append-only action ledger path for a session (.reticle/sessions/<id>/actions.jsonl). */
|
|
45
47
|
export declare function journalActionsPath(root: string, sessionId: SessionId): string;
|
|
48
|
+
/**
|
|
49
|
+
* The event ledger's closure report for a session (.reticle/sessions/<id>/events.closed.json).
|
|
50
|
+
*
|
|
51
|
+
* A sidecar rather than a header inside `events.jsonl`, because the ledger is append-only and
|
|
52
|
+
* readers track a byte offset into it: a header would have to be rewritten in place, which shrinks
|
|
53
|
+
* or shifts the file and silently invalidates every cursor pointing at it.
|
|
54
|
+
*/
|
|
55
|
+
export declare function journalClosedPath(root: string, sessionId: SessionId): string;
|
|
46
56
|
/**
|
|
47
57
|
* A sessionId must be a single safe path segment before it is joined into a disk path (rejects
|
|
48
58
|
* '../', slashes, absolute, dotfiles). Session labels are user/tab-supplied, so this guard runs
|
|
@@ -82,9 +92,9 @@ export declare function visualDiffPath(root: string, name: string, runtime?: str
|
|
|
82
92
|
* apps' flows of the same name; without one it's the flat legacy path (`.reticle/flows/<name>.json`),
|
|
83
93
|
* which is also where pre-existing (untagged) flows stay. Callers pass ONLY a validated projectId.
|
|
84
94
|
*/
|
|
85
|
-
export declare function flowPath(root: string, name: FlowName, projectId?:
|
|
95
|
+
export declare function flowPath(root: string, name: FlowName, projectId?: ProjectId): string;
|
|
86
96
|
/** The directory a flow write targets: the per-project subdir, or the flat flows root for global. */
|
|
87
|
-
export declare function flowDir(root: string, projectId?:
|
|
97
|
+
export declare function flowDir(root: string, projectId?: ProjectId): string;
|
|
88
98
|
/**
|
|
89
99
|
* The directory that must exist before a flow of this name can be written.
|
|
90
100
|
*
|
|
@@ -96,7 +106,7 @@ export declare function flowDir(root: string, projectId?: string): string;
|
|
|
96
106
|
* Derived from the resolved path rather than by splitting the name, so it cannot disagree with
|
|
97
107
|
* `flowPath` about where the file actually goes.
|
|
98
108
|
*/
|
|
99
|
-
export declare function flowParentDir(root: string, name: FlowName, projectId?:
|
|
109
|
+
export declare function flowParentDir(root: string, name: FlowName, projectId?: ProjectId): string;
|
|
100
110
|
/**
|
|
101
111
|
* A flow name may be NAMESPACED — `onboarding/signup` — and still never escapes `.reticle/`.
|
|
102
112
|
*
|
|
@@ -10,6 +10,7 @@ export function reticleDirPaths(root) {
|
|
|
10
10
|
project: join(root, ReticleDir.PROJECT_FILE),
|
|
11
11
|
impact: join(root, ReticleDir.IMPACT_FILE),
|
|
12
12
|
intent: join(root, ReticleDir.INTENT_FILE),
|
|
13
|
+
fillValues: join(root, ReticleDir.FILL_VALUES_FILE),
|
|
13
14
|
visual: join(root, ReticleDir.VISUAL_SUBDIR),
|
|
14
15
|
runs: join(root, ReticleDir.RUNS_SUBDIR),
|
|
15
16
|
sessions: join(root, ReticleDir.SESSIONS_SUBDIR),
|
|
@@ -36,6 +37,16 @@ export function journalEventsPath(root, sessionId) {
|
|
|
36
37
|
export function journalActionsPath(root, sessionId) {
|
|
37
38
|
return join(sessionDirPath(root, sessionId), ReticleDir.JOURNAL_ACTIONS_FILE);
|
|
38
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* The event ledger's closure report for a session (.reticle/sessions/<id>/events.closed.json).
|
|
42
|
+
*
|
|
43
|
+
* A sidecar rather than a header inside `events.jsonl`, because the ledger is append-only and
|
|
44
|
+
* readers track a byte offset into it: a header would have to be rewritten in place, which shrinks
|
|
45
|
+
* or shifts the file and silently invalidates every cursor pointing at it.
|
|
46
|
+
*/
|
|
47
|
+
export function journalClosedPath(root, sessionId) {
|
|
48
|
+
return join(sessionDirPath(root, sessionId), ReticleDir.JOURNAL_EVENTS_CLOSED_FILE);
|
|
49
|
+
}
|
|
39
50
|
/**
|
|
40
51
|
* A sessionId must be a single safe path segment before it is joined into a disk path (rejects
|
|
41
52
|
* '../', slashes, absolute, dotfiles). Session labels are user/tab-supplied, so this guard runs
|
|
@@ -12,10 +12,31 @@ export interface FileSystemPort {
|
|
|
12
12
|
* that lands on a `\n` boundary (the journal's line terminator, 1 ASCII byte) so the returned tail
|
|
13
13
|
* starts on a valid UTF-8 char boundary. Optional — a FileSystemPort that omits it makes readers fall
|
|
14
14
|
* back to a whole-file read.
|
|
15
|
+
*
|
|
16
|
+
* `maxBytes` caps what ONE call materialises as a single string. It is not a performance knob: V8
|
|
17
|
+
* cannot build a string longer than 0x1fffffe8 characters, and `Buffer.prototype.toString` THROWS
|
|
18
|
+
* past that rather than returning a shorter one — so a tail bigger than the ceiling is not a slow
|
|
19
|
+
* read, it is a failed one. When the cap bites, the NEWEST bytes are kept (a journal tail is read
|
|
20
|
+
* to answer about what just happened).
|
|
21
|
+
*
|
|
22
|
+
* Two guarantees make the returned text safe to account for in bytes, and an implementation owes
|
|
23
|
+
* both:
|
|
24
|
+
*
|
|
25
|
+
* - `from` is where the text ACTUALLY starts. It equals `byteOffset` when nothing was skipped,
|
|
26
|
+
* and a caller can compare the two to see that a cap bit. (Omitted by a port that does not
|
|
27
|
+
* implement the cap — read that as `byteOffset`, since such a port never skips.)
|
|
28
|
+
* - When the cap moves the start, `from` lands just past a `\n`, so the text begins at a record
|
|
29
|
+
* boundary and never with the replacement char of a character the window cut in half. A
|
|
30
|
+
* window holding no `\n` at all returns empty text with `from` at the end of what was read.
|
|
31
|
+
*
|
|
32
|
+
* `size` is the file's length as `fstat` reported it, which is NOT where the returned text ends:
|
|
33
|
+
* a truncation between the stat and the read, or any short read, returns less. Advance a cursor
|
|
34
|
+
* by what arrived, never by `size`.
|
|
15
35
|
*/
|
|
16
|
-
readFileFrom?(path: string, byteOffset: number): Promise<{
|
|
36
|
+
readFileFrom?(path: string, byteOffset: number, maxBytes?: number): Promise<{
|
|
17
37
|
text: string;
|
|
18
38
|
size: number;
|
|
39
|
+
from?: number;
|
|
19
40
|
}>;
|
|
20
41
|
writeFile(path: string, data: string): Promise<void>;
|
|
21
42
|
/** Append UTF-8 text, creating the file if absent — for the append-only JSONL journal. */
|
|
@@ -1,18 +1,85 @@
|
|
|
1
1
|
import { access, appendFile, mkdir, open, readdir, readFile, realpath, rename, rm, stat, writeFile, } from 'node:fs/promises';
|
|
2
|
+
/** The JSONL record terminator, as a byte — the only boundary a capped tail read can align to. */
|
|
3
|
+
const NEWLINE_BYTE = 0x0a;
|
|
4
|
+
/**
|
|
5
|
+
* The largest single `read(2)` this port will ask for: 2^31 - 1, the int32 ceiling.
|
|
6
|
+
*
|
|
7
|
+
* Past it `fs.read` does not throw — it ABORTS the process on a V8 assertion
|
|
8
|
+
* (`node::fs::Read ... Assertion failed: args[3]->IsInt32()`, where args[3] is the length). That is
|
|
9
|
+
* not catchable, so one oversized journal takes the daemon down and with it every session on the
|
|
10
|
+
* machine, including unrelated ones. Reported from the field exactly that way.
|
|
11
|
+
*
|
|
12
|
+
* Reachable in practice because `Buffer.allocUnsafe` happily allocates well past this, so a
|
|
13
|
+
* `size - start` over 2 GiB allocates fine and then kills node at the syscall. A `.reticle/sessions`
|
|
14
|
+
* has been observed in the multi-gigabyte range, so "no journal is ever that big" is not a bound
|
|
15
|
+
* anybody is enforcing.
|
|
16
|
+
*
|
|
17
|
+
* Clamping rather than throwing is deliberate: a caller reading a tail wants the newest records, and
|
|
18
|
+
* returning the last 2 GiB of them is a better answer than an error. The boundary realignment below
|
|
19
|
+
* already handles a window that opens mid-record, which is exactly what a clamp produces.
|
|
20
|
+
*/
|
|
21
|
+
const MAX_SINGLE_READ_BYTES = 2_147_483_647;
|
|
22
|
+
/**
|
|
23
|
+
* Refuse an offset that cannot survive the arithmetic below.
|
|
24
|
+
*
|
|
25
|
+
* `length = size - start`, so a non-integer or negative offset produces a non-integer or negative
|
|
26
|
+
* length, and those fail in two different ways one layer apart: `Buffer.allocUnsafe` throws a
|
|
27
|
+
* RangeError for a fractional length, while an oversized one reaches `fs.read` and aborts the
|
|
28
|
+
* process. Checking here means the caller gets one predictable rejection instead of either.
|
|
29
|
+
*/
|
|
30
|
+
function requireByteCount(label, value) {
|
|
31
|
+
if (value === undefined)
|
|
32
|
+
return;
|
|
33
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
34
|
+
throw new RangeError(`${label} must be a non-negative safe integer, got ${String(value)} — a fractional or negative byte count cannot reach a read syscall safely`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
2
37
|
/** Production adapter — the single import site of node:fs/promises in the server. */
|
|
3
38
|
export function createNodeFileSystem() {
|
|
4
39
|
return {
|
|
5
40
|
readFile: (path) => readFile(path, 'utf8'),
|
|
6
|
-
readFileFrom: async (path, byteOffset) => {
|
|
41
|
+
readFileFrom: async (path, byteOffset, maxBytes) => {
|
|
42
|
+
requireByteCount('byteOffset', byteOffset);
|
|
43
|
+
requireByteCount('maxBytes', maxBytes);
|
|
7
44
|
const fh = await open(path, 'r');
|
|
8
45
|
try {
|
|
9
46
|
const { size } = await fh.stat();
|
|
10
47
|
if (byteOffset >= size)
|
|
11
|
-
return { text: '', size };
|
|
12
|
-
|
|
48
|
+
return { text: '', size, from: byteOffset };
|
|
49
|
+
// Keep the newest bytes when the tail is over the caller's ceiling. Clamped into the file
|
|
50
|
+
// rather than trusted: a ceiling of zero would otherwise size a negative buffer.
|
|
51
|
+
const cut = maxBytes === undefined ? byteOffset : Math.max(byteOffset, size - Math.max(maxBytes, 0));
|
|
52
|
+
const capped = cut > byteOffset;
|
|
53
|
+
// One byte BEFORE that cut, so a cut landing exactly on a record boundary is recognised as
|
|
54
|
+
// one instead of sacrificing the record after it.
|
|
55
|
+
const start = capped ? cut - 1 : byteOffset;
|
|
56
|
+
// Clamped to the int32 ceiling: past it `fs.read` aborts the process rather than throwing.
|
|
57
|
+
// Keep the NEWEST bytes, matching what every caller of a tail read is asking for.
|
|
58
|
+
const want = size - start;
|
|
59
|
+
const length = Math.min(want, MAX_SINGLE_READ_BYTES);
|
|
60
|
+
const readFrom = start + (want - length);
|
|
13
61
|
const buf = Buffer.allocUnsafe(length);
|
|
14
|
-
await fh.read(buf, 0, length,
|
|
15
|
-
|
|
62
|
+
const { bytesRead } = await fh.read(buf, 0, length, readFrom);
|
|
63
|
+
// ONLY what the read returned. `allocUnsafe` hands back whatever was last in that heap
|
|
64
|
+
// block, and `fstat` runs one syscall ahead of `read` — so a file truncated in between, or
|
|
65
|
+
// any short read, would otherwise decode that memory and hand it back as journal text.
|
|
66
|
+
const got = buf.subarray(0, bytesRead);
|
|
67
|
+
if (!capped && readFrom === start)
|
|
68
|
+
return { text: got.toString('utf8'), size, from: start };
|
|
69
|
+
// The ceiling moved the start, so the window may open inside a record whose head was never
|
|
70
|
+
// read. Drop up to the first boundary in BYTES, before decoding: `\n` is one byte and
|
|
71
|
+
// cannot occur inside a multi-byte sequence, so everything past it is intact UTF-8 and a
|
|
72
|
+
// whole number of records.
|
|
73
|
+
const newline = got.indexOf(NEWLINE_BYTE);
|
|
74
|
+
// No boundary in the whole window: there is no record here that can be read, and half of
|
|
75
|
+
// one is worse than none. `from` still says how far the read reached.
|
|
76
|
+
if (-1 === newline)
|
|
77
|
+
return { text: '', size, from: readFrom + bytesRead };
|
|
78
|
+
return {
|
|
79
|
+
text: got.subarray(newline + 1).toString('utf8'),
|
|
80
|
+
size,
|
|
81
|
+
from: readFrom + newline + 1,
|
|
82
|
+
};
|
|
16
83
|
}
|
|
17
84
|
finally {
|
|
18
85
|
await fh.close();
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Write a file so a reader never sees a half-written one, and two writers never see each other's.
|
|
3
|
+
*
|
|
4
|
+
* Write to a temporary sibling, then rename. The rename is what makes it atomic: on a POSIX
|
|
5
|
+
* filesystem it either replaces the destination entirely or does not happen, so a reader gets the
|
|
6
|
+
* old file or the new one and never a truncated middle.
|
|
7
|
+
*
|
|
8
|
+
* TWO properties, and the second is the one that was missing. Five stores in this package built
|
|
9
|
+
* this by hand and named the temp file after the DESTINATION — `${path}.tmp` — which is shared by
|
|
10
|
+
* every process writing that path. Two daemons on one repository then write the same temp file:
|
|
11
|
+
* one truncates the other mid-write and renames the remains into place, and the survivor is a file
|
|
12
|
+
* neither of them produced. Two other stores already pid-suffixed theirs, so the fix was known and
|
|
13
|
+
* had simply never been carried to the rest.
|
|
14
|
+
*
|
|
15
|
+
* Running parallel agent sessions against one checkout is the ordinary way to reach this, not an
|
|
16
|
+
* exotic one.
|
|
17
|
+
*
|
|
18
|
+
* The temp file stays BESIDE its destination rather than in the system temp directory, because a
|
|
19
|
+
* rename across devices is not atomic and `/tmp` is routinely a different device.
|
|
20
|
+
*
|
|
21
|
+
* One helper rather than five corrected copies: the sixth store gets this for free and cannot get
|
|
22
|
+
* it wrong, which is the only version of this fix that stays fixed.
|
|
23
|
+
*/
|
|
24
|
+
import type { FileSystemPort } from './fs-port.js';
|
|
25
|
+
export declare function tempNameFor(path: string, pid?: number, seq?: number): string;
|
|
26
|
+
export declare function writeFileAtomic(fs: FileSystemPort, path: string, contents: string): Promise<void>;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unique per WRITE, not per process.
|
|
3
|
+
*
|
|
4
|
+
* The pid separates two daemons. It does not separate two concurrent writes inside ONE daemon —
|
|
5
|
+
* `Promise.all` over two store saves, or two scheduled flushes of the same ledger — and those share
|
|
6
|
+
* a pid, so a pid-only name puts them straight back on the same temp file. The first version of
|
|
7
|
+
* this helper had exactly that hole and its own concurrency test found it.
|
|
8
|
+
*
|
|
9
|
+
* A counter rather than `Math.random()`: rule 7 keeps the clock and the random source out of pure
|
|
10
|
+
* logic, and a monotonic counter is deterministic, collision-free within a process, and needs
|
|
11
|
+
* neither.
|
|
12
|
+
*/
|
|
13
|
+
let writeSeq = 0;
|
|
14
|
+
export function tempNameFor(path, pid = process.pid, seq) {
|
|
15
|
+
writeSeq += 1;
|
|
16
|
+
return `${path}.${String(pid)}.${String(seq ?? writeSeq)}.tmp`;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* What Windows answers, for a moment, when another writer is still replacing the destination.
|
|
20
|
+
* POSIX never refuses such a rename; Windows does, and the save has to wait it out rather than
|
|
21
|
+
* fail. Seen on Windows CI with two writers on one path.
|
|
22
|
+
*/
|
|
23
|
+
const TRANSIENT_RENAME = new Set(['EPERM', 'EBUSY', 'EACCES']);
|
|
24
|
+
const RENAME_ATTEMPTS = 6;
|
|
25
|
+
const RENAME_BACKOFF_MS = 15;
|
|
26
|
+
function isTransientRename(error) {
|
|
27
|
+
const code = error?.code;
|
|
28
|
+
return 'string' === typeof code && TRANSIENT_RENAME.has(code);
|
|
29
|
+
}
|
|
30
|
+
export async function writeFileAtomic(fs, path, contents) {
|
|
31
|
+
const tmp = tempNameFor(path);
|
|
32
|
+
await fs.writeFile(tmp, contents);
|
|
33
|
+
for (let attempt = 1;; attempt += 1) {
|
|
34
|
+
try {
|
|
35
|
+
await fs.rename(tmp, path);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
if (attempt < RENAME_ATTEMPTS && isTransientRename(error)) {
|
|
40
|
+
await new Promise((resolve) => setTimeout(resolve, RENAME_BACKOFF_MS * attempt));
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
// The write failed; its temp file must not outlive it.
|
|
44
|
+
await fs.rm(tmp).catch(() => undefined);
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { type ReticleEvent } from '@reticlehq/core';
|
|
2
2
|
interface RouteLearningSession {
|
|
3
3
|
url: string;
|
|
4
|
+
/**
|
|
5
|
+
* The `.reticle` this session's artifacts belong in, stamped on session-create. Undefined only
|
|
6
|
+
* for a caller that does not stamp one, which resolves to the daemon's own root.
|
|
7
|
+
*/
|
|
8
|
+
artifactRoot?: string | undefined;
|
|
4
9
|
onEvent(handler: (event: ReticleEvent) => void): () => void;
|
|
5
10
|
}
|
|
6
11
|
interface RouteLearningBridge {
|
|
@@ -19,6 +24,15 @@ export declare function routeFromEvent(event: ReticleEvent): string | undefined;
|
|
|
19
24
|
/** Read the same route shape from a session's absolute URL. */
|
|
20
25
|
export declare function routeFromUrl(value: string): string | undefined;
|
|
21
26
|
export declare function routesFromEvents(events: readonly ReticleEvent[]): string[];
|
|
22
|
-
/**
|
|
23
|
-
|
|
27
|
+
/**
|
|
28
|
+
* Persist initial and subsequent routes for existing and future browser sessions.
|
|
29
|
+
*
|
|
30
|
+
* `storeFor` resolves the store for ONE session's artifact root, rather than the daemon being handed
|
|
31
|
+
* a single store bound to its own `process.cwd()`. That binding was right only when the daemon
|
|
32
|
+
* happened to be started inside the app it was driving — and a user-scoped MCP registration, the
|
|
33
|
+
* common case, starts it wherever the editor's cwd is. The routes of every app then landed in that
|
|
34
|
+
* directory, which is how a `.reticle/` kept reappearing in a backend nobody had instrumented,
|
|
35
|
+
* while the driven project's own memory never filled.
|
|
36
|
+
*/
|
|
37
|
+
export declare function attachRouteLearning(bridge: RouteLearningBridge, storeFor: (root: string | undefined) => RouteLearningStore): void;
|
|
24
38
|
export {};
|