@ricsam/r5d-worker 0.0.143 → 0.0.145

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.143",
3
+ "version": "0.0.145",
4
4
  "type": "commonjs"
5
5
  }
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.143" : "development"}`);
10
+ console.log(`r5d-worker ${true ? "0.0.145" : "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>]\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.143" : "development"
18
+ true ? "0.0.145" : "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.143",
3
+ "version": "0.0.145",
4
4
  "type": "module"
5
5
  }
@@ -132,7 +132,7 @@ exec ${quote(executable)} ${quote(cli)} "$@"
132
132
  timeoutMs: 1e4
133
133
  });
134
134
  let current = grant, installed = false, retirementPending = false;
135
- const manifests = /* @__PURE__ */ new Map(), routes = /* @__PURE__ */ new Map();
135
+ const manifests = /* @__PURE__ */ new Map(), routes = /* @__PURE__ */ new Map(), publicationSessions = /* @__PURE__ */ new Map();
136
136
  const manifestFile = path.join(root, "workbenches.json");
137
137
  try {
138
138
  const rows = z.array(ApprovedWorkbench).parse(readPrivateJson(manifestFile));
@@ -140,6 +140,8 @@ exec ${quote(executable)} ${quote(cli)} "$@"
140
140
  if (row.userId !== grant.userId || row.cwd !== workspaceRoot && !row.cwd.startsWith(workspaceRoot + path.sep))
141
141
  throw new Error("Invalid retained workbench");
142
142
  manifests.set(row.id, row);
143
+ routes.set(row.sessionId, row.id);
144
+ if (row.rootProfile === "project") publicationSessions.set(row.id, row.sessionId);
143
145
  }
144
146
  } catch (error) {
145
147
  if (error.code !== "ENOENT") throw error;
@@ -227,6 +229,7 @@ exec ${quote(executable)} ${quote(cli)} "$@"
227
229
  await fs.rename(tmp, manifestFile);
228
230
  }
229
231
  routes.set(input.sessionId, input.id);
232
+ if (row.rootProfile === "project") publicationSessions.set(row.id, input.sessionId);
230
233
  }
231
234
  async function installCredentials(values) {
232
235
  for (const source of values) {
@@ -247,6 +250,48 @@ exec ${quote(executable)} ${quote(cli)} "$@"
247
250
  }
248
251
  }
249
252
  }
253
+ let publication = null, closing = false;
254
+ const synchronize = () => {
255
+ if (publication) return publication;
256
+ const next = (async () => {
257
+ const results = [];
258
+ for (const [workbenchId, sessionId] of publicationSessions) {
259
+ if (closing) break;
260
+ const manifest = manifests.get(workbenchId);
261
+ if (!manifest || manifest.rootProfile !== "project") continue;
262
+ try {
263
+ const result = await authority.publish({ userId: grant.userId, sessionId });
264
+ results.push({ workbenchId, ...result });
265
+ } catch (error) {
266
+ const code = error instanceof WorkspaceError ? error.code : "publication_failed";
267
+ results.push({ workbenchId, error: code });
268
+ }
269
+ }
270
+ return results;
271
+ })();
272
+ publication = next;
273
+ void next.then(
274
+ (results) => {
275
+ if (results.some((result) => result.error && result.error !== "not_initialized"))
276
+ process.stderr.write(`[r5d-worker] periodic workspace publication deferred: ${results.filter((result) => result.error).map((result) => `${result.workbenchId}:${result.error}`).join(", ")}
277
+ `);
278
+ if (publication === next) publication = null;
279
+ },
280
+ (error) => {
281
+ process.stderr.write(`[r5d-worker] periodic workspace publication failed: ${error instanceof WorkspaceError ? error.code : "publication_failed"}
282
+ `);
283
+ if (publication === next) publication = null;
284
+ }
285
+ );
286
+ return next;
287
+ };
288
+ const publicationIntervalMs = options.publicationIntervalMs ?? 6e4;
289
+ if (!Number.isSafeInteger(publicationIntervalMs) || publicationIntervalMs <= 0)
290
+ throw new Error("Publication interval must be a positive integer");
291
+ const publicationTimer = setInterval(() => {
292
+ void synchronize();
293
+ }, publicationIntervalMs);
294
+ publicationTimer.unref?.();
250
295
  const tcp = new PersonalTcpManager(path.join(root, "tcp"));
251
296
  async function dispatch(raw) {
252
297
  const request = PersonalWorkspaceRequest.parse(raw);
@@ -357,7 +402,11 @@ exec ${quote(executable)} ${quote(cli)} "$@"
357
402
  renew,
358
403
  dispatch,
359
404
  authority,
405
+ synchronize,
360
406
  async close() {
407
+ closing = true;
408
+ clearInterval(publicationTimer);
409
+ await publication;
361
410
  await tcp.close();
362
411
  await authority.close();
363
412
  await daemon.close();
@@ -10,7 +10,6 @@ import {
10
10
  ApprovedWorkbench,
11
11
  WorkspaceConfig,
12
12
  WorkspaceError,
13
- WorkspacePublicationNotAdmitted,
14
13
  DEFAULT_WORKSPACE_IGNORE
15
14
  } from "./contracts.mjs";
16
15
  import { SessionArtifactStore, SessionArtifactChunk, RESERVED_ARTIFACT_ENV } from "./artifacts.mjs";
@@ -403,13 +402,11 @@ class WorkspaceAuthority {
403
402
  }
404
403
  await this.save(b);
405
404
  }
406
- async idle(b, allowBlocked = false) {
407
- await this.refresh(b);
408
- if (Object.values(b.state.runs).some((r) => r.mutating && !["completed", "cancelled", "rejected_capacity"].includes(r.state)))
409
- throw new WorkspaceError(
410
- "workbench_busy",
411
- "A mutating command is active/unknown; poll original executor run to terminal before syncing"
412
- );
405
+ /** Workspace synchronization is independent of process lifetime. The serial
406
+ * authority queue excludes other authority mutations, while shells and
407
+ * agents remain ordinary concurrent filesystem writers whose later changes
408
+ * are observed by a subsequent publication cycle. */
409
+ async assertAvailable(b, allowBlocked = false) {
413
410
  if (b.state.blocked && !allowBlocked) throw new WorkspaceError(b.state.blocked.code, b.state.blocked.message);
414
411
  }
415
412
  async status(identity) {
@@ -425,7 +422,11 @@ class WorkspaceAuthority {
425
422
  } catch {
426
423
  }
427
424
  }
428
- return { workbench: { ...b.config, sessionId: identity.sessionId }, ...structuredClone(b.state), files };
425
+ const state = structuredClone(b.state);
426
+ state.runs = Object.fromEntries(
427
+ Object.entries(state.runs).filter(([, run]) => (run.sessionId ?? b.config.sessionId) === identity.sessionId)
428
+ );
429
+ return { workbench: { ...b.config, sessionId: identity.sessionId }, ...state, files };
429
430
  });
430
431
  }
431
432
  async cleanRefreshBase(b, expectedBase) {
@@ -454,8 +455,8 @@ class WorkspaceAuthority {
454
455
  if (!unchangedPublishedMetadata && (await git(b.repo, ["write-tree"], void 0, index)).toString().trim() !== expectedTree)
455
456
  throw new WorkspaceError("dirty_workbench", "Staged changes are retained; resolve them before refreshing");
456
457
  }
457
- /** No-argument hydration is initial-only. Explicit expectedBase permits a clean,
458
- * idle workbench refresh; all replaced source/index/ref bytes are retained. */
458
+ /** No-argument hydration is initial-only. Explicit expectedBase permits a
459
+ * clean workbench refresh; process/PTY liveness never gates synchronization. */
459
460
  async hydrate(identity, expectedBase) {
460
461
  const b = await this.bench(identity);
461
462
  if (b.config.rootProfile === "account") return { head: "", unchanged: true };
@@ -463,7 +464,7 @@ class WorkspaceAuthority {
463
464
  }
464
465
  async hydrateIdle(b, identity, expectedBase) {
465
466
  if (b.state.blocked?.code === "account_initialization_unknown") {
466
- await this.idle(b, true);
467
+ await this.assertAvailable(b, true);
467
468
  await this.assertFreshAccountWorkbench(b, expectedBase);
468
469
  const operationId = `account-initialize-${sha256(b.config.userId)}`;
469
470
  if (b.state.blocked.operationId !== operationId) throw new WorkspaceError("wrong_binding", "Account initialization receipt identity changed");
@@ -474,10 +475,11 @@ class WorkspaceAuthority {
474
475
  b.state.blocked = null;
475
476
  await this.save(b);
476
477
  }
477
- await this.idle(b);
478
+ await this.assertAvailable(b);
478
479
  if (expectedBase !== void 0) {
479
480
  GitOid.parse(expectedBase);
480
- await this.cleanRefreshBase(b, expectedBase);
481
+ if (!b.state.initialized || b.state.head !== expectedBase)
482
+ throw new WorkspaceError("conflict", "Workbench base changed; inspect before refreshing");
481
483
  } else if (b.state.initialized || (await fs.readdir(b.config.cwd)).length)
482
484
  throw new WorkspaceError(
483
485
  "nonempty_workbench",
@@ -507,6 +509,7 @@ class WorkspaceAuthority {
507
509
  if (!head) throw new WorkspaceError("empty_repository", "Seed README explicitly for an empty canonical branch");
508
510
  GitOid.parse(head);
509
511
  if (expectedBase === head) return { head };
512
+ if (expectedBase !== void 0) await this.cleanRefreshBase(b, expectedBase);
510
513
  b.state.blocked = {
511
514
  code: expectedBase === void 0 ? "hydration_incomplete" : "refresh_incomplete",
512
515
  message: "Source synchronization interrupted; preserve workbench and inspect staged tree/retained originals before maintenance"
@@ -732,7 +735,7 @@ class WorkspaceAuthority {
732
735
  async seedReadme(identity, readme) {
733
736
  const b = await this.bench(identity);
734
737
  return this.serial(b, async () => {
735
- await this.idle(b);
738
+ await this.assertAvailable(b);
736
739
  if (Buffer.byteLength(readme) > 1024 * 1024) throw new WorkspaceError("too_large", "README seed exceeds limit");
737
740
  sourceBytes(Buffer.from(readme));
738
741
  if (b.state.initialized || (await fs.readdir(b.config.cwd)).length)
@@ -948,7 +951,7 @@ class WorkspaceAuthority {
948
951
  const head = GitOid.parse((await git(b.repo, ["rev-parse", "FETCH_HEAD"])).toString().trim());
949
952
  return this.importCommit(b, head, input.id, identity);
950
953
  }, async () => {
951
- await this.idle(b);
954
+ await this.assertAvailable(b);
952
955
  if (b.state.initialized || (await fs.readdir(b.config.cwd)).length) throw new WorkspaceError("nonempty_workbench", "Import requires a new empty workbench");
953
956
  }));
954
957
  }
@@ -972,7 +975,7 @@ class WorkspaceAuthority {
972
975
  if (source.config.repositoryId !== target.config.repositoryId || source.config.branch === target.config.branch) throw new WorkspaceError("forbidden", "Branch source must belong to the same project");
973
976
  await this.ensureHydrated(sourceIdentity);
974
977
  const prepared = await this.serial(source, async () => {
975
- await this.idle(source);
978
+ await this.assertAvailable(source);
976
979
  let head = source.state.head;
977
980
  if (input.workingTree === "carry") {
978
981
  const tree = await snapshotTree(source.repo, source.config.cwd, head);
@@ -990,7 +993,7 @@ class WorkspaceAuthority {
990
993
  await git(target.repo, ["bundle", "unbundle", file]);
991
994
  return this.importCommit(target, prepared.head, input.id, identity);
992
995
  }, async () => {
993
- await this.idle(target);
996
+ await this.assertAvailable(target);
994
997
  if (target.state.initialized || (await fs.readdir(target.config.cwd)).length) throw new WorkspaceError("nonempty_workbench", "Branch already has a workbench");
995
998
  }));
996
999
  }
@@ -1070,7 +1073,7 @@ ${DEFAULT_WORKSPACE_IGNORE}`, { mode: 384 });
1070
1073
  await this.installGitPolicy(b, result.head);
