atris 3.71.3 → 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 +7 -1
- package/commands/autoland.js +102 -2
- package/commands/campaign.js +86 -13
- package/commands/clean.js +60 -4
- package/commands/gm.js +1273 -7
- package/commands/member.js +28 -3
- package/commands/mission.js +72 -23
- package/commands/sync.js +2 -2
- package/commands/task.js +49 -13
- package/commands/zen.js +183 -0
- package/lib/auto-accept-certified.js +9 -4
- package/lib/campaign-cadence.js +15 -5
- package/lib/campaign-core.js +44 -17
- package/lib/campaign-loop.js +45 -14
- package/lib/campaign-reading.js +14 -1
- package/lib/campaign-rsi.js +28 -11
- package/lib/campaign-schedule.js +124 -0
- package/lib/campaign-store.js +34 -2
- package/lib/campaign-trace.js +40 -14
- 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-protected-lane.js +52 -4
- package/lib/qr.js +338 -0
- 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/gm-phone.js
ADDED
|
@@ -0,0 +1,682 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// gm phone: the owner runs the business from a phone by tapping decision
|
|
4
|
+
// cards. The Mac serves one page over home wifi; every request needs a
|
|
5
|
+
// random token, and every action maps to a real atris command chosen here on
|
|
6
|
+
// the server (the page only sends a card id and a choice id).
|
|
7
|
+
//
|
|
8
|
+
// The core is pure: a view (board plus every review and stuck row) goes in,
|
|
9
|
+
// ranked cards come out. createPhoneHandler wires it to http with the
|
|
10
|
+
// command runner, the clock, and the file paths passed in, so tests can stub
|
|
11
|
+
// the runner and never touch real data.
|
|
12
|
+
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
const crypto = require('crypto');
|
|
16
|
+
|
|
17
|
+
const CARD_LIMIT = 5;
|
|
18
|
+
const CONFIRM_MS = 5000;
|
|
19
|
+
const UNDO_MS = 5000;
|
|
20
|
+
// the server keeps an undo a little past the toast so a slow tap still counts
|
|
21
|
+
const UNDO_GRACE_MS = 3000;
|
|
22
|
+
const BODY_MAX = 4096;
|
|
23
|
+
const SEND_BACK_NOTE = 'sent back from the phone: the owner wants another pass';
|
|
24
|
+
const MEMBER_NAME_RE = /^[a-zA-Z0-9._-]+$/;
|
|
25
|
+
// `atris task auto-accept-certified` takes 12 rows a run unless --all.
|
|
26
|
+
const ACCEPT_PAGE = 12;
|
|
27
|
+
|
|
28
|
+
function plural(n, one, many = `${one}s`) {
|
|
29
|
+
return `${n} ${n === 1 ? one : many}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function listWords(names) {
|
|
33
|
+
if (names.length <= 1) return names.join('');
|
|
34
|
+
if (names.length === 2) return `${names[0]} and ${names[1]}`;
|
|
35
|
+
return `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function initials(name) {
|
|
39
|
+
const parts = String(name || '').split(/[-_.\s]+/).filter(Boolean);
|
|
40
|
+
if (parts.length >= 2) return `${parts[0][0]}${parts[1][0]}`.toLowerCase();
|
|
41
|
+
return String(name || '?').slice(0, 2).toLowerCase();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function tone(status) {
|
|
45
|
+
const s = String(status || '');
|
|
46
|
+
if (s === 'on a job') return 'working';
|
|
47
|
+
if (s.startsWith('stuck')) return 'stuck';
|
|
48
|
+
if (s.startsWith('waiting on review')) return 'waiting';
|
|
49
|
+
if (s === 'asleep') return 'asleep';
|
|
50
|
+
return 'idle';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// The command the reviewer card runs: the same certify-then-accept pass the
|
|
54
|
+
// hourly autoland heartbeat runs, with the same policy flags. It lands only
|
|
55
|
+
// what a second actor re-checked; protected lanes and jobs with no runnable
|
|
56
|
+
// check wait for the owner. Null when the standing policy is off, because
|
|
57
|
+
// then every accept is the owner's own call.
|
|
58
|
+
function reviewerCommand(policy) {
|
|
59
|
+
if (!policy || !policy.authorized) return null;
|
|
60
|
+
const args = ['task', 'auto-accept-certified', '--json', '--certify-first'];
|
|
61
|
+
if (policy.accept_all) args.push('--all');
|
|
62
|
+
else if (policy.strict_verify === false) args.push('--no-strict-verify');
|
|
63
|
+
return args;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// The same quoting the playable board shows, so the log reads the same.
|
|
67
|
+
function displayCommand(args) {
|
|
68
|
+
return require('../commands/gm').displayCommand(args);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ---- cards ---------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
function reviewCard(view) {
|
|
74
|
+
const reviews = view.reviews || [];
|
|
75
|
+
if (!reviews.length) return null;
|
|
76
|
+
const n = reviews.length;
|
|
77
|
+
const oldest = reviews[0];
|
|
78
|
+
const reviewer = reviewerCommand(view.policy);
|
|
79
|
+
const pass = view.policy && view.policy.accept_all ? n : Math.min(n, ACCEPT_PAGE);
|
|
80
|
+
const jobs = reviews.slice(0, 3).map(r => ({ ref: r.ref || r.task_id, task_id: r.task_id, title: r.title, member: r.member, age: r.age }));
|
|
81
|
+
const choices = [];
|
|
82
|
+
if (reviewer) {
|
|
83
|
+
choices.push({
|
|
84
|
+
id: 'check',
|
|
85
|
+
label: 'have the reviewer check them',
|
|
86
|
+
effect: `up to +${pass} toward your target`,
|
|
87
|
+
member: 'reviewer',
|
|
88
|
+
ack: `reviewer: on it, checking ${plural(n, 'job')}`,
|
|
89
|
+
confirm: true,
|
|
90
|
+
steps: [reviewer],
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
choices.push({
|
|
94
|
+
id: 'oldest',
|
|
95
|
+
label: 'show me the oldest 3',
|
|
96
|
+
effect: 'approve or send back one at a time',
|
|
97
|
+
view: 'oldest',
|
|
98
|
+
});
|
|
99
|
+
choices.push({ id: 'later', label: 'later' });
|
|
100
|
+
return {
|
|
101
|
+
id: 'review',
|
|
102
|
+
text: `${plural(n, 'job')} ${n === 1 ? 'is' : 'are'} waiting on review. the oldest has waited ${oldest.age}.`,
|
|
103
|
+
moves: reviewer ? pass : Math.min(n, 3),
|
|
104
|
+
choices,
|
|
105
|
+
jobs,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function stuckCard(view) {
|
|
110
|
+
const stuck = (view.stuck || []).filter(s => MEMBER_NAME_RE.test(String(s.member || '')));
|
|
111
|
+
if (!stuck.length) return null;
|
|
112
|
+
const n = stuck.length;
|
|
113
|
+
const holders = [...new Set(stuck.map(s => s.member))];
|
|
114
|
+
return {
|
|
115
|
+
id: 'stuck',
|
|
116
|
+
text: `${plural(n, 'job')} ${n === 1 ? 'is' : 'are'} stuck with ${listWords(holders)}. nothing moved on ${n === 1 ? 'it' : 'them'} for 6h or more, the oldest ${stuck[0].age}.`,
|
|
117
|
+
moves: n,
|
|
118
|
+
choices: [
|
|
119
|
+
{
|
|
120
|
+
id: 'free',
|
|
121
|
+
label: `free the ${n} so others can take them`,
|
|
122
|
+
effect: `${plural(n, 'job')} back in the queue, counts as ${plural(n, 'drop')}`,
|
|
123
|
+
member: 'front office',
|
|
124
|
+
ack: `front office: freeing ${plural(n, 'job')}`,
|
|
125
|
+
confirm: true,
|
|
126
|
+
steps: stuck.map(s => ['task', 'release', s.ref || s.task_id, '--as', s.member]),
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
id: 'nudge',
|
|
130
|
+
label: 'nudge the players',
|
|
131
|
+
effect: `wakes ${listWords(holders)}`,
|
|
132
|
+
member: holders[0],
|
|
133
|
+
ack: `${listWords(holders)}: on it`,
|
|
134
|
+
confirm: true,
|
|
135
|
+
steps: holders.map(h => ['member', 'wake', h]),
|
|
136
|
+
},
|
|
137
|
+
{ id: 'later', label: 'later' },
|
|
138
|
+
],
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function targetCard(view) {
|
|
143
|
+
if (view.target) return null;
|
|
144
|
+
const def = view.target_default || { landed: 1, max_abandoned: 2, max_stuck: 0 };
|
|
145
|
+
const lower = Math.ceil(def.landed / 2);
|
|
146
|
+
const setTo = landed => ({ landed, max_abandoned: def.max_abandoned, max_stuck: def.max_stuck });
|
|
147
|
+
const choices = [{
|
|
148
|
+
id: 'set',
|
|
149
|
+
label: `set this week's target: ${def.landed}`,
|
|
150
|
+
effect: `${def.landed} landed, at most ${def.max_abandoned} abandoned, ${def.max_stuck} stuck over a day`,
|
|
151
|
+
member: 'front office',
|
|
152
|
+
ack: `front office: target set to ${def.landed}`,
|
|
153
|
+
local: true,
|
|
154
|
+
target: setTo(def.landed),
|
|
155
|
+
}];
|
|
156
|
+
if (lower < def.landed) {
|
|
157
|
+
choices.push({
|
|
158
|
+
id: 'lower',
|
|
159
|
+
label: `set a lower one: ${lower}`,
|
|
160
|
+
effect: `${lower} landed, same limits`,
|
|
161
|
+
member: 'front office',
|
|
162
|
+
ack: `front office: target set to ${lower}`,
|
|
163
|
+
local: true,
|
|
164
|
+
target: setTo(lower),
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
choices.push({ id: 'later', label: 'later' });
|
|
168
|
+
return {
|
|
169
|
+
id: 'target',
|
|
170
|
+
text: `no target set for this week. over the last 4 weeks you landed about ${def.landed} a week.`,
|
|
171
|
+
moves: null,
|
|
172
|
+
choices,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Every card the view supports, ranked by how much it moves the weekly
|
|
177
|
+
// target. With no target set, the target card leads: it is one cheap,
|
|
178
|
+
// undoable tap, and every other card's "+N toward your target" counts
|
|
179
|
+
// against it. Ties keep review ahead of stuck.
|
|
180
|
+
function buildCards(view) {
|
|
181
|
+
const order = { target: 0, review: 1, stuck: 2 };
|
|
182
|
+
const cards = [targetCard(view), reviewCard(view), stuckCard(view)].filter(Boolean);
|
|
183
|
+
return cards.sort((a, b) => {
|
|
184
|
+
if ((a.moves == null) !== (b.moves == null)) return a.moves == null ? -1 : 1;
|
|
185
|
+
return (b.moves || 0) - (a.moves || 0) || order[a.id] - order[b.id];
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// The cards still to show this session: snoozed or decided cards drop out,
|
|
190
|
+
// and after CARD_LIMIT decisions the session is all clear.
|
|
191
|
+
function sessionCards(cards, session = {}) {
|
|
192
|
+
const snoozed = session.snoozed || new Set();
|
|
193
|
+
if ((session.decided || 0) >= CARD_LIMIT) return [];
|
|
194
|
+
return cards.filter(c => !snoozed.has(c.id)).slice(0, CARD_LIMIT - (session.decided || 0));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// The choice a tap names, resolved against cards built from current state.
|
|
198
|
+
// Job buttons on the oldest-3 view are approve:<ref> and sendback:<ref>.
|
|
199
|
+
function resolveChoice(cards, cardId, choiceId) {
|
|
200
|
+
const card = cards.find(c => c.id === cardId);
|
|
201
|
+
if (!card) return null;
|
|
202
|
+
const direct = card.choices.find(c => c.id === choiceId);
|
|
203
|
+
if (direct) return { card, choice: direct };
|
|
204
|
+
const m = /^(approve|sendback):(.+)$/.exec(String(choiceId || ''));
|
|
205
|
+
if (card.id !== 'review' || !m) return null;
|
|
206
|
+
const job = (card.jobs || []).find(j => String(j.ref) === m[2]);
|
|
207
|
+
if (!job) return null;
|
|
208
|
+
const approve = m[1] === 'approve';
|
|
209
|
+
return {
|
|
210
|
+
card,
|
|
211
|
+
job,
|
|
212
|
+
choice: {
|
|
213
|
+
id: choiceId,
|
|
214
|
+
label: approve ? `approve ${job.ref}` : `send back ${job.ref}`,
|
|
215
|
+
member: job.member,
|
|
216
|
+
ack: approve ? `${job.member}: thanks, landing it` : `${job.member}: on it, taking another pass`,
|
|
217
|
+
confirm: true,
|
|
218
|
+
keepCard: true,
|
|
219
|
+
steps: [approve ? ['task', 'accept', job.ref] : ['task', 'revise', job.ref, '--note', SEND_BACK_NOTE]],
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// What the page needs to draw a card: no commands, only words and flags.
|
|
225
|
+
function publicCard(card) {
|
|
226
|
+
if (!card) return null;
|
|
227
|
+
return {
|
|
228
|
+
id: card.id,
|
|
229
|
+
text: card.text,
|
|
230
|
+
choices: card.choices.map(c => ({
|
|
231
|
+
id: c.id,
|
|
232
|
+
label: c.label,
|
|
233
|
+
effect: c.effect || null,
|
|
234
|
+
ack: c.ack || null,
|
|
235
|
+
confirm: Boolean(c.confirm),
|
|
236
|
+
local: Boolean(c.local),
|
|
237
|
+
view: c.view || null,
|
|
238
|
+
commands: (c.steps || []).map(displayCommand),
|
|
239
|
+
})),
|
|
240
|
+
jobs: card.jobs || null,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ---- the strip, the live line, and outcomes ---------------------------------------
|
|
245
|
+
|
|
246
|
+
function targetStrip(view) {
|
|
247
|
+
const t = view.target;
|
|
248
|
+
if (t) {
|
|
249
|
+
return {
|
|
250
|
+
line: `${t.landed} of ${t.target.landed} landed, ${t.verdict}`,
|
|
251
|
+
landed: t.landed,
|
|
252
|
+
goal: t.target.landed,
|
|
253
|
+
verdict: t.verdict,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
const landed = view.landed_this_week || 0;
|
|
257
|
+
return { line: `${landed} landed this week, no target yet`, landed, goal: null, verdict: null };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function agoLabel(ms) {
|
|
261
|
+
const mins = Math.max(0, Math.round(ms / 60000));
|
|
262
|
+
if (mins < 1) return 'just now';
|
|
263
|
+
if (mins < 60) return `${mins}m ago`;
|
|
264
|
+
const hours = Math.round(mins / 60);
|
|
265
|
+
if (hours < 24) return `${hours}h ago`;
|
|
266
|
+
return `${Math.round(hours / 24)}d ago`;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function liveLine(view, now) {
|
|
270
|
+
const play = (view.last_plays || [])[0];
|
|
271
|
+
if (!play) return 'no task events yet';
|
|
272
|
+
return `${play.member} ${play.play} "${play.title}" ${agoLabel(now - Number(play.at || 0))}`;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// After a command exits: the exit code plus what the task list did since the
|
|
276
|
+
// tap. Never calls a model.
|
|
277
|
+
function actionOutcome(action, outcomes = [], { tally } = {}) {
|
|
278
|
+
if (action.status === 'running') return action.ack;
|
|
279
|
+
const after = outcomes.filter(o => Number(o.at) >= Number(action.started_at));
|
|
280
|
+
const t = tally(after);
|
|
281
|
+
const parts = [];
|
|
282
|
+
if (t.landed) parts.push(`${t.landed} landed`);
|
|
283
|
+
if (t.sent_back) parts.push(`${t.sent_back} sent back`);
|
|
284
|
+
if (t.dropped) parts.push(`${t.dropped} dropped`);
|
|
285
|
+
if (t.abandoned) parts.push(`${t.abandoned} abandoned`);
|
|
286
|
+
const events = parts.length ? `since you tapped: ${parts.join(', ')}` : 'no task events since you tapped';
|
|
287
|
+
if (action.exit_code === 0) return `${action.member}: done. ${events}`;
|
|
288
|
+
const tail = action.last_line ? ` (${action.last_line})` : '';
|
|
289
|
+
return `${action.member}: did not finish, exit code ${action.exit_code}${tail}. ${events}`;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ---- requests ------------------------------------------------------------------------
|
|
293
|
+
|
|
294
|
+
function tokenMatches(given, token) {
|
|
295
|
+
const a = Buffer.from(String(given || ''));
|
|
296
|
+
const b = Buffer.from(String(token || ''));
|
|
297
|
+
return a.length === b.length && b.length > 0 && crypto.timingSafeEqual(a, b);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Every request needs the right token. GET takes it in the link (?t=) or the
|
|
301
|
+
// x-gm-token header; POST only in the header, and only from the page's own
|
|
302
|
+
// origin. The Host header must name an address this server listens on, so a
|
|
303
|
+
// look-alike hostname pointed at the Mac gets nothing.
|
|
304
|
+
function checkRequest(req, { token, hosts = [] }) {
|
|
305
|
+
const host = String(req.headers.host || '').toLowerCase();
|
|
306
|
+
const url = new URL(req.url || '/', 'http://local');
|
|
307
|
+
if (!hosts.includes(host)) return { ok: false, status: 403, reason: 'host' };
|
|
308
|
+
const header = req.headers['x-gm-token'];
|
|
309
|
+
if (req.method === 'GET') {
|
|
310
|
+
if (!tokenMatches(url.searchParams.get('t') || header, token)) return { ok: false, status: 403, reason: 'token' };
|
|
311
|
+
return { ok: true, path: url.pathname };
|
|
312
|
+
}
|
|
313
|
+
if (req.method === 'POST') {
|
|
314
|
+
if (!tokenMatches(header, token)) return { ok: false, status: 403, reason: 'token' };
|
|
315
|
+
if (String(req.headers.origin || '') !== `http://${host}`) return { ok: false, status: 403, reason: 'origin' };
|
|
316
|
+
return { ok: true, path: url.pathname };
|
|
317
|
+
}
|
|
318
|
+
if (!tokenMatches(url.searchParams.get('t') || header, token)) return { ok: false, status: 403, reason: 'token' };
|
|
319
|
+
return { ok: false, status: 405, reason: 'method' };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function readBody(req) {
|
|
323
|
+
return new Promise((resolve, reject) => {
|
|
324
|
+
let size = 0;
|
|
325
|
+
const chunks = [];
|
|
326
|
+
req.on('data', (chunk) => {
|
|
327
|
+
size += chunk.length;
|
|
328
|
+
if (size > BODY_MAX) {
|
|
329
|
+
reject(new Error('too_large'));
|
|
330
|
+
req.destroy();
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
chunks.push(chunk);
|
|
334
|
+
});
|
|
335
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
336
|
+
req.on('error', reject);
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// ---- the handler ----------------------------------------------------------------------
|
|
341
|
+
|
|
342
|
+
function appendLog(logPath, row) {
|
|
343
|
+
try {
|
|
344
|
+
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
|
345
|
+
fs.appendFileSync(logPath, `${JSON.stringify(row)}\n`);
|
|
346
|
+
} catch {
|
|
347
|
+
// the log is a receipt, never a reason to fail the tap
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// deps:
|
|
352
|
+
// workspaceRoot, token, hosts who and where
|
|
353
|
+
// loadView() -> view (see loadPhoneView)
|
|
354
|
+
// runStep(args) -> Promise<{ code, last_line }>, one atris command
|
|
355
|
+
// writeTarget(target) -> boolean; snapshotTarget() -> restore fn
|
|
356
|
+
// logPath, now(), page(nonce) receipts, clock, html
|
|
357
|
+
// makeBetDesk(log) -> optional bet desk (lib/gm-bet createBetDesk)
|
|
358
|
+
function createPhoneHandler(deps) {
|
|
359
|
+
const now = deps.now || Date.now;
|
|
360
|
+
const tally = deps.tally || require('./gm-season').tally;
|
|
361
|
+
const session = { decided: 0, snoozed: new Set() };
|
|
362
|
+
const actions = [];
|
|
363
|
+
let undo = null;
|
|
364
|
+
let seq = 0;
|
|
365
|
+
|
|
366
|
+
const log = (row) => appendLog(deps.logPath, { at: new Date(now()).toISOString(), workspace: deps.workspaceRoot, ...row });
|
|
367
|
+
const bet = deps.makeBetDesk ? deps.makeBetDesk(log) : null;
|
|
368
|
+
|
|
369
|
+
// The steps of a started bet that need the owner's yes come first, then the
|
|
370
|
+
// cards built from the task list.
|
|
371
|
+
function deck(view) {
|
|
372
|
+
return sessionCards([...(bet ? bet.yesCards() : []), ...buildCards(view)], session);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function state() {
|
|
376
|
+
const view = deps.loadView();
|
|
377
|
+
const cards = deck(view);
|
|
378
|
+
const running = actions.filter(a => a.status === 'running');
|
|
379
|
+
const stillRunning = [
|
|
380
|
+
...(view.roster || []).filter(r => tone(r.status) === 'working').map(r => `${r.name} is on a job`),
|
|
381
|
+
...running.map(a => `${a.member}: ${a.label}`),
|
|
382
|
+
];
|
|
383
|
+
return {
|
|
384
|
+
workspace_name: view.workspace_name,
|
|
385
|
+
now: now(),
|
|
386
|
+
strip: targetStrip(view),
|
|
387
|
+
team: (view.roster || []).map(r => ({ name: r.name, initials: initials(r.name), status: r.status, tone: tone(r.status) })),
|
|
388
|
+
card: publicCard(cards[0] || null),
|
|
389
|
+
cards_left: cards.length,
|
|
390
|
+
decided: session.decided,
|
|
391
|
+
card_limit: CARD_LIMIT,
|
|
392
|
+
all_clear: cards.length ? null : { still_running: stillRunning },
|
|
393
|
+
live: liveLine(view, now()),
|
|
394
|
+
actions: actions.slice(-5).reverse().map(a => ({
|
|
395
|
+
id: a.id,
|
|
396
|
+
card: a.card,
|
|
397
|
+
choice: a.choice,
|
|
398
|
+
status: a.status,
|
|
399
|
+
exit_code: a.exit_code,
|
|
400
|
+
line: actionOutcome(a, view.outcomes || [], { tally }),
|
|
401
|
+
})),
|
|
402
|
+
undo: undo && undo.expires_at > now() ? { id: undo.id, until: undo.expires_at } : null,
|
|
403
|
+
error: view.error || null,
|
|
404
|
+
bet: bet ? bet.view() : null,
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
async function runAction(action, steps) {
|
|
409
|
+
let worst = 0;
|
|
410
|
+
let lastLine = '';
|
|
411
|
+
for (const args of steps) {
|
|
412
|
+
let result;
|
|
413
|
+
try {
|
|
414
|
+
result = await deps.runStep(args);
|
|
415
|
+
} catch (err) {
|
|
416
|
+
result = { code: 1, last_line: String((err && err.message) || err) };
|
|
417
|
+
}
|
|
418
|
+
const code = Number.isInteger(result.code) ? result.code : 1;
|
|
419
|
+
log({ card: action.card, choice: action.choice, command: displayCommand(args), exit_code: code });
|
|
420
|
+
if (code !== 0 && worst === 0) {
|
|
421
|
+
worst = code;
|
|
422
|
+
lastLine = String(result.last_line || '').slice(0, 160);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
action.status = worst === 0 ? 'done' : 'failed';
|
|
426
|
+
action.exit_code = worst;
|
|
427
|
+
action.last_line = lastLine;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function act(body) {
|
|
431
|
+
const cards = deck(deps.loadView());
|
|
432
|
+
const cardId = String(body.card || '');
|
|
433
|
+
const choiceId = String(body.choice || '');
|
|
434
|
+
const found = resolveChoice(cards, cardId, choiceId);
|
|
435
|
+
if (!found) return { status: 409, body: { ok: false, reason: 'gone', message: 'that card changed, here is the latest' } };
|
|
436
|
+
const { card, choice } = found;
|
|
437
|
+
if (choice.view) return { status: 400, body: { ok: false, reason: 'view_only' } };
|
|
438
|
+
if (choice.confirm && body.confirm !== true) {
|
|
439
|
+
return { status: 409, body: { ok: false, reason: 'needs_confirm', confirm_ms: CONFIRM_MS } };
|
|
440
|
+
}
|
|
441
|
+
if (choice.id === 'later') {
|
|
442
|
+
session.snoozed.add(card.id);
|
|
443
|
+
session.decided += 1;
|
|
444
|
+
log({ card: card.id, choice: 'later', command: null, exit_code: 0 });
|
|
445
|
+
return { status: 200, body: { ok: true, ack: 'saved for later' } };
|
|
446
|
+
}
|
|
447
|
+
if (!choice.keepCard) {
|
|
448
|
+
session.snoozed.add(card.id);
|
|
449
|
+
session.decided += 1;
|
|
450
|
+
}
|
|
451
|
+
if (bet && card.bet_yes) bet.recordYes(card.bet_yes);
|
|
452
|
+
seq += 1;
|
|
453
|
+
const id = `a${seq}`;
|
|
454
|
+
if (choice.local) {
|
|
455
|
+
const restore = deps.snapshotTarget();
|
|
456
|
+
const ok = deps.writeTarget(choice.target);
|
|
457
|
+
const command = `atris gm target --landed ${choice.target.landed} --max-abandoned ${choice.target.max_abandoned} --max-stuck ${choice.target.max_stuck}`;
|
|
458
|
+
log({ card: card.id, choice: choice.id, command, ran: 'in process', exit_code: ok ? 0 : 1 });
|
|
459
|
+
if (!ok) return { status: 500, body: { ok: false, reason: 'write_failed', message: 'could not save the target' } };
|
|
460
|
+
undo = { id, card: card.id, restore, expires_at: now() + UNDO_MS + UNDO_GRACE_MS };
|
|
461
|
+
return { status: 200, body: { ok: true, id, ack: choice.ack, undo: { id, ms: UNDO_MS } } };
|
|
462
|
+
}
|
|
463
|
+
const action = {
|
|
464
|
+
id,
|
|
465
|
+
card: card.id,
|
|
466
|
+
choice: choice.id,
|
|
467
|
+
label: choice.label,
|
|
468
|
+
member: choice.member,
|
|
469
|
+
ack: choice.ack,
|
|
470
|
+
status: 'running',
|
|
471
|
+
exit_code: null,
|
|
472
|
+
started_at: now(),
|
|
473
|
+
};
|
|
474
|
+
actions.push(action);
|
|
475
|
+
action.done = runAction(action, choice.steps);
|
|
476
|
+
return { status: 200, body: { ok: true, id, ack: choice.ack, commands: choice.steps.map(displayCommand) } };
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function doUndo(body) {
|
|
480
|
+
if (!undo || undo.id !== String(body.id || '') || undo.expires_at <= now()) {
|
|
481
|
+
return { status: 409, body: { ok: false, reason: 'undo_expired', message: 'too late to undo' } };
|
|
482
|
+
}
|
|
483
|
+
const ok = undo.restore();
|
|
484
|
+
session.snoozed.delete(undo.card);
|
|
485
|
+
session.decided = Math.max(0, session.decided - 1);
|
|
486
|
+
log({ card: undo.card, choice: 'undo', command: 'restore the previous target file', ran: 'in process', exit_code: ok ? 0 : 1 });
|
|
487
|
+
undo = null;
|
|
488
|
+
return { status: ok ? 200 : 500, body: { ok, ack: ok ? 'undone, target back how it was' : 'could not undo' } };
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// think shows the next bet from the queue (or waits on the one being made);
|
|
492
|
+
// sharper asks for a new one; do it needs a second tap because it
|
|
493
|
+
// starts agents; not now only marks the bet skipped.
|
|
494
|
+
async function betAction(p, body) {
|
|
495
|
+
let out;
|
|
496
|
+
if (p === '/bet/think') out = bet.next({ sharper: body.sharper === true });
|
|
497
|
+
else if (p === '/bet/skip') out = bet.skip();
|
|
498
|
+
else if (p === '/bet/do') {
|
|
499
|
+
if (body.confirm !== true) return { status: 409, body: { ok: false, reason: 'needs_confirm', confirm_ms: CONFIRM_MS } };
|
|
500
|
+
out = await bet.doIt();
|
|
501
|
+
} else if (p === '/bet/nice') {
|
|
502
|
+
out = bet.nice();
|
|
503
|
+
} else if (p === '/bet/sendback' || p === '/bet/yes') {
|
|
504
|
+
// both start or unblock agents, so both need a second tap
|
|
505
|
+
if (body.confirm !== true) return { status: 409, body: { ok: false, reason: 'needs_confirm', confirm_ms: CONFIRM_MS } };
|
|
506
|
+
out = p === '/bet/yes' ? await bet.answerYes() : await bet.sendBack();
|
|
507
|
+
} else return { status: 404, body: { ok: false, reason: 'not_found' } };
|
|
508
|
+
return { status: out.ok ? 200 : 409, body: out };
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const send = (res, status, body, type = 'application/json; charset=utf-8', extra = {}) => {
|
|
512
|
+
res.writeHead(status, {
|
|
513
|
+
'content-type': type,
|
|
514
|
+
'cache-control': 'no-store',
|
|
515
|
+
'referrer-policy': 'no-referrer',
|
|
516
|
+
'x-content-type-options': 'nosniff',
|
|
517
|
+
'x-frame-options': 'DENY',
|
|
518
|
+
...extra,
|
|
519
|
+
});
|
|
520
|
+
res.end(typeof body === 'string' ? body : JSON.stringify(body));
|
|
521
|
+
};
|
|
522
|
+
|
|
523
|
+
async function handle(req, res) {
|
|
524
|
+
const check = checkRequest(req, { token: deps.token, hosts: deps.hosts });
|
|
525
|
+
if (!check.ok) return send(res, check.status, check.status === 403 ? 'forbidden' : 'method not allowed', 'text/plain; charset=utf-8');
|
|
526
|
+
try {
|
|
527
|
+
if (req.method === 'GET' && check.path === '/') {
|
|
528
|
+
const nonce = crypto.randomBytes(16).toString('base64');
|
|
529
|
+
return send(res, 200, deps.page(nonce), 'text/html; charset=utf-8', {
|
|
530
|
+
'content-security-policy': `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'nonce-${nonce}'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'`,
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
if (req.method === 'GET' && check.path === '/state') return send(res, 200, state());
|
|
534
|
+
if (req.method === 'GET' && check.path === '/bet') return bet ? send(res, 200, bet.warm()) : send(res, 404, { ok: false, reason: 'not_found' });
|
|
535
|
+
if (req.method === 'POST' && (check.path === '/action' || check.path === '/undo' || (bet && check.path.startsWith('/bet/')))) {
|
|
536
|
+
if (!String(req.headers['content-type'] || '').startsWith('application/json')) return send(res, 415, { ok: false, reason: 'json_only' });
|
|
537
|
+
let body;
|
|
538
|
+
try {
|
|
539
|
+
body = JSON.parse(await readBody(req) || '{}');
|
|
540
|
+
} catch {
|
|
541
|
+
return send(res, 400, { ok: false, reason: 'bad_body' });
|
|
542
|
+
}
|
|
543
|
+
if (check.path.startsWith('/bet/')) {
|
|
544
|
+
const out = await betAction(check.path, body || {});
|
|
545
|
+
return send(res, out.status, out.body);
|
|
546
|
+
}
|
|
547
|
+
const out = check.path === '/action' ? act(body || {}) : doUndo(body || {});
|
|
548
|
+
return send(res, out.status, out.body);
|
|
549
|
+
}
|
|
550
|
+
return send(res, 404, { ok: false, reason: 'not_found' });
|
|
551
|
+
} catch (err) {
|
|
552
|
+
return send(res, 500, { ok: false, reason: 'server_error', message: String((err && err.message) || err).slice(0, 200) });
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
handle.session = session;
|
|
557
|
+
handle.actions = actions;
|
|
558
|
+
handle.state = state;
|
|
559
|
+
handle.bet = bet;
|
|
560
|
+
return handle;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// ---- real wiring ----------------------------------------------------------------------
|
|
564
|
+
|
|
565
|
+
// Everything the cards need, read-only: the board, every review and stuck
|
|
566
|
+
// row (oldest first), the season's plays, and the autoland policy.
|
|
567
|
+
function loadPhoneView(workspaceRoot, { now = Date.now(), dbPath, targetsDir } = {}) {
|
|
568
|
+
const season = require('./gm-season');
|
|
569
|
+
const autoland = require('./autoland');
|
|
570
|
+
const loaded = season.loadSeason(workspaceRoot, { now, dbPath, lastSeen: null, targetsDir });
|
|
571
|
+
const board = season.seasonBoard(loaded, { limit: 8, lastSeen: null });
|
|
572
|
+
const auth = autoland.liveAcceptAuthorization(workspaceRoot);
|
|
573
|
+
const policy = autoland.readPolicy(workspaceRoot) || {};
|
|
574
|
+
return {
|
|
575
|
+
workspace_name: board.workspace_name,
|
|
576
|
+
target: board.target,
|
|
577
|
+
target_default: board.target_default,
|
|
578
|
+
landed_this_week: board.mood ? board.mood.landed_by_autoland + board.mood.landed_by_you : 0,
|
|
579
|
+
roster: board.roster,
|
|
580
|
+
last_plays: board.last_plays,
|
|
581
|
+
reviews: season.reviewRows(loaded.reviews || [], now, Infinity),
|
|
582
|
+
stuck: season.stuckRows(loaded.claims || [], now, Infinity),
|
|
583
|
+
outcomes: loaded.outcomes || [],
|
|
584
|
+
policy: { authorized: auth.ok, accept_all: Boolean(policy.accept_all), strict_verify: policy.strict_verify },
|
|
585
|
+
error: board.error,
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// The first private IPv4 on a real interface (en0 first), or null.
|
|
590
|
+
function lanAddress(interfaces = require('os').networkInterfaces()) {
|
|
591
|
+
const names = Object.keys(interfaces).sort((a, b) => (a === 'en0' ? -1 : b === 'en0' ? 1 : a.localeCompare(b)));
|
|
592
|
+
for (const name of names) {
|
|
593
|
+
for (const addr of interfaces[name] || []) {
|
|
594
|
+
const v4 = addr.family === 'IPv4' || addr.family === 4;
|
|
595
|
+
if (v4 && !addr.internal && !String(addr.address).startsWith('169.254.')) return addr.address;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
return null;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// Runs one atris command in the workspace, detached so a started agent keeps
|
|
602
|
+
// going if the page stops. Output goes to a log file; the result carries the
|
|
603
|
+
// exit code and the last output line.
|
|
604
|
+
function makeStepRunner({ cliPath, cwd, logDir }) {
|
|
605
|
+
const { spawn } = require('child_process');
|
|
606
|
+
return args => new Promise((resolve) => {
|
|
607
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
608
|
+
const file = path.join(logDir, `${Date.now()}-${crypto.randomBytes(3).toString('hex')}.log`);
|
|
609
|
+
const fd = fs.openSync(file, 'a');
|
|
610
|
+
let child;
|
|
611
|
+
try {
|
|
612
|
+
child = spawn(process.execPath, [cliPath, ...args], {
|
|
613
|
+
cwd,
|
|
614
|
+
detached: true,
|
|
615
|
+
stdio: ['ignore', fd, fd],
|
|
616
|
+
env: { ...process.env, ATRIS_SKIP_UPDATE_CHECK: '1' },
|
|
617
|
+
});
|
|
618
|
+
} catch (err) {
|
|
619
|
+
fs.closeSync(fd);
|
|
620
|
+
resolve({ code: 1, last_line: String((err && err.message) || err) });
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
fs.closeSync(fd);
|
|
624
|
+
child.unref();
|
|
625
|
+
child.on('error', err => resolve({ code: 1, last_line: String(err.message || err) }));
|
|
626
|
+
child.on('exit', (code, signal) => {
|
|
627
|
+
let last = '';
|
|
628
|
+
try {
|
|
629
|
+
const text = fs.readFileSync(file, 'utf8').trim().split(/\r?\n/).filter(Boolean);
|
|
630
|
+
last = text.length ? text[text.length - 1] : '';
|
|
631
|
+
} catch {
|
|
632
|
+
last = '';
|
|
633
|
+
}
|
|
634
|
+
resolve({ code: code == null ? (signal ? 1 : 0) : code, last_line: last.slice(0, 160) });
|
|
635
|
+
});
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// Snapshot of the target file, so the undo puts back exactly what was there.
|
|
640
|
+
function targetSnapshot(workspaceRoot, dir) {
|
|
641
|
+
const gmTarget = require('./gm-target');
|
|
642
|
+
const file = gmTarget.targetsPath(workspaceRoot, dir || gmTarget.targetsDir());
|
|
643
|
+
let before = null;
|
|
644
|
+
try {
|
|
645
|
+
before = fs.readFileSync(file, 'utf8');
|
|
646
|
+
} catch {
|
|
647
|
+
before = null;
|
|
648
|
+
}
|
|
649
|
+
return () => {
|
|
650
|
+
try {
|
|
651
|
+
if (before == null) fs.rmSync(file, { force: true });
|
|
652
|
+
else fs.writeFileSync(file, before);
|
|
653
|
+
return true;
|
|
654
|
+
} catch {
|
|
655
|
+
return false;
|
|
656
|
+
}
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
module.exports = {
|
|
661
|
+
CARD_LIMIT,
|
|
662
|
+
UNDO_MS,
|
|
663
|
+
SEND_BACK_NOTE,
|
|
664
|
+
actionOutcome,
|
|
665
|
+
buildCards,
|
|
666
|
+
checkRequest,
|
|
667
|
+
createPhoneHandler,
|
|
668
|
+
displayCommand,
|
|
669
|
+
initials,
|
|
670
|
+
lanAddress,
|
|
671
|
+
liveLine,
|
|
672
|
+
loadPhoneView,
|
|
673
|
+
makeStepRunner,
|
|
674
|
+
publicCard,
|
|
675
|
+
resolveChoice,
|
|
676
|
+
reviewerCommand,
|
|
677
|
+
sessionCards,
|
|
678
|
+
targetSnapshot,
|
|
679
|
+
targetStrip,
|
|
680
|
+
tokenMatches,
|
|
681
|
+
tone,
|
|
682
|
+
};
|