@vantageos/backend-doctor 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.
Files changed (108) hide show
  1. package/README.md +88 -0
  2. package/dist/cli.d.ts +2 -0
  3. package/dist/cli.js +94 -0
  4. package/dist/contract.d.ts +18 -0
  5. package/dist/contract.js +29 -0
  6. package/dist/coverage-cli.d.ts +2 -0
  7. package/dist/coverage-cli.js +17 -0
  8. package/dist/coverage.d.ts +32 -0
  9. package/dist/coverage.js +77 -0
  10. package/dist/detectors/oracle-axis1.d.ts +86 -0
  11. package/dist/detectors/oracle-axis1.js +221 -0
  12. package/dist/detectors/oracle-axis2.d.ts +22 -0
  13. package/dist/detectors/oracle-axis2.js +149 -0
  14. package/dist/detectors/predicates.d.ts +19 -0
  15. package/dist/detectors/predicates.js +334 -0
  16. package/dist/detectors/shared.d.ts +56 -0
  17. package/dist/detectors/shared.js +103 -0
  18. package/dist/detectors/source/_scan.d.ts +43 -0
  19. package/dist/detectors/source/_scan.js +82 -0
  20. package/dist/detectors/source/backfill.d.ts +27 -0
  21. package/dist/detectors/source/backfill.js +211 -0
  22. package/dist/detectors/source/contract.d.ts +33 -0
  23. package/dist/detectors/source/contract.js +385 -0
  24. package/dist/detectors/source/identity.d.ts +47 -0
  25. package/dist/detectors/source/identity.js +410 -0
  26. package/dist/detectors/source/jobs.d.ts +10 -0
  27. package/dist/detectors/source/jobs.js +209 -0
  28. package/dist/detectors/source/lifecycle.d.ts +6 -0
  29. package/dist/detectors/source/lifecycle.js +147 -0
  30. package/dist/detectors/source/readbudget.d.ts +6 -0
  31. package/dist/detectors/source/readbudget.js +133 -0
  32. package/dist/detectors/source/refusal.d.ts +6 -0
  33. package/dist/detectors/source/refusal.js +125 -0
  34. package/dist/detectors/source/security.d.ts +37 -0
  35. package/dist/detectors/source/security.js +392 -0
  36. package/dist/detectors/source/writesafety.d.ts +14 -0
  37. package/dist/detectors/source/writesafety.js +345 -0
  38. package/dist/detectors/standard/_doc.d.ts +66 -0
  39. package/dist/detectors/standard/_doc.js +152 -0
  40. package/dist/detectors/standard/r41-frontier.d.ts +58 -0
  41. package/dist/detectors/standard/r41-frontier.js +176 -0
  42. package/dist/detectors/standard/r46-returns-validator.d.ts +61 -0
  43. package/dist/detectors/standard/r46-returns-validator.js +474 -0
  44. package/dist/detectors/standard/standard-doc.d.ts +12 -0
  45. package/dist/detectors/standard/standard-doc.js +271 -0
  46. package/dist/detectors/structural.d.ts +14 -0
  47. package/dist/detectors/structural.js +83 -0
  48. package/dist/engine-types.d.ts +10 -0
  49. package/dist/engine-types.js +1 -0
  50. package/dist/not-checkable.d.ts +22 -0
  51. package/dist/not-checkable.js +37 -0
  52. package/dist/oracle.d.ts +33 -0
  53. package/dist/oracle.js +133 -0
  54. package/dist/registry.d.ts +11 -0
  55. package/dist/registry.js +140 -0
  56. package/dist/report.d.ts +35 -0
  57. package/dist/report.js +138 -0
  58. package/dist/severity.d.ts +30 -0
  59. package/dist/severity.js +121 -0
  60. package/dist/tally.d.ts +57 -0
  61. package/dist/tally.js +152 -0
  62. package/dist/trigger.d.ts +47 -0
  63. package/dist/trigger.js +143 -0
  64. package/fixtures/backend-standard-51ec7af1.md +648 -0
  65. package/fixtures/backend-standard-a9d7877b.md +629 -0
  66. package/fixtures/mcp-app-standard-a74a042b.md +279 -0
  67. package/fixtures/mcp-extension-standard-a74a042b.md +259 -0
  68. package/fixtures/mcp-server-standard-a74a042b.md +434 -0
  69. package/fixtures/mcp-standard-a74a042b.md +354 -0
  70. package/fixtures/vp-1200-green/.pin +1 -0
  71. package/fixtures/vp-1200-green/convex/migrations.ts +350 -0
  72. package/fixtures/vp-1200-green/convex/schema.ts +1243 -0
  73. package/fixtures/vp-1200-red/.pin +1 -0
  74. package/fixtures/vp-1200-red/convex/migrations.ts +239 -0
  75. package/fixtures/vp-1200-red/convex/schema.ts +1243 -0
  76. package/fixtures/vp-by-tool-day158.csv +190 -0
  77. package/fixtures/vp-convex/.github/workflows/npm-publish.yml +163 -0
  78. package/fixtures/vp-convex/.pin +1 -0
  79. package/fixtures/vp-convex/bun.lock +783 -0
  80. package/fixtures/vp-convex/convex/auth.config.ts +8 -0
  81. package/fixtures/vp-convex/convex/briefingNotes.ts +321 -0
  82. package/fixtures/vp-convex/convex/convex.config.ts +14 -0
  83. package/fixtures/vp-convex/convex/credentials.ts +591 -0
  84. package/fixtures/vp-convex/convex/crons.ts +72 -0
  85. package/fixtures/vp-convex/convex/dashboard.ts +199 -0
  86. package/fixtures/vp-convex/convex/gumroadWebhook.ts +815 -0
  87. package/fixtures/vp-convex/convex/issues.ts +544 -0
  88. package/fixtures/vp-convex/convex/kb.ts +305 -0
  89. package/fixtures/vp-convex/convex/kbMutations.ts +192 -0
  90. package/fixtures/vp-convex/convex/kbShared.ts +56 -0
  91. package/fixtures/vp-convex/convex/licenses.ts +443 -0
  92. package/fixtures/vp-convex/convex/memories.ts +406 -0
  93. package/fixtures/vp-convex/convex/memoriesScoped.ts +220 -0
  94. package/fixtures/vp-convex/convex/messages.ts +971 -0
  95. package/fixtures/vp-convex/convex/migrations.ts +239 -0
  96. package/fixtures/vp-convex/convex/missionTemplates.ts +369 -0
  97. package/fixtures/vp-convex/convex/oauth.ts +1608 -0
  98. package/fixtures/vp-convex/convex/oauthMigrations.ts +96 -0
  99. package/fixtures/vp-convex/convex/okfBundleNode.ts +1031 -0
  100. package/fixtures/vp-convex/convex/recurringTasks.ts +386 -0
  101. package/fixtures/vp-convex/convex/schema.ts +1229 -0
  102. package/fixtures/vp-convex/convex/tasks.ts +2782 -0
  103. package/fixtures/vp-r31-green/.pin +1 -0
  104. package/fixtures/vp-r31-green/convex/migrations.ts +350 -0
  105. package/fixtures/vp-r31-red/.pin +1 -0
  106. package/fixtures/vp-r31-red/convex/migrations.ts +286 -0
  107. package/fixtures/vr-by-tool-day159.csv +89 -0
  108. package/package.json +39 -0
