@rynx-ai/server 0.1.11-beta.1 → 0.1.11-beta.3

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.
@@ -7,9 +7,9 @@
7
7
  * Direct adapters can therefore expose the same behavior without copying state
8
8
  * into a Control Plane database.
9
9
  */
10
- import { SESSION_PROVIDER_IDS, getRuntimeProfile, isSessionProviderId, newSessionId, normalizeSessionTitle, } from "@rynx-ai/core";
10
+ import { createHash, randomUUID } from "node:crypto";
11
+ import { SESSION_PROVIDER_IDS, getRuntimeProfile, isSessionProviderId, newSessionItemId, newSessionId, normalizeSessionTitle, } from "@rynx-ai/core";
11
12
  import { REMOTE_RUNTIME_SESSION_DEFAULT_PAGE_SIZE, REMOTE_RUNTIME_SESSION_MAX_CURSOR_CHARS, REMOTE_RUNTIME_SESSION_MAX_ID_CHARS, REMOTE_RUNTIME_SESSION_MAX_PAGE_SIZE, } from "@rynx-ai/protocol/remote-runtime-rpc";
12
- import { ensureCodexResumeRollout, } from "@rynx-ai/runtime";
13
13
  const DEFAULT_LIST_LIMIT = REMOTE_RUNTIME_SESSION_DEFAULT_PAGE_SIZE;
14
14
  const MAX_LIST_LIMIT = REMOTE_RUNTIME_SESSION_MAX_PAGE_SIZE;
15
15
  const DEFAULT_SNAPSHOT_ITEM_LIMIT = REMOTE_RUNTIME_SESSION_DEFAULT_PAGE_SIZE;
