@telorun/runner-core 0.8.2 → 0.9.0

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 (97) hide show
  1. package/README.md +33 -2
  2. package/dist/backend.d.ts +107 -23
  3. package/dist/backend.d.ts.map +1 -1
  4. package/dist/config.d.ts +27 -0
  5. package/dist/config.d.ts.map +1 -1
  6. package/dist/config.js +12 -0
  7. package/dist/config.js.map +1 -1
  8. package/dist/contract.d.ts +199 -15
  9. package/dist/contract.d.ts.map +1 -1
  10. package/dist/contract.js +9 -0
  11. package/dist/contract.js.map +1 -1
  12. package/dist/debug/ports-resolved.d.ts +26 -0
  13. package/dist/debug/ports-resolved.d.ts.map +1 -0
  14. package/dist/debug/ports-resolved.js +42 -0
  15. package/dist/debug/ports-resolved.js.map +1 -0
  16. package/dist/debug/relay.d.ts.map +1 -1
  17. package/dist/debug/relay.js +34 -6
  18. package/dist/debug/relay.js.map +1 -1
  19. package/dist/debug/run-projection.d.ts +53 -0
  20. package/dist/debug/run-projection.d.ts.map +1 -0
  21. package/dist/debug/run-projection.js +115 -0
  22. package/dist/debug/run-projection.js.map +1 -0
  23. package/dist/index.d.ts +8 -2
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +7 -1
  26. package/dist/index.js.map +1 -1
  27. package/dist/routes/apps.d.ts +1 -1
  28. package/dist/routes/apps.d.ts.map +1 -1
  29. package/dist/routes/apps.js +14 -4
  30. package/dist/routes/apps.js.map +1 -1
  31. package/dist/routes/io.d.ts.map +1 -1
  32. package/dist/routes/io.js +42 -9
  33. package/dist/routes/io.js.map +1 -1
  34. package/dist/routes/session-start.d.ts +12 -12
  35. package/dist/routes/session-start.d.ts.map +1 -1
  36. package/dist/routes/session-start.js +86 -13
  37. package/dist/routes/session-start.js.map +1 -1
  38. package/dist/routes/sessions.d.ts +11 -1
  39. package/dist/routes/sessions.d.ts.map +1 -1
  40. package/dist/routes/sessions.js +429 -4
  41. package/dist/routes/sessions.js.map +1 -1
  42. package/dist/server.d.ts.map +1 -1
  43. package/dist/server.js +21 -1
  44. package/dist/server.js.map +1 -1
  45. package/dist/session/byte-ring-buffer.d.ts +6 -1
  46. package/dist/session/byte-ring-buffer.d.ts.map +1 -1
  47. package/dist/session/byte-ring-buffer.js +2 -2
  48. package/dist/session/byte-ring-buffer.js.map +1 -1
  49. package/dist/session/registry.d.ts +144 -13
  50. package/dist/session/registry.d.ts.map +1 -1
  51. package/dist/session/registry.js +166 -25
  52. package/dist/session/registry.js.map +1 -1
  53. package/dist/session/watch-supervisor.d.ts +42 -0
  54. package/dist/session/watch-supervisor.d.ts.map +1 -0
  55. package/dist/session/watch-supervisor.js +99 -0
  56. package/dist/session/watch-supervisor.js.map +1 -0
  57. package/dist/session/workspace-app.d.ts +4 -0
  58. package/dist/session/workspace-app.d.ts.map +1 -0
  59. package/dist/session/workspace-app.js +26 -0
  60. package/dist/session/workspace-app.js.map +1 -0
  61. package/dist/session/workspace-client.d.ts +33 -0
  62. package/dist/session/workspace-client.d.ts.map +1 -0
  63. package/dist/session/workspace-client.js +62 -0
  64. package/dist/session/workspace-client.js.map +1 -0
  65. package/dist/session/workspace-marker.d.ts +25 -0
  66. package/dist/session/workspace-marker.d.ts.map +1 -0
  67. package/dist/session/workspace-marker.js +35 -0
  68. package/dist/session/workspace-marker.js.map +1 -0
  69. package/dist/sse/channel.d.ts.map +1 -1
  70. package/dist/sse/channel.js +4 -0
  71. package/dist/sse/channel.js.map +1 -1
  72. package/package.json +4 -3
  73. package/src/backend.ts +111 -22
  74. package/src/config.ts +59 -0
  75. package/src/contract.ts +197 -15
  76. package/src/debug/ports-resolved.test.ts +66 -0
  77. package/src/debug/ports-resolved.ts +43 -0
  78. package/src/debug/relay.ts +32 -4
  79. package/src/debug/run-projection.test.ts +155 -0
  80. package/src/debug/run-projection.ts +122 -0
  81. package/src/index.ts +20 -1
  82. package/src/routes/apps.ts +14 -5
  83. package/src/routes/io.ts +58 -12
  84. package/src/routes/session-start.ts +106 -27
  85. package/src/routes/sessions.ts +498 -7
  86. package/src/server.ts +22 -1
  87. package/src/session/byte-ring-buffer.ts +8 -2
  88. package/src/session/registry.ts +288 -28
  89. package/src/session/ring-buffer.test.ts +4 -3
  90. package/src/session/watch-lifetime.test.ts +203 -0
  91. package/src/session/watch-supervisor.ts +112 -0
  92. package/src/session/workspace-app.ts +27 -0
  93. package/src/session/workspace-client.ts +80 -0
  94. package/src/session/workspace-marker.test.ts +35 -0
  95. package/src/session/workspace-marker.ts +39 -0
  96. package/src/sse/channel.ts +5 -0
  97. package/workspace-app/telo.yaml +228 -0
