@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.
- package/README.md +88 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +94 -0
- package/dist/contract.d.ts +18 -0
- package/dist/contract.js +29 -0
- package/dist/coverage-cli.d.ts +2 -0
- package/dist/coverage-cli.js +17 -0
- package/dist/coverage.d.ts +32 -0
- package/dist/coverage.js +77 -0
- package/dist/detectors/oracle-axis1.d.ts +86 -0
- package/dist/detectors/oracle-axis1.js +221 -0
- package/dist/detectors/oracle-axis2.d.ts +22 -0
- package/dist/detectors/oracle-axis2.js +149 -0
- package/dist/detectors/predicates.d.ts +19 -0
- package/dist/detectors/predicates.js +334 -0
- package/dist/detectors/shared.d.ts +56 -0
- package/dist/detectors/shared.js +103 -0
- package/dist/detectors/source/_scan.d.ts +43 -0
- package/dist/detectors/source/_scan.js +82 -0
- package/dist/detectors/source/backfill.d.ts +27 -0
- package/dist/detectors/source/backfill.js +211 -0
- package/dist/detectors/source/contract.d.ts +33 -0
- package/dist/detectors/source/contract.js +385 -0
- package/dist/detectors/source/identity.d.ts +47 -0
- package/dist/detectors/source/identity.js +410 -0
- package/dist/detectors/source/jobs.d.ts +10 -0
- package/dist/detectors/source/jobs.js +209 -0
- package/dist/detectors/source/lifecycle.d.ts +6 -0
- package/dist/detectors/source/lifecycle.js +147 -0
- package/dist/detectors/source/readbudget.d.ts +6 -0
- package/dist/detectors/source/readbudget.js +133 -0
- package/dist/detectors/source/refusal.d.ts +6 -0
- package/dist/detectors/source/refusal.js +125 -0
- package/dist/detectors/source/security.d.ts +37 -0
- package/dist/detectors/source/security.js +392 -0
- package/dist/detectors/source/writesafety.d.ts +14 -0
- package/dist/detectors/source/writesafety.js +345 -0
- package/dist/detectors/standard/_doc.d.ts +66 -0
- package/dist/detectors/standard/_doc.js +152 -0
- package/dist/detectors/standard/r41-frontier.d.ts +58 -0
- package/dist/detectors/standard/r41-frontier.js +176 -0
- package/dist/detectors/standard/r46-returns-validator.d.ts +61 -0
- package/dist/detectors/standard/r46-returns-validator.js +474 -0
- package/dist/detectors/standard/standard-doc.d.ts +12 -0
- package/dist/detectors/standard/standard-doc.js +271 -0
- package/dist/detectors/structural.d.ts +14 -0
- package/dist/detectors/structural.js +83 -0
- package/dist/engine-types.d.ts +10 -0
- package/dist/engine-types.js +1 -0
- package/dist/not-checkable.d.ts +22 -0
- package/dist/not-checkable.js +37 -0
- package/dist/oracle.d.ts +33 -0
- package/dist/oracle.js +133 -0
- package/dist/registry.d.ts +11 -0
- package/dist/registry.js +140 -0
- package/dist/report.d.ts +35 -0
- package/dist/report.js +138 -0
- package/dist/severity.d.ts +30 -0
- package/dist/severity.js +121 -0
- package/dist/tally.d.ts +57 -0
- package/dist/tally.js +152 -0
- package/dist/trigger.d.ts +47 -0
- package/dist/trigger.js +143 -0
- package/fixtures/backend-standard-51ec7af1.md +648 -0
- package/fixtures/backend-standard-a9d7877b.md +629 -0
- package/fixtures/mcp-app-standard-a74a042b.md +279 -0
- package/fixtures/mcp-extension-standard-a74a042b.md +259 -0
- package/fixtures/mcp-server-standard-a74a042b.md +434 -0
- package/fixtures/mcp-standard-a74a042b.md +354 -0
- package/fixtures/vp-1200-green/.pin +1 -0
- package/fixtures/vp-1200-green/convex/migrations.ts +350 -0
- package/fixtures/vp-1200-green/convex/schema.ts +1243 -0
- package/fixtures/vp-1200-red/.pin +1 -0
- package/fixtures/vp-1200-red/convex/migrations.ts +239 -0
- package/fixtures/vp-1200-red/convex/schema.ts +1243 -0
- package/fixtures/vp-by-tool-day158.csv +190 -0
- package/fixtures/vp-convex/.github/workflows/npm-publish.yml +163 -0
- package/fixtures/vp-convex/.pin +1 -0
- package/fixtures/vp-convex/bun.lock +783 -0
- package/fixtures/vp-convex/convex/auth.config.ts +8 -0
- package/fixtures/vp-convex/convex/briefingNotes.ts +321 -0
- package/fixtures/vp-convex/convex/convex.config.ts +14 -0
- package/fixtures/vp-convex/convex/credentials.ts +591 -0
- package/fixtures/vp-convex/convex/crons.ts +72 -0
- package/fixtures/vp-convex/convex/dashboard.ts +199 -0
- package/fixtures/vp-convex/convex/gumroadWebhook.ts +815 -0
- package/fixtures/vp-convex/convex/issues.ts +544 -0
- package/fixtures/vp-convex/convex/kb.ts +305 -0
- package/fixtures/vp-convex/convex/kbMutations.ts +192 -0
- package/fixtures/vp-convex/convex/kbShared.ts +56 -0
- package/fixtures/vp-convex/convex/licenses.ts +443 -0
- package/fixtures/vp-convex/convex/memories.ts +406 -0
- package/fixtures/vp-convex/convex/memoriesScoped.ts +220 -0
- package/fixtures/vp-convex/convex/messages.ts +971 -0
- package/fixtures/vp-convex/convex/migrations.ts +239 -0
- package/fixtures/vp-convex/convex/missionTemplates.ts +369 -0
- package/fixtures/vp-convex/convex/oauth.ts +1608 -0
- package/fixtures/vp-convex/convex/oauthMigrations.ts +96 -0
- package/fixtures/vp-convex/convex/okfBundleNode.ts +1031 -0
- package/fixtures/vp-convex/convex/recurringTasks.ts +386 -0
- package/fixtures/vp-convex/convex/schema.ts +1229 -0
- package/fixtures/vp-convex/convex/tasks.ts +2782 -0
- package/fixtures/vp-r31-green/.pin +1 -0
- package/fixtures/vp-r31-green/convex/migrations.ts +350 -0
- package/fixtures/vp-r31-red/.pin +1 -0
- package/fixtures/vp-r31-red/convex/migrations.ts +286 -0
- package/fixtures/vr-by-tool-day159.csv +89 -0
- 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
|
+
});
|