agent-canvas 0.1.42 → 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/bin/lib/helpers.test.ts +30 -0
- package/bin/lib/helpers.ts +27 -0
- package/bin/lib/instructions/overview.md +5 -0
- package/daemon/client/AnnotationProvider.tsx +32 -28
- package/daemon/client/App.tsx +43 -12
- package/daemon/client/PlanRenderer.tsx +67 -10
- package/daemon/client/RenderErrorContext.ts +11 -0
- package/daemon/client/annotationDraft.test.ts +41 -2
- package/daemon/client/annotationDraft.ts +27 -7
- package/daemon/dist/client.js +40 -40
- package/daemon/src/compiler.test.ts +13 -0
- package/daemon/src/compiler.ts +39 -5
- package/daemon/src/websocket.test.ts +99 -0
- package/daemon/src/websocket.ts +64 -10
- package/package.json +1 -1
- package/workers/canvas-share/public/assets/client.js +40 -40
|
@@ -42,3 +42,16 @@ test("runbook secret input compiles", async () => {
|
|
|
42
42
|
const result = await compilePlan(`<SecretInput id="service-token" label="Service token" env="SERVICE_TOKEN" required />`);
|
|
43
43
|
expect(result.ok).toBe(true);
|
|
44
44
|
});
|
|
45
|
+
|
|
46
|
+
test("missing required component prop fails before the browser renders", async () => {
|
|
47
|
+
const result = await compilePlan(`<Table columns={["Name"]} rows={[["Canvas"]]} />`);
|
|
48
|
+
|
|
49
|
+
expect(result.ok).toBe(false);
|
|
50
|
+
if (result.ok) return;
|
|
51
|
+
expect(result.error).toBe("Runtime error: Table: required prop `headers` must be an array");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("component prop validation accepts valid table props", async () => {
|
|
55
|
+
const result = await compilePlan(`<Table headers={["Name"]} rows={[["Canvas"]]} />`);
|
|
56
|
+
expect(result.ok).toBe(true);
|
|
57
|
+
});
|
package/daemon/src/compiler.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { writeFileSync, unlinkSync, readFileSync } from "fs";
|
|
2
2
|
import { join } from "path";
|
|
3
3
|
import { randomUUID } from "crypto";
|
|
4
|
-
import { h, Fragment } from "preact";
|
|
4
|
+
import { h, Fragment, type ComponentChildren } from "preact";
|
|
5
5
|
import renderToString from "preact-render-to-string";
|
|
6
6
|
import { parse as parseMermaid } from "mermaid-parser-bundle";
|
|
7
7
|
import { COMPILE_TEMP_DIR } from "./paths";
|
|
@@ -33,17 +33,51 @@ function mockJsxDEV(type: any, props: any, key?: any) {
|
|
|
33
33
|
return h(type, { ...rest, key }, children);
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
interface StubProps {
|
|
37
|
+
children?: ComponentChildren;
|
|
38
|
+
[key: string]: unknown;
|
|
39
|
+
}
|
|
40
|
+
|
|
36
41
|
/** Passthrough component — just renders its children */
|
|
37
|
-
const Stub = ({ children }:
|
|
42
|
+
const Stub = ({ children }: StubProps) => h(Fragment, null, children);
|
|
43
|
+
|
|
44
|
+
type RequiredPropKind = "array" | "string";
|
|
45
|
+
|
|
46
|
+
function validatingStub(component: string, requiredProps: Record<string, RequiredPropKind>) {
|
|
47
|
+
return (props: StubProps) => {
|
|
48
|
+
for (const [prop, kind] of Object.entries(requiredProps)) {
|
|
49
|
+
const value = props[prop];
|
|
50
|
+
const valid = kind === "array" ? Array.isArray(value) : typeof value === kind;
|
|
51
|
+
if (!valid) {
|
|
52
|
+
throw new Error(`${component}: required prop \`${prop}\` must be ${kind === "array" ? "an array" : `a ${kind}`}`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return h(Fragment, null, props.children);
|
|
56
|
+
};
|
|
57
|
+
}
|
|
38
58
|
|
|
39
59
|
const STUB_COMPONENTS: Record<string, any> = {};
|
|
40
60
|
for (const name of [
|
|
41
|
-
"
|
|
42
|
-
"Table", "Priority", "Checklist", "Note", "Diff",
|
|
43
|
-
"Choice", "MultiChoice", "UserInput", "RangeInput", "SecretInput", "ImageView", "Markdown",
|
|
61
|
+
"CodeBlock", "Callout", "Note", "Markdown",
|
|
44
62
|
]) {
|
|
45
63
|
STUB_COMPONENTS[name] = Stub;
|
|
46
64
|
}
|
|
65
|
+
Object.assign(STUB_COMPONENTS, {
|
|
66
|
+
Section: validatingStub("Section", { title: "string" }),
|
|
67
|
+
Item: validatingStub("Item", { id: "string", label: "string" }),
|
|
68
|
+
Task: validatingStub("Task", { id: "string", label: "string" }),
|
|
69
|
+
FilePreview: validatingStub("FilePreview", { path: "string" }),
|
|
70
|
+
Table: validatingStub("Table", { headers: "array", rows: "array" }),
|
|
71
|
+
Priority: validatingStub("Priority", { level: "string" }),
|
|
72
|
+
Checklist: validatingStub("Checklist", { items: "array" }),
|
|
73
|
+
Diff: validatingStub("Diff", { before: "string", after: "string" }),
|
|
74
|
+
Choice: validatingStub("Choice", { id: "string", label: "string", options: "array" }),
|
|
75
|
+
MultiChoice: validatingStub("MultiChoice", { id: "string", label: "string", options: "array" }),
|
|
76
|
+
UserInput: validatingStub("UserInput", { id: "string", label: "string" }),
|
|
77
|
+
RangeInput: validatingStub("RangeInput", { id: "string", label: "string" }),
|
|
78
|
+
SecretInput: validatingStub("SecretInput", { id: "string", label: "string", env: "string" }),
|
|
79
|
+
ImageView: validatingStub("ImageView", { src: "string" }),
|
|
80
|
+
});
|
|
47
81
|
// Mermaid stub collects diagram sources for post-render validation
|
|
48
82
|
let collectedMermaidSources: string[] = [];
|
|
49
83
|
STUB_COMPONENTS.Mermaid = ({ children }: any) => {
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync } from "fs";
|
|
3
|
+
import { tmpdir } from "os";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
import { SessionManager } from "./session";
|
|
6
|
+
import { createWebSocketManager, type CanvasSocket, type WSData } from "./websocket";
|
|
7
|
+
|
|
8
|
+
const testDirectories: string[] = [];
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
for (const directory of testDirectories.splice(0)) {
|
|
12
|
+
rmSync(directory, { recursive: true, force: true });
|
|
13
|
+
}
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
class FakeSocket implements CanvasSocket {
|
|
17
|
+
readonly sent: string[] = [];
|
|
18
|
+
closed = false;
|
|
19
|
+
|
|
20
|
+
constructor(readonly data: WSData) {}
|
|
21
|
+
|
|
22
|
+
send(message: string) {
|
|
23
|
+
this.sent.push(message);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
close() {
|
|
27
|
+
this.closed = true;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
ping() {}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function createSessionManager(): SessionManager {
|
|
34
|
+
const directory = mkdtempSync(join(tmpdir(), "agent-canvas-websocket-test-"));
|
|
35
|
+
testDirectories.push(directory);
|
|
36
|
+
const manager = new SessionManager(directory);
|
|
37
|
+
manager.upsert(
|
|
38
|
+
"session",
|
|
39
|
+
new Map([["architecture.jsx", "<Section title=\"Architecture\" />"]]),
|
|
40
|
+
directory,
|
|
41
|
+
);
|
|
42
|
+
return manager;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe("canvas render errors", () => {
|
|
46
|
+
test("delivers an error that arrived before the CLI watcher connected", () => {
|
|
47
|
+
const sessionManager = createSessionManager();
|
|
48
|
+
const websocket = createWebSocketManager(sessionManager);
|
|
49
|
+
const browser = new FakeSocket({ type: "browser", sessionId: "session" });
|
|
50
|
+
websocket.handlers.open(browser);
|
|
51
|
+
|
|
52
|
+
websocket.handlers.message(browser, JSON.stringify({
|
|
53
|
+
type: "render-error",
|
|
54
|
+
error: {
|
|
55
|
+
revision: 1,
|
|
56
|
+
filename: "architecture.jsx",
|
|
57
|
+
message: "Cannot read properties of undefined (reading 'map')",
|
|
58
|
+
},
|
|
59
|
+
}));
|
|
60
|
+
|
|
61
|
+
const watcher = new FakeSocket({ type: "wait", sessionId: "session" });
|
|
62
|
+
websocket.handlers.open(watcher);
|
|
63
|
+
|
|
64
|
+
expect(watcher.sent).toHaveLength(1);
|
|
65
|
+
expect(JSON.parse(watcher.sent[0])).toEqual({
|
|
66
|
+
type: "render-error",
|
|
67
|
+
error: {
|
|
68
|
+
revision: 1,
|
|
69
|
+
filename: "architecture.jsx",
|
|
70
|
+
message: "Cannot read properties of undefined (reading 'map')",
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
expect(watcher.closed).toBe(true);
|
|
74
|
+
expect(sessionManager.get("session")?.revisions[0].hasFeedback).toBe(false);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("drops a pending error when a new revision is pushed", () => {
|
|
78
|
+
const sessionManager = createSessionManager();
|
|
79
|
+
const websocket = createWebSocketManager(sessionManager);
|
|
80
|
+
const browser = new FakeSocket({ type: "browser", sessionId: "session" });
|
|
81
|
+
websocket.handlers.open(browser);
|
|
82
|
+
websocket.handlers.message(browser, JSON.stringify({
|
|
83
|
+
type: "render-error",
|
|
84
|
+
error: { revision: 1, filename: "architecture.jsx", message: "Broken revision" },
|
|
85
|
+
}));
|
|
86
|
+
|
|
87
|
+
sessionManager.upsert(
|
|
88
|
+
"session",
|
|
89
|
+
new Map([["architecture.jsx", "<Section title=\"Fixed\" />"]]),
|
|
90
|
+
"/project",
|
|
91
|
+
);
|
|
92
|
+
websocket.broadcastPlanUpdate("session");
|
|
93
|
+
|
|
94
|
+
const watcher = new FakeSocket({ type: "wait", sessionId: "session" });
|
|
95
|
+
websocket.handlers.open(watcher);
|
|
96
|
+
expect(watcher.sent).toEqual([]);
|
|
97
|
+
expect(watcher.closed).toBe(false);
|
|
98
|
+
});
|
|
99
|
+
});
|
package/daemon/src/websocket.ts
CHANGED
|
@@ -1,13 +1,38 @@
|
|
|
1
|
-
import type { ServerWebSocket } from "bun";
|
|
2
1
|
import type { SessionManager, RemoteFeedbackEntry } from "./session";
|
|
3
2
|
|
|
4
3
|
export type WSData = { type: "browser" | "wait"; sessionId: string };
|
|
5
4
|
|
|
5
|
+
export interface CanvasSocket {
|
|
6
|
+
data: WSData;
|
|
7
|
+
send(message: string): unknown;
|
|
8
|
+
close(): void;
|
|
9
|
+
ping(): unknown;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface CanvasRenderError {
|
|
13
|
+
revision: number;
|
|
14
|
+
filename: string;
|
|
15
|
+
message: string;
|
|
16
|
+
stack?: string;
|
|
17
|
+
componentStack?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isCanvasRenderError(value: unknown): value is CanvasRenderError {
|
|
21
|
+
return typeof value === "object" && value !== null
|
|
22
|
+
&& "revision" in value && Number.isInteger(value.revision)
|
|
23
|
+
&& "filename" in value && typeof value.filename === "string"
|
|
24
|
+
&& "message" in value && typeof value.message === "string"
|
|
25
|
+
&& (!("stack" in value) || value.stack === undefined || typeof value.stack === "string")
|
|
26
|
+
&& (!("componentStack" in value) || value.componentStack === undefined || typeof value.componentStack === "string");
|
|
27
|
+
}
|
|
28
|
+
|
|
6
29
|
export function createWebSocketManager(sessionManager: SessionManager) {
|
|
7
|
-
const browserSockets = new Map<string, Set<
|
|
8
|
-
const waitSockets = new Map<string, Set<
|
|
30
|
+
const browserSockets = new Map<string, Set<CanvasSocket>>();
|
|
31
|
+
const waitSockets = new Map<string, Set<CanvasSocket>>();
|
|
32
|
+
const pendingRenderErrors = new Map<string, CanvasRenderError>();
|
|
9
33
|
|
|
10
34
|
function broadcastPlanUpdate(sessionId: string) {
|
|
35
|
+
pendingRenderErrors.delete(sessionId);
|
|
11
36
|
const sockets = browserSockets.get(sessionId);
|
|
12
37
|
if (!sockets) return;
|
|
13
38
|
const session = sessionManager.get(sessionId);
|
|
@@ -59,13 +84,29 @@ export function createWebSocketManager(sessionManager: SessionManager) {
|
|
|
59
84
|
}
|
|
60
85
|
}
|
|
61
86
|
|
|
87
|
+
function deliverPendingRenderError(sessionId: string): boolean {
|
|
88
|
+
const error = pendingRenderErrors.get(sessionId);
|
|
89
|
+
const waiters = waitSockets.get(sessionId);
|
|
90
|
+
if (!error || !waiters || waiters.size === 0) return false;
|
|
91
|
+
|
|
92
|
+
pendingRenderErrors.delete(sessionId);
|
|
93
|
+
waitSockets.delete(sessionId);
|
|
94
|
+
const payload = JSON.stringify({ type: "render-error", error });
|
|
95
|
+
for (const waiter of waiters) {
|
|
96
|
+
waiter.send(payload);
|
|
97
|
+
waiter.close();
|
|
98
|
+
}
|
|
99
|
+
broadcastWatcherStatus(sessionId);
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
|
|
62
103
|
// Ping wait sockets periodically to detect dead connections.
|
|
63
104
|
// We can't rely on ws.close() triggering the close handler for dead sockets,
|
|
64
105
|
// so we manually remove dead sockets and broadcast status.
|
|
65
106
|
const PING_INTERVAL = 5_000;
|
|
66
|
-
const pongReceived = new WeakSet<
|
|
107
|
+
const pongReceived = new WeakSet<CanvasSocket>();
|
|
67
108
|
|
|
68
|
-
function removeWaitSocket(ws:
|
|
109
|
+
function removeWaitSocket(ws: CanvasSocket, sessionId: string) {
|
|
69
110
|
const sockets = waitSockets.get(sessionId);
|
|
70
111
|
if (sockets) {
|
|
71
112
|
sockets.delete(ws);
|
|
@@ -92,7 +133,7 @@ export function createWebSocketManager(sessionManager: SessionManager) {
|
|
|
92
133
|
}, PING_INTERVAL);
|
|
93
134
|
|
|
94
135
|
const handlers = {
|
|
95
|
-
open(ws:
|
|
136
|
+
open(ws: CanvasSocket) {
|
|
96
137
|
const { type, sessionId } = ws.data;
|
|
97
138
|
const map = type === "browser" ? browserSockets : waitSockets;
|
|
98
139
|
if (!map.has(sessionId)) map.set(sessionId, new Set());
|
|
@@ -104,10 +145,12 @@ export function createWebSocketManager(sessionManager: SessionManager) {
|
|
|
104
145
|
} else {
|
|
105
146
|
// CLI waiter connected — notify browsers
|
|
106
147
|
pongReceived.add(ws); // Give it a free pass on first interval
|
|
107
|
-
|
|
148
|
+
if (!deliverPendingRenderError(sessionId)) {
|
|
149
|
+
broadcastWatcherStatus(sessionId);
|
|
150
|
+
}
|
|
108
151
|
}
|
|
109
152
|
},
|
|
110
|
-
message(ws:
|
|
153
|
+
message(ws: CanvasSocket, message: string | Buffer) {
|
|
111
154
|
const { type, sessionId } = ws.data;
|
|
112
155
|
if (type === "browser") {
|
|
113
156
|
try {
|
|
@@ -133,15 +176,26 @@ export function createWebSocketManager(sessionManager: SessionManager) {
|
|
|
133
176
|
}
|
|
134
177
|
}
|
|
135
178
|
}
|
|
179
|
+
if (data.type === "render-error" && isCanvasRenderError(data.error)) {
|
|
180
|
+
const session = sessionManager.get(sessionId);
|
|
181
|
+
if (
|
|
182
|
+
session
|
|
183
|
+
&& data.error.revision === session.currentRevision
|
|
184
|
+
&& session.canvasFiles.includes(data.error.filename)
|
|
185
|
+
) {
|
|
186
|
+
pendingRenderErrors.set(sessionId, data.error);
|
|
187
|
+
deliverPendingRenderError(sessionId);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
136
190
|
} catch {}
|
|
137
191
|
}
|
|
138
192
|
},
|
|
139
|
-
pong(ws:
|
|
193
|
+
pong(ws: CanvasSocket) {
|
|
140
194
|
if (ws.data.type === "wait") {
|
|
141
195
|
pongReceived.add(ws);
|
|
142
196
|
}
|
|
143
197
|
},
|
|
144
|
-
close(ws:
|
|
198
|
+
close(ws: CanvasSocket) {
|
|
145
199
|
const { type, sessionId } = ws.data;
|
|
146
200
|
const map = type === "browser" ? browserSockets : waitSockets;
|
|
147
201
|
map.get(sessionId)?.delete(ws);
|