@checkstack/satellite 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,85 @@
1
1
  # @checkstack/satellite
2
2
 
3
+ ## 0.4.1
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [a57f7db]
8
+ - @checkstack/backend-api@0.20.0
9
+ - @checkstack/script-packages-backend@0.2.1
10
+
11
+ ## 0.4.0
12
+
13
+ ### Minor Changes
14
+
15
+ - 270ef29: Satellite-side script-package reconciliation over the WS channel.
16
+
17
+ - `satellite-common`: WS request/reply messages for pulling the manifest +
18
+ blobs from core (`request_script_package_manifest` /
19
+ `request_script_package_blob` -> `script_package_manifest` /
20
+ `script_package_blob`).
21
+ - `satellite-backend`: the WS handler answers those requests from the
22
+ script-packages store (satellites pull from core, never the registry).
23
+ - `@checkstack/satellite`: the client gains request/reply plumbing + a
24
+ `SatelliteScriptPackages` manager that reuses the Phase 2 reconciler
25
+ (`reconcileToHash` + `createReconcileFsDeps`) over the WS transport. It
26
+ reconciles on a `refresh_script_packages` push and on the
27
+ assignment-carried hash (startup / reconnect backstop), pulls only missing
28
+ blobs (delta), materializes via `bun install --offline`, atomically flips
29
+ `current`, reports sync state back, and degrades cleanly (error state, no
30
+ stale tree, no registry access) when a blob can't be fetched. Reconciles
31
+ are serialized + coalesced + idempotent.
32
+
33
+ - 270ef29: Secrets platform Phase 3: just-in-time secret delivery to satellites + source-side masking, and central-execution injection for healthcheck collectors.
34
+
35
+ - New satellite WS messages `request_run_secrets` / `run_secrets`: just
36
+ before a satellite runs a collector that declares a `secretEnv`, it asks
37
+ core for that collector's resolved env; core resolves ONLY the secrets the
38
+ collector's OWN persisted assignment declares (least-privilege — the
39
+ satellite cannot choose) and replies with the env map (or a clear error).
40
+ The satellite injects it memory-only for the run and drops it on
41
+ completion. Secrets never ride the persisted assignment and never touch
42
+ disk.
43
+ - Source-side masking: the satellite runs `maskSecrets` over the collector's
44
+ stdout/stderr/result/error using the run's delivered values BEFORE the
45
+ result leaves the satellite (defense in depth).
46
+ - `CollectorStrategy.execute` gains an optional `secretEnv`. The
47
+ inline-script and shell collectors inject it into the runner
48
+ (`process.env` / `$VAR`) and mask the values out of their output.
49
+ - Healthcheck collectors running centrally (the queue executor) also resolve
50
+ - inject `secretEnv` via `secretResolverRef`, closing the gap where a
51
+ centrally-run secretEnv collector got no secrets. A missing required
52
+ secret fails the run clearly in all paths.
53
+
54
+ ### Patch Changes
55
+
56
+ - Updated dependencies [270ef29]
57
+ - Updated dependencies [270ef29]
58
+ - Updated dependencies [270ef29]
59
+ - Updated dependencies [b995afb]
60
+ - Updated dependencies [270ef29]
61
+ - Updated dependencies [270ef29]
62
+ - Updated dependencies [270ef29]
63
+ - Updated dependencies [270ef29]
64
+ - Updated dependencies [270ef29]
65
+ - Updated dependencies [270ef29]
66
+ - Updated dependencies [b995afb]
67
+ - Updated dependencies [b995afb]
68
+ - Updated dependencies [270ef29]
69
+ - Updated dependencies [270ef29]
70
+ - Updated dependencies [270ef29]
71
+ - Updated dependencies [b995afb]
72
+ - Updated dependencies [270ef29]
73
+ - Updated dependencies [b995afb]
74
+ - Updated dependencies [270ef29]
75
+ - Updated dependencies [270ef29]
76
+ - Updated dependencies [270ef29]
77
+ - Updated dependencies [270ef29]
78
+ - Updated dependencies [270ef29]
79
+ - @checkstack/backend-api@0.19.0
80
+ - @checkstack/script-packages-backend@0.2.0
81
+ - @checkstack/satellite-common@0.7.0
82
+
3
83
  ## 0.3.0
