@ricsam/r5d-worker 0.0.143 → 0.0.144

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.144",
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.144" : "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.144" : "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.144",
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) {
@@ -454,8 +451,8 @@ class WorkspaceAuthority {
454
451
  if (!unchangedPublishedMetadata && (await git(b.repo, ["write-tree"], void 0, index)).toString().trim() !== expectedTree)
455
452
  throw new WorkspaceError("dirty_workbench", "Staged changes are retained; resolve them before refreshing");
456
453
  }
457
- /** No-argument hydration is initial-only. Explicit expectedBase permits a clean,
458
- * idle workbench refresh; all replaced source/index/ref bytes are retained. */
454
+ /** No-argument hydration is initial-only. Explicit expectedBase permits a
455
+ * clean workbench refresh; process/PTY liveness never gates synchronization. */
459
456
  async hydrate(identity, expectedBase) {
460
457
  const b = await this.bench(identity);
461
458
  if (b.config.rootProfile === "account") return { head: "", unchanged: true };
@@ -463,7 +460,7 @@ class WorkspaceAuthority {
463
460
  }
464
461
  async hydrateIdle(b, identity, expectedBase) {
465
462
  if (b.state.blocked?.code === "account_initialization_unknown") {
466
- await this.idle(b, true);
463
+ await this.assertAvailable(b, true);
467
464
  await this.assertFreshAccountWorkbench(b, expectedBase);
468
465
  const operationId = `account-initialize-${sha256(b.config.userId)}`;
469
466
  if (b.state.blocked.operationId !== operationId) throw new WorkspaceError("wrong_binding", "Account initialization receipt identity changed");
@@ -474,10 +471,11 @@ class WorkspaceAuthority {
474
471
  b.state.blocked = null;
475
472
  await this.save(b);
476
473
  }
477
- await this.idle(b);
474
+ await this.assertAvailable(b);
478
475
  if (expectedBase !== void 0) {
479
476
  GitOid.parse(expectedBase);
480
- await this.cleanRefreshBase(b, expectedBase);
477
+ if (!b.state.initialized || b.state.head !== expectedBase)
478
+ throw new WorkspaceError("conflict", "Workbench base changed; inspect before refreshing");
481
479
  } else if (b.state.initialized || (await fs.readdir(b.config.cwd)).length)
482
480
  throw new WorkspaceError(
483
481
  "nonempty_workbench",
@@ -507,6 +505,7 @@ class WorkspaceAuthority {
507
505
  if (!head) throw new WorkspaceError("empty_repository", "Seed README explicitly for an empty canonical branch");
508
506
  GitOid.parse(head);
509
507
  if (expectedBase === head) return { head };
508
+ if (expectedBase !== void 0) await this.cleanRefreshBase(b, expectedBase);
510
509
  b.state.blocked = {
511
510
  code: expectedBase === void 0 ? "hydration_incomplete" : "refresh_incomplete",
512
511
  message: "Source synchronization interrupted; preserve workbench and inspect staged tree/retained originals before maintenance"
@@ -732,7 +731,7 @@ class WorkspaceAuthority {
732
731
  async seedReadme(identity, readme) {
733
732
  const b = await this.bench(identity);
734
733
  return this.serial(b, async () => {
735
- await this.idle(b);
734
+ await this.assertAvailable(b);
736
735
  if (Buffer.byteLength(readme) > 1024 * 1024) throw new WorkspaceError("too_large", "README seed exceeds limit");
737
736
  sourceBytes(Buffer.from(readme));
738
737
  if (b.state.initialized || (await fs.readdir(b.config.cwd)).length)
@@ -948,7 +947,7 @@ class WorkspaceAuthority {
948
947
  const head = GitOid.parse((await git(b.repo, ["rev-parse", "FETCH_HEAD"])).toString().trim());
949
948
  return this.importCommit(b, head, input.id, identity);
950
949
  }, async () => {
951
- await this.idle(b);
950
+ await this.assertAvailable(b);
952
951
  if (b.state.initialized || (await fs.readdir(b.config.cwd)).length) throw new WorkspaceError("nonempty_workbench", "Import requires a new empty workbench");
953
952
  }));
954
953
  }
@@ -972,7 +971,7 @@ class WorkspaceAuthority {
972
971
  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
972
  await this.ensureHydrated(sourceIdentity);
974
973
  const prepared = await this.serial(source, async () => {
975
- await this.idle(source);
974
+ await this.assertAvailable(source);
976
975
  let head = source.state.head;
977
976
  if (input.workingTree === "carry") {
978
977
  const tree = await snapshotTree(source.repo, source.config.cwd, head);
@@ -990,7 +989,7 @@ class WorkspaceAuthority {
990
989
  await git(target.repo, ["bundle", "unbundle", file]);
991
990
  return this.importCommit(target, prepared.head, input.id, identity);
992
991
  }, async () => {
993
- await this.idle(target);
992
+ await this.assertAvailable(target);
994
993
  if (target.state.initialized || (await fs.readdir(target.config.cwd)).length) throw new WorkspaceError("nonempty_workbench", "Branch already has a workbench");
995
994
  }));
996
995
  }
@@ -1070,7 +1069,7 @@ ${DEFAULT_WORKSPACE_IGNORE}`, { mode: 384 });
1070
1069
  await this.installGitPolicy(b, result.head);
1071
1070
  return { ...result, treeHash: (await git(b.repo, ["rev-parse", `${result.head}^{tree}`])).toString().trim() };
1072
1071
  }, async () => {
1073
- await this.idle(b);
1072
+ await this.assertAvailable(b);
1074
1073
  if (input.expectedHead !== void 0 && input.expectedHead !== b.state.head) throw new WorkspaceError("conflict", "Workbench head changed");
1075
1074
  }));
1076
1075
  }
@@ -1085,7 +1084,7 @@ ${DEFAULT_WORKSPACE_IGNORE}`, { mode: 384 });
1085
1084
  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
1085
  return { head: b.state.head, pushed: true };
1087
1086
  }, async () => {
1088
- await this.idle(b);
1087
+ await this.assertAvailable(b);
1089
1088
  if (!b.state.head || input.expectedHead !== void 0 && input.expectedHead !== b.state.head) throw new WorkspaceError("conflict", "Workbench head changed");
1090
1089
  }));
1091
1090
  }
@@ -1105,13 +1104,13 @@ ${DEFAULT_WORKSPACE_IGNORE}`, { mode: 384 });
1105
1104
  b.state.blocked = { code: "archived", message: "This branch incarnation is archived; its workbench and source are retained" };
1106
1105
  await this.save(b);
1107
1106
  return { removed: true, receipt: result };
1108
- }, () => this.idle(b)));
1107
+ }, () => this.assertAvailable(b)));
1109
1108
  }
1110
1109
  /** An explicit user reset retains the complete old tree/index before hydration. */
1111
1110
  async reset(identity, input) {
1112
1111
  const b = await this.bench(identity);
1113
1112
  return this.serial(b, () => this.productAction(b, input.id, { method: "reset", expectedHead: input.expectedHead }, async () => {
1114
- await this.idle(b, true);
1113
+ await this.assertAvailable(b, true);
1115
1114
  if (input.expectedHead !== void 0 && input.expectedHead !== b.state.head) throw new WorkspaceError("conflict", "Workbench base changed before reset");
1116
1115
  if (b.state.blocked?.code === "archived") throw new WorkspaceError("archived", "A deleted branch cannot be reset");
1117
1116
  const storage = await this.storage(b, identity);
@@ -1145,12 +1144,7 @@ ${DEFAULT_WORKSPACE_IGNORE}`, { mode: 384 });
1145
1144
  const b = await this.bench(identity);
1146
1145
  if (b.config.rootProfile === "account") return { head: "", unchanged: true };
1147
1146
  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
- }
1147
+ await this.assertAvailable(b);
1154
1148
  return this.publishIdle(b, identity);
1155
1149
  });
1156
1150
  }
@@ -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.144",
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.144",
25
+ "@ricsam/r5dctl": "0.0.144",
26
26
  "node-pty": "1.1.0",
27
27
  "zod": "^4.1.13",
28
28
  "picomatch": "^4.0.3"