@voidlabs.gg/voidshift-agents 0.4.3 → 0.4.4

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/manage.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
- import { AGENTS, STEPS } from "./config.js";
3
+ import { AGENTS, STEPS, describeTrigger, triggerSpec } from "./config.js";
4
+ import { parseCron } from "./cron.js";
4
5
  const isInt = (v, min, max) => typeof v === 'number' && Number.isInteger(v) && v >= min && v <= max;
5
6
  // Everything wrong with one workflow, in plain words. Empty means it is fine.
6
7
  export function validateWorkflow(config, workflow) {
@@ -8,10 +9,20 @@ export function validateWorkflow(config, workflow) {
8
9
  if (!workflow.triggers?.length)
9
10
  problems.push('Needs at least one trigger.');
10
11
  for (const t of workflow.triggers ?? []) {
11
- if (!t.team?.trim())
12
- problems.push('A trigger has no team.');
13
- if (!t.label?.trim())
14
- problems.push('A trigger has no label.');
12
+ if (t.source === 'cron') {
13
+ if (!parseCron(t.schedule ?? ''))
14
+ problems.push(`Schedule ${t.schedule} is not a five field cron schedule, like 0 5 * * 1.`);
15
+ if (!t.task?.trim())
16
+ problems.push('A schedule has no task.');
17
+ }
18
+ else if (t.source !== 'linear' && t.source !== 'voidtrack')
19
+ problems.push(`Unknown trigger source ${t.source}.`);
20
+ else {
21
+ if (!t.team?.trim())
22
+ problems.push('A trigger has no team.');
23
+ if (!t.label?.trim())
24
+ problems.push('A trigger has no label.');
25
+ }
15
26
  }
16
27
  if (!workflow.steps?.length)
17
28
  problems.push('Needs at least one step.');
@@ -27,16 +38,27 @@ export function validateWorkflow(config, workflow) {
27
38
  problems.push('Rounds must be a whole number from 1 to 9.');
28
39
  return problems;
29
40
  }
30
- // "ENG:vs-plan" into a trigger; the team is upper-cased.
41
+ export const TRIGGER_FORMS = 'TEAM:LABEL (Linear), voidtrack:TEAM:LABEL, or cron:SCHEDULE:TASK';
42
+ // "ENG:vs-plan" (Linear, also "linear:ENG:vs-plan"), "voidtrack:ORG:vs-start"
43
+ // or "cron:0 5 * * 1:Update the dependencies" into a trigger. Teams are
44
+ // upper-cased; a schedule must parse.
31
45
  export function parseTrigger(input) {
32
- const i = input.indexOf(':');
33
- if (i < 0)
46
+ const text = input.trim();
47
+ // A source prefix decides the form; a bare TEAM:LABEL is Linear.
48
+ if (text.startsWith('cron:')) {
49
+ const cron = /^cron:([^:]+):([\s\S]+)$/.exec(text);
50
+ const schedule = cron?.[1].trim().replace(/\s+/g, ' ') ?? '';
51
+ const task = cron?.[2].trim() ?? '';
52
+ return parseCron(schedule) && task ? { source: 'cron', schedule, task } : null;
53
+ }
54
+ const m = /^(linear|voidtrack):/.test(text) ? /^(linear|voidtrack):([^:]+):(.+)$/.exec(text) : /^()([^:]+):(.+)$/.exec(text);
55
+ if (!m)
34
56
  return null;
35
- const team = input.slice(0, i).trim().toUpperCase();
36
- const label = input.slice(i + 1).trim();
37
- return team && label ? { source: 'linear', team, label } : null;
57
+ const team = m[2].trim().toUpperCase();
58
+ const label = m[3].trim();
59
+ return team && label ? { source: (m[1] || 'linear'), team, label } : null;
38
60
  }
39
- export const sameTrigger = (a, b) => a.team === b.team && a.label === b.label;
61
+ export const sameTrigger = (a, b) => triggerSpec(a) === triggerSpec(b);
40
62
  // Warning lines for triggers this workflow shares with another one. Only the
41
63
  // first workflow in order starts a card, so the rest never get it.
42
64
  export function duplicateTriggerWarnings(workflows, name, workflow) {
@@ -47,7 +69,7 @@ export function duplicateTriggerWarnings(workflows, name, workflow) {
47
69
  for (const other of owners) {
48
70
  if (other === name)
49
71
  continue;
50
- lines.push(`Warning: ${other} also starts on ${t.team} + ${t.label}, and ${first} wins. vsf agents workflow move changes the order.`);
72
+ lines.push(`Warning: ${other} also starts on ${describeTrigger(t)}, and ${first} wins. vsf agents workflow move changes the order.`);
51
73
  }
52
74
  }
53
75
  return lines;
package/dist/runner.d.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  import { type AgentsConfig, type Workflow, type Project, type StepId, type Trigger } from './config.ts';
2
- import type { Card, Linear } from './linear.ts';
2
+ import type { Card } from './linear.ts';
3
+ import { type Tracker, type Trackers } from './tracker.ts';
3
4
  export interface RunContext {
4
5
  configDir: string;
5
6
  config: AgentsConfig;
6
- linear: Linear;
7
+ trackers: Trackers;
7
8
  log: (line: string) => void;
8
9
  selfCommand: string[];
9
10
  }
@@ -11,13 +12,19 @@ export declare function summarize(output: string): string;
11
12
  export declare function runCard(card: Card, trigger: Trigger, workflowName: string, workflow: Workflow, project: Project, rc: RunContext, opts?: {
12
13
  from?: StepId;
13
14
  }): Promise<'verified' | 'changes' | 'parked'>;
14
- export declare function nextJob(rc: RunContext, only?: string, workflowName?: string): Promise<{
15
+ type Job = {
15
16
  card: Card;
16
17
  trigger: Trigger;
17
18
  workflow: string;
18
- } | null>;
19
- export declare function cancelCard(linear: Linear, configDir: string, cardId: string, label: string): Promise<boolean>;
19
+ };
20
+ export declare function cronCard(workflow: string, task: string, at: Date): Card;
21
+ export declare function nextJob(rc: RunContext, only?: string, workflowName?: string, opts?: {
22
+ force?: boolean;
23
+ now?: Date;
24
+ }): Promise<Job | null>;
25
+ export declare function cancelCard(tracker: Tracker, configDir: string, cardId: string, label: string, source?: 'linear' | 'voidtrack'): Promise<boolean>;
20
26
  export declare function tick(rc: RunContext, only?: string, opts?: {
21
27
  workflow?: string;
22
28
  from?: StepId;
23
29
  }): Promise<number>;
30
+ export {};
package/dist/runner.js CHANGED
@@ -2,11 +2,13 @@ import { existsSync, mkdirSync, readFileSync } from 'node:fs';
2
2
  import { basename, join } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  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";
5
+ import { STEPS, agentsDir, hasWork, isCardTrigger, isProducer } from "./config.js";
6
+ import { lastDue, parseCron } from "./cron.js";
6
7
  import { runAgent, stopAgents } from "./engine.js";
7
8
  import { commitsAhead, defaultBranch, ensureDraftPr, ensureWorktree, headSha, markReady, push, resetWorktree, waitForChecks, waitForPrHead } from "./git.js";
9
+ import { noTracker } from "./tracker.js";
8
10
  import { recall, vaultPath } from "./memory.js";
9
- import { bootTime, clearCurrent, isPaused, ours, readCurrent, readPending, writeCurrent, writePending, writeRun } from "./state.js";
11
+ import { bootTime, clearCurrent, isPaused, ours, readCurrent, readPending, writeCurrent, writePending, writeRun, readCronState, writeCronState } from "./state.js";
10
12
  const packDir = fileURLToPath(new URL('../pack/', import.meta.url));
11
13
  const skill = (name) => readFileSync(join(packDir, name, 'SKILL.md'), 'utf8');
12
14
  const stamp = () => new Date().toISOString().replace(/[:.]/g, '-');
@@ -18,7 +20,7 @@ function withLabels(card, drop, add) {
18
20
  const UNATTENDED = `# Running unattended
19
21
 
20
22
  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.
23
+ tracker connection is not available: the card and any plan are included above.
22
24
  Do not write plan files; return your output as your final message. Make the
23
25
  call the code supports and state it.`;
24
26
  // Agents keep the memory vault the way you do: durable learnings become notes.
@@ -41,20 +43,23 @@ const isVerdict = (s) => STEPS[s].output === 'verdict';
41
43
  // step that produces something, Plan or Dev, and the workflow continues from
42
44
  // there, until everything passes or the loop cap parks the card.
43
45
  export async function runCard(card, trigger, workflowName, workflow, project, rc, opts = {}) {
44
- const { linear, config, log } = rc;
46
+ const { config, log } = rc;
47
+ // A scheduled run has no card, so it has nothing to write back to.
48
+ const linear = isCardTrigger(trigger) ? rc.trackers(trigger.source) : noTracker;
49
+ const label = isCardTrigger(trigger) ? trigger.label : '';
45
50
  const runDir = join(agentsDir(rc.configDir), 'runs', card.identifier, stamp());
46
51
  mkdirSync(runDir, { recursive: true });
47
52
  const say = (line) => log(`${card.identifier} ${line}`);
48
53
  const steps = workflow.steps;
49
54
  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 };
55
+ 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
56
  active = { record, runDir };
52
57
  const save = () => writeRun(runDir, record);
53
58
  save();
54
59
  let loops = 0;
55
60
  // 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.
61
+ 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 });
62
+ // Written before the first tracker call, so a cancel always knows the card.
58
63
  mark('Starting');
59
64
  const begin = (step) => {
60
65
  const rec = { step, label: STEPS[step].label, startedAt: new Date().toISOString() };
@@ -84,8 +89,8 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
84
89
  const setRound = (n) => {
85
90
  loops = n;
86
91
  };
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,
92
+ const stage = (name, round) => linear.setLabels(card, stageLabels(card.labels, label, name, round));
93
+ // The tracker hears from the runner twice: the plan once it has passed review,
89
94
  // and one final comment. Findings stay here until the final one.
90
95
  let lastFindings = '';
91
96
  let planToPost = null;
@@ -97,19 +102,21 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
97
102
  };
98
103
  async function park(reason) {
99
104
  say(`parked: ${reason}`);
100
- const comment = `**Parked:** ${reason}${lastFindings ? `\n\n${lastFindings}` : ''}\n\nFix the cause, then add \`${trigger.label}\` again to retry.`;
105
+ const comment = `**Parked:** ${reason}${lastFindings ? `\n\n${lastFindings}` : ''}\n\nFix the cause, then add \`${label}\` again to retry.`;
101
106
  const add = [LABELS.parked, ...(loops ? [LABELS.version(loops)] : [])];
102
107
  let commented = false;
103
108
  try {
104
109
  await postPlan();
105
110
  await linear.comment(card, comment);
106
111
  commented = true;
107
- await linear.setLabels(card, stageLabels(card.labels, trigger.label, LABELS.parked, loops || undefined));
112
+ await linear.setLabels(card, stageLabels(card.labels, label, LABELS.parked, loops || undefined));
108
113
  }
109
114
  catch (err) {
110
- say(`could not reach Linear to park it (${err.message}). Will retry next minute.`);
115
+ if (!isCardTrigger(trigger))
116
+ return done('parked', reason);
117
+ say(`could not reach ${trigger.source === 'voidtrack' ? 'VoidTrack' : 'Linear'} to park it (${err.message}). Will retry next minute.`);
111
118
  // 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 }]);
119
+ writePending(rc.configDir, [...readPending(rc.configDir), { card: card.identifier, comment: commented ? '' : comment, drop: [label], add, dropStatus: true, source: trigger.source }]);
113
120
  }
114
121
  return done('parked', reason);
115
122
  }
@@ -119,7 +126,8 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
119
126
  const from = opts.from ? steps.indexOf(opts.from) : 0;
120
127
  if (from < 0)
121
128
  throw new Park(`Workflow ${workflowName} has no ${STEPS[opts.from].label} step.`);
122
- let plan = latestPlan(await linear.comments(card.identifier));
129
+ // A scheduled run has no card to plan on, so its task is the plan unless a Plan step writes one.
130
+ let plan = isCardTrigger(trigger) ? latestPlan(await linear.comments(card.identifier)) : card.description ?? null;
123
131
  const firstPlan = steps.indexOf('vs-plan', from);
124
132
  const needsPlan = steps.findIndex((s, n) => n >= from && (s === 'vs-dev' || s === 'vs-plan-review'));
125
133
  if (!plan && needsPlan >= 0 && (firstPlan < 0 || firstPlan > needsPlan)) {
@@ -207,7 +215,7 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
207
215
  if (target < 0) {
208
216
  await postPlan();
209
217
  await linear.comment(card, `**Changes needed.**\n\n${notes}`);
210
- await linear.setLabels(card, stageLabels(card.labels, trigger.label, LABELS.changes, round));
218
+ await linear.setLabels(card, stageLabels(card.labels, label, LABELS.changes, round));
211
219
  say('changes requested');
212
220
  return done('changes');
213
221
  }
@@ -278,7 +286,7 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
278
286
  markReady(worktree, card.branchName);
279
287
  moved = await linear.moveTo(card, config.reviewState);
280
288
  }
281
- await linear.setLabels(card, stageLabels(card.labels, trigger.label, LABELS.verified, loops || undefined));
289
+ await linear.setLabels(card, stageLabels(card.labels, label, LABELS.verified, loops || undefined));
282
290
  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
291
  say('verified');
284
292
  return done('verified');
@@ -292,34 +300,107 @@ export async function runCard(card, trigger, workflowName, workflow, project, rc
292
300
  active = null;
293
301
  }
294
302
  }
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 };
303
+ // A scheduled run's stand-in for a card: the task, on a branch of its own.
304
+ export function cronCard(workflow, task, at) {
305
+ const stamp = at.toISOString().slice(0, 16).replace(/[-:T]/g, '');
306
+ const slug = workflow.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'cron';
307
+ return {
308
+ id: '',
309
+ identifier: `${slug.replace(/-/g, '').toUpperCase()}-${stamp}`,
310
+ title: task.split('\n')[0].slice(0, 80),
311
+ description: task,
312
+ url: '',
313
+ branchName: `vs/${slug}-${stamp}`,
314
+ teamId: '',
315
+ teamKey: '',
316
+ labels: [],
317
+ };
318
+ }
319
+ const cronKey = (workflow, schedule) => `${workflow} ${schedule}`;
320
+ // The first job waiting on any workflow's triggers, in workflow order: a card
321
+ // carrying a trigger label, or a schedule that came due since it last fired.
322
+ // A schedule seen for the first time starts counting from now. With a card,
323
+ // only that card is considered; with a workflow name, only that workflow, and
324
+ // `force` fires its schedule now.
325
+ export async function nextJob(rc, only, workflowName, opts = {}) {
326
+ const now = opts.now ?? new Date();
327
+ const workflows = Object.entries(rc.config.workflows).filter(([name]) => !workflowName || name === workflowName);
328
+ let pinned = null;
329
+ if (only) {
330
+ const team = only.split('-')[0].toUpperCase();
331
+ const sources = [...new Set(workflows.flatMap(([, w]) => w.triggers.filter(isCardTrigger).filter((t) => t.team === team).map((t) => t.source)))];
332
+ for (const source of sources) {
333
+ try {
334
+ pinned = { card: await rc.trackers(source).card(only), source };
335
+ break;
336
+ }
337
+ catch (err) {
338
+ if (source === sources.at(-1))
339
+ throw err;
340
+ }
308
341
  }
342
+ if (!pinned)
343
+ return null;
344
+ }
345
+ const cron = readCronState(rc.configDir);
346
+ let cronChanged = false;
347
+ try {
348
+ for (const [name, workflow] of workflows) {
349
+ for (const trigger of workflow.triggers) {
350
+ if (!isCardTrigger(trigger)) {
351
+ const schedule = only ? null : parseCron(trigger.schedule);
352
+ if (!schedule)
353
+ continue;
354
+ const key = cronKey(name, trigger.schedule);
355
+ const last = cron[key];
356
+ const due = opts.force ? now : last ? lastDue(schedule, new Date(last), now) : null;
357
+ if (!last || due) {
358
+ cron[key] = now.toISOString();
359
+ cronChanged = true;
360
+ }
361
+ if (due)
362
+ return { card: cronCard(name, trigger.task, due), trigger, workflow: name };
363
+ continue;
364
+ }
365
+ // A pinned card may have lost its label (a parked card re-run by hand), so match by team.
366
+ if (pinned) {
367
+ if (pinned.source === trigger.source && pinned.card.teamKey === trigger.team)
368
+ return { card: pinned.card, trigger, workflow: name };
369
+ continue;
370
+ }
371
+ let cards;
372
+ try {
373
+ cards = await rc.trackers(trigger.source).myCardsWithLabel(trigger.label, trigger.team);
374
+ }
375
+ catch (err) {
376
+ // One tracker being down or unset must not stop the others.
377
+ rc.log(`Could not check ${trigger.source === 'voidtrack' ? 'VoidTrack' : 'Linear'} for ${trigger.team} + ${trigger.label} (${err.message}). Trying again next minute.`);
378
+ continue;
379
+ }
380
+ const card = cards.find((c) => c.teamKey === trigger.team && c.labels.includes(trigger.label));
381
+ if (card)
382
+ return { card, trigger, workflow: name };
383
+ }
384
+ }
385
+ return null;
386
+ }
387
+ finally {
388
+ if (cronChanged)
389
+ writeCronState(rc.configDir, cron);
309
390
  }
310
- return null;
311
391
  }
