atris 3.70.0 → 3.71.1

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 (79) 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 +38 -8
  7. package/commands/autoland.js +176 -7
  8. package/commands/autopilot.js +71 -11
  9. package/commands/campaign.js +779 -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/integrations.js +17 -5
  17. package/commands/interview.js +15 -3
  18. package/commands/land.js +22 -6
  19. package/commands/link.js +1 -0
  20. package/commands/member.js +359 -94
  21. package/commands/mission.js +249 -36
  22. package/commands/report.js +42 -8
  23. package/commands/run.js +9 -2
  24. package/commands/scout.js +9 -1
  25. package/commands/task.js +71 -17
  26. package/commands/team.js +212 -4
  27. package/commands/unknowns.js +15 -1
  28. package/commands/verify.js +46 -0
  29. package/commands/worktree.js +108 -15
  30. package/commands/write.js +12 -1
  31. package/commands/youtube.js +17 -2
  32. package/lib/accept-verify-gate.js +1 -0
  33. package/lib/account-bound.js +13 -0
  34. package/lib/auto-accept-certified.js +1 -0
  35. package/lib/autoland.js +13 -4
  36. package/lib/campaign-cadence.js +131 -0
  37. package/lib/campaign-core.js +330 -0
  38. package/lib/campaign-holdout.js +65 -0
  39. package/lib/campaign-loop.js +246 -0
  40. package/lib/campaign-missions.js +237 -0
  41. package/lib/campaign-new.js +251 -0
  42. package/lib/campaign-protect.js +75 -0
  43. package/lib/campaign-reading.js +147 -0
  44. package/lib/campaign-run-lock.js +59 -0
  45. package/lib/campaign-schema.js +196 -0
  46. package/lib/campaign-store.js +380 -0
  47. package/lib/campaign-sync.js +302 -0
  48. package/lib/campaign-trace.js +300 -0
  49. package/lib/conductor-artifacts.js +15 -1
  50. package/lib/daily-log.js +11 -1
  51. package/lib/dispatch-scout.js +18 -0
  52. package/lib/engine-ask.js +33 -3
  53. package/lib/engine-registry.js +2 -0
  54. package/lib/engine-resource.js +518 -0
  55. package/lib/fleet.js +125 -16
  56. package/lib/init-commit.js +80 -0
  57. package/lib/intents.js +8 -0
  58. package/lib/known-commands.js +1 -1
  59. package/lib/land-green.js +58 -7
  60. package/lib/landing-proof.js +352 -0
  61. package/lib/member-autoland.js +395 -18
  62. package/lib/member-board.js +7 -1
  63. package/lib/member-map-fold.js +126 -0
  64. package/lib/member-map-inbox.js +80 -0
  65. package/lib/member-progress.js +48 -1
  66. package/lib/member-report.js +127 -8
  67. package/lib/member-self-next-job.js +97 -0
  68. package/lib/member-task-pick.js +41 -14
  69. package/lib/member-up.js +249 -16
  70. package/lib/roster-prep.js +25 -2
  71. package/lib/runner-command.js +5 -0
  72. package/lib/task-db.js +47 -11
  73. package/lib/task-explanation.js +7 -0
  74. package/lib/task-list-keeper.js +8 -1
  75. package/lib/task-receipt.js +6 -1
  76. package/mcp/atris-mcp/index.mjs +212 -10
  77. package/package.json +2 -1
  78. package/scripts/det/engine-reading.js +37 -0
  79. package/scripts/det/ytnotes +29 -5