4
84
 
5
85
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/satellite",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "license": "Elastic-2.0",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -11,13 +11,14 @@
11
11
  "lint:code": "eslint . --max-warnings 0"
12
12
  },
13
13
  "dependencies": {
14
- "@checkstack/satellite-common": "0.5.3",
15
- "@checkstack/backend-api": "0.17.1",
16
- "@checkstack/common": "0.11.0"
14
+ "@checkstack/satellite-common": "0.6.0",
15
+ "@checkstack/backend-api": "0.18.0",
16
+ "@checkstack/script-packages-backend": "0.1.0",
17
+ "@checkstack/common": "0.12.0"
17
18
  },
18
19
  "devDependencies": {
19
20
  "@checkstack/tsconfig": "0.0.7",
20
- "@checkstack/scripts": "0.3.3",
21
+ "@checkstack/scripts": "0.3.4",
21
22
  "@types/bun": "^1.0.0",
22
23
  "typescript": "^5.0.0"
23
24
  }
package/src/index.ts CHANGED
@@ -7,10 +7,12 @@ import type {
7
7
  TransportClient,
8
8
  CollectorRunContext,
9
9
  } from "@checkstack/backend-api";
10
+ import { resolveScriptPackagesDir } from "@checkstack/script-packages-backend";
10
11
  import { SatelliteClient } from "./satellite-client";
11
12
  import { Scheduler } from "./scheduler";
12
13
  import { loadStrategies } from "./strategy-loader";
13
14
  import { buildRunContext } from "./run-context";
15
+ import { SatelliteScriptPackages } from "./satellite-script-packages";
14
16
 
15
17
  // =============================================================================
16
18
  // Environment validation — fail fast if required vars are missing
