@starlove/orbit 1.0.7 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +22 -7
  2. package/SKILL-ORBIT.md +26 -1
  3. package/dist/full/index.html +2 -2
  4. package/dist/full/js/app.a4f045fc.js +676 -0
  5. package/dist/full/js/app.a4f045fc.js.map +7 -0
  6. package/dist/full/{styles.14e9dbbd.css → styles.836872e7.css} +836 -39
  7. package/dist/full/version.json +2 -2
  8. package/docs/AGENT_PROTOCOL.md +23 -5
  9. package/docs/MCP_SETUP.md +5 -3
  10. package/docs/ORBIT_DISPATCH.md +185 -0
  11. package/docs/setup/orbit-init-wizard.md +174 -0
  12. package/package.json +2 -2
  13. package/public/js/actor-labels.js +65 -0
  14. package/public/js/app.js +8 -1
  15. package/public/js/card-actions.js +526 -0
  16. package/public/js/config.js +2 -1
  17. package/public/js/format.js +204 -10
  18. package/public/js/kanban.js +15 -0
  19. package/public/js/local-backend.js +24 -8
  20. package/public/js/router.js +3 -2
  21. package/public/js/search.js +6 -3
  22. package/public/js/settings.js +96 -26
  23. package/public/js/state.js +50 -0
  24. package/public/js/ticket-detail.js +428 -125
  25. package/public/styles.css +836 -39
  26. package/src/cli/orbit.js +217 -32
  27. package/src/core/agent.js +114 -24
  28. package/src/core/agents-md.js +15 -10
  29. package/src/core/attachments.js +238 -0
  30. package/src/core/auth.js +36 -0
  31. package/src/core/backups.js +9 -3
  32. package/src/core/board.js +32 -1
  33. package/src/core/boards.js +1 -1
  34. package/src/core/db.js +42 -2
  35. package/src/core/delete-board.js +87 -21
  36. package/src/core/dispatch.js +532 -0
  37. package/src/core/http.js +31 -11
  38. package/src/core/migrate.js +198 -0
  39. package/src/core/paths.js +6 -0
  40. package/src/core/registry.js +12 -9
  41. package/src/core/router.js +81 -7
  42. package/src/core/search.js +83 -10
  43. package/src/core/snapshots.js +14 -2
  44. package/src/mcp/orbit-client.js +109 -46
  45. package/src/mcp-server.js +238 -12
  46. package/src/server.js +20 -0
  47. package/dist/full/js/app.f4fd5e94.js +0 -604
  48. package/dist/full/js/app.f4fd5e94.js.map +0 -7