1071
1074
  return { ...result, treeHash: (await git(b.repo, ["rev-parse", `${result.head}^{tree}`])).toString().trim() };
1072
1075
  }, async () => {
1073
- await this.idle(b);
1076
+ await this.assertAvailable(b);
1074
1077
  if (input.expectedHead !== void 0 && input.expectedHead !== b.state.head) throw new WorkspaceError("conflict", "Workbench head changed");
1075
1078
  }));
1076
1079
  }
@@ -1085,7 +1088,7 @@ ${DEFAULT_WORKSPACE_IGNORE}`, { mode: 384 });
1085
1088
  await git(b.repo, ["-c", "protocol.https.allow=always", "push", "--porcelain", ...input.remote.expectedHead !== void 0 ? [`--force-with-lease=refs/heads/${input.remote.branch}:${input.remote.expectedHead ?? ""}`] : [], "--", input.remote.url, `${b.state.head}:refs/heads/${input.remote.branch}`], void 0, void 0, { token: input.remote.token });
1086
1089
  return { head: b.state.head, pushed: true };
1087
1090
  }, async () => {
1088
- await this.idle(b);
1091
+ await this.assertAvailable(b);
1089
1092
  if (!b.state.head || input.expectedHead !== void 0 && input.expectedHead !== b.state.head) throw new WorkspaceError("conflict", "Workbench head changed");
1090
1093
  }));
1091
1094
  }
@@ -1105,13 +1108,13 @@ ${DEFAULT_WORKSPACE_IGNORE}`, { mode: 384 });
1105
1108
  b.state.blocked = { code: "archived", message: "This branch incarnation is archived; its workbench and source are retained" };
