@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,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The records Orbit keeps, typed with the names the JSON API, the export
|
|
3
|
+
* format (`.orbit.json`) and the database already use: one name per field
|
|
4
|
+
* everywhere, snake_case, so nothing maps between layers.
|
|
5
|
+
*
|
|
6
|
+
* Each record is what storage holds, field for field, in the order answers list
|
|
7
|
+
* them. Flags are 0 or 1 and JSON columns stay strings, because answers and
|
|
8
|
+
* exports carry them that way. What the core derives for an answer (a card's
|
|
9
|
+
* lane name, its comment count) is built from these and is not stored.
|
|
10
|
+
*
|
|
11
|
+
* This file has no logic but the one helper every adapter needs to turn a
|
|
12
|
+
* stored event into the change it announces.
|
|
13
|
+
*/
|
|
14
|
+
/** A record's id: a UUID for anything Orbit made, any string an import brought. */
|
|
15
|
+
export type Id = string;
|
|
16
|
+
/** An instant as `Date.prototype.toISOString()` writes it (UTC, milliseconds). */
|
|
17
|
+
export type Timestamp = string;
|
|
18
|
+
/** A yes or no as storage and answers carry it. */
|
|
19
|
+
export type Flag = 0 | 1;
|
|
20
|
+
/** 0 to 4. People and agents read them as words: maybe, low, med, high, urgent. */
|
|
21
|
+
export type Priority = 0 | 1 | 2 | 3 | 4;
|
|
22
|
+
export type TicketType = "epic" | "feature" | "task" | "bug";
|
|
23
|
+
/** What a lane does in the agent flow. At most one lane per board holds each role. */
|
|
24
|
+
export type LaneRole = "ai_ready" | "in_progress" | "review" | "done";
|
|
25
|
+
/** A journal entry's kind. */
|
|
26
|
+
export type EntryType = "decision" | "lesson";
|
|
27
|
+
export type Verdict = "PASS" | "BLOCK" | "QUESTION";
|
|
28
|
+
/** A board's own record: what its settings hold. */
|
|
29
|
+
export interface Board {
|
|
30
|
+
id: Id;
|
|
31
|
+
slug: string;
|
|
32
|
+
name: string;
|
|
33
|
+
repo_url: string;
|
|
34
|
+
default_branch: string;
|
|
35
|
+
project_notes: string;
|
|
36
|
+
agent_instructions: string;
|
|
37
|
+
ai_enabled: Flag;
|
|
38
|
+
created_at: Timestamp;
|
|
39
|
+
updated_at: Timestamp;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* A board as the list of boards shows it. `db_path` says where the adapter
|
|
43
|
+
* keeps the board's data, for people to read (a file for SQLite); adapters
|
|
44
|
+
* without files leave it empty. Nothing parses it.
|
|
45
|
+
*/
|
|
46
|
+
export interface RegisteredBoard {
|
|
47
|
+
id: Id;
|
|
48
|
+
slug: string;
|
|
49
|
+
name: string;
|
|
50
|
+
db_path: string;
|
|
51
|
+
repo_url: string;
|
|
52
|
+
default_branch: string;
|
|
53
|
+
last_active_at: Timestamp | null;
|
|
54
|
+
created_at: Timestamp;
|
|
55
|
+
updated_at: Timestamp;
|
|
56
|
+
}
|
|
57
|
+
/** A lane. The code calls lanes states, as the API does. */
|
|
58
|
+
export interface State {
|
|
59
|
+
id: Id;
|
|
60
|
+
board_id: Id;
|
|
61
|
+
name: string;
|
|
62
|
+
position: number;
|
|
63
|
+
is_default: Flag;
|
|
64
|
+
role: LaneRole | null;
|
|
65
|
+
created_at: Timestamp;
|
|
66
|
+
}
|
|
67
|
+
/** A card. The code calls cards tickets, as the API does. */
|
|
68
|
+
export interface Ticket {
|
|
69
|
+
id: Id;
|
|
70
|
+
board_id: Id;
|
|
71
|
+
/** Unique on its board, assigned by storage when the card is made. */
|
|
72
|
+
number: number;
|
|
73
|
+
title: string;
|
|
74
|
+
description: string;
|
|
75
|
+
type: TicketType;
|
|
76
|
+
/** An epic on the same board. */
|
|
77
|
+
parent_ticket_id: Id | null;
|
|
78
|
+
ai_plan: string;
|
|
79
|
+
plan_artifact_path: string;
|
|
80
|
+
implementation_summary: string;
|
|
81
|
+
implementation_artifact_path: string;
|
|
82
|
+
implementation_updates: string;
|
|
83
|
+
/** A link to a Nebula file or folder, or ''. */
|
|
84
|
+
nebula_url: string;
|
|
85
|
+
state_id: Id;
|
|
86
|
+
priority: Priority;
|
|
87
|
+
/** The actor's name. */
|
|
88
|
+
created_by: string;
|
|
89
|
+
created_at: Timestamp;
|
|
90
|
+
/** Every change to the card moves it, a sub-card's change moves its epic's too. */
|
|
91
|
+
updated_at: Timestamp;
|
|
92
|
+
archived_at: Timestamp | null;
|
|
93
|
+
}
|
|
94
|
+
export interface Label {
|
|
95
|
+
id: Id;
|
|
96
|
+
board_id: Id;
|
|
97
|
+
/** Unique on its board. */
|
|
98
|
+
name: string;
|
|
99
|
+
color: string;
|
|
100
|
+
created_at: Timestamp;
|
|
101
|
+
}
|
|
102
|
+
export interface TicketLabel {
|
|
103
|
+
ticket_id: Id;
|
|
104
|
+
label_id: Id;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* A comment on a card. `kind` is free text: Orbit writes human_comment,
|
|
108
|
+
* agent_note, checkpoint and completion, and an import may bring others.
|
|
109
|
+
*/
|
|
110
|
+
export interface Comment {
|
|
111
|
+
id: Id;
|
|
112
|
+
ticket_id: Id;
|
|
113
|
+
author: string;
|
|
114
|
+
kind: string;
|
|
115
|
+
body: string;
|
|
116
|
+
/** 1: hidden from agents. Kept out of everything an agent can read or search. */
|
|
117
|
+
omitted_for_ai: Flag;
|
|
118
|
+
created_at: Timestamp;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* A link between two cards on one board. Orbit writes blocks, blocked_by and
|
|
122
|
+
* relates_to; the JSON API stores any type it is sent.
|
|
123
|
+
*/
|
|
124
|
+
export interface Relation {
|
|
125
|
+
id: Id;
|
|
126
|
+
source_ticket_id: Id;
|
|
127
|
+
target_ticket_id: Id;
|
|
128
|
+
type: string;
|
|
129
|
+
created_at: Timestamp;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* A link from a card on this board to a card on another board of the same
|
|
133
|
+
* Orbit. It lives on the source card's board and names the target by board
|
|
134
|
+
* and id; nothing on the target's board changes. Whether a cross-board blocker
|
|
135
|
+
* still blocks is read from the target card when asked, so nothing has to
|
|
136
|
+
* clean it up when that card is done.
|
|
137
|
+
*/
|
|
138
|
+
export interface CrossBoardRelation {
|
|
139
|
+
id: Id;
|
|
140
|
+
source_ticket_id: Id;
|
|
141
|
+
target_board_id: Id;
|
|
142
|
+
target_ticket_id: Id;
|
|
143
|
+
type: "blocked_by" | "relates_to";
|
|
144
|
+
created_at: Timestamp;
|
|
145
|
+
}
|
|
146
|
+
/** A journal entry: a decision or a lesson, optionally about a card. A struck entry stays, out of agents' context, until a person deletes it. */
|
|
147
|
+
export interface BoardEntry {
|
|
148
|
+
id: Id;
|
|
149
|
+
board_id: Id;
|
|
150
|
+
type: EntryType;
|
|
151
|
+
title: string;
|
|
152
|
+
body: string;
|
|
153
|
+
ticket_id: Id | null;
|
|
154
|
+
created_by: string;
|
|
155
|
+
created_at: Timestamp;
|
|
156
|
+
updated_at: Timestamp;
|
|
157
|
+
struck_at: Timestamp | null;
|
|
158
|
+
}
|
|
159
|
+
/** A reviewer's verdict on a card. The `_json` fields hold JSON arrays as text. */
|
|
160
|
+
export interface ReviewVerdict {
|
|
161
|
+
id: Id;
|
|
162
|
+
ticket_id: Id;
|
|
163
|
+
verdict: Verdict;
|
|
164
|
+
blocking_findings_json: string;
|
|
165
|
+
optional_findings_json: string;
|
|
166
|
+
evidence_commands_json: string;
|
|
167
|
+
reviewer_profile: string;
|
|
168
|
+
reviewer_session_id: string;
|
|
169
|
+
reviewed_commit_sha: string;
|
|
170
|
+
dispatch_run_id: string;
|
|
171
|
+
supersedes_prior_review_id: Id | null;
|
|
172
|
+
created_by: string;
|
|
173
|
+
created_at: Timestamp;
|
|
174
|
+
}
|
|
175
|
+
/** An image on a card. Its bytes are in the BlobStore at `attachments/<stored_path>`. */
|
|
176
|
+
export interface Attachment {
|
|
177
|
+
id: Id;
|
|
178
|
+
ticket_id: Id;
|
|
179
|
+
board_id: Id;
|
|
180
|
+
original_name: string;
|
|
181
|
+
/** Relative to the board's attachments, POSIX-style: tickets/<ticket id>/<attachment id>.png */
|
|
182
|
+
stored_path: string;
|
|
183
|
+
mime_type: string;
|
|
184
|
+
size_bytes: number;
|
|
185
|
+
sha256: string;
|
|
186
|
+
created_by: string;
|
|
187
|
+
created_at: Timestamp;
|
|
188
|
+
}
|
|
189
|
+
/** What Orbit records as it changes a board. Imports may bring other types. */
|
|
190
|
+
export type EventType = "board_created" | "board_updated" | "board_imported" | "board_entry_created" | "board_entry_struck" | "board_entry_restored" | "board_entry_deleted" | "state_created" | "state_updated" | "states_reordered" | "state_deleted" | "ticket_created" | "ticket_updated" | "state_changed" | "ticket_archived" | "ticket_restored" | "ticket_deleted" | "comment_created" | "comment_ai_omission_changed" | "relation_created" | "relation_deleted" | "attachment_created" | "attachment_deleted" | "review_verdict_created" | "agent_claimed" | "checkpoint_requested" | "agent_completed";
|
|
191
|
+
/**
|
|
192
|
+
* One entry in a board's history. `body_json` is the event's data as JSON
|
|
193
|
+
* text. Every write that changes a board appends at least one, and the live
|
|
194
|
+
* stream, an agent's `changes` and the quick menu's sessions are read from
|
|
195
|
+
* them.
|
|
196
|
+
*/
|
|
197
|
+
export interface OrbitEvent {
|
|
198
|
+
id: Id;
|
|
199
|
+
ticket_id: Id | null;
|
|
200
|
+
/** The actor's name. */
|
|
201
|
+
actor: string;
|
|
202
|
+
type: EventType | (string & {});
|
|
203
|
+
body_json: string;
|
|
204
|
+
created_at: Timestamp;
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* A committed change, as `BoardStore.watch()` and the live event stream
|
|
208
|
+
* (`GET /api/events/stream`) announce it: the event, with its board and its
|
|
209
|
+
* data parsed.
|
|
210
|
+
*/
|
|
211
|
+
export interface BoardChange {
|
|
212
|
+
id: Id;
|
|
213
|
+
type: OrbitEvent["type"];
|
|
214
|
+
ticket_id: Id | null;
|
|
215
|
+
board_id: Id;
|
|
216
|
+
actor: string;
|
|
217
|
+
data: Record<string, unknown>;
|
|
218
|
+
timestamp: Timestamp;
|
|
219
|
+
}
|
|
220
|
+
/** The change a stored event announces. Unreadable data announces as `{}`. */
|
|
221
|
+
export declare function changeOf(boardId: Id, event: OrbitEvent): BoardChange;
|
|
222
|
+
/**
|
|
223
|
+
* Who is acting. Orbit has no accounts: a person at the computer is `local`,
|
|
224
|
+
* and an agent is named by `ORBIT_AGENT_NAME` or the `x-orbit-agent-name`
|
|
225
|
+
* header. Hosts that add accounts resolve their own actor here.
|
|
226
|
+
*/
|
|
227
|
+
export interface Actor {
|
|
228
|
+
id: string;
|
|
229
|
+
name: string;
|
|
230
|
+
type: "human" | "agent";
|
|
231
|
+
role: string;
|
|
232
|
+
permissions: string[];
|
|
233
|
+
/** Board slugs the actor may open; ["*"] for all. */
|
|
234
|
+
boards: string[];
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* An agent's run of writes on one board, for the quick menu (`GET
|
|
238
|
+
* /api/sessions`). Derived from events; never stored.
|
|
239
|
+
*/
|
|
240
|
+
export interface AgentSession {
|
|
241
|
+
/** `<board id>:<agent>:<first event id>` */
|
|
242
|
+
id: string;
|
|
243
|
+
board: {
|
|
244
|
+
id: Id;
|
|
245
|
+
slug: string;
|
|
246
|
+
name: string;
|
|
247
|
+
};
|
|
248
|
+
agent: string;
|
|
249
|
+
/** True when the agent never said its name. */
|
|
250
|
+
unnamed: boolean;
|
|
251
|
+
state: "waiting" | "working" | "finished";
|
|
252
|
+
reason: "ask" | "review" | null;
|
|
253
|
+
what: string;
|
|
254
|
+
detail: string;
|
|
255
|
+
card: {
|
|
256
|
+
id: Id;
|
|
257
|
+
number: number;
|
|
258
|
+
title: string;
|
|
259
|
+
} | null;
|
|
260
|
+
started_at: Timestamp;
|
|
261
|
+
at: Timestamp;
|
|
262
|
+
writes: number;
|
|
263
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The records Orbit keeps, typed with the names the JSON API, the export
|
|
3
|
+
* format (`.orbit.json`) and the database already use: one name per field
|
|
4
|
+
* everywhere, snake_case, so nothing maps between layers.
|
|
5
|
+
*
|
|
6
|
+
* Each record is what storage holds, field for field, in the order answers list
|
|
7
|
+
* them. Flags are 0 or 1 and JSON columns stay strings, because answers and
|
|
8
|
+
* exports carry them that way. What the core derives for an answer (a card's
|
|
9
|
+
* lane name, its comment count) is built from these and is not stored.
|
|
10
|
+
*
|
|
11
|
+
* This file has no logic but the one helper every adapter needs to turn a
|
|
12
|
+
* stored event into the change it announces.
|
|
13
|
+
*/
|
|
14
|
+
/** The change a stored event announces. Unreadable data announces as `{}`. */
|
|
15
|
+
export function changeOf(boardId, event) {
|
|
16
|
+
return {
|
|
17
|
+
id: event.id,
|
|
18
|
+
type: event.type,
|
|
19
|
+
ticket_id: event.ticket_id,
|
|
20
|
+
board_id: boardId,
|
|
21
|
+
actor: event.actor,
|
|
22
|
+
data: parseData(event.body_json),
|
|
23
|
+
timestamp: event.created_at
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
function parseData(json) {
|
|
27
|
+
try {
|
|
28
|
+
const value = JSON.parse(json || "{}");
|
|
29
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return {};
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A card's optional link to a Nebula file or folder. Orbit keeps the address
|
|
3
|
+
* people paste when it is a plain web link: http or https, no user name or
|
|
4
|
+
* password, no spaces or control characters, at most 2,048 characters. Any
|
|
5
|
+
* host is allowed, since a Nebula can live on your own server or tailnet as
|
|
6
|
+
* well as at nebula.starscape.app. An empty value clears the link.
|
|
7
|
+
*
|
|
8
|
+
* The web app imports it from here (web/src/card/).
|
|
9
|
+
*/
|
|
10
|
+
export declare const NEBULA_URL_MAX = 2048;
|
|
11
|
+
/** The normalized link, "" for none, or null when Orbit wouldn't keep it. */
|
|
12
|
+
export declare function parseNebulaUrl(value: unknown): string | null;
|
|
13
|
+
/** The link to store ("" clears it), or `invalid_nebula_url`. */
|
|
14
|
+
export declare function normalizeNebulaUrl(value: unknown): string;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A card's optional link to a Nebula file or folder. Orbit keeps the address
|
|
3
|
+
* people paste when it is a plain web link: http or https, no user name or
|
|
4
|
+
* password, no spaces or control characters, at most 2,048 characters. Any
|
|
5
|
+
* host is allowed, since a Nebula can live on your own server or tailnet as
|
|
6
|
+
* well as at nebula.starscape.app. An empty value clears the link.
|
|
7
|
+
*
|
|
8
|
+
* The web app imports it from here (web/src/card/).
|
|
9
|
+
*/
|
|
10
|
+
import { OrbitError } from "./errors.js";
|
|
11
|
+
import { asText } from "./text.js";
|
|
12
|
+
export const NEBULA_URL_MAX = 2048;
|
|
13
|
+
/** The normalized link, "" for none, or null when Orbit wouldn't keep it. */
|
|
14
|
+
export function parseNebulaUrl(value) {
|
|
15
|
+
const text = asText(value ?? "").trim();
|
|
16
|
+
if (!text)
|
|
17
|
+
return "";
|
|
18
|
+
if (text.length > NEBULA_URL_MAX || /[\s\u0000-\u001f\u007f]/.test(text))
|
|
19
|
+
return null;
|
|
20
|
+
let url;
|
|
21
|
+
try {
|
|
22
|
+
url = new URL(text);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
if (url.protocol !== "https:" && url.protocol !== "http:")
|
|
28
|
+
return null;
|
|
29
|
+
if (url.username || url.password || !url.hostname)
|
|
30
|
+
return null;
|
|
31
|
+
return url.href.length > NEBULA_URL_MAX ? null : url.href;
|
|
32
|
+
}
|
|
33
|
+
/** The link to store ("" clears it), or `invalid_nebula_url`. */
|
|
34
|
+
export function normalizeNebulaUrl(value) {
|
|
35
|
+
const kept = parseNebulaUrl(value);
|
|
36
|
+
if (kept === null)
|
|
37
|
+
throw new OrbitError("invalid_nebula_url");
|
|
38
|
+
return kept;
|
|
39
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Links between cards on one board: blocks, blocked_by and relates_to. An
|
|
3
|
+
* epic and its sub-cards are linked by the sub-card's `parent_ticket_id`,
|
|
4
|
+
* which answers show as read-only `child_of` and `parent_of` entries.
|
|
5
|
+
*/
|
|
6
|
+
import type { BoardCtx } from "./context.ts";
|
|
7
|
+
import type { CrossCard, CrossLink } from "./cross-board.ts";
|
|
8
|
+
import type { Id, Relation } from "./model.ts";
|
|
9
|
+
/** The relation types Orbit keeps. */
|
|
10
|
+
export declare const RELATION_TYPES: readonly ["blocks", "blocked_by", "relates_to"];
|
|
11
|
+
export declare function createRelation(c: BoardCtx, input: {
|
|
12
|
+
source_ticket_id?: unknown;
|
|
13
|
+
target_ticket_id?: unknown;
|
|
14
|
+
type?: unknown;
|
|
15
|
+
}): Promise<Relation>;
|
|
16
|
+
export declare function deleteRelation(c: BoardCtx, relationId: Id): Promise<{
|
|
17
|
+
ok: true;
|
|
18
|
+
}>;
|
|
19
|
+
/** The other card of a link, as a card's relations and blockers name it. */
|
|
20
|
+
export interface LinkedCard {
|
|
21
|
+
id: Id;
|
|
22
|
+
number: number;
|
|
23
|
+
title: string;
|
|
24
|
+
type: string;
|
|
25
|
+
state_id: Id;
|
|
26
|
+
state_name: string;
|
|
27
|
+
state_role: string | null;
|
|
28
|
+
parent_ticket_id: Id | null;
|
|
29
|
+
}
|
|
30
|
+
/** One link as a card's relations list it. */
|
|
31
|
+
export interface RelationLine {
|
|
32
|
+
id: Id | null;
|
|
33
|
+
type: string;
|
|
34
|
+
direction: string;
|
|
35
|
+
source: "relation" | "hierarchy";
|
|
36
|
+
other_ticket: LinkedCard;
|
|
37
|
+
}
|
|
38
|
+
/** Every link touching a card: its relations, then its epic (`child_of`) and its open sub-cards (`parent_of`). */
|
|
39
|
+
export declare function ticketRelations(c: BoardCtx, ticketId: Id): Promise<TicketRelations>;
|
|
40
|
+
/** A card's links: its relations, its epic and its open sub-cards, and its links to cards on other boards. */
|
|
41
|
+
export interface TicketRelations {
|
|
42
|
+
ticket_id: Id;
|
|
43
|
+
relations: RelationLine[];
|
|
44
|
+
cross_board: CrossLink[];
|
|
45
|
+
}
|
|
46
|
+
/** A card still blocking another; `via_epic_id` when it blocks the card's epic rather than the card. */
|
|
47
|
+
export type Blocker = Omit<LinkedCard, "parent_ticket_id"> & {
|
|
48
|
+
via_epic_id: Id | null;
|
|
49
|
+
};
|
|
50
|
+
/** What still blocks a card, on its board and on others, and whether it can start. */
|
|
51
|
+
export interface TicketBlockers {
|
|
52
|
+
ticket_id: Id;
|
|
53
|
+
can_start: boolean;
|
|
54
|
+
blockers: Blocker[];
|
|
55
|
+
waiting_on: CrossCard[];
|
|
56
|
+
}
|
|
57
|
+
/** What still blocks a card, and whether it can start. */
|
|
58
|
+
export declare function ticketBlockers(c: BoardCtx, ticketId: Id): Promise<TicketBlockers>;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Links between cards on one board: blocks, blocked_by and relates_to. An
|
|
3
|
+
* epic and its sub-cards are linked by the sub-card's `parent_ticket_id`,
|
|
4
|
+
* which answers show as read-only `child_of` and `parent_of` entries.
|
|
5
|
+
*/
|
|
6
|
+
import { crossBlockersFor, crossLinks } from "./cross-board.js";
|
|
7
|
+
import { OrbitError } from "./errors.js";
|
|
8
|
+
import { record } from "./history.js";
|
|
9
|
+
import { ticketOn } from "./tickets.js";
|
|
10
|
+
import { requiredString } from "./text.js";
|
|
11
|
+
import { Lanes, laned, relationRows, ticketView, unresolvedBlockers } from "./views.js";
|
|
12
|
+
/** The relation types Orbit keeps. */
|
|
13
|
+
export const RELATION_TYPES = ["blocks", "blocked_by", "relates_to"];
|
|
14
|
+
export async function createRelation(c, input) {
|
|
15
|
+
const sourceId = requiredString(input.source_ticket_id, "source_ticket_id");
|
|
16
|
+
const relation = await c.data.write(async (w) => {
|
|
17
|
+
const source = await ticketOn(w, sourceId);
|
|
18
|
+
// A relation lives on one board; a card on another board is no card here.
|
|
19
|
+
const target = await ticketOn(w, requiredString(input.target_ticket_id, "target_ticket_id"));
|
|
20
|
+
if (source.id === target.id)
|
|
21
|
+
throw new OrbitError("relation_self");
|
|
22
|
+
if (source.parent_ticket_id === target.id || target.parent_ticket_id === source.id)
|
|
23
|
+
throw new OrbitError("relation_redundant_with_parent");
|
|
24
|
+
const type = typeof input.type === "string" && input.type ? input.type : "relates_to";
|
|
25
|
+
// Epics and sub-cards are linked by parent_ticket_id, not by relations.
|
|
26
|
+
if (type === "child_of" || type === "parent_of")
|
|
27
|
+
throw new OrbitError("hierarchy_uses_parent_ticket_id");
|
|
28
|
+
if (!RELATION_TYPES.includes(type))
|
|
29
|
+
throw new OrbitError("invalid_relation_type");
|
|
30
|
+
const lanes = await Lanes.of(w);
|
|
31
|
+
// A blocker already done would block nothing.
|
|
32
|
+
if (type === "blocked_by" && lanes.roleOf(target) === "done")
|
|
33
|
+
throw new OrbitError("blocker_target_already_done");
|
|
34
|
+
if (type === "blocks" && lanes.roleOf(source) === "done")
|
|
35
|
+
throw new OrbitError("blocker_source_already_done");
|
|
36
|
+
if ((await w.relations.list(source.id)).some((r) => r.source_ticket_id === source.id && r.target_ticket_id === target.id && r.type === type)) {
|
|
37
|
+
throw new OrbitError("relation_exists");
|
|
38
|
+
}
|
|
39
|
+
const made = { id: c.ids.next(), source_ticket_id: source.id, target_ticket_id: target.id, type, created_at: c.clock.now() };
|
|
40
|
+
await w.relations.insert(made);
|
|
41
|
+
await record(w, c, "relation_created", source.id, { target_ticket_id: target.id, type });
|
|
42
|
+
return made;
|
|
43
|
+
});
|
|
44
|
+
c.changed(c.board.id);
|
|
45
|
+
return relation;
|
|
46
|
+
}
|
|
47
|
+
export async function deleteRelation(c, relationId) {
|
|
48
|
+
await c.data.write(async (w) => {
|
|
49
|
+
const relation = await w.relations.get(relationId);
|
|
50
|
+
if (!relation)
|
|
51
|
+
throw new OrbitError("relation_not_found");
|
|
52
|
+
await w.relations.remove(relationId);
|
|
53
|
+
await record(w, c, "relation_deleted", relation.source_ticket_id, { target_ticket_id: relation.target_ticket_id, type: relation.type });
|
|
54
|
+
});
|
|
55
|
+
c.changed(c.board.id);
|
|
56
|
+
return { ok: true };
|
|
57
|
+
}
|
|
58
|
+
function linkedCard(other) {
|
|
59
|
+
return {
|
|
60
|
+
id: other.id,
|
|
61
|
+
number: other.number,
|
|
62
|
+
title: other.title,
|
|
63
|
+
type: other.type,
|
|
64
|
+
state_id: other.state_id,
|
|
65
|
+
state_name: other.state_name,
|
|
66
|
+
state_role: other.state_role,
|
|
67
|
+
parent_ticket_id: other.parent_ticket_id
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
/** Every link touching a card: its relations, then its epic (`child_of`) and its open sub-cards (`parent_of`). */
|
|
71
|
+
export async function ticketRelations(c, ticketId) {
|
|
72
|
+
const found = await c.data.read(async (r) => {
|
|
73
|
+
const ticket = await ticketOn(r, ticketId);
|
|
74
|
+
const lanes = await Lanes.of(r);
|
|
75
|
+
const relations = (await relationRows(r, ticketId, lanes, c.board)).map((row) => ({
|
|
76
|
+
id: row.id,
|
|
77
|
+
type: row.type,
|
|
78
|
+
direction: row.direction,
|
|
79
|
+
source: "relation",
|
|
80
|
+
other_ticket: linkedCard(row.other_ticket)
|
|
81
|
+
}));
|
|
82
|
+
if (ticket.parent_ticket_id) {
|
|
83
|
+
const parent = await r.tickets.get(ticket.parent_ticket_id);
|
|
84
|
+
if (parent && !parent.archived_at)
|
|
85
|
+
relations.push({ id: null, type: "child_of", direction: "outgoing", source: "hierarchy", other_ticket: linkedCard(ticketView(parent, lanes, c.board)) });
|
|
86
|
+
}
|
|
87
|
+
for (const child of await r.tickets.list({ parent: ticketId, archived: false })) {
|
|
88
|
+
relations.push({ id: null, type: "parent_of", direction: "outgoing", source: "hierarchy", other_ticket: linkedCard(laned(child, lanes)) });
|
|
89
|
+
}
|
|
90
|
+
return { ticket_id: ticket.id, relations };
|
|
91
|
+
});
|
|
92
|
+
// Read after this board's own read: links across boards read the other boards.
|
|
93
|
+
return { ...found, cross_board: await crossLinks(c, ticketId) };
|
|
94
|
+
}
|
|
95
|
+
/** What still blocks a card, and whether it can start. */
|
|
96
|
+
export async function ticketBlockers(c, ticketId) {
|
|
97
|
+
return c.data.read(async (r) => {
|
|
98
|
+
const ticket = await ticketOn(r, ticketId);
|
|
99
|
+
const blockers = (await unresolvedBlockers(r, ticketId, await Lanes.of(r))).map((row) => ({
|
|
100
|
+
id: row.id,
|
|
101
|
+
number: row.number,
|
|
102
|
+
title: row.title,
|
|
103
|
+
type: row.type,
|
|
104
|
+
state_id: row.state_id,
|
|
105
|
+
state_name: row.state_name,
|
|
106
|
+
state_role: row.state_role,
|
|
107
|
+
via_epic_id: row.via_epic_id ?? null
|
|
108
|
+
}));
|
|
109
|
+
const waiting = await crossBlockersFor(c, r, ticket.id);
|
|
110
|
+
return { ticket_id: ticket.id, can_start: blockers.length === 0 && waiting.length === 0, blockers, waiting_on: waiting };
|
|
111
|
+
});
|
|
112
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Search on one board: open cards whose text, plan, summary, revisions or
|
|
3
|
+
* agent-readable comments match, best first. A query that is a card number
|
|
4
|
+
* (`12`, `#12`) puts that card first.
|
|
5
|
+
*/
|
|
6
|
+
import type { BoardCtx } from "./context.ts";
|
|
7
|
+
import type { Id, Label, Ticket } from "./model.ts";
|
|
8
|
+
export interface SearchInput {
|
|
9
|
+
q?: unknown;
|
|
10
|
+
limit?: unknown;
|
|
11
|
+
mode?: unknown;
|
|
12
|
+
include_full?: unknown;
|
|
13
|
+
fields?: unknown;
|
|
14
|
+
max_chars_per_field?: unknown;
|
|
15
|
+
}
|
|
16
|
+
export type SearchMode = "ids" | "summary" | "full";
|
|
17
|
+
/**
|
|
18
|
+
* One hit. Which fields it has depends on the mode: `ids` gives ticket_id,
|
|
19
|
+
* id, number and rank; `summary` (the default) adds title, state_name,
|
|
20
|
+
* state_role, type, priority, parent_ticket_id, a snippet and the labels'
|
|
21
|
+
* names; `full` gives the card's own fields, state_name, state_role,
|
|
22
|
+
* board_slug, rank and the labels as records. With `fields`, only those named.
|
|
23
|
+
*/
|
|
24
|
+
export type SearchHit = Partial<Ticket> & {
|
|
25
|
+
ticket_id?: Id;
|
|
26
|
+
rank?: number;
|
|
27
|
+
state_name?: string;
|
|
28
|
+
state_role?: string | null;
|
|
29
|
+
board_slug?: string;
|
|
30
|
+
snippet?: string;
|
|
31
|
+
labels?: string[] | Label[];
|
|
32
|
+
};
|
|
33
|
+
export interface SearchAnswer {
|
|
34
|
+
query: string;
|
|
35
|
+
mode: SearchMode;
|
|
36
|
+
results: SearchHit[];
|
|
37
|
+
}
|
|
38
|
+
export declare function searchTickets(c: BoardCtx, input: SearchInput): Promise<SearchAnswer>;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Search on one board: open cards whose text, plan, summary, revisions or
|
|
3
|
+
* agent-readable comments match, best first. A query that is a card number
|
|
4
|
+
* (`12`, `#12`) puts that card first.
|
|
5
|
+
*/
|
|
6
|
+
import { cap, positiveInt, asText } from "./text.js";
|
|
7
|
+
import { Lanes, labelsByTicket } from "./views.js";
|
|
8
|
+
function modeOf(input) {
|
|
9
|
+
if (input.include_full === true)
|
|
10
|
+
return "full";
|
|
11
|
+
const mode = asText(input.mode ?? "summary").trim().toLowerCase();
|
|
12
|
+
return mode === "ids" || mode === "full" ? mode : "summary";
|
|
13
|
+
}
|
|
14
|
+
function fieldsOf(fields) {
|
|
15
|
+
if (Array.isArray(fields))
|
|
16
|
+
return new Set(fields.map(String));
|
|
17
|
+
if (typeof fields === "string" && fields.trim())
|
|
18
|
+
return new Set(fields.split(",").map((field) => field.trim()).filter(Boolean));
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
function pick(row, fields) {
|
|
22
|
+
if (!fields)
|
|
23
|
+
return row;
|
|
24
|
+
const picked = {};
|
|
25
|
+
for (const field of fields)
|
|
26
|
+
if (Object.hasOwn(row, field))
|
|
27
|
+
Object.assign(picked, { [field]: row[field] });
|
|
28
|
+
return picked;
|
|
29
|
+
}
|
|
30
|
+
function numberQuery(q) {
|
|
31
|
+
const match = /^#?(\d+)$/.exec(q.trim());
|
|
32
|
+
if (!match)
|
|
33
|
+
return null;
|
|
34
|
+
const number = Number(match[1]);
|
|
35
|
+
return Number.isSafeInteger(number) ? number : null;
|
|
36
|
+
}
|
|
37
|
+
export async function searchTickets(c, input) {
|
|
38
|
+
const q = asText(input.q ?? "").trim();
|
|
39
|
+
const limit = Math.min(Number(input.limit || 20), 50);
|
|
40
|
+
const mode = modeOf(input);
|
|
41
|
+
const maxChars = positiveInt(input.max_chars_per_field, 0);
|
|
42
|
+
const fields = fieldsOf(input.fields);
|
|
43
|
+
if (!q)
|
|
44
|
+
return { query: q, mode, results: [] };
|
|
45
|
+
const { hits, labels } = await c.data.read(async (r) => {
|
|
46
|
+
const number = numberQuery(q);
|
|
47
|
+
const ranked = new Map();
|
|
48
|
+
if (number !== null) {
|
|
49
|
+
const ticket = await r.tickets.byNumber(number);
|
|
50
|
+
if (ticket && !ticket.archived_at)
|
|
51
|
+
ranked.set(ticket.id, -1);
|
|
52
|
+
}
|
|
53
|
+
for (const hit of await r.search.query(q, { limit }))
|
|
54
|
+
if (!ranked.has(hit.ticket_id))
|
|
55
|
+
ranked.set(hit.ticket_id, hit.rank);
|
|
56
|
+
const ids = [...ranked.keys()];
|
|
57
|
+
if (!ids.length)
|
|
58
|
+
return { hits: [], labels: new Map() };
|
|
59
|
+
const [lanes, tickets] = await Promise.all([Lanes.of(r), r.tickets.list({ ids, archived: false })]);
|
|
60
|
+
const found = tickets.map((ticket) => ({ ...ticket, state_name: lanes.nameOf(ticket), state_role: lanes.roleOf(ticket), board_slug: c.board.slug, rank: ranked.get(ticket.id) ?? 0 }));
|
|
61
|
+
found.sort((a, b) => a.rank - b.rank || b.updated_at.localeCompare(a.updated_at) || a.id.localeCompare(b.id));
|
|
62
|
+
const top = found.slice(0, limit);
|
|
63
|
+
return { hits: top, labels: await labelsByTicket(r, top.map((hit) => hit.id)) };
|
|
64
|
+
});
|
|
65
|
+
const results = hits.map((hit) => {
|
|
66
|
+
const cardLabels = labels.get(hit.id) ?? [];
|
|
67
|
+
if (mode === "ids")
|
|
68
|
+
return pick({ ticket_id: hit.id, id: hit.id, number: hit.number, rank: hit.rank }, fields);
|
|
69
|
+
if (mode === "full") {
|
|
70
|
+
const full = { ...hit, labels: cardLabels };
|
|
71
|
+
if (maxChars)
|
|
72
|
+
for (const key of ["description", "ai_plan", "implementation_summary", "implementation_updates"])
|
|
73
|
+
full[key] = cap(full[key], maxChars);
|
|
74
|
+
return pick(full, fields);
|
|
75
|
+
}
|
|
76
|
+
return pick({
|
|
77
|
+
ticket_id: hit.id,
|
|
78
|
+
id: hit.id,
|
|
79
|
+
number: hit.number,
|
|
80
|
+
title: hit.title,
|
|
81
|
+
state_name: hit.state_name,
|
|
82
|
+
state_role: hit.state_role,
|
|
83
|
+
type: hit.type,
|
|
84
|
+
priority: hit.priority,
|
|
85
|
+
parent_ticket_id: hit.parent_ticket_id,
|
|
86
|
+
rank: hit.rank,
|
|
87
|
+
snippet: cap(hit.description || hit.ai_plan || hit.implementation_summary || "", maxChars || 240),
|
|
88
|
+
labels: cardLabels.map((label) => label.name)
|
|
89
|
+
}, fields);
|
|
90
|
+
});
|
|
91
|
+
return { query: q, mode, results };
|
|
92
|
+
}
|