@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
@@ -0,0 +1,409 @@
1
+ /**
2
+ * A board's export (`orbit-board-export`), and importing one: into a new
3
+ * board, or over a board it replaces (keeping that board's id and slug). An
4
+ * import is checked where it enters, with zod; a Trello export is read as
5
+ * one first. One field Orbit wouldn't keep (a link with a password in it)
6
+ * is dropped, not refused, so one bad field never stops a whole board.
7
+ */
8
+ import { cardLinkFromElsewhere } from "./card-paths.js";
9
+ import { exportedAttachment, importedAttachment, isShownImage } from "./files.js";
10
+ import { isAgent, onBoard } from "./context.js";
11
+ import { OrbitError, isOrbitError } from "./errors.js";
12
+ import { record, searchDocument } from "./history.js";
13
+ import { CROSS_BOARD_TYPES } from "./cross-board.js";
14
+ import { parseNebulaUrl } from "./nebula-link.js";
15
+ import { cleanName, normalizeEntryType, normalizePriority, normalizeTicketType, requiredString, slugify, asText } from "./text.js";
16
+ import { looksLikeTrello, trelloToSnapshot } from "./trello.js";
17
+ import { labelsByTicket } from "./views.js";
18
+ export const EXPORT_FORMAT = "orbit-board-export";
19
+ const HEX_COLOR = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
20
+ /** A label colour as an import keeps it. It is CSS the app paints with, so only `#rgb` or `#rrggbb` is kept; anything else (a `url(...)`) gets the default. */
21
+ export function keptLabelColor(value) {
22
+ return typeof value === "string" && HEX_COLOR.test(value) ? value : "#64748b";
23
+ }
24
+ export const EXPORT_VERSION = 9;
25
+ /** Exports a board whole: every record, events included; images' bytes when asked. */
26
+ export async function exportBoard(c, options = {}) {
27
+ const include = Boolean(options.includeAttachments);
28
+ const agent = isAgent(c.actor);
29
+ const data = await c.data.read(async (r) => {
30
+ const [board, states, tickets, labels, comments, verdicts, relations, crossOut, attachments, entries, events] = await Promise.all([
31
+ r.board.get(),
32
+ r.states.list(),
33
+ r.tickets.list(),
34
+ r.labels.list(),
35
+ r.comments.list(),
36
+ r.verdicts.list(),
37
+ r.relations.list(),
38
+ r.crossBoardRelations.list(),
39
+ r.attachments.list(),
40
+ r.entries.list(),
41
+ r.events.list()
42
+ ]);
43
+ return { board, states, tickets, labels, comments, verdicts, relations, crossOut, attachments, entries, events, labelsOf: await labelsByTicket(r) };
44
+ });
45
+ // An agent's export leaves out what agents never read, comments hidden from AI, and says how many,
46
+ // so a replace from it can refuse rather than delete them (importReplacing).
47
+ const comments = agent ? data.comments.filter((comment) => !comment.omitted_for_ai) : data.comments;
48
+ return {
49
+ format: EXPORT_FORMAT,
50
+ version: EXPORT_VERSION,
51
+ exported_at: c.clock.now(),
52
+ include_attachments: include,
53
+ ...(agent ? { comments_hidden_from_ai_left_out: data.comments.length - comments.length } : {}),
54
+ board: { ...data.board },
55
+ states: data.states,
56
+ labels: data.labels,
57
+ tickets: data.tickets.map((ticket) => ({ ...ticket, labels: data.labelsOf.get(ticket.id) ?? [] })),
58
+ comments,
59
+ review_verdicts: data.verdicts,
60
+ relations: data.relations,
61
+ // The board's links to cards on other boards of this Orbit. They name those boards by id, so they mean something only here.
62
+ // Other boards' links to its cards live on those boards, and a replace of this board leaves them be.
63
+ cross_board_relations: data.crossOut,
64
+ attachments: await Promise.all(data.attachments.map((attachment) => exportedAttachment(c, attachment, include))),
65
+ board_entries: data.entries,
66
+ events: data.events
67
+ };
68
+ }
69
+ /** The export in `raw`, read and checked: a Trello export becomes one first. */
70
+ async function readSnapshot(c, raw, target) {
71
+ const { BoardIn, SnapshotIn } = await import("./snapshot-schema.js");
72
+ const source = looksLikeTrello(raw) ? trelloToSnapshot(raw, target, c.clock, c.ids) : raw;
73
+ const parsed = SnapshotIn.safeParse(source);
74
+ if (!parsed.success)
75
+ throw new OrbitError("invalid_import_snapshot");
76
+ if (parsed.data.board === undefined || parsed.data.board === null)
77
+ throw new OrbitError("snapshot_missing_board");
78
+ const board = BoardIn.safeParse(parsed.data.board);
79
+ if (!board.success)
80
+ throw new OrbitError("invalid_import_snapshot");
81
+ return { ...parsed.data, board: board.data };
82
+ }
83
+ const rawOf = (input) => input.snapshot ?? input;
84
+ function boardRecord(c, snapshot, boardId, slug, name) {
85
+ const now = c.clock.now();
86
+ const b = snapshot.board;
87
+ return {
88
+ id: boardId,
89
+ slug,
90
+ name,
91
+ repo_url: b.repo_url,
92
+ default_branch: b.default_branch || "main",
93
+ project_notes: b.project_notes,
94
+ agent_instructions: b.agent_instructions,
95
+ ai_enabled: b.ai_enabled === 0 ? 0 : 1,
96
+ created_at: b.created_at ?? now,
97
+ updated_at: b.updated_at ?? now
98
+ };
99
+ }
100
+ /** Writes every record of the snapshot onto the board `w` writes, its search documents too; not its images. */
101
+ async function writeRecords(w, c, snapshot, boardId) {
102
+ const now = () => c.clock.now();
103
+ for (const s of snapshot.states) {
104
+ const state = { id: s.id, board_id: boardId, name: cleanName(s.name) || "Lane", position: s.position, is_default: s.is_default, role: s.role, created_at: s.created_at ?? now() };
105
+ await w.states.insert(state);
106
+ }
107
+ for (const l of snapshot.labels) {
108
+ const label = { id: l.id, board_id: boardId, name: l.name, color: keptLabelColor(l.color), created_at: l.created_at ?? now() };
109
+ await w.labels.insert(label);
110
+ }
111
+ const tickets = new Set();
112
+ for (const t of snapshot.tickets) {
113
+ const ticket = {
114
+ id: t.id,
115
+ board_id: boardId,
116
+ number: t.number,
117
+ title: t.title,
118
+ description: t.description,
119
+ type: normalizeTicketType(t.type ?? "task"),
120
+ parent_ticket_id: t.parent_ticket_id,
121
+ ai_plan: t.ai_plan,
122
+ plan_artifact_path: cardLinkFromElsewhere(t.plan_artifact_path) ?? "",
123
+ implementation_summary: t.implementation_summary,
124
+ implementation_artifact_path: cardLinkFromElsewhere(t.implementation_artifact_path) ?? "",
125
+ implementation_updates: t.implementation_updates,
126
+ nebula_url: parseNebulaUrl(t.nebula_url) ?? "",
127
+ state_id: t.state_id,
128
+ priority: normalizePriority(t.priority),
129
+ created_by: t.created_by ?? "import",
130
+ created_at: t.created_at ?? now(),
131
+ updated_at: t.updated_at ?? now(),
132
+ archived_at: t.archived_at
133
+ };
134
+ await w.tickets.insert(ticket);
135
+ tickets.add(ticket.id);
136
+ for (const label of t.labels ?? [])
137
+ await w.ticketLabels.add(ticket.id, label.id);
138
+ }
139
+ const comments = new Map();
140
+ for (const k of snapshot.comments) {
141
+ if (!tickets.has(k.ticket_id))
142
+ continue;
143
+ const comment = { id: k.id, ticket_id: k.ticket_id, author: k.author ?? "import", kind: k.kind ?? "comment", body: k.body, omitted_for_ai: k.omitted_for_ai, created_at: k.created_at ?? now() };
144
+ await w.comments.insert(comment);
145
+ comments.set(comment.ticket_id, [...(comments.get(comment.ticket_id) ?? []), comment]);
146
+ }
147
+ const verdicts = new Set();
148
+ for (const v of snapshot.review_verdicts) {
149
+ if (!tickets.has(v.ticket_id))
150
+ continue;
151
+ const verdict = {
152
+ id: v.id,
153
+ ticket_id: v.ticket_id,
154
+ verdict: v.verdict,
155
+ blocking_findings_json: v.blocking_findings_json ?? "[]",
156
+ optional_findings_json: v.optional_findings_json ?? "[]",
157
+ evidence_commands_json: v.evidence_commands_json ?? "[]",
158
+ reviewer_profile: v.reviewer_profile,
159
+ reviewer_session_id: v.reviewer_session_id,
160
+ reviewed_commit_sha: v.reviewed_commit_sha,
161
+ dispatch_run_id: v.dispatch_run_id,
162
+ supersedes_prior_review_id: v.supersedes_prior_review_id && verdicts.has(v.supersedes_prior_review_id) ? v.supersedes_prior_review_id : null,
163
+ created_by: v.created_by ?? "import",
164
+ created_at: v.created_at ?? now()
165
+ };
166
+ await w.verdicts.insert(verdict);
167
+ verdicts.add(verdict.id);
168
+ }
169
+ const relations = new Set();
170
+ for (const x of snapshot.relations) {
171
+ const type = x.type ?? "relates_to";
172
+ const key = `${x.source_ticket_id}\n${x.target_ticket_id}\n${type}`;
173
+ if (!tickets.has(x.source_ticket_id) || !tickets.has(x.target_ticket_id) || relations.has(key))
174
+ continue;
175
+ relations.add(key);
176
+ const relation = { id: x.id, source_ticket_id: x.source_ticket_id, target_ticket_id: x.target_ticket_id, type, created_at: x.created_at ?? now() };
177
+ await w.relations.insert(relation);
178
+ }
179
+ for (const e of snapshot.board_entries) {
180
+ const entry = {
181
+ id: e.id,
182
+ board_id: boardId,
183
+ type: normalizeEntryType(e.type),
184
+ title: requiredString(e.title, "entry_title"),
185
+ body: e.body,
186
+ ticket_id: e.ticket_id && tickets.has(e.ticket_id) ? e.ticket_id : null,
187
+ created_by: e.created_by ?? "import",
188
+ created_at: e.created_at ?? now(),
189
+ updated_at: e.updated_at ?? now(),
190
+ struck_at: e.struck_at
191
+ };
192
+ await w.entries.insert(entry);
193
+ }
194
+ for (const v of snapshot.events) {
195
+ const event = { id: v.id, ticket_id: v.ticket_id && tickets.has(v.ticket_id) ? v.ticket_id : null, actor: v.actor ?? "import", type: v.type ?? "imported_event", body_json: v.body_json ?? "{}", created_at: v.created_at ?? now() };
196
+ await w.events.append(event);
197
+ }
198
+ for (const t of snapshot.tickets) {
199
+ const ticket = await w.tickets.get(t.id);
200
+ if (ticket)
201
+ await w.search.index(ticket.id, searchDocument(ticket, comments.get(ticket.id) ?? []));
202
+ }
203
+ }
204
+ /** Stores the snapshot's images whose cards came in, bytes first, then their records; says how many are kept as downloads only. */
205
+ async function writeImages(c, snapshot) {
206
+ const tickets = new Set(snapshot.tickets.map((ticket) => ticket.id));
207
+ const images = [];
208
+ for (const row of snapshot.attachments) {
209
+ const ticketId = typeof row.ticket_id === "string" ? row.ticket_id : "";
210
+ if (!tickets.has(ticketId))
211
+ continue;
212
+ images.push(await importedAttachment(c, row, ticketId));
213
+ }
214
+ if (!images.length)
215
+ return 0;
216
+ return c.data.write(async (w) => {
217
+ const seen = new Set();
218
+ for (const image of images) {
219
+ if (seen.has(image.id))
220
+ continue;
221
+ seen.add(image.id);
222
+ await w.attachments.insert(image);
223
+ }
224
+ return images.filter((image, i) => images.findIndex((other) => other.id === image.id) === i && !isShownImage(image.mime_type)).length;
225
+ });
226
+ }
227
+ /** A cross-board link from an export, or null when it isn't one Orbit could keep. */
228
+ function crossRow(row, now) {
229
+ const field = (key) => {
230
+ const value = row[key];
231
+ return typeof value === "string" ? value : "";
232
+ };
233
+ const type = field("type");
234
+ if (!field("id") || !field("source_ticket_id") || !field("target_board_id") || !field("target_ticket_id"))
235
+ return null;
236
+ if (!CROSS_BOARD_TYPES.includes(type))
237
+ return null;
238
+ return { id: field("id"), source_ticket_id: field("source_ticket_id"), target_board_id: field("target_board_id"), target_ticket_id: field("target_ticket_id"), type: type, created_at: field("created_at") || now };
239
+ }
240
+ /** Of `wanted` (card ids by board), the ones each board of this Orbit has; a board gone or unreadable has none. */
241
+ async function cardsThere(c, wanted) {
242
+ const found = new Map();
243
+ for (const [boardId, ids] of wanted) {
244
+ if (!(await c.store.boards.get(boardId)))
245
+ continue;
246
+ try {
247
+ const data = await c.store.board(boardId);
248
+ const tickets = await data.read((r) => r.tickets.list({ ids: [...ids] }));
249
+ found.set(boardId, new Set(tickets.map((ticket) => ticket.id)));
250
+ }
251
+ catch (error) {
252
+ if (!isOrbitError(error))
253
+ throw error;
254
+ }
255
+ }
256
+ return found;
257
+ }
258
+ const want = (wanted, boardId, ticketId) => {
259
+ wanted.set(boardId, (wanted.get(boardId) ?? new Set()).add(ticketId));
260
+ };
261
+ /**
262
+ * The cross-board links an import keeps onto board `boardId`: only the
263
+ * board's own, since a link lives on the board it starts from. Replacing a
264
+ * board from its own export (`own`) brings back each link whose card comes
265
+ * back, as it was. Anywhere else a link is kept only when the other card
266
+ * exists in this Orbit, under a new id: a copy's link is its own, never the
267
+ * original's, so removing one can't remove the other. Other boards are never written: their links to this board
268
+ * live on them, and a replace leaves them be. `dropped` counts what didn't
269
+ * come.
270
+ */
271
+ async function plannedCrossLinks(c, snapshot, boardId, own) {
272
+ const tickets = new Set(snapshot.tickets.map((ticket) => ticket.id));
273
+ const now = c.clock.now();
274
+ const rows = snapshot.cross_board_relations.map((row) => crossRow(row, now));
275
+ const wanted = new Map();
276
+ if (!own)
277
+ for (const row of rows)
278
+ if (row)
279
+ want(wanted, row.target_board_id, row.target_ticket_id);
280
+ const there = await cardsThere(c, wanted);
281
+ const has = (board, card) => there.get(board)?.has(card) ?? false;
282
+ const outgoing = [];
283
+ const keys = new Set();
284
+ for (const row of rows) {
285
+ if (!row || !tickets.has(row.source_ticket_id) || row.target_board_id === boardId || !(own || has(row.target_board_id, row.target_ticket_id)))
286
+ continue;
287
+ const key = `${row.source_ticket_id}\n${row.target_board_id}\n${row.target_ticket_id}\n${row.type}`;
288
+ if (keys.has(key))
289
+ continue;
290
+ keys.add(key);
291
+ outgoing.push({ id: own ? row.id : c.ids.next(), source_ticket_id: row.source_ticket_id, target_board_id: row.target_board_id, target_ticket_id: row.target_ticket_id, type: row.type, created_at: row.created_at });
292
+ }
293
+ return { outgoing, dropped: rows.length - outgoing.length };
294
+ }
295
+ function counts(snapshot) {
296
+ return snapshot.imported_counts ?? { states: snapshot.states.length, tickets: snapshot.tickets.length, comments: snapshot.comments.length, labels: snapshot.labels.length };
297
+ }
298
+ /**
299
+ * Replacing a board deletes what it holds, so it is the person's call,
300
+ * never an agent's, as with a self-update. Refused before anything is read,
301
+ * whatever the transport: the JSON API, or a host's in-process client.
302
+ */
303
+ export function refuseAgentReplace(c, input) {
304
+ if (input.replace_existing && isAgent(c.actor))
305
+ throw new OrbitError("forbidden", { message: "Replacing a board is the person's call, not an agent's: import it as a new board instead." });
306
+ }
307
+ /**
308
+ * Replaces a board with an export. The board keeps its own id and slug, so
309
+ * links to it still work; a copy of it is kept first where storage can.
310
+ * Never an agent's (`refuseAgentReplace`).
311
+ */
312
+ export async function importReplacing(c, input) {
313
+ refuseAgentReplace(c, input);
314
+ const snapshot = await readSnapshot(c, rawOf(input), { id: c.board.id, slug: c.board.slug });
315
+ const replace = Boolean(input.replace_existing);
316
+ if (!replace)
317
+ throw new OrbitError("import_requires_empty_board_or_replace_existing");
318
+ // Fails closed: a file that has the marker goes ahead only when it is exactly the number 0. A file
319
+ // whose marker was removed by hand reads as one made in the app; nothing can tell them apart.
320
+ const hidden = snapshot.comments_hidden_from_ai_left_out;
321
+ if (hidden !== undefined && hidden !== 0) {
322
+ const count = typeof hidden === "number" && Number.isInteger(hidden) && hidden > 0 ? hidden : null;
323
+ const message = count === null
324
+ ? "Replacing the board from this export could delete comments hidden from AI: it carries an agent's count of them left out (comments_hidden_from_ai_left_out) that isn't a whole number. Use an export made in the app."
325
+ : `Replacing the board from this export would delete ${String(count)} comment${count === 1 ? "" : "s"} hidden from AI: an agent made it, and an agent's export leaves them out. Use an export made in the app.`;
326
+ throw new OrbitError("import_would_delete_hidden_comments", { message });
327
+ }
328
+ await c.store.backups?.board(c.board.id, "pre-import-replace");
329
+ const board = boardRecord(c, snapshot, c.board.id, c.board.slug, snapshot.board.name || c.board.name);
330
+ const own = snapshot.board.id === c.board.id;
331
+ const cross = await plannedCrossLinks(c, snapshot, board.id, own);
332
+ await c.data.write(async (w) => {
333
+ await w.clear();
334
+ await w.board.set(board);
335
+ await writeRecords(w, c, snapshot, board.id);
336
+ for (const relation of cross.outgoing)
337
+ await w.crossBoardRelations.insert(relation);
338
+ await record(w, c, "board_imported", null, { board_id: board.id, replace_existing: true });
339
+ });
340
+ const downloads = await writeImages(c, snapshot);
341
+ c.changed(board.id);
342
+ return { ok: true, imported_board_id: board.id, imported_board_slug: board.slug, replace_existing: true, source_format: snapshot.source_format ?? EXPORT_FORMAT, imported_counts: counts(snapshot), images_kept_as_downloads: downloads, cross_board_links_dropped: cross.dropped };
343
+ }
344
+ async function freeSlug(c, value) {
345
+ const base = slugify(value) || `imported-board-${c.ids.next().slice(0, 8)}`;
346
+ if (!(await c.store.boards.bySlug(base)))
347
+ return base;
348
+ for (let i = 2; i < 1000; i += 1) {
349
+ const candidate = `${base}-${i}`;
350
+ if (!(await c.store.boards.bySlug(candidate)))
351
+ return candidate;
352
+ }
353
+ return `${base}-${c.ids.next().slice(0, 8)}`;
354
+ }
355
+ /**
356
+ * Brings a board from an older Orbit (its folder read as an export, or its
357
+ * export): a new board with the board's own id and slug, every record with
358
+ * its own id, and no event of its own, so the board reads as it did there.
359
+ * Refused when this Orbit already has a board with that id or slug.
360
+ */
361
+ export async function importKeepingBoard(c, raw, board) {
362
+ const snapshot = await readSnapshot(c, raw, board);
363
+ if ((await c.store.boards.get(board.id)) || (await c.store.boards.bySlug(board.slug)))
364
+ throw new OrbitError("board_slug_taken", { message: `This Orbit already has the board ${board.slug}.` });
365
+ const record = boardRecord(c, snapshot, board.id, board.slug, snapshot.board.name.trim() || board.slug);
366
+ const cross = await plannedCrossLinks(c, snapshot, board.id, false);
367
+ const row = await c.store.boards.create(record, async (w) => {
368
+ await writeRecords(w, c, snapshot, board.id);
369
+ for (const relation of cross.outgoing)
370
+ await w.crossBoardRelations.insert(relation);
371
+ });
372
+ const downloads = await writeImages(await onBoard(c, row), snapshot);
373
+ return { board: row, snapshot, downloads, crossDropped: cross.dropped };
374
+ }
375
+ /** What the browser preview of 1.x and 2.0 exported (and kept in its database): `starscape-orbit-preview`. */
376
+ export const OLD_PREVIEW_FORMAT = "orbit.board.snapshot/v1";
377
+ /**
378
+ * The old browser preview's board as an export: it kept the cards' labels
379
+ * beside them and a relation's type as `kind`. `raw` is its export, or its
380
+ * database's stores read whole.
381
+ */
382
+ export function fromOldPreview(raw) {
383
+ const rows = (value) => (Array.isArray(value) ? value : []);
384
+ const byId = new Map(rows(raw.labels).map((label) => [label.id, label]));
385
+ const links = rows(raw.ticket_labels);
386
+ const tickets = rows(raw.tickets).map((ticket) => ({ ...ticket, labels: links.filter((link) => link.ticket_id === ticket.id).flatMap((link) => byId.get(link.label_id) ?? []) }));
387
+ const relations = rows(raw.relations).map((relation) => ({ ...relation, type: relation.type ?? relation.kind }));
388
+ return { ...raw, format: EXPORT_FORMAT, source_format: OLD_PREVIEW_FORMAT, tickets, relations };
389
+ }
390
+ /** Imports an export as a new board, under its own slug or the next free one. */
391
+ export async function importAsNewBoard(c, input) {
392
+ const raw = rawOf(input);
393
+ const boardId = c.ids.next();
394
+ const snapshot = await readSnapshot(c, raw, { id: boardId, slug: "" });
395
+ const rawName = raw && typeof raw === "object" && "name" in raw ? raw.name : undefined;
396
+ const name = snapshot.board.name.trim() || asText(rawName ?? "").trim() || "Imported Board";
397
+ const slug = await freeSlug(c, input.slug || snapshot.board.slug || rawName || name);
398
+ const board = boardRecord(c, snapshot, boardId, slug, name);
399
+ const cross = await plannedCrossLinks(c, snapshot, boardId, false);
400
+ const row = await c.store.boards.create(board, async (w) => {
401
+ await writeRecords(w, c, snapshot, boardId);
402
+ for (const relation of cross.outgoing)
403
+ await w.crossBoardRelations.insert(relation);
404
+ await record(w, c, "board_imported", null, { board_id: boardId, replace_existing: false });
405
+ });
406
+ const downloads = await writeImages(await onBoard(c, row), snapshot);
407
+ c.changed(boardId);
408
+ return { ok: true, imported_board_id: boardId, imported_board_slug: slug, replace_existing: false, source_format: snapshot.source_format ?? EXPORT_FORMAT, imported_counts: counts(snapshot), images_kept_as_downloads: downloads, cross_board_links_dropped: cross.dropped, created_new_board: true };
409
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Lanes: making, renaming, reordering and deleting them. A lane's role ties it
3
+ * to the agent flow; the AI Ready lane keeps its name, since agents take work
4
+ * from it by name.
5
+ */
6
+ import type { BoardCtx } from "./context.ts";
7
+ import type { Id, State } from "./model.ts";
8
+ export declare function createState(c: BoardCtx, input: {
9
+ name?: unknown;
10
+ role?: unknown;
11
+ }): Promise<State>;
12
+ export declare function updateState(c: BoardCtx, stateId: Id, input: {
13
+ name?: unknown;
14
+ is_default?: unknown;
15
+ role?: unknown;
16
+ }): Promise<State>;
17
+ /** Puts the lanes in this order; every lane of the board, each once. */
18
+ export declare function reorderStates(c: BoardCtx, input: {
19
+ state_ids?: unknown;
20
+ }): Promise<State[]>;
21
+ /** Deletes an empty lane; the board keeps at least one, and a default. */
22
+ export declare function deleteState(c: BoardCtx, stateId: Id): Promise<{
23
+ ok: true;
24
+ deleted_state_id: Id;
25
+ }>;
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Lanes: making, renaming, reordering and deleting them. A lane's role ties it
3
+ * to the agent flow; the AI Ready lane keeps its name, since agents take work
4
+ * from it by name.
5
+ */
6
+ import { OrbitError } from "./errors.js";
7
+ import { record } from "./history.js";
8
+ import { laneNameOf, normalizeStateRole } from "./text.js";
9
+ const AI_READY = "AI Ready";
10
+ /** Takes a role from whichever other lane holds it: at most one lane per board plays each role. */
11
+ async function freeRole(w, role, except) {
12
+ if (!role)
13
+ return;
14
+ for (const state of await w.states.list()) {
15
+ if (state.role === role && state.id !== except)
16
+ await w.states.update(state.id, { role: null });
17
+ }
18
+ }
19
+ export async function createState(c, input) {
20
+ const role = input.role !== undefined ? normalizeStateRole(input.role) : null;
21
+ let name;
22
+ if (role === "ai_ready") {
23
+ if (input.name && input.name !== AI_READY)
24
+ throw new OrbitError("ai_ready_lane_must_be_named_ai_ready");
25
+ name = AI_READY;
26
+ }
27
+ else {
28
+ name = laneNameOf(input.name);
29
+ }
30
+ const state = await c.data.write(async (w) => {
31
+ await freeRole(w, role, null);
32
+ const states = await w.states.list();
33
+ const top = states.reduce((max, s) => Math.max(max, s.position), 0);
34
+ const made = { id: c.ids.next(), board_id: c.board.id, name, position: top + 1, is_default: 0, role, created_at: c.clock.now() };
35
+ await w.states.insert(made);
36
+ await record(w, c, "state_created", null, { board_id: c.board.id, state_id: made.id, name, role });
37
+ return made;
38
+ });
39
+ c.changed(c.board.id);
40
+ return state;
41
+ }
42
+ export async function updateState(c, stateId, input) {
43
+ const state = await c.data.write(async (w) => {
44
+ const before = await w.states.get(stateId);
45
+ if (!before)
46
+ throw new OrbitError("state_not_found");
47
+ const patch = {};
48
+ if (input.name !== undefined) {
49
+ const name = laneNameOf(input.name);
50
+ if (before.role === "ai_ready" && name !== AI_READY)
51
+ throw new OrbitError("cannot_rename_ai_ready_lane");
52
+ patch.name = name;
53
+ }
54
+ if (input.is_default !== undefined) {
55
+ const isDefault = input.is_default ? 1 : 0;
56
+ if (isDefault) {
57
+ for (const other of await w.states.list()) {
58
+ if (other.is_default)
59
+ await w.states.update(other.id, { is_default: 0 });
60
+ }
61
+ }
62
+ patch.is_default = isDefault;
63
+ }
64
+ if (input.role !== undefined) {
65
+ const role = normalizeStateRole(input.role);
66
+ await freeRole(w, role, stateId);
67
+ if (role === "ai_ready") {
68
+ const finalName = input.name !== undefined ? laneNameOf(input.name) : before.name;
69
+ if (finalName !== AI_READY) {
70
+ if (input.name !== undefined)
71
+ throw new OrbitError("ai_ready_lane_must_be_named_ai_ready");
72
+ patch.name = AI_READY;
73
+ }
74
+ }
75
+ patch.role = role;
76
+ }
77
+ if (!Object.keys(patch).length)
78
+ return before;
79
+ const after = await w.states.update(stateId, patch);
80
+ await record(w, c, "state_updated", null, { board_id: c.board.id, state_id: stateId, fields: Object.keys(input) });
81
+ return after;
82
+ });
83
+ c.changed(c.board.id);
84
+ return state;
85
+ }
86
+ /** Puts the lanes in this order; every lane of the board, each once. */
87
+ export async function reorderStates(c, input) {
88
+ const ids = Array.isArray(input.state_ids) ? input.state_ids.map(String) : [];
89
+ const states = await c.data.write(async (w) => {
90
+ const existing = (await w.states.list()).map((state) => state.id);
91
+ if (ids.length !== existing.length || !existing.every((id) => ids.includes(id)))
92
+ throw new OrbitError("invalid_state_order");
93
+ for (const [position, id] of ids.entries())
94
+ await w.states.update(id, { position });
95
+ await record(w, c, "states_reordered", null, { board_id: c.board.id, state_ids: ids });
96
+ return w.states.list();
97
+ });
98
+ c.changed(c.board.id);
99
+ return states;
100
+ }
101
+ /** Deletes an empty lane; the board keeps at least one, and a default. */
102
+ export async function deleteState(c, stateId) {
103
+ const answer = await c.data.write(async (w) => {
104
+ const states = await w.states.list();
105
+ const state = states.find((s) => s.id === stateId);
106
+ if (!state)
107
+ throw new OrbitError("state_not_found");
108
+ if ((await w.tickets.list({ state: stateId })).length)
109
+ throw new OrbitError("state_has_tickets");
110
+ if (states.length <= 1)
111
+ throw new OrbitError("cannot_delete_last_state");
112
+ await w.states.remove(stateId);
113
+ const rest = states.filter((s) => s.id !== stateId);
114
+ for (const [position, s] of rest.entries()) {
115
+ if (s.position !== position)
116
+ await w.states.update(s.id, { position });
117
+ }
118
+ const first = rest[0];
119
+ if (state.is_default && first)
120
+ await w.states.update(first.id, { is_default: 1 });
121
+ await record(w, c, "state_deleted", null, { board_id: c.board.id, state_id: stateId, name: state.name });
122
+ return { ok: true, deleted_state_id: stateId };
123
+ });
124
+ c.changed(c.board.id);
125
+ return answer;
126
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * The small rules every use case shares for what people and agents send:
3
+ * names on one line, slugs, priorities and types read the way the API has
4
+ * always read them, each refusal with its error code.
5
+ */
6
+ import type { EntryType, LaneRole, Priority, TicketType } from "./model.ts";
7
+ /**
8
+ * What a field holds as text: a string as it is, a number or boolean written
9
+ * out, anything else (an object, null, nothing) as "". Input is never
10
+ * stringified as "[object Object]".
11
+ */
12
+ export declare function asText(value: unknown): string;
13
+ /** A board slug: lowercase letters, digits and single dashes, at most 80 characters. */
14
+ export declare function slugify(value: unknown): string;
15
+ /** True for a slug slugify leaves as it is. */
16
+ export declare function isSlug(value: unknown): value is string;
17
+ /** A name as one clean line: line breaks and control characters become spaces. */
18
+ export declare function cleanName(value: unknown): string;
19
+ /** A string with something in it, trimmed; otherwise `missing_<field>`. */
20
+ export declare function requiredString(value: unknown, field: string): string;
21
+ /** A lane's name: required, and one clean line. */
22
+ export declare function laneNameOf(value: unknown): string;
23
+ /** 0 to 4, rounded; anything unreadable is med (2). */
24
+ export declare function normalizePriority(value: unknown): Priority;
25
+ export declare function normalizeTicketType(value: unknown): TicketType;
26
+ export declare function normalizeEntryType(value: unknown): EntryType;
27
+ /** A lane's role in the agent flow, or null for none. */
28
+ export declare function normalizeStateRole(value: unknown): LaneRole | null;
29
+ /** `existing`, then a blank line, then `note`; just `existing` when the note is empty. */
30
+ export declare function appendNote(existing: string, note: unknown): string;
31
+ /** At most `max` characters; `max` 0 means no cap. */
32
+ export declare function cap(value: unknown, max: number): string;
33
+ /** A whole number above zero, else `fallback`. */
34
+ export declare function positiveInt(value: unknown, fallback: number): number;
35
+ /** A whole number from zero, else `fallback`. */
36
+ export declare function nonNegativeInt(value: unknown, fallback: number): number;
37
+ /** An optional string field, trimmed; "" when absent. */
38
+ export declare function optionalString(value: unknown): string;
39
+ /** A field that is a string when given, else "" (the API's `body.x || ""`). */
40
+ export declare function textOr(value: unknown, fallback?: string): string;