@rynx-ai/server 0.1.10 → 0.1.11-beta.2

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;
@@ -49,6 +49,7 @@ export class MachineSessionService {
49
49
  pendingMessageRetryMs;
50
50
  pendingDeliveries = new Map();
51
51
  cancelledPendingDeliveries = new Set();
52
+ forkTasks = new Map();
52
53
  constructor(ports, options = {}) {
53
54
  this.ports = ports;
54
55
  this.maxListLimit = boundedInteger(options.maxListLimit ?? MAX_LIST_LIMIT, 1, MAX_LIST_LIMIT, "maxListLimit");
@@ -58,6 +59,7 @@ export class MachineSessionService {
58
59
  this.directoryScanLimit = boundedInteger(options.directoryScanLimit ?? DEFAULT_DIRECTORY_SCAN_LIMIT, 1, MAX_DIRECTORY_SCAN_LIMIT, "directoryScanLimit");
59
60
  this.pendingMessageRetryMs = boundedInteger(options.pendingMessageRetryMs ?? 1_000, 10, 60_000, "pendingMessageRetryMs");
60
61
  void this.resumePendingDeliveries();
62
+ void this.resumeReservedForks();
61
63
  }
62
64
  async list(input = {}) {
63
65
  const limit = requestLimit(input.limit, DEFAULT_LIST_LIMIT, this.maxListLimit, "limit");
@@ -105,14 +107,22 @@ export class MachineSessionService {
105
107
  })).slice(0, limit + 1).map((item) => structuredClone(item));
106
108
  const runtime = cloneRuntimeSnapshot(this.ports.runtimeState.snapshot(sessionId));
107
109
  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();
110
+ const items = [];
111
+ let itemsJsonBytes = 0;
112
+ for (const item of candidates.slice(0, limit)) {
113
+ const nextItemsJsonBytes = itemsJsonBytes + (items.length > 0 ? 1 : 0) + jsonByteLength(item);
114
+ const nextCount = items.length + 1;
115
+ const hasMore = candidates.length > nextCount;
116
+ if (items.length > 0 &&
117
+ snapshotPageJsonByteLength(sessionId, nextItemsJsonBytes, item.id, runtime, hasMore) > this.snapshotPageMaxBytes) {
118
+ break;
119
+ }
120
+ items.push(item);
121
+ itemsJsonBytes = nextItemsJsonBytes;
114
122
  }
115
- return empty;
123
+ return items.length > 0
124
+ ? snapshotPage(sessionId, items, runtime, candidates.length > items.length)
125
+ : empty;
116
126
  }
