@starlove/orbit 1.0.6 → 1.1.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.
Files changed (58) hide show
  1. package/README.md +22 -7
  2. package/SKILL-ORBIT.md +28 -1
  3. package/build.mjs +2 -2
  4. package/dist/full/index.html +2 -2
  5. package/dist/full/js/app.a4f045fc.js +676 -0
  6. package/dist/full/js/app.a4f045fc.js.map +7 -0
  7. package/dist/full/{styles.c2e3d20a.css → styles.836872e7.css} +1117 -47
  8. package/dist/full/version.json +2 -2
  9. package/docs/AGENT_PROTOCOL.md +27 -5
  10. package/docs/MCP_SETUP.md +7 -3
  11. package/docs/ORBIT_DISPATCH.md +185 -0
  12. package/docs/setup/orbit-init-wizard.md +174 -0
  13. package/package.json +2 -2
  14. package/public/js/actor-labels.js +65 -0
  15. package/public/js/api.js +5 -1
  16. package/public/js/app.js +8 -1
  17. package/public/js/card-actions.js +526 -0
  18. package/public/js/config.js +2 -1
  19. package/public/js/drawer.js +2 -0
  20. package/public/js/format.js +336 -0
  21. package/public/js/kanban.js +25 -2
  22. package/public/js/local-backend.js +24 -8
  23. package/public/js/main.js +14 -6
  24. package/public/js/motion-preference.js +53 -0
  25. package/public/js/router.js +78 -58
  26. package/public/js/search.js +6 -3
  27. package/public/js/settings-tabs.js +1 -1
  28. package/public/js/settings.js +192 -29
  29. package/public/js/starscape.js +25 -18
  30. package/public/js/state.js +61 -2
  31. package/public/js/theme-preference.js +88 -0
  32. package/public/js/ticket-detail.js +432 -126
  33. package/public/js/url-routes.js +94 -0
  34. package/public/styles.css +1117 -47
  35. package/src/cli/orbit.js +217 -32
  36. package/src/core/agent.js +1 -18
  37. package/src/core/agents-md.js +16 -10
  38. package/src/core/attachments.js +238 -0
  39. package/src/core/auth.js +36 -0
  40. package/src/core/backups.js +9 -3
  41. package/src/core/board.js +32 -1
  42. package/src/core/boards.js +1 -1
  43. package/src/core/bootstrap.js +9 -1
  44. package/src/core/db.js +42 -2
  45. package/src/core/delete-board.js +87 -21
  46. package/src/core/dispatch.js +532 -0
  47. package/src/core/http.js +31 -11
  48. package/src/core/migrate.js +198 -0
  49. package/src/core/paths.js +6 -0
  50. package/src/core/registry.js +12 -9
  51. package/src/core/router.js +62 -6
  52. package/src/core/search.js +56 -22
  53. package/src/core/snapshots.js +14 -2
  54. package/src/mcp/orbit-client.js +223 -0
  55. package/src/mcp-server.js +89 -129
  56. package/src/server.js +20 -0
  57. package/dist/full/js/app.aedc1bfe.js +0 -554
  58. package/dist/full/js/app.aedc1bfe.js.map +0 -7
