@ianwremmel/dispatch 0.32.1-bootstrap.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/.claude-plugin/plugin.json +59 -0
- package/.mcp.json +8 -0
- package/LICENSE +21 -0
- package/README.md +93 -0
- package/agents/.gitkeep +0 -0
- package/agents/build-graph.md +99 -0
- package/agents/milestone-reviewer.md +50 -0
- package/agents/pr-worker.md +172 -0
- package/agents/ticket-worker.md +97 -0
- package/bin/dispatch +101 -0
- package/bin/dispatch-mcp +19 -0
- package/bin/pr-status +931 -0
- package/commands/.gitkeep +0 -0
- package/commands/orchestrate.md +6 -0
- package/hooks/.gitkeep +0 -0
- package/hooks/claim-guard.mts +98 -0
- package/hooks/hooks.json +15 -0
- package/package.json +46 -0
- package/skills/.gitkeep +0 -0
- package/skills/land/SKILL.md +238 -0
- package/skills/land/credentials-dedicated.md +33 -0
- package/skills/land/credentials-shared.md +76 -0
- package/skills/land/mode-solo.md +76 -0
- package/skills/land/mode-team.md +103 -0
- package/skills/land/reference.md +152 -0
- package/skills/land/ticket.md +94 -0
- package/skills/orchestrate/SKILL.md +87 -0
- package/skills/tracker-adapter-linear/SKILL.md +142 -0
- package/src/commands/CLAUDE.md +12 -0
- package/src/commands/claim/check.mts +88 -0
- package/src/commands/claim/guard.mts +95 -0
- package/src/commands/claim/status.mts +49 -0
- package/src/commands/edge/add.mts +44 -0
- package/src/commands/edge/rm.mts +44 -0
- package/src/commands/edge/set.mts +56 -0
- package/src/commands/greet.mts +34 -0
- package/src/commands/mcp/ack.mts +43 -0
- package/src/commands/mcp/ping.mts +61 -0
- package/src/commands/mcp/status.mts +89 -0
- package/src/commands/mcp.mts +155 -0
- package/src/commands/milestone/rm.mts +35 -0
- package/src/commands/milestone/set.mts +49 -0
- package/src/commands/outcome/rm.mts +36 -0
- package/src/commands/outcome/set.mts +86 -0
- package/src/commands/pr/rm.mts +33 -0
- package/src/commands/pr/set.mts +110 -0
- package/src/commands/pr/yield.mts +114 -0
- package/src/commands/project/rm.mts +33 -0
- package/src/commands/project/set.mts +50 -0
- package/src/commands/queue.mts +41 -0
- package/src/commands/refresh/done.mts +42 -0
- package/src/commands/refresh/status.mts +40 -0
- package/src/commands/refresh.mts +56 -0
- package/src/commands/review/record.mts +46 -0
- package/src/commands/review/release.mts +49 -0
- package/src/commands/status.mts +85 -0
- package/src/commands/ticket/missing.mts +31 -0
- package/src/commands/ticket/rm.mts +33 -0
- package/src/commands/ticket/set.mts +134 -0
- package/src/commands/worker/rm.mts +46 -0
- package/src/commands/worker/set.mts +63 -0
- package/src/lib/cli/CLAUDE.md +13 -0
- package/src/lib/cli/cli.mts +226 -0
- package/src/lib/cli/index.mts +1 -0
- package/src/lib/command/CLAUDE.md +26 -0
- package/src/lib/command/__fixtures__/bad-export/oops.mts +1 -0
- package/src/lib/command/__fixtures__/bad-name/mismatch.mts +19 -0
- package/src/lib/command/__fixtures__/commands/cli-only.mts +20 -0
- package/src/lib/command/__fixtures__/commands/greet.mts +39 -0
- package/src/lib/command/__fixtures__/commands/math/add.mts +32 -0
- package/src/lib/command/__fixtures__/commands/mcp-only.mts +20 -0
- package/src/lib/command/__fixtures__/commands/needs-token.mts +19 -0
- package/src/lib/command/__fixtures__/commands/store/get.mts +26 -0
- package/src/lib/command/__fixtures__/commands/store.mts +26 -0
- package/src/lib/command/abstract-command.mts +104 -0
- package/src/lib/command/discovery.mts +100 -0
- package/src/lib/command/env.mts +19 -0
- package/src/lib/command/index.mts +6 -0
- package/src/lib/command/parse.mts +64 -0
- package/src/lib/command/test-support.mts +81 -0
- package/src/lib/command/transports.mts +17 -0
- package/src/lib/command/types.mts +53 -0
- package/src/lib/db/CLAUDE.md +13 -0
- package/src/lib/db/database.mts +160 -0
- package/src/lib/db/index.mts +4 -0
- package/src/lib/db/schema.mts +195 -0
- package/src/lib/db/time.mts +24 -0
- package/src/lib/db/with-database.mts +56 -0
- package/src/lib/errors/CLAUDE.md +18 -0
- package/src/lib/errors/command-error.mts +13 -0
- package/src/lib/errors/data-error.mts +12 -0
- package/src/lib/errors/definition-error.mts +6 -0
- package/src/lib/errors/dispatch-error.mts +27 -0
- package/src/lib/errors/ensure.mts +22 -0
- package/src/lib/errors/environment-error.mts +7 -0
- package/src/lib/errors/index.mts +8 -0
- package/src/lib/errors/json-rpc-error.mts +18 -0
- package/src/lib/errors/usage-error.mts +7 -0
- package/src/lib/graph/CLAUDE.md +17 -0
- package/src/lib/graph/anomalies.mts +110 -0
- package/src/lib/graph/derive.mts +96 -0
- package/src/lib/graph/index.mts +26 -0
- package/src/lib/graph/pipeline.mts +410 -0
- package/src/lib/graph/queries.mts +207 -0
- package/src/lib/graph/rows.mts +99 -0
- package/src/lib/graph/types.mts +137 -0
- package/src/lib/liveness/CLAUDE.md +14 -0
- package/src/lib/liveness/index.mts +10 -0
- package/src/lib/liveness/liveness.mts +147 -0
- package/src/lib/liveness/retire.mts +63 -0
- package/src/lib/logger/CLAUDE.md +12 -0
- package/src/lib/logger/index.mts +2 -0
- package/src/lib/logger/logger.mts +58 -0
- package/src/lib/logger/stream-sink.mts +23 -0
- package/src/lib/mcp/CLAUDE.md +21 -0
- package/src/lib/mcp/channel.mts +41 -0
- package/src/lib/mcp/dispatch.mts +60 -0
- package/src/lib/mcp/drain.mts +83 -0
- package/src/lib/mcp/index.mts +5 -0
- package/src/lib/mcp/mcp.mts +267 -0
- package/src/lib/mcp/tools.mts +77 -0
- package/src/lib/model/CLAUDE.md +8 -0
- package/src/lib/model/index.mts +3 -0
- package/src/lib/model/repo-caps.mts +95 -0
- package/src/lib/model/status.mts +91 -0
- package/src/lib/model/types.mts +83 -0
- package/src/lib/refresh/index.mts +2 -0
- package/src/lib/refresh/placeholders.mts +43 -0
- package/src/lib/refresh/refresh-service.mts +203 -0
- package/src/lib/schedule/CLAUDE.md +18 -0
- package/src/lib/schedule/caps.mts +113 -0
- package/src/lib/schedule/correlate.mts +69 -0
- package/src/lib/schedule/index.mts +7 -0
- package/src/lib/schedule/scheduler.mts +355 -0
- package/src/lib/schedule/tick.mts +266 -0
- package/src/lib/stores/CLAUDE.md +24 -0
- package/src/lib/stores/coordination.mts +359 -0
- package/src/lib/stores/cursor.mts +41 -0
- package/src/lib/stores/edge.mts +138 -0
- package/src/lib/stores/fetch-request.mts +346 -0
- package/src/lib/stores/index.mts +19 -0
- package/src/lib/stores/materialize.mts +69 -0
- package/src/lib/stores/milestone.mts +74 -0
- package/src/lib/stores/notice.mts +57 -0
- package/src/lib/stores/policy.mts +48 -0
- package/src/lib/stores/pr-event.mts +94 -0
- package/src/lib/stores/pr.mts +167 -0
- package/src/lib/stores/project.mts +79 -0
- package/src/lib/stores/refresh.mts +197 -0
- package/src/lib/stores/review.mts +113 -0
- package/src/lib/stores/session.mts +170 -0
- package/src/lib/stores/ticket.mts +246 -0
- package/src/lib/stores/watch.mts +360 -0
- package/src/lib/stores/worker.mts +121 -0
- package/src/lib/watch/adopt.mts +151 -0
- package/src/lib/watch/arm.mts +48 -0
- package/src/lib/watch/cadence.mts +45 -0
- package/src/lib/watch/diff.mts +274 -0
- package/src/lib/watch/index.mts +11 -0
- package/src/lib/watch/marker.mts +24 -0
- package/src/lib/watch/payload.mts +56 -0
- package/src/lib/watch/poll.mts +87 -0
- package/src/lib/watch/render.mts +61 -0
- package/src/lib/watch/snapshot.mts +312 -0
- package/src/main.mts +18 -0
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import type {Database} from '../db/database.mts';
|
|
2
|
+
import {assertInstant} from '../db/time.mts';
|
|
3
|
+
import {DataError, ensure} from '../errors/index.mts';
|
|
4
|
+
import type {Session} from '../model/types.mts';
|
|
5
|
+
|
|
6
|
+
/* eslint-disable @typescript-eslint/require-await --
|
|
7
|
+
* Async facade over synchronous `node:sqlite`; see `../db/database.mts`. */
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Sessions: one row per live MCP server process, the only liveness primitive.
|
|
11
|
+
* Claims reference a session and cascade when it is deleted — on a
|
|
12
|
+
* clean `close`, or when `sweepStale` reaps a process whose heartbeat stopped.
|
|
13
|
+
*/
|
|
14
|
+
export class SessionStore {
|
|
15
|
+
readonly #db: Database;
|
|
16
|
+
|
|
17
|
+
constructor(db: Database) {
|
|
18
|
+
this.#db = db;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async register(session: {
|
|
22
|
+
id: string;
|
|
23
|
+
host?: string | null;
|
|
24
|
+
pid?: number | null;
|
|
25
|
+
claudeSessionId?: string | null;
|
|
26
|
+
startedAt: string;
|
|
27
|
+
heartbeatAt: string;
|
|
28
|
+
}): Promise<void> {
|
|
29
|
+
assertInstant(session.startedAt, 'startedAt');
|
|
30
|
+
assertInstant(session.heartbeatAt, 'heartbeatAt');
|
|
31
|
+
this.#db.run(
|
|
32
|
+
`INSERT INTO session (id, host, pid, claude_session_id, started_at, heartbeat_at)
|
|
33
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
34
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
35
|
+
host = excluded.host, pid = excluded.pid,
|
|
36
|
+
claude_session_id = excluded.claude_session_id,
|
|
37
|
+
heartbeat_at = excluded.heartbeat_at`,
|
|
38
|
+
[
|
|
39
|
+
session.id,
|
|
40
|
+
session.host ?? null,
|
|
41
|
+
session.pid ?? null,
|
|
42
|
+
session.claudeSessionId ?? null,
|
|
43
|
+
session.startedAt,
|
|
44
|
+
session.heartbeatAt,
|
|
45
|
+
]
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Record the session's positive acknowledgement of a probe, stamping the
|
|
51
|
+
* acking process's Claude session id onto the row — the one write where the
|
|
52
|
+
* server and the session it serves can be made to agree.
|
|
53
|
+
*/
|
|
54
|
+
async ack(
|
|
55
|
+
id: string,
|
|
56
|
+
claudeSessionId: string | null,
|
|
57
|
+
at: string
|
|
58
|
+
): Promise<boolean> {
|
|
59
|
+
assertInstant(at, 'at');
|
|
60
|
+
// An acking process with no session id of its own (an operator shell) must
|
|
61
|
+
// not wipe the id the server registered — that id is the caller correlator.
|
|
62
|
+
return (
|
|
63
|
+
this.#db.run(
|
|
64
|
+
`UPDATE session
|
|
65
|
+
SET acked_at = ?, claude_session_id = COALESCE(?, claude_session_id)
|
|
66
|
+
WHERE id = ?`,
|
|
67
|
+
[at, claudeSessionId, id]
|
|
68
|
+
) > 0
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Heartbeat-fresh sessions carrying a Claude session id, for correlating a
|
|
74
|
+
* caller to its own server. Freshness is this store's half of liveness;
|
|
75
|
+
* callers judge the other half — whether the registered process still
|
|
76
|
+
* exists — through `lib/liveness`.
|
|
77
|
+
*/
|
|
78
|
+
async liveForCaller(
|
|
79
|
+
claudeSessionId: string,
|
|
80
|
+
now: string,
|
|
81
|
+
windowSeconds: number
|
|
82
|
+
): Promise<Session[]> {
|
|
83
|
+
assertInstant(now, 'now');
|
|
84
|
+
return this.#db
|
|
85
|
+
.all(
|
|
86
|
+
`SELECT id, host, pid, claude_session_id, acked_at, started_at, heartbeat_at
|
|
87
|
+
FROM session
|
|
88
|
+
WHERE claude_session_id = ?
|
|
89
|
+
AND unixepoch(?) - unixepoch(heartbeat_at) <= ?
|
|
90
|
+
ORDER BY id`,
|
|
91
|
+
[claudeSessionId, now, windowSeconds]
|
|
92
|
+
)
|
|
93
|
+
.map(toSession);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Every row carrying this Claude session id, fresh or not. */
|
|
97
|
+
async forCaller(claudeSessionId: string): Promise<Session[]> {
|
|
98
|
+
return this.#db
|
|
99
|
+
.all(
|
|
100
|
+
`SELECT id, host, pid, claude_session_id, acked_at, started_at, heartbeat_at
|
|
101
|
+
FROM session WHERE claude_session_id = ? ORDER BY id`,
|
|
102
|
+
[claudeSessionId]
|
|
103
|
+
)
|
|
104
|
+
.map(toSession);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async heartbeat(id: string, at: string): Promise<boolean> {
|
|
108
|
+
assertInstant(at, 'at');
|
|
109
|
+
return (
|
|
110
|
+
this.#db.run('UPDATE session SET heartbeat_at = ? WHERE id = ?', [
|
|
111
|
+
at,
|
|
112
|
+
id,
|
|
113
|
+
]) > 0
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Clean exit: delete the session; its claims cascade. */
|
|
118
|
+
async close(id: string): Promise<boolean> {
|
|
119
|
+
return this.#db.run('DELETE FROM session WHERE id = ?', [id]) > 0;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Reap sessions whose heartbeat is older than `windowSeconds` before `now`.
|
|
124
|
+
* The staleness sweep is the only place liveness is judged by age. Returns the
|
|
125
|
+
* number of sessions removed (their claims cascade).
|
|
126
|
+
*/
|
|
127
|
+
async sweepStale(now: string, windowSeconds: number): Promise<number> {
|
|
128
|
+
assertInstant(now, 'now');
|
|
129
|
+
ensure(
|
|
130
|
+
Number.isFinite(windowSeconds) && windowSeconds >= 0,
|
|
131
|
+
() =>
|
|
132
|
+
new DataError(`"${String(windowSeconds)}" is not a staleness window`, {
|
|
133
|
+
hint: 'pass a non-negative number of seconds; a negative window would sweep every live session.',
|
|
134
|
+
})
|
|
135
|
+
);
|
|
136
|
+
return this.#db.run(
|
|
137
|
+
'DELETE FROM session WHERE unixepoch(?) - unixepoch(heartbeat_at) > ?',
|
|
138
|
+
[now, windowSeconds]
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async getSession(id: string): Promise<Session | null> {
|
|
143
|
+
const row = this.#db.get(
|
|
144
|
+
`SELECT id, host, pid, claude_session_id, acked_at, started_at, heartbeat_at
|
|
145
|
+
FROM session WHERE id = ?`,
|
|
146
|
+
[id]
|
|
147
|
+
);
|
|
148
|
+
if (row === undefined) return null;
|
|
149
|
+
return toSession(row);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/* eslint-disable @typescript-eslint/no-base-to-string --
|
|
154
|
+
* SQLite hands back `unknown`; `String()` converts a primitive rather than
|
|
155
|
+
* asserting a type the row has not been checked for. */
|
|
156
|
+
function toSession(row: Record<string, unknown>): Session {
|
|
157
|
+
return {
|
|
158
|
+
id: String(row.id),
|
|
159
|
+
host: row.host === null ? null : String(row.host),
|
|
160
|
+
pid: row.pid === null ? null : Number(row.pid),
|
|
161
|
+
claudeSessionId:
|
|
162
|
+
row.claude_session_id === null ? null : String(row.claude_session_id),
|
|
163
|
+
ackedAt: row.acked_at === null ? null : String(row.acked_at),
|
|
164
|
+
startedAt: String(row.started_at),
|
|
165
|
+
heartbeatAt: String(row.heartbeat_at),
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
/* eslint-enable @typescript-eslint/no-base-to-string */
|
|
169
|
+
|
|
170
|
+
/* eslint-enable @typescript-eslint/require-await */
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import {assertInstant} from '../db/time.mts';
|
|
2
|
+
import type {Database} from '../db/database.mts';
|
|
3
|
+
import {DataError, ensure, UsageError} from '../errors/index.mts';
|
|
4
|
+
import {
|
|
5
|
+
isStatus,
|
|
6
|
+
isTargetKind,
|
|
7
|
+
STATUS_LIST,
|
|
8
|
+
TARGET_KIND_LIST,
|
|
9
|
+
} from '../model/status.mts';
|
|
10
|
+
import type {Ticket} from '../model/types.mts';
|
|
11
|
+
import {findNode, materialize} from './materialize.mts';
|
|
12
|
+
|
|
13
|
+
/* eslint-disable @typescript-eslint/require-await --
|
|
14
|
+
* Async facade over synchronous `node:sqlite`; see `../db/database.mts`. */
|
|
15
|
+
|
|
16
|
+
/** A partial write: `id` names the ticket, every other key is optional. */
|
|
17
|
+
export type TicketPatch = Partial<Omit<Ticket, 'id'>> & {id: string};
|
|
18
|
+
|
|
19
|
+
export class TicketStore {
|
|
20
|
+
readonly #db: Database;
|
|
21
|
+
|
|
22
|
+
constructor(db: Database) {
|
|
23
|
+
this.#db = db;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async upsertTicket(ticket: Ticket): Promise<void> {
|
|
27
|
+
this.#validate(ticket);
|
|
28
|
+
await this.#db.transaction(() => {
|
|
29
|
+
this.#apply(ticket, false);
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Write only the fields the caller named, leaving the rest as they are.
|
|
35
|
+
*
|
|
36
|
+
* A re-read answers with what the tracker returned for the fields it was
|
|
37
|
+
* asked about; it is not a declaration that everything absent is now empty.
|
|
38
|
+
* Assigning every column from a partial write blanks the title, url, and
|
|
39
|
+
* labels of a ticket the graph already knew, which is how a ranked, labelled
|
|
40
|
+
* ticket turns into an untitled one nobody can act on.
|
|
41
|
+
*
|
|
42
|
+
* Omitting a field preserves it; passing an explicit value sets it. A ticket
|
|
43
|
+
* the graph has never seen still needs `project` and `status`, since there is
|
|
44
|
+
* nothing to fall back to.
|
|
45
|
+
*/
|
|
46
|
+
async patchTicket(patch: TicketPatch): Promise<void> {
|
|
47
|
+
this.#validate(patch);
|
|
48
|
+
await this.#db.transaction(() => {
|
|
49
|
+
this.#apply(patch, true);
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
#validate(input: TicketPatch): void {
|
|
54
|
+
const {status, targetKind} = input;
|
|
55
|
+
if (status !== undefined) {
|
|
56
|
+
ensure(
|
|
57
|
+
isStatus(status),
|
|
58
|
+
() =>
|
|
59
|
+
new DataError(`"${status}" is not a status`, {
|
|
60
|
+
hint: `use one of: ${STATUS_LIST}.`,
|
|
61
|
+
})
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
if (targetKind !== undefined) {
|
|
65
|
+
ensure(
|
|
66
|
+
isTargetKind(targetKind),
|
|
67
|
+
() =>
|
|
68
|
+
new DataError(`"${targetKind}" is not a target kind`, {
|
|
69
|
+
hint: `use one of: ${TARGET_KIND_LIST}.`,
|
|
70
|
+
})
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
if (input.updatedAt != null) assertInstant(input.updatedAt, 'updatedAt');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
#apply(input: TicketPatch, merge: boolean): void {
|
|
77
|
+
const ticket = merge ? this.#merge(input) : (input as Ticket);
|
|
78
|
+
{
|
|
79
|
+
// The project must already be recorded: silently materializing a
|
|
80
|
+
// placeholder here re-parents the ticket onto an unknown-kind node the
|
|
81
|
+
// refresh cadence's project join can never see, so a session passing
|
|
82
|
+
// the project's name where the id belongs corrupts the graph without
|
|
83
|
+
// any error. Edges tolerate dangling references; a ticket's project
|
|
84
|
+
// does not.
|
|
85
|
+
const project = findNode(this.#db, ticket.project);
|
|
86
|
+
ensure(
|
|
87
|
+
project !== null && project.kind === 'project',
|
|
88
|
+
() =>
|
|
89
|
+
new UsageError(`"${ticket.project}" is not a recorded project`, {
|
|
90
|
+
hint: 'record it first with `dispatch project set`, and pass the project id — a project name is not its id.',
|
|
91
|
+
})
|
|
92
|
+
);
|
|
93
|
+
const projectId = project.id;
|
|
94
|
+
const nodeId = materialize(this.#db, ticket.id, 'ticket');
|
|
95
|
+
const previous = this.#db.get(
|
|
96
|
+
'SELECT status FROM ticket WHERE node_id = ?',
|
|
97
|
+
[nodeId]
|
|
98
|
+
);
|
|
99
|
+
this.#db.run(
|
|
100
|
+
`INSERT INTO ticket (
|
|
101
|
+
node_id, project_id, url, title, status, target_kind,
|
|
102
|
+
requires_human, injected, priority, branch_hint, labels, updated_at
|
|
103
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
104
|
+
ON CONFLICT(node_id) DO UPDATE SET
|
|
105
|
+
project_id = excluded.project_id, url = excluded.url,
|
|
106
|
+
title = excluded.title, status = excluded.status,
|
|
107
|
+
target_kind = excluded.target_kind,
|
|
108
|
+
requires_human = excluded.requires_human,
|
|
109
|
+
injected = excluded.injected, priority = excluded.priority,
|
|
110
|
+
branch_hint = excluded.branch_hint, labels = excluded.labels,
|
|
111
|
+
updated_at = excluded.updated_at`,
|
|
112
|
+
[
|
|
113
|
+
nodeId,
|
|
114
|
+
projectId,
|
|
115
|
+
ticket.url,
|
|
116
|
+
ticket.title,
|
|
117
|
+
ticket.status,
|
|
118
|
+
ticket.targetKind,
|
|
119
|
+
ticket.requiresHuman ? 1 : 0,
|
|
120
|
+
ticket.injected ? 1 : 0,
|
|
121
|
+
ticket.priority,
|
|
122
|
+
ticket.branchHint,
|
|
123
|
+
JSON.stringify(ticket.labels),
|
|
124
|
+
ticket.updatedAt,
|
|
125
|
+
]
|
|
126
|
+
);
|
|
127
|
+
// The write is the answer to any outstanding re-read ask, whoever made
|
|
128
|
+
// it — the graph is current for this ticket as of this transaction.
|
|
129
|
+
this.#db.run(
|
|
130
|
+
`UPDATE fetch_request SET resolution = 'materialized'
|
|
131
|
+
WHERE kind = 'refresh_ticket' AND resolution IS NULL AND payload = ?`,
|
|
132
|
+
[JSON.stringify({ticket: ticket.id})]
|
|
133
|
+
);
|
|
134
|
+
// A status transition is the tracker speaking — an operator reply
|
|
135
|
+
// unparking a wait, a human moving the ticket — and is what a session
|
|
136
|
+
// must hear about without polling. Same event queue as the PR
|
|
137
|
+
// observations; a rewrite that changes nothing says nothing.
|
|
138
|
+
const from =
|
|
139
|
+
typeof previous?.status === 'string' ? previous.status : null;
|
|
140
|
+
if (from !== null && from !== ticket.status) {
|
|
141
|
+
this.#db.run(
|
|
142
|
+
`INSERT INTO pr_event (node_id, kind, summary, meta, session_id, observed_at)
|
|
143
|
+
VALUES (?, 'ticket_changed', ?, ?, NULL, ?)`,
|
|
144
|
+
[
|
|
145
|
+
nodeId,
|
|
146
|
+
`Ticket ${ticket.id} moved ${from} -> ${ticket.status} on the tracker.`,
|
|
147
|
+
JSON.stringify({ticket: ticket.id, from, to: ticket.status}),
|
|
148
|
+
new Date().toISOString(),
|
|
149
|
+
]
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Fill a partial write from the row already stored. A ticket with no row
|
|
157
|
+
* needs `project` and `status` from the caller — nothing else can supply
|
|
158
|
+
* them — and takes the same defaults a fresh registration would.
|
|
159
|
+
*/
|
|
160
|
+
#merge(patch: TicketPatch): Ticket {
|
|
161
|
+
const existing = this.#read(patch.id);
|
|
162
|
+
const need = <K extends keyof Ticket>(key: K): Ticket[K] => {
|
|
163
|
+
const given = patch[key];
|
|
164
|
+
if (given !== undefined) return given as Ticket[K];
|
|
165
|
+
ensure(
|
|
166
|
+
existing !== null,
|
|
167
|
+
() =>
|
|
168
|
+
new UsageError(`"${patch.id}" is new, so --${key} is required`, {
|
|
169
|
+
hint: 'a partial write fills gaps from the stored row; there is none yet for this id.',
|
|
170
|
+
})
|
|
171
|
+
);
|
|
172
|
+
return existing[key];
|
|
173
|
+
};
|
|
174
|
+
return {
|
|
175
|
+
id: patch.id,
|
|
176
|
+
project: need('project'),
|
|
177
|
+
status: need('status'),
|
|
178
|
+
url: patch.url ?? existing?.url ?? '',
|
|
179
|
+
title: patch.title ?? existing?.title ?? '',
|
|
180
|
+
targetKind: patch.targetKind ?? existing?.targetKind ?? 'pr',
|
|
181
|
+
requiresHuman: patch.requiresHuman ?? existing?.requiresHuman ?? false,
|
|
182
|
+
injected: patch.injected ?? existing?.injected ?? false,
|
|
183
|
+
priority:
|
|
184
|
+
patch.priority === undefined
|
|
185
|
+
? (existing?.priority ?? null)
|
|
186
|
+
: patch.priority,
|
|
187
|
+
branchHint:
|
|
188
|
+
patch.branchHint === undefined
|
|
189
|
+
? (existing?.branchHint ?? null)
|
|
190
|
+
: patch.branchHint,
|
|
191
|
+
labels: patch.labels ?? existing?.labels ?? [],
|
|
192
|
+
updatedAt:
|
|
193
|
+
patch.updatedAt === undefined
|
|
194
|
+
? (existing?.updatedAt ?? null)
|
|
195
|
+
: patch.updatedAt,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Remove a ticket; its satellite, edges, claim, and outcome cascade. */
|
|
200
|
+
async removeTicket(id: string): Promise<boolean> {
|
|
201
|
+
return this.#db.transaction(() => {
|
|
202
|
+
const node = findNode(this.#db, id);
|
|
203
|
+
if (node?.kind !== 'ticket') return false;
|
|
204
|
+
this.#db.run('DELETE FROM node WHERE id = ?', [node.id]);
|
|
205
|
+
return true;
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async getTicket(id: string): Promise<Ticket | null> {
|
|
210
|
+
return this.#read(id);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
#read(id: string): Ticket | null {
|
|
214
|
+
const row = this.#db.get(
|
|
215
|
+
`SELECT n.external_id AS id, pn.external_id AS project, t.url, t.title,
|
|
216
|
+
t.status, t.target_kind, t.requires_human, t.injected, t.priority,
|
|
217
|
+
t.branch_hint, t.labels, t.updated_at
|
|
218
|
+
FROM ticket t
|
|
219
|
+
JOIN node n ON n.id = t.node_id
|
|
220
|
+
JOIN node pn ON pn.id = t.project_id
|
|
221
|
+
WHERE n.external_id = ?`,
|
|
222
|
+
[id]
|
|
223
|
+
);
|
|
224
|
+
if (row === undefined) return null;
|
|
225
|
+
/* eslint-disable @typescript-eslint/no-base-to-string --
|
|
226
|
+
* SQLite hands back `unknown`; `String()` converts a primitive rather than
|
|
227
|
+
* asserting a type the row has not been checked for. */
|
|
228
|
+
return {
|
|
229
|
+
id: String(row.id),
|
|
230
|
+
project: String(row.project),
|
|
231
|
+
url: String(row.url),
|
|
232
|
+
title: String(row.title),
|
|
233
|
+
status: row.status as Ticket['status'],
|
|
234
|
+
targetKind: row.target_kind as Ticket['targetKind'],
|
|
235
|
+
requiresHuman: row.requires_human === 1,
|
|
236
|
+
injected: row.injected === 1,
|
|
237
|
+
priority: row.priority === null ? null : Number(row.priority),
|
|
238
|
+
branchHint: row.branch_hint === null ? null : String(row.branch_hint),
|
|
239
|
+
labels: JSON.parse(String(row.labels)) as string[],
|
|
240
|
+
updatedAt: row.updated_at === null ? null : String(row.updated_at),
|
|
241
|
+
};
|
|
242
|
+
/* eslint-enable @typescript-eslint/no-base-to-string */
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/* eslint-enable @typescript-eslint/require-await */
|