@starlove/orbit 1.4.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (329) hide show
  1. package/Dockerfile +10 -8
  2. package/README.md +146 -164
  3. package/dist/full/csp.txt +1 -0
  4. package/dist/full/fonts/JetBrains_Mono/JetBrainsMono-subset.woff2 +0 -0
  5. package/dist/full/fonts/JetBrains_Mono/README.txt +5 -0
  6. package/dist/full/fonts/Mona_Sans/MonaSans-subset.woff2 +0 -0
  7. package/{public/fonts/Open_Sans → dist/full/fonts/Mona_Sans}/OFL.txt +93 -93
  8. package/dist/full/fonts/Mona_Sans/README.txt +10 -0
  9. package/{public/fonts/JetBrains_Mono → dist/full/fonts/Newsreader}/OFL.txt +93 -93
  10. package/dist/full/fonts/Newsreader/README.txt +8 -0
  11. package/dist/full/fonts/Newsreader/newsreader.woff2 +0 -0
  12. package/dist/full/index.html +36 -653
  13. package/dist/full/js/app.BmuponPJ.js +32 -0
  14. package/dist/full/js/app.BmuponPJ.js.map +1 -0
  15. package/dist/full/js/chunk.1fJGQGbW.js +2 -0
  16. package/dist/full/js/chunk.1fJGQGbW.js.map +1 -0
  17. package/dist/full/js/chunk.5D7JO2zp.js +2 -0
  18. package/dist/full/js/chunk.5D7JO2zp.js.map +1 -0
  19. package/dist/full/js/chunk.BrNn_D79.js +2 -0
  20. package/dist/full/js/chunk.BrNn_D79.js.map +1 -0
  21. package/dist/full/js/chunk.CokJXIqM.js +2 -0
  22. package/dist/full/js/chunk.CokJXIqM.js.map +1 -0
  23. package/dist/full/js/chunk.D_Haqy6R.js +2 -0
  24. package/dist/full/js/chunk.D_Haqy6R.js.map +1 -0
  25. package/dist/full/js/chunk.RO0jsH6I.js +15 -0
  26. package/dist/full/js/chunk.RO0jsH6I.js.map +1 -0
  27. package/dist/full/js/chunk.dqWOEtBM.js +2 -0
  28. package/dist/full/js/chunk.dqWOEtBM.js.map +1 -0
  29. package/dist/full/js/warm.f260bbbc.js +15 -0
  30. package/dist/full/orbits-favicon.png +0 -0
  31. package/dist/full/orbits-logo-bg-wide.png +0 -0
  32. package/dist/full/orbits-logo-bg.png +0 -0
  33. package/dist/full/orbits-logo.png +0 -0
  34. package/dist/full/styles.2b154ffe.css +1 -0
  35. package/dist/full/vendor/starlove-ui/theme-init.js +66 -0
  36. package/dist/full/version.json +2 -2
  37. package/docs/AGENT_GUIDE.md +114 -0
  38. package/docs/UPDATING.md +249 -64
  39. package/lib/adapters/indexeddb/blobs.d.ts +3 -0
  40. package/lib/adapters/indexeddb/blobs.js +54 -0
  41. package/lib/adapters/indexeddb/database.d.ts +60 -0
  42. package/lib/adapters/indexeddb/database.js +126 -0
  43. package/lib/adapters/indexeddb/index.d.ts +5 -0
  44. package/lib/adapters/indexeddb/index.js +3 -0
  45. package/lib/adapters/indexeddb/locks.d.ts +14 -0
  46. package/lib/adapters/indexeddb/locks.js +40 -0
  47. package/lib/adapters/indexeddb/store.d.ts +40 -0
  48. package/lib/adapters/indexeddb/store.js +424 -0
  49. package/lib/adapters/indexeddb/tables.d.ts +43 -0
  50. package/lib/adapters/indexeddb/tables.js +127 -0
  51. package/lib/adapters/memory/blobs.d.ts +3 -0
  52. package/lib/adapters/memory/blobs.js +44 -0
  53. package/lib/adapters/memory/board.d.ts +41 -0
  54. package/lib/adapters/memory/board.js +521 -0
  55. package/lib/adapters/memory/index.d.ts +2 -0
  56. package/lib/adapters/memory/index.js +2 -0
  57. package/lib/adapters/memory/search.d.ts +6 -0
  58. package/lib/adapters/memory/search.js +30 -0
  59. package/lib/adapters/memory/store.d.ts +34 -0
  60. package/lib/adapters/memory/store.js +266 -0
  61. package/lib/adapters/sqlite/backups.d.ts +53 -0
  62. package/lib/adapters/sqlite/backups.js +232 -0
  63. package/lib/adapters/sqlite/blobs.d.ts +14 -0
  64. package/lib/adapters/sqlite/blobs.js +109 -0
  65. package/lib/adapters/sqlite/board.d.ts +77 -0
  66. package/lib/adapters/sqlite/board.js +328 -0
  67. package/lib/adapters/sqlite/database.d.ts +67 -0
  68. package/lib/adapters/sqlite/database.js +285 -0
  69. package/lib/adapters/sqlite/index.d.ts +3 -0
  70. package/lib/adapters/sqlite/index.js +2 -0
  71. package/lib/adapters/sqlite/lock.d.ts +11 -0
  72. package/lib/adapters/sqlite/lock.js +34 -0
  73. package/lib/adapters/sqlite/records.d.ts +54 -0
  74. package/lib/adapters/sqlite/records.js +546 -0
  75. package/lib/adapters/sqlite/schema.d.ts +54 -0
  76. package/lib/adapters/sqlite/schema.js +276 -0
  77. package/lib/adapters/sqlite/store.d.ts +39 -0
  78. package/lib/adapters/sqlite/store.js +385 -0
  79. package/lib/browser/in-page.d.ts +31 -0
  80. package/lib/browser/in-page.js +46 -0
  81. package/lib/browser/old-preview.d.ts +13 -0
  82. package/lib/browser/old-preview.js +80 -0
  83. package/lib/browser/preview-board.d.ts +8 -0
  84. package/lib/browser/preview-board.js +69 -0
  85. package/lib/cli/main.d.ts +7 -0
  86. package/lib/cli/main.js +343 -0
  87. package/lib/cli/orbit.d.ts +8 -0
  88. package/lib/cli/orbit.js +25 -0
  89. package/lib/client/http.d.ts +38 -0
  90. package/lib/client/http.js +334 -0
  91. package/lib/client/local.d.ts +26 -0
  92. package/lib/client/local.js +190 -0
  93. package/lib/client/object-url.d.ts +8 -0
  94. package/lib/client/object-url.js +21 -0
  95. package/lib/client/types.d.ts +268 -0
  96. package/lib/client/types.js +8 -0
  97. package/lib/commands/agent-tools.d.ts +31 -0
  98. package/lib/commands/agent-tools.js +100 -0
  99. package/lib/commands/index.d.ts +56 -0
  100. package/lib/commands/index.js +298 -0
  101. package/lib/commands/parse.d.ts +73 -0
  102. package/lib/commands/parse.js +231 -0
  103. package/lib/commands/present.d.ts +134 -0
  104. package/lib/commands/present.js +198 -0
  105. package/lib/commands/session.d.ts +85 -0
  106. package/lib/commands/session.js +138 -0
  107. package/lib/commands/verbs.d.ts +35 -0
  108. package/lib/commands/verbs.js +842 -0
  109. package/lib/core/actors.d.ts +18 -0
  110. package/lib/core/actors.js +45 -0
  111. package/lib/core/agent.d.ts +128 -0
  112. package/lib/core/agent.js +323 -0
  113. package/lib/core/boards.d.ts +113 -0
  114. package/lib/core/boards.js +288 -0
  115. package/lib/core/bootstrap.d.ts +47 -0
  116. package/lib/core/bootstrap.js +100 -0
  117. package/lib/core/bytes.d.ts +10 -0
  118. package/lib/core/bytes.js +52 -0
  119. package/lib/core/card-paths.d.ts +25 -0
  120. package/lib/core/card-paths.js +44 -0
  121. package/lib/core/context.d.ts +60 -0
  122. package/lib/core/context.js +42 -0
  123. package/lib/core/cross-board.d.ts +81 -0
  124. package/lib/core/cross-board.js +228 -0
  125. package/lib/core/describe.d.ts +21 -0
  126. package/lib/core/describe.js +81 -0
  127. package/lib/core/errors.d.ts +159 -0
  128. package/lib/core/errors.js +160 -0
  129. package/lib/core/files.d.ts +79 -0
  130. package/lib/core/files.js +262 -0
  131. package/lib/core/history.d.ts +20 -0
  132. package/lib/core/history.js +53 -0
  133. package/lib/core/image-types.d.ts +16 -0
  134. package/lib/core/image-types.js +36 -0
  135. package/lib/core/model.d.ts +263 -0
  136. package/lib/core/model.js +34 -0
  137. package/lib/core/nebula-link.d.ts +14 -0
  138. package/lib/core/nebula-link.js +39 -0
  139. package/lib/core/relations.d.ts +58 -0
  140. package/lib/core/relations.js +112 -0
  141. package/lib/core/search.d.ts +38 -0
  142. package/lib/core/search.js +92 -0
  143. package/lib/core/sessions.d.ts +44 -0
  144. package/lib/core/sessions.js +158 -0
  145. package/lib/core/snapshot-schema.d.ts +295 -0
  146. package/lib/core/snapshot-schema.js +82 -0
  147. package/lib/core/snapshots.d.ts +75 -0
  148. package/lib/core/snapshots.js +409 -0
  149. package/lib/core/states.d.ts +25 -0
  150. package/lib/core/states.js +126 -0
  151. package/lib/core/text.d.ts +40 -0
  152. package/lib/core/text.js +113 -0
  153. package/lib/core/tickets.d.ts +69 -0
  154. package/lib/core/tickets.js +311 -0
  155. package/lib/core/trello.d.ts +17 -0
  156. package/lib/core/trello.js +269 -0
  157. package/lib/core/verdicts.d.ts +28 -0
  158. package/lib/core/verdicts.js +92 -0
  159. package/lib/core/views.d.ts +95 -0
  160. package/lib/core/views.js +186 -0
  161. package/lib/http/app.d.ts +46 -0
  162. package/lib/http/app.js +491 -0
  163. package/lib/mcp-server.d.ts +10 -0
  164. package/lib/mcp-server.js +120 -0
  165. package/lib/node/assets.d.ts +7 -0
  166. package/lib/node/assets.js +68 -0
  167. package/lib/node/data-folder.d.ts +21 -0
  168. package/lib/node/data-folder.js +34 -0
  169. package/lib/node/env.d.ts +38 -0
  170. package/lib/node/env.js +49 -0
  171. package/lib/node/host-check.d.ts +24 -0
  172. package/lib/node/host-check.js +53 -0
  173. package/lib/node/host-services.d.ts +9 -0
  174. package/lib/node/host-services.js +150 -0
  175. package/lib/node/local.d.ts +22 -0
  176. package/lib/node/local.js +61 -0
  177. package/lib/node/migrate.d.ts +53 -0
  178. package/lib/node/migrate.js +635 -0
  179. package/lib/node/package.d.ts +7 -0
  180. package/lib/node/package.js +14 -0
  181. package/lib/node/repo-board.d.ts +49 -0
  182. package/lib/node/repo-board.js +245 -0
  183. package/lib/node/serve.d.ts +8 -0
  184. package/lib/node/serve.js +106 -0
  185. package/lib/orbit.d.ts +47 -0
  186. package/lib/orbit.js +44 -0
  187. package/lib/quiet-sqlite-warning.d.ts +1 -0
  188. package/lib/quiet-sqlite-warning.js +18 -0
  189. package/lib/store/blob-path.d.ts +6 -0
  190. package/lib/store/blob-path.js +12 -0
  191. package/lib/store/conformance.d.ts +52 -0
  192. package/lib/store/conformance.js +1449 -0
  193. package/lib/store/search-words.d.ts +10 -0
  194. package/lib/store/search-words.js +19 -0
  195. package/lib/store/types.d.ts +452 -0
  196. package/lib/store/types.js +34 -0
  197. package/llms.txt +63 -0
  198. package/package.json +109 -25
  199. package/src/cli/orbit.js +11 -658
  200. package/src/mcp-server.js +16 -876
  201. package/SKILL-ORBIT.md +0 -151
  202. package/build.mjs +0 -210
  203. package/dist/full/js/app.62905fcb.js +0 -952
  204. package/dist/full/js/app.62905fcb.js.map +0 -7
  205. package/dist/full/styles.4e60d824.css +0 -1
  206. package/docs/AGENT_PROTOCOL.md +0 -424
  207. package/docs/MCP_SETUP.md +0 -54
  208. package/docs/ORBIT_DISPATCH.md +0 -280
  209. package/docs/SS-Orbit-Dark.png +0 -0
  210. package/docs/SS-Orbit-Light.png +0 -0
  211. package/docs/mcp-client.example.json +0 -13
  212. package/docs/setup/orbit-init-wizard.md +0 -174
  213. package/public/apple-touch-icon.png +0 -0
  214. package/public/cursors/live_arrow.cur +0 -0
  215. package/public/cursors/live_link.cur +0 -0
  216. package/public/cursors/live_move.cur +0 -0
  217. package/public/cursors/live_select.cur +0 -0
  218. package/public/cursors/live_unavail.cur +0 -0
  219. package/public/favicon-32x32.png +0 -0
  220. package/public/fonts/Chakra_Petch/ChakraPetch-Bold.ttf +0 -0
  221. package/public/fonts/Chakra_Petch/ChakraPetch-BoldItalic.ttf +0 -0
  222. package/public/fonts/Chakra_Petch/ChakraPetch-Italic.ttf +0 -0
  223. package/public/fonts/Chakra_Petch/ChakraPetch-Light.ttf +0 -0
  224. package/public/fonts/Chakra_Petch/ChakraPetch-LightItalic.ttf +0 -0
  225. package/public/fonts/Chakra_Petch/ChakraPetch-Medium.ttf +0 -0
  226. package/public/fonts/Chakra_Petch/ChakraPetch-MediumItalic.ttf +0 -0
  227. package/public/fonts/Chakra_Petch/ChakraPetch-Regular.ttf +0 -0
  228. package/public/fonts/Chakra_Petch/ChakraPetch-SemiBold.ttf +0 -0
  229. package/public/fonts/Chakra_Petch/ChakraPetch-SemiBoldItalic.ttf +0 -0
  230. package/public/fonts/Chakra_Petch/OFL.txt +0 -93
  231. package/public/fonts/Inter/Inter-Italic-VariableFont_opsz,wght.ttf +0 -0
  232. package/public/fonts/Inter/Inter-VariableFont_opsz,wght.ttf +0 -0
  233. package/public/fonts/Inter/OFL.txt +0 -93
  234. package/public/fonts/Inter/README.txt +0 -118
  235. package/public/fonts/JetBrains_Mono/JetBrainsMono-Italic-VariableFont_wght.ttf +0 -0
  236. package/public/fonts/JetBrains_Mono/JetBrainsMono-VariableFont_wght.ttf +0 -0
  237. package/public/fonts/JetBrains_Mono/README.txt +0 -79
  238. package/public/fonts/OpenRing-Regular.ttf +0 -0
  239. package/public/fonts/Open_Sans/OpenSans-Italic-VariableFont_wdth,wght.ttf +0 -0
  240. package/public/fonts/Open_Sans/OpenSans-VariableFont_wdth,wght.ttf +0 -0
  241. package/public/fonts/Open_Sans/README.txt +0 -100
  242. package/public/fonts/Orbitron/OFL.txt +0 -93
  243. package/public/fonts/Orbitron/Orbitron-VariableFont_wght.ttf +0 -0
  244. package/public/fonts/Orbitron/README.txt +0 -68
  245. package/public/fonts/Rajdhani/OFL.txt +0 -93
  246. package/public/fonts/Rajdhani/Rajdhani-Bold.ttf +0 -0
  247. package/public/fonts/Rajdhani/Rajdhani-Light.ttf +0 -0
  248. package/public/fonts/Rajdhani/Rajdhani-Medium.ttf +0 -0
  249. package/public/fonts/Rajdhani/Rajdhani-Regular.ttf +0 -0
  250. package/public/fonts/Rajdhani/Rajdhani-SemiBold.ttf +0 -0
  251. package/public/fonts/runescape-uf.ttf +0 -0
  252. package/public/index.html +0 -769
  253. package/public/js/actor-labels.js +0 -65
  254. package/public/js/api.js +0 -39
  255. package/public/js/app.js +0 -45
  256. package/public/js/artifact-drawer.js +0 -79
  257. package/public/js/board-export.js +0 -161
  258. package/public/js/board-menu.js +0 -374
  259. package/public/js/card-actions.js +0 -545
  260. package/public/js/config.js +0 -20
  261. package/public/js/create-card.js +0 -190
  262. package/public/js/dom.js +0 -19
  263. package/public/js/drawer.js +0 -273
  264. package/public/js/font-preference.js +0 -229
  265. package/public/js/format.js +0 -433
  266. package/public/js/help-menu.js +0 -31
  267. package/public/js/icons.js +0 -16
  268. package/public/js/kanban.js +0 -694
  269. package/public/js/local-backend.js +0 -1015
  270. package/public/js/main.js +0 -161
  271. package/public/js/modal.js +0 -33
  272. package/public/js/motion-preference.js +0 -64
  273. package/public/js/panel.js +0 -58
  274. package/public/js/router.js +0 -134
  275. package/public/js/search.js +0 -56
  276. package/public/js/settings-tabs.js +0 -7
  277. package/public/js/settings.js +0 -1634
  278. package/public/js/sse.js +0 -159
  279. package/public/js/starscape.js +0 -332
  280. package/public/js/state.js +0 -129
  281. package/public/js/text-editing.js +0 -128
  282. package/public/js/theme-preference.js +0 -95
  283. package/public/js/ticket-detail.js +0 -1516
  284. package/public/js/ticket-typeahead.js +0 -49
  285. package/public/js/toast.js +0 -89
  286. package/public/js/topbar-notes.js +0 -155
  287. package/public/js/transport.js +0 -22
  288. package/public/js/typeahead.js +0 -206
  289. package/public/js/unread.js +0 -121
  290. package/public/js/update-check.js +0 -58
  291. package/public/js/url-routes.js +0 -94
  292. package/public/orbits-favicon.png +0 -0
  293. package/public/orbits-logo-bg-wide.png +0 -0
  294. package/public/orbits-logo-bg.png +0 -0
  295. package/public/orbits-logo.png +0 -0
  296. package/public/styles.css +0 -7709
  297. package/src/core/agent.js +0 -387
  298. package/src/core/agents-md.js +0 -109
  299. package/src/core/artifacts.js +0 -149
  300. package/src/core/attachments.js +0 -238
  301. package/src/core/auth.js +0 -85
  302. package/src/core/backups.js +0 -142
  303. package/src/core/board.js +0 -148
  304. package/src/core/boards.js +0 -409
  305. package/src/core/bootstrap.js +0 -170
  306. package/src/core/css-bundle.js +0 -30
  307. package/src/core/db.js +0 -315
  308. package/src/core/delete-board.js +0 -163
  309. package/src/core/dispatch.js +0 -1007
  310. package/src/core/events.js +0 -314
  311. package/src/core/http.js +0 -115
  312. package/src/core/migrate.js +0 -198
  313. package/src/core/paths.js +0 -55
  314. package/src/core/provision-repo-board.js +0 -119
  315. package/src/core/queries.js +0 -246
  316. package/src/core/registry.js +0 -211
  317. package/src/core/relations.js +0 -163
  318. package/src/core/review-verdicts.js +0 -138
  319. package/src/core/router.js +0 -556
  320. package/src/core/search.js +0 -183
  321. package/src/core/seed.js +0 -234
  322. package/src/core/snapshots.js +0 -692
  323. package/src/core/states.js +0 -167
  324. package/src/core/system-picker.js +0 -109
  325. package/src/core/tickets.js +0 -400
  326. package/src/core/util.js +0 -106
  327. package/src/core/version-check.js +0 -154
  328. package/src/mcp/orbit-client.js +0 -287
  329. package/src/server.js +0 -115