@@ -0,0 +1,2782 @@
1
+ import { v } from "convex/values";
2
+ import { ConvexError } from "convex/values";
3
+ import { paginationOptsValidator } from "convex/server";
4
+ import { mutation, internalMutation, internalQuery, query } from "./_generated/server";
5
+ import type { MutationCtx, QueryCtx } from "./_generated/server";
6
+ import type { Doc } from "./_generated/dataModel";
7
+ import { internal } from "./_generated/api";
8
+ import { creatorValidator, taskOriginValidator } from "./schema";
9
+ import { withOrgScope, filterByOrgScope, requireScope } from "./lib/auth";
10
+ import type { OrgScope } from "./lib/auth";
11
+ import { requireId } from "./lib/ids";
12
+ import { enforceClosureGate } from "./lib/taskClosureGate";
13
+
14
+ // ─────────────────────────────────────────────────────────────────────────────
15
+ // Shared validators
16
+ // ─────────────────────────────────────────────────────────────────────────────
17
+
18
+ // Open string — any orchestrator name accepted (issue #132)
19
+ const assigneeValidator = v.string();
20
+
21
+ const priorityValidator = v.union(
22
+ v.literal("urgent"),
23
+ v.literal("high"),
24
+ v.literal("medium"),
25
+ v.literal("low"),
26
+ );
27
+
28
+ const statusValidator = v.union(
29
+ v.literal("todo"),
30
+ v.literal("in_progress"),
31
+ v.literal("review"),
32
+ v.literal("blocked"),
33
+ v.literal("done"),
34
+ v.literal("cancelled"),
35
+ );
36
+
37
+ // Valid task status values for runtime validation
38
+ const TASK_STATUSES = [
39
+ "todo",
40
+ "in_progress",
41
+ "review",
42
+ "blocked",
43
+ "done",
44
+ "cancelled",
45
+ ] as const;
46
+ type TaskStatus = (typeof TASK_STATUSES)[number];
47
+
48
+ // ─────────────────────────────────────────────────────────────────────────────
49
+ // Status alias expansion helpers
50
+ // ─────────────────────────────────────────────────────────────────────────────
51
+
52
+ /**
53
+ * Expand a status arg (string | string[] | undefined) into a concrete array
54
+ * of TaskStatus values. Handles aliases "open" and "active".
55
+ *
56
+ * - "open" → ["todo","in_progress","review","blocked"] (everything except done)
57
+ * - "active" → ["todo","in_progress"]
58
+ * - array → validated element by element; no alias mixing (conservative choice)
59
+ * - single → validated enum value wrapped in array
60
+ * - undefined → undefined (no filter)
61
+ *
62
+ * Throws ConvexError on unknown status values.
63
+ */
64
+
65
+ // ─────────────────────────────────────────────────────────────────────────────
66
+ // assertTaskCallerAuthorized — resource-derived ownership gate, shared by
67
+ // update/complete/start. Authorization is derived from the TARGET task
68
+ // (createdBy/assignedTo), never from the caller's own claim alone.
69
+ //
70
+ // Cross-tenant fix (S0 campaign k17b9z5yjgd8301r6dfawefpzs8b3a03): the prior
71
+ // shape wrapped the entire check in `if (callerOrchestrator !== undefined)`,
72
+ // so omitting the argument skipped verification instead of failing it — the
73
+ // omission deleted the control rather than degrading it. Omitting the caller
74
+ // now REFUSES (RBAC_DENIED), it never bypasses. "system" and matching
75
+ // creator/assignee still pass unconditionally (regression-proofed by tests).
76
+ // ─────────────────────────────────────────────────────────────────────────────
77
+ function assertTaskCallerAuthorized(
78
+ task: { createdBy: string; assignedTo?: string },
79
+ callerOrchestrator: string | undefined,
80
+ taskId: string,
81
+ ): void {
82
+ if (callerOrchestrator === undefined) {
83
+ throw new ConvexError(
84
+ `RBAC_DENIED: callerOrchestrator is required — omitting it is refused, not exempted — ${JSON.stringify({ taskId })}`,
85
+ );
86
+ }
87
+ const isAuthorized =
88
+ task.createdBy === callerOrchestrator ||
89
+ task.assignedTo === callerOrchestrator ||
90
+ callerOrchestrator === "system";
91
+ if (!isAuthorized) {
92
+ throw new ConvexError(
93
+ `RBAC_DENIED: ${callerOrchestrator} is not creator or assignee of task ${taskId} — ${JSON.stringify({ caller: callerOrchestrator, taskId })}`,
94
+ );
95
+ }
96
+ }
97
+
98
+ function expandTaskStatuses(
99
+ status: string | string[] | undefined,
100
+ ): TaskStatus[] | undefined {
101
+ if (status === undefined) return undefined;
102
+ if (status === "all") return undefined;
103
+
104
+ if (Array.isArray(status)) {
105
+ const result: TaskStatus[] = [];
106
+ for (const s of status) {
107
+ if (s === "open" || s === "active" || s === "all") {
108
+ throw new ConvexError(
109
+ `invalid status: alias "${s}" is not allowed inside an array — use a direct string instead`,
110
+ );
111
+ }
112
+ if (!TASK_STATUSES.includes(s as TaskStatus)) {
113
+ throw new ConvexError(`invalid status: "${s}"`);
114
+ }
115
+ result.push(s as TaskStatus);
116
+ }
117
+ return result;
118
+ }
119
+
120
+ // Single string
121
+ if (status === "open") return ["todo", "in_progress", "review", "blocked"];
122
+ if (status === "active") return ["todo", "in_progress"];
123
+ if (!TASK_STATUSES.includes(status as TaskStatus)) {
124
+ throw new ConvexError(`invalid status: "${status}"`);
125
+ }
126
+ return [status as TaskStatus];
127
+ }
128
+
129
+ // ─────────────────────────────────────────────────────────────────────────────
130
+ // Lite projection helpers
131
+ // ─────────────────────────────────────────────────────────────────────────────
132
+
133
+ const taskFullValidator = v.object({
134
+ _id: v.id("tasks"),
135
+ _creationTime: v.number(),
136
+ title: v.string(),
137
+ description: v.optional(v.string()),
138
+ project: v.optional(v.string()),
139
+ tags: v.optional(v.array(v.string())),
140
+ assignedTo: assigneeValidator,
141
+ priority: priorityValidator,
142
+ status: statusValidator,
143
+ completionNote: v.optional(v.string()),
144
+ assignedToInstance: v.optional(v.string()),
145
+ claimedByInstance: v.optional(v.string()),
146
+ dependsOn: v.optional(v.array(v.id("tasks"))),
147
+ missionId: v.optional(v.id("missions")),
148
+ estimatedMinutes: v.optional(v.number()),
149
+ actualMinutes: v.optional(v.number()),
150
+ startedAt: v.optional(v.number()),
151
+ completedAt: v.optional(v.number()),
152
+ dueDate: v.optional(v.number()),
153
+ createdBy: creatorValidator,
154
+ createdAt: v.number(),
155
+ updatedAt: v.number(),
156
+ // PR #360 — Beta multi-tenant scope field. Optional so pre-PR #360 docs pass.
157
+ orgId: v.optional(v.string()),
158
+ // Day 130 follow-up #2 — inforgeable automation signal (see schema.ts).
159
+ origin: v.optional(taskOriginValidator),
160
+ // Day 157 — terminal cancelled status (see schema.ts).
161
+ cancelledBy: v.optional(creatorValidator),
162
+ cancelReason: v.optional(v.string()),
163
+ });
164
+
165
+ type TaskLite = {
166
+ _id: string;
167
+ _creationTime: number;
168
+ title: string;
169
+ status: TaskStatus;
170
+ priority: "urgent" | "high" | "medium" | "low";
171
+ assignedTo: string;
172
+ missionId?: string;
173
+ };
174
+
175
+ function projectTaskLite(doc: Record<string, unknown>): TaskLite {
176
+ return {
177
+ _id: doc._id as string,
178
+ _creationTime: doc._creationTime as number,
179
+ title: doc.title as string,
180
+ status: doc.status as TaskStatus,
181
+ priority: doc.priority as "urgent" | "high" | "medium" | "low",
182
+ assignedTo: doc.assignedTo as string,
183
+ ...(doc.missionId !== undefined
184
+ ? { missionId: doc.missionId as string }
185
+ : {}),
186
+ };
187
+ }
188
+
189
+ // ─────────────────────────────────────────────────────────────────────────────
190
+ // create — insert a new task
191
+ // ─────────────────────────────────────────────────────────────────────────────
192
+
193
+ export const create = mutation({
194
+ args: {
195
+ title: v.string(),
196
+ description: v.optional(v.string()),
197
+ project: v.optional(v.string()),
198
+ tags: v.optional(v.array(v.string())),
199
+ assignedTo: assigneeValidator,
200
+ assignedToInstance: v.optional(v.string()),
201
+ priority: priorityValidator,
202
+ status: statusValidator,
203
+ dependsOn: v.optional(v.array(v.id("tasks"))),
204
+ missionId: v.optional(v.id("missions")),
205
+ estimatedMinutes: v.optional(v.number()),
206
+ dueDate: v.optional(v.number()),
207
+ createdBy: creatorValidator,
208
+ },
209
+ returns: v.id("tasks"),
210
+ handler: async (ctx, args) => {
211
+ // Day 130 follow-up #2 (Eta REVISE, PR #1089) — the closure-gate
212
+ // exemption is NOT driven by `createdBy` (see taskClosureGate.ts):
213
+ // it reads `origin`, which this public mutation never accepts as an
214
+ // arg and never writes — only the internal webhook path
215
+ // (createOrUpdateReviewTask) can write it. `createdBy: "system"` is
216
+ // intentionally still accepted here because it is used elsewhere in
217
+ // this codebase as a plain non-billing convention (RBAC-bypass
218
+ // semantics on update/complete/bulkComplete/start/deleteTask, and as
219
+ // a generic creator string in stats/bridge-automation flows) — see
220
+ // convex/__tests__/tasksMutationConvexErrors.test.ts and
221
+ // convex/stats.test.ts. An earlier attempt to reject it outright
222
+ // here regressed 44 pre-existing tests; that reservation attempt
223
+ // was scoped back out. The billing-bypass vulnerability itself is
224
+ // fully closed by the `origin`-based gate, independent of this
225
+ // value.
226
+ const now = Date.now();
227
+ return await ctx.db.insert("tasks", {
228
+ ...args,
229
+ createdAt: now,
230
+ updatedAt: now,
231
+ });
232
+ },
233
+ });
234
+
235
+ // ─────────────────────────────────────────────────────────────────────────────
236
+ // get — fetch a single task by ID
237
+ // ─────────────────────────────────────────────────────────────────────────────
238
+
239
+ export const get = query({
240
+ args: { taskId: v.id("tasks") },
241
+ returns: v.union(
242
+ v.object({
243
+ _id: v.id("tasks"),
244
+ _creationTime: v.number(),
245
+ title: v.string(),
246
+ description: v.optional(v.string()),
247
+ project: v.optional(v.string()),
248
+ tags: v.optional(v.array(v.string())),
249
+ assignedTo: assigneeValidator,
250
+ priority: priorityValidator,
251
+ status: statusValidator,
252
+ completionNote: v.optional(v.string()),
253
+ assignedToInstance: v.optional(v.string()),
254
+ claimedByInstance: v.optional(v.string()),
255
+ dependsOn: v.optional(v.array(v.id("tasks"))),
256
+ missionId: v.optional(v.id("missions")),
257
+ estimatedMinutes: v.optional(v.number()),
258
+ actualMinutes: v.optional(v.number()),
259
+ startedAt: v.optional(v.number()),
260
+ completedAt: v.optional(v.number()),
261
+ dueDate: v.optional(v.number()),
262
+ createdBy: creatorValidator,
263
+ createdAt: v.number(),
264
+ updatedAt: v.number(),
265
+ // PR #360 — Beta multi-tenant scope field. Optional so pre-PR #360 docs pass.
266
+ orgId: v.optional(v.string()),
267
+ // Day 130 follow-up #2 — inforgeable automation signal (see schema.ts).
268
+ origin: v.optional(taskOriginValidator),
269
+ // Day 157 — terminal cancelled status (see schema.ts).
270
+ cancelledBy: v.optional(creatorValidator),
271
+ cancelReason: v.optional(v.string()),
272
+ }),
273
+ v.null(),
274
+ ),
275
+ handler: async (ctx, args) => {
276
+ return await ctx.db.get(args.taskId);
277
+ },
278
+ });
279
+
280
+ // ─────────────────────────────────────────────────────────────────────────────
281
+ // getById — alias of get, exposed for /api/eta/verify-publish-token HTTP action
282
+ // (Feature D spec requirement, hook v1.2.0).
283
+ // ─────────────────────────────────────────────────────────────────────────────
284
+ export const getById = query({
285
+ // Accept a raw string, not `v.id("tasks")`: the v.id() validator runs BEFORE
286
+ // the handler, so a wrong-table ID is rejected with a message Convex redacts
287
+ // in prod (`Server Error`, `error.data` undefined — measured). Narrowing
288
+ // inside the handler via requireId() throws a ConvexError whose payload
289
+ // survives redaction. Same contract as PR #1069 (markAsRead), on a read.
290
+ args: { taskId: v.string() },
291
+ returns: v.union(
292
+ v.object({
293
+ _id: v.id("tasks"),
294
+ _creationTime: v.number(),
295
+ title: v.string(),
296
+ description: v.optional(v.string()),
297
+ project: v.optional(v.string()),
298
+ tags: v.optional(v.array(v.string())),
299
+ assignedTo: assigneeValidator,
300
+ priority: priorityValidator,
301
+ status: statusValidator,
302
+ completionNote: v.optional(v.string()),
303
+ assignedToInstance: v.optional(v.string()),
304
+ claimedByInstance: v.optional(v.string()),
305
+ dependsOn: v.optional(v.array(v.id("tasks"))),
306
+ missionId: v.optional(v.id("missions")),
307
+ estimatedMinutes: v.optional(v.number()),
308
+ actualMinutes: v.optional(v.number()),
309
+ startedAt: v.optional(v.number()),
310
+ completedAt: v.optional(v.number()),
311
+ dueDate: v.optional(v.number()),
312
+ createdBy: creatorValidator,
313
+ createdAt: v.number(),
314
+ updatedAt: v.number(),
315
+ // PR #360 — Beta multi-tenant scope field. Optional so pre-PR #360 docs pass.
316
+ orgId: v.optional(v.string()),
317
+ // Day 130 follow-up #2 — inforgeable automation signal (see schema.ts).
318
+ origin: v.optional(taskOriginValidator),
319
+ // Day 157 — terminal cancelled status (see schema.ts).
320
+ cancelledBy: v.optional(creatorValidator),
321
+ cancelReason: v.optional(v.string()),
322
+ }),
323
+ v.null(),
324
+ ),
325
+ handler: async (ctx, args) => {
326
+ const taskId = requireId(
327
+ ctx,
328
+ "tasks",
329
+ args.taskId,
330
+ "taskId",
331
+ "Use the full 32-char taskId returned by list_tasks or create_task.",
332
+ );
333
+ // A well-formed tasks ID pointing at a deleted doc stays a null return.
334
+ return await ctx.db.get(taskId);
335
+ },
336
+ });
337
+
338
+ // ─────────────────────────────────────────────────────────────────────────────
339
+ // list — list tasks with optional filters (assignedTo, status, project)
340
+ //
341
+ // New in v1.1:
342
+ // fields="lite" — compact projection: {_id,_creationTime,title,status,priority,assignedTo,missionId}
343
+ // fields="full" (default) — full doc (current behavior, backward-compatible)
344
+ // status="open" — expands to ["todo","in_progress","review","blocked"]
345
+ // status="active" — expands to ["todo","in_progress"]
346
+ // status=["todo","in_progress"] — multi-value array (no alias mixing)
347
+ //
348
+ // updatedSince/createdBy widened-scan fix (same defect class as #1110 on
349
+ // billing): these two filters used to be applied IN-MEMORY after a
350
+ // `.take(limit)` that had already bounded the page in creation-descending
351
+ // order — so a row updated recently but created outside that page was
352
+ // invisible, while the response looked like a complete list. When either
353
+ // filter is supplied, the per-branch fetch is widened to
354
+ // TASK_LIST_SCAN_CAP + 1 rows BEFORE the filter runs, then re-sliced to
355
+ // `limit` afterwards. If even the widened scan hits its cap, we refuse to
356
+ // return a silently-incomplete page — see the SCAN_CAP_EXCEEDED throw below.
357
+ // ─────────────────────────────────────────────────────────────────────────────
358
+
359
+ export const TASK_LIST_SCAN_CAP = 2000;
360
+
361
+ const tasksListArgsValidator = {
362
+ assignedTo: v.optional(assigneeValidator),
363
+ assignedToInstance: v.optional(v.string()),
364
+ status: v.optional(v.union(v.string(), v.array(v.string()))),
365
+ project: v.optional(v.string()),
366
+ limit: v.optional(v.number()),
367
+ fields: v.optional(v.union(v.literal("lite"), v.literal("full"))),
368
+ createdBy: v.optional(creatorValidator),
369
+ updatedSince: v.optional(v.number()),
370
+ // S3.3 B8 — cursor paging anchor. When provided, rows with
371
+ // _creationTime >= createdBefore are filtered out (newest-first
372
+ // forward pagination). Used by MCP list_* cursor layer.
373
+ createdBefore: v.optional(v.number()),
374
+ // PR-E — cron-spam filter. When true, excludes tasks that are
375
+ // auto-generated by the scheduler:
376
+ // - createdBy matches /^cron-/i (dash required; "cronus"/"cron" not filtered)
377
+ // - title matches /^\/?check-messages$/i (exact whole-string)
378
+ // Filter is applied in-memory after page-fetch but before cursor anchor +
379
+ // envelope assembly. Trade-off: filtered-out rows do NOT count toward the
380
+ // limit, so the post-filter page may be smaller than `limit`. Acceptable
381
+ // for this use case: cron catalog is small and the filter is narrowly targeted.
382
+ excludeAutoGenerated: v.optional(v.boolean()),
383
+ // Dashboard B1 — priority filter. When provided, only tasks with the given
384
+ // priority are returned (in-memory filter after page-fetch).
385
+ priority: v.optional(priorityValidator),
386
+ // Dashboard B1 — orgId passthrough. Accepted and ignored at the query layer;
387
+ // multi-tenant scoping is handled server-side by withOrgScope (Clerk JWT).
388
+ // Accepting the field prevents ArgumentValidationError when the dashboard
389
+ // passes orgId from useActiveOrg().
390
+ orgId: v.optional(v.string()),
391
+ };
392
+
393
+ interface TasksListArgs {
394
+ assignedTo?: string;
395
+ assignedToInstance?: string;
396
+ status?: string | string[];
397
+ project?: string;
398
+ limit?: number;
399
+ fields?: "lite" | "full";
400
+ createdBy?: string;
401
+ updatedSince?: number;
402
+ createdBefore?: number;
403
+ excludeAutoGenerated?: boolean;
404
+ priority?: "urgent" | "high" | "medium" | "low";
405
+ orgId?: string;
406
+ }
407
+
408
+ // Shared handler body for tasks.list (public, org-scoped) and
409
+ // tasks.listForWebhook (internal, master-scoped — SEC-AUDIT Day 156: the
410
+ // only genuine no-identity caller was convex/http.ts's GitHub webhook
411
+ // handler, itself gated by HMAC signature verification, not by Clerk
412
+ // identity). Splitting the caller lets the public surface go fail-closed
413
+ // (withOrgScope default) without breaking that internal, structurally
414
+ // unreachable-by-clients call site.
415
+ async function runTasksList(ctx: QueryCtx, args: TasksListArgs, scope: OrgScope) {
416
+ requireScope(scope, "view-own-tasks");
417
+
418
+ const statuses = expandTaskStatuses(args.status);
419
+ const lite = args.fields === "lite";
420
+ // v2.3.3 — auto-clamp limit when fields=full + no explicit limit (overflow protection)
421
+ const explicitLimit = args.limit !== undefined;
422
+ let limit = args.limit ?? 50;
423
+ if (!explicitLimit && !lite) {
424
+ limit = 30;
425
+ console.warn(
426
+ `[tasks.list] auto-clamp: limit=30 applied (fields=full, no explicit limit). Pass fields="lite" or explicit limit to override.`,
427
+ );
428
+ }
429
+ // Capture to local consts so TypeScript narrows inside closures without assertions
430
+ const assignedToInstance = args.assignedToInstance;
431
+ const assignedTo = args.assignedTo;
432
+ const project = args.project;
433
+ const createdBy = args.createdBy;
434
+ const updatedSince = args.updatedSince;
435
+ const priorityFilter = args.priority;
436
+ const before = args.createdBefore;
437
+
438
+ // Day 163 (Pi, k171rbm2txe42jxzddyqakbg7n8ch7zr) — `createdBefore` was
439
+ // OMITTED here. On a cursor-only call fetchCap collapsed to `limit`,
440
+ // so the DB fetched only the `limit` NEWEST rows, then the cursor
441
+ // filter below dropped every one of them (they are all >= the
442
+ // cursor anchor by construction) → an empty page while older rows
443
+ // that should have been page 2 were never fetched. Empty read as
444
+ // end-of-list; callers silently truncated. `createdBefore` is now
445
+ // included so pagination widens the same as createdBy/updatedSince,
446
+ // and is loud (SCAN_CAP_EXCEEDED) rather than silent when it can't
447
+ // see far enough. See per-branch index-push below for the unbounded
448
+ // fix on the common single-status paths.
449
+ //
450
+ // Eta REVISE on PR #1194 @147d260 — a second, independent defect on
451
+ // this same line: `needsWideScan` also has to widen whenever a
452
+ // multi-status (or no-status) filter will be applied POST-fetch on a
453
+ // branch that queries through a compound index ending in `status`
454
+ // (by_assignee, by_project, by_instance, by_assignee_project,
455
+ // by_instance_project…). With only the equality prefix (e.g.
456
+ // assignedTo) pinned, Convex orders the remaining rows by the
457
+ // index's NEXT field first — `status` — then `_creationTime`, NOT by
458
+ // `_creationTime` alone. A narrow `.take(limit)` on that ordering
459
+ // grabs `limit` rows skewed toward one status bucket, never the
460
+ // `limit` most-recent of the STATUS-FILTERED UNION — reproduced by
461
+ // Eta firsthand: 12 alternating todo/in_progress tasks, cursor walk
462
+ // status=["todo","in_progress"] limit=5 saw 7/12 (page 1 was all
463
+ // "todo"; the 5 true union-newest — mixed todo/in_progress — were
464
+ // never fetched). This is independent of cursor presence — the same
465
+ // wrong 5 rows come back on a plain first call, no cursor involved.
466
+ // Multi-status/no-status branches now always widen; single-status
467
+ // branches are untouched (Eta confirmed `canPushCursorIntoIndex`
468
+ // correct as-is).
469
+ const needsWideScan =
470
+ createdBy !== undefined ||
471
+ updatedSince !== undefined ||
472
+ before !== undefined ||
473
+ statuses === undefined ||
474
+ statuses.length > 1;
475
+ const fetchCap = needsWideScan ? TASK_LIST_SCAN_CAP + 1 : limit;
476
+
477
+ // Preferred fix (Pi): push the cursor bound into the index RANGE
478
+ // instead of filtering in-memory. Every Convex index implicitly ends
479
+ // with `_creationTime`, so once all explicit index fields are pinned
480
+ // by equality (single-status branches), `.lt("_creationTime", before)`
481
+ // is a valid additional range clause — unbounded, no SCAN_CAP
482
+ // dependency. Only usable when `updatedSince` is absent (that filter
483
+ // already occupies the one allowed range slot on the assignedTo
484
+ // branch) and exactly one status is pinned (multi-status / no-status
485
+ // branches don't fully consume the index's equality prefix, so the
486
+ // next index field isn't `_creationTime`).
487
+ const canPushCursorIntoIndex =
488
+ before !== undefined &&
489
+ updatedSince === undefined &&
490
+ statuses !== undefined &&
491
+ statuses.length === 1;
492
+
493
+ // Helper: apply multi-status in-memory filter on a pre-fetched slice
494
+ type TaskRow = Doc<"tasks">;
495
+ const applyStatusFilter = (rows: TaskRow[]) => {
496
+ if (statuses === undefined) return rows;
497
+ if (statuses.length === 1)
498
+ return rows.filter((r) => r.status === statuses[0]);
499
+ return rows.filter((r) => statuses.includes(r.status));
500
+ };
501
+
502
+ let allRows: TaskRow[];
503
+ // Set true only when the assignedTo/assignedTo+status branch below pushed
504
+ // the `updatedSince` bound into the query via a compound index. Drives
505
+ // the SCAN_CAP_EXCEEDED message: "shrink the window" is only offered
506
+ // when it can actually change the candidate count.
507
+ let usedIndexedUpdatedSinceBound = false;
508
+
509
+ // ── Guard: mutually-exclusive index-backed filters ────────────────────────
510
+ // assignedToInstance and assignedTo are both index-backed but there is no
511
+ // compound index covering both together (and combining them via post-filter
512
+ // would risk the same silent-narrowing-that-looks-complete failure mode
513
+ // this fix closes). Refuse loudly rather than silently pick one side.
514
+ if (assignedToInstance !== undefined && assignedTo !== undefined) {
515
+ throw new Error(
516
+ `tasks.list: assignedToInstance and assignedTo cannot be combined in a single call ` +
517
+ `(received assignedToInstance="${assignedToInstance}" assignedTo="${assignedTo}"). ` +
518
+ `Call list once per filter, or drop one of the two args.`,
519
+ );
520
+ }
521
+
522
+ // Filter by instance + project together — matching compound index,
523
+ // so BOTH filters are applied, never one silently dropped.
524
+ if (assignedToInstance !== undefined && project !== undefined) {
525
+ if (statuses !== undefined && statuses.length === 1) {
526
+ allRows = await ctx.db
527
+ .query("tasks")
528
+ .withIndex("by_instance_project", (q) => {
529
+ const base = q
530
+ .eq("assignedToInstance", assignedToInstance)
531
+ .eq("project", project)
532
+ .eq("status", statuses[0]);
533
+ return canPushCursorIntoIndex
534
+ ? base.lt("_creationTime", before as number)
535
+ : base;
536
+ })
537
+ .order("desc")
538
+ .take(canPushCursorIntoIndex ? limit : fetchCap);
539
+ } else {
540
+ const base = await ctx.db
541
+ .query("tasks")
542
+ .withIndex("by_instance_project", (q) =>
543
+ q.eq("assignedToInstance", assignedToInstance).eq("project", project),
544
+ )
545
+ .order("desc")
546
+ .take(fetchCap);
547
+ allRows = applyStatusFilter(base);
548
+ }
549
+ }
550
+ // Filter by instance only — use index for primary key, then filter statuses in-memory
551
+ else if (assignedToInstance !== undefined) {
552
+ if (statuses !== undefined && statuses.length === 1) {
553
+ allRows = await ctx.db
554
+ .query("tasks")
555
+ .withIndex("by_instance", (q) => {
556
+ const base = q
557
+ .eq("assignedToInstance", assignedToInstance)
558
+ .eq("status", statuses[0]);
559
+ return canPushCursorIntoIndex
560
+ ? base.lt("_creationTime", before as number)
561
+ : base;
562
+ })
563
+ .order("desc")
564
+ .take(canPushCursorIntoIndex ? limit : fetchCap);
565
+ } else {
566
+ const base = await ctx.db
567
+ .query("tasks")
568
+ .withIndex("by_instance", (q) =>
569
+ q.eq("assignedToInstance", assignedToInstance),
570
+ )
571
+ .order("desc")
572
+ .take(fetchCap);
573
+ allRows = applyStatusFilter(base);
574
+ }
575
+ }
576
+ // Filter by assignee + project together — matching compound index,
577
+ // so BOTH filters are applied, never one silently dropped.
578
+ else if (assignedTo !== undefined && project !== undefined) {
579
+ if (statuses !== undefined && statuses.length === 1) {
580
+ allRows = await ctx.db
581
+ .query("tasks")
582
+ .withIndex("by_assignee_project", (q) => {
583
+ const base = q
584
+ .eq("assignedTo", assignedTo)
585
+ .eq("project", project)
586
+ .eq("status", statuses[0]);
587
+ return canPushCursorIntoIndex
588
+ ? base.lt("_creationTime", before as number)
589
+ : base;
590
+ })
591
+ .order("desc")
592
+ .take(canPushCursorIntoIndex ? limit : fetchCap);
593
+ } else {
594
+ const base = await ctx.db
595
+ .query("tasks")
596
+ .withIndex("by_assignee_project", (q) =>
597
+ q.eq("assignedTo", assignedTo).eq("project", project),
598
+ )
599
+ .order("desc")
600
+ .take(fetchCap);
601
+ allRows = applyStatusFilter(base);
602
+ }
603
+ }
604
+ // Filter by assignee only — the two branches measured to blow the
605
+ // widened-scan cap in production. When `updatedSince` is supplied, push
606
+ // the bound into the query via a compound index ending in `updatedAt`
607
+ // (by_assignee_updatedAt / by_assignee_status_updatedAt) instead of
608
+ // fetching a fixed-size window and filtering in-memory: narrowing the
609
+ // window now actually reduces the rows the DB has to examine, and the
610
+ // scan cap applies to the true matching population, not a widened
611
+ // superset. createdBy (unindexed) is still applied in-memory below.
612
+ else if (assignedTo !== undefined) {
613
+ if (updatedSince !== undefined) {
614
+ usedIndexedUpdatedSinceBound = true;
615
+ if (statuses !== undefined && statuses.length === 1) {
616
+ allRows = await ctx.db
617
+ .query("tasks")
618
+ .withIndex("by_assignee_status_updatedAt", (q) =>
619
+ q
620
+ .eq("assignedTo", assignedTo)
621
+ .eq("status", statuses[0])
622
+ .gte("updatedAt", updatedSince),
623
+ )
624
+ .order("desc")
625
+ .take(TASK_LIST_SCAN_CAP + 1);
626
+ } else {
627
+ const base = await ctx.db
628
+ .query("tasks")
629
+ .withIndex("by_assignee_updatedAt", (q) =>
630
+ q.eq("assignedTo", assignedTo).gte("updatedAt", updatedSince),
631
+ )
632
+ .order("desc")
633
+ .take(TASK_LIST_SCAN_CAP + 1);
634
+ allRows = applyStatusFilter(base);
635
+ }
636
+ } else if (statuses !== undefined && statuses.length === 1) {
637
+ allRows = await ctx.db
638
+ .query("tasks")
639
+ .withIndex("by_assignee", (q) => {
640
+ const base = q.eq("assignedTo", assignedTo).eq("status", statuses[0]);
641
+ return canPushCursorIntoIndex
642
+ ? base.lt("_creationTime", before as number)
643
+ : base;
644
+ })
645
+ .order("desc")
646
+ .take(canPushCursorIntoIndex ? limit : fetchCap);
647
+ } else {
648
+ const base = await ctx.db
649
+ .query("tasks")
650
+ .withIndex("by_assignee", (q) => q.eq("assignedTo", assignedTo))
651
+ .order("desc")
652
+ .take(fetchCap);
653
+ allRows = applyStatusFilter(base);
654
+ }
655
+ }
656
+ // Filter by project only
657
+ else if (project !== undefined) {
658
+ if (statuses !== undefined && statuses.length === 1) {
659
+ allRows = await ctx.db
660
+ .query("tasks")
661
+ .withIndex("by_project", (q) => {
662
+ const base = q.eq("project", project).eq("status", statuses[0]);
663
+ return canPushCursorIntoIndex
664
+ ? base.lt("_creationTime", before as number)
665
+ : base;
666
+ })
667
+ .order("desc")
668
+ .take(canPushCursorIntoIndex ? limit : fetchCap);
669
+ } else {
670
+ const base = await ctx.db
671
+ .query("tasks")
672
+ .withIndex("by_project", (q) => q.eq("project", project))
673
+ .order("desc")
674
+ .take(fetchCap);
675
+ allRows = applyStatusFilter(base);
676
+ }
677
+ }
678
+ // Filter by status only. Not eligible for the index-push above: `by_status`
679
+ // is ["status", "createdAt"], so after pinning `status` by equality the
680
+ // next index field is `createdAt`, not `_creationTime` — Convex only
681
+ // allows a range clause on the field immediately following the pinned
682
+ // equality prefix, so `_creationTime` can't be reached here. Falls back
683
+ // to the widened-scan + loud SCAN_CAP_EXCEEDED path (still correct,
684
+ // just bounded).
685
+ else if (statuses !== undefined) {
686
+ if (statuses.length === 1) {
687
+ allRows = await ctx.db
688
+ .query("tasks")
689
+ .withIndex("by_status", (q) => q.eq("status", statuses[0]))
690
+ .order("desc")
691
+ .take(fetchCap);
692
+ } else {
693
+ // Multi-status without other filter: full table scan bounded by limit.
694
+ // Acceptable for bounded list sizes; no new index required per brief.
695
+ const base = await ctx.db.query("tasks").order("desc").take(fetchCap);
696
+ allRows = applyStatusFilter(base);
697
+ }
698
+ }
699
+ // No filters — return all, newest first
700
+ else {
701
+ allRows = await ctx.db.query("tasks").order("desc").take(fetchCap);
702
+ }
703
+
704
+ // Refuse to return a silently-incomplete page: if the widened scan
705
+ // itself hit its cap, there may be matching rows we never looked at.
706
+ // "I couldn't measure" must never render identically to "complete".
707
+ if (needsWideScan && allRows.length > TASK_LIST_SCAN_CAP) {
708
+ // "shrink the updatedSince window" is only offered when it can
709
+ // actually change the candidate count: on the assignedTo (+status)
710
+ // branch the bound is now pushed into the index, so narrowing the
711
+ // window is a real remedy. On every other branch — and whenever
712
+ // createdBy (unindexed) is the trigger — the fetch is still a
713
+ // fixed-size widened scan, so that advice would send the caller
714
+ // chasing a lever that does nothing; it is left out there.
715
+ const windowAdvice = usedIndexedUpdatedSinceBound
716
+ ? " or shrink the updatedSince window"
717
+ : "";
718
+ throw new ConvexError(
719
+ `tasks.list: SCAN_CAP_EXCEEDED — widened scan for updatedSince/createdBy/createdBefore hit the cap of ${TASK_LIST_SCAN_CAP} candidate rows before the filter ran. The result would be incomplete and indistinguishable from a full match. Narrow with assignedTo/assignedToInstance/project/status${windowAdvice}.`,
720
+ );
721
+ }
722
+
723
+ // v2.3.3 — apply createdBy + updatedSince filters in-memory
724
+ let filtered = allRows;
725
+ if (createdBy !== undefined) {
726
+ filtered = filtered.filter((r) => r.createdBy === createdBy);
727
+ }
728
+ if (updatedSince !== undefined) {
729
+ filtered = filtered.filter((r) => (r.updatedAt ?? 0) >= updatedSince);
730
+ }
731
+ // Day 163 fix — the cursor (createdBefore) filter MUST run BEFORE the
732
+ // re-bound to `limit`, never after. This is idempotent/no-op on the
733
+ // branches above that already pushed the bound into the index (every
734
+ // row is already < before), and is the actual correctness fix on the
735
+ // branches that fell back to the widened scan: applying it after
736
+ // slicing to `limit` was silently discarding the very rows the
737
+ // cursor was supposed to select, independent of the fetchCap defect.
738
+ if (before !== undefined) {
739
+ filtered = filtered.filter((r) => r._creationTime < before);
740
+ }
741
+ // Eta REVISE on PR #1194 @147d260 — widening the fetch (above) was
742
+ // necessary but NOT sufficient. A branch that queries a compound
743
+ // index with only a LEADING field pinned (e.g. by_assignee with
744
+ // `assignedTo` pinned but `status` NOT pinned, for a multi-status or
745
+ // no-status request) returns rows ordered by the index's remaining
746
+ // fields — `status` first, THEN `_creationTime` — not by
747
+ // `_creationTime` alone. Filtering that array down to matching
748
+ // statuses preserves that scrambled order: a plain `.slice(0,
749
+ // limit)` after such a fetch grabs `limit` rows skewed toward
750
+ // whichever status sorts first, never the true `limit` most-recent
751
+ // of the filtered UNION. Re-sorting by `_creationTime` desc here is
752
+ // a no-op (stable) on branches that were already creation-time
753
+ // ordered (single-status index-push, plain table scans) and is the
754
+ // actual fix on the scrambled branches. Sort BEFORE the re-bound so
755
+ // `limit` always yields the N genuinely most-recent survivors.
756
+ filtered = [...filtered].sort((a, b) => b._creationTime - a._creationTime);
757
+ // Re-bound to the requested page size now that every filter AND the
758
+ // creation-time re-sort have run over the full candidate set.
759
+ filtered = filtered.slice(0, limit);
760
+ // PR-E — cron-spam filter: exclude auto-generated tasks when requested.
761
+ // Two signals (OR logic):
762
+ // 1. createdBy starts with "cron-" (dash required — "cronus"/"cron" pass through)
763
+ // 2. title is exactly "/check-messages" or "check-messages" (case-insensitive)
764
+ if (args.excludeAutoGenerated === true) {
765
+ filtered = filtered.filter((r) => {
766
+ const isCronCreator = /^cron-/i.test(r.createdBy ?? "");
767
+ const isSyntheticTitle = /^\/?check-messages$/i.test(r.title ?? "");
768
+ return !isCronCreator && !isSyntheticTitle;
769
+ });
770
+ }
771
+ // Dashboard B1 — priority filter (in-memory, applied after other filters).
772
+ if (priorityFilter !== undefined) {
773
+ filtered = filtered.filter((r) => r.priority === priorityFilter);
774
+ }
775
+
776
+ const scoped = filterByOrgScope(filtered, scope);
777
+ if (lite) return scoped.map(projectTaskLite);
778
+ return scoped;
779
+ }
780
+
781
+ export const list = query({
782
+ args: tasksListArgsValidator,
783
+ // Returns: array of full task docs OR array of lite projections.
784
+ // Validator omitted because v.union of full+lite produces overly-strict
785
+ // inferred types that conflict with Doc<"tasks"> field optionality.
786
+ handler: async (ctx, args) => {
787
+ // ── Beta multi-tenant scope gate — fail-closed default (SEC-AUDIT Day
788
+ // 156): no Clerk identity is no longer master. The only legitimate
789
+ // no-identity caller (GitHub webhook, HMAC-verified) uses
790
+ // listForWebhook (internalQuery) below instead.
791
+ const scope = await withOrgScope(ctx);
792
+ return await runTasksList(ctx, args, scope);
793
+ },
794
+ });
795
+
796
+ // Internal-only mirror of `list`, used exclusively by convex/http.ts's
797
+ // GitHub webhook handler (HMAC-signature-verified, not Clerk-identity
798
+ // gated). `internal.*` functions are never exposed to `api.*` clients — no
799
+ // MCP tool, dashboard route, or direct Convex client call can reach this,
800
+ // which is the structural (not disciplinary) guard SEC-AUDIT Day 156
801
+ // requires for a genuine internal-fleet-only surface.
802
+ export const listForWebhook = internalQuery({
803
+ args: tasksListArgsValidator,
804
+ handler: async (ctx, args) => {
805
+ const masterScope: OrgScope = {
806
+ userId: "internal-webhook",
807
+ orgSlug: null,
808
+ allowedOrchestrators: ["*"],
809
+ scopes: [
810
+ "cross-tenant-read",
811
+ "view-own-tasks",
812
+ "view-own-missions",
813
+ "view-stats-aggregated",
814
+ "view-orchestrator-summary",
815
+ ],
816
+ isMaster: true,
817
+ };
818
+ return await runTasksList(ctx, args, masterScope);
819
+ },
820
+ });
821
+
822
+ // ─────────────────────────────────────────────────────────────────────────────
823
+ // listPaginated — dashboard-native paginated query (Day 116 B1 fix)
824
+ //
825
+ // The dashboard TaskBoard calls usePaginatedQuery which injects paginationOpts
826
+ // into the args. This dedicated query uses .paginate() and returns the expected
827
+ // PaginationResult shape. The dashboard must call api.tasks.listPaginated
828
+ // instead of api.tasks.list.
829
+ //
830
+ // Supported filters (maps to dashboard TaskBoard queryArgs):
831
+ // assignedTo — index-backed (by_assignee)
832
+ // status — single enum value; index-backed (by_status or by_assignee+status)
833
+ // priority — in-memory filter on the page
834
+ // orgId — accepted and ignored; scoping via withOrgScope (Clerk JWT)
835
+ // ─────────────────────────────────────────────────────────────────────────────
836
+
837
+ export const listPaginated = query({
838
+ args: {
839
+ paginationOpts: paginationOptsValidator,
840
+ assignedTo: v.optional(assigneeValidator),
841
+ status: v.optional(statusValidator),
842
+ priority: v.optional(priorityValidator),
843
+ // orgId is accepted and ignored — multi-tenant scoping via Clerk JWT
844
+ orgId: v.optional(v.string()),
845
+ },
846
+ returns: v.object({
847
+ page: v.array(taskFullValidator),
848
+ isDone: v.boolean(),
849
+ continueCursor: v.string(),
850
+ }),
851
+ handler: async (ctx, args) => {
852
+ const scope = await withOrgScope(ctx);
853
+ requireScope(scope, "view-own-tasks");
854
+
855
+ type TaskRow = Doc<"tasks">;
856
+
857
+ // Select the most specific index available for the paginated scan.
858
+ let baseQuery;
859
+ if (args.assignedTo !== undefined && args.status !== undefined) {
860
+ const assignedTo = args.assignedTo;
861
+ const status = args.status;
862
+ baseQuery = ctx.db
863
+ .query("tasks")
864
+ .withIndex("by_assignee", (q) =>
865
+ q.eq("assignedTo", assignedTo).eq("status", status),
866
+ )
867
+ .order("desc");
868
+ } else if (args.assignedTo !== undefined) {
869
+ const assignedTo = args.assignedTo;
870
+ baseQuery = ctx.db
871
+ .query("tasks")
872
+ .withIndex("by_assignee", (q) => q.eq("assignedTo", assignedTo))
873
+ .order("desc");
874
+ } else if (args.status !== undefined) {
875
+ const status = args.status;
876
+ baseQuery = ctx.db
877
+ .query("tasks")
878
+ .withIndex("by_status", (q) => q.eq("status", status))
879
+ .order("desc");
880
+ } else {
881
+ baseQuery = ctx.db.query("tasks").order("desc");
882
+ }
883
+
884
+ const paginatedResult = await baseQuery.paginate(args.paginationOpts);
885
+
886
+ // Apply in-memory priority filter on the page (no index for priority alone).
887
+ let page: TaskRow[] = paginatedResult.page;
888
+ if (args.priority !== undefined) {
889
+ const priority = args.priority;
890
+ page = page.filter((r) => r.priority === priority);
891
+ }
892
+
893
+ // Apply org scope filtering.
894
+ const scopedPage = filterByOrgScope(page, scope);
895
+
896
+ return {
897
+ page: scopedPage,
898
+ isDone: paginatedResult.isDone,
899
+ continueCursor: paginatedResult.continueCursor,
900
+ };
901
+ },
902
+ });
903
+
904
+ // ─────────────────────────────────────────────────────────────────────────────
905
+ // update — partial update of any mutable task field
906
+ // ─────────────────────────────────────────────────────────────────────────────
907
+
908
+ export const update = mutation({
909
+ args: {
910
+ taskId: v.id("tasks"),
911
+ callerOrchestrator: v.optional(creatorValidator),
912
+ title: v.optional(v.string()),
913
+ description: v.optional(v.string()),
914
+ project: v.optional(v.string()),
915
+ tags: v.optional(v.array(v.string())),
916
+ assignedTo: v.optional(assigneeValidator),
917
+ priority: v.optional(priorityValidator),
918
+ status: v.optional(statusValidator),
919
+ missionId: v.optional(v.id("missions")),
920
+ estimatedMinutes: v.optional(v.number()),
921
+ actualMinutes: v.optional(v.number()),
922
+ startedAt: v.optional(v.number()),
923
+ completedAt: v.optional(v.number()),
924
+ dueDate: v.optional(v.number()),
925
+ dependsOn: v.optional(v.array(v.id("tasks"))),
926
+ completionNote: v.optional(v.string()),
927
+ assignedToInstance: v.optional(v.string()),
928
+ // Mandatory reason when status is being set to "cancelled" (Day 157).
929
+ cancelReason: v.optional(v.string()),
930
+ },
931
+ returns: v.null(),
932
+ handler: async (ctx, args) => {
933
+ const { taskId, callerOrchestrator, cancelReason, ...fields } = args;
934
+ const task = await ctx.db.get(taskId);
935
+ if (task === null) {
936
+ throw new ConvexError(
937
+ `TASK_NOT_FOUND: Task ${taskId} not found — ${JSON.stringify({ taskId })}`,
938
+ );
939
+ }
940
+ assertTaskCallerAuthorized(task, callerOrchestrator, taskId);
941
+
942
+ // Build patch object with only provided fields
943
+ const patch: Record<string, any> = { updatedAt: Date.now() };
944
+ for (const [key, value] of Object.entries(fields)) {
945
+ if (value !== undefined) {
946
+ patch[key] = value;
947
+ }
948
+ }
949
+
950
+ // Day 159 — the anonymous-block gate must live at the STATUS boundary,
951
+ // not the verb. `blockTask` refuses an anonymous block, but `update`
952
+ // accepts `status: statusValidator` (which includes "blocked") with no
953
+ // verification — a second, ungated door to the exact defect blockTask
954
+ // exists to close. Refuse here and redirect to block_task.
955
+ if (patch.status === "blocked") {
956
+ throw new ConvexError(
957
+ `BLOCK_VIA_UPDATE_REFUSED: setting status="blocked" through update_task is refused — a block must name the task charged to lift it. Use block_task with blockedOnTaskId=<live task owned by someone else>, or a "# blocked-on-nobody: <reason>" marker for a genuinely ownerless obstacle — ${JSON.stringify({ taskId })}`,
958
+ );
959
+ }
960
+
961
+ // Day 157 — cancelled is a terminal status, settable only by the task's
962
+ // CREATOR (stricter than assertTaskCallerAuthorized above, which also
963
+ // allows the assignee), and requires a non-empty reason. Mirrors the
964
+ // deleteTask RBAC (convex/tasks.ts deleteTask) but never deletes data.
965
+ if (patch.status === "cancelled") {
966
+ // MAJOR #4 (convex-reviewer REVISE) — a task already closed as "done"
967
+ // carries a billable completedAt/actualMinutes. Flipping it to
968
+ // cancelled would silently erode billingSummaryByProject while
969
+ // keeping the stale timestamps. Done is a terminal state; it cannot
970
+ // be re-terminated as cancelled.
971
+ if (task.status === "done") {
972
+ throw new ConvexError(
973
+ `CANNOT_CANCEL_DONE: task ${taskId} is already done — a completed task cannot be cancelled — ${JSON.stringify({ taskId })}`,
974
+ );
975
+ }
976
+ if (
977
+ callerOrchestrator !== "system" &&
978
+ task.createdBy !== callerOrchestrator
979
+ ) {
980
+ throw new ConvexError(
981
+ `RBAC_DENIED: Only ${task.createdBy} (creator) or system can cancel task ${taskId} — ${JSON.stringify({ caller: callerOrchestrator, creator: task.createdBy, taskId })}`,
982
+ );
983
+ }
984
+ if (!cancelReason || cancelReason.trim() === "") {
985
+ throw new ConvexError(
986
+ `CANCEL_REASON_REQUIRED: cancelReason is required to cancel task ${taskId} — ${JSON.stringify({ taskId })}`,
987
+ );
988
+ }
989
+ patch.cancelledBy = callerOrchestrator;
990
+ patch.cancelReason = cancelReason;
991
+ } else if (cancelReason !== undefined) {
992
+ // MINOR #6 (convex-reviewer REVISE) — cancelReason must never be
993
+ // silently dropped. If the task is already cancelled and this call
994
+ // isn't changing status away from it, persist the updated reason.
995
+ // Otherwise (task isn't cancelled and this call isn't cancelling
996
+ // it), refuse rather than accept-and-discard the field.
997
+ if (task.status === "cancelled") {
998
+ patch.cancelReason = cancelReason;
999
+ } else {
1000
+ throw new ConvexError(
1001
+ `CANCEL_REASON_NOT_APPLICABLE: cancelReason was provided but task ${taskId} is not being cancelled and is not already cancelled (status="${task.status}") — pass status="cancelled" to cancel, or omit cancelReason — ${JSON.stringify({ taskId, status: task.status })}`,
1002
+ );
1003
+ }
1004
+ }
1005
+
1006
+ // Day 130 closure gate — `update` is a second path that can also
1007
+ // transition status to "done" (e.g. generic MCP update_task call).
1008
+ // Gate it the same way as `complete` so billable-project closures
1009
+ // can't bypass the machine-timestamp requirement via this path.
1010
+ if (patch.status === "done") {
1011
+ const now = Date.now();
1012
+ const { actualMinutes } = await enforceClosureGate(
1013
+ ctx,
1014
+ task,
1015
+ patch.completionNote ?? task.completionNote,
1016
+ now,
1017
+ );
1018
+ if (patch.completedAt === undefined) {
1019
+ patch.completedAt = now;
1020
+ }
1021
+ if (actualMinutes !== undefined && patch.actualMinutes === undefined) {
1022
+ patch.actualMinutes = actualMinutes;
1023
+ }
1024
+ }
1025
+
1026
+ await ctx.db.patch(taskId, patch);
1027
+
1028
+ if (patch.status === "done") {
1029
+ await unblockWaitersOn(ctx, taskId, patch.completedAt ?? Date.now());
1030
+ }
1031
+
1032
+ return null;
1033
+ },
1034
+ });
1035
+
1036
+ // ─────────────────────────────────────────────────────────────────────────────
1037
+ // unblockWaitersOn — reciprocal half of the block_task commitment (Day 159).
1038
+ // When `doneTaskId` transitions to "done", every task that named it via
1039
+ // blockedOnTaskId is swept back to "todo" and its assignee is notified
1040
+ // through the existing messages/messageReceipts path (same shape sendMessage
1041
+ // writes for a single-recipient channel — see convex/messages.ts:184-196).
1042
+ // Called from both `complete` and `update` (the two paths that can close a
1043
+ // task to "done" — see the Day 130 closure-gate comment on `update` below).
1044
+ // ─────────────────────────────────────────────────────────────────────────────
1045
+
1046
+ async function unblockWaitersOn(
1047
+ ctx: MutationCtx,
1048
+ doneTaskId: import("./_generated/dataModel").Id<"tasks">,
1049
+ now: number,
1050
+ ): Promise<void> {
1051
+ const doneTask = await ctx.db.get(doneTaskId);
1052
+ const waiters = await ctx.db
1053
+ .query("tasks")
1054
+ .withIndex("by_blockedOnTaskId", (q) => q.eq("blockedOnTaskId", doneTaskId))
1055
+ .collect();
1056
+
1057
+ for (const waiter of waiters) {
1058
+ if (waiter.status !== "blocked") continue;
1059
+
1060
+ await ctx.db.patch(waiter._id, {
1061
+ status: "todo",
1062
+ blockedOnTaskId: undefined,
1063
+ updatedAt: now,
1064
+ });
1065
+
1066
+ const content =
1067
+ `UNBLOCKED: task ${waiter._id} ("${waiter.title}") is unblocked — ` +
1068
+ `${doneTaskId} ("${doneTask?.title ?? "unknown"}") is now done. Status reset to todo — ${JSON.stringify(
1069
+ { taskId: waiter._id, unblockedBy: doneTaskId },
1070
+ )}`;
1071
+
1072
+ const messageId = await ctx.db.insert("messages", {
1073
+ from: "system",
1074
+ channel: waiter.assignedTo,
1075
+ content,
1076
+ createdAt: now,
1077
+ });
1078
+ await ctx.db.insert("messageReceipts", {
1079
+ messageId,
1080
+ recipient: waiter.assignedTo,
1081
+ readAt: undefined,
1082
+ });
1083
+ }
1084
+ }
1085
+
1086
+ // ─────────────────────────────────────────────────────────────────────────────
1087
+ // blockTask — server-side gate for the "who is charged to unblock me" defect
1088
+ // (Day 159). A block note is a journal; a task in the responder's queue is a
1089
+ // commitment. Extends the same layer/refusal shape as the complete_task
1090
+ // closure gate: this refusal lives on the server, so it holds for every
1091
+ // station without distribution.
1092
+ //
1093
+ // Two accepted shapes, ANONYMOUS blocking forbidden in both:
1094
+ // 1. blockedOnTaskId set — cited task must (a) exist, (b) be neither done
1095
+ // nor cancelled, (c) be assigned to someone OTHER than this task's own
1096
+ // assignee (you don't wait on yourself).
1097
+ // 2. blockedOnTaskId omitted — an obstacle nobody in the fleet owns
1098
+ // (operator decision, third-party outage). REQUIRES an explicit
1099
+ // "# blocked-on-nobody: <reason>" marker in `reason`; the reason is
1100
+ // stored in blockedOnNobodyReason.
1101
+ // ─────────────────────────────────────────────────────────────────────────────
1102
+
1103
+ const BLOCKED_ON_NOBODY_MARKER = /#\s*blocked-on-nobody:\s*(.+)/is;
1104
+
1105
+ export const blockTask = mutation({
1106
+ args: {
1107
+ taskId: v.id("tasks"),
1108
+ callerOrchestrator: v.optional(creatorValidator),
1109
+ reason: v.optional(v.string()),
1110
+ blockedOnTaskId: v.optional(v.id("tasks")),
1111
+ },
1112
+ returns: v.null(),
1113
+ handler: async (ctx, args) => {
1114
+ const task = await ctx.db.get(args.taskId);
1115
+ if (task === null) {
1116
+ throw new ConvexError(
1117
+ `TASK_NOT_FOUND: Task ${args.taskId} not found — ${JSON.stringify({ taskId: args.taskId })}`,
1118
+ );
1119
+ }
1120
+ assertTaskCallerAuthorized(task, args.callerOrchestrator, args.taskId);
1121
+
1122
+ const patch: Record<string, unknown> = {
1123
+ status: "blocked",
1124
+ updatedAt: Date.now(),
1125
+ blockedOnTaskId: undefined,
1126
+ blockedOnNobodyReason: undefined,
1127
+ };
1128
+
1129
+ if (args.blockedOnTaskId !== undefined) {
1130
+ const blocker = await ctx.db.get(args.blockedOnTaskId);
1131
+ if (blocker === null) {
1132
+ throw new ConvexError(
1133
+ `BLOCKED_ON_TASK_NOT_FOUND: cited task ${args.blockedOnTaskId} does not exist — cite a real, live task ID, or omit blockedOnTaskId and mark the reason with "# blocked-on-nobody: <reason>" if no one owns this obstacle — ${JSON.stringify({ taskId: args.taskId, blockedOnTaskId: args.blockedOnTaskId })}`,
1134
+ );
1135
+ }
1136
+ if (blocker.status === "done" || blocker.status === "cancelled") {
1137
+ throw new ConvexError(
1138
+ `BLOCKED_ON_TASK_CLOSED: cited task ${args.blockedOnTaskId} is already "${blocker.status}" — a closed request blocks no one. Cite a live task, or complete it and let the reciprocal unblock fire — ${JSON.stringify({ taskId: args.taskId, blockedOnTaskId: args.blockedOnTaskId, blockerStatus: blocker.status })}`,
1139
+ );
1140
+ }
1141
+ if (blocker.assignedTo === task.assignedTo) {
1142
+ throw new ConvexError(
1143
+ `BLOCKED_ON_OWN_TASK: cited task ${args.blockedOnTaskId} is assigned to ${blocker.assignedTo}, the same assignee as ${args.taskId} — you cannot block on your own task. Cite a task owned by someone else, or omit blockedOnTaskId and mark the reason with "# blocked-on-nobody: <reason>" — ${JSON.stringify({ taskId: args.taskId, blockedOnTaskId: args.blockedOnTaskId, assignedTo: task.assignedTo })}`,
1144
+ );
1145
+ }
1146
+ patch.blockedOnTaskId = args.blockedOnTaskId;
1147
+ if (args.reason) patch.completionNote = args.reason;
1148
+ } else {
1149
+ const reason = args.reason ?? "";
1150
+ const marker = reason.match(BLOCKED_ON_NOBODY_MARKER);
1151
+ if (!marker || marker[1].trim() === "") {
1152
+ throw new ConvexError(
1153
+ `BLOCKED_LINK_REQUIRED: block_task requires EITHER blockedOnTaskId citing a live task assigned to someone else, OR an explicit "# blocked-on-nobody: <reason>" marker in reason (for an obstacle nobody in the fleet owns — operator decision, third-party outage). Anonymous blocking is refused, never blocking itself — ${JSON.stringify({ taskId: args.taskId })}`,
1154
+ );
1155
+ }
1156
+ patch.blockedOnNobodyReason = reason;
1157
+ patch.completionNote = reason;
1158
+ }
1159
+
1160
+ await ctx.db.patch(args.taskId, patch);
1161
+ return null;
1162
+ },
1163
+ });
1164
+
1165
+ // ─────────────────────────────────────────────────────────────────────────────
1166
+ // listUnlinkedBlocked — migration inventory query (Day 159). Lists tasks
1167
+ // ALREADY in status="blocked" carrying neither blockedOnTaskId nor
1168
+ // blockedOnNobodyReason — pre-existing rows from before this gate shipped.
1169
+ // Read-only: never mutates. This is the invisible-debt inventory Pi's audit
1170
+ // asked for.
1171
+ // ─────────────────────────────────────────────────────────────────────────────
1172
+
1173
+ export const listUnlinkedBlocked = query({
1174
+ args: {},
1175
+ returns: v.array(
1176
+ v.object({
1177
+ taskId: v.id("tasks"),
1178
+ title: v.string(),
1179
+ assignedTo: v.string(),
1180
+ createdBy: v.string(),
1181
+ updatedAt: v.number(),
1182
+ }),
1183
+ ),
1184
+ handler: async (ctx) => {
1185
+ const blocked = await ctx.db
1186
+ .query("tasks")
1187
+ .withIndex("by_status", (q) => q.eq("status", "blocked"))
1188
+ .collect();
1189
+ return blocked
1190
+ .filter((t) => t.blockedOnTaskId === undefined && t.blockedOnNobodyReason === undefined)
1191
+ .map((t) => ({
1192
+ taskId: t._id,
1193
+ title: t.title,
1194
+ assignedTo: t.assignedTo,
1195
+ createdBy: t.createdBy,
1196
+ updatedAt: t.updatedAt,
1197
+ }));
1198
+ },
1199
+ });
1200
+
1201
+ // ─────────────────────────────────────────────────────────────────────────────
1202
+ // complete — shortcut: sets status=done, updatedAt=now
1203
+ // ─────────────────────────────────────────────────────────────────────────────
1204
+
1205
+ export const complete = mutation({
1206
+ args: {
1207
+ taskId: v.id("tasks"),
1208
+ callerOrchestrator: v.optional(creatorValidator),
1209
+ completionNote: v.optional(v.string()),
1210
+ },
1211
+ returns: v.null(),
1212
+ handler: async (ctx, args) => {
1213
+ const task = await ctx.db.get(args.taskId);
1214
+ if (task === null) {
1215
+ throw new ConvexError(
1216
+ `TASK_NOT_FOUND: Task ${args.taskId} not found — ${JSON.stringify({ taskId: args.taskId })}`,
1217
+ );
1218
+ }
1219
+ assertTaskCallerAuthorized(task, args.callerOrchestrator, args.taskId);
1220
+
1221
+ if (!args.completionNote || args.completionNote.trim() === "") {
1222
+ throw new ConvexError(
1223
+ `COMPLETION_NOTE_REQUIRED: completionNote is required for task ${args.taskId}. Describe what was actually done (≥40 chars with verifiable proof token) — ${JSON.stringify({ taskId: args.taskId })}`,
1224
+ );
1225
+ }
1226
+
1227
+ const now = Date.now();
1228
+
1229
+ // Day 130 closure gate — billable-project tasks must carry a
1230
+ // machine-recorded startedAt (or an explicit structured override)
1231
+ // before they can close. Billing derives actualMinutes from
1232
+ // startedAt→completedAt, never a hand-typed time line.
1233
+ const { actualMinutes } = await enforceClosureGate(
1234
+ ctx,
1235
+ task,
1236
+ args.completionNote,
1237
+ now,
1238
+ );
1239
+
1240
+ const patch: Record<string, any> = {
1241
+ status: "done",
1242
+ completedAt: now,
1243
+ updatedAt: now,
1244
+ };
1245
+
1246
+ if (args.completionNote !== undefined) {
1247
+ patch.completionNote = args.completionNote;
1248
+ }
1249
+
1250
+ if (actualMinutes !== undefined) {
1251
+ patch.actualMinutes = actualMinutes;
1252
+ }
1253
+
1254
+ await ctx.db.patch(args.taskId, patch);
1255
+
1256
+ await unblockWaitersOn(ctx, args.taskId, now);
1257
+
1258
+ // Auto-link: if task title contains #NNN, update the corresponding issue
1259
+ const issueMatch = task.title.match(/#(\d+)/);
1260
+ if (issueMatch) {
1261
+ const issueNumber = parseInt(issueMatch[1], 10);
1262
+ // Find repo from project via githubRepoMapping
1263
+ if (task.project) {
1264
+ const mappings = await ctx.db.query("githubRepoMapping").collect();
1265
+ const mapping = mappings.find((m) => m.project === task.project);
1266
+ if (mapping) {
1267
+ // Find the issue
1268
+ const issue = await ctx.db
1269
+ .query("issues")
1270
+ .withIndex("by_repo_number", (q) =>
1271
+ q.eq("repo", mapping.repo).eq("issueNumber", issueNumber),
1272
+ )
1273
+ .unique();
1274
+ if (issue) {
1275
+ // Link the task
1276
+ const existingTaskIds = issue.linkedTaskIds || [];
1277
+ if (!existingTaskIds.includes(args.taskId as string)) {
1278
+ await ctx.db.patch(issue._id, {
1279
+ linkedTaskIds: [...existingTaskIds, args.taskId as string],
1280
+ });
1281
+ }
1282
+ // Check if completionNote mentions fix/fixed/commit SHA
1283
+ const note = args.completionNote || "";
1284
+ const hasFix =
1285
+ /\bfix(ed)?\b/i.test(note) || /\b[0-9a-f]{7,40}\b/.test(note);
1286
+ if (hasFix) {
1287
+ // Extract commit SHA if present
1288
+ const shaMatch = note.match(/\b([0-9a-f]{7,40})\b/);
1289
+ await ctx.db.patch(issue._id, {
1290
+ status: "fixed",
1291
+ fixedBy: task.assignedTo,
1292
+ fixedAt: Date.now(),
1293
+ ...(shaMatch
1294
+ ? {
1295
+ fixCommits: [...(issue.fixCommits || []), shaMatch[1]],
1296
+ }
1297
+ : {}),
1298
+ });
1299
+ }
1300
+ }
1301
+ }
1302
+ }
1303
+ }
1304
+
1305
+ // IRP auto-comments: post a GitHub comment when key IRP steps are completed.
1306
+ // IRP task titles follow the pattern "[#NNN] TN — <step name>".
1307
+ const irpStepMatch = task.title.match(/\[#(\d+)\] T(\d+)/);
1308
+ if (irpStepMatch && task.project) {
1309
+ const irpIssueNumber = parseInt(irpStepMatch[1], 10);
1310
+ const stepNumber = parseInt(irpStepMatch[2], 10);
1311
+
1312
+ // Extract issue author stored in task description by the webhook
1313
+ const authorMatch = task.description?.match(/Issue author: @(\S+)/);
1314
+ const author = authorMatch ? authorMatch[1] : null;
1315
+ const authorMention = author ? `@${author} ` : "";
1316
+
1317
+ const allMappings = await ctx.db.query("githubRepoMapping").take(100);
1318
+ const repoMapping = allMappings.find((m) => m.project === task.project);
1319
+
1320
+ if (repoMapping) {
1321
+ const dateStr = new Date().toISOString().split("T")[0];
1322
+ const orch = task.assignedTo;
1323
+ const orchCapitalized = orch.charAt(0).toUpperCase() + orch.slice(1);
1324
+ const signature = `Orchestrator: ${orchCapitalized} | ${dateStr}`;
1325
+ let commentBody: string | null = null;
1326
+
1327
+ if (stepNumber === 6) {
1328
+ commentBody = `${authorMention}Bug reproduced in test suite. Root cause identified. Fix in progress.\n\n${signature}`;
1329
+ } else if (stepNumber === 8) {
1330
+ commentBody = `${authorMention}Fix ready. All tests pass (including new regression test). Awaiting review and deploy.\n\n${signature}`;
1331
+ } else if (stepNumber === 11) {
1332
+ commentBody = `${authorMention}Fixed and deployed to production. Regression test added to prevent recurrence. Closing.\n\n${signature}`;
1333
+ }
1334
+
1335
+ if (commentBody !== null) {
1336
+ await ctx.scheduler.runAfter(0, internal.githubComments.postComment, {
1337
+ repo: repoMapping.repo,
1338
+ issueNumber: irpIssueNumber,
1339
+ body: commentBody,
1340
+ });
1341
+ }
1342
+
1343
+ // IRP auto-store fixPattern when the Fix step (T7) is completed
1344
+ if (stepNumber === 7 && args.completionNote) {
1345
+ const note = args.completionNote;
1346
+
1347
+ // Parse structured completionNote: "Root cause: ... Fix: ... Files: ..."
1348
+ const rootCauseMatch = note.match(
1349
+ /Root cause:\s*(.+?)(?=\s*Fix:|$)/is,
1350
+ );
1351
+ const fixMatch = note.match(/Fix:\s*(.+?)(?=\s*Files:|$)/is);
1352
+ const filesMatch = note.match(/Files:\s*(.+?)$/is);
1353
+
1354
+ if (rootCauseMatch) {
1355
+ // Extract a clean symptom from the task title: "[#282] T7 — Fix" -> "Fix #282"
1356
+ const issueTitle = `Issue #${irpIssueNumber}: ${task.title.replace(/^\[#\d+\] T\d+ — /, "")}`;
1357
+ const rootCause = rootCauseMatch[1].trim();
1358
+ const validatedFix = fixMatch ? fixMatch[1].trim() : undefined;
1359
+
1360
+ // Use assignedTo directly — creatorValidator is now v.string() (issue #132)
1361
+ const fixPatternCreatedBy: string = task.assignedTo;
1362
+
1363
+ const patternId = await ctx.db.insert("fixPatterns", {
1364
+ symptom: issueTitle,
1365
+ rootCause,
1366
+ validatedFix,
1367
+ files: filesMatch
1368
+ ? filesMatch[1]
1369
+ .trim()
1370
+ .split(",")
1371
+ .map((f) => f.trim())
1372
+ .filter((f) => f.length > 0)
1373
+ : undefined,
1374
+ tags: task.tags ?? [],
1375
+ stack: [],
1376
+ sourceProject: task.project,
1377
+ linkedIssueIds: [`#${irpIssueNumber}`],
1378
+ createdBy: fixPatternCreatedBy,
1379
+ severity: "major" as const,
1380
+ createdAt: Date.now(),
1381
+ updatedAt: Date.now(),
1382
+ });
1383
+
1384
+ // Schedule RAG embedding — matches fixPatterns.create behaviour
1385
+ const ragText = `Symptom: ${issueTitle}\nRoot cause: ${rootCause}${validatedFix ? `\nValidated fix: ${validatedFix}` : ""}`;
1386
+ await ctx.scheduler.runAfter(
1387
+ 0,
1388
+ internal.ragSync.addFixPatternRagEntry,
1389
+ {
1390
+ patternId,
1391
+ content: ragText,
1392
+ sourceProject: task.project,
1393
+ },
1394
+ );
1395
+ }
1396
+ }
1397
+ }
1398
+ }
1399
+
1400
+ // Auto-complete mission: if this task belongs to a mission, check if all tasks are done
1401
+ if (task.missionId) {
1402
+ const missionTasks = await ctx.db
1403
+ .query("tasks")
1404
+ .withIndex("by_mission", (q) => q.eq("missionId", task.missionId!))
1405
+ .collect();
1406
+ const allDone = missionTasks.every(
1407
+ (t) =>
1408
+ t._id.toString() === args.taskId.toString() || t.status === "done",
1409
+ );
1410
+ if (allDone) {
1411
+ const mission = await ctx.db.get(task.missionId);
1412
+ if (mission && mission.status !== "complete") {
1413
+ await ctx.db.patch(task.missionId, {
1414
+ status: "complete",
1415
+ updatedAt: Date.now(),
1416
+ });
1417
+ }
1418
+ }
1419
+ }
1420
+
1421
+ return null;
1422
+ },
1423
+ });
1424
+
1425
+ // ─────────────────────────────────────────────────────────────────────────────
1426
+ // start — sets status=in_progress, startedAt=now, updatedAt=now
1427
+ // ─────────────────────────────────────────────────────────────────────────────
1428
+
1429
+ export const start = mutation({
1430
+ args: {
1431
+ taskId: v.id("tasks"),
1432
+ callerOrchestrator: v.optional(creatorValidator),
1433
+ },
1434
+ returns: v.null(),
1435
+ handler: async (ctx, args) => {
1436
+ const task = await ctx.db.get(args.taskId);
1437
+ if (task === null) {
1438
+ throw new ConvexError(
1439
+ `TASK_NOT_FOUND: Task ${args.taskId} not found — ${JSON.stringify({ taskId: args.taskId })}`,
1440
+ );
1441
+ }
1442
+ assertTaskCallerAuthorized(task, args.callerOrchestrator, args.taskId);
1443
+
1444
+ // Block if any dependsOn tasks are not yet done.
1445
+ if (task.dependsOn && task.dependsOn.length > 0) {
1446
+ const depDocs = await Promise.all(
1447
+ task.dependsOn.map((depId) => ctx.db.get(depId)),
1448
+ );
1449
+ const blockers = depDocs
1450
+ .filter((d): d is NonNullable<typeof d> => d !== null && d.status !== "done")
1451
+ .map((d) => ({ taskId: d._id, title: d.title, status: d.status }));
1452
+ if (blockers.length > 0) {
1453
+ throw new ConvexError(
1454
+ `DEPENDENCY_NOT_DONE: Cannot start task ${args.taskId} — ${blockers.length} dependency(ies) not yet done — ${JSON.stringify({ taskId: args.taskId, blockers })}`,
1455
+ );
1456
+ }
1457
+ }
1458
+
1459
+ // Block if caller has a different unclosed in_progress task IN THE SAME
1460
+ // `project` (repo/stream) as the task being started. Day 156
1461
+ // (mission vp-concurrent-active-tasks-per-stream-v1, T1) — relaxed from
1462
+ // "one in_progress task per orchestrator" (unbounded) to "one
1463
+ // in_progress task per orchestrator PER DISTINCT project". Tasks with
1464
+ // `project === undefined` are treated as one shared "default" stream
1465
+ // (conservative default — preserves pre-relaxation behavior for
1466
+ // un-projected tasks; only projected tasks gain concurrency).
1467
+ // Skip for "system" — it is never an assignee and has no task queue.
1468
+ if (args.callerOrchestrator && args.callerOrchestrator !== "system") {
1469
+ const callerOrc = args.callerOrchestrator;
1470
+ const taskProject = task.project;
1471
+ const inProgressTasks = await ctx.db
1472
+ .query("tasks")
1473
+ .withIndex("by_assignee_project", (q) =>
1474
+ q
1475
+ .eq("assignedTo", callerOrc)
1476
+ .eq("project", taskProject)
1477
+ .eq("status", "in_progress"),
1478
+ )
1479
+ .take(2);
1480
+
1481
+ const conflict = inProgressTasks.find(
1482
+ (t) => t._id !== args.taskId,
1483
+ );
1484
+ if (conflict !== undefined) {
1485
+ throw new ConvexError(
1486
+ `TASK_START_BLOCKED: Cannot start task ${args.taskId} — caller ${callerOrc} has an unclosed in_progress task "${conflict.title}" in project ${JSON.stringify(taskProject ?? null)}. Call complete_task with completionNote first — ${JSON.stringify({ currentInProgressTaskId: conflict._id, currentInProgressTitle: conflict.title, attemptedTaskId: args.taskId, project: taskProject ?? null })}`,
1487
+ );
1488
+ }
1489
+ }
1490
+
1491
+ const now = Date.now();
1492
+ await ctx.db.patch(args.taskId, {
1493
+ status: "in_progress",
1494
+ startedAt: now,
1495
+ updatedAt: now,
1496
+ });
1497
+ return null;
1498
+ },
1499
+ });
1500
+
1501
+ // ─────────────────────────────────────────────────────────────────────────────
1502
+ // checkout — atomically claim a task (only if status=todo)
1503
+ // ─────────────────────────────────────────────────────────────────────────────
1504
+
1505
+ export const checkout = mutation({
1506
+ args: {
1507
+ taskId: v.id("tasks"),
1508
+ callerOrchestrator: creatorValidator,
1509
+ callerInstance: v.optional(v.string()),
1510
+ },
1511
+ returns: v.object({ claimed: v.boolean(), reason: v.optional(v.string()) }),
1512
+ handler: async (ctx, args) => {
1513
+ const task = await ctx.db.get(args.taskId);
1514
+ if (!task) {
1515
+ return { claimed: false, reason: "Task not found" };
1516
+ }
1517
+ if (task.status !== "todo") {
1518
+ return {
1519
+ claimed: false,
1520
+ reason: `Task already ${task.status}${task.claimedByInstance ? ` by ${task.claimedByInstance}` : ""}`,
1521
+ };
1522
+ }
1523
+ await ctx.db.patch(args.taskId, {
1524
+ status: "in_progress",
1525
+ claimedByInstance: args.callerInstance,
1526
+ startedAt: Date.now(),
1527
+ updatedAt: Date.now(),
1528
+ });
1529
+ return { claimed: true };
1530
+ },
1531
+ });
1532
+
1533
+ // ─────────────────────────────────────────────────────────────────────────────
1534
+ // deleteTask — hard delete, owner-only (createdBy must match caller)
1535
+ // ─────────────────────────────────────────────────────────────────────────────
1536
+
1537
+ export const deleteTask = mutation({
1538
+ args: {
1539
+ taskId: v.id("tasks"),
1540
+ callerOrchestrator: v.optional(creatorValidator),
1541
+ },
1542
+ returns: v.object({ deleted: v.boolean() }),
1543
+ handler: async (ctx, args) => {
1544
+ const task = await ctx.db.get(args.taskId);
1545
+ if (!task)
1546
+ throw new ConvexError(
1547
+ `TASK_NOT_FOUND: Task ${args.taskId} not found — ${JSON.stringify({ taskId: args.taskId })}`,
1548
+ );
1549
+
1550
+ if (args.callerOrchestrator === undefined) {
1551
+ throw new ConvexError(
1552
+ `RBAC_DENIED: callerOrchestrator is required to delete task ${args.taskId} — omitting it is refused, not exempted — ${JSON.stringify({ taskId: args.taskId })}`,
1553
+ );
1554
+ }
1555
+ if (
1556
+ args.callerOrchestrator !== "system" &&
1557
+ task.createdBy !== args.callerOrchestrator
1558
+ ) {
1559
+ throw new ConvexError(
1560
+ `RBAC_DENIED: Only ${task.createdBy} (creator) or system can delete task ${args.taskId} — ${JSON.stringify({ caller: args.callerOrchestrator, creator: task.createdBy, taskId: args.taskId })}`,
1561
+ );
1562
+ }
1563
+
1564
+ await ctx.db.delete(args.taskId);
1565
+ return { deleted: true };
1566
+ },
1567
+ });
1568
+
1569
+ // ─────────────────────────────────────────────────────────────────────────────
1570
+ // listByMission — list tasks filtered by missionId
1571
+ //
1572
+ // New in v1.1 (same pattern as `list`):
1573
+ // fields="lite" — compact projection: {_id,_creationTime,title,status,priority,assignedTo,missionId}
1574
+ // fields="full" (default) — full doc (backward-compatible)
1575
+ // status="open" — expands to ["todo","in_progress","review","blocked"]
1576
+ // status="active" — expands to ["todo","in_progress"]
1577
+ // status=["todo","in_progress"] — multi-value array (no alias mixing)
1578
+ // ─────────────────────────────────────────────────────────────────────────────
1579
+
1580
+ export const listByMission = query({
1581
+ args: {
1582
+ missionId: v.id("missions"),
1583
+ status: v.optional(v.union(v.string(), v.array(v.string()))),
1584
+ limit: v.optional(v.number()),
1585
+ fields: v.optional(v.union(v.literal("lite"), v.literal("full"))),
1586
+ createdBy: v.optional(creatorValidator),
1587
+ updatedSince: v.optional(v.number()),
1588
+ // S3.3 B8 follow-up batch 2 — cursor paging anchor (newest-first).
1589
+ createdBefore: v.optional(v.number()),
1590
+ },
1591
+ // Returns validator omitted because union of full+lite produces overly strict types vs Doc<"tasks"> optionality
1592
+ handler: async (ctx, args) => {
1593
+ const statuses = expandTaskStatuses(args.status);
1594
+ const lite = args.fields === "lite";
1595
+ const missionId = args.missionId;
1596
+ const createdBy = args.createdBy;
1597
+ const updatedSince = args.updatedSince;
1598
+ // v2.3.3 — auto-clamp limit when fields=full + no explicit limit
1599
+ const explicitLimit = args.limit !== undefined;
1600
+ let limit = args.limit ?? 50;
1601
+ if (!explicitLimit && !lite) {
1602
+ limit = 30;
1603
+ console.warn(
1604
+ `[tasks.listByMission] auto-clamp: limit=30 applied (fields=full, no explicit limit).`,
1605
+ );
1606
+ }
1607
+
1608
+ // Day 163 (Pi, k171rbm2txe42jxzddyqakbg7n8ch7zr) — same defect and same
1609
+ // fix as `runTasksList` above: `createdBefore` was omitted from
1610
+ // `needsWideScan`, so a cursor-only call fetched only `limit` rows and
1611
+ // then filtered them all out. Included here too.
1612
+ const before = args.createdBefore;
1613
+ // Eta REVISE on PR #1194 @147d260 — same multi-status/no-status
1614
+ // widen-gap as `runTasksList` above: `by_mission` is
1615
+ // ["missionId","status"], so with only `missionId` pinned the
1616
+ // remaining rows are ordered by `status` first, then
1617
+ // `_creationTime` — not pure creation-time order. A narrow
1618
+ // `.take(limit)` on a multi-status (or no-status) request grabs
1619
+ // `limit` rows skewed to one status bucket, not the union's
1620
+ // most-recent. Always widen for those; single-status is unaffected
1621
+ // (untouched, per Eta's confirmation on the assignee/index-push path).
1622
+ const needsWideScan =
1623
+ createdBy !== undefined ||
1624
+ updatedSince !== undefined ||
1625
+ before !== undefined ||
1626
+ statuses === undefined ||
1627
+ statuses.length > 1;
1628
+ const fetchCap = needsWideScan ? TASK_LIST_SCAN_CAP + 1 : limit;
1629
+ // Preferred fix — push the cursor bound into the `by_mission` index
1630
+ // range (["missionId","status"], `_creationTime` implicit last field)
1631
+ // when a single status is pinned and updatedSince doesn't already
1632
+ // occupy the range slot. Unbounded, no SCAN_CAP dependency.
1633
+ const canPushCursorIntoIndex =
1634
+ before !== undefined &&
1635
+ updatedSince === undefined &&
1636
+ statuses !== undefined &&
1637
+ statuses.length === 1;
1638
+
1639
+ type TaskRow = Doc<"tasks">;
1640
+ const applyStatusFilter = (rows: TaskRow[]) => {
1641
+ if (statuses === undefined) return rows;
1642
+ if (statuses.length === 1)
1643
+ return rows.filter((r) => r.status === statuses[0]);
1644
+ return rows.filter((r) => statuses.includes(r.status));
1645
+ };
1646
+
1647
+ let allRows: TaskRow[];
1648
+
1649
+ if (statuses !== undefined && statuses.length === 1) {
1650
+ allRows = await ctx.db
1651
+ .query("tasks")
1652
+ .withIndex("by_mission", (q) => {
1653
+ const base = q.eq("missionId", missionId).eq("status", statuses[0]);
1654
+ return canPushCursorIntoIndex
1655
+ ? base.lt("_creationTime", before as number)
1656
+ : base;
1657
+ })
1658
+ .order("desc")
1659
+ .take(canPushCursorIntoIndex ? limit : fetchCap);
1660
+ } else {
1661
+ const base = await ctx.db
1662
+ .query("tasks")
1663
+ .withIndex("by_mission", (q) => q.eq("missionId", missionId))
1664
+ .order("desc")
1665
+ .take(fetchCap);
1666
+ allRows = applyStatusFilter(base);
1667
+ }
1668
+
1669
+ // Refuse a silently-incomplete page — see `list` above for rationale.
1670
+ // Unlike `list`, this branch (missionId) was not measured to exceed the
1671
+ // cap in production, so no index was added here — the fetch is still a
1672
+ // fixed-size widened scan and "shrink the updatedSince window" would be
1673
+ // a false remedy (narrowing the window doesn't change what got fetched).
1674
+ // Left out of the message on purpose.
1675
+ if (needsWideScan && allRows.length > TASK_LIST_SCAN_CAP) {
1676
+ throw new ConvexError(
1677
+ `tasks.listByMission: SCAN_CAP_EXCEEDED — widened scan for updatedSince/createdBy/createdBefore hit the cap of ${TASK_LIST_SCAN_CAP} candidate rows before the filter ran. The result would be incomplete and indistinguishable from a full match. Narrow with status.`,
1678
+ );
1679
+ }
1680
+
1681
+ // v2.3.3 — apply createdBy + updatedSince in-memory
1682
+ let filtered = allRows;
1683
+ if (createdBy !== undefined) {
1684
+ filtered = filtered.filter((r) => r.createdBy === createdBy);
1685
+ }
1686
+ if (updatedSince !== undefined) {
1687
+ filtered = filtered.filter((r) => (r.updatedAt ?? 0) >= updatedSince);
1688
+ }
1689
+ // Day 163 fix — cursor filter MUST run BEFORE the re-bound to `limit`
1690
+ // (same ordering bug as `runTasksList`). No-op on the index-pushed
1691
+ // branch above; the actual fix on the wide-scan fallback branch.
1692
+ if (before !== undefined) {
1693
+ filtered = filtered.filter((r) => r._creationTime < before);
1694
+ }
1695
+ // Eta REVISE on PR #1194 @147d260 — same re-sort fix as
1696
+ // `runTasksList`: the multi-status/no-status branch above fetches
1697
+ // through `by_mission` with only `missionId` pinned, so the returned
1698
+ // array is ordered by `status` first, then `_creationTime` — not
1699
+ // creation-time alone. No-op on the single-status index-push branch.
1700
+ filtered = [...filtered].sort((a, b) => b._creationTime - a._creationTime);
1701
+ // Re-bound to the requested page size now that every filter AND the
1702
+ // creation-time re-sort — including the cursor — has run over the
1703
+ // full candidate set.
1704
+ filtered = filtered.slice(0, limit);
1705
+
1706
+ if (lite) return filtered.map(projectTaskLite);
1707
+ return filtered;
1708
+ },
1709
+ });
1710
+
1711
+ // ─────────────────────────────────────────────────────────────────────────────
1712
+ // listOverdue — fetch tasks that are past their due date
1713
+ // ─────────────────────────────────────────────────────────────────────────────
1714
+
1715
+ export const listOverdue = query({
1716
+ args: {
1717
+ assignedTo: v.optional(assigneeValidator),
1718
+ limit: v.optional(v.number()),
1719
+ },
1720
+ handler: async (ctx, args) => {
1721
+ const now = Date.now();
1722
+ const limit = args.limit ?? 50;
1723
+
1724
+ let tasks = await ctx.db
1725
+ .query("tasks")
1726
+ .filter((q) =>
1727
+ q.and(
1728
+ q.neq(q.field("status"), "done"),
1729
+ // Day 157 — a cancelled task is a terminal, non-actionable state;
1730
+ // it must never surface as "overdue" (MAJOR #2, convex-reviewer).
1731
+ q.neq(q.field("status"), "cancelled"),
1732
+ q.neq(q.field("dueDate"), undefined),
1733
+ q.lt(q.field("dueDate"), now),
1734
+ ),
1735
+ )
1736
+ .take(limit);
1737
+
1738
+ if (args.assignedTo) {
1739
+ tasks = tasks.filter((t) => t.assignedTo === args.assignedTo);
1740
+ }
1741
+
1742
+ return tasks;
1743
+ },
1744
+ });
1745
+
1746
+ // ─────────────────────────────────────────────────────────────────────────────
1747
+ // DEPLOY TASK TITLE PATTERN
1748
+ // "[Deploy] PR #<prNumber> merged — deploy <repo> to prod"
1749
+ // ─────────────────────────────────────────────────────────────────────────────
1750
+ const DEPLOY_TITLE_RE =
1751
+ /^\[Deploy\] PR #(\d+) merged — deploy ([\w-]+) to prod$/;
1752
+
1753
+ /**
1754
+ * Parse a deploy task title into (prNumber, repo) tuple.
1755
+ * Returns null if the title does not match the expected pattern.
1756
+ */
1757
+ function parseDeployTitle(
1758
+ title: string,
1759
+ ): { prNumber: number; repo: string } | null {
1760
+ const m = DEPLOY_TITLE_RE.exec(title);
1761
+ if (!m) return null;
1762
+ return { prNumber: parseInt(m[1], 10), repo: m[2] };
1763
+ }
1764
+
1765
+ // ─────────────────────────────────────────────────────────────────────────────
1766
+ // createDeployTaskWithDedup — Fix 1 + Fix 3
1767
+ //
1768
+ // Fix 1 (pre-create dedup): if an open deploy task already exists for the same
1769
+ // (repo, prNumber) tuple, skip creating a new one and return the existing ID.
1770
+ //
1771
+ // Fix 3 (post-create supersede): after creating a new deploy task, mark every
1772
+ // other older open deploy task for the same (repo, prNumber) as "done" with
1773
+ // completionNote "[SUPERSEDED-BY-k<newId>] <originalTitle>\nfriction_observed:
1774
+ // superseded-by-newer-deploy-task".
1775
+ //
1776
+ // Called from convex/http.ts GitHub webhook handler (PR merged event).
1777
+ // ─────────────────────────────────────────────────────────────────────────────
1778
+ export const createDeployTaskWithDedup = internalMutation({
1779
+ args: {
1780
+ title: v.string(),
1781
+ description: v.optional(v.string()),
1782
+ project: v.optional(v.string()),
1783
+ assignedTo: assigneeValidator,
1784
+ priority: priorityValidator,
1785
+ createdBy: creatorValidator,
1786
+ tags: v.optional(v.array(v.string())),
1787
+ // Day 98 (k173yr5n1) Mechanism (a) — PR merge timestamp (Unix ms).
1788
+ // If githubRepoMapping.lastDeployedAt > prMergedAt, the PR was shipped
1789
+ // via a bundled deploy that completed AFTER it merged; no per-PR Deploy
1790
+ // task is created and null is returned. Omit to disable the dedup
1791
+ // (preserves pre-Day 98 behavior for callers not yet plumbing mergedAt).
1792
+ prMergedAt: v.optional(v.number()),
1793
+ },
1794
+ returns: v.union(v.id("tasks"), v.null()),
1795
+ handler: async (ctx, args) => {
1796
+ const parsed = parseDeployTitle(args.title);
1797
+ if (!parsed) {
1798
+ // Unexpected title format — fall through to plain create with no dedup.
1799
+ const now = Date.now();
1800
+ // Strip Day 98 arg (not a task column).
1801
+ const { prMergedAt: _ignored, ...taskArgs } = args;
1802
+ return await ctx.db.insert("tasks", {
1803
+ ...taskArgs,
1804
+ status: "todo" as const,
1805
+ createdAt: now,
1806
+ updatedAt: now,
1807
+ });
1808
+ }
1809
+
1810
+ const { prNumber, repo } = parsed;
1811
+
1812
+ // ── Day 98 Mechanism (a): bundled-deploy dedup by timestamp ───────────
1813
+ // If we have prMergedAt AND the repo has a lastDeployedAt newer than
1814
+ // the PR merge, this PR was shipped as part of a bundled deploy chain
1815
+ // (e.g. C5/Day93 release that bundled #683 + #684 + #685). No new task.
1816
+ //
1817
+ // Day 98 F1 — the slug captured by DEPLOY_TITLE_RE is the project name
1818
+ // (e.g. "vantage-memory") because http.ts builds titles from
1819
+ // `mapping.project`. Production githubRepoMapping rows are keyed by
1820
+ // full path (`repo: "vantageos-agency/vantage-peers"`), so the prior
1821
+ // withIndex by_repo lookup never matched — `lastDeployedAt` was
1822
+ // effectively unreadable here. Fix: scan + filter by `project` field.
1823
+ // Scan is O(rows) which is fine — there are ≲ 50 mappings fleet-wide.
1824
+ if (args.prMergedAt !== undefined) {
1825
+ const allMappings = await ctx.db.query("githubRepoMapping").collect();
1826
+ // Bug 5 tiebreaker: among all rows sharing the same project, pick the one
1827
+ // with lastDeployedAt > 0 (most-recent wins). Fallback: newest _creationTime.
1828
+ const projectMappings = allMappings.filter((m) => m.project === repo);
1829
+ const withDeploy = projectMappings.filter(
1830
+ (m) => m.lastDeployedAt !== undefined && m.lastDeployedAt > 0,
1831
+ );
1832
+ const mapping =
1833
+ withDeploy.length > 0
1834
+ ? withDeploy.reduce((a, b) =>
1835
+ (a.lastDeployedAt ?? 0) >= (b.lastDeployedAt ?? 0) ? a : b,
1836
+ )
1837
+ : projectMappings.length > 0
1838
+ ? projectMappings.reduce((a, b) =>
1839
+ a._creationTime >= b._creationTime ? a : b,
1840
+ )
1841
+ : null;
1842
+ if (
1843
+ mapping &&
1844
+ mapping.lastDeployedAt !== undefined &&
1845
+ mapping.lastDeployedAt > args.prMergedAt
1846
+ ) {
1847
+ return null;
1848
+ }
1849
+ }
1850
+
1851
+ // ── Fix 1: pre-create dedup ───────────────────────────────────────────
1852
+ // Scan open tasks with "by_status" index for statuses that are not done,
1853
+ // then filter in memory for matching (repo, prNumber) in title.
1854
+ // We check the four open statuses to keep the query bounded.
1855
+ const OPEN_STATUSES = ["todo", "in_progress", "review", "blocked"] as const;
1856
+
1857
+ const existing: Doc<"tasks">[] = [];
1858
+ for (const status of OPEN_STATUSES) {
1859
+ const batch = await ctx.db
1860
+ .query("tasks")
1861
+ .withIndex("by_status", (q) => q.eq("status", status))
1862
+ .collect();
1863
+ for (const t of batch) {
1864
+ const p = parseDeployTitle(t.title);
1865
+ if (p && p.prNumber === prNumber && p.repo === repo) {
1866
+ existing.push(t);
1867
+ }
1868
+ }
1869
+ }
1870
+
1871
+ if (existing.length > 0) {
1872
+ // At least one open deploy task for the same (repo, prNumber) exists.
1873
+ // Skip creating a duplicate — return the most-recently-created one.
1874
+ const newest = existing.reduce((a, b) =>
1875
+ a.createdAt > b.createdAt ? a : b,
1876
+ );
1877
+ return newest._id;
1878
+ }
1879
+
1880
+ // ── Create the new deploy task ────────────────────────────────────────
1881
+ const now = Date.now();
1882
+ // Strip Day 98 arg (not a task column).
1883
+ const { prMergedAt: _ignoredMergedAt, ...taskArgs } = args;
1884
+ const newId = await ctx.db.insert("tasks", {
1885
+ ...taskArgs,
1886
+ status: "todo" as const,
1887
+ createdAt: now,
1888
+ updatedAt: now,
1889
+ });
1890
+
1891
+ // ── Fix 3: post-create supersede ─────────────────────────────────────
1892
+ // Find any open deploy tasks for (repo, prNumber) created before newId.
1893
+ // (There should be none due to Fix 1, but defend against race conditions.)
1894
+ const toSupersede: Doc<"tasks">[] = [];
1895
+ for (const status of OPEN_STATUSES) {
1896
+ const batch = await ctx.db
1897
+ .query("tasks")
1898
+ .withIndex("by_status", (q) => q.eq("status", status))
1899
+ .collect();
1900
+ for (const t of batch) {
1901
+ if (t._id === newId) continue;
1902
+ const p = parseDeployTitle(t.title);
1903
+ if (p && p.prNumber === prNumber && p.repo === repo) {
1904
+ toSupersede.push(t);
1905
+ }
1906
+ }
1907
+ }
1908
+
1909
+ for (const stale of toSupersede) {
1910
+ await ctx.db.patch(stale._id, {
1911
+ status: "done" as const,
1912
+ completedAt: now,
1913
+ updatedAt: now,
1914
+ completionNote: `[SUPERSEDED-BY-k${newId}] ${stale.title}\nfriction_observed: superseded-by-newer-deploy-task`,
1915
+ });
1916
+ }
1917
+
1918
+ return newId;
1919
+ },
1920
+ });
1921
+
1922
+ // ─────────────────────────────────────────────────────────────────────────────
1923
+ // Day 98 (k173yr5n1) Mechanism (c2) — auto-resolver extension for Deploy tasks
1924
+ //
1925
+ // Cron entry: sweeps open `[Deploy] PR #N` tasks. For each, parses the title
1926
+ // to extract (repo, prNumber), then looks up the repo's lastDeployedAt in
1927
+ // githubRepoMapping. If lastDeployedAt > task.createdAt, the PR was shipped
1928
+ // via a bundled deploy after the task was created — close it with an
1929
+ // evidence-bound completionNote citing the deploy SHA + timestamp.
1930
+ //
1931
+ // Pair with Mechanism (a): (a) prevents NEW per-PR Deploy tasks from
1932
+ // spawning when a deploy already covered the PR. (c2) catches the residual
1933
+ // ones already created before the orchestrator called recordDeployment.
1934
+ //
1935
+ // Bounded by OPEN_STATUSES + same status-index pattern as Fix 1/3 dedup.
1936
+ // ─────────────────────────────────────────────────────────────────────────────
1937
+ export const resolveStaleDeployTasks = internalMutation({
1938
+ args: {},
1939
+ returns: v.object({
1940
+ scanned: v.number(),
1941
+ closed: v.number(),
1942
+ skipped: v.number(),
1943
+ }),
1944
+ handler: async (ctx) => {
1945
+ const OPEN_STATUSES = ["todo", "in_progress", "review", "blocked"] as const;
1946
+ let scanned = 0;
1947
+ let closed = 0;
1948
+ let skipped = 0;
1949
+
1950
+ // Cache repoMapping lookups within a single cron tick.
1951
+ const repoCache = new Map<
1952
+ string,
1953
+ { lastDeployedAt: number | undefined; lastDeployedSHA: string | undefined } | null
1954
+ >();
1955
+
1956
+ // Day 98 F1 — fleet-wide mapping snapshot indexed by project. Same key-
1957
+ // mismatch root cause as (a): DEPLOY_TITLE_RE captures project slug, but
1958
+ // githubRepoMapping rows key on full repo path. Single snapshot per tick
1959
+ // is O(N) where N is mapping count (≲ 50 fleet-wide); per-task lookup
1960
+ // becomes a Map.get.
1961
+ const allMappings = await ctx.db.query("githubRepoMapping").collect();
1962
+ // Bug 5 tiebreaker: group all rows by project, then pick the best one per project.
1963
+ // Preference: row with lastDeployedAt > 0 (most-recent wins); fallback: newest _creationTime.
1964
+ const projectGroups = new Map<string, (typeof allMappings)[number][]>();
1965
+ for (const m of allMappings) {
1966
+ const group = projectGroups.get(m.project);
1967
+ if (group) {
1968
+ group.push(m);
1969
+ } else {
1970
+ projectGroups.set(m.project, [m]);
1971
+ }
1972
+ }
1973
+ const mappingsByProject = new Map<string, (typeof allMappings)[number]>();
1974
+ for (const [project, group] of projectGroups) {
1975
+ const withDeploy = group.filter(
1976
+ (m) => m.lastDeployedAt !== undefined && m.lastDeployedAt > 0,
1977
+ );
1978
+ const winner =
1979
+ withDeploy.length > 0
1980
+ ? withDeploy.reduce((a, b) =>
1981
+ (a.lastDeployedAt ?? 0) >= (b.lastDeployedAt ?? 0) ? a : b,
1982
+ )
1983
+ : group.reduce((a, b) =>
1984
+ a._creationTime >= b._creationTime ? a : b,
1985
+ );
1986
+ mappingsByProject.set(project, winner);
1987
+ }
1988
+
1989
+ for (const status of OPEN_STATUSES) {
1990
+ const batch = await ctx.db
1991
+ .query("tasks")
1992
+ .withIndex("by_status", (q) => q.eq("status", status))
1993
+ .collect();
1994
+ for (const t of batch) {
1995
+ const parsed = parseDeployTitle(t.title);
1996
+ if (!parsed) continue;
1997
+ scanned++;
1998
+
1999
+ let mapping = repoCache.get(parsed.repo);
2000
+ if (mapping === undefined) {
2001
+ const row = mappingsByProject.get(parsed.repo) ?? null;
2002
+ mapping = row
2003
+ ? {
2004
+ lastDeployedAt: row.lastDeployedAt,
2005
+ lastDeployedSHA: row.lastDeployedSHA,
2006
+ }
2007
+ : null;
2008
+ repoCache.set(parsed.repo, mapping);
2009
+ }
2010
+
2011
+ if (
2012
+ !mapping ||
2013
+ mapping.lastDeployedAt === undefined ||
2014
+ mapping.lastDeployedAt <= t.createdAt
2015
+ ) {
2016
+ skipped++;
2017
+ continue;
2018
+ }
2019
+
2020
+ const sha = mapping.lastDeployedSHA ?? "unknown-sha";
2021
+ const at = new Date(mapping.lastDeployedAt).toISOString();
2022
+ const now = Date.now();
2023
+ await ctx.db.patch(t._id, {
2024
+ status: "done" as const,
2025
+ completedAt: now,
2026
+ updatedAt: now,
2027
+ completionNote: `Auto-resolved by Day 98 Mechanism (c2) — repo ${parsed.repo} deployed at ${sha} on ${at} (after task createdAt ${new Date(t.createdAt).toISOString()}). PR #${parsed.prNumber} shipped via bundled deploy chain.\nfriction_observed: per-PR Deploy task accumulated before Mechanism (a) was live — cron sweep closes residue.`,
2028
+ });
2029
+ closed++;
2030
+ }
2031
+ }
2032
+
2033
+ console.log(
2034
+ `[Mechanism c2] resolveStaleDeployTasks scanned=${scanned} closed=${closed} skipped=${skipped}`,
2035
+ );
2036
+ return { scanned, closed, skipped };
2037
+ },
2038
+ });
2039
+
2040
+ // ─────────────────────────────────────────────────────────────────────────────
2041
+ // bulkComplete — PR-F bulk close matching tasks (cron-spam cleanup)
2042
+ //
2043
+ // Safety: dryRun defaults to true — callers must explicitly pass dryRun=false
2044
+ // to mutate. When dryRun=true returns {count, sampleIds} preview without
2045
+ // touching any task. When dryRun=false, closes all matched tasks and returns
2046
+ // {count, sampleIds, bulkRunId, executedAt}.
2047
+ //
2048
+ // Cron detection signals (same as PR-E excludeAutoGenerated):
2049
+ // 1. createdBy matches /^cron-/i (dash required)
2050
+ // 2. title matches /^\/?check-messages$/i (exact whole-string)
2051
+ //
2052
+ // RBAC: when callerOrchestrator is provided and is not "system", every matched
2053
+ // task must have createdBy === callerOrchestrator OR assignedTo === callerOrchestrator.
2054
+ // If any matched task violates this, throws RBAC_DENIED.
2055
+ // ─────────────────────────────────────────────────────────────────────────────
2056
+
2057
+ /** Day 1 = 2026-03-06 UTC (project epoch). */
2058
+ const PROJECT_EPOCH_MS = Date.UTC(2026, 2, 6); // month is 0-indexed
2059
+
2060
+ function computeDayNumber(nowMs: number): number {
2061
+ return Math.floor((nowMs - PROJECT_EPOCH_MS) / 86_400_000) + 1;
2062
+ }
2063
+
2064
+ function randomHex(bytes: number): string {
2065
+ const chars = "0123456789abcdef";
2066
+ let result = "";
2067
+ for (let i = 0; i < bytes * 2; i++) {
2068
+ result += chars[Math.floor(Math.random() * chars.length)];
2069
+ }
2070
+ return result;
2071
+ }
2072
+
2073
+ function renderTemplate(
2074
+ template: string,
2075
+ vars: { day: number; bulkRunId: string; executedAt: number },
2076
+ ): string {
2077
+ return template
2078
+ .replace(/\{\{day\}\}/g, String(vars.day))
2079
+ .replace(/\{\{bulkRunId\}\}/g, vars.bulkRunId)
2080
+ .replace(/\{\{executedAt\}\}/g, String(vars.executedAt));
2081
+ }
2082
+
2083
+ /** Hard cap to prevent blast radius beyond a realistic cron-spam batch. */
2084
+ const BULK_COMPLETE_HARD_CAP = 500;
2085
+
2086
+ /**
2087
+ * Day 163 (Pi, k171rbm2txe42jxzddyqakbg7n8ch7zr) — bulkComplete's live path
2088
+ * used to REFUSE outright once matched > cap ("Narrow your filter and
2089
+ * retry"), even when both filter fields were already at their narrowest
2090
+ * (autoGeneratedOnly + assignedTo both set). That instruction had no
2091
+ * followable next step. Fixed two ways:
2092
+ * 1. A third filter dimension — `status` — lets a caller narrow the scan
2093
+ * to a single open status instead of all four.
2094
+ * 2. The live path now DRAINS in cap-sized batches instead of throwing:
2095
+ * it closes up to BULK_COMPLETE_HARD_CAP matching tasks and reports
2096
+ * `remaining: true` when more exist. Because the scan is over
2097
+ * non-done statuses, closing this batch removes it from the NEXT
2098
+ * call's candidate set automatically — repeated calls with the same
2099
+ * filter terminate the whole pile without any external purge script.
2100
+ * The count is exact when the scan didn't hit the cap, and explicitly
2101
+ * labelled (`cappedAt` + `remaining: true`) when it did — never a bare
2102
+ * scan-stop sentinel presented as a total.
2103
+ */
2104
+ export const bulkComplete = mutation({
2105
+ args: {
2106
+ filter: v.object({
2107
+ autoGeneratedOnly: v.optional(v.boolean()),
2108
+ assignedTo: v.optional(v.string()),
2109
+ // Day 163 — narrows the scan to a single open status instead of the
2110
+ // full todo/in_progress/review/blocked sweep. Counts as a reductive
2111
+ // predicate on its own (it directly bounds what the index scan sees).
2112
+ status: v.optional(
2113
+ v.union(
2114
+ v.literal("todo"),
2115
+ v.literal("in_progress"),
2116
+ v.literal("review"),
2117
+ v.literal("blocked"),
2118
+ ),
2119
+ ),
2120
+ }),
2121
+ dryRun: v.optional(v.boolean()),
2122
+ completionNoteTemplate: v.optional(v.string()),
2123
+ callerOrchestrator: v.optional(v.string()),
2124
+ },
2125
+ returns: v.object({
2126
+ count: v.number(),
2127
+ sampleIds: v.array(v.id("tasks")),
2128
+ bulkRunId: v.string(),
2129
+ executedAt: v.optional(v.number()),
2130
+ cappedAt: v.optional(v.number()),
2131
+ // Day 163 — true when the scan stopped at the cap and more matching
2132
+ // (non-done) rows may exist beyond it. On the live path this also
2133
+ // means: call again with the same filter to drain the remainder,
2134
+ // because this batch is now "done" and drops out of the next scan.
2135
+ remaining: v.optional(v.boolean()),
2136
+ }),
2137
+ handler: async (ctx, args) => {
2138
+ // Default dryRun to true (safety).
2139
+ const dryRun = args.dryRun !== false;
2140
+
2141
+ // Must-fix #3: dryRun=false requires callerOrchestrator.
2142
+ if (!dryRun && !args.callerOrchestrator) {
2143
+ throw new ConvexError(
2144
+ "BULK_CALLER_REQUIRED: callerOrchestrator must be provided for live (dryRun=false) bulk operations.",
2145
+ );
2146
+ }
2147
+
2148
+ // Must-fix #1: require at least one reductive predicate before scanning.
2149
+ const hasAutoGeneratedOnly = args.filter.autoGeneratedOnly === true;
2150
+ const hasAssignedTo =
2151
+ args.filter.assignedTo !== undefined && args.filter.assignedTo !== "";
2152
+ const hasStatus = args.filter.status !== undefined;
2153
+ if (!hasAutoGeneratedOnly && !hasAssignedTo && !hasStatus) {
2154
+ throw new ConvexError(
2155
+ "BULK_FILTER_TOO_BROAD: at least one reductive predicate required (autoGeneratedOnly, assignedTo, or status).",
2156
+ );
2157
+ }
2158
+
2159
+ // Iterate non-done tasks via index with early-stop at cap+1.
2160
+ // The +1 allows dry-run to accurately report "more than cap" without
2161
+ // scanning the entire table. `status` narrows the outer loop itself
2162
+ // when supplied, instead of always sweeping all four open statuses.
2163
+ const matched: Doc<"tasks">[] = [];
2164
+ const statuses = hasStatus
2165
+ ? ([args.filter.status as NonNullable<typeof args.filter.status>] as const)
2166
+ : (["todo", "in_progress", "review", "blocked"] as const);
2167
+
2168
+ outer: for (const status of statuses) {
2169
+ const cursor = ctx.db
2170
+ .query("tasks")
2171
+ .withIndex("by_status", (q) => q.eq("status", status));
2172
+ for await (const task of cursor) {
2173
+ const cronMatch =
2174
+ hasAutoGeneratedOnly &&
2175
+ (/^cron-/i.test(task.createdBy ?? "") ||
2176
+ /^\/?check-messages$/i.test(task.title ?? ""));
2177
+ const assignedMatch =
2178
+ hasAssignedTo && task.assignedTo === args.filter.assignedTo;
2179
+
2180
+ let include: boolean;
2181
+ if (hasAutoGeneratedOnly && hasAssignedTo) {
2182
+ include = cronMatch && assignedMatch;
2183
+ } else if (hasAutoGeneratedOnly) {
2184
+ include = cronMatch;
2185
+ } else if (hasAssignedTo) {
2186
+ include = assignedMatch;
2187
+ } else {
2188
+ // status-only filter: every row in this status matches.
2189
+ include = true;
2190
+ }
2191
+
2192
+ if (include) {
2193
+ matched.push(task);
2194
+ // Collect cap+1 to detect overflow without scanning entire table.
2195
+ if (matched.length > BULK_COMPLETE_HARD_CAP) {
2196
+ break outer;
2197
+ }
2198
+ }
2199
+ }
2200
+ }
2201
+
2202
+ const exceeded = matched.length > BULK_COMPLETE_HARD_CAP;
2203
+ // Truncate to cap (the +1 overflow sentinel is not included in results).
2204
+ // Day 163 — the live path no longer throws BULK_HARD_CAP_EXCEEDED here;
2205
+ // it drains this batch and reports `remaining: true` instead (see the
2206
+ // doc comment above the mutation for why repeated calls terminate).
2207
+ const cappedResults = matched.slice(0, BULK_COMPLETE_HARD_CAP);
2208
+
2209
+ // RBAC check: when callerOrchestrator is provided and is not "system",
2210
+ // every matched task must have createdBy or assignedTo equal to caller.
2211
+ // NOT the same class as the update/complete/start/deleteTask bug: this
2212
+ // `!== undefined` is only reachable on the READ-ONLY dryRun preview path
2213
+ // (any write requires dryRun=false, and BULK_CALLER_REQUIRED above
2214
+ // already makes callerOrchestrator mandatory before a write can happen —
2215
+ // omission never bypasses a mutation here, class sweep 2026-07-23).
2216
+ if (args.callerOrchestrator !== undefined && args.callerOrchestrator !== "system") {
2217
+ const caller = args.callerOrchestrator;
2218
+ const denied = cappedResults.find(
2219
+ (r) => r.createdBy !== caller && r.assignedTo !== caller,
2220
+ );
2221
+ if (denied !== undefined) {
2222
+ throw new ConvexError(
2223
+ `RBAC_DENIED: ${caller} is not creator or assignee of task ${denied._id} — bulk close denied`,
2224
+ );
2225
+ }
2226
+ }
2227
+
2228
+ const count = cappedResults.length;
2229
+ const sampleIds = cappedResults.slice(0, 10).map((r) => r._id);
2230
+
2231
+ const now = Date.now();
2232
+ const bulkRunId = `bulk-${now}-${randomHex(4)}`;
2233
+
2234
+ if (dryRun) {
2235
+ return {
2236
+ count,
2237
+ sampleIds,
2238
+ bulkRunId,
2239
+ executedAt: undefined as number | undefined,
2240
+ ...(exceeded
2241
+ ? { cappedAt: BULK_COMPLETE_HARD_CAP, remaining: true }
2242
+ : {}),
2243
+ };
2244
+ }
2245
+
2246
+ // dryRun=false — mutate.
2247
+ const executedAt = now;
2248
+ const day = computeDayNumber(now);
2249
+ const template =
2250
+ args.completionNoteTemplate ??
2251
+ "bulk-cleanup: cron-spam day {{day}} runId={{bulkRunId}} executedAt={{executedAt}}";
2252
+ const note = renderTemplate(template, { day, bulkRunId, executedAt });
2253
+
2254
+ // Day 130 closure gate — bulkComplete is the SECOND closure path
2255
+ // (tasks.complete is the first). Gate every matched task the same
2256
+ // way, up front, so a single billable-but-never-started task in the
2257
+ // batch aborts the whole bulk operation loudly rather than silently
2258
+ // closing it with a false actualMinutes.
2259
+ const gateResults = await Promise.all(
2260
+ cappedResults.map((task) => enforceClosureGate(ctx, task, note, now)),
2261
+ );
2262
+
2263
+ for (let i = 0; i < cappedResults.length; i++) {
2264
+ const task = cappedResults[i];
2265
+ const { actualMinutes } = gateResults[i];
2266
+ await ctx.db.patch(task._id, {
2267
+ status: "done" as const,
2268
+ completedAt: now,
2269
+ updatedAt: now,
2270
+ completionNote: note,
2271
+ ...(actualMinutes !== undefined ? { actualMinutes } : {}),
2272
+ });
2273
+ }
2274
+
2275
+ return {
2276
+ count,
2277
+ sampleIds,
2278
+ bulkRunId,
2279
+ executedAt,
2280
+ ...(exceeded
2281
+ ? { cappedAt: BULK_COMPLETE_HARD_CAP, remaining: true }
2282
+ : {}),
2283
+ };
2284
+ },
2285
+ });
2286
+
2287
+ // ─────────────────────────────────────────────────────────────────────────────
2288
+ // billingSummaryByProject — Day 130 (k17dhcmzqafve1ayzvh833kf558ae019)
2289
+ // deliverable #6: refacturation base. Sums `actualMinutes` (machine-derived
2290
+ // startedAt→completedAt, never a hand-typed line) grouped by `project` for
2291
+ // tasks completed within [startDate, endDate].
2292
+ //
2293
+ // Day-131 live-defect fix: the scan is now bounded BY THE INDEX on
2294
+ // [status, completedAt] (or [status, project, completedAt] when a project
2295
+ // filter is supplied), so the requested period bounds the QUERY itself —
2296
+ // not a post-hoc in-memory filter applied after an unrelated fixed-size scan
2297
+ // of the oldest rows. `truncated: true` now means exactly what it says: the
2298
+ // PERIOD (optionally + project) itself produced more rows than the cap, not
2299
+ // "the table has more done tasks somewhere else than the cap allows".
2300
+ // ─────────────────────────────────────────────────────────────────────────────
2301
+
2302
+ const BILLING_SUMMARY_SCAN_CAP = 5000;
2303
+
2304
+ export const billingSummaryByProject = query({
2305
+ args: {
2306
+ startDate: v.number(), // Unix ms, inclusive
2307
+ endDate: v.number(), // Unix ms, inclusive
2308
+ // Optional — when supplied, pushed into the index-backed query itself
2309
+ // (by_status_project_completedAt), never applied as a post-hoc filter
2310
+ // over an unfiltered scan (that would reproduce the same "bound applied
2311
+ // after the fetch" defect this handler was fixed for).
2312
+ project: v.optional(v.string()),
2313
+ },
2314
+ returns: v.object({
2315
+ byProject: v.array(
2316
+ v.object({
2317
+ project: v.string(),
2318
+ totalMinutes: v.number(),
2319
+ taskCount: v.number(),
2320
+ }),
2321
+ ),
2322
+ unattributedTaskCount: v.number(), // done tasks in range with no project or no actualMinutes
2323
+ // Rows excluded because actualMinutes < 0 — an impossible value (e.g.
2324
+ // completedAt earlier than startedAt, or a bad write). Never silently
2325
+ // summed and never clamped to zero — surfaced here so the caller sees
2326
+ // "N rows were unusable" instead of a quietly wrong (or negative) total.
2327
+ invalidDurationTaskCount: v.number(),
2328
+ truncated: v.boolean(),
2329
+ }),
2330
+ handler: async (ctx, args) => {
2331
+ if (args.endDate < args.startDate) {
2332
+ throw new ConvexError(
2333
+ `INVALID_RANGE: endDate (${args.endDate}) must be >= startDate (${args.startDate})`,
2334
+ );
2335
+ }
2336
+
2337
+ const scope = await withOrgScope(ctx);
2338
+ requireScope(scope, "view-own-tasks");
2339
+
2340
+ const project = args.project;
2341
+
2342
+ // Index-bounded scan: the period (and project, when supplied) bounds
2343
+ // the QUERY, not an in-memory filter applied after the fetch.
2344
+ const doneTasks =
2345
+ project !== undefined
2346
+ ? await ctx.db
2347
+ .query("tasks")
2348
+ .withIndex("by_status_project_completedAt", (q) =>
2349
+ q
2350
+ .eq("status", "done")
2351
+ .eq("project", project)
2352
+ .gte("completedAt", args.startDate)
2353
+ .lte("completedAt", args.endDate),
2354
+ )
2355
+ .take(BILLING_SUMMARY_SCAN_CAP + 1)
2356
+ : await ctx.db
2357
+ .query("tasks")
2358
+ .withIndex("by_status_completedAt", (q) =>
2359
+ q
2360
+ .eq("status", "done")
2361
+ .gte("completedAt", args.startDate)
2362
+ .lte("completedAt", args.endDate),
2363
+ )
2364
+ .take(BILLING_SUMMARY_SCAN_CAP + 1);
2365
+
2366
+ const truncated = doneTasks.length > BILLING_SUMMARY_SCAN_CAP;
2367
+ const capped = doneTasks.slice(0, BILLING_SUMMARY_SCAN_CAP);
2368
+ const scoped = filterByOrgScope(capped, scope);
2369
+
2370
+ const totals = new Map<string, { totalMinutes: number; taskCount: number }>();
2371
+ let unattributedTaskCount = 0;
2372
+ let invalidDurationTaskCount = 0;
2373
+
2374
+ for (const task of scoped) {
2375
+ if (task.project === undefined || task.actualMinutes === undefined) {
2376
+ unattributedTaskCount++;
2377
+ continue;
2378
+ }
2379
+ if (task.actualMinutes < 0) {
2380
+ invalidDurationTaskCount++;
2381
+ continue;
2382
+ }
2383
+ const existing = totals.get(task.project) ?? {
2384
+ totalMinutes: 0,
2385
+ taskCount: 0,
2386
+ };
2387
+ existing.totalMinutes += task.actualMinutes;
2388
+ existing.taskCount += 1;
2389
+ totals.set(task.project, existing);
2390
+ }
2391
+
2392
+ const byProject = Array.from(totals.entries())
2393
+ .map(([proj, agg]) => ({ project: proj, ...agg }))
2394
+ .sort((a, b) => b.totalMinutes - a.totalMinutes);
2395
+
2396
+ return { byProject, unattributedTaskCount, invalidDurationTaskCount, truncated };
2397
+ },
2398
+ });
2399
+
2400
+ // ─────────────────────────────────────────────────────────────────────────────
2401
+ // taskDurationDistribution — feat/duration-distribution-instrument.
2402
+ // billingSummaryByProject excludes NEGATIVE actualMinutes (a sign check) but
2403
+ // has no view on aberrant POSITIVE values: a single task can carry 74904 or
2404
+ // 64734 minutes (weeks of wall-clock) straight into an invoice. This query
2405
+ // measures the distribution so an aberration threshold can be DERIVED from
2406
+ // real data — it corrects nothing, it never mutates, never estimates.
2407
+ //
2408
+ // Same index-bounded-scan discipline as billingSummaryByProject: the period
2409
+ // (and project, when supplied) bounds the QUERY via by_status_completedAt /
2410
+ // by_status_project_completedAt — never a post-hoc filter over an unrelated
2411
+ // fixed-size scan.
2412
+ // ─────────────────────────────────────────────────────────────────────────────
2413
+
2414
+ const DURATION_DISTRIBUTION_SCAN_CAP = 5000;
2415
+
2416
+ // count === 0 must never render as "a flat distribution" (all-zero
2417
+ // percentiles read exactly like "every task took 0 minutes", which is a
2418
+ // false measurement, not an absence of one). This sentinel is the explicit
2419
+ // "could not measure" value — the caller must branch on `count === 0` rather
2420
+ // than read the percentiles as data.
2421
+ const NO_DATA_SENTINEL = -1;
2422
+
2423
+ function percentile(sorted: number[], p: number): number {
2424
+ if (sorted.length === 0) return NO_DATA_SENTINEL;
2425
+ if (sorted.length === 1) return sorted[0];
2426
+ const rank = (p / 100) * (sorted.length - 1);
2427
+ const lower = Math.floor(rank);
2428
+ const upper = Math.ceil(rank);
2429
+ if (lower === upper) return sorted[lower];
2430
+ const weight = rank - lower;
2431
+ return sorted[lower] + (sorted[upper] - sorted[lower]) * weight;
2432
+ }
2433
+
2434
+ export const taskDurationDistribution = query({
2435
+ args: {
2436
+ from: v.optional(v.number()), // Unix ms, inclusive
2437
+ to: v.optional(v.number()), // Unix ms, inclusive
2438
+ project: v.optional(v.string()),
2439
+ },
2440
+ returns: v.object({
2441
+ count: v.number(),
2442
+ percentiles: v.object({
2443
+ p50: v.number(),
2444
+ p75: v.number(),
2445
+ p90: v.number(),
2446
+ p95: v.number(),
2447
+ p99: v.number(),
2448
+ max: v.number(),
2449
+ }),
2450
+ negativeCount: v.number(),
2451
+ withProjectCount: v.number(),
2452
+ withoutProjectCount: v.number(),
2453
+ truncated: v.boolean(),
2454
+ }),
2455
+ handler: async (ctx, args) => {
2456
+ if (args.from !== undefined && args.to !== undefined && args.to < args.from) {
2457
+ throw new ConvexError(
2458
+ `INVALID_RANGE: to (${args.to}) must be >= from (${args.from})`,
2459
+ );
2460
+ }
2461
+
2462
+ const scope = await withOrgScope(ctx);
2463
+ requireScope(scope, "view-own-tasks");
2464
+
2465
+ const project = args.project;
2466
+ const from = args.from ?? -Infinity;
2467
+ const to = args.to ?? Infinity;
2468
+
2469
+ // Index-bounded scan: the period (and project, when supplied) bounds
2470
+ // the QUERY, not an in-memory filter applied after the fetch.
2471
+ const doneTasks =
2472
+ project !== undefined
2473
+ ? await ctx.db
2474
+ .query("tasks")
2475
+ .withIndex("by_status_project_completedAt", (q) =>
2476
+ q.eq("status", "done").eq("project", project).gte("completedAt", from).lte("completedAt", to),
2477
+ )
2478
+ .take(DURATION_DISTRIBUTION_SCAN_CAP + 1)
2479
+ : await ctx.db
2480
+ .query("tasks")
2481
+ .withIndex("by_status_completedAt", (q) =>
2482
+ q.eq("status", "done").gte("completedAt", from).lte("completedAt", to),
2483
+ )
2484
+ .take(DURATION_DISTRIBUTION_SCAN_CAP + 1);
2485
+
2486
+ const truncated = doneTasks.length > DURATION_DISTRIBUTION_SCAN_CAP;
2487
+ const capped = doneTasks.slice(0, DURATION_DISTRIBUTION_SCAN_CAP);
2488
+ const scoped = filterByOrgScope(capped, scope);
2489
+
2490
+ let negativeCount = 0;
2491
+ let withProjectCount = 0;
2492
+ let withoutProjectCount = 0;
2493
+ const durations: number[] = [];
2494
+
2495
+ for (const task of scoped) {
2496
+ if (task.actualMinutes === undefined) continue;
2497
+ if (task.actualMinutes < 0) {
2498
+ negativeCount++;
2499
+ continue;
2500
+ }
2501
+ if (task.project === undefined) {
2502
+ withoutProjectCount++;
2503
+ } else {
2504
+ withProjectCount++;
2505
+ }
2506
+ durations.push(task.actualMinutes);
2507
+ }
2508
+
2509
+ durations.sort((a, b) => a - b);
2510
+ const count = durations.length;
2511
+
2512
+ // count === 0: no positive-duration rows measured in the period. The
2513
+ // sentinel makes this explicit rather than silently reporting zeros
2514
+ // that would read as "every task took 0 minutes" — a false measurement
2515
+ // distinct from "we could not measure".
2516
+ const percentiles =
2517
+ count === 0
2518
+ ? {
2519
+ p50: NO_DATA_SENTINEL,
2520
+ p75: NO_DATA_SENTINEL,
2521
+ p90: NO_DATA_SENTINEL,
2522
+ p95: NO_DATA_SENTINEL,
2523
+ p99: NO_DATA_SENTINEL,
2524
+ max: NO_DATA_SENTINEL,
2525
+ }
2526
+ : {
2527
+ p50: percentile(durations, 50),
2528
+ p75: percentile(durations, 75),
2529
+ p90: percentile(durations, 90),
2530
+ p95: percentile(durations, 95),
2531
+ p99: percentile(durations, 99),
2532
+ max: durations[durations.length - 1],
2533
+ };
2534
+
2535
+ return {
2536
+ count,
2537
+ percentiles,
2538
+ negativeCount,
2539
+ withProjectCount,
2540
+ withoutProjectCount,
2541
+ truncated,
2542
+ };
2543
+ },
2544
+ });
2545
+
2546
+ // ─────────────────────────────────────────────────────────────────────────────
2547
+ // Day 102 v2.11.0 — CRUD baseline PR-C-bis option B (mission k575kc1r).
2548
+ // BM25 keyword search over task titles via Convex native .searchIndex().
2549
+ //
2550
+ // Backed by the `search_title` searchIndex declared in schema.ts. Filter fields
2551
+ // (assignedTo, status, project, missionId) are pushed into the index so the
2552
+ // scoped + filtered query stays sub-linear at fleet scale (~2 - 30k tasks).
2553
+
2554
+ export const searchTasksByKeyword = query({
2555
+ args: {
2556
+ query: v.string(),
2557
+ assignedTo: v.optional(assigneeValidator),
2558
+ status: v.optional(
2559
+ v.union(
2560
+ v.literal("todo"),
2561
+ v.literal("in_progress"),
2562
+ v.literal("review"),
2563
+ v.literal("blocked"),
2564
+ v.literal("done"),
2565
+ ),
2566
+ ),
2567
+ project: v.optional(v.string()),
2568
+ missionId: v.optional(v.id("missions")),
2569
+ limit: v.optional(v.number()),
2570
+ fields: v.optional(v.union(v.literal("lite"), v.literal("full"))),
2571
+ },
2572
+ handler: async (ctx, args) => {
2573
+ const scope = await withOrgScope(ctx);
2574
+ requireScope(scope, "view-own-tasks");
2575
+
2576
+ const limit = Math.min(Math.max(args.limit ?? 20, 1), 200);
2577
+ const lite = args.fields === "lite";
2578
+
2579
+ const results = await ctx.db
2580
+ .query("tasks")
2581
+ .withSearchIndex("search_title", (q) => {
2582
+ let qb = q.search("title", args.query);
2583
+ if (args.assignedTo !== undefined) {
2584
+ qb = qb.eq("assignedTo", args.assignedTo);
2585
+ }
2586
+ if (args.status !== undefined) {
2587
+ qb = qb.eq("status", args.status);
2588
+ }
2589
+ if (args.project !== undefined) {
2590
+ qb = qb.eq("project", args.project);
2591
+ }
2592
+ if (args.missionId !== undefined) {
2593
+ qb = qb.eq("missionId", args.missionId);
2594
+ }
2595
+ if (!scope.isMaster && scope.orgSlug !== null) {
2596
+ qb = qb.eq("orgId", scope.orgSlug);
2597
+ }
2598
+ return qb;
2599
+ })
2600
+ .take(limit);
2601
+
2602
+ const filtered = filterByOrgScope(results, scope);
2603
+
2604
+ if (!lite) return filtered;
2605
+ return filtered.map((t) => ({
2606
+ _id: t._id,
2607
+ title: t.title,
2608
+ status: t.status,
2609
+ priority: t.priority,
2610
+ assignedTo: t.assignedTo,
2611
+ missionId: t.missionId,
2612
+ }));
2613
+ },
2614
+ });
2615
+
2616
+ // ─────────────────────────────────────────────────────────────────────────────
2617
+ // REVIEW TASK LIFECYCLE — Day 127 (repo /root/coding/vantage-memory)
2618
+ //
2619
+ // Measured bug: on a real Eta queue of 28 "[Review]" todo tasks, ~20 were dead
2620
+ // (their PR already MERGED, task never closed) and 6 were strict duplicates
2621
+ // (PR #1073 x4 — 1 opened + 3 pushes; #1075/#1076/#1078/#1071/#250 x2 each).
2622
+ //
2623
+ // Title pattern created by convex/http.ts on pull_request opened/synchronize:
2624
+ // "[Review] <repoFullName> PR #<prNumber>: <prTitle>"
2625
+ //
2626
+ // This mirrors the createDeployTaskWithDedup mechanism (Fix 1 pre-create
2627
+ // dedup) but with a DIFFERENT resolution on repeat events: rather than
2628
+ // superseding (create-new + mark-old-done), a repeat synchronize UPDATES the
2629
+ // existing open review task in place (new title/description/tags) — a review
2630
+ // task represents "please review the current state of PR #N", not a series
2631
+ // of independent events, so there is nothing to supersede, only to refresh.
2632
+ // ─────────────────────────────────────────────────────────────────────────────
2633
+
2634
+ // repoFullName may contain "/" (e.g. "org/repo"); prTitle may contain
2635
+ // arbitrary text including ":" — greedy `.+` naturally backtracks to the
2636
+ // rightmost " PR #<digits>: " split point, which matches how the title was
2637
+ // built (repoFullName is always the first token group, with no user-supplied
2638
+ // wildcards ahead of " PR #").
2639
+ const REVIEW_TITLE_RE = /^\[Review\] (.+) PR #(\d+): ([\s\S]*)$/;
2640
+
2641
+ /**
2642
+ * Parse a "[Review] <repoFullName> PR #<prNumber>: <prTitle>" task title.
2643
+ * Returns null if the title does not match the expected pattern.
2644
+ */
2645
+ function parseReviewTitle(
2646
+ title: string,
2647
+ ): { repoFullName: string; prNumber: number; prTitle: string } | null {
2648
+ const m = REVIEW_TITLE_RE.exec(title);
2649
+ if (!m) return null;
2650
+ return { repoFullName: m[1], prNumber: parseInt(m[2], 10), prTitle: m[3] };
2651
+ }
2652
+
2653
+ const REVIEW_OPEN_STATUSES = ["todo", "in_progress", "review", "blocked"] as const;
2654
+
2655
+ /**
2656
+ * Find all currently-open "[Review]" tasks matching a (repoFullName,
2657
+ * prNumber) tuple. Scans the by_status index per open status (bounded to 4
2658
+ * scans), then filters in memory by parsing the title — same pattern as
2659
+ * createDeployTaskWithDedup's Fix 1/Fix 3 scans.
2660
+ */
2661
+ async function findOpenReviewTasks(
2662
+ ctx: MutationCtx,
2663
+ repoFullName: string,
2664
+ prNumber: number,
2665
+ ): Promise<Doc<"tasks">[]> {
2666
+ const matches: Doc<"tasks">[] = [];
2667
+ for (const status of REVIEW_OPEN_STATUSES) {
2668
+ const batch = await ctx.db
2669
+ .query("tasks")
2670
+ .withIndex("by_status", (q) => q.eq("status", status))
2671
+ .collect();
2672
+ for (const t of batch) {
2673
+ const p = parseReviewTitle(t.title);
2674
+ if (p && p.repoFullName === repoFullName && p.prNumber === prNumber) {
2675
+ matches.push(t);
2676
+ }
2677
+ }
2678
+ }
2679
+ return matches;
2680
+ }
2681
+
2682
+ /**
2683
+ * createOrUpdateReviewTask — dedup key is (repoFullName, prNumber), NOT
2684
+ * prNumber alone (fixes cross-repo collisions on shared PR numbers).
2685
+ *
2686
+ * - No open review task for this tuple -> insert a new one.
2687
+ * - An open review task already exists -> UPDATE it in place (new title,
2688
+ * description, tags, updatedAt) instead of creating a duplicate. This is
2689
+ * what makes repeated `synchronize` events on the same PR collapse to a
2690
+ * single row.
2691
+ */
2692
+ export const createOrUpdateReviewTask = internalMutation({
2693
+ args: {
2694
+ repoFullName: v.string(),
2695
+ prNumber: v.number(),
2696
+ prTitle: v.string(),
2697
+ description: v.optional(v.string()),
2698
+ assignedTo: assigneeValidator,
2699
+ project: v.optional(v.string()),
2700
+ priority: priorityValidator,
2701
+ createdBy: creatorValidator,
2702
+ tags: v.optional(v.array(v.string())),
2703
+ },
2704
+ returns: v.id("tasks"),
2705
+ handler: async (ctx, args) => {
2706
+ const title = `[Review] ${args.repoFullName} PR #${args.prNumber}: ${args.prTitle}`;
2707
+ const now = Date.now();
2708
+
2709
+ const existing = await findOpenReviewTasks(
2710
+ ctx,
2711
+ args.repoFullName,
2712
+ args.prNumber,
2713
+ );
2714
+
2715
+ if (existing.length > 0) {
2716
+ // Update the most-recently-created open review task in place.
2717
+ const target = existing.reduce((a, b) =>
2718
+ a.createdAt > b.createdAt ? a : b,
2719
+ );
2720
+ await ctx.db.patch(target._id, {
2721
+ title,
2722
+ description: args.description,
2723
+ tags: args.tags,
2724
+ priority: args.priority,
2725
+ updatedAt: now,
2726
+ });
2727
+ return target._id;
2728
+ }
2729
+
2730
+ return await ctx.db.insert("tasks", {
2731
+ title,
2732
+ description: args.description,
2733
+ project: args.project,
2734
+ assignedTo: args.assignedTo,
2735
+ priority: args.priority,
2736
+ status: "todo" as const,
2737
+ createdBy: args.createdBy,
2738
+ tags: args.tags,
2739
+ createdAt: now,
2740
+ updatedAt: now,
2741
+ // Day 130 follow-up #2 — the inforgeable automation signal. This
2742
+ // internalMutation is the ONLY code path that writes `origin`;
2743
+ // it is unreachable from the public MCP surface (webhook-only).
2744
+ origin: "automation" as const,
2745
+ });
2746
+ },
2747
+ });
2748
+
2749
+ /**
2750
+ * closeReviewTasksForPr — closes every OPEN "[Review]" task matching
2751
+ * (repoFullName, prNumber). Called from convex/http.ts on `pull_request`
2752
+ * `closed`, REGARDLESS of whether the PR was merged: once the PR is closed,
2753
+ * there is nothing left to review either way (merged -> covered by the
2754
+ * separate Deploy-task flow; closed-without-merge -> review is moot).
2755
+ */
2756
+ export const closeReviewTasksForPr = internalMutation({
2757
+ args: {
2758
+ repoFullName: v.string(),
2759
+ prNumber: v.number(),
2760
+ completionNote: v.string(),
2761
+ },
2762
+ returns: v.object({ closed: v.number() }),
2763
+ handler: async (ctx, args) => {
2764
+ const now = Date.now();
2765
+ const matches = await findOpenReviewTasks(
2766
+ ctx,
2767
+ args.repoFullName,
2768
+ args.prNumber,
2769
+ );
2770
+
2771
+ for (const t of matches) {
2772
+ await ctx.db.patch(t._id, {
2773
+ status: "done" as const,
2774
+ completedAt: now,
2775
+ updatedAt: now,
2776
+ completionNote: args.completionNote,
2777
+ });
2778
+ }
2779
+
2780
+ return { closed: matches.length };
2781
+ },
2782
+ });