atris 3.70.0 → 3.71.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.
Files changed (76) hide show
  1. package/README.md +18 -0
  2. package/atris/skills/README.md +1 -0
  3. package/atris/skills/campaigns/SKILL.md +81 -0
  4. package/atris/skills/meta/SKILL.md +52 -8
  5. package/ax +226 -42
  6. package/bin/atris.js +26 -6
  7. package/commands/autoland.js +176 -7
  8. package/commands/autopilot.js +71 -11
  9. package/commands/campaign.js +759 -0
  10. package/commands/compile.js +19 -8
  11. package/commands/doctor.js +58 -0
  12. package/commands/dream.js +18 -3
  13. package/commands/engine.js +147 -5
  14. package/commands/improve.js +119 -9
  15. package/commands/init.js +18 -1
  16. package/commands/interview.js +15 -3
  17. package/commands/land.js +22 -6
  18. package/commands/link.js +1 -0
  19. package/commands/member.js +359 -94
  20. package/commands/mission.js +249 -36
  21. package/commands/report.js +42 -8
  22. package/commands/run.js +9 -2
  23. package/commands/scout.js +9 -1
  24. package/commands/task.js +71 -17
  25. package/commands/team.js +212 -4
  26. package/commands/unknowns.js +15 -1
  27. package/commands/verify.js +46 -0
  28. package/commands/worktree.js +108 -15
  29. package/commands/write.js +12 -1
  30. package/commands/youtube.js +17 -2
  31. package/lib/accept-verify-gate.js +1 -0
  32. package/lib/auto-accept-certified.js +1 -0
  33. package/lib/autoland.js +13 -4
  34. package/lib/campaign-cadence.js +131 -0
  35. package/lib/campaign-core.js +330 -0
  36. package/lib/campaign-holdout.js +65 -0
  37. package/lib/campaign-loop.js +242 -0
  38. package/lib/campaign-missions.js +237 -0
  39. package/lib/campaign-new.js +251 -0
  40. package/lib/campaign-protect.js +75 -0
  41. package/lib/campaign-reading.js +147 -0
  42. package/lib/campaign-schema.js +196 -0
  43. package/lib/campaign-store.js +380 -0
  44. package/lib/campaign-sync.js +302 -0
  45. package/lib/campaign-trace.js +300 -0
  46. package/lib/conductor-artifacts.js +15 -1
  47. package/lib/daily-log.js +11 -1
  48. package/lib/dispatch-scout.js +18 -0
  49. package/lib/engine-ask.js +33 -3
  50. package/lib/engine-registry.js +2 -0
  51. package/lib/engine-resource.js +518 -0
  52. package/lib/fleet.js +125 -16
  53. package/lib/init-commit.js +80 -0
  54. package/lib/intents.js +8 -0
  55. package/lib/known-commands.js +1 -1
  56. package/lib/land-green.js +58 -7
  57. package/lib/landing-proof.js +352 -0
  58. package/lib/member-autoland.js +395 -18
  59. package/lib/member-board.js +7 -1
  60. package/lib/member-map-fold.js +126 -0
  61. package/lib/member-map-inbox.js +80 -0
  62. package/lib/member-progress.js +48 -1
  63. package/lib/member-report.js +127 -8
  64. package/lib/member-self-next-job.js +97 -0
  65. package/lib/member-task-pick.js +41 -14
  66. package/lib/member-up.js +249 -16
  67. package/lib/roster-prep.js +25 -2
  68. package/lib/runner-command.js +5 -0
  69. package/lib/task-db.js +47 -11
  70. package/lib/task-explanation.js +7 -0
  71. package/lib/task-list-keeper.js +8 -1
  72. package/lib/task-receipt.js +6 -1
  73. package/mcp/atris-mcp/index.mjs +212 -10
  74. package/package.json +2 -1
  75. package/scripts/det/engine-reading.js +37 -0
  76. package/scripts/det/ytnotes +29 -5