@@ -47,8 +47,11 @@ export class MachineSessionService {
47
47
  snapshotPageMaxBytes;
48
48
  directoryScanLimit;
49
49
  pendingMessageRetryMs;
50
+ admissionOpen;
51
+ admissionReserve;
50
52
  pendingDeliveries = new Map();
51
53
  cancelledPendingDeliveries = new Set();
54
+ forkTasks = new Map();
52
55
  constructor(ports, options = {}) {
53
56
  this.ports = ports;
54
57
  this.maxListLimit = boundedInteger(options.maxListLimit ?? MAX_LIST_LIMIT, 1, MAX_LIST_LIMIT, "maxListLimit");
@@ -57,7 +60,10 @@ export class MachineSessionService {
57
60
  this.snapshotPageMaxBytes = boundedInteger(options.snapshotPageMaxBytes ?? DEFAULT_SNAPSHOT_PAGE_MAX_BYTES, MIN_PAGE_MAX_BYTES, MAX_PAGE_MAX_BYTES, "snapshotPageMaxBytes");
58
61
  this.directoryScanLimit = boundedInteger(options.directoryScanLimit ?? DEFAULT_DIRECTORY_SCAN_LIMIT, 1, MAX_DIRECTORY_SCAN_LIMIT, "directoryScanLimit");
59
62
  this.pendingMessageRetryMs = boundedInteger(options.pendingMessageRetryMs ?? 1_000, 10, 60_000, "pendingMessageRetryMs");
63
+ this.admissionOpen = options.admissionOpen ?? (() => true);
64
+ this.admissionReserve = options.admissionReserve;
60
65
  void this.resumePendingDeliveries();
66
+ void this.resumeReservedForks();
61
67
  }
62
68
  async list(input = {}) {
63
69
  const limit = requestLimit(input.limit, DEFAULT_LIST_LIMIT, this.maxListLimit, "limit");
@@ -105,14 +111,22 @@ export class MachineSessionService {
105
111
  })).slice(0, limit + 1).map((item) => structuredClone(item));
106
112
  const runtime = cloneRuntimeSnapshot(this.ports.runtimeState.snapshot(sessionId));
107
113
  const empty = snapshotPage(sessionId, [], runtime, false);
108
- const items = candidates.slice(0, limit);
109
- while (items.length > 0) {
110
- const result = snapshotPage(sessionId, items, runtime, candidates.length > items.length);
111
- if (items.length === 1 || jsonByteLength(result) <= this.snapshotPageMaxBytes)
112
- return result;
113
- items.pop();
114
+ const items = [];
115
+ let itemsJsonBytes = 0;
116
+ for (const item of candidates.slice(0, limit)) {
117
+ const nextItemsJsonBytes = itemsJsonBytes + (items.length > 0 ? 1 : 0) + jsonByteLength(item);
118
+ const nextCount = items.length + 1;
119
+ const hasMore = candidates.length > nextCount;
120
+ if (items.length > 0 &&
121
+ snapshotPageJsonByteLength(sessionId, nextItemsJsonBytes, item.id, runtime, hasMore) > this.snapshotPageMaxBytes) {
122
+ break;
123
+ }
124
+ items.push(item);
125
+ itemsJsonBytes = nextItemsJsonBytes;
114
126
  }
115
- return empty;
127
+ return items.length > 0
128
+ ? snapshotPage(sessionId, items, runtime, candidates.length > items.length)
129
+ : empty;
116
130
  }
117
131
  /**
118
132
  * Install a live subscriber synchronously, before returning control to an
@@ -180,6 +194,9 @@ export class MachineSessionService {
180
194
  }
181
195
  /** Create target-owned Session identity from one Agent preset or direct Provider. */
182
196
  async create(input) {
197
+ return this.withAdmission(() => this.createAdmitted(input));
198
+ }
199
+ async createAdmitted(input) {
183
200
  const hasAgent = input.agent !== undefined;
184
201
  const hasProvider = input.provider !== undefined;
185
202
  if (hasAgent === hasProvider) {
@@ -199,21 +216,188 @@ export class MachineSessionService {
199
216
  }
200
217
  target = { provider: input.provider };
201
218
  }
202
- const sessionId = newSessionId();
203
- this.ports.registry.create({
204
- id: sessionId,
205
- source: "console",
206
- ...target,
207
- ...(input.model === undefined ? {} : { model: input.model }),
208
- ...(input.reasoningEffort === undefined ? {} : { reasoningEffort: input.reasoningEffort }),
209
- ...(input.title === undefined ? {} : { title: input.title }),
210
- createdAt: new Date().toISOString(),
219
+ const workspacePort = this.requireWorkspace();
220
+ const workspace = await workspacePort.resolve(input.projectId);
221
+ try {
222
+ const execution = await this.requireExecution().resolve({
223
+ ...target,
224
+ ...(input.model === undefined ? {} : { model: input.model }),
225
+ ...(input.reasoningEffort === undefined ? {} : { reasoningEffort: input.reasoningEffort }),
226
+ cwd: workspace.cwd,
227
+ });
228
+ const sessionId = newSessionId();
229
+ this.ports.registry.create({
230
+ id: sessionId,
231
+ source: "console",
232
+ workspace,
233
+ execution,
234
+ ...(input.title === undefined ? {} : { title: input.title }),
235
+ createdAt: new Date().toISOString(),
236
+ });
237
+ return { sessionId };
238
+ }
239
+ catch (error) {
240
+ if (input.projectId === undefined) {
241
+ await workspacePort.discardUnbound?.(workspace).catch(() => undefined);
242
+ }
243
+ throw error;
244
+ }
245
+ }
246
+ /** Create one independent Session at the source's stable Provider/canonical
247
+ * boundary. Project and Agent selectors are intentionally absent: the target
248
+ * receives exact copies of the source's already-frozen snapshots. */
249
+ async fork(input) {
250
+ return this.withAdmission(() => this.forkAdmitted(input));
251
+ }
252
+ async forkAdmitted(input) {
253
+ const sourceSessionId = validOpaqueToken(input.sourceSessionId, "sourceSessionId", MAX_SESSION_ID_CHARS);
254
+ const operationId = validOpaqueToken(input.operationId, "operationId", MAX_SESSION_ID_CHARS);
255
+ const title = input.title === undefined
256
+ ? undefined
257
+ : normalizeSessionTitle(input.title.trim());
258
+ if (input.title !== undefined && !title) {
259
+ throw new MachineSessionServiceInputError("title must not be empty");
260
+ }
261
+ const requestHash = createHash("sha256")
262
+ .update(JSON.stringify({ sourceSessionId, title: title ?? null }))
263
+ .digest("hex");
264
+ const operation = await this.requireForks().claim({
265
+ operationId,
266
+ requestHash,
267
+ sourceSessionId,
268
+ ...(title ? { title } : {}),
269
+ });
270
+ if (operation.state === "target_deleted") {
271
+ throw new MachineSessionServiceFailure("conflict", "fork target was deleted");
272
+ }
273
+ if (operation.state === "completed") {
274
+ return { sessionId: operation.targetSessionId, disposition: "replayed" };
275
+ }
276
+ const inflight = this.forkTasks.get(operationId);
277
+ if (inflight)
278
+ return inflight;
279
+ const task = this.performFork(operation).finally(() => {
280
+ if (this.forkTasks.get(operationId) === task) {
281
+ this.forkTasks.delete(operationId);
282
+ }
211
283
  });
212
- return { sessionId };
284
+ this.forkTasks.set(operationId, task);
285
+ return task;
286
+ }
287
+ /** Publish a Provider TUI `/clear` or `/fork` after its native binding has
288
+ * already been persisted. The source snapshots remain the only workspace and
289
+ * execution authority; Provider events cannot alter them during rotation. */
290
+ async recordNativeRotation(input) {
291
+ const sourceSessionId = validOpaqueToken(input.sourceSessionId, "sourceSessionId", MAX_SESSION_ID_CHARS);
292
+ const targetSessionId = validOpaqueToken(input.targetSessionId, "targetSessionId", MAX_SESSION_ID_CHARS);
293
+ if (sourceSessionId === targetSessionId) {
294
+ throw new MachineSessionServiceInputError("source and target Session must differ");
295
+ }
296
+ const source = this.ports.registry.get(sourceSessionId);
297
+ if (!source) {
298
+ throw new MachineSessionServiceFailure("not_found", "source Session not found");
299
+ }
300
+ const existing = this.ports.registry.get(targetSessionId);
301
+ if (existing) {
302
+ if (input.kind === "fork" &&
303
+ existing.forkedFromSessionId === sourceSessionId)
304
+ return;
305
+ throw new MachineSessionServiceFailure("conflict", "target Session already exists");
306
+ }
307
+ const workspace = structuredClone(source.workspace);
308
+ const execution = structuredClone(source.execution);
309
+ const sourceTitle = source.title;
310
+ const now = new Date().toISOString();
311
+ const target = {
312
+ id: targetSessionId,
313
+ source: input.kind,
314
+ workspace,
315
+ execution,
316
+ ...(input.kind === "fork" && sourceTitle ? { title: sourceTitle } : {}),
317
+ ...(input.kind === "fork" ? { forkedFromSessionId: sourceSessionId } : {}),
318
+ createdAt: now,
319
+ updatedAt: now,
320
+ };
321
+ if (input.kind === "clear") {
322
+ this.ports.registry.create(target);
323
+ return;
324
+ }
325
+ const items = (await this.ports.log.snapshot(sourceSessionId))
326
+ .map((item) => structuredClone(item));
327
+ await this.requireForks().publishNativeFork({
328
+ sourceSessionId,
329
+ target,
330
+ items,
331
+ });
332
+ }
333
+ async performFork(operation) {
334
+ const source = this.ports.registry.get(operation.sourceSessionId);
335
+ if (!source) {
336
+ throw new MachineSessionServiceFailure("not_found", "source Session not found");
337
+ }
338
+ const runner = this.requireExecution().runner;
339
+ if (!runner.forkSession) {
340
+ throw new MachineSessionServiceFailure("failed_precondition", "Provider does not support Session fork");
341
+ }
342
+ const workspace = structuredClone(source.workspace);
343
+ const execution = structuredClone(source.execution);
344
+ const sourceTitle = source.title;
345
+ let items;
346
+ try {
347
+ this.assertForkableSource(operation.sourceSessionId);
348
+ const native = await runner.forkSession(operation.sourceSessionId, operation.targetSessionId, {
349
+ workspace: structuredClone(workspace),
350
+ execution: structuredClone(execution),
351
+ beforeProviderFork: async () => {
352
+ this.assertForkableSource(operation.sourceSessionId);
353
+ items = (await this.ports.log.snapshot(operation.sourceSessionId))
354
+ .map((item) => structuredClone(item));
355
+ },
356
+ });
357
+ if (!native.ok) {
358
+ throw new MachineSessionServiceFailure("failed_precondition", "Provider could not fork the Session", native.message);
359
+ }
360
+ if (!items) {
361
+ throw new MachineSessionServiceFailure("outcome_unknown", "Provider fork completed without a canonical fork point");
362
+ }
363
+ const now = new Date().toISOString();
364
+ const targetTitle = operation.title ?? sourceTitle;
365
+ await this.requireForks().commit({
366
+ operationId: operation.operationId,
367
+ target: {
368
+ id: operation.targetSessionId,
369
+ source: "fork",
370
+ workspace: structuredClone(workspace),
371
+ execution: structuredClone(execution),
372
+ ...(targetTitle ? { title: targetTitle } : {}),
373
+ forkedFromSessionId: operation.sourceSessionId,
374
+ createdAt: now,
375
+ updatedAt: now,
376
+ },
377
+ items,
378
+ });
379
+ return { sessionId: operation.targetSessionId, disposition: "created" };
380
+ }
381
+ catch (error) {
382
+ await Promise.resolve(this.requireForks().markError(operation.operationId, error instanceof Error ? error.message : String(error))).catch(() => undefined);
383
+ throw error;
384
+ }
385
+ }
386
+ assertForkableSource(sessionId) {
387
+ const runtime = this.ports.runtimeState.snapshot(sessionId);
388
+ if (runtime.status !== "idle" ||
389
+ runtime.activeResponseIds.length > 0 ||
390
+ runtime.pendingInteractions.length > 0) {
391
+ throw new MachineSessionServiceFailure("failed_precondition", "source Session must be idle before it can be forked");
392
+ }
213
393
  }
214
394
  /** Inject one turn through the target daemon's native single-writer runner. */
215
395
  async sendMessage(sessionIdInput, messageInput) {
396
+ return this.withAdmission(() => this.sendMessageAdmitted(sessionIdInput, messageInput));
397
+ }
398
+ async sendMessageAdmitted(sessionIdInput, messageInput) {
216
399
  const sessionId = validOpaqueToken(sessionIdInput, "sessionId", MAX_SESSION_ID_CHARS);
400
+ await this.assertNotForkReserved(sessionId);
217
401
  const request = typeof messageInput === "string"
218
402
  ? { message: messageInput }
219
403
  : messageInput;
@@ -272,11 +456,54 @@ export class MachineSessionService {
272
456
  if (request.clientMessageId) {
273
457
  await resources?.markMessageInjecting(sessionId, request.clientMessageId);
274
458
  }
459
+ const responseId = this.ports.publishEvent ? `resp_${randomUUID()}` : undefined;
460
+ if (responseId) {
461
+ await this.ports.publishEvent(sessionId, {
462
+ type: "session.input.consumed",
463
+ item: {
464
+ id: newSessionItemId("message"),
465
+ sessionId,
466
+ position: 0,
467
+ responseId,
468
+ status: "completed",
469
+ createdAt: Date.now(),
470
+ type: "message",
471
+ data: {
472
+ role: "user",
473
+ content: prepared.input.content.map((part) => part.type === "text"
474
+ ? { type: "input_text", text: part.text }
475
+ : { ...part.resource }),
476
+ },
477
+ },
478
+ });
479
+ await this.ports.publishEvent(sessionId, {
480
+ type: "session.status",
481
+ sessionId,
482
+ responseId,
483
+ status: "running",
484
+ statusKind: "startup",
485
+ });
486
+ }
487
+ const abandonResponseHandoff = responseId
488
+ ? this.ports.runtimeState.beginResponseHandoff?.(sessionId, responseId)
489
+ : undefined;
490
+ const clearStartup = async (status) => {
491
+ if (!responseId)
492
+ return;
493
+ await this.ports.publishEvent(sessionId, {
494
+ type: "session.status",
495
+ sessionId,
496
+ responseId,
497
+ status,
498
+ });
499
+ };
275
500
  let execution;
276
501
  try {
277
502
  execution = await this.ensureLiveSession(sessionId, meta);
278
503
  }
279
504
  catch (error) {
505
+ abandonResponseHandoff?.();
506
+ await clearStartup("idle");
280
507
  if (request.clientMessageId) {
281
508
  await resources?.markMessageFailedNotStarted(sessionId, request.clientMessageId, error instanceof Error ? error.message : String(error));
282
509
  }
@@ -284,26 +511,36 @@ export class MachineSessionService {
284
511
  }
285
512
  let outcome;
286
513
  try {
287
- outcome = await execution.runner.injectMessage(sessionId, hasMessage && !needsPreparedOperation ? request.message : prepared.input);
514
+ const runtimeInput = responseId
515
+ ? { ...prepared.input, responseId }
516
+ : prepared.input;
517
+ outcome = await execution.runner.injectMessage(sessionId, responseId || needsPreparedOperation ? runtimeInput : request.message);
288
518
  }
289
519
  catch (error) {
520
+ abandonResponseHandoff?.();
521
+ await clearStartup("idle");
290
522
  if (request.clientMessageId) {
291
523
  await Promise.resolve(resources?.markMessageOutcomeUnknown(sessionId, request.clientMessageId, error instanceof Error ? error.message : String(error))).catch(() => undefined);
292
524
  }
293
525
  throw new MachineSessionServiceFailure("outcome_unknown", "live injection outcome is unknown");
294
526
  }
295
527
  if (outcome === "failed") {
528
+ abandonResponseHandoff?.();
529
+ await clearStartup("idle");
296
530
  if (request.clientMessageId) {
297
531
  await resources?.markMessageOutcomeUnknown(sessionId, request.clientMessageId, `live injection ${outcome}`);
298
532
  }
299
533
  throw new MachineSessionServiceFailure("outcome_unknown", `live injection ${outcome}`);
300
534
  }
301
535
  if (outcome !== "injected") {
536
+ abandonResponseHandoff?.();
537
+ await clearStartup("idle");
302
538
  if (request.clientMessageId) {
303
539
  await resources?.markMessageFailedNotStarted(sessionId, request.clientMessageId, `live injection ${outcome}`);
304
540
  }
305
541
  throw new MachineSessionServiceFailure("failed_precondition", `live injection ${outcome}`);
306
542
  }
543
+ await clearStartup("running");
307
544
  if (request.clientMessageId) {
308
545
  try {
309
546
  await resources?.markMessageInjected(sessionId, request.clientMessageId);
@@ -347,7 +584,11 @@ export class MachineSessionService {
347
584
  * delivery worker starts the pane now, waits for a real native thread, then
348
585
  * injects exactly once. `failed` is fenced as outcome_unknown and never retried. */
349
586
  async enqueueMessage(sessionIdInput, messageInput) {
587
+ return this.withAdmission(() => this.enqueueMessageAdmitted(sessionIdInput, messageInput));
588
+ }
589
+ async enqueueMessageAdmitted(sessionIdInput, messageInput) {
350
590
  const sessionId = validOpaqueToken(sessionIdInput, "sessionId", MAX_SESSION_ID_CHARS);
591
+ await this.assertNotForkReserved(sessionId);
351
592
  if (typeof messageInput !== "string" || messageInput.length === 0) {
352
593
  throw new MachineSessionServiceInputError("message must be a non-empty string");
353
594
  }
@@ -377,6 +618,9 @@ export class MachineSessionService {
377
618
  }
378
619
  /** Explicitly restore the target daemon's live runner without starting a turn. */
379
620
  async startTerminal(sessionIdInput) {
621
+ return this.withAdmission(() => this.startTerminalAdmitted(sessionIdInput));
622
+ }
623
+ async startTerminalAdmitted(sessionIdInput) {
380
624
  const sessionId = validOpaqueToken(sessionIdInput, "sessionId", MAX_SESSION_ID_CHARS);
381
625
  const request = await this.liveSessionRequest(sessionId);
382
626
  const start = request.execution.runner.startLiveSession?.bind(request.execution.runner) ??
@@ -397,40 +641,13 @@ export class MachineSessionService {
397
641
  }
398
642
  async liveSessionRequest(sessionId, meta = this.ports.registry.get(sessionId)) {
399
643
  const execution = this.requireExecution();
400
- const consoleMeta = meta?.source === "console" ? meta : undefined;
401
- const bound = consoleMeta ? null : ((await execution.sessionStore?.get(sessionId)) ?? null);
402
- if (!meta && !bound) {
644
+ if (!meta) {
403
645
  throw new MachineSessionServiceFailure("not_found", "session not found");
404
646
  }
405
- const liveRuntime = await execution.runtime.resolveRuntime({
406
- agentName: consoleMeta?.agent,
407
- agentSpec: consoleMeta?.config,
408
- provider: meta?.provider ?? bound?.runtime,
409
- });
410
- if (liveRuntime === "codex" || liveRuntime === "traex") {
411
- const codexRecord = await execution.sessionStore?.get(sessionId);
412
- if (codexRecord?.codexSessionId) {
413
- ensureCodexResumeRollout({
414
- sessionId,
415
- runtime: liveRuntime,
416
- threadId: codexRecord.codexSessionId,
417
- cwd: codexRecord.cwd ?? process.cwd(),
418
- items: await this.ports.log.snapshot(sessionId),
419
- });
420
- }
421
- }
647
+ const liveRuntime = meta.execution.provider;
422
648
  const options = {
423
- ...(bound?.cwd
424
- ? { cwd: bound.cwd }
425
- : consoleMeta?.config?.osEnv?.cwd
426
- ? { cwd: consoleMeta.config.osEnv.cwd }
427
- : {}),
428
- runtime: liveRuntime,
429
- ...(consoleMeta?.reasoningEffort
430
- ? { reasoningEffort: consoleMeta.reasoningEffort }
431
- : {}),
432
- ...(consoleMeta?.agent ? { agentName: consoleMeta.agent } : {}),
433
- ...(consoleMeta?.config ? { agentSpec: consoleMeta.config } : {}),
649
+ workspace: structuredClone(meta.workspace),
650
+ execution: structuredClone(meta.execution),
434
651
  };
435
652
  return { execution, liveRuntime, options };
436
653
  }
@@ -441,6 +658,7 @@ export class MachineSessionService {
441
658
  }
442
659
  async delete(sessionIdInput) {
443
660
  const sessionId = validOpaqueToken(sessionIdInput, "sessionId", MAX_SESSION_ID_CHARS);
661
+ await this.assertNotForkReserved(sessionId);
444
662
  try {
445
663
  await this.ports.lifecycle?.beforeDelete?.(sessionId);
446
664
  }
@@ -455,6 +673,7 @@ export class MachineSessionService {
455
673
  this.ports.registry.remove(sessionId);
456
674
  await this.ports.log.deleteSession(sessionId);
457
675
  this.ports.runtimeState.remove?.(sessionId);
676
+ await this.ports.forks?.markTargetDeleted(sessionId);
458
677
  try {
459
678
  await this.ports.lifecycle?.afterDelete?.(sessionId);
460
679
  }
@@ -469,6 +688,24 @@ export class MachineSessionService {
469
688
  }
470
689
  return this.ports.agents;
471
690
  }
691
+ reserveAdmission() {
692
+ const reservation = this.admissionReserve?.();
693
+ if (reservation)
694
+ return reservation;
695
+ if (!this.admissionReserve && this.admissionOpen()) {
696
+ return { release() { } };
697
+ }
698
+ throw new MachineSessionServiceFailure("failed_precondition", "daemon maintenance is in progress");
699
+ }
700
+ async withAdmission(operation) {
701
+ const reservation = this.reserveAdmission();
702
+ try {
703
+ return await operation();
704
+ }
705
+ finally {
706
+ reservation.release();
707
+ }
708
+ }
472
709
  requireResources() {
473
710
  if (!this.ports.resources) {
474
711
  throw new MachineSessionServiceFailure("failed_precondition", "Session resources are unavailable");
@@ -520,7 +757,9 @@ export class MachineSessionService {
520
757
  if (!pending || pending.state !== "queued")
521
758
  return;
522
759
  let injectionAttempted = false;
760
+ let admission;
523
761
  try {
762
+ admission = this.reserveAdmission();
524
763
  const execution = await this.ensureLiveSession(sessionId);
525
764
  if (this.cancelledPendingDeliveries.has(sessionId))
526
765
  return;
@@ -553,6 +792,9 @@ export class MachineSessionService {
553
792
  return;
554
793
  }
555
794
  }
795
+ finally {
796
+ admission?.release();
797
+ }
556
798
  await retryDelay(this.pendingMessageRetryMs);
557
799
  }
558
800
  }
@@ -592,6 +834,44 @@ export class MachineSessionService {
592
834
  }
593
835
  return this.ports.execution;
594
836
  }
837
+ requireWorkspace() {
838
+ const workspace = this.ports.workspace;
839
+ if (!workspace) {
840
+ throw new MachineSessionServiceFailure("failed_precondition", "Session workspace resolution is unavailable");
841
+ }
842
+ return workspace;
843
+ }
844
+ requireForks() {
845
+ if (!this.ports.forks) {
846
+ throw new MachineSessionServiceFailure("failed_precondition", "Session fork storage is unavailable");
847
+ }
848
+ return this.ports.forks;
849
+ }
850
+ async assertNotForkReserved(sessionId) {
851
+ if (await this.ports.forks?.hasReservedSource(sessionId)) {
852
+ throw new MachineSessionServiceFailure("conflict", "Session fork is in progress");
853
+ }
854
+ }
855
+ async resumeReservedForks() {
856
+ if (!this.ports.forks)
857
+ return;
858
+ try {
859
+ for (const operation of await this.ports.forks.listReserved()) {
860
+ if (this.forkTasks.has(operation.operationId))
861
+ continue;
862
+ const task = this.performFork(operation).finally(() => {
863
+ if (this.forkTasks.get(operation.operationId) === task) {
864
+ this.forkTasks.delete(operation.operationId);
865
+ }
866
+ });
867
+ this.forkTasks.set(operation.operationId, task);
868
+ void task.catch(() => undefined);
869
+ }
870
+ }
871
+ catch {
872
+ // A direct replay or the next daemon restart retries durable operations.
873
+ }
874
+ }
595
875
  }
596
876
  /** One-line title derived from the first user message; no model call. */
597
877
  export function synthesizeSessionTitle(message, limit = 60) {
@@ -688,6 +968,13 @@ function snapshotPage(sessionId, items, runtime, hasMore) {
688
968
  ...(hasMore && items.length > 0 ? { nextAfterId: items.at(-1).id } : {}),
689
969
  };
690
970
  }
971
+ /** Exact UTF-8 size of {@link snapshotPage} without repeatedly serializing the
972
+ * complete item array while searching for a page boundary. */
973
+ function snapshotPageJsonByteLength(sessionId, itemsJsonBytes, finalItemId, runtime, hasMore) {
974
+ return Buffer.byteLength(`{"sessionId":${JSON.stringify(sessionId)},"items":[`, "utf8")
975
+ + itemsJsonBytes
976
+ + Buffer.byteLength(`],"runtime":${JSON.stringify(runtime)},"hasMore":${hasMore}${hasMore ? `,"nextAfterId":${JSON.stringify(finalItemId)}` : ""}}`, "utf8");
977
+ }
691
978
  function sessionSummary(id, meta, log, runtimeState, pendingState) {
692
979
  const createdAt = meta?.createdAt ?? timestampFromEpoch(log?.createdAt, "createdAt");
693
980
  const updatedAt = log
@@ -695,8 +982,7 @@ function sessionSummary(id, meta, log, runtimeState, pendingState) {
695
982
  : (meta?.updatedAt ?? createdAt);
696
983
  return {
697
984
  id,
698
- ...(meta?.provider === undefined ? {} : { provider: meta.provider }),
699
- ...(meta?.agent === undefined ? {} : { agent: meta.agent }),
985
+ ...(meta?.execution?.provider === undefined ? {} : { provider: meta.execution.provider }),
700
986
  ...(meta?.title === undefined ? {} : { title: normalizeSessionTitle(meta.title) }),
701
987
  status: runtimeState.snapshot(id).status,
702
988
  ...(pendingState === undefined
@@ -1,4 +1,4 @@
1
- import type { RemoteRuntimeEmulatorGesturePoint, RemoteRuntimeRpcRequest, RemoteRuntimeRpcResponse, RemoteRuntimeSequencedSessionEvent } from "@rynx-ai/protocol/remote-runtime-rpc";
1
+ import type { RemoteRuntimeEmulatorGesturePoint, RemoteRuntimeProject, RemoteRuntimeProjectCreateParams, RemoteRuntimeRpcRequest, RemoteRuntimeRpcResponse, RemoteRuntimeSequencedSessionEvent } from "@rynx-ai/protocol/remote-runtime-rpc";
2
2
  import { type DaemonRuntimeHost } from "./remote-runtime.js";
3
3
  import { type MachineSessionService } from "./machine-session-service.js";
4
4
  import { type SessionBrowserService } from "./session-browser-service.js";
@@ -22,10 +22,19 @@ export interface RemoteRuntimeDispatcherOptions {
22
22
  browsers?: RemoteRuntimeBrowserHost;
23
23
  terminals?: SessionTerminalHost;
24
24
  providerClis?: ProviderCliStatusHost;
25
+ projects?: RemoteRuntimeProjectHost;
25
26
  /** Optional diagnostic sink. Wire errors deliberately omit internal details. */
26
27
  onError?: (error: unknown, request: RemoteRuntimeRpcRequest) => void;
27
28
  }
28
- export type RemoteRuntimeSessionHost = Pick<MachineSessionService, "list" | "snapshot" | "watch" | "interrupt" | "agentOptions" | "launchOptions" | "create" | "sendMessage" | "resourcePolicy" | "beginResourceUpload" | "writeResourceUploadChunk" | "commitResourceUpload" | "getResource" | "readResource" | "deleteResource" | "enqueueMessage" | "startTerminal" | "delete" | "resolveInteraction">;
29
+ /** Runtime-local Project persistence. Project ids never cross into Session records. */
30
+ export interface RemoteRuntimeProjectHost {
31
+ list(): RemoteRuntimeProject[] | Promise<RemoteRuntimeProject[]>;
32
+ get(projectId: string): RemoteRuntimeProject | undefined | Promise<RemoteRuntimeProject | undefined>;
33
+ create(input: RemoteRuntimeProjectCreateParams): RemoteRuntimeProject | Promise<RemoteRuntimeProject>;
34
+ update(projectId: string, input: RemoteRuntimeProjectCreateParams): RemoteRuntimeProject | undefined | Promise<RemoteRuntimeProject | undefined>;
35
+ remove(projectId: string): boolean | Promise<boolean>;
36
+ }
37
+ export type RemoteRuntimeSessionHost = Pick<MachineSessionService, "list" | "snapshot" | "watch" | "interrupt" | "agentOptions" | "launchOptions" | "create" | "fork" | "sendMessage" | "resourcePolicy" | "beginResourceUpload" | "writeResourceUploadChunk" | "commitResourceUpload" | "getResource" | "readResource" | "deleteResource" | "enqueueMessage" | "startTerminal" | "delete" | "resolveInteraction">;
29
38
  export type RemoteRuntimeBrowserHost = Pick<SessionBrowserService, "getState" | "open" | "close" | "createPage" | "closePage" | "activatePage" | "navigatePage" | "goBack" | "goForward" | "reload">;
30
39
  export type RemoteRuntimeSessionEmulatorHost = Pick<SessionEmulatorService, "getState" | "listDevices" | "attach" | "detach" | "tap" | "type" | "button" | "rotate" | "launch" | "gesture" | "releaseDevice" | "stopDevice">;
31
40
  export interface OpenRemoteRuntimeSessionEvents {
@@ -88,6 +97,7 @@ export declare class RemoteRuntimeDispatcher {
88
97
  openSessionEvents(sessionId: string, context: RemoteRuntimeDispatchContext): OpenRemoteRuntimeSessionEvents;
89
98
  openSessionTerminal(sessionId: string, options: SessionTerminalOpenOptions, context: RemoteRuntimeDispatchContext): Promise<SessionTerminalAttachment>;
90
99
  private withEmulator;
100
+ private withProjects;
91
101
  private withSessions;
92
102
  private withSessionEmulators;
93
103
  private withBrowsers;