@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.
- package/README.md +22 -7
- package/SKILL-ORBIT.md +26 -1
- package/dist/full/index.html +2 -2
- package/dist/full/js/app.a4f045fc.js +676 -0
- package/dist/full/js/app.a4f045fc.js.map +7 -0
- package/dist/full/{styles.14e9dbbd.css → styles.836872e7.css} +836 -39
- package/dist/full/version.json +2 -2
- package/docs/AGENT_PROTOCOL.md +23 -5
- package/docs/MCP_SETUP.md +5 -3
- package/docs/ORBIT_DISPATCH.md +185 -0
- package/docs/setup/orbit-init-wizard.md +174 -0
- package/package.json +2 -2
- package/public/js/actor-labels.js +65 -0
- package/public/js/app.js +8 -1
- package/public/js/card-actions.js +526 -0
- package/public/js/config.js +2 -1
- package/public/js/format.js +204 -10
- package/public/js/kanban.js +15 -0
- package/public/js/local-backend.js +24 -8
- package/public/js/router.js +3 -2
- package/public/js/search.js +6 -3
- package/public/js/settings.js +96 -26
- package/public/js/state.js +50 -0
- package/public/js/ticket-detail.js +428 -125
- package/public/styles.css +836 -39
- package/src/cli/orbit.js +217 -32
- package/src/core/agent.js +114 -24
- package/src/core/agents-md.js +15 -10
- package/src/core/attachments.js +238 -0
- package/src/core/auth.js +36 -0
- package/src/core/backups.js +9 -3
- package/src/core/board.js +32 -1
- package/src/core/boards.js +1 -1
- package/src/core/db.js +42 -2
- package/src/core/delete-board.js +87 -21
- package/src/core/dispatch.js +532 -0
- package/src/core/http.js +31 -11
- package/src/core/migrate.js +198 -0
- package/src/core/paths.js +6 -0
- package/src/core/registry.js +12 -9
- package/src/core/router.js +81 -7
- package/src/core/search.js +83 -10
- package/src/core/snapshots.js +14 -2
- package/src/mcp/orbit-client.js +109 -46
- package/src/mcp-server.js +238 -12
- package/src/server.js +20 -0
- package/dist/full/js/app.f4fd5e94.js +0 -604
- package/dist/full/js/app.f4fd5e94.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
|
}
|
package/src/core/backups.js
CHANGED
|
@@ -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,
|
|
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,
|
|
@@ -103,7 +134,7 @@ export {
|
|
|
103
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
|
-
export { checkpointTicket, claimNext, completeTicket, getContextPack, readComments, readTicket } from "./agent.js";
|
|
137
|
+
export { checkpointTicket, claimNext, completeTicket, getAgentDispatchPacket, getContextPack, readComments, readTicket } from "./agent.js";
|
|
107
138
|
export { searchTickets } from "./search.js";
|
|
108
139
|
export { exportBoard } from "./snapshots.js";
|
|
109
140
|
|
package/src/core/boards.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
34
|
-
|
|
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;
|
package/src/core/delete-board.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { existsSync, rmSync
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
?
|
|
59
|
-
|
|
60
|
-
|
|
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)
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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);
|