@starlove/orbit 1.0.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 (86) hide show
  1. package/.dockerignore +13 -0
  2. package/Dockerfile +34 -0
  3. package/README.md +216 -0
  4. package/SKILL-ORBIT.md +94 -0
  5. package/build.mjs +204 -0
  6. package/dist/full/apple-touch-icon.png +0 -0
  7. package/dist/full/cursors/live_arrow.cur +0 -0
  8. package/dist/full/cursors/live_link.cur +0 -0
  9. package/dist/full/cursors/live_move.cur +0 -0
  10. package/dist/full/cursors/live_select.cur +0 -0
  11. package/dist/full/cursors/live_unavail.cur +0 -0
  12. package/dist/full/favicon-32x32.png +0 -0
  13. package/dist/full/index.html +314 -0
  14. package/dist/full/js/app.70be7d06.js +554 -0
  15. package/dist/full/js/app.70be7d06.js.map +7 -0
  16. package/dist/full/orbits-favicon.png +0 -0
  17. package/dist/full/orbits-logo-bg.png +0 -0
  18. package/dist/full/orbits-logo.png +0 -0
  19. package/dist/full/styles.c2e3d20a.css +4892 -0
  20. package/dist/full/version.json +6 -0
  21. package/docs/AGENT_PROTOCOL.md +379 -0
  22. package/docs/MCP_SETUP.md +49 -0
  23. package/docs/mcp-client.example.json +13 -0
  24. package/package.json +62 -0
  25. package/pnpm-workspace.yaml +2 -0
  26. package/public/apple-touch-icon.png +0 -0
  27. package/public/cursors/live_arrow.cur +0 -0
  28. package/public/cursors/live_link.cur +0 -0
  29. package/public/cursors/live_move.cur +0 -0
  30. package/public/cursors/live_select.cur +0 -0
  31. package/public/cursors/live_unavail.cur +0 -0
  32. package/public/favicon-32x32.png +0 -0
  33. package/public/index.html +314 -0
  34. package/public/js/api.js +35 -0
  35. package/public/js/app.js +35 -0
  36. package/public/js/board-menu.js +145 -0
  37. package/public/js/config.js +19 -0
  38. package/public/js/create-card.js +153 -0
  39. package/public/js/dom.js +19 -0
  40. package/public/js/drawer.js +137 -0
  41. package/public/js/format.js +108 -0
  42. package/public/js/kanban.js +475 -0
  43. package/public/js/local-backend.js +934 -0
  44. package/public/js/main.js +142 -0
  45. package/public/js/modal.js +31 -0
  46. package/public/js/router.js +114 -0
  47. package/public/js/search.js +53 -0
  48. package/public/js/settings-tabs.js +7 -0
  49. package/public/js/settings.js +931 -0
  50. package/public/js/sse.js +156 -0
  51. package/public/js/starscape.js +315 -0
  52. package/public/js/state.js +54 -0
  53. package/public/js/ticket-detail.js +766 -0
  54. package/public/js/toast.js +74 -0
  55. package/public/js/transport.js +22 -0
  56. package/public/js/unread.js +121 -0
  57. package/public/orbits-favicon.png +0 -0
  58. package/public/orbits-logo-bg.png +0 -0
  59. package/public/orbits-logo.png +0 -0
  60. package/public/styles.css +4892 -0
  61. package/src/cli/orbit.js +438 -0
  62. package/src/core/agent.js +262 -0
  63. package/src/core/auth.js +49 -0
  64. package/src/core/backups.js +136 -0
  65. package/src/core/board.js +112 -0
  66. package/src/core/boards.js +310 -0
  67. package/src/core/bootstrap.js +162 -0
  68. package/src/core/db.js +237 -0
  69. package/src/core/delete-board.js +53 -0
  70. package/src/core/events.js +314 -0
  71. package/src/core/http.js +81 -0
  72. package/src/core/paths.js +25 -0
  73. package/src/core/provision-repo-board.js +113 -0
  74. package/src/core/queries.js +246 -0
  75. package/src/core/registry.js +207 -0
  76. package/src/core/relations.js +163 -0
  77. package/src/core/router.js +414 -0
  78. package/src/core/search.js +70 -0
  79. package/src/core/seed.js +226 -0
  80. package/src/core/snapshots.js +255 -0
  81. package/src/core/states.js +167 -0
  82. package/src/core/system-picker.js +93 -0
  83. package/src/core/tickets.js +344 -0
  84. package/src/core/util.js +105 -0
  85. package/src/mcp-server.js +773 -0
  86. package/src/server.js +48 -0
