@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/dist/runner.js CHANGED
@@ -1,14 +1,14 @@
1
- import { existsSync, mkdirSync, readFileSync } from 'node:fs';
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 { STEPS, agentsDir, hasWork, isProducer } from "./config.js";
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 { bootTime, clearCurrent, isPaused, ours, readCurrent, readPending, writeCurrent, writePending, writeRun } from "./state.js";
10
- const packDir = fileURLToPath(new URL('../pack/', import.meta.url));
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
- Linear connection is not available: the card and any plan are included above.
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 = (s) => STEPS[s].output === 'verdict';
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 { linear, config, log } = rc;
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
- const steps = workflow.steps;
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.label, steps: [], outcome: 'running', startedAt };
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 Linear call, so a cancel always knows the card.
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 = (step) => {
60
- const rec = { step, label: STEPS[step].label, startedAt: new Date().toISOString() };
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) => linear.setLabels(card, stageLabels(card.labels, trigger.label, name, round));
88
- // Linear hears from the runner twice: the plan once it has passed review,
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 \`${trigger.label}\` again to retry.`;
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 linear.setLabels(card, stageLabels(card.labels, trigger.label, LABELS.parked, loops || undefined));
121
+ await endLabels(LABELS.parked, loops || undefined);
108
122
  }
109
123
  catch (err) {
110
- say(`could not reach Linear to park it (${err.message}). Will retry next minute.`);
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: [trigger.label], add, dropStatus: true }]);
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 from = opts.from ? steps.indexOf(opts.from) : 0;
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 ${STEPS[opts.from].label} step.`);
122
- let plan = latestPlan(await linear.comments(card.identifier));
123
- const firstPlan = steps.indexOf('vs-plan', from);
124
- const needsPlan = steps.findIndex((s, n) => n >= from && (s === 'vs-dev' || s === 'vs-plan-review'));
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(LABELS.stage[steps[from]]);
147
+ await stage(stageOf(steps[from]));
129
148
  const base = project.base || defaultBranch(project.path);
130
149
  const vault = vaultPath(rc.configDir);
131
- const known = [recall(rc.selfCommand, rc.configDir, `${workflow.project} ${card.title}\n${card.description ?? ''}`), vault ? memoryNote(vault) : ''].filter(Boolean).join('\n\n');
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(step, round) {
143
- const label = STEPS[step].label;
144
- const rec = begin(step);
169
+ async function check(def, round) {
170
+ const label = def.label;
171
+ const rec = begin(def);
145
172
  let ciNote = '';
146
- if (step === 'vs-qa') {
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(step);
201
+ const log = logFile(def.id);
175
202
  const r = await runAgent({
176
- engine: config.engine, model: config.models.review, cwd: worktree, vault, write: step === 'vs-qa', logFile: log, timeoutMs,
177
- prompt: buildPrompt({ skill: skill(step), card, plan: planText(), base, round, extra: [known, ciNote, UNATTENDED].filter(Boolean).join('\n\n') }),
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 (step === 'vs-qa')
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 step = steps[i];
188
- say(`${STEPS[step].label}`);
189
- if (isVerdict(step)) {
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 === 'vs-plan' ? runs.plan : runs.dev, 1);
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(LABELS.stage[s], round);
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 linear.setLabels(card, stageLabels(card.labels, trigger.label, LABELS.changes, round));
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 === 'vs-plan' ? 'plan' : 'dev'} rounds.${prUrl ? ` PR: ${prUrl}` : ''}`);
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 (step === 'vs-plan') {
249
+ if (def.output === 'plan') {
223
250
  setRound(runs.plan + 1);
224
- await stage(LABELS.stage[step], runs.plan + 1);
225
- const rec = begin(step);
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: config.engine, model: config.models.dev, cwd: worktree, vault, write: false, logFile: log, timeoutMs,
229
- prompt: buildPrompt({ skill: skill('vs-plan'), 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') }),
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 (step === 'vs-publish') {
266
+ else if (def.output === 'publish') {
240
267
  await postPlan();
241
- const rec = begin(step);
268
+ const rec = begin(def);
242
269
  push(worktree, card.branchName);
243
270
  end(rec, 'pushed');
244
271
  }
245
- else if (step === 'vs-dev') {
272
+ else if (def.output === 'commits') {
246
273
  await postPlan();
247
274
  setRound(runs.dev + 1);
248
- await stage(LABELS.stage[step], runs.dev + 1);
249
- const rec = begin(step);
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: config.engine, model: config.models.dev, cwd: worktree, vault, write: true, logFile: log, timeoutMs,
254
- prompt: buildPrompt({ skill: skill('vs-dev'), card, plan: planText(), base, round: runs.dev + 1, findings: findings.get(i), extra: [known, UNATTENDED].filter(Boolean).join('\n\n') }),
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
- let moved = false;
277
- if (hasWork(workflow)) {
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
- moved = await linear.moveTo(card, config.reviewState);
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
- // The first card waiting on any workflow's triggers, in workflow order.
296
- // With a workflow name, only that workflow is considered.
297
- export async function nextJob(rc, only, workflowName) {
298
- const pinned = only ? await rc.linear.card(only) : null;
299
- for (const [name, workflow] of Object.entries(rc.config.workflows)) {
300
- if (workflowName && name !== workflowName)
301
- continue;
302
- for (const trigger of workflow.triggers) {
303
- const cards = pinned ? [pinned] : await rc.linear.myCardsWithLabel(trigger.label);
304
- // A pinned card may have lost its label (a parked card re-run by hand), so match by team.
305
- const card = cards.find((c) => c.teamKey === trigger.team && (pinned || c.labels.includes(trigger.label)));
306
- if (card)
307
- return { card, trigger, workflow: name };
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 Linear writes that failed while parking. Never throws.
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 card = await rc.linear.card(p.card);
435
+ const tracker = rc.trackers(p.source ?? 'linear');
436
+ const card = await tracker.card(p.card);
318
437
  if (p.comment) {
319
- await rc.linear.comment(card, p.comment);
438
+ await tracker.comment(card, p.comment);
320
439
  p.comment = '';
321
440
  }
322
- await rc.linear.setLabels(card, withLabels(card, (l) => p.drop.includes(l) || (!!p.dropStatus && isStatusLabel(l)), p.add));
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 Linear
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(linear, configDir, cardId, label) {
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 linear.card(cardId);
341
- await linear.setLabels(card, withLabels(card, isStatusLabel, []));
342
- await linear.comment(card, comment);
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 finished = await Promise.race([cancelCard(rc.linear, rc.configDir, card, trigger), timeout]);
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 check Linear (${err.message}). Trying again next minute.`);
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.includes(opts.from)) {
425
- rc.log(`Workflow ${job.workflow} has no ${STEPS[opts.from].label} step.`);
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 StepId } from './config.ts';
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: StepId;
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;