@starlove/orbit 1.0.7 → 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.
- 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 +1 -18
- 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 +31 -0
- 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 +61 -5
- package/src/core/search.js +3 -3
- package/src/core/snapshots.js +14 -2
- package/src/mcp/orbit-client.js +81 -38
- package/src/mcp-server.js +190 -6
- 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,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* migrate.js — one-time startup migration of per-board SQLite files from
|
|
3
|
+
* in-repo .orbit/ directories into the central DATA_DIR/boards/<slug>/ store.
|
|
4
|
+
*
|
|
5
|
+
* Called during board.js startup, before ensureSchemaForEveryBoard, so that
|
|
6
|
+
* by the time per-board connections are opened they are already at their
|
|
7
|
+
* canonical central location.
|
|
8
|
+
*
|
|
9
|
+
* If a migration fails the registry row is NOT updated and Orbit continues
|
|
10
|
+
* to serve from the old in-repo path. A structured warning with agent-actionable
|
|
11
|
+
* manual instructions is printed to stderr.
|
|
12
|
+
*/
|
|
13
|
+
import { existsSync, mkdirSync, readdirSync, rmSync, unlinkSync } from "node:fs";
|
|
14
|
+
import { dirname, join } from "node:path";
|
|
15
|
+
import { closeConnection, getRegistry, openConnection } from "./db.js";
|
|
16
|
+
import { BOARDS_DIR } from "./paths.js";
|
|
17
|
+
import { listBoards } from "./registry.js";
|
|
18
|
+
|
|
19
|
+
/** Normalize to forward slashes for consistent cross-platform prefix checks. */
|
|
20
|
+
const fwd = (p) => p.replace(/\\/g, "/");
|
|
21
|
+
|
|
22
|
+
/** True when db_path already lives inside the canonical BOARDS_DIR. */
|
|
23
|
+
function isCentral(dbPath) {
|
|
24
|
+
return fwd(dbPath).startsWith(fwd(BOARDS_DIR) + "/");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Canonical central path for a given slug. */
|
|
28
|
+
function centralPathFor(slug) {
|
|
29
|
+
return join(BOARDS_DIR, slug, "board.db");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Delete a file if it exists; silently ignore errors. */
|
|
33
|
+
function tryUnlink(p) {
|
|
34
|
+
try { if (existsSync(p)) unlinkSync(p); } catch { /* ignore */ }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Walk upward from startDir, removing each directory that is now empty.
|
|
39
|
+
* Stops at stopDir (inclusive) or the first non-empty directory.
|
|
40
|
+
* This cleans up empty .orbit/ trees left behind after moving the db out.
|
|
41
|
+
*/
|
|
42
|
+
function pruneEmptyAncestors(startDir, stopDir) {
|
|
43
|
+
const stopFwd = fwd(stopDir);
|
|
44
|
+
let cur = fwd(startDir);
|
|
45
|
+
while (cur.startsWith(stopFwd)) {
|
|
46
|
+
try {
|
|
47
|
+
if (!existsSync(cur)) {
|
|
48
|
+
/* already gone, climb */
|
|
49
|
+
} else if (readdirSync(cur).length > 0) {
|
|
50
|
+
break; // non-empty — stop here
|
|
51
|
+
} else {
|
|
52
|
+
// rmSync({recursive:true, force:true}) works correctly for both files and
|
|
53
|
+
// empty directories across Node versions and platforms. We've already
|
|
54
|
+
// confirmed the directory is empty so nothing is over-deleted.
|
|
55
|
+
rmSync(cur, { recursive: true, force: true });
|
|
56
|
+
}
|
|
57
|
+
} catch {
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
if (cur === stopFwd) break; // reached the limit
|
|
61
|
+
const parent = fwd(dirname(cur));
|
|
62
|
+
if (parent === cur) break; // filesystem root
|
|
63
|
+
cur = parent;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* For a db path inside a repo's .orbit directory, return the .orbit parent.
|
|
69
|
+
* /repo/.orbit/board.db → /repo/.orbit
|
|
70
|
+
* /repo/.orbit/boards/x/board.db → /repo/.orbit
|
|
71
|
+
* Returns null if ".orbit" is not found in the path.
|
|
72
|
+
*/
|
|
73
|
+
function orbitParentDir(dbPath) {
|
|
74
|
+
const parts = fwd(dbPath).split("/");
|
|
75
|
+
const idx = parts.lastIndexOf(".orbit");
|
|
76
|
+
if (idx < 0) return null;
|
|
77
|
+
return parts.slice(0, idx + 1).join("/");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function sqlLiteral(v) {
|
|
81
|
+
return `'${String(v).replace(/'/g, "''")}'`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Attempt to migrate one board from in-repo storage to central storage.
|
|
86
|
+
* Returns a result object with status: 'migrated' | 'failed' | 'missing_source'.
|
|
87
|
+
*/
|
|
88
|
+
function migrateOneBoard(boardRow) {
|
|
89
|
+
const { id, slug, db_path: oldPath } = boardRow;
|
|
90
|
+
|
|
91
|
+
if (!existsSync(oldPath)) {
|
|
92
|
+
// Source file is gone; ensureSchemaForEveryBoard will prune the stale row.
|
|
93
|
+
return { status: "missing_source", board: boardRow, oldPath };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const newPath = centralPathFor(slug);
|
|
97
|
+
mkdirSync(dirname(newPath), { recursive: true });
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
// Safety: if the target path is already registered to a *different* board id,
|
|
101
|
+
// skip rather than overwrite live data (guards against corrupted registries with
|
|
102
|
+
// duplicate slugs — cannot happen through normal API use but is defensive).
|
|
103
|
+
const existingOwner = getRegistry().prepare("SELECT id FROM boards WHERE db_path = ?").get(newPath);
|
|
104
|
+
if (existingOwner && existingOwner.id !== id) {
|
|
105
|
+
return {
|
|
106
|
+
status: "failed",
|
|
107
|
+
board: boardRow,
|
|
108
|
+
oldPath,
|
|
109
|
+
newPath,
|
|
110
|
+
error: new Error(
|
|
111
|
+
`central path ${newPath} is already registered to board ${existingOwner.id} — skipping to avoid data loss`
|
|
112
|
+
)
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Remove any stale central copy left by a previous partial migration.
|
|
117
|
+
if (existsSync(newPath)) tryUnlink(newPath);
|
|
118
|
+
|
|
119
|
+
// VACUUM INTO creates a clean, WAL-flushed copy. It captures only committed
|
|
120
|
+
// data and produces a single-file database (no -wal/-shm at the destination).
|
|
121
|
+
const db = openConnection(oldPath);
|
|
122
|
+
db.exec(`VACUUM INTO ${sqlLiteral(newPath)};`);
|
|
123
|
+
closeConnection(oldPath);
|
|
124
|
+
|
|
125
|
+
// Update the registry before deleting the source so the canonical pointer is
|
|
126
|
+
// always valid: if the process dies between these two steps the old file still
|
|
127
|
+
// exists and the next run will redo the migration cleanly.
|
|
128
|
+
getRegistry()
|
|
129
|
+
.prepare("UPDATE boards SET db_path = ?, updated_at = ? WHERE id = ?")
|
|
130
|
+
.run(newPath, new Date().toISOString(), id);
|
|
131
|
+
|
|
132
|
+
// Remove source files (main db + WAL + SHM + journal).
|
|
133
|
+
tryUnlink(oldPath);
|
|
134
|
+
tryUnlink(oldPath + "-wal");
|
|
135
|
+
tryUnlink(oldPath + "-shm");
|
|
136
|
+
tryUnlink(oldPath + "-journal");
|
|
137
|
+
|
|
138
|
+
// Prune now-empty .orbit/ directories from the repo tree.
|
|
139
|
+
const orbitDir = orbitParentDir(oldPath);
|
|
140
|
+
if (orbitDir) pruneEmptyAncestors(dirname(oldPath), orbitDir);
|
|
141
|
+
|
|
142
|
+
return { status: "migrated", board: boardRow, oldPath, newPath };
|
|
143
|
+
} catch (error) {
|
|
144
|
+
// Ensure no lingering open connection or partial central file on failure.
|
|
145
|
+
try { closeConnection(oldPath); } catch { /* ignore */ }
|
|
146
|
+
tryUnlink(newPath);
|
|
147
|
+
return { status: "failed", board: boardRow, oldPath, newPath, error };
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Migrate all boards whose db_path lives outside DATA_DIR into central storage.
|
|
153
|
+
* Called at server/MCP startup before any per-board connections are opened.
|
|
154
|
+
* Prints a concise summary for migrated boards; prints agent-actionable
|
|
155
|
+
* instructions for any failures so a human or AI can complete the migration.
|
|
156
|
+
*/
|
|
157
|
+
export function migrateInRepoBoards() {
|
|
158
|
+
const pending = listBoards().filter((row) => !isCentral(row.db_path));
|
|
159
|
+
if (pending.length === 0) return;
|
|
160
|
+
|
|
161
|
+
const results = pending.map(migrateOneBoard);
|
|
162
|
+
const migrated = results.filter((r) => r.status === "migrated");
|
|
163
|
+
const failed = results.filter((r) => r.status === "failed");
|
|
164
|
+
|
|
165
|
+
if (migrated.length > 0) {
|
|
166
|
+
const n = migrated.length;
|
|
167
|
+
console.log(`[orbit] Migrated ${n} board${n > 1 ? "s" : ""} to central storage (${BOARDS_DIR}):`);
|
|
168
|
+
for (const r of migrated) {
|
|
169
|
+
console.log(` ✓ ${r.board.slug}`);
|
|
170
|
+
console.log(` from: ${r.oldPath}`);
|
|
171
|
+
console.log(` to: ${r.newPath}`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (failed.length > 0) {
|
|
176
|
+
const sep = " │";
|
|
177
|
+
const n = failed.length;
|
|
178
|
+
console.error(`\n[orbit] ⚠ Could not auto-migrate ${n} board${n > 1 ? "s" : ""} to central storage.`);
|
|
179
|
+
console.error(`${sep} These boards continue to work from their current paths.`);
|
|
180
|
+
console.error(`${sep} To complete the migration manually, run the commands below and`);
|
|
181
|
+
console.error(`${sep} restart orbit serve:\n`);
|
|
182
|
+
for (const r of failed) {
|
|
183
|
+
const slash = process.platform === "win32" ? "\\" : "/";
|
|
184
|
+
const destDir = dirname(r.newPath).replace(/\//g, slash);
|
|
185
|
+
const src = r.oldPath.replace(/\//g, slash);
|
|
186
|
+
const dest = r.newPath.replace(/\//g, slash);
|
|
187
|
+
console.error(`${sep} Board: ${r.board.slug} (error: ${r.error?.message ?? r.error})`);
|
|
188
|
+
if (process.platform === "win32") {
|
|
189
|
+
console.error(`${sep} md "${destDir}"`);
|
|
190
|
+
console.error(`${sep} copy "${src}" "${dest}"`);
|
|
191
|
+
} else {
|
|
192
|
+
console.error(`${sep} mkdir -p '${destDir}'`);
|
|
193
|
+
console.error(`${sep} cp '${src}' '${dest}'`);
|
|
194
|
+
}
|
|
195
|
+
console.error(sep);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
package/src/core/paths.js
CHANGED
|
@@ -16,10 +16,16 @@ export const PROJECT_ROOT = process.env.PROJECT_ROOT ? resolve(process.env.PROJE
|
|
|
16
16
|
export const DATA_DIR = process.env.DATA_DIR || join(homedir(), ".orbit");
|
|
17
17
|
/** Central registry of all boards (metadata + paths to per-board SQLite files). */
|
|
18
18
|
export const REGISTRY_DB_PATH = join(DATA_DIR, "registry.db");
|
|
19
|
+
/** Central directory where every board's SQLite file lives: DATA_DIR/boards/<slug>/board.db */
|
|
20
|
+
export const BOARDS_DIR = join(DATA_DIR, "boards");
|
|
19
21
|
export const EXPORT_DIR = join(DATA_DIR, "exports");
|
|
20
22
|
export const BACKUP_DIR = join(DATA_DIR, "backups");
|
|
23
|
+
/** Central directory for dispatch run artifacts: DATA_DIR/dispatch-runs/<board-slug>/<run-id>/ */
|
|
24
|
+
export const DISPATCH_RUNS_DIR = join(DATA_DIR, "dispatch-runs");
|
|
21
25
|
export const PORT = Number(process.env.PORT || 3337);
|
|
22
26
|
|
|
23
27
|
mkdirSync(DATA_DIR, { recursive: true });
|
|
28
|
+
mkdirSync(BOARDS_DIR, { recursive: true });
|
|
24
29
|
mkdirSync(EXPORT_DIR, { recursive: true });
|
|
25
30
|
mkdirSync(BACKUP_DIR, { recursive: true });
|
|
31
|
+
mkdirSync(DISPATCH_RUNS_DIR, { recursive: true });
|
package/src/core/registry.js
CHANGED
|
@@ -4,9 +4,10 @@
|
|
|
4
4
|
// the board that owns a given ticket / state / entry / relation. Callers
|
|
5
5
|
// receive `{ board, db }` and pass it down via `ctx`.
|
|
6
6
|
|
|
7
|
-
import { mkdirSync } from "node:fs";
|
|
7
|
+
import { existsSync, mkdirSync } from "node:fs";
|
|
8
8
|
import { dirname, join } from "node:path";
|
|
9
9
|
import { createBoardSchema, getRegistry, openConnection } from "./db.js";
|
|
10
|
+
import { BOARDS_DIR } from "./paths.js";
|
|
10
11
|
import { now, slugify } from "./util.js";
|
|
11
12
|
|
|
12
13
|
export function listBoards() {
|
|
@@ -92,14 +93,16 @@ export function updateBoardMeta(boardId, patch) {
|
|
|
92
93
|
.run(...vals);
|
|
93
94
|
}
|
|
94
95
|
|
|
95
|
-
/** Where a new board.db should live:
|
|
96
|
-
*
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
96
|
+
/** Where a new board.db should live: always in the central DATA_DIR/boards/<slug>/ directory.
|
|
97
|
+
* Falls back to a numeric suffix if the canonical path already exists on disk (orphaned file). */
|
|
98
|
+
export function computeNewBoardDbPath(_repoPathNorm, slug) {
|
|
99
|
+
const preferred = join(BOARDS_DIR, slug, "board.db");
|
|
100
|
+
if (!existsSync(preferred)) return preferred;
|
|
101
|
+
for (let i = 2; i < 1000; i++) {
|
|
102
|
+
const alt = join(BOARDS_DIR, `${slug}-${i}`, "board.db");
|
|
103
|
+
if (!existsSync(alt)) return alt;
|
|
104
|
+
}
|
|
105
|
+
return preferred; // callers will handle any existing file
|
|
103
106
|
}
|
|
104
107
|
|
|
105
108
|
/** Create the directory + file and apply the per-board schema. Returns the
|
package/src/core/router.js
CHANGED
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
// board, it is rejected with 400 board_id_required rather than silently
|
|
6
6
|
// inheriting some other caller's recent state.
|
|
7
7
|
|
|
8
|
-
import {
|
|
8
|
+
import { actorFromHttpRequest } from "./auth.js";
|
|
9
9
|
import { scheduleAutomaticBoardBackup } from "./backups.js";
|
|
10
|
-
import { readJson, sendJson, setCors } from "./http.js";
|
|
10
|
+
import { readJson, readRaw, sendFile, sendJson, setCors } from "./http.js";
|
|
11
11
|
import {
|
|
12
12
|
findBoardForBoardEntry,
|
|
13
13
|
findBoardForRelation,
|
|
@@ -54,11 +54,20 @@ import {
|
|
|
54
54
|
getTicketRelations
|
|
55
55
|
} from "./relations.js";
|
|
56
56
|
import { searchTickets } from "./search.js";
|
|
57
|
+
import {
|
|
58
|
+
createTicketAttachment,
|
|
59
|
+
deleteTicketAttachment,
|
|
60
|
+
getTicketAttachmentContent,
|
|
61
|
+
listTicketAttachments,
|
|
62
|
+
MAX_IMAGE_ATTACHMENT_BYTES
|
|
63
|
+
} from "./attachments.js";
|
|
57
64
|
import {
|
|
58
65
|
checkpointTicket,
|
|
59
66
|
claimNext,
|
|
60
67
|
completeTicket,
|
|
61
|
-
getContextPack
|
|
68
|
+
getContextPack,
|
|
69
|
+
readComments,
|
|
70
|
+
readTicket
|
|
62
71
|
} from "./agent.js";
|
|
63
72
|
|
|
64
73
|
/** Build a ctx for an already-resolved registry row + actor. */
|
|
@@ -103,7 +112,7 @@ export async function handleApi(req, res, url) {
|
|
|
103
112
|
return;
|
|
104
113
|
}
|
|
105
114
|
|
|
106
|
-
const actor =
|
|
115
|
+
const actor = actorFromHttpRequest(req);
|
|
107
116
|
|
|
108
117
|
// Bootstrap: read-only registry + one chosen board. No global state mutated.
|
|
109
118
|
if (req.method === "GET" && url.pathname === "/api/bootstrap") {
|
|
@@ -183,7 +192,9 @@ export async function handleApi(req, res, url) {
|
|
|
183
192
|
return;
|
|
184
193
|
}
|
|
185
194
|
if (sub === "export" && req.method === "GET") {
|
|
186
|
-
sendJson(res, 200, exportBoard(row.id, ctx
|
|
195
|
+
sendJson(res, 200, exportBoard(row.id, ctx, {
|
|
196
|
+
includeAttachments: url.searchParams.get("include_attachments") === "true" || url.searchParams.get("include_images") === "true"
|
|
197
|
+
}));
|
|
187
198
|
return;
|
|
188
199
|
}
|
|
189
200
|
if (sub === "states" && req.method === "POST") {
|
|
@@ -212,6 +223,18 @@ export async function handleApi(req, res, url) {
|
|
|
212
223
|
return;
|
|
213
224
|
}
|
|
214
225
|
|
|
226
|
+
// /api/tickets/lookup GET — exact lightweight lookup by number or title.
|
|
227
|
+
if (req.method === "GET" && url.pathname === "/api/tickets/lookup") {
|
|
228
|
+
const { boardId, boardSlug } = boardHintsFromQuery(url);
|
|
229
|
+
const row = resolveBoardFromHint(boardId, boardSlug);
|
|
230
|
+
if (!row) throw httpError(400, "board_id_required");
|
|
231
|
+
const lookup = {};
|
|
232
|
+
if (url.searchParams.has("number")) lookup.number = url.searchParams.get("number");
|
|
233
|
+
if (url.searchParams.has("title")) lookup.title = url.searchParams.get("title");
|
|
234
|
+
sendJson(res, 200, readTicket(lookup, ctxFromBoardRow(row, actor)));
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
215
238
|
// /api/tickets/:id/... — board resolved from query hint or via lookup.
|
|
216
239
|
const ticketPath = url.pathname.match(/^\/api\/tickets\/([^/]+)(?:\/(.*))?$/);
|
|
217
240
|
if (ticketPath) {
|
|
@@ -223,6 +246,10 @@ export async function handleApi(req, res, url) {
|
|
|
223
246
|
if (!found) throw httpError(404, "ticket_not_found");
|
|
224
247
|
const ctx = ctxFromBoardRow(found.board, actor);
|
|
225
248
|
|
|
249
|
+
if (sub === "" && req.method === "GET") {
|
|
250
|
+
sendJson(res, 200, readTicket(ticketId, ctx));
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
226
253
|
if (sub === "" && req.method === "PATCH") {
|
|
227
254
|
const body = await readJson(req);
|
|
228
255
|
sendMutationJson(res, 200, updateTicket(ticketId, body, ctx), ctx);
|
|
@@ -257,11 +284,40 @@ export async function handleApi(req, res, url) {
|
|
|
257
284
|
sendJson(res, 200, getTicketBlockers(ticketId, ctx));
|
|
258
285
|
return;
|
|
259
286
|
}
|
|
287
|
+
if (sub === "comments" && req.method === "GET") {
|
|
288
|
+
sendJson(res, 200, readComments(ticketId, ctx));
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
260
291
|
if (sub === "comments" && req.method === "POST") {
|
|
261
292
|
const body = await readJson(req);
|
|
262
293
|
sendMutationJson(res, 201, createComment(ticketId, body, ctx), ctx);
|
|
263
294
|
return;
|
|
264
295
|
}
|
|
296
|
+
if (sub === "attachments" && req.method === "GET") {
|
|
297
|
+
sendJson(res, 200, listTicketAttachments(ticketId, ctx));
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
if (sub === "attachments" && req.method === "POST") {
|
|
301
|
+
const bytes = await readRaw(req, MAX_IMAGE_ATTACHMENT_BYTES);
|
|
302
|
+
const attachment = createTicketAttachment(ticketId, {
|
|
303
|
+
bytes,
|
|
304
|
+
mime_type: req.headers["content-type"],
|
|
305
|
+
original_name: req.headers["x-file-name"] || url.searchParams.get("filename") || "image"
|
|
306
|
+
}, ctx);
|
|
307
|
+
sendMutationJson(res, 201, attachment, ctx);
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
const attachmentContentPath = sub.match(/^attachments\/([^/]+)\/content$/);
|
|
311
|
+
if (attachmentContentPath && req.method === "GET") {
|
|
312
|
+
const result = getTicketAttachmentContent(ticketId, decodeURIComponent(attachmentContentPath[1]), ctx);
|
|
313
|
+
sendFile(res, result.filePath, result.row.mime_type);
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
const attachmentPath = sub.match(/^attachments\/([^/]+)$/);
|
|
317
|
+
if (attachmentPath && req.method === "DELETE") {
|
|
318
|
+
sendMutationJson(res, 200, deleteTicketAttachment(ticketId, decodeURIComponent(attachmentPath[1]), ctx), ctx);
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
265
321
|
sendJson(res, 404, { error: "not_found" });
|
|
266
322
|
return;
|
|
267
323
|
}
|
package/src/core/search.js
CHANGED
|
@@ -23,7 +23,7 @@ export function searchTickets(args, ctx) {
|
|
|
23
23
|
if (ticketNumber !== null) {
|
|
24
24
|
rows = db
|
|
25
25
|
.prepare(
|
|
26
|
-
`SELECT t.*, s.name AS state_name, b.slug AS board_slug, -1 AS rank
|
|
26
|
+
`SELECT t.*, s.name AS state_name, s.role AS state_role, b.slug AS board_slug, -1 AS rank
|
|
27
27
|
FROM tickets t
|
|
28
28
|
JOIN states s ON s.id = t.state_id
|
|
29
29
|
JOIN boards b ON b.id = t.board_id
|
|
@@ -37,7 +37,7 @@ export function searchTickets(args, ctx) {
|
|
|
37
37
|
rows = rows.concat(
|
|
38
38
|
db
|
|
39
39
|
.prepare(
|
|
40
|
-
`SELECT t.*, s.name AS state_name, b.slug AS board_slug,
|
|
40
|
+
`SELECT t.*, s.name AS state_name, s.role AS state_role, b.slug AS board_slug,
|
|
41
41
|
bm25(ticket_fts) AS rank
|
|
42
42
|
FROM ticket_fts
|
|
43
43
|
JOIN tickets t ON t.id = ticket_fts.ticket_id
|
|
@@ -61,7 +61,7 @@ export function searchTickets(args, ctx) {
|
|
|
61
61
|
rows = rows.concat(
|
|
62
62
|
db
|
|
63
63
|
.prepare(
|
|
64
|
-
`SELECT DISTINCT t.*, s.name AS state_name, b.slug AS board_slug, ${numberRank} AS rank
|
|
64
|
+
`SELECT DISTINCT t.*, s.name AS state_name, s.role AS state_role, b.slug AS board_slug, ${numberRank} AS rank
|
|
65
65
|
FROM tickets t
|
|
66
66
|
JOIN states s ON s.id = t.state_id
|
|
67
67
|
JOIN boards b ON b.id = t.board_id
|
package/src/core/snapshots.js
CHANGED
|
@@ -13,8 +13,13 @@ import {
|
|
|
13
13
|
requiredString
|
|
14
14
|
} from "./util.js";
|
|
15
15
|
import { reindexAllTickets } from "./tickets.js";
|
|
16
|
+
import {
|
|
17
|
+
attachmentRowsForBoard,
|
|
18
|
+
importAttachmentRows,
|
|
19
|
+
serializeAttachmentForExport
|
|
20
|
+
} from "./attachments.js";
|
|
16
21
|
|
|
17
|
-
export function exportBoard(boardId, ctx) {
|
|
22
|
+
export function exportBoard(boardId, ctx, options = {}) {
|
|
18
23
|
const { db, board, actor } = ctx;
|
|
19
24
|
if (boardId !== board.id) throw httpError(404, "board_not_found");
|
|
20
25
|
const innerBoard = boardById(db, boardId);
|
|
@@ -56,17 +61,22 @@ export function exportBoard(boardId, ctx) {
|
|
|
56
61
|
ORDER BY created_at ASC`
|
|
57
62
|
)
|
|
58
63
|
.all(boardId);
|
|
64
|
+
const attachments = attachmentRowsForBoard(db, boardId).map((row) =>
|
|
65
|
+
serializeAttachmentForExport(board, row, Boolean(options.includeAttachments))
|
|
66
|
+
);
|
|
59
67
|
|
|
60
68
|
return {
|
|
61
69
|
format: "orbit-board-export",
|
|
62
|
-
version:
|
|
70
|
+
version: 6,
|
|
63
71
|
exported_at: now(),
|
|
72
|
+
include_attachments: Boolean(options.includeAttachments),
|
|
64
73
|
board: { ...innerBoard },
|
|
65
74
|
states,
|
|
66
75
|
labels,
|
|
67
76
|
tickets,
|
|
68
77
|
comments,
|
|
69
78
|
relations,
|
|
79
|
+
attachments,
|
|
70
80
|
board_entries: entries,
|
|
71
81
|
events
|
|
72
82
|
};
|
|
@@ -200,6 +210,8 @@ export function importBoardSnapshot(body, ctx) {
|
|
|
200
210
|
);
|
|
201
211
|
}
|
|
202
212
|
|
|
213
|
+
importAttachmentRows(snapshot, ctx, targetBoardId);
|
|
214
|
+
|
|
203
215
|
for (const entry of snapshot.board_entries || []) {
|
|
204
216
|
db.prepare(
|
|
205
217
|
`INSERT INTO board_entries
|
package/src/mcp/orbit-client.js
CHANGED
|
@@ -61,6 +61,7 @@ export function createHttpOrbitClient(env = process.env, fetchImpl = globalThis.
|
|
|
61
61
|
return {
|
|
62
62
|
mode: "remote",
|
|
63
63
|
sessionLabel: apiUrl,
|
|
64
|
+
close() {},
|
|
64
65
|
async boardContext(args = {}) {
|
|
65
66
|
return request("GET", `/api/boards/${encodeURIComponent(requireBoard(args))}/context`, { query: { include_struck: args.include_struck ? "true" : undefined } });
|
|
66
67
|
},
|
|
@@ -69,11 +70,26 @@ export function createHttpOrbitClient(env = process.env, fetchImpl = globalThis.
|
|
|
69
70
|
async claimNext(args = {}) { return request("POST", "/api/agent/claim-next", { body: { ...args, board: requireBoard(args) } }); },
|
|
70
71
|
async getTicketContext(args = {}) { return request("GET", `/api/tickets/${encodeURIComponent(args.ticket_id)}/context`, { query: { ...ticketQuery(args), depth: args.depth || 1 } }); },
|
|
71
72
|
async readTicket(args = {}) {
|
|
72
|
-
if (args.ticket_id)
|
|
73
|
-
|
|
74
|
-
|
|
73
|
+
if (args.ticket_id) {
|
|
74
|
+
return request("GET", `/api/tickets/${encodeURIComponent(args.ticket_id)}`, { query: ticketQuery(args) });
|
|
75
|
+
}
|
|
76
|
+
if (args.number !== undefined || args.title) {
|
|
77
|
+
return request("GET", "/api/tickets/lookup", { query: { ...ticketQuery(args), number: args.number, title: args.title } });
|
|
78
|
+
}
|
|
79
|
+
throw rpcError(-32602, "ticket_id_or_number_or_title_required");
|
|
80
|
+
},
|
|
81
|
+
async readComments(args = {}) {
|
|
82
|
+
if (args.ticket_id) {
|
|
83
|
+
return request("GET", `/api/tickets/${encodeURIComponent(args.ticket_id)}/comments`, { query: ticketQuery(args) });
|
|
84
|
+
}
|
|
85
|
+
const found = await this.readTicket(args);
|
|
86
|
+
const ticket = found.ticket || found.results?.find((row) =>
|
|
87
|
+
(args.number !== undefined && Number(row.number) === Number(args.number)) ||
|
|
88
|
+
(args.title && String(row.title || "").toLowerCase() === String(args.title).toLowerCase())
|
|
89
|
+
);
|
|
90
|
+
if (!ticket?.id) throw rpcError(-32004, "ticket_not_found");
|
|
91
|
+
return request("GET", `/api/tickets/${encodeURIComponent(ticket.id)}/comments`, { query: ticketQuery(args) });
|
|
75
92
|
},
|
|
76
|
-
async readComments(args = {}) { const ctx = await this.readTicket(args); return { comments: ctx.comments || ctx.ticket?.comments || [] }; },
|
|
77
93
|
async getTicketRelations(args = {}) { return request("GET", `/api/tickets/${encodeURIComponent(args.ticket_id)}/relations`, { query: ticketQuery(args) }); },
|
|
78
94
|
async getTicketBlockers(args = {}) { return request("GET", `/api/tickets/${encodeURIComponent(args.ticket_id)}/blockers`, { query: ticketQuery(args) }); },
|
|
79
95
|
async search(args = {}) { return request("GET", "/api/search", { query: { q: args.q, limit: args.limit, board_id: args.board_id, board: args.board_slug || args.board || defaultBoard } }); },
|
|
@@ -87,7 +103,11 @@ export function createHttpOrbitClient(env = process.env, fetchImpl = globalThis.
|
|
|
87
103
|
async restoreTicket(args = {}) { return request("POST", `/api/tickets/${encodeURIComponent(args.ticket_id)}/restore`, { query: ticketQuery(args) }); },
|
|
88
104
|
async deleteTicket(args = {}) { return request("DELETE", `/api/tickets/${encodeURIComponent(args.ticket_id)}`, { query: ticketQuery(args) }); },
|
|
89
105
|
async listArchive(args = {}) { return request("GET", `/api/boards/${encodeURIComponent(requireBoard(args))}/archive`); },
|
|
90
|
-
async exportBoard(args = {}) {
|
|
106
|
+
async exportBoard(args = {}) {
|
|
107
|
+
return request("GET", `/api/boards/${encodeURIComponent(requireBoard(args))}/export`, {
|
|
108
|
+
query: { include_attachments: args.include_attachments || args.include_images ? "true" : undefined }
|
|
109
|
+
});
|
|
110
|
+
},
|
|
91
111
|
async updateSettings(args = {}) { const { board_id, board_slug, board, ...patch } = args; return request("PATCH", `/api/boards/${encodeURIComponent(requireBoard(args))}`, { body: patch }); }
|
|
92
112
|
};
|
|
93
113
|
}
|
|
@@ -100,45 +120,59 @@ export async function createLocalOrbitClient(env = process.env) {
|
|
|
100
120
|
const seed = await import("../core/seed.js");
|
|
101
121
|
const util = await import("../core/util.js");
|
|
102
122
|
|
|
123
|
+
const provision = await import("../core/provision-repo-board.js");
|
|
103
124
|
let sessionBoardRow = null;
|
|
104
125
|
const setSessionBoard = (row) => { sessionBoardRow = row; if (row) registry.touchBoardActive(row.id); };
|
|
105
126
|
const getSessionBoard = () => sessionBoardRow;
|
|
106
127
|
const walkUp = (startDir, predicate) => { let dir = startDir; while (true) { if (predicate(dir)) return dir; const parent = dirname(dir); if (parent === dir) return null; dir = parent; } };
|
|
107
|
-
const createBoardAtRoot = (root) => {
|
|
108
|
-
const dbPath = join(root, ".orbit", "board.db");
|
|
109
|
-
mkdirSync(dirname(dbPath), { recursive: true });
|
|
110
|
-
const db = dbMod.openConnection(dbPath);
|
|
111
|
-
dbMod.createBoardSchema(db);
|
|
112
|
-
const seeded = seed.seedIfEmpty(db, root);
|
|
113
|
-
if (seeded) {
|
|
114
|
-
const t = util.now();
|
|
115
|
-
registry.insertBoard({ id: seeded.id, slug: seeded.slug, name: seeded.name, repo_path: root, db_path: dbPath, repo_url: seeded.repo_url || "", default_branch: seeded.default_branch || "main", last_active_at: t, created_at: t, updated_at: t });
|
|
116
|
-
}
|
|
117
|
-
const fresh = registry.getBoardByRepoPath(root);
|
|
118
|
-
if (fresh) { registry.syncRegistryFromBoardDb(fresh, db); setSessionBoard(fresh); }
|
|
119
|
-
};
|
|
120
128
|
|
|
121
129
|
const start = util.normalizePath(env.PROJECT_ROOT ? resolve(env.PROJECT_ROOT) : process.cwd());
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
130
|
+
const displayStart = resolve(env.PROJECT_ROOT ? env.PROJECT_ROOT : process.cwd());
|
|
131
|
+
|
|
132
|
+
// Find repo root (walk up for .git, fall back to start).
|
|
133
|
+
const gitRoot = walkUp(start, (dir) => existsSync(join(dir, ".git")));
|
|
134
|
+
const repoRoot = util.normalizePath(gitRoot || start);
|
|
135
|
+
|
|
136
|
+
// 1. Registry-first lookup: walk upward from start so we find boards regardless
|
|
137
|
+
// of whether they were registered at the git root, at PROJECT_ROOT, or at any
|
|
138
|
+
// ancestor (handles boards originally created from a subdirectory).
|
|
139
|
+
let foundByRegistry = false;
|
|
140
|
+
{
|
|
141
|
+
let dir = start;
|
|
142
|
+
while (true) {
|
|
143
|
+
const row = registry.getBoardByRepoPath(dir);
|
|
144
|
+
if (row && existsSync(row.db_path)) { setSessionBoard(row); foundByRegistry = true; break; }
|
|
145
|
+
const parent = util.normalizePath(dirname(dir));
|
|
146
|
+
if (parent === dir) break; // filesystem root
|
|
147
|
+
dir = parent;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (!foundByRegistry) {
|
|
151
|
+
// 2. Legacy fallback: walk up for an existing in-repo .orbit/board.db.
|
|
152
|
+
const boardRoot = walkUp(start, (dir) => existsSync(join(dir, ".orbit", "board.db")));
|
|
153
|
+
if (boardRoot) {
|
|
154
|
+
const root = util.normalizePath(boardRoot);
|
|
155
|
+
const existing = registry.getBoardByRepoPath(root);
|
|
156
|
+
if (existing) {
|
|
157
|
+
setSessionBoard(existing);
|
|
158
|
+
} else {
|
|
159
|
+
const dbPath = join(boardRoot, ".orbit", "board.db");
|
|
160
|
+
const db = dbMod.openConnection(dbPath);
|
|
161
|
+
dbMod.createBoardSchema(db);
|
|
162
|
+
const seeded = seed.seedIfEmpty(db, root);
|
|
163
|
+
if (seeded) {
|
|
164
|
+
const t = util.now();
|
|
165
|
+
registry.insertBoard({ id: seeded.id, slug: seeded.slug, name: seeded.name, repo_path: root, db_path: dbPath, repo_url: seeded.repo_url || "", default_branch: seeded.default_branch || "main", last_active_at: t, created_at: t, updated_at: t });
|
|
166
|
+
}
|
|
167
|
+
const fresh = registry.getBoardByRepoPath(root);
|
|
168
|
+
if (fresh) { registry.syncRegistryFromBoardDb(fresh, db); setSessionBoard(fresh); }
|
|
135
169
|
}
|
|
136
|
-
|
|
137
|
-
|
|
170
|
+
} else {
|
|
171
|
+
// 3. No board found — auto-create one (goes to central DATA_DIR/boards/).
|
|
172
|
+
provision.provisionRepoBoard(repoRoot, { enableAi: true });
|
|
173
|
+
const fresh = registry.getBoardByRepoPath(repoRoot);
|
|
174
|
+
if (fresh) setSessionBoard(fresh);
|
|
138
175
|
}
|
|
139
|
-
} else {
|
|
140
|
-
const gitRoot = walkUp(start, (dir) => existsSync(join(dir, ".git")));
|
|
141
|
-
createBoardAtRoot(util.normalizePath(gitRoot || start));
|
|
142
176
|
}
|
|
143
177
|
|
|
144
178
|
const ctxFor = (row, actor) => {
|
|
@@ -153,7 +187,11 @@ export async function createLocalOrbitClient(env = process.env) {
|
|
|
153
187
|
|
|
154
188
|
return {
|
|
155
189
|
mode: "local",
|
|
156
|
-
sessionLabel: () =>
|
|
190
|
+
sessionLabel: () => {
|
|
191
|
+
const b = getSessionBoard();
|
|
192
|
+
return b ? `${displayStart} target: ${b.repo_path} db: ${b.db_path}` : "(none)";
|
|
193
|
+
},
|
|
194
|
+
close: () => dbMod.closeAllConnections(),
|
|
157
195
|
boardContext: (args = {}) => { const ctx = ctxFor(rowOrSession(args), actor()); return board.getBoardContext(ctx.board.id, ctx, { includeStruck: Boolean(args.include_struck) }); },
|
|
158
196
|
boardList: () => ({ boards: registry.listBoards().map((row) => ({ id: row.id, slug: row.slug, name: row.name, repo_path: row.repo_path })) }),
|
|
159
197
|
boardSetActive: (args = {}) => { const slug = String(args.slug || "").trim(); if (!slug) throw rpcError(-32602, "slug is required."); const row = registry.getBoardBySlug(slug); if (!row) throw rpcError(-32004, `Board slug not found: ${slug}`); setSessionBoard(row); return { ok: true, board_id: row.id, slug: row.slug, name: row.name, db_path: row.db_path }; },
|
|
@@ -174,7 +212,12 @@ export async function createLocalOrbitClient(env = process.env) {
|
|
|
174
212
|
restoreTicket: (args = {}) => { const ctx = sessionCtx(actor()); return backup(ctx, board.restoreTicket(args.ticket_id, ctx)); },
|
|
175
213
|
deleteTicket: (args = {}) => { const ctx = sessionCtx(actor()); return backup(ctx, board.deleteTicket(args.ticket_id, ctx)); },
|
|
176
214
|
listArchive: (args = {}) => { const ctx = ctxFor(rowOrSession(args), actor()); return { tickets: board.archivedTicketsForBoard(ctx.db, ctx.board.id) }; },
|
|
177
|
-
exportBoard: (args = {}) => {
|
|
215
|
+
exportBoard: (args = {}) => {
|
|
216
|
+
const ctx = ctxFor(rowOrSession(args), actor());
|
|
217
|
+
return board.exportBoard(ctx.board.id, ctx, {
|
|
218
|
+
includeAttachments: Boolean(args.include_attachments || args.include_images)
|
|
219
|
+
});
|
|
220
|
+
},
|
|
178
221
|
updateSettings: (args = {}) => { const ctx = ctxFor(rowOrSession(args), actor()); const { board_id, board_slug, ...patch } = args; return backup(ctx, board.updateBoard(ctx.board.id, patch, ctx)); }
|
|
179
222
|
};
|
|
180
223
|
}
|