@@ -0,0 +1,112 @@
1
+ import type { SessionEntry, SessionRegistry } from "./registry.js";
2
+
3
+ export interface WatchSupervisorDeps {
4
+ registry: SessionRegistry;
5
+ /** No SSE/WS subscriber for this long → suspend. */
6
+ idleMs: number;
7
+ /** How often to pull a whole-tree workspace snapshot. */
8
+ checkpointMs: number;
9
+ log?: { warn(obj: unknown, msg: string): void };
10
+ }
11
+
12
+ /** How often the supervisor wakes. Both of its jobs are coarse — a checkpoint
13
+ * cadence and an idle window, both measured in minutes — so a fine tick would
14
+ * buy nothing but wakeups. */
15
+ const TICK_MS = 5_000;
16
+
17
+ /**
18
+ * The two background jobs that make a watch session affordable, and they only
19
+ * work as a pair: a periodic workspace checkpoint, and an idle reap that deletes
20
+ * the pod while keeping the session record and that checkpoint.
21
+ *
22
+ * Aggressive reaping is what makes per-visitor watch sessions affordable, and
23
+ * the checkpoint is what makes aggressive reaping safe to do.
24
+ *
25
+ * The checkpoint is a CACHE, never the only copy. The runner is a single replica
26
+ * with an in-memory registry, so a redeploy or crash drops every suspended
27
+ * session — the editor holds the authoritative workspace, already diffs its own
28
+ * files against `GET /workspace`, and on a `404` at resume creates a new session
29
+ * and re-seeds from its own copy in one change set. What the checkpoint saves is
30
+ * that upload.
31
+ */
32
+ export class WatchSupervisor {
33
+ private timer: NodeJS.Timeout | null = null;
34
+ /** Sessions with a checkpoint or suspend in flight, so a slow snapshot cannot
35
+ * have a second one started on top of it every tick. */
36
+ private readonly busy = new Set<string>();
37
+
38
+ constructor(private readonly deps: WatchSupervisorDeps) {}
39
+
40
+ start(): void {
41
+ if (this.timer) return;
42
+ this.timer = setInterval(() => void this.tick(), TICK_MS);
43
+ this.timer.unref?.();
44
+ }
45
+
46
+ stop(): void {
47
+ if (!this.timer) return;
48
+ clearInterval(this.timer);
49
+ this.timer = null;
50
+ }
51
+
52
+ /** Exposed for tests, which drive the clock rather than waiting on it. */
53
+ async tick(): Promise<void> {
54
+ const now = Date.now();
55
+ for (const entry of this.deps.registry.list()) {
56
+ if (entry.mode !== "watch") continue;
57
+ if (entry.status.kind !== "running") continue;
58
+ if (this.busy.has(entry.sessionId)) continue;
59
+
60
+ const idle =
61
+ entry.subscribers === 0 &&
62
+ entry.idleSince !== null &&
63
+ now - entry.idleSince >= this.deps.idleMs;
64
+
65
+ if (idle) {
66
+ void this.run(entry, () => this.suspend(entry));
67
+ } else if (
68
+ entry.checkpointedAt === null ||
69
+ now - entry.checkpointedAt >= this.deps.checkpointMs
70
+ ) {
71
+ void this.run(entry, () => this.checkpoint(entry));
72
+ }
73
+ }
74
+ }
75
+
76
+ private async run(entry: SessionEntry, job: () => Promise<void>): Promise<void> {
77
+ this.busy.add(entry.sessionId);
78
+ try {
79
+ await job();
80
+ } catch (err) {
81
+ // A failed checkpoint is not a failed session: the editor still holds the
82
+ // authoritative copy, and the next tick retries. Reported rather than
83
+ // swallowed so an operator sees a workspace that has stopped answering.
84
+ this.deps.log?.warn(
85
+ { err, sessionId: entry.sessionId },
86
+ "watch session checkpoint/suspend failed",
87
+ );
88
+ } finally {
89
+ this.busy.delete(entry.sessionId);
90
+ }
91
+ }
92
+
93
+ private async checkpoint(entry: SessionEntry): Promise<void> {
94
+ const workspace = entry.session?.workspace;
95
+ if (!workspace) return;
96
+ const files = await workspace.snapshot();
97
+ entry.checkpoint = { takenAt: new Date(), files };
98
+ entry.checkpointedAt = Date.now();
99
+ }
100
+
101
+ private async suspend(entry: SessionEntry): Promise<void> {
102
+ const session = entry.session;
103
+ if (!session?.suspend) return;
104
+ // Snapshot BEFORE the pod goes: the volume dies with it. A snapshot that
105
+ // fails aborts the suspend rather than taking the pod down with an older
106
+ // checkpoint — the session stays up and the next tick tries again.
107
+ await this.checkpoint(entry);
108
+ await session.suspend();
109
+ entry.session = null;
110
+ this.deps.registry.emit(entry.sessionId, { type: "status", status: { kind: "suspended" } });
111
+ }
112
+ }
@@ -0,0 +1,27 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ /** The name the manifest is mounted under, in every backend. */
6
+ export const WORKSPACE_APP_FILENAME = "telo.yaml";
7
+
8
+ /**
9
+ * The workspace application's manifest, read from the package rather than
10
+ * inlined as a string: it is a Telo manifest, so it must stay a `.yaml` file the
11
+ * repo's own `telo check` and formatter see. It lives in runner-core because the
12
+ * workspace surface is part of the `/v1` session contract — both backends mount
13
+ * the same bytes, and a copy per backend would be two manifests to hold in
14
+ * agreement.
15
+ *
16
+ * Read once and memoized: it never changes within a process.
17
+ */
18
+ let cached: string | undefined;
19
+
20
+ export function workspaceAppManifest(): string {
21
+ if (cached !== undefined) return cached;
22
+ // `dist/session/` at runtime, `src/session/` under a source run — three levels
23
+ // up is the package root either way.
24
+ const here = dirname(fileURLToPath(import.meta.url));
25
+ cached = readFileSync(join(here, "..", "..", "workspace-app", WORKSPACE_APP_FILENAME), "utf8");
26
+ return cached;
27
+ }
@@ -0,0 +1,80 @@
1
+ import type { WorkspaceAccess } from "../backend.js";
2
+ import type {
3
+ WorkspaceChangeSet,
4
+ WorkspaceCheckpointFile,
5
+ WorkspaceTree,
6
+ } from "../contract.js";
7
+
8
+ /**
9
+ * HTTP client for the `workspace` container's surface, shared by both backends:
10
+ * the container is the same image running the same manifest whether it is a
11
+ * sibling container on a docker network or a container in a kubernetes pod, so
12
+ * only the base URL differs.
13
+ *
14
+ * The runner reaches it over its own network (a pod IP, a container address) and
15
+ * proxies it outward. It is never published.
16
+ */
17
+ export class WorkspaceClient implements WorkspaceAccess {
18
+ constructor(
19
+ private readonly baseUrl: string,
20
+ private readonly timeoutMs = 30_000,
21
+ ) {}
22
+
23
+ async tree(): Promise<WorkspaceTree> {
24
+ return this.request<WorkspaceTree>("GET", "/workspace");
25
+ }
26
+
27
+ async readFile(path: string): Promise<{ content: string; size: number }> {
28
+ return this.request<{ content: string; size: number }>(
29
+ "GET",
30
+ `/workspace/file?path=${encodeURIComponent(path)}`,
31
+ );
32
+ }
33
+
34
+ async apply(changes: WorkspaceChangeSet): Promise<{ written: number; deleted: number }> {
35
+ return this.request<{ written: number; deleted: number }>("POST", "/workspace", changes);
36
+ }
37
+
38
+ async snapshot(): Promise<WorkspaceCheckpointFile[]> {
39
+ const body = await this.request<{ files: WorkspaceCheckpointFile[] }>(
40
+ "GET",
41
+ "/workspace/snapshot",
42
+ );
43
+ return body.files;
44
+ }
45
+
46
+ /** Re-run one app with no file change: rewrite its entry manifest with the
47
+ * bytes it already holds, through the same write path everything else takes.
48
+ * That is what makes `reload` need no signalling into the container, no
49
+ * shared PID namespace and no `exec` — RBAC is unchanged. */
50
+ async touch(path: string): Promise<void> {
51
+ const file = await this.request<{ content: string }>(
52
+ "GET",
53
+ `/workspace/file?path=${encodeURIComponent(path)}&encoding=base64`,
54
+ );
55
+ await this.apply({ write: [{ path, content: file.content, encoding: "base64" }] });
56
+ }
57
+
58
+ private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
59
+ const controller = new AbortController();
60
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
61
+ try {
62
+ const response = await fetch(`${this.baseUrl}${path}`, {
63
+ method,
64
+ signal: controller.signal,
65
+ ...(body === undefined
66
+ ? {}
67
+ : { headers: { "content-type": "application/json" }, body: JSON.stringify(body) }),
68
+ });
69
+ const text = await response.text();
70
+ if (!response.ok) {
71
+ // Surfaced, never swallowed: a workspace that has stopped answering is
72
+ // the one failure that makes every later edit silently do nothing.
73
+ throw new Error(`workspace ${method} ${path} failed: ${response.status} ${text.slice(0, 500)}`);
74
+ }
75
+ return JSON.parse(text) as T;
76
+ } finally {
77
+ clearTimeout(timer);
78
+ }
79
+ }
80
+ }
@@ -0,0 +1,35 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import type { RunBundle } from "../contract.js";
4
+ import {
5
+ WORKSPACE_MARKER_CONTENTS,
6
+ WORKSPACE_MARKER_FILENAME,
7
+ workspaceMarkerWrite,
8
+ } from "./workspace-marker.js";
9
+
10
+ const bundle = (paths: string[]): RunBundle => ({
11
+ entryRelativePath: "telo.yaml",
12
+ files: paths.map((relativePath) => ({ relativePath, contents: "x" })),
13
+ });
14
+
15
+ describe("workspaceMarkerWrite", () => {
16
+ it("seeds the marker at the workspace root", () => {
17
+ // Its LOCATION is what anchors the cache: the kernel walks up from an app's
18
+ // entry manifest, so the marker has to sit at the root, not beside an app.
19
+ expect(workspaceMarkerWrite(bundle(["telo.yaml", "worker.yaml"]))).toEqual([
20
+ { path: WORKSPACE_MARKER_FILENAME, content: WORKSPACE_MARKER_CONTENTS },
21
+ ]);
22
+ });
23
+
24
+ it("leaves a workspace that brings its own marker alone", () => {
25
+ // A project that really is a Telo workspace has a marker with a real
26
+ // `modules:` list; overwriting it would change what `telo release` finds.
27
+ expect(workspaceMarkerWrite(bundle(["telo.yaml", WORKSPACE_MARKER_FILENAME]))).toEqual([]);
28
+ });
29
+
30
+ it("writes a marker the workspace parser accepts", () => {
31
+ // An empty `modules:` is a hard error in that parser, so a marker seeded
32
+ // with one would be a file this repo's own tooling rejects.
33
+ expect(WORKSPACE_MARKER_CONTENTS).toMatch(/^modules: \[.+\]$/m);
34
+ });
35
+ });
@@ -0,0 +1,39 @@
1
+ import type { RunBundle, WorkspaceChangeSet } from "../contract.js";
2
+
3
+ /** The kernel anchors its `.telo` cache at the directory holding this file. */
4
+ export const WORKSPACE_MARKER_FILENAME = "telo-workspace.yaml";
5
+
6
+ /**
7
+ * The marker a session's workspace root carries.
8
+ *
9
+ * Its LOCATION is what matters here: the kernel walks up from an app's entry
10
+ * manifest looking for it and anchors the `.telo` cache at the directory that
11
+ * holds it. Without one, each app anchors on its OWN entry directory, so two
12
+ * apps in one workspace resolve the same module twice into two caches — and an
13
+ * app in a subdirectory gets a third.
14
+ *
15
+ * `modules:` is release scope and is read by `telo release` alone, which never
16
+ * runs in a session. It is written anyway because an empty list is a hard error
17
+ * in the parser, and a file this repo's own tooling would reject is not one to
18
+ * seed into a user's workspace.
19
+ */
20
+ export const WORKSPACE_MARKER_CONTENTS = `# Marks the root of this session's workspace.
21
+ #
22
+ # The Telo kernel anchors its module cache (.telo) at the directory holding this
23
+ # file, so every application in this workspace resolves its imports once into one
24
+ # cache instead of once per app.
25
+ modules: ["*"]
26
+ `;
27
+
28
+ /**
29
+ * The marker, unless the workspace already brings its own. A user whose project
30
+ * really is a Telo workspace has a marker with a real \`modules:\` list, and
31
+ * overwriting it would silently change what \`telo release\` discovers.
32
+ */
33
+ export function workspaceMarkerWrite(
34
+ bundle: RunBundle,
35
+ ): NonNullable<WorkspaceChangeSet["write"]> {
36
+ const provided = bundle.files.some((f) => f.relativePath === WORKSPACE_MARKER_FILENAME);
37
+ if (provided) return [];
38
+ return [{ path: WORKSPACE_MARKER_FILENAME, content: WORKSPACE_MARKER_CONTENTS }];
39
+ }
@@ -64,9 +64,14 @@ export async function streamSessionEvents(args: SseStreamArgs): Promise<void> {
64
64
  let heartbeat: NodeJS.Timeout | null = null;
65
65
  let closed = false;
66
66
 
67
+ // An attached event stream is a live client. Idleness — no subscriber on
68
+ // either channel for the configured window — is what suspends a watch session.
69
+ const releaseSubscriber = registry.addSubscriber(sessionId);
70
+
67
71
  const cleanup = (): void => {
68
72
  if (closed) return;
69
73
  closed = true;
74
+ releaseSubscriber();
70
75
  if (heartbeat) clearInterval(heartbeat);
71
76
  if (unsubscribe) unsubscribe();
72
77
  if (!raw.writableEnded) raw.end();
@@ -0,0 +1,228 @@
1
+ kind: Telo.Application
2
+ metadata:
3
+ name: TeloWorkspace
4
+ version: 0.1.0
5
+ description: |
6
+ The workspace surface of a watch session: an HTTP API over the session's
7
+ shared volume that the runner proxies to `/v1/sessions/:id/workspace`.
8
+
9
+ It is runner INFRASTRUCTURE, not agent functionality — part of the `/v1`
10
+ session contract — so the runner owns it and the co-resident agent is one
11
+ more writer on the volume beside the application containers, using its own
12
+ filesystem tools rather than these routes. Hanging this off the agent's
13
+ catalog image would invert the dependency (a session contract resting on an
14
+ application the operator configures) and would need a second implementation
15
+ for the agentless case, with the contract depending on the two staying in
16
+ agreement.
17
+
18
+ It runs the plain kernel image (`telorun/node`) over this manifest, mounted
19
+ read-only from a ConfigMap. There is deliberately no third image to build:
20
+ the whole surface is four routes over the `fs` module's tree primitives.
21
+ imports:
22
+ Http: oci://ghcr.io/telorun/http-server@0.29.0#sha256-6OIi1b-_cF4uMZ3O20EWRWAJn6JbgomkE4amBBMofqM
23
+ Fs: oci://ghcr.io/telorun/fs@0.9.4#sha256-w8NLcREIq8SvxtsrULgRVKMPXISnR4iJd7LxeFnBFGM
24
+ Run: oci://ghcr.io/telorun/run@0.26.0#sha256-ZhW56yiMCImqBh0wJ4mVaUvh5WuUVIaWErASFx--wCY
25
+ ports:
26
+ http:
27
+ env: PORT
28
+ default: 8099
29
+ variables:
30
+ root:
31
+ env: WORKSPACE_DIR
32
+ type: string
33
+ default: /workspace
34
+ # Base names skipped at any depth. Declared once because the two readers below
35
+ # are the tree the editor DIFFS and the snapshot that RE-SEEDS a resumed pod:
36
+ # if they drift, a file survives one and not the other. These are caches a
37
+ # workspace can always rebuild — deliberately not `dist`, which in a hand-written
38
+ # workspace is ordinary content, and dropping it would lose it across a suspend.
39
+ excluded:
40
+ env: WORKSPACE_EXCLUDE
41
+ type: array
42
+ default: [node_modules, .telo, .git]
43
+ targets:
44
+ - !ref server
45
+ ---
46
+ # The tree primitives, all rooted at the shared volume. `cwd` is not a security
47
+ # boundary — the runner normalizes every client-supplied path before it gets
48
+ # here, the same traversal guard a session bundle passes through.
49
+ kind: Fs.TreeSnapshot
50
+ metadata: { name: treeSnapshot }
51
+ cwd: !cel "variables.root"
52
+ ---
53
+ kind: Fs.TreeSync
54
+ metadata: { name: treeSync }
55
+ cwd: !cel "variables.root"
56
+ ---
57
+ kind: Fs.File
58
+ metadata: { name: readFile }
59
+ cwd: !cel "variables.root"
60
+ ---
61
+ # Whole-tree pull for the runner's checkpoint timer and for suspend. Contents are
62
+ # ALWAYS base64: a checkpoint has to round-trip a workspace exactly, and deciding
63
+ # text-versus-binary per file would be a guess that silently corrupts whichever
64
+ # file it gets wrong. A manifest workspace is small, so the encoding overhead is
65
+ # not worth a heuristic.
66
+ kind: Run.Projection
67
+ metadata: { name: readAll }
68
+ inputType:
69
+ kind: Telo.JsonSchema
70
+ schema:
71
+ type: object
72
+ required: [files]
73
+ properties:
74
+ files:
75
+ type: array
76
+ items:
77
+ type: object
78
+ required: [path, hash]
79
+ properties:
80
+ path: { type: string }
81
+ hash: { type: string }
82
+ collection: !cel "inputs.files"
83
+ concurrency: 8
84
+ steps:
85
+ - name: read
86
+ invoke: !ref readFile
87
+ inputs:
88
+ path: !cel "item.path"
89
+ encoding: base64
90
+ outputs:
91
+ path: !cel "item.path"
92
+ hash: !cel "item.hash"
93
+ content: !cel "steps.read.result.content"
94
+ encoding: base64
95
+ ---
96
+ kind: Run.Sequence
97
+ metadata: { name: snapshot }
98
+ steps:
99
+ - name: tree
100
+ invoke: !ref treeSnapshot
101
+ inputs:
102
+ exclude: !cel "variables.excluded"
103
+ - name: read
104
+ invoke: !ref readAll
105
+ inputs:
106
+ files: !cel "steps.tree.result.files"
107
+ outputs:
108
+ files: !cel "steps.read.result"
109
+ ---
110
+ kind: Http.Api
111
+ metadata: { name: api }
112
+ routes:
113
+ # The content-hash tree the editor diffs against its own files. Hashing content
114
+ # rather than comparing sizes or timestamps is what makes two snapshots diff
115
+ # into an exact change set.
116
+ - request:
117
+ path: /workspace
118
+ method: GET
119
+ handler: !ref treeSnapshot
120
+ inputs:
121
+ exclude: !cel "variables.excluded"
122
+ returns:
123
+ - status: 200
124
+ content:
125
+ application/json:
126
+ body:
127
+ files: !cel "result.files"
128
+ catches:
129
+ - status: 500
130
+ content:
131
+ application/json:
132
+ body:
133
+ error: !cel "error.message"
134
+ # An explicit write/delete list, not a whole-tree PUT: a deletion has to be
135
+ # expressible, and a whole-tree PUT can only express it by treating absence as
136
+ # intent. There is deliberately no single-file write route — a one-file save is
137
+ # a change set of one, and a second write path would be a second set of
138
+ # concurrency rules over the same directory.
139
+ - request:
140
+ path: /workspace
141
+ method: POST
142
+ schema:
143
+ body:
144
+ type: object
145
+ properties:
146
+ write:
147
+ type: array
148
+ items:
149
+ type: object
150
+ required: [path, content]
151
+ properties:
152
+ path: { type: string }
153
+ content: { type: string }
154
+ encoding: { type: string, enum: [utf8, base64] }
155
+ delete:
156
+ type: array
157
+ items: { type: string }
158
+ handler: !ref treeSync
159
+ inputs:
160
+ write: !cel "has(request.body.write) ? request.body.write : []"
161
+ delete: !cel "has(request.body.delete) ? request.body.delete : []"
162
+ returns:
163
+ - status: 200
164
+ content:
165
+ application/json:
166
+ body:
167
+ written: !cel "result.written"
168
+ deleted: !cel "result.deleted"
169
+ catches:
170
+ - status: 500
171
+ content:
172
+ application/json:
173
+ body:
174
+ error: !cel "error.message"
175
+ - request:
176
+ path: /workspace/file
177
+ method: GET
178
+ schema:
179
+ query:
180
+ type: object
181
+ required: [path]
182
+ properties:
183
+ path: { type: string }
184
+ encoding: { type: string, enum: [utf8, base64] }
185
+ handler: !ref readFile
186
+ inputs:
187
+ path: !cel "request.query.path"
188
+ encoding: !cel "has(request.query.encoding) ? request.query.encoding : 'utf8'"
189
+ returns:
190
+ - status: 200
191
+ content:
192
+ application/json:
193
+ body:
194
+ content: !cel "result.content"
195
+ size: !cel "result.size"
196
+ catches:
197
+ - status: 500
198
+ content:
199
+ application/json:
200
+ body:
201
+ error: !cel "error.message"
202
+ - request:
203
+ path: /workspace/snapshot
204
+ method: GET
205
+ handler: !ref snapshot
206
+ returns:
207
+ - status: 200
208
+ content:
209
+ application/json:
210
+ body:
211
+ files: !cel "result.files"
212
+ catches:
213
+ - status: 500
214
+ content:
215
+ application/json:
216
+ body:
217
+ error: !cel "error.message"
218
+ ---
219
+ kind: Http.Server
220
+ metadata: { name: server }
221
+ # Pod-local only. The runner reaches it by pod IP over the cluster network and
222
+ # proxies it outward; nothing here is published via Service or Ingress, and the
223
+ # container holds no credentials of any kind.
224
+ host: 0.0.0.0
225
+ port: !cel "ports.http"
226
+ mounts:
227
+ - path: /
228
+ mount: !ref api