@voidlabs.gg/voidshift-agents 0.4.2 → 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/README.md +18 -6
- package/dist/config.d.ts +12 -2
- package/dist/config.js +14 -1
- package/dist/control.d.ts +4 -2
- package/dist/control.js +79 -52
- package/dist/cron.d.ts +9 -0
- package/dist/cron.js +66 -0
- package/dist/index.js +77 -44
- package/dist/launchd.d.ts +1 -0
- package/dist/launchd.js +6 -0
- package/dist/linear.d.ts +1 -1
- package/dist/linear.js +3 -3
- package/dist/manage.d.ts +1 -0
- package/dist/manage.js +35 -13
- package/dist/runner.d.ts +12 -5
- package/dist/runner.js +130 -47
- package/dist/secrets.d.ts +9 -0
- package/dist/secrets.js +55 -0
- package/dist/service.d.ts +9 -0
- package/dist/service.js +18 -0
- package/dist/state.d.ts +6 -1
- package/dist/state.js +15 -0
- package/dist/systemd.d.ts +14 -0
- package/dist/systemd.js +85 -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/package.json +3 -5
- package/dist/keychain.d.ts +0 -3
- package/dist/keychain.js +0 -19
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
|
-
|
|
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 {
|
|
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.
|
|
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
|
|
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,
|
|
88
|
-
//
|
|
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 \`${
|
|
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,
|
|
112
|
+
await linear.setLabels(card, stageLabels(card.labels, label, LABELS.parked, loops || undefined));
|
|
108
113
|
}
|
|
109
114
|
catch (err) {
|
|
110
|
-
|
|
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: [
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
-
//
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
const
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
|
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
|
|
397
|
+
const tracker = rc.trackers(p.source ?? 'linear');
|
|
398
|
+
const card = await tracker.card(p.card);
|
|
318
399
|
if (p.comment) {
|
|
319
|
-
await
|
|
400
|
+
await tracker.comment(card, p.comment);
|
|
320
401
|
p.comment = '';
|
|
321
402
|
}
|
|
322
|
-
await
|
|
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
|
|
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(
|
|
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
|
|
341
|
-
await
|
|
342
|
-
await
|
|
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
|
|
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
|
|
493
|
+
rc.log(`Could not read ${only} (${err.message}).`);
|
|
411
494
|
return 1;
|
|
412
495
|
}
|
|
413
496
|
if (!job) {
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export declare const looksLikeLinearKey: (key: string) => boolean;
|
|
2
|
+
export declare const secretsPath: (configDir: string) => string;
|
|
3
|
+
export declare const keyStore: (platform?: NodeJS.Platform) => "your macOS Keychain" | "a file only you can read";
|
|
4
|
+
export declare function readSecretsFile(path: string): Record<string, string>;
|
|
5
|
+
export declare function writeSecretsFile(path: string, values: Record<string, string>): void;
|
|
6
|
+
export declare function readSecret(configDir: string, name: string, platform?: NodeJS.Platform): string | null;
|
|
7
|
+
export declare function storeSecret(configDir: string, name: string, value: string, platform?: NodeJS.Platform): boolean;
|
|
8
|
+
export declare const readLinearKey: (configDir: string) => string | null;
|
|
9
|
+
export declare const storeLinearKey: (configDir: string, key: string) => boolean;
|
package/dist/secrets.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { userInfo } from 'node:os';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import { agentsDir } from "./config.js";
|
|
6
|
+
// Keys live in the macOS Keychain, or on other platforms in a file only this
|
|
7
|
+
// user can read, in the agents config folder.
|
|
8
|
+
const KEY_SHAPE = /^lin_api_[A-Za-z0-9]+$/;
|
|
9
|
+
// Values the Keychain is handed on stdin; anything that could break out of
|
|
10
|
+
// the `security -i` command line is refused.
|
|
11
|
+
const SAFE_VALUE = /^[A-Za-z0-9_\-.:/+=]+$/;
|
|
12
|
+
export const looksLikeLinearKey = (key) => KEY_SHAPE.test(key);
|
|
13
|
+
export const secretsPath = (configDir) => join(agentsDir(configDir), 'secrets.json');
|
|
14
|
+
// Where keys are kept on this machine, for messages.
|
|
15
|
+
export const keyStore = (platform = process.platform) => platform === 'darwin' ? 'your macOS Keychain' : 'a file only you can read';
|
|
16
|
+
// The secrets file, refused when anyone but its owner can read it.
|
|
17
|
+
export function readSecretsFile(path) {
|
|
18
|
+
if (!existsSync(path))
|
|
19
|
+
return {};
|
|
20
|
+
const mode = statSync(path).mode & 0o777;
|
|
21
|
+
if (mode & 0o077)
|
|
22
|
+
throw new Error(`${path} can be read by other users (mode ${mode.toString(8)}). Run chmod 600 on it.`);
|
|
23
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
24
|
+
}
|
|
25
|
+
// Written to a temporary file with mode 0600, then renamed into place, so the
|
|
26
|
+
// file is never readable by others, not even for a moment.
|
|
27
|
+
export function writeSecretsFile(path, values) {
|
|
28
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
29
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
30
|
+
writeFileSync(tmp, `${JSON.stringify(values, null, 2)}\n`, { mode: 0o600 });
|
|
31
|
+
chmodSync(tmp, 0o600);
|
|
32
|
+
renameSync(tmp, path);
|
|
33
|
+
}
|
|
34
|
+
const service = (name) => `voidshift-${name}`;
|
|
35
|
+
export function readSecret(configDir, name, platform = process.platform) {
|
|
36
|
+
if (platform === 'darwin') {
|
|
37
|
+
const r = spawnSync('/usr/bin/security', ['find-generic-password', '-s', service(name), '-w'], { encoding: 'utf8' });
|
|
38
|
+
return (r.status === 0 ? r.stdout.trim() : '') || null;
|
|
39
|
+
}
|
|
40
|
+
return readSecretsFile(secretsPath(configDir))[name] || null;
|
|
41
|
+
}
|
|
42
|
+
export function storeSecret(configDir, name, value, platform = process.platform) {
|
|
43
|
+
if (!SAFE_VALUE.test(value) || !/^[a-z-]+$/.test(name))
|
|
44
|
+
return false;
|
|
45
|
+
if (platform === 'darwin') {
|
|
46
|
+
// On stdin through `security -i`, so the value never appears in a process list.
|
|
47
|
+
const command = `add-generic-password -U -s ${service(name)} -a ${userInfo().username} -w ${value}\n`;
|
|
48
|
+
return spawnSync('/usr/bin/security', ['-i'], { input: command, encoding: 'utf8' }).status === 0;
|
|
49
|
+
}
|
|
50
|
+
const path = secretsPath(configDir);
|
|
51
|
+
writeSecretsFile(path, { ...readSecretsFile(path), [name]: value });
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
export const readLinearKey = (configDir) => readSecret(configDir, 'linear');
|
|
55
|
+
export const storeLinearKey = (configDir, key) => looksLikeLinearKey(key) && storeSecret(configDir, 'linear', key);
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Check } from './types.ts';
|
|
2
|
+
export interface RunnerService {
|
|
3
|
+
loaded(): boolean;
|
|
4
|
+
start(home: string, program: string[], logPath: string): boolean;
|
|
5
|
+
stop(home: string): void;
|
|
6
|
+
programArgs(home: string): string[] | null;
|
|
7
|
+
checks(): Check[];
|
|
8
|
+
}
|
|
9
|
+
export declare const runnerService: (platform?: NodeJS.Platform) => RunnerService;
|
package/dist/service.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import * as launchd from "./launchd.js";
|
|
3
|
+
import * as systemd from "./systemd.js";
|
|
4
|
+
const mac = {
|
|
5
|
+
loaded: launchd.runnerLoaded,
|
|
6
|
+
start: launchd.startRunner,
|
|
7
|
+
stop: launchd.stopRunner,
|
|
8
|
+
programArgs: (home) => (existsSync(launchd.plistPath(home)) ? launchd.parsePlistArgs(readFileSync(launchd.plistPath(home), 'utf8')) : null),
|
|
9
|
+
checks: () => [],
|
|
10
|
+
};
|
|
11
|
+
const linux = {
|
|
12
|
+
loaded: systemd.runnerLoaded,
|
|
13
|
+
start: (home, program, logPath) => systemd.startRunner(home, program, launchd.runnerEnv(), logPath),
|
|
14
|
+
stop: systemd.stopRunner,
|
|
15
|
+
programArgs: systemd.programArgs,
|
|
16
|
+
checks: () => [systemd.lingerCheck()],
|
|
17
|
+
};
|
|
18
|
+
export const runnerService = (platform = process.platform) => (platform === 'darwin' ? mac : linux);
|
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,14 @@
|
|
|
1
|
+
import type { Check } from './types.ts';
|
|
2
|
+
export declare const UNIT = "voidshift-agents";
|
|
3
|
+
export declare const servicePath: (home: string) => string;
|
|
4
|
+
export declare const timerPath: (home: string) => string;
|
|
5
|
+
export declare function quote(s: string): string;
|
|
6
|
+
export declare function unquote(s: string): string;
|
|
7
|
+
export declare function serviceUnit(program: string[], env: Record<string, string>, logPath: string): string;
|
|
8
|
+
export declare const timerUnit: () => string;
|
|
9
|
+
export declare function parseExecStart(unit: string): string[];
|
|
10
|
+
export declare const runnerLoaded: () => boolean;
|
|
11
|
+
export declare function startRunner(home: string, program: string[], env: Record<string, string>, logPath: string): boolean;
|
|
12
|
+
export declare function stopRunner(home: string): void;
|
|
13
|
+
export declare function programArgs(home: string): string[] | null;
|
|
14
|
+
export declare function lingerCheck(): Check;
|
package/dist/systemd.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { userInfo } from 'node:os';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
// The runner on Linux: a systemd user service that runs one check, and a
|
|
6
|
+
// timer that starts it a minute after the last one finished.
|
|
7
|
+
export const UNIT = 'voidshift-agents';
|
|
8
|
+
const unitDir = (home) => join(process.env.XDG_CONFIG_HOME ?? join(home, '.config'), 'systemd', 'user');
|
|
9
|
+
export const servicePath = (home) => join(unitDir(home), `${UNIT}.service`);
|
|
10
|
+
export const timerPath = (home) => join(unitDir(home), `${UNIT}.timer`);
|
|
11
|
+
// `systemctl --user` needs the user's runtime dir, which a `sudo -iu` or `su`
|
|
12
|
+
// shell does not set.
|
|
13
|
+
function systemctl(args) {
|
|
14
|
+
const env = { ...process.env, XDG_RUNTIME_DIR: process.env.XDG_RUNTIME_DIR ?? `/run/user/${userInfo().uid}` };
|
|
15
|
+
const r = spawnSync('systemctl', ['--user', ...args], { encoding: 'utf8', env });
|
|
16
|
+
return { ok: r.status === 0, stdout: (r.stdout ?? '').trim() };
|
|
17
|
+
}
|
|
18
|
+
// One word of a unit file line: quoted, with the characters systemd expands
|
|
19
|
+
// escaped.
|
|
20
|
+
export function quote(s) {
|
|
21
|
+
return `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/%/g, '%%').replace(/\$/g, '$$$$')}"`;
|
|
22
|
+
}
|
|
23
|
+
export function unquote(s) {
|
|
24
|
+
return s.replace(/\$\$/g, '$').replace(/%%/g, '%').replace(/\\(.)/g, '$1');
|
|
25
|
+
}
|
|
26
|
+
export function serviceUnit(program, env, logPath) {
|
|
27
|
+
const vars = Object.entries(env).map(([k, v]) => `Environment=${quote(`${k}=${v}`)}`);
|
|
28
|
+
return `[Unit]
|
|
29
|
+
Description=VoidShift agents runner, one check for cards to work on
|
|
30
|
+
|
|
31
|
+
[Service]
|
|
32
|
+
Type=oneshot
|
|
33
|
+
ExecStart=${program.map(quote).join(' ')}
|
|
34
|
+
${vars.join('\n')}
|
|
35
|
+
StandardOutput=append:${logPath}
|
|
36
|
+
StandardError=append:${logPath}
|
|
37
|
+
TimeoutStartSec=infinity
|
|
38
|
+
`;
|
|
39
|
+
}
|
|
40
|
+
export const timerUnit = () => `[Unit]
|
|
41
|
+
Description=Starts the VoidShift agents runner every minute
|
|
42
|
+
|
|
43
|
+
[Timer]
|
|
44
|
+
OnActiveSec=5s
|
|
45
|
+
OnUnitInactiveSec=60s
|
|
46
|
+
AccuracySec=1s
|
|
47
|
+
|
|
48
|
+
[Install]
|
|
49
|
+
WantedBy=timers.target
|
|
50
|
+
`;
|
|
51
|
+
// The words of a service unit's ExecStart line.
|
|
52
|
+
export function parseExecStart(unit) {
|
|
53
|
+
const line = /^ExecStart=(.*)$/m.exec(unit)?.[1] ?? '';
|
|
54
|
+
return [...line.matchAll(/"((?:\\.|[^"\\])*)"|(\S+)/g)].map((m) => (m[1] !== undefined ? unquote(m[1]) : m[2]));
|
|
55
|
+
}
|
|
56
|
+
export const runnerLoaded = () => systemctl(['is-active', '--quiet', `${UNIT}.timer`]).ok;
|
|
57
|
+
export function startRunner(home, program, env, logPath) {
|
|
58
|
+
mkdirSync(unitDir(home), { recursive: true });
|
|
59
|
+
mkdirSync(dirname(logPath), { recursive: true });
|
|
60
|
+
writeFileSync(servicePath(home), serviceUnit(program, env, logPath));
|
|
61
|
+
writeFileSync(timerPath(home), timerUnit());
|
|
62
|
+
if (!systemctl(['daemon-reload']).ok)
|
|
63
|
+
return false;
|
|
64
|
+
systemctl(['restart', `${UNIT}.timer`]);
|
|
65
|
+
return systemctl(['enable', '--now', `${UNIT}.timer`]).ok;
|
|
66
|
+
}
|
|
67
|
+
export function stopRunner(home) {
|
|
68
|
+
systemctl(['disable', '--now', `${UNIT}.timer`]);
|
|
69
|
+
systemctl(['stop', `${UNIT}.service`]);
|
|
70
|
+
rmSync(servicePath(home), { force: true });
|
|
71
|
+
rmSync(timerPath(home), { force: true });
|
|
72
|
+
systemctl(['daemon-reload']);
|
|
73
|
+
}
|
|
74
|
+
export function programArgs(home) {
|
|
75
|
+
return existsSync(servicePath(home)) ? parseExecStart(readFileSync(servicePath(home), 'utf8')) : null;
|
|
76
|
+
}
|
|
77
|
+
// Without lingering, systemd stops the user's timers when they log out and
|
|
78
|
+
// does not start them after a reboot.
|
|
79
|
+
export function lingerCheck() {
|
|
80
|
+
const user = userInfo().username;
|
|
81
|
+
const r = spawnSync('loginctl', ['show-user', user, '--property=Linger', '--value'], { encoding: 'utf8' });
|
|
82
|
+
return r.status === 0 && r.stdout.trim() === 'yes'
|
|
83
|
+
? { name: 'runner after logout', status: 'ok', detail: 'lingering is on, the runner keeps going after logout and reboot' }
|
|
84
|
+
: { name: 'runner after logout', status: 'warn', detail: 'lingering is off, the runner stops when you log out', fix: `sudo loginctl enable-linger ${user}` };
|
|
85
|
+
}
|
|
@@ -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;
|
package/dist/tracker.js
ADDED
|
@@ -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
|
+
};
|