@@ -0,0 +1,198 @@
1
+ /**
2
+ * migrate.js — one-time startup migration of per-board SQLite files from
3
+ * in-repo .orbit/ directories into the central DATA_DIR/boards/<slug>/ store.
4
+ *
5
+ * Called during board.js startup, before ensureSchemaForEveryBoard, so that
6
+ * by the time per-board connections are opened they are already at their
7
+ * canonical central location.
8
+ *
9
+ * If a migration fails the registry row is NOT updated and Orbit continues
10
+ * to serve from the old in-repo path. A structured warning with agent-actionable
11
+ * manual instructions is printed to stderr.
12
+ */
13
+ import { existsSync, mkdirSync, readdirSync, rmSync, unlinkSync } from "node:fs";
14
+ import { dirname, join } from "node:path";
15
+ import { closeConnection, getRegistry, openConnection } from "./db.js";
16
+ import { BOARDS_DIR } from "./paths.js";
17
+ import { listBoards } from "./registry.js";
18
+
19
+ /** Normalize to forward slashes for consistent cross-platform prefix checks. */
20
+ const fwd = (p) => p.replace(/\\/g, "/");
21
+
22
+ /** True when db_path already lives inside the canonical BOARDS_DIR. */
23
+ function isCentral(dbPath) {
24
+ return fwd(dbPath).startsWith(fwd(BOARDS_DIR) + "/");
25
+ }
26
+
27
+ /** Canonical central path for a given slug. */
28
+ function centralPathFor(slug) {
29
+ return join(BOARDS_DIR, slug, "board.db");
30
+ }
31
+
32
+ /** Delete a file if it exists; silently ignore errors. */
33
+ function tryUnlink(p) {
34
+ try { if (existsSync(p)) unlinkSync(p); } catch { /* ignore */ }
35
+ }
36
+
37
+ /**
38
+ * Walk upward from startDir, removing each directory that is now empty.
39
+ * Stops at stopDir (inclusive) or the first non-empty directory.
40
+ * This cleans up empty .orbit/ trees left behind after moving the db out.
41
+ */
42
+ function pruneEmptyAncestors(startDir, stopDir) {
43
+ const stopFwd = fwd(stopDir);
44
+ let cur = fwd(startDir);
45
+ while (cur.startsWith(stopFwd)) {
46
+ try {
47
+ if (!existsSync(cur)) {
48
+ /* already gone, climb */
49
+ } else if (readdirSync(cur).length > 0) {
50
+ break; // non-empty — stop here
51
+ } else {
52
+ // rmSync({recursive:true, force:true}) works correctly for both files and
53
+ // empty directories across Node versions and platforms. We've already
54
+ // confirmed the directory is empty so nothing is over-deleted.
55
+ rmSync(cur, { recursive: true, force: true });
56
+ }
57
+ } catch {
58
+ break;
59
+ }
60
+ if (cur === stopFwd) break; // reached the limit
61
+ const parent = fwd(dirname(cur));
62
+ if (parent === cur) break; // filesystem root
63
+ cur = parent;
64
+ }
65
+ }
66
+
67
+ /**
68
+ * For a db path inside a repo's .orbit directory, return the .orbit parent.
69
+ * /repo/.orbit/board.db → /repo/.orbit
70
+ * /repo/.orbit/boards/x/board.db → /repo/.orbit
71
+ * Returns null if ".orbit" is not found in the path.
72
+ */
73
+ function orbitParentDir(dbPath) {
74
+ const parts = fwd(dbPath).split("/");
75
+ const idx = parts.lastIndexOf(".orbit");
76
+ if (idx < 0) return null;
77
+ return parts.slice(0, idx + 1).join("/");
78
+ }
79
+
80
+ function sqlLiteral(v) {
81
+ return `'${String(v).replace(/'/g, "''")}'`;
82
+ }
83
+
84
+ /**
85
+ * Attempt to migrate one board from in-repo storage to central storage.
86
+ * Returns a result object with status: 'migrated' | 'failed' | 'missing_source'.
87
+ */
88
+ function migrateOneBoard(boardRow) {
89
+ const { id, slug, db_path: oldPath } = boardRow;
90
+
91
+ if (!existsSync(oldPath)) {
92
+ // Source file is gone; ensureSchemaForEveryBoard will prune the stale row.
93
+ return { status: "missing_source", board: boardRow, oldPath };
94
+ }
95
+
96
+ const newPath = centralPathFor(slug);
97
+ mkdirSync(dirname(newPath), { recursive: true });
98
+
99
+ try {
100
+ // Safety: if the target path is already registered to a *different* board id,
101
+ // skip rather than overwrite live data (guards against corrupted registries with
102
+ // duplicate slugs — cannot happen through normal API use but is defensive).
103
+ const existingOwner = getRegistry().prepare("SELECT id FROM boards WHERE db_path = ?").get(newPath);
104
+ if (existingOwner && existingOwner.id !== id) {
105
+ return {
106
+ status: "failed",
107
+ board: boardRow,
108
+ oldPath,
109
+ newPath,
110
+ error: new Error(
111
+ `central path ${newPath} is already registered to board ${existingOwner.id} — skipping to avoid data loss`
112
+ )
113
+ };
114
+ }
115
+
116
+ // Remove any stale central copy left by a previous partial migration.
117
+ if (existsSync(newPath)) tryUnlink(newPath);
118
+
119
+ // VACUUM INTO creates a clean, WAL-flushed copy. It captures only committed
120
+ // data and produces a single-file database (no -wal/-shm at the destination).
121
+ const db = openConnection(oldPath);
122
+ db.exec(`VACUUM INTO ${sqlLiteral(newPath)};`);
123
+ closeConnection(oldPath);
124
+
125
+ // Update the registry before deleting the source so the canonical pointer is
126
+ // always valid: if the process dies between these two steps the old file still
127
+ // exists and the next run will redo the migration cleanly.
128
+ getRegistry()
129
+ .prepare("UPDATE boards SET db_path = ?, updated_at = ? WHERE id = ?")
130
+ .run(newPath, new Date().toISOString(), id);
131
+
132
+ // Remove source files (main db + WAL + SHM + journal).
133
+ tryUnlink(oldPath);
134
+ tryUnlink(oldPath + "-wal");
135
+ tryUnlink(oldPath + "-shm");
136
+ tryUnlink(oldPath + "-journal");
137
+
138
+ // Prune now-empty .orbit/ directories from the repo tree.
139
+ const orbitDir = orbitParentDir(oldPath);
140
+ if (orbitDir) pruneEmptyAncestors(dirname(oldPath), orbitDir);
141
+
142
+ return { status: "migrated", board: boardRow, oldPath, newPath };
143
+ } catch (error) {
144
+ // Ensure no lingering open connection or partial central file on failure.
145
+ try { closeConnection(oldPath); } catch { /* ignore */ }
146
+ tryUnlink(newPath);
147
+ return { status: "failed", board: boardRow, oldPath, newPath, error };
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Migrate all boards whose db_path lives outside DATA_DIR into central storage.
153
+ * Called at server/MCP startup before any per-board connections are opened.
154
+ * Prints a concise summary for migrated boards; prints agent-actionable
155
+ * instructions for any failures so a human or AI can complete the migration.
156
+ */
157
+ export function migrateInRepoBoards() {
158
+ const pending = listBoards().filter((row) => !isCentral(row.db_path));
159
+ if (pending.length === 0) return;
160
+
161
+ const results = pending.map(migrateOneBoard);
162
+ const migrated = results.filter((r) => r.status === "migrated");
163
+ const failed = results.filter((r) => r.status === "failed");
164
+
165
+ if (migrated.length > 0) {
166
+ const n = migrated.length;
167
+ console.log(`[orbit] Migrated ${n} board${n > 1 ? "s" : ""} to central storage (${BOARDS_DIR}):`);
168
+ for (const r of migrated) {
169
+ console.log(` ✓ ${r.board.slug}`);
170
+ console.log(` from: ${r.oldPath}`);
171
+ console.log(` to: ${r.newPath}`);
172
+ }
173
+ }
174
+
175
+ if (failed.length > 0) {
176
+ const sep = " │";
177
+ const n = failed.length;
178
+ console.error(`\n[orbit] ⚠ Could not auto-migrate ${n} board${n > 1 ? "s" : ""} to central storage.`);
179
+ console.error(`${sep} These boards continue to work from their current paths.`);
180
+ console.error(`${sep} To complete the migration manually, run the commands below and`);
181
+ console.error(`${sep} restart orbit serve:\n`);
182
+ for (const r of failed) {
183
+ const slash = process.platform === "win32" ? "\\" : "/";
184
+ const destDir = dirname(r.newPath).replace(/\//g, slash);
185
+ const src = r.oldPath.replace(/\//g, slash);
186
+ const dest = r.newPath.replace(/\//g, slash);
187
+ console.error(`${sep} Board: ${r.board.slug} (error: ${r.error?.message ?? r.error})`);
188
+ if (process.platform === "win32") {
189
+ console.error(`${sep} md "${destDir}"`);
190
+ console.error(`${sep} copy "${src}" "${dest}"`);
191
+ } else {
192
+ console.error(`${sep} mkdir -p '${destDir}'`);
193
+ console.error(`${sep} cp '${src}' '${dest}'`);
194
+ }
195
+ console.error(sep);
196
+ }
197
+ }
198
+ }
package/src/core/paths.js CHANGED
@@ -16,10 +16,16 @@ export const PROJECT_ROOT = process.env.PROJECT_ROOT ? resolve(process.env.PROJE
16
16
  export const DATA_DIR = process.env.DATA_DIR || join(homedir(), ".orbit");
17
17
  /** Central registry of all boards (metadata + paths to per-board SQLite files). */
18
18
  export const REGISTRY_DB_PATH = join(DATA_DIR, "registry.db");
19
+ /** Central directory where every board's SQLite file lives: DATA_DIR/boards/<slug>/board.db */
20
+ export const BOARDS_DIR = join(DATA_DIR, "boards");
19
21
  export const EXPORT_DIR = join(DATA_DIR, "exports");
20
22
  export const BACKUP_DIR = join(DATA_DIR, "backups");
23
+ /** Central directory for dispatch run artifacts: DATA_DIR/dispatch-runs/<board-slug>/<run-id>/ */
24
+ export const DISPATCH_RUNS_DIR = join(DATA_DIR, "dispatch-runs");
21
25
  export const PORT = Number(process.env.PORT || 3337);
22
26
 
23
27
  mkdirSync(DATA_DIR, { recursive: true });
28
+ mkdirSync(BOARDS_DIR, { recursive: true });
24
29
  mkdirSync(EXPORT_DIR, { recursive: true });
25
30
  mkdirSync(BACKUP_DIR, { recursive: true });
31
+ mkdirSync(DISPATCH_RUNS_DIR, { recursive: true });
@@ -4,9 +4,10 @@
4
4
  // the board that owns a given ticket / state / entry / relation. Callers
5
5
  // receive `{ board, db }` and pass it down via `ctx`.
6
6
 
7
- import { mkdirSync } from "node:fs";
7
+ import { existsSync, mkdirSync } from "node:fs";
8
8
  import { dirname, join } from "node:path";
9
9
  import { createBoardSchema, getRegistry, openConnection } from "./db.js";
10
+ import { BOARDS_DIR } from "./paths.js";
10
11
  import { now, slugify } from "./util.js";
11
12
 
12
13
  export function listBoards() {
@@ -92,14 +93,16 @@ export function updateBoardMeta(boardId, patch) {
92
93
  .run(...vals);
93
94
  }
94
95
 
95
- /** Where a new board.db should live: flat .orbit/board.db for a repo's first
96
- * board, or .orbit/boards/<slug>/board.db for additional boards on the same
97
- * repo. */
98
- export function computeNewBoardDbPath(repoPathNorm, slug) {
99
- const reg = getRegistry();
100
- const count = reg.prepare("SELECT COUNT(*) AS c FROM boards WHERE repo_path = ?").get(repoPathNorm).c;
101
- if (count === 0) return join(repoPathNorm, ".orbit", "board.db");
102
- return join(repoPathNorm, ".orbit", "boards", slug, "board.db");
96
+ /** Where a new board.db should live: always in the central DATA_DIR/boards/<slug>/ directory.
97
+ * Falls back to a numeric suffix if the canonical path already exists on disk (orphaned file). */
98
+ export function computeNewBoardDbPath(_repoPathNorm, slug) {
99
+ const preferred = join(BOARDS_DIR, slug, "board.db");
100
+ if (!existsSync(preferred)) return preferred;
101
+ for (let i = 2; i < 1000; i++) {
102
+ const alt = join(BOARDS_DIR, `${slug}-${i}`, "board.db");
103
+ if (!existsSync(alt)) return alt;
104
+ }
105
+ return preferred; // callers will handle any existing file
103
106
  }
104
107
 
105
108
  /** Create the directory + file and apply the per-board schema. Returns the
@@ -5,9 +5,9 @@
5
5
  // board, it is rejected with 400 board_id_required rather than silently
6
6
  // inheriting some other caller's recent state.
7
7
 
8
- import { localOwnerActor } from "./auth.js";
8
+ import { actorFromHttpRequest } from "./auth.js";
9
9
  import { scheduleAutomaticBoardBackup } from "./backups.js";
10
- import { readJson, sendJson, setCors } from "./http.js";
10
+ import { readJson, readRaw, sendFile, sendJson, setCors } from "./http.js";
11
11
  import {
12
12
  findBoardForBoardEntry,
13
13
  findBoardForRelation,
@@ -54,11 +54,21 @@ import {
54
54
  getTicketRelations
55
55
  } from "./relations.js";
56
56
  import { searchTickets } from "./search.js";
57
+ import {
58
+ createTicketAttachment,
59
+ deleteTicketAttachment,
60
+ getTicketAttachmentContent,
61
+ listTicketAttachments,
62
+ MAX_IMAGE_ATTACHMENT_BYTES
63
+ } from "./attachments.js";
57
64
  import {
58
65
  checkpointTicket,
59
66
  claimNext,
60
67
  completeTicket,
61
- getContextPack
68
+ getAgentDispatchPacket,
69
+ getContextPack,
70
+ readComments,
71
+ readTicket
62
72
  } from "./agent.js";
63
73
 
64
74
  /** Build a ctx for an already-resolved registry row + actor. */
@@ -95,6 +105,15 @@ function boardHintsFromQuery(url) {
95
105
  };
96
106
  }
97
107
 
108
+ function agentContextOptions(url) {
109
+ return {
110
+ max_chars_per_field: url.searchParams.get("max_chars_per_field"),
111
+ comment_limit: url.searchParams.get("comment_limit"),
112
+ include_parent_full: url.searchParams.get("include_parent_full") === "true",
113
+ include_related_full: url.searchParams.get("include_related_full") === "true"
114
+ };
115
+ }
116
+
98
117
  export async function handleApi(req, res, url) {
99
118
  setCors(res);
100
119
 
@@ -103,7 +122,7 @@ export async function handleApi(req, res, url) {
103
122
  return;
104
123
  }
105
124
 
106
- const actor = localOwnerActor();
125
+ const actor = actorFromHttpRequest(req);
107
126
 
108
127
  // Bootstrap: read-only registry + one chosen board. No global state mutated.
109
128
  if (req.method === "GET" && url.pathname === "/api/bootstrap") {
@@ -183,7 +202,9 @@ export async function handleApi(req, res, url) {
183
202
  return;
184
203
  }
185
204
  if (sub === "export" && req.method === "GET") {
186
- sendJson(res, 200, exportBoard(row.id, ctx));
205
+ sendJson(res, 200, exportBoard(row.id, ctx, {
206
+ includeAttachments: url.searchParams.get("include_attachments") === "true" || url.searchParams.get("include_images") === "true"
207
+ }));
187
208
  return;
188
209
  }
189
210
  if (sub === "states" && req.method === "POST") {
@@ -212,6 +233,18 @@ export async function handleApi(req, res, url) {
212
233
  return;
213
234
  }
214
235
 
236
+ // /api/tickets/lookup GET — exact lightweight lookup by number or title.
237
+ if (req.method === "GET" && url.pathname === "/api/tickets/lookup") {
238
+ const { boardId, boardSlug } = boardHintsFromQuery(url);
239
+ const row = resolveBoardFromHint(boardId, boardSlug);
240
+ if (!row) throw httpError(400, "board_id_required");
241
+ const lookup = {};
242
+ if (url.searchParams.has("number")) lookup.number = url.searchParams.get("number");
243
+ if (url.searchParams.has("title")) lookup.title = url.searchParams.get("title");
244
+ sendJson(res, 200, readTicket(lookup, ctxFromBoardRow(row, actor)));
245
+ return;
246
+ }
247
+
215
248
  // /api/tickets/:id/... — board resolved from query hint or via lookup.
216
249
  const ticketPath = url.pathname.match(/^\/api\/tickets\/([^/]+)(?:\/(.*))?$/);
217
250
  if (ticketPath) {
@@ -223,6 +256,10 @@ export async function handleApi(req, res, url) {
223
256
  if (!found) throw httpError(404, "ticket_not_found");
224
257
  const ctx = ctxFromBoardRow(found.board, actor);
225
258
 
259
+ if (sub === "" && req.method === "GET") {
260
+ sendJson(res, 200, readTicket(ticketId, ctx));
261
+ return;
262
+ }
226
263
  if (sub === "" && req.method === "PATCH") {
227
264
  const body = await readJson(req);
228
265
  sendMutationJson(res, 200, updateTicket(ticketId, body, ctx), ctx);
@@ -242,7 +279,11 @@ export async function handleApi(req, res, url) {
242
279
  }
243
280
  if (sub === "context" && req.method === "GET") {
244
281
  const depth = Number(url.searchParams.get("depth") || 1);
245
- sendJson(res, 200, getContextPack(ticketId, ctx, depth));
282
+ sendJson(res, 200, getContextPack(ticketId, ctx, depth, agentContextOptions(url)));
283
+ return;
284
+ }
285
+ if ((sub === "dispatch-packet" || sub === "agent-dispatch-packet") && req.method === "GET") {
286
+ sendJson(res, 200, getAgentDispatchPacket(ticketId, ctx, agentContextOptions(url)));
246
287
  return;
247
288
  }
248
289
  if (sub === "history" && req.method === "GET") {
@@ -257,11 +298,40 @@ export async function handleApi(req, res, url) {
257
298
  sendJson(res, 200, getTicketBlockers(ticketId, ctx));
258
299
  return;
259
300
  }
301
+ if (sub === "comments" && req.method === "GET") {
302
+ sendJson(res, 200, readComments(ticketId, ctx));
303
+ return;
304
+ }
260
305
  if (sub === "comments" && req.method === "POST") {
261
306
  const body = await readJson(req);
262
307
  sendMutationJson(res, 201, createComment(ticketId, body, ctx), ctx);
263
308
  return;
264
309
  }
310
+ if (sub === "attachments" && req.method === "GET") {
311
+ sendJson(res, 200, listTicketAttachments(ticketId, ctx));
312
+ return;
313
+ }
314
+ if (sub === "attachments" && req.method === "POST") {
315
+ const bytes = await readRaw(req, MAX_IMAGE_ATTACHMENT_BYTES);
316
+ const attachment = createTicketAttachment(ticketId, {
317
+ bytes,
318
+ mime_type: req.headers["content-type"],
319
+ original_name: req.headers["x-file-name"] || url.searchParams.get("filename") || "image"
320
+ }, ctx);
321
+ sendMutationJson(res, 201, attachment, ctx);
322
+ return;
323
+ }
324
+ const attachmentContentPath = sub.match(/^attachments\/([^/]+)\/content$/);
325
+ if (attachmentContentPath && req.method === "GET") {
326
+ const result = getTicketAttachmentContent(ticketId, decodeURIComponent(attachmentContentPath[1]), ctx);
327
+ sendFile(res, result.filePath, result.row.mime_type);
328
+ return;
329
+ }
330
+ const attachmentPath = sub.match(/^attachments\/([^/]+)$/);
331
+ if (attachmentPath && req.method === "DELETE") {
332
+ sendMutationJson(res, 200, deleteTicketAttachment(ticketId, decodeURIComponent(attachmentPath[1]), ctx), ctx);
333
+ return;
334
+ }
265
335
  sendJson(res, 404, { error: "not_found" });
266
336
  return;
267
337
  }
@@ -343,7 +413,11 @@ export async function handleApi(req, res, url) {
343
413
  if (!row) throw httpError(400, "board_id_required");
344
414
  const args = {
345
415
  q: url.searchParams.get("q") || "",
346
- limit: url.searchParams.get("limit")
416
+ limit: url.searchParams.get("limit"),
417
+ mode: url.searchParams.get("mode"),
418
+ include_full: url.searchParams.get("include_full") === "true" ? true : undefined,
419
+ fields: url.searchParams.get("fields"),
420
+ max_chars_per_field: url.searchParams.get("max_chars_per_field")
347
421
  };
348
422
  sendJson(res, 200, searchTickets(args, ctxFromBoardRow(row, actor)));
349
423
  return;
@@ -12,9 +12,12 @@ export function searchTickets(args, ctx) {
12
12
  const { db, board, actor } = ctx;
13
13
  const q = String(args.q || "").trim();
14
14
  const limit = Math.min(Number(args.limit || 20), 50);
15
+ const mode = normalizeMode(args);
16
+ const maxCharsPerField = positiveInt(args.max_chars_per_field ?? args.maxCharsPerField, 0);
17
+ const fields = parseFields(args.fields);
15
18
 
16
- if (!q) return { query: q, results: [] };
17
- if (!canAccessBoard(actor, board)) return { query: q, results: [] };
19
+ if (!q) return { query: q, mode, results: [] };
20
+ if (!canAccessBoard(actor, board)) return { query: q, mode, results: [] };
18
21
 
19
22
  const ticketNumber = ticketNumberQuery(q);
20
23
  const fts = toFtsQuery(q);
@@ -23,7 +26,7 @@ export function searchTickets(args, ctx) {
23
26
  if (ticketNumber !== null) {
24
27
  rows = db
25
28
  .prepare(
26
- `SELECT t.*, s.name AS state_name, b.slug AS board_slug, -1 AS rank
29
+ `SELECT t.*, s.name AS state_name, s.role AS state_role, b.slug AS board_slug, -1 AS rank
27
30
  FROM tickets t
28
31
  JOIN states s ON s.id = t.state_id
29
32
  JOIN boards b ON b.id = t.board_id
@@ -37,7 +40,7 @@ export function searchTickets(args, ctx) {
37
40
  rows = rows.concat(
38
41
  db
39
42
  .prepare(
40
- `SELECT t.*, s.name AS state_name, b.slug AS board_slug,
43
+ `SELECT t.*, s.name AS state_name, s.role AS state_role, b.slug AS board_slug,
41
44
  bm25(ticket_fts) AS rank
42
45
  FROM ticket_fts
43
46
  JOIN tickets t ON t.id = ticket_fts.ticket_id
@@ -61,7 +64,7 @@ export function searchTickets(args, ctx) {
61
64
  rows = rows.concat(
62
65
  db
63
66
  .prepare(
64
- `SELECT DISTINCT t.*, s.name AS state_name, b.slug AS board_slug, ${numberRank} AS rank
67
+ `SELECT DISTINCT t.*, s.name AS state_name, s.role AS state_role, b.slug AS board_slug, ${numberRank} AS rank
65
68
  FROM tickets t
66
69
  JOIN states s ON s.id = t.state_id
67
70
  JOIN boards b ON b.id = t.board_id
@@ -93,12 +96,82 @@ export function searchTickets(args, ctx) {
93
96
  if (deduped.length >= limit) break;
94
97
  }
95
98
 
96
- const results = deduped.map((ticket) => ({
97
- ...ticket,
98
- labels: labelsForTicket(db, ticket.id)
99
- }));
99
+ const results = deduped.map((ticket) =>
100
+ shapeSearchResult(ticket, labelsForTicket(db, ticket.id), { mode, fields, maxCharsPerField })
101
+ );
100
102
 
101
- return { query: q, results };
103
+ return { query: q, mode, results };
104
+ }
105
+
106
+ function normalizeMode(args) {
107
+ if (args.include_full === true || args.includeFull === true) return "full";
108
+ const mode = String(args.mode || "summary").trim().toLowerCase();
109
+ return ["ids", "summary", "full"].includes(mode) ? mode : "summary";
110
+ }
111
+
112
+ function positiveInt(value, fallback) {
113
+ const n = Number(value);
114
+ return Number.isInteger(n) && n > 0 ? n : fallback;
115
+ }
116
+
117
+ function parseFields(fields) {
118
+ if (Array.isArray(fields)) return new Set(fields.map(String));
119
+ if (typeof fields === "string" && fields.trim()) {
120
+ return new Set(fields.split(",").map((field) => field.trim()).filter(Boolean));
121
+ }
122
+ return null;
123
+ }
124
+
125
+ function cap(value, maxChars) {
126
+ if (!maxChars || typeof value !== "string") return value;
127
+ return value.length > maxChars ? value.slice(0, maxChars) : value;
128
+ }
129
+
130
+ function snippetFor(ticket, maxChars) {
131
+ const text = ticket.description || ticket.ai_plan || ticket.implementation_summary || "";
132
+ return cap(text, maxChars || 240);
133
+ }
134
+
135
+ function labelNames(labels) {
136
+ return labels.map((label) => label.name);
137
+ }
138
+
139
+ function pickFields(row, fields) {
140
+ if (!fields) return row;
141
+ const picked = {};
142
+ for (const field of fields) {
143
+ if (Object.hasOwn(row, field)) picked[field] = row[field];
144
+ }
145
+ return picked;
146
+ }
147
+
148
+ function shapeSearchResult(ticket, labels, { mode, fields, maxCharsPerField }) {
149
+ if (mode === "ids") {
150
+ return pickFields({ ticket_id: ticket.id, id: ticket.id, number: ticket.number, rank: ticket.rank }, fields);
151
+ }
152
+ if (mode === "full") {
153
+ const full = { ...ticket, labels };
154
+ if (maxCharsPerField) {
155
+ for (const key of ["description", "ai_plan", "implementation_summary", "implementation_updates"]) {
156
+ full[key] = cap(full[key], maxCharsPerField);
157
+ }
158
+ }
159
+ return pickFields(full, fields);
160
+ }
161
+ return pickFields({
162
+ ticket_id: ticket.id,
163
+ id: ticket.id,
164
+ number: ticket.number,
165
+ title: ticket.title,
166
+ state_name: ticket.state_name,
167
+ state_role: ticket.state_role,
168
+ type: ticket.type,
169
+ priority: ticket.priority,
170
+ parent_ticket_id: ticket.parent_ticket_id,
171
+ rank: ticket.rank,
172
+ snippet: snippetFor(ticket, maxCharsPerField),
173
+ labels: labelNames(labels)
174
+ }, fields);
102
175
  }
103
176
 
104
177
  function ticketNumberQuery(q) {
@@ -13,8 +13,13 @@ import {
13
13
  requiredString
14
14
  } from "./util.js";
15
15
  import { reindexAllTickets } from "./tickets.js";
16
+ import {
17
+ attachmentRowsForBoard,
18
+ importAttachmentRows,
19
+ serializeAttachmentForExport
20
+ } from "./attachments.js";
16
21
 
17
- export function exportBoard(boardId, ctx) {
22
+ export function exportBoard(boardId, ctx, options = {}) {
18
23
  const { db, board, actor } = ctx;
19
24
  if (boardId !== board.id) throw httpError(404, "board_not_found");
20
25
  const innerBoard = boardById(db, boardId);
@@ -56,17 +61,22 @@ export function exportBoard(boardId, ctx) {
56
61
  ORDER BY created_at ASC`
57
62
  )
58
63
  .all(boardId);
64
+ const attachments = attachmentRowsForBoard(db, boardId).map((row) =>
65
+ serializeAttachmentForExport(board, row, Boolean(options.includeAttachments))
66
+ );
59
67
 
60
68
  return {
61
69
  format: "orbit-board-export",
62
- version: 5,
70
+ version: 6,
63
71
  exported_at: now(),
72
+ include_attachments: Boolean(options.includeAttachments),
64
73
  board: { ...innerBoard },
65
74
  states,
66
75
  labels,
67
76
  tickets,
68
77
  comments,
69
78
  relations,
79
+ attachments,
70
80
  board_entries: entries,
71
81
  events
72
82
  };
@@ -200,6 +210,8 @@ export function importBoardSnapshot(body, ctx) {
200
210
  );
201
211
  }
202
212
 
213
+ importAttachmentRows(snapshot, ctx, targetBoardId);
214
+
203
215
  for (const entry of snapshot.board_entries || []) {
204
216
  db.prepare(
205
217
  `INSERT INTO board_entries