@nylorun/runtime 0.15.2-beta → 0.16.0-beta

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.
Files changed (219) hide show
  1. package/CHANGELOG.md +76 -0
  2. package/README.md +33 -6
  3. package/dist/adapters/execution/restate.d.ts +9 -1
  4. package/dist/adapters/execution/restate.js +64 -5
  5. package/dist/adapters/sandbox/local.d.ts +10 -0
  6. package/dist/adapters/sandbox/local.js +195 -0
  7. package/dist/adapters/sandbox/virtual.js +36 -1
  8. package/dist/admin-openapi.json +37 -3
  9. package/dist/api/a2a/tasks.js +6 -1
  10. package/dist/api/ag-ui/history.js +6 -1
  11. package/dist/api/ag-ui/routes.js +5 -1
  12. package/dist/api/components.d.ts +2673 -655
  13. package/dist/api/components.js +17 -1
  14. package/dist/api/gate/keys.d.ts +4 -3
  15. package/dist/api/gate/routes.d.ts +19 -3
  16. package/dist/api/gate/routes.js +162 -53
  17. package/dist/api/http/app.js +4 -0
  18. package/dist/api/http/define.d.ts +10 -0
  19. package/dist/api/http/define.js +2 -1
  20. package/dist/api/http/routes/artifacts.d.ts +11 -0
  21. package/dist/api/http/routes/artifacts.js +518 -0
  22. package/dist/api/http/routes/sandboxes.d.ts +9 -0
  23. package/dist/api/http/routes/sandboxes.js +152 -0
  24. package/dist/api/http/routes/sessions.js +3 -1
  25. package/dist/api/http/routes/tenant.js +36 -2
  26. package/dist/artifacts/config.d.ts +23 -0
  27. package/dist/artifacts/config.js +27 -0
  28. package/dist/artifacts/export.d.ts +8 -0
  29. package/dist/artifacts/export.js +205 -0
  30. package/dist/artifacts/files.d.ts +36 -0
  31. package/dist/artifacts/files.js +45 -0
  32. package/dist/artifacts/folders.d.ts +60 -0
  33. package/dist/artifacts/folders.js +209 -0
  34. package/dist/artifacts/links.d.ts +28 -0
  35. package/dist/artifacts/links.js +92 -0
  36. package/dist/artifacts/media-types.d.ts +13 -0
  37. package/dist/artifacts/media-types.js +80 -0
  38. package/dist/artifacts/parts.d.ts +34 -0
  39. package/dist/artifacts/parts.js +38 -0
  40. package/dist/artifacts/service.d.ts +74 -0
  41. package/dist/artifacts/service.js +301 -0
  42. package/dist/artifacts/workspace.d.ts +60 -0
  43. package/dist/artifacts/workspace.js +57 -0
  44. package/dist/artifacts/zip.d.ts +10 -0
  45. package/dist/artifacts/zip.js +126 -0
  46. package/dist/blob/fs.d.ts +13 -0
  47. package/dist/blob/fs.js +194 -0
  48. package/dist/blob/index.d.ts +9 -0
  49. package/dist/blob/index.js +9 -0
  50. package/dist/blob/s3.d.ts +42 -0
  51. package/dist/blob/s3.js +366 -0
  52. package/dist/blob/types.d.ts +108 -0
  53. package/dist/blob/types.js +85 -0
  54. package/dist/core/sandbox-routes.d.ts +2 -2
  55. package/dist/core/sandbox-routes.js +4 -3
  56. package/dist/execution/memory.d.ts +11 -1
  57. package/dist/execution/memory.js +77 -3
  58. package/dist/execution/types.d.ts +54 -1
  59. package/dist/execution/types.js +31 -0
  60. package/dist/gates/contract.d.ts +14 -13
  61. package/dist/gates/contract.js +13 -12
  62. package/dist/gates/egress.d.ts +72 -0
  63. package/dist/gates/egress.js +319 -0
  64. package/dist/gates/handler.d.ts +3 -0
  65. package/dist/gates/handler.js +3 -2
  66. package/dist/gates/http-client.d.ts +3 -2
  67. package/dist/gates/http-client.js +17 -6
  68. package/dist/gates/in-process.d.ts +13 -1
  69. package/dist/gates/in-process.js +11 -0
  70. package/dist/gates/inflight.d.ts +23 -4
  71. package/dist/gates/inflight.js +18 -4
  72. package/dist/gates/model-gate.d.ts +6 -2
  73. package/dist/gates/tool-calls.d.ts +9 -5
  74. package/dist/gates/tool-calls.js +6 -6
  75. package/dist/gates/tool-client.d.ts +9 -2
  76. package/dist/gates/tool-client.js +56 -22
  77. package/dist/gates/tool-contract.d.ts +12 -7
  78. package/dist/gates/tool-contract.js +10 -4
  79. package/dist/gates/tool-gate.d.ts +1 -0
  80. package/dist/harness/calls.d.ts +52 -0
  81. package/dist/harness/calls.js +113 -0
  82. package/dist/harness/executors.d.ts +22 -0
  83. package/dist/harness/executors.js +96 -0
  84. package/dist/harness/main.d.ts +6 -0
  85. package/dist/harness/main.js +103 -0
  86. package/dist/harness/pod.d.ts +21 -0
  87. package/dist/harness/pod.js +119 -0
  88. package/dist/harness/run-tokens.d.ts +18 -0
  89. package/dist/harness/run-tokens.js +15 -0
  90. package/dist/harness/service.d.ts +46 -0
  91. package/dist/harness/service.js +179 -0
  92. package/dist/harness/ws-client.d.ts +43 -0
  93. package/dist/harness/ws-client.js +122 -0
  94. package/dist/harness/ws-port.d.ts +22 -0
  95. package/dist/harness/ws-port.js +78 -0
  96. package/dist/harness-api/in-process.d.ts +24 -0
  97. package/dist/harness-api/in-process.js +119 -0
  98. package/dist/harness-api/record.d.ts +44 -0
  99. package/dist/harness-api/record.js +250 -0
  100. package/dist/harness-api/renew.d.ts +37 -0
  101. package/dist/harness-api/renew.js +77 -0
  102. package/dist/harness-api/server.d.ts +104 -0
  103. package/dist/harness-api/server.js +408 -0
  104. package/dist/harness-api/start.d.ts +36 -0
  105. package/dist/harness-api/start.js +118 -0
  106. package/dist/harness-api/workspace.d.ts +53 -0
  107. package/dist/harness-api/workspace.js +201 -0
  108. package/dist/harness-api/ws-server.d.ts +34 -0
  109. package/dist/harness-api/ws-server.js +227 -0
  110. package/dist/host/app.js +23 -2
  111. package/dist/host/gates.d.ts +27 -2
  112. package/dist/host/gates.js +46 -1
  113. package/dist/host/http.d.ts +4 -1
  114. package/dist/host/http.js +6 -1
  115. package/dist/host/main.js +135 -27
  116. package/dist/host/stack-config.d.ts +101 -1
  117. package/dist/host/stack-config.js +199 -8
  118. package/dist/mcp/snapshot.d.ts +8 -1
  119. package/dist/mcp/snapshot.js +28 -0
  120. package/dist/media.d.ts +5 -24
  121. package/dist/media.js +5 -0
  122. package/dist/model/pi-model.d.ts +6 -2
  123. package/dist/model/pi-model.js +39 -22
  124. package/dist/node/index.d.ts +1 -1
  125. package/dist/node/index.js +1 -1
  126. package/dist/openapi.json +15245 -8850
  127. package/dist/record/index.d.ts +1 -0
  128. package/dist/record/index.js +1 -0
  129. package/dist/record/sandbox.d.ts +25 -0
  130. package/dist/record/sandbox.js +43 -0
  131. package/dist/sandbox/capability.d.ts +3 -0
  132. package/dist/sandbox/capability.js +10 -0
  133. package/dist/sandbox/egress-token.d.ts +26 -0
  134. package/dist/sandbox/egress-token.js +8 -0
  135. package/dist/sandbox/join.d.ts +37 -0
  136. package/dist/sandbox/join.js +165 -0
  137. package/dist/sandbox/manager.d.ts +99 -34
  138. package/dist/sandbox/manager.js +216 -84
  139. package/dist/sandbox/placement.d.ts +27 -0
  140. package/dist/sandbox/placement.js +44 -0
  141. package/dist/sandbox/pods/client.d.ts +90 -0
  142. package/dist/sandbox/pods/client.js +79 -0
  143. package/dist/sandbox/pods/lifecycle.d.ts +91 -0
  144. package/dist/sandbox/pods/lifecycle.js +178 -0
  145. package/dist/sandbox/pods/name.d.ts +1 -0
  146. package/dist/sandbox/pods/name.js +28 -0
  147. package/dist/sandbox/pods/network-gate.d.ts +25 -0
  148. package/dist/sandbox/pods/network-gate.js +71 -0
  149. package/dist/sandbox/pods/reconcile.d.ts +11 -0
  150. package/dist/sandbox/pods/reconcile.js +152 -0
  151. package/dist/sandbox/pods/spec.d.ts +45 -0
  152. package/dist/sandbox/pods/spec.js +62 -0
  153. package/dist/sandbox/records.d.ts +29 -0
  154. package/dist/sandbox/records.js +79 -0
  155. package/dist/sandbox/resolve.d.ts +5 -0
  156. package/dist/sandbox/resolve.js +3 -2
  157. package/dist/sandbox/session-sandbox.js +7 -5
  158. package/dist/sandbox/share.d.ts +11 -0
  159. package/dist/sandbox/share.js +10 -0
  160. package/dist/sandbox/tenant-config.d.ts +16 -1
  161. package/dist/sandbox/tenant-config.js +9 -0
  162. package/dist/sandbox/types.d.ts +29 -1
  163. package/dist/sandbox/types.js +13 -1
  164. package/dist/store/postgres/drizzle/0004_sandbox_resources.sql +20 -0
  165. package/dist/store/postgres/drizzle/0005_artifacts.sql +27 -0
  166. package/dist/store/postgres/drizzle/0006_folder_artifacts.sql +12 -0
  167. package/dist/store/postgres/drizzle/0007_sandbox_pods.sql +14 -0
  168. package/dist/store/postgres/drizzle/meta/_journal.json +28 -0
  169. package/dist/store/postgres/record-writer.d.ts +6 -1
  170. package/dist/store/postgres/record-writer.js +20 -2
  171. package/dist/store/postgres/schema.d.ts +894 -0
  172. package/dist/store/postgres/schema.js +130 -2
  173. package/dist/store/postgres/store.js +289 -4
  174. package/dist/store/types.d.ts +143 -6
  175. package/dist/streams/control.d.ts +2 -0
  176. package/dist/streams/control.js +5 -0
  177. package/dist/streams/types.d.ts +10 -1
  178. package/dist/tenant/advance.js +218 -160
  179. package/dist/tenant/artifact-tool.d.ts +5 -0
  180. package/dist/tenant/artifact-tool.js +119 -0
  181. package/dist/tenant/commands.js +30 -5
  182. package/dist/tenant/context.d.ts +64 -5
  183. package/dist/tenant/effects.d.ts +19 -13
  184. package/dist/tenant/effects.js +5 -382
  185. package/dist/tenant/history.d.ts +13 -23
  186. package/dist/tenant/history.js +14 -62
  187. package/dist/tenant/host-token.d.ts +92 -0
  188. package/dist/tenant/host-token.js +179 -0
  189. package/dist/tenant/module.js +1 -0
  190. package/dist/tenant/paths.d.ts +2 -1
  191. package/dist/tenant/paths.js +3 -1
  192. package/dist/tenant/reset.d.ts +5 -2
  193. package/dist/tenant/reset.js +22 -2
  194. package/dist/tenant/run-grants.d.ts +49 -0
  195. package/dist/tenant/run-grants.js +62 -0
  196. package/dist/tenant/run-token.d.ts +70 -0
  197. package/dist/tenant/run-token.js +138 -0
  198. package/dist/tenant/runtime.d.ts +38 -0
  199. package/dist/tenant/runtime.js +120 -32
  200. package/dist/tenant/sandboxes.d.ts +119 -0
  201. package/dist/tenant/sandboxes.js +551 -0
  202. package/dist/tenant/session.d.ts +2 -13
  203. package/dist/tenant/session.js +4 -28
  204. package/dist/tenant/sessions.d.ts +5 -0
  205. package/dist/tenant/sessions.js +28 -1
  206. package/dist/tenant/status.d.ts +8 -3
  207. package/dist/tenant/status.js +5 -0
  208. package/dist/tenant/streams.d.ts +7 -0
  209. package/dist/tenant/streams.js +24 -1
  210. package/dist/tenant/sweep.js +37 -4
  211. package/dist/tenant/tokens.js +9 -2
  212. package/dist/tenant/types.d.ts +18 -1
  213. package/dist/tenant/worker.d.ts +11 -5
  214. package/dist/tenant/worker.js +12 -6
  215. package/dist/version.d.ts +1 -1
  216. package/dist/version.js +1 -1
  217. package/package.json +13 -10
  218. package/dist/adapters/media.d.ts +0 -24
  219. package/dist/adapters/media.js +0 -111
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Lease renewal of an advance (§10.6). One beat renews the session's ownership lease and
3
+ * re-mints the run token when it nears its end (F5, `run-grants.ts`). The harness asks for a
4
+ * beat every `renewEveryMs` (`lease.renew`) and gets the current token back; until it holds the
5
+ * run, core beats itself (`startHeartbeat`).
6
+ *
7
+ * A lease that cannot be renewed drops the run token and aborts the advance with
8
+ * `ownership.lost`. Once the advance is aborted (cancel, deadline, Worker stop) a beat renews
9
+ * nothing: an advance that does not wind down within the lease is taken over when it lapses
10
+ * (`worker.ts`).
11
+ */
12
+ import type { Lease, TenantContext } from "../tenant/context.js";
13
+ import { type RunOf } from "../tenant/run-grants.js";
14
+ export type Renewal = {
15
+ ok: true;
16
+ token?: string;
17
+ tokenExpiresAt?: string;
18
+ } | {
19
+ ok: false;
20
+ };
21
+ /** How often a lease is renewed: a third of its length. */
22
+ export declare function renewEveryMs(ctx: TenantContext): number;
23
+ /** The run token the advance of `lease` holds, as a run grant carries it. */
24
+ export declare function tokenOf(ctx: TenantContext, lease: Lease): {
25
+ token?: string;
26
+ tokenExpiresAt?: string;
27
+ };
28
+ /**
29
+ * One renewal. `active` is false once the renewing side stopped: a lease lost then aborts
30
+ * nothing, and a token minted then is not registered. Throws when the store fails; the next
31
+ * beat tries again.
32
+ */
33
+ export declare function beat(ctx: TenantContext, lease: Lease, controller: AbortController, run: RunOf, active?: () => boolean): Promise<Renewal>;
34
+ /** Beats every `renewEveryMs` until stopped or the advance is aborted. */
35
+ export declare function startHeartbeat(ctx: TenantContext, lease: Lease, controller: AbortController, run: RunOf): {
36
+ stop(): void;
37
+ };
@@ -0,0 +1,77 @@
1
+ import { dropRunGrant, renewRunGrant } from "../tenant/run-grants.js";
2
+ import { AdvanceAbort } from "../tenant/worker.js";
3
+ /** How often a lease is renewed: a third of its length. */
4
+ export function renewEveryMs(ctx) {
5
+ return Math.max(10, Math.floor(ctx.ownerLeaseMs / 3));
6
+ }
7
+ /** The run token the advance of `lease` holds, as a run grant carries it. */
8
+ export function tokenOf(ctx, lease) {
9
+ const grant = ctx.runGrants?.get(lease.sessionId);
10
+ if (!grant || grant.claims.epoch !== lease.epoch)
11
+ return {};
12
+ return { token: grant.token, tokenExpiresAt: new Date(grant.claims.expiresAt).toISOString() };
13
+ }
14
+ /**
15
+ * One renewal. `active` is false once the renewing side stopped: a lease lost then aborts
16
+ * nothing, and a token minted then is not registered. Throws when the store fails; the next
17
+ * beat tries again.
18
+ */
19
+ export async function beat(ctx, lease, controller, run, active = () => true) {
20
+ if (controller.signal.aborted)
21
+ return { ok: true };
22
+ const renewed = await ctx.store.tx((t) => t.renewOwnership(lease.sessionId, lease.owner, lease.epoch, new Date(Date.now() + ctx.ownerLeaseMs)));
23
+ if (!renewed) {
24
+ if (active()) {
25
+ // Calls under the lost lease are stale at the gate anyway; make no more of them.
26
+ dropRunGrant(ctx, lease);
27
+ controller.abort(new AdvanceAbort("ownership.lost", "Ownership lost"));
28
+ }
29
+ return { ok: false };
30
+ }
31
+ if (active())
32
+ await renewRunGrant(ctx, lease, run, active).catch((error) => ctx.config.logger.warn("advance failed to renew its run token", {
33
+ sessionId: lease.sessionId,
34
+ message: error instanceof Error ? error.message : String(error),
35
+ }));
36
+ return { ok: true, ...tokenOf(ctx, lease) };
37
+ }
38
+ /** Beats every `renewEveryMs` until stopped or the advance is aborted. */
39
+ export function startHeartbeat(ctx, lease, controller, run) {
40
+ const every = renewEveryMs(ctx);
41
+ let stopped = false;
42
+ let timer;
43
+ const tick = async () => {
44
+ if (controller.signal.aborted) {
45
+ stopped = true;
46
+ return;
47
+ }
48
+ try {
49
+ const renewal = await beat(ctx, lease, controller, run, () => !stopped);
50
+ if (!renewal.ok) {
51
+ stopped = true;
52
+ return;
53
+ }
54
+ }
55
+ catch (error) {
56
+ if (!stopped)
57
+ ctx.config.logger.warn("advance heartbeat failed", {
58
+ sessionId: lease.sessionId,
59
+ message: error instanceof Error ? error.message : String(error),
60
+ });
61
+ }
62
+ arm();
63
+ };
64
+ const arm = () => {
65
+ if (stopped)
66
+ return;
67
+ timer = setTimeout(() => void tick(), every);
68
+ timer.unref();
69
+ };
70
+ arm();
71
+ return {
72
+ stop() {
73
+ stopped = true;
74
+ clearTimeout(timer);
75
+ },
76
+ };
77
+ }
@@ -0,0 +1,104 @@
1
+ import { type CoreMethod, type HarnessChannel, type OutputMethod, type ParamsOf, type ReleaseReason, type ResultOf, type TurnOutput, type TurnStart } from "@nylorun/core/harness-api";
2
+ import type { ActionOutcome } from "@nylorun/core/contracts";
3
+ import { type Lease, type TenantContext } from "../tenant/context.js";
4
+ import type { RunOf } from "../tenant/run-grants.js";
5
+ /** Who is on the other end of a connection. */
6
+ export interface HarnessPeer {
7
+ /** For logs. */
8
+ readonly name: string;
9
+ /** A pod sandbox's engine (F7.2): the sandbox it hosts, at the host epoch of its token. */
10
+ readonly host?: {
11
+ readonly sandboxId: string;
12
+ readonly epoch: number;
13
+ };
14
+ }
15
+ /** A segment an advance offers to a harness. */
16
+ export interface RunOffer {
17
+ readonly lease: Lease;
18
+ readonly start: TurnStart;
19
+ /** The advance's controller: its abort is the run's `cancel`. */
20
+ readonly controller: AbortController;
21
+ /** The transcript core folded at `start.transcript.cursor`, for `transcript.read`. */
22
+ readonly transcript: readonly unknown[];
23
+ /** What the run token names, for its re-mint on renewal (F5). */
24
+ readonly run: RunOf;
25
+ /** Called when a harness takes the run. */
26
+ readonly onTaken?: () => void;
27
+ /**
28
+ * The pod sandbox whose engine runs it (F7.2): only that sandbox's host takes it. Absent,
29
+ * only a harness that hosts no sandbox does.
30
+ */
31
+ readonly pod?: string;
32
+ /** How long to wait for a harness to take it. Default the server's `offerWaitMs`. */
33
+ readonly waitMs?: number;
34
+ }
35
+ /** How a run ended. An output waits for `reply` (or `refuse`) before the harness hears back. */
36
+ export type RunEnd = {
37
+ readonly kind: "output";
38
+ readonly method: OutputMethod;
39
+ readonly output: TurnOutput;
40
+ reply(answer: {
41
+ cursor?: number;
42
+ }): void;
43
+ refuse(error: unknown): void;
44
+ } | {
45
+ readonly kind: "released";
46
+ readonly reason: ReleaseReason;
47
+ }
48
+ /** Core's controller aborted before any harness took the run. */
49
+ | {
50
+ readonly kind: "aborted";
51
+ }
52
+ /** No harness took the run within `offerWaitMs`. */
53
+ | {
54
+ readonly kind: "unavailable";
55
+ };
56
+ export interface HarnessApiServer {
57
+ /** Serves a harness's channel until it closes. Returns a function that detaches it. */
58
+ attach(channel: HarnessChannel, peer: HarnessPeer): () => void;
59
+ offer(offer: RunOffer): Promise<RunEnd>;
60
+ /**
61
+ * An Action outcome of session `sessionId` was recorded: the run holding it, if any, gets it
62
+ * (`effect.resolved`). Call it after the outcome's commit.
63
+ */
64
+ resolved(sessionId: string, effectId: string, outcome: ActionOutcome): void;
65
+ /** A harness holds a run of `sessionId` here. */
66
+ holds(sessionId: string): boolean;
67
+ /**
68
+ * Sends a `workspace.*` request to a harness that serves workspaces: with `pod`, the host of
69
+ * that pod sandbox; otherwise one that hosts no sandbox. Throws `NoWorkspaceHarness` when
70
+ * none is attached.
71
+ */
72
+ workspace<M extends CoreMethod>(method: M, params: ParamsOf<M>, signal?: AbortSignal, target?: {
73
+ readonly pod?: string;
74
+ }): Promise<ResultOf<M>>;
75
+ /**
76
+ * Pod sandbox `sandboxId`'s host epoch moved to `epoch` (F7.2): connections hosting it at an
77
+ * older epoch are closed.
78
+ */
79
+ revokeHost(sandboxId: string, epoch: number): void;
80
+ /** Whether a host of pod sandbox `sandboxId` is connected. */
81
+ hosting(sandboxId: string): boolean;
82
+ /** Harnesses attached now, and whether one serves workspaces. */
83
+ status(): HarnessStatus;
84
+ /** Harnesses attached now. */
85
+ readonly connected: number;
86
+ close(): void;
87
+ }
88
+ /** The Harness API's state, for Tenant and admin status. */
89
+ export interface HarnessStatus {
90
+ readonly connected: number;
91
+ readonly workspace: boolean;
92
+ }
93
+ /** No attached harness serves workspaces (or, with `pod`, that pod sandbox's). */
94
+ export declare class NoWorkspaceHarness extends Error {
95
+ readonly pod?: string | undefined;
96
+ readonly name = "NoWorkspaceHarness";
97
+ constructor(pod?: string | undefined);
98
+ }
99
+ export interface HarnessApiServerOptions {
100
+ offerWaitMs?: number;
101
+ /** The sandbox backend preference a harness that serves workspaces selects with. */
102
+ sandboxPreference?: string;
103
+ }
104
+ export declare function createHarnessApiServer(ctx: TenantContext, options?: HarnessApiServerOptions): HarnessApiServer;
@@ -0,0 +1,408 @@
1
+ /**
2
+ * The Harness API's server, one per Tenant (blueprint D37). Harnesses attach a channel each and
3
+ * keep `lease` requests waiting; an advance that took a session's lease *offers* its segment
4
+ * (`offer`), and the first waiting harness takes it as a run. Offers are served in order; one
5
+ * no harness takes within `offerWaitMs` ends `unavailable`.
6
+ *
7
+ * A run is bound to the connection that leased it: any other gets `run_not_held`. Core's abort
8
+ * of the advance reaches the harness as `cancel` with the abort's reason. The run ends with an
9
+ * output (answered once core has settled it, with the transcript's new cursor), a release, or
10
+ * the connection's loss (`connection.lost`).
11
+ *
12
+ * F6.2: a harness readies the session's MCP servers itself and records what it found
13
+ * (`session.mcp`); an Action outcome recorded while its run is held reaches it as
14
+ * `effect.resolved`; and a harness that declared `workspace` in `hello` serves the Tenant's
15
+ * workspaces (`workspace`, the `workspace.*` requests). A harness claims `sandbox.*` events
16
+ * only for a run it holds (its session, or the session owning the run's sandbox) or a session a
17
+ * workspace request it is serving acts for; a `sandbox.state` claim's record becomes the
18
+ * workspace's row in the `sandboxes` table.
19
+ *
20
+ * F7.2: a pod sandbox's engine connects as that sandbox's *host* (`HarnessPeer.host`, from its
21
+ * host token): it leases only runs whose session is attached to its sandbox (`RunOffer.pod`),
22
+ * serves only that sandbox's workspace (`workspace(..., { pod })`), and claims events only for
23
+ * those. Every other connection (the harness container, the in-process harness) never leases a
24
+ * pod sandbox's run and never serves its workspace. A connection whose host epoch is older than
25
+ * the sandbox's (`revokeHost`: a new join, a stop, a loss) is closed; its runs end as
26
+ * `connection.lost`.
27
+ */
28
+ import { randomUUID } from "node:crypto";
29
+ import { HARNESS_API_VERSION, HARNESS_CLAIMS, HarnessApiError, } from "@nylorun/core/harness-api";
30
+ import { isOwnershipLost } from "../store/ownership.js";
31
+ import { ownedSession } from "../tenant/context.js";
32
+ import { sessionToolsOf } from "../mcp/snapshot.js";
33
+ import { sandboxWorkspaceKey, workspacePrefix } from "../sandbox/records.js";
34
+ import { abortKind } from "../tenant/worker.js";
35
+ import { recordIntent, recordOutcome } from "./record.js";
36
+ import { beat, renewEveryMs, tokenOf } from "./renew.js";
37
+ /** No attached harness serves workspaces (or, with `pod`, that pod sandbox's). */
38
+ export class NoWorkspaceHarness extends Error {
39
+ pod;
40
+ name = "NoWorkspaceHarness";
41
+ constructor(pod) {
42
+ super(pod === undefined
43
+ ? "No harness serving workspaces is connected"
44
+ : `Sandbox ${pod} has no pod connected: it is stopped or starting`);
45
+ this.pod = pod;
46
+ }
47
+ }
48
+ const OUTPUTS = [
49
+ "turn.completed",
50
+ "turn.paused",
51
+ "turn.waiting",
52
+ "turn.failed",
53
+ "checkpoint",
54
+ ];
55
+ export function createHarnessApiServer(ctx, options = {}) {
56
+ const offerWaitMs = options.offerWaitMs ?? 10_000;
57
+ const connections = new Set();
58
+ const waiting = [];
59
+ const queued = [];
60
+ const runs = new Map();
61
+ let closed = false;
62
+ /** A harness may take a run: a pod sandbox's host its sandbox's runs, any other the rest. */
63
+ const takes = (connection, run) => connection.host?.sandboxId === run.offer.pod;
64
+ /** Hands queued runs to waiting harnesses that may take them, in order. */
65
+ const match = () => {
66
+ for (let i = 0; i < queued.length;) {
67
+ const run = queued[i];
68
+ const at = waiting.findIndex((entry) => takes(entry.connection, run));
69
+ if (at < 0) {
70
+ i += 1;
71
+ continue;
72
+ }
73
+ queued.splice(i, 1);
74
+ const lease = waiting.splice(at, 1)[0];
75
+ run.connection = lease.connection;
76
+ // The run token the advance holds now (F5): the gate calls of this run present it.
77
+ run.grant = { ...run.grant, ...tokenOf(ctx, run.offer.lease) };
78
+ runs.set(run.grant.runId, run);
79
+ lease.resolve({ run: run.grant, input: run.offer.start });
80
+ run.offer.onTaken?.();
81
+ }
82
+ };
83
+ const held = (connection, runId, output) => {
84
+ const run = runs.get(runId);
85
+ if (!run || run.connection !== connection || (run.ended && !(output && run.outputs.has(output))))
86
+ throw new HarnessApiError("run_not_held", `Run ${runId} is not held by this harness`);
87
+ return run;
88
+ };
89
+ const scope = (run) => ({
90
+ ctx,
91
+ lease: run.offer.lease,
92
+ signal: run.offer.controller.signal,
93
+ });
94
+ const serve = (connection) => async (method, params, signal) => {
95
+ const p = params;
96
+ if (method === "hello") {
97
+ const { capabilities } = p;
98
+ connection.workspace = capabilities.workspace !== undefined;
99
+ return {
100
+ api: HARNESS_API_VERSION,
101
+ tenantId: ctx.config.tenantId,
102
+ // A pod's engine runs its sandbox's backend, whatever the Tenant's preference.
103
+ sandbox: { backend: connection.host ? null : (options.sandboxPreference ?? null) },
104
+ renewEveryMs: renewEveryMs(ctx),
105
+ };
106
+ }
107
+ if (method === "event")
108
+ return claim(connection, p);
109
+ if (method === "lease")
110
+ return new Promise((resolve, reject) => {
111
+ const entry = { connection, resolve };
112
+ waiting.push(entry);
113
+ signal.addEventListener("abort", () => {
114
+ const index = waiting.indexOf(entry);
115
+ if (index >= 0)
116
+ waiting.splice(index, 1);
117
+ reject(signal.reason);
118
+ });
119
+ match();
120
+ });
121
+ if (OUTPUTS.includes(method))
122
+ return output(held(connection, String(p.runId), method), method, p);
123
+ const run = held(connection, String(p.runId));
124
+ switch (method) {
125
+ case "lease.renew":
126
+ return beat(ctx, run.offer.lease, run.offer.controller, run.offer.run, () => !run.ended);
127
+ case "lease.release":
128
+ run.end({ kind: "released", reason: p.reason });
129
+ return {};
130
+ case "effect.intent": {
131
+ const { effect, requestHash } = p;
132
+ if (effect.sessionId !== run.grant.sessionId || effect.turnId !== run.grant.turnId)
133
+ throw new HarnessApiError("invalid", "The effect is not of this run's turn");
134
+ return recordIntent(scope(run), effect, requestHash);
135
+ }
136
+ case "effect.outcome": {
137
+ const { effectId, ...result } = p;
138
+ return recordOutcome(scope(run), effectId, "error" in result ? { error: result.error } : { value: result.value });
139
+ }
140
+ case "transcript.read":
141
+ return { cursor: run.offer.start.transcript.cursor, entries: [...run.offer.transcript] };
142
+ case "session.mcp":
143
+ return recordMcp(run, p);
144
+ default:
145
+ throw new HarnessApiError("invalid", `Unknown request ${method}`);
146
+ }
147
+ };
148
+ /**
149
+ * A `sandbox.*` event the harness claims: for a run it holds (the run's session, or the
150
+ * session owning its sandbox), or a session one of its workspace requests acts for. A
151
+ * `sandbox.state` record becomes the workspace's `sandboxes` row. Claims are best effort, as
152
+ * the events of core's own SandboxManager are: a session gone since is skipped.
153
+ */
154
+ const claim = async (connection, event) => {
155
+ if (!HARNESS_CLAIMS.includes(event.type))
156
+ throw new HarnessApiError("invalid", `A harness may not claim ${event.type}`);
157
+ let allowed = connection.sweeping > 0 || connection.claims.has(event.sessionId);
158
+ if (event.runId !== undefined) {
159
+ const run = held(connection, event.runId);
160
+ allowed ||=
161
+ event.sessionId === run.grant.sessionId || event.sessionId === run.offer.start.routing.sandbox?.ownerId;
162
+ }
163
+ if (!allowed)
164
+ throw new HarnessApiError("run_not_held", `This harness may not claim events of ${event.sessionId}`);
165
+ const record = event.record;
166
+ if (record &&
167
+ (event.type !== "sandbox.state" ||
168
+ record.sessionId !== event.sessionId ||
169
+ !record.key.startsWith(workspacePrefix(ctx.config.tenantId)) ||
170
+ (connection.host !== undefined &&
171
+ record.key !== sandboxWorkspaceKey(ctx.config.tenantId, connection.host.sandboxId))))
172
+ throw new HarnessApiError("invalid", "The workspace record is not this session's");
173
+ try {
174
+ await ctx.store.tx(async (t) => {
175
+ if (record)
176
+ await t.put("sandboxes", record.key, record);
177
+ await t.event(event.sessionId, event.turnId, event.type, event.payload);
178
+ });
179
+ }
180
+ catch (error) {
181
+ ctx.config.logger.warn("harness event claim not recorded", {
182
+ sessionId: event.sessionId,
183
+ type: event.type,
184
+ message: error instanceof Error ? error.message : String(error),
185
+ });
186
+ }
187
+ return {};
188
+ };
189
+ /**
190
+ * What a harness found readying the run's MCP servers: the first snapshot recorded for the
191
+ * session wins; diagnostics replace those of the same server. Answers the session's snapshot
192
+ * and the tools the engine advertises.
193
+ */
194
+ const recordMcp = (run, params) => ctx.store.tx(async (t) => {
195
+ const { lease } = run.offer;
196
+ const current = await ownedSession(t, lease, lease.sessionId);
197
+ const diagnostics = params.diagnostics;
198
+ const snapshot = params.snapshot;
199
+ if (snapshot !== undefined && !isSnapshot(snapshot))
200
+ throw new HarnessApiError("invalid", "The MCP snapshot is malformed");
201
+ if (snapshot && !current.mcpSnapshot) {
202
+ current.mcpSnapshot = snapshot;
203
+ current.mcpDiagnostics = diagnostics;
204
+ await t.put("sessions", current.id, current);
205
+ }
206
+ else if (diagnostics.length > 0) {
207
+ const prior = [...(current.mcpDiagnostics ?? [])];
208
+ for (const item of diagnostics) {
209
+ const index = prior.findIndex((existing) => existing.capabilityId === item.capabilityId && existing.serverName === item.serverName);
210
+ if (index >= 0)
211
+ prior[index] = item;
212
+ else
213
+ prior.push(item);
214
+ }
215
+ current.mcpDiagnostics = prior;
216
+ await t.put("sessions", current.id, current);
217
+ }
218
+ return {
219
+ ...(current.mcpSnapshot ? { snapshot: current.mcpSnapshot } : {}),
220
+ sessionTools: [...(sessionToolsOf(current.mcpSnapshot) ?? [])],
221
+ };
222
+ });
223
+ const output = (run, method, params) => {
224
+ const known = run.outputs.get(method);
225
+ if (known)
226
+ return known;
227
+ const answer = new Promise((reply, refuse) => run.end({
228
+ kind: "output",
229
+ method: method,
230
+ output: params,
231
+ reply,
232
+ refuse,
233
+ }));
234
+ run.outputs.set(method, answer);
235
+ return answer;
236
+ };
237
+ return {
238
+ get connected() {
239
+ return connections.size;
240
+ },
241
+ status() {
242
+ return {
243
+ connected: connections.size,
244
+ workspace: [...connections].some((connection) => connection.workspace && !connection.host && !connection.channel.closed),
245
+ };
246
+ },
247
+ hosting(sandboxId) {
248
+ return [...connections].some((connection) => connection.host?.sandboxId === sandboxId && !connection.channel.closed);
249
+ },
250
+ revokeHost(sandboxId, epoch) {
251
+ for (const connection of [...connections])
252
+ if (connection.host?.sandboxId === sandboxId && connection.host.epoch < epoch)
253
+ connection.channel.close("the sandbox's host epoch moved");
254
+ },
255
+ holds(sessionId) {
256
+ for (const run of runs.values())
257
+ if (run.grant.sessionId === sessionId && !run.ended && run.connection)
258
+ return true;
259
+ return false;
260
+ },
261
+ resolved(sessionId, effectId, outcome) {
262
+ for (const run of runs.values())
263
+ if (run.grant.sessionId === sessionId && !run.ended && run.connection)
264
+ run.connection.channel.notify("effect.resolved", { runId: run.grant.runId, effectId, outcome });
265
+ },
266
+ async workspace(method, params, signal, target) {
267
+ const pod = target?.pod;
268
+ const connection = [...connections].find((item) => item.workspace && !item.channel.closed && item.host?.sandboxId === pod);
269
+ if (!connection)
270
+ throw new NoWorkspaceHarness(pod);
271
+ const session = params.session?.ownerId;
272
+ if (session)
273
+ connection.claims.set(session, (connection.claims.get(session) ?? 0) + 1);
274
+ else
275
+ connection.sweeping += 1;
276
+ try {
277
+ return await connection.channel.request(method, params, signal);
278
+ }
279
+ finally {
280
+ if (session) {
281
+ const left = (connection.claims.get(session) ?? 1) - 1;
282
+ if (left > 0)
283
+ connection.claims.set(session, left);
284
+ else
285
+ connection.claims.delete(session);
286
+ }
287
+ else
288
+ connection.sweeping -= 1;
289
+ }
290
+ },
291
+ attach(channel, peer) {
292
+ const connection = {
293
+ channel,
294
+ peer,
295
+ ...(peer.host ? { host: { sandboxId: peer.host.sandboxId, epoch: peer.host.epoch } } : {}),
296
+ workspace: false,
297
+ claims: new Map(),
298
+ sweeping: 0,
299
+ };
300
+ connections.add(connection);
301
+ const handler = serve(connection);
302
+ channel.handle(async (method, params, signal) => {
303
+ try {
304
+ return await handler(method, params, signal);
305
+ }
306
+ catch (error) {
307
+ if (isOwnershipLost(error))
308
+ throw new HarnessApiError("ownership_lost", error.message);
309
+ throw error;
310
+ }
311
+ });
312
+ const detach = () => {
313
+ if (!connections.delete(connection))
314
+ return;
315
+ for (let i = waiting.length - 1; i >= 0; i -= 1)
316
+ if (waiting[i].connection === connection)
317
+ waiting.splice(i, 1);
318
+ for (const run of runs.values())
319
+ if (run.connection === connection)
320
+ run.end({ kind: "released", reason: "connection.lost" });
321
+ };
322
+ channel.onClose(detach);
323
+ return detach;
324
+ },
325
+ offer(offer) {
326
+ const { lease, controller } = offer;
327
+ return new Promise((resolve) => {
328
+ const checkpoint = offer.start.checkpoint;
329
+ let timer;
330
+ const onAbort = () => {
331
+ const index = queued.indexOf(run);
332
+ if (index >= 0) {
333
+ queued.splice(index, 1);
334
+ run.end({ kind: "aborted" });
335
+ return;
336
+ }
337
+ if (run.ended || !run.connection)
338
+ return;
339
+ const reason = controller.signal.reason;
340
+ run.connection.channel.notify("cancel", {
341
+ runId: run.grant.runId,
342
+ reason: abortKind(controller.signal),
343
+ ...(reason instanceof Error ? { message: reason.message } : {}),
344
+ });
345
+ };
346
+ const run = {
347
+ grant: { runId: randomUUID(), sessionId: lease.sessionId, turnId: checkpoint.turnId, epoch: lease.epoch },
348
+ offer,
349
+ outputs: new Map(),
350
+ end(end) {
351
+ if (run.ended)
352
+ return;
353
+ run.ended = true;
354
+ clearTimeout(timer);
355
+ controller.signal.removeEventListener("abort", onAbort);
356
+ // An output's run stays held until core answers it, so a repeat gets the same answer.
357
+ if (end.kind === "output") {
358
+ const forget = () => runs.delete(run.grant.runId);
359
+ resolve({
360
+ ...end,
361
+ reply: (answer) => {
362
+ forget();
363
+ end.reply(answer);
364
+ },
365
+ refuse: (error) => {
366
+ forget();
367
+ end.refuse(error);
368
+ },
369
+ });
370
+ }
371
+ else {
372
+ runs.delete(run.grant.runId);
373
+ resolve(end);
374
+ }
375
+ },
376
+ };
377
+ if (closed)
378
+ return run.end({ kind: "unavailable" });
379
+ queued.push(run);
380
+ if (controller.signal.aborted)
381
+ return onAbort();
382
+ controller.signal.addEventListener("abort", onAbort);
383
+ timer = setTimeout(() => {
384
+ const index = queued.indexOf(run);
385
+ if (index < 0)
386
+ return;
387
+ queued.splice(index, 1);
388
+ run.end({ kind: "unavailable" });
389
+ }, offer.waitMs ?? offerWaitMs);
390
+ timer.unref();
391
+ match();
392
+ });
393
+ },
394
+ close() {
395
+ closed = true;
396
+ for (const run of queued.splice(0))
397
+ run.end({ kind: "unavailable" });
398
+ },
399
+ };
400
+ }
401
+ function isSnapshot(value) {
402
+ const snapshot = value;
403
+ return (typeof snapshot === "object" &&
404
+ snapshot !== null &&
405
+ snapshot.snapshotSchemaVersion === 1 &&
406
+ typeof snapshot.manifestHash === "string" &&
407
+ Array.isArray(snapshot.mcpTools));
408
+ }
@@ -0,0 +1,36 @@
1
+ import type { RecordedOutcome, TurnStart } from "@nylorun/core/harness-api";
2
+ import type { SandboxPodState } from "../store/types.js";
3
+ import { type Lease, type Session, type TenantContext } from "../tenant/context.js";
4
+ /** A segment ready to run: the session as it starts, and its transcript folded from the record. */
5
+ export interface SegmentStart {
6
+ readonly current: Session;
7
+ readonly fixtureModel: boolean;
8
+ /** The transcript the engine resumes from (agents with state; empty otherwise). */
9
+ readonly transcript: unknown[];
10
+ /** The record position of `transcript` (`foldTranscriptWithCursor`). */
11
+ readonly cursor: number;
12
+ readonly outcomes: RecordedOutcome[];
13
+ /** The tree's sandbox workspace: its owning session, and the sandbox resource it is attached to. */
14
+ readonly sandbox: {
15
+ readonly ownerId: string;
16
+ readonly sandboxId?: string;
17
+ };
18
+ /** The sandbox is a pod sandbox (F7.2): its engine runs the segment. Its lifecycle state. */
19
+ readonly pod?: {
20
+ readonly id: string;
21
+ readonly pod: SandboxPodState;
22
+ };
23
+ }
24
+ /** The segment's starting transaction and fold. */
25
+ export declare function startSegment(ctx: TenantContext, lease: Lease,
26
+ /** For a harness: also the segment's outcomes and the sandbox's owner. */
27
+ options?: {
28
+ harness?: boolean;
29
+ }): Promise<SegmentStart>;
30
+ /** The run's `turn.start`. */
31
+ export declare function buildTurnStart(ctx: TenantContext, segment: SegmentStart): TurnStart;
32
+ /** The engine's yield budget for a segment. */
33
+ export declare function yieldAfterOf(ctx: TenantContext): {
34
+ steps: number;
35
+ ms: number;
36
+ };