heyio 0.2.0 → 0.3.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/dist/api/server.js +16 -1
- package/dist/copilot/agents.js +304 -10
- package/dist/copilot/cron.js +136 -0
- package/dist/copilot/event-summary.js +286 -0
- package/dist/copilot/orchestrator.js +43 -2
- package/dist/copilot/scheduler.js +155 -0
- package/dist/copilot/system-message.js +52 -15
- package/dist/copilot/tools.js +295 -5
- package/dist/daemon.js +4 -0
- package/dist/store/db.js +25 -0
- package/dist/store/schedules.js +73 -0
- package/dist/store/squads.js +18 -0
- package/dist/store/tasks.js +14 -0
- package/dist/tui/index.js +62 -2
- package/package.json +1 -1
- package/web-dist/assets/index-BWGQix5_.css +1 -0
- package/web-dist/assets/index-BksyB2za.js +74 -0
- package/web-dist/index.html +2 -2
- package/web-dist/assets/index-B6FXWKsy.js +0 -74
- package/web-dist/assets/index-CXTrW8OO.css +0 -1
package/dist/api/server.js
CHANGED
|
@@ -6,6 +6,7 @@ import { config } from "../config.js";
|
|
|
6
6
|
import { listSkills } from "../copilot/skills.js";
|
|
7
7
|
import { listSquads, createSquad, listSquadAgents } from "../store/squads.js";
|
|
8
8
|
import { getAgentInfo, cancelAgentTask, getTaskEvents, subscribeToTaskEvents } from "../copilot/agents.js";
|
|
9
|
+
import { summarize, summarizeEvent } from "../copilot/event-summary.js";
|
|
9
10
|
import { abortOrchestrator } from "../copilot/orchestrator.js";
|
|
10
11
|
import { getActiveTasks, getTask, listRecentTasks } from "../store/tasks.js";
|
|
11
12
|
import { IO_VERSION } from "../paths.js";
|
|
@@ -153,6 +154,18 @@ export async function startApiServer() {
|
|
|
153
154
|
res.status(500).json({ error: "Failed to fetch task" });
|
|
154
155
|
}
|
|
155
156
|
});
|
|
157
|
+
api.get("/tasks/:taskId/activity", (req, res) => {
|
|
158
|
+
const taskId = Array.isArray(req.params.taskId) ? req.params.taskId[0] : req.params.taskId;
|
|
159
|
+
try {
|
|
160
|
+
const events = getTaskEvents(taskId);
|
|
161
|
+
const activity = summarize(events);
|
|
162
|
+
res.json({ taskId, activity });
|
|
163
|
+
}
|
|
164
|
+
catch (e) {
|
|
165
|
+
console.error("Error building task activity:", e);
|
|
166
|
+
res.status(500).json({ error: "Failed to build task activity" });
|
|
167
|
+
}
|
|
168
|
+
});
|
|
156
169
|
api.get("/tasks/:taskId/events", (req, res) => {
|
|
157
170
|
const taskId = Array.isArray(req.params.taskId) ? req.params.taskId[0] : req.params.taskId;
|
|
158
171
|
res.setHeader("Content-Type", "text/event-stream");
|
|
@@ -162,7 +175,9 @@ export async function startApiServer() {
|
|
|
162
175
|
res.flushHeaders();
|
|
163
176
|
const send = (ev) => {
|
|
164
177
|
try {
|
|
165
|
-
|
|
178
|
+
const summary = summarizeEvent(ev);
|
|
179
|
+
const payload = { ...ev, summary };
|
|
180
|
+
res.write(`data: ${JSON.stringify(payload)}\n\n`);
|
|
166
181
|
}
|
|
167
182
|
catch {
|
|
168
183
|
// client likely disconnected; cleanup happens on req.close
|
package/dist/copilot/agents.js
CHANGED
|
@@ -8,8 +8,8 @@ import { defineTool, approveAll } from "@github/copilot-sdk";
|
|
|
8
8
|
import { z } from "zod";
|
|
9
9
|
import { getClient } from "./client.js";
|
|
10
10
|
import { getModelForTask, getModelForTier, classifyComplexity } from "./model-router.js";
|
|
11
|
-
import { getSquad, updateSquadSession, updateSquadStatus, getDecisionsSummary, logDecision, listSquadAgents, getSquadAgent, updateAgentSession, updateAgentStatus, } from "../store/squads.js";
|
|
12
|
-
import { createTask, completeTask, failTask, getActiveTasks, getTask, cancelTask, } from "../store/tasks.js";
|
|
11
|
+
import { getSquad, updateSquadSession, updateSquadStatus, getDecisionsSummary, logDecision, listSquadAgents, getSquadAgent, getSquadLead, updateAgentSession, updateAgentStatus, } from "../store/squads.js";
|
|
12
|
+
import { createTask, completeTask, createReview, failTask, getActiveTasks, getTask, cancelTask, } from "../store/tasks.js";
|
|
13
13
|
import { SESSIONS_DIR } from "../paths.js";
|
|
14
14
|
import { getUniverse } from "./universes.js";
|
|
15
15
|
// Key format: "squadSlug:characterName" for per-agent sessions, "squadSlug" for legacy
|
|
@@ -48,6 +48,7 @@ export function getAgentInfo() {
|
|
|
48
48
|
status: agent?.status === "working" ? "working" : currentTask ? "working" : "idle",
|
|
49
49
|
currentTask,
|
|
50
50
|
currentTaskId,
|
|
51
|
+
model: agentSessionModels.get(key),
|
|
51
52
|
});
|
|
52
53
|
}
|
|
53
54
|
else {
|
|
@@ -60,6 +61,7 @@ export function getAgentInfo() {
|
|
|
60
61
|
status: currentTask ? "working" : squad?.status === "error" ? "error" : "idle",
|
|
61
62
|
currentTask,
|
|
62
63
|
currentTaskId,
|
|
64
|
+
model: agentSessionModels.get(key),
|
|
63
65
|
});
|
|
64
66
|
}
|
|
65
67
|
}
|
|
@@ -116,10 +118,17 @@ export async function delegateToAgent(squadSlug, task, onComplete, targetAgent)
|
|
|
116
118
|
}
|
|
117
119
|
}
|
|
118
120
|
else {
|
|
119
|
-
//
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
121
|
+
// Prefer the designated team lead if one exists; otherwise fall back to
|
|
122
|
+
// the first idle agent (or just the first agent on the roster).
|
|
123
|
+
const lead = getSquadLead(squadSlug);
|
|
124
|
+
if (lead) {
|
|
125
|
+
agent = lead;
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
const agents = listSquadAgents(squadSlug);
|
|
129
|
+
if (agents.length > 0) {
|
|
130
|
+
agent = agents.find((a) => a.status === "idle") ?? agents[0];
|
|
131
|
+
}
|
|
123
132
|
}
|
|
124
133
|
}
|
|
125
134
|
const session = agent
|
|
@@ -159,6 +168,19 @@ export async function delegateToAgent(squadSlug, task, onComplete, targetAgent)
|
|
|
159
168
|
if (agent)
|
|
160
169
|
updateAgentStatus(squadSlug, agent.character_name, "idle");
|
|
161
170
|
recordTaskEvent(taskId, { ts: Date.now(), type: "task.done", data: { result } });
|
|
171
|
+
try {
|
|
172
|
+
await runPeerReview(squadSlug, agent?.character_name ?? "", taskId, task, result);
|
|
173
|
+
}
|
|
174
|
+
catch (reviewErr) {
|
|
175
|
+
console.error("[io] Peer review error:", reviewErr instanceof Error ? reviewErr.message : reviewErr);
|
|
176
|
+
recordTaskEvent(taskId, {
|
|
177
|
+
ts: Date.now(),
|
|
178
|
+
type: "task.review_error",
|
|
179
|
+
data: {
|
|
180
|
+
error: reviewErr instanceof Error ? reviewErr.message : String(reviewErr),
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
}
|
|
162
184
|
onComplete(taskId, result);
|
|
163
185
|
}
|
|
164
186
|
catch (err) {
|
|
@@ -241,7 +263,34 @@ async function getOrCreateAgentSession(squadSlug, agent, taskDescription) {
|
|
|
241
263
|
const universeName = squad.universe
|
|
242
264
|
? getUniverse(squad.universe)?.name ?? squad.universe
|
|
243
265
|
: "Unknown";
|
|
244
|
-
const
|
|
266
|
+
const isLead = agent.is_lead === 1;
|
|
267
|
+
const agentTools = buildAgentTools(squadSlug, isLead);
|
|
268
|
+
let leadSection = "";
|
|
269
|
+
if (isLead) {
|
|
270
|
+
const teammates = listSquadAgents(squadSlug).filter((a) => a.character_name !== agent.character_name);
|
|
271
|
+
const roster = teammates.length > 0
|
|
272
|
+
? teammates
|
|
273
|
+
.map((t) => {
|
|
274
|
+
const charter = t.charter
|
|
275
|
+
? t.charter.length > 200
|
|
276
|
+
? t.charter.slice(0, 200) + "…"
|
|
277
|
+
: t.charter
|
|
278
|
+
: "(no charter)";
|
|
279
|
+
return `- **${t.character_name}** — ${t.role_title}: ${charter}`;
|
|
280
|
+
})
|
|
281
|
+
.join("\n")
|
|
282
|
+
: "_(no other agents on this squad yet — ask IO to add some)_";
|
|
283
|
+
leadSection = `
|
|
284
|
+
|
|
285
|
+
## Team Lead Role
|
|
286
|
+
You are the team lead for this squad. When you receive a task, your job is to:
|
|
287
|
+
1. Break it down into concrete subtasks
|
|
288
|
+
2. Assign each subtask to the most appropriate teammate using the \`delegate_to_teammate\` tool
|
|
289
|
+
3. Collect results and synthesize a final summary
|
|
290
|
+
|
|
291
|
+
## Your Team
|
|
292
|
+
${roster}`;
|
|
293
|
+
}
|
|
245
294
|
const systemMessage = `You are ${agent.character_name}, a specialist agent on the "${squad.name}" project team (${universeName} universe).
|
|
246
295
|
|
|
247
296
|
## Your Identity
|
|
@@ -256,7 +305,7 @@ ${agent.charter ?? "General-purpose agent. Handle tasks as they come."}
|
|
|
256
305
|
- **Path**: ${squad.project_path}
|
|
257
306
|
|
|
258
307
|
## Past Decisions
|
|
259
|
-
${decisions}
|
|
308
|
+
${decisions}${leadSection}
|
|
260
309
|
|
|
261
310
|
## Instructions
|
|
262
311
|
You are a coding agent. Use the shell tool to run commands and file_ops to read/write files.
|
|
@@ -343,7 +392,7 @@ Log important decisions with squad_log_decision so they persist.`,
|
|
|
343
392
|
agentSessions.set(squadSlug, session);
|
|
344
393
|
return session;
|
|
345
394
|
}
|
|
346
|
-
function buildAgentTools(squadSlug) {
|
|
395
|
+
function buildAgentTools(squadSlug, isLead = false) {
|
|
347
396
|
const shell = defineTool("shell", {
|
|
348
397
|
description: "Run a shell command. Use for git, build tools, file operations, etc.",
|
|
349
398
|
skipPermission: true,
|
|
@@ -461,7 +510,50 @@ function buildAgentTools(squadSlug) {
|
|
|
461
510
|
}
|
|
462
511
|
},
|
|
463
512
|
});
|
|
464
|
-
|
|
513
|
+
const tools = [shell, fileOps, squadLogDecision];
|
|
514
|
+
if (isLead) {
|
|
515
|
+
const delegateToTeammate = defineTool("delegate_to_teammate", {
|
|
516
|
+
description: "Delegate a subtask to a teammate on this squad. The teammate runs the task synchronously and returns its result. Use this to divvy work as the team lead.",
|
|
517
|
+
skipPermission: true,
|
|
518
|
+
parameters: z.object({
|
|
519
|
+
teammate: z
|
|
520
|
+
.string()
|
|
521
|
+
.describe("The teammate's character_name (e.g., 'Optimus Prime')"),
|
|
522
|
+
task: z
|
|
523
|
+
.string()
|
|
524
|
+
.describe("The concrete task or subtask the teammate should perform"),
|
|
525
|
+
}),
|
|
526
|
+
handler: async ({ teammate, task }) => {
|
|
527
|
+
try {
|
|
528
|
+
const teammateAgent = getSquadAgent(squadSlug, teammate);
|
|
529
|
+
if (!teammateAgent) {
|
|
530
|
+
return `Error: teammate "${teammate}" not found in squad "${squadSlug}". Use squad_agents to list the roster.`;
|
|
531
|
+
}
|
|
532
|
+
if (teammateAgent.is_lead === 1) {
|
|
533
|
+
return `Error: "${teammate}" is the team lead. Delegate to a non-lead teammate.`;
|
|
534
|
+
}
|
|
535
|
+
updateAgentStatus(squadSlug, teammateAgent.character_name, "working");
|
|
536
|
+
try {
|
|
537
|
+
const session = await getOrCreateAgentSession(squadSlug, teammateAgent, task);
|
|
538
|
+
const response = await session.sendAndWait({ prompt: task }, 300_000);
|
|
539
|
+
const result = response?.data?.content ?? "(teammate returned no output)";
|
|
540
|
+
updateAgentStatus(squadSlug, teammateAgent.character_name, "idle");
|
|
541
|
+
return result;
|
|
542
|
+
}
|
|
543
|
+
catch (err) {
|
|
544
|
+
updateAgentStatus(squadSlug, teammateAgent.character_name, "error");
|
|
545
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
546
|
+
return `Error from teammate "${teammate}": ${message}`;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
catch (err) {
|
|
550
|
+
return `Error: ${err instanceof Error ? err.message : String(err)}`;
|
|
551
|
+
}
|
|
552
|
+
},
|
|
553
|
+
});
|
|
554
|
+
tools.push(delegateToTeammate);
|
|
555
|
+
}
|
|
556
|
+
return tools;
|
|
465
557
|
}
|
|
466
558
|
function walkDirectory(dir, maxDepth = 3, depth = 0) {
|
|
467
559
|
if (depth >= maxDepth)
|
|
@@ -481,6 +573,208 @@ function walkDirectory(dir, maxDepth = 3, depth = 0) {
|
|
|
481
573
|
}
|
|
482
574
|
return results;
|
|
483
575
|
}
|
|
576
|
+
/**
|
|
577
|
+
* Parse APPROVED/REJECTED verdict from a reviewer's free-form response.
|
|
578
|
+
*
|
|
579
|
+
* Robust to common formatting variants:
|
|
580
|
+
* - Leading blank lines or markdown headers (e.g. "## Review\n\nAPPROVED")
|
|
581
|
+
* - Markdown emphasis (e.g. "**APPROVED**")
|
|
582
|
+
* - Verdict appearing only later in the response
|
|
583
|
+
* - Both tokens appearing in the same line ("I almost said REJECTED but APPROVED")
|
|
584
|
+
*
|
|
585
|
+
* Strategy:
|
|
586
|
+
* 1. Strip markdown noise.
|
|
587
|
+
* 2. Look at the first 10 non-empty lines for a *line-leading* verdict.
|
|
588
|
+
* 3. Fall back to the first occurrence of either token anywhere in the body.
|
|
589
|
+
* 4. If neither token appears, treat as REJECTED (conservative).
|
|
590
|
+
*/
|
|
591
|
+
export function parseReviewVerdict(content) {
|
|
592
|
+
if (!content)
|
|
593
|
+
return false;
|
|
594
|
+
const stripped = content.replace(/[*_`#>]/g, "");
|
|
595
|
+
const lines = stripped
|
|
596
|
+
.split(/\r?\n/)
|
|
597
|
+
.map((l) => l.trim())
|
|
598
|
+
.filter(Boolean)
|
|
599
|
+
.slice(0, 10);
|
|
600
|
+
for (const line of lines) {
|
|
601
|
+
const lead = line
|
|
602
|
+
.toUpperCase()
|
|
603
|
+
.match(/^[^A-Z]*\b(APPROVED|REJECTED)\b/);
|
|
604
|
+
if (lead)
|
|
605
|
+
return lead[1] === "APPROVED";
|
|
606
|
+
}
|
|
607
|
+
const upper = stripped.toUpperCase();
|
|
608
|
+
const a = upper.search(/\bAPPROVED\b/);
|
|
609
|
+
const r = upper.search(/\bREJECTED\b/);
|
|
610
|
+
if (a === -1 && r === -1)
|
|
611
|
+
return false;
|
|
612
|
+
if (a === -1)
|
|
613
|
+
return false;
|
|
614
|
+
if (r === -1)
|
|
615
|
+
return true;
|
|
616
|
+
return a < r;
|
|
617
|
+
}
|
|
618
|
+
/**
|
|
619
|
+
* Return the reviewer's prose comments with any leading verdict line stripped.
|
|
620
|
+
* Preserves the original formatting (no upper-casing, no markdown stripping).
|
|
621
|
+
*/
|
|
622
|
+
export function stripLeadingVerdictLine(content) {
|
|
623
|
+
if (!content)
|
|
624
|
+
return "";
|
|
625
|
+
const lines = content.split(/\r?\n/);
|
|
626
|
+
let i = 0;
|
|
627
|
+
while (i < lines.length && lines[i].trim() === "")
|
|
628
|
+
i++;
|
|
629
|
+
if (i < lines.length) {
|
|
630
|
+
const probe = lines[i]
|
|
631
|
+
.replace(/[*_`#>]/g, "")
|
|
632
|
+
.trim()
|
|
633
|
+
.toUpperCase();
|
|
634
|
+
if (/^(APPROVED|REJECTED)\b/.test(probe))
|
|
635
|
+
i++;
|
|
636
|
+
}
|
|
637
|
+
return lines.slice(i).join("\n").trim();
|
|
638
|
+
}
|
|
639
|
+
/**
|
|
640
|
+
* Run a peer review phase after a task completes. Every other agent on the
|
|
641
|
+
* squad reviews the work and votes APPROVED / REJECTED. QA agents
|
|
642
|
+
* (is_qa === 1) have veto power: if any QA agent rejects, the PR is left as
|
|
643
|
+
* draft. Otherwise, any GitHub PR URL found in the task result is promoted
|
|
644
|
+
* from draft to ready via `gh pr ready`.
|
|
645
|
+
*/
|
|
646
|
+
async function runPeerReview(squadSlug, originalAgentCharacter, taskId, taskDescription, taskResult) {
|
|
647
|
+
const reviewers = listSquadAgents(squadSlug).filter((a) => a.character_name !== originalAgentCharacter);
|
|
648
|
+
if (reviewers.length === 0) {
|
|
649
|
+
recordTaskEvent(taskId, {
|
|
650
|
+
ts: Date.now(),
|
|
651
|
+
type: "task.review_complete",
|
|
652
|
+
data: { promoted: false, reason: "No other agents to review" },
|
|
653
|
+
});
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
const reviewPrompt = `You are reviewing the following completed task:
|
|
657
|
+
|
|
658
|
+
## Task
|
|
659
|
+
${taskDescription}
|
|
660
|
+
|
|
661
|
+
## Work Done
|
|
662
|
+
${taskResult}
|
|
663
|
+
|
|
664
|
+
Review the work. Respond with:
|
|
665
|
+
- First line: APPROVED or REJECTED
|
|
666
|
+
- Remaining lines: your review comments`;
|
|
667
|
+
const reviews = [];
|
|
668
|
+
for (const reviewer of reviewers) {
|
|
669
|
+
try {
|
|
670
|
+
const session = await getOrCreateAgentSession(squadSlug, reviewer, `Peer review of task ${taskId}`);
|
|
671
|
+
const response = await session.sendAndWait({ prompt: reviewPrompt }, 300_000);
|
|
672
|
+
const content = response?.data?.content ?? "";
|
|
673
|
+
const approved = parseReviewVerdict(content);
|
|
674
|
+
const comments = stripLeadingVerdictLine(content) || null;
|
|
675
|
+
createReview(taskId, squadSlug, reviewer.character_name, approved, comments ?? undefined);
|
|
676
|
+
recordTaskEvent(taskId, {
|
|
677
|
+
ts: Date.now(),
|
|
678
|
+
type: "task.review",
|
|
679
|
+
data: {
|
|
680
|
+
reviewer: reviewer.character_name,
|
|
681
|
+
is_qa: reviewer.is_qa === 1,
|
|
682
|
+
is_lead: reviewer.is_lead === 1,
|
|
683
|
+
approved,
|
|
684
|
+
comments,
|
|
685
|
+
},
|
|
686
|
+
});
|
|
687
|
+
reviews.push({
|
|
688
|
+
reviewer: reviewer.character_name,
|
|
689
|
+
is_qa: reviewer.is_qa === 1,
|
|
690
|
+
is_lead: reviewer.is_lead === 1,
|
|
691
|
+
approved,
|
|
692
|
+
comments: comments ?? "",
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
catch (err) {
|
|
696
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
697
|
+
console.error(`[io] Reviewer ${reviewer.character_name} failed:`, message);
|
|
698
|
+
recordTaskEvent(taskId, {
|
|
699
|
+
ts: Date.now(),
|
|
700
|
+
type: "task.review_error",
|
|
701
|
+
data: { reviewer: reviewer.character_name, error: message },
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
const hasQaReviewers = reviews.some((r) => r.is_qa);
|
|
706
|
+
const hasLeadReviewer = reviews.some((r) => r.is_lead);
|
|
707
|
+
const qaRejection = reviews.find((r) => r.is_qa && !r.approved);
|
|
708
|
+
// Team lead has implicit veto power equivalent to a QA reviewer. If the lead
|
|
709
|
+
// is also a QA agent the qaRejection branch already covers it; this catches
|
|
710
|
+
// the lead-but-not-QA case.
|
|
711
|
+
const leadRejection = reviews.find((r) => r.is_lead && !r.is_qa && !r.approved);
|
|
712
|
+
const advisoryRejections = reviews.filter((r) => !r.is_qa && !r.is_lead && !r.approved);
|
|
713
|
+
if (!hasQaReviewers && !hasLeadReviewer && advisoryRejections.length > 0) {
|
|
714
|
+
recordTaskEvent(taskId, {
|
|
715
|
+
ts: Date.now(),
|
|
716
|
+
type: "task.review_advisory",
|
|
717
|
+
data: {
|
|
718
|
+
reason: "No QA reviewers or team lead designated; rejections are advisory and do not block promotion.",
|
|
719
|
+
rejectedBy: advisoryRejections.map((r) => r.reviewer),
|
|
720
|
+
},
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
const prMatch = taskResult.match(/https:\/\/github\.com\/([^/\s]+)\/([^/\s]+)\/pull\/(\d+)/);
|
|
724
|
+
if (qaRejection) {
|
|
725
|
+
recordTaskEvent(taskId, {
|
|
726
|
+
ts: Date.now(),
|
|
727
|
+
type: "task.review_complete",
|
|
728
|
+
data: {
|
|
729
|
+
promoted: false,
|
|
730
|
+
reason: `QA veto from ${qaRejection.reviewer}`,
|
|
731
|
+
prUrl: prMatch ? prMatch[0] : null,
|
|
732
|
+
},
|
|
733
|
+
});
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
if (leadRejection) {
|
|
737
|
+
recordTaskEvent(taskId, {
|
|
738
|
+
ts: Date.now(),
|
|
739
|
+
type: "task.review_complete",
|
|
740
|
+
data: {
|
|
741
|
+
promoted: false,
|
|
742
|
+
reason: `Lead veto from ${leadRejection.reviewer}`,
|
|
743
|
+
prUrl: prMatch ? prMatch[0] : null,
|
|
744
|
+
},
|
|
745
|
+
});
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
if (!prMatch) {
|
|
749
|
+
recordTaskEvent(taskId, {
|
|
750
|
+
ts: Date.now(),
|
|
751
|
+
type: "task.review_complete",
|
|
752
|
+
data: { promoted: false, reason: "No PR URL found in task result" },
|
|
753
|
+
});
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
const [prUrl, owner, repo, prNumber] = prMatch;
|
|
757
|
+
try {
|
|
758
|
+
execSync(`gh pr ready ${prNumber} --repo ${owner}/${repo}`, {
|
|
759
|
+
encoding: "utf-8",
|
|
760
|
+
timeout: 30_000,
|
|
761
|
+
env: { ...process.env, HOME: process.env.HOME || homedir() },
|
|
762
|
+
});
|
|
763
|
+
recordTaskEvent(taskId, {
|
|
764
|
+
ts: Date.now(),
|
|
765
|
+
type: "task.review_complete",
|
|
766
|
+
data: { promoted: true, prUrl },
|
|
767
|
+
});
|
|
768
|
+
}
|
|
769
|
+
catch (err) {
|
|
770
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
771
|
+
recordTaskEvent(taskId, {
|
|
772
|
+
ts: Date.now(),
|
|
773
|
+
type: "task.review_complete",
|
|
774
|
+
data: { promoted: false, reason: `gh pr ready failed: ${message}`, prUrl },
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
}
|
|
484
778
|
/**
|
|
485
779
|
* Cancel a running agent task by aborting its session and marking the task
|
|
486
780
|
* cancelled. Returns true if the task existed and was running.
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// Minimal standard 5-field cron parser + next-run calculator.
|
|
2
|
+
//
|
|
3
|
+
// Fields (in order): minute, hour, day-of-month, month, day-of-week.
|
|
4
|
+
// Supported syntax per field:
|
|
5
|
+
// * — all values
|
|
6
|
+
// N — single value
|
|
7
|
+
// A,B,C — list
|
|
8
|
+
// A-B — range
|
|
9
|
+
// A-B/N or */N — step (A-B/N restricts to range; */N applies to full range)
|
|
10
|
+
//
|
|
11
|
+
// Day-of-week: 0 or 7 = Sunday, 1 = Monday … (standard Unix cron).
|
|
12
|
+
//
|
|
13
|
+
// Matching semantics: when both day-of-month and day-of-week are restricted
|
|
14
|
+
// (i.e. neither is "*"), Vixie cron uses an OR between them — we follow that.
|
|
15
|
+
const FIELD_RANGES = [
|
|
16
|
+
[0, 59],
|
|
17
|
+
[0, 23],
|
|
18
|
+
[1, 31],
|
|
19
|
+
[1, 12],
|
|
20
|
+
[0, 7],
|
|
21
|
+
];
|
|
22
|
+
function parseField(raw, [min, max]) {
|
|
23
|
+
const out = new Set();
|
|
24
|
+
for (const part of raw.split(",")) {
|
|
25
|
+
const stepMatch = part.match(/^(.+?)\/(\d+)$/);
|
|
26
|
+
const body = stepMatch ? stepMatch[1] : part;
|
|
27
|
+
const step = stepMatch ? parseInt(stepMatch[2], 10) : 1;
|
|
28
|
+
if (!(step >= 1))
|
|
29
|
+
throw new Error(`Invalid step in cron field: "${part}"`);
|
|
30
|
+
let lo, hi;
|
|
31
|
+
if (body === "*") {
|
|
32
|
+
lo = min;
|
|
33
|
+
hi = max;
|
|
34
|
+
}
|
|
35
|
+
else if (body.includes("-")) {
|
|
36
|
+
const [a, b] = body.split("-").map((n) => parseInt(n, 10));
|
|
37
|
+
if (Number.isNaN(a) || Number.isNaN(b)) {
|
|
38
|
+
throw new Error(`Invalid range in cron field: "${part}"`);
|
|
39
|
+
}
|
|
40
|
+
lo = a;
|
|
41
|
+
hi = b;
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
const v = parseInt(body, 10);
|
|
45
|
+
if (Number.isNaN(v))
|
|
46
|
+
throw new Error(`Invalid cron field value: "${part}"`);
|
|
47
|
+
lo = v;
|
|
48
|
+
hi = v;
|
|
49
|
+
}
|
|
50
|
+
if (lo < min || hi > max || lo > hi) {
|
|
51
|
+
throw new Error(`Cron field out of range [${min}-${max}]: "${part}"`);
|
|
52
|
+
}
|
|
53
|
+
for (let v = lo; v <= hi; v += step)
|
|
54
|
+
out.add(v);
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
export function parseCron(expr) {
|
|
59
|
+
const trimmed = expr.trim().replace(/\s+/g, " ");
|
|
60
|
+
const parts = trimmed.split(" ");
|
|
61
|
+
if (parts.length !== 5) {
|
|
62
|
+
throw new Error(`Cron expression must have 5 fields (minute hour dom month dow), got ${parts.length}: "${expr}"`);
|
|
63
|
+
}
|
|
64
|
+
const [mRaw, hRaw, domRaw, monRaw, dowRaw] = parts;
|
|
65
|
+
const minutes = parseField(mRaw, FIELD_RANGES[0]);
|
|
66
|
+
const hours = parseField(hRaw, FIELD_RANGES[1]);
|
|
67
|
+
const doms = parseField(domRaw, FIELD_RANGES[2]);
|
|
68
|
+
const months = parseField(monRaw, FIELD_RANGES[3]);
|
|
69
|
+
const dowsRaw = parseField(dowRaw, FIELD_RANGES[4]);
|
|
70
|
+
const dows = new Set();
|
|
71
|
+
for (const v of dowsRaw)
|
|
72
|
+
dows.add(v === 7 ? 0 : v);
|
|
73
|
+
return {
|
|
74
|
+
minutes,
|
|
75
|
+
hours,
|
|
76
|
+
doms,
|
|
77
|
+
months,
|
|
78
|
+
dows,
|
|
79
|
+
domStar: domRaw === "*",
|
|
80
|
+
dowStar: dowRaw === "*",
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Return the next Date strictly after `after` that matches the cron expression.
|
|
85
|
+
* Iterates minute-by-minute with month/day fast-forwarding. Capped at ~5
|
|
86
|
+
* years lookahead to guard against unsatisfiable expressions.
|
|
87
|
+
*/
|
|
88
|
+
export function nextRun(expr, after = new Date()) {
|
|
89
|
+
const c = typeof expr === "string" ? parseCron(expr) : expr;
|
|
90
|
+
const cursor = new Date(after.getTime());
|
|
91
|
+
cursor.setSeconds(0, 0);
|
|
92
|
+
cursor.setMinutes(cursor.getMinutes() + 1);
|
|
93
|
+
const limit = new Date(after.getTime() + 5 * 366 * 24 * 60 * 60 * 1000);
|
|
94
|
+
while (cursor <= limit) {
|
|
95
|
+
const month = cursor.getMonth() + 1;
|
|
96
|
+
if (!c.months.has(month)) {
|
|
97
|
+
cursor.setMonth(cursor.getMonth() + 1, 1);
|
|
98
|
+
cursor.setHours(0, 0, 0, 0);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const dom = cursor.getDate();
|
|
102
|
+
const dow = cursor.getDay();
|
|
103
|
+
const dayOk = c.domStar && c.dowStar
|
|
104
|
+
? true
|
|
105
|
+
: c.domStar
|
|
106
|
+
? c.dows.has(dow)
|
|
107
|
+
: c.dowStar
|
|
108
|
+
? c.doms.has(dom)
|
|
109
|
+
: c.doms.has(dom) || c.dows.has(dow);
|
|
110
|
+
if (!dayOk) {
|
|
111
|
+
cursor.setDate(cursor.getDate() + 1);
|
|
112
|
+
cursor.setHours(0, 0, 0, 0);
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (!c.hours.has(cursor.getHours())) {
|
|
116
|
+
cursor.setHours(cursor.getHours() + 1, 0, 0, 0);
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (!c.minutes.has(cursor.getMinutes())) {
|
|
120
|
+
cursor.setMinutes(cursor.getMinutes() + 1, 0, 0);
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
return cursor;
|
|
124
|
+
}
|
|
125
|
+
throw new Error(`No next run found within 5 years for cron expression`);
|
|
126
|
+
}
|
|
127
|
+
export function validateCron(expr) {
|
|
128
|
+
try {
|
|
129
|
+
const next = nextRun(expr);
|
|
130
|
+
return { ok: true, next };
|
|
131
|
+
}
|
|
132
|
+
catch (err) {
|
|
133
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
//# sourceMappingURL=cron.js.map
|