117
127
  /**
118
128
  * Install a live subscriber synchronously, before returning control to an
@@ -199,21 +209,182 @@ export class MachineSessionService {
199
209
  }
200
210
  target = { provider: input.provider };
201
211
  }
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(),
212
+ const workspacePort = this.requireWorkspace();
213
+ const workspace = await workspacePort.resolve(input.projectId);
214
+ try {
215
+ const execution = await this.requireExecution().resolve({
216
+ ...target,
217
+ ...(input.model === undefined ? {} : { model: input.model }),
218
+ ...(input.reasoningEffort === undefined ? {} : { reasoningEffort: input.reasoningEffort }),
219
+ cwd: workspace.cwd,
220
+ });
221
+ const sessionId = newSessionId();
222
+ this.ports.registry.create({
223
+ id: sessionId,
224
+ source: "console",
225
+ workspace,
226
+ execution,
227
+ ...(input.title === undefined ? {} : { title: input.title }),
228
+ createdAt: new Date().toISOString(),
229
+ });
230
+ return { sessionId };
231
+ }
232
+ catch (error) {
233
+ if (input.projectId === undefined) {
234
+ await workspacePort.discardUnbound?.(workspace).catch(() => undefined);
235
+ }
236
+ throw error;
237
+ }
238
+ }
239
+ /** Create one independent Session at the source's stable Provider/canonical
240
+ * boundary. Project and Agent selectors are intentionally absent: the target
241
+ * receives exact copies of the source's already-frozen snapshots. */
242
+ async fork(input) {
243
+ const sourceSessionId = validOpaqueToken(input.sourceSessionId, "sourceSessionId", MAX_SESSION_ID_CHARS);
244
+ const operationId = validOpaqueToken(input.operationId, "operationId", MAX_SESSION_ID_CHARS);
245
+ const title = input.title === undefined
246
+ ? undefined
247
+ : normalizeSessionTitle(input.title.trim());
248
+ if (input.title !== undefined && !title) {
249
+ throw new MachineSessionServiceInputError("title must not be empty");
250
+ }
251
+ const requestHash = createHash("sha256")
252
+ .update(JSON.stringify({ sourceSessionId, title: title ?? null }))
253
+ .digest("hex");
254
+ const operation = await this.requireForks().claim({
255
+ operationId,
256
+ requestHash,
257
+ sourceSessionId,
258
+ ...(title ? { title } : {}),
259
+ });
260
+ if (operation.state === "target_deleted") {
261
+ throw new MachineSessionServiceFailure("conflict", "fork target was deleted");
262
+ }
263
+ if (operation.state === "completed") {
264
+ return { sessionId: operation.targetSessionId, disposition: "replayed" };
265
+ }
266
+ const inflight = this.forkTasks.get(operationId);
267
+ if (inflight)
268
+ return inflight;
269
+ const task = this.performFork(operation).finally(() => {
270
+ if (this.forkTasks.get(operationId) === task) {
271
+ this.forkTasks.delete(operationId);
272
+ }
211
273
  });
212
- return { sessionId };
274
+ this.forkTasks.set(operationId, task);
275
+ return task;
276
+ }
277
+ /** Publish a Provider TUI `/clear` or `/fork` after its native binding has
278
+ * already been persisted. The source snapshots remain the only workspace and
279
+ * execution authority; Provider events cannot alter them during rotation. */
280
+ async recordNativeRotation(input) {
281
+ const sourceSessionId = validOpaqueToken(input.sourceSessionId, "sourceSessionId", MAX_SESSION_ID_CHARS);
282
+ const targetSessionId = validOpaqueToken(input.targetSessionId, "targetSessionId", MAX_SESSION_ID_CHARS);
283
+ if (sourceSessionId === targetSessionId) {
284
+ throw new MachineSessionServiceInputError("source and target Session must differ");
285
+ }
286
+ const source = this.ports.registry.get(sourceSessionId);
287
+ if (!source) {
288
+ throw new MachineSessionServiceFailure("not_found", "source Session not found");
289
+ }
290
+ const existing = this.ports.registry.get(targetSessionId);
291
+ if (existing) {
292
+ if (input.kind === "fork" &&
293
+ existing.forkedFromSessionId === sourceSessionId)
294
+ return;
295
+ throw new MachineSessionServiceFailure("conflict", "target Session already exists");
296
+ }
297
+ const workspace = structuredClone(source.workspace);
298
+ const execution = structuredClone(source.execution);
299
+ const sourceTitle = source.title;
300
+ const now = new Date().toISOString();
301
+ const target = {
302
+ id: targetSessionId,
303
+ source: input.kind,
304
+ workspace,
305
+ execution,
306
+ ...(input.kind === "fork" && sourceTitle ? { title: sourceTitle } : {}),
307
+ ...(input.kind === "fork" ? { forkedFromSessionId: sourceSessionId } : {}),
308
+ createdAt: now,
309
+ updatedAt: now,
310
+ };
311
+ if (input.kind === "clear") {
312
+ this.ports.registry.create(target);
313
+ return;
314
+ }
315
+ const items = (await this.ports.log.snapshot(sourceSessionId))
316
+ .map((item) => structuredClone(item));
317
+ await this.requireForks().publishNativeFork({
318
+ sourceSessionId,
319
+ target,
320
+ items,
321
+ });
322
+ }
323
+ async performFork(operation) {
324
+ const source = this.ports.registry.get(operation.sourceSessionId);
325
+ if (!source) {
326
+ throw new MachineSessionServiceFailure("not_found", "source Session not found");
327
+ }
328
+ const runner = this.requireExecution().runner;
329
+ if (!runner.forkSession) {
330
+ throw new MachineSessionServiceFailure("failed_precondition", "Provider does not support Session fork");
331
+ }
332
+ const workspace = structuredClone(source.workspace);
333
+ const execution = structuredClone(source.execution);
334
+ const sourceTitle = source.title;
335
+ let items;
336
+ try {
337
+ this.assertForkableSource(operation.sourceSessionId);
338
+ const native = await runner.forkSession(operation.sourceSessionId, operation.targetSessionId, {
339
+ workspace: structuredClone(workspace),
340
+ execution: structuredClone(execution),
341
+ beforeProviderFork: async () => {
342
+ this.assertForkableSource(operation.sourceSessionId);
343
+ items = (await this.ports.log.snapshot(operation.sourceSessionId))
344
+ .map((item) => structuredClone(item));
345
+ },
346
+ });
347
+ if (!native.ok) {
348
+ throw new MachineSessionServiceFailure("failed_precondition", "Provider could not fork the Session", native.message);
349
+ }
350
+ if (!items) {
351
+ throw new MachineSessionServiceFailure("outcome_unknown", "Provider fork completed without a canonical fork point");
352
+ }
353
+ const now = new Date().toISOString();
354
+ const targetTitle = operation.title ?? sourceTitle;
355
+ await this.requireForks().commit({
356
+ operationId: operation.operationId,
357
+ target: {
358
+ id: operation.targetSessionId,
359
+ source: "fork",
360
+ workspace: structuredClone(workspace),
361
+ execution: structuredClone(execution),
362
+ ...(targetTitle ? { title: targetTitle } : {}),
363
+ forkedFromSessionId: operation.sourceSessionId,
364
+ createdAt: now,
365
+ updatedAt: now,
366
+ },
367
+ items,
368
+ });
369
+ return { sessionId: operation.targetSessionId, disposition: "created" };
370
+ }
371
+ catch (error) {
372
+ await Promise.resolve(this.requireForks().markError(operation.operationId, error instanceof Error ? error.message : String(error))).catch(() => undefined);
373
+ throw error;
374
+ }
375
+ }
376
+ assertForkableSource(sessionId) {
377
+ const runtime = this.ports.runtimeState.snapshot(sessionId);
378
+ if (runtime.status !== "idle" ||
379
+ runtime.activeResponseIds.length > 0 ||
380
+ runtime.pendingInteractions.length > 0) {
381
+ throw new MachineSessionServiceFailure("failed_precondition", "source Session must be idle before it can be forked");
382
+ }
213
383
  }
