agent-canvas 0.1.26 → 0.1.28
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/.github/workflows/ci.yml +25 -0
- package/.github/workflows/deploy-worker-prod.yml +20 -0
- package/.github/workflows/deploy-worker-stage.yml +22 -0
- package/.github/workflows/deploy-worker.yml +73 -0
- package/bin/lib/instructions/overview.md +1 -0
- package/bun.lock +193 -0
- package/daemon/build.ts +22 -0
- package/daemon/client/AnnotationEditor.tsx +12 -2
- package/daemon/client/AnnotationProvider.tsx +20 -6
- package/daemon/client/AnnotationSidebar.tsx +72 -35
- package/daemon/client/App.tsx +219 -10
- package/daemon/client/FileBrowser.tsx +16 -0
- package/daemon/client/FileViewer.tsx +18 -0
- package/daemon/client/PlanRenderer.tsx +3 -2
- package/daemon/client/ReviewerIdentityDialog.tsx +95 -0
- package/daemon/client/SessionSwitcher.tsx +5 -0
- package/daemon/client/ShareDialog.tsx +230 -0
- package/daemon/client/clientApi.ts +142 -0
- package/daemon/client/runtime.ts +22 -0
- package/daemon/dist/client.css +1 -1
- package/daemon/dist/client.js +74 -74
- package/daemon/dist/components.js +50 -50
- package/daemon/src/handlers/api.ts +63 -0
- package/daemon/src/remote-feedback.test.ts +179 -0
- package/daemon/src/remote-feedback.ts +128 -0
- package/daemon/src/server.ts +5 -0
- package/daemon/src/session.ts +139 -6
- package/daemon/src/share.test.ts +221 -0
- package/daemon/src/share.ts +187 -0
- package/daemon/src/websocket.ts +15 -2
- package/package.json +5 -1
- package/workers/canvas-share/README.md +159 -0
- package/workers/canvas-share/oblaka.ts +56 -0
- package/workers/canvas-share/package.json +25 -0
- package/workers/canvas-share/public/assets/.gitkeep +0 -0
- package/workers/canvas-share/public/assets/client.css +2 -0
- package/workers/canvas-share/public/assets/client.js +91 -0
- package/workers/canvas-share/public/assets/components.js +63 -0
- package/workers/canvas-share/public/assets/jsx-runtime.js +3 -0
- package/workers/canvas-share/public/assets/preact-compat.js +1 -0
- package/workers/canvas-share/public/assets/runtime.js +1 -0
- package/workers/canvas-share/public/index.html +385 -0
- package/workers/canvas-share/public/screenshot-dark.png +0 -0
- package/workers/canvas-share/public/screenshot-light.png +0 -0
- package/workers/canvas-share/src/index.test.ts +165 -0
- package/workers/canvas-share/src/index.ts +510 -0
- package/workers/canvas-share/src/types.ts +97 -0
- package/workers/canvas-share/src/util.ts +156 -0
- package/workers/canvas-share/src/validation.ts +117 -0
- package/workers/canvas-share/tsconfig.json +16 -0
|
@@ -3,6 +3,7 @@ import type { SessionManager } from "../session";
|
|
|
3
3
|
import { compilePlan } from "../compiler";
|
|
4
4
|
import { watchSession } from "../watcher";
|
|
5
5
|
import { jsonResponse } from "./utils";
|
|
6
|
+
import { loadShareConfig, shareRevision, revokeShare } from "../share";
|
|
6
7
|
import type { Route } from "../router";
|
|
7
8
|
|
|
8
9
|
export interface ApiContext {
|
|
@@ -154,9 +155,67 @@ export function createApiHandlers(ctx: ApiContext): Route[] {
|
|
|
154
155
|
canvasFiles: session.canvasFiles,
|
|
155
156
|
createdAt: session.createdAt,
|
|
156
157
|
updatedAt: session.updatedAt,
|
|
158
|
+
shares: session.shares ?? [],
|
|
159
|
+
shareEnabled: !!process.env.CANVAS_SHARE_ENDPOINT,
|
|
157
160
|
});
|
|
158
161
|
}
|
|
159
162
|
|
|
163
|
+
async function handleShareRevision(_req: Request, _url: URL, match: URLPatternResult): Promise<Response> {
|
|
164
|
+
const sessionId = match.pathname.groups.id!;
|
|
165
|
+
const rev = parseInt(match.pathname.groups.rev!, 10);
|
|
166
|
+
|
|
167
|
+
const session = sessionManager.get(sessionId);
|
|
168
|
+
if (!session) return jsonResponse({ error: "Session not found" }, 404);
|
|
169
|
+
if (!session.revisions.find((r) => r.revision === rev)) {
|
|
170
|
+
return jsonResponse({ error: `Revision ${rev} not found` }, 404);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const config = loadShareConfig(ctx.version);
|
|
174
|
+
if (!config) {
|
|
175
|
+
return jsonResponse({
|
|
176
|
+
error: "Sharing disabled. Set CANVAS_SHARE_ENDPOINT to the deployed canvas-share worker URL.",
|
|
177
|
+
}, 501);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
try {
|
|
181
|
+
const entry = await shareRevision(sessionManager, sessionId, rev, config);
|
|
182
|
+
broadcastRevisionUpdate(sessionId);
|
|
183
|
+
return jsonResponse({ ok: true, share: entry });
|
|
184
|
+
} catch (e: any) {
|
|
185
|
+
return jsonResponse({ error: e.message || String(e) }, 502);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function handleShareRevoke(_req: Request, _url: URL, match: URLPatternResult): Promise<Response> {
|
|
190
|
+
const sessionId = match.pathname.groups.id!;
|
|
191
|
+
const shareId = match.pathname.groups.shareId!;
|
|
192
|
+
if (!sessionManager.get(sessionId)) return jsonResponse({ error: "Session not found" }, 404);
|
|
193
|
+
const config = loadShareConfig(ctx.version);
|
|
194
|
+
if (!config) return jsonResponse({ error: "Sharing disabled" }, 501);
|
|
195
|
+
try {
|
|
196
|
+
await revokeShare(sessionManager, sessionId, shareId, config);
|
|
197
|
+
broadcastRevisionUpdate(sessionId);
|
|
198
|
+
return jsonResponse({ ok: true });
|
|
199
|
+
} catch (e: any) {
|
|
200
|
+
return jsonResponse({ error: e.message || String(e) }, 502);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function handleSharesList(_req: Request, _url: URL, match: URLPatternResult): Response {
|
|
205
|
+
const sessionId = match.pathname.groups.id!;
|
|
206
|
+
const session = sessionManager.get(sessionId);
|
|
207
|
+
if (!session) return jsonResponse({ error: "Session not found" }, 404);
|
|
208
|
+
return jsonResponse({ shares: session.shares ?? [] });
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function handleRemoteFeedbackGet(_req: Request, _url: URL, match: URLPatternResult): Response {
|
|
212
|
+
const sessionId = match.pathname.groups.id!;
|
|
213
|
+
const rev = parseInt(match.pathname.groups.rev!, 10);
|
|
214
|
+
if (!sessionManager.get(sessionId)) return jsonResponse({ error: "Session not found" }, 404);
|
|
215
|
+
const entries = sessionManager.getRemoteFeedback(sessionId, rev);
|
|
216
|
+
return jsonResponse({ entries });
|
|
217
|
+
}
|
|
218
|
+
|
|
160
219
|
function handleSessions(): Response {
|
|
161
220
|
return jsonResponse(sessionManager.list().map((s) => ({
|
|
162
221
|
id: s.id,
|
|
@@ -190,6 +249,10 @@ export function createApiHandlers(ctx: ApiContext): Route[] {
|
|
|
190
249
|
{ method: "GET", pattern: new URLPattern({ pathname: "/api/session/:id/meta" }), handler: handleMeta },
|
|
191
250
|
{ method: "GET", pattern: new URLPattern({ pathname: "/api/session/:id/revision/:rev/feedback" }), handler: handleFeedbackGet },
|
|
192
251
|
{ method: "POST", pattern: new URLPattern({ pathname: "/api/session/:id/feedback/consume" }), handler: handleFeedbackConsume },
|
|
252
|
+
{ method: "POST", pattern: new URLPattern({ pathname: "/api/session/:id/revision/:rev/share" }), handler: handleShareRevision },
|
|
253
|
+
{ method: "POST", pattern: new URLPattern({ pathname: "/api/session/:id/shares/:shareId/revoke" }), handler: handleShareRevoke },
|
|
254
|
+
{ method: "GET", pattern: new URLPattern({ pathname: "/api/session/:id/shares" }), handler: handleSharesList },
|
|
255
|
+
{ method: "GET", pattern: new URLPattern({ pathname: "/api/session/:id/revision/:rev/remote-feedback" }), handler: handleRemoteFeedbackGet },
|
|
193
256
|
{ method: "GET", pattern: new URLPattern({ pathname: "/api/sessions" }), handler: handleSessions },
|
|
194
257
|
];
|
|
195
258
|
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the remote feedback poller. Uses a real SessionManager with
|
|
3
|
+
* a real share recorded on a session, and mocks `fetch` to simulate
|
|
4
|
+
* worker responses. Verifies persistence, broadcast, dedup, and backoff.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, expect, test, afterEach, beforeEach } from "bun:test";
|
|
8
|
+
import { mkdtempSync, rmSync } from "fs";
|
|
9
|
+
import { join } from "path";
|
|
10
|
+
import { tmpdir } from "os";
|
|
11
|
+
import { SessionManager } from "./session";
|
|
12
|
+
import { startRemoteFeedbackPoller } from "./remote-feedback";
|
|
13
|
+
|
|
14
|
+
let testDir: string;
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
testDir = mkdtempSync(join(tmpdir(), "canvas-rf-test-"));
|
|
17
|
+
});
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
try { rmSync(testDir, { recursive: true, force: true }); } catch {}
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
function newSessionManager(): SessionManager {
|
|
23
|
+
return new SessionManager(testDir);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const originalFetch = globalThis.fetch;
|
|
27
|
+
const originalEndpoint = process.env.CANVAS_SHARE_ENDPOINT;
|
|
28
|
+
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
process.env.CANVAS_SHARE_ENDPOINT = "https://mock.example.com";
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
globalThis.fetch = originalFetch;
|
|
35
|
+
if (originalEndpoint !== undefined) process.env.CANVAS_SHARE_ENDPOINT = originalEndpoint;
|
|
36
|
+
else delete process.env.CANVAS_SHARE_ENDPOINT;
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
function setupSession(sm: SessionManager): { sessionId: string; shareId: string } {
|
|
40
|
+
const sessionId = `test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
41
|
+
const shareId = "abc123def456";
|
|
42
|
+
sm.upsert(sessionId, new Map([["plan.jsx", "x"]]), "/tmp");
|
|
43
|
+
sm.saveCompiled(sessionId, "plan.jsx", "x");
|
|
44
|
+
sm.addShare(sessionId, {
|
|
45
|
+
shareId,
|
|
46
|
+
url: "https://mock.example.com/s/" + shareId,
|
|
47
|
+
revision: 1,
|
|
48
|
+
createdAt: new Date().toISOString(),
|
|
49
|
+
});
|
|
50
|
+
return { sessionId, shareId };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe("startRemoteFeedbackPoller", () => {
|
|
54
|
+
test("persists fetched entries and updates lastFeedbackAt", async () => {
|
|
55
|
+
const sm = newSessionManager();
|
|
56
|
+
const { sessionId, shareId } = setupSession(sm);
|
|
57
|
+
|
|
58
|
+
let calls = 0;
|
|
59
|
+
(globalThis as any).fetch = async (url: any) => {
|
|
60
|
+
calls++;
|
|
61
|
+
expect(String(url)).toContain(`/shares/${shareId}/feedback`);
|
|
62
|
+
return new Response(JSON.stringify({
|
|
63
|
+
entries: [{
|
|
64
|
+
id: "fb1",
|
|
65
|
+
shareId,
|
|
66
|
+
revision: 1,
|
|
67
|
+
submittedAt: "2026-04-09T10:00:00.000Z",
|
|
68
|
+
author: { id: "u1", name: "Alice" },
|
|
69
|
+
annotations: [{
|
|
70
|
+
id: "a1",
|
|
71
|
+
snippet: "x",
|
|
72
|
+
note: "good",
|
|
73
|
+
createdAt: "2026-04-09T10:00:00.000Z",
|
|
74
|
+
}],
|
|
75
|
+
}],
|
|
76
|
+
latestAt: "2026-04-09T10:00:00.000Z",
|
|
77
|
+
}), { status: 200 });
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
let broadcasted: { sessionId: string; revision: number; count: number }[] = [];
|
|
81
|
+
const broadcast = (sid: string, rev: number, entries: any[]) => {
|
|
82
|
+
broadcasted.push({ sessionId: sid, revision: rev, count: entries.length });
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const poller = startRemoteFeedbackPoller(sm, broadcast, "0.0.0");
|
|
86
|
+
// Wait for at least one poll cycle
|
|
87
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
88
|
+
poller.stop();
|
|
89
|
+
|
|
90
|
+
expect(calls).toBeGreaterThanOrEqual(1);
|
|
91
|
+
expect(broadcasted.length).toBeGreaterThanOrEqual(1);
|
|
92
|
+
expect(broadcasted[0].sessionId).toBe(sessionId);
|
|
93
|
+
expect(broadcasted[0].revision).toBe(1);
|
|
94
|
+
expect(broadcasted[0].count).toBe(1);
|
|
95
|
+
|
|
96
|
+
const persisted = sm.getRemoteFeedback(sessionId, 1);
|
|
97
|
+
expect(persisted).toHaveLength(1);
|
|
98
|
+
expect(persisted[0].id).toBe("fb1");
|
|
99
|
+
expect(persisted[0].author.name).toBe("Alice");
|
|
100
|
+
|
|
101
|
+
const updatedShare = sm.getShares(sessionId).find((s) => s.shareId === shareId);
|
|
102
|
+
expect(updatedShare?.lastFeedbackAt).toBe("2026-04-09T10:00:00.000Z");
|
|
103
|
+
|
|
104
|
+
sm.remove(sessionId);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("dedupes entries by id across polls", async () => {
|
|
108
|
+
const sm = newSessionManager();
|
|
109
|
+
const { sessionId } = setupSession(sm);
|
|
110
|
+
|
|
111
|
+
const sameEntry = {
|
|
112
|
+
id: "fb-same",
|
|
113
|
+
shareId: "abc123def456",
|
|
114
|
+
revision: 1,
|
|
115
|
+
submittedAt: "2026-04-09T10:00:00.000Z",
|
|
116
|
+
author: { id: "u1", name: "Bob" },
|
|
117
|
+
annotations: [],
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
(globalThis as any).fetch = async () => new Response(JSON.stringify({ entries: [sameEntry] }), { status: 200 });
|
|
121
|
+
|
|
122
|
+
const poller = startRemoteFeedbackPoller(sm, () => {}, "0.0.0");
|
|
123
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
124
|
+
poller.stop();
|
|
125
|
+
|
|
126
|
+
// Even after multiple polls returning the same entry, only one should be persisted.
|
|
127
|
+
const persisted = sm.getRemoteFeedback(sessionId, 1);
|
|
128
|
+
expect(persisted).toHaveLength(1);
|
|
129
|
+
|
|
130
|
+
sm.remove(sessionId);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("404 stops further polls (revoked share)", async () => {
|
|
134
|
+
const sm = newSessionManager();
|
|
135
|
+
const { sessionId, shareId } = setupSession(sm);
|
|
136
|
+
|
|
137
|
+
let calls = 0;
|
|
138
|
+
(globalThis as any).fetch = async () => {
|
|
139
|
+
calls++;
|
|
140
|
+
return new Response("not found", { status: 404 });
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const poller = startRemoteFeedbackPoller(sm, () => {}, "0.0.0");
|
|
144
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
145
|
+
poller.stop();
|
|
146
|
+
|
|
147
|
+
// After the first 404 the share is marked dead — calls should stay at 1.
|
|
148
|
+
expect(calls).toBe(1);
|
|
149
|
+
void shareId;
|
|
150
|
+
sm.remove(sessionId);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("does nothing when no shares exist", async () => {
|
|
154
|
+
const sm = newSessionManager();
|
|
155
|
+
let calls = 0;
|
|
156
|
+
(globalThis as any).fetch = async () => { calls++; return new Response("", { status: 200 }); };
|
|
157
|
+
|
|
158
|
+
const poller = startRemoteFeedbackPoller(sm, () => {}, "0.0.0");
|
|
159
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
160
|
+
poller.stop();
|
|
161
|
+
|
|
162
|
+
expect(calls).toBe(0);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("does nothing when CANVAS_SHARE_ENDPOINT not set", async () => {
|
|
166
|
+
delete process.env.CANVAS_SHARE_ENDPOINT;
|
|
167
|
+
|
|
168
|
+
const sm = newSessionManager();
|
|
169
|
+
setupSession(sm);
|
|
170
|
+
let calls = 0;
|
|
171
|
+
(globalThis as any).fetch = async () => { calls++; return new Response("", { status: 200 }); };
|
|
172
|
+
|
|
173
|
+
const poller = startRemoteFeedbackPoller(sm, () => {}, "0.0.0");
|
|
174
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
175
|
+
poller.stop();
|
|
176
|
+
|
|
177
|
+
expect(calls).toBe(0);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import type { SessionManager, RemoteFeedbackEntry } from "./session";
|
|
2
|
+
import { loadShareConfig } from "./share";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Background polling loop for remote feedback.
|
|
6
|
+
*
|
|
7
|
+
* For every session that has at least one share, periodically asks the
|
|
8
|
+
* CF Worker for feedback submitted since we last checked. New entries are
|
|
9
|
+
* persisted locally (as `remote_feedback.json` per revision) and broadcast
|
|
10
|
+
* to any connected browser WebSockets so the UI can merge them into the
|
|
11
|
+
* annotation layer in real time.
|
|
12
|
+
*
|
|
13
|
+
* The polling loop is cheap: one HTTP call per share per 5 seconds. If no
|
|
14
|
+
* shares exist anywhere, the loop sleeps. If the worker is unreachable,
|
|
15
|
+
* errors are logged but the loop keeps running so feedback is picked up
|
|
16
|
+
* as soon as the worker comes back.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const POLL_INTERVAL_MS = 5_000;
|
|
20
|
+
const MAX_BACKOFF_MS = 60_000;
|
|
21
|
+
|
|
22
|
+
interface WorkerFeedbackResponse {
|
|
23
|
+
entries: RemoteFeedbackEntry[];
|
|
24
|
+
/** Server-reported high water mark — echo back in the next `since` param */
|
|
25
|
+
latestAt?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function startRemoteFeedbackPoller(
|
|
29
|
+
sessionManager: SessionManager,
|
|
30
|
+
broadcastRemoteFeedback: (sessionId: string, revision: number, entries: RemoteFeedbackEntry[]) => void,
|
|
31
|
+
version: string,
|
|
32
|
+
): { stop: () => void } {
|
|
33
|
+
let stopped = false;
|
|
34
|
+
|
|
35
|
+
// Per-share consecutive error count → exponential backoff so a dead
|
|
36
|
+
// worker doesn't generate a fire-hose of failed requests.
|
|
37
|
+
const errorCounts = new Map<string, number>();
|
|
38
|
+
function backoffFor(shareId: string): number {
|
|
39
|
+
const errors = errorCounts.get(shareId) || 0;
|
|
40
|
+
if (errors === 0) return 0;
|
|
41
|
+
return Math.min(MAX_BACKOFF_MS, POLL_INTERVAL_MS * Math.pow(2, errors - 1));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function pollShare(sessionId: string, share: { shareId: string; lastFeedbackAt?: string }, endpoint: string) {
|
|
45
|
+
try {
|
|
46
|
+
const since = share.lastFeedbackAt ?? "";
|
|
47
|
+
const url = `${endpoint}/shares/${share.shareId}/feedback${since ? `?since=${encodeURIComponent(since)}` : ""}`;
|
|
48
|
+
const res = await fetch(url);
|
|
49
|
+
if (!res.ok) {
|
|
50
|
+
if (res.status === 404) {
|
|
51
|
+
// Share no longer exists on the worker — likely revoked or expired.
|
|
52
|
+
// Stop trying so we don't hammer it forever.
|
|
53
|
+
errorCounts.set(share.shareId, 99);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
throw new Error(`HTTP ${res.status}`);
|
|
57
|
+
}
|
|
58
|
+
const data = (await res.json()) as WorkerFeedbackResponse;
|
|
59
|
+
errorCounts.delete(share.shareId);
|
|
60
|
+
|
|
61
|
+
if (!data.entries || data.entries.length === 0) return;
|
|
62
|
+
|
|
63
|
+
const byRev = new Map<number, RemoteFeedbackEntry[]>();
|
|
64
|
+
for (const e of data.entries) {
|
|
65
|
+
if (!byRev.has(e.revision)) byRev.set(e.revision, []);
|
|
66
|
+
byRev.get(e.revision)!.push(e);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
for (const [rev, entries] of byRev) {
|
|
70
|
+
sessionManager.appendRemoteFeedback(sessionId, rev, entries);
|
|
71
|
+
broadcastRemoteFeedback(sessionId, rev, entries);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const latest = data.latestAt
|
|
75
|
+
?? data.entries.reduce((acc, e) => (e.submittedAt > acc ? e.submittedAt : acc), share.lastFeedbackAt ?? "");
|
|
76
|
+
if (latest) {
|
|
77
|
+
sessionManager.updateShareLastFeedback(sessionId, share.shareId, latest);
|
|
78
|
+
}
|
|
79
|
+
} catch (e: any) {
|
|
80
|
+
errorCounts.set(share.shareId, (errorCounts.get(share.shareId) || 0) + 1);
|
|
81
|
+
console.warn(`[remote-feedback] ${share.shareId}: ${e.message || e} (will retry with backoff)`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Per-share next-poll timestamps so backoffs are independent.
|
|
86
|
+
const nextPollAt = new Map<string, number>();
|
|
87
|
+
|
|
88
|
+
async function pollOnce() {
|
|
89
|
+
const config = loadShareConfig(version);
|
|
90
|
+
if (!config) return;
|
|
91
|
+
|
|
92
|
+
const sessions = sessionManager.listSessionsWithShares();
|
|
93
|
+
if (sessions.length === 0) return;
|
|
94
|
+
|
|
95
|
+
const now = Date.now();
|
|
96
|
+
const tasks: Promise<void>[] = [];
|
|
97
|
+
for (const session of sessions) {
|
|
98
|
+
for (const share of session.shares ?? []) {
|
|
99
|
+
const next = nextPollAt.get(share.shareId) || 0;
|
|
100
|
+
if (now < next) continue;
|
|
101
|
+
// Skip "permanently dead" shares (after a 404).
|
|
102
|
+
if ((errorCounts.get(share.shareId) || 0) >= 99) continue;
|
|
103
|
+
nextPollAt.set(share.shareId, now + Math.max(POLL_INTERVAL_MS, backoffFor(share.shareId)));
|
|
104
|
+
tasks.push(pollShare(session.id, share, config.endpoint));
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
await Promise.all(tasks);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function loop() {
|
|
111
|
+
while (!stopped) {
|
|
112
|
+
try {
|
|
113
|
+
await pollOnce();
|
|
114
|
+
} catch (e: any) {
|
|
115
|
+
console.warn(`[remote-feedback] loop error: ${e.message || e}`);
|
|
116
|
+
}
|
|
117
|
+
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
loop();
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
stop: () => {
|
|
125
|
+
stopped = true;
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
package/daemon/src/server.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { createApiHandlers } from "./handlers/api";
|
|
|
9
9
|
import { createFileHandlers } from "./handlers/files";
|
|
10
10
|
import { createStaticHandlers } from "./handlers/static";
|
|
11
11
|
import { createUploadHandlers } from "./handlers/uploads";
|
|
12
|
+
import { startRemoteFeedbackPoller } from "./remote-feedback";
|
|
12
13
|
|
|
13
14
|
const PORT = parseInt(process.env.CANVAS_PORT || "19400", 10);
|
|
14
15
|
const VERSION = JSON.parse(readFileSync(join(dirname(import.meta.dir), "..", "package.json"), "utf-8")).version as string;
|
|
@@ -82,4 +83,8 @@ export const broadcastPlanUpdate = wsManager.broadcastPlanUpdate;
|
|
|
82
83
|
|
|
83
84
|
setInterval(() => sessionManager.cleanupStale(), 60 * 60 * 1000);
|
|
84
85
|
|
|
86
|
+
// Start polling for remote feedback from shared canvases.
|
|
87
|
+
// No-ops if CANVAS_SHARE_ENDPOINT is not set.
|
|
88
|
+
startRemoteFeedbackPoller(sessionManager, wsManager.broadcastRemoteFeedback, VERSION);
|
|
89
|
+
|
|
85
90
|
console.log(`Planner daemon listening on http://localhost:${PORT}`);
|
package/daemon/src/session.ts
CHANGED
|
@@ -22,6 +22,64 @@ export interface RevisionInfo {
|
|
|
22
22
|
response?: string;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* A single piece of feedback received from a remote (shared) reviewer.
|
|
27
|
+
* Structured so it can be merged into the local annotation system alongside
|
|
28
|
+
* the author's own annotations. `annotations` is the structured list of
|
|
29
|
+
* highlighted-text comments; `generalNote` is the free-form markdown.
|
|
30
|
+
*/
|
|
31
|
+
export interface RemoteFeedbackEntry {
|
|
32
|
+
/** Stable unique id assigned by the worker; used to dedupe on re-poll */
|
|
33
|
+
id: string;
|
|
34
|
+
shareId: string;
|
|
35
|
+
revision: number;
|
|
36
|
+
/** ISO timestamp when the reviewer submitted */
|
|
37
|
+
submittedAt: string;
|
|
38
|
+
author: { id: string; name: string };
|
|
39
|
+
annotations: RemoteAnnotation[];
|
|
40
|
+
generalNote?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Mirror of the client-side Annotation shape, minus fields that only make
|
|
45
|
+
* sense locally. Kept intentionally narrow so the wire format is stable.
|
|
46
|
+
*/
|
|
47
|
+
export interface RemoteAnnotation {
|
|
48
|
+
id: string;
|
|
49
|
+
snippet: string;
|
|
50
|
+
note: string;
|
|
51
|
+
createdAt: string;
|
|
52
|
+
filePath?: string;
|
|
53
|
+
canvasFile?: string;
|
|
54
|
+
context?: {
|
|
55
|
+
before: string;
|
|
56
|
+
after: string;
|
|
57
|
+
hierarchy: string[];
|
|
58
|
+
lineStart?: number;
|
|
59
|
+
lineEnd?: number;
|
|
60
|
+
};
|
|
61
|
+
attachments?: { url: string; mime?: string }[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* A share is a snapshot of a specific revision that has been pushed to a
|
|
66
|
+
* remote endpoint (CF Worker) for external review. The `shareId` is the
|
|
67
|
+
* opaque capability token returned by the worker; the `url` is the full
|
|
68
|
+
* public URL the user shared with reviewers.
|
|
69
|
+
*/
|
|
70
|
+
export interface ShareEntry {
|
|
71
|
+
shareId: string;
|
|
72
|
+
url: string;
|
|
73
|
+
revision: number;
|
|
74
|
+
createdAt: string;
|
|
75
|
+
/** Bearer token returned by the worker — proves ownership for revoke. */
|
|
76
|
+
ownerToken?: string;
|
|
77
|
+
/** ISO timestamp when the share will expire on the worker side. */
|
|
78
|
+
expiresAt?: string;
|
|
79
|
+
/** ISO timestamp of the most recent remote feedback we've seen */
|
|
80
|
+
lastFeedbackAt?: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
25
83
|
export interface SessionData {
|
|
26
84
|
id: string;
|
|
27
85
|
projectRoot: string;
|
|
@@ -30,6 +88,7 @@ export interface SessionData {
|
|
|
30
88
|
revisions: RevisionInfo[];
|
|
31
89
|
createdAt: string;
|
|
32
90
|
updatedAt: string;
|
|
91
|
+
shares?: ShareEntry[];
|
|
33
92
|
}
|
|
34
93
|
|
|
35
94
|
interface SessionMeta {
|
|
@@ -38,6 +97,7 @@ interface SessionMeta {
|
|
|
38
97
|
updatedAt: string;
|
|
39
98
|
currentRevision: number;
|
|
40
99
|
revisions: RevisionInfo[];
|
|
100
|
+
shares?: ShareEntry[];
|
|
41
101
|
}
|
|
42
102
|
|
|
43
103
|
// Legacy format (flat files, pre-revision)
|
|
@@ -85,14 +145,22 @@ function computeLineDiffStats(oldText: string, newText: string): DiffStats {
|
|
|
85
145
|
|
|
86
146
|
export class SessionManager {
|
|
87
147
|
private sessions = new Map<string, SessionData>();
|
|
148
|
+
private readonly sessionsDir: string;
|
|
88
149
|
|
|
89
|
-
|
|
90
|
-
|
|
150
|
+
/**
|
|
151
|
+
* @param sessionsDir Override the on-disk sessions root. Defaults to
|
|
152
|
+
* the global SESSIONS_DIR (`~/.claude/agent-canvas/sessions`).
|
|
153
|
+
* Tests pass an isolated temp directory so they don't interfere with
|
|
154
|
+
* the user's real sessions.
|
|
155
|
+
*/
|
|
156
|
+
constructor(sessionsDir?: string) {
|
|
157
|
+
this.sessionsDir = sessionsDir ?? SESSIONS_DIR;
|
|
158
|
+
mkdirSync(this.sessionsDir, { recursive: true });
|
|
91
159
|
this.loadFromDisk();
|
|
92
160
|
}
|
|
93
161
|
|
|
94
162
|
private sessionDir(id: string): string {
|
|
95
|
-
return join(
|
|
163
|
+
return join(this.sessionsDir, id);
|
|
96
164
|
}
|
|
97
165
|
|
|
98
166
|
private revisionDir(id: string, rev: number): string {
|
|
@@ -126,10 +194,10 @@ export class SessionManager {
|
|
|
126
194
|
}
|
|
127
195
|
|
|
128
196
|
private loadFromDisk() {
|
|
129
|
-
if (!existsSync(
|
|
130
|
-
for (const name of readdirSync(
|
|
197
|
+
if (!existsSync(this.sessionsDir)) return;
|
|
198
|
+
for (const name of readdirSync(this.sessionsDir, { withFileTypes: true })) {
|
|
131
199
|
if (!name.isDirectory()) continue;
|
|
132
|
-
const dir = join(
|
|
200
|
+
const dir = join(this.sessionsDir, name.name);
|
|
133
201
|
const metaPath = join(dir, "meta.json");
|
|
134
202
|
if (!existsSync(metaPath)) continue;
|
|
135
203
|
|
|
@@ -186,6 +254,7 @@ export class SessionManager {
|
|
|
186
254
|
revisions: meta.revisions,
|
|
187
255
|
createdAt: meta.createdAt,
|
|
188
256
|
updatedAt: meta.updatedAt,
|
|
257
|
+
...(meta.shares ? { shares: meta.shares } : {}),
|
|
189
258
|
});
|
|
190
259
|
} catch {}
|
|
191
260
|
}
|
|
@@ -321,10 +390,46 @@ export class SessionManager {
|
|
|
321
390
|
updatedAt: session.updatedAt,
|
|
322
391
|
currentRevision: session.currentRevision,
|
|
323
392
|
revisions: session.revisions,
|
|
393
|
+
...(session.shares?.length ? { shares: session.shares } : {}),
|
|
324
394
|
};
|
|
325
395
|
writeFileSync(join(this.sessionDir(session.id), "meta.json"), JSON.stringify(meta, null, 2));
|
|
326
396
|
}
|
|
327
397
|
|
|
398
|
+
/** Record a new share for a session revision. */
|
|
399
|
+
addShare(id: string, entry: ShareEntry): void {
|
|
400
|
+
const session = this.sessions.get(id);
|
|
401
|
+
if (!session) return;
|
|
402
|
+
session.shares = [...(session.shares ?? []), entry];
|
|
403
|
+
session.updatedAt = new Date().toISOString();
|
|
404
|
+
this.persistMeta(session);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
getShares(id: string): ShareEntry[] {
|
|
408
|
+
return this.sessions.get(id)?.shares ?? [];
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
removeShare(id: string, shareId: string): void {
|
|
412
|
+
const session = this.sessions.get(id);
|
|
413
|
+
if (!session?.shares) return;
|
|
414
|
+
session.shares = session.shares.filter((s) => s.shareId !== shareId);
|
|
415
|
+
session.updatedAt = new Date().toISOString();
|
|
416
|
+
this.persistMeta(session);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
updateShareLastFeedback(id: string, shareId: string, at: string): void {
|
|
420
|
+
const session = this.sessions.get(id);
|
|
421
|
+
if (!session?.shares) return;
|
|
422
|
+
const entry = session.shares.find((s) => s.shareId === shareId);
|
|
423
|
+
if (!entry) return;
|
|
424
|
+
entry.lastFeedbackAt = at;
|
|
425
|
+
this.persistMeta(session);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** List all sessions that have at least one share. Used by the remote feedback poller. */
|
|
429
|
+
listSessionsWithShares(): SessionData[] {
|
|
430
|
+
return Array.from(this.sessions.values()).filter((s) => (s.shares?.length ?? 0) > 0);
|
|
431
|
+
}
|
|
432
|
+
|
|
328
433
|
private persistToDisk(session: SessionData, canvasFiles: Map<string, string>) {
|
|
329
434
|
const revDir = this.revisionDir(session.id, session.currentRevision);
|
|
330
435
|
mkdirSync(revDir, { recursive: true });
|
|
@@ -419,6 +524,34 @@ export class SessionManager {
|
|
|
419
524
|
return join(this.revisionDir(id, rev), filename);
|
|
420
525
|
}
|
|
421
526
|
|
|
527
|
+
/**
|
|
528
|
+
* Remote feedback from shared views. Stored as one JSON file per revision
|
|
529
|
+
* at `revisions/{rev}/remote_feedback.json`, containing an array of
|
|
530
|
+
* RemoteFeedbackEntry. Appended to (not replaced) as new feedback arrives.
|
|
531
|
+
*/
|
|
532
|
+
appendRemoteFeedback(id: string, rev: number, entries: RemoteFeedbackEntry[]): void {
|
|
533
|
+
if (entries.length === 0) return;
|
|
534
|
+
const revDir = this.revisionDir(id, rev);
|
|
535
|
+
mkdirSync(revDir, { recursive: true });
|
|
536
|
+
const file = join(revDir, "remote_feedback.json");
|
|
537
|
+
let existing: RemoteFeedbackEntry[] = [];
|
|
538
|
+
try {
|
|
539
|
+
existing = JSON.parse(readFileSync(file, "utf-8"));
|
|
540
|
+
} catch {}
|
|
541
|
+
const seen = new Set(existing.map((e) => e.id));
|
|
542
|
+
const merged = [...existing];
|
|
543
|
+
for (const e of entries) if (!seen.has(e.id)) merged.push(e);
|
|
544
|
+
writeFileSync(file, JSON.stringify(merged, null, 2));
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
getRemoteFeedback(id: string, rev: number): RemoteFeedbackEntry[] {
|
|
548
|
+
try {
|
|
549
|
+
return JSON.parse(readFileSync(join(this.revisionDir(id, rev), "remote_feedback.json"), "utf-8"));
|
|
550
|
+
} catch {
|
|
551
|
+
return [];
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
422
555
|
cleanupStale(maxAge = STALE_TIMEOUT_MS) {
|
|
423
556
|
const now = Date.now();
|
|
424
557
|
for (const [id, session] of this.sessions) {
|