@telorun/runner-core 0.8.1 → 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.
- 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 +27 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +12 -0
- package/dist/config.js.map +1 -1
- package/dist/contract.d.ts +199 -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 +12 -12
- package/dist/routes/session-start.d.ts.map +1 -1
- package/dist/routes/session-start.js +86 -13
- 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 +429 -4
- 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 +5 -4
- package/src/backend.ts +111 -22
- package/src/config.ts +59 -0
- package/src/contract.ts +197 -15
- 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 +106 -27
- package/src/routes/sessions.ts +498 -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,122 @@
|
|
|
1
|
+
import { isEventFrame, type DebugFrame } from "@telorun/debug-wire";
|
|
2
|
+
|
|
3
|
+
import type { RunTrigger } from "../contract.js";
|
|
4
|
+
import type { SessionRegistry } from "../session/registry.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Projects per-app `run` events out of the kernel lifecycle events the debug
|
|
8
|
+
* stream already carries.
|
|
9
|
+
*
|
|
10
|
+
* The runner cannot see inside the container, and parsing the merged PTY stream
|
|
11
|
+
* is not a contract — so a watch session always runs with the kernel debug
|
|
12
|
+
* stream on and this reads it. A watch reload is a stop/start pair on ONE debug
|
|
13
|
+
* connection, so `generation` is counted here and the kernel is asked for
|
|
14
|
+
* nothing.
|
|
15
|
+
*
|
|
16
|
+
* Three event names are read, and only three:
|
|
17
|
+
* - `Kernel.Starting` → the generation began
|
|
18
|
+
* - `Kernel.Stopped` → it ended, carrying `exitCode`
|
|
19
|
+
* - `Kernel.RunFailed` → it never reached a running state, carrying why
|
|
20
|
+
*
|
|
21
|
+
* `Kernel.Started` is deliberately ignored: it and `Kernel.Starting` bracket the
|
|
22
|
+
* same transition, and one `started` per generation is the contract.
|
|
23
|
+
*/
|
|
24
|
+
export class RunProjection {
|
|
25
|
+
/** What to attribute the NEXT generation of each app to. A reload is a watch
|
|
26
|
+
* reload unless something told us otherwise, so `watch` is the resting value
|
|
27
|
+
* and `initial` / `manual` / `resume` are set by whoever caused them. */
|
|
28
|
+
private readonly triggers = new Map<string, RunTrigger>();
|
|
29
|
+
|
|
30
|
+
constructor(
|
|
31
|
+
private readonly registry: SessionRegistry,
|
|
32
|
+
private readonly sessionId: string,
|
|
33
|
+
) {}
|
|
34
|
+
|
|
35
|
+
/** Attribute an app's next generation. Called with `initial` when the session
|
|
36
|
+
* comes up, `manual` from the reload route, `resume` after a pod recreate. */
|
|
37
|
+
expect(app: string, trigger: RunTrigger): void {
|
|
38
|
+
this.triggers.set(app, trigger);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Attribute every app's next generation — the session-wide cases (initial,
|
|
42
|
+
* resume) always move all of them together. */
|
|
43
|
+
expectAll(trigger: RunTrigger): void {
|
|
44
|
+
const entry = this.registry.get(this.sessionId);
|
|
45
|
+
for (const app of entry?.apps.keys() ?? []) this.triggers.set(app, trigger);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Feed one frame from `app`'s debug stream. Frames that are not one of the
|
|
49
|
+
* three lifecycle events pass through untouched. */
|
|
50
|
+
frame(app: string, frame: DebugFrame): void {
|
|
51
|
+
if (!isEventFrame(frame)) return;
|
|
52
|
+
switch (frame.event) {
|
|
53
|
+
case "Kernel.Starting":
|
|
54
|
+
this.begin(app);
|
|
55
|
+
return;
|
|
56
|
+
case "Kernel.Stopped":
|
|
57
|
+
this.registry.finishGeneration(this.sessionId, app, {
|
|
58
|
+
phase: "completed",
|
|
59
|
+
code: exitCodeOf(frame.payload),
|
|
60
|
+
});
|
|
61
|
+
return;
|
|
62
|
+
case "Kernel.RunFailed":
|
|
63
|
+
// A LOAD failure emits no `Kernel.Starting` — the kernel never got that
|
|
64
|
+
// far — so open the generation here rather than reporting a failure of a
|
|
65
|
+
// generation the client never saw start. The pair is what a consumer
|
|
66
|
+
// reads as "this attempt happened and did not survive".
|
|
67
|
+
this.begin(app);
|
|
68
|
+
this.registry.finishGeneration(this.sessionId, app, {
|
|
69
|
+
phase: "failed",
|
|
70
|
+
reason: failureReason(frame.payload),
|
|
71
|
+
});
|
|
72
|
+
return;
|
|
73
|
+
default:
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Close the open generation because its WORKLOAD ended, not because the kernel
|
|
80
|
+
* said so. A container that goes away under `--watch` emits no `Kernel.Stopped`
|
|
81
|
+
* — there is nothing left to emit it — so the backend reports the ending and
|
|
82
|
+
* this turns it into the same run outcome the kernel path produces.
|
|
83
|
+
*/
|
|
84
|
+
endGeneration(app: string, outcome: { code?: number; reason?: string }): void {
|
|
85
|
+
const channel = this.registry.get(this.sessionId)?.apps.get(app);
|
|
86
|
+
if (!channel || channel.startedAt === null) return;
|
|
87
|
+
this.registry.finishGeneration(
|
|
88
|
+
this.sessionId,
|
|
89
|
+
app,
|
|
90
|
+
outcome.reason === undefined && outcome.code !== undefined
|
|
91
|
+
? { phase: "completed", code: outcome.code }
|
|
92
|
+
: { phase: "failed", reason: outcome.reason ?? "workload ended" },
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Open a generation unless one is already open — `Kernel.Starting` followed
|
|
97
|
+
* by a `Kernel.RunFailed` from the boot phase must not count twice. */
|
|
98
|
+
private begin(app: string): void {
|
|
99
|
+
const channel = this.registry.get(this.sessionId)?.apps.get(app);
|
|
100
|
+
if (!channel || channel.startedAt !== null) return;
|
|
101
|
+
const trigger = this.triggers.get(app) ?? "watch";
|
|
102
|
+
// Consumed: the next generation is a plain watch reload unless something
|
|
103
|
+
// says otherwise again.
|
|
104
|
+
this.triggers.delete(app);
|
|
105
|
+
this.registry.startGeneration(this.sessionId, app, trigger);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function exitCodeOf(payload: unknown): number {
|
|
110
|
+
const code = (payload as { exitCode?: unknown } | undefined)?.exitCode;
|
|
111
|
+
return typeof code === "number" ? code : 0;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The most actionable thing the failure carries: a diagnostic code when the
|
|
115
|
+
* kernel had one (`CEL_UNKNOWN_FIELD`, `ERR_MANIFEST_VALIDATION_FAILED`), else
|
|
116
|
+
* its message. A code is what a reader — an agent above all — branches on. */
|
|
117
|
+
function failureReason(payload: unknown): string {
|
|
118
|
+
const p = payload as { code?: unknown; message?: unknown } | undefined;
|
|
119
|
+
if (typeof p?.code === "string" && p.code !== "") return p.code;
|
|
120
|
+
if (typeof p?.message === "string" && p.message !== "") return p.message;
|
|
121
|
+
return "run failed";
|
|
122
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -17,17 +17,36 @@ export {
|
|
|
17
17
|
SessionRegistry,
|
|
18
18
|
SessionLimitError,
|
|
19
19
|
SessionEvictedError,
|
|
20
|
+
UnknownAppError,
|
|
21
|
+
type AppChannel,
|
|
22
|
+
type RunAttribution,
|
|
20
23
|
type SessionEntry,
|
|
24
|
+
type RegisterArgs,
|
|
21
25
|
type RegistryDeps,
|
|
26
|
+
type WorkspaceCheckpoint,
|
|
22
27
|
} from "./session/registry.js";
|
|
28
|
+
export { WatchSupervisor, type WatchSupervisorDeps } from "./session/watch-supervisor.js";
|
|
29
|
+
export { RunProjection } from "./debug/run-projection.js";
|
|
30
|
+
export {
|
|
31
|
+
portKey,
|
|
32
|
+
portsResolvedFrom,
|
|
33
|
+
PORTS_RESOLVED_EVENT,
|
|
34
|
+
} from "./debug/ports-resolved.js";
|
|
23
35
|
export { EventRingBuffer, type BufferedEvent } from "./session/ring-buffer.js";
|
|
24
36
|
export { ByteRingBuffer, type BufferedBytes } from "./session/byte-ring-buffer.js";
|
|
25
37
|
export { normalizeBundlePath, validateSessionId, BundlePathError } from "./session/bundle-path.js";
|
|
38
|
+
export { WorkspaceClient } from "./session/workspace-client.js";
|
|
39
|
+
export { workspaceAppManifest, WORKSPACE_APP_FILENAME } from "./session/workspace-app.js";
|
|
40
|
+
export {
|
|
41
|
+
workspaceMarkerWrite,
|
|
42
|
+
WORKSPACE_MARKER_CONTENTS,
|
|
43
|
+
WORKSPACE_MARKER_FILENAME,
|
|
44
|
+
} from "./session/workspace-marker.js";
|
|
26
45
|
export { streamSessionEvents, type SseStreamArgs } from "./sse/channel.js";
|
|
27
46
|
export { healthRoute } from "./routes/health.js";
|
|
28
47
|
export { capabilitiesRoute } from "./routes/capabilities.js";
|
|
29
48
|
export { probeRoute, type ProbeRouteDeps } from "./routes/probe.js";
|
|
30
|
-
export { sessionsRoute, type SessionsRouteDeps } from "./routes/sessions.js";
|
|
49
|
+
export { sessionsRoute, type SessionsRouteDeps, type WatchConfig } from "./routes/sessions.js";
|
|
31
50
|
export { appsRoute, type AppsRouteDeps } from "./routes/apps.js";
|
|
32
51
|
export { ioRoute, type IoRouteDeps } from "./routes/io.js";
|
|
33
52
|
export { relayDebugStream, type DebugRelayOptions } from "./debug/relay.js";
|
package/src/routes/apps.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { FastifyInstance, FastifyPluginAsync } from "fastify";
|
|
|
2
2
|
|
|
3
3
|
import type { RunnerBackend } from "../backend.js";
|
|
4
4
|
import type { ResolvedRunnerApp } from "../config.js";
|
|
5
|
-
import type
|
|
5
|
+
import { DEFAULT_APP_NAME, type PortMapping, type RunnerTerms } from "../contract.js";
|
|
6
6
|
import type { SessionRegistry } from "../session/registry.js";
|
|
7
7
|
import { enforceTerms, portsSchema, startWorkloadSession } from "./session-start.js";
|
|
8
8
|
|
|
@@ -88,15 +88,24 @@ export function appsRoute(deps: AppsRouteDeps): FastifyPluginAsync {
|
|
|
88
88
|
app,
|
|
89
89
|
deps,
|
|
90
90
|
{
|
|
91
|
-
// Self-contained image — no bundle to deliver
|
|
92
|
-
//
|
|
91
|
+
// Self-contained image — no bundle to deliver, and no entry path:
|
|
92
|
+
// the image's own entrypoint runs.
|
|
93
93
|
bundle: { entryRelativePath: "telo.yaml", files: [] },
|
|
94
|
-
entryRelativePath: "telo.yaml",
|
|
95
94
|
env,
|
|
96
|
-
ports: req.body?.ports ?? [],
|
|
97
95
|
config: { image: appEntry.image, pullPolicy: appEntry.pullPolicy },
|
|
98
96
|
selfContained: true,
|
|
99
97
|
inspect: req.body?.inspect ?? false,
|
|
98
|
+
// An operator-predefined app session is one run of one application.
|
|
99
|
+
// Watch is a workspace shape, and this door carries no workspace.
|
|
100
|
+
mode: "run",
|
|
101
|
+
apps: [
|
|
102
|
+
{
|
|
103
|
+
name: DEFAULT_APP_NAME,
|
|
104
|
+
entryRelativePath: "",
|
|
105
|
+
ports: req.body?.ports ?? [],
|
|
106
|
+
io: "tty",
|
|
107
|
+
},
|
|
108
|
+
],
|
|
100
109
|
},
|
|
101
110
|
reply,
|
|
102
111
|
);
|
package/src/routes/io.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { FastifyInstance, FastifyPluginAsync, FastifyRequest } from "fastify";
|
|
2
2
|
import type { WebSocket } from "@fastify/websocket";
|
|
3
3
|
|
|
4
|
-
import { isTerminal } from "../contract.js";
|
|
4
|
+
import { isTerminal, type ByteStreamTag } from "../contract.js";
|
|
5
5
|
import type { BufferedBytes } from "../session/byte-ring-buffer.js";
|
|
6
6
|
import type { SessionRegistry } from "../session/registry.js";
|
|
7
7
|
|
|
@@ -12,6 +12,11 @@ export interface IoRouteDeps {
|
|
|
12
12
|
|
|
13
13
|
const RESIZE_THROTTLE_MS = 50;
|
|
14
14
|
const SEQ_PREFIX_BYTES = 4;
|
|
15
|
+
const STREAM_TAG_BYTES = 1;
|
|
16
|
+
/** Wire codes for the per-frame stream tag. A tag rather than a labelled merged
|
|
17
|
+
* stream: under `io: "streams"` the transport really did separate stdout from
|
|
18
|
+
* stderr, and under `tty` it really did not. */
|
|
19
|
+
const STREAM_CODES: Record<ByteStreamTag, number> = { tty: 0, stdout: 1, stderr: 2 };
|
|
15
20
|
/** Upper bound on cols/rows accepted from the client. xterm + a fit addon
|
|
16
21
|
* produce values in the low thousands at extreme zoom; anything past this
|
|
17
22
|
* is either nonsense or a malicious client trying to feed
|
|
@@ -28,7 +33,7 @@ interface ControlFrame {
|
|
|
28
33
|
|
|
29
34
|
export function ioRoute(deps: IoRouteDeps): FastifyPluginAsync {
|
|
30
35
|
return async (app: FastifyInstance) => {
|
|
31
|
-
app.get<{ Params: { id: string }; Querystring:
|
|
36
|
+
app.get<{ Params: { id: string }; Querystring: IoQuery }>(
|
|
32
37
|
"/v1/sessions/:id/io",
|
|
33
38
|
{ websocket: true },
|
|
34
39
|
(socket, req) => handleIo(socket, req, deps),
|
|
@@ -36,9 +41,16 @@ export function ioRoute(deps: IoRouteDeps): FastifyPluginAsync {
|
|
|
36
41
|
};
|
|
37
42
|
}
|
|
38
43
|
|
|
44
|
+
interface IoQuery {
|
|
45
|
+
lastSeq?: string;
|
|
46
|
+
/** Which application's terminal to attach to. Required whenever the session
|
|
47
|
+
* runs more than one app — there is no defensible default among several. */
|
|
48
|
+
app?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
39
51
|
function handleIo(
|
|
40
52
|
socket: WebSocket,
|
|
41
|
-
req: FastifyRequest<{ Params: { id: string }; Querystring:
|
|
53
|
+
req: FastifyRequest<{ Params: { id: string }; Querystring: IoQuery }>,
|
|
42
54
|
deps: IoRouteDeps,
|
|
43
55
|
): void {
|
|
44
56
|
// Origin allowlist runs INSIDE the handler (post-handshake) rather than
|
|
@@ -61,6 +73,24 @@ function handleIo(
|
|
|
61
73
|
return;
|
|
62
74
|
}
|
|
63
75
|
|
|
76
|
+
// Resolve which app's terminal this attachment is for. An explicit name must
|
|
77
|
+
// exist; an omitted one is only defensible on a single-app session.
|
|
78
|
+
const requestedApp = req.query.app?.trim() || undefined;
|
|
79
|
+
const channel = requestedApp
|
|
80
|
+
? entry.apps.get(requestedApp)
|
|
81
|
+
: deps.registry.soleApp(entry);
|
|
82
|
+
if (!channel) {
|
|
83
|
+
closeWith(
|
|
84
|
+
socket,
|
|
85
|
+
4404,
|
|
86
|
+
requestedApp
|
|
87
|
+
? `session runs no app named '${requestedApp}'`
|
|
88
|
+
: `session runs ${entry.apps.size} apps — ?app= is required`,
|
|
89
|
+
);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const appName = channel.name;
|
|
93
|
+
|
|
64
94
|
const lastSeq = parseLastSeq(req.query.lastSeq);
|
|
65
95
|
|
|
66
96
|
// Subscribe BEFORE snapshotting the replay buffer. This is load-bearing:
|
|
@@ -71,7 +101,7 @@ function handleIo(
|
|
|
71
101
|
// the handler switches to direct-send mode.
|
|
72
102
|
let mode: "deferred" | "direct" = "deferred";
|
|
73
103
|
const liveQueue: BufferedBytes[] = [];
|
|
74
|
-
const unsubscribe = deps.registry.subscribeBytes(sessionId, (buffered) => {
|
|
104
|
+
const unsubscribe = deps.registry.subscribeBytes(sessionId, appName, (buffered) => {
|
|
75
105
|
if (socket.readyState !== socket.OPEN) return;
|
|
76
106
|
if (mode === "deferred") {
|
|
77
107
|
liveQueue.push(buffered);
|
|
@@ -80,7 +110,7 @@ function handleIo(
|
|
|
80
110
|
}
|
|
81
111
|
});
|
|
82
112
|
|
|
83
|
-
const { entries, hasGap } =
|
|
113
|
+
const { entries, hasGap } = channel.byteBuffer.replay(lastSeq);
|
|
84
114
|
|
|
85
115
|
// If the session is already terminal AND the byte buffer holds nothing
|
|
86
116
|
// newer than what the client already saw, there's no live channel to
|
|
@@ -91,8 +121,9 @@ function handleIo(
|
|
|
91
121
|
return;
|
|
92
122
|
}
|
|
93
123
|
|
|
94
|
-
// Confirm the resume point
|
|
95
|
-
|
|
124
|
+
// Confirm the resume point and which attachment this is, so a client knows
|
|
125
|
+
// whether to offer resize before it sends one.
|
|
126
|
+
sendJson(socket, { type: "seq", seq: lastSeq, app: appName, io: channel.io });
|
|
96
127
|
if (hasGap) {
|
|
97
128
|
sendJson(socket, { type: "gap", reason: "buffer_evicted" });
|
|
98
129
|
}
|
|
@@ -125,7 +156,7 @@ function handleIo(
|
|
|
125
156
|
if (!next) return;
|
|
126
157
|
// Session may have exited between schedule and flush; backends treat
|
|
127
158
|
// resize on a gone workload as a no-op.
|
|
128
|
-
entry.session?.resize(next.cols, next.rows);
|
|
159
|
+
entry.session?.resize(appName, next.cols, next.rows);
|
|
129
160
|
};
|
|
130
161
|
|
|
131
162
|
socket.on("message", (raw, isBinary) => {
|
|
@@ -133,7 +164,7 @@ function handleIo(
|
|
|
133
164
|
const session = entry.session;
|
|
134
165
|
if (!session) return;
|
|
135
166
|
const buf = raw instanceof Buffer ? raw : Buffer.from(raw as ArrayBuffer);
|
|
136
|
-
session.writeStdin(buf);
|
|
167
|
+
session.writeStdin(appName, buf);
|
|
137
168
|
return;
|
|
138
169
|
}
|
|
139
170
|
const text = raw.toString();
|
|
@@ -144,6 +175,13 @@ function handleIo(
|
|
|
144
175
|
return;
|
|
145
176
|
}
|
|
146
177
|
if (parsed.type === "resize") {
|
|
178
|
+
// A resize is meaningless with no PTY — rejected rather than silently
|
|
179
|
+
// dropped, so a client attached to a `streams` app learns it must not
|
|
180
|
+
// wire its terminal's resize through.
|
|
181
|
+
if (channel.io !== "tty") {
|
|
182
|
+
sendJson(socket, { type: "error", error: "resize_unsupported", io: channel.io });
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
147
185
|
const cols = clampDimension(parsed.cols);
|
|
148
186
|
const rows = clampDimension(parsed.rows);
|
|
149
187
|
if (cols === null || rows === null) return;
|
|
@@ -190,7 +228,12 @@ function handleIo(
|
|
|
190
228
|
drainAndClose(Date.now() + TERMINAL_DRAIN_MAX_MS);
|
|
191
229
|
}
|
|
192
230
|
|
|
231
|
+
// A byte-channel attachment counts as a live client: a session whose terminal
|
|
232
|
+
// someone is watching is not idle, even with no SSE stream open.
|
|
233
|
+
const releaseSubscriber = deps.registry.addSubscriber(sessionId);
|
|
234
|
+
|
|
193
235
|
const cleanup = (): void => {
|
|
236
|
+
releaseSubscriber();
|
|
194
237
|
unsubscribe();
|
|
195
238
|
unsubscribeStatus();
|
|
196
239
|
if (resizeTimer) clearTimeout(resizeTimer);
|
|
@@ -200,14 +243,17 @@ function handleIo(
|
|
|
200
243
|
socket.on("error", cleanup);
|
|
201
244
|
}
|
|
202
245
|
|
|
203
|
-
/** Wire format for a binary frame: `[seq:4 BE][payload:N]`. The 4-byte
|
|
246
|
+
/** Wire format for a binary frame: `[seq:4 BE][stream:1][payload:N]`. The 4-byte
|
|
204
247
|
* prefix lets the client de-sync detect — it knows the authoritative seq
|
|
205
248
|
* for every byte received, instead of inferring from frame count. Replay
|
|
206
|
-
* duplicates and reconnect resumes both lean on this.
|
|
249
|
+
* duplicates and reconnect resumes both lean on this. The stream byte says
|
|
250
|
+
* which source produced the payload; it is `tty` under a terminal attach and
|
|
251
|
+
* never asserts a split the transport did not make. */
|
|
207
252
|
function sendBytesFrame(socket: WebSocket, buffered: BufferedBytes): void {
|
|
208
253
|
if (socket.readyState !== socket.OPEN) return;
|
|
209
|
-
const prefix = Buffer.alloc(SEQ_PREFIX_BYTES);
|
|
254
|
+
const prefix = Buffer.alloc(SEQ_PREFIX_BYTES + STREAM_TAG_BYTES);
|
|
210
255
|
prefix.writeUInt32BE(buffered.seq, 0);
|
|
256
|
+
prefix.writeUInt8(STREAM_CODES[buffered.stream] ?? 0, SEQ_PREFIX_BYTES);
|
|
211
257
|
try {
|
|
212
258
|
socket.send(Buffer.concat([prefix, buffered.bytes]));
|
|
213
259
|
} catch {
|
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
|
2
2
|
|
|
3
3
|
import { isEventFrame } from "@telorun/debug-wire";
|
|
4
|
-
import type { RunnerBackend } from "../backend.js";
|
|
4
|
+
import type { RunnerBackend, WorkloadLaunch } from "../backend.js";
|
|
5
|
+
import { RunProjection } from "../debug/run-projection.js";
|
|
5
6
|
import {
|
|
6
7
|
ACCEPTED_TERMS_HEADER,
|
|
7
8
|
SessionStartError,
|
|
8
|
-
type PortMapping,
|
|
9
|
-
type RunBundle,
|
|
10
9
|
type RunnerTerms,
|
|
11
|
-
type SessionConfig,
|
|
12
10
|
} from "../contract.js";
|
|
13
11
|
import { generateSessionId } from "../session/session-id.js";
|
|
14
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
SessionLimitError,
|
|
14
|
+
type SessionEntry,
|
|
15
|
+
type SessionRegistry,
|
|
16
|
+
} from "../session/registry.js";
|
|
15
17
|
|
|
16
18
|
/** JSON Schema for the `ports` body field, shared by every session-creating
|
|
17
19
|
* route so bundle and app sessions validate port mappings identically. */
|
|
@@ -58,15 +60,7 @@ export interface WorkloadStartDeps {
|
|
|
58
60
|
defaultRegistryUrl?: string;
|
|
59
61
|
}
|
|
60
62
|
|
|
61
|
-
export
|
|
62
|
-
bundle: RunBundle;
|
|
63
|
-
entryRelativePath: string;
|
|
64
|
-
env: Record<string, string>;
|
|
65
|
-
ports: PortMapping[];
|
|
66
|
-
config: SessionConfig;
|
|
67
|
-
selfContained: boolean;
|
|
68
|
-
inspect: boolean;
|
|
69
|
-
}
|
|
63
|
+
export type WorkloadStartArgs = WorkloadLaunch;
|
|
70
64
|
|
|
71
65
|
/**
|
|
72
66
|
* The session-creation leaf shared by `POST /v1/sessions` (bundle sessions) and
|
|
@@ -86,7 +80,12 @@ export async function startWorkloadSession(
|
|
|
86
80
|
|
|
87
81
|
let entry: ReturnType<SessionRegistry["register"]>;
|
|
88
82
|
try {
|
|
89
|
-
entry = deps.registry.register({
|
|
83
|
+
entry = deps.registry.register({
|
|
84
|
+
sessionId,
|
|
85
|
+
mode: args.mode,
|
|
86
|
+
agent: args.agent?.name,
|
|
87
|
+
apps: args.apps.map((a) => ({ name: a.name, io: a.io, ports: a.ports })),
|
|
88
|
+
});
|
|
90
89
|
} catch (err) {
|
|
91
90
|
if (err instanceof SessionLimitError) {
|
|
92
91
|
reply.code(409).send({ error: "too_many_sessions", message: err.message });
|
|
@@ -101,10 +100,11 @@ export async function startWorkloadSession(
|
|
|
101
100
|
// whitespace from an editor input doesn't flow into the workload.
|
|
102
101
|
const configRegistryUrl = args.config.registryUrl?.trim() || undefined;
|
|
103
102
|
const registryUrl = configRegistryUrl ?? deps.defaultRegistryUrl;
|
|
104
|
-
const sessionEnv =
|
|
103
|
+
const sessionEnv = withoutCacheOverride(
|
|
105
104
|
registryUrl && !("TELO_REGISTRY_URL" in args.env)
|
|
106
105
|
? { ...args.env, TELO_REGISTRY_URL: registryUrl }
|
|
107
|
-
: args.env
|
|
106
|
+
: args.env,
|
|
107
|
+
);
|
|
108
108
|
|
|
109
109
|
// Respond as soon as the session is registered — BEFORE the backend starts.
|
|
110
110
|
// `backend.start()` now spans the on-cluster image build and pod bring-up,
|
|
@@ -119,29 +119,92 @@ export async function startWorkloadSession(
|
|
|
119
119
|
createdAt: entry.createdAt.toISOString(),
|
|
120
120
|
});
|
|
121
121
|
|
|
122
|
+
// Every app's first generation is attributed to the session coming up; a
|
|
123
|
+
// reload after that is a watch reload unless a route says otherwise.
|
|
124
|
+
const projection = new RunProjection(deps.registry, sessionId);
|
|
125
|
+
projection.expectAll("initial");
|
|
126
|
+
// Parked on the entry so the reload / resume routes can attribute the
|
|
127
|
+
// generation they are about to cause.
|
|
128
|
+
entry.attribution = projection;
|
|
129
|
+
|
|
130
|
+
const launch: WorkloadLaunch = { ...args, env: sessionEnv };
|
|
131
|
+
entry.launch = launch;
|
|
132
|
+
launchWorkload(app, deps, launch, entry);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Wire a backend workload to an EXISTING registry entry and start it in the
|
|
137
|
+
* background. Shared by session creation and by `resume`, which builds a fresh
|
|
138
|
+
* pod from the checkpoint under the same session id — the callback wiring is the
|
|
139
|
+
* contract's, so a second copy of it would be a second place run outcomes,
|
|
140
|
+
* output routing and the credential boundary could drift.
|
|
141
|
+
*/
|
|
142
|
+
export function launchWorkload(
|
|
143
|
+
app: FastifyInstance,
|
|
144
|
+
deps: WorkloadStartDeps,
|
|
145
|
+
args: WorkloadStartArgs,
|
|
146
|
+
entry: SessionEntry,
|
|
147
|
+
): void {
|
|
148
|
+
const sessionId = entry.sessionId;
|
|
149
|
+
const projection = entry.attribution;
|
|
150
|
+
|
|
122
151
|
deps.backend
|
|
123
152
|
.start({
|
|
124
153
|
sessionId,
|
|
125
154
|
bundle: args.bundle,
|
|
126
|
-
|
|
127
|
-
env: sessionEnv,
|
|
128
|
-
ports: args.ports,
|
|
155
|
+
env: args.env,
|
|
129
156
|
config: args.config,
|
|
130
157
|
selfContained: args.selfContained,
|
|
131
158
|
inspect: args.inspect,
|
|
159
|
+
mode: args.mode,
|
|
160
|
+
apps: args.apps,
|
|
161
|
+
agent: args.agent,
|
|
132
162
|
onStatus: (status) => deps.registry.emit(sessionId, { type: "status", status }),
|
|
133
|
-
onProgress: (phase, message, done) =>
|
|
134
|
-
deps.registry.emit(sessionId, { type: "progress", phase, message, done }),
|
|
135
|
-
onOutput: (chunk) => deps.registry.pushBytes(sessionId, chunk),
|
|
163
|
+
onProgress: (phase, message, done, app) =>
|
|
164
|
+
deps.registry.emit(sessionId, { type: "progress", app, phase, message, done }),
|
|
165
|
+
onOutput: (app, chunk, stream) => deps.registry.pushBytes(sessionId, app, chunk, stream),
|
|
136
166
|
// Relay only kernel *event* frames to the client. stdout/stderr already
|
|
137
167
|
// arrive over the byte channel (onOutput), so forwarding log frames would
|
|
138
168
|
// double the traffic and let log spam evict lifecycle events from the
|
|
139
169
|
// byte-capped replay buffer. The editor discards relayed logs anyway.
|
|
140
|
-
|
|
141
|
-
|
|
170
|
+
//
|
|
171
|
+
// The projection sees EVERY frame, including the ones not relayed — run
|
|
172
|
+
// outcomes are derived from lifecycle events, not from what a client
|
|
173
|
+
// happens to be shown.
|
|
174
|
+
onDebug: (appName, frame) => {
|
|
175
|
+
projection?.frame(appName, frame);
|
|
176
|
+
if (isEventFrame(frame)) {
|
|
177
|
+
deps.registry.emit(sessionId, { type: "debug", app: appName, frame });
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
// A watch session's app dying is a RUN outcome, not a session status: the
|
|
181
|
+
// rest of the session is still up, and the next edit starts the next
|
|
182
|
+
// generation. The projection owns generation identity, so the ending goes
|
|
183
|
+
// through it rather than being emitted directly.
|
|
184
|
+
onRunEnded: (appName, outcome) =>
|
|
185
|
+
projection?.endGeneration(appName, outcome) ??
|
|
186
|
+
deps.registry.finishGeneration(sessionId, appName, {
|
|
187
|
+
phase: "failed",
|
|
188
|
+
reason: outcome.reason ?? "workload ended",
|
|
189
|
+
}),
|
|
190
|
+
onReachability: (app, port, state) =>
|
|
191
|
+
deps.registry.emit(sessionId, { type: "reachability", app, port, state }),
|
|
192
|
+
onEndpoints: (appName, change) => {
|
|
193
|
+
// The registry's own channel is what `GET /v1/sessions/:id` reports, so
|
|
194
|
+
// the delta has to land there too — otherwise the session document keeps
|
|
195
|
+
// describing the port set the session STARTED with, forever.
|
|
196
|
+
const channel = entry.apps.get(appName);
|
|
197
|
+
if (channel) {
|
|
198
|
+
const removed = new Set(
|
|
199
|
+
(change.removed ?? []).map((e) => `${e.protocol}/${e.port}`),
|
|
200
|
+
);
|
|
201
|
+
channel.ports = [
|
|
202
|
+
...channel.ports.filter((p) => !removed.has(`${p.protocol}/${p.port}`)),
|
|
203
|
+
...(change.added ?? []).map((e) => ({ port: e.port, protocol: e.protocol })),
|
|
204
|
+
];
|
|
205
|
+
}
|
|
206
|
+
deps.registry.emit(sessionId, { type: "endpoints", app: appName, ...change });
|
|
142
207
|
},
|
|
143
|
-
onReachability: (port, state) =>
|
|
144
|
-
deps.registry.emit(sessionId, { type: "reachability", port, state }),
|
|
145
208
|
isUserStopped: () => entry.userStopped,
|
|
146
209
|
})
|
|
147
210
|
.then(async (session) => {
|
|
@@ -172,3 +235,19 @@ export async function startWorkloadSession(
|
|
|
172
235
|
deps.registry.emit(sessionId, { type: "status", status: { kind: "failed", message } });
|
|
173
236
|
});
|
|
174
237
|
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Drop a client-supplied `TELO_CACHE_DIR`.
|
|
241
|
+
*
|
|
242
|
+
* It OUTRANKS the `telo-workspace.yaml` marker the session seeds at its
|
|
243
|
+
* workspace root, so a client that sets it silently gives every app its own
|
|
244
|
+
* module cache — the exact thing the marker exists to prevent. Where a cache
|
|
245
|
+
* root is genuinely needed (the workspace container, whose manifest lives
|
|
246
|
+
* outside the workspace) the backend sets it itself.
|
|
247
|
+
*/
|
|
248
|
+
function withoutCacheOverride(env: Record<string, string>): Record<string, string> {
|
|
249
|
+
if (!("TELO_CACHE_DIR" in env)) return env;
|
|
250
|
+
const rest = { ...env };
|
|
251
|
+
delete rest.TELO_CACHE_DIR;
|
|
252
|
+
return rest;
|
|
253
|
+
}
|