@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,49 @@
1
+ export function localOwnerActor() {
2
+ return {
3
+ id: "local",
4
+ name: "local",
5
+ type: "human",
6
+ role: "owner",
7
+ permissions: ["owner", "read", "write", "claim"],
8
+ boards: ["*"]
9
+ };
10
+ }
11
+
12
+ export function localAgentActor() {
13
+ return {
14
+ id: "local-agent",
15
+ name: "agent",
16
+ type: "agent",
17
+ role: "owner",
18
+ permissions: ["owner", "read", "write", "claim"],
19
+ boards: ["*"]
20
+ };
21
+ }
22
+
23
+ export function authenticate() {
24
+ return localOwnerActor();
25
+ }
26
+
27
+ export function actorFromToken() {
28
+ return localOwnerActor();
29
+ }
30
+
31
+ export function canAccessBoard() {
32
+ return true;
33
+ }
34
+
35
+ export function requireBoardAccess() {}
36
+
37
+ export function requireOwner() {}
38
+
39
+ export function requirePermission() {}
40
+
41
+ export function publicActor(actor) {
42
+ return {
43
+ id: actor.id,
44
+ name: actor.name,
45
+ type: actor.type,
46
+ role: actor.role,
47
+ permissions: actor.permissions
48
+ };
49
+ }
@@ -0,0 +1,136 @@
1
+ import { existsSync, mkdirSync, readdirSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { getRegistry } from "./db.js";
4
+ import { BACKUP_DIR } from "./paths.js";
5
+ import { now } from "./util.js";
6
+
7
+ const DEFAULT_RETENTION = 30;
8
+ const DEFAULT_AUTO_DELAY_MS = 120_000;
9
+ const scheduledBoardBackups = new Map();
10
+
11
+ function backupsEnabled() {
12
+ return !["0", "false", "off"].includes(String(process.env.ORBIT_AUTO_BACKUPS || "1").toLowerCase());
13
+ }
14
+
15
+ function retentionLimit() {
16
+ const parsed = Number(process.env.ORBIT_BACKUP_RETENTION || DEFAULT_RETENTION);
17
+ if (!Number.isFinite(parsed) || parsed < 1) return DEFAULT_RETENTION;
18
+ return Math.floor(parsed);
19
+ }
20
+
21
+ function cleanSegment(value) {
22
+ return String(value || "unknown")
23
+ .trim()
24
+ .replace(/[^a-zA-Z0-9._-]+/g, "-")
25
+ .replace(/^-+|-+$/g, "")
26
+ .slice(0, 80) || "unknown";
27
+ }
28
+
29
+ function timestampForPath() {
30
+ return now().replace(/[:.]/g, "-");
31
+ }
32
+
33
+ function sqlLiteral(value) {
34
+ return `'${String(value).replace(/'/g, "''")}'`;
35
+ }
36
+
37
+ function pruneBackups(dir, suffix) {
38
+ if (!existsSync(dir)) return;
39
+ const limit = retentionLimit();
40
+ const files = readdirSync(dir)
41
+ .filter((name) => name.endsWith(suffix))
42
+ .sort()
43
+ .reverse();
44
+ for (const stale of files.slice(limit)) {
45
+ unlinkSync(join(dir, stale));
46
+ const manifest = join(dir, `${stale}.json`);
47
+ if (existsSync(manifest)) unlinkSync(manifest);
48
+ }
49
+ }
50
+
51
+ function latestBackupStartsWith(dir, prefix, suffix) {
52
+ if (!existsSync(dir)) return false;
53
+ return readdirSync(dir).some((name) => name.startsWith(prefix) && name.endsWith(suffix));
54
+ }
55
+
56
+ function writeBackup(db, targetPath) {
57
+ mkdirSync(dirname(targetPath), { recursive: true });
58
+ db.exec(`VACUUM INTO ${sqlLiteral(targetPath)};`);
59
+ }
60
+
61
+ function writeManifest(targetPath, payload) {
62
+ writeFileSync(`${targetPath}.json`, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
63
+ }
64
+
65
+ function automaticDelayMs() {
66
+ const parsed = Number(process.env.ORBIT_AUTO_BACKUP_DELAY_MS || DEFAULT_AUTO_DELAY_MS);
67
+ if (!Number.isFinite(parsed) || parsed < 0) return DEFAULT_AUTO_DELAY_MS;
68
+ return Math.floor(parsed);
69
+ }
70
+
71
+ export function backupBoardDatabase(boardRow, db, reason = "manual", options = {}) {
72
+ if (!backupsEnabled()) return null;
73
+ const boardId = cleanSegment(boardRow.id);
74
+ const slug = cleanSegment(boardRow.slug);
75
+ const dir = join(BACKUP_DIR, "boards", boardId);
76
+ const dayPrefix = `${now().slice(0, 10)}T`;
77
+ if (options.oncePerDay && latestBackupStartsWith(dir, dayPrefix, ".board.db")) return null;
78
+
79
+ const filename = `${timestampForPath()}-${cleanSegment(reason)}-${slug}.board.db`;
80
+ const targetPath = join(dir, filename);
81
+ writeBackup(db, targetPath);
82
+ writeManifest(targetPath, {
83
+ kind: "board",
84
+ reason,
85
+ created_at: now(),
86
+ board_id: boardRow.id,
87
+ slug: boardRow.slug,
88
+ name: boardRow.name,
89
+ repo_path: boardRow.repo_path,
90
+ db_path: boardRow.db_path
91
+ });
92
+ pruneBackups(dir, ".board.db");
93
+ return { path: targetPath };
94
+ }
95
+
96
+ export function scheduleAutomaticBoardBackup(boardRow, db) {
97
+ if (!backupsEnabled() || !boardRow?.id || !db) return;
98
+ const existing = scheduledBoardBackups.get(boardRow.id);
99
+ if (existing) clearTimeout(existing);
100
+
101
+ const timer = setTimeout(() => {
102
+ scheduledBoardBackups.delete(boardRow.id);
103
+ try {
104
+ backupBoardDatabase(boardRow, db, "auto-write");
105
+ backupRegistry("auto-write");
106
+ } catch (error) {
107
+ console.warn(`[orbit] Automatic write backup failed for "${boardRow.slug}": ${error.message}`);
108
+ }
109
+ }, automaticDelayMs());
110
+ timer.unref?.();
111
+ scheduledBoardBackups.set(boardRow.id, timer);
112
+ }
113
+
114
+ export function cancelAutomaticBoardBackup(boardId) {
115
+ const existing = scheduledBoardBackups.get(boardId);
116
+ if (!existing) return;
117
+ clearTimeout(existing);
118
+ scheduledBoardBackups.delete(boardId);
119
+ }
120
+
121
+ export function backupRegistry(reason = "manual", options = {}) {
122
+ if (!backupsEnabled()) return null;
123
+ const dir = join(BACKUP_DIR, "registry");
124
+ const dayPrefix = `${now().slice(0, 10)}T`;
125
+ if (options.oncePerDay && latestBackupStartsWith(dir, dayPrefix, ".registry.db")) return null;
126
+
127
+ const targetPath = join(dir, `${timestampForPath()}-${cleanSegment(reason)}.registry.db`);
128
+ writeBackup(getRegistry(), targetPath);
129
+ writeManifest(targetPath, {
130
+ kind: "registry",
131
+ reason,
132
+ created_at: now()
133
+ });
134
+ pruneBackups(dir, ".registry.db");
135
+ return { path: targetPath };
136
+ }
@@ -0,0 +1,112 @@
1
+ // Barrel module + startup orchestration.
2
+ //
3
+ // Startup contract:
4
+ // 1. Ensure the registry DB and its schema exist.
5
+ // 2. If the registry has zero boards, create a first board under
6
+ // PROJECT_ROOT and seed it. This is the open-source one-command-startup
7
+ // ergonomic — drop into a repo, run the server, get a board.
8
+ // 3. Iterate every registry board, ensuring the per-board schema exists
9
+ // and reindexing FTS for that board's DB. No "active board" singleton:
10
+ // each iteration opens its own connection.
11
+ //
12
+ // Beyond startup this module just re-exports per-domain functions for
13
+ // `src/server.js` and `src/mcp-server.js` to consume.
14
+
15
+ import { existsSync, mkdirSync } from "node:fs";
16
+ import "./paths.js";
17
+ import { createBoardSchema, createRegistrySchema } from "./db.js";
18
+ import { reindexAllTickets } from "./tickets.js";
19
+ import { deleteBoard, listBoards, openBoardDb, syncRegistryFromBoardDb } from "./registry.js";
20
+ import { provisionRepoBoard } from "./provision-repo-board.js";
21
+ import { id, slugify } from "./util.js";
22
+ import {
23
+ DATA_DIR,
24
+ EXPORT_DIR,
25
+ BACKUP_DIR,
26
+ PORT,
27
+ PROJECT_ROOT,
28
+ PUBLIC_DIR,
29
+ REGISTRY_DB_PATH,
30
+ ROOT_DIR
31
+ } from "./paths.js";
32
+
33
+ function ensureWorkspace() {
34
+ mkdirSync(DATA_DIR, { recursive: true });
35
+ createRegistrySchema();
36
+ }
37
+
38
+ function ensureFirstBoard() {
39
+ if (listBoards().length > 0) return;
40
+ // Bootstrap a first board under PROJECT_ROOT so the server is useful out of
41
+ // the box. Shared logic with `orbit init` (provision-repo-board.js).
42
+ provisionRepoBoard(PROJECT_ROOT);
43
+ }
44
+
45
+ function ensureSchemaForEveryBoard() {
46
+ for (const row of listBoards()) {
47
+ if (!existsSync(row.db_path)) {
48
+ console.warn(
49
+ `[orbit] Removing stale registry row for "${row.slug}": database file missing (${row.db_path}).`
50
+ );
51
+ deleteBoard(row.id);
52
+ continue;
53
+ }
54
+ const db = openBoardDb(row);
55
+ createBoardSchema(db);
56
+ reindexAllTickets(db);
57
+ syncRegistryFromBoardDb(row, db);
58
+ }
59
+ }
60
+
61
+ ensureWorkspace();
62
+ // Reconcile the registry against disk *before* deciding whether to bootstrap a
63
+ // first board — otherwise a stale row from a deleted .orbit/ folder makes
64
+ // listBoards() non-empty and we never re-seed.
65
+ ensureSchemaForEveryBoard();
66
+ ensureFirstBoard();
67
+
68
+ export const boardRuntime = {
69
+ rootDir: ROOT_DIR,
70
+ publicDir: PUBLIC_DIR,
71
+ projectRoot: PROJECT_ROOT,
72
+ dataDir: DATA_DIR,
73
+ registryPath: REGISTRY_DB_PATH,
74
+ exportDir: EXPORT_DIR,
75
+ backupDir: BACKUP_DIR,
76
+ port: PORT
77
+ };
78
+
79
+ export function startupSummary() {
80
+ const rows = listBoards();
81
+ return {
82
+ url: `http://localhost:${PORT}`,
83
+ registryPath: REGISTRY_DB_PATH,
84
+ boardCount: rows.length,
85
+ boardPaths: rows.map((row) => row.db_path)
86
+ };
87
+ }
88
+
89
+ export { localOwnerActor, localAgentActor } from "./auth.js";
90
+ export { startSSEStream } from "./events.js";
91
+ export { sendEmpty, sendJson, serveStatic } from "./http.js";
92
+ export { handleApi } from "./router.js";
93
+ export { getBootstrap } from "./bootstrap.js";
94
+ export {
95
+ createBoard,
96
+ createBoardEntry,
97
+ getBoardContext,
98
+ insertBoardEntry,
99
+ updateBoard,
100
+ updateBoardEntry,
101
+ boardManual
102
+ } from "./boards.js";
103
+ export { archiveTicket, createComment, deleteTicket, restoreTicket, updateTicket } from "./tickets.js";
104
+ export { archivedTicketsForBoard } from "./queries.js";
105
+ export { getTicketBlockers, getTicketRelations } from "./relations.js";
106
+ export { checkpointTicket, claimNext, completeTicket, getContextPack, readComments, readTicket } from "./agent.js";
107
+ export { searchTickets } from "./search.js";
108
+ export { exportBoard } from "./snapshots.js";
109
+
110
+ // Helpers for ad-hoc id/slug minting in callers (e.g. MCP tools that mint
111
+ // per-process tokens). Intentionally kept tiny and side-effect-free.
112
+ export const mint = { id, slugify };
@@ -0,0 +1,310 @@
1
+ import { getRegistry, tx } from "./db.js";
2
+ import { boardById } from "./queries.js";
3
+ import {
4
+ computeNewBoardDbPath,
5
+ ensureBoardDbFileAndSchema,
6
+ insertBoard,
7
+ updateBoardMeta
8
+ } from "./registry.js";
9
+ import { requireBoardAccess, requirePermission } from "./auth.js";
10
+ import { recordEvent } from "./events.js";
11
+ import { buildBoardFromRepo } from "./seed.js";
12
+ import { httpError, id, now, normalizePath, normalizeProjectEntryType, requiredString, slugify } from "./util.js";
13
+ import { DATA_DIR, MCP_SERVER_PATH, ROOT_DIR } from "./paths.js";
14
+
15
+ /** Create a brand-new board on disk + register it. Does not require an
16
+ * existing ctx.board / ctx.db: this is registry-only. */
17
+ export function createBoard(body, ctx) {
18
+ const { actor } = ctx;
19
+ requirePermission(actor, "owner");
20
+
21
+ const name = requiredString(body.name, "name");
22
+ const slug = slugify(body.slug || name);
23
+ const repoPath = normalizePath(requiredString(body.repo_path || body.system_path, "repo_path"));
24
+ const reg = getRegistry();
25
+ if (reg.prepare("SELECT id FROM boards WHERE slug = ?").get(slug)) {
26
+ throw httpError(409, "board_slug_taken");
27
+ }
28
+
29
+ const dbPath = computeNewBoardDbPath(repoPath, slug);
30
+ const db = ensureBoardDbFileAndSchema(dbPath);
31
+
32
+ const boardId = id();
33
+ const time = now();
34
+ const sniff = buildBoardFromRepo(repoPath);
35
+
36
+ const defaultStates = body.states || [
37
+ ["Backlog", null],
38
+ ["Todo", null],
39
+ ["In Progress", "in_progress"],
40
+ ["Review", "review"],
41
+ ["Done", "done"],
42
+ ["Cancelled", null]
43
+ ];
44
+
45
+ // Board row + lanes + creation event land atomically in the board DB. The
46
+ // registry insert below is a different database (can't share this
47
+ // transaction); syncRegistryFromBoardDb repairs it idempotently if that
48
+ // second write is ever lost.
49
+ tx(db, () => {
50
+ db.prepare(
51
+ `INSERT INTO boards
52
+ (id, slug, name, repo_url, system_path, default_branch, created_at, updated_at)
53
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
54
+ ).run(
55
+ boardId,
56
+ slug,
57
+ name,
58
+ body.repo_url || sniff.repoUrl || "",
59
+ repoPath,
60
+ body.default_branch || sniff.defaultBranch || "main",
61
+ time,
62
+ time
63
+ );
64
+
65
+ defaultStates.forEach(([stateName, role], position) => {
66
+ db.prepare(
67
+ `INSERT INTO states
68
+ (id, board_id, name, position, is_default, role, created_at)
69
+ VALUES (?, ?, ?, ?, ?, ?, ?)`
70
+ ).run(id(), boardId, stateName, position, stateName === "Todo" ? 1 : 0, role || null, time);
71
+ });
72
+
73
+ recordEvent(db, boardId, "board_created", null, actor.name, { board_id: boardId, slug });
74
+ });
75
+
76
+ insertBoard({
77
+ id: boardId,
78
+ slug,
79
+ name,
80
+ repo_path: repoPath,
81
+ db_path: dbPath,
82
+ repo_url: body.repo_url || sniff.repoUrl || "",
83
+ default_branch: body.default_branch || sniff.defaultBranch || "main",
84
+ last_active_at: time,
85
+ created_at: time,
86
+ updated_at: time
87
+ });
88
+
89
+ return boardById(db, boardId);
90
+ }
91
+
92
+ const AGENT_LANES = [
93
+ { name: "AI Ready", role: "ai_ready" },
94
+ { name: "In Progress", role: "in_progress" },
95
+ { name: "Review", role: "review" }
96
+ ];
97
+
98
+ /** Ensure the three role-bearing lanes that agent flow depends on
99
+ * (ai_ready / in_progress / review) exist on the board. Missing lanes are
100
+ * appended at the end of the lane order. Idempotent. */
101
+ export function ensureAgentLanes(db, boardId) {
102
+ const time = now();
103
+ for (const lane of AGENT_LANES) {
104
+ const existing = db
105
+ .prepare("SELECT id FROM states WHERE board_id = ? AND role = ?")
106
+ .get(boardId, lane.role);
107
+ if (existing) continue;
108
+ const maxPosition = db
109
+ .prepare("SELECT MAX(position) AS value FROM states WHERE board_id = ?")
110
+ .get(boardId).value;
111
+ db.prepare(
112
+ `INSERT INTO states
113
+ (id, board_id, name, position, is_default, role, created_at)
114
+ VALUES (?, ?, ?, ?, 0, ?, ?)`
115
+ ).run(id(), boardId, lane.name, Number(maxPosition || 0) + 1, lane.role, time);
116
+ }
117
+ }
118
+
119
+ export function enableAiCollaboration(db, boardId, options = {}) {
120
+ const innerBoard = boardById(db, boardId);
121
+ if (!innerBoard) throw httpError(404, "board_not_found");
122
+
123
+ const time = now();
124
+ tx(db, () => {
125
+ db.prepare("UPDATE boards SET ai_enabled = 1, updated_at = ? WHERE id = ?").run(time, boardId);
126
+ ensureAgentLanes(db, boardId);
127
+
128
+ if (options.stageOnboardingTicket) {
129
+ const aiReadyLane = db.prepare("SELECT id FROM states WHERE board_id = ? AND role = 'ai_ready'").get(boardId);
130
+ if (aiReadyLane) {
131
+ db.prepare(
132
+ `UPDATE tickets
133
+ SET state_id = ?, updated_at = ?
134
+ WHERE board_id = ?
135
+ AND number = 12
136
+ AND title = 'Try Orbit MCP on this ticket'`
137
+ ).run(aiReadyLane.id, time, boardId);
138
+ }
139
+ }
140
+
141
+ recordEvent(db, boardId, "ai_enabled", null, options.actor || "system", { board_id: boardId });
142
+ });
143
+ return boardById(db, boardId);
144
+ }
145
+
146
+ export function updateBoard(boardId, body, ctx) {
147
+ const { db, board, actor } = ctx;
148
+ if (boardId !== board.id) throw httpError(404, "board_not_found");
149
+ const innerBoard = boardById(db, boardId);
150
+ if (!innerBoard) throw httpError(404, "board_not_found");
151
+ requireBoardAccess(actor, innerBoard);
152
+ requirePermission(actor, "write");
153
+
154
+ const allowed = [
155
+ "name",
156
+ "repo_url",
157
+ "system_path",
158
+ "default_branch",
159
+ "project_notes",
160
+ "agent_instructions"
161
+ ];
162
+ const sets = [];
163
+ const values = [];
164
+
165
+ for (const field of allowed) {
166
+ if (body[field] !== undefined) {
167
+ sets.push(`${field} = ?`);
168
+ values.push(String(body[field] ?? ""));
169
+ }
170
+ }
171
+
172
+ const aiTurningOn = body.ai_enabled !== undefined && Boolean(body.ai_enabled);
173
+ if (body.ai_enabled !== undefined) {
174
+ sets.push("ai_enabled = ?");
175
+ values.push(body.ai_enabled ? 1 : 0);
176
+ }
177
+
178
+ if (sets.length === 0) return innerBoard;
179
+ sets.push("updated_at = ?");
180
+ values.push(now(), boardId);
181
+ tx(db, () => {
182
+ db.prepare(`UPDATE boards SET ${sets.join(", ")} WHERE id = ?`).run(...values);
183
+ if (aiTurningOn) ensureAgentLanes(db, boardId);
184
+ recordEvent(db, boardId, "board_updated", null, actor.name, { board_id: boardId, fields: Object.keys(body) });
185
+ });
186
+
187
+ // Mirror the changed fields into the registry row so cross-board listings
188
+ // stay coherent without needing a second source of truth.
189
+ const after = boardById(db, boardId);
190
+ updateBoardMeta(boardId, {
191
+ name: after.name,
192
+ repo_url: after.repo_url,
193
+ default_branch: after.default_branch,
194
+ repo_path: normalizePath(after.system_path || "")
195
+ });
196
+
197
+ return after;
198
+ }
199
+
200
+ export function getBoardContext(boardId, ctx, options = {}) {
201
+ const { db, board, actor } = ctx;
202
+ if (boardId !== board.id) throw httpError(404, "board_not_found");
203
+ const innerBoard = boardById(db, boardId);
204
+ if (!innerBoard) throw httpError(404, "board_not_found");
205
+ requireBoardAccess(actor, innerBoard);
206
+ return boardManual(boardId, ctx, options);
207
+ }
208
+
209
+ export function boardManual(boardId, ctx, options = {}) {
210
+ const { db } = ctx;
211
+ const innerBoard = boardById(db, boardId);
212
+ if (!innerBoard) throw httpError(404, "board_not_found");
213
+ const includeStruck = Boolean(options.includeStruck);
214
+ const allEntries = db
215
+ .prepare("SELECT * FROM board_entries WHERE board_id = ? ORDER BY created_at DESC")
216
+ .all(boardId);
217
+ const activeEntries = allEntries.filter((entry) => !entry.struck_at);
218
+ const entries = includeStruck ? allEntries : activeEntries;
219
+ return {
220
+ board: {
221
+ id: innerBoard.id,
222
+ slug: innerBoard.slug,
223
+ name: innerBoard.name,
224
+ repo_url: innerBoard.repo_url,
225
+ system_path: innerBoard.system_path || ctx.board.repo_path || "",
226
+ default_branch: innerBoard.default_branch,
227
+ project_notes: innerBoard.project_notes,
228
+ agent_instructions: innerBoard.agent_instructions,
229
+ ai_enabled: innerBoard.ai_enabled,
230
+ created_at: innerBoard.created_at,
231
+ updated_at: innerBoard.updated_at
232
+ },
233
+ entries,
234
+ deployment: {
235
+ app_root: ROOT_DIR,
236
+ system_path: innerBoard.system_path || ctx.board.repo_path || "",
237
+ data_dir: DATA_DIR,
238
+ mcp_server_path: MCP_SERVER_PATH,
239
+ db_path: ctx.board.db_path
240
+ }
241
+ };
242
+ }
243
+
244
+ export function createBoardEntry(boardId, body, ctx) {
245
+ const { db, board, actor } = ctx;
246
+ if (boardId !== board.id) throw httpError(404, "board_not_found");
247
+ const innerBoard = boardById(db, boardId);
248
+ if (!innerBoard) throw httpError(404, "board_not_found");
249
+ requireBoardAccess(actor, innerBoard);
250
+ requirePermission(actor, "write");
251
+
252
+ const entryType = normalizeProjectEntryType(body.type);
253
+ const entryTitle = requiredString(body.title, "title");
254
+ return tx(db, () => {
255
+ const entry = insertBoardEntry(
256
+ db,
257
+ boardId,
258
+ entryType,
259
+ entryTitle,
260
+ body.body || "",
261
+ actor.name,
262
+ body.ticket_id || null
263
+ );
264
+ recordEvent(db, boardId, "board_entry_created", body.ticket_id || null, actor.name, {
265
+ board_id: boardId,
266
+ entry_id: entry.id,
267
+ type: entry.type
268
+ });
269
+ return entry;
270
+ });
271
+ }
272
+
273
+ export function updateBoardEntry(entryId, body, ctx) {
274
+ const { db, board, actor } = ctx;
275
+ const entry = db.prepare("SELECT * FROM board_entries WHERE id = ?").get(entryId);
276
+ if (!entry || entry.board_id !== board.id) throw httpError(404, "board_entry_not_found");
277
+
278
+ const innerBoard = boardById(db, entry.board_id);
279
+ if (!innerBoard) throw httpError(404, "board_not_found");
280
+ requireBoardAccess(actor, innerBoard);
281
+ requirePermission(actor, "write");
282
+
283
+ const shouldStrike = Boolean(body.struck);
284
+ const struckAt = shouldStrike ? entry.struck_at || now() : null;
285
+ const updatedAt = now();
286
+ return tx(db, () => {
287
+ db.prepare("UPDATE board_entries SET struck_at = ?, updated_at = ? WHERE id = ?").run(struckAt, updatedAt, entryId);
288
+ const updated = db.prepare("SELECT * FROM board_entries WHERE id = ?").get(entryId);
289
+ recordEvent(
290
+ db,
291
+ entry.board_id,
292
+ shouldStrike ? "board_entry_struck" : "board_entry_restored",
293
+ entry.ticket_id || null,
294
+ actor.name,
295
+ { board_id: entry.board_id, entry_id: entry.id, type: entry.type }
296
+ );
297
+ return updated;
298
+ });
299
+ }
300
+
301
+ export function insertBoardEntry(db, boardId, type, title, body, createdBy, ticketId = null) {
302
+ const entryId = id();
303
+ const time = now();
304
+ db.prepare(
305
+ `INSERT INTO board_entries
306
+ (id, board_id, type, title, body, ticket_id, created_by, created_at, updated_at)
307
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
308
+ ).run(entryId, boardId, normalizeProjectEntryType(type), title, body || "", ticketId, createdBy, time, time);
309
+ return db.prepare("SELECT * FROM board_entries WHERE id = ?").get(entryId);
310
+ }