312
- // Retries Linear writes that failed while parking. Never throws.
392
+ // Retries tracker writes that failed while parking. Never throws.
313
393
  async function flushPending(rc) {
314
394
  const left = [];
315
395
  for (const p of readPending(rc.configDir)) {
316
396
  try {
317
- const card = await rc.linear.card(p.card);
397
+ const tracker = rc.trackers(p.source ?? 'linear');
398
+ const card = await tracker.card(p.card);
318
399
  if (p.comment) {
319
- await rc.linear.comment(card, p.comment);
400
+ await tracker.comment(card, p.comment);
320
401
  p.comment = '';
321
402
  }
322
- await rc.linear.setLabels(card, withLabels(card, (l) => p.drop.includes(l) || (!!p.dropStatus && isStatusLabel(l)), p.add));
403
+ await tracker.setLabels(card, withLabels(card, (l) => p.drop.includes(l) || (!!p.dropStatus && isStatusLabel(l)), p.add));
323
404
  }
324
405
  catch {
325
406
  left.push(p);
@@ -332,27 +413,27 @@ async function flushPending(rc) {
332
413
  // nothing more to do
333
414
  }
334
415
  }
335
- // Takes the working label off a cancelled card and says so. When Linear
416
+ // Takes the working label off a cancelled card and says so. When the tracker
336
417
  // cannot be reached the same change is queued for the next tick.
337
- export async function cancelCard(linear, configDir, cardId, label) {
418
+ export async function cancelCard(tracker, configDir, cardId, label, source = 'linear') {
338
419
  const comment = `**Cancelled.** Add \`${label}\` again to restart.`;
339
420
  try {
340
- const card = await linear.card(cardId);
341
- await linear.setLabels(card, withLabels(card, isStatusLabel, []));
342
- await linear.comment(card, comment);
421
+ const card = await tracker.card(cardId);
422
+ await tracker.setLabels(card, withLabels(card, isStatusLabel, []));
423
+ await tracker.comment(card, comment);
343
424
  return true;
344
425
  }
345
426
  catch {
346
- queueCancel(configDir, cardId, comment);
427
+ queueCancel(configDir, cardId, comment, source);
347
428
  return false;
348
429
  }
349
430
  }
350
- function queueCancel(configDir, cardId, comment) {
431
+ function queueCancel(configDir, cardId, comment, source = 'linear') {
351
432
  const pending = readPending(configDir);
352
433
  if (pending.some((p) => p.card === cardId && p.comment === comment))
353
434
  return;
354
435
  try {
355
- writePending(configDir, [...pending, { card: cardId, comment, drop: [], add: [], dropStatus: true }]);
436
+ writePending(configDir, [...pending, { card: cardId, comment, drop: [], add: [], dropStatus: true, source }]);
356
437
  }
357
438
  catch {
358
439
  // nothing more to do
@@ -385,13 +466,15 @@ export async function tick(rc, only, opts = {}) {
385
466
  // the run folder is gone
386
467
  }
387
468
  }
388
- if (running) {
469
+ if (running && running.record.source !== 'cron') {
389
470
  const { card, trigger } = running.record;
471
+ const source = running.record.source ?? 'linear';
390
472
  const timeout = new Promise((resolve) => setTimeout(() => resolve(false), 5000).unref());
391
- const finished = await Promise.race([cancelCard(rc.linear, rc.configDir, card, trigger), timeout]);
473
+ const work = (async () => cancelCard(rc.trackers(source), rc.configDir, card, trigger, source))().catch(() => false);
474
+ const finished = await Promise.race([work, timeout]);
392
475
  // Did not finish in time: leave the work for the next tick.
393
476
  if (!finished)
394
- queueCancel(rc.configDir, card, `**Cancelled.** Add \`${trigger}\` again to restart.`);
477
+ queueCancel(rc.configDir, card, `**Cancelled.** Add \`${trigger}\` again to restart.`, source);
395
478
  }
396
479
  clearCurrent(rc.configDir);
397
480
  process.exit(143);
@@ -404,10 +487,10 @@ export async function tick(rc, only, opts = {}) {
404
487
  return 0;
405
488
  let job;
406
489
  try {
407
- job = await nextJob(rc, only, opts.workflow);
490
+ job = await nextJob(rc, only, opts.workflow, { force: !!opts.workflow && !only });
408
491
  }
409
492
  catch (err) {
410
- rc.log(`Could not check Linear (${err.message}). Trying again next minute.`);
493
+ rc.log(`Could not read ${only} (${err.message}).`);
411
494
  return 1;
412
495
  }
413
496
  if (!job) {
package/dist/state.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type StepId } from './config.ts';
1
+ import { type StepId, 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,6 +26,7 @@ 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;
@@ -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;
@@ -0,0 +1,36 @@
1
+ import { createLinear } from "./linear.js";
2
+ import { readSecret } from "./secrets.js";
3
+ import { createVoidTrack } from "./voidtrack.js";
4
+ export const TRACKERS = {
5
+ linear: { name: 'Linear', keyHint: 'Linear > Settings > Security & access > API keys' },
6
+ voidtrack: { name: 'VoidTrack', keyHint: 'VoidTrack > Settings > API keys' },
7
+ };
8
+ export function trackersFor(configDir) {
9
+ const open = new Map();
10
+ return (kind) => {
11
+ let t = open.get(kind);
12
+ if (!t) {
13
+ const key = readSecret(configDir, kind);
14
+ if (!key)
15
+ throw new Error(`No ${TRACKERS[kind].name} API key. Run vsf agents key${kind === 'linear' ? '' : ` --${kind}`}`);
16
+ t = kind === 'voidtrack' ? createVoidTrack(key) : createLinear(key);
17
+ open.set(kind, t);
18
+ }
19
+ return t;
20
+ };
21
+ }
22
+ // Stands in for a tracker on a scheduled run, which has no card: reads find
23
+ // nothing and writes go nowhere.
24
+ export const noTracker = {
25
+ viewer: async () => ({ id: '', name: '' }),
26
+ myCardsWithLabel: async () => [],
27
+ card: async (id) => {
28
+ throw new Error(`a scheduled run has no card ${id}`);
29
+ },
30
+ comments: async () => [],
31
+ comment: async () => { },
32
+ setLabels: async (card, next) => {
33
+ card.labels = next;
34
+ },
35
+ moveTo: async () => false,
36
+ };
@@ -0,0 +1,18 @@
1
+ import type { Card } from './linear.ts';
2
+ export declare const branchFor: (identifier: string) => string;
3
+ export declare function mapIssue(i: any, web: string): Card;
4
+ export declare function createVoidTrack(apiKey: string, fetcher?: typeof fetch): {
5
+ viewer(): Promise<{
6
+ id: string;
7
+ name: string;
8
+ }>;
9
+ myCardsWithLabel(label: string, team: string): Promise<Card[]>;
10
+ card(identifier: string): Promise<Card>;
11
+ comments(identifier: string): Promise<{
12
+ body: string;
13
+ createdAt: string;
14
+ }[]>;
15
+ comment(card: Card, body: string): Promise<void>;
16
+ setLabels(card: Card, next: string[]): Promise<void>;
17
+ moveTo(card: Card, stateName: string): Promise<boolean>;
18
+ };
@@ -0,0 +1,89 @@
1
+ // VoidTrack over its REST API with a bearer key. Self hosted installs set
2
+ // VOIDTRACK_API_URL; the web app lives at the same address without /api.
3
+ const apiBase = () => (process.env.VOIDTRACK_API_URL ?? 'https://web.voidtrack.app/api').replace(/\/$/, '');
4
+ // One branch per card that survives a retitle: GitHub closes a pull request
5
+ // whose branch is renamed.
6
+ export const branchFor = (identifier) => identifier.toLowerCase();
7
+ export function mapIssue(i, web) {
8
+ return {
9
+ id: i.id,
10
+ identifier: i.identifier,
11
+ title: i.title,
12
+ description: i.description ?? undefined,
13
+ url: `${web}/issue/${i.identifier}`,
14
+ branchName: branchFor(i.identifier),
15
+ teamId: i.team?.id ?? '',
16
+ teamKey: i.team?.key ?? i.identifier.split('-')[0],
17
+ projectName: i.project?.name ?? undefined,
18
+ labels: (i.labels ?? []).map((l) => (typeof l === 'string' ? l : l.name)),
19
+ };
20
+ }
21
+ export function createVoidTrack(apiKey, fetcher = fetch) {
22
+ const base = apiBase();
23
+ const web = base.replace(/\/api$/, '');
24
+ async function call(method, path, body) {
25
+ let res;
26
+ try {
27
+ res = await fetcher(`${base}${path}`, {
28
+ method,
29
+ signal: AbortSignal.timeout(30_000),
30
+ headers: { Authorization: `Bearer ${apiKey}`, ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) },
31
+ body: body === undefined ? undefined : JSON.stringify(body),
32
+ });
33
+ }
34
+ catch (err) {
35
+ const cause = err.cause;
36
+ throw new Error(`could not reach VoidTrack (${cause?.code ?? cause?.message ?? err.message})`);
37
+ }
38
+ const text = await res.text();
39
+ if (!res.ok)
40
+ throw Object.assign(new Error(`voidtrack ${method} ${path.split('?')[0]} ${res.status}: ${text.slice(0, 200)}`), { status: res.status });
41
+ return text ? JSON.parse(text) : {};
42
+ }
43
+ const enc = encodeURIComponent;
44
+ return {
45
+ async viewer() {
46
+ const d = await call('GET', '/v1/users');
47
+ const me = (d.users ?? []).find((u) => u.isMe);
48
+ if (!me)
49
+ throw new Error('voidtrack did not say which user this key belongs to');
50
+ return { id: me.id, name: me.name };
51
+ },
52
+ // My open cards in the team carrying the label. The API cannot filter on
53
+ // labels, so that part happens here.
54
+ async myCardsWithLabel(label, team) {
55
+ const d = await call('GET', `/v1/issues?team=${enc(team)}&assignee=me&stateType=backlog,unstarted,started&sort=created&limit=200`);
56
+ return (d.issues ?? []).map((i) => mapIssue(i, web)).filter((c) => c.labels.includes(label));
57
+ },
58
+ async card(identifier) {
59
+ const d = await call('GET', `/v1/issues/${enc(identifier)}`);
60
+ if (!d.issue)
61
+ throw new Error(`voidtrack has no card ${identifier}`);
62
+ return mapIssue(d.issue, web);
63
+ },
64
+ async comments(identifier) {
65
+ const d = await call('GET', `/v1/issues/${enc(identifier)}/comments`);
66
+ return (d.comments ?? []).map((c) => ({ body: c.body, createdAt: c.createdAt }));
67
+ },
68
+ async comment(card, body) {
69
+ await call('POST', `/v1/issues/${enc(card.identifier)}/comments`, { body });
70
+ },
71
+ // Labels are free text on VoidTrack and the list replaces the whole set.
72
+ async setLabels(card, next) {
73
+ await call('PATCH', `/v1/issues/${enc(card.identifier)}`, { labels: next });
74
+ card.labels = next;
75
+ },
76
+ // Move to a named state; false if the team has no such state.
77
+ async moveTo(card, stateName) {
78
+ try {
79
+ await call('PATCH', `/v1/issues/${enc(card.identifier)}`, { state: stateName });
80
+ return true;
81
+ }
82
+ catch (err) {
83
+ if (err.status === 400 || err.status === 404)
84
+ return false;
85
+ throw err;
86
+ }
87
+ },
88
+ };
89
+ }