lockage 0.0.0-stage → 0.9.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.
Files changed (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +355 -2
  3. package/dist/db/index.d.ts +52 -0
  4. package/dist/db/index.js +238 -0
  5. package/dist/db/schema.sql +238 -0
  6. package/dist/hook/clients.d.ts +56 -0
  7. package/dist/hook/clients.js +258 -0
  8. package/dist/hook/context.d.ts +22 -0
  9. package/dist/hook/context.js +40 -0
  10. package/dist/hook/dispatch.d.ts +21 -0
  11. package/dist/hook/dispatch.js +212 -0
  12. package/dist/hook/guard.d.ts +34 -0
  13. package/dist/hook/guard.js +106 -0
  14. package/dist/hook/repo.d.ts +40 -0
  15. package/dist/hook/repo.js +173 -0
  16. package/dist/hook/setup-kiro.d.ts +30 -0
  17. package/dist/hook/setup-kiro.js +118 -0
  18. package/dist/index.d.ts +47 -0
  19. package/dist/index.js +694 -0
  20. package/dist/launcher/client.d.ts +22 -0
  21. package/dist/launcher/client.js +57 -0
  22. package/dist/launcher/publish-adapter.d.ts +88 -0
  23. package/dist/launcher/publish-adapter.js +233 -0
  24. package/dist/launcher/publish-cli.d.ts +2 -0
  25. package/dist/launcher/publish-cli.js +123 -0
  26. package/dist/launcher/publish.d.ts +140 -0
  27. package/dist/launcher/publish.js +522 -0
  28. package/dist/lockage/board.d.ts +247 -0
  29. package/dist/lockage/board.js +407 -0
  30. package/dist/lockage/claim.d.ts +307 -0
  31. package/dist/lockage/claim.js +580 -0
  32. package/dist/lockage/files.d.ts +229 -0
  33. package/dist/lockage/files.js +403 -0
  34. package/dist/lockage/git.d.ts +92 -0
  35. package/dist/lockage/git.js +183 -0
  36. package/dist/lockage/globs.d.ts +26 -0
  37. package/dist/lockage/globs.js +125 -0
  38. package/dist/lockage/merge.d.ts +132 -0
  39. package/dist/lockage/merge.js +831 -0
  40. package/dist/lockage/publish-lock.d.ts +38 -0
  41. package/dist/lockage/publish-lock.js +73 -0
  42. package/dist/lockage/tickets.d.ts +213 -0
  43. package/dist/lockage/tickets.js +340 -0
  44. package/dist/lockage/worktree.d.ts +212 -0
  45. package/dist/lockage/worktree.js +563 -0
  46. package/package.json +42 -3
@@ -0,0 +1,38 @@
1
+ import { type CoordDb } from '../db/index.js';
2
+ export declare const PUBLISH_LOCK_TTL_SEC = 300;
3
+ /** Result of acquirePublishLock. Returns a holder token if acquired. */
4
+ export type AcquirePublishLockResult = {
5
+ ok: true;
6
+ holder: string;
7
+ } | {
8
+ ok: false;
9
+ };
10
+ /**
11
+ * Acquire a per-ticket publish advisory lock (§area 1).
12
+ *
13
+ * Blocks the race where two lockage-publish processes pick up the same ticket's ticket_ready at once
14
+ * and both proceed to publish between read→act. Symmetric with claims' lease/TTL, acquired via a CAS
15
+ * under BEGIN IMMEDIATE:
16
+ * 1. If there is a stale row that expired (expires_at < now), DELETE it (prepare to steal).
17
+ * 2. Run INSERT ... ON CONFLICT(ticket_id) DO NOTHING; changes===0 = a live lock exists = acquire
18
+ * failed (ok:false), changes>0 = acquire succeeded (ok:true).
19
+ *
20
+ * Since PK=ticket_id, it does not block other tickets (processing of other tickets in the batch can
21
+ * continue). holder is a unique ulid() token, used by releasePublishLock to verify ownership.
22
+ *
23
+ * Modeled on the BEGIN IMMEDIATE/ROLLBACK style of claim.ts's claimTask / handoffTask and the
24
+ * expiry reclamation by expires_at in sweepExpired.
25
+ */
26
+ export declare function acquirePublishLock(cdb: CoordDb, ticketId: string, opts?: {
27
+ ttlSec?: number;
28
+ now?: number;
29
+ }): AcquirePublishLockResult;
30
+ /**
31
+ * Release a publish advisory lock (§area 1).
32
+ *
33
+ * Conditioning on a holder match, it never wrongly deletes a lock stolen by another process after
34
+ * TTL expiry (= the holder has changed). For a row that is not your holder, the DELETE affects 0
35
+ * rows and nothing happens. Assumed to be called from the finally of publishReadyTickets /
36
+ * reconcileMergedPrs on every path.
37
+ */
38
+ export declare function releasePublishLock(cdb: CoordDb, ticketId: string, holder: string): void;
@@ -0,0 +1,73 @@
1
+ import { ulid } from 'ulid';
2
+ import { unixNow } from '../db/index.js';
3
+ // Default TTL (seconds) of a publish lock. Assuming lockage-publish's single-ticket processing
4
+ // finishes within the TTL, this lets another process steal the lock on TTL expiry even if the
5
+ // process crashes and cannot release it. The same "reclaim on expiry" idea as claims' lease_ttl.
6
+ // Default 5 minutes.
7
+ export const PUBLISH_LOCK_TTL_SEC = 300;
8
+ /**
9
+ * Acquire a per-ticket publish advisory lock (§area 1).
10
+ *
11
+ * Blocks the race where two lockage-publish processes pick up the same ticket's ticket_ready at once
12
+ * and both proceed to publish between read→act. Symmetric with claims' lease/TTL, acquired via a CAS
13
+ * under BEGIN IMMEDIATE:
14
+ * 1. If there is a stale row that expired (expires_at < now), DELETE it (prepare to steal).
15
+ * 2. Run INSERT ... ON CONFLICT(ticket_id) DO NOTHING; changes===0 = a live lock exists = acquire
16
+ * failed (ok:false), changes>0 = acquire succeeded (ok:true).
17
+ *
18
+ * Since PK=ticket_id, it does not block other tickets (processing of other tickets in the batch can
19
+ * continue). holder is a unique ulid() token, used by releasePublishLock to verify ownership.
20
+ *
21
+ * Modeled on the BEGIN IMMEDIATE/ROLLBACK style of claim.ts's claimTask / handoffTask and the
22
+ * expiry reclamation by expires_at in sweepExpired.
23
+ */
24
+ export function acquirePublishLock(cdb, ticketId, opts) {
25
+ const db = cdb.db;
26
+ const now = opts?.now ?? unixNow();
27
+ const ttlSec = opts?.ttlSec ?? PUBLISH_LOCK_TTL_SEC;
28
+ const holder = ulid();
29
+ const expiresAt = now + ttlSec;
30
+ db.exec('BEGIN IMMEDIATE');
31
+ try {
32
+ // Clean up the expired stale row first (prepare to steal). A live lock is not removed here.
33
+ db.prepare(`DELETE FROM publish_locks WHERE ticket_id = ? AND expires_at < ?`).run(ticketId, now);
34
+ // Run the INSERT as a CAS. If a live lock remains, ON CONFLICT DO NOTHING means no row is
35
+ // inserted and changes===0 (= acquire failed). Any other exception is propagated without folding.
36
+ const info = db
37
+ .prepare(`INSERT INTO publish_locks (ticket_id, holder, acquired_at, expires_at, host_id, origin)
38
+ VALUES (?, ?, ?, ?, ?, 'local')
39
+ ON CONFLICT(ticket_id) DO NOTHING`)
40
+ .run(ticketId, holder, now, expiresAt, cdb.hostId);
41
+ if (info.changes === 0) {
42
+ // changes===0 under ON CONFLICT DO NOTHING = a live lock exists = acquire failed.
43
+ db.exec('ROLLBACK');
44
+ return { ok: false };
45
+ }
46
+ db.exec('COMMIT');
47
+ return { ok: true, holder };
48
+ }
49
+ catch (e) {
50
+ // Always roll back on exception (do not exit while holding the lock).
51
+ try {
52
+ db.exec('ROLLBACK');
53
+ }
54
+ catch {
55
+ /* ignore if already closed */
56
+ }
57
+ throw e;
58
+ }
59
+ }
60
+ /**
61
+ * Release a publish advisory lock (§area 1).
62
+ *
63
+ * Conditioning on a holder match, it never wrongly deletes a lock stolen by another process after
64
+ * TTL expiry (= the holder has changed). For a row that is not your holder, the DELETE affects 0
65
+ * rows and nothing happens. Assumed to be called from the finally of publishReadyTickets /
66
+ * reconcileMergedPrs on every path.
67
+ */
68
+ export function releasePublishLock(cdb, ticketId, holder) {
69
+ cdb.db
70
+ .prepare(`DELETE FROM publish_locks WHERE ticket_id = ? AND holder = ?`)
71
+ .run(ticketId, holder);
72
+ }
73
+ //# sourceMappingURL=publish-lock.js.map
@@ -0,0 +1,213 @@
1
+ import { type CoordDb } from '../db/index.js';
2
+ /**
3
+ * §2.2 Ticket operations (model 3: a grouping unit for issues).
4
+ *
5
+ * A ticket is a cross-repo, long-lived, unclaimed grouping unit for issues (requirement FR-7). It
6
+ * does not have the heavy claim/lease/worktree machinery and sticks to light operations on status +
7
+ * body FTS5. 1 ticket → N tasks via tasks.ticket_id (nullable FK).
8
+ *
9
+ * - D-1 decided: ticket↔repo is the join table ticket_repos. **A ticket with no rows = fully cross-repo (repo-independent)**.
10
+ * - D-4 decided: concurrent-edit conflicts on body/status are detected by version-CAS (only update_ticket is CAS).
11
+ * - The body is full-text searched via tickets_fts (FTS5 external content). Auto-synced by triggers (schema.sql).
12
+ */
13
+ export interface Ticket {
14
+ id: string;
15
+ title: string;
16
+ body: string | null;
17
+ status: 'open' | 'in_progress' | 'closed';
18
+ priority: number;
19
+ assignee: string | null;
20
+ version: number;
21
+ created_by: string | null;
22
+ created_at: number;
23
+ updated_at: number;
24
+ host_id: string;
25
+ origin: string;
26
+ /** The ticket integration branch name added by problem A (not yet generated = null). Problem B's publisher pushes it. */
27
+ integration_branch: string | null;
28
+ /** The ticket_ready emission time of problem A (not emitted = null). Idempotent emission is ensured by CAS. */
29
+ ticket_ready_at: number | null;
30
+ }
31
+ /** Fetch one (undefined if it does not exist). */
32
+ export declare function getTicket(cdb: CoordDb, id: string): Ticket | undefined;
33
+ export type ClearIntegrationBranchResult = {
34
+ ok: true;
35
+ cleared: boolean;
36
+ } | {
37
+ ok: false;
38
+ reason: 'not_found';
39
+ };
40
+ /**
41
+ * §4.5 / problem B B-3 — reset the ticket integration branch registration (integration_branch) to NULL.
42
+ *
43
+ * The only core write-back the publisher (problem B) makes during post-merge cleanup.
44
+ * integration_branch is a system column that "problem A reserves at aggregation and problem B
45
+ * releases after merge", not a field users edit. So it is separated from update_ticket (which
46
+ * guards user edits with version-CAS) into a dedicated path that does not bump the version (the D-B1 resolution).
47
+ *
48
+ * Idempotent: if already NULL, just return cleared=false (changes==0) and do nothing. A missing
49
+ * ticket is not_found. Passing expectedBranch is a "reset to NULL only when the name matches" guard,
50
+ * so a new integration_branch re-created by another aggregation is not wrongly cleared (insurance
51
+ * for the case where re-aggregation happened between merge detection and release).
52
+ */
53
+ export declare function clearIntegrationBranch(cdb: CoordDb, ticketId: string, expectedBranch?: string): ClearIntegrationBranchResult;
54
+ /**
55
+ * §4.3 fix option A (F2 re-arm) — reset ticket_ready_at to NULL to make ticket_ready be emitted "again".
56
+ *
57
+ * When an unfinished task is added/linked to a ticket that already emitted ready (ticket_ready_at IS
58
+ * NOT NULL), merge.ts's tryEmitTicketReady loses the NULL→value CAS and cannot re-emit (= a B added
59
+ * later does not make it into the PR). To solve this, issue a reverse CAS that resets ticket_ready_at
60
+ * from value→NULL. Then on the new task's done, tryEmitTicketReady's NULL→value CAS passes again and
61
+ * ticket_ready is re-emitted.
62
+ *
63
+ * A local CAS symmetric to tryEmitTicketReady's NULL→value CAS (WHERE ticket_ready_at IS NOT NULL).
64
+ * Does not change integration_branch (the integration branch name) or version (ticket_ready_at is a
65
+ * system column with an unchanged version; same idea as clearIntegrationBranch).
66
+ *
67
+ * @returns true if changes>0 (= actually re-armed). false if already NULL (not emitted).
68
+ */
69
+ export declare function rearmTicketReady(cdb: CoordDb, ticketId: string): boolean;
70
+ /** Result of closeTicketOnMerge. */
71
+ export type CloseTicketOnMergeResult = {
72
+ ok: true;
73
+ closed: boolean;
74
+ } | {
75
+ ok: false;
76
+ reason: 'not_found';
77
+ };
78
+ /**
79
+ * Problem B B-3 — set the ticket to closed during post-merge cleanup (reconcile only).
80
+ *
81
+ * "Marking a ticket merged (closed)" is the publisher's mechanical post-merge cleanup, not the kind
82
+ * of edit where a user rewrites the body. So, aligned with clearIntegrationBranch / rearmTicketReady
83
+ * (D-B1), it is separated from update_ticket (which guards user edits with version-CAS) into a
84
+ * dedicated path that does not bump the version. As a result:
85
+ * - reconcile's cleanup (① integration_branch release / ② status close) is symmetric as "system
86
+ * operations that do not bump the version".
87
+ * - It does not use version-CAS, so a version conflict cannot occur structurally and reconcile's
88
+ * conflict retry is unnecessary. Even if a human/other agent is concurrently editing the ticket's
89
+ * body etc., it does not steal that version and induce a lost update (a local CAS that only closes
90
+ * status one-way, so it does not interfere with edits).
91
+ *
92
+ * Idempotent: if already closed (status='closed'), just return closed=false (changes==0) and do
93
+ * nothing. A missing ticket is not_found. A local CAS symmetric to rearmTicketReady (WHERE status != 'closed').
94
+ *
95
+ * Note: this path only flips status one-way to closed and never re-opens (reconcile never re-opens).
96
+ * If a ticket needs to be re-opened, the user does it via update_ticket as before.
97
+ */
98
+ export declare function closeTicketOnMerge(cdb: CoordDb, ticketId: string): CloseTicketOnMergeResult;
99
+ /** Result of markTicketInProgressOnClaim. Follows closeTicketOnMerge's CloseTicketOnMergeResult. */
100
+ export type MarkTicketInProgressResult = {
101
+ ok: true;
102
+ changed: boolean;
103
+ } | {
104
+ ok: false;
105
+ reason: 'not_found';
106
+ };
107
+ /**
108
+ * 1A — when any task under it is claimed, flip the parent ticket open→in_progress (reconcile only).
109
+ *
110
+ * "Marking a ticket in progress (in_progress)" is a status operation done automatically in step with
111
+ * the mechanical event of a claim, not the kind of edit where a user rewrites the body. So, aligned
112
+ * with closeTicketOnMerge / clearIntegrationBranch / rearmTicketReady (D-B1), it is separated from
113
+ * update_ticket (which guards user edits with version-CAS) into a dedicated path that does not bump
114
+ * the version. As a result:
115
+ * - It does not use version-CAS, so a version conflict cannot occur structurally, and even if a
116
+ * human/other agent is concurrently editing the ticket's body etc., it does not steal that
117
+ * version and induce a lost update (a local CAS that only flips status one-way, so it does not interfere with edits).
118
+ * - A local CAS dedicated to open → in_progress, symmetric to closeTicketOnMerge (open/in_progress → closed).
119
+ *
120
+ * One-way transition (spec 2A): open → in_progress only. It does not automatically return to open
121
+ * (even if a task returns to open via release/sweep, the ticket stays in_progress until it becomes
122
+ * closed). If a re-open is needed, the user does it via update_ticket.
123
+ *
124
+ * Idempotent: a ticket already in_progress / closed falls out of WHERE status = 'open' and is not
125
+ * touched (just returns changed=false and does nothing). A local CAS symmetric to closeTicketOnMerge
126
+ * (WHERE status = 'open'). A missing ticket is not_found.
127
+ *
128
+ * This function assumes the same SQL is written inline inside claimTask's transaction (for
129
+ * atomicity), but like closeTicketOnMerge / rearmTicketReady it is exported to be unit-testable.
130
+ */
131
+ export declare function markTicketInProgressOnClaim(cdb: CoordDb, ticketId: string): MarkTicketInProgressResult;
132
+ /** The list of repos tied to the ticket (fully cross-repo = empty array). */
133
+ export declare function ticketRepos(cdb: CoordDb, ticketId: string): string[];
134
+ /**
135
+ * §2.2 open_ticket — create a ticket.
136
+ * repos omitted or empty array = fully cross-repo (no row inserted into ticket_repos). The body is FTS5-indexed (triggers).
137
+ */
138
+ export declare function openTicket(cdb: CoordDb, args: {
139
+ title: string;
140
+ body?: string | null;
141
+ repos?: string[];
142
+ priority?: number;
143
+ createdBy?: string | null;
144
+ }): Ticket;
145
+ export type UpdateTicketResult = {
146
+ ok: true;
147
+ ticket: Ticket;
148
+ } | {
149
+ ok: false;
150
+ reason: 'conflict' | 'not_found';
151
+ current?: Pick<Ticket, 'status' | 'version'>;
152
+ };
153
+ /**
154
+ * §2.2 update_ticket — update a ticket (D-4: version-CAS).
155
+ * Partially updates status/body/priority/assignee. expected_version prevents lost updates.
156
+ * A conflict (version mismatch) is ok:false + the current value; absence is not_found.
157
+ */
158
+ export declare function updateTicket(cdb: CoordDb, args: {
159
+ ticketId: string;
160
+ expectedVersion: number;
161
+ status?: Ticket['status'];
162
+ body?: string | null;
163
+ priority?: number;
164
+ assignee?: string | null;
165
+ }): UpdateTicketResult;
166
+ export interface TicketListItem extends Ticket {
167
+ repos: string[];
168
+ }
169
+ /**
170
+ * §2.2 list_tickets — list/search.
171
+ * - Filter by status.
172
+ * - When repo is specified, return "tickets having that repo + fully cross-repo (no ticket_repos row)" (the D-1 query convention).
173
+ * - When query is specified, full-text search the body/title via tickets_fts (FTS5).
174
+ * priority descending → creation order. Each ticket carries repos.
175
+ */
176
+ export declare function listTickets(cdb: CoordDb, args?: {
177
+ status?: Ticket['status'];
178
+ repo?: string;
179
+ query?: string;
180
+ limit?: number;
181
+ }): TicketListItem[];
182
+ export type LinkTaskResult = {
183
+ ok: true;
184
+ } | {
185
+ ok: false;
186
+ reason: 'task_not_found' | 'ticket_not_found';
187
+ detail: string;
188
+ };
189
+ /**
190
+ * §2.2 link_task_to_ticket — re-link a task to a parent ticket (ticket_id=null to unlink).
191
+ * Cross-project decomposition is expressed by "creating N tasks per repo under the same ticket_id".
192
+ *
193
+ * Fix option A (F2 re-arm): if the link-target ticket already emitted ready (ticket_ready_at IS NOT
194
+ * NULL) and the task being linked is unfinished (tasks.status != 'done'), reset ticket_ready_at to
195
+ * NULL to make ticket_ready re-emit (equivalent to rearmTicketReady). Re-linking an already-done task
196
+ * does not break the all-done decision, so it does not re-arm (the trigger condition is "the task
197
+ * being linked is unfinished AND the link-target ticket already emitted ready"). The tasks UPDATE and
198
+ * the re-arm are atomized in the same BEGIN IMMEDIATE transaction (so consistency does not break if only one succeeds).
199
+ *
200
+ * 1A (auto-transition on late link / symmetric to claimTask): when an already-claimed task
201
+ * (status === 'in_progress') is later linked to an open ticket, auto-transition the parent ticket
202
+ * open → in_progress. This is symmetric to claimTask flipping the parent ticket on a successful
203
+ * claim; because the parent of a claimed task may be left open (e.g. the ticket was unlinked at claim
204
+ * time), the late link supplements the same transition. The trigger condition is only
205
+ * task.status === 'in_progress' (do not transition for blocked/open/done/abandoned; aligned
206
+ * symmetric to claimTask). When ticketId is null (unlink), it does not touch the ticket. With the
207
+ * same idea as markTicketInProgressOnClaim / closeTicketOnMerge (D-B1), it uses a one-way, idempotent
208
+ * local CAS that does not bump the version (WHERE status = 'open'). This way a ticket already
209
+ * in_progress/closed is untouched, and a user's/other agent's concurrent edit of the ticket body is
210
+ * not lost-updated. For atomicity, like the re-arm SQL above, this transition SQL is also written
211
+ * inline in the same transaction (the LinkTaskResult return contract is unchanged).
212
+ */
213
+ export declare function linkTaskToTicket(cdb: CoordDb, taskId: string, ticketId: string | null): LinkTaskResult;
@@ -0,0 +1,340 @@
1
+ import { ulid } from 'ulid';
2
+ import { unixNow } from '../db/index.js';
3
+ /** Fetch one (undefined if it does not exist). */
4
+ export function getTicket(cdb, id) {
5
+ return cdb.db.prepare('SELECT * FROM tickets WHERE id = ?').get(id);
6
+ }
7
+ /**
8
+ * §4.5 / problem B B-3 — reset the ticket integration branch registration (integration_branch) to NULL.
9
+ *
10
+ * The only core write-back the publisher (problem B) makes during post-merge cleanup.
11
+ * integration_branch is a system column that "problem A reserves at aggregation and problem B
12
+ * releases after merge", not a field users edit. So it is separated from update_ticket (which
13
+ * guards user edits with version-CAS) into a dedicated path that does not bump the version (the D-B1 resolution).
14
+ *
15
+ * Idempotent: if already NULL, just return cleared=false (changes==0) and do nothing. A missing
16
+ * ticket is not_found. Passing expectedBranch is a "reset to NULL only when the name matches" guard,
17
+ * so a new integration_branch re-created by another aggregation is not wrongly cleared (insurance
18
+ * for the case where re-aggregation happened between merge detection and release).
19
+ */
20
+ export function clearIntegrationBranch(cdb, ticketId, expectedBranch) {
21
+ const db = cdb.db;
22
+ const exists = db.prepare('SELECT 1 FROM tickets WHERE id = ?').get(ticketId);
23
+ if (!exists)
24
+ return { ok: false, reason: 'not_found' };
25
+ let info;
26
+ if (expectedBranch !== undefined) {
27
+ info = db
28
+ .prepare(`UPDATE tickets SET integration_branch = NULL, updated_at = ?
29
+ WHERE id = ? AND integration_branch = ?`)
30
+ .run(unixNow(), ticketId, expectedBranch);
31
+ }
32
+ else {
33
+ info = db
34
+ .prepare(`UPDATE tickets SET integration_branch = NULL, updated_at = ?
35
+ WHERE id = ? AND integration_branch IS NOT NULL`)
36
+ .run(unixNow(), ticketId);
37
+ }
38
+ return { ok: true, cleared: info.changes > 0 };
39
+ }
40
+ /**
41
+ * §4.3 fix option A (F2 re-arm) — reset ticket_ready_at to NULL to make ticket_ready be emitted "again".
42
+ *
43
+ * When an unfinished task is added/linked to a ticket that already emitted ready (ticket_ready_at IS
44
+ * NOT NULL), merge.ts's tryEmitTicketReady loses the NULL→value CAS and cannot re-emit (= a B added
45
+ * later does not make it into the PR). To solve this, issue a reverse CAS that resets ticket_ready_at
46
+ * from value→NULL. Then on the new task's done, tryEmitTicketReady's NULL→value CAS passes again and
47
+ * ticket_ready is re-emitted.
48
+ *
49
+ * A local CAS symmetric to tryEmitTicketReady's NULL→value CAS (WHERE ticket_ready_at IS NOT NULL).
50
+ * Does not change integration_branch (the integration branch name) or version (ticket_ready_at is a
51
+ * system column with an unchanged version; same idea as clearIntegrationBranch).
52
+ *
53
+ * @returns true if changes>0 (= actually re-armed). false if already NULL (not emitted).
54
+ */
55
+ export function rearmTicketReady(cdb, ticketId) {
56
+ const info = cdb.db
57
+ .prepare(`UPDATE tickets SET ticket_ready_at = NULL, updated_at = ?
58
+ WHERE id = ? AND ticket_ready_at IS NOT NULL`)
59
+ .run(unixNow(), ticketId);
60
+ return info.changes > 0;
61
+ }
62
+ /**
63
+ * Problem B B-3 — set the ticket to closed during post-merge cleanup (reconcile only).
64
+ *
65
+ * "Marking a ticket merged (closed)" is the publisher's mechanical post-merge cleanup, not the kind
66
+ * of edit where a user rewrites the body. So, aligned with clearIntegrationBranch / rearmTicketReady
67
+ * (D-B1), it is separated from update_ticket (which guards user edits with version-CAS) into a
68
+ * dedicated path that does not bump the version. As a result:
69
+ * - reconcile's cleanup (① integration_branch release / ② status close) is symmetric as "system
70
+ * operations that do not bump the version".
71
+ * - It does not use version-CAS, so a version conflict cannot occur structurally and reconcile's
72
+ * conflict retry is unnecessary. Even if a human/other agent is concurrently editing the ticket's
73
+ * body etc., it does not steal that version and induce a lost update (a local CAS that only closes
74
+ * status one-way, so it does not interfere with edits).
75
+ *
76
+ * Idempotent: if already closed (status='closed'), just return closed=false (changes==0) and do
77
+ * nothing. A missing ticket is not_found. A local CAS symmetric to rearmTicketReady (WHERE status != 'closed').
78
+ *
79
+ * Note: this path only flips status one-way to closed and never re-opens (reconcile never re-opens).
80
+ * If a ticket needs to be re-opened, the user does it via update_ticket as before.
81
+ */
82
+ export function closeTicketOnMerge(cdb, ticketId) {
83
+ const db = cdb.db;
84
+ const exists = db.prepare('SELECT 1 FROM tickets WHERE id = ?').get(ticketId);
85
+ if (!exists)
86
+ return { ok: false, reason: 'not_found' };
87
+ const info = db
88
+ .prepare(`UPDATE tickets SET status = 'closed', updated_at = ?
89
+ WHERE id = ? AND status != 'closed'`)
90
+ .run(unixNow(), ticketId);
91
+ return { ok: true, closed: info.changes > 0 };
92
+ }
93
+ /**
94
+ * 1A — when any task under it is claimed, flip the parent ticket open→in_progress (reconcile only).
95
+ *
96
+ * "Marking a ticket in progress (in_progress)" is a status operation done automatically in step with
97
+ * the mechanical event of a claim, not the kind of edit where a user rewrites the body. So, aligned
98
+ * with closeTicketOnMerge / clearIntegrationBranch / rearmTicketReady (D-B1), it is separated from
99
+ * update_ticket (which guards user edits with version-CAS) into a dedicated path that does not bump
100
+ * the version. As a result:
101
+ * - It does not use version-CAS, so a version conflict cannot occur structurally, and even if a
102
+ * human/other agent is concurrently editing the ticket's body etc., it does not steal that
103
+ * version and induce a lost update (a local CAS that only flips status one-way, so it does not interfere with edits).
104
+ * - A local CAS dedicated to open → in_progress, symmetric to closeTicketOnMerge (open/in_progress → closed).
105
+ *
106
+ * One-way transition (spec 2A): open → in_progress only. It does not automatically return to open
107
+ * (even if a task returns to open via release/sweep, the ticket stays in_progress until it becomes
108
+ * closed). If a re-open is needed, the user does it via update_ticket.
109
+ *
110
+ * Idempotent: a ticket already in_progress / closed falls out of WHERE status = 'open' and is not
111
+ * touched (just returns changed=false and does nothing). A local CAS symmetric to closeTicketOnMerge
112
+ * (WHERE status = 'open'). A missing ticket is not_found.
113
+ *
114
+ * This function assumes the same SQL is written inline inside claimTask's transaction (for
115
+ * atomicity), but like closeTicketOnMerge / rearmTicketReady it is exported to be unit-testable.
116
+ */
117
+ export function markTicketInProgressOnClaim(cdb, ticketId) {
118
+ const db = cdb.db;
119
+ const exists = db.prepare('SELECT 1 FROM tickets WHERE id = ?').get(ticketId);
120
+ if (!exists)
121
+ return { ok: false, reason: 'not_found' };
122
+ const info = db
123
+ .prepare(`UPDATE tickets SET status = 'in_progress', updated_at = ?
124
+ WHERE id = ? AND status = 'open'`)
125
+ .run(unixNow(), ticketId);
126
+ return { ok: true, changed: info.changes > 0 };
127
+ }
128
+ /** The list of repos tied to the ticket (fully cross-repo = empty array). */
129
+ export function ticketRepos(cdb, ticketId) {
130
+ const rows = cdb.db
131
+ .prepare('SELECT repo FROM ticket_repos WHERE ticket_id = ? ORDER BY repo')
132
+ .all(ticketId);
133
+ return rows.map((r) => r.repo);
134
+ }
135
+ /**
136
+ * §2.2 open_ticket — create a ticket.
137
+ * repos omitted or empty array = fully cross-repo (no row inserted into ticket_repos). The body is FTS5-indexed (triggers).
138
+ */
139
+ export function openTicket(cdb, args) {
140
+ const db = cdb.db;
141
+ const now = unixNow();
142
+ const id = ulid();
143
+ const repos = args.repos ?? [];
144
+ db.exec('BEGIN IMMEDIATE');
145
+ try {
146
+ db.prepare(`INSERT INTO tickets (id, title, body, status, priority, assignee, version,
147
+ created_by, created_at, updated_at, host_id, origin)
148
+ VALUES (?, ?, ?, 'open', ?, NULL, 0, ?, ?, ?, ?, 'local')`).run(id, args.title, args.body ?? null, args.priority ?? 0, args.createdBy ?? null, now, now, cdb.hostId);
149
+ // Insert join rows only when repos is specified (empty = the fully-cross-repo convention). Ignore duplicates.
150
+ const seen = new Set();
151
+ for (const repo of repos) {
152
+ if (seen.has(repo))
153
+ continue;
154
+ seen.add(repo);
155
+ db.prepare(`INSERT OR IGNORE INTO ticket_repos (ticket_id, repo, host_id, origin)
156
+ VALUES (?, ?, ?, 'local')`).run(id, repo, cdb.hostId);
157
+ }
158
+ db.exec('COMMIT');
159
+ return getTicket(cdb, id);
160
+ }
161
+ catch (e) {
162
+ try {
163
+ db.exec('ROLLBACK');
164
+ }
165
+ catch {
166
+ /* already closed */
167
+ }
168
+ throw e;
169
+ }
170
+ }
171
+ /**
172
+ * §2.2 update_ticket — update a ticket (D-4: version-CAS).
173
+ * Partially updates status/body/priority/assignee. expected_version prevents lost updates.
174
+ * A conflict (version mismatch) is ok:false + the current value; absence is not_found.
175
+ */
176
+ export function updateTicket(cdb, args) {
177
+ const db = cdb.db;
178
+ const now = unixNow();
179
+ // Build the SET clause dynamically (update only the passed fields). version is always +1; the CAS is in WHERE.
180
+ const sets = ['version = version + 1', 'updated_at = ?'];
181
+ const params = [now];
182
+ if (args.status !== undefined) {
183
+ sets.push('status = ?');
184
+ params.push(args.status);
185
+ }
186
+ if (args.body !== undefined) {
187
+ sets.push('body = ?');
188
+ params.push(args.body);
189
+ }
190
+ if (args.priority !== undefined) {
191
+ sets.push('priority = ?');
192
+ params.push(args.priority);
193
+ }
194
+ if (args.assignee !== undefined) {
195
+ sets.push('assignee = ?');
196
+ params.push(args.assignee);
197
+ }
198
+ db.exec('BEGIN IMMEDIATE');
199
+ try {
200
+ const info = db
201
+ .prepare(`UPDATE tickets SET ${sets.join(', ')} WHERE id = ? AND version = ?`)
202
+ .run(...params, args.ticketId, args.expectedVersion);
203
+ if (info.changes === 0) {
204
+ const current = db
205
+ .prepare('SELECT status, version FROM tickets WHERE id = ?')
206
+ .get(args.ticketId);
207
+ db.exec('ROLLBACK');
208
+ if (!current)
209
+ return { ok: false, reason: 'not_found' };
210
+ return { ok: false, reason: 'conflict', current };
211
+ }
212
+ const ticket = getTicket(cdb, args.ticketId);
213
+ db.exec('COMMIT');
214
+ return { ok: true, ticket };
215
+ }
216
+ catch (e) {
217
+ try {
218
+ db.exec('ROLLBACK');
219
+ }
220
+ catch {
221
+ /* already closed */
222
+ }
223
+ throw e;
224
+ }
225
+ }
226
+ /**
227
+ * §2.2 list_tickets — list/search.
228
+ * - Filter by status.
229
+ * - When repo is specified, return "tickets having that repo + fully cross-repo (no ticket_repos row)" (the D-1 query convention).
230
+ * - When query is specified, full-text search the body/title via tickets_fts (FTS5).
231
+ * priority descending → creation order. Each ticket carries repos.
232
+ */
233
+ export function listTickets(cdb, args = {}) {
234
+ const db = cdb.db;
235
+ const limit = args.limit ?? 100;
236
+ const clauses = [];
237
+ const params = [];
238
+ const q = args.query?.trim();
239
+ // trigram cannot index 2 chars or fewer, so a short query falls back to LIKE.
240
+ const useLike = !!q && [...q].length < 3;
241
+ // FTS5 search: narrow rowids with tickets_fts MATCH and JOIN to the main table.
242
+ let from = 'tickets i';
243
+ if (q && !useLike) {
244
+ from = 'tickets i JOIN tickets_fts f ON f.rowid = i.rowid';
245
+ clauses.push('tickets_fts MATCH ?');
246
+ params.push(q);
247
+ }
248
+ else if (q && useLike) {
249
+ const like = `%${q}%`;
250
+ clauses.push('(i.title LIKE ? OR i.body LIKE ?)');
251
+ params.push(like, like);
252
+ }
253
+ if (args.status) {
254
+ clauses.push('i.status = ?');
255
+ params.push(args.status);
256
+ }
257
+ if (args.repo) {
258
+ // Has that repo OR fully cross-repo (no ticket_repos row)
259
+ clauses.push(`( EXISTS (SELECT 1 FROM ticket_repos r WHERE r.ticket_id = i.id AND r.repo = ?)
260
+ OR NOT EXISTS (SELECT 1 FROM ticket_repos r WHERE r.ticket_id = i.id) )`);
261
+ params.push(args.repo);
262
+ }
263
+ const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
264
+ const rows = db
265
+ .prepare(`SELECT i.* FROM ${from} ${where}
266
+ ORDER BY i.priority DESC, i.created_at ASC LIMIT ?`)
267
+ .all(...params, limit);
268
+ return rows.map((r) => ({ ...r, repos: ticketRepos(cdb, r.id) }));
269
+ }
270
+ /**
271
+ * §2.2 link_task_to_ticket — re-link a task to a parent ticket (ticket_id=null to unlink).
272
+ * Cross-project decomposition is expressed by "creating N tasks per repo under the same ticket_id".
273
+ *
274
+ * Fix option A (F2 re-arm): if the link-target ticket already emitted ready (ticket_ready_at IS NOT
275
+ * NULL) and the task being linked is unfinished (tasks.status != 'done'), reset ticket_ready_at to
276
+ * NULL to make ticket_ready re-emit (equivalent to rearmTicketReady). Re-linking an already-done task
277
+ * does not break the all-done decision, so it does not re-arm (the trigger condition is "the task
278
+ * being linked is unfinished AND the link-target ticket already emitted ready"). The tasks UPDATE and
279
+ * the re-arm are atomized in the same BEGIN IMMEDIATE transaction (so consistency does not break if only one succeeds).
280
+ *
281
+ * 1A (auto-transition on late link / symmetric to claimTask): when an already-claimed task
282
+ * (status === 'in_progress') is later linked to an open ticket, auto-transition the parent ticket
283
+ * open → in_progress. This is symmetric to claimTask flipping the parent ticket on a successful
284
+ * claim; because the parent of a claimed task may be left open (e.g. the ticket was unlinked at claim
285
+ * time), the late link supplements the same transition. The trigger condition is only
286
+ * task.status === 'in_progress' (do not transition for blocked/open/done/abandoned; aligned
287
+ * symmetric to claimTask). When ticketId is null (unlink), it does not touch the ticket. With the
288
+ * same idea as markTicketInProgressOnClaim / closeTicketOnMerge (D-B1), it uses a one-way, idempotent
289
+ * local CAS that does not bump the version (WHERE status = 'open'). This way a ticket already
290
+ * in_progress/closed is untouched, and a user's/other agent's concurrent edit of the ticket body is
291
+ * not lost-updated. For atomicity, like the re-arm SQL above, this transition SQL is also written
292
+ * inline in the same transaction (the LinkTaskResult return contract is unchanged).
293
+ */
294
+ export function linkTaskToTicket(cdb, taskId, ticketId) {
295
+ const db = cdb.db;
296
+ const task = db.prepare('SELECT status FROM tasks WHERE id = ?').get(taskId);
297
+ if (!task) {
298
+ return { ok: false, reason: 'task_not_found', detail: `task '${taskId}' does not exist` };
299
+ }
300
+ if (ticketId !== null) {
301
+ const ticket = db.prepare('SELECT 1 FROM tickets WHERE id = ?').get(ticketId);
302
+ if (!ticket) {
303
+ return {
304
+ ok: false,
305
+ reason: 'ticket_not_found',
306
+ detail: `ticket '${ticketId}' does not exist`,
307
+ };
308
+ }
309
+ }
310
+ const now = unixNow();
311
+ db.exec('BEGIN IMMEDIATE');
312
+ try {
313
+ db.prepare('UPDATE tasks SET ticket_id = ?, updated_at = ? WHERE id = ?').run(ticketId, now, taskId);
314
+ // Re-arm (reverse CAS) only when linking an unfinished task to a ticket that already emitted ready.
315
+ if (ticketId !== null && task.status !== 'done') {
316
+ db.prepare(`UPDATE tickets SET ticket_ready_at = NULL, updated_at = ?
317
+ WHERE id = ? AND ticket_ready_at IS NOT NULL`).run(now, ticketId);
318
+ }
319
+ // 1A: when an already-claimed (in_progress) task is late-linked, auto-transition the parent
320
+ // ticket open→in_progress (symmetric to claimTask). The trigger condition is only
321
+ // task.status === 'in_progress'. A one-way, idempotent local CAS that does not bump the version
322
+ // (WHERE status = 'open'): a ticket already in_progress/closed is untouched.
323
+ if (ticketId !== null && task.status === 'in_progress') {
324
+ db.prepare(`UPDATE tickets SET status = 'in_progress', updated_at = ?
325
+ WHERE id = ? AND status = 'open'`).run(now, ticketId);
326
+ }
327
+ db.exec('COMMIT');
328
+ }
329
+ catch (e) {
330
+ try {
331
+ db.exec('ROLLBACK');
332
+ }
333
+ catch {
334
+ /* already closed */
335
+ }
336
+ throw e;
337
+ }
338
+ return { ok: true };
339
+ }
340
+ //# sourceMappingURL=tickets.js.map