@voidlabs.gg/voidshift-agents 0.4.3 → 0.4.5
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 +52 -4
- package/dist/config.d.ts +65 -6
- package/dist/config.js +114 -16
- package/dist/control.d.ts +3 -1
- package/dist/control.js +69 -39
- package/dist/cron.d.ts +9 -0
- package/dist/cron.js +66 -0
- package/dist/engine.d.ts +1 -0
- package/dist/engine.js +2 -0
- package/dist/git.d.ts +6 -0
- package/dist/git.js +60 -0
- package/dist/index.js +151 -35
- package/dist/linear.d.ts +1 -1
- package/dist/linear.js +3 -3
- package/dist/manage.d.ts +5 -2
- package/dist/manage.js +138 -21
- package/dist/names.d.ts +1 -0
- package/dist/names.js +4 -1
- package/dist/profile.d.ts +10 -0
- package/dist/profile.js +70 -0
- package/dist/runner.d.ts +15 -8
- package/dist/runner.js +219 -98
- package/dist/state.d.ts +7 -2
- package/dist/state.js +15 -0
- package/dist/tracker.d.ts +24 -0
- package/dist/tracker.js +36 -0
- package/dist/voidtrack.d.ts +18 -0
- package/dist/voidtrack.js +89 -0
- package/dist/workflow.d.ts +2 -2
- package/package.json +1 -1
package/dist/runner.js
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
import { existsSync, mkdirSync
|
|
1
|
+
import { existsSync, mkdirSync } from 'node:fs';
|
|
2
2
|
import { basename, join } from 'node:path';
|
|
3
|
-
import { fileURLToPath } from 'node:url';
|
|
4
3
|
import { LABELS, PLAN_HEADING, buildPrompt, findingsFor, fixTarget, isStatusLabel, latestPlan, parseDevResult, parseVerdict, planBody, qaPassed, stageLabels, verdictBlock, } from "./workflow.js";
|
|
5
|
-
import {
|
|
4
|
+
import { agentsDir, hasWork, isCardTrigger, resolveStep, stepId, stepIdLabel } from "./config.js";
|
|
5
|
+
import { lastDue, parseCron } from "./cron.js";
|
|
6
6
|
import { runAgent, stopAgents } from "./engine.js";
|
|
7
|
-
import { commitsAhead, defaultBranch, ensureDraftPr, ensureWorktree, headSha, markReady, push, resetWorktree, waitForChecks, waitForPrHead } from "./git.js";
|
|
7
|
+
import { commitsAhead, enforceCommitPolicy, defaultBranch, ensureDraftPr, ensureWorktree, headSha, markReady, push, resetWorktree, waitForChecks, waitForPrHead } from "./git.js";
|
|
8
|
+
import { noTracker } from "./tracker.js";
|
|
8
9
|
import { recall, vaultPath } from "./memory.js";
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
const skill = (name) => readFileSync(join(packDir, name, 'SKILL.md'), 'utf8');
|
|
10
|
+
import { claudeSettingsFile, readRules, rulesSection } from "./profile.js";
|
|
11
|
+
import { bootTime, clearCurrent, isPaused, ours, readCurrent, readPending, writeCurrent, writePending, writeRun, readCronState, writeCronState } from "./state.js";
|
|
12
12
|
const stamp = () => new Date().toISOString().replace(/[:.]/g, '-');
|
|
13
13
|
class Park extends Error {
|
|
14
14
|
}
|
|
@@ -18,7 +18,7 @@ function withLabels(card, drop, add) {
|
|
|
18
18
|
const UNATTENDED = `# Running unattended
|
|
19
19
|
|
|
20
20
|
You are one step in a workflow. Nobody can answer questions and the
|
|
21
|
-
|
|
21
|
+
tracker connection is not available: the card and any plan are included above.
|
|
22
22
|
Do not write plan files; return your output as your final message. Make the
|
|
23
23
|
call the code supports and state it.`;
|
|
24
24
|
// Agents keep the memory vault the way you do: durable learnings become notes.
|
|
@@ -35,29 +35,37 @@ export function summarize(output) {
|
|
|
35
35
|
}
|
|
36
36
|
// The run in progress, so a cancel signal can mark it.
|
|
37
37
|
let active = null;
|
|
38
|
-
const isVerdict = (
|
|
38
|
+
const isVerdict = (d) => d.output === 'verdict';
|
|
39
|
+
const isProducer = (d) => d.output === 'plan' || d.output === 'commits';
|
|
40
|
+
// The card label for a step: its own for a built-in step, a generic one otherwise.
|
|
41
|
+
const stageOf = (d) => LABELS.stage[d.builtin ?? ''] ?? LABELS.working;
|
|
39
42
|
// One card through a trigger's workflow. Steps run in order. A failed check (or a
|
|
40
43
|
// run of consecutive checks) sends its findings back to the nearest earlier
|
|
41
44
|
// step that produces something, Plan or Dev, and the workflow continues from
|
|
42
45
|
// there, until everything passes or the loop cap parks the card.
|
|
43
46
|
export async function runCard(card, trigger, workflowName, workflow, project, rc, opts = {}) {
|
|
44
|
-
const {
|
|
47
|
+
const { config, log } = rc;
|
|
48
|
+
// A scheduled run has no card, so it has nothing to write back to.
|
|
49
|
+
const linear = isCardTrigger(trigger) ? rc.trackers(trigger.source) : noTracker;
|
|
50
|
+
const label = isCardTrigger(trigger) ? trigger.label : '';
|
|
45
51
|
const runDir = join(agentsDir(rc.configDir), 'runs', card.identifier, stamp());
|
|
46
52
|
mkdirSync(runDir, { recursive: true });
|
|
47
53
|
const say = (line) => log(`${card.identifier} ${line}`);
|
|
48
|
-
|
|
54
|
+
// Write back settings apply to card runs; a scheduled run has nothing to write to.
|
|
55
|
+
const writeBack = isCardTrigger(trigger) ? (workflow.writeBack ?? {}) : {};
|
|
56
|
+
const labelsOn = writeBack.labels !== false;
|
|
49
57
|
const startedAt = new Date().toISOString();
|
|
50
|
-
const record = { card: card.identifier, title: card.title, url: card.url, workflow: workflowName, trigger: trigger.
|
|
58
|
+
const record = { card: card.identifier, title: card.title, url: card.url || undefined, workflow: workflowName, trigger: label || (trigger.source === 'cron' ? trigger.schedule : ''), source: trigger.source, steps: [], outcome: 'running', startedAt };
|
|
51
59
|
active = { record, runDir };
|
|
52
60
|
const save = () => writeRun(runDir, record);
|
|
53
61
|
save();
|
|
54
62
|
let loops = 0;
|
|
55
63
|
// The lock tells status and cancel what is running.
|
|
56
|
-
const mark = (step) => writeCurrent(rc.configDir, { pid: process.pid, card: card.identifier, title: card.title, workflow: workflowName, step, round: loops, startedAt, runDir });
|
|
57
|
-
// Written before the first
|
|
64
|
+
const mark = (step) => writeCurrent(rc.configDir, { pid: process.pid, card: card.identifier, title: card.title, workflow: workflowName, step, round: loops, startedAt, runDir, source: trigger.source });
|
|
65
|
+
// Written before the first tracker call, so a cancel always knows the card.
|
|
58
66
|
mark('Starting');
|
|
59
|
-
const begin = (
|
|
60
|
-
const rec = { step, label:
|
|
67
|
+
const begin = (def) => {
|
|
68
|
+
const rec = { step: def.id, label: def.label, startedAt: new Date().toISOString() };
|
|
61
69
|
record.steps.push(rec);
|
|
62
70
|
mark(rec.label);
|
|
63
71
|
save();
|
|
@@ -84,8 +92,14 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
|
|
|
84
92
|
const setRound = (n) => {
|
|
85
93
|
loops = n;
|
|
86
94
|
};
|
|
87
|
-
const stage = (name, round) =>
|
|
88
|
-
|
|
95
|
+
const stage = async (name, round) => {
|
|
96
|
+
if (labelsOn)
|
|
97
|
+
await linear.setLabels(card, stageLabels(card.labels, label, name, round));
|
|
98
|
+
};
|
|
99
|
+
// How a run ends on the card: a stage label, or with labels off only the
|
|
100
|
+
// trigger taken away so the card is not picked up again.
|
|
101
|
+
const endLabels = (name, round) => linear.setLabels(card, labelsOn ? stageLabels(card.labels, label, name, round) : card.labels.filter((l) => l !== label));
|
|
102
|
+
// The tracker hears from the runner twice: the plan once it has passed review,
|
|
89
103
|
// and one final comment. Findings stay here until the final one.
|
|
90
104
|
let lastFindings = '';
|
|
91
105
|
let planToPost = null;
|
|
@@ -97,38 +111,52 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
|
|
|
97
111
|
};
|
|
98
112
|
async function park(reason) {
|
|
99
113
|
say(`parked: ${reason}`);
|
|
100
|
-
const comment = `**Parked:** ${reason}${lastFindings ? `\n\n${lastFindings}` : ''}\n\nFix the cause, then add \`${
|
|
101
|
-
const add = [LABELS.parked, ...(loops ? [LABELS.version(loops)] : [])];
|
|
114
|
+
const comment = `**Parked:** ${reason}${lastFindings ? `\n\n${lastFindings}` : ''}\n\nFix the cause, then add \`${label}\` again to retry.`;
|
|
115
|
+
const add = labelsOn ? [LABELS.parked, ...(loops ? [LABELS.version(loops)] : [])] : [];
|
|
102
116
|
let commented = false;
|
|
103
117
|
try {
|
|
104
118
|
await postPlan();
|
|
105
119
|
await linear.comment(card, comment);
|
|
106
120
|
commented = true;
|
|
107
|
-
await
|
|
121
|
+
await endLabels(LABELS.parked, loops || undefined);
|
|
108
122
|
}
|
|
109
123
|
catch (err) {
|
|
110
|
-
|
|
124
|
+
if (!isCardTrigger(trigger))
|
|
125
|
+
return done('parked', reason);
|
|
126
|
+
say(`could not reach ${trigger.source === 'voidtrack' ? 'VoidTrack' : 'Linear'} to park it (${err.message}). Will retry next minute.`);
|
|
111
127
|
// An empty comment is skipped on retry, so it is not posted twice.
|
|
112
|
-
writePending(rc.configDir, [...readPending(rc.configDir), { card: card.identifier, comment: commented ? '' : comment, drop: [
|
|
128
|
+
writePending(rc.configDir, [...readPending(rc.configDir), { card: card.identifier, comment: commented ? '' : comment, drop: [label], add, dropStatus: labelsOn, source: trigger.source }]);
|
|
113
129
|
}
|
|
114
130
|
return done('parked', reason);
|
|
115
131
|
}
|
|
116
132
|
try {
|
|
117
133
|
if (!existsSync(join(project.path, '.git')))
|
|
118
134
|
throw new Park(`The project folder ${project.path} is not a git repository on this Mac.`);
|
|
119
|
-
const
|
|
135
|
+
const steps = workflow.steps.map((s) => resolveStep(s, config, config.folders?.[workflowName]));
|
|
136
|
+
const from = opts.from ? steps.findIndex((d) => d.id === opts.from) : 0;
|
|
120
137
|
if (from < 0)
|
|
121
|
-
throw new Park(`Workflow ${workflowName} has no ${
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
const
|
|
138
|
+
throw new Park(`Workflow ${workflowName} has no ${stepIdLabel(opts.from)} step.`);
|
|
139
|
+
// A scheduled run has no card to plan on, so its task is the plan unless a Plan step writes one.
|
|
140
|
+
let plan = isCardTrigger(trigger) ? latestPlan(await linear.comments(card.identifier)) : card.description ?? null;
|
|
141
|
+
const firstPlan = steps.findIndex((d, n) => n >= from && d.output === 'plan');
|
|
142
|
+
// A check before any commits is a check on the plan, like Plan review.
|
|
143
|
+
const needsPlan = steps.findIndex((d, n) => n >= from && (d.output === 'commits' || (d.output === 'verdict' && d.builtin !== 'vs-qa')));
|
|
125
144
|
if (!plan && needsPlan >= 0 && (firstPlan < 0 || firstPlan > needsPlan)) {
|
|
126
145
|
throw new Park('No plan comment (starting with "## Plan") was found. Publish a plan first, or start the workflow with Plan.');
|
|
127
146
|
}
|
|
128
|
-
await stage(
|
|
147
|
+
await stage(stageOf(steps[from]));
|
|
129
148
|
const base = project.base || defaultBranch(project.path);
|
|
130
149
|
const vault = vaultPath(rc.configDir);
|
|
131
|
-
|
|
150
|
+
// Your profile rides along on every step: rules in the prompt, Claude settings as a file.
|
|
151
|
+
let settingsFile;
|
|
152
|
+
try {
|
|
153
|
+
settingsFile = claudeSettingsFile(rc.configDir, runDir);
|
|
154
|
+
}
|
|
155
|
+
catch (err) {
|
|
156
|
+
throw new Park(`Your agent profile's Claude settings are broken: ${err.message}`);
|
|
157
|
+
}
|
|
158
|
+
const rules = rulesSection(readRules(rc.configDir));
|
|
159
|
+
const known = [recall(rc.selfCommand, rc.configDir, `${workflow.project} ${card.title}\n${card.description ?? ''}`), vault ? memoryNote(vault) : '', rules].filter(Boolean).join('\n\n');
|
|
132
160
|
const worktree = join(agentsDir(rc.configDir), 'worktrees', card.identifier);
|
|
133
161
|
ensureWorktree(project.path, worktree, card.branchName, base);
|
|
134
162
|
say(`working in ${worktree} on ${card.branchName}`);
|
|
@@ -137,13 +165,12 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
|
|
|
137
165
|
let prUrl = '';
|
|
138
166
|
let logs = 0;
|
|
139
167
|
const planText = () => plan ?? '(No plan was posted on this card. Check the work against the card itself.)';
|
|
140
|
-
const timeoutMs = config.timeoutMinutes * 60_000;
|
|
141
168
|
const logFile = (name) => join(runDir, `${String(logs++).padStart(2, '0')}-${name}.log`);
|
|
142
|
-
async function check(
|
|
143
|
-
const label =
|
|
144
|
-
const rec = begin(
|
|
169
|
+
async function check(def, round) {
|
|
170
|
+
const label = def.label;
|
|
171
|
+
const rec = begin(def);
|
|
145
172
|
let ciNote = '';
|
|
146
|
-
if (
|
|
173
|
+
if (def.builtin === 'vs-qa') {
|
|
147
174
|
if (commitsAhead(worktree, base) === 0) {
|
|
148
175
|
end(rec, 'SKIP', 'No changes on the branch yet.');
|
|
149
176
|
return { step: label, verdict: 'SKIP', report: 'No changes on the branch yet.' };
|
|
@@ -171,12 +198,12 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
|
|
|
171
198
|
ciNote = ci.verdict === 'SHIP' ? '# CI\n\nCI passed on this branch.' : '# CI\n\nThis repo has no CI checks.';
|
|
172
199
|
}
|
|
173
200
|
}
|
|
174
|
-
const log = logFile(
|
|
201
|
+
const log = logFile(def.id);
|
|
175
202
|
const r = await runAgent({
|
|
176
|
-
engine:
|
|
177
|
-
prompt: buildPrompt({ skill:
|
|
203
|
+
engine: def.engine, model: def.model, cwd: worktree, vault, settingsFile, write: def.write, logFile: log, timeoutMs: def.timeoutMs,
|
|
204
|
+
prompt: buildPrompt({ skill: def.prompt(), card, plan: planText(), base, round, extra: [known, ciNote, UNATTENDED].filter(Boolean).join('\n\n') }),
|
|
178
205
|
});
|
|
179
|
-
if (
|
|
206
|
+
if (def.builtin === 'vs-qa')
|
|
180
207
|
resetWorktree(worktree);
|
|
181
208
|
const verdict = parseVerdict(r.output);
|
|
182
209
|
end(rec, verdict, r.output, log);
|
|
@@ -184,18 +211,18 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
|
|
|
184
211
|
}
|
|
185
212
|
let i = from;
|
|
186
213
|
while (i < steps.length) {
|
|
187
|
-
const
|
|
188
|
-
say(
|
|
189
|
-
if (isVerdict(
|
|
214
|
+
const def = steps[i];
|
|
215
|
+
say(def.label);
|
|
216
|
+
if (isVerdict(def)) {
|
|
190
217
|
const [start, end] = verdictBlock(steps, i, isVerdict);
|
|
191
218
|
const target = fixTarget(steps, start, isProducer);
|
|
192
219
|
const producer = target >= 0 ? steps[target] : undefined;
|
|
193
220
|
// The round being checked: how many times its producer has run.
|
|
194
|
-
const round = Math.max(producer === '
|
|
221
|
+
const round = Math.max(producer?.output === 'plan' ? runs.plan : runs.dev, 1);
|
|
195
222
|
setRound(round);
|
|
196
223
|
const results = [];
|
|
197
224
|
for (const s of steps.slice(start, end)) {
|
|
198
|
-
await stage(
|
|
225
|
+
await stage(stageOf(s), round);
|
|
199
226
|
results.push(await check(s, round));
|
|
200
227
|
}
|
|
201
228
|
if (qaPassed(results)) {
|
|
@@ -207,26 +234,26 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
|
|
|
207
234
|
if (target < 0) {
|
|
208
235
|
await postPlan();
|
|
209
236
|
await linear.comment(card, `**Changes needed.**\n\n${notes}`);
|
|
210
|
-
await
|
|
237
|
+
await endLabels(LABELS.changes, round);
|
|
211
238
|
say('changes requested');
|
|
212
239
|
return done('changes');
|
|
213
240
|
}
|
|
214
241
|
// Rounds caps how many times Plan or Dev runs, counting the first.
|
|
215
242
|
if (round >= workflow.maxRounds) {
|
|
216
|
-
throw new Park(`Checks still failed after ${round} ${producer === '
|
|
243
|
+
throw new Park(`Checks still failed after ${round} ${producer?.output === 'plan' ? 'plan' : 'dev'} rounds.${prUrl ? ` PR: ${prUrl}` : ''}`);
|
|
217
244
|
}
|
|
218
245
|
findings.set(target, notes);
|
|
219
246
|
i = target;
|
|
220
247
|
continue;
|
|
221
248
|
}
|
|
222
|
-
if (
|
|
249
|
+
if (def.output === 'plan') {
|
|
223
250
|
setRound(runs.plan + 1);
|
|
224
|
-
await stage(
|
|
225
|
-
const rec = begin(
|
|
226
|
-
const log = logFile('plan');
|
|
251
|
+
await stage(stageOf(def), runs.plan + 1);
|
|
252
|
+
const rec = begin(def);
|
|
253
|
+
const log = logFile(def.builtin ? 'plan' : def.id);
|
|
227
254
|
const r = await runAgent({
|
|
228
|
-
engine:
|
|
229
|
-
prompt: buildPrompt({ skill:
|
|
255
|
+
engine: def.engine, model: def.model, cwd: worktree, vault, settingsFile, write: def.write, logFile: log, timeoutMs: def.timeoutMs,
|
|
256
|
+
prompt: buildPrompt({ skill: def.prompt(), card, plan: plan ?? '(none yet, you are writing it)', base, round: runs.plan + 1, findings: findings.get(i), extra: [known, UNATTENDED].filter(Boolean).join('\n\n') }),
|
|
230
257
|
});
|
|
231
258
|
const result = parseDevResult(r.output);
|
|
232
259
|
end(rec, result.status === 'blocked' ? 'blocked' : 'done', r.output, log);
|
|
@@ -236,22 +263,22 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
|
|
|
236
263
|
planToPost = plan;
|
|
237
264
|
runs.plan++;
|
|
238
265
|
}
|
|
239
|
-
else if (
|
|
266
|
+
else if (def.output === 'publish') {
|
|
240
267
|
await postPlan();
|
|
241
|
-
const rec = begin(
|
|
268
|
+
const rec = begin(def);
|
|
242
269
|
push(worktree, card.branchName);
|
|
243
270
|
end(rec, 'pushed');
|
|
244
271
|
}
|
|
245
|
-
else if (
|
|
272
|
+
else if (def.output === 'commits') {
|
|
246
273
|
await postPlan();
|
|
247
274
|
setRound(runs.dev + 1);
|
|
248
|
-
await stage(
|
|
249
|
-
const rec = begin(
|
|
250
|
-
const log = logFile('dev');
|
|
275
|
+
await stage(stageOf(def), runs.dev + 1);
|
|
276
|
+
const rec = begin(def);
|
|
277
|
+
const log = logFile(def.builtin ? 'dev' : def.id);
|
|
251
278
|
const before = headSha(worktree);
|
|
252
279
|
const dev = await runAgent({
|
|
253
|
-
engine:
|
|
254
|
-
prompt: buildPrompt({ skill:
|
|
280
|
+
engine: def.engine, model: def.model, cwd: worktree, vault, settingsFile, write: def.write, logFile: log, timeoutMs: def.timeoutMs,
|
|
281
|
+
prompt: buildPrompt({ skill: def.prompt(), card, plan: planText(), base, round: runs.dev + 1, findings: findings.get(i), extra: [known, UNATTENDED].filter(Boolean).join('\n\n') }),
|
|
255
282
|
});
|
|
256
283
|
const result = parseDevResult(dev.output);
|
|
257
284
|
end(rec, result.status === 'blocked' ? 'blocked' : 'done', dev.output, log);
|
|
@@ -263,23 +290,42 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
|
|
|
263
290
|
// just check the same commit again.
|
|
264
291
|
if (runs.dev > 0 && headSha(worktree) === before)
|
|
265
292
|
throw new Park('The dev agent made no new commits this round.');
|
|
293
|
+
// Only this round's commits, which are not pushed yet.
|
|
294
|
+
const fixed = enforceCommitPolicy(worktree, before, config.commits ?? {});
|
|
295
|
+
if (fixed)
|
|
296
|
+
say(`applied the commit policy to ${fixed} commit${fixed === 1 ? '' : 's'}`);
|
|
266
297
|
push(worktree, card.branchName);
|
|
267
298
|
prUrl = ensureDraftPr(worktree, card.branchName, base, `${card.identifier}: ${card.title}`, card.url);
|
|
268
299
|
record.prUrl = prUrl;
|
|
269
300
|
save();
|
|
270
301
|
runs.dev++;
|
|
271
302
|
}
|
|
303
|
+
else {
|
|
304
|
+
// A step with no output the workflow reads: it runs once and must finish.
|
|
305
|
+
await stage(stageOf(def), loops || undefined);
|
|
306
|
+
const rec = begin(def);
|
|
307
|
+
const log = logFile(def.id);
|
|
308
|
+
const r = await runAgent({
|
|
309
|
+
engine: def.engine, model: def.model, cwd: worktree, vault, settingsFile, write: def.write, logFile: log, timeoutMs: def.timeoutMs,
|
|
310
|
+
prompt: buildPrompt({ skill: def.prompt(), card, plan: plan ?? '(none)', base, round: loops || 1, extra: [known, UNATTENDED].filter(Boolean).join('\n\n') }),
|
|
311
|
+
});
|
|
312
|
+
end(rec, r.ok ? 'done' : 'failed', r.output, log);
|
|
313
|
+
if (!r.ok)
|
|
314
|
+
throw new Park(`The ${def.label} step failed or timed out.`);
|
|
315
|
+
}
|
|
272
316
|
findings.delete(i);
|
|
273
317
|
i++;
|
|
274
318
|
}
|
|
275
319
|
await postPlan();
|
|
276
|
-
|
|
277
|
-
|
|
320
|
+
// Unset moves a card with commits to reviewState; a state always moves it; null never does.
|
|
321
|
+
const state = writeBack.state === undefined ? (hasWork(workflow) ? config.reviewState : null) : writeBack.state;
|
|
322
|
+
if (hasWork(workflow))
|
|
278
323
|
markReady(worktree, card.branchName);
|
|
279
|
-
|
|
324
|
+
const moved = state ? await linear.moveTo(card, state) : true;
|
|
325
|
+
await endLabels(LABELS.verified, loops || undefined);
|
|
326
|
+
if (writeBack.comment !== false) {
|
|
327
|
+
await linear.comment(card, `**All checks passed.**${prUrl ? ` PR ready: ${prUrl}` : ''}${!moved ? `\n\n(No "${state}" state on this team, so the card was not moved.)` : ''}`);
|
|
280
328
|
}
|
|
281
|
-
await linear.setLabels(card, stageLabels(card.labels, trigger.label, LABELS.verified, loops || undefined));
|
|
282
|
-
await linear.comment(card, `**All checks passed.**${prUrl ? ` PR ready: ${prUrl}` : ''}${hasWork(workflow) && !moved ? `\n\n(No "${config.reviewState}" state on this team, so the card was not moved.)` : ''}`);
|
|
283
329
|
say('verified');
|
|
284
330
|
return done('verified');
|
|
285
331
|
}
|
|
@@ -292,34 +338,107 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
|
|
|
292
338
|
active = null;
|
|
293
339
|
}
|
|
294
340
|
}
|
|
295
|
-
//
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
const
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
341
|
+
// A scheduled run's stand-in for a card: the task, on a branch of its own.
|
|
342
|
+
export function cronCard(workflow, task, at) {
|
|
343
|
+
const stamp = at.toISOString().slice(0, 16).replace(/[-:T]/g, '');
|
|
344
|
+
const slug = workflow.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'cron';
|
|
345
|
+
return {
|
|
346
|
+
id: '',
|
|
347
|
+
identifier: `${slug.replace(/-/g, '').toUpperCase()}-${stamp}`,
|
|
348
|
+
title: task.split('\n')[0].slice(0, 80),
|
|
349
|
+
description: task,
|
|
350
|
+
url: '',
|
|
351
|
+
branchName: `vs/${slug}-${stamp}`,
|
|
352
|
+
teamId: '',
|
|
353
|
+
teamKey: '',
|
|
354
|
+
labels: [],
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
const cronKey = (workflow, schedule) => `${workflow} ${schedule}`;
|
|
358
|
+
// The first job waiting on any workflow's triggers, in workflow order: a card
|
|
359
|
+
// carrying a trigger label, or a schedule that came due since it last fired.
|
|
360
|
+
// A schedule seen for the first time starts counting from now. With a card,
|
|
361
|
+
// only that card is considered; with a workflow name, only that workflow, and
|
|
362
|
+
// `force` fires its schedule now.
|
|
363
|
+
export async function nextJob(rc, only, workflowName, opts = {}) {
|
|
364
|
+
const now = opts.now ?? new Date();
|
|
365
|
+
const workflows = Object.entries(rc.config.workflows).filter(([name]) => !workflowName || name === workflowName);
|
|
366
|
+
let pinned = null;
|
|
367
|
+
if (only) {
|
|
368
|
+
const team = only.split('-')[0].toUpperCase();
|
|
369
|
+
const sources = [...new Set(workflows.flatMap(([, w]) => w.triggers.filter(isCardTrigger).filter((t) => t.team === team).map((t) => t.source)))];
|
|
370
|
+
for (const source of sources) {
|
|
371
|
+
try {
|
|
372
|
+
pinned = { card: await rc.trackers(source).card(only), source };
|
|
373
|
+
break;
|
|
374
|
+
}
|
|
375
|
+
catch (err) {
|
|
376
|
+
if (source === sources.at(-1))
|
|
377
|
+
throw err;
|
|
378
|
+
}
|
|
308
379
|
}
|
|
380
|
+
if (!pinned)
|
|
381
|
+
return null;
|
|
382
|
+
}
|
|
383
|
+
const cron = readCronState(rc.configDir);
|
|
384
|
+
let cronChanged = false;
|
|
385
|
+
try {
|
|
386
|
+
for (const [name, workflow] of workflows) {
|
|
387
|
+
for (const trigger of workflow.triggers) {
|
|
388
|
+
if (!isCardTrigger(trigger)) {
|
|
389
|
+
const schedule = only ? null : parseCron(trigger.schedule);
|
|
390
|
+
if (!schedule)
|
|
391
|
+
continue;
|
|
392
|
+
const key = cronKey(name, trigger.schedule);
|
|
393
|
+
const last = cron[key];
|
|
394
|
+
const due = opts.force ? now : last ? lastDue(schedule, new Date(last), now) : null;
|
|
395
|
+
if (!last || due) {
|
|
396
|
+
cron[key] = now.toISOString();
|
|
397
|
+
cronChanged = true;
|
|
398
|
+
}
|
|
399
|
+
if (due)
|
|
400
|
+
return { card: cronCard(name, trigger.task, due), trigger, workflow: name };
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
403
|
+
// A pinned card may have lost its label (a parked card re-run by hand), so match by team.
|
|
404
|
+
if (pinned) {
|
|
405
|
+
if (pinned.source === trigger.source && pinned.card.teamKey === trigger.team)
|
|
406
|
+
return { card: pinned.card, trigger, workflow: name };
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
409
|
+
let cards;
|
|
410
|
+
try {
|
|
411
|
+
cards = await rc.trackers(trigger.source).myCardsWithLabel(trigger.label, trigger.team);
|
|
412
|
+
}
|
|
413
|
+
catch (err) {
|
|
414
|
+
// One tracker being down or unset must not stop the others.
|
|
415
|
+
rc.log(`Could not check ${trigger.source === 'voidtrack' ? 'VoidTrack' : 'Linear'} for ${trigger.team} + ${trigger.label} (${err.message}). Trying again next minute.`);
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
const card = cards.find((c) => c.teamKey === trigger.team && c.labels.includes(trigger.label));
|
|
419
|
+
if (card)
|
|
420
|
+
return { card, trigger, workflow: name };
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
return null;
|
|
424
|
+
}
|
|
425
|
+
finally {
|
|
426
|
+
if (cronChanged)
|
|
427
|
+
writeCronState(rc.configDir, cron);
|
|
309
428
|
}
|
|
310
|
-
return null;
|
|
311
429
|
}
|
|
312
|
-
// Retries
|
|
430
|
+
// Retries tracker writes that failed while parking. Never throws.
|
|
313
431
|
async function flushPending(rc) {
|
|
314
432
|
const left = [];
|
|
315
433
|
for (const p of readPending(rc.configDir)) {
|
|
316
434
|
try {
|
|
317
|
-
const
|
|
435
|
+
const tracker = rc.trackers(p.source ?? 'linear');
|
|
436
|
+
const card = await tracker.card(p.card);
|
|
318
437
|
if (p.comment) {
|
|
319
|
-
await
|
|
438
|
+
await tracker.comment(card, p.comment);
|
|
320
439
|
p.comment = '';
|
|
321
440
|
}
|
|
322
|
-
await
|
|
441
|
+
await tracker.setLabels(card, withLabels(card, (l) => p.drop.includes(l) || (!!p.dropStatus && isStatusLabel(l)), p.add));
|
|
323
442
|
}
|
|
324
443
|
catch {
|
|
325
444
|
left.push(p);
|
|
@@ -332,27 +451,27 @@ async function flushPending(rc) {
|
|
|
332
451
|
// nothing more to do
|
|
333
452
|
}
|
|
334
453
|
}
|
|
335
|
-
// Takes the working label off a cancelled card and says so. When
|
|
454
|
+
// Takes the working label off a cancelled card and says so. When the tracker
|
|
336
455
|
// cannot be reached the same change is queued for the next tick.
|
|
337
|
-
export async function cancelCard(
|
|
456
|
+
export async function cancelCard(tracker, configDir, cardId, label, source = 'linear') {
|
|
338
457
|
const comment = `**Cancelled.** Add \`${label}\` again to restart.`;
|
|
339
458
|
try {
|
|
340
|
-
const card = await
|
|
341
|
-
await
|
|
342
|
-
await
|
|
459
|
+
const card = await tracker.card(cardId);
|
|
460
|
+
await tracker.setLabels(card, withLabels(card, isStatusLabel, []));
|
|
461
|
+
await tracker.comment(card, comment);
|
|
343
462
|
return true;
|
|
344
463
|
}
|
|
345
464
|
catch {
|
|
346
|
-
queueCancel(configDir, cardId, comment);
|
|
465
|
+
queueCancel(configDir, cardId, comment, source);
|
|
347
466
|
return false;
|
|
348
467
|
}
|
|
349
468
|
}
|
|
350
|
-
function queueCancel(configDir, cardId, comment) {
|
|
469
|
+
function queueCancel(configDir, cardId, comment, source = 'linear') {
|
|
351
470
|
const pending = readPending(configDir);
|
|
352
471
|
if (pending.some((p) => p.card === cardId && p.comment === comment))
|
|
353
472
|
return;
|
|
354
473
|
try {
|
|
355
|
-
writePending(configDir, [...pending, { card: cardId, comment, drop: [], add: [], dropStatus: true }]);
|
|
474
|
+
writePending(configDir, [...pending, { card: cardId, comment, drop: [], add: [], dropStatus: true, source }]);
|
|
356
475
|
}
|
|
357
476
|
catch {
|
|
358
477
|
// nothing more to do
|
|
@@ -385,13 +504,15 @@ export async function tick(rc, only, opts = {}) {
|
|
|
385
504
|
// the run folder is gone
|
|
386
505
|
}
|
|
387
506
|
}
|
|
388
|
-
if (running) {
|
|
507
|
+
if (running && running.record.source !== 'cron') {
|
|
389
508
|
const { card, trigger } = running.record;
|
|
509
|
+
const source = running.record.source ?? 'linear';
|
|
390
510
|
const timeout = new Promise((resolve) => setTimeout(() => resolve(false), 5000).unref());
|
|
391
|
-
const
|
|
511
|
+
const work = (async () => cancelCard(rc.trackers(source), rc.configDir, card, trigger, source))().catch(() => false);
|
|
512
|
+
const finished = await Promise.race([work, timeout]);
|
|
392
513
|
// Did not finish in time: leave the work for the next tick.
|
|
393
514
|
if (!finished)
|
|
394
|
-
queueCancel(rc.configDir, card, `**Cancelled.** Add \`${trigger}\` again to restart
|
|
515
|
+
queueCancel(rc.configDir, card, `**Cancelled.** Add \`${trigger}\` again to restart.`, source);
|
|
395
516
|
}
|
|
396
517
|
clearCurrent(rc.configDir);
|
|
397
518
|
process.exit(143);
|
|
@@ -404,10 +525,10 @@ export async function tick(rc, only, opts = {}) {
|
|
|
404
525
|
return 0;
|
|
405
526
|
let job;
|
|
406
527
|
try {
|
|
407
|
-
job = await nextJob(rc, only, opts.workflow);
|
|
528
|
+
job = await nextJob(rc, only, opts.workflow, { force: !!opts.workflow && !only });
|
|
408
529
|
}
|
|
409
530
|
catch (err) {
|
|
410
|
-
rc.log(`Could not
|
|
531
|
+
rc.log(`Could not read ${only} (${err.message}).`);
|
|
411
532
|
return 1;
|
|
412
533
|
}
|
|
413
534
|
if (!job) {
|
|
@@ -421,8 +542,8 @@ export async function tick(rc, only, opts = {}) {
|
|
|
421
542
|
rc.log(`Workflow ${job.workflow} points at a missing project ${workflow.project}. Run vsf agents workflow edit to fix it.`);
|
|
422
543
|
return 1;
|
|
423
544
|
}
|
|
424
|
-
if (opts.from && !workflow.steps.
|
|
425
|
-
rc.log(`Workflow ${job.workflow} has no ${
|
|
545
|
+
if (opts.from && !workflow.steps.some((s) => stepId(s) === opts.from)) {
|
|
546
|
+
rc.log(`Workflow ${job.workflow} has no ${stepIdLabel(opts.from)} step.`);
|
|
426
547
|
return 2;
|
|
427
548
|
}
|
|
428
549
|
const outcome = await runCard(job.card, job.trigger, job.workflow, workflow, project, rc, { from: opts.from });
|
package/dist/state.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type
|
|
1
|
+
import { type Trigger } from './config.ts';
|
|
2
2
|
export interface Current {
|
|
3
3
|
pid: number;
|
|
4
4
|
card?: string;
|
|
@@ -8,6 +8,7 @@ export interface Current {
|
|
|
8
8
|
round?: number;
|
|
9
9
|
startedAt?: string;
|
|
10
10
|
runDir?: string;
|
|
11
|
+
source?: Trigger['source'];
|
|
11
12
|
}
|
|
12
13
|
export declare const lockPath: (configDir: string) => string;
|
|
13
14
|
export declare function readCurrent(configDir: string): Current | null;
|
|
@@ -25,12 +26,13 @@ export interface Pending {
|
|
|
25
26
|
drop: string[];
|
|
26
27
|
add: string[];
|
|
27
28
|
dropStatus?: boolean;
|
|
29
|
+
source?: 'linear' | 'voidtrack';
|
|
28
30
|
}
|
|
29
31
|
export declare function readPending(configDir: string): Pending[];
|
|
30
32
|
export declare function writePending(configDir: string, pending: Pending[]): void;
|
|
31
33
|
export type Outcome = 'running' | 'verified' | 'changes' | 'parked' | 'cancelled';
|
|
32
34
|
export interface StepRecord {
|
|
33
|
-
step:
|
|
35
|
+
step: string;
|
|
34
36
|
label: string;
|
|
35
37
|
startedAt: string;
|
|
36
38
|
endedAt?: string;
|
|
@@ -44,6 +46,7 @@ export interface RunRecord {
|
|
|
44
46
|
url?: string;
|
|
45
47
|
workflow: string;
|
|
46
48
|
trigger: string;
|
|
49
|
+
source?: Trigger['source'];
|
|
47
50
|
steps: StepRecord[];
|
|
48
51
|
outcome: Outcome;
|
|
49
52
|
reason?: string;
|
|
@@ -61,3 +64,5 @@ export interface RunEntry {
|
|
|
61
64
|
logs: string[];
|
|
62
65
|
}
|
|
63
66
|
export declare function listRuns(configDir: string, card?: string): RunEntry[];
|
|
67
|
+
export declare function readCronState(configDir: string): Record<string, string>;
|
|
68
|
+
export declare function writeCronState(configDir: string, state: Record<string, string>): void;
|
package/dist/state.js
CHANGED
|
@@ -105,3 +105,18 @@ export function listRuns(configDir, card) {
|
|
|
105
105
|
}
|
|
106
106
|
return out.sort((a, b) => (a.stamp < b.stamp ? 1 : -1));
|
|
107
107
|
}
|
|
108
|
+
// When each schedule last fired, by workflow and schedule, so a restart or a
|
|
109
|
+
// busy runner never fires one twice.
|
|
110
|
+
const cronPath = (configDir) => join(agentsDir(configDir), 'cron.json');
|
|
111
|
+
export function readCronState(configDir) {
|
|
112
|
+
try {
|
|
113
|
+
return JSON.parse(readFileSync(cronPath(configDir), 'utf8'));
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return {};
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
export function writeCronState(configDir, state) {
|
|
120
|
+
mkdirSync(agentsDir(configDir), { recursive: true });
|
|
121
|
+
writeFileSync(cronPath(configDir), JSON.stringify(state, null, 2) + '\n');
|
|
122
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Tracker as Kind } from './config.ts';
|
|
2
|
+
import type { Card } from './linear.ts';
|
|
3
|
+
export interface Tracker {
|
|
4
|
+
viewer(): Promise<{
|
|
5
|
+
id: string;
|
|
6
|
+
name: string;
|
|
7
|
+
}>;
|
|
8
|
+
myCardsWithLabel(label: string, team: string): Promise<Card[]>;
|
|
9
|
+
card(identifier: string): Promise<Card>;
|
|
10
|
+
comments(identifier: string): Promise<{
|
|
11
|
+
body: string;
|
|
12
|
+
createdAt: string;
|
|
13
|
+
}[]>;
|
|
14
|
+
comment(card: Card, body: string): Promise<void>;
|
|
15
|
+
setLabels(card: Card, next: string[]): Promise<void>;
|
|
16
|
+
moveTo(card: Card, stateName: string): Promise<boolean>;
|
|
17
|
+
}
|
|
18
|
+
export declare const TRACKERS: Record<Kind, {
|
|
19
|
+
name: string;
|
|
20
|
+
keyHint: string;
|
|
21
|
+
}>;
|
|
22
|
+
export type Trackers = (kind: Kind) => Tracker;
|
|
23
|
+
export declare function trackersFor(configDir: string): Trackers;
|
|
24
|
+
export declare const noTracker: Tracker;
|