@telorun/runner-core 0.8.2 → 0.10.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.
- package/README.md +33 -2
- package/dist/backend.d.ts +107 -23
- package/dist/backend.d.ts.map +1 -1
- package/dist/config.d.ts +56 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +36 -0
- package/dist/config.js.map +1 -1
- package/dist/contract.d.ts +210 -15
- package/dist/contract.d.ts.map +1 -1
- package/dist/contract.js +9 -0
- package/dist/contract.js.map +1 -1
- package/dist/debug/ports-resolved.d.ts +26 -0
- package/dist/debug/ports-resolved.d.ts.map +1 -0
- package/dist/debug/ports-resolved.js +42 -0
- package/dist/debug/ports-resolved.js.map +1 -0
- package/dist/debug/relay.d.ts.map +1 -1
- package/dist/debug/relay.js +34 -6
- package/dist/debug/relay.js.map +1 -1
- package/dist/debug/run-projection.d.ts +53 -0
- package/dist/debug/run-projection.d.ts.map +1 -0
- package/dist/debug/run-projection.js +115 -0
- package/dist/debug/run-projection.js.map +1 -0
- package/dist/index.d.ts +8 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -1
- package/dist/index.js.map +1 -1
- package/dist/routes/apps.d.ts +1 -1
- package/dist/routes/apps.d.ts.map +1 -1
- package/dist/routes/apps.js +14 -4
- package/dist/routes/apps.js.map +1 -1
- package/dist/routes/io.d.ts.map +1 -1
- package/dist/routes/io.js +42 -9
- package/dist/routes/io.js.map +1 -1
- package/dist/routes/session-start.d.ts +18 -13
- package/dist/routes/session-start.d.ts.map +1 -1
- package/dist/routes/session-start.js +95 -14
- package/dist/routes/session-start.js.map +1 -1
- package/dist/routes/sessions.d.ts +11 -1
- package/dist/routes/sessions.d.ts.map +1 -1
- package/dist/routes/sessions.js +465 -5
- package/dist/routes/sessions.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +21 -1
- package/dist/server.js.map +1 -1
- package/dist/session/byte-ring-buffer.d.ts +6 -1
- package/dist/session/byte-ring-buffer.d.ts.map +1 -1
- package/dist/session/byte-ring-buffer.js +2 -2
- package/dist/session/byte-ring-buffer.js.map +1 -1
- package/dist/session/registry.d.ts +144 -13
- package/dist/session/registry.d.ts.map +1 -1
- package/dist/session/registry.js +166 -25
- package/dist/session/registry.js.map +1 -1
- package/dist/session/watch-supervisor.d.ts +42 -0
- package/dist/session/watch-supervisor.d.ts.map +1 -0
- package/dist/session/watch-supervisor.js +99 -0
- package/dist/session/watch-supervisor.js.map +1 -0
- package/dist/session/workspace-app.d.ts +4 -0
- package/dist/session/workspace-app.d.ts.map +1 -0
- package/dist/session/workspace-app.js +26 -0
- package/dist/session/workspace-app.js.map +1 -0
- package/dist/session/workspace-client.d.ts +33 -0
- package/dist/session/workspace-client.d.ts.map +1 -0
- package/dist/session/workspace-client.js +62 -0
- package/dist/session/workspace-client.js.map +1 -0
- package/dist/session/workspace-marker.d.ts +25 -0
- package/dist/session/workspace-marker.d.ts.map +1 -0
- package/dist/session/workspace-marker.js +35 -0
- package/dist/session/workspace-marker.js.map +1 -0
- package/dist/sse/channel.d.ts.map +1 -1
- package/dist/sse/channel.js +4 -0
- package/dist/sse/channel.js.map +1 -1
- package/package.json +4 -3
- package/src/app-catalog.test.ts +49 -1
- package/src/backend.ts +111 -22
- package/src/config.ts +98 -0
- package/src/contract.ts +213 -16
- package/src/debug/ports-resolved.test.ts +66 -0
- package/src/debug/ports-resolved.ts +43 -0
- package/src/debug/relay.ts +32 -4
- package/src/debug/run-projection.test.ts +155 -0
- package/src/debug/run-projection.ts +122 -0
- package/src/index.ts +20 -1
- package/src/routes/apps.ts +14 -5
- package/src/routes/io.ts +58 -12
- package/src/routes/session-start.ts +115 -27
- package/src/routes/sessions.ts +538 -7
- package/src/server.ts +22 -1
- package/src/session/byte-ring-buffer.ts +8 -2
- package/src/session/registry.ts +288 -28
- package/src/session/ring-buffer.test.ts +4 -3
- package/src/session/watch-lifetime.test.ts +203 -0
- package/src/session/watch-supervisor.ts +112 -0
- package/src/session/workspace-app.ts +27 -0
- package/src/session/workspace-client.ts +80 -0
- package/src/session/workspace-marker.test.ts +35 -0
- package/src/session/workspace-marker.ts +39 -0
- package/src/sse/channel.ts +5 -0
- 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
|
+
}
|
package/src/sse/channel.ts
CHANGED
|
@@ -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
|