@@ -0,0 +1,380 @@
1
+ // Campaign store: append-only events behind one small interface, with a
2
+ // SQLite adapter (one file per workspace) and an in-memory adapter for tests.
3
+ //
4
+ // Built so Campaign does not inherit the mission ledger's problems
5
+ // (atris/features/campaign/build.md Phase -1): every append is one
6
+ // transaction, events carry a unique id plus a per-origin sequence, writers
7
+ // can demand an expected version, and a corrupt row fails closed.
8
+
9
+ 'use strict';
10
+
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+ const crypto = require('crypto');
14
+ // task-db installs the narrow node:sqlite warning filter and owns the shared
15
+ // busy retry; load it before node:sqlite so the filter is in place.
16
+ const { withBusyRetry } = require('./task-db');
17
+ const { DatabaseSync } = require('node:sqlite');
18
+ const { mainCheckoutRoot } = require('./engine-registry');
19
+ const { validateEvent } = require('./campaign-schema');
20
+ const { fold } = require('./campaign-core');
21
+
22
+ // Any event naming a mission ties that mission to its campaign.
23
+ function missionIdOf(event) {
24
+ return event.data && typeof event.data.mission_id === 'string' && event.data.mission_id ? event.data.mission_id : null;
25
+ }
26
+
27
+ function storeError(code, message) {
28
+ const error = new Error(message || code);
29
+ error.code = code;
30
+ return error;
31
+ }
32
+
33
+ // The checkout holding root, so a call from a subfolder finds the same store.
34
+ function checkoutRoot(root) {
35
+ let dir = path.resolve(root);
36
+ for (;;) {
37
+ if (fs.existsSync(path.join(dir, '.git'))) return dir;
38
+ const parent = path.dirname(dir);
39
+ if (parent === dir) return path.resolve(root);
40
+ dir = parent;
41
+ }
42
+ }
43
+
44
+ function campaignDbPath(root = process.cwd()) {
45
+ if (process.env.ATRIS_CAMPAIGN_DB) return process.env.ATRIS_CAMPAIGN_DB;
46
+ const checkout = checkoutRoot(root);
47
+ const home = mainCheckoutRoot(checkout) || checkout;
48
+ return path.join(home, '.atris', 'state', 'atris.db');
49
+ }
50
+
51
+ // Files a campaign owns (like a copied judge script) live beside the store.
52
+ function campaignFolder(campaignId, root = process.cwd()) {
53
+ return path.join(path.dirname(campaignDbPath(root)), 'campaigns', campaignId);
54
+ }
55
+
56
+ function newCampaignId(title = '') {
57
+ const slug = String(title).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 24).replace(/-+$/, '');
58
+ const suffix = crypto.randomBytes(2).toString('hex');
59
+ return slug ? `cmp-${slug}-${suffix}` : `cmp-${suffix}`;
60
+ }
61
+
62
+ function newOrigin() {
63
+ return `c-${crypto.randomBytes(4).toString('hex')}`;
64
+ }
65
+
66
+ // Fill the fields the store owns, then validate the whole event.
67
+ function completeEvent(input, origin, seq, now) {
68
+ const event = {
69
+ id: input.id || `evt-${crypto.randomUUID()}`,
70
+ campaign_id: input.campaign_id,
71
+ type: input.type,
72
+ at: input.at || now(),
73
+ origin,
74
+ seq,
75
+ actor: input.actor,
76
+ data: input.data || {},
77
+ };
78
+ const check = validateEvent(event);
79
+ if (!check.ok) throw storeError('invalid_event', `invalid ${input.type || 'event'}: ${check.errors.join(', ')}`);
80
+ return event;
81
+ }
82
+
83
+ const SCHEMA = `
84
+ CREATE TABLE IF NOT EXISTS meta (
85
+ key TEXT PRIMARY KEY,
86
+ value TEXT NOT NULL
87
+ );
88
+ CREATE TABLE IF NOT EXISTS campaigns (
89
+ id TEXT PRIMARY KEY,
90
+ created_at TEXT NOT NULL,
91
+ origin TEXT NOT NULL
92
+ );
93
+ CREATE TABLE IF NOT EXISTS events (
94
+ pos INTEGER PRIMARY KEY AUTOINCREMENT,
95
+ id TEXT NOT NULL UNIQUE,
96
+ campaign_id TEXT NOT NULL,
97
+ origin TEXT NOT NULL,
98
+ seq INTEGER NOT NULL,
99
+ type TEXT NOT NULL,
100
+ at TEXT NOT NULL,
101
+ actor TEXT NOT NULL,
102
+ data TEXT NOT NULL,
103
+ UNIQUE (origin, seq)
104
+ );
105
+ CREATE INDEX IF NOT EXISTS events_by_campaign ON events (campaign_id, pos);
106
+ CREATE TABLE IF NOT EXISTS missions (
107
+ mission_id TEXT PRIMARY KEY,
108
+ campaign_id TEXT NOT NULL
109
+ );
110
+ `;
111
+
112
+ function createSqliteStore(dbPath, options = {}) {
113
+ const now = options.now || (() => new Date().toISOString());
114
+ fs.mkdirSync(path.dirname(dbPath), { recursive: true });
115
+ let readOnly = false;
116
+ let db;
117
+ try {
118
+ fs.accessSync(fs.existsSync(dbPath) ? dbPath : path.dirname(dbPath), fs.constants.W_OK);
119
+ db = new DatabaseSync(dbPath);
120
+ withBusyRetry(() => db.exec('PRAGMA journal_mode = WAL'));
121
+ db.exec('PRAGMA busy_timeout = 30000');
122
+ withBusyRetry(() => db.exec(SCHEMA));
123
+ withBusyRetry(() => db.exec('PRAGMA user_version = 1'));
124
+ } catch (error) {
125
+ // A store someone can only read still shows its campaigns; writes say why they cannot.
126
+ if (!fs.existsSync(dbPath)) throw error;
127
+ if (db) try { db.close(); } catch {}
128
+ db = new DatabaseSync(dbPath, { readOnly: true });
129
+ readOnly = true;
130
+ }
131
+
132
+ const tx = (fn) => {
133
+ if (readOnly) throw storeError('read_only', `the campaign store at ${dbPath} is read-only`);
134
+ withBusyRetry(() => db.exec('BEGIN IMMEDIATE'));
135
+ try {
136
+ const result = fn();
137
+ db.exec('COMMIT');
138
+ return result;
139
+ } catch (error) {
140
+ try { db.exec('ROLLBACK'); } catch {}
141
+ throw error;
142
+ }
143
+ };
144
+
145
+ const origin = readOnly ? ((db.prepare("SELECT value FROM meta WHERE key = 'origin'").get() || {}).value || 'read-only') : tx(() => {
146
+ const row = db.prepare("SELECT value FROM meta WHERE key = 'origin'").get();
147
+ if (row) return row.value;
148
+ const value = options.origin || newOrigin();
149
+ db.prepare("INSERT INTO meta (key, value) VALUES ('origin', ?)").run(value);
150
+ return value;
151
+ });
152
+
153
+ const linkMission = (event) => {
154
+ const missionId = missionIdOf(event);
155
+ if (!missionId) return;
156
+ const row = db.prepare('SELECT campaign_id FROM missions WHERE mission_id = ?').get(missionId);
157
+ if (row && row.campaign_id !== event.campaign_id) {
158
+ throw storeError('mission_in_other_campaign', `mission ${missionId} already belongs to ${row.campaign_id}`);
159
+ }
160
+ if (!row) db.prepare('INSERT INTO missions (mission_id, campaign_id) VALUES (?, ?)').run(missionId, event.campaign_id);
161
+ };
162
+
163
+ const insert = (event) => {
164
+ db.prepare('INSERT INTO events (id, campaign_id, origin, seq, type, at, actor, data) VALUES (?, ?, ?, ?, ?, ?, ?, ?)')
165
+ .run(event.id, event.campaign_id, event.origin, event.seq, event.type, event.at, event.actor, JSON.stringify(event.data));
166
+ };
167
+
168
+ const count = (campaignId) => db.prepare('SELECT COUNT(*) AS n FROM events WHERE campaign_id = ?').get(campaignId).n;
169
+
170
+ const readEvents = (campaignId) => db.prepare('SELECT * FROM events WHERE campaign_id = ? ORDER BY pos').all(campaignId).map((row) => {
171
+ let data;
172
+ try {
173
+ data = JSON.parse(row.data);
174
+ } catch {
175
+ throw storeError('corrupt_event', `event ${row.id} in ${campaignId} has unreadable data`);
176
+ }
177
+ return {
178
+ id: row.id, campaign_id: row.campaign_id, type: row.type, at: row.at,
179
+ origin: row.origin, seq: row.seq, actor: row.actor, data,
180
+ };
181
+ });
182
+
183
+ return {
184
+ kind: 'sqlite',
185
+ path: dbPath,
186
+ origin,
187
+ readOnly,
188
+
189
+ // Write a new event from this computer. options.expectedVersion is the
190
+ // event count the writer last read; a mismatch refuses the write.
191
+ append(input, appendOptions = {}) {
192
+ return tx(() => {
193
+ const campaignId = input.campaign_id;
194
+ if (appendOptions.expectedVersion !== undefined && count(campaignId) !== appendOptions.expectedVersion) {
195
+ throw storeError('version_conflict', `campaign ${campaignId} changed since version ${appendOptions.expectedVersion}`);
196
+ }
197
+ const exists = db.prepare('SELECT 1 FROM campaigns WHERE id = ?').get(campaignId);
198
+ if (input.type === 'campaign_started' && exists) throw storeError('campaign_exists', `campaign ${campaignId} already started`);
199
+ if (input.type !== 'campaign_started' && !exists) throw storeError('unknown_campaign', `no campaign ${campaignId}`);
200
+ // A check that must see the campaign as it is at the moment of the
201
+ // write, such as a budget reservation, runs inside this transaction.
202
+ if (appendOptions.check) appendOptions.check(fold(readEvents(campaignId)));
203
+ const last = db.prepare('SELECT MAX(seq) AS seq FROM events WHERE origin = ?').get(origin).seq || 0;
204
+ const event = completeEvent(input, origin, last + 1, now);
205
+ if (input.type === 'campaign_started') {
206
+ db.prepare('INSERT INTO campaigns (id, created_at, origin) VALUES (?, ?, ?)').run(campaignId, event.at, origin);
207
+ }
208
+ linkMission(event);
209
+ insert(event);
210
+ return event;
211
+ });
212
+ },
213
+
214
+ // Take an event written elsewhere (sync). Returns false for one already
215
+ // held; a different event claiming the same origin and seq is refused.
216
+ ingest(event) {
217
+ const check = validateEvent(event);
218
+ if (!check.ok) throw storeError('invalid_event', `invalid ${event && event.type}: ${check.errors.join(', ')}`);
219
+ return tx(() => {
220
+ if (db.prepare('SELECT 1 FROM events WHERE id = ?').get(event.id)) return false;
221
+ const clash = db.prepare('SELECT id FROM events WHERE origin = ? AND seq = ?').get(event.origin, event.seq);
222
+ if (clash) throw storeError('seq_conflict', `origin ${event.origin} seq ${event.seq} is already ${clash.id}`);
223
+ if (event.type === 'campaign_started') {
224
+ db.prepare('INSERT OR IGNORE INTO campaigns (id, created_at, origin) VALUES (?, ?, ?)').run(event.campaign_id, event.at, event.origin);
225
+ }
226
+ linkMission(event);
227
+ insert(event);
228
+ return true;
229
+ });
230
+ },
231
+
232
+ read: readEvents,
233
+
234
+ version: count,
235
+
236
+ campaignIds() {
237
+ return db.prepare('SELECT id FROM campaigns ORDER BY created_at, id').all().map((row) => row.id);
238
+ },
239
+
240
+ campaignForMission(missionId) {
241
+ const row = db.prepare('SELECT campaign_id FROM missions WHERE mission_id = ?').get(missionId);
242
+ return row ? row.campaign_id : null;
243
+ },
244
+
245
+ // Small notes the store keeps beside the events, such as a campaign's
246
+ // cloud link and the last cloud seq pulled (lib/campaign-sync.js).
247
+ getMeta(key) {
248
+ const row = db.prepare('SELECT value FROM meta WHERE key = ?').get(key);
249
+ return row ? row.value : null;
250
+ },
251
+
252
+ setMeta(key, value) {
253
+ tx(() => db.prepare('INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value').run(key, String(value)));
254
+ },
255
+
256
+ close() {
257
+ db.close();
258
+ },
259
+ };
260
+ }
261
+
262
+ function createMemoryStore(options = {}) {
263
+ const now = options.now || (() => new Date().toISOString());
264
+ const origin = options.origin || newOrigin();
265
+ const events = [];
266
+ const campaigns = new Map();
267
+ const missions = new Map();
268
+ const seqByOrigin = new Map();
269
+ const meta = new Map();
270
+
271
+ const clone = (value) => JSON.parse(JSON.stringify(value));
272
+ const count = (campaignId) => events.filter((e) => e.campaign_id === campaignId).length;
273
+ const checkMission = (event) => {
274
+ const missionId = missionIdOf(event);
275
+ if (!missionId) return;
276
+ const owner = missions.get(missionId);
277
+ if (owner && owner !== event.campaign_id) {
278
+ throw storeError('mission_in_other_campaign', `mission ${missionId} already belongs to ${owner}`);
279
+ }
280
+ };
281
+ const commit = (event) => {
282
+ const missionId = missionIdOf(event);
283
+ if (missionId && !missions.has(missionId)) missions.set(missionId, event.campaign_id);
284
+ seqByOrigin.set(event.origin, Math.max(seqByOrigin.get(event.origin) || 0, event.seq));
285
+ events.push(clone(event));
286
+ };
287
+
288
+ return {
289
+ kind: 'memory',
290
+ path: null,
291
+ origin,
292
+ append(input, appendOptions = {}) {
293
+ const campaignId = input.campaign_id;
294
+ if (appendOptions.expectedVersion !== undefined && count(campaignId) !== appendOptions.expectedVersion) {
295
+ throw storeError('version_conflict', `campaign ${campaignId} changed since version ${appendOptions.expectedVersion}`);
296
+ }
297
+ if (input.type === 'campaign_started' && campaigns.has(campaignId)) throw storeError('campaign_exists', `campaign ${campaignId} already started`);
298
+ if (input.type !== 'campaign_started' && !campaigns.has(campaignId)) throw storeError('unknown_campaign', `no campaign ${campaignId}`);
299
+ if (appendOptions.check) appendOptions.check(fold(events.filter((e) => e.campaign_id === campaignId)));
300
+ const event = completeEvent(input, origin, (seqByOrigin.get(origin) || 0) + 1, now);
301
+ checkMission(event);
302
+ if (input.type === 'campaign_started') campaigns.set(campaignId, event.at);
303
+ commit(event);
304
+ return clone(event);
305
+ },
306
+ ingest(event) {
307
+ const check = validateEvent(event);
308
+ if (!check.ok) throw storeError('invalid_event', `invalid ${event && event.type}: ${check.errors.join(', ')}`);
309
+ if (events.some((e) => e.id === event.id)) return false;
310
+ const clash = events.find((e) => e.origin === event.origin && e.seq === event.seq);
311
+ if (clash) throw storeError('seq_conflict', `origin ${event.origin} seq ${event.seq} is already ${clash.id}`);
312
+ checkMission(event);
313
+ if (event.type === 'campaign_started' && !campaigns.has(event.campaign_id)) campaigns.set(event.campaign_id, event.at);
314
+ commit(event);
315
+ return true;
316
+ },
317
+ read(campaignId) {
318
+ return events.filter((e) => e.campaign_id === campaignId).map(clone);
319
+ },
320
+ version: count,
321
+ campaignIds() {
322
+ return [...campaigns.entries()].sort((a, b) => (a[1] === b[1] ? (a[0] < b[0] ? -1 : 1) : (a[1] < b[1] ? -1 : 1))).map(([id]) => id);
323
+ },
324
+ campaignForMission(missionId) {
325
+ return missions.get(missionId) || null;
326
+ },
327
+ getMeta(key) {
328
+ return meta.has(key) ? meta.get(key) : null;
329
+ },
330
+ setMeta(key, value) {
331
+ meta.set(key, String(value));
332
+ },
333
+ close() {},
334
+ };
335
+ }
336
+
337
+ const openStores = new Map();
338
+
339
+ function openCampaignStore(root = process.cwd()) {
340
+ const dbPath = campaignDbPath(root);
341
+ if (!openStores.has(dbPath)) openStores.set(dbPath, createSqliteStore(dbPath));
342
+ return openStores.get(dbPath);
343
+ }
344
+
345
+ function closeCampaignStores() {
346
+ for (const store of openStores.values()) store.close();
347
+ openStores.clear();
348
+ }
349
+
350
+ function getCampaign(storeOrRoot, campaignId) {
351
+ const store = typeof storeOrRoot === 'string' ? openCampaignStore(storeOrRoot) : storeOrRoot;
352
+ const events = store.read(campaignId);
353
+ if (events.length === 0) return null;
354
+ return fold(events);
355
+ }
356
+
357
+ // One unreadable campaign never hides the others: it comes back as { id, error }.
358
+ function listCampaigns(storeOrRoot) {
359
+ const store = typeof storeOrRoot === 'string' ? openCampaignStore(storeOrRoot) : storeOrRoot;
360
+ return store.campaignIds().map((id) => {
361
+ try {
362
+ const view = fold(store.read(id));
363
+ return view.error ? { id, error: view.error } : view;
364
+ } catch (error) {
365
+ return { id, error: error.code || error.message };
366
+ }
367
+ });
368
+ }
369
+
370
+ module.exports = {
371
+ campaignDbPath,
372
+ campaignFolder,
373
+ closeCampaignStores,
374
+ createMemoryStore,
375
+ createSqliteStore,
376
+ getCampaign,
377
+ listCampaigns,
378
+ newCampaignId,
379
+ openCampaignStore,
380
+ };
@@ -0,0 +1,302 @@
1
+ // Campaign cloud sync: a campaign on this computer and its copy in the Atris
2
+ // cloud trade events by id, so web and iOS show it and a person's answer from
3
+ // a phone reaches a running loop. Contract: atris/features/campaign/api.md.
4
+ //
5
+ // Push sends the events the cloud lacks; pull ingests the ones this computer
6
+ // lacks (answers and actions from screens). Both are idempotent by event id.
7
+ // A 403 or 409 is the cloud saying no on purpose, so it is reported plainly
8
+ // and never retried.
9
+ //
10
+ // A computer that has never seen the campaign (an Atris cloud computer told to
11
+ // run it) pulls the whole log first, campaign_started before anything else.
12
+
13
+ 'use strict';
14
+
15
+ const fs = require('fs');
16
+ const { apiRequestJson, getApiBaseUrl } = require('../utils/api');
17
+ const { loadCredentials } = require('../utils/auth');
18
+ const { CAMPAIGN_ID } = require('./campaign-schema');
19
+
20
+ const PUSH_BATCH = 200;
21
+ const PULL_LIMIT = 500;
22
+ const REQUEST_TIMEOUT_MS = 15000;
23
+ // Only a person's screen may send these to the cloud (api.md, Write). A local
24
+ // copy stays local; a person answers on the phone, the web, or with
25
+ // atris campaign answer, which goes to the cloud for a synced campaign.
26
+ const PERSON_ONLY = new Set(['ask_answered', 'budget_added', 'done']);
27
+
28
+ const metaKey = (campaignId) => `campaign_sync:${campaignId}`;
29
+
30
+ function readState(store, campaignId) {
31
+ const raw = store.getMeta(metaKey(campaignId));
32
+ if (!raw) return null;
33
+ try {
34
+ const state = JSON.parse(raw);
35
+ return state && typeof state === 'object' ? state : null;
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ function writeState(store, campaignId, state) {
42
+ store.setMeta(metaKey(campaignId), JSON.stringify(state));
43
+ }
44
+
45
+ // The cloud link for a campaign, or null when it has never been synced.
46
+ function cloudLink(store, campaignId) {
47
+ const state = readState(store, campaignId);
48
+ return state && state.link ? state : null;
49
+ }
50
+
51
+ function plainError(response) {
52
+ const status = response && response.status;
53
+ const said = response && (response.error || (response.data && (response.data.error || response.data.detail)));
54
+ if (!status) return `could not reach the Atris cloud${said ? ` (${said})` : ''}`;
55
+ if (status === 401) return 'the Atris cloud did not accept your login. run: atris login';
56
+ if (status === 403) return `the Atris cloud refused this (403)${said ? `: ${said}` : ''}`;
57
+ if (status === 409) return `the Atris cloud holds a different event at the same place (409)${said ? `: ${said}` : ''}`;
58
+ return `the Atris cloud answered ${status}${said ? `: ${said}` : ''}`;
59
+ }
60
+
61
+ function failure(result, response) {
62
+ return { ...result, ok: false, status: (response && response.status) || 0, error: plainError(response) };
63
+ }
64
+
65
+ function tokenFrom(load) {
66
+ try {
67
+ const credentials = load();
68
+ const token = credentials && String(credentials.token || '').trim();
69
+ return token || null;
70
+ } catch {
71
+ return null;
72
+ }
73
+ }
74
+
75
+ // Every event the cloud holds for a campaign this computer has never seen,
76
+ // ingested with campaign_started first. Returns { ok, pulled, last_seq } or
77
+ // { ok: false, status, error } with what exactly went wrong.
78
+ async function pullWholeCampaign(store, campaignId, call) {
79
+ const base = `/campaigns/${encodeURIComponent(campaignId)}`;
80
+ const incoming = [];
81
+ let after = 0;
82
+ for (;;) {
83
+ const response = await call(`${base}/events?after=${after}&limit=${PULL_LIMIT}`, { method: 'GET' });
84
+ if (response && response.status === 404) {
85
+ return { ok: false, status: 404, error: `the Atris cloud has no campaign ${campaignId} for this login, so there is nothing to pull. Start it with --cloud on the computer that has it, or check the login (atris whoami)` };
86
+ }
87
+ if (!response || !response.ok) return { ok: false, status: (response && response.status) || 0, error: plainError(response) };
88
+ const data = response.data || {};
89
+ const page = (Array.isArray(data.events) ? data.events : []).filter((event) => event && event.campaign_id === campaignId);
90
+ incoming.push(...page);
91
+ const seq = Number(data.last_event_seq);
92
+ if (Number.isInteger(seq) && seq > after) after = seq;
93
+ if (!Array.isArray(data.events) || data.events.length < PULL_LIMIT) break;
94
+ }
95
+ if (!incoming.length) {
96
+ return { ok: false, status: 404, error: `the Atris cloud has no events for ${campaignId}, so there is nothing to pull. Start it with --cloud on the computer that has it` };
97
+ }
98
+ const starts = incoming.filter((event) => event.type === 'campaign_started');
99
+ if (starts.length !== 1) {
100
+ return { ok: false, status: 0, error: `the cloud copy of ${campaignId} has ${starts.length} campaign_started events, not 1, so this computer cannot start it` };
101
+ }
102
+ let pulled = 0;
103
+ for (const event of [starts[0], ...incoming.filter((e) => e !== starts[0])]) {
104
+ try {
105
+ if (store.ingest(event)) pulled += 1;
106
+ } catch (error) {
107
+ return { ok: false, status: 0, error: `cloud event ${event.id} could not be taken in: ${error.message}` };
108
+ }
109
+ }
110
+ return { ok: true, pulled, last_seq: after };
111
+ }
112
+
113
+ // An Atris cloud computer running a campaign holds a token for that one
114
+ // campaign's sync routes, in a file the backend placed (0600). The path comes
115
+ // in ATRIS_CAMPAIGN_TOKEN_FILE. It is read once and taken out of the
116
+ // environment, so no mission, engine, or child CLI started here inherits it.
117
+ const CAMPAIGN_TOKEN_ENV = 'ATRIS_CAMPAIGN_TOKEN_FILE';
118
+
119
+ function takeCampaignToken(env = process.env, read = fs.readFileSync) {
120
+ if (!env || env[CAMPAIGN_TOKEN_ENV] === undefined) return null;
121
+ const file = String(env[CAMPAIGN_TOKEN_ENV]).trim();
122
+ delete env[CAMPAIGN_TOKEN_ENV];
123
+ try {
124
+ const body = JSON.parse(read(file, 'utf8'));
125
+ const token = body && typeof body.token === 'string' ? body.token.trim() : '';
126
+ if (token && CAMPAIGN_ID.test(String(body.campaign_id || ''))) return { campaign_id: body.campaign_id, token };
127
+ } catch {}
128
+ return { error: `the campaign token file ${file} could not be read` };
129
+ }
130
+
131
+ // The token a sync of this campaign uses: the campaign's own token when this
132
+ // computer holds one (never the login, even if that fails), else the login.
133
+ function syncToken(campaignId, deps) {
134
+ const scoped = deps.campaignToken;
135
+ if (scoped) {
136
+ if (scoped.error) return { error: scoped.error };
137
+ if (scoped.campaign_id !== campaignId) return { error: `the campaign token here is for ${scoped.campaign_id}, not ${campaignId}` };
138
+ return { token: scoped.token };
139
+ }
140
+ const token = tokenFrom(deps.loadCredentials || loadCredentials);
141
+ return token ? { token } : { error: 'not logged in to Atris. run: atris login' };
142
+ }
143
+
144
+ // One sync of one campaign. Never throws for cloud trouble; returns
145
+ // { ok, pushed, duplicates, held, pulled, last_seq, link, error?, status? }.
146
+ async function syncCampaign(store, campaignId, deps = {}) {
147
+ const request = deps.apiRequestJson || apiRequestJson;
148
+ const now = deps.now || (() => new Date().toISOString());
149
+ const base = `/campaigns/${encodeURIComponent(campaignId)}`;
150
+ const state = readState(store, campaignId) || { pushed: 0, last_seq: 0 };
151
+ // A campaign this computer has never seen is linked only once its pull
152
+ // works, so a wrong id leaves nothing behind.
153
+ const firstPull = store.read(campaignId).length === 0;
154
+ const link = state.link || { url: `${getApiBaseUrl()}${base}`, linked_at: now() };
155
+ if (!state.link && !firstPull) {
156
+ // Linked from the first try, so a computer that is offline now keeps
157
+ // trying on every later run step.
158
+ state.link = link;
159
+ writeState(store, campaignId, state);
160
+ }
161
+ const result = { ok: true, campaign_id: campaignId, pushed: 0, duplicates: 0, held: 0, pulled: 0, last_seq: state.last_seq || 0, link: link.url };
162
+
163
+ const { token, error: tokenError } = syncToken(campaignId, deps);
164
+ if (!token) return { ...result, ok: false, status: 401, error: tokenError };
165
+ const call = (path, options) => request(path, { token, retries: 0, timeoutMs: REQUEST_TIMEOUT_MS, ...options });
166
+
167
+ if (firstPull) {
168
+ const whole = await pullWholeCampaign(store, campaignId, call);
169
+ if (!whole.ok) return { ...result, ok: false, status: whole.status, error: whole.error, first_pull: true };
170
+ // Everything here came from the cloud, so none of it needs pushing back.
171
+ state.link = link;
172
+ state.pushed = store.read(campaignId).length;
173
+ state.last_seq = whole.last_seq;
174
+ writeState(store, campaignId, state);
175
+ result.pulled = whole.pulled;
176
+ result.first_pull = true;
177
+ }
178
+
179
+ // Push: every local event past the last one the cloud is known to hold.
180
+ const events = store.read(campaignId);
181
+ if (!events.length) return { ...result, ok: false, error: `no campaign ${campaignId} on this computer` };
182
+ const known = new Set();
183
+ const outgoing = [];
184
+ for (const event of events.slice(state.pushed || 0)) {
185
+ if (event.origin === 'cloud') { known.add(event.id); continue; }
186
+ if (PERSON_ONLY.has(event.type)) { result.held += 1; known.add(event.id); continue; }
187
+ outgoing.push(event);
188
+ }
189
+ for (let i = 0; i < outgoing.length; i += PUSH_BATCH) {
190
+ const batch = outgoing.slice(i, i + PUSH_BATCH);
191
+ const response = await call(`${base}/events`, { method: 'POST', body: { events: batch } });
192
+ if (!response || !response.ok) return failure(result, response);
193
+ const data = response.data || {};
194
+ result.pushed += Number(data.accepted) || 0;
195
+ result.duplicates += Number(data.duplicates) || 0;
196
+ for (const event of batch) known.add(event.id);
197
+ }
198
+
199
+ // Pull: everything the cloud took in after the last seq seen here.
200
+ let after = state.last_seq || 0;
201
+ for (;;) {
202
+ const response = await call(`${base}/events?after=${after}&limit=${PULL_LIMIT}`, { method: 'GET' });
203
+ if (!response || !response.ok) {
204
+ state.pushed = advance(events, state.pushed || 0, known);
205
+ writeState(store, campaignId, state);
206
+ return failure(result, response);
207
+ }
208
+ const data = response.data || {};
209
+ const incoming = Array.isArray(data.events) ? data.events : [];
210
+ for (const event of incoming) {
211
+ if (!event || event.campaign_id !== campaignId) continue;
212
+ try {
213
+ if (store.ingest(event)) result.pulled += 1;
214
+ } catch (error) {
215
+ state.pushed = advance(events, state.pushed || 0, known);
216
+ writeState(store, campaignId, state);
217
+ return { ...result, ok: false, status: 0, error: `a cloud event could not be taken in: ${error.message}` };
218
+ }
219
+ known.add(event.id);
220
+ }
221
+ const seq = Number(data.last_event_seq);
222
+ if (Number.isInteger(seq) && seq > after) after = seq;
223
+ state.last_seq = after;
224
+ if (incoming.length < PULL_LIMIT) break;
225
+ }
226
+ result.last_seq = after;
227
+
228
+ // Everything the cloud now holds, including what was just pulled.
229
+ state.pushed = advance(store.read(campaignId), state.pushed || 0, known);
230
+ state.synced_at = now();
231
+ writeState(store, campaignId, state);
232
+ return result;
233
+ }
234
+
235
+ // Move the pushed mark forward over events the cloud is known to hold, and
236
+ // stop at the first one it may not have, so an event written mid-sync is sent
237
+ // next time.
238
+ function advance(events, from, known) {
239
+ let mark = from;
240
+ while (mark < events.length && known.has(events[mark].id)) mark += 1;
241
+ return mark;
242
+ }
243
+
244
+ // A person's answer or action on a synced campaign goes through the cloud's
245
+ // person routes, then comes back down by pull. Returns { ok, error?, status? }.
246
+ async function sendPersonAction(store, campaignId, action, deps = {}) {
247
+ const request = deps.apiRequestJson || apiRequestJson;
248
+ const token = tokenFrom(deps.loadCredentials || loadCredentials);
249
+ if (!token) return { ok: false, status: 401, error: 'not logged in to Atris. run: atris login' };
250
+ const base = `/campaigns/${encodeURIComponent(campaignId)}`;
251
+ const route = action.ask_id ? `${base}/asks/${encodeURIComponent(action.ask_id)}/answer` : `${base}/actions`;
252
+ const body = action.ask_id ? { answer: action.answer } : { action: action.action, amount_usd: action.amount_usd };
253
+ const response = await request(route, { method: 'POST', token, retries: 0, timeoutMs: REQUEST_TIMEOUT_MS, body });
254
+ if (!response || !response.ok) return failure({}, response);
255
+ const synced = await syncCampaign(store, campaignId, deps);
256
+ return synced.ok ? { ok: true, sync: synced } : { ok: true, sync: synced, warning: synced.error };
257
+ }
258
+
259
+ // Ask the cloud to run a campaign on the person's Atris computer
260
+ // (POST /campaigns/<id>/run-in-cloud), or read how that run is going
261
+ // (GET /campaigns/<id>/cloud-run). Returns { ok, data } or { ok: false, status, error }.
262
+ const CLOUD_RUN_TIMEOUT_MS = 120000; // waking a computer can take most of a minute
263
+
264
+ async function runInCloud(campaignId, body, deps = {}) {
265
+ const request = deps.apiRequestJson || apiRequestJson;
266
+ const token = tokenFrom(deps.loadCredentials || loadCredentials);
267
+ if (!token) return { ok: false, status: 401, error: 'not logged in to Atris. run: atris login' };
268
+ const response = await request(`/campaigns/${encodeURIComponent(campaignId)}/run-in-cloud`, {
269
+ method: 'POST', token, retries: 0, timeoutMs: CLOUD_RUN_TIMEOUT_MS, body,
270
+ });
271
+ if (!response || !response.ok) return cloudRunFailure(response);
272
+ return { ok: true, data: response.data || {} };
273
+ }
274
+
275
+ async function cloudRunStatus(campaignId, deps = {}) {
276
+ const request = deps.apiRequestJson || apiRequestJson;
277
+ const token = tokenFrom(deps.loadCredentials || loadCredentials);
278
+ if (!token) return { ok: false, status: 401, error: 'not logged in to Atris. run: atris login' };
279
+ const response = await request(`/campaigns/${encodeURIComponent(campaignId)}/cloud-run`, {
280
+ method: 'GET', token, retries: 0, timeoutMs: REQUEST_TIMEOUT_MS,
281
+ });
282
+ if (!response || !response.ok) return cloudRunFailure(response);
283
+ return { ok: true, data: response.data || {} };
284
+ }
285
+
286
+ // The run routes answer in plain sentences, so pass the cloud's own words on.
287
+ function cloudRunFailure(response) {
288
+ const status = (response && response.status) || 0;
289
+ const said = response && ((response.data && (response.data.error || response.data.detail)) || response.error);
290
+ if (!status) return { ok: false, status, error: plainError(response) };
291
+ if (status === 401) return { ok: false, status, error: plainError(response) };
292
+ if (status === 404 && !(response.data && response.data.error)) {
293
+ return { ok: false, status, error: 'the Atris cloud does not run campaigns on computers yet (404). Try again after the next backend deploy' };
294
+ }
295
+ return { ok: false, status, error: typeof said === 'string' && said ? said : plainError(response) };
296
+ }
297
+
298
+ // What this CLI can do for an Atris cloud computer; the backend checks it
299
+ // (atris campaign features --json) before it starts a cloud run here.
300
+ const CLOUD_FEATURES = ['cloud_pull', 'campaign_token_file'];
301
+
302
+ module.exports = { CAMPAIGN_TOKEN_ENV, CLOUD_FEATURES, cloudLink, cloudRunStatus, runInCloud, sendPersonAction, syncCampaign, takeCampaignToken };