214
384
  /** Inject one turn through the target daemon's native single-writer runner. */
215
385
  async sendMessage(sessionIdInput, messageInput) {
216
386
  const sessionId = validOpaqueToken(sessionIdInput, "sessionId", MAX_SESSION_ID_CHARS);
387
+ await this.assertNotForkReserved(sessionId);
217
388
  const request = typeof messageInput === "string"
218
389
  ? { message: messageInput }
219
390
  : messageInput;
@@ -272,11 +443,50 @@ export class MachineSessionService {
272
443
  if (request.clientMessageId) {
273
444
  await resources?.markMessageInjecting(sessionId, request.clientMessageId);
274
445
  }
446
+ const responseId = this.ports.publishEvent ? `resp_${randomUUID()}` : undefined;
447
+ if (responseId) {
448
+ await this.ports.publishEvent(sessionId, {
449
+ type: "session.input.consumed",
450
+ item: {
451
+ id: newSessionItemId("message"),
452
+ sessionId,
453
+ position: 0,
454
+ responseId,
455
+ status: "completed",
456
+ createdAt: Date.now(),
457
+ type: "message",
458
+ data: {
459
+ role: "user",
460
+ content: prepared.input.content.map((part) => part.type === "text"
461
+ ? { type: "input_text", text: part.text }
462
+ : { ...part.resource }),
463
+ },
464
+ },
465
+ });
466
+ await this.ports.publishEvent(sessionId, {
467
+ type: "session.status",
468
+ sessionId,
469
+ responseId,
470
+ status: "running",
471
+ statusKind: "startup",
472
+ });
473
+ }
474
+ const clearStartup = async (status) => {
475
+ if (!responseId)
476
+ return;
477
+ await this.ports.publishEvent(sessionId, {
478
+ type: "session.status",
479
+ sessionId,
480
+ responseId,
481
+ status,
482
+ });
483
+ };
275
484
  let execution;
276
485
  try {
277
486
  execution = await this.ensureLiveSession(sessionId, meta);
278
487
  }
279
488
  catch (error) {
489
+ await clearStartup("idle");
280
490
  if (request.clientMessageId) {
281
491
  await resources?.markMessageFailedNotStarted(sessionId, request.clientMessageId, error instanceof Error ? error.message : String(error));
282
492
  }
@@ -284,26 +494,33 @@ export class MachineSessionService {
284
494
  }
285
495
  let outcome;
286
496
  try {
287
- outcome = await execution.runner.injectMessage(sessionId, hasMessage && !needsPreparedOperation ? request.message : prepared.input);
497
+ const runtimeInput = responseId
498
+ ? { ...prepared.input, responseId }
499
+ : prepared.input;
500
+ outcome = await execution.runner.injectMessage(sessionId, responseId || needsPreparedOperation ? runtimeInput : request.message);
288
501
  }
289
502
  catch (error) {
503
+ await clearStartup("idle");
290
504
  if (request.clientMessageId) {
291
505
  await Promise.resolve(resources?.markMessageOutcomeUnknown(sessionId, request.clientMessageId, error instanceof Error ? error.message : String(error))).catch(() => undefined);
292
506
  }
293
507
  throw new MachineSessionServiceFailure("outcome_unknown", "live injection outcome is unknown");
294
508
  }
295
509
  if (outcome === "failed") {
510
+ await clearStartup("idle");
296
511
  if (request.clientMessageId) {
297
512
  await resources?.markMessageOutcomeUnknown(sessionId, request.clientMessageId, `live injection ${outcome}`);
298
513
  }
299
514
  throw new MachineSessionServiceFailure("outcome_unknown", `live injection ${outcome}`);
300
515
  }
301
516
  if (outcome !== "injected") {
517
+ await clearStartup("idle");
302
518
  if (request.clientMessageId) {
303
519
  await resources?.markMessageFailedNotStarted(sessionId, request.clientMessageId, `live injection ${outcome}`);
304
520
  }
305
521
  throw new MachineSessionServiceFailure("failed_precondition", `live injection ${outcome}`);
306
522
  }
523
+ await clearStartup("running");
307
524
  if (request.clientMessageId) {
308
525
  try {
309
526
  await resources?.markMessageInjected(sessionId, request.clientMessageId);
@@ -348,6 +565,7 @@ export class MachineSessionService {
348
565
  * injects exactly once. `failed` is fenced as outcome_unknown and never retried. */
349
566
  async enqueueMessage(sessionIdInput, messageInput) {
350
567
  const sessionId = validOpaqueToken(sessionIdInput, "sessionId", MAX_SESSION_ID_CHARS);
568
+ await this.assertNotForkReserved(sessionId);
351
569
  if (typeof messageInput !== "string" || messageInput.length === 0) {
352
570
  throw new MachineSessionServiceInputError("message must be a non-empty string");
353
571
  }
@@ -397,40 +615,13 @@ export class MachineSessionService {
397
615
  }
398
616
  async liveSessionRequest(sessionId, meta = this.ports.registry.get(sessionId)) {
399
617
  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) {
618
+ if (!meta) {
403
619
  throw new MachineSessionServiceFailure("not_found", "session not found");
404
620
  }
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
- }
621
+ const liveRuntime = meta.execution.provider;
422
622
  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 } : {}),
623
+ workspace: structuredClone(meta.workspace),
624
+ execution: structuredClone(meta.execution),
434
625
  };
435
626
  return { execution, liveRuntime, options };
436
627
  }
@@ -441,6 +632,7 @@ export class MachineSessionService {
441
632
  }
442
633
  async delete(sessionIdInput) {
443
634
  const sessionId = validOpaqueToken(sessionIdInput, "sessionId", MAX_SESSION_ID_CHARS);
635
+ await this.assertNotForkReserved(sessionId);
444
636
  try {
445
637
  await this.ports.lifecycle?.beforeDelete?.(sessionId);
446
638
  }
@@ -455,6 +647,7 @@ export class MachineSessionService {
455
647
  this.ports.registry.remove(sessionId);
456
648
  await this.ports.log.deleteSession(sessionId);
457
649
  this.ports.runtimeState.remove?.(sessionId);
650
+ await this.ports.forks?.markTargetDeleted(sessionId);
458
651
  try {
459
652
  await this.ports.lifecycle?.afterDelete?.(sessionId);
460
653
  }
@@ -592,6 +785,44 @@ export class MachineSessionService {
592
785
  }
593
786
  return this.ports.execution;
594
787
  }
788
+ requireWorkspace() {
789
+ const workspace = this.ports.workspace;
790
+ if (!workspace) {
791
+ throw new MachineSessionServiceFailure("failed_precondition", "Session workspace resolution is unavailable");
792
+ }
793
+ return workspace;
794
+ }
795
+ requireForks() {
796
+ if (!this.ports.forks) {
797
+ throw new MachineSessionServiceFailure("failed_precondition", "Session fork storage is unavailable");
798
+ }
799
+ return this.ports.forks;
800
+ }
801
+ async assertNotForkReserved(sessionId) {
802
+ if (await this.ports.forks?.hasReservedSource(sessionId)) {
803
+ throw new MachineSessionServiceFailure("conflict", "Session fork is in progress");
804
+ }
805
+ }
806
+ async resumeReservedForks() {
807
+ if (!this.ports.forks)
808
+ return;
809
+ try {
810
+ for (const operation of await this.ports.forks.listReserved()) {
811
+ if (this.forkTasks.has(operation.operationId))
812
+ continue;
813
+ const task = this.performFork(operation).finally(() => {
814
+ if (this.forkTasks.get(operation.operationId) === task) {
815
+ this.forkTasks.delete(operation.operationId);
816
+ }
817
+ });
818
+ this.forkTasks.set(operation.operationId, task);
819
+ void task.catch(() => undefined);
820
+ }
821
+ }
822
+ catch {
823
+ // A direct replay or the next daemon restart retries durable operations.
824
+ }
825
+ }
595
826
  }
596
827
  /** One-line title derived from the first user message; no model call. */
597
828
  export function synthesizeSessionTitle(message, limit = 60) {
@@ -688,6 +919,13 @@ function snapshotPage(sessionId, items, runtime, hasMore) {
688
919
  ...(hasMore && items.length > 0 ? { nextAfterId: items.at(-1).id } : {}),
689
920
  };
690
921
  }
922
+ /** Exact UTF-8 size of {@link snapshotPage} without repeatedly serializing the
923
+ * complete item array while searching for a page boundary. */
924
+ function snapshotPageJsonByteLength(sessionId, itemsJsonBytes, finalItemId, runtime, hasMore) {
925
+ return Buffer.byteLength(`{"sessionId":${JSON.stringify(sessionId)},"items":[`, "utf8")
926
+ + itemsJsonBytes
927
+ + Buffer.byteLength(`],"runtime":${JSON.stringify(runtime)},"hasMore":${hasMore}${hasMore ? `,"nextAfterId":${JSON.stringify(finalItemId)}` : ""}}`, "utf8");
928
+ }
691
929
  function sessionSummary(id, meta, log, runtimeState, pendingState) {
692
930
  const createdAt = meta?.createdAt ?? timestampFromEpoch(log?.createdAt, "createdAt");
693
931
  const updatedAt = log
@@ -695,8 +933,7 @@ function sessionSummary(id, meta, log, runtimeState, pendingState) {
695
933
  : (meta?.updatedAt ?? createdAt);
696
934
  return {
697
935
  id,
698
- ...(meta?.provider === undefined ? {} : { provider: meta.provider }),
699
- ...(meta?.agent === undefined ? {} : { agent: meta.agent }),
936
+ ...(meta?.execution?.provider === undefined ? {} : { provider: meta.execution.provider }),
700
937
  ...(meta?.title === undefined ? {} : { title: normalizeSessionTitle(meta.title) }),
701
938
  status: runtimeState.snapshot(id).status,
702
939
  ...(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;
@@ -1,4 +1,5 @@
1
1
  import { encodeRemoteRuntimeRpcResponse, parseRemoteRuntimeRpcResponseForMethod, REMOTE_RUNTIME_RPC_METHOD_METADATA, } from "@rynx-ai/protocol/remote-runtime-rpc";
2
+ import { RuntimeProjectInputError } from "@rynx-ai/core";
2
3
  import { encodeDaemonStatus } from "./remote-runtime.js";
3
4
  import { MachineSessionServiceFailure, MachineSessionServiceInputError, } from "./machine-session-service.js";
4
5
  import { SessionBrowserServiceError, } from "./session-browser-service.js";
@@ -35,6 +36,30 @@ export class RemoteRuntimeDispatcher {
35
36
  return this.options.providerClis
36
37
  ? rpcResult(request, await this.options.providerClis.listStatuses())
37
38
  : rpcError(request.id, "method_not_found", "Provider CLI status is unavailable");
39
+ case "project.list":
40
+ return await this.withProjects(request, async (projects) => ({
41
+ projects: await projects.list(),
42
+ }));
43
+ case "project.get":
44
+ return await this.withProjects(request, async (projects) => ({
45
+ project: requireProject(await projects.get(request.params.projectId)),
46
+ }));
47
+ case "project.create":
48
+ return await this.withProjects(request, async (projects) => ({
49
+ project: await projects.create(request.params),
50
+ }));
51
+ case "project.update": {
52
+ const { projectId, ...input } = request.params;
53
+ return await this.withProjects(request, async (projects) => ({
54
+ project: requireProject(await projects.update(projectId, input)),
55
+ }));
56
+ }
57
+ case "project.delete":
58
+ return await this.withProjects(request, async (projects) => {
59
+ if (!await projects.remove(request.params.projectId))
60
+ throw new ProjectNotFoundError();
61
+ return { deleted: true };
62
+ });
38
63
  case "emulator.devices.list":
39
64
  return await this.withEmulator(request, (emulator) => emulator.devices());
40
65
  case "emulator.active.get":
@@ -102,6 +127,8 @@ export class RemoteRuntimeDispatcher {
102
127
  return await this.withSessions(request, (sessions) => sessions.launchOptions());
103
128
  case "session.create":
104
129
  return await this.withSessions(request, (sessions) => sessions.create(request.params));
130
+ case "session.fork":
131
+ return await this.withSessions(request, (sessions) => sessions.fork(request.params));
105
132
  case "session.message.send":
106
133
  return await this.withSessions(request, (sessions) => {
107
134
  const { sessionId, ...input } = request.params;
@@ -234,6 +261,13 @@ export class RemoteRuntimeDispatcher {
234
261
  }
235
262
  return rpcResult(request, await invoke(emulator));
236
263
  }
264
+ async withProjects(request, invoke) {
265
+ const projects = this.options.projects;
266
+ if (!projects) {
267
+ return rpcError(request.id, "method_not_found", "Runtime Projects are unavailable");
268
+ }
269
+ return rpcResult(request, await invoke(projects));
270
+ }
237
271
  async withSessions(request, invoke) {
238
272
  const sessions = this.options.sessions;
239
273
  if (!sessions) {
@@ -257,6 +291,17 @@ export class RemoteRuntimeDispatcher {
257
291
  }
258
292
  }
259
293
  function mapHostError(request, error) {
294
+ if (error instanceof ProjectNotFoundError) {
295
+ return { code: "not_found", message: "Runtime Project was not found" };
296
+ }
297
+ if (error instanceof RuntimeProjectInputError) {
298
+ return {
299
+ code: error.code,
300
+ message: error.code === "invalid_request"
301
+ ? "Runtime Project configuration is invalid"
302
+ : "Runtime Project directory is unavailable",
303
+ };
304
+ }
260
305
  if (error instanceof SessionBrowserServiceError) {
261
306
  switch (error.code) {
262
307
  case "invalid_request":
@@ -340,6 +385,13 @@ function mapHostError(request, error) {
340
385
  }
341
386
  return { code: "internal", message: "Remote Runtime request failed" };
342
387
  }
388
+ class ProjectNotFoundError extends Error {
389
+ }
390
+ function requireProject(project) {
391
+ if (!project)
392
+ throw new ProjectNotFoundError();
393
+ return project;
394
+ }
343
395
  function idempotentCancel(watch) {
344
396
  let pending;
345
397
  return () => {
@@ -64,6 +64,7 @@ function projectRuntimeSnapshot(value) {
64
64
  const originalActive = runtime.activeResponseIds;
65
65
  const projected = {
66
66
  status: runtime.status,
67
+ ...(runtime.statusKind === undefined ? {} : { statusKind: runtime.statusKind }),
67
68
  activeResponseIds: [],
68
69
  pendingInteractions: [],
69
70
  };