@fabrika/canvas-kernel 0.1.44
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/LICENSE +21 -0
- package/README.md +74 -0
- package/annotate.ts +94 -0
- package/daemon.ts +46 -0
- package/index.ts +84 -0
- package/package.json +74 -0
- package/src/client/AnnotationDraftFooter.tsx +76 -0
- package/src/client/AnnotationEditor.tsx +320 -0
- package/src/client/AnnotationList.tsx +190 -0
- package/src/client/AnnotationProvider.tsx +294 -0
- package/src/client/AnnotationSidebar.tsx +374 -0
- package/src/client/FileIcon.tsx +76 -0
- package/src/client/PlanRenderer.tsx +587 -0
- package/src/client/Popover.tsx +361 -0
- package/src/client/RenderErrorContext.ts +11 -0
- package/src/client/ResponsePreview.tsx +186 -0
- package/src/client/SessionContext.tsx +3 -0
- package/src/client/annotationContext.ts +130 -0
- package/src/client/annotationDom.ts +115 -0
- package/src/client/annotationDraft.ts +130 -0
- package/src/client/annotationMarkdown.ts +102 -0
- package/src/client/annotationTarget.ts +73 -0
- package/src/client/appContext.tsx +61 -0
- package/src/client/blockTarget.ts +127 -0
- package/src/client/components/Callout.tsx +27 -0
- package/src/client/components/Checklist.tsx +37 -0
- package/src/client/components/Choice.tsx +178 -0
- package/src/client/components/CodeBlock.tsx +35 -0
- package/src/client/components/Diff.tsx +36 -0
- package/src/client/components/FilePreview.tsx +88 -0
- package/src/client/components/Image.tsx +48 -0
- package/src/client/components/Markdown.tsx +44 -0
- package/src/client/components/Mermaid.tsx +576 -0
- package/src/client/components/Note.tsx +14 -0
- package/src/client/components/Priority.tsx +19 -0
- package/src/client/components/ResponseNote.tsx +19 -0
- package/src/client/components/Section.tsx +33 -0
- package/src/client/components/Table.tsx +35 -0
- package/src/client/components/Task.tsx +76 -0
- package/src/client/components/UserInput.tsx +176 -0
- package/src/client/components/index.ts +18 -0
- package/src/client/components/renderMarkdownChildren.tsx +100 -0
- package/src/client/diffLines.ts +50 -0
- package/src/client/generateMarkdown.ts +250 -0
- package/src/client/highlightRange.ts +262 -0
- package/src/client/hostContext.tsx +55 -0
- package/src/client/index.ts +128 -0
- package/src/client/popoverPosition.ts +46 -0
- package/src/client/preact-all.ts +4 -0
- package/src/client/regionTarget.ts +180 -0
- package/src/client/runtime.ts +149 -0
- package/src/client/styles.css +492 -0
- package/src/client/testing/dom.ts +58 -0
- package/src/client/textTarget.ts +31 -0
- package/src/client/theme.css +66 -0
- package/src/client/useRegionAnnotation.tsx +188 -0
- package/src/client/useTextAnnotation.tsx +194 -0
- package/src/client/utils.ts +33 -0
- package/src/engine/channel.ts +278 -0
- package/src/engine/compiler.ts +415 -0
- package/src/engine/http-utils.ts +14 -0
- package/src/engine/lifecycle.ts +302 -0
- package/src/engine/paths.ts +63 -0
- package/src/engine/router.ts +16 -0
- package/src/engine/session.ts +678 -0
- package/src/engine/waiter.ts +188 -0
- package/src/engine/watcher.ts +84 -0
- package/src/engine/websocket.ts +159 -0
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Daemon process lifecycle: is it up, start it, stop it, keep it in step with
|
|
3
|
+
* the CLI that talks to it.
|
|
4
|
+
*
|
|
5
|
+
* The kernel owns the algorithm — health probing, the concurrent-start race,
|
|
6
|
+
* which pid may be signalled — and the host owns every location: base URL, pid
|
|
7
|
+
* file, spawn argv, log file, health shape, timeouts.
|
|
8
|
+
*
|
|
9
|
+
* The daemon's own pid, reported through `/health`, is the only pid this module
|
|
10
|
+
* will signal. A pid file is written at spawn and read only to notice that
|
|
11
|
+
* *someone* is already booting a daemon; a stale file or a reused pid must
|
|
12
|
+
* never let the CLI kill an unrelated process.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync, appendFileSync } from "fs";
|
|
16
|
+
import { dirname } from "path";
|
|
17
|
+
|
|
18
|
+
/** Every host health shape must carry the daemon's own pid. */
|
|
19
|
+
export interface DaemonPid {
|
|
20
|
+
pid: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type DaemonHealthUp<H extends DaemonPid> = { status: "up"; ok: true; url: string } & H;
|
|
24
|
+
|
|
25
|
+
export interface DaemonHealthDown {
|
|
26
|
+
status: "down";
|
|
27
|
+
ok: false;
|
|
28
|
+
url: string;
|
|
29
|
+
error?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type DaemonHealth<H extends DaemonPid> = DaemonHealthUp<H> | DaemonHealthDown;
|
|
33
|
+
|
|
34
|
+
export type DaemonStopReason =
|
|
35
|
+
| "stopped"
|
|
36
|
+
| "not-running"
|
|
37
|
+
/** Nothing was running; a pid file left behind was cleaned up. */
|
|
38
|
+
| "stale-pid"
|
|
39
|
+
/** A daemon answered `/health`, but signalling its pid failed. */
|
|
40
|
+
| "signal-failed"
|
|
41
|
+
| "refused-self";
|
|
42
|
+
|
|
43
|
+
export interface DaemonStopResult {
|
|
44
|
+
stopped: boolean;
|
|
45
|
+
/** The daemon's own pid, or the stale pid-file value that was cleaned up. */
|
|
46
|
+
pid?: number;
|
|
47
|
+
reason: DaemonStopReason;
|
|
48
|
+
message: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Progress worth telling a user about; the host writes the words. */
|
|
52
|
+
export type DaemonEvent =
|
|
53
|
+
| { type: "version-mismatch"; running: string; expected: string }
|
|
54
|
+
| { type: "starting"; command: readonly string[] }
|
|
55
|
+
| { type: "started"; pid: number }
|
|
56
|
+
| { type: "stopped"; pid: number };
|
|
57
|
+
|
|
58
|
+
export interface DaemonTimeouts {
|
|
59
|
+
/** Per `/health` request. Default 1000. */
|
|
60
|
+
health?: number;
|
|
61
|
+
/** Wait for a daemon this process spawned. Default 3000. */
|
|
62
|
+
start?: number;
|
|
63
|
+
/** Wait for a daemon another process is already starting. Default 1000. */
|
|
64
|
+
concurrentStart?: number;
|
|
65
|
+
/** Wait for a signalled daemon to go down. Default 2000. */
|
|
66
|
+
stop?: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface DaemonLifecycleConfig<H extends DaemonPid> {
|
|
70
|
+
/** Daemon origin, e.g. `http://localhost:19400`. */
|
|
71
|
+
baseUrl: string;
|
|
72
|
+
/** Health endpoint path. Default `/health`. */
|
|
73
|
+
healthPath?: string;
|
|
74
|
+
/** Narrows the `/health` body; `undefined` means "not a daemon we can talk to". */
|
|
75
|
+
parseHealth(body: unknown): H | undefined;
|
|
76
|
+
/** Written at spawn, so a concurrent start can see a daemon that is still booting. */
|
|
77
|
+
pidFile: string;
|
|
78
|
+
/** argv of the daemon process, e.g. `[process.execPath, entry, "serve"]`. */
|
|
79
|
+
command: readonly string[];
|
|
80
|
+
/** Working directory of the spawned daemon. Defaults to the current one. */
|
|
81
|
+
cwd?: string;
|
|
82
|
+
/** File the daemon's stdout and stderr are appended to. */
|
|
83
|
+
logFile: string;
|
|
84
|
+
/** Restart the daemon when its version differs from this CLI's. Off when omitted. */
|
|
85
|
+
versionCheck?: {
|
|
86
|
+
expected: string;
|
|
87
|
+
of(health: H): string | undefined;
|
|
88
|
+
};
|
|
89
|
+
/** Removed with the pid file when the daemon stops (auth tokens, sockets, …). */
|
|
90
|
+
cleanupFiles?: readonly string[];
|
|
91
|
+
timeouts?: DaemonTimeouts;
|
|
92
|
+
onEvent?(event: DaemonEvent): void;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export class DaemonStartError extends Error {
|
|
96
|
+
constructor(message: string, readonly logFile: string, readonly logTail: string) {
|
|
97
|
+
super(message);
|
|
98
|
+
this.name = "DaemonStartError";
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface DaemonLifecycle<H extends DaemonPid> {
|
|
103
|
+
health(timeoutMs?: number): Promise<DaemonHealth<H>>;
|
|
104
|
+
isRunning(): Promise<boolean>;
|
|
105
|
+
start(): Promise<DaemonHealthUp<H>>;
|
|
106
|
+
/** A healthy daemon of the expected version, starting or restarting one if needed. */
|
|
107
|
+
ensure(): Promise<DaemonHealthUp<H>>;
|
|
108
|
+
stop(): Promise<DaemonStopResult>;
|
|
109
|
+
restart(): Promise<DaemonHealthUp<H>>;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const LOG_TAIL_BYTES = 4096;
|
|
113
|
+
|
|
114
|
+
function sleep(ms: number): Promise<void> {
|
|
115
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function errorMessage(error: unknown): string {
|
|
119
|
+
return error instanceof Error ? error.message : String(error);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function createDaemonLifecycle<H extends DaemonPid>(
|
|
123
|
+
config: DaemonLifecycleConfig<H>,
|
|
124
|
+
): DaemonLifecycle<H> {
|
|
125
|
+
const healthUrl = `${config.baseUrl}${config.healthPath ?? "/health"}`;
|
|
126
|
+
const timeouts = {
|
|
127
|
+
health: config.timeouts?.health ?? 1_000,
|
|
128
|
+
start: config.timeouts?.start ?? 3_000,
|
|
129
|
+
concurrentStart: config.timeouts?.concurrentStart ?? 1_000,
|
|
130
|
+
stop: config.timeouts?.stop ?? 2_000,
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
function emit(event: DaemonEvent): void {
|
|
134
|
+
config.onEvent?.(event);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function health(timeoutMs: number = timeouts.health): Promise<DaemonHealth<H>> {
|
|
138
|
+
const url = config.baseUrl;
|
|
139
|
+
try {
|
|
140
|
+
const response = await fetch(healthUrl, { signal: AbortSignal.timeout(timeoutMs) });
|
|
141
|
+
if (!response.ok) return { status: "down", ok: false, url, error: `HTTP ${response.status}` };
|
|
142
|
+
const parsed = config.parseHealth(await response.json());
|
|
143
|
+
if (!parsed) return { status: "down", ok: false, url, error: "invalid health response" };
|
|
144
|
+
return { status: "up", ok: true, url, ...parsed };
|
|
145
|
+
} catch (error) {
|
|
146
|
+
return { status: "down", ok: false, url, error: errorMessage(error) };
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function pollHealthy(timeoutMs: number): Promise<DaemonHealth<H>> {
|
|
151
|
+
const deadline = Date.now() + timeoutMs;
|
|
152
|
+
let last = await health();
|
|
153
|
+
while (last.status === "down" && Date.now() < deadline) {
|
|
154
|
+
await sleep(100);
|
|
155
|
+
last = await health();
|
|
156
|
+
}
|
|
157
|
+
return last;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function readPid(): number | undefined {
|
|
161
|
+
try {
|
|
162
|
+
const pid = Number(readFileSync(config.pidFile, "utf-8").trim());
|
|
163
|
+
return Number.isInteger(pid) && pid > 0 ? pid : undefined;
|
|
164
|
+
} catch {
|
|
165
|
+
return undefined;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function processIsAlive(pid: number): boolean {
|
|
170
|
+
try {
|
|
171
|
+
process.kill(pid, 0);
|
|
172
|
+
return true;
|
|
173
|
+
} catch {
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function cleanupFiles(): void {
|
|
179
|
+
for (const file of [config.pidFile, ...(config.cleanupFiles ?? [])]) {
|
|
180
|
+
try { rmSync(file, { force: true }); } catch {}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function readLogTail(): string {
|
|
185
|
+
if (!existsSync(config.logFile)) return "";
|
|
186
|
+
try {
|
|
187
|
+
const log = readFileSync(config.logFile, "utf-8");
|
|
188
|
+
return log.slice(-LOG_TAIL_BYTES).trim();
|
|
189
|
+
} catch {
|
|
190
|
+
return "";
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function spawnDaemon(): number {
|
|
195
|
+
mkdirSync(dirname(config.logFile), { recursive: true });
|
|
196
|
+
appendFileSync(config.logFile, `--- daemon start ${new Date().toISOString()}\n`);
|
|
197
|
+
const stdout = openSync(config.logFile, "a");
|
|
198
|
+
const stderr = openSync(config.logFile, "a");
|
|
199
|
+
try {
|
|
200
|
+
const child = Bun.spawn([...config.command], {
|
|
201
|
+
cwd: config.cwd ?? process.cwd(),
|
|
202
|
+
stdin: "ignore",
|
|
203
|
+
stdout,
|
|
204
|
+
stderr,
|
|
205
|
+
detached: true,
|
|
206
|
+
});
|
|
207
|
+
child.unref();
|
|
208
|
+
mkdirSync(dirname(config.pidFile), { recursive: true });
|
|
209
|
+
writeFileSync(config.pidFile, `${child.pid}\n`);
|
|
210
|
+
return child.pid;
|
|
211
|
+
} finally {
|
|
212
|
+
closeSync(stdout);
|
|
213
|
+
closeSync(stderr);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function start(): Promise<DaemonHealthUp<H>> {
|
|
218
|
+
emit({ type: "starting", command: config.command });
|
|
219
|
+
spawnDaemon();
|
|
220
|
+
const started = await pollHealthy(timeouts.start);
|
|
221
|
+
if (started.status === "up") {
|
|
222
|
+
emit({ type: "started", pid: started.pid });
|
|
223
|
+
return started;
|
|
224
|
+
}
|
|
225
|
+
throw new DaemonStartError(
|
|
226
|
+
`Daemon did not become healthy within ${timeouts.start}ms.`,
|
|
227
|
+
config.logFile,
|
|
228
|
+
readLogTail(),
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function stop(): Promise<DaemonStopResult> {
|
|
233
|
+
const current = await health();
|
|
234
|
+
|
|
235
|
+
if (current.status === "down") {
|
|
236
|
+
const stale = readPid();
|
|
237
|
+
if (stale === undefined) return { stopped: false, reason: "not-running", message: "Daemon is not running." };
|
|
238
|
+
cleanupFiles();
|
|
239
|
+
return { stopped: false, pid: stale, reason: "stale-pid", message: `Daemon is not running; removed stale pid ${stale}.` };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const pid = current.pid;
|
|
243
|
+
if (pid === process.pid) {
|
|
244
|
+
return { stopped: false, pid, reason: "refused-self", message: `Daemon pid ${pid} is this process; refusing to stop self.` };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
try {
|
|
248
|
+
process.kill(pid, "SIGTERM");
|
|
249
|
+
} catch {
|
|
250
|
+
cleanupFiles();
|
|
251
|
+
return { stopped: false, pid, reason: "signal-failed", message: `Daemon pid ${pid} could not be signalled.` };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const deadline = Date.now() + timeouts.stop;
|
|
255
|
+
while (Date.now() < deadline && (await health()).status === "up") {
|
|
256
|
+
await sleep(100);
|
|
257
|
+
}
|
|
258
|
+
cleanupFiles();
|
|
259
|
+
emit({ type: "stopped", pid });
|
|
260
|
+
return { stopped: true, pid, reason: "stopped", message: `Stopped daemon pid ${pid}.` };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async function ensure(): Promise<DaemonHealthUp<H>> {
|
|
264
|
+
const current = await health();
|
|
265
|
+
if (current.status === "up") {
|
|
266
|
+
const check = config.versionCheck;
|
|
267
|
+
const running = check?.of(current);
|
|
268
|
+
if (check && running && running !== check.expected) {
|
|
269
|
+
emit({ type: "version-mismatch", running, expected: check.expected });
|
|
270
|
+
await stop();
|
|
271
|
+
return await start();
|
|
272
|
+
}
|
|
273
|
+
return current;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Someone else may be mid-spawn: a live pid with no health yet is a daemon
|
|
277
|
+
// still booting, and racing it would put a second one on the same port.
|
|
278
|
+
const pid = readPid();
|
|
279
|
+
if (pid !== undefined && processIsAlive(pid)) {
|
|
280
|
+
const starting = await pollHealthy(timeouts.concurrentStart);
|
|
281
|
+
if (starting.status === "up") return starting;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return await start();
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async function restart(): Promise<DaemonHealthUp<H>> {
|
|
288
|
+
await stop();
|
|
289
|
+
return await start();
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return {
|
|
293
|
+
health,
|
|
294
|
+
async isRunning() {
|
|
295
|
+
return (await health()).status === "up";
|
|
296
|
+
},
|
|
297
|
+
start,
|
|
298
|
+
ensure,
|
|
299
|
+
stop,
|
|
300
|
+
restart,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { join } from "path";
|
|
2
|
+
import { tmpdir, homedir } from "os";
|
|
3
|
+
import { mkdirSync } from "fs";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* On-disk layout for one canvas host (agent-canvas, fabrika, …).
|
|
7
|
+
*
|
|
8
|
+
* Built explicitly by the host at startup rather than resolved from module-level
|
|
9
|
+
* constants, so importing the kernel has no side effects and two hosts can
|
|
10
|
+
* coexist in one process (tests rely on this).
|
|
11
|
+
*/
|
|
12
|
+
export interface CanvasPaths {
|
|
13
|
+
/** Persistent root — survives reboots. */
|
|
14
|
+
dataDir: string;
|
|
15
|
+
/** Session + revision store. */
|
|
16
|
+
sessionsDir: string;
|
|
17
|
+
/** Ephemeral root — may be lost on reboot. */
|
|
18
|
+
tempDir: string;
|
|
19
|
+
/** Daemon PID file. */
|
|
20
|
+
pidFile: string;
|
|
21
|
+
/** Scratch space for JSX compilation. */
|
|
22
|
+
compileTempDir: string;
|
|
23
|
+
/** Uploaded annotation images. */
|
|
24
|
+
uploadsDir: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface CanvasPathsConfig {
|
|
28
|
+
/** Namespace for on-disk locations, e.g. `"agent-canvas"` or `"fabrika"`. */
|
|
29
|
+
appName: string;
|
|
30
|
+
/**
|
|
31
|
+
* Persistent root. Defaults to `~/.claude/<appName>`. Hosts that keep data
|
|
32
|
+
* elsewhere — or that migrate from a legacy location — resolve it themselves
|
|
33
|
+
* and pass the result; the kernel holds no migration policy.
|
|
34
|
+
*/
|
|
35
|
+
dataDir?: string;
|
|
36
|
+
/** Ephemeral root. Defaults to `<tmpdir>/<appName>`. */
|
|
37
|
+
tempDir?: string;
|
|
38
|
+
/** Create the directories. Pass `false` when only the strings are needed. */
|
|
39
|
+
ensure?: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function createCanvasPaths(config: CanvasPathsConfig): CanvasPaths {
|
|
43
|
+
const { appName, ensure = true } = config;
|
|
44
|
+
const dataDir = config.dataDir ?? join(homedir(), ".claude", appName);
|
|
45
|
+
const tempDir = config.tempDir ?? join(tmpdir(), appName);
|
|
46
|
+
|
|
47
|
+
const paths: CanvasPaths = {
|
|
48
|
+
dataDir,
|
|
49
|
+
sessionsDir: join(dataDir, "sessions"),
|
|
50
|
+
tempDir,
|
|
51
|
+
pidFile: join(tempDir, "daemon.pid"),
|
|
52
|
+
compileTempDir: join(tempDir, "compile"),
|
|
53
|
+
uploadsDir: join(tempDir, "uploads"),
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
if (ensure) {
|
|
57
|
+
mkdirSync(paths.sessionsDir, { recursive: true });
|
|
58
|
+
mkdirSync(paths.compileTempDir, { recursive: true });
|
|
59
|
+
mkdirSync(paths.uploadsDir, { recursive: true });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return paths;
|
|
63
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export type RouteHandler = (req: Request, url: URL, match: URLPatternResult) => Response | Promise<Response>;
|
|
2
|
+
|
|
3
|
+
export interface Route {
|
|
4
|
+
method: string;
|
|
5
|
+
pattern: URLPattern;
|
|
6
|
+
handler: RouteHandler;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function dispatch(routes: Route[], req: Request, url: URL): Response | Promise<Response> | null {
|
|
10
|
+
for (const route of routes) {
|
|
11
|
+
if (route.method !== req.method) continue;
|
|
12
|
+
const match = route.pattern.exec(url);
|
|
13
|
+
if (match) return route.handler(req, url, match);
|
|
14
|
+
}
|
|
15
|
+
return null;
|
|
16
|
+
}
|