@reticlehq/server 2.12.0 → 2.13.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/SKILL.md +67 -109
- package/dist/bridge/auth-failure-reason.d.ts +6 -2
- package/dist/bridge/auth-failure-reason.js +15 -8
- package/dist/bridge/bridge.d.ts +18 -1
- package/dist/bridge/bridge.js +247 -164
- package/dist/bridge/pairing-token.d.ts +5 -0
- package/dist/bridge/pairing-token.js +28 -0
- package/dist/bridge/protocol-skew.d.ts +37 -0
- package/dist/bridge/protocol-skew.js +45 -0
- package/dist/capsule/capsule-store.d.ts +6 -6
- package/dist/capsule/causal-summary.js +7 -2
- package/dist/cli/cli-doctor.js +31 -1
- package/dist/cli/cli-launch.d.ts +11 -1
- package/dist/cli/cli-launch.js +59 -10
- package/dist/cli/cli-parse.d.ts +15 -1
- package/dist/cli/cli-parse.js +212 -8
- package/dist/cli/cli-verify.d.ts +1 -0
- package/dist/cli/cli-verify.js +3 -2
- package/dist/cli/cloud-cli.js +428 -259
- package/dist/cli/cloud-keystore.d.ts +37 -0
- package/dist/cli/cloud-keystore.js +97 -0
- package/dist/cli/cloud-kit.d.ts +37 -0
- package/dist/cli/cloud-kit.js +121 -0
- package/dist/cli/cloud-login.d.ts +17 -0
- package/dist/cli/cloud-login.js +183 -0
- package/dist/cli/cloud-session.d.ts +48 -0
- package/dist/cli/cloud-session.js +53 -0
- package/dist/cli/config-discovery.js +1 -0
- package/dist/cli/daemon-start-options.d.ts +32 -0
- package/dist/cli/daemon-start-options.js +38 -0
- package/dist/cli/device-flow.d.ts +76 -0
- package/dist/cli/device-flow.js +56 -0
- package/dist/cli/doctor-rows.d.ts +9 -0
- package/dist/cli/doctor-rows.js +9 -0
- package/dist/cli/drive-attach.d.ts +13 -0
- package/dist/cli/drive-attach.js +18 -0
- package/dist/cli/drive-command.js +7 -3
- package/dist/cli/mcp-command.js +51 -20
- package/dist/cli/project-name.d.ts +30 -0
- package/dist/cli/project-name.js +44 -0
- package/dist/cli/status-next-action.d.ts +14 -0
- package/dist/cli/status-next-action.js +4 -4
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +134 -80
- package/dist/cloud/cloud-config.d.ts +22 -1
- package/dist/cloud/cloud-config.js +64 -5
- package/dist/cloud/cloud-sync.d.ts +5 -5
- package/dist/cloud/cloud-sync.js +33 -2
- package/dist/cloud/sync-cycle.d.ts +110 -0
- package/dist/cloud/sync-cycle.js +270 -0
- package/dist/cloud/sync-daemon.d.ts +69 -0
- package/dist/cloud/sync-daemon.js +260 -0
- package/dist/cloud/sync-disk.d.ts +9 -0
- package/dist/cloud/sync-disk.js +191 -0
- package/dist/cloud/sync-hash.d.ts +2 -0
- package/dist/cloud/sync-hash.js +35 -0
- package/dist/crawl/crawl-tools.js +5 -3
- package/dist/crawl/crawl.d.ts +3 -0
- package/dist/crawl/crawl.js +5 -0
- package/dist/daemon/daemon-resolve.d.ts +43 -0
- package/dist/daemon/daemon-resolve.js +69 -0
- package/dist/daemon/dev-servers.d.ts +15 -0
- package/dist/daemon/dev-servers.js +68 -0
- package/dist/daemon/wake-decision.d.ts +32 -0
- package/dist/daemon/wake-decision.js +38 -0
- package/dist/domain/domain-tools.js +2 -2
- package/dist/events/contradictions.d.ts +18 -0
- package/dist/events/contradictions.js +134 -21
- package/dist/events/declared.d.ts +7 -4
- package/dist/events/declared.js +15 -6
- package/dist/events/echo-mismatch.js +64 -14
- package/dist/events/event-filters.d.ts +8 -0
- package/dist/events/event-filters.js +19 -2
- package/dist/events/predicate-eval.js +53 -5
- package/dist/events/predicate-precheck.d.ts +8 -0
- package/dist/events/predicate-precheck.js +41 -0
- package/dist/events/predicate-route.d.ts +14 -0
- package/dist/events/predicate-route.js +36 -8
- package/dist/events/predicate.d.ts +5 -0
- package/dist/events/predicate.js +37 -5
- package/dist/events/split-text-miss.d.ts +30 -0
- package/dist/events/split-text-miss.js +44 -0
- package/dist/flows/flake.d.ts +6 -6
- package/dist/flows/flow-memory-consult.d.ts +55 -0
- package/dist/flows/flow-memory-consult.js +38 -0
- package/dist/flows/flow-replay-run.d.ts +18 -7
- package/dist/flows/flow-replay-run.js +239 -22
- package/dist/flows/flow-replay.js +11 -2
- package/dist/flows/flow-step-runners.d.ts +8 -0
- package/dist/flows/flow-step-runners.js +8 -2
- package/dist/flows/flow-store-for-session.d.ts +31 -0
- package/dist/flows/flow-store-for-session.js +33 -0
- package/dist/flows/flow-tools.js +58 -32
- package/dist/flows/flows.d.ts +8 -0
- package/dist/flows/flows.js +468 -2
- package/dist/flows/heal-run.js +5 -2
- package/dist/flows/predicate-to-expect.d.ts +17 -12
- package/dist/flows/predicate-to-expect.js +30 -12
- package/dist/flows/recordings.d.ts +13 -1
- package/dist/flows/recordings.js +17 -2
- package/dist/flows/replay.d.ts +22 -2
- package/dist/flows/replay.js +28 -2
- package/dist/flows/verify-change-tools.js +5 -2
- package/dist/honesty/blind-spots.d.ts +55 -2
- package/dist/honesty/blind-spots.js +63 -3
- package/dist/honesty/honesty.d.ts +24 -0
- package/dist/honesty/honesty.js +25 -1
- package/dist/honesty/instrumentation-gaps.d.ts +9 -0
- package/dist/honesty/instrumentation-gaps.js +29 -2
- package/dist/honesty/verified.d.ts +2 -1
- package/dist/honesty/verified.js +16 -1
- package/dist/http-server.js +12 -0
- package/dist/impact/impact-recorder.d.ts +31 -8
- package/dist/impact/impact-recorder.js +104 -21
- package/dist/impact/impact-store.d.ts +15 -1
- package/dist/impact/impact-store.js +43 -2
- package/dist/index.js +113 -7
- package/dist/init/agent-rules.d.ts +13 -6
- package/dist/init/agent-rules.js +23 -12
- package/dist/init/astro-patch.d.ts +16 -0
- package/dist/init/astro-patch.js +28 -0
- package/dist/init/capabilities.d.ts +4 -1
- package/dist/init/capabilities.js +22 -2
- package/dist/init/confirm.d.ts +31 -7
- package/dist/init/confirm.js +48 -8
- package/dist/init/cra.d.ts +20 -4
- package/dist/init/cra.js +57 -17
- package/dist/init/csp-check.js +17 -2
- package/dist/init/csp-doctor.d.ts +8 -1
- package/dist/init/csp-doctor.js +8 -3
- package/dist/init/dev-script.d.ts +3 -1
- package/dist/init/dev-script.js +8 -5
- package/dist/init/format-generated.d.ts +18 -0
- package/dist/init/format-generated.js +39 -0
- package/dist/init/install-hint.d.ts +9 -0
- package/dist/init/install-hint.js +29 -0
- package/dist/init/mcp-clients.d.ts +7 -0
- package/dist/init/mcp-clients.js +7 -0
- package/dist/init/mcp.d.ts +1 -1
- package/dist/init/mcp.js +1 -1
- package/dist/init/node-io.js +11 -1
- package/dist/init/plan-framework.js +81 -45
- package/dist/init/plan.d.ts +20 -7
- package/dist/init/plan.js +20 -45
- package/dist/init/preflight.d.ts +28 -0
- package/dist/init/preflight.js +38 -0
- package/dist/init/run.d.ts +32 -9
- package/dist/init/run.js +141 -119
- package/dist/init/slash-command.d.ts +1 -1
- package/dist/init/snippets.js +41 -25
- package/dist/init/vite-config.d.ts +1 -1
- package/dist/init/vite-config.js +39 -25
- package/dist/init/workspace-apps.d.ts +17 -10
- package/dist/init/workspace-apps.js +110 -0
- package/dist/input/network-mock-tools.js +3 -6
- package/dist/input/real-input.js +25 -5
- package/dist/input/scroll-tools.js +4 -6
- package/dist/input/viewport-tools.js +5 -4
- package/dist/intent/inline-intent.d.ts +10 -19
- package/dist/intent/inline-intent.js +91 -1
- package/dist/intent/intent-shard-store.d.ts +72 -0
- package/dist/intent/intent-shard-store.js +203 -0
- package/dist/intent/intent-shard.d.ts +411 -0
- package/dist/intent/intent-shard.js +176 -0
- package/dist/intent/intent-store.d.ts +12 -0
- package/dist/intent/intent-store.js +21 -0
- package/dist/intent/intent-subject.d.ts +45 -0
- package/dist/intent/intent-subject.js +177 -0
- package/dist/intent/intent-tools.js +65 -2
- package/dist/intent/open-intents.d.ts +21 -0
- package/dist/intent/open-intents.js +15 -0
- package/dist/license/license-env.d.ts +29 -0
- package/dist/license/license-env.js +148 -0
- package/dist/loopback-agent.d.ts +31 -0
- package/dist/loopback-agent.js +57 -0
- package/dist/mcp/attach-memory.d.ts +18 -2
- package/dist/mcp/attach-memory.js +50 -7
- package/dist/mcp/client-identity.d.ts +31 -0
- package/dist/mcp/client-identity.js +51 -0
- package/dist/mcp/mcp-post-transport.d.ts +26 -0
- package/dist/mcp/mcp-post-transport.js +109 -0
- package/dist/mcp/mcp-proxy.d.ts +2 -1
- package/dist/mcp/mcp-proxy.js +52 -62
- package/dist/mcp/mcp.js +7 -1
- package/dist/mcp/proxy-backoff.d.ts +11 -0
- package/dist/mcp/proxy-backoff.js +13 -0
- package/dist/mcp/proxy-daemon-probe.d.ts +1 -0
- package/dist/mcp/proxy-daemon-probe.js +7 -1
- package/dist/mcp/proxy-handshake.d.ts +23 -0
- package/dist/mcp/proxy-handshake.js +31 -0
- package/dist/mcp/tool-catalog-cache.d.ts +8 -0
- package/dist/mcp/tool-catalog-cache.js +12 -0
- package/dist/memory/memory-tools.d.ts +2 -0
- package/dist/memory/memory-tools.js +82 -0
- package/dist/memory/project-memory.d.ts +34 -0
- package/dist/memory/project-memory.js +96 -0
- package/dist/oracles/propose-consequences.js +12 -3
- package/dist/pool/browser-pool.d.ts +63 -4
- package/dist/pool/browser-pool.js +113 -4
- package/dist/pool/playwright-launcher.js +9 -0
- package/dist/project/project-tools.js +3 -3
- package/dist/project/reticle-dir.d.ts +15 -2
- package/dist/project/reticle-dir.js +21 -4
- package/dist/runs/run-store.d.ts +10 -0
- package/dist/runs/run-store.js +10 -0
- package/dist/runs/verification-sync.d.ts +0 -11
- package/dist/runs/verification-sync.js +22 -1
- package/dist/runs/verify-server.d.ts +8 -1
- package/dist/runs/verify-server.js +14 -2
- package/dist/session/command-timeout.d.ts +5 -2
- package/dist/session/command-timeout.js +5 -4
- package/dist/session/lease-visibility.d.ts +67 -0
- package/dist/session/lease-visibility.js +76 -0
- package/dist/session/no-session-diagnosis.d.ts +23 -0
- package/dist/session/no-session-diagnosis.js +58 -17
- package/dist/session/no-session-next-action.d.ts +7 -0
- package/dist/session/no-session-next-action.js +42 -3
- package/dist/session/no-session-watch.d.ts +10 -6
- package/dist/session/no-session-watch.js +51 -19
- package/dist/session/pending-commands.d.ts +11 -1
- package/dist/session/pending-commands.js +12 -2
- package/dist/session/resolve-within.d.ts +34 -0
- package/dist/session/resolve-within.js +53 -0
- package/dist/session/session-info.d.ts +18 -0
- package/dist/session/session-info.js +11 -0
- package/dist/session/session-manager.d.ts +14 -0
- package/dist/session/session-manager.js +61 -3
- package/dist/session/session-mirror.d.ts +16 -0
- package/dist/session/session-mirror.js +22 -0
- package/dist/session/session-recommendation.d.ts +19 -5
- package/dist/session/session-recommendation.js +25 -9
- package/dist/session/session-successor.js +36 -1
- package/dist/session/session-tools.js +2 -2
- package/dist/session/session.d.ts +22 -2
- package/dist/session/session.js +87 -7
- package/dist/session/wall-clock.d.ts +12 -0
- package/dist/session/wall-clock.js +14 -0
- package/dist/setup/agent-configs.d.ts +96 -0
- package/dist/setup/agent-configs.js +333 -0
- package/dist/setup/agent-io.d.ts +9 -0
- package/dist/setup/agent-io.js +16 -0
- package/dist/setup/agent-writer.d.ts +51 -0
- package/dist/setup/agent-writer.js +115 -0
- package/dist/setup/approval-migration.d.ts +47 -0
- package/dist/setup/approval-migration.js +68 -0
- package/dist/setup/auto-approve.d.ts +85 -0
- package/dist/setup/auto-approve.js +150 -0
- package/dist/setup/bridge-port.d.ts +15 -0
- package/dist/setup/bridge-port.js +24 -0
- package/dist/setup/desktop-shape.d.ts +57 -0
- package/dist/setup/desktop-shape.js +63 -0
- package/dist/setup/dev-server-wait.d.ts +68 -0
- package/dist/setup/dev-server-wait.js +67 -0
- package/dist/setup/drive-agent.d.ts +71 -0
- package/dist/setup/drive-agent.js +171 -0
- package/dist/setup/drive-plan.d.ts +68 -0
- package/dist/setup/drive-plan.js +130 -0
- package/dist/setup/ensure-daemon.d.ts +29 -0
- package/dist/setup/ensure-daemon.js +58 -0
- package/dist/setup/init-runtime.d.ts +47 -0
- package/dist/setup/init-runtime.js +160 -0
- package/dist/setup/interrupt.d.ts +25 -0
- package/dist/setup/interrupt.js +40 -0
- package/dist/setup/license-key.d.ts +39 -0
- package/dist/setup/license-key.js +72 -0
- package/dist/setup/listeners.d.ts +37 -0
- package/dist/setup/listeners.js +85 -0
- package/dist/setup/node-effects.d.ts +60 -0
- package/dist/setup/node-effects.js +210 -0
- package/dist/setup/page-probe.d.ts +39 -0
- package/dist/setup/page-probe.js +49 -0
- package/dist/setup/relaunch.d.ts +36 -0
- package/dist/setup/relaunch.js +57 -0
- package/dist/setup/remaining-steps.d.ts +19 -0
- package/dist/setup/remaining-steps.js +31 -0
- package/dist/setup/run-setup.d.ts +99 -0
- package/dist/setup/run-setup.js +203 -0
- package/dist/setup/session-pick.d.ts +47 -0
- package/dist/setup/session-pick.js +51 -0
- package/dist/setup/setup-command.d.ts +45 -0
- package/dist/setup/setup-command.js +199 -0
- package/dist/setup/setup-options.d.ts +74 -0
- package/dist/setup/setup-options.js +67 -0
- package/dist/setup/transcripts.d.ts +16 -0
- package/dist/setup/transcripts.js +43 -0
- package/dist/status-payload.d.ts +14 -1
- package/dist/status-payload.js +23 -1
- package/dist/telemetry/daemon-telemetry.d.ts +8 -1
- package/dist/telemetry/daemon-telemetry.js +52 -5
- package/dist/telemetry/error-class.js +1 -1
- package/dist/telemetry/feedback-context.d.ts +9 -1
- package/dist/telemetry/feedback-context.js +60 -29
- package/dist/telemetry/feedback-local.d.ts +15 -0
- package/dist/telemetry/feedback-local.js +56 -0
- package/dist/telemetry/feedback.d.ts +11 -0
- package/dist/telemetry/feedback.js +19 -6
- package/dist/telemetry/init-telemetry.d.ts +2 -0
- package/dist/telemetry/init-telemetry.js +2 -0
- package/dist/telemetry/install-source.d.ts +14 -0
- package/dist/telemetry/install-source.js +30 -0
- package/dist/telemetry/license-activation.d.ts +7 -1
- package/dist/telemetry/license-activation.js +9 -5
- package/dist/telemetry/mcp-connection.js +7 -0
- package/dist/telemetry/project-profile.d.ts +12 -1
- package/dist/telemetry/project-profile.js +11 -6
- package/dist/telemetry/proxy-telemetry.d.ts +3 -0
- package/dist/telemetry/proxy-telemetry.js +21 -0
- package/dist/telemetry/session-metrics.d.ts +12 -0
- package/dist/telemetry/session-metrics.js +35 -1
- package/dist/telemetry/telemetry.js +17 -2
- package/dist/telemetry/tool-refused.d.ts +3 -1
- package/dist/telemetry/tool-refused.js +34 -2
- package/dist/tools/act-capsule.d.ts +10 -0
- package/dist/tools/act-capsule.js +8 -2
- package/dist/tools/act-danger.d.ts +2 -1
- package/dist/tools/act-danger.js +11 -3
- package/dist/tools/act-observation.d.ts +41 -0
- package/dist/tools/act-observation.js +36 -0
- package/dist/tools/act-preflight.d.ts +20 -0
- package/dist/tools/act-preflight.js +98 -0
- package/dist/tools/act-sequence-retry.d.ts +45 -0
- package/dist/tools/act-sequence-retry.js +58 -0
- package/dist/tools/act-sequence-tool.d.ts +13 -0
- package/dist/tools/act-sequence-tool.js +135 -0
- package/dist/tools/act-target.d.ts +14 -0
- package/dist/tools/act-target.js +47 -0
- package/dist/tools/act-tools.d.ts +21 -1
- package/dist/tools/act-tools.js +100 -213
- package/dist/tools/assert-verdict.js +22 -16
- package/dist/tools/browser-tools.js +6 -2
- package/dist/tools/dynamic-tools.js +24 -5
- package/dist/tools/error-recovery.d.ts +1 -0
- package/dist/tools/error-recovery.js +8 -0
- package/dist/tools/invoke-tool.js +59 -9
- package/dist/tools/lease-tools.d.ts +30 -4
- package/dist/tools/lease-tools.js +169 -5
- package/dist/tools/merged-name-redirect.d.ts +13 -0
- package/dist/tools/merged-name-redirect.js +22 -0
- package/dist/tools/numeric-bounds.d.ts +100 -0
- package/dist/tools/numeric-bounds.js +125 -0
- package/dist/tools/observe-tools.js +47 -40
- package/dist/tools/read-tools.js +28 -7
- package/dist/tools/real-input-attempt.d.ts +10 -0
- package/dist/tools/real-input-attempt.js +59 -1
- package/dist/tools/reconcile-tools.js +4 -4
- package/dist/tools/tool-kit.d.ts +12 -0
- package/dist/tools/tool-kit.js +6 -1
- package/dist/tools/tool-names.d.ts +2 -0
- package/dist/tools/tool-names.js +2 -0
- package/dist/tools/tools-helpers.d.ts +19 -0
- package/dist/tools/tools-helpers.js +46 -0
- package/dist/tools/tools.d.ts +2 -0
- package/dist/tools/tools.js +56 -8
- package/dist/update/reticle-deps.d.ts +9 -0
- package/dist/update/reticle-deps.js +27 -0
- package/dist/update/sdk-sync.d.ts +1 -2
- package/dist/update/sdk-sync.js +1 -21
- package/dist/update/update-nudge.d.ts +18 -0
- package/dist/update/update-nudge.js +20 -0
- package/dist/version/peer-announce.js +4 -2
- package/dist/version/sdk-fix.d.ts +47 -0
- package/dist/version/sdk-fix.js +127 -0
- package/dist/version/version-nudge.d.ts +14 -0
- package/dist/version/version-nudge.js +21 -0
- package/dist/version/version-skew.d.ts +38 -3
- package/dist/version/version-skew.js +83 -7
- package/dist/visual/visual-store.d.ts +6 -6
- package/dist/visual/visual-store.js +18 -16
- package/dist/visual/visual-tools.js +57 -12
- package/docs/cli/doctor.mdx +1 -0
- package/docs/cli/serve.mdx +4 -2
- package/docs/cli.mdx +7 -3
- package/docs/desktop.mdx +1 -1
- package/docs/docs.json +1 -1
- package/docs/getting-started.md +19 -3
- package/docs/index.mdx +9 -6
- package/docs/install-agentic.mdx +38 -12
- package/docs/install-manual.mdx +1 -1
- package/docs/packages.mdx +1 -1
- package/docs/skill-file.mdx +3 -3
- package/docs/telemetry-contract.md +108 -4
- package/docs/tools-act-sequence.mdx +16 -0
- package/docs/usage.md +1 -1
- package/package.json +4 -3
- package/dist/init/cursor.d.ts +0 -24
- package/dist/init/cursor.js +0 -80
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One full conversation with the dashboard: ask, send the difference, collect what came back.
|
|
3
|
+
*
|
|
4
|
+
* ── WHY IT LOOKS LIKE THIS ─────────────────────────────────────────────────────────────────────
|
|
5
|
+
* `.reticle/` is the source of truth and always will be. Reticle has to work with no account and no
|
|
6
|
+
* network; a verification that blocks on an HTTP round trip is one somebody switches off. So the
|
|
7
|
+
* dashboard is a REPLICA, and this is the replication protocol:
|
|
8
|
+
*
|
|
9
|
+
* 1. ASK GET /v1/sync/status → run ids it already holds, a content hash per derived record
|
|
10
|
+
* 2. SEND POST /v1/sync → only unseen runs, only records whose hash moved
|
|
11
|
+
* 3. COLLECT GET /v1/sync/pull → decisions a human made there, since our cursor
|
|
12
|
+
* 4. APPLY write them to .reticle/issues.json so the HUD and the next run can see them
|
|
13
|
+
*
|
|
14
|
+
* ASK-THEN-SEND, not send-everything. A three-hour session rewrites impact.json on every tool call;
|
|
15
|
+
* uploading it each time would re-send the same megabytes dozens of times, and a sync that costs
|
|
16
|
+
* that much is one people turn off — which is the real failure, because a dashboard nobody syncs to
|
|
17
|
+
* is a dashboard nobody opens. When nothing has moved, a cycle sends nothing at all, and that is
|
|
18
|
+
* what makes running it on a timer affordable.
|
|
19
|
+
*
|
|
20
|
+
* HASHES, NOT TIMESTAMPS. Two machines comparing their own clocks is a guess: they drift, and a
|
|
21
|
+
* laptop that was asleep will cheerfully decide it is current. A content hash is an answer.
|
|
22
|
+
*
|
|
23
|
+
* BOTH DIRECTIONS, ALWAYS. The pull runs even when there is nothing to push — a quiet machine is
|
|
24
|
+
* precisely the one whose dashboard somebody has been triaging on, so skipping the pull when the
|
|
25
|
+
* push is empty would starve the direction that matters most.
|
|
26
|
+
*
|
|
27
|
+
* NOTHING IS DELETED LOCALLY ON SUCCESS. A lost response costs one redundant upload rather than a
|
|
28
|
+
* hole in the record, and the server dedupes by run id anyway.
|
|
29
|
+
*
|
|
30
|
+
* Every dependency is injected — the clock, the fetch, the reads and writes — because this is the
|
|
31
|
+
* one piece of Reticle that talks to a network AND to a disk, and it must be provable without either.
|
|
32
|
+
*/
|
|
33
|
+
import { ReticleDir } from '@reticlehq/core';
|
|
34
|
+
import { hashPayload } from './sync-hash.js';
|
|
35
|
+
/** The server's own doors. Kept beside the code that calls them, like the other cloud paths. */
|
|
36
|
+
const SYNC_PATH = '/v1/sync';
|
|
37
|
+
const SYNC_STATUS_PATH = '/v1/sync/status';
|
|
38
|
+
const SYNC_PULL_PATH = '/v1/sync/pull';
|
|
39
|
+
/**
|
|
40
|
+
* The derived records that ride along with runs, and the file each one lives in.
|
|
41
|
+
*
|
|
42
|
+
* A list rather than three hand-written blocks so adding a fourth is one line and cannot be
|
|
43
|
+
* half-done — the bundle, the hashing and the reporting all walk this.
|
|
44
|
+
*/
|
|
45
|
+
export const DERIVED_RECORDS = [
|
|
46
|
+
{ kind: 'impact', file: ReticleDir.IMPACT_FILE },
|
|
47
|
+
{ kind: 'flake', file: ReticleDir.FLAKE_FILE },
|
|
48
|
+
{ kind: 'intent', file: ReticleDir.INTENT_FILE },
|
|
49
|
+
];
|
|
50
|
+
const asJson = (text) => {
|
|
51
|
+
try {
|
|
52
|
+
return JSON.parse(text);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
const isRecord = (v) => 'object' === typeof v && null !== v;
|
|
59
|
+
/**
|
|
60
|
+
* Run one cycle. Never throws: the local record is already safe on disk, and a sync that can take
|
|
61
|
+
* down the thing it is backing up is worse than no sync.
|
|
62
|
+
*/
|
|
63
|
+
export async function runSyncCycle(deps) {
|
|
64
|
+
const empty = {
|
|
65
|
+
ok: false,
|
|
66
|
+
runsSent: 0,
|
|
67
|
+
runsRejected: [],
|
|
68
|
+
flowsSent: 0,
|
|
69
|
+
derivedSent: [],
|
|
70
|
+
pulled: 0,
|
|
71
|
+
morePending: false,
|
|
72
|
+
};
|
|
73
|
+
const call = async (path, init) => {
|
|
74
|
+
const res = await deps.request(`${deps.config.url}${path}`, {
|
|
75
|
+
method: init?.method ?? 'GET',
|
|
76
|
+
headers: {
|
|
77
|
+
'content-type': 'application/json',
|
|
78
|
+
authorization: `Bearer ${deps.config.apiKey}`,
|
|
79
|
+
},
|
|
80
|
+
...(init?.body === undefined ? {} : { body: init.body }),
|
|
81
|
+
});
|
|
82
|
+
return { status: res.status, json: asJson(res.text), text: res.text };
|
|
83
|
+
};
|
|
84
|
+
const nextState = { ...deps.state };
|
|
85
|
+
try {
|
|
86
|
+
// 1. ASK.
|
|
87
|
+
const status = await call(SYNC_STATUS_PATH);
|
|
88
|
+
if (200 !== status.status) {
|
|
89
|
+
const error = `status ${String(status.status)}: ${status.text.slice(0, 200)}`;
|
|
90
|
+
deps.sink.writeState({ ...nextState, lastError: error });
|
|
91
|
+
return { ...empty, error };
|
|
92
|
+
}
|
|
93
|
+
const held = isRecord(status.json) ? status.json : {};
|
|
94
|
+
/*
|
|
95
|
+
* Validated, not trusted. `new Set(someString)` builds a set of CHARACTERS, so a `knownRunIds`
|
|
96
|
+
* that arrived as a string made `known.has('a')` true and silently skipped any run whose id was
|
|
97
|
+
* one of those letters — the client deciding, on the server's malformed word, not to upload
|
|
98
|
+
* something the server does not have. Silent data loss is the one failure this protocol must
|
|
99
|
+
* not have, and an unreadable answer has to mean "it knows nothing", never "it knows this".
|
|
100
|
+
*/
|
|
101
|
+
const known = new Set(Array.isArray(held.knownRunIds)
|
|
102
|
+
? held.knownRunIds.filter((id) => 'string' === typeof id)
|
|
103
|
+
: []);
|
|
104
|
+
// Same rule for the hashes: only a string can equal a hash we computed, so anything else reads
|
|
105
|
+
// as "unknown" and the record is sent once. Re-sending costs a request; skipping costs the data.
|
|
106
|
+
const hashes = isRecord(held.stateHashes) ? held.stateHashes : {};
|
|
107
|
+
// 2. SEND — only what the server does not already have.
|
|
108
|
+
const allRuns = deps.source.runs();
|
|
109
|
+
const unsent = allRuns.filter((r) => !known.has(r.runId));
|
|
110
|
+
const derivedSent = [];
|
|
111
|
+
const bundle = {};
|
|
112
|
+
if (unsent.length > 0)
|
|
113
|
+
bundle['runs'] = unsent.map((r) => r.payload);
|
|
114
|
+
for (const { kind } of DERIVED_RECORDS) {
|
|
115
|
+
const payload = deps.source.derived(kind);
|
|
116
|
+
if (payload === undefined)
|
|
117
|
+
continue;
|
|
118
|
+
// The one comparison the whole protocol rests on. Same hash, do not send it.
|
|
119
|
+
if (hashPayload(payload) === hashes[kind])
|
|
120
|
+
continue;
|
|
121
|
+
bundle[kind] = payload;
|
|
122
|
+
derivedSent.push(kind);
|
|
123
|
+
}
|
|
124
|
+
// Flows are small and upserted by name, so they ride along whenever anything else does rather
|
|
125
|
+
// than earning a round trip of their own.
|
|
126
|
+
const flows = bundle['runs'] === undefined && 0 === derivedSent.length ? [] : deps.source.flows();
|
|
127
|
+
if (flows.length > 0)
|
|
128
|
+
bundle['flows'] = flows;
|
|
129
|
+
let runsSent = 0;
|
|
130
|
+
let flowsSent = 0;
|
|
131
|
+
let runsRejected = [];
|
|
132
|
+
if (Object.keys(bundle).length > 0) {
|
|
133
|
+
const pushed = await call(SYNC_PATH, { method: 'POST', body: JSON.stringify(bundle) });
|
|
134
|
+
if (200 !== pushed.status) {
|
|
135
|
+
const error = `sync ${String(pushed.status)}: ${pushed.text.slice(0, 200)}`;
|
|
136
|
+
deps.sink.writeState({ ...nextState, lastError: error });
|
|
137
|
+
return { ...empty, error };
|
|
138
|
+
}
|
|
139
|
+
const body = isRecord(pushed.json) ? pushed.json : {};
|
|
140
|
+
const runs = isRecord(body['runs']) ? body['runs'] : {};
|
|
141
|
+
const flowsPart = isRecord(body['flows']) ? body['flows'] : {};
|
|
142
|
+
runsSent = 'number' === typeof runs['accepted'] ? runs['accepted'] : 0;
|
|
143
|
+
flowsSent = 'number' === typeof flowsPart['accepted'] ? flowsPart['accepted'] : 0;
|
|
144
|
+
runsRejected = Array.isArray(runs['rejected'])
|
|
145
|
+
? runs['rejected']
|
|
146
|
+
: [];
|
|
147
|
+
nextState.lastPushAt = deps.now();
|
|
148
|
+
}
|
|
149
|
+
// 3. COLLECT — always, even when there was nothing to send.
|
|
150
|
+
const query = nextState.cursor === undefined ? '' : `?since=${encodeURIComponent(nextState.cursor)}`;
|
|
151
|
+
const pull = await call(`${SYNC_PULL_PATH}${query}`);
|
|
152
|
+
if (200 !== pull.status) {
|
|
153
|
+
// The push already landed; report it rather than throwing the whole cycle away.
|
|
154
|
+
const error = `pull ${String(pull.status)}: ${pull.text.slice(0, 200)}`;
|
|
155
|
+
deps.sink.writeState({ ...nextState, lastError: error });
|
|
156
|
+
return { ...empty, ok: false, runsSent, flowsSent, derivedSent, runsRejected, error };
|
|
157
|
+
}
|
|
158
|
+
const pulled = isRecord(pull.json) ? pull.json : {};
|
|
159
|
+
const decisions = pulled.triage ?? [];
|
|
160
|
+
// 4. APPLY.
|
|
161
|
+
if (decisions.length > 0) {
|
|
162
|
+
const triage = {};
|
|
163
|
+
for (const d of decisions) {
|
|
164
|
+
triage[d.fingerprint] = {
|
|
165
|
+
status: d.status,
|
|
166
|
+
flowName: d.flowName ?? null,
|
|
167
|
+
title: d.title ?? '',
|
|
168
|
+
at: d.at,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
deps.sink.writeIssues({ triage });
|
|
172
|
+
}
|
|
173
|
+
// The cursor is written even on an empty page: it is the server's, and it never goes backwards.
|
|
174
|
+
if ('string' === typeof pulled.cursor)
|
|
175
|
+
nextState.cursor = pulled.cursor;
|
|
176
|
+
nextState.lastPullAt = deps.now();
|
|
177
|
+
delete nextState.lastError;
|
|
178
|
+
deps.sink.writeState(nextState);
|
|
179
|
+
return {
|
|
180
|
+
ok: true,
|
|
181
|
+
runsSent,
|
|
182
|
+
runsRejected,
|
|
183
|
+
flowsSent,
|
|
184
|
+
derivedSent,
|
|
185
|
+
pulled: decisions.length,
|
|
186
|
+
morePending: true === pulled.more,
|
|
187
|
+
/*
|
|
188
|
+
* Nothing on disk at all, as opposed to nothing NEW. Computed from what the source offered
|
|
189
|
+
* before any cursor filtering, because a repo whose runs were all already pushed is healthy
|
|
190
|
+
* and a repo that has never recorded one is usually misconfigured.
|
|
191
|
+
*/
|
|
192
|
+
localIsEmpty: 0 === allRuns.length &&
|
|
193
|
+
0 === deps.source.flows().length &&
|
|
194
|
+
DERIVED_RECORDS.every(({ kind }) => deps.source.derived(kind) === undefined),
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
catch (error) {
|
|
198
|
+
// A network that is down is not an error condition for a local-first tool; it is Tuesday.
|
|
199
|
+
const message = describeTransportError(error, deps.config.url);
|
|
200
|
+
deps.sink.writeState({ ...nextState, lastError: message });
|
|
201
|
+
return { ...empty, error: message };
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Say WHICH host failed and WHY.
|
|
206
|
+
*
|
|
207
|
+
* Node's fetch rejects with the bare string "fetch failed" and hides the real reason — a refused
|
|
208
|
+
* connection, a bad hostname, an expired certificate — one level down in `cause`. A machine that
|
|
209
|
+
* has quietly stopped syncing shows that string in `reticle whoami`, and on its own it is
|
|
210
|
+
* indistinguishable from every other network problem, so the person reading it learns nothing and
|
|
211
|
+
* checks nothing. Naming the origin and the underlying code turns it into one thing to look at.
|
|
212
|
+
*/
|
|
213
|
+
function describeTransportError(error, url) {
|
|
214
|
+
if (!(error instanceof Error))
|
|
215
|
+
return String(error);
|
|
216
|
+
const cause = error.cause;
|
|
217
|
+
if (!(cause instanceof Error))
|
|
218
|
+
return `${error.message} (${url})`;
|
|
219
|
+
const code = cause.code;
|
|
220
|
+
// The code and the message are each sometimes empty — an aggregate DNS failure carries a code and
|
|
221
|
+
// no text, a TLS failure the reverse. Joined only when both are there, so the line never trails
|
|
222
|
+
// off into a dangling separator.
|
|
223
|
+
const parts = ['string' === typeof code && code.length > 0 ? code : '', cause.message].filter((part) => part.length > 0);
|
|
224
|
+
const detail = parts.join(': ');
|
|
225
|
+
return 0 === detail.length
|
|
226
|
+
? `${error.message} (${url})`
|
|
227
|
+
: `${error.message} — ${detail} (${url})`;
|
|
228
|
+
}
|
|
229
|
+
/** One line a human can read, for the CLI and the daemon log. */
|
|
230
|
+
export function describeSync(report) {
|
|
231
|
+
if (report.error !== undefined)
|
|
232
|
+
return `sync failed — ${report.error}`;
|
|
233
|
+
const sent = [];
|
|
234
|
+
if (report.runsSent > 0)
|
|
235
|
+
sent.push(`${String(report.runsSent)} run(s)`);
|
|
236
|
+
if (report.flowsSent > 0)
|
|
237
|
+
sent.push(`${String(report.flowsSent)} flow(s)`);
|
|
238
|
+
if (report.derivedSent.length > 0)
|
|
239
|
+
sent.push(report.derivedSent.join(', '));
|
|
240
|
+
/*
|
|
241
|
+
* "Nothing to send" is a statement about the QUEUE, and it is false the moment the queue was full
|
|
242
|
+
* and the server threw it away. Built from what was accepted, the sentence used to read "nothing
|
|
243
|
+
* to send, 3 rejected" — something was very much sent, and the reader is told both that it was
|
|
244
|
+
* not and nothing about why.
|
|
245
|
+
*/
|
|
246
|
+
const push = sent.length > 0
|
|
247
|
+
? `sent ${sent.join(' + ')}`
|
|
248
|
+
: report.runsRejected.length > 0
|
|
249
|
+
? 'nothing accepted'
|
|
250
|
+
: true === report.localIsEmpty
|
|
251
|
+
? // Not the same statement as "nothing to send", which describes a repo that is simply up
|
|
252
|
+
// to date. This one has never recorded anything, which for a LINKED repo usually means
|
|
253
|
+
// the app announces no projectId and its runs are landing under a different root.
|
|
254
|
+
'nothing recorded here yet — if this app has been driven, it is reporting somewhere else'
|
|
255
|
+
: 'nothing to send';
|
|
256
|
+
const pull = 0 === report.pulled
|
|
257
|
+
? ''
|
|
258
|
+
: `, pulled ${String(report.pulled)} decision(s)${report.morePending ? ' (more waiting)' : ''}`;
|
|
259
|
+
/*
|
|
260
|
+
* One reason, not a count. A rejection count tells somebody they have a problem and nothing about
|
|
261
|
+
* which problem — and these arrive from a BACKGROUND daemon, so the summary line is often the only
|
|
262
|
+
* place anybody ever sees it. Rejections in one cycle almost always share a cause (a version skew
|
|
263
|
+
* refuses every payload the same way), so the first reason plus the count is the whole story
|
|
264
|
+
* without printing a line per run; `reticle sync` still lists them all.
|
|
265
|
+
*/
|
|
266
|
+
const bad = 0 === report.runsRejected.length
|
|
267
|
+
? ''
|
|
268
|
+
: `, ${String(report.runsRejected.length)} rejected — ${report.runsRejected[0]?.reason ?? 'no reason given'}`;
|
|
269
|
+
return `${push}${pull}${bad}`;
|
|
270
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { type SyncReport } from './sync-cycle.js';
|
|
2
|
+
import type { ProjectCloud } from './cloud-config.js';
|
|
3
|
+
/** How often the daemon cycles. See the note above on why this is a constant and not a curve. */
|
|
4
|
+
export declare const DAEMON_SYNC_INTERVAL_MS = 60000;
|
|
5
|
+
/**
|
|
6
|
+
* The cadence while something is actually happening.
|
|
7
|
+
*
|
|
8
|
+
* A fixed minute is the wrong shape for both states it has to cover. During a drive the ledger
|
|
9
|
+
* changes on every tool call and a minute of lag is what makes a dashboard feel dead; idle, a minute
|
|
10
|
+
* is already more often than nothing has changed deserves.
|
|
11
|
+
*
|
|
12
|
+
* So the interval follows the work: a cycle that MOVED something earns the fast rate, a cycle that
|
|
13
|
+
* moved nothing backs off to the slow one. No session plumbing and no new configuration — activity
|
|
14
|
+
* is inferred from what the last cycle actually sent, which is the only honest evidence available.
|
|
15
|
+
*
|
|
16
|
+
* Deliberately not per-tool-call. A push in the tool path would put the network in the agent's inner
|
|
17
|
+
* loop, and verification working with the network down is a promise this product makes.
|
|
18
|
+
*/
|
|
19
|
+
export declare const DAEMON_SYNC_ACTIVE_INTERVAL_MS = 5000;
|
|
20
|
+
export interface SyncDaemonDeps {
|
|
21
|
+
reticleRoot: string;
|
|
22
|
+
/** Resolved per tick, not once: a repo linked while the daemon is alive starts syncing itself. */
|
|
23
|
+
cloud: () => Promise<ProjectCloud>;
|
|
24
|
+
/**
|
|
25
|
+
* Every OTHER `.reticle` root on this machine that might be linked.
|
|
26
|
+
*
|
|
27
|
+
* One daemon serves many projects — that is what `artifactRootFor` exists for — and this loop
|
|
28
|
+
* pushed exactly one of them: whichever directory the daemon happened to be started in. Every
|
|
29
|
+
* other linked repo went silent, and silent is indistinguishable from "nobody verified anything",
|
|
30
|
+
* which is the worst possible failure for a dashboard somebody is deciding budget on.
|
|
31
|
+
*
|
|
32
|
+
* Optional: a caller that does not supply it gets exactly the old single-root behaviour.
|
|
33
|
+
*/
|
|
34
|
+
otherRoots?: () => Promise<readonly string[]>;
|
|
35
|
+
/** Resolve the link for a root that is not this daemon's own. Required to use `otherRoots`. */
|
|
36
|
+
cloudFor?: (root: string) => Promise<ProjectCloud>;
|
|
37
|
+
now?: () => number;
|
|
38
|
+
intervalMs?: number;
|
|
39
|
+
/** Injected for the test; the real one is `fetch`. */
|
|
40
|
+
request?: (url: string, init: {
|
|
41
|
+
method: string;
|
|
42
|
+
headers: Record<string, string>;
|
|
43
|
+
body?: string;
|
|
44
|
+
}) => Promise<{
|
|
45
|
+
status: number;
|
|
46
|
+
text: string;
|
|
47
|
+
}>;
|
|
48
|
+
}
|
|
49
|
+
export interface SyncDaemon {
|
|
50
|
+
/** Run one cycle now, whatever the timer is doing. Returns undefined when not linked. */
|
|
51
|
+
syncNow: () => Promise<SyncReport | undefined>;
|
|
52
|
+
/**
|
|
53
|
+
* Something worth shipping just landed — cycle soon rather than at the next tick.
|
|
54
|
+
*
|
|
55
|
+
* A verification RUN is the artifact people wait on, and making them wait up to a full interval
|
|
56
|
+
* for it is the difference between a dashboard that reflects the work and one that lags it. The
|
|
57
|
+
* counters keep the timer: they are a rolling aggregate, and a write per tool call to move a
|
|
58
|
+
* number nobody is watching that second is exactly the cost the interval exists to avoid.
|
|
59
|
+
*
|
|
60
|
+
* Coalescing, never additive: many nudges in a burst collapse into one cycle.
|
|
61
|
+
*/
|
|
62
|
+
nudge: () => void;
|
|
63
|
+
stop: () => void;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Start the loop. Safe to call for an UNLINKED project: it resolves the link on every tick and does
|
|
67
|
+
* nothing until one appears, which is what lets `reticle link` take effect without a restart.
|
|
68
|
+
*/
|
|
69
|
+
export declare function startSyncDaemon(deps: SyncDaemonDeps): SyncDaemon;
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Automatic sync, for the session nobody thinks about.
|
|
3
|
+
*
|
|
4
|
+
* Somebody runs Reticle all morning. Every tool call rewrites the impact record; every verification
|
|
5
|
+
* writes a run. If sync is a command a human types, the dashboard is stale for exactly as long as
|
|
6
|
+
* they are busy — which is precisely when the numbers on it would have been worth looking at.
|
|
7
|
+
*
|
|
8
|
+
* So the daemon does it. Not a new process, not a thing to remember: the daemon is already running
|
|
9
|
+
* for the whole session (it is what the agent talks to), so it is the only place where "sync while
|
|
10
|
+
* work is happening" costs nobody anything.
|
|
11
|
+
*
|
|
12
|
+
* ── THE THREE RULES THAT KEEP IT INVISIBLE ────────────────────────────────────────────────────
|
|
13
|
+
* QUIET WHEN IDLE. A cycle on an unchanged session is one small GET and nothing else. That is what
|
|
14
|
+
* makes a one-minute timer affordable; it is also why the timer is not adaptive — a scheme that
|
|
15
|
+
* "backs off when idle" is complexity paid for a cost that is already near zero.
|
|
16
|
+
*
|
|
17
|
+
* NEVER IN THE WAY. Nothing here is awaited by a tool call. A sync that can slow down or fail a
|
|
18
|
+
* verification would be a sync worth switching off, and the local record is authoritative anyway.
|
|
19
|
+
*
|
|
20
|
+
* SILENT UNLESS IT MATTERS. A failed cycle is logged once per NEW error, not once per minute: a
|
|
21
|
+
* laptop on a train would otherwise fill the log with the same line four hundred times and teach
|
|
22
|
+
* everyone to ignore it.
|
|
23
|
+
*/
|
|
24
|
+
import { log } from '../log.js';
|
|
25
|
+
import { describeSync, runSyncCycle } from './sync-cycle.js';
|
|
26
|
+
import { diskSink, diskSource, readCloudState } from './sync-disk.js';
|
|
27
|
+
/** How often the daemon cycles. See the note above on why this is a constant and not a curve. */
|
|
28
|
+
export const DAEMON_SYNC_INTERVAL_MS = 60_000;
|
|
29
|
+
/**
|
|
30
|
+
* The cadence while something is actually happening.
|
|
31
|
+
*
|
|
32
|
+
* A fixed minute is the wrong shape for both states it has to cover. During a drive the ledger
|
|
33
|
+
* changes on every tool call and a minute of lag is what makes a dashboard feel dead; idle, a minute
|
|
34
|
+
* is already more often than nothing has changed deserves.
|
|
35
|
+
*
|
|
36
|
+
* So the interval follows the work: a cycle that MOVED something earns the fast rate, a cycle that
|
|
37
|
+
* moved nothing backs off to the slow one. No session plumbing and no new configuration — activity
|
|
38
|
+
* is inferred from what the last cycle actually sent, which is the only honest evidence available.
|
|
39
|
+
*
|
|
40
|
+
* Deliberately not per-tool-call. A push in the tool path would put the network in the agent's inner
|
|
41
|
+
* loop, and verification working with the network down is a promise this product makes.
|
|
42
|
+
*/
|
|
43
|
+
export const DAEMON_SYNC_ACTIVE_INTERVAL_MS = 5_000;
|
|
44
|
+
/** Given to the first cycle so a freshly-started daemon does not race the session that woke it. */
|
|
45
|
+
const FIRST_CYCLE_DELAY_MS = 5_000;
|
|
46
|
+
/**
|
|
47
|
+
* How long a nudge waits before cycling.
|
|
48
|
+
*
|
|
49
|
+
* Long enough to COALESCE — a battery that writes six runs in a second must cause one cycle, not
|
|
50
|
+
* six — and short enough that finishing a verification and looking at the dashboard feels like one
|
|
51
|
+
* action. Not zero: a run artifact is renamed into place and the sibling records it implies are
|
|
52
|
+
* written just after, so cycling on the very first byte would ship a bundle that is missing them.
|
|
53
|
+
*/
|
|
54
|
+
const NUDGE_DELAY_MS = 1_500;
|
|
55
|
+
const defaultRequest = async (url, init) => {
|
|
56
|
+
const res = await fetch(url, init);
|
|
57
|
+
return { status: res.status, text: await res.text() };
|
|
58
|
+
};
|
|
59
|
+
/**
|
|
60
|
+
* Start the loop. Safe to call for an UNLINKED project: it resolves the link on every tick and does
|
|
61
|
+
* nothing until one appears, which is what lets `reticle link` take effect without a restart.
|
|
62
|
+
*/
|
|
63
|
+
export function startSyncDaemon(deps) {
|
|
64
|
+
const intervalMs = deps.intervalMs ?? DAEMON_SYNC_INTERVAL_MS;
|
|
65
|
+
// Never slower than the idle rate: a deployment that lengthens the interval means "sync less", and
|
|
66
|
+
// an active burst must not quietly re-introduce the cost it was lowering.
|
|
67
|
+
const activeIntervalMs = Math.min(DAEMON_SYNC_ACTIVE_INTERVAL_MS, intervalMs);
|
|
68
|
+
const now = deps.now ?? (() => Date.now());
|
|
69
|
+
const request = deps.request ?? defaultRequest;
|
|
70
|
+
let timer;
|
|
71
|
+
let stopped = false;
|
|
72
|
+
let running = false;
|
|
73
|
+
/** The last error already reported. Repeats are swallowed so an offline laptop stays quiet. */
|
|
74
|
+
let reportedError;
|
|
75
|
+
/**
|
|
76
|
+
* Whether the last cycle found a link — undefined until the first one has looked.
|
|
77
|
+
*
|
|
78
|
+
* An unlinked project used to sync nothing and say nothing, which is indistinguishable from a
|
|
79
|
+
* working sync until somebody notices the dashboard is hours behind. Observed for real: the
|
|
80
|
+
* daemon was started from a sibling repo that had never been linked, so every call for an entire
|
|
81
|
+
* session was recorded into THAT repo's ledger and pushed nowhere, and the first evidence was a
|
|
82
|
+
* stale number on a dashboard nobody had reason to distrust.
|
|
83
|
+
*
|
|
84
|
+
* Announced on the FIRST cycle and on every change after it, so `reticle link` mid-session says
|
|
85
|
+
* so too. Once per transition, never per tick — a line every minute is a line people stop reading.
|
|
86
|
+
*/
|
|
87
|
+
let wasLinked;
|
|
88
|
+
/** One root's bundle. The state, source and sink are already per-root; only the caller was not. */
|
|
89
|
+
const pushRoot = async (root, cloud) => {
|
|
90
|
+
if (null === cloud.config)
|
|
91
|
+
return undefined;
|
|
92
|
+
const full = diskSource(root);
|
|
93
|
+
return runSyncCycle({
|
|
94
|
+
config: cloud.config,
|
|
95
|
+
source: {
|
|
96
|
+
runs: () => (cloud.policy.runs ? full.runs() : []),
|
|
97
|
+
flows: () => (cloud.policy.flows ? full.flows() : []),
|
|
98
|
+
derived: (kind) => (cloud.policy.memory ? full.derived(kind) : undefined),
|
|
99
|
+
},
|
|
100
|
+
sink: diskSink(root),
|
|
101
|
+
state: readCloudState(root),
|
|
102
|
+
now,
|
|
103
|
+
request,
|
|
104
|
+
});
|
|
105
|
+
};
|
|
106
|
+
/**
|
|
107
|
+
* Push every other linked root this machine knows about.
|
|
108
|
+
*
|
|
109
|
+
* Isolated per root on purpose. A revoked credential, a deleted directory or an unreachable
|
|
110
|
+
* self-hosted server in ONE repo is a fact about that repo; letting it throw would take the
|
|
111
|
+
* daemon's own push down with it and turn one broken link into a machine-wide outage.
|
|
112
|
+
*/
|
|
113
|
+
const syncOtherRoots = async () => {
|
|
114
|
+
const roots = deps.otherRoots;
|
|
115
|
+
const cloudFor = deps.cloudFor;
|
|
116
|
+
if (roots === undefined || cloudFor === undefined)
|
|
117
|
+
return;
|
|
118
|
+
let list = [];
|
|
119
|
+
try {
|
|
120
|
+
list = await roots();
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
// Enumeration is best-effort: a registry that cannot be read must not stop this daemon
|
|
124
|
+
// syncing the root it is standing in.
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
for (const root of list) {
|
|
128
|
+
if (root === deps.reticleRoot)
|
|
129
|
+
continue;
|
|
130
|
+
try {
|
|
131
|
+
const cloud = await cloudFor(root);
|
|
132
|
+
if (null === cloud.config)
|
|
133
|
+
continue;
|
|
134
|
+
const report = await pushRoot(root, cloud);
|
|
135
|
+
if (report === undefined)
|
|
136
|
+
continue;
|
|
137
|
+
if (report.error !== undefined) {
|
|
138
|
+
log('reticle_cloud_sync_failed', { root, error: report.error });
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
const moved = report.runsSent > 0 ||
|
|
142
|
+
report.flowsSent > 0 ||
|
|
143
|
+
report.derivedSent.length > 0 ||
|
|
144
|
+
report.pulled > 0;
|
|
145
|
+
// The root is NAMED here and not in the single-root log below, because with several repos
|
|
146
|
+
// reporting, "synced 3 runs" without a directory is not an answer to "synced from where".
|
|
147
|
+
if (moved)
|
|
148
|
+
log('reticle_cloud_synced', { root, summary: describeSync(report) });
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
log('reticle_cloud_sync_failed', {
|
|
152
|
+
root,
|
|
153
|
+
error: error instanceof Error ? error.message : String(error),
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
const cycle = async () => {
|
|
159
|
+
// Overlap guard: a slow cycle must not have a second one started on top of it, or two bundles
|
|
160
|
+
// race and the cursor written by the loser silently rewinds the winner's progress.
|
|
161
|
+
if (running)
|
|
162
|
+
return undefined;
|
|
163
|
+
running = true;
|
|
164
|
+
try {
|
|
165
|
+
const cloud = await deps.cloud();
|
|
166
|
+
const linked = null !== cloud.config;
|
|
167
|
+
if (linked !== wasLinked) {
|
|
168
|
+
wasLinked = linked;
|
|
169
|
+
if (linked)
|
|
170
|
+
log('reticle_cloud_linked', {
|
|
171
|
+
projectId: cloud.projectId,
|
|
172
|
+
root: deps.reticleRoot,
|
|
173
|
+
});
|
|
174
|
+
// Names the ROOT, because the answer is almost always "the daemon is not where you think it
|
|
175
|
+
// is" — and a message that omits the directory sends people to check their key instead.
|
|
176
|
+
else
|
|
177
|
+
log('reticle_cloud_unlinked', {
|
|
178
|
+
root: deps.reticleRoot,
|
|
179
|
+
fix: 'run `reticle link` in this directory, or start the daemon in the linked one — nothing is being synced from here',
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
// Every OTHER linked root, first. Their failures are logged and never abort this daemon's own
|
|
183
|
+
// push: one repo whose credential was revoked must not silence the rest of the machine.
|
|
184
|
+
await syncOtherRoots();
|
|
185
|
+
if (null === cloud.config)
|
|
186
|
+
return undefined;
|
|
187
|
+
const report = await pushRoot(deps.reticleRoot, cloud);
|
|
188
|
+
// `pushRoot` returns undefined only for an unlinked root, which the guard above has ruled out.
|
|
189
|
+
if (report === undefined)
|
|
190
|
+
return undefined;
|
|
191
|
+
if (report.error !== undefined) {
|
|
192
|
+
if (report.error !== reportedError) {
|
|
193
|
+
reportedError = report.error;
|
|
194
|
+
log('reticle_cloud_sync_failed', { error: report.error });
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
else {
|
|
198
|
+
reportedError = undefined;
|
|
199
|
+
// Only when something actually moved. A per-minute "nothing to send" is noise that trains
|
|
200
|
+
// people to stop reading the log.
|
|
201
|
+
const moved = report.runsSent > 0 ||
|
|
202
|
+
report.flowsSent > 0 ||
|
|
203
|
+
report.derivedSent.length > 0 ||
|
|
204
|
+
report.pulled > 0;
|
|
205
|
+
if (moved)
|
|
206
|
+
log('reticle_cloud_synced', { summary: describeSync(report) });
|
|
207
|
+
/*
|
|
208
|
+
* A cycle that moved something means a drive is in progress, so the next one comes sooner;
|
|
209
|
+
* one that moved nothing means the machine is idle, so it backs straight off. The rate
|
|
210
|
+
* follows the work without needing to be told about sessions, and an idle laptop settles at
|
|
211
|
+
* the same cost it had before this existed.
|
|
212
|
+
*/
|
|
213
|
+
nextDelay = moved ? activeIntervalMs : intervalMs;
|
|
214
|
+
}
|
|
215
|
+
return report;
|
|
216
|
+
}
|
|
217
|
+
catch (error) {
|
|
218
|
+
// Belt and braces: runSyncCycle already swallows, and a throw here would kill the timer.
|
|
219
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
220
|
+
if (message !== reportedError) {
|
|
221
|
+
reportedError = message;
|
|
222
|
+
log('reticle_cloud_sync_failed', { error: message });
|
|
223
|
+
}
|
|
224
|
+
return undefined;
|
|
225
|
+
}
|
|
226
|
+
finally {
|
|
227
|
+
running = false;
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
/** The delay for the NEXT cycle: fast while the last one moved something, slow once it stops. */
|
|
231
|
+
let nextDelay = intervalMs;
|
|
232
|
+
const schedule = (delay) => {
|
|
233
|
+
if (stopped)
|
|
234
|
+
return;
|
|
235
|
+
// Replace, never stack. `nudge` and the interval both schedule, and leaving the old timer armed
|
|
236
|
+
// would let every nudge add a permanent extra cycle per minute for the life of the process.
|
|
237
|
+
if (timer !== undefined)
|
|
238
|
+
clearTimeout(timer);
|
|
239
|
+
timer = setTimeout(() => {
|
|
240
|
+
void cycle().finally(() => schedule(nextDelay));
|
|
241
|
+
}, delay);
|
|
242
|
+
// Unref'd: a pending sync must never be the reason a process refuses to exit.
|
|
243
|
+
timer.unref?.();
|
|
244
|
+
};
|
|
245
|
+
schedule(FIRST_CYCLE_DELAY_MS);
|
|
246
|
+
return {
|
|
247
|
+
syncNow: cycle,
|
|
248
|
+
nudge: () => {
|
|
249
|
+
// A cycle already in flight will not pick this up, so the nudge is still scheduled behind it
|
|
250
|
+
// rather than dropped — otherwise the run that arrived during a slow cycle waits a full tick.
|
|
251
|
+
schedule(NUDGE_DELAY_MS);
|
|
252
|
+
},
|
|
253
|
+
stop: () => {
|
|
254
|
+
stopped = true;
|
|
255
|
+
if (timer !== undefined)
|
|
256
|
+
clearTimeout(timer);
|
|
257
|
+
timer = undefined;
|
|
258
|
+
},
|
|
259
|
+
};
|
|
260
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { CloudSyncState, PulledIssues, SyncSink, SyncSource } from './sync-cycle.js';
|
|
2
|
+
/** What the cycle reads, backed by a real `.reticle` directory. */
|
|
3
|
+
export declare function diskSource(reticleRoot: string): SyncSource;
|
|
4
|
+
/** What the cycle writes back into `.reticle/`. */
|
|
5
|
+
export declare function diskSink(reticleRoot: string): SyncSink;
|
|
6
|
+
/** This machine's side of the conversation, or a blank one on first run. */
|
|
7
|
+
export declare function readCloudState(reticleRoot: string): CloudSyncState;
|
|
8
|
+
/** The decisions pulled back from the dashboard, for the HUD and for the next run to read. */
|
|
9
|
+
export declare function readCloudIssues(reticleRoot: string): PulledIssues;
|