@ricsam/r5d-worker 0.0.182 → 0.0.184

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.
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "@ricsam/r5d-worker",
3
- "version": "0.0.182",
3
+ "version": "0.0.184",
4
4
  "type": "commonjs"
5
5
  }
@@ -20605,7 +20605,7 @@ function resolveEntrypointPath(entrypoint) {
20605
20605
  }
20606
20606
  }
20607
20607
  function getR5dctlVersion() {
20608
- if (true) return "0.0.182";
20608
+ if (true) return "0.0.184";
20609
20609
  const entrypoint = process.argv[1] ? resolveEntrypointPath(process.argv[1]) : null;
20610
20610
  let current = entrypoint ? import_node_path2.default.dirname(entrypoint) : process.cwd();
20611
20611
  for (let index = 0; index < 12; index += 1) {
package/dist/mjs/main.mjs CHANGED
@@ -7,7 +7,7 @@ import { startManagerRpc } from "./runtime/releases/rpc-main.mjs";
7
7
  import { ManagerRpcConfig } from "./runtime/releases/rpc-protocol.mjs";
8
8
  const args = process.argv.slice(2);
9
9
  if (args.includes("--version")) {
10
- console.log(`r5d-worker ${true ? "0.0.182" : "development"}`);
10
+ console.log(`r5d-worker ${true ? "0.0.184" : "development"}`);
11
11
  } else if (!args.length || args.includes("--help")) {
12
12
  console.log(
13
13
  "Usage: r5d-worker start --label <label> [--root <dir>] [--base-url <url>] [--token <worker-token>] [--initial-sync merge|reset]\n r5d-worker executor /absolute/private/config.json\n r5d-worker manager /absolute/private/config.json\n\nRun independently supervised durable runtime services. Provision configuration with r5dinfra."
@@ -15,7 +15,7 @@ if (args.includes("--version")) {
15
15
  } else if (args[0] === "start") {
16
16
  const runtime = await startPersonalWorker(
17
17
  parsePersonalWorkerOptions(args.slice(1)),
18
- true ? "0.0.182" : "development"
18
+ true ? "0.0.184" : "development"
19
19
  );
20
20
  console.log(`Worker connected: ${runtime.resourceId}`);
21
21
  let closing = false;
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "@ricsam/r5d-worker",
3
- "version": "0.0.182",
3
+ "version": "0.0.184",
4
4
  "type": "module"
5
5
  }
@@ -66,6 +66,28 @@ function parsePersonalWorkerOptions(args, environment = process.env) {
66
66
  initialSync
67
67
  };
68
68
  }
69
+ function transportFailure(error) {
70
+ const name = error instanceof Error ? error.name : "";
71
+ return new PersonalResponseError(name === "TimeoutError" || name === "AbortError" ? "personal_transport_timeout" : "personal_transport_failed", 0, false);
72
+ }
73
+ async function readPersonalResponse(response) {
74
+ let data;
75
+ try {
76
+ data = await response.json();
77
+ } catch {
78
+ throw new PersonalResponseError(response.ok ? "personal_transport_invalid" : "personal_transport_rejected", response.status, false);
79
+ }
80
+ const body = data && typeof data === "object" && !Array.isArray(data) ? data : null;
81
+ if (!response.ok) {
82
+ throw new PersonalResponseError(
83
+ typeof body?.error === "string" ? body.error : "personal_transport_rejected",
84
+ response.status,
85
+ body?.rejectedBeforeAdmission === true
86
+ );
87
+ }
88
+ if (!body) throw new PersonalResponseError("personal_transport_invalid", response.status, false);
89
+ return body;
90
+ }
69
91
  function safeError(error) {
70
92
  const value = error.code;
71
93
  return typeof value === "string" && /^[a-zA-Z0-9_-]{1,100}$/.test(value) ? value : "personal_operation_unknown";
@@ -93,18 +115,19 @@ async function startPersonalWorker(options, version) {
93
115
  }
94
116
  const endpoint = `/api/personal/resources/worker/${encodeURIComponent(identity.resourceId)}`;
95
117
  async function request(pathname, body, timeoutMs = 3e4) {
96
- const response = await fetch(options.baseUrl + pathname, {
97
- method: "POST",
98
- headers: { authorization: `Bearer ${options.credential}`, "content-type": "application/json" },
99
- redirect: "error",
100
- body: JSON.stringify(body),
101
- signal: AbortSignal.timeout(timeoutMs)
102
- });
103
- const data = await response.json();
104
- if (!response.ok) {
105
- throw new PersonalResponseError(typeof data.error === "string" ? data.error : "personal_transport_rejected", response.status, data.rejectedBeforeAdmission === true);
118
+ let response;
119
+ try {
120
+ response = await fetch(options.baseUrl + pathname, {
121
+ method: "POST",
122
+ headers: { authorization: `Bearer ${options.credential}`, "content-type": "application/json" },
123
+ redirect: "error",
124
+ body: JSON.stringify(body),
125
+ signal: AbortSignal.timeout(timeoutMs)
126
+ });
127
+ } catch (error) {
128
+ throw transportFailure(error);
106
129
  }
107
- return data;
130
+ return readPersonalResponse(response);
108
131
  }
109
132
  const cli = await resolvePersonalCliEntrypoint(options.cliEntrypoint, version);
110
133
  let bootId = randomUUID();
@@ -134,14 +157,18 @@ async function startPersonalWorker(options, version) {
134
157
  if (!response || !("result" in response)) throw new Error("Invalid scoped storage response");
135
158
  return response.result;
136
159
  } catch (error) {
137
- if (error instanceof PersonalResponseError && storageErrorCodes.has(error.code))
138
- throw new WorkspaceError(error.code, "Scoped storage rejected the request; retain its original operation identity", error.rejectedBeforeAdmission);
160
+ if (error instanceof PersonalResponseError)
161
+ throw new WorkspaceError(
162
+ error.code,
163
+ "Scoped storage rejected the request; retain its original operation identity",
164
+ error.rejectedBeforeAdmission && storageErrorCodes.has(error.code)
165
+ );
139
166
  throw error;
140
167
  }
141
168
  },
142
169
  publicationReport: async (report) => {
143
170
  const response = await request(`${endpoint}/publication`, { instanceId: identity.instanceId, report });
144
- const result = response?.result;
171
+ const result = response.result;
145
172
  return result && typeof result === "object" && typeof result.incidentId === "string" ? { incidentId: result.incidentId } : void 0;
146
173
  }
147
174
  });
@@ -269,6 +296,9 @@ async function startPersonalWorker(options, version) {
269
296
  };
270
297
  }
271
298
  export {
299
+ PersonalResponseError,
272
300
  parsePersonalWorkerOptions,
273
- startPersonalWorker
301
+ readPersonalResponse,
302
+ startPersonalWorker,
303
+ transportFailure
274
304
  };
@@ -1,3 +1,4 @@
1
+ import { WorkspaceError } from "../runtime/workspace/contracts.mjs";
1
2
  const CONFLICT_PUBLICATION_CODES = /* @__PURE__ */ new Set([
2
3
  "conflict",
3
4
  "sync_conflict",
@@ -9,8 +10,20 @@ const TRANSIENT_PUBLICATION_CODES = /* @__PURE__ */ new Set([
9
10
  "apply_refused",
10
11
  "mirror_advanced",
11
12
  "authority_closed",
12
- "busy"
13
+ "busy",
14
+ // The relay or the storage behind it did not answer this cycle: nothing was
15
+ // refused about the workspace, the next cycle asks again.
16
+ "internal_error",
17
+ "personal_transport_failed",
18
+ "personal_transport_timeout",
19
+ "personal_transport_rejected",
20
+ "personal_transport_invalid"
13
21
  ]);
22
+ function publicationFailureDetail(error) {
23
+ if (error instanceof WorkspaceError) return { code: error.code };
24
+ const detail = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
25
+ return { code: "publication_failed", detail: detail.replace(/\s+/g, " ").slice(0, 300) };
26
+ }
14
27
  const BLOCKED_PUBLICATION_CODES = /* @__PURE__ */ new Set([
15
28
  "secret_or_runtime_path",
16
29
  "secret_content",
@@ -42,6 +55,7 @@ export {
42
55
  PUBLICATION_OBSERVATION_THRESHOLDS,
43
56
  STUCK_OPERATION_OBSERVATIONS,
44
57
  TRANSIENT_PUBLICATION_CODES,
58
+ publicationFailureDetail,
45
59
  publicationRefusalElects,
46
60
  publicationRefusalOutcome
47
61
  };
@@ -1,5 +1,5 @@
1
1
  import path from "node:path";
2
- import { publicationRefusalElects, publicationRefusalOutcome, TRANSIENT_PUBLICATION_CODES } from "./publication-refusal.mjs";
2
+ import { publicationFailureDetail, publicationRefusalElects, publicationRefusalOutcome, TRANSIENT_PUBLICATION_CODES } from "./publication-refusal.mjs";
3
3
  import { PersonalTcpManager } from "./tcp.mjs";
4
4
  import { createHash, randomBytes, randomUUID } from "node:crypto";
5
5
  import { promises as fs } from "node:fs";
@@ -273,8 +273,8 @@ exec ${quote(executable)} ${quote(cli)} "$@"
273
273
  failedPublicationAttempts.delete("workspace");
274
274
  results.push({ workbenchId: "workspace", ...outer });
275
275
  } catch (error) {
276
- const code = error instanceof WorkspaceError ? error.code : "publication_failed";
277
- results.push({ workbenchId: "workspace", error: code });
276
+ const { code, detail } = publicationFailureDetail(error);
277
+ results.push({ workbenchId: "workspace", error: code, ...detail ? { detail } : {} });
278
278
  if (!TRANSIENT_PUBLICATION_CODES.has(code) && options.publicationReport) {
279
279
  let startingHead = null;
280
280
  try {
@@ -332,12 +332,13 @@ exec ${quote(executable)} ${quote(cli)} "$@"
332
332
  (results) => {
333
333
  const deferred = results.filter((result) => result.error && result.error !== "remediation_paused" && !TRANSIENT_PUBLICATION_CODES.has(result.error));
334
334
  if (deferred.length)
335
- process.stderr.write(`[r5d-worker] periodic workspace publication deferred: ${deferred.map((result) => `${result.workbenchId}:${result.error}`).join(", ")}
335
+ process.stderr.write(`[r5d-worker] periodic workspace publication deferred: ${deferred.map((result) => `${result.workbenchId}:${result.error}${result.detail ? ` (${result.detail})` : ""}`).join(", ")}
336
336
  `);
337
337
  if (publication === next) publication = null;
338
338
  },
339
339
  (error) => {
340
- process.stderr.write(`[r5d-worker] periodic workspace publication failed: ${error instanceof WorkspaceError ? error.code : "publication_failed"}
340
+ const failure = publicationFailureDetail(error);
341
+ process.stderr.write(`[r5d-worker] periodic workspace publication failed: ${failure.code}${failure.detail ? ` (${failure.detail})` : ""}
341
342
  `);
342
343
  if (publication === next) publication = null;
343
344
  }
@@ -375,7 +376,14 @@ exec ${quote(executable)} ${quote(cli)} "$@"
375
376
  if (command.method === "search") return authority.searchFiles(identity, WorkspaceSearch.parse(command));
376
377
  }
377
378
  if (request.kind === "terminal") {
378
- if (command.method === "open" || command.method === "start") await authority.ensureHydrated(identity);
379
+ if (command.method === "open" || command.method === "start") {
380
+ try {
381
+ await authority.ensureHydrated(identity);
382
+ } catch (error) {
383
+ const code = error instanceof WorkspaceError ? error.code : "hydration_failed";
384
+ throw new WorkspaceError(code, `Shell not opened: checkout hydration failed (${code})`, true);
385
+ }
386
+ }
379
387
  if (command.method === "open" || command.method === "start") {
380
388
  const argv = command.method === "open" ? ["/bin/bash", "--noprofile", "--norc", ...command.command ? ["-c", command.command] : []] : ["/bin/sh", "-c", command.command];
381
389
  return {
@@ -1242,9 +1242,44 @@ class WorkspaceAuthority {
1242
1242
  b.state.mirroredHead = null;
1243
1243
  await this.save(b);
1244
1244
  const result = this.outerEnabled(b) ? await this.hydrateLinkedIdle(b, identity) : await this.hydrateIdle(b, identity);
1245
+ if (this.outerEnabled(b)) await this.restoreOuterEntries(b);
1245
1246
  return { ...result, reset: true, retained: true };
1246
1247
  }));
1247
1248
  }
1249
+ /** A checkout rebuilt from its branch head is not what the workspace holds
1250
+ * for it: the branch does not track the files every worker synchronizes (an
1251
+ * install's lock file, a note, a sentinel), it has not committed the edits
1252
+ * they published, and it still carries what they deleted. The checkout is
1253
+ * brought to what a worker installing it afresh receives, the outer head's
1254
+ * subtree over the branch head, so the next cycle sees the canonical state
1255
+ * again instead of publishing the difference to every other worker (stage
1256
+ * 2026-09-17 run 25 published the sentinels' absence as deletions). The
1257
+ * discarded divergence is already retained beside the checkout's state. */
1258
+ async restoreOuterEntries(b) {
1259
+ const workTree = this.config.workspaceRoot;
1260
+ if (!workTree) return;
1261
+ const { repo, state } = await this.outer(b.config.userId);
1262
+ if (!state.head) return;
1263
+ const relative = path.relative(workTree, b.config.cwd);
1264
+ if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) return;
1265
+ const root = relative.split(path.sep).join("/");
1266
+ const held = (await repo.listTree(await repo.treeOf(state.head))).filter((entry) => entry.mode !== "160000" && entry.file.startsWith(`${root}/`));
1267
+ if (!held.length) return;
1268
+ await repo.restore(state.head, held.map((entry) => entry.file));
1269
+ const entries = new Set(held.map((entry) => entry.file));
1270
+ const missing = [...await this.innerTracked(b)].map((file) => `${root}/${file}`).filter((file) => !entries.has(file));
1271
+ if (!missing.length) return;
1272
+ const excluded = await repo.ignored(missing);
1273
+ for (const file of missing) {
1274
+ if (excluded.has(file)) continue;
1275
+ const absolute = path.join(workTree, file);
1276
+ const stat = await fs.lstat(absolute).catch((error) => {
1277
+ if (error.code === "ENOENT" || error.code === "ENOTDIR") return null;
1278
+ throw error;
1279
+ });
1280
+ if (stat?.isFile()) await fs.rm(absolute);
1281
+ }
1282
+ }
1248
1283
  // ---- Outer repository ---------------------------------------------------
1249
1284
  outerEnabled(b) {
1250
1285
  return !!this.options.accountStorage && (!b || b.config.rootProfile === "account" || this.linkedWorkbench(b));
@@ -1896,7 +1931,11 @@ class WorkspaceAuthority {
1896
1931
  }
1897
1932
  async runRoute(identity, run) {
1898
1933
  const b = await this.bench(identity);
1899
- if (run.userId !== identity.userId || run.sessionId !== identity.sessionId || !b.state.runs[run.operationId] || (b.state.runs[run.operationId].sessionId ?? b.config.sessionId) !== identity.sessionId)
1934
+ if (run.userId !== identity.userId || run.sessionId !== identity.sessionId)
1935
+ throw new WorkspaceError("forbidden", "Run does not belong to this workbench", true);
1936
+ if (!b.state.runs[run.operationId])
1937
+ throw new WorkspaceError("run_not_found", "No run with this operation id was admitted into this workbench", true);
1938
+ if ((b.state.runs[run.operationId].sessionId ?? b.config.sessionId) !== identity.sessionId)
1900
1939
  throw new WorkspaceError("forbidden", "Run does not belong to this workbench", true);
1901
1940
  if (b.state.runs[run.operationId].state === "rejected_capacity")
1902
1941
  throw new WorkspaceError("run_not_admitted", "This operation was definitively rejected; no executor run exists", true);
@@ -218,9 +218,7 @@ class OuterRepository {
218
218
  }
219
219
  const paths = [...untracked.values()].flat();
220
220
  if (!paths.length) return;
221
- const { code, stdout } = await this.result(["check-ignore", "--no-index", "-z", "--stdin"], `${paths.join("\0")}\0`);
222
- if (code !== 0 && code !== 1) throw new WorkspaceError("git_failed", "Outer repository ignore rules are unreadable");
223
- const ignored = new Set(nulSplit(stdout));
221
+ const ignored = await this.ignored(paths);
224
222
  const removals = [];
225
223
  for (const [root, files] of untracked) {
226
224
  const drop = files.filter((file) => ignored.has(file));
@@ -229,6 +227,14 @@ class OuterRepository {
229
227
  }
230
228
  if (removals.length) await this.run(["update-index", "--force-remove", "-z", "--stdin"], `${removals.join("\0")}\0`);
231
229
  }
230
+ /** The subset of `paths` the workspace's ignore rules exclude. --no-index
231
+ * evaluates the nested ignore files for tracked paths as well. */
232
+ async ignored(paths) {
233
+ if (!paths.length) return /* @__PURE__ */ new Set();
234
+ const { code, stdout } = await this.result(["check-ignore", "--no-index", "-z", "--stdin"], `${paths.join("\0")}\0`);
235
+ if (code !== 0 && code !== 1) throw new WorkspaceError("git_failed", "Outer repository ignore rules are unreadable");
236
+ return new Set(nulSplit(stdout));
237
+ }
232
238
  /** Staging records a symlink as a link entry, and a nested repository that
233
239
  * appeared between the walk and the add as a gitlink. Neither can be
234
240
  * materialized on another host; both are dropped from the index without
@@ -271,6 +277,13 @@ class OuterRepository {
271
277
  await this.run(["read-tree", GitOid.parse(commit)]);
272
278
  await this.run(["checkout-index", "-a"]);
273
279
  }
280
+ /** Materialize `paths` of `commit` into the index and the working tree,
281
+ * leaving every other path alone. */
282
+ async restore(commit, paths) {
283
+ if (!paths.length) return;
284
+ for (const file of paths) safeTreePath(file);
285
+ await this.run(["checkout", GitOid.parse(commit), "--pathspec-from-file=-", "--pathspec-file-nul", "--"], `${paths.join("\0")}\0`);
286
+ }
274
287
  /** Reset index and working tree to a commit, deleting tracked paths it lacks.
275
288
  * Never `clean`: the second `-f` that would reach nested repositories is the
276
289
  * one switch this repository must never pass. */
@@ -1,3 +1,9 @@
1
+ export declare class PersonalResponseError extends Error {
2
+ readonly code: string;
3
+ readonly status: number;
4
+ readonly rejectedBeforeAdmission: boolean;
5
+ constructor(code: string, status: number, rejectedBeforeAdmission: boolean);
6
+ }
1
7
  /** What the first synchronization after `r5d-worker start` does to checkouts
2
8
  * that diverged from the server while the worker was away: `merge` integrates
3
9
  * them and opens a conflict remediation when needed (the default); `reset`
@@ -14,6 +20,20 @@ export type PersonalWorkerOptions = {
14
20
  pollTimeoutMs?: number;
15
21
  };
16
22
  export declare function parsePersonalWorkerOptions(args: string[], environment?: Record<string, string | undefined>): PersonalWorkerOptions;
23
+ /** A request that never got an answer is a typed, transient transport
24
+ * failure: a timed-out or refused connection is not the platform refusing
25
+ * anything, and the journal names it rather than the bare `publication_failed`
26
+ * it became (stage 2026-09-17 run 25). */
27
+ export declare function transportFailure(error: unknown): PersonalResponseError;
28
+ /** Every relay answer is a JSON object: a refusal names its `error` code and
29
+ * whether it was rejected before admission, a success carries the route's own
30
+ * fields. The caller names the fields it reads. */
31
+ export type PersonalResponseBody = {
32
+ [field: string]: unknown;
33
+ };
34
+ /** The relay answers JSON; anything else (an edge's error page, an empty body)
35
+ * is a typed transport rejection carrying the status, never a parse error. */
36
+ export declare function readPersonalResponse<T extends PersonalResponseBody = PersonalResponseBody>(response: Response): Promise<T>;
17
37
  /** HTTP reconnects never replace the local source/PTY keeper. */
18
38
  export declare function startPersonalWorker(options: PersonalWorkerOptions, version: string): Promise<{
19
39
  resourceId: string;
@@ -11,6 +11,13 @@ export declare const CONFLICT_PUBLICATION_CODES: ReadonlySet<string>;
11
11
  * landing, or the mirror moved between read and publish. The next cycle
12
12
  * repeats the work; nothing durable happened and nothing is reported. */
13
13
  export declare const TRANSIENT_PUBLICATION_CODES: ReadonlySet<string>;
14
+ /** The journal's account of a synchronization failure: a typed refusal is its
15
+ * code; anything else keeps its name and message, since the code alone
16
+ * (`publication_failed`) told nobody what broke (stage 2026-09-17 run 25). */
17
+ export declare function publicationFailureDetail(error: unknown): {
18
+ code: string;
19
+ detail?: string;
20
+ };
14
21
  /** Refused because the source candidate itself is unsafe or oversized. */
15
22
  export declare const BLOCKED_PUBLICATION_CODES: ReadonlySet<string>;
16
23
  /** Consecutive identical observations required before electing an incident.
@@ -163,6 +163,7 @@ export declare function openPersonalWorkerRuntime(options: {
163
163
  head?: string | null;
164
164
  unchanged?: boolean;
165
165
  error?: string;
166
+ detail?: string;
166
167
  }[]>;
167
168
  subscribeTerminal: (input: {
168
169
  sessionId: string;
@@ -383,6 +383,16 @@ export declare class WorkspaceAuthority {
383
383
  retained: boolean;
384
384
  head: string;
385
385
  }>;
386
+ /** A checkout rebuilt from its branch head is not what the workspace holds
387
+ * for it: the branch does not track the files every worker synchronizes (an
388
+ * install's lock file, a note, a sentinel), it has not committed the edits
389
+ * they published, and it still carries what they deleted. The checkout is
390
+ * brought to what a worker installing it afresh receives, the outer head's
391
+ * subtree over the branch head, so the next cycle sees the canonical state
392
+ * again instead of publishing the difference to every other worker (stage
393
+ * 2026-09-17 run 25 published the sentinels' absence as deletions). The
394
+ * discarded divergence is already retained beside the checkout's state. */
395
+ private restoreOuterEntries;
386
396
  private outerEnabled;
387
397
  private outerStateFile;
388
398
  private outer;
@@ -93,6 +93,9 @@ export declare class OuterRepository {
93
93
  * published. The seeding invariant holds: a checkout never loses its last
94
94
  * outer entry. */
95
95
  private evictIgnored;
96
+ /** The subset of `paths` the workspace's ignore rules exclude. --no-index
97
+ * evaluates the nested ignore files for tracked paths as well. */
98
+ ignored(paths: readonly string[]): Promise<ReadonlySet<string>>;
96
99
  /** Staging records a symlink as a link entry, and a nested repository that
97
100
  * appeared between the walk and the add as a gitlink. Neither can be
98
101
  * materialized on another host; both are dropped from the index without
@@ -109,6 +112,9 @@ export declare class OuterRepository {
109
112
  apply(fromTree: string, toTree: string): Promise<void>;
110
113
  /** Materialize a commit into a working tree that holds none of its paths yet. */
111
114
  checkout(commit: string): Promise<void>;
115
+ /** Materialize `paths` of `commit` into the index and the working tree,
116
+ * leaving every other path alone. */
117
+ restore(commit: string, paths: readonly string[]): Promise<void>;
112
118
  /** Reset index and working tree to a commit, deleting tracked paths it lacks.
113
119
  * Never `clean`: the second `-f` that would reach nested repositories is the
114
120
  * one switch this repository must never pass. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ricsam/r5d-worker",
3
- "version": "0.0.182",
3
+ "version": "0.0.184",
4
4
  "type": "module",
5
5
  "main": "./dist/mjs/main.mjs",
6
6
  "module": "./dist/mjs/main.mjs",
@@ -21,7 +21,7 @@
21
21
  "r5d-worker": "dist/mjs/main.mjs"
22
22
  },
23
23
  "dependencies": {
24
- "@ricsam/r5d-api": "^0.0.182",
24
+ "@ricsam/r5d-api": "^0.0.184",
25
25
  "node-pty": "1.1.0",
26
26
  "zod": "^4.1.13",
27
27
  "picomatch": "^4.0.3"