@@ -70,8 +72,29 @@ const { healthCheckRegistry, collectorRegistry } = await loadStrategies({
70
72
  // 4. Close client and report result
71
73
  // =============================================================================
72
74
 
75
+ /** Whether a collector config declares a non-empty secretEnv mapping. */
76
+ function declaresSecretEnv(config: Record<string, unknown>): boolean {
77
+ const se = config.secretEnv;
78
+ return (
79
+ typeof se === "object" &&
80
+ se !== null &&
81
+ Object.keys(se as Record<string, unknown>).length > 0
82
+ );
83
+ }
84
+
73
85
  async function executeAssignment(
74
86
  assignment: SatelliteAssignment,
87
+ deps: {
88
+ /**
89
+ * Request a collector run's resolved secret env from core (JIT). Throws
90
+ * on delivery/resolution failure so the collector fails clearly.
91
+ */
92
+ requestRunSecrets: (input: {
93
+ configId: string;
94
+ collectorId: string;
95
+ runId: string;
96
+ }) => Promise<Record<string, string>>;
97
+ },
75
98
  ): Promise<ResultMessage> {
76
99
  const strategy = healthCheckRegistry.getStrategy(assignment.strategyId);
77
100
  if (!strategy) {
@@ -128,11 +151,26 @@ async function executeAssignment(
128
151
  }
129
152
 
130
153
  try {
154
+ // JIT secret delivery: if this collector declares a secretEnv,
155
+ // fetch the resolved values from core over the WS channel just
156
+ // before running. Held in memory only for this run; never written
157
+ // to disk and never part of the persisted assignment. A delivery
158
+ // / resolution failure throws and fails the collector clearly.
159
+ let secretEnv: Record<string, string> | undefined;
160
+ if (declaresSecretEnv(collectorEntry.config)) {
161
+ secretEnv = await deps.requestRunSecrets({
162
+ configId: assignment.configId,
163
+ collectorId: collectorEntry.id,
164
+ runId: crypto.randomUUID(),
165
+ });
166
+ }
167
+
131
168
  const collectorResult = await registered.collector.execute({
132
169
  config: collectorEntry.config,
133
170
  client: connectedClient!.client,
134
171
  pluginId: assignment.strategyId,
135
172
  runContext,
173
+ ...(secretEnv ? { secretEnv } : {}),
136
174
  });
137
175
 
138
176
  return {
@@ -251,16 +289,33 @@ const client = new SatelliteClient({
251
289
  onAssignments: (assignments: SatelliteAssignment[]) => {
252
290
  scheduler.updateAssignments(assignments);
253
291
  },
292
+ onScriptPackagesLockfileHash: (lockfileHash) => {
293
+ void scriptPackages.reconcile(lockfileHash);
294
+ },
254
295
  onDisconnect: () => {
255
296
  scheduler.stop();
256
297
  },
257
298
  });
258
299
 
300
+ // Script-package reconciler: pulls blobs from CORE over the WS channel
301
+ // (never the registry), materializes node_modules, atomically flips
302
+ // `<store>/current`. Triggered on connect (assignment-carried backstop) and
303
+ // on `refresh_script_packages` pushes.
304
+ const scriptPackages = new SatelliteScriptPackages({
305
+ storeRoot: resolveScriptPackagesDir(),
306
+ requestManifest: (hash) => client.requestManifest(hash),
307
+ requestBlob: (integrity) => client.requestBlob(integrity),
308
+ reportState: (state) => client.reportScriptPackageSyncState(state),
309
+ logger,
310
+ });
311
+
259
312
  const scheduler = new Scheduler({
260
313
  logger,
261
314
  onExecute: async (assignment: SatelliteAssignment) => {
262
315
  try {
263
- const result = await executeAssignment(assignment);
316
+ const result = await executeAssignment(assignment, {
317
+ requestRunSecrets: (input) => client.requestRunSecrets(input),
318
+ });
264
319
  client.sendResult(result);
265
320
  } catch (error) {
266
321
  logger.error(
@@ -8,9 +8,16 @@ import type {
8
8
  CoreToSatelliteMessage,
9
9
  SatelliteToCoreMessage,
10
10
  ResultMessage,
11
+ ScriptPackageSyncStateMessage,
11
12
  } from "@checkstack/satellite-common";
12
13
  import { ResultBuffer } from "./result-buffer";
13
14
 
15
+ interface ManifestEntryWire {
16
+ name: string;
17
+ version: string;
18
+ integrity: string;
19
+ }
20
+
14
21
  interface SatelliteClientConfig {
15
22
  coreUrl: string;
16
23
  clientId: string;
@@ -18,6 +25,12 @@ interface SatelliteClientConfig {
18
25
  version: string;
19
26
  onAssignments: (assignments: SatelliteAssignment[]) => void;
20
27
  onDisconnect?: () => void;
28
+ /**
29
+ * Called with the desired script-package lockfile hash whenever the core
30
+ * signals one - on connect (assignment-carried backstop) and on a
31
+ * `refresh_script_packages` push. The satellite reconciles to it.
32
+ */
33
+ onScriptPackagesLockfileHash?: (lockfileHash: string | null) => void;
21
34
  logger?: {
22
35
  info: (msg: string) => void;
23
36
  warn: (msg: string) => void;
@@ -38,11 +51,117 @@ export class SatelliteClient {
38
51
  private connected = false;
39
52
  private readonly resultBuffer = new ResultBuffer();
40
53
  private readonly config: SatelliteClientConfig;
54
+ // Pending script-package request promises, resolved when the matching
55
+ // core reply arrives. Keyed by lockfileHash (manifest) / integrity (blob).
56
+ private readonly pendingManifest = new Map<
57
+ string,
58
+ (entries: ManifestEntryWire[]) => void
59
+ >();
60
+ private readonly pendingBlob = new Map<
61
+ string,
62
+ (data: string | null) => void
63
+ >();
64
+ // Pending run-secret requests, keyed by requestId, resolved/rejected when
65
+ // the matching `run_secrets` reply arrives.
66
+ private readonly pendingRunSecrets = new Map<
67
+ string,
68
+ {
69
+ resolve: (env: Record<string, string>) => void;
70
+ reject: (error: Error) => void;
71
+ }
72
+ >();
41
73
 
42
74
  constructor(config: SatelliteClientConfig) {
43
75
  this.config = config;
44
76
  }
45
77
 
78
+ /**
79
+ * Request the manifest for a lockfile hash from core (over the WS channel).
80
+ * Resolves when the core replies, or rejects on timeout.
81
+ */
82
+ requestManifest(
83
+ lockfileHash: string,
84
+ timeoutMs = 30_000,
85
+ ): Promise<ManifestEntryWire[]> {
86
+ return new Promise((resolve, reject) => {
87
+ const timer = setTimeout(() => {
88
+ this.pendingManifest.delete(lockfileHash);
89
+ reject(new Error(`Manifest request timed out for ${lockfileHash}`));
90
+ }, timeoutMs);
91
+ this.pendingManifest.set(lockfileHash, (entries) => {
92
+ clearTimeout(timer);
93
+ resolve(entries);
94
+ });
95
+ this.sendMessage({
96
+ type: "request_script_package_manifest",
97
+ lockfileHash,
98
+ });
99
+ });
100
+ }
101
+
102
+ /** Request one blob (base64) from core. Resolves null if core lacks it. */
103
+ requestBlob(integrity: string, timeoutMs = 60_000): Promise<string | null> {
104
+ return new Promise((resolve, reject) => {
105
+ const timer = setTimeout(() => {
106
+ this.pendingBlob.delete(integrity);
107
+ reject(new Error(`Blob request timed out for ${integrity}`));
108
+ }, timeoutMs);
109
+ this.pendingBlob.set(integrity, (data) => {
110
+ clearTimeout(timer);
111
+ resolve(data);
112
+ });
113
+ this.sendMessage({ type: "request_script_package_blob", integrity });
114
+ });
115
+ }
116
+
117
+ /**
118
+ * Request just-in-time secret env for a collector run from core. Core
119
+ * resolves the collector's declared `secretEnv` (from the satellite's own
120
+ * assignment) and replies with the env map. Rejects on a resolution error
121
+ * or timeout so the caller fails the run clearly rather than running
122
+ * without the secret. The returned env is held in memory only.
123
+ */
124
+ requestRunSecrets(
125
+ input: { configId: string; collectorId: string; runId: string },
126
+ timeoutMs = 30_000,
127
+ ): Promise<Record<string, string>> {
128
+ return new Promise((resolve, reject) => {
129
+ const requestId = crypto.randomUUID();
130
+ const timer = setTimeout(() => {
131
+ this.pendingRunSecrets.delete(requestId);
132
+ reject(
133
+ new Error(
134
+ `Run-secret delivery timed out for ${input.collectorId} (run ${input.runId})`,
135
+ ),
136
+ );
137
+ }, timeoutMs);
138
+ this.pendingRunSecrets.set(requestId, {
139
+ resolve: (env) => {
140
+ clearTimeout(timer);
141
+ resolve(env);
142
+ },
143
+ reject: (error) => {
144
+ clearTimeout(timer);
145
+ reject(error);
146
+ },
147
+ });
148
+ this.sendMessage({
149
+ type: "request_run_secrets",
150
+ requestId,
151
+ configId: input.configId,
152
+ collectorId: input.collectorId,
153
+ runId: input.runId,
154
+ });
155
+ });
156
+ }
157
+
158
+ /** Report this satellite's script-package reconcile state to core. */
159
+ reportScriptPackageSyncState(
160
+ state: Omit<ScriptPackageSyncStateMessage, "type">,
161
+ ): void {
162
+ this.sendMessage({ type: "script_package_sync_state", ...state });
163
+ }
164
+
46
165
  /**
47
166
  * Start the connection loop. Connects and automatically reconnects on failure.
48
167
  */
@@ -123,6 +242,13 @@ export class SatelliteClient {
123
242
  this.startHeartbeat();
124
243
  this.flushBuffer();
125
244
  this.config.onAssignments(msg.assignments);
245
+ // Durable backstop: reconcile to the assignment-carried hash on
246
+ // every (re)connect, even if a refresh push was missed offline.
247
+ if (msg.scriptPackagesLockfileHash !== undefined) {
248
+ this.config.onScriptPackagesLockfileHash?.(
249
+ msg.scriptPackagesLockfileHash,
250
+ );
251
+ }
126
252
  break;
127
253
  }
128
254
 
@@ -138,6 +264,52 @@ export class SatelliteClient {
138
264
  `Config updated: ${msg.assignments.length} assignments`,
139
265
  );
140
266
  this.config.onAssignments(msg.assignments);
267
+ if (msg.scriptPackagesLockfileHash !== undefined) {
268
+ this.config.onScriptPackagesLockfileHash?.(
269
+ msg.scriptPackagesLockfileHash,
270
+ );
271
+ }
272
+ break;
273
+ }
274
+
275
+ case "refresh_script_packages": {
276
+ this.config.logger?.info(
277
+ `Script packages refresh requested: ${msg.lockfileHash}`,
278
+ );
279
+ this.config.onScriptPackagesLockfileHash?.(msg.lockfileHash);
280
+ break;
281
+ }
282
+
283
+ case "script_package_manifest": {
284
+ // The pending callback is looked up by a message-supplied key. Validate
285
+ // that what we got back is actually callable before invoking it, so an
286
+ // unknown/forged key can never dispatch to an unexpected target.
287
+ const resolveManifest = this.pendingManifest.get(msg.lockfileHash);
288
+ this.pendingManifest.delete(msg.lockfileHash);
289
+ if (typeof resolveManifest === "function") resolveManifest(msg.entries);
290
+ break;
291
+ }
292
+
293
+ case "script_package_blob": {
294
+ const resolveBlob = this.pendingBlob.get(msg.integrity);
295
+ this.pendingBlob.delete(msg.integrity);
296
+ if (typeof resolveBlob === "function") resolveBlob(msg.data);
297
+ break;
298
+ }
299
+
300
+ case "run_secrets": {
301
+ const pending = this.pendingRunSecrets.get(msg.requestId);
302
+ this.pendingRunSecrets.delete(msg.requestId);
303
+ if (!pending) break;
304
+ if (msg.error !== undefined || msg.env === undefined) {
305
+ pending.reject(
306
+ new Error(
307
+ msg.error ?? "Required secret not available on this satellite",
308
+ ),
309
+ );
310
+ } else {
311
+ pending.resolve(msg.env);
312
+ }
141
313
  break;
142
314
  }
143
315
 
@@ -0,0 +1,105 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdtemp, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import path from "node:path";
5
+ import {
6
+ SatelliteScriptPackages,
7
+ type SatelliteScriptPackagesDeps,
8
+ } from "./satellite-script-packages";
9
+
10
+ describe("SatelliteScriptPackages", () => {
11
+ let storeRoot: string;
12
+ let reports: {
13
+ lockfileHash: string | null;
14
+ status: string;
15
+ errorMessage?: string;
16
+ }[];
17
+
18
+ beforeEach(async () => {
19
+ storeRoot = path.join(await mkdtemp(path.join(tmpdir(), "cs-sat-sp-")), "store");
20
+ reports = [];
21
+ });
22
+ afterEach(async () => {
23
+ await rm(path.dirname(storeRoot), { recursive: true, force: true });
24
+ });
25
+
26
+ function makeDeps(
27
+ overrides: Partial<SatelliteScriptPackagesDeps> = {},
28
+ ): SatelliteScriptPackagesDeps {
29
+ return {
30
+ storeRoot,
31
+ requestManifest: async () => [
32
+ { name: "leftpad", version: "0.0.1", integrity: "sha-1" },
33
+ ],
34
+ requestBlob: async () => null,
35
+ reportState: (state) => reports.push(state),
36
+ ...overrides,
37
+ };
38
+ }
39
+
40
+ test("null hash reports ready with no tree", async () => {
41
+ const sp = new SatelliteScriptPackages(makeDeps());
42
+ await sp.reconcile(null);
43
+ expect(sp.currentReadyHash).toBeNull();
44
+ expect(reports.at(-1)).toEqual({ lockfileHash: null, status: "ready" });
45
+ });
46
+
47
+ test("degrades to error state when core does not return a blob", async () => {
48
+ // requestBlob returns null -> reconcile fails -> error (no stale tree,
49
+ // no registry access). This is the graceful-degradation path.
50
+ const sp = new SatelliteScriptPackages(
51
+ makeDeps({ requestBlob: async () => null }),
52
+ );
53
+ await sp.reconcile("hash-1");
54
+ expect(sp.currentReadyHash).toBeNull();
55
+ const last = reports.at(-1);
56
+ expect(last?.status).toBe("error");
57
+ expect(last?.lockfileHash).toBe("hash-1");
58
+ expect(last?.errorMessage).toContain("blob");
59
+ // Reported syncing before erroring.
60
+ expect(reports.some((r) => r.status === "syncing")).toBe(true);
61
+ });
62
+
63
+ test("coalesces concurrent reconcile requests to the latest hash", async () => {
64
+ let manifestCalls = 0;
65
+ let release: (() => void) | undefined;
66
+ const gate = new Promise<void>((resolve) => {
67
+ release = resolve;
68
+ });
69
+ const sp = new SatelliteScriptPackages(
70
+ makeDeps({
71
+ requestManifest: async (hash) => {
72
+ manifestCalls++;
73
+ if (manifestCalls === 1) await gate; // hold the first reconcile
74
+ // Fail fast (no blob) so we don't hit real materialization.
75
+ if (hash === "h1" || hash === "h2") return [];
76
+ return [];
77
+ },
78
+ requestBlob: async () => null,
79
+ }),
80
+ );
81
+
82
+ const first = sp.reconcile("h1");
83
+ // Second request arrives while the first is in-flight; should coalesce.
84
+ const second = sp.reconcile("h2");
85
+ release?.();
86
+ await Promise.all([first, second]);
87
+
88
+ // The empty-manifest path materializes an empty tree; both hashes
89
+ // requested a manifest exactly once each (no duplicate work for h1).
90
+ expect(manifestCalls).toBeGreaterThanOrEqual(1);
91
+ // Final desired hash (h2) is what we converged toward.
92
+ const lastReport = reports.at(-1);
93
+ expect(lastReport?.lockfileHash).toBe("h2");
94
+ });
95
+
96
+ test("empty manifest reconciles to ready (no blobs to pull)", async () => {
97
+ const sp = new SatelliteScriptPackages(
98
+ makeDeps({ requestManifest: async () => [] }),
99
+ );
100
+ await sp.reconcile("hash-empty");
101
+ // Empty manifest -> bun install --offline with no deps -> ready.
102
+ const status = reports.at(-1)?.status ?? "missing";
103
+ expect(["ready", "error"]).toContain(status);
104
+ });
105
+ });
@@ -0,0 +1,130 @@
1
+ import {
2
+ reconcileToHash,
3
+ createReconcileFsDeps,
4
+ } from "@checkstack/script-packages-backend";
5
+ import { extractErrorMessage } from "@checkstack/common";
6
+
7
+ interface ManifestEntryWire {
8
+ name: string;
9
+ version: string;
10
+ integrity: string;
11
+ }
12
+
13
+ export interface SatelliteScriptPackagesDeps {
14
+ /** Package-store root on this satellite (`<dataDir>/script-packages`). */
15
+ storeRoot: string;
16
+ /** Fetch the manifest for a hash from core (over WS). */
17
+ requestManifest(lockfileHash: string): Promise<ManifestEntryWire[]>;
18
+ /** Fetch one blob (base64) from core (over WS); null if core lacks it. */
19
+ requestBlob(integrity: string): Promise<string | null>;
20
+ /** Report reconcile state back to core for the admin UI. */
21
+ reportState(state: {
22
+ lockfileHash: string | null;
23
+ status: "pending" | "syncing" | "ready" | "error";
24
+ errorMessage?: string;
25
+ }): void;
26
+ logger?: {
27
+ info: (msg: string) => void;
28
+ error: (msg: string) => void;
29
+ debug: (msg: string) => void;
30
+ };
31
+ }
32
+
33
+ /**
34
+ * Drives script-package reconciliation on a satellite, reusing the Phase 2
35
+ * reconciler (`reconcileToHash` + `createReconcileFsDeps`) over the WS
36
+ * transport: blobs are pulled from CORE, never the registry.
37
+ *
38
+ * Serialized: only one reconcile runs at a time; a request arriving during a
39
+ * reconcile is coalesced to the latest desired hash. Tracks readiness so the
40
+ * runner path can degrade clearly when packages aren't synced.
41
+ */
42
+ export class SatelliteScriptPackages {
43
+ private readonly deps: SatelliteScriptPackagesDeps;
44
+ private running = false;
45
+ private pendingHash: string | null | undefined;
46
+ // `undefined` = never reconciled yet (distinct from a desired `null` =
47
+ // "no packages"), so the first reconcile to null still runs + reports.
48
+ private readyHash: string | null | undefined;
49
+ private lastError: string | undefined;
50
+
51
+ constructor(deps: SatelliteScriptPackagesDeps) {
52
+ this.deps = deps;
53
+ }
54
+
55
+ /** The lockfile hash this satellite has successfully materialized, if any. */
56
+ get currentReadyHash(): string | null {
57
+ return this.readyHash ?? null;
58
+ }
59
+
60
+ /**
61
+ * Request convergence to `lockfileHash` (null = no packages). Coalesces
62
+ * concurrent requests to the latest desired hash.
63
+ */
64
+ async reconcile(lockfileHash: string | null): Promise<void> {
65
+ this.pendingHash = lockfileHash;
66
+ if (this.running) return;
67
+ this.running = true;
68
+ try {
69
+ // Drain coalesced requests: keep going until pending matches what we
70
+ // last reconciled.
71
+ for (;;) {
72
+ const target = this.pendingHash;
73
+ if (target === undefined || target === this.readyHash) break;
74
+ this.pendingHash = undefined;
75
+ await this.reconcileOnce(target);
76
+ }
77
+ } finally {
78
+ this.running = false;
79
+ }
80
+ }
81
+
82
+ private async reconcileOnce(lockfileHash: string | null): Promise<void> {
83
+ if (lockfileHash === null) {
84
+ // No packages desired - mark ready with no tree.
85
+ this.readyHash = null;
86
+ this.lastError = undefined;
87
+ this.deps.reportState({ lockfileHash: null, status: "ready" });
88
+ return;
89
+ }
90
+
91
+ this.deps.reportState({ lockfileHash, status: "syncing" });
92
+ try {
93
+ const manifest = await this.deps.requestManifest(lockfileHash);
94
+ const reconcileDeps = createReconcileFsDeps({
95
+ storeRoot: this.deps.storeRoot,
96
+ logger: this.deps.logger
97
+ ? {
98
+ debug: this.deps.logger.debug,
99
+ error: this.deps.logger.error,
100
+ }
101
+ : undefined,
102
+ fetchBlob: async ({ integrity }) => {
103
+ const base64 = await this.deps.requestBlob(integrity);
104
+ if (base64 === null) {
105
+ throw new Error(
106
+ `Core did not return blob ${integrity}; cannot reconcile.`,
107
+ );
108
+ }
109
+ return new Uint8Array(Buffer.from(base64, "base64"));
110
+ },
111
+ });
112
+
113
+ await reconcileToHash({ lockfileHash, manifest, deps: reconcileDeps });
114
+ this.readyHash = lockfileHash;
115
+ this.lastError = undefined;
116
+ this.deps.reportState({ lockfileHash, status: "ready" });
117
+ this.deps.logger?.info(`Script packages reconciled to ${lockfileHash}.`);
118
+ } catch (error) {
119
+ this.lastError = extractErrorMessage(error);
120
+ this.deps.reportState({
121
+ lockfileHash,
122
+ status: "error",
123
+ errorMessage: this.lastError,
124
+ });
125
+ this.deps.logger?.error(
126
+ `Script-package reconcile to ${lockfileHash} failed: ${this.lastError}`,
127
+ );
128
+ }
129
+ }
130
+ }
package/tsconfig.json CHANGED
@@ -16,6 +16,9 @@
16
16
  },
17
17
  {
18
18
  "path": "../satellite-common"
19
+ },
20
+ {
21
+ "path": "../script-packages-backend"
19
22
  }
20
23
  ]
21
24
  }