@starlove/orbit 1.0.5 → 1.0.7
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/.dockerignore +0 -0
- package/Dockerfile +0 -0
- package/LICENSE +0 -0
- package/README.md +8 -0
- package/SKILL-ORBIT.md +2 -0
- package/build.mjs +2 -2
- package/dist/full/apple-touch-icon.png +0 -0
- package/dist/full/cursors/live_arrow.cur +0 -0
- package/dist/full/cursors/live_link.cur +0 -0
- package/dist/full/cursors/live_move.cur +0 -0
- package/dist/full/cursors/live_select.cur +0 -0
- package/dist/full/cursors/live_unavail.cur +0 -0
- package/dist/full/favicon-32x32.png +0 -0
- package/dist/full/index.html +2 -2
- package/dist/full/js/app.f4fd5e94.js +604 -0
- package/dist/full/js/app.f4fd5e94.js.map +7 -0
- package/dist/full/orbits-favicon.png +0 -0
- package/dist/full/orbits-logo-bg.png +0 -0
- package/dist/full/orbits-logo.png +0 -0
- package/dist/full/{styles.c2e3d20a.css → styles.14e9dbbd.css} +295 -22
- package/dist/full/version.json +2 -2
- package/docs/AGENT_PROTOCOL.md +4 -0
- package/docs/MCP_SETUP.md +3 -1
- package/docs/SS-Orbit-Dark.png +0 -0
- package/docs/SS-Orbit-Light.png +0 -0
- package/docs/mcp-client.example.json +0 -0
- package/package.json +2 -2
- package/public/apple-touch-icon.png +0 -0
- package/public/cursors/live_arrow.cur +0 -0
- package/public/cursors/live_link.cur +0 -0
- package/public/cursors/live_move.cur +0 -0
- package/public/cursors/live_select.cur +0 -0
- package/public/cursors/live_unavail.cur +0 -0
- package/public/favicon-32x32.png +0 -0
- package/public/index.html +0 -0
- package/public/js/api.js +5 -1
- package/public/js/app.js +0 -0
- package/public/js/board-menu.js +0 -0
- package/public/js/config.js +0 -0
- package/public/js/create-card.js +0 -0
- package/public/js/dom.js +0 -0
- package/public/js/drawer.js +2 -0
- package/public/js/format.js +142 -0
- package/public/js/kanban.js +18 -2
- package/public/js/local-backend.js +29 -2
- package/public/js/main.js +14 -6
- package/public/js/modal.js +0 -0
- package/public/js/motion-preference.js +53 -0
- package/public/js/router.js +75 -56
- package/public/js/search.js +0 -0
- package/public/js/settings-tabs.js +1 -1
- package/public/js/settings.js +96 -3
- package/public/js/sse.js +0 -0
- package/public/js/starscape.js +25 -18
- package/public/js/state.js +11 -2
- package/public/js/theme-preference.js +88 -0
- package/public/js/ticket-detail.js +6 -3
- package/public/js/toast.js +0 -0
- package/public/js/transport.js +0 -0
- package/public/js/unread.js +0 -0
- package/public/js/url-routes.js +94 -0
- package/public/orbits-favicon.png +0 -0
- package/public/orbits-logo-bg.png +0 -0
- package/public/orbits-logo.png +0 -0
- package/public/styles.css +295 -22
- package/src/core/agent.js +10 -4
- package/src/core/agents-md.js +1 -0
- package/src/core/auth.js +0 -0
- package/src/core/backups.js +0 -0
- package/src/core/board.js +1 -1
- package/src/core/boards.js +0 -0
- package/src/core/bootstrap.js +10 -2
- package/src/core/db.js +0 -0
- package/src/core/delete-board.js +0 -0
- package/src/core/events.js +0 -0
- package/src/core/http.js +0 -0
- package/src/core/paths.js +0 -0
- package/src/core/provision-repo-board.js +0 -0
- package/src/core/queries.js +0 -0
- package/src/core/registry.js +0 -0
- package/src/core/relations.js +0 -0
- package/src/core/router.js +1 -1
- package/src/core/search.js +62 -23
- package/src/core/seed.js +0 -0
- package/src/core/snapshots.js +0 -0
- package/src/core/states.js +0 -0
- package/src/core/system-picker.js +0 -0
- package/src/core/tickets.js +28 -3
- package/src/core/util.js +0 -0
- package/src/mcp/orbit-client.js +180 -0
- package/src/mcp-server.js +50 -274
- package/src/server.js +0 -0
- package/dist/full/js/app.7e25f59b.js +0 -554
- package/dist/full/js/app.7e25f59b.js.map +0 -7
package/src/core/agent.js
CHANGED
|
@@ -24,7 +24,7 @@ import {
|
|
|
24
24
|
requiredString
|
|
25
25
|
} from "./util.js";
|
|
26
26
|
import { boardManual } from "./boards.js";
|
|
27
|
-
import { reindexTicket } from "./tickets.js";
|
|
27
|
+
import { bumpTicketUpdatedAt, reindexTicket } from "./tickets.js";
|
|
28
28
|
|
|
29
29
|
/**
|
|
30
30
|
* Claim the next AI-ready ticket on the resolved board. The router/MCP layer
|
|
@@ -71,14 +71,16 @@ export function claimNext(body, ctx) {
|
|
|
71
71
|
if (!inProgress) throw httpError(400, "missing_in_progress_state");
|
|
72
72
|
|
|
73
73
|
tx(db, () => {
|
|
74
|
+
const time = now();
|
|
74
75
|
db.prepare("UPDATE tickets SET state_id = ?, updated_at = ? WHERE id = ?").run(
|
|
75
76
|
inProgress.id,
|
|
76
|
-
|
|
77
|
+
time,
|
|
77
78
|
candidate.id
|
|
78
79
|
);
|
|
79
80
|
addComment(db, candidate.id, actor.name, "agent_note", "Agent claimed this ticket and is starting work.");
|
|
80
81
|
recordEvent(db, candidate.board_id, "agent_claimed", candidate.id, actor.name, { agent_id: actor.id });
|
|
81
82
|
reindexTicket(db, candidate.id);
|
|
83
|
+
bumpTicketUpdatedAt(db, candidate.parent_ticket_id, time);
|
|
82
84
|
});
|
|
83
85
|
|
|
84
86
|
return {
|
|
@@ -157,7 +159,8 @@ export function checkpointTicket(body, ctx) {
|
|
|
157
159
|
if (!review) throw httpError(400, "missing_review_state");
|
|
158
160
|
const message = requiredString(body.message, "message");
|
|
159
161
|
tx(db, () => {
|
|
160
|
-
|
|
162
|
+
const time = now();
|
|
163
|
+
db.prepare("UPDATE tickets SET state_id = ?, updated_at = ? WHERE id = ?").run(review.id, time, ticket.id);
|
|
161
164
|
addComment(db, ticket.id, actor.name, "checkpoint", message);
|
|
162
165
|
recordEvent(db, ticket.board_id, "checkpoint_requested", ticket.id, actor.name, {
|
|
163
166
|
from: ticket.state_name,
|
|
@@ -167,6 +170,7 @@ export function checkpointTicket(body, ctx) {
|
|
|
167
170
|
actor_id: actor.id
|
|
168
171
|
});
|
|
169
172
|
reindexTicket(db, ticket.id);
|
|
173
|
+
bumpTicketUpdatedAt(db, ticket.parent_ticket_id, time);
|
|
170
174
|
});
|
|
171
175
|
return getContextPack(ticket.id, ctx, 1);
|
|
172
176
|
}
|
|
@@ -190,10 +194,11 @@ export function completeTicket(body, ctx) {
|
|
|
190
194
|
if (body.pr_url) lines.push(`PR: ${body.pr_url}`);
|
|
191
195
|
const updates = body.updates ? appendFieldNote(ticket.implementation_updates, body.updates, actor.name) : ticket.implementation_updates;
|
|
192
196
|
tx(db, () => {
|
|
197
|
+
const time = now();
|
|
193
198
|
addComment(db, ticket.id, actor.name, "completion", lines.join("\n\n"));
|
|
194
199
|
db.prepare(
|
|
195
200
|
"UPDATE tickets SET state_id = ?, implementation_summary = ?, implementation_updates = ?, updated_at = ? WHERE id = ?"
|
|
196
|
-
).run(nextState.id, summary, updates,
|
|
201
|
+
).run(nextState.id, summary, updates, time, ticket.id);
|
|
197
202
|
recordEvent(db, ticket.board_id, "agent_completed", ticket.id, actor.name, {
|
|
198
203
|
from: ticket.state_name,
|
|
199
204
|
next_state: nextState.name,
|
|
@@ -203,6 +208,7 @@ export function completeTicket(body, ctx) {
|
|
|
203
208
|
actor_id: actor.id
|
|
204
209
|
});
|
|
205
210
|
reindexTicket(db, ticket.id);
|
|
211
|
+
bumpTicketUpdatedAt(db, ticket.parent_ticket_id, time);
|
|
206
212
|
});
|
|
207
213
|
return getContextPack(ticket.id, ctx, 1);
|
|
208
214
|
}
|
package/src/core/agents-md.js
CHANGED
|
@@ -11,6 +11,7 @@ export function orbitAgentsSection() {
|
|
|
11
11
|
${ORBIT_AGENTS_CANONICAL_LINE}
|
|
12
12
|
|
|
13
13
|
When work mentions Orbit, kanban, board, lane, ticket, card, epic, blocker, claim, AI Ready, implementation fields, planning state, project memory, or handoff: read \`SKILL-ORBIT.md\` first and follow it.
|
|
14
|
+
Use Orbit API/MCP tools for tickets/cards; do not edit .orbit/board.db directly.
|
|
14
15
|
${ORBIT_AGENTS_END}\n`;
|
|
15
16
|
}
|
|
16
17
|
|
package/src/core/auth.js
CHANGED
|
File without changes
|
package/src/core/backups.js
CHANGED
|
File without changes
|
package/src/core/board.js
CHANGED
|
@@ -100,7 +100,7 @@ export {
|
|
|
100
100
|
updateBoardEntry,
|
|
101
101
|
boardManual
|
|
102
102
|
} from "./boards.js";
|
|
103
|
-
export { archiveTicket, createComment, deleteTicket, restoreTicket, updateTicket } from "./tickets.js";
|
|
103
|
+
export { archiveTicket, createComment, createTicket, deleteTicket, restoreTicket, updateTicket } from "./tickets.js";
|
|
104
104
|
export { archivedTicketsForBoard } from "./queries.js";
|
|
105
105
|
export { getTicketBlockers, getTicketRelations } from "./relations.js";
|
|
106
106
|
export { checkpointTicket, claimNext, completeTicket, getContextPack, readComments, readTicket } from "./agent.js";
|
package/src/core/boards.js
CHANGED
|
File without changes
|
package/src/core/bootstrap.js
CHANGED
|
@@ -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)) {
|
|
@@ -105,7 +109,7 @@ export function getBootstrap(actor, requestedBoardId = null) {
|
|
|
105
109
|
JOIN states s ON s.id = t.state_id
|
|
106
110
|
JOIN boards b ON b.id = t.board_id
|
|
107
111
|
WHERE t.archived_at IS NULL
|
|
108
|
-
ORDER BY t.updated_at DESC`
|
|
112
|
+
ORDER BY t.updated_at DESC, t.id ASC`
|
|
109
113
|
)
|
|
110
114
|
.all()
|
|
111
115
|
.filter((ticket) => boardIds.has(ticket.board_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
|
File without changes
|
package/src/core/delete-board.js
CHANGED
|
File without changes
|
package/src/core/events.js
CHANGED
|
File without changes
|
package/src/core/http.js
CHANGED
|
File without changes
|
package/src/core/paths.js
CHANGED
|
File without changes
|
|
File without changes
|
package/src/core/queries.js
CHANGED
|
File without changes
|
package/src/core/registry.js
CHANGED
|
File without changes
|
package/src/core/relations.js
CHANGED
|
File without changes
|
package/src/core/router.js
CHANGED
|
@@ -107,7 +107,7 @@ export async function handleApi(req, res, url) {
|
|
|
107
107
|
|
|
108
108
|
// Bootstrap: read-only registry + one chosen board. No global state mutated.
|
|
109
109
|
if (req.method === "GET" && url.pathname === "/api/bootstrap") {
|
|
110
|
-
sendJson(res, 200, getBootstrap(actor, url.searchParams.get("board_id")));
|
|
110
|
+
sendJson(res, 200, getBootstrap(actor, url.searchParams.get("board_id"), url.searchParams.get("board")));
|
|
111
111
|
return;
|
|
112
112
|
}
|
|
113
113
|
|
package/src/core/search.js
CHANGED
|
@@ -16,41 +16,73 @@ export function searchTickets(args, ctx) {
|
|
|
16
16
|
if (!q) return { query: q, results: [] };
|
|
17
17
|
if (!canAccessBoard(actor, board)) return { query: q, results: [] };
|
|
18
18
|
|
|
19
|
+
const ticketNumber = ticketNumberQuery(q);
|
|
19
20
|
const fts = toFtsQuery(q);
|
|
20
21
|
let rows = [];
|
|
21
|
-
|
|
22
|
+
|
|
23
|
+
if (ticketNumber !== null) {
|
|
22
24
|
rows = db
|
|
23
25
|
.prepare(
|
|
24
|
-
`SELECT t.*, s.name AS state_name, b.slug AS board_slug,
|
|
25
|
-
|
|
26
|
-
FROM ticket_fts
|
|
27
|
-
JOIN tickets t ON t.id = ticket_fts.ticket_id
|
|
26
|
+
`SELECT t.*, s.name AS state_name, b.slug AS board_slug, -1 AS rank
|
|
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
|
|
30
|
-
WHERE
|
|
31
|
-
ORDER BY rank
|
|
30
|
+
WHERE t.board_id = ? AND t.number = ? AND t.archived_at IS NULL
|
|
32
31
|
LIMIT ?`
|
|
33
32
|
)
|
|
34
|
-
.all(
|
|
33
|
+
.all(board.id, ticketNumber, limit);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
rows = rows.concat(
|
|
38
|
+
db
|
|
39
|
+
.prepare(
|
|
40
|
+
`SELECT t.*, s.name AS state_name, b.slug AS board_slug,
|
|
41
|
+
bm25(ticket_fts) AS rank
|
|
42
|
+
FROM ticket_fts
|
|
43
|
+
JOIN tickets t ON t.id = ticket_fts.ticket_id
|
|
44
|
+
JOIN states s ON s.id = t.state_id
|
|
45
|
+
JOIN boards b ON b.id = t.board_id
|
|
46
|
+
WHERE ticket_fts MATCH ? AND t.board_id = ? AND t.archived_at IS NULL
|
|
47
|
+
ORDER BY rank
|
|
48
|
+
LIMIT ?`
|
|
49
|
+
)
|
|
50
|
+
.all(fts, board.id, limit)
|
|
51
|
+
);
|
|
35
52
|
} catch {
|
|
36
53
|
const like = `%${q}%`;
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
54
|
+
const numberRank = ticketNumber !== null ? "CASE WHEN t.number = ? THEN -1 ELSE 0 END" : "0";
|
|
55
|
+
const numberPredicate = ticketNumber !== null ? "OR t.number = ?" : "";
|
|
56
|
+
const fallbackArgs = ticketNumber !== null ? [ticketNumber, board.id] : [board.id];
|
|
57
|
+
fallbackArgs.push(like, like, like, like, like, like);
|
|
58
|
+
if (ticketNumber !== null) fallbackArgs.push(ticketNumber);
|
|
59
|
+
fallbackArgs.push(limit);
|
|
60
|
+
|
|
61
|
+
rows = rows.concat(
|
|
62
|
+
db
|
|
63
|
+
.prepare(
|
|
64
|
+
`SELECT DISTINCT t.*, s.name AS state_name, b.slug AS board_slug, ${numberRank} AS rank
|
|
65
|
+
FROM tickets t
|
|
66
|
+
JOIN states s ON s.id = t.state_id
|
|
67
|
+
JOIN boards b ON b.id = t.board_id
|
|
68
|
+
LEFT JOIN comments c ON c.ticket_id = t.id
|
|
69
|
+
WHERE t.board_id = ? AND t.archived_at IS NULL
|
|
70
|
+
AND (t.title LIKE ? OR t.description LIKE ? OR t.ai_plan LIKE ?
|
|
71
|
+
OR t.implementation_summary LIKE ? OR t.implementation_updates LIKE ? OR c.body LIKE ?
|
|
72
|
+
${numberPredicate})
|
|
73
|
+
ORDER BY t.updated_at DESC, t.id ASC
|
|
74
|
+
LIMIT ?`
|
|
75
|
+
)
|
|
76
|
+
.all(...fallbackArgs)
|
|
77
|
+
);
|
|
51
78
|
}
|
|
52
79
|
|
|
53
|
-
rows.sort((a, b) =>
|
|
80
|
+
rows.sort((a, b) => {
|
|
81
|
+
if (a.rank !== b.rank) return a.rank - b.rank;
|
|
82
|
+
const byTime = String(b.updated_at).localeCompare(String(a.updated_at));
|
|
83
|
+
if (byTime !== 0) return byTime;
|
|
84
|
+
return String(a.id).localeCompare(String(b.id));
|
|
85
|
+
});
|
|
54
86
|
|
|
55
87
|
const seen = new Set();
|
|
56
88
|
const deduped = [];
|
|
@@ -68,3 +100,10 @@ export function searchTickets(args, ctx) {
|
|
|
68
100
|
|
|
69
101
|
return { query: q, results };
|
|
70
102
|
}
|
|
103
|
+
|
|
104
|
+
function ticketNumberQuery(q) {
|
|
105
|
+
const match = String(q).trim().match(/^#?(\d+)$/);
|
|
106
|
+
if (!match) return null;
|
|
107
|
+
const number = Number(match[1]);
|
|
108
|
+
return Number.isSafeInteger(number) ? number : null;
|
|
109
|
+
}
|
package/src/core/seed.js
CHANGED
|
File without changes
|
package/src/core/snapshots.js
CHANGED
|
File without changes
|
package/src/core/states.js
CHANGED
|
File without changes
|
|
File without changes
|
package/src/core/tickets.js
CHANGED
|
@@ -17,6 +17,17 @@ import {
|
|
|
17
17
|
requiredString
|
|
18
18
|
} from "./util.js";
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Bump a ticket's `updated_at` without recording an event or touching other
|
|
22
|
+
* fields. Used to roll child activity up to the parent epic so the column sort
|
|
23
|
+
* (ORDER BY updated_at DESC) keeps the epic group near recent activity even
|
|
24
|
+
* when only its children changed. Pass `null`/undefined to no-op.
|
|
25
|
+
*/
|
|
26
|
+
export function bumpTicketUpdatedAt(db, ticketId, time) {
|
|
27
|
+
if (!ticketId) return;
|
|
28
|
+
db.prepare("UPDATE tickets SET updated_at = ? WHERE id = ?").run(time, ticketId);
|
|
29
|
+
}
|
|
30
|
+
|
|
20
31
|
export function createTicket(body, ctx) {
|
|
21
32
|
const { db, board, actor } = ctx;
|
|
22
33
|
// The router resolves `ctx.board` from path/body/query before we get here.
|
|
@@ -82,6 +93,7 @@ export function createTicket(body, ctx) {
|
|
|
82
93
|
parent_ticket_id: parentTicketId
|
|
83
94
|
});
|
|
84
95
|
reindexTicket(db, ticketId);
|
|
96
|
+
bumpTicketUpdatedAt(db, parentTicketId, time);
|
|
85
97
|
return ticketById(db, ticketId);
|
|
86
98
|
});
|
|
87
99
|
}
|
|
@@ -139,11 +151,12 @@ export function updateTicket(ticketId, body, ctx) {
|
|
|
139
151
|
}
|
|
140
152
|
|
|
141
153
|
return tx(db, () => {
|
|
154
|
+
const time = now();
|
|
142
155
|
if (fields.length > 0) {
|
|
143
156
|
const sets = fields.map((field) => `${field} = ?`);
|
|
144
157
|
const values = fields.map((field) => applied[field]);
|
|
145
158
|
sets.push("updated_at = ?");
|
|
146
|
-
values.push(
|
|
159
|
+
values.push(time, ticketId);
|
|
147
160
|
db.prepare(`UPDATE tickets SET ${sets.join(", ")} WHERE id = ?`).run(...values);
|
|
148
161
|
}
|
|
149
162
|
|
|
@@ -160,7 +173,7 @@ export function updateTicket(ticketId, body, ctx) {
|
|
|
160
173
|
db.prepare("INSERT OR IGNORE INTO ticket_labels (ticket_id, label_id) VALUES (?, ?)").run(ticketId, label.id);
|
|
161
174
|
}
|
|
162
175
|
if (fields.length === 0) {
|
|
163
|
-
db.prepare("UPDATE tickets SET updated_at = ? WHERE id = ?").run(
|
|
176
|
+
db.prepare("UPDATE tickets SET updated_at = ? WHERE id = ?").run(time, ticketId);
|
|
164
177
|
}
|
|
165
178
|
}
|
|
166
179
|
|
|
@@ -191,6 +204,13 @@ export function updateTicket(ticketId, body, ctx) {
|
|
|
191
204
|
}
|
|
192
205
|
}
|
|
193
206
|
reindexTicket(db, ticketId);
|
|
207
|
+
// Roll activity up to the parent epic so the column sort keeps the epic
|
|
208
|
+
// group near recent child activity. On reparent, bump both old and new
|
|
209
|
+
// parent — both views changed (one lost a child, the other gained one).
|
|
210
|
+
bumpTicketUpdatedAt(db, before.parent_ticket_id, time);
|
|
211
|
+
if (after.parent_ticket_id && after.parent_ticket_id !== before.parent_ticket_id) {
|
|
212
|
+
bumpTicketUpdatedAt(db, after.parent_ticket_id, time);
|
|
213
|
+
}
|
|
194
214
|
return after;
|
|
195
215
|
});
|
|
196
216
|
}
|
|
@@ -208,6 +228,7 @@ export function archiveTicket(ticketId, ctx) {
|
|
|
208
228
|
const time = now();
|
|
209
229
|
db.prepare("UPDATE tickets SET archived_at = ?, updated_at = ? WHERE id = ?").run(time, time, ticketId);
|
|
210
230
|
recordEvent(db, ticket.board_id, "ticket_archived", ticketId, actor.name, { title: ticket.title });
|
|
231
|
+
bumpTicketUpdatedAt(db, ticket.parent_ticket_id, time);
|
|
211
232
|
return ticketById(db, ticketId);
|
|
212
233
|
});
|
|
213
234
|
}
|
|
@@ -225,6 +246,7 @@ export function restoreTicket(ticketId, ctx) {
|
|
|
225
246
|
const time = now();
|
|
226
247
|
db.prepare("UPDATE tickets SET archived_at = NULL, updated_at = ? WHERE id = ?").run(time, ticketId);
|
|
227
248
|
recordEvent(db, ticket.board_id, "ticket_restored", ticketId, actor.name, { title: ticket.title });
|
|
249
|
+
bumpTicketUpdatedAt(db, ticket.parent_ticket_id, time);
|
|
228
250
|
return ticketById(db, ticketId);
|
|
229
251
|
});
|
|
230
252
|
}
|
|
@@ -246,6 +268,7 @@ export function deleteTicket(ticketId, ctx) {
|
|
|
246
268
|
});
|
|
247
269
|
db.prepare("DELETE FROM ticket_fts WHERE ticket_id = ?").run(ticketId);
|
|
248
270
|
db.prepare("DELETE FROM tickets WHERE id = ?").run(ticketId);
|
|
271
|
+
bumpTicketUpdatedAt(db, ticket.parent_ticket_id, now());
|
|
249
272
|
return { ok: true, deleted_id: ticketId };
|
|
250
273
|
});
|
|
251
274
|
}
|
|
@@ -262,10 +285,12 @@ export function createComment(ticketId, body, ctx) {
|
|
|
262
285
|
const kind = body.kind || (actor.type === "agent" ? "agent_note" : "human_comment");
|
|
263
286
|
|
|
264
287
|
return tx(db, () => {
|
|
288
|
+
const time = now();
|
|
265
289
|
const comment = addComment(db, ticketId, author, kind, commentBody);
|
|
266
|
-
db.prepare("UPDATE tickets SET updated_at = ? WHERE id = ?").run(
|
|
290
|
+
db.prepare("UPDATE tickets SET updated_at = ? WHERE id = ?").run(time, ticketId);
|
|
267
291
|
recordEvent(db, ticket.board_id, "comment_created", ticketId, actor.name, { kind: comment.kind });
|
|
268
292
|
reindexTicket(db, ticketId);
|
|
293
|
+
bumpTicketUpdatedAt(db, ticket.parent_ticket_id, time);
|
|
269
294
|
return comment;
|
|
270
295
|
});
|
|
271
296
|
}
|
package/src/core/util.js
CHANGED
|
File without changes
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { existsSync, mkdirSync } from "node:fs";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
function rpcError(code, message) {
|
|
5
|
+
const error = new Error(message);
|
|
6
|
+
error.code = code;
|
|
7
|
+
return error;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function orbitMode(env = process.env) {
|
|
11
|
+
const mode = String(env.ORBIT_MODE || "local").trim().toLowerCase();
|
|
12
|
+
if (mode === "remote" || mode === "http") return "remote";
|
|
13
|
+
if (mode === "local" || mode === "") return "local";
|
|
14
|
+
throw rpcError(-32602, `Unsupported ORBIT_MODE: ${env.ORBIT_MODE}`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function createOrbitClient(env = process.env) {
|
|
18
|
+
return orbitMode(env) === "remote" ? createHttpOrbitClient(env) : createLocalOrbitClient(env);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function createHttpOrbitClient(env = process.env, fetchImpl = globalThis.fetch) {
|
|
22
|
+
const apiUrl = String(env.ORBIT_API_URL || "").trim().replace(/\/+$/, "");
|
|
23
|
+
const defaultBoard = String(env.ORBIT_DEFAULT_BOARD || "").trim();
|
|
24
|
+
if (!apiUrl) throw rpcError(-32602, "ORBIT_MODE=remote requires ORBIT_API_URL.");
|
|
25
|
+
if (typeof fetchImpl !== "function") throw rpcError(-32603, "ORBIT_MODE=remote requires global fetch support.");
|
|
26
|
+
|
|
27
|
+
async function request(method, path, { query = {}, body, ok = [200] } = {}) {
|
|
28
|
+
const url = new URL(`${apiUrl}${path}`);
|
|
29
|
+
for (const [key, value] of Object.entries(query)) {
|
|
30
|
+
if (value !== undefined && value !== null && value !== "") url.searchParams.set(key, String(value));
|
|
31
|
+
}
|
|
32
|
+
const response = await fetchImpl(url, {
|
|
33
|
+
method,
|
|
34
|
+
headers: body === undefined ? undefined : { "Content-Type": "application/json" },
|
|
35
|
+
body: body === undefined ? undefined : JSON.stringify(body)
|
|
36
|
+
});
|
|
37
|
+
let payload = null;
|
|
38
|
+
const text = await response.text();
|
|
39
|
+
if (text) {
|
|
40
|
+
try { payload = JSON.parse(text); } catch { payload = { message: text }; }
|
|
41
|
+
}
|
|
42
|
+
if (!ok.includes(response.status)) {
|
|
43
|
+
const message = payload?.message || payload?.error || `Orbit API ${method} ${url.pathname} failed with HTTP ${response.status}`;
|
|
44
|
+
throw rpcError(-32000, message);
|
|
45
|
+
}
|
|
46
|
+
return payload ?? {};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function boardArg(args = {}) {
|
|
50
|
+
return args.board_id || args.board_slug || args.board || defaultBoard;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function requireBoard(args = {}) {
|
|
54
|
+
const board = boardArg(args);
|
|
55
|
+
if (!board) throw rpcError(-32602, "Remote Orbit MCP requires board_id/board_slug/board or ORBIT_DEFAULT_BOARD.");
|
|
56
|
+
return board;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const ticketQuery = (args = {}) => ({ board_id: args.board_id, board: args.board_slug || args.board || defaultBoard });
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
mode: "remote",
|
|
63
|
+
sessionLabel: apiUrl,
|
|
64
|
+
async boardContext(args = {}) {
|
|
65
|
+
return request("GET", `/api/boards/${encodeURIComponent(requireBoard(args))}/context`, { query: { include_struck: args.include_struck ? "true" : undefined } });
|
|
66
|
+
},
|
|
67
|
+
async boardList() { return request("GET", "/api/boards"); },
|
|
68
|
+
async boardSetActive(args = {}) { if (!args.slug) throw rpcError(-32602, "slug is required."); env.ORBIT_DEFAULT_BOARD = String(args.slug); return { ok: true, slug: String(args.slug), mode: "remote" }; },
|
|
69
|
+
async claimNext(args = {}) { return request("POST", "/api/agent/claim-next", { body: { ...args, board: requireBoard(args) } }); },
|
|
70
|
+
async getTicketContext(args = {}) { return request("GET", `/api/tickets/${encodeURIComponent(args.ticket_id)}/context`, { query: { ...ticketQuery(args), depth: args.depth || 1 } }); },
|
|
71
|
+
async readTicket(args = {}) {
|
|
72
|
+
if (args.ticket_id) return this.getTicketContext({ ...args, depth: 1 });
|
|
73
|
+
const found = await this.search({ q: args.title || String(args.number || ""), limit: 10, ...args });
|
|
74
|
+
return found;
|
|
75
|
+
},
|
|
76
|
+
async readComments(args = {}) { const ctx = await this.readTicket(args); return { comments: ctx.comments || ctx.ticket?.comments || [] }; },
|
|
77
|
+
async getTicketRelations(args = {}) { return request("GET", `/api/tickets/${encodeURIComponent(args.ticket_id)}/relations`, { query: ticketQuery(args) }); },
|
|
78
|
+
async getTicketBlockers(args = {}) { return request("GET", `/api/tickets/${encodeURIComponent(args.ticket_id)}/blockers`, { query: ticketQuery(args) }); },
|
|
79
|
+
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 } }); },
|
|
80
|
+
async createTicket(args = {}) { return request("POST", "/api/tickets", { body: { ...args, board: requireBoard(args) }, ok: [201] }); },
|
|
81
|
+
async updateTicket({ ticket_id, ...patch }) { return request("PATCH", `/api/tickets/${encodeURIComponent(ticket_id)}`, { query: ticketQuery(patch), body: patch }); },
|
|
82
|
+
async addComment({ ticket_id, ...body }) { return request("POST", `/api/tickets/${encodeURIComponent(ticket_id)}/comments`, { body, ok: [201] }); },
|
|
83
|
+
async addBoardEntry(args = {}) { return request("POST", `/api/boards/${encodeURIComponent(requireBoard(args))}/entries`, { body: args, ok: [201] }); },
|
|
84
|
+
async checkpoint(args = {}) { return request("POST", "/api/agent/checkpoint", { body: args }); },
|
|
85
|
+
async complete(args = {}) { return request("POST", "/api/agent/complete", { body: args }); },
|
|
86
|
+
async archiveTicket(args = {}) { return request("POST", `/api/tickets/${encodeURIComponent(args.ticket_id)}/archive`, { query: ticketQuery(args) }); },
|
|
87
|
+
async restoreTicket(args = {}) { return request("POST", `/api/tickets/${encodeURIComponent(args.ticket_id)}/restore`, { query: ticketQuery(args) }); },
|
|
88
|
+
async deleteTicket(args = {}) { return request("DELETE", `/api/tickets/${encodeURIComponent(args.ticket_id)}`, { query: ticketQuery(args) }); },
|
|
89
|
+
async listArchive(args = {}) { return request("GET", `/api/boards/${encodeURIComponent(requireBoard(args))}/archive`); },
|
|
90
|
+
async exportBoard(args = {}) { return request("GET", `/api/boards/${encodeURIComponent(requireBoard(args))}/export`); },
|
|
91
|
+
async updateSettings(args = {}) { const { board_id, board_slug, board, ...patch } = args; return request("PATCH", `/api/boards/${encodeURIComponent(requireBoard(args))}`, { body: patch }); }
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function createLocalOrbitClient(env = process.env) {
|
|
96
|
+
const board = await import("../core/board.js");
|
|
97
|
+
const dbMod = await import("../core/db.js");
|
|
98
|
+
const registry = await import("../core/registry.js");
|
|
99
|
+
const backups = await import("../core/backups.js");
|
|
100
|
+
const seed = await import("../core/seed.js");
|
|
101
|
+
const util = await import("../core/util.js");
|
|
102
|
+
|
|
103
|
+
let sessionBoardRow = null;
|
|
104
|
+
const setSessionBoard = (row) => { sessionBoardRow = row; if (row) registry.touchBoardActive(row.id); };
|
|
105
|
+
const getSessionBoard = () => sessionBoardRow;
|
|
106
|
+
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
|
+
|
|
121
|
+
const start = util.normalizePath(env.PROJECT_ROOT ? resolve(env.PROJECT_ROOT) : process.cwd());
|
|
122
|
+
const boardRoot = walkUp(start, (dir) => existsSync(join(dir, ".orbit", "board.db")));
|
|
123
|
+
if (boardRoot) {
|
|
124
|
+
const root = util.normalizePath(boardRoot);
|
|
125
|
+
const existing = registry.getBoardByRepoPath(root);
|
|
126
|
+
if (existing) setSessionBoard(existing);
|
|
127
|
+
else {
|
|
128
|
+
const dbPath = join(boardRoot, ".orbit", "board.db");
|
|
129
|
+
const db = dbMod.openConnection(dbPath);
|
|
130
|
+
dbMod.createBoardSchema(db);
|
|
131
|
+
const seeded = seed.seedIfEmpty(db, root);
|
|
132
|
+
if (seeded) {
|
|
133
|
+
const t = util.now();
|
|
134
|
+
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 });
|
|
135
|
+
}
|
|
136
|
+
const fresh = registry.getBoardByRepoPath(root);
|
|
137
|
+
if (fresh) { registry.syncRegistryFromBoardDb(fresh, db); setSessionBoard(fresh); }
|
|
138
|
+
}
|
|
139
|
+
} else {
|
|
140
|
+
const gitRoot = walkUp(start, (dir) => existsSync(join(dir, ".git")));
|
|
141
|
+
createBoardAtRoot(util.normalizePath(gitRoot || start));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const ctxFor = (row, actor) => {
|
|
145
|
+
if (!row) throw rpcError(-32602, "No board in session; pass board_id or board_slug, or call board_set_active first.");
|
|
146
|
+
return { actor, board: row, db: registry.openBoardDb(row) };
|
|
147
|
+
};
|
|
148
|
+
const sessionCtx = (actor) => ctxFor(getSessionBoard(), actor);
|
|
149
|
+
const backup = (ctx, result) => { backups.scheduleAutomaticBoardBackup(ctx.board, ctx.db); return result; };
|
|
150
|
+
const explicit = (args = {}) => args.board_id ? registry.getBoardByRegistryId(args.board_id) : (args.board_slug || args.board ? registry.getBoardBySlug(args.board_slug || args.board) : null);
|
|
151
|
+
const rowOrSession = (args = {}) => explicit(args) || getSessionBoard();
|
|
152
|
+
const actor = () => board.localAgentActor();
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
mode: "local",
|
|
156
|
+
sessionLabel: () => getSessionBoard()?.db_path || "(none)",
|
|
157
|
+
boardContext: (args = {}) => { const ctx = ctxFor(rowOrSession(args), actor()); return board.getBoardContext(ctx.board.id, ctx, { includeStruck: Boolean(args.include_struck) }); },
|
|
158
|
+
boardList: () => ({ boards: registry.listBoards().map((row) => ({ id: row.id, slug: row.slug, name: row.name, repo_path: row.repo_path })) }),
|
|
159
|
+
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 }; },
|
|
160
|
+
claimNext: (args = {}) => { const ctx = ctxFor(rowOrSession(args), actor()); return backup(ctx, board.claimNext(args, ctx)); },
|
|
161
|
+
getTicketContext: (args = {}) => board.getContextPack(args.ticket_id, sessionCtx(actor()), Number(args.depth || 1)),
|
|
162
|
+
readTicket: (args = {}) => board.readTicket(args, sessionCtx(actor())),
|
|
163
|
+
readComments: (args = {}) => board.readComments(args, sessionCtx(actor())),
|
|
164
|
+
getTicketRelations: (args = {}) => board.getTicketRelations(args.ticket_id, sessionCtx(actor())),
|
|
165
|
+
getTicketBlockers: (args = {}) => board.getTicketBlockers(args.ticket_id, sessionCtx(actor())),
|
|
166
|
+
search: (args = {}) => { const ctx = ctxFor(rowOrSession(args), actor()); return board.searchTickets({ q: args.q, limit: args.limit }, ctx); },
|
|
167
|
+
createTicket: (args = {}) => { const ctx = ctxFor(rowOrSession(args), actor()); const { board_slug, board: _b, ...body } = args; body.board_id = ctx.board.id; return backup(ctx, board.createTicket(body, ctx)); },
|
|
168
|
+
updateTicket: ({ ticket_id, ...patch }) => { const ctx = sessionCtx(actor()); return backup(ctx, board.updateTicket(ticket_id, patch, ctx)); },
|
|
169
|
+
addComment: ({ ticket_id, ...body }) => { const ctx = sessionCtx(actor()); return backup(ctx, board.createComment(ticket_id, body, ctx)); },
|
|
170
|
+
addBoardEntry: (args = {}) => { const ctx = ctxFor(rowOrSession(args), actor()); return backup(ctx, board.createBoardEntry(ctx.board.id, args, ctx)); },
|
|
171
|
+
checkpoint: (args = {}) => { const ctx = sessionCtx(actor()); return backup(ctx, board.checkpointTicket(args, ctx)); },
|
|
172
|
+
complete: (args = {}) => { const ctx = sessionCtx(actor()); return backup(ctx, board.completeTicket(args, ctx)); },
|
|
173
|
+
archiveTicket: (args = {}) => { const ctx = sessionCtx(actor()); return backup(ctx, board.archiveTicket(args.ticket_id, ctx)); },
|
|
174
|
+
restoreTicket: (args = {}) => { const ctx = sessionCtx(actor()); return backup(ctx, board.restoreTicket(args.ticket_id, ctx)); },
|
|
175
|
+
deleteTicket: (args = {}) => { const ctx = sessionCtx(actor()); return backup(ctx, board.deleteTicket(args.ticket_id, ctx)); },
|
|
176
|
+
listArchive: (args = {}) => { const ctx = ctxFor(rowOrSession(args), actor()); return { tickets: board.archivedTicketsForBoard(ctx.db, ctx.board.id) }; },
|
|
177
|
+
exportBoard: (args = {}) => { const ctx = ctxFor(rowOrSession(args), actor()); return board.exportBoard(ctx.board.id, ctx); },
|
|
178
|
+
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
|
+
};
|
|
180
|
+
}
|