@workerdeck/server 0.16.0 → 0.17.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 +10 -0
- package/build/index.d.mts +67 -7
- package/build/index.mjs +398 -14
- package/build/index.mjs.map +1 -1
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -208,6 +208,16 @@ OAuth, never reads or forwards tokens — see the repo README's
|
|
|
208
208
|
VFS only when *not* restoring, and dispose per-session resources via `onClose`. Every one is a
|
|
209
209
|
runtime-only failure. `createProviderRunner()` does all four; reach for the raw hook only when it
|
|
210
210
|
genuinely doesn't fit.
|
|
211
|
+
- **`parking.persistLive` is how a *provider* session survives a restart, and it needs a durable
|
|
212
|
+
store to mean anything.** Claude and codex go dormant — remembered by engine session id and
|
|
213
|
+
resumed from the engine's own store — which a provider session cannot do, so its record carries
|
|
214
|
+
the state itself, written through after every turn. With the default in-memory store the option
|
|
215
|
+
does nothing and says nothing. It is off by default: a library must not start writing sessions'
|
|
216
|
+
transcripts to disk because someone upgraded, and the record holds the whole transcript in
|
|
217
|
+
plaintext.
|
|
218
|
+
- **A restored session is refreshed in place, not consumed.** A park's record *is* the session and
|
|
219
|
+
is deleted on wake; a live or dormant one is a way back the session still needs next time. If you
|
|
220
|
+
implement a `SessionStore`, do not "tidy up" a record on read.
|
|
211
221
|
- **One origin is not a convenience.** A browser cannot put an `Authorization` header on a
|
|
212
222
|
WebSocket upgrade, so a cookie is the only credential a tab can present on an attach, and a
|
|
213
223
|
cookie is per-origin. That is what `fallback` is for — an app served from the gateway's own port.
|
package/build/index.d.mts
CHANGED
|
@@ -48,6 +48,11 @@ type SessionNotificationOptions = {
|
|
|
48
48
|
onNotification?: (notification: SessionNotification) => void; /** Delivery attempts per notification (exponential backoff). Default 3. */
|
|
49
49
|
attempts?: number; /** Initial backoff between attempts. Default 500ms. */
|
|
50
50
|
retryDelayMs?: number;
|
|
51
|
+
/** Gateway wiring, not a host option: the serve-time `SessionInfo` decoration
|
|
52
|
+
* (project identity today), so a webhook or push consumer reads the same
|
|
53
|
+
* record every REST caller does. The assembly supplies it; identity when
|
|
54
|
+
* absent. */
|
|
55
|
+
decorateInfo?: (info: SessionInfo) => SessionInfo;
|
|
51
56
|
};
|
|
52
57
|
/**
|
|
53
58
|
* Turns session events into the handful of notifications a human away from the
|
|
@@ -111,20 +116,41 @@ declare class SessionRegistry {
|
|
|
111
116
|
//#endregion
|
|
112
117
|
//#region src/services/session-store.d.ts
|
|
113
118
|
/**
|
|
114
|
-
* A session
|
|
119
|
+
* A session captured whole: its wire-visible info, the config to rebuild the
|
|
120
|
+
* runner, the engine's snapshot, and what it is waiting for.
|
|
115
121
|
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
122
|
+
* Two things are stored in this one shape, and the discriminator is `kind`:
|
|
123
|
+
*
|
|
124
|
+
* - **`parked`** — the live runner is torn down and the session is waiting on
|
|
125
|
+
* deferred executions. The record *is* the session, so waking consumes it.
|
|
126
|
+
* - **`live`** — the runner is up and this is a copy taken after a turn, so the
|
|
127
|
+
* session survives a restart (`persistLive`). It is a way back that the
|
|
128
|
+
* session still needs the *next* time the process dies, so waking **refreshes
|
|
129
|
+
* it in place** and only `session_closed` removes it.
|
|
130
|
+
*
|
|
131
|
+
* That difference is the whole reason these are two kinds rather than one, and
|
|
132
|
+
* it is a correctness difference, not bookkeeping: consuming a live record on
|
|
133
|
+
* wake opens a window from the attach to the next turn in which the session
|
|
134
|
+
* exists nowhere durable. A user who opens a session, reads it and types nothing
|
|
135
|
+
* would lose it to a redeploy — silently, which is the failure class worth
|
|
136
|
+
* spending a discriminator on.
|
|
137
|
+
*
|
|
138
|
+
* They share the shape so a store, and an older server, need no new code: every
|
|
139
|
+
* other branch (rebuild from `config` + `snapshot`, serve `snapshot.vfs`, arm
|
|
140
|
+
* `executions`, subscribe past `snapshot.seq`) is already right for both.
|
|
119
141
|
*/
|
|
120
142
|
type ParkedSessionRecord = {
|
|
121
|
-
/** Absent on records written before dormant sessions existed — those are all parked. */kind?: 'parked';
|
|
122
|
-
id: string;
|
|
143
|
+
/** Absent on records written before dormant sessions existed — those are all parked. */kind?: 'parked' | 'live';
|
|
144
|
+
id: string;
|
|
145
|
+
/** Session info as of the write: `parked` for a park, `idle` for a live copy —
|
|
146
|
+
* never `running`, which would come back as a spinner over no process. */
|
|
123
147
|
info: SessionInfo;
|
|
124
148
|
profile?: string; /** The config the session was created with (profile defaults already applied). */
|
|
125
149
|
config: SessionRunnerConfig;
|
|
126
150
|
snapshot: RunnerSnapshot;
|
|
127
|
-
|
|
151
|
+
/** Empty for a live record: an idle session is waiting on nothing, so there is
|
|
152
|
+
* nothing for `hydrate` to arm a watchdog for. */
|
|
153
|
+
executions: ParkedExecution[]; /** When it was written. Named for the park that came first. */
|
|
128
154
|
parkedAt: number;
|
|
129
155
|
};
|
|
130
156
|
/**
|
|
@@ -246,6 +272,24 @@ type SessionParkOptions = {
|
|
|
246
272
|
/** Wait this long after the last client detaches before parking, so a reconnect
|
|
247
273
|
* (a wifi blip, a page reload) doesn't cost a teardown. Default 2000. */
|
|
248
274
|
parkDelayMs?: number;
|
|
275
|
+
/**
|
|
276
|
+
* Keep a live session's snapshot written through to the store, so it survives
|
|
277
|
+
* a gateway restart. **Off by default**: a library must not start writing a
|
|
278
|
+
* session's whole transcript to disk because someone upgraded.
|
|
279
|
+
*
|
|
280
|
+
* This is the restart story for the engine that cannot go dormant. Dormancy
|
|
281
|
+
* works by remembering an *engine* session id to resume from, which the
|
|
282
|
+
* provider engine does not have — there is no store behind it, the history
|
|
283
|
+
* lives in the runner. What it has instead is `snapshot()`, so the record
|
|
284
|
+
* carries the state itself and rehydration is the ordinary `restore` path.
|
|
285
|
+
* Between the two, every engine survives a restart.
|
|
286
|
+
*
|
|
287
|
+
* Written at the end of a turn, never on a shutdown hook — a `kill -9`, an OOM
|
|
288
|
+
* or a pulled power cable run no hook, and that is precisely the case this
|
|
289
|
+
* exists for. Turn-end is also a natural rate limit: one write per turn, not
|
|
290
|
+
* one per token.
|
|
291
|
+
*/
|
|
292
|
+
persistLive?: boolean;
|
|
249
293
|
/** Grace given at {@link SessionParkManager.hydrate} to an execution whose
|
|
250
294
|
* deadline passed while the server was down. Its result could not have been
|
|
251
295
|
* delivered during the outage, so failing it the instant the process is back
|
|
@@ -676,6 +720,22 @@ type WorkerServerOptions = {
|
|
|
676
720
|
/** Grace given on boot to an execution whose deadline passed while the server
|
|
677
721
|
* was down (durable stores only — nothing else survives a restart). Default 60000. */
|
|
678
722
|
expiredGraceMs?: number;
|
|
723
|
+
/**
|
|
724
|
+
* Keep live `provider` sessions written through to the store after each
|
|
725
|
+
* turn, so they survive a gateway restart. **Off by default** — this writes
|
|
726
|
+
* a session's whole transcript to `store` and a library must not start doing
|
|
727
|
+
* that because someone upgraded.
|
|
728
|
+
*
|
|
729
|
+
* It is the restart story for the one engine dormancy cannot cover: claude
|
|
730
|
+
* and codex are remembered by *engine session id* and resumed from their own
|
|
731
|
+
* on-disk store, which a provider session does not have. Pair it with a
|
|
732
|
+
* durable `store` — with the default in-memory one it does nothing a park
|
|
733
|
+
* did not already do.
|
|
734
|
+
*
|
|
735
|
+
* The record is rebuilt lazily, on first attach, exactly like a dormant one;
|
|
736
|
+
* a boot with fifty remembered sessions spawns nothing.
|
|
737
|
+
*/
|
|
738
|
+
persistLive?: boolean;
|
|
679
739
|
/** Park/remember/resume failures — storage or engine-assembly problems, not
|
|
680
740
|
* session errors. 'remember' is the write that lets a live session survive a
|
|
681
741
|
* restart; losing one costs that session its way back and nothing else. */
|
package/build/index.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import { createServer } from "node:http";
|
|
|
2
2
|
import { WebSocketServer } from "ws";
|
|
3
3
|
import { BrowserBridgeExecutor, SessionRunner, attachmentKind, checkClaudeAuth, createEngineSession, getEngineAdapter, normalizeMediaType } from "@workerdeck/core";
|
|
4
4
|
import { JobQueue } from "@workerdeck/queue";
|
|
5
|
-
import { ENGINE_CAPABILITIES, PROTOCOL_VERSION, supportsPermissionMode } from "@workerdeck/protocol";
|
|
5
|
+
import { ENGINE_CAPABILITIES, PROTOCOL_VERSION, imagePartRef, supportsPermissionMode } from "@workerdeck/protocol";
|
|
6
6
|
import { createHash, randomUUID } from "node:crypto";
|
|
7
7
|
import { closeSync, constants, createReadStream, existsSync, fstatSync, ftruncateSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
8
8
|
import { homedir } from "node:os";
|
|
@@ -214,6 +214,18 @@ function parseSessionRoute(basePath, url) {
|
|
|
214
214
|
produced: true,
|
|
215
215
|
producedFileId: parts[2] === void 0 ? void 0 : decodeURIComponent(parts[2])
|
|
216
216
|
};
|
|
217
|
+
if (parts.length === 3 && parts[1] === "project" && parts[2] === "icon") return {
|
|
218
|
+
id: decodeURIComponent(parts[0]),
|
|
219
|
+
projectIcon: true
|
|
220
|
+
};
|
|
221
|
+
if (parts.length === 4 && parts[1] === "events" && parts[3] === "result") {
|
|
222
|
+
const seq = Number(parts[2]);
|
|
223
|
+
if (!Number.isInteger(seq) || seq < 0) return null;
|
|
224
|
+
return {
|
|
225
|
+
id: decodeURIComponent(parts[0]),
|
|
226
|
+
resultSeq: seq
|
|
227
|
+
};
|
|
228
|
+
}
|
|
217
229
|
if (parts.length <= 3 && parts[1] === "mcp") return {
|
|
218
230
|
id: decodeURIComponent(parts[0]),
|
|
219
231
|
mcp: true,
|
|
@@ -335,7 +347,9 @@ function invalidRequest(requested) {
|
|
|
335
347
|
/** Both sides are realpath output, so this is a pure lexical question — but a
|
|
336
348
|
* bare prefix check gets the boundary wrong (`/x/app` would swallow
|
|
337
349
|
* `/x/application`). `relative` answers it exactly: inside iff the walk from
|
|
338
|
-
* root to candidate is empty or never has to leave through `..`.
|
|
350
|
+
* root to candidate is empty or never has to leave through `..`. Exported for
|
|
351
|
+
* the project-icon resolver (`project-info.ts`), which makes the same claim
|
|
352
|
+
* against a project root; both callers must hand it realpath output only. */
|
|
339
353
|
function contained(rootCanonical, candidate) {
|
|
340
354
|
const rel = relative(rootCanonical, candidate);
|
|
341
355
|
return rel === "" || rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
|
|
@@ -1116,6 +1130,8 @@ function withinRoots(sessions, roots, limit, offset = 0) {
|
|
|
1116
1130
|
}
|
|
1117
1131
|
//#endregion
|
|
1118
1132
|
//#region src/services/session-store.ts
|
|
1133
|
+
/** A live record is refreshed in place on wake; a park is consumed by it. */
|
|
1134
|
+
const isLiveRecord = (record) => record.kind === "live";
|
|
1119
1135
|
const isDormant = (record) => record.kind === "dormant";
|
|
1120
1136
|
/** Single-process, no persistence: parks survive a client disconnect, not a restart. */
|
|
1121
1137
|
var MemorySessionStore = class {
|
|
@@ -1462,12 +1478,295 @@ async function handleProducedFiles(ctx, req, res, sessionId, fileId) {
|
|
|
1462
1478
|
});
|
|
1463
1479
|
}
|
|
1464
1480
|
//#endregion
|
|
1481
|
+
//#region src/services/project-info.ts
|
|
1482
|
+
/**
|
|
1483
|
+
* Project identity discovery: the `.workerdeck.json` ancestor walk behind
|
|
1484
|
+
* `SessionInfo.project`, and the read side of `GET /sessions/:id/project/icon`.
|
|
1485
|
+
*
|
|
1486
|
+
* The gateway resolves this — not each client — because the file lives on the
|
|
1487
|
+
* gateway's filesystem, which a phone or a remote browser cannot see. It is
|
|
1488
|
+
* stamped onto `SessionInfo` at **serve time** (`withProject`), never persisted:
|
|
1489
|
+
* a copy captured into a parking record would replay a stale name forever,
|
|
1490
|
+
* where a serve-time read picks up an edited file on every session at once
|
|
1491
|
+
* within the TTL. That is the profile tracker's 0%-after-reset placement
|
|
1492
|
+
* argument, applied to a filesystem fact instead of a clock.
|
|
1493
|
+
*
|
|
1494
|
+
* Every failure degrades to "no project" and never to an error: a session must
|
|
1495
|
+
* not fail, or even warn, because of a display declaration. A malformed,
|
|
1496
|
+
* oversized, or symlinked `.workerdeck.json` is *skipped and the walk
|
|
1497
|
+
* continues* — a broken file in `packages/ui` must not shadow the repo root's
|
|
1498
|
+
* valid one — and inside a valid file each field degrades on its own (junk
|
|
1499
|
+
* name → the root's basename, junk icon → no icon).
|
|
1500
|
+
*
|
|
1501
|
+
* The icon is the security surface, because its path comes out of a config
|
|
1502
|
+
* file the *agent* can write (the session cwd is the agent's working tree).
|
|
1503
|
+
* The rules are `host-files.ts`'s, not `cwdAllowed`'s (docs/GOTCHAS.md §Host
|
|
1504
|
+
* filesystem): the declared path is resolved against the project root and then
|
|
1505
|
+
* realpath'd **whole**, containment is decided on the canonical result only
|
|
1506
|
+
* (so `"icon": "../../../../etc/key.png"` and a planted `icon.png → ~/.ssh/…`
|
|
1507
|
+
* symlink both fail the same check), the open goes through `readContained`
|
|
1508
|
+
* (O_NOFOLLOW, fstat-before-io), and the media type comes from the *declared*
|
|
1509
|
+
* extension — png and svg only, by decision. A refused icon is
|
|
1510
|
+
* indistinguishable on the wire and on the route from a never-declared one:
|
|
1511
|
+
* `icon` absent, the route 404s. The one disclosure this feature accepts is
|
|
1512
|
+
* inherent to it: the walk reads ancestors of a vetted cwd, so a project file
|
|
1513
|
+
* an operator placed *above* their roots (`~/.workerdeck.json`) applies to
|
|
1514
|
+
* everything under it — nearest-wins from the cwd, exactly git's own
|
|
1515
|
+
* discovery, and the file is a display declaration by definition.
|
|
1516
|
+
*
|
|
1517
|
+
* Cache: per exact cwd string, TTL'd, with negative results cached at the same
|
|
1518
|
+
* price — `GET /sessions` polls at 1.2s while anything is working and the hit
|
|
1519
|
+
* path must be a Map lookup, never a walk. Keyed by cwd rather than by root
|
|
1520
|
+
* because the root is not known until the walk has run. Bounded by sweeping
|
|
1521
|
+
* expired entries once the map outgrows any plausible live session count.
|
|
1522
|
+
*/
|
|
1523
|
+
const PROJECT_FILE = ".workerdeck.json";
|
|
1524
|
+
/** A config file, not a document — anything bigger is skipped as malformed. */
|
|
1525
|
+
const MAX_PROJECT_FILE_BYTES = 64 * 1024;
|
|
1526
|
+
/** Display name clip — a list row's width, not a document's. */
|
|
1527
|
+
const MAX_NAME_CHARS = 80;
|
|
1528
|
+
/** lucide's naming: lowercase kebab-case. Shape-only — the gateway has no icon
|
|
1529
|
+
* catalog and must not grow one; an unknown-but-well-formed name ships and the
|
|
1530
|
+
* client falls back (a stale row, never withheld state). */
|
|
1531
|
+
const GLYPH_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
1532
|
+
const DEFAULT_TTL_MS = 3e4;
|
|
1533
|
+
/** Sweep threshold: above this many cached cwds, expired entries are evicted
|
|
1534
|
+
* on the next resolve so dead sessions' keys do not accumulate forever. */
|
|
1535
|
+
const SWEEP_ABOVE = 256;
|
|
1536
|
+
var ProjectInfoService = class {
|
|
1537
|
+
#ttlMs;
|
|
1538
|
+
#byCwd = /* @__PURE__ */ new Map();
|
|
1539
|
+
constructor(options = {}) {
|
|
1540
|
+
this.#ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
|
|
1541
|
+
}
|
|
1542
|
+
/**
|
|
1543
|
+
* The serve-time decoration: `info` with `project` stamped on, or the same
|
|
1544
|
+
* object untouched when there is nothing to add — the common case on a list
|
|
1545
|
+
* poll, so no allocation for it (replaySlice's same-object rule).
|
|
1546
|
+
*/
|
|
1547
|
+
withProject(info) {
|
|
1548
|
+
if (!info.cwd) return info;
|
|
1549
|
+
const project = this.#resolve(info.cwd).project;
|
|
1550
|
+
return project ? {
|
|
1551
|
+
...info,
|
|
1552
|
+
project
|
|
1553
|
+
} : info;
|
|
1554
|
+
}
|
|
1555
|
+
/** The icon route's read side: the canonical, contained icon file for this
|
|
1556
|
+
* session's cwd — resolved from the gateway's own cache, never from anything
|
|
1557
|
+
* the client named. Undefined = no project, no icon, or an icon refused. */
|
|
1558
|
+
iconFor(cwd) {
|
|
1559
|
+
if (!cwd) return void 0;
|
|
1560
|
+
return this.#resolve(cwd).icon;
|
|
1561
|
+
}
|
|
1562
|
+
#resolve(cwd) {
|
|
1563
|
+
const now = Date.now();
|
|
1564
|
+
const held = this.#byCwd.get(cwd);
|
|
1565
|
+
if (held && held.expiresAt > now) return held;
|
|
1566
|
+
if (this.#byCwd.size > SWEEP_ABOVE) {
|
|
1567
|
+
for (const [key, entry] of this.#byCwd) if (entry.expiresAt <= now) this.#byCwd.delete(key);
|
|
1568
|
+
}
|
|
1569
|
+
const fresh = {
|
|
1570
|
+
...discover(cwd),
|
|
1571
|
+
expiresAt: now + this.#ttlMs
|
|
1572
|
+
};
|
|
1573
|
+
this.#byCwd.set(cwd, fresh);
|
|
1574
|
+
return fresh;
|
|
1575
|
+
}
|
|
1576
|
+
};
|
|
1577
|
+
/** The ancestor walk: realpath the cwd (a lexical walk over `/tmp/x` would
|
|
1578
|
+
* miss the file at `/private/tmp/x`, and canonicalizing here is what makes
|
|
1579
|
+
* `root` — the grouping key — spell identically for every cwd inside one
|
|
1580
|
+
* project), then nearest `.workerdeck.json` wins, to the filesystem root. */
|
|
1581
|
+
function discover(cwd) {
|
|
1582
|
+
if (!isAbsolute(cwd) || cwd.includes("\0")) return {};
|
|
1583
|
+
let dir;
|
|
1584
|
+
try {
|
|
1585
|
+
dir = realpathSync(cwd);
|
|
1586
|
+
} catch {
|
|
1587
|
+
return {};
|
|
1588
|
+
}
|
|
1589
|
+
for (;;) {
|
|
1590
|
+
const found = tryLoad(join(dir, PROJECT_FILE), dir);
|
|
1591
|
+
if (found) return found;
|
|
1592
|
+
const parent = dirname(dir);
|
|
1593
|
+
if (parent === dir) return {};
|
|
1594
|
+
dir = parent;
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
/** One directory's verdict: a project record, or undefined to keep walking —
|
|
1598
|
+
* which is the same answer for "absent" and for every malformed shape. */
|
|
1599
|
+
function tryLoad(file, root) {
|
|
1600
|
+
let stat;
|
|
1601
|
+
try {
|
|
1602
|
+
stat = lstatSync(file);
|
|
1603
|
+
} catch {
|
|
1604
|
+
return;
|
|
1605
|
+
}
|
|
1606
|
+
if (!stat.isFile() || stat.size > MAX_PROJECT_FILE_BYTES) return void 0;
|
|
1607
|
+
const read = readContained(file);
|
|
1608
|
+
if (!read.ok) return void 0;
|
|
1609
|
+
let parsed;
|
|
1610
|
+
try {
|
|
1611
|
+
parsed = JSON.parse(read.data.toString("utf8"));
|
|
1612
|
+
} catch {
|
|
1613
|
+
return;
|
|
1614
|
+
}
|
|
1615
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
|
|
1616
|
+
const raw = parsed;
|
|
1617
|
+
const name = typeof raw.name === "string" && raw.name.trim() ? raw.name.trim().slice(0, MAX_NAME_CHARS) : basename(root);
|
|
1618
|
+
const icon = classifyIcon(raw.icon, root);
|
|
1619
|
+
return {
|
|
1620
|
+
project: {
|
|
1621
|
+
name,
|
|
1622
|
+
root,
|
|
1623
|
+
...icon ? { icon: icon.wire } : {}
|
|
1624
|
+
},
|
|
1625
|
+
...icon?.resolved ? { icon: icon.resolved } : {}
|
|
1626
|
+
};
|
|
1627
|
+
}
|
|
1628
|
+
/**
|
|
1629
|
+
* The one-string icon rule (documented on protocol's `ProjectInfo`): ends in
|
|
1630
|
+
* `.png`/`.svg` → repo-relative image path, else lucide-shaped glyph name,
|
|
1631
|
+
* else ignored. Total and collision-free — a glyph name contains no dot.
|
|
1632
|
+
*/
|
|
1633
|
+
function classifyIcon(value, root) {
|
|
1634
|
+
if (typeof value !== "string") return void 0;
|
|
1635
|
+
const declared = value.trim();
|
|
1636
|
+
if (!declared || declared.includes("\0") || declared.length > 512) return void 0;
|
|
1637
|
+
const lower = declared.toLowerCase();
|
|
1638
|
+
const mediaType = lower.endsWith(".png") ? "image/png" : lower.endsWith(".svg") ? "image/svg+xml" : void 0;
|
|
1639
|
+
if (!mediaType) {
|
|
1640
|
+
if (!GLYPH_RE.test(declared) || declared.length > 64) return void 0;
|
|
1641
|
+
return { wire: {
|
|
1642
|
+
type: "glyph",
|
|
1643
|
+
name: declared
|
|
1644
|
+
} };
|
|
1645
|
+
}
|
|
1646
|
+
if (isAbsolute(declared) || declared.includes("\\")) return void 0;
|
|
1647
|
+
let canonical;
|
|
1648
|
+
try {
|
|
1649
|
+
canonical = realpathSync(resolve(root, declared));
|
|
1650
|
+
} catch {
|
|
1651
|
+
return;
|
|
1652
|
+
}
|
|
1653
|
+
if (!contained(root, canonical)) return void 0;
|
|
1654
|
+
let stat;
|
|
1655
|
+
try {
|
|
1656
|
+
stat = lstatSync(canonical);
|
|
1657
|
+
} catch {
|
|
1658
|
+
return;
|
|
1659
|
+
}
|
|
1660
|
+
if (!stat.isFile() || stat.size === 0 || stat.size > 524288) return void 0;
|
|
1661
|
+
const read = readContained(canonical);
|
|
1662
|
+
if (!read.ok || read.data.length > 524288) return void 0;
|
|
1663
|
+
const hash = createHash("sha256").update(read.data).digest("hex");
|
|
1664
|
+
return {
|
|
1665
|
+
wire: {
|
|
1666
|
+
type: "image",
|
|
1667
|
+
mediaType,
|
|
1668
|
+
hash
|
|
1669
|
+
},
|
|
1670
|
+
resolved: {
|
|
1671
|
+
path: canonical,
|
|
1672
|
+
mediaType,
|
|
1673
|
+
hash
|
|
1674
|
+
}
|
|
1675
|
+
};
|
|
1676
|
+
}
|
|
1677
|
+
//#endregion
|
|
1678
|
+
//#region src/routes/project-icon.ts
|
|
1679
|
+
function handleProjectIcon(projects, req, res, cwd) {
|
|
1680
|
+
if (req.method !== "GET") {
|
|
1681
|
+
json(res, 405, { error: "method not allowed" });
|
|
1682
|
+
return;
|
|
1683
|
+
}
|
|
1684
|
+
const icon = projects.iconFor(cwd);
|
|
1685
|
+
if (!icon) {
|
|
1686
|
+
json(res, 404, { error: "no project icon" });
|
|
1687
|
+
return;
|
|
1688
|
+
}
|
|
1689
|
+
const etag = `"${icon.hash}"`;
|
|
1690
|
+
if (req.headers["if-none-match"] === etag) {
|
|
1691
|
+
res.writeHead(304, { etag });
|
|
1692
|
+
res.end();
|
|
1693
|
+
return;
|
|
1694
|
+
}
|
|
1695
|
+
const read = readContained(icon.path);
|
|
1696
|
+
if (!read.ok || read.data.length > 524288) {
|
|
1697
|
+
json(res, 404, { error: "no project icon" });
|
|
1698
|
+
return;
|
|
1699
|
+
}
|
|
1700
|
+
res.writeHead(200, {
|
|
1701
|
+
"content-type": icon.mediaType,
|
|
1702
|
+
"content-length": read.data.length,
|
|
1703
|
+
etag,
|
|
1704
|
+
"cache-control": "private, max-age=300",
|
|
1705
|
+
"content-disposition": "attachment; filename=\"project-icon\"",
|
|
1706
|
+
"x-content-type-options": "nosniff"
|
|
1707
|
+
});
|
|
1708
|
+
res.end(read.data);
|
|
1709
|
+
}
|
|
1710
|
+
//#endregion
|
|
1711
|
+
//#region src/routes/tool-results.ts
|
|
1712
|
+
function handleToolResult(req, res, lookup, seq) {
|
|
1713
|
+
if (req.method !== "GET") {
|
|
1714
|
+
json(res, 405, { error: "method not allowed" });
|
|
1715
|
+
return;
|
|
1716
|
+
}
|
|
1717
|
+
const url = new URL(req.url ?? "/", "http://internal");
|
|
1718
|
+
const toolUseId = url.searchParams.get("toolUseId");
|
|
1719
|
+
if (!toolUseId) {
|
|
1720
|
+
json(res, 400, { error: "toolUseId is required" });
|
|
1721
|
+
return;
|
|
1722
|
+
}
|
|
1723
|
+
if (!lookup) {
|
|
1724
|
+
json(res, 501, { error: "engine does not serve stored events" });
|
|
1725
|
+
return;
|
|
1726
|
+
}
|
|
1727
|
+
const event = lookup(seq);
|
|
1728
|
+
if (!event || event.type !== "user_message" || !Array.isArray(event.message.content)) {
|
|
1729
|
+
json(res, 404, { error: "no such event" });
|
|
1730
|
+
return;
|
|
1731
|
+
}
|
|
1732
|
+
const block = event.message.content.find((candidate) => candidate.type === "tool_result" && candidate.tool_use_id === toolUseId);
|
|
1733
|
+
if (!block) {
|
|
1734
|
+
json(res, 404, { error: "no such tool result in that event" });
|
|
1735
|
+
return;
|
|
1736
|
+
}
|
|
1737
|
+
const partParam = url.searchParams.get("part");
|
|
1738
|
+
if (partParam !== null) {
|
|
1739
|
+
const index = Number(partParam);
|
|
1740
|
+
const parts = block.content;
|
|
1741
|
+
const part = Number.isInteger(index) && Array.isArray(parts) ? parts[index] : void 0;
|
|
1742
|
+
const ref = part ? imagePartRef(part, index) : void 0;
|
|
1743
|
+
if (!ref) {
|
|
1744
|
+
json(res, 404, { error: "no such image part in that tool result" });
|
|
1745
|
+
return;
|
|
1746
|
+
}
|
|
1747
|
+
const source = part.source;
|
|
1748
|
+
const bytes = Buffer.from(source?.data ?? "", "base64");
|
|
1749
|
+
res.writeHead(200, {
|
|
1750
|
+
"content-type": ref.media_type,
|
|
1751
|
+
"content-length": String(bytes.length)
|
|
1752
|
+
});
|
|
1753
|
+
res.end(bytes);
|
|
1754
|
+
return;
|
|
1755
|
+
}
|
|
1756
|
+
json(res, 200, {
|
|
1757
|
+
seq,
|
|
1758
|
+
toolUseId,
|
|
1759
|
+
content: url.searchParams.get("imageRefs") === "1" && Array.isArray(block.content) ? block.content.map((part, index) => imagePartRef(part, index) ?? part) : block.content ?? "",
|
|
1760
|
+
isError: block.is_error === true
|
|
1761
|
+
});
|
|
1762
|
+
}
|
|
1763
|
+
//#endregion
|
|
1465
1764
|
//#region src/routes/sessions.ts
|
|
1466
1765
|
async function handleSessions(ctx, req, res, route, auth) {
|
|
1467
|
-
const { attachmentStore, auth: authSvc, availability, bridge, factory, parking, producedFiles, registry } = ctx;
|
|
1766
|
+
const { attachmentStore, auth: authSvc, availability, bridge, factory, parking, producedFiles, projects, registry } = ctx;
|
|
1468
1767
|
if (!route.id) {
|
|
1469
1768
|
if (req.method === "GET") {
|
|
1470
|
-
json(res, 200, { sessions: [...registry.list(), ...await parking.listInfo()].filter((session) => authSvc.canSee(auth, session)) });
|
|
1769
|
+
json(res, 200, { sessions: [...registry.list(), ...await parking.listInfo()].filter((session) => authSvc.canSee(auth, session)).map((session) => projects.withProject(session)) });
|
|
1471
1770
|
return;
|
|
1472
1771
|
}
|
|
1473
1772
|
if (req.method === "POST") {
|
|
@@ -1506,7 +1805,7 @@ async function handleSessions(ctx, req, res, route, auth) {
|
|
|
1506
1805
|
body.profile = resolved.profile?.name;
|
|
1507
1806
|
const runner = await factory.createRunner(factory.buildRunnerConfig(body));
|
|
1508
1807
|
factory.watchAuthSource(runner);
|
|
1509
|
-
json(res, 201, { session: runner.info() });
|
|
1808
|
+
json(res, 201, { session: projects.withProject(runner.info()) });
|
|
1510
1809
|
return;
|
|
1511
1810
|
}
|
|
1512
1811
|
json(res, 405, { error: "method not allowed" });
|
|
@@ -1574,6 +1873,15 @@ async function handleSessions(ctx, req, res, route, auth) {
|
|
|
1574
1873
|
await handleProducedFiles(ctx, req, res, route.id, route.producedFileId);
|
|
1575
1874
|
return;
|
|
1576
1875
|
}
|
|
1876
|
+
if (route.projectIcon) {
|
|
1877
|
+
handleProjectIcon(projects, req, res, (runner?.info() ?? parked.info).cwd);
|
|
1878
|
+
return;
|
|
1879
|
+
}
|
|
1880
|
+
if (route.resultSeq !== void 0) {
|
|
1881
|
+
const snapshot = parked && !isDormant(parked) ? parked.snapshot.events : void 0;
|
|
1882
|
+
handleToolResult(req, res, runner?.eventAt?.bind(runner) ?? (snapshot && ((seq) => snapshot.find((event) => event.seq === seq))), route.resultSeq);
|
|
1883
|
+
return;
|
|
1884
|
+
}
|
|
1577
1885
|
if (route.permissionId) {
|
|
1578
1886
|
if (req.method !== "POST") {
|
|
1579
1887
|
json(res, 405, { error: "method not allowed" });
|
|
@@ -1596,7 +1904,7 @@ async function handleSessions(ctx, req, res, route, auth) {
|
|
|
1596
1904
|
return;
|
|
1597
1905
|
}
|
|
1598
1906
|
if (req.method === "GET") {
|
|
1599
|
-
json(res, 200, { session: runner?.info() ?? parked.info });
|
|
1907
|
+
json(res, 200, { session: projects.withProject(runner?.info() ?? parked.info) });
|
|
1600
1908
|
return;
|
|
1601
1909
|
}
|
|
1602
1910
|
if (req.method === "PATCH") {
|
|
@@ -1614,7 +1922,7 @@ async function handleSessions(ctx, req, res, route, auth) {
|
|
|
1614
1922
|
runner.setTitle(title || void 0);
|
|
1615
1923
|
parking.touch(runner);
|
|
1616
1924
|
}
|
|
1617
|
-
json(res, 200, { session: runner.info() });
|
|
1925
|
+
json(res, 200, { session: projects.withProject(runner.info()) });
|
|
1618
1926
|
return;
|
|
1619
1927
|
}
|
|
1620
1928
|
if (req.method === "DELETE") {
|
|
@@ -1623,10 +1931,10 @@ async function handleSessions(ctx, req, res, route, auth) {
|
|
|
1623
1931
|
await parking.discard(route.id);
|
|
1624
1932
|
attachmentStore.drop(route.id);
|
|
1625
1933
|
producedFiles.drop(route.id);
|
|
1626
|
-
json(res, 200, { session: runner?.info() ?? {
|
|
1934
|
+
json(res, 200, { session: projects.withProject(runner?.info() ?? {
|
|
1627
1935
|
...parked.info,
|
|
1628
1936
|
status: "closed"
|
|
1629
|
-
} });
|
|
1937
|
+
}) });
|
|
1630
1938
|
return;
|
|
1631
1939
|
}
|
|
1632
1940
|
json(res, 405, { error: "method not allowed" });
|
|
@@ -1637,19 +1945,25 @@ function attachClient(ctx, ws, runner, req) {
|
|
|
1637
1945
|
const { bridge, parking } = ctx;
|
|
1638
1946
|
const url = new URL(req.url ?? "/", "http://internal");
|
|
1639
1947
|
const afterSeq = Number(url.searchParams.get("afterSeq") ?? "0") || 0;
|
|
1948
|
+
const truncateResults = url.searchParams.get("truncateResults") === "1";
|
|
1949
|
+
const imageRefs = url.searchParams.get("imageRefs") === "1";
|
|
1640
1950
|
const send = (frame) => {
|
|
1641
1951
|
if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(frame));
|
|
1642
1952
|
};
|
|
1643
1953
|
send({
|
|
1644
1954
|
type: "attached",
|
|
1645
1955
|
protocolVersion: PROTOCOL_VERSION,
|
|
1646
|
-
session: runner.info(),
|
|
1956
|
+
session: ctx.projects.withProject(runner.info()),
|
|
1647
1957
|
replayingFrom: afterSeq
|
|
1648
1958
|
});
|
|
1649
1959
|
const unsubscribe = runner.subscribe((event) => send({
|
|
1650
1960
|
type: "event",
|
|
1651
1961
|
event
|
|
1652
|
-
}), afterSeq, {
|
|
1962
|
+
}), afterSeq, {
|
|
1963
|
+
coalesceReplay: true,
|
|
1964
|
+
truncateResults,
|
|
1965
|
+
imageRefs
|
|
1966
|
+
});
|
|
1653
1967
|
const detachBridge = bridge.attach(runner.id, send);
|
|
1654
1968
|
ws.on("message", (data) => {
|
|
1655
1969
|
let frame;
|
|
@@ -2241,7 +2555,7 @@ var SessionNotifier = class {
|
|
|
2241
2555
|
const notification = {
|
|
2242
2556
|
...body,
|
|
2243
2557
|
sessionId: runner.id,
|
|
2244
|
-
session: runner.info(),
|
|
2558
|
+
session: (this.#options.decorateInfo ?? ((info) => info))(runner.info()),
|
|
2245
2559
|
seq,
|
|
2246
2560
|
ts
|
|
2247
2561
|
};
|
|
@@ -2353,6 +2667,7 @@ var SessionParkManager = class {
|
|
|
2353
2667
|
*/
|
|
2354
2668
|
touch(runner) {
|
|
2355
2669
|
this.#rememberDormant(runner);
|
|
2670
|
+
this.#persistLive(runner);
|
|
2356
2671
|
}
|
|
2357
2672
|
/**
|
|
2358
2673
|
* Adopt the store's contents (a durable store after a restart): re-index the
|
|
@@ -2396,6 +2711,13 @@ var SessionParkManager = class {
|
|
|
2396
2711
|
if (event.status === "parked") this.#park(runner);
|
|
2397
2712
|
else this.#rememberDormant(runner);
|
|
2398
2713
|
return;
|
|
2714
|
+
case "turn_result":
|
|
2715
|
+
this.#persistLive(runner);
|
|
2716
|
+
return;
|
|
2717
|
+
case "permission_mode_changed":
|
|
2718
|
+
case "model_changed":
|
|
2719
|
+
this.#persistLive(runner);
|
|
2720
|
+
return;
|
|
2399
2721
|
case "system_init":
|
|
2400
2722
|
this.#rememberDormant(runner);
|
|
2401
2723
|
return;
|
|
@@ -2534,6 +2856,62 @@ var SessionParkManager = class {
|
|
|
2534
2856
|
});
|
|
2535
2857
|
}
|
|
2536
2858
|
}
|
|
2859
|
+
/**
|
|
2860
|
+
* Write a live session's snapshot through to the store, so a restart can
|
|
2861
|
+
* rebuild it. The counterpart to {@link #rememberDormant} for the engine that
|
|
2862
|
+
* has no engine-side session to resume from — same discipline, different
|
|
2863
|
+
* mechanism: that one remembers *where the transcript is*, this one carries it.
|
|
2864
|
+
*
|
|
2865
|
+
* The gates are the same four, plus the option and the engine's ability. The
|
|
2866
|
+
* `registry.get(runner.id) !== runner` check is doing the same work it does
|
|
2867
|
+
* there: a runner that has been evicted (parked, or replaced by a rebuild)
|
|
2868
|
+
* finds itself a stranger here and writes nothing, so a late event cannot
|
|
2869
|
+
* overwrite a park with a stale live record.
|
|
2870
|
+
*
|
|
2871
|
+
* **This must not run synchronously inside the event listener**, and that is
|
|
2872
|
+
* easy to lose. `turn_result` is emitted from inside the turn, *before* the
|
|
2873
|
+
* `finally` that clears the runner's abort controller — so a `snapshot()`
|
|
2874
|
+
* called straight from the listener would see a turn in flight and refuse,
|
|
2875
|
+
* every single time, silently. `#queue`'s microtask hop is what puts the call
|
|
2876
|
+
* after it. A refactor that "simplifies" this into a direct call produces a
|
|
2877
|
+
* write-through that never writes and nothing that says so.
|
|
2878
|
+
*/
|
|
2879
|
+
async #persistLive(runner) {
|
|
2880
|
+
if (this.#closed || !this.#options.persistLive || !runner.snapshot) return;
|
|
2881
|
+
const config = this.#configs.get(runner.id);
|
|
2882
|
+
if (!config) return;
|
|
2883
|
+
if (this.#options.registry.get(runner.id) !== runner) return;
|
|
2884
|
+
try {
|
|
2885
|
+
await this.#queue(runner.id, async () => {
|
|
2886
|
+
if (this.#closed || this.#options.registry.get(runner.id) !== runner) return;
|
|
2887
|
+
const snapshot = runner.snapshot?.();
|
|
2888
|
+
if (!snapshot) return;
|
|
2889
|
+
const info = runner.info();
|
|
2890
|
+
const record = {
|
|
2891
|
+
kind: "live",
|
|
2892
|
+
id: runner.id,
|
|
2893
|
+
info: {
|
|
2894
|
+
...info,
|
|
2895
|
+
status: "idle"
|
|
2896
|
+
},
|
|
2897
|
+
profile: info.profile,
|
|
2898
|
+
config: {
|
|
2899
|
+
...config,
|
|
2900
|
+
meta: info.meta
|
|
2901
|
+
},
|
|
2902
|
+
snapshot,
|
|
2903
|
+
executions: snapshot.parked,
|
|
2904
|
+
parkedAt: Date.now()
|
|
2905
|
+
};
|
|
2906
|
+
await this.#options.store.save(record);
|
|
2907
|
+
});
|
|
2908
|
+
} catch (error) {
|
|
2909
|
+
this.#options.onError?.(error, {
|
|
2910
|
+
sessionId: runner.id,
|
|
2911
|
+
phase: "remember"
|
|
2912
|
+
});
|
|
2913
|
+
}
|
|
2914
|
+
}
|
|
2537
2915
|
async #park(runner) {
|
|
2538
2916
|
if (this.#closed || !runner.park) return;
|
|
2539
2917
|
const id = runner.id;
|
|
@@ -2609,7 +2987,7 @@ var SessionParkManager = class {
|
|
|
2609
2987
|
this.remember(id, record.config);
|
|
2610
2988
|
this.watch(runner, isDormant(record) ? 0 : record.snapshot.seq);
|
|
2611
2989
|
this.#options.onResumed?.(id, runner);
|
|
2612
|
-
if (!isDormant(record)) await this.#queue(id, () => this.#options.store.delete(id));
|
|
2990
|
+
if (!isDormant(record) && !isLiveRecord(record)) await this.#queue(id, () => this.#options.store.delete(id));
|
|
2613
2991
|
runner.start();
|
|
2614
2992
|
return runner;
|
|
2615
2993
|
}
|
|
@@ -3348,7 +3726,11 @@ function createWorkerServer(options = {}) {
|
|
|
3348
3726
|
adapterFor,
|
|
3349
3727
|
sessionEnvFor: factory.sessionEnvFor
|
|
3350
3728
|
});
|
|
3351
|
-
const
|
|
3729
|
+
const projects = new ProjectInfoService();
|
|
3730
|
+
const notifier = new SessionNotifier({
|
|
3731
|
+
...options.notifications,
|
|
3732
|
+
decorateInfo: (info) => projects.withProject(info)
|
|
3733
|
+
});
|
|
3352
3734
|
const producedFiles = new ProducedFileStore();
|
|
3353
3735
|
const registry = new SessionRegistry({ onRegister: (runner) => {
|
|
3354
3736
|
notifier.watch(runner);
|
|
@@ -3374,6 +3756,7 @@ function createWorkerServer(options = {}) {
|
|
|
3374
3756
|
store: options.parking?.store ?? new MemorySessionStore(),
|
|
3375
3757
|
parkDelayMs: options.parking?.parkDelayMs,
|
|
3376
3758
|
expiredGraceMs: options.parking?.expiredGraceMs,
|
|
3759
|
+
persistLive: options.parking?.persistLive,
|
|
3377
3760
|
onError: options.parking?.onError,
|
|
3378
3761
|
rebuild: (record) => isDormant(record) ? factory.buildRunner(factory.buildRunnerConfig({
|
|
3379
3762
|
...record.config,
|
|
@@ -3445,6 +3828,7 @@ function createWorkerServer(options = {}) {
|
|
|
3445
3828
|
registry,
|
|
3446
3829
|
parking,
|
|
3447
3830
|
bridge,
|
|
3831
|
+
projects,
|
|
3448
3832
|
queue,
|
|
3449
3833
|
attachmentStore,
|
|
3450
3834
|
producedFiles,
|