atris 3.71.2 → 3.72.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/atris/skills/campaigns/SKILL.md +1 -1
- package/bin/atris.js +8 -2
- package/commands/autoland.js +102 -2
- package/commands/campaign.js +263 -11
- package/commands/clean.js +60 -4
- package/commands/gm.js +1273 -7
- package/commands/improve.js +3 -2
- package/commands/member.js +28 -3
- package/commands/mission.js +82 -27
- package/commands/pulse.js +89 -5
- package/commands/sync.js +2 -2
- package/commands/task.js +49 -13
- package/commands/worktree.js +24 -2
- package/commands/zen.js +183 -0
- package/lib/auto-accept-certified.js +9 -4
- package/lib/campaign-cadence.js +53 -14
- package/lib/campaign-core.js +98 -17
- package/lib/campaign-keep.js +91 -0
- package/lib/campaign-lanes.js +11 -6
- package/lib/campaign-loop.js +173 -32
- package/lib/campaign-reading.js +14 -1
- package/lib/campaign-rsi.js +697 -0
- package/lib/campaign-run-lock.js +9 -1
- package/lib/campaign-schedule.js +124 -0
- package/lib/campaign-schema.js +3 -1
- package/lib/campaign-store.js +34 -2
- package/lib/campaign-trace.js +77 -29
- package/lib/engine-resource.js +5 -1
- package/lib/gm-bet.js +2219 -0
- package/lib/gm-phone-page.js +1102 -0
- package/lib/gm-phone.js +682 -0
- package/lib/gm-season.js +1162 -0
- package/lib/gm-target.js +292 -0
- package/lib/job-code-verify.js +169 -0
- package/lib/known-commands.js +1 -1
- package/lib/member-autoland.js +105 -14
- package/lib/member-progress.js +2 -2
- package/lib/member-report.js +11 -1
- package/lib/member-self-next-job.js +6 -1
- package/lib/member-up.js +17 -8
- package/lib/merged-review-close.js +206 -0
- package/lib/mission-code-provenance.js +118 -0
- package/lib/mission-protected-lane.js +52 -4
- package/lib/pulse.js +2 -1
- package/lib/qr.js +338 -0
- package/lib/rsi-record.js +88 -0
- package/lib/task-receipt.js +24 -6
- package/lib/yes-lock.js +530 -0
- package/lib/zen-apply.js +511 -0
- package/lib/zen-workspace.js +121 -0
- package/lib/zen.js +395 -0
- package/package.json +1 -1
package/lib/campaign-core.js
CHANGED
|
@@ -60,7 +60,47 @@ function newMission(id) {
|
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
// Returns the folded view, or { error } when the events must be refused.
|
|
63
|
+
// A judge reading can carry a saved wait: nothing to do until wake_at, then
|
|
64
|
+
// check the judge again. It rides as extra data on judge_ran so a reader that
|
|
65
|
+
// predates it still folds the campaign. Anything new (a mission, an answer, a
|
|
66
|
+
// new judge, more money, a resume) ends the wait.
|
|
67
|
+
const WAIT_ENDS = new Set(['mission_proposed', 'mission_claimed', 'mission_started', 'mission_ended', 'judge_set', 'ask_answered', 'resumed', 'budget_added']);
|
|
68
|
+
|
|
69
|
+
function savedWait(data) {
|
|
70
|
+
const w = data.wait;
|
|
71
|
+
if (data.passed || !w || !Number.isFinite(w.wake_at) || !(w.wait_ms > 0)) return null;
|
|
72
|
+
return { wake_at: w.wake_at, wait_ms: w.wait_ms };
|
|
73
|
+
}
|
|
74
|
+
|
|
63
75
|
function fold(events) {
|
|
76
|
+
return foldEvents(events);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Internal consumers can project selected canonical prefix counts in the same
|
|
80
|
+
// traversal. The synchronous projector is trusted and read-only; only its
|
|
81
|
+
// result is retained, so callers need not keep every full historical view.
|
|
82
|
+
function foldWithPrefixes(events, prefixLengths, project = (view) => view) {
|
|
83
|
+
return collectPrefixes(events, prefixLengths, (state, start, owner, count) =>
|
|
84
|
+
project(renderView(state, start, owner), count));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Trace history needs counts, not detached copies of every earlier mission.
|
|
88
|
+
// Use the same transitions and rendering rules, retaining only summary fields.
|
|
89
|
+
function foldWithSummaryPrefixes(events, prefixLengths) {
|
|
90
|
+
return collectPrefixes(events, prefixLengths, (state, start, owner, count, missionCounts) =>
|
|
91
|
+
renderView(state, start, owner, missionCounts), true);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function collectPrefixes(events, prefixLengths, project, trackMissionCounts = false) {
|
|
95
|
+
const requested = new Set(prefixLengths.filter((n) => Number.isSafeInteger(n) && n >= 1));
|
|
96
|
+
const prefixes = new Map();
|
|
97
|
+
const view = foldEvents(events, (state, start, owner, count, missionCounts) => {
|
|
98
|
+
if (requested.has(count)) prefixes.set(count, project(state, start, owner, count, missionCounts));
|
|
99
|
+
}, trackMissionCounts);
|
|
100
|
+
return view.error ? view : { view, prefixes };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function foldEvents(events, visit, trackMissionCounts = false) {
|
|
64
104
|
if (!Array.isArray(events)) return { error: 'invalid_event' };
|
|
65
105
|
for (const event of events) {
|
|
66
106
|
if (!validateEvent(event).ok) return { error: 'invalid_event' };
|
|
@@ -94,13 +134,18 @@ function fold(events) {
|
|
|
94
134
|
asks: new Map(),
|
|
95
135
|
judgeRuns: [],
|
|
96
136
|
lastEndedPos: -1,
|
|
137
|
+
wait: null,
|
|
97
138
|
judgeSetPos: -1,
|
|
98
139
|
judge: start.data.judge,
|
|
99
140
|
lastJudgePos: -1,
|
|
141
|
+
// Jobs that did real work and ended since the last reading, waiting on it.
|
|
142
|
+
awaitingReading: [],
|
|
100
143
|
warnings: [],
|
|
101
144
|
};
|
|
102
145
|
const d = start.data;
|
|
103
146
|
const owner = typeof d.owner === 'string' && d.owner ? d.owner : 'mission-lead';
|
|
147
|
+
const missionCounts = trackMissionCounts
|
|
148
|
+
? { proposed: 0, claimed: 0, running: 0, ended: 0, vetoed: 0, dropped: 0 } : null;
|
|
104
149
|
for (const seat of d.seats || []) addSeat(s.seats, seatFrom(seat));
|
|
105
150
|
if (d.bet) s.bet = { text: d.bet, wrong_if: null };
|
|
106
151
|
|
|
@@ -113,6 +158,9 @@ function fold(events) {
|
|
|
113
158
|
const sequence = [start, ...ordered.filter((e) => e !== start)];
|
|
114
159
|
sequence.forEach((event, pos) => {
|
|
115
160
|
const data = event.data;
|
|
161
|
+
const missionId = missionCounts ? data.mission_id : undefined;
|
|
162
|
+
const previousStatus = missionCounts ? s.missions.get(missionId)?.status : undefined;
|
|
163
|
+
if (WAIT_ENDS.has(event.type)) s.wait = null;
|
|
116
164
|
switch (event.type) {
|
|
117
165
|
case 'campaign_started':
|
|
118
166
|
if (event !== start) warn(event, 'duplicate_start');
|
|
@@ -129,6 +177,7 @@ function fold(events) {
|
|
|
129
177
|
} else {
|
|
130
178
|
s.judge = data.judge;
|
|
131
179
|
s.judgeSetPos = pos;
|
|
180
|
+
s.awaitingReading = [];
|
|
132
181
|
}
|
|
133
182
|
break;
|
|
134
183
|
case 'attached':
|
|
@@ -181,6 +230,8 @@ function fold(events) {
|
|
|
181
230
|
const m = mission(data.mission_id);
|
|
182
231
|
m.status = 'ended';
|
|
183
232
|
m.outcome = data.outcome;
|
|
233
|
+
if (data.outcome === 'fault' && typeof data.reason === 'string') m.reason = data.reason;
|
|
234
|
+
if (m.steps > 0 && data.outcome !== 'kept' && data.outcome !== 'fault') s.awaitingReading.push(m);
|
|
184
235
|
m.costCents = cents(data.cost_usd);
|
|
185
236
|
s.spentCents += m.costCents;
|
|
186
237
|
s.reservations.delete(data.mission_id);
|
|
@@ -207,6 +258,11 @@ function fold(events) {
|
|
|
207
258
|
at: event.at,
|
|
208
259
|
});
|
|
209
260
|
s.lastJudgePos = pos;
|
|
261
|
+
s.wait = savedWait(data);
|
|
262
|
+
// A job that worked but stopped short is credited when the very next
|
|
263
|
+
// reading passes: its work is what moved the check.
|
|
264
|
+
if (data.passed) for (const m of s.awaitingReading) m.credited = true;
|
|
265
|
+
s.awaitingReading = [];
|
|
210
266
|
if (data.passed && !d.standing && !s.finished) s.finished = 'judge_passed';
|
|
211
267
|
break;
|
|
212
268
|
case 'ask_opened':
|
|
@@ -236,9 +292,22 @@ function fold(events) {
|
|
|
236
292
|
default:
|
|
237
293
|
break;
|
|
238
294
|
}
|
|
295
|
+
if (missionCounts) {
|
|
296
|
+
const status = s.missions.get(missionId)?.status;
|
|
297
|
+
if (status !== previousStatus) {
|
|
298
|
+
if (previousStatus !== undefined) missionCounts[previousStatus]--;
|
|
299
|
+
if (status !== undefined) missionCounts[status]++;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
if (visit) visit(s, start, owner, pos + 1, missionCounts);
|
|
239
303
|
});
|
|
240
304
|
|
|
241
|
-
|
|
305
|
+
return renderView(s, start, owner);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function renderView(s, start, owner, missionCounts = null) {
|
|
309
|
+
const d = start.data;
|
|
310
|
+
const missions = missionCounts ? null : [...s.missions.values()];
|
|
242
311
|
let state;
|
|
243
312
|
let stopReason = null;
|
|
244
313
|
if (s.finished) {
|
|
@@ -251,9 +320,9 @@ function fold(events) {
|
|
|
251
320
|
state = 'paused';
|
|
252
321
|
} else if (s.asks.size > 0) {
|
|
253
322
|
state = 'needs_you';
|
|
254
|
-
} else if (missions.some((m) => m.status === 'running')) {
|
|
323
|
+
} else if (missionCounts ? missionCounts.running > 0 : missions.some((m) => m.status === 'running')) {
|
|
255
324
|
state = 'running';
|
|
256
|
-
} else if (missions.some((m) => m.status === 'proposed' || m.status === 'claimed')) {
|
|
325
|
+
} else if (missionCounts ? missionCounts.proposed + missionCounts.claimed > 0 : missions.some((m) => m.status === 'proposed' || m.status === 'claimed')) {
|
|
257
326
|
state = 'queued';
|
|
258
327
|
} else {
|
|
259
328
|
state = 'resting';
|
|
@@ -266,37 +335,43 @@ function fold(events) {
|
|
|
266
335
|
confidence = 'answered';
|
|
267
336
|
} else if (latest && latest.passed) {
|
|
268
337
|
let streak = 0;
|
|
269
|
-
for (let i = runs.length - 1; i >= 0 && runs[i].passed; i--) streak++;
|
|
338
|
+
for (let i = runs.length - 1; i >= 0 && streak < 3 && runs[i].passed; i--) streak++;
|
|
270
339
|
confidence = streak >= 3 ? 'stronger' : 'leaning';
|
|
271
340
|
} else if (latest) {
|
|
272
341
|
confidence = latest.confidence || 'open';
|
|
273
342
|
}
|
|
274
343
|
|
|
275
344
|
const reservedCents = [...s.reservations.values()].reduce((sum, value) => sum + value, 0);
|
|
345
|
+
const budget = {
|
|
346
|
+
total: dollars(s.totalCents),
|
|
347
|
+
spent: dollars(s.spentCents),
|
|
348
|
+
reserved: dollars(reservedCents),
|
|
349
|
+
available: dollars(s.totalCents - s.spentCents - reservedCents),
|
|
350
|
+
};
|
|
351
|
+
if (missionCounts) {
|
|
352
|
+
return { state, confidence, judge_runs: runs.length, budget, mission_counts: { ...missionCounts }, open_asks: s.asks.size };
|
|
353
|
+
}
|
|
276
354
|
return {
|
|
277
355
|
id: start.campaign_id,
|
|
278
356
|
title: d.title,
|
|
279
357
|
judge: s.judge,
|
|
280
358
|
owner,
|
|
281
359
|
standing: d.standing === true,
|
|
282
|
-
bet: s.bet,
|
|
360
|
+
bet: s.bet ? { ...s.bet } : null,
|
|
283
361
|
parent: s.parent,
|
|
284
362
|
home: s.home,
|
|
285
363
|
// Only when the start event names one, so the shared golden views stay as
|
|
286
364
|
// pinned; no basis means the person's own engines.
|
|
287
365
|
...(d.basis === 'atris_cloud' || d.basis === 'own_engines' ? { basis: d.basis } : {}),
|
|
288
366
|
created_from: d.created_from === undefined ? null : d.created_from,
|
|
289
|
-
seats: s.seats,
|
|
367
|
+
seats: s.seats.map((seat) => ({ ...seat })),
|
|
290
368
|
state,
|
|
291
369
|
stop_reason: stopReason,
|
|
292
370
|
answer: { text: latest ? latest.summary : null, confidence },
|
|
293
|
-
budget
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
available: dollars(s.totalCents - s.spentCents - reservedCents),
|
|
298
|
-
},
|
|
299
|
-
asks: [...s.asks.values()],
|
|
371
|
+
budget,
|
|
372
|
+
asks: [...s.asks.values()].map((ask) => ({ ...ask })),
|
|
373
|
+
// Only while a wait is saved, so the shared golden views stay as pinned.
|
|
374
|
+
...(s.wait ? { next_move: { action: 'wait', reason: 'waiting on the world', ...s.wait } } : {}),
|
|
300
375
|
missions: missions.map((m) => ({
|
|
301
376
|
id: m.id,
|
|
302
377
|
member: m.member,
|
|
@@ -307,11 +382,13 @@ function fold(events) {
|
|
|
307
382
|
steps: m.steps,
|
|
308
383
|
kept: m.kept,
|
|
309
384
|
reverted: m.reverted,
|
|
385
|
+
...(m.reason ? { reason: m.reason } : {}),
|
|
386
|
+
...(m.credited ? { credited: true } : {}),
|
|
310
387
|
})),
|
|
311
388
|
judge_runs: runs.length,
|
|
312
389
|
judge_due: runs.length === 0 || Math.max(s.lastEndedPos, s.judgeSetPos) > s.lastJudgePos,
|
|
313
390
|
last_judge: latest ? { passed: latest.passed, reading: latest.reading, at: latest.at } : null,
|
|
314
|
-
warnings: s.warnings,
|
|
391
|
+
warnings: s.warnings.map((warning) => ({ ...warning })),
|
|
315
392
|
};
|
|
316
393
|
}
|
|
317
394
|
|
|
@@ -319,9 +396,13 @@ function decide(view) {
|
|
|
319
396
|
if (view.state === 'done') return { action: 'none', reason: 'finished' };
|
|
320
397
|
if (view.state === 'out_of_budget') return { action: 'none', reason: 'budget spent' };
|
|
321
398
|
if (view.state === 'paused') return { action: 'none', reason: 'paused' };
|
|
322
|
-
|
|
399
|
+
// An open ask holds only the mission it names. When every claimed mission is
|
|
400
|
+
// held, the campaign waits for the person; an ask that names no mission holds nothing.
|
|
401
|
+
const held = new Set(view.asks.map((a) => a.mission_id).filter(Boolean));
|
|
402
|
+
const queued = view.missions.filter((m) => m.status === 'claimed');
|
|
403
|
+
const claimed = queued.filter((m) => !held.has(m.id));
|
|
404
|
+
if (queued.length > 0 && claimed.length === 0) return { action: 'wait', reason: 'open asks', asks: view.asks.map((a) => a.id) };
|
|
323
405
|
if (view.judge_due) return { action: 'run_judge' };
|
|
324
|
-
const claimed = view.missions.filter((m) => m.status === 'claimed');
|
|
325
406
|
const available = cents(view.budget.available);
|
|
326
407
|
const fits = claimed.find((m) => cents(m.estimate_usd || 0) <= available);
|
|
327
408
|
if (fits) return { action: 'start_mission', mission_id: fits.id };
|
|
@@ -338,4 +419,4 @@ function paysDollars(view) {
|
|
|
338
419
|
return Boolean(view && view.basis === 'atris_cloud');
|
|
339
420
|
}
|
|
340
421
|
|
|
341
|
-
module.exports = { cents, compareEvents, decide, fold, paysDollars };
|
|
422
|
+
module.exports = { cents, compareEvents, decide, fold, foldWithPrefixes, foldWithSummaryPrefixes, paysDollars };
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// atris campaign keep: a one-minute timer that runs `atris campaign wake`,
|
|
2
|
+
// so a campaign's promise to check again holds while no terminal is open
|
|
3
|
+
// (atris/features/campaign/promise.md). launchd on macOS, cron elsewhere.
|
|
4
|
+
|
|
5
|
+
'use strict';
|
|
6
|
+
|
|
7
|
+
const crypto = require('crypto');
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const os = require('os');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const { spawnSync } = require('child_process');
|
|
12
|
+
|
|
13
|
+
const MARKER = 'ATRIS_CAMPAIGN_WAKE';
|
|
14
|
+
|
|
15
|
+
function xml(text) {
|
|
16
|
+
return String(text).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function shellQuote(text) {
|
|
20
|
+
return `'${String(text).replace(/'/g, `'\\''`)}'`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// One timer per checkout: the label and marker carry a short hash of its root.
|
|
24
|
+
function planKeep({ root, home, platform = process.platform, homeDir = os.homedir(), nodeBin = 'node', atrisBin, envPath = process.env.PATH || '' }) {
|
|
25
|
+
const slot = crypto.createHash('sha256').update(root).digest('hex').slice(0, 8);
|
|
26
|
+
// node by name on the saved PATH, so upgrading node does not break the timer.
|
|
27
|
+
const command = ['/usr/bin/env', nodeBin, atrisBin, 'campaign', 'wake', '--json'];
|
|
28
|
+
const logPath = path.join(home, 'campaign-wake.log');
|
|
29
|
+
const base = { root, slot, log_path: logPath, command: command.join(' '), every_seconds: 60 };
|
|
30
|
+
if (platform === 'darwin') {
|
|
31
|
+
const label = `ai.atris.campaign-wake.${slot}`;
|
|
32
|
+
const file = path.join(homeDir, 'Library', 'LaunchAgents', `${label}.plist`);
|
|
33
|
+
const contents = `<?xml version="1.0" encoding="UTF-8"?>
|
|
34
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
35
|
+
<plist version="1.0">
|
|
36
|
+
<dict>
|
|
37
|
+
<key>Label</key><string>${xml(label)}</string>
|
|
38
|
+
<key>ProgramArguments</key>
|
|
39
|
+
<array>${command.map((part) => `<string>${xml(part)}</string>`).join('')}</array>
|
|
40
|
+
<key>WorkingDirectory</key><string>${xml(root)}</string>
|
|
41
|
+
<key>EnvironmentVariables</key>
|
|
42
|
+
<dict><key>PATH</key><string>${xml(envPath)}</string><key>ATRIS_SKIP_UPDATE_CHECK</key><string>1</string></dict>
|
|
43
|
+
<key>StartInterval</key><integer>60</integer>
|
|
44
|
+
<key>StandardOutPath</key><string>${xml(logPath)}</string>
|
|
45
|
+
<key>StandardErrorPath</key><string>${xml(logPath)}</string>
|
|
46
|
+
</dict>
|
|
47
|
+
</plist>
|
|
48
|
+
`;
|
|
49
|
+
return { ...base, kind: 'launchd', label, file, contents };
|
|
50
|
+
}
|
|
51
|
+
const marker = `${MARKER}_${slot}`;
|
|
52
|
+
const line = `* * * * * cd ${shellQuote(root)} && PATH=${shellQuote(envPath)} ATRIS_SKIP_UPDATE_CHECK=1 ${command.map(shellQuote).join(' ')} >> ${shellQuote(logPath)} 2>&1 # ${marker}`;
|
|
53
|
+
return { ...base, kind: 'cron', marker, crontab_line: line };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function withoutMarker(crontab, marker) {
|
|
57
|
+
return String(crontab || '').split('\n').filter((l) => l.trim() && !l.includes(marker)).join('\n');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Replaces any timer this checkout already has. Returns { ok, error? }.
|
|
61
|
+
function installKeep(plan, { spawn = spawnSync, uid = process.getuid && process.getuid() } = {}) {
|
|
62
|
+
fs.mkdirSync(path.dirname(plan.log_path), { recursive: true });
|
|
63
|
+
if (plan.kind === 'launchd') {
|
|
64
|
+
fs.mkdirSync(path.dirname(plan.file), { recursive: true });
|
|
65
|
+
fs.writeFileSync(plan.file, plan.contents, 'utf8');
|
|
66
|
+
spawn('launchctl', ['bootout', `gui/${uid}/${plan.label}`], { encoding: 'utf8', timeout: 10000 });
|
|
67
|
+
const r = spawn('launchctl', ['bootstrap', `gui/${uid}`, plan.file], { encoding: 'utf8', timeout: 10000 });
|
|
68
|
+
return r.status === 0 ? { ok: true } : { ok: false, error: `launchctl bootstrap failed: ${String(r.stderr || r.stdout || '').trim()}` };
|
|
69
|
+
}
|
|
70
|
+
const existing = spawn('crontab', ['-l'], { encoding: 'utf8', timeout: 10000 });
|
|
71
|
+
const kept = withoutMarker(existing.status === 0 ? existing.stdout : '', plan.marker);
|
|
72
|
+
const r = spawn('crontab', ['-'], { input: `${kept ? `${kept}\n` : ''}${plan.crontab_line}\n`, encoding: 'utf8', timeout: 10000 });
|
|
73
|
+
return r.status === 0 ? { ok: true } : { ok: false, error: `crontab failed: ${String(r.stderr || r.stdout || '').trim()}` };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function removeKeep(plan, { spawn = spawnSync, uid = process.getuid && process.getuid() } = {}) {
|
|
77
|
+
if (plan.kind === 'launchd') {
|
|
78
|
+
spawn('launchctl', ['bootout', `gui/${uid}/${plan.label}`], { encoding: 'utf8', timeout: 10000 });
|
|
79
|
+
const had = fs.existsSync(plan.file);
|
|
80
|
+
if (had) fs.unlinkSync(plan.file);
|
|
81
|
+
return { ok: true, removed: had };
|
|
82
|
+
}
|
|
83
|
+
const existing = spawn('crontab', ['-l'], { encoding: 'utf8', timeout: 10000 });
|
|
84
|
+
const prior = existing.status === 0 ? String(existing.stdout || '') : '';
|
|
85
|
+
if (!prior.includes(plan.marker)) return { ok: true, removed: false };
|
|
86
|
+
const kept = withoutMarker(prior, plan.marker);
|
|
87
|
+
const r = spawn('crontab', ['-'], { input: kept ? `${kept}\n` : '', encoding: 'utf8', timeout: 10000 });
|
|
88
|
+
return r.status === 0 ? { ok: true, removed: true } : { ok: false, error: `crontab failed: ${String(r.stderr || r.stdout || '').trim()}` };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
module.exports = { MARKER, installKeep, planKeep, removeKeep };
|
package/lib/campaign-lanes.js
CHANGED
|
@@ -32,15 +32,20 @@ function approvedLanes(events) {
|
|
|
32
32
|
return lanes;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
//
|
|
36
|
-
function
|
|
37
|
-
const open = new
|
|
35
|
+
// Lanes with an open ask in this campaign.
|
|
36
|
+
function openLaneAsks(events) {
|
|
37
|
+
const open = new Map();
|
|
38
38
|
for (const event of events || []) {
|
|
39
39
|
const data = event && event.data ? event.data : {};
|
|
40
|
-
if (event.type === 'ask_opened' && data.kind === LANE_ASK_KIND && data.lane
|
|
40
|
+
if (event.type === 'ask_opened' && data.kind === LANE_ASK_KIND && data.lane) open.set(data.ask_id, data.lane);
|
|
41
41
|
if (event.type === 'ask_answered') open.delete(data.ask_id);
|
|
42
42
|
}
|
|
43
|
-
return open.
|
|
43
|
+
return new Set(open.values());
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// True when this campaign already has an open ask for the lane, so the loop asks once.
|
|
47
|
+
function laneAskOpen(events, lane) {
|
|
48
|
+
return openLaneAsks(events).has(lane);
|
|
44
49
|
}
|
|
45
50
|
|
|
46
51
|
function laneQuestion(lane, member, objective) {
|
|
@@ -49,4 +54,4 @@ function laneQuestion(lane, member, objective) {
|
|
|
49
54
|
return `${member}'s mission touches ${lane}: "${short}". Missions that touch ${lane} wait for you. Let this campaign work on ${lane}? Answer yes to allow it.`;
|
|
50
55
|
}
|
|
51
56
|
|
|
52
|
-
module.exports = { LANE_ASK_KIND, pausedLane, approvedLanes, laneAskOpen, laneQuestion };
|
|
57
|
+
module.exports = { LANE_ASK_KIND, pausedLane, approvedLanes, laneAskOpen, laneQuestion, openLaneAsks };
|