@yattalo/task-system 0.3.6 → 0.5.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 +48 -0
- package/dashboard-app/assets/spa-entry-CnIKatv4.js +24 -0
- package/dashboard-app/assets/styles-CAIFwsCh.css +1 -0
- package/dashboard-app/index.html +14 -0
- package/dist/commands/agent.d.ts +2 -0
- package/dist/commands/agent.d.ts.map +1 -0
- package/dist/commands/agent.js +97 -0
- package/dist/commands/agent.js.map +1 -0
- package/dist/commands/context.d.ts +2 -0
- package/dist/commands/context.d.ts.map +1 -0
- package/dist/commands/context.js +151 -0
- package/dist/commands/context.js.map +1 -0
- package/dist/commands/dashboard.d.ts +2 -0
- package/dist/commands/dashboard.d.ts.map +1 -1
- package/dist/commands/dashboard.js +133 -6
- package/dist/commands/dashboard.js.map +1 -1
- package/dist/commands/day.d.ts +9 -0
- package/dist/commands/day.d.ts.map +1 -0
- package/dist/commands/day.js +98 -0
- package/dist/commands/day.js.map +1 -0
- package/dist/commands/drift.d.ts +2 -0
- package/dist/commands/drift.d.ts.map +1 -0
- package/dist/commands/drift.js +190 -0
- package/dist/commands/drift.js.map +1 -0
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +35 -1
- package/dist/commands/init.js.map +1 -1
- package/dist/generators/mgrep-setup.d.ts +6 -0
- package/dist/generators/mgrep-setup.d.ts.map +1 -0
- package/dist/generators/mgrep-setup.js +191 -0
- package/dist/generators/mgrep-setup.js.map +1 -0
- package/dist/generators/mgrep-skill.d.ts +6 -0
- package/dist/generators/mgrep-skill.d.ts.map +1 -0
- package/dist/generators/mgrep-skill.js +173 -0
- package/dist/generators/mgrep-skill.js.map +1 -0
- package/dist/generators/uca-functions.d.ts +8 -0
- package/dist/generators/uca-functions.d.ts.map +1 -0
- package/dist/generators/uca-functions.js +57 -0
- package/dist/generators/uca-functions.js.map +1 -0
- package/dist/generators/uca-reexports.d.ts +8 -0
- package/dist/generators/uca-reexports.d.ts.map +1 -0
- package/dist/generators/uca-reexports.js +112 -0
- package/dist/generators/uca-reexports.js.map +1 -0
- package/dist/generators/uca-schema.d.ts +8 -0
- package/dist/generators/uca-schema.d.ts.map +1 -0
- package/dist/generators/uca-schema.js +650 -0
- package/dist/generators/uca-schema.js.map +1 -0
- package/dist/index.js +19 -1
- package/dist/index.js.map +1 -1
- package/dist/presets/research.d.ts.map +1 -1
- package/dist/presets/research.js +10 -0
- package/dist/presets/research.js.map +1 -1
- package/dist/presets/software.d.ts.map +1 -1
- package/dist/presets/software.js +10 -0
- package/dist/presets/software.js.map +1 -1
- package/dist/utils/convex-run.d.ts +10 -0
- package/dist/utils/convex-run.d.ts.map +1 -0
- package/dist/utils/convex-run.js +34 -0
- package/dist/utils/convex-run.js.map +1 -0
- package/dist/utils/detect.d.ts.map +1 -1
- package/dist/utils/detect.js +15 -0
- package/dist/utils/detect.js.map +1 -1
- package/dist/utils/merge.d.ts.map +1 -1
- package/dist/utils/merge.js +2 -0
- package/dist/utils/merge.js.map +1 -1
- package/package.json +6 -4
- package/templates/uca/agents.ts +59 -0
- package/templates/uca/contextEntries.ts +125 -0
- package/templates/uca/cronManager.ts +255 -0
- package/templates/uca/cronUtils.ts +99 -0
- package/templates/uca/driftEvents.ts +106 -0
- package/templates/uca/heartbeats.ts +167 -0
- package/templates/uca/hooks.ts +430 -0
- package/templates/uca/memory.ts +326 -0
- package/templates/uca/sessionBridge.ts +238 -0
- package/templates/uca/skills.ts +284 -0
- package/templates/uca/ucaTasks.ts +500 -0
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { internalMutation, mutation, query } from "../_generated/server";
|
|
2
|
+
import { v } from "convex/values";
|
|
3
|
+
import { computeNextCronRun, parseCronExpression } from "./cronUtils";
|
|
4
|
+
|
|
5
|
+
const laneValidator = v.union(v.literal("main"), v.literal("cron"), v.literal("subagent"));
|
|
6
|
+
|
|
7
|
+
const priorityValidator = v.union(
|
|
8
|
+
v.literal("critical"),
|
|
9
|
+
v.literal("high"),
|
|
10
|
+
v.literal("medium"),
|
|
11
|
+
v.literal("low"),
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
function makeId(prefix: string): string {
|
|
15
|
+
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const listSchedules = query({
|
|
19
|
+
args: {
|
|
20
|
+
enabled: v.optional(v.boolean()),
|
|
21
|
+
lane: v.optional(laneValidator),
|
|
22
|
+
},
|
|
23
|
+
handler: async (ctx, args) => {
|
|
24
|
+
let schedules = await ctx.db.query("cronSchedules").collect();
|
|
25
|
+
if (args.enabled !== undefined) {
|
|
26
|
+
schedules = schedules.filter((schedule) => schedule.enabled === args.enabled);
|
|
27
|
+
}
|
|
28
|
+
if (args.lane) schedules = schedules.filter((schedule) => schedule.lane === args.lane);
|
|
29
|
+
return schedules.sort((a, b) => (a.nextRunAt ?? Number.MAX_SAFE_INTEGER) - (b.nextRunAt ?? Number.MAX_SAFE_INTEGER));
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
export const createSchedule = mutation({
|
|
34
|
+
args: {
|
|
35
|
+
scheduleId: v.optional(v.string()),
|
|
36
|
+
name: v.string(),
|
|
37
|
+
jobId: v.optional(v.string()),
|
|
38
|
+
lane: laneValidator,
|
|
39
|
+
cronExpression: v.string(),
|
|
40
|
+
timezone: v.optional(v.string()),
|
|
41
|
+
createdBy: v.string(),
|
|
42
|
+
enabled: v.optional(v.boolean()),
|
|
43
|
+
},
|
|
44
|
+
handler: async (ctx, args) => {
|
|
45
|
+
parseCronExpression(args.cronExpression);
|
|
46
|
+
|
|
47
|
+
const now = Date.now();
|
|
48
|
+
const scheduleId = args.scheduleId ?? makeId("cron");
|
|
49
|
+
const nextRunAt = computeNextCronRun(args.cronExpression, now);
|
|
50
|
+
|
|
51
|
+
await ctx.db.insert("cronSchedules", {
|
|
52
|
+
scheduleId,
|
|
53
|
+
name: args.name,
|
|
54
|
+
jobId: args.jobId,
|
|
55
|
+
lane: args.lane,
|
|
56
|
+
cronExpression: args.cronExpression,
|
|
57
|
+
timezone: args.timezone,
|
|
58
|
+
enabled: args.enabled ?? true,
|
|
59
|
+
lastRunAt: undefined,
|
|
60
|
+
nextRunAt,
|
|
61
|
+
createdBy: args.createdBy,
|
|
62
|
+
createdAt: now,
|
|
63
|
+
updatedAt: now,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
return { scheduleId, nextRunAt };
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
export const updateSchedule = mutation({
|
|
71
|
+
args: {
|
|
72
|
+
scheduleId: v.string(),
|
|
73
|
+
name: v.optional(v.string()),
|
|
74
|
+
cronExpression: v.optional(v.string()),
|
|
75
|
+
timezone: v.optional(v.string()),
|
|
76
|
+
enabled: v.optional(v.boolean()),
|
|
77
|
+
lane: v.optional(laneValidator),
|
|
78
|
+
jobId: v.optional(v.string()),
|
|
79
|
+
},
|
|
80
|
+
handler: async (ctx, args) => {
|
|
81
|
+
const schedule = await ctx.db
|
|
82
|
+
.query("cronSchedules")
|
|
83
|
+
.withIndex("by_scheduleId", (q: any) => q.eq("scheduleId", args.scheduleId))
|
|
84
|
+
.first();
|
|
85
|
+
if (!schedule) throw new Error(`Schedule ${args.scheduleId} not found`);
|
|
86
|
+
|
|
87
|
+
const patch: Record<string, unknown> = {
|
|
88
|
+
updatedAt: Date.now(),
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
if (args.name !== undefined) patch.name = args.name;
|
|
92
|
+
if (args.cronExpression !== undefined) {
|
|
93
|
+
parseCronExpression(args.cronExpression);
|
|
94
|
+
patch.cronExpression = args.cronExpression;
|
|
95
|
+
patch.nextRunAt = computeNextCronRun(args.cronExpression, Date.now());
|
|
96
|
+
}
|
|
97
|
+
if (args.timezone !== undefined) patch.timezone = args.timezone;
|
|
98
|
+
if (args.enabled !== undefined) patch.enabled = args.enabled;
|
|
99
|
+
if (args.lane !== undefined) patch.lane = args.lane;
|
|
100
|
+
if (args.jobId !== undefined) patch.jobId = args.jobId;
|
|
101
|
+
|
|
102
|
+
await ctx.db.patch(schedule._id, patch);
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
export const deleteSchedule = mutation({
|
|
107
|
+
args: { scheduleId: v.string() },
|
|
108
|
+
handler: async (ctx, { scheduleId }) => {
|
|
109
|
+
const schedule = await ctx.db
|
|
110
|
+
.query("cronSchedules")
|
|
111
|
+
.withIndex("by_scheduleId", (q: any) => q.eq("scheduleId", scheduleId))
|
|
112
|
+
.first();
|
|
113
|
+
if (!schedule) return;
|
|
114
|
+
await ctx.db.delete(schedule._id);
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
export const enqueue = mutation({
|
|
119
|
+
args: {
|
|
120
|
+
lane: laneValidator,
|
|
121
|
+
taskId: v.optional(v.string()),
|
|
122
|
+
jobId: v.optional(v.string()),
|
|
123
|
+
runId: v.optional(v.string()),
|
|
124
|
+
agent: v.optional(v.string()),
|
|
125
|
+
payload: v.optional(v.any()),
|
|
126
|
+
priority: v.optional(priorityValidator),
|
|
127
|
+
availableAt: v.optional(v.number()),
|
|
128
|
+
},
|
|
129
|
+
handler: async (ctx, args) => {
|
|
130
|
+
const now = Date.now();
|
|
131
|
+
const queueId = makeId("queue");
|
|
132
|
+
await ctx.db.insert("executionQueue", {
|
|
133
|
+
queueId,
|
|
134
|
+
lane: args.lane,
|
|
135
|
+
status: "queued",
|
|
136
|
+
taskId: args.taskId,
|
|
137
|
+
jobId: args.jobId,
|
|
138
|
+
runId: args.runId,
|
|
139
|
+
agent: args.agent,
|
|
140
|
+
payload: args.payload,
|
|
141
|
+
priority: args.priority ?? "medium",
|
|
142
|
+
attempts: 0,
|
|
143
|
+
availableAt: args.availableAt ?? now,
|
|
144
|
+
createdAt: now,
|
|
145
|
+
updatedAt: now,
|
|
146
|
+
});
|
|
147
|
+
return { queueId };
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
export const completeQueueItem = mutation({
|
|
152
|
+
args: {
|
|
153
|
+
queueId: v.string(),
|
|
154
|
+
status: v.union(v.literal("done"), v.literal("failed"), v.literal("cancelled")),
|
|
155
|
+
error: v.optional(v.string()),
|
|
156
|
+
},
|
|
157
|
+
handler: async (ctx, { queueId, status, error }) => {
|
|
158
|
+
const item = await ctx.db
|
|
159
|
+
.query("executionQueue")
|
|
160
|
+
.withIndex("by_queueId", (q: any) => q.eq("queueId", queueId))
|
|
161
|
+
.first();
|
|
162
|
+
if (!item) throw new Error(`Queue item ${queueId} not found`);
|
|
163
|
+
|
|
164
|
+
await ctx.db.patch(item._id, {
|
|
165
|
+
status,
|
|
166
|
+
completedAt: Date.now(),
|
|
167
|
+
updatedAt: Date.now(),
|
|
168
|
+
payload: {
|
|
169
|
+
...(item.payload ?? {}),
|
|
170
|
+
error,
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
export const internalDispatchDueSchedules = internalMutation({
|
|
177
|
+
args: {},
|
|
178
|
+
handler: async (ctx) => {
|
|
179
|
+
const now = Date.now();
|
|
180
|
+
const schedules = await ctx.db.query("cronSchedules").collect();
|
|
181
|
+
const due = schedules.filter(
|
|
182
|
+
(schedule) => schedule.enabled && typeof schedule.nextRunAt === "number" && schedule.nextRunAt <= now,
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
const dispatched: Array<{ scheduleId: string; queueId: string; runId?: string }> = [];
|
|
186
|
+
|
|
187
|
+
for (const schedule of due) {
|
|
188
|
+
let runId: string | undefined;
|
|
189
|
+
let taskId: string | undefined;
|
|
190
|
+
let agent: string | undefined;
|
|
191
|
+
|
|
192
|
+
if (schedule.jobId) {
|
|
193
|
+
const job = await ctx.db
|
|
194
|
+
.query("agentOpsJobs")
|
|
195
|
+
.withIndex("by_jobId", (q: any) => q.eq("jobId", schedule.jobId))
|
|
196
|
+
.first();
|
|
197
|
+
|
|
198
|
+
if (job && job.enabled && job.status === "active") {
|
|
199
|
+
runId = makeId(`run-${job.jobId}`);
|
|
200
|
+
taskId = job.taskId;
|
|
201
|
+
agent = job.agent;
|
|
202
|
+
await ctx.db.insert("agentOpsRuns", {
|
|
203
|
+
runId,
|
|
204
|
+
jobId: job.jobId,
|
|
205
|
+
taskId: job.taskId,
|
|
206
|
+
agent: job.agent,
|
|
207
|
+
lane: job.lane,
|
|
208
|
+
status: "queued",
|
|
209
|
+
triggeredBy: "schedule",
|
|
210
|
+
queuedAt: now,
|
|
211
|
+
logs: [{ timestamp: now, level: "info", message: `Queued from schedule ${schedule.scheduleId}` }],
|
|
212
|
+
metadata: { scheduleId: schedule.scheduleId },
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
await ctx.db.patch(job._id, {
|
|
216
|
+
totalRuns: job.totalRuns + 1,
|
|
217
|
+
updatedAt: now,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const queueId = makeId("queue");
|
|
223
|
+
await ctx.db.insert("executionQueue", {
|
|
224
|
+
queueId,
|
|
225
|
+
lane: schedule.lane,
|
|
226
|
+
status: "queued",
|
|
227
|
+
taskId,
|
|
228
|
+
jobId: schedule.jobId,
|
|
229
|
+
runId,
|
|
230
|
+
agent,
|
|
231
|
+
payload: { scheduleId: schedule.scheduleId, source: "internalDispatchDueSchedules" },
|
|
232
|
+
priority: "medium",
|
|
233
|
+
attempts: 0,
|
|
234
|
+
availableAt: now,
|
|
235
|
+
createdAt: now,
|
|
236
|
+
updatedAt: now,
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
const nextRunAt = computeNextCronRun(schedule.cronExpression, now);
|
|
240
|
+
await ctx.db.patch(schedule._id, {
|
|
241
|
+
lastRunAt: now,
|
|
242
|
+
nextRunAt,
|
|
243
|
+
updatedAt: now,
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
dispatched.push({ scheduleId: schedule.scheduleId, queueId, runId });
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return {
|
|
250
|
+
scanned: schedules.length,
|
|
251
|
+
due: due.length,
|
|
252
|
+
dispatched,
|
|
253
|
+
};
|
|
254
|
+
},
|
|
255
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
export type ParsedCron = {
|
|
2
|
+
minute: number[];
|
|
3
|
+
hour: number[];
|
|
4
|
+
day: number[];
|
|
5
|
+
month: number[];
|
|
6
|
+
weekday: number[];
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
const FIELD_LIMITS: Array<[number, number]> = [
|
|
10
|
+
[0, 59],
|
|
11
|
+
[0, 23],
|
|
12
|
+
[1, 31],
|
|
13
|
+
[1, 12],
|
|
14
|
+
[0, 6],
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
function expandRange(part: string, min: number, max: number): number[] {
|
|
18
|
+
if (part === "*") {
|
|
19
|
+
const values: number[] = [];
|
|
20
|
+
for (let value = min; value <= max; value += 1) values.push(value);
|
|
21
|
+
return values;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const stepParts = part.split("/");
|
|
25
|
+
const base = stepParts[0];
|
|
26
|
+
const step = stepParts[1] ? Number(stepParts[1]) : 1;
|
|
27
|
+
if (!Number.isFinite(step) || step <= 0) throw new Error(`Invalid cron step: ${part}`);
|
|
28
|
+
|
|
29
|
+
const [startRaw, endRaw] = base.includes("-") ? base.split("-") : [base, base];
|
|
30
|
+
const start = startRaw === "*" ? min : Number(startRaw);
|
|
31
|
+
const end = endRaw === "*" ? max : Number(endRaw);
|
|
32
|
+
|
|
33
|
+
if (!Number.isFinite(start) || !Number.isFinite(end)) {
|
|
34
|
+
throw new Error(`Invalid cron range: ${part}`);
|
|
35
|
+
}
|
|
36
|
+
if (start < min || end > max || start > end) {
|
|
37
|
+
throw new Error(`Cron range out of bounds: ${part}`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const values: number[] = [];
|
|
41
|
+
for (let value = start; value <= end; value += step) values.push(value);
|
|
42
|
+
return values;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function parseField(field: string, min: number, max: number): number[] {
|
|
46
|
+
const values = new Set<number>();
|
|
47
|
+
for (const part of field.split(",")) {
|
|
48
|
+
const trimmed = part.trim();
|
|
49
|
+
if (!trimmed) continue;
|
|
50
|
+
for (const value of expandRange(trimmed, min, max)) {
|
|
51
|
+
values.add(value);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (values.size === 0) {
|
|
55
|
+
throw new Error(`Invalid cron field: ${field}`);
|
|
56
|
+
}
|
|
57
|
+
return [...values].sort((a, b) => a - b);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function parseCronExpression(expression: string): ParsedCron {
|
|
61
|
+
const parts = expression.trim().split(/\s+/);
|
|
62
|
+
if (parts.length !== 5) {
|
|
63
|
+
throw new Error("Cron expression must have 5 fields: minute hour day month weekday");
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
minute: parseField(parts[0]!, ...FIELD_LIMITS[0]),
|
|
67
|
+
hour: parseField(parts[1]!, ...FIELD_LIMITS[1]),
|
|
68
|
+
day: parseField(parts[2]!, ...FIELD_LIMITS[2]),
|
|
69
|
+
month: parseField(parts[3]!, ...FIELD_LIMITS[3]),
|
|
70
|
+
weekday: parseField(parts[4]!, ...FIELD_LIMITS[4]),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function isMatch(parsed: ParsedCron, date: Date): boolean {
|
|
75
|
+
return (
|
|
76
|
+
parsed.minute.includes(date.getMinutes()) &&
|
|
77
|
+
parsed.hour.includes(date.getHours()) &&
|
|
78
|
+
parsed.day.includes(date.getDate()) &&
|
|
79
|
+
parsed.month.includes(date.getMonth() + 1) &&
|
|
80
|
+
parsed.weekday.includes(date.getDay())
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function computeNextCronRun(expression: string, fromTimestamp: number): number {
|
|
85
|
+
const parsed = parseCronExpression(expression);
|
|
86
|
+
const candidate = new Date(fromTimestamp);
|
|
87
|
+
candidate.setSeconds(0, 0);
|
|
88
|
+
candidate.setMinutes(candidate.getMinutes() + 1);
|
|
89
|
+
|
|
90
|
+
const maxIterations = 60 * 24 * 90;
|
|
91
|
+
for (let i = 0; i < maxIterations; i += 1) {
|
|
92
|
+
if (isMatch(parsed, candidate)) {
|
|
93
|
+
return candidate.getTime();
|
|
94
|
+
}
|
|
95
|
+
candidate.setMinutes(candidate.getMinutes() + 1);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
throw new Error(`Unable to compute next run for cron: ${expression}`);
|
|
99
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { query, mutation } from "../_generated/server";
|
|
2
|
+
import { v } from "convex/values";
|
|
3
|
+
|
|
4
|
+
export const list = query({
|
|
5
|
+
handler: async (ctx) => {
|
|
6
|
+
return await ctx.db.query("driftEvents").collect();
|
|
7
|
+
},
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
export const stats = query({
|
|
11
|
+
handler: async (ctx) => {
|
|
12
|
+
const all = await ctx.db.query("driftEvents").collect();
|
|
13
|
+
const bySeverity: Record<string, number> = {};
|
|
14
|
+
const byStatus: Record<string, number> = {};
|
|
15
|
+
for (const drift of all) {
|
|
16
|
+
bySeverity[drift.severity] = (bySeverity[drift.severity] ?? 0) + 1;
|
|
17
|
+
byStatus[drift.status] = (byStatus[drift.status] ?? 0) + 1;
|
|
18
|
+
}
|
|
19
|
+
const open = all.filter((d) => d.status === "open");
|
|
20
|
+
const critical = all.filter((d) => d.severity === "high" && d.status === "open");
|
|
21
|
+
return {
|
|
22
|
+
total: all.length,
|
|
23
|
+
open: open.length,
|
|
24
|
+
critical: critical.length,
|
|
25
|
+
bySeverity,
|
|
26
|
+
byStatus,
|
|
27
|
+
};
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
export const create = mutation({
|
|
32
|
+
args: {
|
|
33
|
+
eventId: v.string(),
|
|
34
|
+
projectId: v.string(),
|
|
35
|
+
taskId: v.optional(v.string()),
|
|
36
|
+
agent: v.string(),
|
|
37
|
+
driftType: v.string(),
|
|
38
|
+
description: v.string(),
|
|
39
|
+
severity: v.string(),
|
|
40
|
+
},
|
|
41
|
+
handler: async (ctx, args) => {
|
|
42
|
+
return await ctx.db.insert("driftEvents", {
|
|
43
|
+
...args,
|
|
44
|
+
status: "open",
|
|
45
|
+
detectedAt: Date.now(),
|
|
46
|
+
} as any);
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
export const resolve = mutation({
|
|
51
|
+
args: {
|
|
52
|
+
eventId: v.string(),
|
|
53
|
+
resolvedBy: v.string(),
|
|
54
|
+
resolution: v.string(),
|
|
55
|
+
},
|
|
56
|
+
handler: async (ctx, { eventId, resolvedBy, resolution }) => {
|
|
57
|
+
const event = await ctx.db
|
|
58
|
+
.query("driftEvents")
|
|
59
|
+
.filter((q) => q.eq(q.field("eventId"), eventId))
|
|
60
|
+
.first();
|
|
61
|
+
if (!event) throw new Error(`Drift event ${eventId} not found`);
|
|
62
|
+
await ctx.db.patch(event._id, {
|
|
63
|
+
status: "resolved",
|
|
64
|
+
resolvedBy,
|
|
65
|
+
resolvedAt: Date.now(),
|
|
66
|
+
resolution,
|
|
67
|
+
} as any);
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
export const dismiss = mutation({
|
|
72
|
+
args: {
|
|
73
|
+
eventId: v.string(),
|
|
74
|
+
dismissedBy: v.string(),
|
|
75
|
+
},
|
|
76
|
+
handler: async (ctx, { eventId, dismissedBy }) => {
|
|
77
|
+
const event = await ctx.db
|
|
78
|
+
.query("driftEvents")
|
|
79
|
+
.filter((q) => q.eq(q.field("eventId"), eventId))
|
|
80
|
+
.first();
|
|
81
|
+
if (!event) throw new Error(`Drift event ${eventId} not found`);
|
|
82
|
+
await ctx.db.patch(event._id, {
|
|
83
|
+
status: "dismissed",
|
|
84
|
+
resolvedBy: dismissedBy,
|
|
85
|
+
resolvedAt: Date.now(),
|
|
86
|
+
} as any);
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
export const acknowledge = mutation({
|
|
91
|
+
args: {
|
|
92
|
+
eventId: v.string(),
|
|
93
|
+
acknowledgedBy: v.string(),
|
|
94
|
+
},
|
|
95
|
+
handler: async (ctx, { eventId, acknowledgedBy }) => {
|
|
96
|
+
const event = await ctx.db
|
|
97
|
+
.query("driftEvents")
|
|
98
|
+
.filter((q) => q.eq(q.field("eventId"), eventId))
|
|
99
|
+
.first();
|
|
100
|
+
if (!event) throw new Error(`Drift event ${eventId} not found`);
|
|
101
|
+
await ctx.db.patch(event._id, {
|
|
102
|
+
status: "acknowledged",
|
|
103
|
+
resolvedBy: acknowledgedBy,
|
|
104
|
+
} as any);
|
|
105
|
+
},
|
|
106
|
+
});
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { internalMutation, mutation, query } from "../_generated/server";
|
|
2
|
+
import { v } from "convex/values";
|
|
3
|
+
|
|
4
|
+
const DEFAULT_TTL_SECONDS = 300;
|
|
5
|
+
const YELLOW_WINDOW_MS = 60_000;
|
|
6
|
+
|
|
7
|
+
function classifyHeartbeat(now: number, expiresAt: number): "green" | "yellow" | "red" {
|
|
8
|
+
if (expiresAt <= now) return "red";
|
|
9
|
+
if (expiresAt - now <= YELLOW_WINDOW_MS) return "yellow";
|
|
10
|
+
return "green";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function mapHeartbeatToAgentStatus(status: "green" | "yellow" | "red"): "active" | "idle" | "offline" {
|
|
14
|
+
if (status === "green") return "active";
|
|
15
|
+
if (status === "yellow") return "idle";
|
|
16
|
+
return "offline";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function syncRegistryStatus(ctx: any, agentId: string, heartbeatStatus: "green" | "yellow" | "red"): Promise<void> {
|
|
20
|
+
const agent = await ctx.db
|
|
21
|
+
.query("agentRegistry")
|
|
22
|
+
.withIndex("by_agentId", (q: any) => q.eq("agentId", agentId))
|
|
23
|
+
.first();
|
|
24
|
+
if (!agent) return;
|
|
25
|
+
const currentStatus = mapHeartbeatToAgentStatus(heartbeatStatus);
|
|
26
|
+
if (agent.currentStatus === currentStatus) return;
|
|
27
|
+
await ctx.db.patch(agent._id, { currentStatus });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const beat = mutation({
|
|
31
|
+
args: {
|
|
32
|
+
agentId: v.string(),
|
|
33
|
+
sessionId: v.optional(v.string()),
|
|
34
|
+
ttlSeconds: v.optional(v.number()),
|
|
35
|
+
latencyMs: v.optional(v.number()),
|
|
36
|
+
details: v.optional(v.string()),
|
|
37
|
+
},
|
|
38
|
+
handler: async (ctx, args) => {
|
|
39
|
+
const now = Date.now();
|
|
40
|
+
const ttlMs = (args.ttlSeconds ?? DEFAULT_TTL_SECONDS) * 1000;
|
|
41
|
+
const expiresAt = now + ttlMs;
|
|
42
|
+
const status = classifyHeartbeat(now, expiresAt);
|
|
43
|
+
|
|
44
|
+
const existing = await ctx.db
|
|
45
|
+
.query("agentHeartbeats")
|
|
46
|
+
.withIndex("by_agentId", (q: any) => q.eq("agentId", args.agentId))
|
|
47
|
+
.first();
|
|
48
|
+
|
|
49
|
+
if (existing) {
|
|
50
|
+
await ctx.db.patch(existing._id, {
|
|
51
|
+
sessionId: args.sessionId,
|
|
52
|
+
protocol: "HEARTBEAT_OK",
|
|
53
|
+
status,
|
|
54
|
+
heartbeatAt: now,
|
|
55
|
+
lastSeenAt: now,
|
|
56
|
+
expiresAt,
|
|
57
|
+
latencyMs: args.latencyMs,
|
|
58
|
+
details: args.details,
|
|
59
|
+
updatedAt: now,
|
|
60
|
+
});
|
|
61
|
+
} else {
|
|
62
|
+
await ctx.db.insert("agentHeartbeats", {
|
|
63
|
+
agentId: args.agentId,
|
|
64
|
+
sessionId: args.sessionId,
|
|
65
|
+
protocol: "HEARTBEAT_OK",
|
|
66
|
+
status,
|
|
67
|
+
heartbeatAt: now,
|
|
68
|
+
lastSeenAt: now,
|
|
69
|
+
expiresAt,
|
|
70
|
+
latencyMs: args.latencyMs,
|
|
71
|
+
details: args.details,
|
|
72
|
+
updatedAt: now,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
await syncRegistryStatus(ctx, args.agentId, status);
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
agentId: args.agentId,
|
|
80
|
+
status,
|
|
81
|
+
heartbeatAt: now,
|
|
82
|
+
expiresAt,
|
|
83
|
+
protocol: "HEARTBEAT_OK",
|
|
84
|
+
};
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
export const getAgentHeartbeat = query({
|
|
89
|
+
args: { agentId: v.string() },
|
|
90
|
+
handler: async (ctx, { agentId }) => {
|
|
91
|
+
const record = await ctx.db
|
|
92
|
+
.query("agentHeartbeats")
|
|
93
|
+
.withIndex("by_agentId", (q: any) => q.eq("agentId", agentId))
|
|
94
|
+
.first();
|
|
95
|
+
if (!record) return null;
|
|
96
|
+
|
|
97
|
+
const now = Date.now();
|
|
98
|
+
const computed = classifyHeartbeat(now, record.expiresAt);
|
|
99
|
+
return {
|
|
100
|
+
...record,
|
|
101
|
+
computedStatus: computed,
|
|
102
|
+
msUntilExpiry: Math.max(0, record.expiresAt - now),
|
|
103
|
+
};
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
export const listHeartbeats = query({
|
|
108
|
+
args: {},
|
|
109
|
+
handler: async (ctx) => {
|
|
110
|
+
const now = Date.now();
|
|
111
|
+
const heartbeats = await ctx.db.query("agentHeartbeats").collect();
|
|
112
|
+
return heartbeats
|
|
113
|
+
.map((record) => ({
|
|
114
|
+
...record,
|
|
115
|
+
computedStatus: classifyHeartbeat(now, record.expiresAt),
|
|
116
|
+
msUntilExpiry: Math.max(0, record.expiresAt - now),
|
|
117
|
+
}))
|
|
118
|
+
.sort((a, b) => b.lastSeenAt - a.lastSeenAt);
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
export const statusSummary = query({
|
|
123
|
+
args: {},
|
|
124
|
+
handler: async (ctx) => {
|
|
125
|
+
const now = Date.now();
|
|
126
|
+
const all = await ctx.db.query("agentHeartbeats").collect();
|
|
127
|
+
const byStatus: Record<string, number> = { green: 0, yellow: 0, red: 0 };
|
|
128
|
+
for (const heartbeat of all) {
|
|
129
|
+
const status = classifyHeartbeat(now, heartbeat.expiresAt);
|
|
130
|
+
byStatus[status] += 1;
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
total: all.length,
|
|
134
|
+
byStatus,
|
|
135
|
+
healthy: byStatus.green,
|
|
136
|
+
degraded: byStatus.yellow,
|
|
137
|
+
offline: byStatus.red,
|
|
138
|
+
};
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
export const internalSweepStaleHeartbeats = internalMutation({
|
|
143
|
+
args: {},
|
|
144
|
+
handler: async (ctx) => {
|
|
145
|
+
const now = Date.now();
|
|
146
|
+
const all = await ctx.db.query("agentHeartbeats").collect();
|
|
147
|
+
const updates: Array<{ agentId: string; from: string; to: string }> = [];
|
|
148
|
+
|
|
149
|
+
for (const heartbeat of all) {
|
|
150
|
+
const nextStatus = classifyHeartbeat(now, heartbeat.expiresAt);
|
|
151
|
+
if (heartbeat.status !== nextStatus) {
|
|
152
|
+
await ctx.db.patch(heartbeat._id, {
|
|
153
|
+
status: nextStatus,
|
|
154
|
+
updatedAt: now,
|
|
155
|
+
});
|
|
156
|
+
updates.push({ agentId: heartbeat.agentId, from: heartbeat.status, to: nextStatus });
|
|
157
|
+
}
|
|
158
|
+
await syncRegistryStatus(ctx, heartbeat.agentId, nextStatus);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
scanned: all.length,
|
|
163
|
+
updated: updates.length,
|
|
164
|
+
updates,
|
|
165
|
+
};
|
|
166
|
+
},
|
|
167
|
+
});
|