@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,94 @@
|
|
|
1
|
+
import type {Database} from '../db/database.mts';
|
|
2
|
+
import {assertInstant} from '../db/time.mts';
|
|
3
|
+
import type {ObservationKind} from '../watch/diff.mts';
|
|
4
|
+
import {findNode} from './materialize.mts';
|
|
5
|
+
|
|
6
|
+
/* eslint-disable @typescript-eslint/require-await --
|
|
7
|
+
* Async facade over synchronous `node:sqlite`; see `../db/database.mts`. */
|
|
8
|
+
|
|
9
|
+
export interface PendingEvent {
|
|
10
|
+
readonly id: number;
|
|
11
|
+
readonly node: string;
|
|
12
|
+
readonly kind: ObservationKind;
|
|
13
|
+
readonly summary: string;
|
|
14
|
+
readonly meta: Readonly<Record<string, string>>;
|
|
15
|
+
readonly observedAt: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Observations the poll took and the session has not yet been told about.
|
|
20
|
+
*
|
|
21
|
+
* They outlive the tick that produced them: a push into a channel nobody is
|
|
22
|
+
* listening to would otherwise lose the one notice a waiting worker gets, and
|
|
23
|
+
* unlike a fetch instruction there is no later poll that re-derives it — the
|
|
24
|
+
* next snapshot compares against the state that already includes the change.
|
|
25
|
+
* So delivery is recorded, and a restart re-pushes what was never marked.
|
|
26
|
+
*/
|
|
27
|
+
export class PrEventStore {
|
|
28
|
+
readonly #db: Database;
|
|
29
|
+
|
|
30
|
+
constructor(db: Database) {
|
|
31
|
+
this.#db = db;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Undelivered observations this session may push, oldest first.
|
|
36
|
+
*
|
|
37
|
+
* Scoped to the session that armed the wait: several servers share one
|
|
38
|
+
* graph DB, and only the session whose worker holds the PR can route the
|
|
39
|
+
* event to it. A row with no session is drainable by any — it belongs to a
|
|
40
|
+
* wait armed outside a server. So is a row whose session is gone: the worker
|
|
41
|
+
* it named died with it, and a watch outlives its session often enough that
|
|
42
|
+
* holding the row would strand the last notice of every wait a restart
|
|
43
|
+
* interrupted.
|
|
44
|
+
*/
|
|
45
|
+
async undelivered(session: string, limit = 50): Promise<PendingEvent[]> {
|
|
46
|
+
return this.#db
|
|
47
|
+
.all(
|
|
48
|
+
`SELECT e.id, n.external_id AS node, e.kind, e.summary, e.meta, e.observed_at
|
|
49
|
+
FROM pr_event e JOIN node n ON n.id = e.node_id
|
|
50
|
+
WHERE e.delivered_at IS NULL
|
|
51
|
+
AND (e.session_id IS NULL OR e.session_id = ?
|
|
52
|
+
OR NOT EXISTS (SELECT 1 FROM session s WHERE s.id = e.session_id))
|
|
53
|
+
ORDER BY e.id
|
|
54
|
+
LIMIT ?`,
|
|
55
|
+
[session, limit]
|
|
56
|
+
)
|
|
57
|
+
.map((row) => ({
|
|
58
|
+
id: Number(row.id),
|
|
59
|
+
node: String(row.node),
|
|
60
|
+
kind: row.kind as ObservationKind,
|
|
61
|
+
summary: String(row.summary),
|
|
62
|
+
meta: JSON.parse(String(row.meta)) as Record<string, string>,
|
|
63
|
+
observedAt: String(row.observed_at),
|
|
64
|
+
}));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Claim an event for delivery. True from exactly one caller: a session-NULL
|
|
69
|
+
* event is drainable by any server, and select-then-mark would let two of
|
|
70
|
+
* them push the same tracker transition — which a session then acts on
|
|
71
|
+
* twice. The claim is the conditional write, so push only after it.
|
|
72
|
+
*/
|
|
73
|
+
async markDelivered(id: number, at: string): Promise<boolean> {
|
|
74
|
+
assertInstant(at, 'at');
|
|
75
|
+
return (
|
|
76
|
+
this.#db.run(
|
|
77
|
+
'UPDATE pr_event SET delivered_at = ? WHERE id = ? AND delivered_at IS NULL',
|
|
78
|
+
[at, id]
|
|
79
|
+
) > 0
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Drop a node's events. Called when its outcome lands: the item concluded,
|
|
85
|
+
* so anything still owed describes a wait that no longer exists.
|
|
86
|
+
*/
|
|
87
|
+
async clear(node: string): Promise<number> {
|
|
88
|
+
const found = findNode(this.#db, node);
|
|
89
|
+
if (found === null) return 0;
|
|
90
|
+
return this.#db.run('DELETE FROM pr_event WHERE node_id = ?', [found.id]);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/* eslint-enable @typescript-eslint/require-await */
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import {assertInstant} from '../db/time.mts';
|
|
2
|
+
import type {Database} from '../db/database.mts';
|
|
3
|
+
import {DataError, ensure} from '../errors/index.mts';
|
|
4
|
+
import {isPrOrigin, PR_ORIGIN_LIST} from '../model/status.mts';
|
|
5
|
+
import type {Pr} from '../model/types.mts';
|
|
6
|
+
import {findNode, materialize, nodeRef} from './materialize.mts';
|
|
7
|
+
|
|
8
|
+
/* eslint-disable @typescript-eslint/require-await --
|
|
9
|
+
* Async facade over synchronous `node:sqlite`; see `../db/database.mts`. */
|
|
10
|
+
|
|
11
|
+
/** A partial write: `id` names the item, every other key is optional. */
|
|
12
|
+
export type PrPatch = Partial<Omit<Pr, 'id'>> & {id: string};
|
|
13
|
+
|
|
14
|
+
function pick<K extends keyof Pr>(
|
|
15
|
+
patch: PrPatch,
|
|
16
|
+
key: K,
|
|
17
|
+
fallback: Pr[K]
|
|
18
|
+
): Pr[K] {
|
|
19
|
+
const given = patch[key];
|
|
20
|
+
return given === undefined ? fallback : (given as Pr[K]);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class PrStore {
|
|
24
|
+
readonly #db: Database;
|
|
25
|
+
|
|
26
|
+
constructor(db: Database) {
|
|
27
|
+
this.#db = db;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async upsertPr(pr: Pr): Promise<void> {
|
|
31
|
+
ensure(
|
|
32
|
+
isPrOrigin(pr.origin),
|
|
33
|
+
() =>
|
|
34
|
+
new DataError(`"${pr.origin}" is not a pr origin`, {
|
|
35
|
+
hint: `use one of: ${PR_ORIGIN_LIST}.`,
|
|
36
|
+
})
|
|
37
|
+
);
|
|
38
|
+
if (pr.updatedAt !== null) assertInstant(pr.updatedAt, '--updated-at');
|
|
39
|
+
|
|
40
|
+
await this.#db.transaction(() => {
|
|
41
|
+
this.#write(pr);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
#write(pr: Pr): void {
|
|
46
|
+
const nodeId = materialize(this.#db, pr.id, 'pr');
|
|
47
|
+
const ticketId = pr.ticket === null ? null : nodeRef(this.#db, pr.ticket);
|
|
48
|
+
this.#db.run(
|
|
49
|
+
`INSERT INTO pr (
|
|
50
|
+
node_id, ticket_id, origin, repo, pr_number, url, branch, title,
|
|
51
|
+
injected, priority, updated_at
|
|
52
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
53
|
+
ON CONFLICT(node_id) DO UPDATE SET
|
|
54
|
+
ticket_id = excluded.ticket_id, origin = excluded.origin,
|
|
55
|
+
repo = excluded.repo, pr_number = excluded.pr_number,
|
|
56
|
+
url = excluded.url, branch = excluded.branch, title = excluded.title,
|
|
57
|
+
injected = excluded.injected, priority = excluded.priority,
|
|
58
|
+
updated_at = excluded.updated_at`,
|
|
59
|
+
[
|
|
60
|
+
nodeId,
|
|
61
|
+
ticketId,
|
|
62
|
+
pr.origin,
|
|
63
|
+
pr.repo,
|
|
64
|
+
pr.prNumber,
|
|
65
|
+
pr.url,
|
|
66
|
+
pr.branch,
|
|
67
|
+
pr.title,
|
|
68
|
+
pr.injected ? 1 : 0,
|
|
69
|
+
pr.priority,
|
|
70
|
+
pr.updatedAt,
|
|
71
|
+
]
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Write only the fields the caller named, leaving the rest as they are.
|
|
77
|
+
*
|
|
78
|
+
* The full-record `upsertPr` is for a writer that holds the whole record —
|
|
79
|
+
* an adoption sweep reading the forge. A worker does not: its documented job
|
|
80
|
+
* is to keep the URL and PR number current as they come to exist, and it
|
|
81
|
+
* knows nothing about the ticket link or title the item was registered with.
|
|
82
|
+
* Assigning every column from a write like that silently unlinks the item
|
|
83
|
+
* from its ticket and resets its origin, which drops it out of every
|
|
84
|
+
* project-scoped view.
|
|
85
|
+
*
|
|
86
|
+
* Omitting a field preserves it; passing an explicit value — `null`
|
|
87
|
+
* included — sets it. An id with no row yet is created, taking the same
|
|
88
|
+
* defaults a bare registration would.
|
|
89
|
+
*/
|
|
90
|
+
async patchPr(patch: PrPatch): Promise<void> {
|
|
91
|
+
const {origin} = patch;
|
|
92
|
+
if (origin !== undefined) {
|
|
93
|
+
ensure(
|
|
94
|
+
isPrOrigin(origin),
|
|
95
|
+
() =>
|
|
96
|
+
new DataError(`"${origin}" is not a pr origin`, {
|
|
97
|
+
hint: `use one of: ${PR_ORIGIN_LIST}.`,
|
|
98
|
+
})
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
if (patch.updatedAt != null) assertInstant(patch.updatedAt, '--updated-at');
|
|
102
|
+
|
|
103
|
+
await this.#db.transaction(() => {
|
|
104
|
+
const existing = this.#read(patch.id);
|
|
105
|
+
const merged: Pr = {
|
|
106
|
+
id: patch.id,
|
|
107
|
+
ticket: pick(patch, 'ticket', existing?.ticket ?? null),
|
|
108
|
+
origin: pick(patch, 'origin', existing?.origin ?? 'prompt'),
|
|
109
|
+
repo: pick(patch, 'repo', existing?.repo ?? null),
|
|
110
|
+
prNumber: pick(patch, 'prNumber', existing?.prNumber ?? null),
|
|
111
|
+
url: pick(patch, 'url', existing?.url ?? null),
|
|
112
|
+
branch: pick(patch, 'branch', existing?.branch ?? null),
|
|
113
|
+
title: pick(patch, 'title', existing?.title ?? ''),
|
|
114
|
+
injected: pick(patch, 'injected', existing?.injected ?? false),
|
|
115
|
+
priority: pick(patch, 'priority', existing?.priority ?? null),
|
|
116
|
+
updatedAt: pick(patch, 'updatedAt', existing?.updatedAt ?? null),
|
|
117
|
+
};
|
|
118
|
+
this.#write(merged);
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async removePr(id: string): Promise<boolean> {
|
|
123
|
+
return this.#db.transaction(() => {
|
|
124
|
+
const node = findNode(this.#db, id);
|
|
125
|
+
if (node?.kind !== 'pr') return false;
|
|
126
|
+
this.#db.run('DELETE FROM node WHERE id = ?', [node.id]);
|
|
127
|
+
return true;
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/* eslint-disable @typescript-eslint/no-base-to-string --
|
|
132
|
+
* SQLite hands back `unknown`; `String()` converts a primitive rather than
|
|
133
|
+
* asserting a type the row has not been checked for. */
|
|
134
|
+
async getPr(id: string): Promise<Pr | null> {
|
|
135
|
+
return this.#read(id);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
#read(id: string): Pr | null {
|
|
139
|
+
const row = this.#db.get(
|
|
140
|
+
`SELECT n.external_id AS id, tn.external_id AS ticket, p.origin, p.repo,
|
|
141
|
+
p.pr_number, p.url, p.branch, p.title, p.injected, p.priority,
|
|
142
|
+
p.updated_at
|
|
143
|
+
FROM pr p
|
|
144
|
+
JOIN node n ON n.id = p.node_id
|
|
145
|
+
LEFT JOIN node tn ON tn.id = p.ticket_id
|
|
146
|
+
WHERE n.external_id = ?`,
|
|
147
|
+
[id]
|
|
148
|
+
);
|
|
149
|
+
if (row === undefined) return null;
|
|
150
|
+
return {
|
|
151
|
+
id: String(row.id),
|
|
152
|
+
ticket: row.ticket === null ? null : String(row.ticket),
|
|
153
|
+
origin: row.origin as Pr['origin'],
|
|
154
|
+
repo: row.repo === null ? null : String(row.repo),
|
|
155
|
+
prNumber: row.pr_number === null ? null : Number(row.pr_number),
|
|
156
|
+
url: row.url === null ? null : String(row.url),
|
|
157
|
+
branch: row.branch === null ? null : String(row.branch),
|
|
158
|
+
title: String(row.title),
|
|
159
|
+
injected: row.injected === 1,
|
|
160
|
+
priority: row.priority === null ? null : Number(row.priority),
|
|
161
|
+
updatedAt: row.updated_at === null ? null : String(row.updated_at),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
/* eslint-enable @typescript-eslint/no-base-to-string */
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/* eslint-enable @typescript-eslint/require-await */
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type {Database} from '../db/database.mts';
|
|
2
|
+
import type {Project} from '../model/types.mts';
|
|
3
|
+
import {findNode, materialize} from './materialize.mts';
|
|
4
|
+
|
|
5
|
+
/* eslint-disable @typescript-eslint/require-await --
|
|
6
|
+
* Async facade over synchronous `node:sqlite`; see `../db/database.mts`. */
|
|
7
|
+
|
|
8
|
+
/** Projects: the 1:1 scoping partition tickets and milestones belong to. */
|
|
9
|
+
export class ProjectStore {
|
|
10
|
+
readonly #db: Database;
|
|
11
|
+
|
|
12
|
+
constructor(db: Database) {
|
|
13
|
+
this.#db = db;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async upsertProject(project: {
|
|
17
|
+
id: string;
|
|
18
|
+
name: string;
|
|
19
|
+
source?: string | null;
|
|
20
|
+
}): Promise<void> {
|
|
21
|
+
await this.#db.transaction(() => {
|
|
22
|
+
const nodeId = materialize(this.#db, project.id, 'project');
|
|
23
|
+
this.#db.run(
|
|
24
|
+
`INSERT INTO project (node_id, name, source) VALUES (?, ?, ?)
|
|
25
|
+
ON CONFLICT(node_id) DO UPDATE SET
|
|
26
|
+
name = excluded.name, source = excluded.source`,
|
|
27
|
+
[nodeId, project.name, project.source ?? null]
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Remove a project. If a ticket or milestone still names it, demote its node
|
|
34
|
+
* to an `unknown` placeholder (the reference stays valid); otherwise delete
|
|
35
|
+
* the node outright. Returns whether a declared project was found.
|
|
36
|
+
*/
|
|
37
|
+
async removeProject(id: string): Promise<boolean> {
|
|
38
|
+
return this.#db.transaction(() => {
|
|
39
|
+
const node = findNode(this.#db, id);
|
|
40
|
+
if (node?.kind !== 'project') return false;
|
|
41
|
+
const referenced =
|
|
42
|
+
this.#db.get(
|
|
43
|
+
`SELECT 1 FROM ticket WHERE project_id = ?
|
|
44
|
+
UNION SELECT 1 FROM milestone WHERE project_id = ? LIMIT 1`,
|
|
45
|
+
[node.id, node.id]
|
|
46
|
+
) !== undefined;
|
|
47
|
+
this.#db.run('DELETE FROM project WHERE node_id = ?', [node.id]);
|
|
48
|
+
if (referenced) {
|
|
49
|
+
this.#db.run("UPDATE node SET kind = 'unknown' WHERE id = ?", [
|
|
50
|
+
node.id,
|
|
51
|
+
]);
|
|
52
|
+
} else {
|
|
53
|
+
this.#db.run('DELETE FROM node WHERE id = ?', [node.id]);
|
|
54
|
+
}
|
|
55
|
+
return true;
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/* eslint-disable @typescript-eslint/no-base-to-string --
|
|
60
|
+
* SQLite hands back `unknown`; `String()` converts a primitive rather than
|
|
61
|
+
* asserting a type the row has not been checked for. */
|
|
62
|
+
async getProject(id: string): Promise<Project | null> {
|
|
63
|
+
const row = this.#db.get(
|
|
64
|
+
`SELECT n.external_id AS id, p.name AS name, p.source AS source
|
|
65
|
+
FROM project p JOIN node n ON n.id = p.node_id
|
|
66
|
+
WHERE n.external_id = ?`,
|
|
67
|
+
[id]
|
|
68
|
+
);
|
|
69
|
+
if (row === undefined) return null;
|
|
70
|
+
return {
|
|
71
|
+
id: String(row.id),
|
|
72
|
+
name: String(row.name),
|
|
73
|
+
source: row.source === null ? null : String(row.source),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
/* eslint-enable @typescript-eslint/no-base-to-string */
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/* eslint-enable @typescript-eslint/require-await */
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import type {Database} from '../db/database.mts';
|
|
2
|
+
import {assertInstant} from '../db/time.mts';
|
|
3
|
+
|
|
4
|
+
/* eslint-disable @typescript-eslint/require-await --
|
|
5
|
+
* Async facade over synchronous `node:sqlite`; see `../db/database.mts`. */
|
|
6
|
+
|
|
7
|
+
export const REFRESH_STATES = ['scanning', 'resolving', 'idle'] as const;
|
|
8
|
+
export type RefreshState = (typeof REFRESH_STATES)[number];
|
|
9
|
+
|
|
10
|
+
export interface RefreshRow {
|
|
11
|
+
source: string;
|
|
12
|
+
state: RefreshState;
|
|
13
|
+
sessionId: string | null;
|
|
14
|
+
projects: string[];
|
|
15
|
+
pendingCursor: string | null;
|
|
16
|
+
startedAt: string;
|
|
17
|
+
completedAt: string | null;
|
|
18
|
+
completionEmittedAt: string | null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const COLUMNS = `source, state, session_id, projects, pending_cursor,
|
|
22
|
+
started_at, completed_at, completion_emitted_at`;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* One row per tracker source: which phase its ingest is in, who owns it, and
|
|
26
|
+
* whether its completion event still owes a push.
|
|
27
|
+
*/
|
|
28
|
+
export class RefreshStore {
|
|
29
|
+
readonly #db: Database;
|
|
30
|
+
|
|
31
|
+
constructor(db: Database) {
|
|
32
|
+
this.#db = db;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async open(input: {
|
|
36
|
+
source: string;
|
|
37
|
+
projects: readonly string[];
|
|
38
|
+
sessionId: string | null;
|
|
39
|
+
at: string;
|
|
40
|
+
}): Promise<void> {
|
|
41
|
+
assertInstant(input.at, 'at');
|
|
42
|
+
this.#upsert(
|
|
43
|
+
input.source,
|
|
44
|
+
'scanning',
|
|
45
|
+
input.sessionId,
|
|
46
|
+
JSON.stringify([...input.projects]),
|
|
47
|
+
input.at
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async openResolving(input: {
|
|
52
|
+
source: string;
|
|
53
|
+
sessionId: string | null;
|
|
54
|
+
at: string;
|
|
55
|
+
}): Promise<void> {
|
|
56
|
+
assertInstant(input.at, 'at');
|
|
57
|
+
this.#upsert(input.source, 'resolving', input.sessionId, '[]', input.at);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async setState(source: string, state: RefreshState): Promise<void> {
|
|
61
|
+
this.#db.run('UPDATE refresh SET state = ? WHERE source = ?', [
|
|
62
|
+
state,
|
|
63
|
+
source,
|
|
64
|
+
]);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async setPendingCursor(source: string, cursor: string): Promise<void> {
|
|
68
|
+
this.#db.run('UPDATE refresh SET pending_cursor = ? WHERE source = ?', [
|
|
69
|
+
cursor,
|
|
70
|
+
source,
|
|
71
|
+
]);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async close(source: string, at: string): Promise<void> {
|
|
75
|
+
assertInstant(at, 'at');
|
|
76
|
+
this.#db.run(
|
|
77
|
+
`UPDATE refresh
|
|
78
|
+
SET state = 'idle', completed_at = ?, completion_emitted_at = NULL,
|
|
79
|
+
pending_cursor = NULL
|
|
80
|
+
WHERE source = ?`,
|
|
81
|
+
[at, source]
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async markCompletionEmitted(source: string, at: string): Promise<void> {
|
|
86
|
+
assertInstant(at, 'at');
|
|
87
|
+
this.#db.run(
|
|
88
|
+
'UPDATE refresh SET completion_emitted_at = ? WHERE source = ?',
|
|
89
|
+
[at, source]
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async pendingCompletions(): Promise<string[]> {
|
|
94
|
+
return this.#db
|
|
95
|
+
.all(
|
|
96
|
+
`SELECT source FROM refresh
|
|
97
|
+
WHERE completed_at IS NOT NULL AND completion_emitted_at IS NULL
|
|
98
|
+
ORDER BY source`
|
|
99
|
+
)
|
|
100
|
+
.map((row) => String(row.source));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async hasLiveSession(source: string): Promise<boolean> {
|
|
104
|
+
// A refresh records the Claude session id its command ran under, while a
|
|
105
|
+
// server registers under a minted registry id and carries the Claude id
|
|
106
|
+
// in its own column — so the owning session matches on either.
|
|
107
|
+
return (
|
|
108
|
+
this.#db.get(
|
|
109
|
+
`SELECT 1 FROM refresh r
|
|
110
|
+
JOIN session s
|
|
111
|
+
ON s.id = r.session_id OR s.claude_session_id = r.session_id
|
|
112
|
+
WHERE r.source = ?`,
|
|
113
|
+
[source]
|
|
114
|
+
) !== undefined
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Sources whose ingest is mid-flight and still owned by a live session. A
|
|
120
|
+
* scan writes a ticket before it writes that ticket's edges, so the graph
|
|
121
|
+
* under one is missing dependencies it will have moments later — and a
|
|
122
|
+
* missing edge reads as "no blockers", the permissive answer. Callers that
|
|
123
|
+
* would act on the graph wait for this to come back empty.
|
|
124
|
+
*
|
|
125
|
+
* A refresh whose session has been swept is excluded: nobody is going to
|
|
126
|
+
* finish it, so leaving it in would wedge those callers for good.
|
|
127
|
+
*/
|
|
128
|
+
async ingesting(): Promise<string[]> {
|
|
129
|
+
return this.#db
|
|
130
|
+
.all(
|
|
131
|
+
`SELECT r.source FROM refresh r
|
|
132
|
+
JOIN session s
|
|
133
|
+
ON s.id = r.session_id OR s.claude_session_id = r.session_id
|
|
134
|
+
WHERE r.state <> 'idle'
|
|
135
|
+
ORDER BY r.source`
|
|
136
|
+
)
|
|
137
|
+
.map((row) => String(row.source));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async get(source: string): Promise<RefreshRow | null> {
|
|
141
|
+
const row = this.#db.get(
|
|
142
|
+
`SELECT ${COLUMNS} FROM refresh WHERE source = ?`,
|
|
143
|
+
[source]
|
|
144
|
+
);
|
|
145
|
+
return row === undefined ? null : toRow(row);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async active(): Promise<RefreshRow[]> {
|
|
149
|
+
return this.#db
|
|
150
|
+
.all(
|
|
151
|
+
`SELECT ${COLUMNS} FROM refresh WHERE state <> 'idle' ORDER BY source`
|
|
152
|
+
)
|
|
153
|
+
.map(toRow);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
#upsert(
|
|
157
|
+
source: string,
|
|
158
|
+
state: RefreshState,
|
|
159
|
+
sessionId: string | null,
|
|
160
|
+
projects: string,
|
|
161
|
+
at: string
|
|
162
|
+
): void {
|
|
163
|
+
this.#db.run(
|
|
164
|
+
`INSERT INTO refresh (${COLUMNS})
|
|
165
|
+
VALUES (?, ?, ?, ?, NULL, ?, NULL, NULL)
|
|
166
|
+
ON CONFLICT(source) DO UPDATE SET
|
|
167
|
+
state = excluded.state, session_id = excluded.session_id,
|
|
168
|
+
projects = excluded.projects, pending_cursor = NULL,
|
|
169
|
+
started_at = excluded.started_at, completed_at = NULL,
|
|
170
|
+
completion_emitted_at = NULL`,
|
|
171
|
+
[source, state, sessionId, projects, at]
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/* eslint-disable @typescript-eslint/no-base-to-string --
|
|
177
|
+
* SQLite hands back `unknown`; `String()` converts a primitive rather than
|
|
178
|
+
* asserting a type the row has not been checked for. */
|
|
179
|
+
function toRow(row: Record<string, unknown>): RefreshRow {
|
|
180
|
+
return {
|
|
181
|
+
source: String(row.source),
|
|
182
|
+
state: row.state as RefreshState,
|
|
183
|
+
sessionId: row.session_id === null ? null : String(row.session_id),
|
|
184
|
+
projects: JSON.parse(String(row.projects)) as string[],
|
|
185
|
+
pendingCursor:
|
|
186
|
+
row.pending_cursor === null ? null : String(row.pending_cursor),
|
|
187
|
+
startedAt: String(row.started_at),
|
|
188
|
+
completedAt: row.completed_at === null ? null : String(row.completed_at),
|
|
189
|
+
completionEmittedAt:
|
|
190
|
+
row.completion_emitted_at === null
|
|
191
|
+
? null
|
|
192
|
+
: String(row.completion_emitted_at),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
/* eslint-enable @typescript-eslint/no-base-to-string */
|
|
196
|
+
|
|
197
|
+
/* eslint-enable @typescript-eslint/require-await */
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/require-await --
|
|
2
|
+
* Async facade over synchronous `node:sqlite`; see `../db/database.mts`. */
|
|
3
|
+
import type {Database} from '../db/database.mts';
|
|
4
|
+
import {assertInstant} from '../db/time.mts';
|
|
5
|
+
import {DataError, ensure} from '../errors/index.mts';
|
|
6
|
+
import {RESOLVED_STATUSES} from '../model/status.mts';
|
|
7
|
+
import {findNode} from './materialize.mts';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Milestone reviews. Recording snapshots the member set, which is what makes
|
|
11
|
+
* a review expire: the read-model counts a review only while it covers
|
|
12
|
+
* exactly the current members and none moved after it was recorded.
|
|
13
|
+
*/
|
|
14
|
+
export class ReviewStore {
|
|
15
|
+
readonly #db: Database;
|
|
16
|
+
|
|
17
|
+
constructor(db: Database) {
|
|
18
|
+
this.#db = db;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async record(milestone: string, at: string, session: string): Promise<void> {
|
|
22
|
+
assertInstant(at, 'at');
|
|
23
|
+
await this.#db.transaction(() => {
|
|
24
|
+
const node = findNode(this.#db, milestone);
|
|
25
|
+
ensure(
|
|
26
|
+
node !== null && node.kind === 'milestone',
|
|
27
|
+
() =>
|
|
28
|
+
new DataError(`"${milestone}" is not a milestone in the graph`, {
|
|
29
|
+
hint: 'record a review on a milestone the graph already holds.',
|
|
30
|
+
})
|
|
31
|
+
);
|
|
32
|
+
const members = this.#db.all(
|
|
33
|
+
`SELECT n.external_id AS id, t.status
|
|
34
|
+
FROM edge e
|
|
35
|
+
JOIN ticket t ON t.node_id = e.blocker
|
|
36
|
+
JOIN node n ON n.id = e.blocker
|
|
37
|
+
WHERE e.blocked = ?
|
|
38
|
+
ORDER BY id`,
|
|
39
|
+
[node.id]
|
|
40
|
+
);
|
|
41
|
+
ensure(
|
|
42
|
+
members.length > 0,
|
|
43
|
+
() =>
|
|
44
|
+
new DataError(`milestone "${milestone}" has no members to review`, {
|
|
45
|
+
hint: 'a review covers member tickets; join them with edge add first.',
|
|
46
|
+
})
|
|
47
|
+
);
|
|
48
|
+
const open = members.filter(
|
|
49
|
+
(member) =>
|
|
50
|
+
!(RESOLVED_STATUSES as ReadonlySet<string>).has(String(member.status))
|
|
51
|
+
);
|
|
52
|
+
ensure(
|
|
53
|
+
open.length === 0,
|
|
54
|
+
() =>
|
|
55
|
+
new DataError(
|
|
56
|
+
`milestone "${milestone}" still has unresolved members: ${open
|
|
57
|
+
.map((member) => String(member.id))
|
|
58
|
+
.join(', ')}`,
|
|
59
|
+
{
|
|
60
|
+
hint: 'a milestone is reviewed once every member is verified or canceled.',
|
|
61
|
+
}
|
|
62
|
+
)
|
|
63
|
+
);
|
|
64
|
+
this.#db.run(
|
|
65
|
+
`INSERT INTO review (milestone_id, recorded_at) VALUES (?, ?)
|
|
66
|
+
ON CONFLICT(milestone_id) DO UPDATE SET recorded_at = excluded.recorded_at`,
|
|
67
|
+
[node.id, at]
|
|
68
|
+
);
|
|
69
|
+
this.#db.run('DELETE FROM review_member WHERE milestone_id = ?', [
|
|
70
|
+
node.id,
|
|
71
|
+
]);
|
|
72
|
+
for (const member of members) {
|
|
73
|
+
this.#db.run(
|
|
74
|
+
'INSERT INTO review_member (milestone_id, member_external_id) VALUES (?, ?)',
|
|
75
|
+
[node.id, String(member.id)]
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
// The review opens the gate; the reviewing claim has served its
|
|
79
|
+
// purpose. Only the reporter's own claim goes: a reviewer whose session
|
|
80
|
+
// was swept can record late, by which time the milestone may have been
|
|
81
|
+
// re-dispatched, and revoking that reviewer's grant would strand it.
|
|
82
|
+
this.#db.run('DELETE FROM claim WHERE node_id = ? AND session_id = ?', [
|
|
83
|
+
node.id,
|
|
84
|
+
session,
|
|
85
|
+
]);
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* End a review without recording it — the gate stays closed. Releases only
|
|
91
|
+
* the caller's own claim, for the same reason `record` does.
|
|
92
|
+
*/
|
|
93
|
+
async release(milestone: string, session: string): Promise<boolean> {
|
|
94
|
+
return this.#db.guard(() => {
|
|
95
|
+
const node = findNode(this.#db, milestone);
|
|
96
|
+
ensure(
|
|
97
|
+
node !== null && node.kind === 'milestone',
|
|
98
|
+
() =>
|
|
99
|
+
new DataError(`"${milestone}" is not a milestone in the graph`, {
|
|
100
|
+
hint: 'name the milestone the review order carried.',
|
|
101
|
+
})
|
|
102
|
+
);
|
|
103
|
+
return (
|
|
104
|
+
this.#db.run('DELETE FROM claim WHERE node_id = ? AND session_id = ?', [
|
|
105
|
+
node.id,
|
|
106
|
+
session,
|
|
107
|
+
]) > 0
|
|
108
|
+
);
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/* eslint-enable @typescript-eslint/require-await */
|