@starlove/orbit 1.0.1

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.
Files changed (86) hide show
  1. package/.dockerignore +13 -0
  2. package/Dockerfile +34 -0
  3. package/README.md +216 -0
  4. package/SKILL-ORBIT.md +94 -0
  5. package/build.mjs +204 -0
  6. package/dist/full/apple-touch-icon.png +0 -0
  7. package/dist/full/cursors/live_arrow.cur +0 -0
  8. package/dist/full/cursors/live_link.cur +0 -0
  9. package/dist/full/cursors/live_move.cur +0 -0
  10. package/dist/full/cursors/live_select.cur +0 -0
  11. package/dist/full/cursors/live_unavail.cur +0 -0
  12. package/dist/full/favicon-32x32.png +0 -0
  13. package/dist/full/index.html +314 -0
  14. package/dist/full/js/app.70be7d06.js +554 -0
  15. package/dist/full/js/app.70be7d06.js.map +7 -0
  16. package/dist/full/orbits-favicon.png +0 -0
  17. package/dist/full/orbits-logo-bg.png +0 -0
  18. package/dist/full/orbits-logo.png +0 -0
  19. package/dist/full/styles.c2e3d20a.css +4892 -0
  20. package/dist/full/version.json +6 -0
  21. package/docs/AGENT_PROTOCOL.md +379 -0
  22. package/docs/MCP_SETUP.md +49 -0
  23. package/docs/mcp-client.example.json +13 -0
  24. package/package.json +62 -0
  25. package/pnpm-workspace.yaml +2 -0
  26. package/public/apple-touch-icon.png +0 -0
  27. package/public/cursors/live_arrow.cur +0 -0
  28. package/public/cursors/live_link.cur +0 -0
  29. package/public/cursors/live_move.cur +0 -0
  30. package/public/cursors/live_select.cur +0 -0
  31. package/public/cursors/live_unavail.cur +0 -0
  32. package/public/favicon-32x32.png +0 -0
  33. package/public/index.html +314 -0
  34. package/public/js/api.js +35 -0
  35. package/public/js/app.js +35 -0
  36. package/public/js/board-menu.js +145 -0
  37. package/public/js/config.js +19 -0
  38. package/public/js/create-card.js +153 -0
  39. package/public/js/dom.js +19 -0
  40. package/public/js/drawer.js +137 -0
  41. package/public/js/format.js +108 -0
  42. package/public/js/kanban.js +475 -0
  43. package/public/js/local-backend.js +934 -0
  44. package/public/js/main.js +142 -0
  45. package/public/js/modal.js +31 -0
  46. package/public/js/router.js +114 -0
  47. package/public/js/search.js +53 -0
  48. package/public/js/settings-tabs.js +7 -0
  49. package/public/js/settings.js +931 -0
  50. package/public/js/sse.js +156 -0
  51. package/public/js/starscape.js +315 -0
  52. package/public/js/state.js +54 -0
  53. package/public/js/ticket-detail.js +766 -0
  54. package/public/js/toast.js +74 -0
  55. package/public/js/transport.js +22 -0
  56. package/public/js/unread.js +121 -0
  57. package/public/orbits-favicon.png +0 -0
  58. package/public/orbits-logo-bg.png +0 -0
  59. package/public/orbits-logo.png +0 -0
  60. package/public/styles.css +4892 -0
  61. package/src/cli/orbit.js +438 -0
  62. package/src/core/agent.js +262 -0
  63. package/src/core/auth.js +49 -0
  64. package/src/core/backups.js +136 -0
  65. package/src/core/board.js +112 -0
  66. package/src/core/boards.js +310 -0
  67. package/src/core/bootstrap.js +162 -0
  68. package/src/core/db.js +237 -0
  69. package/src/core/delete-board.js +53 -0
  70. package/src/core/events.js +314 -0
  71. package/src/core/http.js +81 -0
  72. package/src/core/paths.js +25 -0
  73. package/src/core/provision-repo-board.js +113 -0
  74. package/src/core/queries.js +246 -0
  75. package/src/core/registry.js +207 -0
  76. package/src/core/relations.js +163 -0
  77. package/src/core/router.js +414 -0
  78. package/src/core/search.js +70 -0
  79. package/src/core/seed.js +226 -0
  80. package/src/core/snapshots.js +255 -0
  81. package/src/core/states.js +167 -0
  82. package/src/core/system-picker.js +93 -0
  83. package/src/core/tickets.js +344 -0
  84. package/src/core/util.js +105 -0
  85. package/src/mcp-server.js +773 -0
  86. package/src/server.js +48 -0