@@ -0,0 +1,196 @@
1
+ // Campaign schema: the struct, event types, and actions shared by every
2
+ // surface (CLI, backend, desktop, web, terrace). Pure, no I/O.
3
+ //
4
+ // The fold rules that turn events into state live in
5
+ // test/fixtures/campaign/README.md, which is the cross-surface contract.
6
+ // Spec: atris/features/campaign/idea.md.
7
+
8
+ 'use strict';
9
+
10
+ // idea.md "Events" list, plus two the spec's actions need but never named:
11
+ // add_budget writes budget_added, veto writes mission_vetoed, and judge_set
12
+ // changes the judge (found running real campaigns: a judge is not forever).
13
+ const EVENT_TYPES = Object.freeze([
14
+ 'campaign_started', 'seat_filled', 'bet_set', 'attached',
15
+ 'mission_proposed', 'mission_claimed', 'mission_started', 'step', 'kept', 'reverted', 'mission_ended',
16
+ 'mission_vetoed',
17
+ 'judge_ran', 'ask_opened', 'ask_answered',
18
+ 'budget_reserved', 'budget_released', 'budget_added',
19
+ 'paused', 'resumed', 'stopped', 'home_moved', 'done',
20
+ 'judge_set',
21
+ ]);
22
+
23
+ const ACTIONS = Object.freeze([
24
+ 'start', 'pause', 'resume', 'answer_ask', 'change_bet', 'add_budget', 'fill_seat', 'veto', 'stop',
25
+ ]);
26
+
27
+ const CONFIDENCE = Object.freeze(['open', 'leaning', 'stronger', 'answered']);
28
+
29
+ const STATES = Object.freeze([
30
+ 'running', 'needs_you', 'resting', 'queued', 'done', 'out_of_budget', 'paused',
31
+ ]);
32
+
33
+ const STOP_REASONS = Object.freeze(['judge_passed', 'stopped', 'done_by_human', 'budget_spent']);
34
+
35
+ const MISSION_OUTCOMES = Object.freeze(['kept', 'reverted', 'failed']);
36
+
37
+ // What a person or agent writes. Everything else is folded from events.
38
+ const CAMPAIGN_FIELDS = Object.freeze({
39
+ title: { required: true, type: 'string' },
40
+ judge: { required: true, type: 'string' },
41
+ budget: { required: true, type: 'number' },
42
+ owner: { required: false, type: 'string' },
43
+ seats: { required: false, type: 'array' },
44
+ bet: { required: false, type: 'string' },
45
+ standing: { required: false, type: 'boolean' },
46
+ parent: { required: false, type: 'string' },
47
+ });
48
+
49
+ const CAMPAIGN_ID = /^cmp-[a-z0-9][a-z0-9-]{0,63}$/;
50
+ const EVENT_ID = /^[\x21-\x7e]{1,128}$/;
51
+ const ORIGIN = /^[A-Za-z0-9._:-]{1,64}$/;
52
+ // RFC 3339 with a zone: no surface may read a timestamp in its own timezone.
53
+ const TIMESTAMP = /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2})(?:\.(\d{1,9}))?(Z|[+-]\d{2}:\d{2})$/;
54
+
55
+ function isObject(value) {
56
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
57
+ }
58
+
59
+ function nonEmpty(value) {
60
+ return typeof value === 'string' && value.trim().length > 0;
61
+ }
62
+
63
+ function positiveNumber(value) {
64
+ return typeof value === 'number' && Number.isFinite(value) && value > 0;
65
+ }
66
+
67
+ function nonNegativeNumber(value) {
68
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0;
69
+ }
70
+
71
+ // A seat is "member" (role comes from the member's own job) or { member, role }.
72
+ function seatErrors(seats) {
73
+ if (!Array.isArray(seats)) return ['seats_not_list'];
74
+ const errors = [];
75
+ for (const seat of seats) {
76
+ if (nonEmpty(seat)) continue;
77
+ if (isObject(seat) && nonEmpty(seat.member) && (seat.role === undefined || seat.role === null || nonEmpty(seat.role))) continue;
78
+ errors.push('bad_seat');
79
+ }
80
+ return errors;
81
+ }
82
+
83
+ // record: the hand-written struct. options.lookup(id) returns another
84
+ // campaign record (or null) so the one-level nesting rule can be checked.
85
+ function validateCampaign(record, options = {}) {
86
+ const errors = [];
87
+ if (!isObject(record)) return { ok: false, errors: ['not_object'] };
88
+ for (const key of Object.keys(record)) {
89
+ if (!Object.prototype.hasOwnProperty.call(CAMPAIGN_FIELDS, key)) errors.push(`unknown_field:${key}`);
90
+ }
91
+ if (!nonEmpty(record.title)) errors.push('missing_title');
92
+ if (!nonEmpty(record.judge)) errors.push('missing_judge');
93
+ if (!positiveNumber(record.budget)) errors.push('bad_budget');
94
+ if (record.owner !== undefined && !nonEmpty(record.owner)) errors.push('bad_owner');
95
+ if (record.bet !== undefined && !nonEmpty(record.bet)) errors.push('bad_bet');
96
+ if (record.standing !== undefined && typeof record.standing !== 'boolean') errors.push('bad_standing');
97
+ if (record.seats !== undefined) errors.push(...seatErrors(record.seats));
98
+ if (record.parent !== undefined) {
99
+ if (!nonEmpty(record.parent)) {
100
+ errors.push('bad_parent');
101
+ } else if (typeof options.lookup === 'function') {
102
+ const parent = options.lookup(record.parent);
103
+ if (!parent) errors.push('unknown_parent');
104
+ else if (parent.parent) errors.push('nested_parent');
105
+ }
106
+ }
107
+ return { ok: errors.length === 0, errors };
108
+ }
109
+
110
+ // Fields each event type must carry in its data.
111
+ const EVENT_DATA_RULES = Object.freeze({
112
+ campaign_started: (d) => [
113
+ nonEmpty(d.title) ? null : 'missing_title',
114
+ nonEmpty(d.judge) ? null : 'missing_judge',
115
+ positiveNumber(d.budget) ? null : 'bad_budget',
116
+ d.seats === undefined ? null : (seatErrors(d.seats)[0] || null),
117
+ d.standing === undefined || typeof d.standing === 'boolean' ? null : 'bad_standing',
118
+ ],
119
+ seat_filled: (d) => [nonEmpty(d.member) ? null : 'missing_member', nonEmpty(d.role) ? null : 'missing_role'],
120
+ bet_set: (d) => [nonEmpty(d.bet) ? null : 'missing_bet'],
121
+ judge_set: (d) => [nonEmpty(d.judge) ? null : 'missing_judge'],
122
+ attached: (d) => [nonEmpty(d.parent_id) ? null : 'missing_parent_id'],
123
+ mission_proposed: (d) => [nonEmpty(d.mission_id) ? null : 'missing_mission_id'],
124
+ mission_claimed: (d) => [
125
+ nonEmpty(d.mission_id) ? null : 'missing_mission_id',
126
+ nonEmpty(d.claim_key) ? null : 'missing_claim_key',
127
+ ],
128
+ mission_started: (d) => [
129
+ nonEmpty(d.mission_id) ? null : 'missing_mission_id',
130
+ nonEmpty(d.member) ? null : 'missing_member',
131
+ ],
132
+ step: (d) => [nonEmpty(d.mission_id) ? null : 'missing_mission_id'],
133
+ kept: (d) => [nonEmpty(d.mission_id) ? null : 'missing_mission_id'],
134
+ reverted: (d) => [nonEmpty(d.mission_id) ? null : 'missing_mission_id'],
135
+ // Every mission reports its cost (idea.md review rule 1).
136
+ mission_ended: (d) => [
137
+ nonEmpty(d.mission_id) ? null : 'missing_mission_id',
138
+ MISSION_OUTCOMES.includes(d.outcome) ? null : 'bad_outcome',
139
+ nonNegativeNumber(d.cost_usd) ? null : 'missing_cost',
140
+ ],
141
+ mission_vetoed: (d) => [nonEmpty(d.mission_id) ? null : 'missing_mission_id'],
142
+ judge_ran: (d) => [
143
+ typeof d.passed === 'boolean' ? null : 'missing_passed',
144
+ d.reading === undefined || (typeof d.reading === 'number' && Number.isFinite(d.reading)) ? null : 'bad_reading',
145
+ d.confidence === undefined || ['open', 'leaning', 'stronger'].includes(d.confidence) ? null : 'bad_confidence',
146
+ ],
147
+ ask_opened: (d) => [nonEmpty(d.ask_id) ? null : 'missing_ask_id', nonEmpty(d.question) ? null : 'missing_question'],
148
+ ask_answered: (d) => [nonEmpty(d.ask_id) ? null : 'missing_ask_id', nonEmpty(d.answer) ? null : 'missing_answer'],
149
+ budget_reserved: (d) => [
150
+ nonEmpty(d.mission_id) ? null : 'missing_mission_id',
151
+ positiveNumber(d.amount_usd) ? null : 'bad_amount',
152
+ ],
153
+ budget_released: (d) => [nonEmpty(d.mission_id) ? null : 'missing_mission_id'],
154
+ budget_added: (d) => [positiveNumber(d.amount_usd) ? null : 'bad_amount'],
155
+ paused: () => [],
156
+ resumed: () => [],
157
+ stopped: () => [],
158
+ home_moved: (d) => [nonEmpty(d.to) ? null : 'missing_to'],
159
+ done: () => [],
160
+ });
161
+
162
+ function validateEvent(event) {
163
+ if (!isObject(event)) return { ok: false, errors: ['not_object'] };
164
+ const errors = [];
165
+ if (typeof event.id !== 'string' || !EVENT_ID.test(event.id)) errors.push('bad_id');
166
+ if (typeof event.campaign_id !== 'string' || !CAMPAIGN_ID.test(event.campaign_id)) errors.push('bad_campaign_id');
167
+ if (!EVENT_TYPES.includes(event.type)) errors.push('unknown_type');
168
+ if (typeof event.at !== 'string' || !TIMESTAMP.test(event.at) || Number.isNaN(Date.parse(event.at))) errors.push('bad_at');
169
+ if (typeof event.origin !== 'string' || !ORIGIN.test(event.origin)) errors.push('bad_origin');
170
+ if (!Number.isInteger(event.seq) || event.seq < 1) errors.push('bad_seq');
171
+ if (!nonEmpty(event.actor)) errors.push('missing_actor');
172
+ if (!isObject(event.data)) errors.push('bad_data');
173
+ if (errors.length === 0) {
174
+ for (const code of EVENT_DATA_RULES[event.type](event.data)) if (code) errors.push(code);
175
+ }
176
+ return { ok: errors.length === 0, errors };
177
+ }
178
+
179
+ function isHumanActor(actor) {
180
+ return typeof actor === 'string' && actor.startsWith('human:');
181
+ }
182
+
183
+ module.exports = {
184
+ ACTIONS,
185
+ CAMPAIGN_FIELDS,
186
+ CAMPAIGN_ID,
187
+ CONFIDENCE,
188
+ EVENT_TYPES,
189
+ TIMESTAMP,
190
+ MISSION_OUTCOMES,
191
+ STATES,
192
+ STOP_REASONS,
193
+ isHumanActor,
194
+ validateCampaign,
195
+ validateEvent,
196
+ };
@@ -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
+ };