@bumpyclock/pi-tasque 0.1.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.
@@ -0,0 +1,331 @@
1
+ import type { AgentToolResult } from "@earendil-works/pi-coding-agent";
2
+ import { runQueuedMutation } from "../durable-tasks/mutation-queue.js";
3
+ import { runTsqJson } from "../durable-tasks/runner.js";
4
+ import { cloneTaskState } from "../session-todos/state/state.js";
5
+ import { applyTaskMutation } from "../session-todos/state/state-reducer.js";
6
+ import { commitState, getState } from "../session-todos/state/store.js";
7
+ import type { Task } from "../session-todos/tool/types.js";
8
+ import {
9
+ errorToolDetails,
10
+ okToolDetails,
11
+ textToolResult,
12
+ } from "../shared/tool-result.js";
13
+ import type {
14
+ PromoteTodoBridgeParams,
15
+ TaskBridgeDetails,
16
+ TaskBridgeHandlerContext,
17
+ } from "./types.js";
18
+
19
+ const DEFAULT_KIND = "task";
20
+ const DEFAULT_PRIORITY = 2;
21
+ const DEFAULT_PROMOTED_BY = "pi";
22
+ const PROMOTION_NOTE_PREFIX = "Promoted from pi-tasque session todo #";
23
+
24
+ interface ValidPromotionParams {
25
+ readonly todoId: number;
26
+ readonly kind: string;
27
+ readonly priority: number;
28
+ readonly description?: string;
29
+ readonly parent?: string;
30
+ readonly planned?: boolean;
31
+ readonly needsPlan?: boolean;
32
+ readonly promotedBy: string;
33
+ }
34
+
35
+ export async function promoteTodoHandler(
36
+ params: PromoteTodoBridgeParams,
37
+ ctx: TaskBridgeHandlerContext,
38
+ ): Promise<AgentToolResult<TaskBridgeDetails>> {
39
+ const validation = validateParams(params);
40
+ if (!validation.ok) {
41
+ return errorResult("validation_error", validation.message);
42
+ }
43
+
44
+ const todo = findPromotableTodo(validation.value.todoId);
45
+ if (!todo.ok) {
46
+ return errorResult("validation_error", todo.message);
47
+ }
48
+
49
+ const similarArgv = ["find", "similar", todo.value.subject];
50
+ let similarResult: unknown;
51
+ try {
52
+ similarResult = await runTsqJson(ctx.pi, { cwd: ctx.cwd }, similarArgv, {
53
+ ...(ctx.signal === undefined ? {} : { signal: ctx.signal }),
54
+ });
55
+ } catch (error) {
56
+ return errorResult(getErrorCode(error), getErrorMessage(error), {
57
+ action: "promote_todo",
58
+ argv: { similar: similarArgv },
59
+ error: serializeError(error),
60
+ });
61
+ }
62
+ const similarCandidates = extractSimilarCandidates(similarResult);
63
+
64
+ const createArgv = buildCreateArgv(todo.value, validation.value);
65
+ let createResult: unknown;
66
+ try {
67
+ createResult = await runQueuedMutation(ctx.cwd, () =>
68
+ runTsqJson(ctx.pi, { cwd: ctx.cwd }, createArgv, {
69
+ ...(ctx.signal === undefined ? {} : { signal: ctx.signal }),
70
+ }),
71
+ );
72
+ } catch (error) {
73
+ return errorResult(getErrorCode(error), getErrorMessage(error), {
74
+ action: "promote_todo",
75
+ similarCandidates,
76
+ argv: { similar: similarArgv, create: createArgv },
77
+ error: serializeError(error),
78
+ });
79
+ }
80
+
81
+ const tsqId = extractCreatedTaskId(createResult);
82
+ if (tsqId === undefined) {
83
+ return errorResult(
84
+ "invalid_tsq_response",
85
+ "tsq create response did not include a task id",
86
+ {
87
+ action: "promote_todo",
88
+ similarCandidates,
89
+ argv: { similar: similarArgv, create: createArgv },
90
+ result: createResult,
91
+ },
92
+ );
93
+ }
94
+
95
+ const noteText = `${PROMOTION_NOTE_PREFIX}${validation.value.todoId}`;
96
+ const noteArgv = ["note", tsqId, "--", noteText];
97
+ let noteResult: unknown;
98
+ const warnings: string[] = [];
99
+ try {
100
+ noteResult = await runQueuedMutation(ctx.cwd, () =>
101
+ runTsqJson(ctx.pi, { cwd: ctx.cwd }, noteArgv, {
102
+ ...(ctx.signal === undefined ? {} : { signal: ctx.signal }),
103
+ }),
104
+ );
105
+ } catch (error) {
106
+ warnings.push(
107
+ `Failed to add promotion note to ${tsqId}: ${getErrorMessage(error)}`,
108
+ );
109
+ }
110
+
111
+ const promotedAt = new Date().toISOString();
112
+ const updateResult = applyTaskMutation(getState(), "update", {
113
+ id: validation.value.todoId,
114
+ status: "completed",
115
+ metadata: {
116
+ tsqId,
117
+ promotedAt,
118
+ promotedBy: validation.value.promotedBy,
119
+ },
120
+ });
121
+ if (updateResult.op.kind === "error") {
122
+ return errorResult("todo_update_failed", updateResult.op.message, {
123
+ action: "promote_todo",
124
+ tsqId,
125
+ similarCandidates,
126
+ argv: { similar: similarArgv, create: createArgv, note: noteArgv },
127
+ createResult,
128
+ noteResult,
129
+ warnings,
130
+ });
131
+ }
132
+ commitState(updateResult.state);
133
+ const todoSnapshot = cloneTaskState(updateResult.state);
134
+ const updatedTodo = todoSnapshot.tasks.find(
135
+ (task) => task.id === validation.value.todoId,
136
+ );
137
+
138
+ const content = [`Promoted todo #${validation.value.todoId} to ${tsqId}`];
139
+ if (warnings.length > 0) {
140
+ content.push(...warnings.map((warning) => `Warning: ${warning}`));
141
+ }
142
+
143
+ return textToolResult(
144
+ content.join("\n"),
145
+ okToolDetails(
146
+ {
147
+ action: "promote_todo" as const,
148
+ todo: updatedTodo,
149
+ tsqId,
150
+ todoSnapshot,
151
+ similarCandidates,
152
+ createResult,
153
+ noteResult,
154
+ argv: {
155
+ similar: similarArgv,
156
+ create: createArgv,
157
+ note: noteArgv,
158
+ },
159
+ },
160
+ warnings.length === 0 ? {} : { warnings },
161
+ ),
162
+ );
163
+ }
164
+
165
+ function validateParams(
166
+ params: PromoteTodoBridgeParams,
167
+ ):
168
+ | { readonly ok: true; readonly value: ValidPromotionParams }
169
+ | { readonly ok: false; readonly message: string } {
170
+ const todoId = params.todoId;
171
+ if (typeof todoId !== "number" || !Number.isInteger(todoId) || todoId < 1) {
172
+ return { ok: false, message: "todoId is required" };
173
+ }
174
+
175
+ const kind = params.kind === undefined ? DEFAULT_KIND : params.kind.trim();
176
+ if (kind.length === 0) {
177
+ return { ok: false, message: "kind must be a non-empty string" };
178
+ }
179
+
180
+ const priority = params.priority ?? DEFAULT_PRIORITY;
181
+ if (!Number.isInteger(priority)) {
182
+ return { ok: false, message: "priority must be an integer" };
183
+ }
184
+
185
+ const planned = params.planned;
186
+ const needsPlan = params.needsPlan;
187
+ if (planned === true && needsPlan === true) {
188
+ return {
189
+ ok: false,
190
+ message: "planned and needsPlan cannot both be true",
191
+ };
192
+ }
193
+
194
+ const promotedBy =
195
+ normalizeOptionalString(params.assignee) ?? DEFAULT_PROMOTED_BY;
196
+ const description = normalizeOptionalString(params.description);
197
+ const parent = normalizeOptionalString(params.parent);
198
+
199
+ return {
200
+ ok: true,
201
+ value: {
202
+ todoId,
203
+ kind,
204
+ priority,
205
+ promotedBy,
206
+ ...(description === undefined ? {} : { description }),
207
+ ...(parent === undefined ? {} : { parent }),
208
+ ...(planned === undefined ? {} : { planned }),
209
+ ...(needsPlan === undefined ? {} : { needsPlan }),
210
+ },
211
+ };
212
+ }
213
+
214
+ function findPromotableTodo(
215
+ todoId: number,
216
+ ):
217
+ | { readonly ok: true; readonly value: Task }
218
+ | { readonly ok: false; readonly message: string } {
219
+ const todo = getState().tasks.find((task) => task.id === todoId);
220
+ if (todo === undefined) {
221
+ return { ok: false, message: `todo #${todoId} not found` };
222
+ }
223
+ if (todo.status === "deleted") {
224
+ return { ok: false, message: `todo #${todoId} is deleted` };
225
+ }
226
+ return { ok: true, value: todo };
227
+ }
228
+
229
+ function buildCreateArgv(todo: Task, params: ValidPromotionParams): string[] {
230
+ const argv = [
231
+ "create",
232
+ `--kind=${params.kind}`,
233
+ "-p",
234
+ String(params.priority),
235
+ ];
236
+ const description =
237
+ params.description ?? normalizeOptionalString(todo.description);
238
+ if (description !== undefined) {
239
+ argv.push(`--description=${description}`);
240
+ }
241
+ if (params.parent !== undefined) {
242
+ argv.push(`--parent=${params.parent}`);
243
+ }
244
+ if (params.planned === true) {
245
+ argv.push("--planned");
246
+ } else if (params.needsPlan === true) {
247
+ argv.push("--needs-plan");
248
+ }
249
+ argv.push("--", todo.subject);
250
+ return argv;
251
+ }
252
+
253
+ function normalizeOptionalString(
254
+ value: string | undefined,
255
+ ): string | undefined {
256
+ if (value === undefined) return undefined;
257
+ const trimmed = value.trim();
258
+ return trimmed.length === 0 ? undefined : trimmed;
259
+ }
260
+
261
+ function extractSimilarCandidates(result: unknown): readonly unknown[] {
262
+ if (Array.isArray(result)) return result;
263
+ if (isRecord(result) && Array.isArray(result.candidates)) {
264
+ return result.candidates;
265
+ }
266
+ return [];
267
+ }
268
+
269
+ function extractCreatedTaskId(result: unknown): string | undefined {
270
+ if (!isRecord(result)) return undefined;
271
+ const directId =
272
+ readNonEmptyString(result.id) ?? readNonEmptyString(result.task_id);
273
+ if (directId !== undefined) return directId;
274
+ if (isRecord(result.task)) {
275
+ return (
276
+ readNonEmptyString(result.task.id) ??
277
+ readNonEmptyString(result.task.task_id)
278
+ );
279
+ }
280
+ return undefined;
281
+ }
282
+
283
+ function readNonEmptyString(value: unknown): string | undefined {
284
+ if (typeof value !== "string") return undefined;
285
+ const trimmed = value.trim();
286
+ return trimmed.length === 0 ? undefined : trimmed;
287
+ }
288
+
289
+ function errorResult(
290
+ code: string,
291
+ message: string,
292
+ details?: unknown,
293
+ ): AgentToolResult<TaskBridgeDetails> {
294
+ return textToolResult(
295
+ `Error: ${message}`,
296
+ errorToolDetails({
297
+ code,
298
+ message,
299
+ ...(details === undefined ? {} : { details }),
300
+ }),
301
+ );
302
+ }
303
+
304
+ function getErrorMessage(error: unknown): string {
305
+ if (error instanceof Error) return error.message;
306
+ return String(error);
307
+ }
308
+
309
+ function getErrorCode(error: unknown): string {
310
+ if (isRecord(error) && typeof error.code === "string") {
311
+ return error.code;
312
+ }
313
+ return "tsq_error";
314
+ }
315
+
316
+ function serializeError(error: unknown): unknown {
317
+ if (error instanceof Error) {
318
+ return {
319
+ name: error.name,
320
+ message: error.message,
321
+ ...(isRecord(error) && typeof error.code === "string"
322
+ ? { code: error.code }
323
+ : {}),
324
+ };
325
+ }
326
+ return error;
327
+ }
328
+
329
+ function isRecord(value: unknown): value is Record<string, unknown> {
330
+ return typeof value === "object" && value !== null && !Array.isArray(value);
331
+ }
@@ -0,0 +1,156 @@
1
+ import { StringEnum } from "@earendil-works/pi-ai";
2
+ import type {
3
+ AgentToolResult,
4
+ ExtensionAPI,
5
+ ExtensionContext,
6
+ } from "@earendil-works/pi-coding-agent";
7
+ import { type Static, Type } from "typebox";
8
+ import type { Task, TaskStatus } from "../session-todos/tool/types.js";
9
+ import type { StandardToolDetails } from "../shared/tool-result.js";
10
+
11
+ export const TASK_BRIDGE_TOOL_NAME = "task_bridge";
12
+
13
+ export const TASK_BRIDGE_ACTIONS = [
14
+ "link",
15
+ "list_links",
16
+ "promote_todo",
17
+ "import_tsq",
18
+ ] as const;
19
+
20
+ export type TaskBridgeAction = (typeof TASK_BRIDGE_ACTIONS)[number];
21
+
22
+ export const TaskBridgeParamsSchema = Type.Object(
23
+ {
24
+ action: StringEnum(TASK_BRIDGE_ACTIONS, {
25
+ description:
26
+ "Explicit bridge operation between session todo state and durable Tasque tasks.",
27
+ }),
28
+ todoId: Type.Optional(
29
+ Type.Integer({
30
+ description:
31
+ "Session todo id. Required for link and promote_todo actions.",
32
+ minimum: 1,
33
+ }),
34
+ ),
35
+ tsqId: Type.Optional(
36
+ Type.String({
37
+ description:
38
+ "Durable Tasque task id. Required for link and import_tsq actions.",
39
+ }),
40
+ ),
41
+ assignee: Type.Optional(
42
+ Type.String({
43
+ description:
44
+ "Agent/owner name used by promote_todo/import_tsq bridge actions.",
45
+ }),
46
+ ),
47
+ owner: Type.Optional(
48
+ Type.String({
49
+ description: "Todo owner used by import_tsq bridge action.",
50
+ }),
51
+ ),
52
+ kind: Type.Optional(
53
+ Type.String({
54
+ description: "Tasque task kind used by promote_todo bridge action.",
55
+ }),
56
+ ),
57
+ priority: Type.Optional(
58
+ Type.Integer({
59
+ description: "Tasque priority used by promote_todo bridge action.",
60
+ }),
61
+ ),
62
+ description: Type.Optional(
63
+ Type.String({
64
+ description: "Description override used by promote_todo bridge action.",
65
+ }),
66
+ ),
67
+ parent: Type.Optional(
68
+ Type.String({
69
+ description:
70
+ "Parent Tasque task id used by promote_todo bridge action.",
71
+ }),
72
+ ),
73
+ planned: Type.Optional(
74
+ Type.Boolean({
75
+ description: "Planning flag used by promote_todo bridge action.",
76
+ }),
77
+ ),
78
+ needsPlan: Type.Optional(
79
+ Type.Boolean({
80
+ description: "Planning flag used by promote_todo bridge action.",
81
+ }),
82
+ ),
83
+ },
84
+ { additionalProperties: false },
85
+ );
86
+
87
+ export type TaskBridgeParams = Static<typeof TaskBridgeParamsSchema>;
88
+
89
+ export interface TaskBridgeLink {
90
+ readonly todoId: number;
91
+ readonly todoSubject: string;
92
+ readonly todoStatus: Exclude<TaskStatus, "deleted">;
93
+ readonly tsqId: string;
94
+ }
95
+
96
+ export interface TaskBridgeTodoSnapshot {
97
+ readonly tasks: readonly Task[];
98
+ readonly nextId: number;
99
+ }
100
+
101
+ export interface LinkBridgeParams extends TaskBridgeParams {
102
+ readonly action: "link";
103
+ readonly todoId?: number;
104
+ readonly tsqId?: string;
105
+ }
106
+
107
+ export interface ListLinksBridgeParams extends TaskBridgeParams {
108
+ readonly action: "list_links";
109
+ }
110
+
111
+ export interface PromoteTodoBridgeParams extends TaskBridgeParams {
112
+ readonly action: "promote_todo";
113
+ readonly todoId?: number;
114
+ }
115
+
116
+ export interface ImportTsqBridgeParams extends TaskBridgeParams {
117
+ readonly action: "import_tsq";
118
+ readonly tsqId?: string;
119
+ }
120
+
121
+ export type TaskBridgeSuccessData =
122
+ | {
123
+ readonly action: "link";
124
+ readonly link: TaskBridgeLink;
125
+ readonly todo: Task;
126
+ }
127
+ | {
128
+ readonly action: "list_links";
129
+ readonly links: readonly TaskBridgeLink[];
130
+ }
131
+ | {
132
+ readonly action: "promote_todo" | "import_tsq";
133
+ readonly todoSnapshot?: TaskBridgeTodoSnapshot;
134
+ readonly [key: string]: unknown;
135
+ };
136
+
137
+ export type TaskBridgeDetails = StandardToolDetails<TaskBridgeSuccessData>;
138
+
139
+ export interface TaskBridgeHandlerContext {
140
+ readonly pi: ExtensionAPI;
141
+ readonly cwd: string;
142
+ readonly signal?: AbortSignal;
143
+ readonly extensionContext: ExtensionContext;
144
+ }
145
+
146
+ export type TaskBridgeActionHandler<TParams extends TaskBridgeParams> = (
147
+ params: TParams,
148
+ ctx: TaskBridgeHandlerContext,
149
+ ) =>
150
+ | AgentToolResult<TaskBridgeDetails>
151
+ | Promise<AgentToolResult<TaskBridgeDetails>>;
152
+
153
+ export interface TaskBridgeHandlers {
154
+ readonly promote_todo?: TaskBridgeActionHandler<PromoteTodoBridgeParams>;
155
+ readonly import_tsq?: TaskBridgeActionHandler<ImportTsqBridgeParams>;
156
+ }
@@ -0,0 +1,167 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { runTsqJson, type TsqRunContext } from "./runner.js";
3
+
4
+ export interface TasqueStatusCacheState {
5
+ readonly readyCoding: number;
6
+ readonly readyPlanning: number;
7
+ readonly inProgressMine: number;
8
+ readonly refreshedAt: number | undefined;
9
+ readonly error: string | undefined;
10
+ }
11
+
12
+ export interface TasqueStatusCache {
13
+ readonly state: TasqueStatusCacheState;
14
+ }
15
+
16
+ export interface TasqueStatusRefreshOptions {
17
+ readonly now?: () => number;
18
+ readonly timeout?: number;
19
+ readonly signal?: AbortSignal;
20
+ }
21
+
22
+ export interface TasqueStatusFormatOptions {
23
+ readonly now?: () => number;
24
+ readonly staleAfterMs?: number;
25
+ }
26
+
27
+ const DEFAULT_REFRESH_TIMEOUT_MS = 4_000;
28
+ const DEFAULT_STALE_AFTER_MS = 120_000;
29
+ const MAX_ERROR_LENGTH = 80;
30
+
31
+ export function createTasqueStatusCache(
32
+ state: Partial<TasqueStatusCacheState> = {},
33
+ ): TasqueStatusCache {
34
+ return {
35
+ state: {
36
+ readyCoding: state.readyCoding ?? 0,
37
+ readyPlanning: state.readyPlanning ?? 0,
38
+ inProgressMine: state.inProgressMine ?? 0,
39
+ refreshedAt: state.refreshedAt,
40
+ error: state.error,
41
+ },
42
+ };
43
+ }
44
+
45
+ export async function refreshTasqueStatusCache(
46
+ pi: ExtensionAPI,
47
+ ctx: TsqRunContext,
48
+ cache: TasqueStatusCache,
49
+ options: TasqueStatusRefreshOptions = {},
50
+ ): Promise<TasqueStatusCache> {
51
+ const now = options.now ?? Date.now;
52
+ const timeout = options.timeout ?? DEFAULT_REFRESH_TIMEOUT_MS;
53
+ const runOptions = {
54
+ timeout,
55
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
56
+ };
57
+
58
+ try {
59
+ await runTsqJson<unknown>(pi, ctx, ["doctor"], runOptions);
60
+ const [readyCoding, readyPlanning, inProgressMine] = await Promise.all([
61
+ runTsqJson<unknown>(
62
+ pi,
63
+ ctx,
64
+ ["find", "ready", "--lane", "coding"],
65
+ runOptions,
66
+ ),
67
+ runTsqJson<unknown>(
68
+ pi,
69
+ ctx,
70
+ ["find", "ready", "--lane", "planning"],
71
+ runOptions,
72
+ ),
73
+ runTsqJson<unknown>(
74
+ pi,
75
+ ctx,
76
+ ["find", "in-progress", "--assignee", "pi"],
77
+ runOptions,
78
+ ),
79
+ ]);
80
+
81
+ return createTasqueStatusCache({
82
+ readyCoding: countTasks(readyCoding),
83
+ readyPlanning: countTasks(readyPlanning),
84
+ inProgressMine: countTasks(inProgressMine),
85
+ refreshedAt: now(),
86
+ error: undefined,
87
+ });
88
+ } catch (error) {
89
+ return createTasqueStatusCache({
90
+ ...cache.state,
91
+ error: getErrorMessage(error),
92
+ });
93
+ }
94
+ }
95
+
96
+ export function formatTasqueStatusText(
97
+ state: TasqueStatusCacheState,
98
+ options: TasqueStatusFormatOptions = {},
99
+ ): string {
100
+ if (state.error !== undefined) {
101
+ return `tsq: stale · ${state.error}`;
102
+ }
103
+
104
+ if (state.refreshedAt === undefined) {
105
+ return "tsq: loading";
106
+ }
107
+
108
+ const now = options.now ?? Date.now;
109
+ const staleAfterMs = options.staleAfterMs ?? DEFAULT_STALE_AFTER_MS;
110
+ const ageMs = Math.max(0, now() - state.refreshedAt);
111
+ const counts = formatCounts(state);
112
+
113
+ if (ageMs > staleAfterMs) {
114
+ return `tsq: stale ${formatAge(ageMs)} · ${counts}`;
115
+ }
116
+
117
+ return `tsq: ${counts} · ${formatAge(ageMs)}`;
118
+ }
119
+
120
+ function formatCounts(state: TasqueStatusCacheState): string {
121
+ return `coding ${state.readyCoding} · planning ${state.readyPlanning} · mine ${state.inProgressMine}`;
122
+ }
123
+
124
+ function countTasks(data: unknown): number {
125
+ if (Array.isArray(data)) {
126
+ return data.length;
127
+ }
128
+ if (!isRecord(data)) {
129
+ return 0;
130
+ }
131
+ if (Array.isArray(data.tasks)) {
132
+ return data.tasks.length;
133
+ }
134
+ if (Array.isArray(data.tree)) {
135
+ return data.tree.length;
136
+ }
137
+ return 0;
138
+ }
139
+
140
+ function formatAge(ageMs: number): string {
141
+ const seconds = Math.floor(ageMs / 1_000);
142
+ if (seconds < 60) {
143
+ return `${seconds}s`;
144
+ }
145
+ const minutes = Math.floor(seconds / 60);
146
+ if (minutes < 60) {
147
+ return `${minutes}m`;
148
+ }
149
+ const hours = Math.floor(minutes / 60);
150
+ return `${hours}h`;
151
+ }
152
+
153
+ function getErrorMessage(error: unknown): string {
154
+ const message = error instanceof Error ? error.message : String(error);
155
+ return truncateInline(message.replace(/\s+/gu, " ").trim(), MAX_ERROR_LENGTH);
156
+ }
157
+
158
+ function truncateInline(text: string, maxLength: number): string {
159
+ if (text.length <= maxLength) {
160
+ return text;
161
+ }
162
+ return `${text.slice(0, Math.max(0, maxLength - 1))}…`;
163
+ }
164
+
165
+ function isRecord(value: unknown): value is Record<string, unknown> {
166
+ return typeof value === "object" && value !== null && !Array.isArray(value);
167
+ }
@@ -0,0 +1,30 @@
1
+ type MutationFunction<T> = () => T | Promise<T>;
2
+
3
+ const queueTailsByCwd = new Map<string, Promise<void>>();
4
+
5
+ export function runQueuedMutation<T>(
6
+ cwd: string,
7
+ fn: MutationFunction<T>,
8
+ ): Promise<T> {
9
+ const previousTail = queueTailsByCwd.get(cwd) ?? Promise.resolve();
10
+ const operation = previousTail.then(fn, fn);
11
+ const tail = operation.then(
12
+ () => undefined,
13
+ () => undefined,
14
+ );
15
+
16
+ queueTailsByCwd.set(cwd, tail);
17
+
18
+ void tail.finally(() => {
19
+ if (queueTailsByCwd.get(cwd) === tail) {
20
+ queueTailsByCwd.delete(cwd);
21
+ }
22
+ });
23
+
24
+ return operation;
25
+ }
26
+
27
+ /** @internal Exposed for queue cleanup tests. */
28
+ export function getQueuedMutationCwdCount(): number {
29
+ return queueTailsByCwd.size;
30
+ }