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.
- package/LICENSE +21 -0
- package/README.md +355 -2
- package/dist/db/index.d.ts +52 -0
- package/dist/db/index.js +238 -0
- package/dist/db/schema.sql +238 -0
- package/dist/hook/clients.d.ts +56 -0
- package/dist/hook/clients.js +258 -0
- package/dist/hook/context.d.ts +22 -0
- package/dist/hook/context.js +40 -0
- package/dist/hook/dispatch.d.ts +21 -0
- package/dist/hook/dispatch.js +212 -0
- package/dist/hook/guard.d.ts +34 -0
- package/dist/hook/guard.js +106 -0
- package/dist/hook/repo.d.ts +40 -0
- package/dist/hook/repo.js +173 -0
- package/dist/hook/setup-kiro.d.ts +30 -0
- package/dist/hook/setup-kiro.js +118 -0
- package/dist/index.d.ts +47 -0
- package/dist/index.js +694 -0
- package/dist/launcher/client.d.ts +22 -0
- package/dist/launcher/client.js +57 -0
- package/dist/launcher/publish-adapter.d.ts +88 -0
- package/dist/launcher/publish-adapter.js +233 -0
- package/dist/launcher/publish-cli.d.ts +2 -0
- package/dist/launcher/publish-cli.js +123 -0
- package/dist/launcher/publish.d.ts +140 -0
- package/dist/launcher/publish.js +522 -0
- package/dist/lockage/board.d.ts +247 -0
- package/dist/lockage/board.js +407 -0
- package/dist/lockage/claim.d.ts +307 -0
- package/dist/lockage/claim.js +580 -0
- package/dist/lockage/files.d.ts +229 -0
- package/dist/lockage/files.js +403 -0
- package/dist/lockage/git.d.ts +92 -0
- package/dist/lockage/git.js +183 -0
- package/dist/lockage/globs.d.ts +26 -0
- package/dist/lockage/globs.js +125 -0
- package/dist/lockage/merge.d.ts +132 -0
- package/dist/lockage/merge.js +831 -0
- package/dist/lockage/publish-lock.d.ts +38 -0
- package/dist/lockage/publish-lock.js +73 -0
- package/dist/lockage/tickets.d.ts +213 -0
- package/dist/lockage/tickets.js +340 -0
- package/dist/lockage/worktree.d.ts +212 -0
- package/dist/lockage/worktree.js +563 -0
- 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
|