@@ -0,0 +1,226 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { basename, join } from "node:path";
4
+ import { addComment } from "./queries.js";
5
+ import { recordEvent } from "./events.js";
6
+ import { id, now, normalizeTicketType, slugify, titleize } from "./util.js";
7
+
8
+ /**
9
+ * Seed a fresh per-board DB with the universal first-run profile. Caller
10
+ * supplies the connection and the repo path the board lives in. Returns the
11
+ * new board row, or null if the DB already had a board.
12
+ */
13
+ export function seedIfEmpty(db, repoPath, options = {}) {
14
+ const existing = db.prepare("SELECT COUNT(*) AS count FROM boards").get().count;
15
+ if (existing > 0) return null;
16
+
17
+ const time = now();
18
+ const boardId = id();
19
+ const seed = buildBoardFromRepo(repoPath);
20
+
21
+ db.exec("BEGIN;");
22
+ try {
23
+ db.prepare(
24
+ `INSERT INTO boards
25
+ (id, slug, name, repo_url, system_path, default_branch,
26
+ project_notes, agent_instructions, created_at, updated_at)
27
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
28
+ ).run(
29
+ boardId,
30
+ seed.boardSlug,
31
+ seed.boardName,
32
+ seed.repoUrl,
33
+ seed.systemPath,
34
+ seed.defaultBranch,
35
+ seed.projectNotes,
36
+ seed.agentInstructions,
37
+ time,
38
+ time
39
+ );
40
+
41
+ const states = [
42
+ ["Backlog", null],
43
+ ["Todo", null],
44
+ ["In Progress", "in_progress"],
45
+ ["Review", "review"],
46
+ ["Done", "done"],
47
+ ["Cancelled", null]
48
+ ];
49
+ const stateIds = new Map();
50
+ states.forEach(([name, role], position) => {
51
+ const stateId = id();
52
+ stateIds.set(name, stateId);
53
+ db.prepare(
54
+ `INSERT INTO states
55
+ (id, board_id, name, position, is_default, role, created_at)
56
+ VALUES (?, ?, ?, ?, ?, ?, ?)`
57
+ ).run(stateId, boardId, name, position, name === "Todo" ? 1 : 0, role, time);
58
+ });
59
+
60
+ // User-defined labels only at seed time.
61
+ const labelIds = new Map();
62
+ [
63
+ ["human-only", "#7c2d12"],
64
+ ["needs-human-input", "#dc2626"],
65
+ ["needs-decomposition", "#9333ea"],
66
+ ["needs-followup", "#b45309"],
67
+ ["tech-debt", "#64748b"],
68
+ ["security", "#991b1b"],
69
+ ["onboarding", "#0f766e"]
70
+ ].forEach(([name, color]) => {
71
+ const labelId = id();
72
+ labelIds.set(name, labelId);
73
+ db.prepare(
74
+ "INSERT INTO labels (id, board_id, name, color, created_at) VALUES (?, ?, ?, ?, ?)"
75
+ ).run(labelId, boardId, name, color, time);
76
+ });
77
+
78
+ if (options.includeExamples) {
79
+ const epicTicket = insertSeedTicket(db, boardId, 1, {
80
+ title: `Onboard ${seed.boardName} into this board`,
81
+ description:
82
+ "First-run onboarding epic. Use this to turn the generic seed into a useful repo-specific project context and work queue.",
83
+ type: "epic",
84
+ aiPlan:
85
+ "1. Fill Settings → AI (Agent Instructions) and Settings → Notes (Notes For You).\n2. Capture initial decisions and lessons in the Journal.\n3. Create the first real epic or AI-ready implementation ticket.",
86
+ stateId: stateIds.get("Todo"),
87
+ priority: 4,
88
+ labels: [labelIds.get("human-only"), labelIds.get("needs-decomposition"), labelIds.get("onboarding")]
89
+ });
90
+
91
+ const firstTicket = insertSeedTicket(db, boardId, 2, {
92
+ title: "Fill agent context & personal notes",
93
+ description:
94
+ "Open Settings → AI (Agent Instructions) and Settings → Notes, and replace the seed text with real project context plus your own reminders.",
95
+ type: "feature",
96
+ parentTicketId: epicTicket,
97
+ stateId: stateIds.get("Todo"),
98
+ priority: 3,
99
+ labels: [labelIds.get("human-only"), labelIds.get("onboarding")]
100
+ });
101
+
102
+ insertSeedTicket(db, boardId, 3, {
103
+ title: "Create the first real implementation ticket",
104
+ description:
105
+ "Create a focused feature, task, or bug card with acceptance criteria, labels, and enough context for an agent or human to execute.",
106
+ type: "feature",
107
+ parentTicketId: epicTicket,
108
+ stateId: stateIds.get("Backlog"),
109
+ priority: 1,
110
+ labels: [labelIds.get("human-only"), labelIds.get("onboarding")]
111
+ });
112
+
113
+ // #12 is a stable “try the agent on this card” handle for docs and `orbit init` next steps (MCP + claim-next).
114
+ insertSeedTicket(db, boardId, 12, {
115
+ title: "Try Orbit MCP on this ticket",
116
+ description:
117
+ "After `orbit init` and registering MCP, ask your agent from this repo to use the Orbit MCP tools on ticket 12 (claim context, update fields, move lanes). Read SKILL-ORBIT.md first.",
118
+ type: "task",
119
+ stateId: stateIds.get("Backlog"),
120
+ priority: 2,
121
+ labels: [labelIds.get("onboarding")]
122
+ });
123
+
124
+ addComment(db, epicTicket, "system", "note", "Universal first-run seed. Delete or complete these onboarding cards once this board reflects the repo.");
125
+ addComment(db, firstTicket, "system", "note", "Settings → AI: agent_instructions (ticket context). Settings → Notes: project_notes (you only).");
126
+ }
127
+ recordEvent(db, boardId, "seed", null, "system", { board: seed.boardSlug });
128
+ db.exec("COMMIT;");
129
+ } catch (error) {
130
+ db.exec("ROLLBACK;");
131
+ throw error;
132
+ }
133
+
134
+ return db.prepare("SELECT * FROM boards WHERE id = ?").get(boardId);
135
+ }
136
+
137
+ function insertSeedTicket(db, boardId, number, ticket) {
138
+ const ticketId = id();
139
+ const time = now();
140
+ db.prepare(
141
+ `INSERT INTO tickets
142
+ (id, board_id, number, title, description, type, parent_ticket_id, ai_plan,
143
+ implementation_summary, implementation_updates, state_id, priority, created_by, created_at, updated_at)
144
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
145
+ ).run(
146
+ ticketId,
147
+ boardId,
148
+ number,
149
+ ticket.title,
150
+ ticket.description,
151
+ normalizeTicketType(ticket.type || (ticket.parentTicketId ? "feature" : "task")),
152
+ ticket.parentTicketId || null,
153
+ ticket.aiPlan || "",
154
+ ticket.implementationSummary || "",
155
+ ticket.implementationUpdates || "",
156
+ ticket.stateId,
157
+ ticket.priority,
158
+ "system",
159
+ time,
160
+ time
161
+ );
162
+
163
+ for (const labelId of ticket.labels || []) {
164
+ db.prepare("INSERT OR IGNORE INTO ticket_labels (ticket_id, label_id) VALUES (?, ?)").run(ticketId, labelId);
165
+ }
166
+ return ticketId;
167
+ }
168
+
169
+ /** Sniff repo metadata for a new board (package.json, git, env). */
170
+ export function buildBoardFromRepo(projectRoot) {
171
+ const packageInfo = readPackageInfo(projectRoot);
172
+ const directoryName = basename(projectRoot);
173
+ const boardName =
174
+ readSeedValue("board_name") || titleize(packageInfo.name || directoryName || "My Board");
175
+ const boardSlug = slugify(
176
+ readSeedValue("board_slug") || packageInfo.name || boardName || "my-board"
177
+ );
178
+ const repoUrl = readSeedValue("repo_url") || gitValue(["config", "--get", "remote.origin.url"], projectRoot) || "";
179
+ const systemPath = readSeedValue("system_path") || projectRoot;
180
+ const defaultBranch =
181
+ readSeedValue("default_branch") ||
182
+ gitValue(["branch", "--show-current"], projectRoot) ||
183
+ gitValue(["symbolic-ref", "--short", "HEAD"], projectRoot) ||
184
+ "main";
185
+ const defaultProjectNotes =
186
+ "Personal reminders (Settings → Notes: Notes For You). Point yourself at SKILL-ORBIT.md, runbooks, or nudges for your tools.";
187
+ const defaultAgentInstructions = `Describe why ${boardName} exists, who it serves, and what success looks like. Add features, stack, constraints, and anything agents should know — this project-level context is provided to agents when they work on tickets.`;
188
+ return {
189
+ boardName,
190
+ boardSlug,
191
+ repoUrl,
192
+ systemPath,
193
+ defaultBranch,
194
+ projectNotes: readSeedValue("project_notes") || defaultProjectNotes,
195
+ agentInstructions: readSeedValue("agent_instructions") || defaultAgentInstructions
196
+ };
197
+ }
198
+
199
+ function readPackageInfo(root) {
200
+ const path = join(root, "package.json");
201
+ if (!existsSync(path)) return {};
202
+ try {
203
+ return JSON.parse(readFileSync(path, "utf8"));
204
+ } catch {
205
+ return {};
206
+ }
207
+ }
208
+
209
+ function readSeedValue(key) {
210
+ const envValue = process.env[key.toUpperCase()];
211
+ if (typeof envValue === "string" && envValue.trim()) return envValue.trim();
212
+ return "";
213
+ }
214
+
215
+ function gitValue(args, cwd) {
216
+ try {
217
+ return execFileSync("git", args, {
218
+ cwd,
219
+ encoding: "utf8",
220
+ stdio: ["ignore", "pipe", "ignore"],
221
+ timeout: 2000
222
+ }).trim();
223
+ } catch {
224
+ return "";
225
+ }
226
+ }
@@ -0,0 +1,255 @@
1
+ import { resetBoard, tx } from "./db.js";
2
+ import { backupBoardDatabase } from "./backups.js";
3
+ import { boardById, labelsForTicket } from "./queries.js";
4
+ import { requireBoardAccess } from "./auth.js";
5
+ import { recordEvent } from "./events.js";
6
+ import { syncRegistryFromBoardDb } from "./registry.js";
7
+ import {
8
+ httpError,
9
+ normalizePriority,
10
+ normalizeProjectEntryType,
11
+ normalizeTicketType,
12
+ now,
13
+ requiredString
14
+ } from "./util.js";
15
+ import { reindexAllTickets } from "./tickets.js";
16
+
17
+ export function exportBoard(boardId, ctx) {
18
+ const { db, board, actor } = ctx;
19
+ if (boardId !== board.id) throw httpError(404, "board_not_found");
20
+ const innerBoard = boardById(db, boardId);
21
+ if (!innerBoard) throw httpError(404, "board_not_found");
22
+ requireBoardAccess(actor, innerBoard);
23
+
24
+ const states = db.prepare("SELECT * FROM states WHERE board_id = ? ORDER BY position").all(boardId);
25
+ const tickets = db
26
+ .prepare("SELECT * FROM tickets WHERE board_id = ? ORDER BY number ASC")
27
+ .all(boardId)
28
+ .map((ticket) => ({ ...ticket, labels: labelsForTicket(db, ticket.id) }));
29
+ const ticketIds = tickets.map((ticket) => ticket.id);
30
+ const comments = ticketIds.length
31
+ ? db
32
+ .prepare("SELECT * FROM comments WHERE ticket_id IN (SELECT id FROM tickets WHERE board_id = ?) ORDER BY created_at ASC")
33
+ .all(boardId)
34
+ : [];
35
+ const relations = ticketIds.length
36
+ ? db
37
+ .prepare(
38
+ `SELECT *
39
+ FROM relations
40
+ WHERE source_ticket_id IN (SELECT id FROM tickets WHERE board_id = ?)
41
+ OR target_ticket_id IN (SELECT id FROM tickets WHERE board_id = ?)
42
+ ORDER BY created_at ASC`
43
+ )
44
+ .all(boardId, boardId)
45
+ : [];
46
+ const entries = db
47
+ .prepare("SELECT * FROM board_entries WHERE board_id = ? ORDER BY created_at ASC")
48
+ .all(boardId);
49
+ const labels = db.prepare("SELECT * FROM labels WHERE board_id = ? ORDER BY name ASC").all(boardId);
50
+ const events = db
51
+ .prepare(
52
+ `SELECT *
53
+ FROM events
54
+ WHERE ticket_id IS NULL
55
+ OR ticket_id IN (SELECT id FROM tickets WHERE board_id = ?)
56
+ ORDER BY created_at ASC`
57
+ )
58
+ .all(boardId);
59
+
60
+ return {
61
+ format: "orbit-board-export",
62
+ version: 5,
63
+ exported_at: now(),
64
+ board: { ...innerBoard },
65
+ states,
66
+ labels,
67
+ tickets,
68
+ comments,
69
+ relations,
70
+ board_entries: entries,
71
+ events
72
+ };
73
+ }
74
+
75
+ export function importBoardSnapshot(body, ctx) {
76
+ const { db, actor, board } = ctx;
77
+ const snapshot = body?.snapshot || body;
78
+ const format = snapshot?.format;
79
+ if (!snapshot || format !== "orbit-board-export") throw httpError(400, "invalid_import_snapshot");
80
+ if (!snapshot.board) throw httpError(400, "snapshot_missing_board");
81
+
82
+ const replaceExisting = Boolean(body?.replace_existing);
83
+ const existingCount = db.prepare("SELECT COUNT(*) AS count FROM boards").get().count;
84
+ if (existingCount > 0 && !replaceExisting) {
85
+ throw httpError(409, "import_requires_empty_board_or_replace_existing");
86
+ }
87
+
88
+ // Snapshot the existing DB file before a destructive replace, outside the
89
+ // transaction (it copies the file on disk, not rows).
90
+ if (existingCount > 0 && replaceExisting) {
91
+ backupBoardDatabase(ctx.board, db, "pre-import-replace");
92
+ }
93
+
94
+ const targetBoardId = board.id;
95
+ const importedBoard = {
96
+ ...snapshot.board,
97
+ id: targetBoardId
98
+ };
99
+
100
+ const result = tx(db, () => {
101
+ if (existingCount > 0 && replaceExisting) resetBoard(db);
102
+
103
+ db.prepare(
104
+ `INSERT INTO boards
105
+ (id, slug, name, repo_url, system_path, default_branch,
106
+ project_notes, agent_instructions, ai_enabled, created_at, updated_at)
107
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
108
+ ).run(
109
+ importedBoard.id,
110
+ importedBoard.slug,
111
+ importedBoard.name,
112
+ importedBoard.repo_url || "",
113
+ importedBoard.system_path || "",
114
+ importedBoard.default_branch || "main",
115
+ importedBoard.project_notes || "",
116
+ importedBoard.agent_instructions || "",
117
+ importedBoard.ai_enabled === 0 ? 0 : 1,
118
+ importedBoard.created_at || now(),
119
+ importedBoard.updated_at || now()
120
+ );
121
+
122
+ for (const state of snapshot.states || []) {
123
+ db.prepare(
124
+ `INSERT INTO states
125
+ (id, board_id, name, position, is_default, role, created_at)
126
+ VALUES (?, ?, ?, ?, ?, ?, ?)`
127
+ ).run(
128
+ state.id,
129
+ targetBoardId,
130
+ state.name,
131
+ state.position,
132
+ state.is_default || 0,
133
+ state.role || null,
134
+ state.created_at || now()
135
+ );
136
+ }
137
+
138
+ for (const label of snapshot.labels || []) {
139
+ db.prepare("INSERT INTO labels (id, board_id, name, color, created_at) VALUES (?, ?, ?, ?, ?)").run(
140
+ label.id,
141
+ targetBoardId,
142
+ label.name,
143
+ label.color || "#64748b",
144
+ label.created_at || now()
145
+ );
146
+ }
147
+
148
+ for (const ticket of snapshot.tickets || []) {
149
+ db.prepare(
150
+ `INSERT INTO tickets
151
+ (id, board_id, number, title, description, type, parent_ticket_id, ai_plan,
152
+ implementation_summary, implementation_updates, state_id, priority,
153
+ created_by, created_at, updated_at)
154
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
155
+ ).run(
156
+ ticket.id,
157
+ targetBoardId,
158
+ ticket.number,
159
+ ticket.title,
160
+ ticket.description || "",
161
+ normalizeTicketType(ticket.type || "task"),
162
+ ticket.parent_ticket_id || null,
163
+ ticket.ai_plan || "",
164
+ ticket.implementation_summary || "",
165
+ ticket.implementation_updates || "",
166
+ ticket.state_id,
167
+ normalizePriority(ticket.priority),
168
+ ticket.created_by || "import",
169
+ ticket.created_at || now(),
170
+ ticket.updated_at || now()
171
+ );
172
+
173
+ for (const label of ticket.labels || []) {
174
+ db.prepare("INSERT OR IGNORE INTO ticket_labels (ticket_id, label_id) VALUES (?, ?)").run(ticket.id, label.id);
175
+ }
176
+ }
177
+
178
+ for (const comment of snapshot.comments || []) {
179
+ db.prepare(
180
+ "INSERT INTO comments (id, ticket_id, author, kind, body, created_at) VALUES (?, ?, ?, ?, ?, ?)"
181
+ ).run(
182
+ comment.id,
183
+ comment.ticket_id,
184
+ comment.author || "import",
185
+ comment.kind || "comment",
186
+ comment.body || "",
187
+ comment.created_at || now()
188
+ );
189
+ }
190
+
191
+ for (const relation of snapshot.relations || []) {
192
+ db.prepare(
193
+ "INSERT OR IGNORE INTO relations (id, source_ticket_id, target_ticket_id, type, created_at) VALUES (?, ?, ?, ?, ?)"
194
+ ).run(
195
+ relation.id,
196
+ relation.source_ticket_id,
197
+ relation.target_ticket_id,
198
+ relation.type || "relates_to",
199
+ relation.created_at || now()
200
+ );
201
+ }
202
+
203
+ for (const entry of snapshot.board_entries || []) {
204
+ db.prepare(
205
+ `INSERT INTO board_entries
206
+ (id, board_id, type, title, body, ticket_id, created_by, created_at, updated_at, struck_at)
207
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
208
+ ).run(
209
+ entry.id,
210
+ targetBoardId,
211
+ normalizeProjectEntryType(entry.type),
212
+ requiredString(entry.title, "entry_title"),
213
+ entry.body || "",
214
+ entry.ticket_id || null,
215
+ entry.created_by || "import",
216
+ entry.created_at || now(),
217
+ entry.updated_at || now(),
218
+ entry.struck_at || null
219
+ );
220
+ }
221
+
222
+ for (const event of snapshot.events || []) {
223
+ db.prepare(
224
+ "INSERT INTO events (id, ticket_id, actor, type, body_json, created_at) VALUES (?, ?, ?, ?, ?, ?)"
225
+ ).run(
226
+ event.id,
227
+ event.ticket_id || null,
228
+ event.actor || "import",
229
+ event.type || "imported_event",
230
+ event.body_json || "{}",
231
+ event.created_at || now()
232
+ );
233
+ }
234
+
235
+ // Reindex inside the transaction: a committed import must never leave the
236
+ // FTS table empty/stale (previously this ran after COMMIT and a throw here
237
+ // left search broken on otherwise-imported data).
238
+ reindexAllTickets(db);
239
+ recordEvent(db, targetBoardId, "board_imported", null, actor.name, {
240
+ board_id: targetBoardId,
241
+ replace_existing: replaceExisting
242
+ });
243
+ return {
244
+ ok: true,
245
+ imported_board_id: targetBoardId,
246
+ imported_board_slug: importedBoard.slug,
247
+ replace_existing: replaceExisting
248
+ };
249
+ });
250
+
251
+ // Registry sync writes the separate registry DB (a different connection), so
252
+ // it runs after the board-DB transaction commits, not inside it.
253
+ syncRegistryFromBoardDb(board, db);
254
+ return result;
255
+ }
@@ -0,0 +1,167 @@
1
+ import { boardById, compactStatePositions, stateById } from "./queries.js";
2
+ import { tx } from "./db.js";
3
+ import { requireBoardAccess, requirePermission } from "./auth.js";
4
+ import { recordEvent } from "./events.js";
5
+ import {
6
+ httpError,
7
+ id,
8
+ normalizeStateRole,
9
+ now,
10
+ requiredString
11
+ } from "./util.js";
12
+
13
+ export function createState(boardId, body, ctx) {
14
+ const { db, actor } = ctx;
15
+ const board = boardById(db, boardId);
16
+ if (!board) throw httpError(404, "board_not_found");
17
+ requireBoardAccess(actor, board);
18
+ requirePermission(actor, "write");
19
+
20
+ const role = body.role !== undefined ? normalizeStateRole(body.role) : null;
21
+ // role='ai_ready' implies canonical name; reject mismatches so the AI tab
22
+ // checkbox always lands a lane named exactly "AI Ready".
23
+ const name =
24
+ role === "ai_ready"
25
+ ? body.name && body.name !== "AI Ready"
26
+ ? (() => { throw httpError(409, "ai_ready_lane_must_be_named_ai_ready"); })()
27
+ : "AI Ready"
28
+ : requiredString(body.name, "name");
29
+ const stateId = id();
30
+ return tx(db, () => {
31
+ if (role) {
32
+ db.prepare("UPDATE states SET role = NULL WHERE board_id = ? AND role = ?").run(boardId, role);
33
+ }
34
+ const maxPosition = db.prepare("SELECT MAX(position) AS value FROM states WHERE board_id = ?").get(boardId).value;
35
+ db.prepare(
36
+ `INSERT INTO states
37
+ (id, board_id, name, position, is_default, role, created_at)
38
+ VALUES (?, ?, ?, ?, 0, ?, ?)`
39
+ ).run(stateId, boardId, name, Number(maxPosition || 0) + 1, role, now());
40
+ recordEvent(db, boardId, "state_created", null, actor.name, {
41
+ board_id: boardId,
42
+ state_id: stateId,
43
+ name,
44
+ role
45
+ });
46
+ return stateById(db, stateId);
47
+ });
48
+ }
49
+
50
+ export function updateState(stateId, body, ctx) {
51
+ const { db, actor } = ctx;
52
+ const before = stateById(db, stateId);
53
+ if (!before) throw httpError(404, "state_not_found");
54
+ const board = boardById(db, before.board_id);
55
+ requireBoardAccess(actor, board);
56
+ requirePermission(actor, "write");
57
+
58
+ return tx(db, () => {
59
+ const sets = [];
60
+ const values = [];
61
+ if (body.name !== undefined) {
62
+ const nextName = requiredString(body.name, "name");
63
+ // The AI Ready lane is the canonical anchor for agent claim-next. Locking
64
+ // its name removes a whole class of silent breakage; users who want a
65
+ // different label can disable the AI Ready column from Settings → AI.
66
+ if (before.role === "ai_ready" && nextName !== "AI Ready") {
67
+ throw httpError(409, "cannot_rename_ai_ready_lane");
68
+ }
69
+ sets.push("name = ?");
70
+ values.push(nextName);
71
+ }
72
+ if (body.is_default !== undefined) {
73
+ const nextDefault = body.is_default ? 1 : 0;
74
+ if (nextDefault) db.prepare("UPDATE states SET is_default = 0 WHERE board_id = ?").run(before.board_id);
75
+ sets.push("is_default = ?");
76
+ values.push(nextDefault);
77
+ }
78
+ if (body.role !== undefined) {
79
+ const nextRole = normalizeStateRole(body.role);
80
+ // Roles are unique per board: at most one lane plays each role. If another
81
+ // lane already holds it, clear that one first so the new assignment wins.
82
+ if (nextRole) {
83
+ db.prepare("UPDATE states SET role = NULL WHERE board_id = ? AND role = ? AND id != ?")
84
+ .run(before.board_id, nextRole, stateId);
85
+ }
86
+ // Assigning role='ai_ready' implies the lane name must be "AI Ready" (the
87
+ // canonical name agents key off). If the caller is also renaming via
88
+ // body.name above, that took precedence; otherwise force it here.
89
+ if (nextRole === "ai_ready") {
90
+ const finalName = body.name !== undefined ? requiredString(body.name, "name") : before.name;
91
+ if (finalName !== "AI Ready") {
92
+ if (body.name === undefined) {
93
+ // Caller didn't ask to rename — silently set the canonical name.
94
+ sets.push("name = ?");
95
+ values.push("AI Ready");
96
+ } else {
97
+ throw httpError(409, "ai_ready_lane_must_be_named_ai_ready");
98
+ }
99
+ }
100
+ }
101
+ sets.push("role = ?");
102
+ values.push(nextRole);
103
+ }
104
+ if (sets.length === 0) return before;
105
+
106
+ values.push(stateId);
107
+ db.prepare(`UPDATE states SET ${sets.join(", ")} WHERE id = ?`).run(...values);
108
+ recordEvent(db, before.board_id, "state_updated", null, actor.name, {
109
+ board_id: before.board_id,
110
+ state_id: stateId,
111
+ fields: Object.keys(body)
112
+ });
113
+ return stateById(db, stateId);
114
+ });
115
+ }
116
+
117
+ export function reorderStates(boardId, body, ctx) {
118
+ const { db, actor } = ctx;
119
+ const board = boardById(db, boardId);
120
+ if (!board) throw httpError(404, "board_not_found");
121
+ requireBoardAccess(actor, board);
122
+ requirePermission(actor, "write");
123
+
124
+ const ids = Array.isArray(body.state_ids) ? body.state_ids : [];
125
+ const existing = db.prepare("SELECT id FROM states WHERE board_id = ?").all(boardId).map((row) => row.id);
126
+ if (ids.length !== existing.length || !existing.every((stateId) => ids.includes(stateId))) {
127
+ throw httpError(400, "invalid_state_order");
128
+ }
129
+
130
+ return tx(db, () => {
131
+ ids.forEach((stateId, position) => {
132
+ db.prepare("UPDATE states SET position = ? WHERE id = ? AND board_id = ?").run(position, stateId, boardId);
133
+ });
134
+ recordEvent(db, boardId, "states_reordered", null, actor.name, { board_id: boardId, state_ids: ids });
135
+ return db.prepare("SELECT * FROM states WHERE board_id = ? ORDER BY position").all(boardId);
136
+ });
137
+ }
138
+
139
+ export function deleteState(stateId, ctx) {
140
+ const { db, actor } = ctx;
141
+ const state = stateById(db, stateId);
142
+ if (!state) throw httpError(404, "state_not_found");
143
+ const board = boardById(db, state.board_id);
144
+ requireBoardAccess(actor, board);
145
+ requirePermission(actor, "write");
146
+
147
+ const ticketCount = db.prepare("SELECT COUNT(*) AS count FROM tickets WHERE state_id = ?").get(stateId).count;
148
+ if (ticketCount > 0) throw httpError(409, "state_has_tickets");
149
+
150
+ const stateCount = db.prepare("SELECT COUNT(*) AS count FROM states WHERE board_id = ?").get(state.board_id).count;
151
+ if (stateCount <= 1) throw httpError(409, "cannot_delete_last_state");
152
+
153
+ return tx(db, () => {
154
+ db.prepare("DELETE FROM states WHERE id = ?").run(stateId);
155
+ compactStatePositions(db, state.board_id);
156
+ if (state.is_default) {
157
+ const first = db.prepare("SELECT id FROM states WHERE board_id = ? ORDER BY position LIMIT 1").get(state.board_id);
158
+ if (first) db.prepare("UPDATE states SET is_default = 1 WHERE id = ?").run(first.id);
159
+ }
160
+ recordEvent(db, state.board_id, "state_deleted", null, actor.name, {
161
+ board_id: state.board_id,
162
+ state_id: stateId,
163
+ name: state.name
164
+ });
165
+ return { ok: true, deleted_state_id: stateId };
166
+ });
167
+ }