@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.
- package/Dockerfile +10 -8
- package/README.md +146 -164
- package/dist/full/csp.txt +1 -0
- package/dist/full/fonts/JetBrains_Mono/JetBrainsMono-subset.woff2 +0 -0
- package/dist/full/fonts/JetBrains_Mono/README.txt +5 -0
- package/dist/full/fonts/Mona_Sans/MonaSans-subset.woff2 +0 -0
- package/{public/fonts/Open_Sans → dist/full/fonts/Mona_Sans}/OFL.txt +93 -93
- package/dist/full/fonts/Mona_Sans/README.txt +10 -0
- package/{public/fonts/JetBrains_Mono → dist/full/fonts/Newsreader}/OFL.txt +93 -93
- package/dist/full/fonts/Newsreader/README.txt +8 -0
- package/dist/full/fonts/Newsreader/newsreader.woff2 +0 -0
- package/dist/full/index.html +36 -653
- package/dist/full/js/app.BmuponPJ.js +32 -0
- package/dist/full/js/app.BmuponPJ.js.map +1 -0
- package/dist/full/js/chunk.1fJGQGbW.js +2 -0
- package/dist/full/js/chunk.1fJGQGbW.js.map +1 -0
- package/dist/full/js/chunk.5D7JO2zp.js +2 -0
- package/dist/full/js/chunk.5D7JO2zp.js.map +1 -0
- package/dist/full/js/chunk.BrNn_D79.js +2 -0
- package/dist/full/js/chunk.BrNn_D79.js.map +1 -0
- package/dist/full/js/chunk.CokJXIqM.js +2 -0
- package/dist/full/js/chunk.CokJXIqM.js.map +1 -0
- package/dist/full/js/chunk.D_Haqy6R.js +2 -0
- package/dist/full/js/chunk.D_Haqy6R.js.map +1 -0
- package/dist/full/js/chunk.RO0jsH6I.js +15 -0
- package/dist/full/js/chunk.RO0jsH6I.js.map +1 -0
- package/dist/full/js/chunk.dqWOEtBM.js +2 -0
- package/dist/full/js/chunk.dqWOEtBM.js.map +1 -0
- package/dist/full/js/warm.f260bbbc.js +15 -0
- package/dist/full/orbits-favicon.png +0 -0
- package/dist/full/orbits-logo-bg-wide.png +0 -0
- package/dist/full/orbits-logo-bg.png +0 -0
- package/dist/full/orbits-logo.png +0 -0
- package/dist/full/styles.2b154ffe.css +1 -0
- package/dist/full/vendor/starlove-ui/theme-init.js +66 -0
- package/dist/full/version.json +2 -2
- package/docs/AGENT_GUIDE.md +114 -0
- package/docs/UPDATING.md +249 -64
- package/lib/adapters/indexeddb/blobs.d.ts +3 -0
- package/lib/adapters/indexeddb/blobs.js +54 -0
- package/lib/adapters/indexeddb/database.d.ts +60 -0
- package/lib/adapters/indexeddb/database.js +126 -0
- package/lib/adapters/indexeddb/index.d.ts +5 -0
- package/lib/adapters/indexeddb/index.js +3 -0
- package/lib/adapters/indexeddb/locks.d.ts +14 -0
- package/lib/adapters/indexeddb/locks.js +40 -0
- package/lib/adapters/indexeddb/store.d.ts +40 -0
- package/lib/adapters/indexeddb/store.js +424 -0
- package/lib/adapters/indexeddb/tables.d.ts +43 -0
- package/lib/adapters/indexeddb/tables.js +127 -0
- package/lib/adapters/memory/blobs.d.ts +3 -0
- package/lib/adapters/memory/blobs.js +44 -0
- package/lib/adapters/memory/board.d.ts +41 -0
- package/lib/adapters/memory/board.js +521 -0
- package/lib/adapters/memory/index.d.ts +2 -0
- package/lib/adapters/memory/index.js +2 -0
- package/lib/adapters/memory/search.d.ts +6 -0
- package/lib/adapters/memory/search.js +30 -0
- package/lib/adapters/memory/store.d.ts +34 -0
- package/lib/adapters/memory/store.js +266 -0
- package/lib/adapters/sqlite/backups.d.ts +53 -0
- package/lib/adapters/sqlite/backups.js +232 -0
- package/lib/adapters/sqlite/blobs.d.ts +14 -0
- package/lib/adapters/sqlite/blobs.js +109 -0
- package/lib/adapters/sqlite/board.d.ts +77 -0
- package/lib/adapters/sqlite/board.js +328 -0
- package/lib/adapters/sqlite/database.d.ts +67 -0
- package/lib/adapters/sqlite/database.js +285 -0
- package/lib/adapters/sqlite/index.d.ts +3 -0
- package/lib/adapters/sqlite/index.js +2 -0
- package/lib/adapters/sqlite/lock.d.ts +11 -0
- package/lib/adapters/sqlite/lock.js +34 -0
- package/lib/adapters/sqlite/records.d.ts +54 -0
- package/lib/adapters/sqlite/records.js +546 -0
- package/lib/adapters/sqlite/schema.d.ts +54 -0
- package/lib/adapters/sqlite/schema.js +276 -0
- package/lib/adapters/sqlite/store.d.ts +39 -0
- package/lib/adapters/sqlite/store.js +385 -0
- package/lib/browser/in-page.d.ts +31 -0
- package/lib/browser/in-page.js +46 -0
- package/lib/browser/old-preview.d.ts +13 -0
- package/lib/browser/old-preview.js +80 -0
- package/lib/browser/preview-board.d.ts +8 -0
- package/lib/browser/preview-board.js +69 -0
- package/lib/cli/main.d.ts +7 -0
- package/lib/cli/main.js +343 -0
- package/lib/cli/orbit.d.ts +8 -0
- package/lib/cli/orbit.js +25 -0
- package/lib/client/http.d.ts +38 -0
- package/lib/client/http.js +334 -0
- package/lib/client/local.d.ts +26 -0
- package/lib/client/local.js +190 -0
- package/lib/client/object-url.d.ts +8 -0
- package/lib/client/object-url.js +21 -0
- package/lib/client/types.d.ts +268 -0
- package/lib/client/types.js +8 -0
- package/lib/commands/agent-tools.d.ts +31 -0
- package/lib/commands/agent-tools.js +100 -0
- package/lib/commands/index.d.ts +56 -0
- package/lib/commands/index.js +298 -0
- package/lib/commands/parse.d.ts +73 -0
- package/lib/commands/parse.js +231 -0
- package/lib/commands/present.d.ts +134 -0
- package/lib/commands/present.js +198 -0
- package/lib/commands/session.d.ts +85 -0
- package/lib/commands/session.js +138 -0
- package/lib/commands/verbs.d.ts +35 -0
- package/lib/commands/verbs.js +842 -0
- package/lib/core/actors.d.ts +18 -0
- package/lib/core/actors.js +45 -0
- package/lib/core/agent.d.ts +128 -0
- package/lib/core/agent.js +323 -0
- package/lib/core/boards.d.ts +113 -0
- package/lib/core/boards.js +288 -0
- package/lib/core/bootstrap.d.ts +47 -0
- package/lib/core/bootstrap.js +100 -0
- package/lib/core/bytes.d.ts +10 -0
- package/lib/core/bytes.js +52 -0
- package/lib/core/card-paths.d.ts +25 -0
- package/lib/core/card-paths.js +44 -0
- package/lib/core/context.d.ts +60 -0
- package/lib/core/context.js +42 -0
- package/lib/core/cross-board.d.ts +81 -0
- package/lib/core/cross-board.js +228 -0
- package/lib/core/describe.d.ts +21 -0
- package/lib/core/describe.js +81 -0
- package/lib/core/errors.d.ts +159 -0
- package/lib/core/errors.js +160 -0
- package/lib/core/files.d.ts +79 -0
- package/lib/core/files.js +262 -0
- package/lib/core/history.d.ts +20 -0
- package/lib/core/history.js +53 -0
- package/lib/core/image-types.d.ts +16 -0
- package/lib/core/image-types.js +36 -0
- package/lib/core/model.d.ts +263 -0
- package/lib/core/model.js +34 -0
- package/lib/core/nebula-link.d.ts +14 -0
- package/lib/core/nebula-link.js +39 -0
- package/lib/core/relations.d.ts +58 -0
- package/lib/core/relations.js +112 -0
- package/lib/core/search.d.ts +38 -0
- package/lib/core/search.js +92 -0
- package/lib/core/sessions.d.ts +44 -0
- package/lib/core/sessions.js +158 -0
- package/lib/core/snapshot-schema.d.ts +295 -0
- package/lib/core/snapshot-schema.js +82 -0
- package/lib/core/snapshots.d.ts +75 -0
- package/lib/core/snapshots.js +409 -0
- package/lib/core/states.d.ts +25 -0
- package/lib/core/states.js +126 -0
- package/lib/core/text.d.ts +40 -0
- package/lib/core/text.js +113 -0
- package/lib/core/tickets.d.ts +69 -0
- package/lib/core/tickets.js +311 -0
- package/lib/core/trello.d.ts +17 -0
- package/lib/core/trello.js +269 -0
- package/lib/core/verdicts.d.ts +28 -0
- package/lib/core/verdicts.js +92 -0
- package/lib/core/views.d.ts +95 -0
- package/lib/core/views.js +186 -0
- package/lib/http/app.d.ts +46 -0
- package/lib/http/app.js +491 -0
- package/lib/mcp-server.d.ts +10 -0
- package/lib/mcp-server.js +120 -0
- package/lib/node/assets.d.ts +7 -0
- package/lib/node/assets.js +68 -0
- package/lib/node/data-folder.d.ts +21 -0
- package/lib/node/data-folder.js +34 -0
- package/lib/node/env.d.ts +38 -0
- package/lib/node/env.js +49 -0
- package/lib/node/host-check.d.ts +24 -0
- package/lib/node/host-check.js +53 -0
- package/lib/node/host-services.d.ts +9 -0
- package/lib/node/host-services.js +150 -0
- package/lib/node/local.d.ts +22 -0
- package/lib/node/local.js +61 -0
- package/lib/node/migrate.d.ts +53 -0
- package/lib/node/migrate.js +635 -0
- package/lib/node/package.d.ts +7 -0
- package/lib/node/package.js +14 -0
- package/lib/node/repo-board.d.ts +49 -0
- package/lib/node/repo-board.js +245 -0
- package/lib/node/serve.d.ts +8 -0
- package/lib/node/serve.js +106 -0
- package/lib/orbit.d.ts +47 -0
- package/lib/orbit.js +44 -0
- package/lib/quiet-sqlite-warning.d.ts +1 -0
- package/lib/quiet-sqlite-warning.js +18 -0
- package/lib/store/blob-path.d.ts +6 -0
- package/lib/store/blob-path.js +12 -0
- package/lib/store/conformance.d.ts +52 -0
- package/lib/store/conformance.js +1449 -0
- package/lib/store/search-words.d.ts +10 -0
- package/lib/store/search-words.js +19 -0
- package/lib/store/types.d.ts +452 -0
- package/lib/store/types.js +34 -0
- package/llms.txt +63 -0
- package/package.json +109 -25
- package/src/cli/orbit.js +11 -658
- package/src/mcp-server.js +16 -876
- package/SKILL-ORBIT.md +0 -151
- package/build.mjs +0 -210
- package/dist/full/js/app.62905fcb.js +0 -952
- package/dist/full/js/app.62905fcb.js.map +0 -7
- package/dist/full/styles.4e60d824.css +0 -1
- package/docs/AGENT_PROTOCOL.md +0 -424
- package/docs/MCP_SETUP.md +0 -54
- package/docs/ORBIT_DISPATCH.md +0 -280
- package/docs/SS-Orbit-Dark.png +0 -0
- package/docs/SS-Orbit-Light.png +0 -0
- package/docs/mcp-client.example.json +0 -13
- package/docs/setup/orbit-init-wizard.md +0 -174
- package/public/apple-touch-icon.png +0 -0
- package/public/cursors/live_arrow.cur +0 -0
- package/public/cursors/live_link.cur +0 -0
- package/public/cursors/live_move.cur +0 -0
- package/public/cursors/live_select.cur +0 -0
- package/public/cursors/live_unavail.cur +0 -0
- package/public/favicon-32x32.png +0 -0
- package/public/fonts/Chakra_Petch/ChakraPetch-Bold.ttf +0 -0
- package/public/fonts/Chakra_Petch/ChakraPetch-BoldItalic.ttf +0 -0
- package/public/fonts/Chakra_Petch/ChakraPetch-Italic.ttf +0 -0
- package/public/fonts/Chakra_Petch/ChakraPetch-Light.ttf +0 -0
- package/public/fonts/Chakra_Petch/ChakraPetch-LightItalic.ttf +0 -0
- package/public/fonts/Chakra_Petch/ChakraPetch-Medium.ttf +0 -0
- package/public/fonts/Chakra_Petch/ChakraPetch-MediumItalic.ttf +0 -0
- package/public/fonts/Chakra_Petch/ChakraPetch-Regular.ttf +0 -0
- package/public/fonts/Chakra_Petch/ChakraPetch-SemiBold.ttf +0 -0
- package/public/fonts/Chakra_Petch/ChakraPetch-SemiBoldItalic.ttf +0 -0
- package/public/fonts/Chakra_Petch/OFL.txt +0 -93
- package/public/fonts/Inter/Inter-Italic-VariableFont_opsz,wght.ttf +0 -0
- package/public/fonts/Inter/Inter-VariableFont_opsz,wght.ttf +0 -0
- package/public/fonts/Inter/OFL.txt +0 -93
- package/public/fonts/Inter/README.txt +0 -118
- package/public/fonts/JetBrains_Mono/JetBrainsMono-Italic-VariableFont_wght.ttf +0 -0
- package/public/fonts/JetBrains_Mono/JetBrainsMono-VariableFont_wght.ttf +0 -0
- package/public/fonts/JetBrains_Mono/README.txt +0 -79
- package/public/fonts/OpenRing-Regular.ttf +0 -0
- package/public/fonts/Open_Sans/OpenSans-Italic-VariableFont_wdth,wght.ttf +0 -0
- package/public/fonts/Open_Sans/OpenSans-VariableFont_wdth,wght.ttf +0 -0
- package/public/fonts/Open_Sans/README.txt +0 -100
- package/public/fonts/Orbitron/OFL.txt +0 -93
- package/public/fonts/Orbitron/Orbitron-VariableFont_wght.ttf +0 -0
- package/public/fonts/Orbitron/README.txt +0 -68
- package/public/fonts/Rajdhani/OFL.txt +0 -93
- package/public/fonts/Rajdhani/Rajdhani-Bold.ttf +0 -0
- package/public/fonts/Rajdhani/Rajdhani-Light.ttf +0 -0
- package/public/fonts/Rajdhani/Rajdhani-Medium.ttf +0 -0
- package/public/fonts/Rajdhani/Rajdhani-Regular.ttf +0 -0
- package/public/fonts/Rajdhani/Rajdhani-SemiBold.ttf +0 -0
- package/public/fonts/runescape-uf.ttf +0 -0
- package/public/index.html +0 -769
- package/public/js/actor-labels.js +0 -65
- package/public/js/api.js +0 -39
- package/public/js/app.js +0 -45
- package/public/js/artifact-drawer.js +0 -79
- package/public/js/board-export.js +0 -161
- package/public/js/board-menu.js +0 -374
- package/public/js/card-actions.js +0 -545
- package/public/js/config.js +0 -20
- package/public/js/create-card.js +0 -190
- package/public/js/dom.js +0 -19
- package/public/js/drawer.js +0 -273
- package/public/js/font-preference.js +0 -229
- package/public/js/format.js +0 -433
- package/public/js/help-menu.js +0 -31
- package/public/js/icons.js +0 -16
- package/public/js/kanban.js +0 -694
- package/public/js/local-backend.js +0 -1015
- package/public/js/main.js +0 -161
- package/public/js/modal.js +0 -33
- package/public/js/motion-preference.js +0 -64
- package/public/js/panel.js +0 -58
- package/public/js/router.js +0 -134
- package/public/js/search.js +0 -56
- package/public/js/settings-tabs.js +0 -7
- package/public/js/settings.js +0 -1634
- package/public/js/sse.js +0 -159
- package/public/js/starscape.js +0 -332
- package/public/js/state.js +0 -129
- package/public/js/text-editing.js +0 -128
- package/public/js/theme-preference.js +0 -95
- package/public/js/ticket-detail.js +0 -1516
- package/public/js/ticket-typeahead.js +0 -49
- package/public/js/toast.js +0 -89
- package/public/js/topbar-notes.js +0 -155
- package/public/js/transport.js +0 -22
- package/public/js/typeahead.js +0 -206
- package/public/js/unread.js +0 -121
- package/public/js/update-check.js +0 -58
- package/public/js/url-routes.js +0 -94
- package/public/orbits-favicon.png +0 -0
- package/public/orbits-logo-bg-wide.png +0 -0
- package/public/orbits-logo-bg.png +0 -0
- package/public/orbits-logo.png +0 -0
- package/public/styles.css +0 -7709
- package/src/core/agent.js +0 -387
- package/src/core/agents-md.js +0 -109
- package/src/core/artifacts.js +0 -149
- package/src/core/attachments.js +0 -238
- package/src/core/auth.js +0 -85
- package/src/core/backups.js +0 -142
- package/src/core/board.js +0 -148
- package/src/core/boards.js +0 -409
- package/src/core/bootstrap.js +0 -170
- package/src/core/css-bundle.js +0 -30
- package/src/core/db.js +0 -315
- package/src/core/delete-board.js +0 -163
- package/src/core/dispatch.js +0 -1007
- package/src/core/events.js +0 -314
- package/src/core/http.js +0 -115
- package/src/core/migrate.js +0 -198
- package/src/core/paths.js +0 -55
- package/src/core/provision-repo-board.js +0 -119
- package/src/core/queries.js +0 -246
- package/src/core/registry.js +0 -211
- package/src/core/relations.js +0 -163
- package/src/core/review-verdicts.js +0 -138
- package/src/core/router.js +0 -556
- package/src/core/search.js +0 -183
- package/src/core/seed.js +0 -234
- package/src/core/snapshots.js +0 -692
- package/src/core/states.js +0 -167
- package/src/core/system-picker.js +0 -109
- package/src/core/tickets.js +0 -400
- package/src/core/util.js +0 -106
- package/src/core/version-check.js +0 -154
- package/src/mcp/orbit-client.js +0 -287
- 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;
|