atris 3.71.2 → 3.71.3
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/bin/atris.js +1 -1
- package/commands/campaign.js +185 -6
- package/commands/improve.js +3 -2
- package/commands/mission.js +10 -4
- package/commands/pulse.js +89 -5
- package/commands/worktree.js +24 -2
- package/lib/campaign-cadence.js +39 -10
- package/lib/campaign-core.js +61 -7
- package/lib/campaign-keep.js +91 -0
- package/lib/campaign-lanes.js +11 -6
- package/lib/campaign-loop.js +137 -27
- package/lib/campaign-rsi.js +680 -0
- package/lib/campaign-run-lock.js +9 -1
- package/lib/campaign-schema.js +3 -1
- package/lib/campaign-trace.js +42 -20
- package/lib/mission-code-provenance.js +118 -0
- package/lib/pulse.js +2 -1
- package/lib/rsi-record.js +88 -0
- package/lib/task-receipt.js +24 -6
- package/package.json +1 -1
package/lib/campaign-cadence.js
CHANGED
|
@@ -11,6 +11,7 @@ const fs = require('fs');
|
|
|
11
11
|
const path = require('path');
|
|
12
12
|
const { paysDollars } = require('./campaign-core');
|
|
13
13
|
const { getCampaign, openCampaignStore } = require('./campaign-store');
|
|
14
|
+
const { readingKey } = require('./campaign-loop');
|
|
14
15
|
|
|
15
16
|
const LESSON_LINES_FOR_PLANNING = 30;
|
|
16
17
|
|
|
@@ -26,16 +27,35 @@ function lessonsPath(root, campaignId) {
|
|
|
26
27
|
return path.join(root, 'atris', 'campaigns', campaignId, 'lessons.md');
|
|
27
28
|
}
|
|
28
29
|
|
|
29
|
-
|
|
30
|
-
|
|
30
|
+
function globalLessonsPath(root) {
|
|
31
|
+
return path.join(root, 'atris', 'campaigns', 'lessons.md');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function parseBulletLines(filePath) {
|
|
31
35
|
try {
|
|
32
|
-
const text = fs.readFileSync(
|
|
33
|
-
return text.split('\n').filter((line) => line.trim().startsWith('- '))
|
|
36
|
+
const text = fs.readFileSync(filePath, 'utf8');
|
|
37
|
+
return text.split('\n').filter((line) => line.trim().startsWith('- '));
|
|
34
38
|
} catch {
|
|
35
|
-
return
|
|
39
|
+
return [];
|
|
36
40
|
}
|
|
37
41
|
}
|
|
38
42
|
|
|
43
|
+
// The newest lessons for the planner: local campaign lessons plus deduplicated
|
|
44
|
+
// cross-campaign RSI lessons so every new campaign inherits earlier lessons.
|
|
45
|
+
function readLessons(root, campaignId, lines = LESSON_LINES_FOR_PLANNING) {
|
|
46
|
+
const local = parseBulletLines(lessonsPath(root, campaignId));
|
|
47
|
+
const global = parseBulletLines(globalLessonsPath(root));
|
|
48
|
+
const seen = new Set();
|
|
49
|
+
const merged = [];
|
|
50
|
+
for (const line of [...local, ...global]) {
|
|
51
|
+
const key = line.trim().toLowerCase();
|
|
52
|
+
if (seen.has(key)) continue;
|
|
53
|
+
seen.add(key);
|
|
54
|
+
merged.push(line);
|
|
55
|
+
}
|
|
56
|
+
return merged.slice(-lines).join('\n');
|
|
57
|
+
}
|
|
58
|
+
|
|
39
59
|
function roundSummary(before, after) {
|
|
40
60
|
const fresh = after.missions.filter((m) => !before.missions.some((b) => b.id === m.id && b.status === m.status));
|
|
41
61
|
const kept = fresh.filter((m) => m.status === 'ended' && m.outcome === 'kept').length;
|
|
@@ -45,6 +65,12 @@ function roundSummary(before, after) {
|
|
|
45
65
|
return { kept, failed, vetoed, spent, fresh };
|
|
46
66
|
}
|
|
47
67
|
|
|
68
|
+
// Nothing finished and the check reads the same: there is nothing to learn.
|
|
69
|
+
function quietRound(before, after, summary) {
|
|
70
|
+
const key = (v) => (v.last_judge ? readingKey(v.last_judge.passed, v.last_judge.reading, v.answer.text) : null);
|
|
71
|
+
return !summary.fresh.some((m) => m.status === 'ended' || m.status === 'vetoed') && key(before) === key(after);
|
|
72
|
+
}
|
|
73
|
+
|
|
48
74
|
// On a subscription the round is told without dollars, since none were paid.
|
|
49
75
|
function roundTally(view, summary) {
|
|
50
76
|
return `${summary.kept} kept, ${summary.failed} failed${paysDollars(view) ? `, $${summary.spent} spent` : ''}`;
|
|
@@ -82,9 +108,10 @@ function makeCadence(root, loop, options = {}, deps = {}) {
|
|
|
82
108
|
async function review(id, before) {
|
|
83
109
|
const after = getCampaign(store, id);
|
|
84
110
|
const summary = roundSummary(before, after);
|
|
111
|
+
const quiet = quietRound(before, after, summary);
|
|
85
112
|
const lessons = readLessons(root, id, 200);
|
|
86
113
|
let written = [];
|
|
87
|
-
if (ask) {
|
|
114
|
+
if (ask && !quiet) {
|
|
88
115
|
const answer = await ask(root, after, reviewPrompt(after, summary, lessons));
|
|
89
116
|
// A lesson already on file is not written twice.
|
|
90
117
|
const known = new Set(lessons.split('\n').map((line) => line.trim().toLowerCase()));
|
|
@@ -96,8 +123,10 @@ function makeCadence(root, loop, options = {}, deps = {}) {
|
|
|
96
123
|
if (!fs.existsSync(file)) fs.writeFileSync(file, `# Lessons: ${after.title}\n\nWritten by the owner at each review; the next round plans from them.\n`);
|
|
97
124
|
const stamp = new Date(now()).toISOString().slice(0, 16).replace('T', ' ');
|
|
98
125
|
const header = `\n## ${stamp}: ${roundTally(after, summary)}\n`;
|
|
99
|
-
|
|
100
|
-
|
|
126
|
+
// A quiet round is noted as plain text, so the planner never reads it as a lesson.
|
|
127
|
+
const body = quiet ? ['(no review written this round: nothing ended and the reading held)'] : (written.length ? written : ['- (no review written this round)']);
|
|
128
|
+
fs.appendFileSync(file, `${header}${body.join('\n')}\n`);
|
|
129
|
+
return { summary, lessons: written, view: after, quiet };
|
|
101
130
|
}
|
|
102
131
|
|
|
103
132
|
async function run(id) {
|
|
@@ -118,8 +147,8 @@ function makeCadence(root, loop, options = {}, deps = {}) {
|
|
|
118
147
|
}
|
|
119
148
|
const breakStarted = now();
|
|
120
149
|
log(`round ${round}: review`);
|
|
121
|
-
const { summary, lessons, view: reviewed } = await review(id, before);
|
|
122
|
-
log(` ${roundTally(reviewed, summary)}; ${lessons.length} lesson${lessons.length === 1 ? '' : 's'} written`);
|
|
150
|
+
const { summary, lessons, view: reviewed, quiet } = await review(id, before);
|
|
151
|
+
log(quiet ? ' nothing new this round; no review' : ` ${roundTally(reviewed, summary)}; ${lessons.length} lesson${lessons.length === 1 ? '' : 's'} written`);
|
|
123
152
|
const view = getCampaign(store, id);
|
|
124
153
|
last = { reason: stopped || 'work window ended', round };
|
|
125
154
|
// Finished, out of money, or waiting on a person: no point in another round.
|
package/lib/campaign-core.js
CHANGED
|
@@ -60,7 +60,35 @@ 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
|
+
const requested = new Set(prefixLengths.filter((n) => Number.isSafeInteger(n) && n >= 1));
|
|
84
|
+
const prefixes = new Map();
|
|
85
|
+
const view = foldEvents(events, (state, start, owner, count) => {
|
|
86
|
+
if (requested.has(count)) prefixes.set(count, project(renderView(state, start, owner), count));
|
|
87
|
+
});
|
|
88
|
+
return view.error ? view : { view, prefixes };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function foldEvents(events, visit) {
|
|
64
92
|
if (!Array.isArray(events)) return { error: 'invalid_event' };
|
|
65
93
|
for (const event of events) {
|
|
66
94
|
if (!validateEvent(event).ok) return { error: 'invalid_event' };
|
|
@@ -94,9 +122,12 @@ function fold(events) {
|
|
|
94
122
|
asks: new Map(),
|
|
95
123
|
judgeRuns: [],
|
|
96
124
|
lastEndedPos: -1,
|
|
125
|
+
wait: null,
|
|
97
126
|
judgeSetPos: -1,
|
|
98
127
|
judge: start.data.judge,
|
|
99
128
|
lastJudgePos: -1,
|
|
129
|
+
// Jobs that did real work and ended since the last reading, waiting on it.
|
|
130
|
+
awaitingReading: [],
|
|
100
131
|
warnings: [],
|
|
101
132
|
};
|
|
102
133
|
const d = start.data;
|
|
@@ -113,6 +144,7 @@ function fold(events) {
|
|
|
113
144
|
const sequence = [start, ...ordered.filter((e) => e !== start)];
|
|
114
145
|
sequence.forEach((event, pos) => {
|
|
115
146
|
const data = event.data;
|
|
147
|
+
if (WAIT_ENDS.has(event.type)) s.wait = null;
|
|
116
148
|
switch (event.type) {
|
|
117
149
|
case 'campaign_started':
|
|
118
150
|
if (event !== start) warn(event, 'duplicate_start');
|
|
@@ -129,6 +161,7 @@ function fold(events) {
|
|
|
129
161
|
} else {
|
|
130
162
|
s.judge = data.judge;
|
|
131
163
|
s.judgeSetPos = pos;
|
|
164
|
+
s.awaitingReading = [];
|
|
132
165
|
}
|
|
133
166
|
break;
|
|
134
167
|
case 'attached':
|
|
@@ -181,6 +214,8 @@ function fold(events) {
|
|
|
181
214
|
const m = mission(data.mission_id);
|
|
182
215
|
m.status = 'ended';
|
|
183
216
|
m.outcome = data.outcome;
|
|
217
|
+
if (data.outcome === 'fault' && typeof data.reason === 'string') m.reason = data.reason;
|
|
218
|
+
if (m.steps > 0 && data.outcome !== 'kept' && data.outcome !== 'fault') s.awaitingReading.push(m);
|
|
184
219
|
m.costCents = cents(data.cost_usd);
|
|
185
220
|
s.spentCents += m.costCents;
|
|
186
221
|
s.reservations.delete(data.mission_id);
|
|
@@ -207,6 +242,11 @@ function fold(events) {
|
|
|
207
242
|
at: event.at,
|
|
208
243
|
});
|
|
209
244
|
s.lastJudgePos = pos;
|
|
245
|
+
s.wait = savedWait(data);
|
|
246
|
+
// A job that worked but stopped short is credited when the very next
|
|
247
|
+
// reading passes: its work is what moved the check.
|
|
248
|
+
if (data.passed) for (const m of s.awaitingReading) m.credited = true;
|
|
249
|
+
s.awaitingReading = [];
|
|
210
250
|
if (data.passed && !d.standing && !s.finished) s.finished = 'judge_passed';
|
|
211
251
|
break;
|
|
212
252
|
case 'ask_opened':
|
|
@@ -236,8 +276,14 @@ function fold(events) {
|
|
|
236
276
|
default:
|
|
237
277
|
break;
|
|
238
278
|
}
|
|
279
|
+
if (visit) visit(s, start, owner, pos + 1);
|
|
239
280
|
});
|
|
240
281
|
|
|
282
|
+
return renderView(s, start, owner);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function renderView(s, start, owner) {
|
|
286
|
+
const d = start.data;
|
|
241
287
|
const missions = [...s.missions.values()];
|
|
242
288
|
let state;
|
|
243
289
|
let stopReason = null;
|
|
@@ -279,14 +325,14 @@ function fold(events) {
|
|
|
279
325
|
judge: s.judge,
|
|
280
326
|
owner,
|
|
281
327
|
standing: d.standing === true,
|
|
282
|
-
bet: s.bet,
|
|
328
|
+
bet: s.bet ? { ...s.bet } : null,
|
|
283
329
|
parent: s.parent,
|
|
284
330
|
home: s.home,
|
|
285
331
|
// Only when the start event names one, so the shared golden views stay as
|
|
286
332
|
// pinned; no basis means the person's own engines.
|
|
287
333
|
...(d.basis === 'atris_cloud' || d.basis === 'own_engines' ? { basis: d.basis } : {}),
|
|
288
334
|
created_from: d.created_from === undefined ? null : d.created_from,
|
|
289
|
-
seats: s.seats,
|
|
335
|
+
seats: s.seats.map((seat) => ({ ...seat })),
|
|
290
336
|
state,
|
|
291
337
|
stop_reason: stopReason,
|
|
292
338
|
answer: { text: latest ? latest.summary : null, confidence },
|
|
@@ -296,7 +342,9 @@ function fold(events) {
|
|
|
296
342
|
reserved: dollars(reservedCents),
|
|
297
343
|
available: dollars(s.totalCents - s.spentCents - reservedCents),
|
|
298
344
|
},
|
|
299
|
-
asks: [...s.asks.values()],
|
|
345
|
+
asks: [...s.asks.values()].map((ask) => ({ ...ask })),
|
|
346
|
+
// Only while a wait is saved, so the shared golden views stay as pinned.
|
|
347
|
+
...(s.wait ? { next_move: { action: 'wait', reason: 'waiting on the world', ...s.wait } } : {}),
|
|
300
348
|
missions: missions.map((m) => ({
|
|
301
349
|
id: m.id,
|
|
302
350
|
member: m.member,
|
|
@@ -307,11 +355,13 @@ function fold(events) {
|
|
|
307
355
|
steps: m.steps,
|
|
308
356
|
kept: m.kept,
|
|
309
357
|
reverted: m.reverted,
|
|
358
|
+
...(m.reason ? { reason: m.reason } : {}),
|
|
359
|
+
...(m.credited ? { credited: true } : {}),
|
|
310
360
|
})),
|
|
311
361
|
judge_runs: runs.length,
|
|
312
362
|
judge_due: runs.length === 0 || Math.max(s.lastEndedPos, s.judgeSetPos) > s.lastJudgePos,
|
|
313
363
|
last_judge: latest ? { passed: latest.passed, reading: latest.reading, at: latest.at } : null,
|
|
314
|
-
warnings: s.warnings,
|
|
364
|
+
warnings: s.warnings.map((warning) => ({ ...warning })),
|
|
315
365
|
};
|
|
316
366
|
}
|
|
317
367
|
|
|
@@ -319,9 +369,13 @@ function decide(view) {
|
|
|
319
369
|
if (view.state === 'done') return { action: 'none', reason: 'finished' };
|
|
320
370
|
if (view.state === 'out_of_budget') return { action: 'none', reason: 'budget spent' };
|
|
321
371
|
if (view.state === 'paused') return { action: 'none', reason: 'paused' };
|
|
322
|
-
|
|
372
|
+
// An open ask holds only the mission it names. When every claimed mission is
|
|
373
|
+
// held, the campaign waits for the person; an ask that names no mission holds nothing.
|
|
374
|
+
const held = new Set(view.asks.map((a) => a.mission_id).filter(Boolean));
|
|
375
|
+
const queued = view.missions.filter((m) => m.status === 'claimed');
|
|
376
|
+
const claimed = queued.filter((m) => !held.has(m.id));
|
|
377
|
+
if (queued.length > 0 && claimed.length === 0) return { action: 'wait', reason: 'open asks', asks: view.asks.map((a) => a.id) };
|
|
323
378
|
if (view.judge_due) return { action: 'run_judge' };
|
|
324
|
-
const claimed = view.missions.filter((m) => m.status === 'claimed');
|
|
325
379
|
const available = cents(view.budget.available);
|
|
326
380
|
const fits = claimed.find((m) => cents(m.estimate_usd || 0) <= available);
|
|
327
381
|
if (fits) return { action: 'start_mission', mission_id: fits.id };
|
|
@@ -338,4 +392,4 @@ function paysDollars(view) {
|
|
|
338
392
|
return Boolean(view && view.basis === 'atris_cloud');
|
|
339
393
|
}
|
|
340
394
|
|
|
341
|
-
module.exports = { cents, compareEvents, decide, fold, paysDollars };
|
|
395
|
+
module.exports = { cents, compareEvents, decide, fold, foldWithPrefixes, 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 };
|