@reticlehq/server 3.3.0 → 3.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE-ENTERPRISE +1 -1
- package/README.md +3 -3
- package/SKILL.md +12 -14
- package/dist/chromium-launch-options.d.ts +4 -3
- package/dist/chromium-launch-options.js +6 -2
- package/dist/command/cli/adhoc-suite.d.ts +22 -0
- package/dist/command/cli/adhoc-suite.js +90 -19
- package/dist/command/cli/adhoc-verdict.d.ts +35 -0
- package/dist/command/cli/adhoc-verdict.js +90 -3
- package/dist/command/cli/answers/gate-hook-message.d.ts +6 -0
- package/dist/command/cli/answers/gate-hook-message.js +6 -0
- package/dist/command/cli/auth/cloud-keystore.d.ts +0 -2
- package/dist/command/cli/auth/cloud-keystore.js +0 -3
- package/dist/command/cli/auth/cloud-session.d.ts +4 -0
- package/dist/command/cli/auth/cloud-session.js +2 -0
- package/dist/command/cli/cli-doctor.js +7 -5
- package/dist/command/cli/cli-flow-commands.d.ts +3 -6
- package/dist/command/cli/cli-flow-commands.js +67 -4
- package/dist/command/cli/cli-parse.d.ts +8 -1
- package/dist/command/cli/cli-parse.js +21 -3
- package/dist/command/cli/cli-update-commands.js +8 -1
- package/dist/command/cli/cli-verify.d.ts +24 -3
- package/dist/command/cli/cli-verify.js +54 -11
- package/dist/command/cli/cloud-cli.js +5 -2
- package/dist/command/cli/cloud-kit.js +3 -5
- package/dist/command/cli/cloud-login.js +21 -3
- package/dist/command/cli/demo/demo-page.js +6 -3
- package/dist/command/cli/doctor/browser/chromium-hint.d.ts +5 -0
- package/dist/command/cli/doctor/browser/chromium-hint.js +5 -0
- package/dist/command/cli/launch/cli-launch.d.ts +8 -0
- package/dist/command/cli/launch/cli-launch.js +21 -9
- package/dist/command/cli/launch/sdk-version-match.d.ts +84 -0
- package/dist/command/cli/launch/sdk-version-match.js +206 -0
- package/dist/command/cli/lifecycle/daemon-lifecycle.js +2 -16
- package/dist/command/cli/ports/resolve/cli-port.d.ts +25 -0
- package/dist/command/cli/ports/resolve/cli-port.js +125 -15
- package/dist/command/cli/setup-mcp-cli.js +13 -13
- package/dist/command/cli/tutorial.d.ts +2 -2
- package/dist/command/cli/tutorial.js +21 -7
- package/dist/command/cli.js +62 -10
- package/dist/command/daemon/binding/daemon-status-probe.d.ts +14 -2
- package/dist/command/daemon/binding/daemon-status-probe.js +34 -6
- package/dist/command/daemon/daemon-resolve.d.ts +9 -0
- package/dist/command/daemon/daemon-resolve.js +1 -1
- package/dist/command/daemon/lifetime/daemon-usefulness.d.ts +7 -1
- package/dist/command/daemon/lifetime/daemon-usefulness.js +30 -7
- package/dist/command/dev/stale-issue-guard.d.ts +3 -4
- package/dist/command/dev/stale-issue-guard.js +8 -8
- package/dist/command/setup/bringup/connect-lease.d.ts +17 -0
- package/dist/command/setup/bringup/connect-lease.js +38 -0
- package/dist/command/setup/bringup/dev-server-files.d.ts +68 -0
- package/dist/command/setup/bringup/dev-server-files.js +131 -0
- package/dist/command/setup/bringup/dev-server-log-cap.d.ts +35 -0
- package/dist/command/setup/bringup/dev-server-log-cap.js +121 -0
- package/dist/command/setup/bringup/ensure-daemon.d.ts +65 -4
- package/dist/command/setup/bringup/ensure-daemon.js +135 -16
- package/dist/command/setup/bringup/static-page-server.d.ts +35 -0
- package/dist/command/setup/bringup/static-page-server.js +151 -0
- package/dist/command/setup/desktop-shape.d.ts +4 -0
- package/dist/command/setup/desktop-shape.js +18 -2
- package/dist/command/setup/init/init-port.d.ts +15 -0
- package/dist/command/setup/init/init-port.js +44 -0
- package/dist/command/setup/init/init-runtime.d.ts +1 -1
- package/dist/command/setup/init/init-runtime.js +39 -2
- package/dist/command/setup/node-effects.d.ts +27 -14
- package/dist/command/setup/node-effects.js +80 -44
- package/dist/command/setup/probe/loopback-probe-urls.d.ts +7 -0
- package/dist/command/setup/probe/loopback-probe-urls.js +40 -0
- package/dist/command/setup/probe/page-probe.d.ts +13 -2
- package/dist/command/setup/probe/page-probe.js +18 -3
- package/dist/command/setup/run-setup.d.ts +26 -1
- package/dist/command/setup/run-setup.js +96 -13
- package/dist/command/setup/session-pick.d.ts +13 -0
- package/dist/command/setup/session-pick.js +20 -0
- package/dist/command/setup/setup-command.d.ts +2 -14
- package/dist/command/setup/setup-command.js +105 -31
- package/dist/command/setup/setup-mcp.js +3 -3
- package/dist/command/setup/terminal/wait-progress.d.ts +8 -0
- package/dist/command/setup/terminal/wait-progress.js +17 -0
- package/dist/features/crawl/crawl-empty.js +1 -1
- package/dist/features/crawl/crawl-tools.d.ts +6 -0
- package/dist/features/crawl/crawl-tools.js +57 -0
- package/dist/features/crawl/crawl.js +171 -157
- package/dist/features/exhaust/app-coverage.d.ts +33 -0
- package/dist/features/exhaust/app-coverage.js +31 -0
- package/dist/features/exhaust/code-coverage.d.ts +47 -0
- package/dist/features/exhaust/code-coverage.js +77 -0
- package/dist/features/exhaust/exhaust-run.d.ts +33 -0
- package/dist/features/exhaust/exhaust-run.js +82 -0
- package/dist/features/exhaust/explorer.d.ts +103 -0
- package/dist/features/exhaust/explorer.js +195 -0
- package/dist/features/exhaust/failure-branches.d.ts +33 -0
- package/dist/features/exhaust/failure-branches.js +92 -0
- package/dist/features/exhaust/ledger.d.ts +202 -0
- package/dist/features/exhaust/ledger.js +252 -0
- package/dist/features/exhaust/session-fold.d.ts +31 -0
- package/dist/features/exhaust/session-fold.js +79 -0
- package/dist/features/exhaust/session-port.d.ts +36 -0
- package/dist/features/exhaust/session-port.js +102 -0
- package/dist/features/harness/drive-report.d.ts +0 -9
- package/dist/features/harness/drive-report.js +18 -0
- package/dist/features/harness/driver.js +4 -7
- package/dist/features/harness/fill-values.d.ts +6 -0
- package/dist/features/harness/fill-values.js +3 -0
- package/dist/features/harness/jev-driver.js +30 -13
- package/dist/features/harness/openai-driver.js +4 -7
- package/dist/features/harness/platform-config.js +5 -6
- package/dist/index.d.ts +9 -0
- package/dist/index.js +40 -11
- package/dist/judgement/domain/domain-model.js +3 -1
- package/dist/judgement/oracles/self-instrument.js +4 -2
- package/dist/judgement/runs/artifact/replay-mapping.d.ts +2 -2
- package/dist/judgement/runs/artifact/replay-mapping.js +44 -9
- package/dist/judgement/runs/verification-sync.d.ts +3 -1
- package/dist/judgement/runs/verification-sync.js +1 -1
- package/dist/language/flows/change/affected.d.ts +9 -0
- package/dist/language/flows/change/affected.js +35 -0
- package/dist/language/flows/change/gate.d.ts +20 -0
- package/dist/language/flows/change/gate.js +12 -1
- package/dist/language/flows/flow-author.d.ts +7 -0
- package/dist/language/flows/flow-author.js +19 -0
- package/dist/language/flows/flow-expect-grammar.js +1 -1
- package/dist/language/flows/flow-journey.d.ts +54 -0
- package/dist/language/flows/flow-journey.js +119 -0
- package/dist/language/flows/flow-replay-run.d.ts +2 -3
- package/dist/language/flows/flow-replay-run.js +13 -47
- package/dist/language/flows/flow-replay.d.ts +22 -8
- package/dist/language/flows/flow-replay.js +55 -9
- package/dist/language/flows/flow-result.d.ts +2 -0
- package/dist/language/flows/flow-result.js +25 -0
- package/dist/language/flows/flow-tools.js +17 -5
- package/dist/language/flows/flows.d.ts +10 -1
- package/dist/language/flows/flows.js +90 -14
- package/dist/language/flows/recording/replay-halt.js +3 -1
- package/dist/language/flows/recording/tape/recordings.d.ts +30 -1
- package/dist/language/flows/recording/tape/recordings.js +55 -1
- package/dist/language/flows/replay.d.ts +19 -0
- package/dist/language/flows/replay.js +42 -6
- package/dist/language/flows/verify-change-tools.js +5 -2
- package/dist/launch-chromium.d.ts +80 -0
- package/dist/launch-chromium.js +218 -0
- package/dist/memory/cloud/cloud-config.d.ts +11 -2
- package/dist/memory/cloud/cloud-config.js +47 -7
- package/dist/memory/cloud/cloud-sync.d.ts +4 -1
- package/dist/memory/cloud/cloud-sync.js +6 -9
- package/dist/memory/cloud/harness-offer.js +5 -6
- package/dist/memory/cloud/harness-switch.js +5 -6
- package/dist/memory/cloud/sync-cycle.d.ts +68 -3
- package/dist/memory/cloud/sync-cycle.js +298 -55
- package/dist/memory/cloud/sync-daemon.js +24 -20
- package/dist/memory/cloud/sync-disk.js +16 -2
- package/dist/memory/intent/intent-shard.d.ts +10 -32
- package/dist/memory/intent/intent-shard.js +0 -10
- package/dist/memory/journal/drive-flow.d.ts +18 -13
- package/dist/memory/journal/drive-flow.js +45 -37
- package/dist/memory/journal/on-disk/workspace-tiers.js +2 -0
- package/dist/memory/journal/session-end.d.ts +9 -1
- package/dist/memory/journal/session-end.js +24 -12
- package/dist/memory/project/dir/reticle-dir.d.ts +2 -0
- package/dist/memory/project/dir/reticle-dir.js +1 -0
- package/dist/memory/project/project-tools.js +3 -1
- package/dist/portal/bridge/auth-failure-reason.d.ts +1 -1
- package/dist/portal/bridge/auth-failure-reason.js +4 -2
- package/dist/portal/bridge/bridge.d.ts +8 -0
- package/dist/portal/bridge/bridge.js +43 -2
- package/dist/portal/input/js-coverage.d.ts +25 -0
- package/dist/portal/input/js-coverage.js +30 -0
- package/dist/portal/input/real-input.d.ts +11 -0
- package/dist/portal/input/real-input.js +29 -4
- package/dist/portal/pool/playwright-launcher.d.ts +6 -0
- package/dist/portal/pool/playwright-launcher.js +20 -13
- package/dist/portal/session/dev-server/dev-command.d.ts +2 -2
- package/dist/portal/session/dev-server/dev-command.js +30 -12
- package/dist/portal/session/dev-server/dev-server-probe.js +17 -1
- package/dist/portal/session/dev-server/route-status-probe.d.ts +24 -0
- package/dist/portal/session/dev-server/route-status-probe.js +57 -0
- package/dist/portal/session/facts/observed-state.d.ts +11 -0
- package/dist/portal/session/facts/observed-state.js +34 -0
- package/dist/portal/session/no-session-diagnosis.d.ts +29 -0
- package/dist/portal/session/no-session-diagnosis.js +62 -5
- package/dist/portal/session/no-session-next-action.d.ts +13 -0
- package/dist/portal/session/no-session-next-action.js +48 -9
- package/dist/portal/session/no-session-watch.d.ts +18 -0
- package/dist/portal/session/no-session-watch.js +88 -2
- package/dist/portal/session/session-health.js +3 -2
- package/dist/portal/session/session-manager.d.ts +20 -6
- package/dist/portal/session/session-manager.js +19 -3
- package/dist/portal/session/session-reaper.d.ts +28 -0
- package/dist/portal/session/session-reaper.js +49 -1
- package/dist/portal/session/session.d.ts +5 -0
- package/dist/portal/session/session.js +15 -19
- package/dist/surface/http-server.d.ts +2 -1
- package/dist/surface/http-server.js +4 -2
- package/dist/surface/mcp/mcp-proxy.d.ts +2 -2
- package/dist/surface/mcp/mcp-proxy.js +2 -2
- package/dist/surface/mcp/mcp.js +4 -2
- package/dist/surface/mcp/proxy/proxy-daemon-probe.d.ts +13 -1
- package/dist/surface/mcp/proxy/proxy-daemon-probe.js +27 -2
- package/dist/surface/mcp/server-instructions.js +9 -7
- package/dist/surface/tools/act/after-match.d.ts +17 -0
- package/dist/surface/tools/act/after-match.js +30 -0
- package/dist/surface/tools/act/durable.d.ts +69 -0
- package/dist/surface/tools/act/durable.js +139 -0
- package/dist/surface/tools/act/navigation/navigate-arrival.d.ts +13 -6
- package/dist/surface/tools/act/navigation/navigate-arrival.js +47 -10
- package/dist/surface/tools/act/navigation/navigate-result.d.ts +1 -12
- package/dist/surface/tools/act/navigation/navigate-result.js +21 -3
- package/dist/surface/tools/act/proved-controls.d.ts +9 -0
- package/dist/surface/tools/act/proved-controls.js +11 -0
- package/dist/surface/tools/act/sequence-grade.d.ts +5 -0
- package/dist/surface/tools/act/sequence-grade.js +32 -1
- package/dist/surface/tools/act-capsule.d.ts +7 -0
- package/dist/surface/tools/act-capsule.js +46 -1
- package/dist/surface/tools/act-sequence-tool.js +25 -8
- package/dist/surface/tools/act-tools.js +26 -50
- package/dist/surface/tools/assert/assert-grade.js +12 -1
- package/dist/surface/tools/assert/assert-verdict.js +2 -2
- package/dist/surface/tools/browser-tools.js +14 -2
- package/dist/surface/tools/coverage-tools.js +27 -1
- package/dist/surface/tools/error-recovery.js +4 -4
- package/dist/surface/tools/feedback-invite.js +6 -4
- package/dist/surface/tools/harness-explore.d.ts +3 -2
- package/dist/surface/tools/harness-explore.js +7 -6
- package/dist/surface/tools/lease-tools.js +15 -6
- package/dist/surface/tools/live-call-text.js +4 -1
- package/dist/surface/tools/observe-tools.js +9 -1
- package/dist/surface/tools/tools.js +4 -0
- package/dist/telemetry/argument-shape.d.ts +0 -2
- package/dist/telemetry/argument-shape.js +0 -4
- package/dist/telemetry/error-class.d.ts +2 -3
- package/dist/telemetry/error-class.js +2 -3
- package/dist/telemetry/harness-drive.d.ts +0 -2
- package/dist/telemetry/harness-drive.js +0 -4
- package/dist/wire-journal.d.ts +3 -0
- package/dist/wire-journal.js +4 -0
- package/dist/workspace-packages.d.ts +0 -2
- package/dist/workspace-packages.js +1 -9
- package/docs/README.md +4 -2
- package/docs/actions.mdx +3 -3
- package/docs/agent-cheatsheet.md +4 -4
- package/docs/architecture.md +6 -6
- package/docs/best-practices.mdx +1 -1
- package/docs/capabilities.mdx +32 -1
- package/docs/cli/cloud.mdx +14 -4
- package/docs/cli/doctor.mdx +4 -0
- package/docs/cli/feedback.mdx +2 -2
- package/docs/cli/gate.mdx +9 -4
- package/docs/cli/update.mdx +10 -0
- package/docs/cli.mdx +9 -0
- package/docs/desktop.mdx +14 -10
- package/docs/environment.mdx +87 -0
- package/docs/faq.mdx +18 -16
- package/docs/first-drive.md +5 -13
- package/docs/flows.md +21 -2
- package/docs/frameworks.mdx +29 -10
- package/docs/getting-started.md +3 -3
- package/docs/http-transport.md +6 -3
- package/docs/human-control.md +2 -2
- package/docs/index.mdx +16 -6
- package/docs/install-agentic.mdx +1 -1
- package/docs/install-manual.mdx +1 -1
- package/docs/integration-patterns.md +3 -3
- package/docs/multi-agent-testing.md +1 -1
- package/docs/packages/babel-plugin.mdx +1 -1
- package/docs/packages/browser.mdx +2 -2
- package/docs/packages/react.mdx +2 -2
- package/docs/packages/tauri.mdx +1 -1
- package/docs/packages/vite-plugin.mdx +2 -2
- package/docs/packages.mdx +9 -9
- package/docs/predicates.mdx +50 -5
- package/docs/quickstart.mdx +2 -2
- package/docs/recipes.mdx +2 -2
- package/docs/state-management.mdx +2 -2
- package/docs/system-map.md +1 -1
- package/docs/telemetry.md +1 -1
- package/docs/token-efficiency.md +3 -3
- package/docs/tools/act-and-wait.mdx +1 -0
- package/docs/tools/act-sequence.mdx +1 -1
- package/docs/tools/console.mdx +1 -1
- package/docs/tools/inspect.mdx +1 -1
- package/docs/tools/network.mdx +1 -1
- package/docs/tools/query.mdx +1 -1
- package/docs/tools/sessions.mdx +1 -1
- package/docs/tools/snapshot.mdx +1 -1
- package/docs/tools/state.mdx +1 -1
- package/docs/tools/wait-for.mdx +1 -1
- package/docs/usage.md +7 -6
- package/docs/use-cases.mdx +157 -0
- package/docs/vs-screenshots.mdx +3 -3
- package/docs/what-is-recorded.md +2 -2
- package/docs/what-is-reticle.mdx +17 -20
- package/docs/why-reticle.mdx +14 -21
- package/package.json +10 -9
- package/dist/judgement/capsule/minimize.d.ts +0 -12
- package/dist/judgement/capsule/minimize.js +0 -22
- package/dist/judgement/phenomena/phenomena.d.ts +0 -27
- package/dist/judgement/phenomena/phenomena.js +0 -101
- package/dist/language/flows/flow-report.d.ts +0 -22
- package/dist/language/flows/flow-report.js +0 -106
|
@@ -13,6 +13,12 @@ declare const DERIVED_RECORDS: readonly [{
|
|
|
13
13
|
}, {
|
|
14
14
|
readonly kind: "intent";
|
|
15
15
|
readonly file: "intent.json";
|
|
16
|
+
}, {
|
|
17
|
+
readonly kind: "envelopes";
|
|
18
|
+
readonly file: "envelopes.json";
|
|
19
|
+
}, {
|
|
20
|
+
readonly kind: "assertion-tiers";
|
|
21
|
+
readonly file: "assertion-tiers.json";
|
|
16
22
|
}];
|
|
17
23
|
type DerivedKind = (typeof DERIVED_RECORDS)[number]['kind'];
|
|
18
24
|
/** What the machine reads from disk. Injected so a cycle is testable with no filesystem at all. */
|
|
@@ -69,11 +75,43 @@ export interface CloudSyncState {
|
|
|
69
75
|
* fills the gap the server declared and never overrides the server on a question it answered.
|
|
70
76
|
*/
|
|
71
77
|
sentRunIds?: string[];
|
|
78
|
+
/**
|
|
79
|
+
* Runs the server refused, by run id: its reason, and the two things that could change the answer
|
|
80
|
+
* (a hash of the run file, and of what the platform said it reads). A refused run is not offered
|
|
81
|
+
* again until one of them changes; re-offering it every cycle got the same refusal and dragged
|
|
82
|
+
* every flow and capsule along with it.
|
|
83
|
+
*/
|
|
84
|
+
refusedRuns?: Record<string, RefusedRun>;
|
|
85
|
+
/**
|
|
86
|
+
* Hashes of the flow set and the capsule set the platform last answered for. A flow saved or
|
|
87
|
+
* edited with no new run used to wait for one, because flows only rode along with other data; now
|
|
88
|
+
* a changed set is sent on its own, and an unchanged one is not sent again.
|
|
89
|
+
*/
|
|
90
|
+
sentFlowsHash?: string;
|
|
91
|
+
sentCapsulesHash?: string;
|
|
92
|
+
/**
|
|
93
|
+
* A flow or capsule set the platform refused, kept so the refusal stays visible on the cycles that
|
|
94
|
+
* do not resend it, and so the set is offered again once it changes or the platform reads more.
|
|
95
|
+
*/
|
|
96
|
+
refusedSets?: Partial<Record<SetPart, RefusedSet>>;
|
|
97
|
+
}
|
|
98
|
+
type SetPart = 'flow' | 'capsule';
|
|
99
|
+
interface RefusedSet {
|
|
100
|
+
hash: string;
|
|
101
|
+
acceptsHash: string;
|
|
102
|
+
count: number;
|
|
103
|
+
reason: string;
|
|
104
|
+
}
|
|
105
|
+
interface RefusedRun {
|
|
106
|
+
reason: string;
|
|
107
|
+
payloadHash: string;
|
|
108
|
+
acceptsHash: string;
|
|
72
109
|
}
|
|
73
110
|
export interface SyncReport {
|
|
74
111
|
/**
|
|
75
|
-
* False if the cycle failed or
|
|
76
|
-
*
|
|
112
|
+
* False if the cycle failed, the server refused anything, or a run or flow was held back for a
|
|
113
|
+
* version the platform does not read. A completed HTTP exchange alone is not a successful push.
|
|
114
|
+
* A record KIND the platform does not know yet is reported in `held` and does not fail it.
|
|
77
115
|
*/
|
|
78
116
|
ok: boolean;
|
|
79
117
|
/** Runs the server accepted this cycle. */
|
|
@@ -86,8 +124,29 @@ export interface SyncReport {
|
|
|
86
124
|
flowsSent: number;
|
|
87
125
|
/** Capsules the server accepted. Zero when it reported none, which includes not knowing the field. */
|
|
88
126
|
capsulesSent: number;
|
|
89
|
-
/**
|
|
127
|
+
/** Derived records the server ACCEPTED this cycle. Empty on a quiet cycle, the normal case. */
|
|
90
128
|
derivedSent: DerivedKind[];
|
|
129
|
+
/**
|
|
130
|
+
* Everything else the server refused: a flow, a capsule, or a derived record. Each reason is a
|
|
131
|
+
* complete sentence naming the item, because this is often read from a background daemon's log.
|
|
132
|
+
*/
|
|
133
|
+
refused: Refusal[];
|
|
134
|
+
/**
|
|
135
|
+
* What was deliberately NOT sent, and why: a record kind or a file version this platform does
|
|
136
|
+
* not read. Sending it would only be refused again on every cycle.
|
|
137
|
+
*/
|
|
138
|
+
held: string[];
|
|
139
|
+
/** Runs refused on an earlier cycle and not offered again, because nothing has changed since. */
|
|
140
|
+
notRetried: Array<{
|
|
141
|
+
runId: string;
|
|
142
|
+
reason: string;
|
|
143
|
+
}>;
|
|
144
|
+
/** Flow or capsule sets refused on an earlier cycle and not offered again. */
|
|
145
|
+
setsNotRetried: Array<{
|
|
146
|
+
part: SetPart;
|
|
147
|
+
count: number;
|
|
148
|
+
reason: string;
|
|
149
|
+
}>;
|
|
91
150
|
/** Decisions collected from the dashboard. */
|
|
92
151
|
pulled: number;
|
|
93
152
|
/** True when the pull page was full — call again now rather than waiting for the next tick. */
|
|
@@ -104,6 +163,12 @@ export interface SyncReport {
|
|
|
104
163
|
/** Set when the cycle could not complete. The local record is untouched either way. */
|
|
105
164
|
error?: string;
|
|
106
165
|
}
|
|
166
|
+
/** One item the server refused that is not a run. */
|
|
167
|
+
export interface Refusal {
|
|
168
|
+
/** `flow`, `capsule`, or the derived record's kind. */
|
|
169
|
+
part: string;
|
|
170
|
+
reason: string;
|
|
171
|
+
}
|
|
107
172
|
interface SyncDeps {
|
|
108
173
|
config: {
|
|
109
174
|
url: string;
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
* Every dependency is injected — the clock, the fetch, the reads and writes — because this is the
|
|
31
31
|
* one piece of Reticle that talks to a network AND to a disk, and it must be provable without either.
|
|
32
32
|
*/
|
|
33
|
-
import { ReticleDir } from '@reticlehq/core';
|
|
33
|
+
import { FlowErrorCode, ReticleDir, SYNC_BATCH_LIMITS } from '@reticlehq/core';
|
|
34
34
|
import { hashPayload } from './sync-hash.js';
|
|
35
35
|
/** The server's own doors. Kept beside the code that calls them, like the other cloud paths. */
|
|
36
36
|
const SYNC_PATH = '/v1/sync';
|
|
@@ -46,6 +46,10 @@ const DERIVED_RECORDS = [
|
|
|
46
46
|
{ kind: 'impact', file: ReticleDir.IMPACT_FILE },
|
|
47
47
|
{ kind: 'flake', file: ReticleDir.FLAKE_FILE },
|
|
48
48
|
{ kind: 'intent', file: ReticleDir.INTENT_FILE },
|
|
49
|
+
// How each page normally behaves, and how strong each flow's checks are. Kept only here, they
|
|
50
|
+
// cannot tell a server a page that drifted from one that always behaved that way.
|
|
51
|
+
{ kind: 'envelopes', file: ReticleDir.ENVELOPES_FILE },
|
|
52
|
+
{ kind: 'assertion-tiers', file: ReticleDir.TIERS_FILE },
|
|
49
53
|
];
|
|
50
54
|
const asJson = (text) => {
|
|
51
55
|
try {
|
|
@@ -56,6 +60,57 @@ const asJson = (text) => {
|
|
|
56
60
|
}
|
|
57
61
|
};
|
|
58
62
|
const isRecord = (v) => 'object' === typeof v && null !== v;
|
|
63
|
+
/** A list of numbers, or undefined when the server did not send a list at all. */
|
|
64
|
+
const numbersIn = (v) => Array.isArray(v) ? v.filter((n) => 'number' === typeof n) : undefined;
|
|
65
|
+
/** A numeric field of an artifact, or undefined when it has none. */
|
|
66
|
+
const numberAt = (v, field) => {
|
|
67
|
+
const n = isRecord(v) ? v[field] : undefined;
|
|
68
|
+
return 'number' === typeof n ? n : undefined;
|
|
69
|
+
};
|
|
70
|
+
/** The name a person knows a flow by, falling back to its position. */
|
|
71
|
+
const flowName = (flow, index) => {
|
|
72
|
+
const name = isRecord(flow) ? flow['name'] : undefined;
|
|
73
|
+
return 'string' === typeof name && name.length > 0 ? name : `flow #${String(index)}`;
|
|
74
|
+
};
|
|
75
|
+
const FLOW_VERSION_FIX = 'update the platform or remove `compare` from the flow';
|
|
76
|
+
const RUN_VERSION_FIX = 'update the platform';
|
|
77
|
+
/** The server's per-part answer: accepted count and rejection list, each read defensively. */
|
|
78
|
+
function partResult(body, part) {
|
|
79
|
+
const raw = isRecord(body[part]) ? body[part] : {};
|
|
80
|
+
const accepted = 'number' === typeof raw['accepted'] ? raw['accepted'] : 0;
|
|
81
|
+
const rejected = (Array.isArray(raw['rejected']) ? raw['rejected'] : [])
|
|
82
|
+
.filter(isRecord)
|
|
83
|
+
.map((r) => ({
|
|
84
|
+
index: 'number' === typeof r['index'] ? r['index'] : -1,
|
|
85
|
+
reason: 'string' === typeof r['reason'] ? r['reason'] : 'no reason given',
|
|
86
|
+
code: r['code'],
|
|
87
|
+
version: r['version'],
|
|
88
|
+
}));
|
|
89
|
+
return { accepted, rejected };
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Split runs into requests bounded by count and by serialized size. A run bigger than the byte
|
|
93
|
+
* bound on its own still goes, alone: it cannot be split, and holding it back would hide it.
|
|
94
|
+
*/
|
|
95
|
+
function batchRuns(runs) {
|
|
96
|
+
const batches = [];
|
|
97
|
+
let current = [];
|
|
98
|
+
let bytes = 0;
|
|
99
|
+
for (const run of runs) {
|
|
100
|
+
const size = Buffer.byteLength(JSON.stringify(run.payload));
|
|
101
|
+
const full = current.length >= SYNC_BATCH_LIMITS.MAX_RUNS || bytes + size > SYNC_BATCH_LIMITS.MAX_BYTES;
|
|
102
|
+
if (current.length > 0 && full) {
|
|
103
|
+
batches.push(current);
|
|
104
|
+
current = [];
|
|
105
|
+
bytes = 0;
|
|
106
|
+
}
|
|
107
|
+
current.push(run);
|
|
108
|
+
bytes += size;
|
|
109
|
+
}
|
|
110
|
+
if (current.length > 0)
|
|
111
|
+
batches.push(current);
|
|
112
|
+
return batches;
|
|
113
|
+
}
|
|
59
114
|
/**
|
|
60
115
|
* Run one cycle. Never throws: the local record is already safe on disk, and a sync that can take
|
|
61
116
|
* down the thing it is backing up is worse than no sync.
|
|
@@ -68,6 +123,10 @@ export async function runSyncCycle(deps) {
|
|
|
68
123
|
flowsSent: 0,
|
|
69
124
|
capsulesSent: 0,
|
|
70
125
|
derivedSent: [],
|
|
126
|
+
refused: [],
|
|
127
|
+
held: [],
|
|
128
|
+
notRetried: [],
|
|
129
|
+
setsNotRetried: [],
|
|
71
130
|
pulled: 0,
|
|
72
131
|
morePending: false,
|
|
73
132
|
};
|
|
@@ -116,66 +175,213 @@ export async function runSyncCycle(deps) {
|
|
|
116
175
|
// Same rule for the hashes: only a string can equal a hash we computed, so anything else reads
|
|
117
176
|
// as "unknown" and the record is sent once. Re-sending costs a request; skipping costs the data.
|
|
118
177
|
const hashes = isRecord(held.stateHashes) ? held.stateHashes : {};
|
|
119
|
-
|
|
178
|
+
/*
|
|
179
|
+
* What this platform reads. `accepts` says so outright; a platform that predates it still lists
|
|
180
|
+
* every record kind it knows in `stateHashes`, null when it holds nothing. A kind it does not
|
|
181
|
+
* list is one it does not know, and it will never answer with a hash for it: sending on "hash
|
|
182
|
+
* differs" then re-sent that record, and every flow and capsule riding with it, on every cycle.
|
|
183
|
+
*/
|
|
184
|
+
const accepts = isRecord(held.accepts) ? held.accepts : {};
|
|
185
|
+
const acceptedKinds = new Set(Array.isArray(accepts['derived']) ? onlyStrings(accepts['derived']) : Object.keys(hashes));
|
|
186
|
+
const flowVersions = numbersIn(accepts['flowVersions']);
|
|
187
|
+
const runVersions = numbersIn(accepts['runVersions']);
|
|
188
|
+
const heldBack = [];
|
|
189
|
+
/** Held runs and flows mean the dashboard is missing something; unknown record kinds do not. */
|
|
190
|
+
let heldArtifacts = false;
|
|
191
|
+
// 2. SEND — only what the server does not already have, and only what it can read.
|
|
120
192
|
const allRuns = deps.source.runs();
|
|
121
|
-
const
|
|
122
|
-
const
|
|
193
|
+
const acceptsHash = hashPayload(held.accepts ?? null);
|
|
194
|
+
const priorRefusals = isRecord(deps.state.refusedRuns) ? deps.state.refusedRuns : {};
|
|
195
|
+
const refusedRuns = {};
|
|
196
|
+
const notRetried = [];
|
|
197
|
+
const unsent = allRuns.filter((run) => {
|
|
198
|
+
if (known.has(run.runId))
|
|
199
|
+
return false;
|
|
200
|
+
const prior = priorRefusals[run.runId];
|
|
201
|
+
const unchanged = prior !== undefined &&
|
|
202
|
+
'string' === typeof prior.reason &&
|
|
203
|
+
prior.acceptsHash === acceptsHash &&
|
|
204
|
+
prior.payloadHash === hashPayload(run.payload);
|
|
205
|
+
if (!unchanged)
|
|
206
|
+
return true;
|
|
207
|
+
refusedRuns[run.runId] = prior;
|
|
208
|
+
notRetried.push({ runId: run.runId, reason: prior.reason });
|
|
209
|
+
return false;
|
|
210
|
+
});
|
|
211
|
+
const sendable = unsent.filter((run) => {
|
|
212
|
+
const version = numberAt(run.payload, 'schemaVersion');
|
|
213
|
+
if (runVersions === undefined || version === undefined || runVersions.includes(version))
|
|
214
|
+
return true;
|
|
215
|
+
heldBack.push(`this platform reads run versions ${runVersions.join(', ')}; run ${run.runId} is version ${String(version)} — ${RUN_VERSION_FIX}`);
|
|
216
|
+
heldArtifacts = true;
|
|
217
|
+
return false;
|
|
218
|
+
});
|
|
219
|
+
const derivedOffered = [];
|
|
220
|
+
const unsupported = [];
|
|
123
221
|
const bundle = {};
|
|
124
|
-
if (unsent.length > 0)
|
|
125
|
-
bundle['runs'] = unsent.map((r) => r.payload);
|
|
126
222
|
for (const { kind } of DERIVED_RECORDS) {
|
|
127
223
|
const payload = deps.source.derived(kind);
|
|
128
224
|
if (payload === undefined)
|
|
129
225
|
continue;
|
|
226
|
+
if (!acceptedKinds.has(kind)) {
|
|
227
|
+
unsupported.push(kind);
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
130
230
|
// The one comparison the whole protocol rests on. Same hash, do not send it.
|
|
131
231
|
if (hashPayload(payload) === hashes[kind])
|
|
132
232
|
continue;
|
|
133
233
|
bundle[kind] = payload;
|
|
134
|
-
|
|
234
|
+
derivedOffered.push(kind);
|
|
135
235
|
}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
236
|
+
if (unsupported.length > 0)
|
|
237
|
+
heldBack.push(`this platform does not accept ${unsupported.join(', ')} yet`);
|
|
238
|
+
// Flows and capsules are upserted by name/id, so the whole set goes whenever it differs from the
|
|
239
|
+
// set the platform last answered for, and rides along with anything else being sent.
|
|
240
|
+
const flows = deps.source.flows().filter((flow, index) => {
|
|
241
|
+
const version = numberAt(flow, 'version');
|
|
242
|
+
if (flowVersions === undefined || version === undefined || flowVersions.includes(version))
|
|
243
|
+
return true;
|
|
244
|
+
heldBack.push(`this platform reads flow versions ${flowVersions.join(', ')}; ${flowName(flow, index)} is version ${String(version)} — ${FLOW_VERSION_FIX}`);
|
|
245
|
+
heldArtifacts = true;
|
|
246
|
+
return false;
|
|
247
|
+
});
|
|
248
|
+
const capsulesAll = deps.source.capsules();
|
|
249
|
+
const flowsHash = hashPayload(flows);
|
|
250
|
+
const capsulesHash = hashPayload(capsulesAll);
|
|
251
|
+
const priorSets = isRecord(deps.state.refusedSets) ? deps.state.refusedSets : {};
|
|
252
|
+
const setsNotRetried = [];
|
|
253
|
+
const refusedSets = {};
|
|
254
|
+
/** A set is due when it changed, or when it was refused and the platform now reads more. */
|
|
255
|
+
const due = (part, hash, size, sentHash) => {
|
|
256
|
+
if (0 === size)
|
|
257
|
+
return false;
|
|
258
|
+
const prior = priorSets[part];
|
|
259
|
+
if (prior !== undefined && prior.hash === hash) {
|
|
260
|
+
if (prior.acceptsHash !== acceptsHash)
|
|
261
|
+
return true;
|
|
262
|
+
refusedSets[part] = prior;
|
|
263
|
+
setsNotRetried.push({ part, count: prior.count, reason: prior.reason });
|
|
264
|
+
return false;
|
|
265
|
+
}
|
|
266
|
+
return hash !== sentHash;
|
|
267
|
+
};
|
|
268
|
+
const flowsChanged = due('flow', flowsHash, flows.length, deps.state.sentFlowsHash);
|
|
269
|
+
const capsulesChanged = due('capsule', capsulesHash, capsulesAll.length, deps.state.sentCapsulesHash);
|
|
270
|
+
const ridesAlong = sendable.length > 0 || derivedOffered.length > 0 || flowsChanged || capsulesChanged;
|
|
271
|
+
if (ridesAlong && flows.length > 0)
|
|
141
272
|
bundle['flows'] = flows;
|
|
142
|
-
|
|
143
|
-
// nothing else would wake the server on every tick of an idle machine.
|
|
144
|
-
const capsules = ridesAlong ? deps.source.capsules() : [];
|
|
273
|
+
const capsules = ridesAlong ? capsulesAll : [];
|
|
145
274
|
if (capsules.length > 0)
|
|
146
275
|
bundle['capsules'] = capsules;
|
|
147
276
|
let runsSent = 0;
|
|
148
277
|
let flowsSent = 0;
|
|
149
278
|
let capsulesSent = 0;
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
279
|
+
const runsRejected = [];
|
|
280
|
+
const refused = [];
|
|
281
|
+
const derivedSent = [];
|
|
282
|
+
let pushError;
|
|
283
|
+
/*
|
|
284
|
+
* Runs go up in bounded batches; everything else rides in the first. One request used to carry
|
|
285
|
+
* the whole backlog, and a backlog over the platform's body limit was refused whole, forever.
|
|
286
|
+
*/
|
|
287
|
+
const batches = batchRuns(sendable);
|
|
288
|
+
const requests = Object.keys(bundle).length > 0 || batches.length > 0 ? Math.max(1, batches.length) : 0;
|
|
289
|
+
let offset = 0;
|
|
290
|
+
for (let i = 0; i < requests; i += 1) {
|
|
291
|
+
const batch = batches[i] ?? [];
|
|
292
|
+
const body = 0 === i ? { ...bundle } : {};
|
|
293
|
+
if (batch.length > 0)
|
|
294
|
+
body['runs'] = batch.map((r) => r.payload);
|
|
295
|
+
const pushed = await call(SYNC_PATH, { method: 'POST', body: JSON.stringify(body) });
|
|
153
296
|
if (200 !== pushed.status) {
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
return { ...empty, error };
|
|
297
|
+
pushError = `sync ${String(pushed.status)}: ${pushed.text.slice(0, 200)}`;
|
|
298
|
+
break;
|
|
157
299
|
}
|
|
158
|
-
const
|
|
159
|
-
const runs =
|
|
160
|
-
|
|
161
|
-
// Absent means the server said nothing about capsules — an older one that does not know the
|
|
162
|
-
// field. Read as zero accepted, never as an error: an added field must not break a sync that
|
|
163
|
-
// was otherwise fine, and the push's own status code is what reports a real refusal.
|
|
164
|
-
const capsulesPart = isRecord(body['capsules']) ? body['capsules'] : {};
|
|
165
|
-
runsSent = 'number' === typeof runs['accepted'] ? runs['accepted'] : 0;
|
|
166
|
-
flowsSent = 'number' === typeof flowsPart['accepted'] ? flowsPart['accepted'] : 0;
|
|
167
|
-
capsulesSent = 'number' === typeof capsulesPart['accepted'] ? capsulesPart['accepted'] : 0;
|
|
168
|
-
runsRejected = Array.isArray(runs['rejected'])
|
|
169
|
-
? runs['rejected']
|
|
170
|
-
: [];
|
|
171
|
-
nextState.lastPushAt = deps.now();
|
|
300
|
+
const answer = isRecord(pushed.json) ? pushed.json : {};
|
|
301
|
+
const runs = partResult(answer, 'runs');
|
|
302
|
+
runsSent += runs.accepted;
|
|
172
303
|
// Accepted means the server has it. A rejected run was refused by index, so it is exactly as
|
|
173
304
|
// unsent as it was before and must never be remembered as delivered.
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
|
|
305
|
+
const refusedHere = new Map(runs.rejected.map((r) => [r.index, r.reason]));
|
|
306
|
+
batch.forEach((run, index) => {
|
|
307
|
+
const reason = refusedHere.get(index);
|
|
308
|
+
if (reason === undefined)
|
|
177
309
|
known.add(run.runId);
|
|
310
|
+
else
|
|
311
|
+
refusedRuns[run.runId] = { reason, payloadHash: hashPayload(run.payload), acceptsHash };
|
|
178
312
|
});
|
|
313
|
+
runsRejected.push(...runs.rejected.map((r) => ({ index: r.index + offset, reason: r.reason })));
|
|
314
|
+
offset += batch.length;
|
|
315
|
+
if (0 !== i)
|
|
316
|
+
continue;
|
|
317
|
+
// Absent means the server said nothing about a part — an older one that does not know the
|
|
318
|
+
// field. Read as zero accepted, never as an error: an added field must not break a sync.
|
|
319
|
+
const flowsPart = partResult(answer, 'flows');
|
|
320
|
+
const capsulesPart = partResult(answer, 'capsules');
|
|
321
|
+
flowsSent = flowsPart.accepted;
|
|
322
|
+
capsulesSent = capsulesPart.accepted;
|
|
323
|
+
for (const r of flowsPart.rejected) {
|
|
324
|
+
const name = flowName(flows[r.index], r.index);
|
|
325
|
+
refused.push({
|
|
326
|
+
part: 'flow',
|
|
327
|
+
reason: FlowErrorCode.WRONG_VERSION === r.code && 'number' === typeof r.version
|
|
328
|
+
? `${name} is version ${String(r.version)} — ${FLOW_VERSION_FIX}`
|
|
329
|
+
: `flow ${name}: ${r.reason}`,
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
for (const r of capsulesPart.rejected)
|
|
333
|
+
refused.push({ part: 'capsule', reason: `capsule #${String(r.index)}: ${r.reason}` });
|
|
334
|
+
const firstOf = (part) => refused.find((r) => r.part === part)?.reason ?? 'no reason given';
|
|
335
|
+
if (flowsPart.rejected.length > 0)
|
|
336
|
+
refusedSets.flow = {
|
|
337
|
+
hash: flowsHash,
|
|
338
|
+
acceptsHash,
|
|
339
|
+
count: flowsPart.rejected.length,
|
|
340
|
+
reason: firstOf('flow'),
|
|
341
|
+
};
|
|
342
|
+
if (capsulesPart.rejected.length > 0)
|
|
343
|
+
refusedSets.capsule = {
|
|
344
|
+
hash: capsulesHash,
|
|
345
|
+
acceptsHash,
|
|
346
|
+
count: capsulesPart.rejected.length,
|
|
347
|
+
reason: firstOf('capsule'),
|
|
348
|
+
};
|
|
349
|
+
// `state` has a key per record sent: "accepted", or the message that refused it.
|
|
350
|
+
const state = isRecord(answer['state']) ? answer['state'] : {};
|
|
351
|
+
for (const kind of derivedOffered) {
|
|
352
|
+
const verdict = state[kind];
|
|
353
|
+
if ('accepted' === verdict)
|
|
354
|
+
derivedSent.push(kind);
|
|
355
|
+
else if ('string' === typeof verdict)
|
|
356
|
+
refused.push({ part: kind, reason: `${kind}: ${verdict}` });
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
if (requests > 0 && pushError === undefined) {
|
|
360
|
+
nextState.lastPushAt = deps.now();
|
|
361
|
+
// The platform answered for these sets, refusals included (reported above, and not re-offered
|
|
362
|
+
// until the set changes — the same rule as a refused run).
|
|
363
|
+
if (ridesAlong && flows.length > 0)
|
|
364
|
+
nextState.sentFlowsHash = flowsHash;
|
|
365
|
+
if (capsules.length > 0)
|
|
366
|
+
nextState.sentCapsulesHash = capsulesHash;
|
|
367
|
+
}
|
|
368
|
+
nextState.refusedRuns = refusedRuns;
|
|
369
|
+
nextState.refusedSets = refusedSets;
|
|
370
|
+
if (pushError !== undefined) {
|
|
371
|
+
nextState.sentRunIds = allRuns.map((r) => r.runId).filter((id) => known.has(id));
|
|
372
|
+
deps.sink.writeState({ ...nextState, lastError: pushError });
|
|
373
|
+
return {
|
|
374
|
+
...empty,
|
|
375
|
+
runsSent,
|
|
376
|
+
runsRejected,
|
|
377
|
+
flowsSent,
|
|
378
|
+
capsulesSent,
|
|
379
|
+
derivedSent,
|
|
380
|
+
refused,
|
|
381
|
+
held: heldBack,
|
|
382
|
+
notRetried,
|
|
383
|
+
error: pushError,
|
|
384
|
+
};
|
|
179
385
|
}
|
|
180
386
|
/*
|
|
181
387
|
* Bounded by the runs still on disk rather than by a cap. Retention already decides how many
|
|
@@ -198,6 +404,9 @@ export async function runSyncCycle(deps) {
|
|
|
198
404
|
capsulesSent,
|
|
199
405
|
derivedSent,
|
|
200
406
|
runsRejected,
|
|
407
|
+
refused,
|
|
408
|
+
held: heldBack,
|
|
409
|
+
notRetried,
|
|
201
410
|
error,
|
|
202
411
|
};
|
|
203
412
|
}
|
|
@@ -220,16 +429,22 @@ export async function runSyncCycle(deps) {
|
|
|
220
429
|
if ('string' === typeof pulled.cursor)
|
|
221
430
|
nextState.cursor = pulled.cursor;
|
|
222
431
|
nextState.lastPullAt = deps.now();
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
432
|
+
const report = {
|
|
433
|
+
// Anything refused, or any artifact held back, means the dashboard is missing something.
|
|
434
|
+
ok: 0 === runsRejected.length &&
|
|
435
|
+
0 === refused.length &&
|
|
436
|
+
0 === notRetried.length &&
|
|
437
|
+
0 === setsNotRetried.length &&
|
|
438
|
+
!heldArtifacts,
|
|
228
439
|
runsSent,
|
|
229
440
|
runsRejected,
|
|
230
441
|
flowsSent,
|
|
231
442
|
capsulesSent,
|
|
232
443
|
derivedSent,
|
|
444
|
+
refused,
|
|
445
|
+
held: heldBack,
|
|
446
|
+
notRetried,
|
|
447
|
+
setsNotRetried,
|
|
233
448
|
pulled: decisions.length,
|
|
234
449
|
morePending: true === pulled.more,
|
|
235
450
|
/*
|
|
@@ -241,6 +456,16 @@ export async function runSyncCycle(deps) {
|
|
|
241
456
|
0 === deps.source.flows().length &&
|
|
242
457
|
DERIVED_RECORDS.every(({ kind }) => deps.source.derived(kind) === undefined),
|
|
243
458
|
};
|
|
459
|
+
/*
|
|
460
|
+
* A refusal is kept as the last error rather than cleared by a completed exchange: it is what
|
|
461
|
+
* `reticle whoami` shows, and "the transfer worked" is not "the dashboard has it".
|
|
462
|
+
*/
|
|
463
|
+
if (report.ok)
|
|
464
|
+
delete nextState.lastError;
|
|
465
|
+
else
|
|
466
|
+
nextState.lastError = problemsOf(report).join('; ');
|
|
467
|
+
deps.sink.writeState(nextState);
|
|
468
|
+
return report;
|
|
244
469
|
}
|
|
245
470
|
catch (error) {
|
|
246
471
|
// A network that is down is not an error condition for a local-first tool; it is Tuesday.
|
|
@@ -293,9 +518,10 @@ export function describeSync(report) {
|
|
|
293
518
|
* to send, 3 rejected" — something was very much sent, and the reader is told both that it was
|
|
294
519
|
* not and nothing about why.
|
|
295
520
|
*/
|
|
521
|
+
const rejectedCount = report.runsRejected.length + report.refused.length;
|
|
296
522
|
const push = sent.length > 0
|
|
297
523
|
? `sent ${sent.join(' + ')}`
|
|
298
|
-
:
|
|
524
|
+
: rejectedCount > 0
|
|
299
525
|
? 'nothing accepted'
|
|
300
526
|
: true === report.localIsEmpty
|
|
301
527
|
? // Not the same statement as "nothing to send", which describes a repo that is simply up
|
|
@@ -306,15 +532,32 @@ export function describeSync(report) {
|
|
|
306
532
|
const pull = 0 === report.pulled
|
|
307
533
|
? ''
|
|
308
534
|
: `, pulled ${String(report.pulled)} decision(s)${report.morePending ? ' (more waiting)' : ''}`;
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
535
|
+
return `${push}${pull}${problemsOf(report)
|
|
536
|
+
.map((problem) => `, ${problem}`)
|
|
537
|
+
.join('')}`;
|
|
538
|
+
}
|
|
539
|
+
/**
|
|
540
|
+
* What went wrong, one phrase per kind of problem.
|
|
541
|
+
*
|
|
542
|
+
* One reason, not a count. A rejection count tells somebody they have a problem and nothing about
|
|
543
|
+
* which problem — and these arrive from a BACKGROUND daemon, so the summary line is often the only
|
|
544
|
+
* place anybody ever sees it. Rejections in one cycle almost always share a cause (a version skew
|
|
545
|
+
* refuses every payload the same way), so the first reason plus the count is the whole story
|
|
546
|
+
* without printing a line per item; `reticle sync` still lists them all.
|
|
547
|
+
*/
|
|
548
|
+
function problemsOf(report) {
|
|
549
|
+
const problems = [];
|
|
550
|
+
const count = report.runsRejected.length + report.refused.length;
|
|
551
|
+
if (count > 0) {
|
|
552
|
+
const first = report.runsRejected[0]?.reason ?? report.refused[0]?.reason ?? 'no reason given';
|
|
553
|
+
problems.push(`${String(count)} rejected — ${first}`);
|
|
554
|
+
}
|
|
555
|
+
const firstRefusal = report.notRetried[0];
|
|
556
|
+
if (firstRefusal !== undefined)
|
|
557
|
+
problems.push(`refused, not retried: ${String(report.notRetried.length)} run(s) (${firstRefusal.reason})`);
|
|
558
|
+
for (const set of report.setsNotRetried)
|
|
559
|
+
problems.push(`refused, not retried: ${String(set.count)} ${set.part}(s) (${set.reason})`);
|
|
560
|
+
if (report.held.length > 0)
|
|
561
|
+
problems.push(`not sent: ${report.held.join('; ')}`);
|
|
562
|
+
return problems;
|
|
320
563
|
}
|
|
@@ -63,6 +63,12 @@ const NUDGE_DELAY_MS = 1_500;
|
|
|
63
63
|
*/
|
|
64
64
|
const FLUSH_WAIT_MS = 3_000;
|
|
65
65
|
const FLUSH_POLL_MS = 25;
|
|
66
|
+
/** Whether a cycle landed or collected anything — what earns the fast interval and a log line. */
|
|
67
|
+
const movedAnything = (report) => report.runsSent > 0 ||
|
|
68
|
+
report.flowsSent > 0 ||
|
|
69
|
+
report.capsulesSent > 0 ||
|
|
70
|
+
report.derivedSent.length > 0 ||
|
|
71
|
+
report.pulled > 0;
|
|
66
72
|
const defaultRequest = async (url, init) => {
|
|
67
73
|
const res = await fetch(url, init);
|
|
68
74
|
return { status: res.status, text: await res.text() };
|
|
@@ -150,11 +156,9 @@ export function startSyncDaemon(deps) {
|
|
|
150
156
|
log('reticle_cloud_sync_failed', { root, error: report.error });
|
|
151
157
|
continue;
|
|
152
158
|
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
report.derivedSent.length > 0 ||
|
|
157
|
-
report.pulled > 0;
|
|
159
|
+
if (!report.ok)
|
|
160
|
+
log('reticle_cloud_sync_failed', { root, error: describeSync(report) });
|
|
161
|
+
const moved = movedAnything(report);
|
|
158
162
|
// The root is NAMED here and not in the single-root log below, because with several repos
|
|
159
163
|
// reporting, "synced 3 runs" without a directory is not an answer to "synced from where".
|
|
160
164
|
if (moved)
|
|
@@ -205,27 +209,27 @@ export function startSyncDaemon(deps) {
|
|
|
205
209
|
// failed. The log below is deliberately quiet when idle, which is right for a log and wrong
|
|
206
210
|
// for a hook: from outside, "nothing changed" and "sync has been broken since Tuesday" are
|
|
207
211
|
// the same silence, and a consumer that cannot tell them apart has to guess.
|
|
212
|
+
/*
|
|
213
|
+
* `ok`, not "no transport error": a push the server refused completed its HTTP exchange and
|
|
214
|
+
* still left the dashboard without the thing. The refusal's reason rides as the error.
|
|
215
|
+
*/
|
|
216
|
+
const failure = report.error ?? (report.ok ? undefined : describeSync(report));
|
|
208
217
|
emitSyncHook({
|
|
209
218
|
runsPushed: report.runsSent,
|
|
210
|
-
ok: report.
|
|
211
|
-
...(
|
|
219
|
+
ok: report.ok,
|
|
220
|
+
...(failure === undefined ? {} : { error: failure }),
|
|
212
221
|
});
|
|
213
|
-
if (
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
log('reticle_cloud_sync_failed', { error: report.error });
|
|
217
|
-
}
|
|
222
|
+
if (failure !== undefined && failure !== reportedError) {
|
|
223
|
+
reportedError = failure;
|
|
224
|
+
log('reticle_cloud_sync_failed', { error: failure });
|
|
218
225
|
}
|
|
219
|
-
|
|
220
|
-
|
|
226
|
+
if (report.error === undefined) {
|
|
227
|
+
if (failure === undefined)
|
|
228
|
+
reportedError = undefined;
|
|
221
229
|
// Only when something actually moved. A per-minute "nothing to send" is noise that trains
|
|
222
230
|
// people to stop reading the log.
|
|
223
|
-
const moved = report
|
|
224
|
-
|
|
225
|
-
report.capsulesSent > 0 ||
|
|
226
|
-
report.derivedSent.length > 0 ||
|
|
227
|
-
report.pulled > 0;
|
|
228
|
-
if (moved)
|
|
231
|
+
const moved = movedAnything(report);
|
|
232
|
+
if (moved && failure === undefined)
|
|
229
233
|
log('reticle_cloud_synced', { summary: describeSync(report) });
|
|
230
234
|
/*
|
|
231
235
|
* A cycle that moved something means a drive is in progress, so the next one comes sooner;
|
|
@@ -55,13 +55,25 @@ function readJsonDir(dir) {
|
|
|
55
55
|
return [];
|
|
56
56
|
}
|
|
57
57
|
}
|
|
58
|
-
/**
|
|
58
|
+
/**
|
|
59
|
+
* Flows: one directory deeper under the app's own project id, and also flat in `.reticle/flows/`,
|
|
60
|
+
* which the flow store still saves and loads (a flow saved with no project id, or from before flows
|
|
61
|
+
* were kept per project). Reading only the subfolders skipped every flat flow while the cycle
|
|
62
|
+
* reported ok.
|
|
63
|
+
*/
|
|
59
64
|
function readFlows(root) {
|
|
60
65
|
const dir = join(root, ReticleDir.FLOWS_SUBDIR);
|
|
61
66
|
try {
|
|
62
67
|
if (!existsSync(dir))
|
|
63
68
|
return [];
|
|
64
|
-
return readdirSync(dir).flatMap((
|
|
69
|
+
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
|
70
|
+
if (entry.isDirectory())
|
|
71
|
+
return readJsonDir(join(dir, entry.name));
|
|
72
|
+
if (!entry.name.endsWith(JSON_SUFFIX))
|
|
73
|
+
return [];
|
|
74
|
+
const flow = readJson(join(dir, entry.name));
|
|
75
|
+
return flow === undefined ? [] : [flow];
|
|
76
|
+
});
|
|
65
77
|
}
|
|
66
78
|
catch {
|
|
67
79
|
return [];
|
|
@@ -71,6 +83,8 @@ const DERIVED_FILE = {
|
|
|
71
83
|
impact: ReticleDir.IMPACT_FILE,
|
|
72
84
|
flake: ReticleDir.FLAKE_FILE,
|
|
73
85
|
intent: ReticleDir.INTENT_FILE,
|
|
86
|
+
envelopes: ReticleDir.ENVELOPES_FILE,
|
|
87
|
+
'assertion-tiers': ReticleDir.TIERS_FILE,
|
|
74
88
|
};
|
|
75
89
|
/** The directory the sharded intent store writes into, beside the legacy flat file. */
|
|
76
90
|
const INTENT_SUBDIR = ReticleDir.INTENT_SUBDIR;
|