@@ -0,0 +1,934 @@
1
+ // Preview-edition local backend. Replaces transport.js at build time so the
2
+ // app's existing `api()` calls land in IndexedDB instead of an HTTP server.
3
+ // Single-board, no auth, no agent routes — just enough surface to drive the
4
+ // kanban UI for a "try it in the browser" demo.
5
+ //
6
+ // Shape parity matters more than internal cleanliness here: every response
7
+ // has to look enough like the server's JSON that the UI doesn't notice.
8
+
9
+ const DB_NAME = "starscape-orbit-preview";
10
+ const DB_VERSION = 1;
11
+ const BOARD_ID = "preview-board";
12
+
13
+ // Object stores (each is a SQL table, roughly).
14
+ const STORES = [
15
+ "boards", // single record id=BOARD_ID
16
+ "states", // {id, board_id, name, position, is_default, role, created_at}
17
+ "labels", // {id, board_id, name, color, created_at}
18
+ "tickets", // full ticket row (no joins)
19
+ "ticket_labels", // {ticket_id, label_id} composite key as `${ticket_id}|${label_id}`
20
+ "comments", // {id, ticket_id, author, kind, body, created_at}
21
+ "relations", // {id, source_ticket_id, target_ticket_id, kind, created_at, ...}
22
+ "board_entries", // {id, board_id, type, title, body, ticket_id, struck_at, created_at, updated_at}
23
+ "events", // {id, board_id, ticket_id, type, actor, body_json, created_at}
24
+ "meta" // {key, value} — schema_seeded, etc.
25
+ ];
26
+
27
+ let dbPromise = null;
28
+
29
+ function openDb() {
30
+ if (dbPromise) return dbPromise;
31
+ dbPromise = new Promise((resolve, reject) => {
32
+ const req = indexedDB.open(DB_NAME, DB_VERSION);
33
+ req.onupgradeneeded = () => {
34
+ const db = req.result;
35
+ for (const name of STORES) {
36
+ if (db.objectStoreNames.contains(name)) continue;
37
+ if (name === "ticket_labels") {
38
+ const store = db.createObjectStore(name, { keyPath: "key" });
39
+ store.createIndex("ticket_id", "ticket_id");
40
+ store.createIndex("label_id", "label_id");
41
+ } else if (name === "meta") {
42
+ db.createObjectStore(name, { keyPath: "key" });
43
+ } else {
44
+ const store = db.createObjectStore(name, { keyPath: "id" });
45
+ if (name === "tickets") {
46
+ store.createIndex("board_id", "board_id");
47
+ store.createIndex("number", "number");
48
+ store.createIndex("parent_ticket_id", "parent_ticket_id");
49
+ }
50
+ if (name === "comments" || name === "events") {
51
+ store.createIndex("ticket_id", "ticket_id");
52
+ }
53
+ if (name === "relations") {
54
+ store.createIndex("source_ticket_id", "source_ticket_id");
55
+ store.createIndex("target_ticket_id", "target_ticket_id");
56
+ }
57
+ }
58
+ }
59
+ };
60
+ req.onsuccess = () => resolve(req.result);
61
+ req.onerror = () => reject(req.error);
62
+ });
63
+ return dbPromise;
64
+ }
65
+
66
+ function tx(db, storeNames, mode = "readonly") {
67
+ const t = db.transaction(storeNames, mode);
68
+ const stores = {};
69
+ for (const name of storeNames) stores[name] = t.objectStore(name);
70
+ const done = new Promise((resolve, reject) => {
71
+ t.oncomplete = () => resolve();
72
+ t.onerror = () => reject(t.error);
73
+ t.onabort = () => reject(t.error);
74
+ });
75
+ return { stores, done };
76
+ }
77
+
78
+ function reqPromise(req) {
79
+ return new Promise((resolve, reject) => {
80
+ req.onsuccess = () => resolve(req.result);
81
+ req.onerror = () => reject(req.error);
82
+ });
83
+ }
84
+
85
+ async function getAll(store, query, indexName) {
86
+ const target = indexName ? store.index(indexName) : store;
87
+ return reqPromise(target.getAll(query));
88
+ }
89
+
90
+ // ──────────────────────────────────────────────────────────────────────────
91
+ // Helpers
92
+
93
+ function uid() {
94
+ return crypto.randomUUID();
95
+ }
96
+ function nowIso() {
97
+ return new Date().toISOString();
98
+ }
99
+ function tlKey(ticketId, labelId) {
100
+ return `${ticketId}|${labelId}`;
101
+ }
102
+ function slugify(input) {
103
+ return String(input || "")
104
+ .toLowerCase()
105
+ .trim()
106
+ .replace(/[^a-z0-9]+/g, "-")
107
+ .replace(/^-+|-+$/g, "")
108
+ .slice(0, 60) || "label";
109
+ }
110
+ function err(status, code) {
111
+ const e = new Error(code);
112
+ e.status = status;
113
+ e.payload = { error: code };
114
+ return e;
115
+ }
116
+
117
+ // ──────────────────────────────────────────────────────────────────────────
118
+ // Seed (one-shot, mirrors src/core/seed.js minus the parent-cycle business)
119
+
120
+ async function seedIfEmpty(db) {
121
+ const { stores: ro } = tx(db, ["meta"], "readonly");
122
+ const seeded = await reqPromise(ro.meta.get("schema_seeded"));
123
+ if (seeded?.value) return;
124
+
125
+ const t = nowIso();
126
+ const stateRows = [
127
+ ["Backlog", null],
128
+ ["Todo", null],
129
+ ["In Progress", "in_progress"],
130
+ ["Review", "review"],
131
+ ["Done", "done"],
132
+ ["Cancelled", null]
133
+ ].map(([name, role], position) => ({
134
+ id: uid(),
135
+ board_id: BOARD_ID,
136
+ name,
137
+ position,
138
+ is_default: name === "Todo" ? 1 : 0,
139
+ role,
140
+ created_at: t
141
+ }));
142
+ const stateByName = Object.fromEntries(stateRows.map((s) => [s.name, s]));
143
+
144
+ const labelRows = [
145
+ ["human-only", "#7c2d12"],
146
+ ["needs-human-input", "#dc2626"],
147
+ ["needs-decomposition", "#9333ea"],
148
+ ["needs-followup", "#b45309"],
149
+ ["tech-debt", "#64748b"],
150
+ ["security", "#991b1b"],
151
+ ["onboarding", "#0f766e"]
152
+ ].map(([name, color]) => ({
153
+ id: uid(),
154
+ board_id: BOARD_ID,
155
+ name,
156
+ color,
157
+ created_at: t
158
+ }));
159
+ const labelByName = Object.fromEntries(labelRows.map((l) => [l.name, l]));
160
+
161
+ const board = {
162
+ id: BOARD_ID,
163
+ slug: "preview",
164
+ name: "Preview Board",
165
+ repo_url: "",
166
+ system_path: "",
167
+ default_branch: "main",
168
+ project_notes:
169
+ "This preview lives entirely in your browser (IndexedDB). Use it to try the flow; export the snapshot from Settings → Repository when you want to move to a real Orbit install.",
170
+ agent_instructions:
171
+ "Preview edition has no agent or MCP integration. The full Orbit install (npm i -g starscape-orbit && orbit init) provides those.",
172
+ ai_enabled: 0,
173
+ repo_path: "",
174
+ created_at: t,
175
+ updated_at: t
176
+ };
177
+
178
+ const tickets = [];
179
+ const ticketLabels = [];
180
+ const comments = [];
181
+
182
+ function addTicket(number, fields) {
183
+ const id = uid();
184
+ tickets.push({
185
+ id,
186
+ board_id: BOARD_ID,
187
+ number,
188
+ title: fields.title,
189
+ description: fields.description,
190
+ type: fields.type,
191
+ parent_ticket_id: fields.parentTicketId || null,
192
+ ai_plan: fields.aiPlan || "",
193
+ implementation_summary: "",
194
+ implementation_updates: "",
195
+ state_id: fields.stateId,
196
+ priority: fields.priority,
197
+ created_by: "system",
198
+ archived_at: null,
199
+ created_at: t,
200
+ updated_at: t
201
+ });
202
+ for (const labelId of fields.labels || []) {
203
+ ticketLabels.push({ key: tlKey(id, labelId), ticket_id: id, label_id: labelId });
204
+ }
205
+ return id;
206
+ }
207
+
208
+ const epicId = addTicket(1, {
209
+ title: "Try the preview kanban",
210
+ description:
211
+ "This onboarding card is for the in-browser preview. Drag it across lanes, edit fields, add comments — it all stays in this browser only.",
212
+ type: "epic",
213
+ aiPlan: "1. Drag this card to In Progress.\n2. Add a comment.\n3. Export the snapshot from Settings → Repository.",
214
+ stateId: stateByName.Todo.id,
215
+ priority: 4,
216
+ labels: [labelByName["onboarding"].id, labelByName["needs-decomposition"].id]
217
+ });
218
+
219
+ addTicket(2, {
220
+ title: "Edit a card and watch it persist",
221
+ description: "Click into a card, change its title, refresh — your edit survives because it lives in IndexedDB.",
222
+ type: "feature",
223
+ parentTicketId: epicId,
224
+ stateId: stateByName.Todo.id,
225
+ priority: 3,
226
+ labels: [labelByName["onboarding"].id]
227
+ });
228
+
229
+ addTicket(3, {
230
+ title: "Move me to In Progress",
231
+ description: "Drag this card to the In Progress lane, then to Review.",
232
+ type: "task",
233
+ parentTicketId: epicId,
234
+ stateId: stateByName.Backlog.id,
235
+ priority: 2,
236
+ labels: [labelByName["onboarding"].id]
237
+ });
238
+
239
+ addTicket(4, {
240
+ title: "Export your snapshot when ready",
241
+ description: "Settings → Repository → Export downloads a JSON snapshot you can import into a real Orbit install.",
242
+ type: "task",
243
+ stateId: stateByName.Backlog.id,
244
+ priority: 1,
245
+ labels: [labelByName["onboarding"].id]
246
+ });
247
+
248
+ comments.push({
249
+ id: uid(),
250
+ ticket_id: epicId,
251
+ author: "system",
252
+ kind: "note",
253
+ body: "Welcome to the Orbit preview. Everything lives in your browser.",
254
+ created_at: t
255
+ });
256
+
257
+ const { stores, done } = tx(
258
+ db,
259
+ ["boards", "states", "labels", "tickets", "ticket_labels", "comments", "meta"],
260
+ "readwrite"
261
+ );
262
+ stores.boards.put(board);
263
+ stateRows.forEach((r) => stores.states.put(r));
264
+ labelRows.forEach((r) => stores.labels.put(r));
265
+ tickets.forEach((r) => stores.tickets.put(r));
266
+ ticketLabels.forEach((r) => stores.ticket_labels.put(r));
267
+ comments.forEach((r) => stores.comments.put(r));
268
+ stores.meta.put({ key: "schema_seeded", value: true });
269
+ await done;
270
+ }
271
+
272
+ // ──────────────────────────────────────────────────────────────────────────
273
+ // Snapshot helpers (full DB read, used by bootstrap / context / export)
274
+
275
+ async function readBoardSnapshot(db) {
276
+ const { stores } = tx(
277
+ db,
278
+ [
279
+ "boards",
280
+ "states",
281
+ "labels",
282
+ "tickets",
283
+ "ticket_labels",
284
+ "comments",
285
+ "relations",
286
+ "board_entries",
287
+ "events"
288
+ ],
289
+ "readonly"
290
+ );
291
+ const [board, states, labels, tickets, ticketLabels, comments, relations, boardEntries, events] = await Promise.all([
292
+ reqPromise(stores.boards.get(BOARD_ID)),
293
+ getAll(stores.states),
294
+ getAll(stores.labels),
295
+ getAll(stores.tickets),
296
+ getAll(stores.ticket_labels),
297
+ getAll(stores.comments),
298
+ getAll(stores.relations),
299
+ getAll(stores.board_entries),
300
+ getAll(stores.events)
301
+ ]);
302
+ return { board, states, labels, tickets, ticketLabels, comments, relations, boardEntries, events };
303
+ }
304
+
305
+ function decorateTickets(snapshot, includeArchived = false) {
306
+ const { board, states, labels, tickets, ticketLabels, comments } = snapshot;
307
+ const labelById = Object.fromEntries(labels.map((l) => [l.id, l]));
308
+ const stateById = Object.fromEntries(states.map((s) => [s.id, s]));
309
+ const visible = tickets.filter((t) => includeArchived || !t.archived_at);
310
+ const sorted = [...visible].sort((a, b) => String(b.updated_at).localeCompare(String(a.updated_at)));
311
+ return sorted.map((ticket) => {
312
+ const stateRow = stateById[ticket.state_id];
313
+ const tLabels = ticketLabels
314
+ .filter((tl) => tl.ticket_id === ticket.id)
315
+ .map((tl) => labelById[tl.label_id])
316
+ .filter(Boolean)
317
+ .map((l) => ({ ticket_id: ticket.id, id: l.id, name: l.name, color: l.color }));
318
+ const commentCount = comments.filter((c) => c.ticket_id === ticket.id).length;
319
+ const childCount = tickets.filter((t) => t.parent_ticket_id === ticket.id && !t.archived_at).length;
320
+ const parent = ticket.parent_ticket_id ? tickets.find((t) => t.id === ticket.parent_ticket_id) : null;
321
+ const parentState = parent ? stateById[parent.state_id] : null;
322
+ return {
323
+ ...ticket,
324
+ state_name: stateRow?.name || "",
325
+ state_role: stateRow?.role || null,
326
+ board_slug: board.slug,
327
+ board_name: board.name,
328
+ labels: tLabels,
329
+ comment_count: commentCount,
330
+ child_count: childCount,
331
+ parent_ticket: parent
332
+ ? {
333
+ id: parent.id,
334
+ board_id: parent.board_id,
335
+ board_slug: board.slug,
336
+ number: parent.number,
337
+ title: parent.title,
338
+ type: parent.type,
339
+ state_name: parentState?.name || "",
340
+ state_role: parentState?.role || null,
341
+ priority: parent.priority
342
+ }
343
+ : null
344
+ };
345
+ });
346
+ }
347
+
348
+ // ──────────────────────────────────────────────────────────────────────────
349
+ // Mutation helpers
350
+
351
+ async function putAndEvent(db, mutate) {
352
+ const { stores, done } = tx(
353
+ db,
354
+ ["boards", "states", "labels", "tickets", "ticket_labels", "comments", "relations", "board_entries", "events"],
355
+ "readwrite"
356
+ );
357
+ const result = await mutate(stores);
358
+ await done;
359
+ return result;
360
+ }
361
+
362
+ function recordEvent(stores, type, ticketId, body) {
363
+ const ev = {
364
+ id: uid(),
365
+ board_id: BOARD_ID,
366
+ ticket_id: ticketId || null,
367
+ type,
368
+ actor: "you",
369
+ body_json: JSON.stringify(body || {}),
370
+ created_at: nowIso()
371
+ };
372
+ stores.events.put(ev);
373
+ return ev;
374
+ }
375
+
376
+ async function ensureLabel(stores, name) {
377
+ const all = await getAll(stores.labels);
378
+ const existing = all.find((l) => l.name.toLowerCase() === String(name).toLowerCase());
379
+ if (existing) return existing;
380
+ const created = {
381
+ id: uid(),
382
+ board_id: BOARD_ID,
383
+ name: slugify(name),
384
+ color: "#64748b",
385
+ created_at: nowIso()
386
+ };
387
+ stores.labels.put(created);
388
+ return created;
389
+ }
390
+
391
+ // ──────────────────────────────────────────────────────────────────────────
392
+ // Handlers
393
+
394
+ async function handleBootstrap() {
395
+ const db = await openDb();
396
+ await seedIfEmpty(db);
397
+ const snap = await readBoardSnapshot(db);
398
+ const tickets = decorateTickets(snap);
399
+ return {
400
+ actor: { name: "you", role: "owner" },
401
+ meta: { db_path: null, export_dir: null },
402
+ boards: [snap.board],
403
+ board_entries: [...snap.boardEntries].sort((a, b) =>
404
+ String(b.created_at).localeCompare(String(a.created_at))
405
+ ),
406
+ states: [...snap.states].sort((a, b) => a.position - b.position),
407
+ labels: [...snap.labels].sort((a, b) => a.name.localeCompare(b.name)),
408
+ tickets
409
+ };
410
+ }
411
+
412
+ async function handleBoardsList() {
413
+ const db = await openDb();
414
+ await seedIfEmpty(db);
415
+ const { stores } = tx(db, ["boards"], "readonly");
416
+ const board = await reqPromise(stores.boards.get(BOARD_ID));
417
+ return {
418
+ boards: [
419
+ {
420
+ id: board.id,
421
+ slug: board.slug,
422
+ name: board.name,
423
+ repo_path: board.repo_path || "",
424
+ repo_url: board.repo_url || "",
425
+ default_branch: board.default_branch,
426
+ db_path: null,
427
+ last_active_at: board.updated_at
428
+ }
429
+ ]
430
+ };
431
+ }
432
+
433
+ async function handleBoardContext(boardId, includeStruck) {
434
+ if (boardId !== BOARD_ID) throw err(404, "board_not_found");
435
+ const db = await openDb();
436
+ await seedIfEmpty(db);
437
+ const snap = await readBoardSnapshot(db);
438
+ const entries = (includeStruck ? snap.boardEntries : snap.boardEntries.filter((e) => !e.struck_at)).sort(
439
+ (a, b) => String(b.created_at).localeCompare(String(a.created_at))
440
+ );
441
+ return {
442
+ board: snap.board,
443
+ entries,
444
+ deployment: { app_root: "browser", system_path: "", data_dir: "", mcp_server_path: "", db_path: null }
445
+ };
446
+ }
447
+
448
+ async function handleBoardArchive(boardId) {
449
+ if (boardId !== BOARD_ID) throw err(404, "board_not_found");
450
+ const db = await openDb();
451
+ const snap = await readBoardSnapshot(db);
452
+ const tickets = decorateTickets(snap, true).filter((t) => t.archived_at);
453
+ return { tickets };
454
+ }
455
+
456
+ async function handleBoardPatch(boardId, body) {
457
+ if (boardId !== BOARD_ID) throw err(404, "board_not_found");
458
+ const db = await openDb();
459
+ return putAndEvent(db, async (stores) => {
460
+ const board = await reqPromise(stores.boards.get(BOARD_ID));
461
+ const ALLOWED = [
462
+ "name",
463
+ "slug",
464
+ "repo_url",
465
+ "system_path",
466
+ "default_branch",
467
+ "project_notes",
468
+ "agent_instructions",
469
+ "ai_enabled"
470
+ ];
471
+ for (const k of ALLOWED) if (k in body) board[k] = body[k];
472
+ board.updated_at = nowIso();
473
+ stores.boards.put(board);
474
+ recordEvent(stores, "board_updated", null, { fields: Object.keys(body) });
475
+ return board;
476
+ });
477
+ }
478
+
479
+ async function handleBoardEntries(boardId, body) {
480
+ if (boardId !== BOARD_ID) throw err(404, "board_not_found");
481
+ const db = await openDb();
482
+ return putAndEvent(db, async (stores) => {
483
+ const entry = {
484
+ id: uid(),
485
+ board_id: BOARD_ID,
486
+ type: body.type || "note",
487
+ title: body.title || "",
488
+ body: body.body || "",
489
+ ticket_id: body.ticket_id || null,
490
+ struck_at: null,
491
+ created_by: "you",
492
+ created_at: nowIso(),
493
+ updated_at: nowIso()
494
+ };
495
+ stores.board_entries.put(entry);
496
+ recordEvent(stores, "board_entry_created", entry.ticket_id, { entry_id: entry.id, type: entry.type });
497
+ return entry;
498
+ });
499
+ }
500
+
501
+ async function handleBoardEntryPatch(entryId, body) {
502
+ const db = await openDb();
503
+ return putAndEvent(db, async (stores) => {
504
+ const entry = await reqPromise(stores.board_entries.get(entryId));
505
+ if (!entry) throw err(404, "board_entry_not_found");
506
+ const shouldStrike = Boolean(body.struck);
507
+ entry.struck_at = shouldStrike ? entry.struck_at || nowIso() : null;
508
+ entry.updated_at = nowIso();
509
+ stores.board_entries.put(entry);
510
+ return entry;
511
+ });
512
+ }
513
+
514
+ async function handleStateCreate(boardId, body) {
515
+ if (boardId !== BOARD_ID) throw err(404, "board_not_found");
516
+ const db = await openDb();
517
+ return putAndEvent(db, async (stores) => {
518
+ const states = await getAll(stores.states);
519
+ const maxPos = states.reduce((a, s) => Math.max(a, s.position), -1);
520
+ const state = {
521
+ id: uid(),
522
+ board_id: BOARD_ID,
523
+ name: body.name || "New",
524
+ position: maxPos + 1,
525
+ is_default: 0,
526
+ role: body.role || null,
527
+ created_at: nowIso()
528
+ };
529
+ stores.states.put(state);
530
+ recordEvent(stores, "state_created", null, { state_id: state.id });
531
+ return state;
532
+ });
533
+ }
534
+
535
+ async function handleStatePatch(stateId, body) {
536
+ const db = await openDb();
537
+ return putAndEvent(db, async (stores) => {
538
+ const state = await reqPromise(stores.states.get(stateId));
539
+ if (!state) throw err(404, "state_not_found");
540
+ for (const k of ["name", "role"]) if (k in body) state[k] = body[k];
541
+ stores.states.put(state);
542
+ return state;
543
+ });
544
+ }
545
+
546
+ async function handleStateDelete(stateId) {
547
+ const db = await openDb();
548
+ return putAndEvent(db, async (stores) => {
549
+ const state = await reqPromise(stores.states.get(stateId));
550
+ if (!state) throw err(404, "state_not_found");
551
+ const tickets = await getAll(stores.tickets);
552
+ if (tickets.some((t) => t.state_id === stateId && !t.archived_at)) {
553
+ throw err(409, "state_has_tickets");
554
+ }
555
+ stores.states.delete(stateId);
556
+ recordEvent(stores, "state_deleted", null, { state_id: stateId });
557
+ return { ok: true };
558
+ });
559
+ }
560
+
561
+ async function handleStateReorder(boardId, body) {
562
+ if (boardId !== BOARD_ID) throw err(404, "board_not_found");
563
+ const db = await openDb();
564
+ return putAndEvent(db, async (stores) => {
565
+ const order = body.order || body.state_ids || [];
566
+ for (let i = 0; i < order.length; i++) {
567
+ const state = await reqPromise(stores.states.get(order[i]));
568
+ if (!state) continue;
569
+ state.position = i;
570
+ stores.states.put(state);
571
+ }
572
+ recordEvent(stores, "states_reordered", null, { order });
573
+ const states = await getAll(stores.states);
574
+ return { states: states.sort((a, b) => a.position - b.position) };
575
+ });
576
+ }
577
+
578
+ async function handleTicketCreate(body) {
579
+ const db = await openDb();
580
+ return putAndEvent(db, async (stores) => {
581
+ const states = await getAll(stores.states);
582
+ const stateId =
583
+ body.state_id ||
584
+ states.find((s) => s.is_default)?.id ||
585
+ states.sort((a, b) => a.position - b.position)[0]?.id;
586
+ if (!stateId) throw err(400, "no_states");
587
+ const tickets = await getAll(stores.tickets);
588
+ const nextNumber = tickets.reduce((a, t) => Math.max(a, t.number || 0), 0) + 1;
589
+ const ticket = {
590
+ id: uid(),
591
+ board_id: BOARD_ID,
592
+ number: nextNumber,
593
+ title: body.title || "Untitled",
594
+ description: body.description || "",
595
+ type: body.type || (body.parent_ticket_id ? "feature" : "task"),
596
+ parent_ticket_id: body.parent_ticket_id || null,
597
+ ai_plan: body.ai_plan || "",
598
+ implementation_summary: body.implementation_summary || "",
599
+ implementation_updates: body.implementation_updates || "",
600
+ state_id: stateId,
601
+ priority: body.priority ?? 0,
602
+ created_by: "you",
603
+ archived_at: null,
604
+ created_at: nowIso(),
605
+ updated_at: nowIso()
606
+ };
607
+ stores.tickets.put(ticket);
608
+ for (const name of body.labels || []) {
609
+ const label = await ensureLabel(stores, name);
610
+ stores.ticket_labels.put({ key: tlKey(ticket.id, label.id), ticket_id: ticket.id, label_id: label.id });
611
+ }
612
+ recordEvent(stores, "ticket_created", ticket.id, { title: ticket.title, type: ticket.type });
613
+ return ticket;
614
+ });
615
+ }
616
+
617
+ async function handleTicketPatch(ticketId, body) {
618
+ const db = await openDb();
619
+ return putAndEvent(db, async (stores) => {
620
+ const ticket = await reqPromise(stores.tickets.get(ticketId));
621
+ if (!ticket) throw err(404, "ticket_not_found");
622
+ const oldStateId = ticket.state_id;
623
+ const SIMPLE = [
624
+ "title",
625
+ "description",
626
+ "type",
627
+ "parent_ticket_id",
628
+ "ai_plan",
629
+ "implementation_summary",
630
+ "implementation_updates",
631
+ "priority"
632
+ ];
633
+ for (const k of SIMPLE) if (k in body) ticket[k] = body[k];
634
+ if ("state_id" in body) ticket.state_id = body.state_id;
635
+ ticket.updated_at = nowIso();
636
+ stores.tickets.put(ticket);
637
+
638
+ if ("labels" in body) {
639
+ const existing = await getAll(stores.ticket_labels, ticketId, "ticket_id");
640
+ for (const row of existing) stores.ticket_labels.delete(row.key);
641
+ for (const name of body.labels || []) {
642
+ const label = await ensureLabel(stores, name);
643
+ stores.ticket_labels.put({ key: tlKey(ticketId, label.id), ticket_id: ticketId, label_id: label.id });
644
+ }
645
+ }
646
+
647
+ if (oldStateId !== ticket.state_id) {
648
+ const states = await getAll(stores.states);
649
+ const fromState = states.find((s) => s.id === oldStateId);
650
+ const toState = states.find((s) => s.id === ticket.state_id);
651
+ // Mirror tickets.js: when a ticket lands in role='done', auto-delete
652
+ // every relation row that names it as a blocker.
653
+ if (toState?.role === "done" && fromState?.role !== "done") {
654
+ const relations = await getAll(stores.relations);
655
+ for (const r of relations) {
656
+ if ((r.source_ticket_id === ticketId && r.type === "blocks") ||
657
+ (r.target_ticket_id === ticketId && r.type === "blocked_by")) {
658
+ stores.relations.delete(r.id);
659
+ }
660
+ }
661
+ }
662
+ recordEvent(stores, "state_changed", ticketId, { from: fromState?.name || "", to: toState?.name || "" });
663
+ } else {
664
+ recordEvent(stores, "ticket_updated", ticketId, { fields: Object.keys(body) });
665
+ }
666
+ return ticket;
667
+ });
668
+ }
669
+
670
+ async function handleTicketDelete(ticketId) {
671
+ const db = await openDb();
672
+ return putAndEvent(db, async (stores) => {
673
+ const ticket = await reqPromise(stores.tickets.get(ticketId));
674
+ if (!ticket) throw err(404, "ticket_not_found");
675
+ stores.tickets.delete(ticketId);
676
+ const tlinks = await getAll(stores.ticket_labels, ticketId, "ticket_id");
677
+ for (const row of tlinks) stores.ticket_labels.delete(row.key);
678
+ const cmts = await getAll(stores.comments, ticketId, "ticket_id");
679
+ for (const c of cmts) stores.comments.delete(c.id);
680
+ recordEvent(stores, "ticket_deleted", ticketId, {});
681
+ return { ok: true };
682
+ });
683
+ }
684
+
685
+ async function handleTicketArchive(ticketId, archive) {
686
+ const db = await openDb();
687
+ return putAndEvent(db, async (stores) => {
688
+ const ticket = await reqPromise(stores.tickets.get(ticketId));
689
+ if (!ticket) throw err(404, "ticket_not_found");
690
+ ticket.archived_at = archive ? nowIso() : null;
691
+ ticket.updated_at = nowIso();
692
+ stores.tickets.put(ticket);
693
+ recordEvent(stores, archive ? "ticket_archived" : "ticket_restored", ticketId, {});
694
+ return ticket;
695
+ });
696
+ }
697
+
698
+ async function handleTicketContext(ticketId) {
699
+ const db = await openDb();
700
+ const snap = await readBoardSnapshot(db);
701
+ const ticket = snap.tickets.find((t) => t.id === ticketId);
702
+ if (!ticket) throw err(404, "ticket_not_found");
703
+ const labels = snap.ticketLabels
704
+ .filter((tl) => tl.ticket_id === ticketId)
705
+ .map((tl) => snap.labels.find((l) => l.id === tl.label_id))
706
+ .filter(Boolean)
707
+ .map((l) => ({ ticket_id: ticketId, id: l.id, name: l.name, color: l.color }));
708
+ const comments = snap.comments
709
+ .filter((c) => c.ticket_id === ticketId)
710
+ .sort((a, b) => String(a.created_at).localeCompare(String(b.created_at)));
711
+ const stateById = Object.fromEntries(snap.states.map((s) => [s.id, s]));
712
+ const compact = (t) => {
713
+ if (!t) return null;
714
+ const s = stateById[t.state_id];
715
+ return {
716
+ id: t.id,
717
+ board_id: t.board_id,
718
+ board_slug: snap.board.slug,
719
+ number: t.number,
720
+ title: t.title,
721
+ type: t.type,
722
+ state_name: s?.name || "",
723
+ state_role: s?.role || null,
724
+ priority: t.priority
725
+ };
726
+ };
727
+ const parent = ticket.parent_ticket_id ? snap.tickets.find((t) => t.id === ticket.parent_ticket_id) : null;
728
+ const childTickets = snap.tickets
729
+ .filter((t) => t.parent_ticket_id === ticketId && !t.archived_at)
730
+ .map((child) => ({
731
+ ...child,
732
+ labels: snap.ticketLabels
733
+ .filter((tl) => tl.ticket_id === child.id)
734
+ .map((tl) => snap.labels.find((l) => l.id === tl.label_id))
735
+ .filter(Boolean)
736
+ .map((l) => ({ ticket_id: child.id, id: l.id, name: l.name, color: l.color })),
737
+ blockers: [],
738
+ child_count: snap.tickets.filter((t) => t.parent_ticket_id === child.id && !t.archived_at).length
739
+ }));
740
+ const relations = snap.relations
741
+ .filter((r) => r.source_ticket_id === ticketId || r.target_ticket_id === ticketId)
742
+ .map((r) => {
743
+ const otherId = r.source_ticket_id === ticketId ? r.target_ticket_id : r.source_ticket_id;
744
+ const other = snap.tickets.find((t) => t.id === otherId);
745
+ return {
746
+ ...r,
747
+ direction: r.source_ticket_id === ticketId ? "outgoing" : "incoming",
748
+ other_ticket: compact(other)
749
+ };
750
+ });
751
+ return {
752
+ ticket: { ...ticket, labels },
753
+ board: snap.board,
754
+ board_manual: { entries: snap.boardEntries.filter((e) => !e.struck_at) },
755
+ comments,
756
+ parent_ticket: compact(parent),
757
+ child_tickets: childTickets,
758
+ relations,
759
+ blockers: [],
760
+ related_tickets: [...childTickets, ...(parent ? [parent] : [])],
761
+ related_comments: []
762
+ };
763
+ }
764
+
765
+ async function handleTicketHistory(ticketId) {
766
+ const db = await openDb();
767
+ const { stores } = tx(db, ["events"], "readonly");
768
+ const events = await getAll(stores.events, ticketId, "ticket_id");
769
+ return events
770
+ .filter((e) => ["state_changed", "checkpoint_requested", "agent_completed"].includes(e.type))
771
+ .sort((a, b) => String(a.created_at).localeCompare(String(b.created_at)))
772
+ .map((e) => ({ ...e, body: safeJson(e.body_json) }));
773
+ }
774
+
775
+ function safeJson(text) {
776
+ try {
777
+ return JSON.parse(text || "{}");
778
+ } catch {
779
+ return {};
780
+ }
781
+ }
782
+
783
+ async function handleCommentCreate(ticketId, body) {
784
+ const db = await openDb();
785
+ return putAndEvent(db, async (stores) => {
786
+ const ticket = await reqPromise(stores.tickets.get(ticketId));
787
+ if (!ticket) throw err(404, "ticket_not_found");
788
+ const comment = {
789
+ id: uid(),
790
+ ticket_id: ticketId,
791
+ author: "you",
792
+ kind: body.kind || "note",
793
+ body: body.body || "",
794
+ created_at: nowIso()
795
+ };
796
+ stores.comments.put(comment);
797
+ recordEvent(stores, "comment_created", ticketId, { kind: comment.kind });
798
+ return comment;
799
+ });
800
+ }
801
+
802
+ async function handleRelationCreate(body) {
803
+ const db = await openDb();
804
+ return putAndEvent(db, async (stores) => {
805
+ const relation = {
806
+ id: uid(),
807
+ board_id: BOARD_ID,
808
+ source_ticket_id: body.source_ticket_id,
809
+ target_ticket_id: body.target_ticket_id,
810
+ kind: body.kind || "related",
811
+ created_by: "you",
812
+ created_at: nowIso()
813
+ };
814
+ stores.relations.put(relation);
815
+ return relation;
816
+ });
817
+ }
818
+
819
+ async function handleRelationDelete(relationId) {
820
+ const db = await openDb();
821
+ return putAndEvent(db, async (stores) => {
822
+ stores.relations.delete(relationId);
823
+ return { ok: true };
824
+ });
825
+ }
826
+
827
+ async function handleSearch(query) {
828
+ const db = await openDb();
829
+ const snap = await readBoardSnapshot(db);
830
+ const q = String(query || "").toLowerCase().trim();
831
+ if (!q) return { tickets: [] };
832
+ const tickets = decorateTickets(snap).filter((t) => {
833
+ const haystack = [t.title, t.description, t.ai_plan, t.implementation_summary, `#${t.number}`]
834
+ .join(" ")
835
+ .toLowerCase();
836
+ return haystack.includes(q);
837
+ });
838
+ return { tickets: tickets.slice(0, 50) };
839
+ }
840
+
841
+ async function handleExport(boardId) {
842
+ if (boardId !== BOARD_ID) throw err(404, "board_not_found");
843
+ const db = await openDb();
844
+ const snap = await readBoardSnapshot(db);
845
+ return {
846
+ schema: "orbit.board.snapshot/v1",
847
+ exported_at: nowIso(),
848
+ board: snap.board,
849
+ states: snap.states,
850
+ labels: snap.labels,
851
+ tickets: snap.tickets,
852
+ ticket_labels: snap.ticketLabels,
853
+ comments: snap.comments,
854
+ relations: snap.relations,
855
+ board_entries: snap.boardEntries
856
+ };
857
+ }
858
+
859
+ async function handleImport(body) {
860
+ const snapshot = body.snapshot || body;
861
+ if (!snapshot || !snapshot.board) throw err(400, "invalid_snapshot");
862
+ const db = await openDb();
863
+ return putAndEvent(db, async (stores) => {
864
+ // Replace within this single browser-board: clear stores then load.
865
+ for (const name of [
866
+ "states",
867
+ "labels",
868
+ "tickets",
869
+ "ticket_labels",
870
+ "comments",
871
+ "relations",
872
+ "board_entries"
873
+ ]) {
874
+ const all = await getAll(stores[name]);
875
+ for (const row of all) stores[name].delete(row.key ?? row.id);
876
+ }
877
+ const incomingBoard = { ...snapshot.board, id: BOARD_ID };
878
+ stores.boards.put(incomingBoard);
879
+ (snapshot.states || []).forEach((s) => stores.states.put({ ...s, board_id: BOARD_ID }));
880
+ (snapshot.labels || []).forEach((l) => stores.labels.put({ ...l, board_id: BOARD_ID }));
881
+ (snapshot.tickets || []).forEach((t) => stores.tickets.put({ ...t, board_id: BOARD_ID }));
882
+ (snapshot.ticket_labels || []).forEach((tl) =>
883
+ stores.ticket_labels.put({ ...tl, key: tl.key || tlKey(tl.ticket_id, tl.label_id) })
884
+ );
885
+ (snapshot.comments || []).forEach((c) => stores.comments.put(c));
886
+ (snapshot.relations || []).forEach((r) => stores.relations.put({ ...r, board_id: BOARD_ID }));
887
+ (snapshot.board_entries || []).forEach((e) => stores.board_entries.put({ ...e, board_id: BOARD_ID }));
888
+ recordEvent(stores, "board_imported", null, { source: snapshot.board?.slug });
889
+ return { imported: true };
890
+ });
891
+ }
892
+
893
+ // ──────────────────────────────────────────────────────────────────────────
894
+ // Dispatch — match transport.js's contract: request({method, path, body})
895
+
896
+ const ROUTES = [
897
+ { m: "GET", re: /^\/api\/bootstrap$/, fn: () => handleBootstrap() },
898
+ { m: "GET", re: /^\/api\/boards$/, fn: () => handleBoardsList() },
899
+ { m: "POST", re: /^\/api\/boards$/, fn: () => { throw err(403, "preview_is_single_board"); } },
900
+ { m: "GET", re: /^\/api\/boards\/([^/]+)\/context$/, fn: (m, u) => handleBoardContext(decodeURIComponent(m[1]), u.searchParams.get("include_struck") === "true") },
901
+ { m: "GET", re: /^\/api\/boards\/([^/]+)\/archive$/, fn: (m) => handleBoardArchive(decodeURIComponent(m[1])) },
902
+ { m: "GET", re: /^\/api\/boards\/([^/]+)\/export$/, fn: (m) => handleExport(decodeURIComponent(m[1])) },
903
+ { m: "PATCH", re: /^\/api\/boards\/([^/]+)$/, fn: (m, u, body) => handleBoardPatch(decodeURIComponent(m[1]), body) },
904
+ { m: "POST", re: /^\/api\/boards\/([^/]+)\/entries$/, fn: (m, u, body) => handleBoardEntries(decodeURIComponent(m[1]), body) },
905
+ { m: "POST", re: /^\/api\/boards\/([^/]+)\/states$/, fn: (m, u, body) => handleStateCreate(decodeURIComponent(m[1]), body) },
906
+ { m: "PATCH", re: /^\/api\/boards\/([^/]+)\/states$/, fn: (m, u, body) => handleStateReorder(decodeURIComponent(m[1]), body) },
907
+ { m: "PATCH", re: /^\/api\/board-entries\/([^/]+)$/, fn: (m, u, body) => handleBoardEntryPatch(decodeURIComponent(m[1]), body) },
908
+ { m: "POST", re: /^\/api\/states$/, fn: (m, u, body) => handleStateCreate(BOARD_ID, body) },
909
+ { m: "PATCH", re: /^\/api\/states\/([^/]+)$/, fn: (m, u, body) => handleStatePatch(decodeURIComponent(m[1]), body) },
910
+ { m: "DELETE", re: /^\/api\/states\/([^/]+)$/, fn: (m) => handleStateDelete(decodeURIComponent(m[1])) },
911
+ { m: "POST", re: /^\/api\/tickets$/, fn: (m, u, body) => handleTicketCreate(body) },
912
+ { m: "PATCH", re: /^\/api\/tickets\/([^/]+)$/, fn: (m, u, body) => handleTicketPatch(decodeURIComponent(m[1]), body) },
913
+ { m: "DELETE", re: /^\/api\/tickets\/([^/]+)$/, fn: (m) => handleTicketDelete(decodeURIComponent(m[1])) },
914
+ { m: "POST", re: /^\/api\/tickets\/([^/]+)\/archive$/, fn: (m) => handleTicketArchive(decodeURIComponent(m[1]), true) },
915
+ { m: "POST", re: /^\/api\/tickets\/([^/]+)\/restore$/, fn: (m) => handleTicketArchive(decodeURIComponent(m[1]), false) },
916
+ { m: "GET", re: /^\/api\/tickets\/([^/]+)\/context$/, fn: (m) => handleTicketContext(decodeURIComponent(m[1])) },
917
+ { m: "GET", re: /^\/api\/tickets\/([^/]+)\/history$/, fn: (m) => handleTicketHistory(decodeURIComponent(m[1])) },
918
+ { m: "POST", re: /^\/api\/tickets\/([^/]+)\/comments$/, fn: (m, u, body) => handleCommentCreate(decodeURIComponent(m[1]), body) },
919
+ { m: "POST", re: /^\/api\/relations$/, fn: (m, u, body) => handleRelationCreate(body) },
920
+ { m: "DELETE", re: /^\/api\/relations\/([^/]+)$/, fn: (m) => handleRelationDelete(decodeURIComponent(m[1])) },
921
+ { m: "GET", re: /^\/api\/search$/, fn: (m, u) => handleSearch(u.searchParams.get("q")) },
922
+ { m: "POST", re: /^\/api\/admin\/import$/, fn: (m, u, body) => handleImport(body) }
923
+ ];
924
+
925
+ export async function request({ method = "GET", path, body }) {
926
+ const url = new URL(path, "http://local");
927
+ for (const route of ROUTES) {
928
+ if (route.m !== method) continue;
929
+ const m = url.pathname.match(route.re);
930
+ if (!m) continue;
931
+ return route.fn(m, url, body || {});
932
+ }
933
+ throw err(404, "not_found");
934
+ }