1106
1109
  await this.save(b);
1107
1110
  return { removed: true, receipt: result };
1108
- }, () => this.idle(b)));
1111
+ }, () => this.assertAvailable(b)));
1109
1112
  }
1110
1113
  /** An explicit user reset retains the complete old tree/index before hydration. */
1111
1114
  async reset(identity, input) {
1112
1115
  const b = await this.bench(identity);
1113
1116
  return this.serial(b, () => this.productAction(b, input.id, { method: "reset", expectedHead: input.expectedHead }, async () => {
1114
- await this.idle(b, true);
1117
+ await this.assertAvailable(b, true);
1115
1118
  if (input.expectedHead !== void 0 && input.expectedHead !== b.state.head) throw new WorkspaceError("conflict", "Workbench base changed before reset");
1116
1119
  if (b.state.blocked?.code === "archived") throw new WorkspaceError("archived", "A deleted branch cannot be reset");
1117
1120
  const storage = await this.storage(b, identity);
@@ -1145,12 +1148,7 @@ ${DEFAULT_WORKSPACE_IGNORE}`, { mode: 384 });
1145
1148
  const b = await this.bench(identity);
1146
1149
  if (b.config.rootProfile === "account") return { head: "", unchanged: true };
1147
1150
  return this.serial(b, async () => {
1148
- try {
1149
- await this.idle(b);
1150
- } catch (error) {
1151
- if (!(error instanceof WorkspaceError) || error.code !== "workbench_busy") throw error;
1152
- throw new WorkspacePublicationNotAdmitted("workbench_busy", error.message);
1153
- }
1151
+ await this.assertAvailable(b);
1154
1152
  return this.publishIdle(b, identity);
1155
1153
  });
1156
1154
  }
@@ -1361,13 +1359,13 @@ ${DEFAULT_WORKSPACE_IGNORE}`, { mode: 384 });
1361
1359
  async runRoute(identity, run) {
1362
1360
  const b = await this.bench(identity);
1363
1361
  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)
1364
- throw new WorkspaceError("forbidden", "Run does not belong to this workbench");
1362
+ throw new WorkspaceError("forbidden", "Run does not belong to this workbench", true);
1365
1363
  if (b.state.runs[run.operationId].state === "rejected_capacity")
1366
- throw new WorkspaceError("run_not_admitted", "This operation was definitively rejected; no executor run exists");
1364
+ throw new WorkspaceError("run_not_admitted", "This operation was definitively rejected; no executor run exists", true);
1367
1365
  await this.owned();
1368
1366
  const route = await this.route(b, identity);
1369
1367
  if (run.fence.installationId !== this.config.installationId || run.fence.resourceType !== route.workerFence.resourceType || run.fence.resourceId !== route.workerFence.resourceId)
1370
- throw new WorkspaceError("wrong_resource", "Wrong worker resource");
1368
+ throw new WorkspaceError("wrong_resource", "Wrong worker resource", true);
1371
1369
  return { b, route };
1372
1370
  }
1373
1371
  action(fn) {
@@ -34,8 +34,6 @@ class WorkspaceError extends Error {
34
34
  code;
35
35
  rejectedBeforeAdmission;
36
36
  }
37
- class WorkspacePublicationNotAdmitted extends WorkspaceError {
38
- }
39
37
  const DEFAULT_WORKSPACE_IGNORE = `# Required destination workbench exclusions; keep these rules effective.
40
38
  .env
41
39
  .env.*
@@ -59,6 +57,5 @@ export {
59
57
  ApprovedWorkbench,
60
58
  DEFAULT_WORKSPACE_IGNORE,
61
59
  WorkspaceConfig,
62
- WorkspaceError,
63
- WorkspacePublicationNotAdmitted
60
+ WorkspaceError
64
61
  };
@@ -101,6 +101,8 @@ export declare function openPersonalWorkerRuntime(options: {
101
101
  grant: z.infer<typeof PersonalWorkerGrant>;
102
102
  storage: (sessionId: string) => StorageTransport;
103
103
  cliEntrypoint?: string;
104
+ /** Internal test/embedding override. Personal workers publish every minute. */
105
+ publicationIntervalMs?: number;
104
106
  }): Promise<{
105
107
  grant: {
106
108
  [x: string]: unknown;
@@ -132,5 +134,11 @@ export declare function openPersonalWorkerRuntime(options: {
132
134
  renew: (value: unknown) => Promise<void>;
133
135
  dispatch: (raw: unknown) => Promise<any>;
134
136
  authority: WorkspaceAuthority;
137
+ synchronize: () => Promise<{
138
+ workbenchId: string;
139
+ head?: string;
140
+ unchanged?: boolean;
141
+ error?: string;
142
+ }[]>;
135
143
  close(): Promise<void>;
136
144
  }>;
@@ -11,7 +11,7 @@ export interface WorkspaceAuthorityOptions {
11
11
  /** Trusted catalog authorization; never a client-supplied filesystem path. */
12
12
  resolveWorkbench?: (identity: WorkspaceIdentity) => Promise<ApprovedWorkbenchInput | null>;
13
13
  }
14
- /** Independent resource owner. Gate and workbench never live in replaceable adapters/gateways.
14
+ /** Independent resource owner. Run receipts and workbench never live in replaceable adapters/gateways.
15
15
  * Private root is exclusively owned; a crash leaves lock+intent for explicit maintenance.
16
16
  */
17
17
  export declare class WorkspaceAuthority {
@@ -179,7 +179,11 @@ export declare class WorkspaceAuthority {
179
179
  private route;
180
180
  private storage;
181
181
  private refresh;
182
- private idle;
182
+ /** Workspace synchronization is independent of process lifetime. The serial
183
+ * authority queue excludes other authority mutations, while shells and
184
+ * agents remain ordinary concurrent filesystem writers whose later changes
185
+ * are observed by a subsequent publication cycle. */
186
+ private assertAvailable;
183
187
  status(identity: WorkspaceIdentity): Promise<{
184
188
  files: string[] | null;
185
189
  initialized: boolean;
@@ -221,8 +225,8 @@ export declare class WorkspaceAuthority {
221
225
  };
222
226
  }>;
223
227
  private cleanRefreshBase;
224
- /** No-argument hydration is initial-only. Explicit expectedBase permits a clean,
225
- * idle workbench refresh; all replaced source/index/ref bytes are retained. */
228
+ /** No-argument hydration is initial-only. Explicit expectedBase permits a
229
+ * clean workbench refresh; process/PTY liveness never gates synchronization. */
226
230
  hydrate(identity: WorkspaceIdentity, expectedBase?: string): Promise<{
227
231
  head: string;
228
232
  } | {
@@ -54,20 +54,9 @@ export declare class WorkspaceError extends Error {
54
54
  readonly rejectedBeforeAdmission: boolean;
55
55
  constructor(code: string, message: string, rejectedBeforeAdmission?: boolean);
56
56
  }
57
- /** Constructed only by the authority before it admits publication work. */
58
- export declare class WorkspacePublicationNotAdmitted extends WorkspaceError {
59
- }
60
57
  export type WorkspacePublishResult = {
61
58
  head: string;
62
59
  unchanged?: boolean;
63
- rejectedBeforeAdmission?: never;
64
- } | {
65
- state: "rejected";
66
- code: "capacity_busy";
67
- rejectedBeforeAdmission: true;
68
- message: string;
69
- head?: never;
70
- unchanged?: never;
71
60
  };
72
61
  export type WorkspaceState = {
73
62
  initialized: boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ricsam/r5d-worker",
3
- "version": "0.0.143",
3
+ "version": "0.0.145",
4
4
  "type": "module",
5
5
  "main": "./dist/mjs/main.mjs",
6
6
  "module": "./dist/mjs/main.mjs",
@@ -21,8 +21,8 @@
21
21
  "r5d-worker": "dist/mjs/main.mjs"
22
22
  },
23
23
  "dependencies": {
24
- "@ricsam/r5d-api": "^0.0.143",
25
- "@ricsam/r5dctl": "0.0.143",
24
+ "@ricsam/r5d-api": "^0.0.145",
25
+ "@ricsam/r5dctl": "0.0.145",
26
26
  "node-pty": "1.1.0",
27
27
  "zod": "^4.1.13",
28
28
  "picomatch": "^4.0.3"