@reticlehq/server 3.4.0 → 3.6.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/README.md +1 -1
- package/SKILL.md +16 -8
- package/dist/chromium-launch-options.d.ts +3 -1
- package/dist/chromium-launch-options.js +2 -1
- package/dist/command/cli/adhoc-verdict.d.ts +1 -0
- package/dist/command/cli/adhoc-verdict.js +6 -3
- package/dist/command/cli/answers/open-note.d.ts +2 -0
- package/dist/command/cli/answers/open-note.js +22 -0
- package/dist/command/cli/auth/cloud-session.d.ts +4 -4
- package/dist/command/cli/auth/device-flow.d.ts +2 -2
- package/dist/command/cli/cli-flow-commands.js +4 -8
- package/dist/command/cli/cli-parse.d.ts +1 -1
- package/dist/command/cli/cli-parse.js +13 -11
- package/dist/command/cli/cli-verify.d.ts +1 -1
- package/dist/command/cli/cli-verify.js +35 -25
- package/dist/command/cli/cloud-cli.js +110 -12
- package/dist/command/cli/cloud-login.d.ts +9 -1
- package/dist/command/cli/cloud-login.js +43 -8
- package/dist/command/cli/demo/demo-page.js +11 -3
- package/dist/command/cli/doctor/browser/chromium-hint.d.ts +46 -0
- package/dist/command/cli/doctor/browser/chromium-hint.js +99 -1
- package/dist/command/cli/drive/drive-attach.d.ts +27 -2
- package/dist/command/cli/drive/drive-attach.js +71 -4
- package/dist/command/cli/drive/drive-command.d.ts +2 -1
- package/dist/command/cli/drive/drive-command.js +13 -7
- package/dist/command/cli/lifecycle/daemon-lifecycle.js +4 -1
- package/dist/command/cli/ports/port-holder.d.ts +10 -0
- package/dist/command/cli/ports/port-holder.js +24 -0
- package/dist/command/cli/setup-mcp-cli.js +15 -5
- package/dist/command/cli/tutorial.d.ts +2 -2
- package/dist/command/cli/tutorial.js +6 -9
- package/dist/command/cli.d.ts +6 -0
- package/dist/command/cli.js +45 -23
- package/dist/command/daemon/binding/daemon-status-probe.d.ts +2 -0
- package/dist/command/daemon/binding/daemon-status-probe.js +1 -0
- package/dist/command/daemon/daemon-resolve.d.ts +14 -0
- package/dist/command/daemon/daemon-resolve.js +33 -2
- package/dist/command/setup/agent-writer.d.ts +1 -1
- package/dist/command/setup/agent-writer.js +3 -2
- package/dist/command/setup/bringup/connect-lease.d.ts +1 -0
- package/dist/command/setup/bringup/connect-lease.js +1 -0
- package/dist/command/setup/bringup/dev-server-wait.d.ts +10 -0
- package/dist/command/setup/bringup/dev-server-wait.js +20 -1
- package/dist/command/setup/init/init-port.d.ts +4 -0
- package/dist/command/setup/init/init-port.js +10 -0
- package/dist/command/setup/init/init-runtime.d.ts +10 -0
- package/dist/command/setup/init/init-runtime.js +71 -9
- package/dist/command/setup/node-effects.js +8 -2
- package/dist/command/setup/remaining-steps.js +2 -1
- package/dist/command/setup/run-setup.d.ts +7 -0
- package/dist/command/setup/run-setup.js +38 -25
- package/dist/command/setup/setup-command.d.ts +2 -1
- package/dist/command/setup/setup-command.js +2 -0
- package/dist/command/setup/setup-mcp.d.ts +2 -0
- package/dist/command/setup/setup-mcp.js +14 -5
- package/dist/command/version/peer-announce.d.ts +6 -2
- package/dist/command/version/peer-announce.js +8 -6
- package/dist/command/version/version-nudge.d.ts +17 -3
- package/dist/command/version/version-nudge.js +25 -4
- package/dist/command/version/version-skew.js +11 -2
- package/dist/features/crawl/crawl-tools.js +4 -0
- package/dist/features/crawl/crawl.d.ts +6 -0
- package/dist/features/crawl/crawl.js +9 -1
- package/dist/features/exhaust/ledger.d.ts +7 -0
- package/dist/features/exhaust/ledger.js +35 -9
- package/dist/features/harness/app-findings.d.ts +2 -0
- package/dist/features/harness/app-findings.js +52 -0
- package/dist/features/harness/drive-report.d.ts +27 -1
- package/dist/features/harness/drive-report.js +162 -11
- package/dist/features/harness/drivers.d.ts +10 -14
- package/dist/features/harness/drivers.js +10 -14
- package/dist/features/harness/fill-values.d.ts +3 -69
- package/dist/features/harness/fill-values.js +3 -133
- package/dist/features/harness/goals.d.ts +21 -0
- package/dist/features/harness/goals.js +77 -0
- package/dist/features/harness/harness.d.ts +21 -0
- package/dist/features/harness/harness.js +69 -2
- package/dist/features/harness/platform/drive-target.d.ts +30 -0
- package/dist/features/harness/platform/drive-target.js +81 -0
- package/dist/features/harness/platform/local-apps.d.ts +94 -0
- package/dist/features/harness/platform/local-apps.js +204 -0
- package/dist/features/harness/platform/remote-drive.d.ts +180 -0
- package/dist/features/harness/platform/remote-drive.js +365 -0
- package/dist/features/harness/platform/script.d.ts +48 -0
- package/dist/features/harness/platform/script.js +128 -0
- package/dist/features/harness/platform/server-driver.d.ts +41 -0
- package/dist/features/harness/platform/server-driver.js +179 -0
- package/dist/features/harness/platform-config.d.ts +7 -0
- package/dist/features/harness/platform-config.js +14 -2
- package/dist/features/harness/script/app-controls.d.ts +5 -0
- package/dist/features/harness/script/app-controls.js +40 -0
- package/dist/features/harness/script/lane-ids.d.ts +14 -0
- package/dist/features/harness/script/lane-ids.js +16 -0
- package/dist/features/harness/script/script-run.d.ts +41 -0
- package/dist/features/harness/script/script-run.js +299 -0
- package/dist/features/license/license.d.ts +2 -2
- package/dist/features/visual/visual-tools.d.ts +10 -1
- package/dist/features/visual/visual-tools.js +40 -3
- package/dist/hooks/driven-by.d.ts +4 -0
- package/dist/hooks/driven-by.js +16 -0
- package/dist/hooks/hook-commands.js +15 -4
- package/dist/hooks/tool-log.d.ts +9 -0
- package/dist/hooks/tool-log.js +29 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +91 -89
- package/dist/judgement/capsule/capsule-store.d.ts +4 -4
- package/dist/judgement/runs/artifact/build-verification-run.js +6 -0
- package/dist/judgement/runs/artifact/replay-mapping.d.ts +1 -1
- package/dist/judgement/runs/artifact/replay-mapping.js +16 -6
- package/dist/judgement/runs/artifact/run-store.d.ts +1 -1
- package/dist/judgement/runs/artifact/run-store.js +26 -1
- package/dist/judgement/runs/drive-run.d.ts +22 -0
- package/dist/judgement/runs/drive-run.js +99 -1
- package/dist/judgement/runs/repair-prompt.js +1 -1
- package/dist/judgement/runs/reticle-runner.js +8 -1
- package/dist/judgement/runs/verification-sync.d.ts +3 -2
- package/dist/judgement/runs/verification-sync.js +4 -3
- package/dist/language/flows/change/gate.d.ts +10 -0
- package/dist/language/flows/change/gate.js +22 -0
- package/dist/language/flows/decision.js +34 -0
- package/dist/language/flows/flow-anchor.d.ts +54 -8
- package/dist/language/flows/flow-anchor.js +88 -3
- package/dist/language/flows/flow-cross-step.d.ts +15 -0
- package/dist/language/flows/flow-cross-step.js +21 -0
- package/dist/language/flows/flow-learning.js +8 -11
- package/dist/language/flows/flow-mutate-tools.js +5 -3
- package/dist/language/flows/flow-replay-run.d.ts +23 -9
- package/dist/language/flows/flow-replay-run.js +101 -94
- package/dist/language/flows/flow-replay.d.ts +23 -1
- package/dist/language/flows/flow-replay.js +92 -19
- package/dist/language/flows/flow-scope.js +5 -1
- package/dist/language/flows/flow-step-runners.d.ts +20 -2
- package/dist/language/flows/flow-step-runners.js +236 -42
- package/dist/language/flows/flow-store-for-session.d.ts +2 -2
- package/dist/language/flows/flow-store-for-session.js +2 -2
- package/dist/language/flows/flow-tools.js +58 -34
- package/dist/language/flows/flows.js +5 -2
- package/dist/language/flows/heal-run.js +6 -4
- package/dist/language/flows/recording/flake.d.ts +2 -2
- package/dist/language/flows/recording/tape/recordings.d.ts +30 -4
- package/dist/language/flows/recording/tape/recordings.js +40 -8
- package/dist/language/flows/replay-from-hud.d.ts +20 -0
- package/dist/language/flows/replay-from-hud.js +51 -0
- package/dist/language/flows/replay.d.ts +9 -5
- package/dist/language/flows/replay.js +27 -9
- package/dist/language/flows/suite/suite-flakes.js +2 -0
- package/dist/language/flows/suite-selection.d.ts +2 -15
- package/dist/language/flows/suite-selection.js +4 -3
- package/dist/language/flows/verify-change-tools.js +8 -4
- package/dist/launch-chromium.d.ts +27 -5
- package/dist/launch-chromium.js +38 -8
- package/dist/memory/cloud/cloud-config.d.ts +12 -0
- package/dist/memory/cloud/cloud-config.js +15 -0
- package/dist/memory/cloud/cloud-sync.d.ts +17 -14
- package/dist/memory/cloud/cloud-sync.js +12 -1
- package/dist/memory/cloud/harness-config.d.ts +26 -1
- package/dist/memory/cloud/harness-config.js +99 -4
- package/dist/memory/cloud/hud-notices-source.d.ts +21 -0
- package/dist/memory/cloud/hud-notices-source.js +128 -0
- package/dist/memory/cloud/memory-scope.d.ts +144 -0
- package/dist/memory/cloud/memory-scope.js +205 -0
- package/dist/memory/cloud/sync-cycle.d.ts +21 -3
- package/dist/memory/cloud/sync-cycle.js +45 -5
- package/dist/memory/cloud/sync-daemon.js +7 -5
- package/dist/memory/cloud/sync-disk.d.ts +1 -1
- package/dist/memory/cloud/sync-disk.js +5 -8
- package/dist/memory/impact/account-watch.d.ts +19 -0
- package/dist/memory/impact/account-watch.js +34 -0
- package/dist/memory/impact/impact-recorder.d.ts +8 -0
- package/dist/memory/impact/impact-recorder.js +12 -2
- package/dist/memory/impact/impact-store.d.ts +16 -0
- package/dist/memory/impact/impact-store.js +142 -26
- package/dist/memory/intent/intent-shard.d.ts +2 -2
- package/dist/memory/intent/intent-tools.js +88 -5
- package/dist/memory/journal/envelope.d.ts +16 -16
- package/dist/memory/journal/journal-query.d.ts +14 -0
- package/dist/memory/journal/journal-query.js +15 -0
- package/dist/memory/journal/journal-recorder.js +7 -1
- package/dist/memory/journal/on-disk/retention.d.ts +3 -0
- package/dist/memory/journal/on-disk/retention.js +5 -0
- package/dist/memory/journal/on-disk/startup-maintenance.js +3 -1
- package/dist/memory/journal/on-disk/workspace-tiers.js +7 -4
- package/dist/memory/journal/session-end.js +6 -4
- package/dist/memory/project/artifact-root-resolver.d.ts +36 -1
- package/dist/memory/project/artifact-root-resolver.js +134 -26
- package/dist/memory/project/artifact-root.d.ts +42 -5
- package/dist/memory/project/artifact-root.js +87 -8
- package/dist/memory/project/dir/reticle-dir.d.ts +4 -2
- package/dist/memory/project/dir/reticle-dir.js +3 -1
- package/dist/memory/project/project-tools.js +22 -3
- package/dist/memory/project/session-root.d.ts +24 -1
- package/dist/memory/project/session-root.js +41 -5
- package/dist/memory/project/sync-status.d.ts +39 -0
- package/dist/memory/project/sync-status.js +90 -0
- package/dist/memory/recall/memory-tools.js +2 -0
- package/dist/memory/recall/project-memory.d.ts +19 -0
- package/dist/memory/recall/project-memory.js +45 -6
- package/dist/portal/bridge/bridge.d.ts +7 -4
- package/dist/portal/bridge/bridge.js +25 -3
- package/dist/portal/bridge/pairing-token.d.ts +9 -0
- package/dist/portal/bridge/pairing-token.js +17 -0
- package/dist/portal/input/real-input.d.ts +81 -10
- package/dist/portal/input/real-input.js +207 -22
- package/dist/portal/input/viewport-tools.js +19 -9
- package/dist/portal/pool/browser-pool.d.ts +22 -9
- package/dist/portal/pool/browser-pool.js +70 -35
- package/dist/portal/pool/playwright-launcher.d.ts +13 -0
- package/dist/portal/pool/playwright-launcher.js +17 -1
- package/dist/portal/pool/pool-contract.d.ts +7 -4
- package/dist/portal/session/command-payload.d.ts +8 -0
- package/dist/portal/session/command-payload.js +19 -0
- package/dist/portal/session/dev-server/dev-command.d.ts +12 -0
- package/dist/portal/session/dev-server/dev-command.js +61 -12
- package/dist/portal/session/facts/departure-note.d.ts +22 -0
- package/dist/portal/session/facts/departure-note.js +47 -0
- package/dist/portal/session/facts/session-replaced.d.ts +5 -0
- package/dist/portal/session/facts/session-replaced.js +5 -0
- package/dist/portal/session/first-run-wiring.d.ts +42 -0
- package/dist/portal/session/first-run-wiring.js +132 -0
- package/dist/portal/session/human/notes-ledger.d.ts +20 -0
- package/dist/portal/session/human/notes-ledger.js +55 -0
- package/dist/portal/session/human/review-store.d.ts +12 -0
- package/dist/portal/session/human/review-store.js +18 -10
- package/dist/portal/session/live-control-tools.d.ts +0 -13
- package/dist/portal/session/live-control-tools.js +28 -3
- package/dist/portal/session/no-session-diagnosis.d.ts +7 -0
- package/dist/portal/session/no-session-diagnosis.js +15 -0
- package/dist/portal/session/no-session-next-action.d.ts +11 -0
- package/dist/portal/session/no-session-next-action.js +31 -5
- package/dist/portal/session/no-session-watch.d.ts +14 -1
- package/dist/portal/session/no-session-watch.js +63 -10
- package/dist/portal/session/presence/session-mirror.js +3 -0
- package/dist/portal/session/serving-directory.d.ts +27 -0
- package/dist/portal/session/serving-directory.js +45 -0
- package/dist/portal/session/session-health.d.ts +7 -0
- package/dist/portal/session/session-health.js +6 -0
- package/dist/portal/session/session-info.d.ts +8 -1
- package/dist/portal/session/session-info.js +6 -0
- package/dist/portal/session/session-manager.d.ts +21 -0
- package/dist/portal/session/session-manager.js +44 -8
- package/dist/portal/session/session-reaper.js +4 -1
- package/dist/portal/session/session-successor.d.ts +5 -0
- package/dist/portal/session/session-tools.js +26 -3
- package/dist/portal/session/session.d.ts +12 -10
- package/dist/portal/session/session.js +30 -31
- package/dist/start-options.d.ts +6 -0
- package/dist/status-payload.d.ts +10 -0
- package/dist/status-payload.js +23 -0
- package/dist/surface/http-server.d.ts +1 -1
- package/dist/surface/http-server.js +15 -4
- package/dist/surface/mcp/mcp-proxy.d.ts +5 -0
- package/dist/surface/mcp/mcp-proxy.js +36 -5
- package/dist/surface/mcp/mcp.d.ts +11 -6
- package/dist/surface/mcp/mcp.js +28 -14
- package/dist/surface/mcp/proxy/proxy-log.js +3 -5
- package/dist/surface/mcp/server-instructions.d.ts +4 -2
- package/dist/surface/mcp/server-instructions.js +14 -4
- package/dist/surface/mcp/shared-params.js +2 -2
- package/dist/surface/tools/act/navigation/navigate-arrival.d.ts +8 -0
- package/dist/surface/tools/act/navigation/navigate-arrival.js +56 -2
- package/dist/surface/tools/act/settle-in-flight.js +25 -3
- package/dist/surface/tools/act-sequence-tool.js +2 -2
- package/dist/surface/tools/act-tools.js +25 -9
- package/dist/surface/tools/assert/assert-verdict.d.ts +1 -1
- package/dist/surface/tools/assert/assert-verdict.js +17 -2
- package/dist/surface/tools/browser-tools.js +8 -1
- package/dist/surface/tools/chat-drives.d.ts +26 -0
- package/dist/surface/tools/chat-drives.js +165 -0
- package/dist/surface/tools/dynamic-tools.js +29 -2
- package/dist/surface/tools/empty-session-list.d.ts +41 -0
- package/dist/surface/tools/empty-session-list.js +80 -0
- package/dist/surface/tools/error-recovery.d.ts +6 -0
- package/dist/surface/tools/error-recovery.js +38 -6
- package/dist/surface/tools/explore-tools.d.ts +10 -1
- package/dist/surface/tools/explore-tools.js +82 -13
- package/dist/surface/tools/feedback-tools.js +14 -0
- package/dist/surface/tools/harness-explore.d.ts +96 -14
- package/dist/surface/tools/harness-explore.js +227 -202
- package/dist/surface/tools/harness-plan.d.ts +12 -0
- package/dist/surface/tools/harness-plan.js +31 -0
- package/dist/surface/tools/harness-script.d.ts +8 -0
- package/dist/surface/tools/harness-script.js +342 -0
- package/dist/surface/tools/harness-toolset.d.ts +14 -1
- package/dist/surface/tools/harness-toolset.js +77 -6
- package/dist/surface/tools/invoke-tool.js +64 -9
- package/dist/surface/tools/lease-seed.d.ts +29 -0
- package/dist/surface/tools/lease-seed.js +123 -0
- package/dist/surface/tools/lease-tools.d.ts +3 -23
- package/dist/surface/tools/lease-tools.js +30 -131
- package/dist/surface/tools/live-call-text.js +2 -2
- package/dist/surface/tools/next-step.d.ts +19 -0
- package/dist/surface/tools/next-step.js +159 -0
- package/dist/surface/tools/observe-tools.js +2 -4
- package/dist/surface/tools/platform-moment.d.ts +12 -0
- package/dist/surface/tools/platform-moment.js +168 -0
- package/dist/surface/tools/query-hint-schema.d.ts +7 -0
- package/dist/surface/tools/query-hint-schema.js +10 -0
- package/dist/surface/tools/read-tools.js +8 -2
- package/dist/surface/tools/real-input-attempt.js +96 -13
- package/dist/surface/tools/tool-kit.d.ts +25 -1
- package/dist/surface/tools/tool-kit.js +4 -6
- package/dist/surface/tools/tools.js +20 -48
- package/dist/telemetry/argument-shape.js +1 -1
- package/dist/telemetry/hud-metrics.d.ts +2 -0
- package/dist/telemetry/hud-metrics.js +17 -0
- package/dist/telemetry/onboarding-firsts.d.ts +2 -2
- package/dist/telemetry/onboarding-firsts.js +2 -2
- package/dist/telemetry/telemetry.d.ts +12 -0
- package/dist/telemetry/telemetry.js +43 -14
- package/dist/telemetry/verification-of.js +1 -0
- package/dist/wire-journal.js +4 -1
- package/docs/README.md +1 -1
- package/docs/actions.mdx +1 -2
- package/docs/agent-cheatsheet.md +1 -1
- package/docs/autodrive.md +29 -25
- package/docs/cli/cloud.mdx +6 -4
- package/docs/cli/doctor.mdx +6 -0
- package/docs/cli/drive.mdx +8 -2
- package/docs/cli/init.mdx +11 -13
- package/docs/cli/status.mdx +5 -1
- package/docs/cli/version.mdx +3 -3
- package/docs/cli.mdx +14 -8
- package/docs/desktop.mdx +5 -1
- package/docs/environment.mdx +3 -7
- package/docs/getting-started.md +14 -2
- package/docs/human-control.md +1 -2
- package/docs/index.mdx +4 -3
- package/docs/install-agentic.mdx +1 -1
- package/docs/install-manual.mdx +2 -2
- package/docs/packages/next.mdx +3 -0
- package/docs/packages/tauri.mdx +2 -2
- package/docs/packages/vite-plugin.mdx +24 -1
- package/docs/packages.mdx +1 -1
- package/docs/quickstart.mdx +23 -11
- package/docs/telemetry.md +1 -1
- package/docs/tools/act.mdx +1 -1
- package/docs/tools/session-and-feedback.mdx +9 -9
- package/docs/usage.md +5 -3
- package/docs/what-is-recorded.md +13 -3
- package/docs/what-is-reticle.mdx +3 -1
- package/package.json +8 -12
- package/dist/features/harness/driver.d.ts +0 -50
- package/dist/features/harness/driver.js +0 -236
- package/dist/features/harness/jev-driver.d.ts +0 -188
- package/dist/features/harness/jev-driver.js +0 -678
- package/dist/features/harness/openai-driver.d.ts +0 -46
- package/dist/features/harness/openai-driver.js +0 -194
- package/dist/memory/project/dir/fill-value-store.d.ts +0 -32
- package/dist/memory/project/dir/fill-value-store.js +0 -88
- package/docs/local-registry.md +0 -143
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
* guard refuses — correctly, and for the same reason it refused the tool surface reaching the other
|
|
19
19
|
* way. Whoever wires the daemon owns both sides and can hand one to the other.
|
|
20
20
|
*/
|
|
21
|
+
import { writeHarnessSwitch } from './harness-switch.js';
|
|
21
22
|
/** How long a cached answer is trusted. A person who just changed it expects the panel to notice. */
|
|
22
23
|
const FRESH_MS = 30 * 1_000;
|
|
23
24
|
export function harnessConfigSource(load, now = () => Date.now()) {
|
|
@@ -26,23 +27,47 @@ export function harnessConfigSource(load, now = () => Date.now()) {
|
|
|
26
27
|
// conflating them made a source with an injected clock never take its first read.
|
|
27
28
|
let fetchedAt;
|
|
28
29
|
let inFlight = false;
|
|
30
|
+
let queuedRefresh = false;
|
|
31
|
+
let revision = 0;
|
|
32
|
+
/** Set for a background poll, so an unchanged answer does not repaint every HUD. */
|
|
33
|
+
let quiet = false;
|
|
34
|
+
const listeners = new Set();
|
|
35
|
+
const notify = () => {
|
|
36
|
+
for (const listener of listeners)
|
|
37
|
+
listener();
|
|
38
|
+
};
|
|
29
39
|
const refresh = () => {
|
|
30
40
|
if (inFlight)
|
|
31
41
|
return;
|
|
32
42
|
inFlight = true;
|
|
43
|
+
const startedAtRevision = revision;
|
|
33
44
|
void load()
|
|
34
45
|
.then((cfg) => {
|
|
35
46
|
// A failed read keeps the LAST good answer rather than blanking the control mid-session:
|
|
36
47
|
// one dropped request is not evidence that somebody changed their mind.
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
48
|
+
const before = JSON.stringify(cached);
|
|
49
|
+
if (startedAtRevision === revision) {
|
|
50
|
+
if (cfg !== undefined)
|
|
51
|
+
cached = cfg;
|
|
52
|
+
fetchedAt = now();
|
|
53
|
+
}
|
|
54
|
+
// A poll that learned nothing new repaints nothing; anything else is said at once.
|
|
55
|
+
if (!quiet || before !== JSON.stringify(cached))
|
|
56
|
+
notify();
|
|
57
|
+
quiet = false;
|
|
40
58
|
})
|
|
41
59
|
.catch(() => {
|
|
42
|
-
|
|
60
|
+
if (startedAtRevision === revision)
|
|
61
|
+
fetchedAt = now();
|
|
62
|
+
quiet = false;
|
|
63
|
+
notify();
|
|
43
64
|
})
|
|
44
65
|
.finally(() => {
|
|
45
66
|
inFlight = false;
|
|
67
|
+
if (queuedRefresh) {
|
|
68
|
+
queuedRefresh = false;
|
|
69
|
+
refresh();
|
|
70
|
+
}
|
|
46
71
|
});
|
|
47
72
|
};
|
|
48
73
|
// Asked at construction, not first read: the daemon pushes a snapshot the instant a session
|
|
@@ -54,5 +79,75 @@ export function harnessConfigSource(load, now = () => Date.now()) {
|
|
|
54
79
|
refresh();
|
|
55
80
|
return cached;
|
|
56
81
|
},
|
|
82
|
+
applyWrite(enabled) {
|
|
83
|
+
revision += 1;
|
|
84
|
+
if (cached !== undefined)
|
|
85
|
+
cached = { ...cached, harnessEnabled: enabled };
|
|
86
|
+
fetchedAt = now();
|
|
87
|
+
notify();
|
|
88
|
+
if (cached === undefined) {
|
|
89
|
+
if (inFlight)
|
|
90
|
+
queuedRefresh = true;
|
|
91
|
+
else
|
|
92
|
+
refresh();
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
recheck() {
|
|
96
|
+
notify(); // Roll back an optimistic HUD switch immediately, even if the GET is slow.
|
|
97
|
+
if (inFlight)
|
|
98
|
+
queuedRefresh = true;
|
|
99
|
+
else
|
|
100
|
+
refresh();
|
|
101
|
+
},
|
|
102
|
+
subscribe(listener) {
|
|
103
|
+
listeners.add(listener);
|
|
104
|
+
return () => listeners.delete(listener);
|
|
105
|
+
},
|
|
106
|
+
poll() {
|
|
107
|
+
if (inFlight)
|
|
108
|
+
return;
|
|
109
|
+
quiet = true;
|
|
110
|
+
refresh();
|
|
111
|
+
},
|
|
57
112
|
};
|
|
58
113
|
}
|
|
114
|
+
/**
|
|
115
|
+
* One live config source per project root, created on first ask; `onChange` hears every update.
|
|
116
|
+
*
|
|
117
|
+
* The platform read arrives as `load`, so this cache never reaches for the harness feature that
|
|
118
|
+
* performs it: the daemon, which owns both, joins them. It sat inline in `start` and `startDaemon`.
|
|
119
|
+
*/
|
|
120
|
+
export function harnessConfigsByRoot(load, onChange) {
|
|
121
|
+
const sources = new Map();
|
|
122
|
+
return (root) => {
|
|
123
|
+
let source = sources.get(root);
|
|
124
|
+
if (source === undefined) {
|
|
125
|
+
source = harnessConfigSource(() => load(root));
|
|
126
|
+
if (onChange !== undefined) {
|
|
127
|
+
source.subscribe(() => onChange(root));
|
|
128
|
+
// The switch can be flipped anywhere: the console, another tab, the API. A HUD that only
|
|
129
|
+
// learned of it on its next tool call showed Harness ON over a drive that had stopped.
|
|
130
|
+
const live = source;
|
|
131
|
+
setInterval(() => live.poll(), FRESH_MS).unref();
|
|
132
|
+
}
|
|
133
|
+
sources.set(root, source);
|
|
134
|
+
}
|
|
135
|
+
return source;
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* The HUD's harness switch, written to the platform with this project's credential. Nothing is
|
|
140
|
+
* awaited: an accepted write updates the cache at once, and a refused or failed one re-reads the
|
|
141
|
+
* platform so the switch springs back to the truth rather than staying where the user left it.
|
|
142
|
+
*/
|
|
143
|
+
export function applyHarnessSwitch(configs, root, enabled, envFor) {
|
|
144
|
+
void envFor()
|
|
145
|
+
.then((env) => writeHarnessSwitch(env, enabled))
|
|
146
|
+
.then((saved) => {
|
|
147
|
+
if (saved)
|
|
148
|
+
configs(root).applyWrite(enabled);
|
|
149
|
+
else
|
|
150
|
+
configs(root).recheck();
|
|
151
|
+
})
|
|
152
|
+
.catch(() => configs(root).recheck());
|
|
153
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type HudNoticeEntry } from '@reticlehq/core/hud';
|
|
2
|
+
/**
|
|
3
|
+
* The largest body the daemon will read or keep. The real file is a few hundred bytes; anything near
|
|
4
|
+
* this is not a notices file, and the cache on disk must not grow with whatever the network sends.
|
|
5
|
+
*/
|
|
6
|
+
export declare const MAX_NOTICES_BODY_BYTES: number;
|
|
7
|
+
export type NoticesLoad = (url: string, etag: string | undefined) => Promise<{
|
|
8
|
+
status: number;
|
|
9
|
+
etag?: string | undefined;
|
|
10
|
+
body?: string | undefined;
|
|
11
|
+
}>;
|
|
12
|
+
export interface NoticesSource {
|
|
13
|
+
/** The entries this daemon knows, unfiltered. Never throws, never waits. */
|
|
14
|
+
read(): HudNoticeEntry[];
|
|
15
|
+
}
|
|
16
|
+
export declare function hudNoticesSource(opts: {
|
|
17
|
+
cacheFile: string;
|
|
18
|
+
now?: () => number;
|
|
19
|
+
load?: NoticesLoad;
|
|
20
|
+
url?: string;
|
|
21
|
+
}): NoticesSource;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The HUD rail's notices, as the daemon keeps them.
|
|
3
|
+
*
|
|
4
|
+
* Read synchronously (the snapshot is rebuilt many times a session), served from a disk cache that
|
|
5
|
+
* survives restarts, and revalidated in the background with the stored ETag, so an unchanged file
|
|
6
|
+
* costs a 304 and no body. A failed or garbled refresh keeps the last good copy: the rail is
|
|
7
|
+
* marketing, and nothing about it may block or break a session. See core `hud-notices.ts`.
|
|
8
|
+
*/
|
|
9
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
10
|
+
import { dirname } from 'node:path';
|
|
11
|
+
import { ReticleEnv } from '@reticlehq/core';
|
|
12
|
+
import { HUD_NOTICES_URL, NOTICES_FILE_VERSION, parseHudNotices, } from '@reticlehq/core/hud';
|
|
13
|
+
/** How long a copy is trusted before it is revalidated. Notices change over days, not minutes. */
|
|
14
|
+
const FRESH_MS = 6 * 60 * 60 * 1_000;
|
|
15
|
+
const TIMEOUT_MS = 5_000;
|
|
16
|
+
/**
|
|
17
|
+
* The largest body the daemon will read or keep. The real file is a few hundred bytes; anything near
|
|
18
|
+
* this is not a notices file, and the cache on disk must not grow with whatever the network sends.
|
|
19
|
+
*/
|
|
20
|
+
export const MAX_NOTICES_BODY_BYTES = 64 * 1_024;
|
|
21
|
+
/** Overrides the notices URL: self-hosting, staging, tests. */
|
|
22
|
+
const NOTICES_URL_ENV = 'RETICLE_HUD_NOTICES_URL';
|
|
23
|
+
/**
|
|
24
|
+
* The same opt-outs telemetry honours. The notices file carries nothing about anybody, but it is a
|
|
25
|
+
* request to reticle.sh, and somebody who switched Reticle's outbound calls off asked for none.
|
|
26
|
+
*/
|
|
27
|
+
function outboundOff(env) {
|
|
28
|
+
const telemetry = (env[ReticleEnv.TELEMETRY] ?? '').toLowerCase();
|
|
29
|
+
const dnt = env[ReticleEnv.DO_NOT_TRACK];
|
|
30
|
+
return (['0', 'false', 'off'].includes(telemetry) ||
|
|
31
|
+
('string' === typeof dnt && dnt !== '' && dnt !== '0'));
|
|
32
|
+
}
|
|
33
|
+
const defaultLoad = async (url, etag) => {
|
|
34
|
+
const res = await fetch(url, {
|
|
35
|
+
headers: etag === undefined ? {} : { 'if-none-match': etag },
|
|
36
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
37
|
+
});
|
|
38
|
+
return {
|
|
39
|
+
status: res.status,
|
|
40
|
+
etag: res.headers.get('etag') ?? undefined,
|
|
41
|
+
body: 304 === res.status ? undefined : await res.text(),
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
function readCache(path) {
|
|
45
|
+
try {
|
|
46
|
+
const raw = JSON.parse(readFileSync(path, 'utf8'));
|
|
47
|
+
if ('object' !== typeof raw || null === raw)
|
|
48
|
+
return undefined;
|
|
49
|
+
const record = raw;
|
|
50
|
+
const fetchedAt = record['fetchedAt'];
|
|
51
|
+
const etag = record['etag'];
|
|
52
|
+
return {
|
|
53
|
+
fetchedAt: 'number' === typeof fetchedAt ? fetchedAt : 0,
|
|
54
|
+
...('string' === typeof etag ? { etag } : {}),
|
|
55
|
+
file: record['file'],
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function writeCache(path, cached) {
|
|
63
|
+
try {
|
|
64
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
65
|
+
writeFileSync(path, JSON.stringify(cached));
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
/* a cache that cannot be written is a fetch next time, not an error */
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
export function hudNoticesSource(opts) {
|
|
72
|
+
const now = opts.now ?? (() => Date.now());
|
|
73
|
+
const load = opts.load ?? defaultLoad;
|
|
74
|
+
const url = opts.url ?? process.env[NOTICES_URL_ENV] ?? HUD_NOTICES_URL;
|
|
75
|
+
let cached = readCache(opts.cacheFile);
|
|
76
|
+
let entries = cached === undefined ? [] : parseHudNotices(cached.file);
|
|
77
|
+
let inFlight = false;
|
|
78
|
+
let askedAt;
|
|
79
|
+
const refresh = () => {
|
|
80
|
+
inFlight = true;
|
|
81
|
+
askedAt = now();
|
|
82
|
+
void load(url, cached?.etag)
|
|
83
|
+
.then((answer) => {
|
|
84
|
+
if (304 === answer.status && cached !== undefined) {
|
|
85
|
+
cached = { ...cached, fetchedAt: now() };
|
|
86
|
+
writeCache(opts.cacheFile, cached);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (200 !== answer.status || answer.body === undefined)
|
|
90
|
+
return;
|
|
91
|
+
if (MAX_NOTICES_BODY_BYTES < Buffer.byteLength(answer.body))
|
|
92
|
+
return;
|
|
93
|
+
let file;
|
|
94
|
+
try {
|
|
95
|
+
file = JSON.parse(answer.body);
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
// An unreadable file keeps the last good notices rather than emptying the rail.
|
|
101
|
+
const parsed = parseHudNotices(file);
|
|
102
|
+
if (0 === parsed.length && 0 < entries.length)
|
|
103
|
+
return;
|
|
104
|
+
entries = parsed;
|
|
105
|
+
// The cache holds what VALIDATED, rebuilt from the schema's output, never the body itself:
|
|
106
|
+
// unknown fields and rejected entries in a network answer do not reach the disk.
|
|
107
|
+
cached = {
|
|
108
|
+
etag: answer.etag,
|
|
109
|
+
fetchedAt: now(),
|
|
110
|
+
file: { version: NOTICES_FILE_VERSION, notices: parsed },
|
|
111
|
+
};
|
|
112
|
+
writeCache(opts.cacheFile, cached);
|
|
113
|
+
})
|
|
114
|
+
.catch(() => undefined)
|
|
115
|
+
.finally(() => {
|
|
116
|
+
inFlight = false;
|
|
117
|
+
});
|
|
118
|
+
};
|
|
119
|
+
return {
|
|
120
|
+
read() {
|
|
121
|
+
const last = Math.max(cached?.fetchedAt ?? 0, askedAt ?? Number.NEGATIVE_INFINITY);
|
|
122
|
+
const stale = cached === undefined && askedAt === undefined ? true : FRESH_MS <= now() - last;
|
|
123
|
+
if (!inFlight && stale && !outboundOff(process.env))
|
|
124
|
+
refresh();
|
|
125
|
+
return entries;
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which project a shared-memory read is about, on the wire and in the response.
|
|
3
|
+
*
|
|
4
|
+
* `GET /v1/memory` used to be asked with the API key and `?subject`, and nothing else. The key covers
|
|
5
|
+
* a whole workspace, and a workspace can hold more than one repo: a `cloud.json` naming project B was
|
|
6
|
+
* read with a key that also reaches project A, so B's agent could be handed A's "established
|
|
7
|
+
* knowledge" and never know it. Two repos, one key, and the wrong one's facts arrive looking exactly
|
|
8
|
+
* like the right one's.
|
|
9
|
+
*
|
|
10
|
+
* The link file has always known the answer — `resolveProjectCloud` returns it as `projectId`, and
|
|
11
|
+
* every reader simply did not pass it on.
|
|
12
|
+
*
|
|
13
|
+
* ## Why the envelope, and not the entries
|
|
14
|
+
*
|
|
15
|
+
* Sending `projectId` is the fix, and it only works once the platform filters on it. Against a server
|
|
16
|
+
* that does not know the parameter yet, the client would go on being handed the whole workspace and
|
|
17
|
+
* would report it as this project's knowledge — the same lie, now with a parameter in the URL that
|
|
18
|
+
* makes it look handled. So the response is checked here too, and WHERE it is checked is the lesson:
|
|
19
|
+
* the platform puts the project id on the response ENVELOPE — `{ projectId, entries }` — and not on
|
|
20
|
+
* each entry. An entry-level filter therefore matched nothing, kept everything, and read as a fix
|
|
21
|
+
* while being none. `scopeOfMemoryResponse` reads the envelope and refuses the whole response when
|
|
22
|
+
* it names another project, or names none.
|
|
23
|
+
*
|
|
24
|
+
* The entry-level filter stays as a second pass. It is redundant against the platform's real shape,
|
|
25
|
+
* and it is the right answer against a server that labels entries instead — cheap to keep, and
|
|
26
|
+
* nothing here rests on it.
|
|
27
|
+
*
|
|
28
|
+
* ## Every reader, and that word is load-bearing
|
|
29
|
+
*
|
|
30
|
+
* Three callers read this endpoint: the `reticle_memory` tool, flow replay's automatic consultation,
|
|
31
|
+
* and `reticle memory` at a terminal. The first cut gave the first two an entry-level filter and the
|
|
32
|
+
* third the parameter alone, while a comment claimed all three were covered — and none of the three
|
|
33
|
+
* was, because the project id arrives on the envelope. They now share `scopedMemoryEntries`, so a
|
|
34
|
+
* fourth reader cannot get a different answer than the other three.
|
|
35
|
+
*
|
|
36
|
+
* Lives beside `cloud-sync.ts` rather than in `recall/` because that is where the other platform path
|
|
37
|
+
* constants live, and because both readers already reach this directory: a home under `recall/` would
|
|
38
|
+
* make `flows` reach a directory it never needed.
|
|
39
|
+
*/
|
|
40
|
+
/**
|
|
41
|
+
* The wire names, spelled once.
|
|
42
|
+
*
|
|
43
|
+
* `PROJECT_ID` is a request parameter AND a response field. They are the same word on purpose — the
|
|
44
|
+
* server echoes back what it was asked about — and one constant keeps a rename from moving only one
|
|
45
|
+
* of the two, which would silently restore the unfiltered read. `SUBJECT` is a request parameter only
|
|
46
|
+
* and is named for the same reason: it is a spelling the platform owns, not ours.
|
|
47
|
+
*/
|
|
48
|
+
export declare const MemoryScopeField: {
|
|
49
|
+
readonly PROJECT_ID: "projectId";
|
|
50
|
+
readonly SUBJECT: "subject";
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* What a read is asked about: the linked project, and optionally one subject within it.
|
|
54
|
+
*
|
|
55
|
+
* `projectId` takes `null` as well as `undefined`, because that is the shape `resolveProjectCloud`
|
|
56
|
+
* already answers with — "the link declared none" is one fact, and making every caller spell the
|
|
57
|
+
* conversion is how a reader starts wondering whether the two spellings mean different things.
|
|
58
|
+
*/
|
|
59
|
+
export interface MemoryReadScope {
|
|
60
|
+
/** The cloud project the link file names. Absent, `null` and empty all mean "no project to name". */
|
|
61
|
+
projectId: string | null | undefined;
|
|
62
|
+
subject?: string | undefined;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* The URL for one memory read.
|
|
66
|
+
*
|
|
67
|
+
* Encoded with `encodeURIComponent` rather than `URLSearchParams`, which spells a space `+`. Both are
|
|
68
|
+
* valid in a query string and the server may accept either — but this is a URL a test asserts against
|
|
69
|
+
* and a person reads in a log, and one spelling is easier to reason about than two.
|
|
70
|
+
*/
|
|
71
|
+
export declare function memoryReadUrl(baseUrl: string, scope: MemoryReadScope): string;
|
|
72
|
+
/**
|
|
73
|
+
* Drop the entries that state they belong to another project.
|
|
74
|
+
*
|
|
75
|
+
* The field is read defensively because the wire is somebody else's server: a number, an object or an
|
|
76
|
+
* absent key all mean "this entry does not say", and only a non-empty string that disagrees is enough
|
|
77
|
+
* to refuse one. See the note above for why silence is kept rather than dropped.
|
|
78
|
+
*/
|
|
79
|
+
export declare function keepOwnProject(entries: readonly unknown[], projectId: string | null | undefined): unknown[];
|
|
80
|
+
/**
|
|
81
|
+
* Whether a memory response can be read as THIS project's knowledge.
|
|
82
|
+
*
|
|
83
|
+
* The platform puts the project id on the response ENVELOPE, not on each entry (see the module note).
|
|
84
|
+
* Reading it there is the only check that works against the real shape: an entry-level filter sees no
|
|
85
|
+
* project field at all and keeps everything, so a server that ignores the request parameter — or
|
|
86
|
+
* resolves the key to a different project — hands back a whole workspace under this project's
|
|
87
|
+
* heading with nothing to distinguish it.
|
|
88
|
+
*/
|
|
89
|
+
export declare const MemoryResponseScope: {
|
|
90
|
+
/** The envelope names the project this read asked about. Its entries may be read. */
|
|
91
|
+
readonly OWN: "own";
|
|
92
|
+
/** The envelope names a DIFFERENT project. Nothing in it belongs to this one. */
|
|
93
|
+
readonly OTHER: "other";
|
|
94
|
+
/**
|
|
95
|
+
* The envelope does not say which project it is about — an older server, or a shape we do not
|
|
96
|
+
* know. Unverifiable, which is NOT the same as empty and must not be shown as though it were.
|
|
97
|
+
*/
|
|
98
|
+
readonly UNSCOPED: "unscoped";
|
|
99
|
+
/**
|
|
100
|
+
* This read named no project to begin with, so there is nothing for the envelope to contradict.
|
|
101
|
+
* The env-credential path with no `cloud.json`: CI, and a single-project setup that never linked.
|
|
102
|
+
*/
|
|
103
|
+
readonly NOT_ASKED: "not-asked";
|
|
104
|
+
};
|
|
105
|
+
export type MemoryResponseScope = (typeof MemoryResponseScope)[keyof typeof MemoryResponseScope];
|
|
106
|
+
/** The two verdicts that permit reading the entries. Named so no caller re-derives the pair. */
|
|
107
|
+
export declare const isReadableMemoryScope: (scope: MemoryResponseScope) => boolean;
|
|
108
|
+
/**
|
|
109
|
+
* What the envelope says about which project this response is about.
|
|
110
|
+
*
|
|
111
|
+
* A missing or non-string field is `UNSCOPED` rather than a mismatch: the wire is somebody else's
|
|
112
|
+
* server, and "this response never said" is a different fact from "this response named a rival".
|
|
113
|
+
* Both refuse; only the message a reader shows differs.
|
|
114
|
+
*/
|
|
115
|
+
export declare function scopeOfMemoryResponse(body: unknown, projectId: string | null | undefined): MemoryResponseScope;
|
|
116
|
+
/**
|
|
117
|
+
* The entries a reader may treat as this project's, or `undefined` when the response cannot be read
|
|
118
|
+
* that way at all.
|
|
119
|
+
*
|
|
120
|
+
* `undefined` is the point of the return type. Three callers need three different sentences and none
|
|
121
|
+
* of them is a list: the tool reports `UNVERIFIED`, the CLI refuses out loud, and replay attaches
|
|
122
|
+
* nothing. "This project knows nothing" and "the server answered about something else" are different
|
|
123
|
+
* facts, and only the first is safe to act on.
|
|
124
|
+
*/
|
|
125
|
+
export declare function scopedMemoryEntries(body: unknown, projectId: string | null | undefined): unknown[] | undefined;
|
|
126
|
+
/**
|
|
127
|
+
* The same filter, for a caller that prints the server's envelope rather than reading a list.
|
|
128
|
+
*
|
|
129
|
+
* `reticle memory` writes the response to stdout as JSON, so filtering has to preserve every other
|
|
130
|
+
* field the server sent and replace only `entries`. A body whose `entries` is not an array is handed
|
|
131
|
+
* back as it arrived: at that point the envelope has already been accepted, and guessing further is
|
|
132
|
+
* how a diagnostic command starts dropping the thing it was asked to print.
|
|
133
|
+
*
|
|
134
|
+
* It REFUSES on its own, answering `undefined` for a body it cannot show to be this project's. It
|
|
135
|
+
* used to hand the body back and leave the check to a JSDoc line telling callers to run
|
|
136
|
+
* `scopeOfMemoryResponse` first — which is the exact failure this module documents, written one
|
|
137
|
+
* function below the lesson. A reader that hands a body back cannot refuse it: the day somebody
|
|
138
|
+
* calls this first, a sibling project's knowledge goes to stdout with nothing to stop it. The
|
|
139
|
+
* `undefined` is what makes the unsafe call unexpressible rather than merely discouraged — a caller
|
|
140
|
+
* cannot print what it was not handed.
|
|
141
|
+
*
|
|
142
|
+
* The caller therefore checks for `undefined` and refuses out loud, instead of asking twice.
|
|
143
|
+
*/
|
|
144
|
+
export declare function scopeMemoryResponse(body: unknown, projectId: string | null | undefined): unknown;
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which project a shared-memory read is about, on the wire and in the response.
|
|
3
|
+
*
|
|
4
|
+
* `GET /v1/memory` used to be asked with the API key and `?subject`, and nothing else. The key covers
|
|
5
|
+
* a whole workspace, and a workspace can hold more than one repo: a `cloud.json` naming project B was
|
|
6
|
+
* read with a key that also reaches project A, so B's agent could be handed A's "established
|
|
7
|
+
* knowledge" and never know it. Two repos, one key, and the wrong one's facts arrive looking exactly
|
|
8
|
+
* like the right one's.
|
|
9
|
+
*
|
|
10
|
+
* The link file has always known the answer — `resolveProjectCloud` returns it as `projectId`, and
|
|
11
|
+
* every reader simply did not pass it on.
|
|
12
|
+
*
|
|
13
|
+
* ## Why the envelope, and not the entries
|
|
14
|
+
*
|
|
15
|
+
* Sending `projectId` is the fix, and it only works once the platform filters on it. Against a server
|
|
16
|
+
* that does not know the parameter yet, the client would go on being handed the whole workspace and
|
|
17
|
+
* would report it as this project's knowledge — the same lie, now with a parameter in the URL that
|
|
18
|
+
* makes it look handled. So the response is checked here too, and WHERE it is checked is the lesson:
|
|
19
|
+
* the platform puts the project id on the response ENVELOPE — `{ projectId, entries }` — and not on
|
|
20
|
+
* each entry. An entry-level filter therefore matched nothing, kept everything, and read as a fix
|
|
21
|
+
* while being none. `scopeOfMemoryResponse` reads the envelope and refuses the whole response when
|
|
22
|
+
* it names another project, or names none.
|
|
23
|
+
*
|
|
24
|
+
* The entry-level filter stays as a second pass. It is redundant against the platform's real shape,
|
|
25
|
+
* and it is the right answer against a server that labels entries instead — cheap to keep, and
|
|
26
|
+
* nothing here rests on it.
|
|
27
|
+
*
|
|
28
|
+
* ## Every reader, and that word is load-bearing
|
|
29
|
+
*
|
|
30
|
+
* Three callers read this endpoint: the `reticle_memory` tool, flow replay's automatic consultation,
|
|
31
|
+
* and `reticle memory` at a terminal. The first cut gave the first two an entry-level filter and the
|
|
32
|
+
* third the parameter alone, while a comment claimed all three were covered — and none of the three
|
|
33
|
+
* was, because the project id arrives on the envelope. They now share `scopedMemoryEntries`, so a
|
|
34
|
+
* fourth reader cannot get a different answer than the other three.
|
|
35
|
+
*
|
|
36
|
+
* Lives beside `cloud-sync.ts` rather than in `recall/` because that is where the other platform path
|
|
37
|
+
* constants live, and because both readers already reach this directory: a home under `recall/` would
|
|
38
|
+
* make `flows` reach a directory it never needed.
|
|
39
|
+
*/
|
|
40
|
+
/**
|
|
41
|
+
* The path this module builds, spelled once, for the same reason as the names below.
|
|
42
|
+
*
|
|
43
|
+
* Here rather than in `cloud-sync.ts`'s group of `CLOUD_*_PATH` constants: that group describes the
|
|
44
|
+
* calls THAT file makes, and cloud-sync has no memory reader at all. Adding this one there would make
|
|
45
|
+
* the group a registry of every endpoint in the package rather than a description of one file's calls.
|
|
46
|
+
*/
|
|
47
|
+
const CLOUD_MEMORY_PATH = '/v1/memory';
|
|
48
|
+
/**
|
|
49
|
+
* The wire names, spelled once.
|
|
50
|
+
*
|
|
51
|
+
* `PROJECT_ID` is a request parameter AND a response field. They are the same word on purpose — the
|
|
52
|
+
* server echoes back what it was asked about — and one constant keeps a rename from moving only one
|
|
53
|
+
* of the two, which would silently restore the unfiltered read. `SUBJECT` is a request parameter only
|
|
54
|
+
* and is named for the same reason: it is a spelling the platform owns, not ours.
|
|
55
|
+
*/
|
|
56
|
+
export const MemoryScopeField = {
|
|
57
|
+
PROJECT_ID: 'projectId',
|
|
58
|
+
SUBJECT: 'subject',
|
|
59
|
+
};
|
|
60
|
+
/**
|
|
61
|
+
* The project id to name, or `undefined` when there is none.
|
|
62
|
+
*
|
|
63
|
+
* `null`, `undefined` and `''` all say the same thing and all arrive in practice: `resolveProjectCloud`
|
|
64
|
+
* answers `null` when the link declares nothing, an absent argument is `undefined`, and a truncated
|
|
65
|
+
* config gives `''`. One helper, so the URL builder and the response filter cannot disagree about
|
|
66
|
+
* which of them counts as unscoped — a disagreement that would send `?projectId=` and come back
|
|
67
|
+
* empty, which reads as "this project knows nothing" rather than as a bug.
|
|
68
|
+
*
|
|
69
|
+
* Returns the id rather than a boolean so callers get the narrowing with it. A `boolean` predicate
|
|
70
|
+
* would leave `string | null | undefined` as-is and force a `String(...)` at the call site, which is
|
|
71
|
+
* a cast dressed up as a conversion.
|
|
72
|
+
*/
|
|
73
|
+
const projectToName = (projectId) => 'string' === typeof projectId && projectId.length > 0 ? projectId : undefined;
|
|
74
|
+
/**
|
|
75
|
+
* The URL for one memory read.
|
|
76
|
+
*
|
|
77
|
+
* Encoded with `encodeURIComponent` rather than `URLSearchParams`, which spells a space `+`. Both are
|
|
78
|
+
* valid in a query string and the server may accept either — but this is a URL a test asserts against
|
|
79
|
+
* and a person reads in a log, and one spelling is easier to reason about than two.
|
|
80
|
+
*/
|
|
81
|
+
export function memoryReadUrl(baseUrl, scope) {
|
|
82
|
+
const parts = [];
|
|
83
|
+
if (scope.subject !== undefined) {
|
|
84
|
+
parts.push(`${MemoryScopeField.SUBJECT}=${encodeURIComponent(scope.subject)}`);
|
|
85
|
+
}
|
|
86
|
+
const projectId = projectToName(scope.projectId);
|
|
87
|
+
if (projectId !== undefined) {
|
|
88
|
+
parts.push(`${MemoryScopeField.PROJECT_ID}=${encodeURIComponent(projectId)}`);
|
|
89
|
+
}
|
|
90
|
+
const url = `${baseUrl}${CLOUD_MEMORY_PATH}`;
|
|
91
|
+
return 0 === parts.length ? url : `${url}?${parts.join('&')}`;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Drop the entries that state they belong to another project.
|
|
95
|
+
*
|
|
96
|
+
* The field is read defensively because the wire is somebody else's server: a number, an object or an
|
|
97
|
+
* absent key all mean "this entry does not say", and only a non-empty string that disagrees is enough
|
|
98
|
+
* to refuse one. See the note above for why silence is kept rather than dropped.
|
|
99
|
+
*/
|
|
100
|
+
export function keepOwnProject(entries, projectId) {
|
|
101
|
+
const mine = projectToName(projectId);
|
|
102
|
+
if (mine === undefined)
|
|
103
|
+
return [...entries];
|
|
104
|
+
return entries.filter((entry) => {
|
|
105
|
+
if (null === entry || 'object' !== typeof entry)
|
|
106
|
+
return true;
|
|
107
|
+
const stated = entry[MemoryScopeField.PROJECT_ID];
|
|
108
|
+
if ('string' !== typeof stated || 0 === stated.length)
|
|
109
|
+
return true;
|
|
110
|
+
return stated === mine;
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Whether a memory response can be read as THIS project's knowledge.
|
|
115
|
+
*
|
|
116
|
+
* The platform puts the project id on the response ENVELOPE, not on each entry (see the module note).
|
|
117
|
+
* Reading it there is the only check that works against the real shape: an entry-level filter sees no
|
|
118
|
+
* project field at all and keeps everything, so a server that ignores the request parameter — or
|
|
119
|
+
* resolves the key to a different project — hands back a whole workspace under this project's
|
|
120
|
+
* heading with nothing to distinguish it.
|
|
121
|
+
*/
|
|
122
|
+
export const MemoryResponseScope = {
|
|
123
|
+
/** The envelope names the project this read asked about. Its entries may be read. */
|
|
124
|
+
OWN: 'own',
|
|
125
|
+
/** The envelope names a DIFFERENT project. Nothing in it belongs to this one. */
|
|
126
|
+
OTHER: 'other',
|
|
127
|
+
/**
|
|
128
|
+
* The envelope does not say which project it is about — an older server, or a shape we do not
|
|
129
|
+
* know. Unverifiable, which is NOT the same as empty and must not be shown as though it were.
|
|
130
|
+
*/
|
|
131
|
+
UNSCOPED: 'unscoped',
|
|
132
|
+
/**
|
|
133
|
+
* This read named no project to begin with, so there is nothing for the envelope to contradict.
|
|
134
|
+
* The env-credential path with no `cloud.json`: CI, and a single-project setup that never linked.
|
|
135
|
+
*/
|
|
136
|
+
NOT_ASKED: 'not-asked',
|
|
137
|
+
};
|
|
138
|
+
/** The two verdicts that permit reading the entries. Named so no caller re-derives the pair. */
|
|
139
|
+
export const isReadableMemoryScope = (scope) => MemoryResponseScope.OWN === scope || MemoryResponseScope.NOT_ASKED === scope;
|
|
140
|
+
/**
|
|
141
|
+
* What the envelope says about which project this response is about.
|
|
142
|
+
*
|
|
143
|
+
* A missing or non-string field is `UNSCOPED` rather than a mismatch: the wire is somebody else's
|
|
144
|
+
* server, and "this response never said" is a different fact from "this response named a rival".
|
|
145
|
+
* Both refuse; only the message a reader shows differs.
|
|
146
|
+
*/
|
|
147
|
+
export function scopeOfMemoryResponse(body, projectId) {
|
|
148
|
+
const mine = projectToName(projectId);
|
|
149
|
+
if (mine === undefined)
|
|
150
|
+
return MemoryResponseScope.NOT_ASKED;
|
|
151
|
+
if (null === body || 'object' !== typeof body)
|
|
152
|
+
return MemoryResponseScope.UNSCOPED;
|
|
153
|
+
const stated = body[MemoryScopeField.PROJECT_ID];
|
|
154
|
+
if ('string' !== typeof stated || 0 === stated.length)
|
|
155
|
+
return MemoryResponseScope.UNSCOPED;
|
|
156
|
+
return stated === mine ? MemoryResponseScope.OWN : MemoryResponseScope.OTHER;
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* The entries a reader may treat as this project's, or `undefined` when the response cannot be read
|
|
160
|
+
* that way at all.
|
|
161
|
+
*
|
|
162
|
+
* `undefined` is the point of the return type. Three callers need three different sentences and none
|
|
163
|
+
* of them is a list: the tool reports `UNVERIFIED`, the CLI refuses out loud, and replay attaches
|
|
164
|
+
* nothing. "This project knows nothing" and "the server answered about something else" are different
|
|
165
|
+
* facts, and only the first is safe to act on.
|
|
166
|
+
*/
|
|
167
|
+
export function scopedMemoryEntries(body, projectId) {
|
|
168
|
+
if (!isReadableMemoryScope(scopeOfMemoryResponse(body, projectId)))
|
|
169
|
+
return undefined;
|
|
170
|
+
if (null === body || 'object' !== typeof body)
|
|
171
|
+
return undefined;
|
|
172
|
+
const entries = body['entries'];
|
|
173
|
+
if (!Array.isArray(entries))
|
|
174
|
+
return undefined;
|
|
175
|
+
return keepOwnProject(entries, projectId);
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* The same filter, for a caller that prints the server's envelope rather than reading a list.
|
|
179
|
+
*
|
|
180
|
+
* `reticle memory` writes the response to stdout as JSON, so filtering has to preserve every other
|
|
181
|
+
* field the server sent and replace only `entries`. A body whose `entries` is not an array is handed
|
|
182
|
+
* back as it arrived: at that point the envelope has already been accepted, and guessing further is
|
|
183
|
+
* how a diagnostic command starts dropping the thing it was asked to print.
|
|
184
|
+
*
|
|
185
|
+
* It REFUSES on its own, answering `undefined` for a body it cannot show to be this project's. It
|
|
186
|
+
* used to hand the body back and leave the check to a JSDoc line telling callers to run
|
|
187
|
+
* `scopeOfMemoryResponse` first — which is the exact failure this module documents, written one
|
|
188
|
+
* function below the lesson. A reader that hands a body back cannot refuse it: the day somebody
|
|
189
|
+
* calls this first, a sibling project's knowledge goes to stdout with nothing to stop it. The
|
|
190
|
+
* `undefined` is what makes the unsafe call unexpressible rather than merely discouraged — a caller
|
|
191
|
+
* cannot print what it was not handed.
|
|
192
|
+
*
|
|
193
|
+
* The caller therefore checks for `undefined` and refuses out loud, instead of asking twice.
|
|
194
|
+
*/
|
|
195
|
+
export function scopeMemoryResponse(body, projectId) {
|
|
196
|
+
if (!isReadableMemoryScope(scopeOfMemoryResponse(body, projectId)))
|
|
197
|
+
return undefined;
|
|
198
|
+
if (null === body || 'object' !== typeof body)
|
|
199
|
+
return undefined;
|
|
200
|
+
const envelope = body;
|
|
201
|
+
const entries = envelope['entries'];
|
|
202
|
+
if (!Array.isArray(entries))
|
|
203
|
+
return body;
|
|
204
|
+
return { ...envelope, entries: keepOwnProject(entries, projectId) };
|
|
205
|
+
}
|