@@ -0,0 +1,238 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { dirname, extname, join, normalize, sep } from "node:path";
4
+ import { boardById, ticketById } from "./queries.js";
5
+ import { requireBoardAccess, requirePermission } from "./auth.js";
6
+ import { tx } from "./db.js";
7
+ import { recordEvent } from "./events.js";
8
+ import { bumpTicketUpdatedAt } from "./tickets.js";
9
+ import { httpError, id, now } from "./util.js";
10
+
11
+ export const MAX_IMAGE_ATTACHMENT_BYTES = 10 * 1024 * 1024;
12
+
13
+ const IMAGE_EXTENSIONS = new Map([
14
+ ["image/png", ".png"],
15
+ ["image/jpeg", ".jpg"],
16
+ ["image/gif", ".gif"],
17
+ ["image/webp", ".webp"],
18
+ ["image/svg+xml", ".svg"],
19
+ ["image/bmp", ".bmp"],
20
+ ["image/avif", ".avif"]
21
+ ]);
22
+
23
+ export function attachmentStorageRoot(boardRow) {
24
+ return join(dirname(boardRow.db_path), "attachments");
25
+ }
26
+
27
+ function ticketAttachmentDir(boardRow, ticketId) {
28
+ return join(attachmentStorageRoot(boardRow), "tickets", ticketId);
29
+ }
30
+
31
+ function safeOriginalName(value) {
32
+ const name = String(value || "image").replace(/[\0\r\n]/g, "").trim();
33
+ return name.slice(0, 240) || "image";
34
+ }
35
+
36
+ function normalizeImageMime(value) {
37
+ const mime = String(value || "").split(";")[0].trim().toLowerCase();
38
+ if (!IMAGE_EXTENSIONS.has(mime)) throw httpError(415, "unsupported_attachment_type");
39
+ return mime;
40
+ }
41
+
42
+ function extensionFor(mime, originalName = "") {
43
+ const originalExt = extname(originalName).toLowerCase();
44
+ if ([...IMAGE_EXTENSIONS.values()].includes(originalExt)) return originalExt;
45
+ return IMAGE_EXTENSIONS.get(mime) || ".img";
46
+ }
47
+
48
+ function relativeStoredPath(ticketId, attachmentId, mime, originalName) {
49
+ return normalize(join("tickets", ticketId, `${attachmentId}${extensionFor(mime, originalName)}`)).replace(/\\/g, "/");
50
+ }
51
+
52
+ function resolveStoredPath(boardRow, storedPath) {
53
+ const root = attachmentStorageRoot(boardRow);
54
+ const abs = join(root, storedPath);
55
+ const normalizedRoot = normalize(root + sep);
56
+ const normalizedAbs = normalize(abs);
57
+ if (!normalizedAbs.startsWith(normalizedRoot)) throw httpError(400, "invalid_attachment_path");
58
+ return normalizedAbs;
59
+ }
60
+
61
+ function assertTicket(ctx, ticketId, write = false) {
62
+ const { db, board, actor } = ctx;
63
+ const ticket = ticketById(db, ticketId);
64
+ if (!ticket || ticket.board_id !== board.id) throw httpError(404, "ticket_not_found");
65
+ requireBoardAccess(actor, boardById(db, ticket.board_id));
66
+ if (write) requirePermission(actor, "write");
67
+ return ticket;
68
+ }
69
+
70
+ function rowToAttachment(boardRow, row) {
71
+ const path = resolveStoredPath(boardRow, row.stored_path);
72
+ let missing = true;
73
+ let size = row.size_bytes;
74
+ try {
75
+ const stat = statSync(path);
76
+ missing = !stat.isFile();
77
+ if (!missing) size = stat.size;
78
+ } catch {
79
+ missing = true;
80
+ }
81
+ return {
82
+ id: row.id,
83
+ ticket_id: row.ticket_id,
84
+ board_id: row.board_id,
85
+ original_name: row.original_name,
86
+ mime_type: row.mime_type,
87
+ size_bytes: row.size_bytes,
88
+ sha256: row.sha256,
89
+ stored_path: row.stored_path,
90
+ created_by: row.created_by,
91
+ created_at: row.created_at,
92
+ missing,
93
+ content_url: missing ? null : `/api/tickets/${encodeURIComponent(row.ticket_id)}/attachments/${encodeURIComponent(row.id)}/content`,
94
+ filesystem_size_bytes: missing ? null : size
95
+ };
96
+ }
97
+
98
+ export function listTicketAttachments(ticketId, ctx) {
99
+ assertTicket(ctx, ticketId);
100
+ const rows = ctx.db
101
+ .prepare("SELECT * FROM ticket_attachments WHERE ticket_id = ? ORDER BY created_at ASC")
102
+ .all(ticketId);
103
+ return { attachments: rows.map((row) => rowToAttachment(ctx.board, row)) };
104
+ }
105
+
106
+ export function getTicketAttachmentContent(ticketId, attachmentId, ctx) {
107
+ assertTicket(ctx, ticketId);
108
+ const row = ctx.db
109
+ .prepare("SELECT * FROM ticket_attachments WHERE id = ? AND ticket_id = ?")
110
+ .get(attachmentId, ticketId);
111
+ if (!row) throw httpError(404, "attachment_not_found");
112
+ const filePath = resolveStoredPath(ctx.board, row.stored_path);
113
+ if (!existsSync(filePath)) throw httpError(404, "attachment_file_missing");
114
+ return { row, filePath };
115
+ }
116
+
117
+ export function createTicketAttachment(ticketId, input, ctx) {
118
+ const ticket = assertTicket(ctx, ticketId, true);
119
+ const mime = normalizeImageMime(input.mime_type);
120
+ const bytes = Buffer.isBuffer(input.bytes) ? input.bytes : Buffer.from(input.bytes || []);
121
+ if (bytes.length === 0) throw httpError(400, "empty_attachment");
122
+ if (bytes.length > MAX_IMAGE_ATTACHMENT_BYTES) throw httpError(413, "attachment_too_large");
123
+ const originalName = safeOriginalName(input.original_name);
124
+ const attachmentId = id();
125
+ const storedPath = relativeStoredPath(ticketId, attachmentId, mime, originalName);
126
+ const filePath = resolveStoredPath(ctx.board, storedPath);
127
+ const hash = createHash("sha256").update(bytes).digest("hex");
128
+ const time = now();
129
+
130
+ mkdirSync(ticketAttachmentDir(ctx.board, ticketId), { recursive: true });
131
+ writeFileSync(filePath, bytes, { flag: "wx" });
132
+
133
+ try {
134
+ return tx(ctx.db, () => {
135
+ ctx.db.prepare(
136
+ `INSERT INTO ticket_attachments
137
+ (id, ticket_id, board_id, original_name, stored_path, mime_type, size_bytes, sha256, created_by, created_at)
138
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
139
+ ).run(attachmentId, ticketId, ticket.board_id, originalName, storedPath, mime, bytes.length, hash, ctx.actor.name, time);
140
+ ctx.db.prepare("UPDATE tickets SET updated_at = ? WHERE id = ?").run(time, ticketId);
141
+ recordEvent(ctx.db, ticket.board_id, "attachment_created", ticketId, ctx.actor.name, {
142
+ attachment_id: attachmentId,
143
+ original_name: originalName,
144
+ mime_type: mime,
145
+ size_bytes: bytes.length,
146
+ sha256: hash
147
+ });
148
+ bumpTicketUpdatedAt(ctx.db, ticket.parent_ticket_id, time);
149
+ return rowToAttachment(ctx.board, ctx.db.prepare("SELECT * FROM ticket_attachments WHERE id = ?").get(attachmentId));
150
+ });
151
+ } catch (error) {
152
+ try { unlinkSync(filePath); } catch {}
153
+ throw error;
154
+ }
155
+ }
156
+
157
+ export function deleteTicketAttachment(ticketId, attachmentId, ctx) {
158
+ const ticket = assertTicket(ctx, ticketId, true);
159
+ const row = ctx.db
160
+ .prepare("SELECT * FROM ticket_attachments WHERE id = ? AND ticket_id = ?")
161
+ .get(attachmentId, ticketId);
162
+ if (!row) throw httpError(404, "attachment_not_found");
163
+ const filePath = resolveStoredPath(ctx.board, row.stored_path);
164
+ return tx(ctx.db, () => {
165
+ ctx.db.prepare("DELETE FROM ticket_attachments WHERE id = ?").run(attachmentId);
166
+ const time = now();
167
+ ctx.db.prepare("UPDATE tickets SET updated_at = ? WHERE id = ?").run(time, ticketId);
168
+ recordEvent(ctx.db, ticket.board_id, "attachment_deleted", ticketId, ctx.actor.name, {
169
+ attachment_id: attachmentId,
170
+ original_name: row.original_name
171
+ });
172
+ bumpTicketUpdatedAt(ctx.db, ticket.parent_ticket_id, time);
173
+ try { unlinkSync(filePath); } catch {}
174
+ return { ok: true, deleted_id: attachmentId };
175
+ });
176
+ }
177
+
178
+ export function attachmentRowsForBoard(db, boardId) {
179
+ return db
180
+ .prepare("SELECT * FROM ticket_attachments WHERE board_id = ? ORDER BY created_at ASC")
181
+ .all(boardId);
182
+ }
183
+
184
+ export function serializeAttachmentForExport(boardRow, row, includeBytes = false) {
185
+ const attachment = rowToAttachment(boardRow, row);
186
+ if (!includeBytes || attachment.missing) {
187
+ return { ...attachment, data_base64: null, included: false };
188
+ }
189
+ const filePath = resolveStoredPath(boardRow, row.stored_path);
190
+ const data = readFileSync(filePath);
191
+ return {
192
+ ...attachment,
193
+ data_base64: data.toString("base64"),
194
+ included: true,
195
+ missing: false
196
+ };
197
+ }
198
+
199
+ export function importAttachmentRows(snapshot, ctx, targetBoardId) {
200
+ const rows = Array.isArray(snapshot.attachments) ? snapshot.attachments : [];
201
+ for (const attachment of rows) {
202
+ const attachmentId = attachment.id || id();
203
+ const ticketId = attachment.ticket_id;
204
+ if (!ticketId || !ctx.db.prepare("SELECT 1 FROM tickets WHERE id = ?").get(ticketId)) continue;
205
+ const mime = normalizeImageMime(attachment.mime_type || "image/png");
206
+ const originalName = safeOriginalName(attachment.original_name);
207
+ const storedPath = relativeStoredPath(ticketId, attachmentId, mime, originalName);
208
+ let size = Number(attachment.size_bytes || 0);
209
+ let sha = String(attachment.sha256 || "");
210
+
211
+ if (attachment.data_base64) {
212
+ const bytes = Buffer.from(String(attachment.data_base64), "base64");
213
+ if (bytes.length > MAX_IMAGE_ATTACHMENT_BYTES) throw httpError(413, "attachment_too_large");
214
+ size = bytes.length;
215
+ sha = createHash("sha256").update(bytes).digest("hex");
216
+ const filePath = resolveStoredPath(ctx.board, storedPath);
217
+ mkdirSync(dirname(filePath), { recursive: true });
218
+ writeFileSync(filePath, bytes);
219
+ }
220
+
221
+ ctx.db.prepare(
222
+ `INSERT OR IGNORE INTO ticket_attachments
223
+ (id, ticket_id, board_id, original_name, stored_path, mime_type, size_bytes, sha256, created_by, created_at)
224
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
225
+ ).run(
226
+ attachmentId,
227
+ ticketId,
228
+ targetBoardId,
229
+ originalName,
230
+ storedPath,
231
+ mime,
232
+ size,
233
+ sha,
234
+ attachment.created_by || "import",
235
+ attachment.created_at || now()
236
+ );
237
+ }
238
+ }
package/src/core/auth.js CHANGED
@@ -20,6 +20,42 @@ export function localAgentActor() {
20
20
  };
21
21
  }
22
22
 
23
+ function headerValue(req, name) {
24
+ const value = req?.headers?.[name.toLowerCase()];
25
+ if (Array.isArray(value)) return value[0] || "";
26
+ return String(value || "");
27
+ }
28
+
29
+ function cleanActorField(value, fallback) {
30
+ const clean = String(value || "")
31
+ .trim()
32
+ .replace(/\s+/g, " ")
33
+ .slice(0, 80);
34
+ return clean || fallback;
35
+ }
36
+
37
+ function actorIdFromName(name) {
38
+ return String(name || "agent")
39
+ .toLowerCase()
40
+ .replace(/[^a-z0-9]+/g, "-")
41
+ .replace(/^-+|-+$/g, "")
42
+ .slice(0, 80) || "agent";
43
+ }
44
+
45
+ export function actorFromHttpRequest(req) {
46
+ const actorType = headerValue(req, "x-orbit-actor-type").trim().toLowerCase();
47
+ const agentName = headerValue(req, "x-orbit-agent-name");
48
+ if (actorType !== "agent" && !agentName.trim()) return localOwnerActor();
49
+
50
+ const name = cleanActorField(headerValue(req, "x-orbit-actor-name") || agentName, "agent");
51
+ const id = cleanActorField(headerValue(req, "x-orbit-actor-id"), actorIdFromName(name));
52
+ return {
53
+ ...localAgentActor(),
54
+ id,
55
+ name
56
+ };
57
+ }
58
+
23
59
  export function authenticate() {
24
60
  return localOwnerActor();
25
61
  }
@@ -1,7 +1,7 @@
1
1
  import { existsSync, mkdirSync, readdirSync, unlinkSync, writeFileSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
- import { getRegistry } from "./db.js";
4
- import { BACKUP_DIR } from "./paths.js";
3
+ import { closeConnection, getRegistry, hasConnection, openConnection } from "./db.js";
4
+ import { BACKUP_DIR, REGISTRY_DB_PATH } from "./paths.js";
5
5
  import { now } from "./util.js";
6
6
 
7
7
  const DEFAULT_RETENTION = 30;
@@ -100,11 +100,17 @@ export function scheduleAutomaticBoardBackup(boardRow, db) {
100
100
 
101
101
  const timer = setTimeout(() => {
102
102
  scheduledBoardBackups.delete(boardRow.id);
103
+ const hadBoardConnection = hasConnection(boardRow.db_path);
104
+ const hadRegistryConnection = hasConnection(REGISTRY_DB_PATH);
105
+ const backupDb = openConnection(boardRow.db_path);
103
106
  try {
104
- backupBoardDatabase(boardRow, db, "auto-write");
107
+ backupBoardDatabase(boardRow, backupDb, "auto-write");
105
108
  backupRegistry("auto-write");
106
109
  } catch (error) {
107
110
  console.warn(`[orbit] Automatic write backup failed for "${boardRow.slug}": ${error.message}`);
111
+ } finally {
112
+ if (!hadBoardConnection) closeConnection(boardRow.db_path);
113
+ if (!hadRegistryConnection) closeConnection(REGISTRY_DB_PATH);
108
114
  }
109
115
  }, automaticDelayMs());
110
116
  timer.unref?.();
package/src/core/board.js CHANGED
@@ -8,16 +8,21 @@
8
8
  // 3. Iterate every registry board, ensuring the per-board schema exists
9
9
  // and reindexing FTS for that board's DB. No "active board" singleton:
10
10
  // each iteration opens its own connection.
11
+ // 4. When launched through `orbit serve`, refresh Orbit-managed
12
+ // SKILL-ORBIT.md copies for registered repo paths.
11
13
  //
12
14
  // Beyond startup this module just re-exports per-domain functions for
13
15
  // `src/server.js` and `src/mcp-server.js` to consume.
14
16
 
15
17
  import { existsSync, mkdirSync } from "node:fs";
18
+ import { resolve } from "node:path";
16
19
  import "./paths.js";
20
+ import { syncSkillOrbitMd } from "./agents-md.js";
17
21
  import { createBoardSchema, createRegistrySchema } from "./db.js";
18
22
  import { reindexAllTickets } from "./tickets.js";
19
23
  import { deleteBoard, listBoards, openBoardDb, syncRegistryFromBoardDb } from "./registry.js";
20
24
  import { provisionRepoBoard } from "./provision-repo-board.js";
25
+ import { migrateInRepoBoards } from "./migrate.js";
21
26
  import { id, slugify } from "./util.js";
22
27
  import {
23
28
  DATA_DIR,
@@ -58,12 +63,38 @@ function ensureSchemaForEveryBoard() {
58
63
  }
59
64
  }
60
65
 
66
+ function syncManagedSkillOrbitFiles() {
67
+ const skillSrc = resolve(ROOT_DIR, "SKILL-ORBIT.md");
68
+ for (const row of listBoards()) {
69
+ if (!row.repo_path) continue;
70
+ try {
71
+ const result = syncSkillOrbitMd(row.repo_path, skillSrc);
72
+ if (result.status === "missing_source") {
73
+ console.warn(`[orbit] Missing managed SKILL-ORBIT.md source at ${skillSrc}; skipped repo guidance refresh.`);
74
+ return;
75
+ }
76
+ if (result.status === "written") {
77
+ console.log(`[orbit] Refreshed managed SKILL-ORBIT.md in ${row.repo_path}`);
78
+ }
79
+ } catch (error) {
80
+ console.warn(`[orbit] Could not refresh SKILL-ORBIT.md in ${row.repo_path}: ${error.message}`);
81
+ }
82
+ }
83
+ }
84
+
61
85
  ensureWorkspace();
86
+ // Move any per-board SQLite files that still live in in-repo .orbit/ directories
87
+ // into the central DATA_DIR/boards/<slug>/ store. Must run before
88
+ // ensureSchemaForEveryBoard so all connections open against the canonical paths.
89
+ migrateInRepoBoards();
62
90
  // Reconcile the registry against disk *before* deciding whether to bootstrap a
63
91
  // first board — otherwise a stale row from a deleted .orbit/ folder makes
64
92
  // listBoards() non-empty and we never re-seed.
65
93
  ensureSchemaForEveryBoard();
66
94
  ensureFirstBoard();
95
+ if (process.env.ORBIT_SYNC_MANAGED_SKILL_ORBIT === "1") {
96
+ syncManagedSkillOrbitFiles();
97
+ }
67
98
 
68
99
  export const boardRuntime = {
69
100
  rootDir: ROOT_DIR,
@@ -100,7 +131,7 @@ export {
100
131
  updateBoardEntry,
101
132
  boardManual
102
133
  } from "./boards.js";
103
- export { archiveTicket, createComment, deleteTicket, restoreTicket, updateTicket } from "./tickets.js";
134
+ export { archiveTicket, createComment, createTicket, deleteTicket, restoreTicket, updateTicket } from "./tickets.js";
104
135
  export { archivedTicketsForBoard } from "./queries.js";
105
136
  export { getTicketBlockers, getTicketRelations } from "./relations.js";
106
137
  export { checkpointTicket, claimNext, completeTicket, getContextPack, readComments, readTicket } from "./agent.js";
@@ -222,7 +222,7 @@ export function updateBoard(boardId, body, ctx) {
222
222
  for (const field of allowed) {
223
223
  if (body[field] !== undefined) {
224
224
  sets.push(`${field} = ?`);
225
- values.push(String(body[field] ?? ""));
225
+ values.push(field === "name" ? requiredString(body[field], "name") : String(body[field] ?? ""));
226
226
  }
227
227
  }
228
228
 
@@ -33,8 +33,9 @@ function boardSummaryFromRegistry(r) {
33
33
  *
34
34
  * @param actor authenticated actor
35
35
  * @param requestedBoardId optional board id from `?board_id=` (or null)
36
+ * @param requestedBoardSlug optional board slug from `?board=` (or null)
36
37
  */
37
- export function getBootstrap(actor, requestedBoardId = null) {
38
+ export function getBootstrap(actor, requestedBoardId = null, requestedBoardSlug = null) {
38
39
  const registryRows = listBoards();
39
40
  const accessible = registryRows.filter((row) => canAccessBoard(actor, { slug: row.slug }));
40
41
 
@@ -57,6 +58,9 @@ export function getBootstrap(actor, requestedBoardId = null) {
57
58
  if (requestedBoardId) {
58
59
  chosen = accessible.find((row) => row.id === requestedBoardId) || null;
59
60
  }
61
+ if (!chosen && requestedBoardSlug) {
62
+ chosen = accessible.find((row) => row.slug === requestedBoardSlug) || null;
63
+ }
60
64
  if (!chosen) {
61
65
  const defaultRow = pickDefaultBoard();
62
66
  if (defaultRow && accessible.some((row) => row.id === defaultRow.id)) {
@@ -143,6 +147,10 @@ export function getBootstrap(actor, requestedBoardId = null) {
143
147
 
144
148
  return {
145
149
  actor: publicActor(actor),
150
+ // The server may choose a default board that is not first in the registry's
151
+ // alphabetic board list. Expose that choice explicitly so first-page load
152
+ // selects the same board whose lanes/tickets are included below.
153
+ active_board_id: chosenRow.id,
146
154
  meta: {
147
155
  db_path: chosenRow.db_path,
148
156
  export_dir: EXPORT_DIR
package/src/core/db.js CHANGED
@@ -30,8 +30,32 @@ export function openConnection(dbPath) {
30
30
  export function closeConnection(dbPath) {
31
31
  const conn = connections.get(dbPath);
32
32
  if (!conn) return;
33
- conn.close();
34
- connections.delete(dbPath);
33
+ try {
34
+ conn.exec("PRAGMA wal_checkpoint(TRUNCATE);");
35
+ } catch {
36
+ // Another process may still be attached; closing this connection is enough
37
+ // to release our file handle.
38
+ }
39
+ try {
40
+ conn.close();
41
+ } finally {
42
+ connections.delete(dbPath);
43
+ }
44
+ }
45
+
46
+ export function hasConnection(dbPath) {
47
+ return connections.has(dbPath);
48
+ }
49
+
50
+ export function closeAllConnections() {
51
+ for (const dbPath of [...connections.keys()]) {
52
+ try {
53
+ closeConnection(dbPath);
54
+ } catch {
55
+ // Shutdown cleanup should release every handle it can without masking
56
+ // the original exit path.
57
+ }
58
+ }
35
59
  }
36
60
 
37
61
  // Reentrancy depth per connection. Only the outermost tx() issues
@@ -155,6 +179,19 @@ export function createBoardSchema(db) {
155
179
  created_at TEXT NOT NULL
156
180
  );
157
181
 
182
+ CREATE TABLE IF NOT EXISTS ticket_attachments (
183
+ id TEXT PRIMARY KEY,
184
+ ticket_id TEXT NOT NULL REFERENCES tickets(id) ON DELETE CASCADE,
185
+ board_id TEXT NOT NULL REFERENCES boards(id) ON DELETE CASCADE,
186
+ original_name TEXT NOT NULL,
187
+ stored_path TEXT NOT NULL,
188
+ mime_type TEXT NOT NULL,
189
+ size_bytes INTEGER NOT NULL,
190
+ sha256 TEXT NOT NULL,
191
+ created_by TEXT NOT NULL DEFAULT 'human',
192
+ created_at TEXT NOT NULL
193
+ );
194
+
158
195
  CREATE TABLE IF NOT EXISTS board_entries (
159
196
  id TEXT PRIMARY KEY,
160
197
  board_id TEXT NOT NULL REFERENCES boards(id) ON DELETE CASCADE,
@@ -214,6 +251,8 @@ export function createBoardSchema(db) {
214
251
  CREATE INDEX IF NOT EXISTS idx_tickets_archived ON tickets(board_id, archived_at);
215
252
  CREATE INDEX IF NOT EXISTS idx_tickets_parent ON tickets(parent_ticket_id);
216
253
  CREATE INDEX IF NOT EXISTS idx_comments_ticket ON comments(ticket_id, created_at);
254
+ CREATE INDEX IF NOT EXISTS idx_ticket_attachments_ticket ON ticket_attachments(ticket_id, created_at);
255
+ CREATE INDEX IF NOT EXISTS idx_ticket_attachments_board ON ticket_attachments(board_id, created_at);
217
256
  CREATE INDEX IF NOT EXISTS idx_board_entries_board ON board_entries(board_id, type, created_at);
218
257
  CREATE INDEX IF NOT EXISTS idx_relations_source ON relations(source_ticket_id);
219
258
  CREATE INDEX IF NOT EXISTS idx_relations_target ON relations(target_ticket_id);
@@ -227,6 +266,7 @@ export function resetBoard(db) {
227
266
  DELETE FROM ticket_labels;
228
267
  DELETE FROM relations;
229
268
  DELETE FROM comments;
269
+ DELETE FROM ticket_attachments;
230
270
  DELETE FROM board_entries;
231
271
  DELETE FROM events;
232
272
  DELETE FROM tickets;
@@ -1,9 +1,10 @@
1
- import { existsSync, rmSync, unlinkSync } from "node:fs";
1
+ import { existsSync, rmSync } from "node:fs";
2
2
  import { basename, dirname, join, relative } from "node:path";
3
3
  import { removeOrbitAgentsSection } from "./agents-md.js";
4
4
  import { requireBoardAccess } from "./auth.js";
5
5
  import { backupBoardDatabase, backupRegistry, cancelAutomaticBoardBackup } from "./backups.js";
6
6
  import { closeConnection } from "./db.js";
7
+ import { BOARDS_DIR, DATA_DIR, DISPATCH_RUNS_DIR } from "./paths.js";
7
8
  import { deleteBoard, listBoards, openBoardDb } from "./registry.js";
8
9
  import { httpError, normalizePath } from "./util.js";
9
10
 
@@ -13,17 +14,31 @@ function isInside(parent, child) {
13
14
  }
14
15
 
15
16
  function boardDbDeleteTargets(boardRow) {
16
- const repoRoot = normalizePath(boardRow.repo_path || "");
17
17
  const dbPath = normalizePath(boardRow.db_path || "");
18
- const orbitRoot = normalizePath(join(repoRoot, ".orbit"));
19
- if (!repoRoot || !dbPath || !isInside(orbitRoot, dbPath) || basename(dbPath) !== "board.db") {
18
+ if (!dbPath || basename(dbPath) !== "board.db") {
20
19
  throw httpError(409, "unsafe_board_db_path");
21
20
  }
22
21
 
22
+ // Central storage: db lives inside DATA_DIR/boards/<slug>/board.db
23
+ const boardsDirNorm = normalizePath(BOARDS_DIR);
24
+ if (dbPath.startsWith(boardsDirNorm + "/")) {
25
+ const boardDir = dirname(dbPath);
26
+ // Safety: boardDir must be exactly one level below BOARDS_DIR
27
+ if (normalizePath(dirname(boardDir)) !== boardsDirNorm) {
28
+ throw httpError(409, "unsafe_board_db_path");
29
+ }
30
+ return [boardDir];
31
+ }
32
+
33
+ // Legacy in-repo storage: db lives inside <repo>/.orbit/
34
+ const repoRoot = normalizePath(boardRow.repo_path || "");
35
+ const orbitRoot = normalizePath(join(repoRoot, ".orbit"));
36
+ if (!repoRoot || !isInside(orbitRoot, dbPath)) {
37
+ throw httpError(409, "unsafe_board_db_path");
38
+ }
23
39
  const boardDir = dirname(dbPath);
24
40
  const boardsRoot = normalizePath(join(orbitRoot, "boards"));
25
- const removeBoardDir = isInside(boardsRoot, boardDir);
26
- if (removeBoardDir) return [boardDir];
41
+ if (isInside(boardsRoot, boardDir)) return [boardDir];
27
42
  return [dbPath, `${dbPath}-wal`, `${dbPath}-shm`, `${dbPath}-journal`];
28
43
  }
29
44
 
@@ -35,16 +50,49 @@ function isLastBoardForRepo(boardRow) {
35
50
  function repoArtifactTargets(boardRow) {
36
51
  const repoRoot = normalizePath(boardRow.repo_path || "");
37
52
  const dbPath = normalizePath(boardRow.db_path || "");
38
- const orbitRoot = normalizePath(join(repoRoot, ".orbit"));
39
- if (!repoRoot || !dbPath || !isInside(orbitRoot, dbPath) || basename(dbPath) !== "board.db") {
53
+ if (!repoRoot || !dbPath || basename(dbPath) !== "board.db") {
40
54
  throw httpError(409, "unsafe_board_db_path");
41
55
  }
42
56
  return {
43
- orbitRoot,
57
+ orbitRoot: normalizePath(join(repoRoot, ".orbit")),
44
58
  skillMd: join(repoRoot, "SKILL-ORBIT.md")
45
59
  };
46
60
  }
47
61
 
62
+ function removeOptions(recursive = false) {
63
+ const options = { force: true };
64
+ if (recursive) options.recursive = true;
65
+ if (process.platform === "win32") {
66
+ options.maxRetries = 10;
67
+ options.retryDelay = 100;
68
+ }
69
+ return options;
70
+ }
71
+
72
+ function isBusyFilesystemError(error) {
73
+ const message = String(error?.message || "").toLowerCase();
74
+ return (
75
+ ["EBUSY", "EPERM", "ENOTEMPTY"].includes(error?.code) ||
76
+ (error?.code === "ERR_SQLITE_ERROR" && (message.includes("locked") || message.includes("busy")))
77
+ );
78
+ }
79
+
80
+ function boardFilesBusyError(error, boardRow) {
81
+ if (!isBusyFilesystemError(error)) return error;
82
+ const wrapped = new Error(
83
+ [
84
+ `Orbit could not remove local board files for "${boardRow.slug}".`,
85
+ "On Windows this usually means an Orbit web server, MCP helper, AI client, editor, or terminal still has .orbit/board.db open.",
86
+ "Close those processes, then retry the delete from Settings or run orbit reset --cwd <repo>.",
87
+ `Original error: ${error.message}`
88
+ ].join("\n")
89
+ );
90
+ wrapped.status = 409;
91
+ wrapped.code = "board_files_busy";
92
+ wrapped.cause = error;
93
+ return wrapped;
94
+ }
95
+
48
96
  export function deleteRegisteredBoard(boardRow, body, actor) {
49
97
  requireBoardAccess(actor, { slug: boardRow.slug });
50
98
 
@@ -54,26 +102,44 @@ export function deleteRegisteredBoard(boardRow, body, actor) {
54
102
  const deleteFiles = body?.delete_files !== false;
55
103
  const removeRepoArtifacts = deleteFiles && isLastBoardForRepo(boardRow);
56
104
  const repoArtifacts = removeRepoArtifacts ? repoArtifactTargets(boardRow) : null;
105
+ // Always delete the board's own db files/directory; when it is the last board
106
+ // for a repo also sweep in-repo artifacts (.orbit/, SKILL-ORBIT.md, AGENTS.md).
107
+ // Also remove the board's central dispatch-runs directory.
108
+ const dispatchRunsDir = join(DISPATCH_RUNS_DIR, boardRow.slug);
57
109
  const targets = deleteFiles
58
- ? removeRepoArtifacts
59
- ? [repoArtifacts.orbitRoot]
60
- : boardDbDeleteTargets(boardRow)
110
+ ? [
111
+ ...boardDbDeleteTargets(boardRow),
112
+ ...(repoArtifacts ? [repoArtifacts.orbitRoot] : []),
113
+ ...(existsSync(dispatchRunsDir) ? [dispatchRunsDir] : [])
114
+ ]
61
115
  : [];
62
116
  const artifactResults = [];
63
117
  cancelAutomaticBoardBackup(boardRow.id);
64
- if (deleteFiles) backupBoardDatabase(boardRow, openBoardDb(boardRow), "pre-board-delete");
118
+ if (deleteFiles) {
119
+ try {
120
+ backupBoardDatabase(boardRow, openBoardDb(boardRow), "pre-board-delete");
121
+ } catch (error) {
122
+ throw boardFilesBusyError(error, boardRow);
123
+ } finally {
124
+ closeConnection(boardRow.db_path);
125
+ }
126
+ }
65
127
  backupRegistry("pre-board-delete");
66
128
  closeConnection(boardRow.db_path);
67
129
 
68
- for (const target of targets) {
69
- if (existsSync(target)) {
70
- rmSync(target, { recursive: true, force: true });
71
- artifactResults.push({ path: target, removed: true });
130
+ try {
131
+ for (const target of targets) {
132
+ if (existsSync(target)) {
133
+ rmSync(target, removeOptions(true));
134
+ artifactResults.push({ path: target, removed: true });
135
+ }
72
136
  }
73
- }
74
- if (repoArtifacts?.skillMd && existsSync(repoArtifacts.skillMd)) {
75
- unlinkSync(repoArtifacts.skillMd);
76
- artifactResults.push({ path: repoArtifacts.skillMd, removed: true });
137
+ if (repoArtifacts?.skillMd && existsSync(repoArtifacts.skillMd)) {
138
+ rmSync(repoArtifacts.skillMd, removeOptions(false));
139
+ artifactResults.push({ path: repoArtifacts.skillMd, removed: true });
140
+ }
141
+ } catch (error) {
142
+ throw boardFilesBusyError(error, boardRow);
77
143
  }
78
144
  if (removeRepoArtifacts) {
79
145
  const agentsCleanup = removeOrbitAgentsSection(boardRow.repo_path);