@@ -1,1015 +0,0 @@
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, omitted_for_ai, 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 → Data 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 → Data.",
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 → Data → 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
- omitted_for_ai: 0,
255
- created_at: t
256
- });
257
-
258
- const { stores, done } = tx(
259
- db,
260
- ["boards", "states", "labels", "tickets", "ticket_labels", "comments", "meta"],
261
- "readwrite"
262
- );
263
- stores.boards.put(board);
264
- stateRows.forEach((r) => stores.states.put(r));
265
- labelRows.forEach((r) => stores.labels.put(r));
266
- tickets.forEach((r) => stores.tickets.put(r));
267
- ticketLabels.forEach((r) => stores.ticket_labels.put(r));
268
- comments.forEach((r) => stores.comments.put(r));
269
- stores.meta.put({ key: "schema_seeded", value: true });
270
- await done;
271
- }
272
-
273
- // ──────────────────────────────────────────────────────────────────────────
274
- // Snapshot helpers (full DB read, used by bootstrap / context / export)
275
-
276
- async function readBoardSnapshot(db) {
277
- const { stores } = tx(
278
- db,
279
- [
280
- "boards",
281
- "states",
282
- "labels",
283
- "tickets",
284
- "ticket_labels",
285
- "comments",
286
- "relations",
287
- "board_entries",
288
- "events"
289
- ],
290
- "readonly"
291
- );
292
- const [board, states, labels, tickets, ticketLabels, comments, relations, boardEntries, events] = await Promise.all([
293
- reqPromise(stores.boards.get(BOARD_ID)),
294
- getAll(stores.states),
295
- getAll(stores.labels),
296
- getAll(stores.tickets),
297
- getAll(stores.ticket_labels),
298
- getAll(stores.comments),
299
- getAll(stores.relations),
300
- getAll(stores.board_entries),
301
- getAll(stores.events)
302
- ]);
303
- return { board, states, labels, tickets, ticketLabels, comments, relations, boardEntries, events };
304
- }
305
-
306
- function decorateTickets(snapshot, includeArchived = false) {
307
- const { board, states, labels, tickets, ticketLabels, comments } = snapshot;
308
- const labelById = Object.fromEntries(labels.map((l) => [l.id, l]));
309
- const stateById = Object.fromEntries(states.map((s) => [s.id, s]));
310
- const visible = tickets.filter((t) => includeArchived || !t.archived_at);
311
- const sorted = [...visible].sort((a, b) => {
312
- const byTime = String(b.updated_at).localeCompare(String(a.updated_at));
313
- if (byTime !== 0) return byTime;
314
- return String(a.id).localeCompare(String(b.id));
315
- });
316
- return sorted.map((ticket) => {
317
- const stateRow = stateById[ticket.state_id];
318
- const tLabels = ticketLabels
319
- .filter((tl) => tl.ticket_id === ticket.id)
320
- .map((tl) => labelById[tl.label_id])
321
- .filter(Boolean)
322
- .map((l) => ({ ticket_id: ticket.id, id: l.id, name: l.name, color: l.color }));
323
- const commentCount = comments.filter((c) => c.ticket_id === ticket.id).length;
324
- const childCount = tickets.filter((t) => t.parent_ticket_id === ticket.id && !t.archived_at).length;
325
- const parent = ticket.parent_ticket_id ? tickets.find((t) => t.id === ticket.parent_ticket_id) : null;
326
- const parentState = parent ? stateById[parent.state_id] : null;
327
- return {
328
- ...ticket,
329
- state_name: stateRow?.name || "",
330
- state_role: stateRow?.role || null,
331
- board_slug: board.slug,
332
- board_name: board.name,
333
- labels: tLabels,
334
- comment_count: commentCount,
335
- child_count: childCount,
336
- parent_ticket: parent
337
- ? {
338
- id: parent.id,
339
- board_id: parent.board_id,
340
- board_slug: board.slug,
341
- number: parent.number,
342
- title: parent.title,
343
- type: parent.type,
344
- state_name: parentState?.name || "",
345
- state_role: parentState?.role || null,
346
- priority: parent.priority
347
- }
348
- : null
349
- };
350
- });
351
- }
352
-
353
- // ──────────────────────────────────────────────────────────────────────────
354
- // Mutation helpers
355
-
356
- async function putAndEvent(db, mutate) {
357
- const { stores, done } = tx(
358
- db,
359
- ["boards", "states", "labels", "tickets", "ticket_labels", "comments", "relations", "board_entries", "events"],
360
- "readwrite"
361
- );
362
- const result = await mutate(stores);
363
- await done;
364
- return result;
365
- }
366
-
367
- function recordEvent(stores, type, ticketId, body) {
368
- const ev = {
369
- id: uid(),
370
- board_id: BOARD_ID,
371
- ticket_id: ticketId || null,
372
- type,
373
- actor: "you",
374
- body_json: JSON.stringify(body || {}),
375
- created_at: nowIso()
376
- };
377
- stores.events.put(ev);
378
- return ev;
379
- }
380
-
381
- async function ensureLabel(stores, name) {
382
- const all = await getAll(stores.labels);
383
- const existing = all.find((l) => l.name.toLowerCase() === String(name).toLowerCase());
384
- if (existing) return existing;
385
- const created = {
386
- id: uid(),
387
- board_id: BOARD_ID,
388
- name: slugify(name),
389
- color: "#64748b",
390
- created_at: nowIso()
391
- };
392
- stores.labels.put(created);
393
- return created;
394
- }
395
-
396
- // ──────────────────────────────────────────────────────────────────────────
397
- // Handlers
398
-
399
- async function handleBootstrap() {
400
- const db = await openDb();
401
- await seedIfEmpty(db);
402
- const snap = await readBoardSnapshot(db);
403
- const tickets = decorateTickets(snap);
404
- return {
405
- actor: { name: "you", role: "owner" },
406
- meta: { db_path: null, export_dir: null },
407
- boards: [snap.board],
408
- board_entries: [...snap.boardEntries].sort((a, b) =>
409
- String(b.created_at).localeCompare(String(a.created_at))
410
- ),
411
- states: [...snap.states].sort((a, b) => a.position - b.position),
412
- labels: [...snap.labels].sort((a, b) => a.name.localeCompare(b.name)),
413
- tickets
414
- };
415
- }
416
-
417
- async function handleBoardsList() {
418
- const db = await openDb();
419
- await seedIfEmpty(db);
420
- const { stores } = tx(db, ["boards"], "readonly");
421
- const board = await reqPromise(stores.boards.get(BOARD_ID));
422
- return {
423
- boards: [
424
- {
425
- id: board.id,
426
- slug: board.slug,
427
- name: board.name,
428
- repo_path: board.repo_path || "",
429
- repo_url: board.repo_url || "",
430
- default_branch: board.default_branch,
431
- db_path: null,
432
- last_active_at: board.updated_at
433
- }
434
- ]
435
- };
436
- }
437
-
438
- async function handleBoardContext(boardId, includeStruck) {
439
- if (boardId !== BOARD_ID) throw err(404, "board_not_found");
440
- const db = await openDb();
441
- await seedIfEmpty(db);
442
- const snap = await readBoardSnapshot(db);
443
- const entries = (includeStruck ? snap.boardEntries : snap.boardEntries.filter((e) => !e.struck_at)).sort(
444
- (a, b) => String(b.created_at).localeCompare(String(a.created_at))
445
- );
446
- return {
447
- board: snap.board,
448
- entries,
449
- deployment: { app_root: "browser", system_path: "", data_dir: "", mcp_server_path: "", db_path: null }
450
- };
451
- }
452
-
453
- async function handleBoardArchive(boardId) {
454
- if (boardId !== BOARD_ID) throw err(404, "board_not_found");
455
- const db = await openDb();
456
- const snap = await readBoardSnapshot(db);
457
- const tickets = decorateTickets(snap, true).filter((t) => t.archived_at);
458
- return { tickets };
459
- }
460
-
461
- async function handleBoardPatch(boardId, body) {
462
- if (boardId !== BOARD_ID) throw err(404, "board_not_found");
463
- const db = await openDb();
464
- return putAndEvent(db, async (stores) => {
465
- const board = await reqPromise(stores.boards.get(BOARD_ID));
466
- const ALLOWED = [
467
- "name",
468
- "repo_url",
469
- "system_path",
470
- "default_branch",
471
- "project_notes",
472
- "agent_instructions",
473
- "ai_enabled"
474
- ];
475
- for (const k of ALLOWED) {
476
- if (!(k in body)) continue;
477
- if (k === "name") {
478
- const name = String(body[k] || "").trim();
479
- if (!name) throw err(400, "missing_name");
480
- board[k] = name;
481
- } else {
482
- board[k] = body[k];
483
- }
484
- }
485
- board.updated_at = nowIso();
486
- stores.boards.put(board);
487
- recordEvent(stores, "board_updated", null, { fields: Object.keys(body) });
488
- return board;
489
- });
490
- }
491
-
492
- async function handleBoardEntries(boardId, body) {
493
- if (boardId !== BOARD_ID) throw err(404, "board_not_found");
494
- const db = await openDb();
495
- return putAndEvent(db, async (stores) => {
496
- const entry = {
497
- id: uid(),
498
- board_id: BOARD_ID,
499
- type: body.type || "note",
500
- title: body.title || "",
501
- body: body.body || "",
502
- ticket_id: body.ticket_id || null,
503
- struck_at: null,
504
- created_by: "you",
505
- created_at: nowIso(),
506
- updated_at: nowIso()
507
- };
508
- stores.board_entries.put(entry);
509
- recordEvent(stores, "board_entry_created", entry.ticket_id, { entry_id: entry.id, type: entry.type });
510
- return entry;
511
- });
512
- }
513
-
514
- async function handleBoardEntryPatch(entryId, body) {
515
- const db = await openDb();
516
- return putAndEvent(db, async (stores) => {
517
- const entry = await reqPromise(stores.board_entries.get(entryId));
518
- if (!entry) throw err(404, "board_entry_not_found");
519
- const shouldStrike = Boolean(body.struck);
520
- entry.struck_at = shouldStrike ? entry.struck_at || nowIso() : null;
521
- entry.updated_at = nowIso();
522
- stores.board_entries.put(entry);
523
- return entry;
524
- });
525
- }
526
-
527
- async function handleStateCreate(boardId, body) {
528
- if (boardId !== BOARD_ID) throw err(404, "board_not_found");
529
- const db = await openDb();
530
- return putAndEvent(db, async (stores) => {
531
- const states = await getAll(stores.states);
532
- const maxPos = states.reduce((a, s) => Math.max(a, s.position), -1);
533
- const state = {
534
- id: uid(),
535
- board_id: BOARD_ID,
536
- name: body.name || "New",
537
- position: maxPos + 1,
538
- is_default: 0,
539
- role: body.role || null,
540
- created_at: nowIso()
541
- };
542
- stores.states.put(state);
543
- recordEvent(stores, "state_created", null, { state_id: state.id });
544
- return state;
545
- });
546
- }
547
-
548
- async function handleStatePatch(stateId, body) {
549
- const db = await openDb();
550
- return putAndEvent(db, async (stores) => {
551
- const state = await reqPromise(stores.states.get(stateId));
552
- if (!state) throw err(404, "state_not_found");
553
- for (const k of ["name", "role"]) if (k in body) state[k] = body[k];
554
- stores.states.put(state);
555
- return state;
556
- });
557
- }
558
-
559
- async function handleStateDelete(stateId) {
560
- const db = await openDb();
561
- return putAndEvent(db, async (stores) => {
562
- const state = await reqPromise(stores.states.get(stateId));
563
- if (!state) throw err(404, "state_not_found");
564
- const tickets = await getAll(stores.tickets);
565
- if (tickets.some((t) => t.state_id === stateId && !t.archived_at)) {
566
- throw err(409, "state_has_tickets");
567
- }
568
- stores.states.delete(stateId);
569
- recordEvent(stores, "state_deleted", null, { state_id: stateId });
570
- return { ok: true };
571
- });
572
- }
573
-
574
- async function handleStateReorder(boardId, body) {
575
- if (boardId !== BOARD_ID) throw err(404, "board_not_found");
576
- const db = await openDb();
577
- return putAndEvent(db, async (stores) => {
578
- const order = body.order || body.state_ids || [];
579
- for (let i = 0; i < order.length; i++) {
580
- const state = await reqPromise(stores.states.get(order[i]));
581
- if (!state) continue;
582
- state.position = i;
583
- stores.states.put(state);
584
- }
585
- recordEvent(stores, "states_reordered", null, { order });
586
- const states = await getAll(stores.states);
587
- return { states: states.sort((a, b) => a.position - b.position) };
588
- });
589
- }
590
-
591
- // Mirrors src/core/tickets.js#bumpTicketUpdatedAt — rolls child activity up to
592
- // the parent epic so the column sort (updated_at DESC) keeps the epic group
593
- // near recent child activity even when only its children changed.
594
- async function bumpTicketUpdatedAt(stores, ticketId, time) {
595
- if (!ticketId) return;
596
- const parent = await reqPromise(stores.tickets.get(ticketId));
597
- if (!parent) return;
598
- parent.updated_at = time;
599
- stores.tickets.put(parent);
600
- }
601
-
602
- async function handleTicketCreate(body) {
603
- const db = await openDb();
604
- return putAndEvent(db, async (stores) => {
605
- const states = await getAll(stores.states);
606
- const stateId =
607
- body.state_id ||
608
- states.find((s) => s.is_default)?.id ||
609
- states.sort((a, b) => a.position - b.position)[0]?.id;
610
- if (!stateId) throw err(400, "no_states");
611
- const tickets = await getAll(stores.tickets);
612
- const nextNumber = tickets.reduce((a, t) => Math.max(a, t.number || 0), 0) + 1;
613
- const ticket = {
614
- id: uid(),
615
- board_id: BOARD_ID,
616
- number: nextNumber,
617
- title: body.title || "Untitled",
618
- description: body.description || "",
619
- type: body.type || (body.parent_ticket_id ? "feature" : "task"),
620
- parent_ticket_id: body.parent_ticket_id || null,
621
- ai_plan: body.ai_plan || "",
622
- plan_artifact_path: body.plan_artifact_path || "",
623
- implementation_summary: body.implementation_summary || "",
624
- implementation_artifact_path: body.implementation_artifact_path || "",
625
- implementation_updates: body.implementation_updates || "",
626
- state_id: stateId,
627
- priority: body.priority ?? 0,
628
- created_by: "you",
629
- archived_at: null,
630
- created_at: nowIso(),
631
- updated_at: nowIso()
632
- };
633
- stores.tickets.put(ticket);
634
- for (const name of body.labels || []) {
635
- const label = await ensureLabel(stores, name);
636
- stores.ticket_labels.put({ key: tlKey(ticket.id, label.id), ticket_id: ticket.id, label_id: label.id });
637
- }
638
- recordEvent(stores, "ticket_created", ticket.id, { title: ticket.title, type: ticket.type });
639
- await bumpTicketUpdatedAt(stores, ticket.parent_ticket_id, ticket.updated_at);
640
- return ticket;
641
- });
642
- }
643
-
644
- async function handleTicketPatch(ticketId, body) {
645
- const db = await openDb();
646
- return putAndEvent(db, async (stores) => {
647
- const ticket = await reqPromise(stores.tickets.get(ticketId));
648
- if (!ticket) throw err(404, "ticket_not_found");
649
- const oldStateId = ticket.state_id;
650
- const oldParentId = ticket.parent_ticket_id;
651
- const SIMPLE = [
652
- "title",
653
- "description",
654
- "type",
655
- "parent_ticket_id",
656
- "ai_plan",
657
- "plan_artifact_path",
658
- "implementation_summary",
659
- "implementation_artifact_path",
660
- "implementation_updates",
661
- "priority"
662
- ];
663
- for (const k of SIMPLE) if (k in body) ticket[k] = body[k];
664
- if ("state_id" in body) ticket.state_id = body.state_id;
665
- ticket.updated_at = nowIso();
666
- stores.tickets.put(ticket);
667
-
668
- if ("labels" in body) {
669
- const existing = await getAll(stores.ticket_labels, ticketId, "ticket_id");
670
- for (const row of existing) stores.ticket_labels.delete(row.key);
671
- for (const name of body.labels || []) {
672
- const label = await ensureLabel(stores, name);
673
- stores.ticket_labels.put({ key: tlKey(ticketId, label.id), ticket_id: ticketId, label_id: label.id });
674
- }
675
- }
676
-
677
- if (oldStateId !== ticket.state_id) {
678
- const states = await getAll(stores.states);
679
- const fromState = states.find((s) => s.id === oldStateId);
680
- const toState = states.find((s) => s.id === ticket.state_id);
681
- // Mirror tickets.js: when a ticket lands in role='done', auto-delete
682
- // every relation row that names it as a blocker.
683
- if (toState?.role === "done" && fromState?.role !== "done") {
684
- const relations = await getAll(stores.relations);
685
- for (const r of relations) {
686
- if ((r.source_ticket_id === ticketId && r.type === "blocks") ||
687
- (r.target_ticket_id === ticketId && r.type === "blocked_by")) {
688
- stores.relations.delete(r.id);
689
- }
690
- }
691
- }
692
- recordEvent(stores, "state_changed", ticketId, { from: fromState?.name || "", to: toState?.name || "" });
693
- } else {
694
- recordEvent(stores, "ticket_updated", ticketId, { fields: Object.keys(body) });
695
- }
696
- await bumpTicketUpdatedAt(stores, oldParentId, ticket.updated_at);
697
- if (ticket.parent_ticket_id && ticket.parent_ticket_id !== oldParentId) {
698
- await bumpTicketUpdatedAt(stores, ticket.parent_ticket_id, ticket.updated_at);
699
- }
700
- return ticket;
701
- });
702
- }
703
-
704
- async function handleTicketDelete(ticketId) {
705
- const db = await openDb();
706
- return putAndEvent(db, async (stores) => {
707
- const ticket = await reqPromise(stores.tickets.get(ticketId));
708
- if (!ticket) throw err(404, "ticket_not_found");
709
- stores.tickets.delete(ticketId);
710
- const tlinks = await getAll(stores.ticket_labels, ticketId, "ticket_id");
711
- for (const row of tlinks) stores.ticket_labels.delete(row.key);
712
- const cmts = await getAll(stores.comments, ticketId, "ticket_id");
713
- for (const c of cmts) stores.comments.delete(c.id);
714
- recordEvent(stores, "ticket_deleted", ticketId, {});
715
- await bumpTicketUpdatedAt(stores, ticket.parent_ticket_id, nowIso());
716
- return { ok: true };
717
- });
718
- }
719
-
720
- async function handleTicketArchive(ticketId, archive) {
721
- const db = await openDb();
722
- return putAndEvent(db, async (stores) => {
723
- const ticket = await reqPromise(stores.tickets.get(ticketId));
724
- if (!ticket) throw err(404, "ticket_not_found");
725
- ticket.archived_at = archive ? nowIso() : null;
726
- ticket.updated_at = nowIso();
727
- stores.tickets.put(ticket);
728
- recordEvent(stores, archive ? "ticket_archived" : "ticket_restored", ticketId, {});
729
- await bumpTicketUpdatedAt(stores, ticket.parent_ticket_id, ticket.updated_at);
730
- return ticket;
731
- });
732
- }
733
-
734
- async function handleTicketContext(ticketId, { includeProjectContext = false } = {}) {
735
- const db = await openDb();
736
- const snap = await readBoardSnapshot(db);
737
- const ticket = snap.tickets.find((t) => t.id === ticketId);
738
- if (!ticket) throw err(404, "ticket_not_found");
739
- const labels = snap.ticketLabels
740
- .filter((tl) => tl.ticket_id === ticketId)
741
- .map((tl) => snap.labels.find((l) => l.id === tl.label_id))
742
- .filter(Boolean)
743
- .map((l) => ({ ticket_id: ticketId, id: l.id, name: l.name, color: l.color }));
744
- const stateById = Object.fromEntries(snap.states.map((s) => [s.id, s]));
745
- const compact = (t) => {
746
- if (!t) return null;
747
- const s = stateById[t.state_id];
748
- return {
749
- id: t.id,
750
- board_id: t.board_id,
751
- board_slug: snap.board.slug,
752
- number: t.number,
753
- title: t.title,
754
- type: t.type,
755
- state_name: s?.name || "",
756
- state_role: s?.role || null,
757
- priority: t.priority
758
- };
759
- };
760
- const parent = ticket.parent_ticket_id ? snap.tickets.find((t) => t.id === ticket.parent_ticket_id) : null;
761
- const childTickets = snap.tickets
762
- .filter((t) => t.parent_ticket_id === ticketId && !t.archived_at)
763
- .map((child) => ({
764
- ...child,
765
- labels: snap.ticketLabels
766
- .filter((tl) => tl.ticket_id === child.id)
767
- .map((tl) => snap.labels.find((l) => l.id === tl.label_id))
768
- .filter(Boolean)
769
- .map((l) => ({ ticket_id: child.id, id: l.id, name: l.name, color: l.color })),
770
- blockers: [],
771
- child_count: snap.tickets.filter((t) => t.parent_ticket_id === child.id && !t.archived_at).length
772
- }));
773
- const relations = snap.relations
774
- .filter((r) => r.source_ticket_id === ticketId || r.target_ticket_id === ticketId)
775
- .map((r) => {
776
- const otherId = r.source_ticket_id === ticketId ? r.target_ticket_id : r.source_ticket_id;
777
- const other = snap.tickets.find((t) => t.id === otherId);
778
- return {
779
- ...r,
780
- direction: r.source_ticket_id === ticketId ? "outgoing" : "incoming",
781
- other_ticket: compact(other)
782
- };
783
- });
784
- const context = {
785
- ticket: { ...ticket, labels },
786
- board: includeProjectContext
787
- ? snap.board
788
- : {
789
- id: snap.board.id,
790
- slug: snap.board.slug,
791
- name: snap.board.name,
792
- repo_path: snap.board.system_path || "",
793
- system_path: snap.board.system_path || "",
794
- default_branch: snap.board.default_branch || "main"
795
- },
796
- parent_ticket: compact(parent),
797
- child_tickets: childTickets,
798
- relations,
799
- blockers: [],
800
- related_tickets: [...childTickets, ...(parent ? [parent] : [])]
801
- };
802
- if (includeProjectContext) context.board_manual = { entries: snap.boardEntries.filter((e) => !e.struck_at) };
803
- return context;
804
- }
805
-
806
- async function handleTicketComments(ticketId) {
807
- const snap = await snapshotAll();
808
- const ticket = snap.tickets.find((t) => t.id === ticketId && !t.archived_at);
809
- if (!ticket) throw err(404, "ticket_not_found");
810
- return {
811
- ticket_id: ticketId,
812
- comments: snap.comments
813
- .filter((c) => c.ticket_id === ticketId)
814
- .sort((a, b) => String(a.created_at).localeCompare(String(b.created_at)))
815
- };
816
- }
817
-
818
- async function handleTicketHistory(ticketId) {
819
- const db = await openDb();
820
- const { stores } = tx(db, ["events"], "readonly");
821
- const events = await getAll(stores.events, ticketId, "ticket_id");
822
- return events
823
- .filter((e) => ["state_changed", "checkpoint_requested", "agent_completed"].includes(e.type))
824
- .sort((a, b) => String(a.created_at).localeCompare(String(b.created_at)))
825
- .map((e) => ({ ...e, body: safeJson(e.body_json) }));
826
- }
827
-
828
- function safeJson(text) {
829
- try {
830
- return JSON.parse(text || "{}");
831
- } catch {
832
- return {};
833
- }
834
- }
835
-
836
- async function handleCommentCreate(ticketId, body) {
837
- const db = await openDb();
838
- return putAndEvent(db, async (stores) => {
839
- const ticket = await reqPromise(stores.tickets.get(ticketId));
840
- if (!ticket) throw err(404, "ticket_not_found");
841
- const time = nowIso();
842
- const comment = {
843
- id: uid(),
844
- ticket_id: ticketId,
845
- author: "you",
846
- kind: body.kind || "note",
847
- body: body.body || "",
848
- omitted_for_ai: 0,
849
- created_at: time
850
- };
851
- stores.comments.put(comment);
852
- ticket.updated_at = time;
853
- stores.tickets.put(ticket);
854
- recordEvent(stores, "comment_created", ticketId, { kind: comment.kind });
855
- await bumpTicketUpdatedAt(stores, ticket.parent_ticket_id, time);
856
- return comment;
857
- });
858
- }
859
-
860
- async function handleCommentPatch(ticketId, commentId, body) {
861
- const db = await openDb();
862
- return putAndEvent(db, async (stores) => {
863
- const ticket = await reqPromise(stores.tickets.get(ticketId));
864
- if (!ticket) throw err(404, "ticket_not_found");
865
- const comment = await reqPromise(stores.comments.get(commentId));
866
- if (!comment || comment.ticket_id !== ticketId) throw err(404, "comment_not_found");
867
- const omittedForAi = body.omitted_for_ai ?? body.omittedForAi;
868
- if (typeof omittedForAi !== "boolean") throw err(400, "omitted_for_ai_required");
869
- const time = nowIso();
870
- comment.omitted_for_ai = omittedForAi ? 1 : 0;
871
- ticket.updated_at = time;
872
- stores.comments.put(comment);
873
- stores.tickets.put(ticket);
874
- recordEvent(stores, "comment_ai_omission_changed", ticketId, { comment_id: commentId, omitted_for_ai: omittedForAi });
875
- await bumpTicketUpdatedAt(stores, ticket.parent_ticket_id, time);
876
- return comment;
877
- });
878
- }
879
-
880
- async function handleRelationCreate(body) {
881
- const db = await openDb();
882
- return putAndEvent(db, async (stores) => {
883
- const relation = {
884
- id: uid(),
885
- board_id: BOARD_ID,
886
- source_ticket_id: body.source_ticket_id,
887
- target_ticket_id: body.target_ticket_id,
888
- kind: body.kind || "related",
889
- created_by: "you",
890
- created_at: nowIso()
891
- };
892
- stores.relations.put(relation);
893
- return relation;
894
- });
895
- }
896
-
897
- async function handleRelationDelete(relationId) {
898
- const db = await openDb();
899
- return putAndEvent(db, async (stores) => {
900
- stores.relations.delete(relationId);
901
- return { ok: true };
902
- });
903
- }
904
-
905
- async function handleSearch(query) {
906
- const db = await openDb();
907
- const snap = await readBoardSnapshot(db);
908
- const q = String(query || "").toLowerCase().trim();
909
- if (!q) return { tickets: [] };
910
- const tickets = decorateTickets(snap).filter((t) => {
911
- const haystack = [t.title, t.description, t.ai_plan, t.implementation_summary, `#${t.number}`]
912
- .join(" ")
913
- .toLowerCase();
914
- return haystack.includes(q);
915
- });
916
- return { tickets: tickets.slice(0, 50) };
917
- }
918
-
919
- async function handleExport(boardId) {
920
- if (boardId !== BOARD_ID) throw err(404, "board_not_found");
921
- const db = await openDb();
922
- const snap = await readBoardSnapshot(db);
923
- return {
924
- schema: "orbit.board.snapshot/v1",
925
- exported_at: nowIso(),
926
- board: snap.board,
927
- states: snap.states,
928
- labels: snap.labels,
929
- tickets: snap.tickets,
930
- ticket_labels: snap.ticketLabels,
931
- comments: snap.comments,
932
- relations: snap.relations,
933
- board_entries: snap.boardEntries
934
- };
935
- }
936
-
937
- async function handleImport(body) {
938
- const snapshot = body.snapshot || body;
939
- if (!snapshot || !snapshot.board) throw err(400, "invalid_snapshot");
940
- const db = await openDb();
941
- return putAndEvent(db, async (stores) => {
942
- // Replace within this single browser-board: clear stores then load.
943
- for (const name of [
944
- "states",
945
- "labels",
946
- "tickets",
947
- "ticket_labels",
948
- "comments",
949
- "relations",
950
- "board_entries"
951
- ]) {
952
- const all = await getAll(stores[name]);
953
- for (const row of all) stores[name].delete(row.key ?? row.id);
954
- }
955
- const incomingBoard = { ...snapshot.board, id: BOARD_ID };
956
- stores.boards.put(incomingBoard);
957
- (snapshot.states || []).forEach((s) => stores.states.put({ ...s, board_id: BOARD_ID }));
958
- (snapshot.labels || []).forEach((l) => stores.labels.put({ ...l, board_id: BOARD_ID }));
959
- (snapshot.tickets || []).forEach((t) => stores.tickets.put({ ...t, board_id: BOARD_ID }));
960
- (snapshot.ticket_labels || []).forEach((tl) =>
961
- stores.ticket_labels.put({ ...tl, key: tl.key || tlKey(tl.ticket_id, tl.label_id) })
962
- );
963
- (snapshot.comments || []).forEach((c) => stores.comments.put(c));
964
- (snapshot.relations || []).forEach((r) => stores.relations.put({ ...r, board_id: BOARD_ID }));
965
- (snapshot.board_entries || []).forEach((e) => stores.board_entries.put({ ...e, board_id: BOARD_ID }));
966
- recordEvent(stores, "board_imported", null, { source: snapshot.board?.slug });
967
- return { imported: true };
968
- });
969
- }
970
-
971
- // ──────────────────────────────────────────────────────────────────────────
972
- // Dispatch — match transport.js's contract: request({method, path, body})
973
-
974
- const ROUTES = [
975
- { m: "GET", re: /^\/api\/bootstrap$/, fn: () => handleBootstrap() },
976
- { m: "GET", re: /^\/api\/boards$/, fn: () => handleBoardsList() },
977
- { m: "POST", re: /^\/api\/boards$/, fn: () => { throw err(403, "preview_is_single_board"); } },
978
- { m: "GET", re: /^\/api\/boards\/([^/]+)\/context$/, fn: (m, u) => handleBoardContext(decodeURIComponent(m[1]), u.searchParams.get("include_struck") === "true") },
979
- { m: "GET", re: /^\/api\/boards\/([^/]+)\/archive$/, fn: (m) => handleBoardArchive(decodeURIComponent(m[1])) },
980
- { m: "GET", re: /^\/api\/boards\/([^/]+)\/export$/, fn: (m) => handleExport(decodeURIComponent(m[1])) },
981
- { m: "PATCH", re: /^\/api\/boards\/([^/]+)$/, fn: (m, u, body) => handleBoardPatch(decodeURIComponent(m[1]), body) },
982
- { m: "POST", re: /^\/api\/boards\/([^/]+)\/entries$/, fn: (m, u, body) => handleBoardEntries(decodeURIComponent(m[1]), body) },
983
- { m: "POST", re: /^\/api\/boards\/([^/]+)\/states$/, fn: (m, u, body) => handleStateCreate(decodeURIComponent(m[1]), body) },
984
- { m: "PATCH", re: /^\/api\/boards\/([^/]+)\/states$/, fn: (m, u, body) => handleStateReorder(decodeURIComponent(m[1]), body) },
985
- { m: "PATCH", re: /^\/api\/board-entries\/([^/]+)$/, fn: (m, u, body) => handleBoardEntryPatch(decodeURIComponent(m[1]), body) },
986
- { m: "POST", re: /^\/api\/states$/, fn: (m, u, body) => handleStateCreate(BOARD_ID, body) },
987
- { m: "PATCH", re: /^\/api\/states\/([^/]+)$/, fn: (m, u, body) => handleStatePatch(decodeURIComponent(m[1]), body) },
988
- { m: "DELETE", re: /^\/api\/states\/([^/]+)$/, fn: (m) => handleStateDelete(decodeURIComponent(m[1])) },
989
- { m: "POST", re: /^\/api\/tickets$/, fn: (m, u, body) => handleTicketCreate(body) },
990
- { m: "PATCH", re: /^\/api\/tickets\/([^/]+)$/, fn: (m, u, body) => handleTicketPatch(decodeURIComponent(m[1]), body) },
991
- { m: "DELETE", re: /^\/api\/tickets\/([^/]+)$/, fn: (m) => handleTicketDelete(decodeURIComponent(m[1])) },
992
- { m: "POST", re: /^\/api\/tickets\/([^/]+)\/archive$/, fn: (m) => handleTicketArchive(decodeURIComponent(m[1]), true) },
993
- { m: "POST", re: /^\/api\/tickets\/([^/]+)\/restore$/, fn: (m) => handleTicketArchive(decodeURIComponent(m[1]), false) },
994
- { m: "GET", re: /^\/api\/tickets\/([^/]+)\/context\/full$/, fn: (m) => handleTicketContext(decodeURIComponent(m[1]), { includeProjectContext: true }) },
995
- { m: "GET", re: /^\/api\/tickets\/([^/]+)\/context$/, fn: (m) => handleTicketContext(decodeURIComponent(m[1])) },
996
- { m: "GET", re: /^\/api\/tickets\/([^/]+)\/comments$/, fn: (m) => handleTicketComments(decodeURIComponent(m[1])) },
997
- { m: "GET", re: /^\/api\/tickets\/([^/]+)\/history$/, fn: (m) => handleTicketHistory(decodeURIComponent(m[1])) },
998
- { m: "POST", re: /^\/api\/tickets\/([^/]+)\/comments$/, fn: (m, u, body) => handleCommentCreate(decodeURIComponent(m[1]), body) },
999
- { m: "PATCH", re: /^\/api\/tickets\/([^/]+)\/comments\/([^/]+)$/, fn: (m, u, body) => handleCommentPatch(decodeURIComponent(m[1]), decodeURIComponent(m[2]), body) },
1000
- { m: "POST", re: /^\/api\/relations$/, fn: (m, u, body) => handleRelationCreate(body) },
1001
- { m: "DELETE", re: /^\/api\/relations\/([^/]+)$/, fn: (m) => handleRelationDelete(decodeURIComponent(m[1])) },
1002
- { m: "GET", re: /^\/api\/search$/, fn: (m, u) => handleSearch(u.searchParams.get("q")) },
1003
- { m: "POST", re: /^\/api\/admin\/import$/, fn: (m, u, body) => handleImport(body) }
1004
- ];
1005
-
1006
- export async function request({ method = "GET", path, body }) {
1007
- const url = new URL(path, "http://local");
1008
- for (const route of ROUTES) {
1009
- if (route.m !== method) continue;
1010
- const m = url.pathname.match(route.re);
1011
- if (!m) continue;
1012
- return route.fn(m, url, body || {});
1013
- }
1014
- throw err(404, "not_found");
1015
- }