@arjunkhera/atlas 0.3.11 → 0.3.13

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.
@@ -51,9 +51,13 @@ export class Evidence {
51
51
  ways: {}, assertions: [], tables: {}, fresh: [], calls: [], notes: [], files: [],
52
52
  provided_secrets: providedSecrets, fault_run: process.env.ATLAS_FAULT_RUN || null,
53
53
  };
54
- for (const way of scenario.through) {
54
+ // One entry for each assertion and each way in that it belongs to (scenario.mjs, waysOf). An
55
+ // assertion that reads a result of a step with its own way in (the page) has that way only.
56
+ for (const way of scenario.ways ?? scenario.through) {
55
57
  this.record.ways[way] = { verdict: 'not finished', reason: null, started: null, ended: null };
56
- for (const a of scenario.assertions) this.record.assertions.push({ id: a.fullId, way, result: 'not checked', proof: null });
58
+ }
59
+ for (const a of scenario.assertions) {
60
+ for (const way of a.ways ?? scenario.through) this.record.assertions.push({ id: a.fullId, way, result: 'not checked', proof: null });
57
61
  }
58
62
  mkdirSync(this.dir, { recursive: true });
59
63
  this.path = this.claim(scenario.id);
package/tests/index.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
  export { scenario, defineAdapter, waitUntil, RUN_ID } from './contract.mjs';
3
3
  export { startEnvironment, parseDotenv, provideSecrets } from './environment.mjs';
4
4
  export { createStandIn, serveStandIn, CALLS_PATH, LIBRARY_HEADER } from './stand-in.mjs';
5
- export { createHttpDriver, createMcpStdioDriver, createFunctionDriver, makeDriver } from './drivers/index.mjs';
5
+ export { createHttpDriver, createMcpStdioDriver, createFunctionDriver, createBrowserDriver, makeDriver } from './drivers/index.mjs';
6
6
  export { parseTestsYaml, readTestsYaml, checkTestsYaml, guardParts, compareGuardParts, GUARD_PARTS, looksLikeSecret } from './tests-yaml.mjs';
7
7
  export { parseScenario, readScenario, addFingerprints, FINGERPRINT_METHOD } from './scenario.mjs';
8
8
  export { Blocked, Unavailable, WaitFailed, WaitTimeout, GateFailed } from './errors.mjs';
@@ -500,7 +500,7 @@ export function checkRun({ evidenceDir, runId = null, environment = null, scenar
500
500
  continue;
501
501
  }
502
502
  for (const a of s.assertions) {
503
- for (const way of s.through) {
503
+ for (const way of a.ways ?? s.through) {
504
504
  const seen = mine.some((r) => r.data.assertions?.some((e) => e.id === a.fullId && e.way === way && reached.has(e.result)));
505
505
  if (!seen) findings.push({ file: rel(s.file), line: a.line, code: 'not-checked', message: `no run checked ${s.id}/${a.id}#${a.fp} through ${way}` });
506
506
  }
@@ -271,6 +271,23 @@ export function readScenario(path) {
271
271
 
272
272
  const runsIn = (scenario) => [].concat(scenario.fields['runs-in'] ?? []).map(String);
273
273
 
274
+ // The ways in that an assertion is checked through. An assertion reads a kept result. When the
275
+ // step that kept it names its own way in ("As `owner`, through `page`, ..."), the assertion belongs
276
+ // to that way: the proof table shows it under that column (design section 6.3). An assertion that
277
+ // names no kept result of such a step belongs to every way of `through:`. The ways of `through:`
278
+ // stay first. The words and the fingerprint do not change.
279
+ function waysOf(assertion, scenario, kept) {
280
+ const named = new Set();
281
+ let plain = false;
282
+ for (const name of assertion.names) {
283
+ const step = kept.get(name);
284
+ if (!step) continue;
285
+ if (step.way) named.add(step.way); else plain = true;
286
+ }
287
+ if (!named.size || plain) return [...new Set([...scenario.through, ...named])];
288
+ return [...named];
289
+ }
290
+
274
291
  // Sets `fp` and `fullId` on every assertion. `tests` is the parsed value of
275
292
  // tests.yaml. Returns the scenario.
276
293
  export function addFingerprints(scenario, tests) {
@@ -315,7 +332,9 @@ export function addFingerprints(scenario, tests) {
315
332
  lines.push(`through=${[...scenario.through].sort().join(',')}`, `runs-in=${runsIn(scenario).sort().join(',')}`);
316
333
  a.fp = fingerprintOf([a.words, ...lines].join('\n'));
317
334
  a.fullId = `${scenario.id}/${a.id}#${a.fp}`;
335
+ a.ways = waysOf(a, scenario, kept);
318
336
  }
337
+ scenario.ways = [...new Set([...scenario.through, ...scenario.assertions.flatMap((a) => a.ways)])];
319
338
  return scenario;
320
339
  }
321
340
 
@@ -0,0 +1,246 @@
1
+ // Work item additions, slice 3: the log of a work item.
2
+ // The design: the work item additions, steps 5, 6, 8 and 9.
3
+ //
4
+ // One record type, entry, holds both a comment and a change. Each entry
5
+ // carries the tag item:<id>, so one list by type and tag finds every entry of
6
+ // an item: no scan of the tenant and no edge walk.
7
+ //
8
+ // The item verbs put a short diff into their own receipt on the item, in the
9
+ // same write (verbs.mjs, patch). After the verb, logEntries copies the diff
10
+ // into an entry. That second write is best effort: a failure is a warning,
11
+ // the diff stays in the receipt, and the next write on the item tries again.
12
+ import { VerbError, refuse, text, oneOf, LIVE_LIFECYCLES } from './verb-fields.mjs';
13
+ import { oneLine, scrubSecrets } from './questions.mjs';
14
+
15
+ export const ACTIVITY_WRITE_VERBS = Object.freeze(['item_comment']);
16
+ export const ACTIVITY_READ_VERBS = Object.freeze(['item_activity']);
17
+ export const VOICES = Object.freeze(['agent', 'owner']);
18
+ // The item verbs whose writes carry a diff (step 9). item_propose writes a
19
+ // "created" diff when it makes the item.
20
+ export const LOG_VERBS = Object.freeze(['item_lock', 'item_split', 'item_park', 'item_resume', 'item_drop', 'item_edit', 'item_stage', 'item_link', 'item_block', 'item_unblock', 'item_done', 'item_reopen', 'item_adopt']);
21
+ export const ENTRY_VERBS = Object.freeze(['item_propose', ...LOG_VERBS]);
22
+ const MAX_COMMENT = 4000;
23
+ const PREVIEW = 300;
24
+ // How many missing entries one write repairs, beside its own. Only a diff
25
+ // older than REPAIR_AFTER is repaired, so a write running at the same moment
26
+ // on the same item copies its own diff and no other writer copies it too.
27
+ const MAX_REPAIR = 4;
28
+ const REPAIR_AFTER_MS = 120_000;
29
+ const MAX_LIST_ITEMS = 20;
30
+ // Bookkeeping fields and stamps: the entry has its own time, so these are
31
+ // left out of the diff.
32
+ const NOT_LOGGED = new Set(['writer', 'request_id', 'atlas_receipts', 'edits', 'first_title', 'stage_changed_at', 'parked_at', 'resumed_at', 'undropped_at', 'dropped_at']);
33
+ export const itemTag = (id) => `item:${id}`;
34
+
35
+ // One value as the log keeps it. A text, number, true, false or null up to
36
+ // 300 characters stays whole. A longer text, a list or an object becomes a
37
+ // 300-character preview with cut. The secret filter runs on every value.
38
+ function shown(value) {
39
+ if (value === undefined) return { value: null, cut: false };
40
+ if (value === null || typeof value === 'number' || typeof value === 'boolean') return { value, cut: false };
41
+ const whole = typeof value === 'string' ? value : JSON.stringify(value);
42
+ // Cut by code point: half an emoji makes Postgres refuse the whole write.
43
+ const chars = Array.from(scrubSecrets(whole).text);
44
+ if (typeof value === 'string' && chars.length <= PREVIEW) return { value: chars.join(''), cut: false };
45
+ return { value: chars.length <= PREVIEW ? chars.join('') : `${chars.slice(0, PREVIEW - 1).join('')}…`, cut: true };
46
+ }
47
+ const same = (a, b) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
48
+ const isList = (value) => Array.isArray(value) || value === undefined || value === null;
49
+ // A list is kept as the members it gained and lost, each shown on its own,
50
+ // so a member at the end of a long list is never cut away.
51
+ function pair(from, to) {
52
+ if (isList(from) && isList(to) && (Array.isArray(from) || Array.isArray(to))) {
53
+ const before = from ?? [], after = to ?? [];
54
+ const gone = before.filter((row) => !after.some((other) => same(row, other)));
55
+ const come = after.filter((row) => !before.some((other) => same(row, other)));
56
+ const list = (rows) => rows.slice(0, MAX_LIST_ITEMS).map((row) => shown(row));
57
+ const added = list(come), removed = list(gone);
58
+ const cut = come.length > MAX_LIST_ITEMS || gone.length > MAX_LIST_ITEMS || [...added, ...removed].some((row) => row.cut);
59
+ return { added: added.map((row) => row.value), removed: removed.map((row) => row.value), ...(cut ? { cut: true } : {}) };
60
+ }
61
+ const a = shown(from), b = shown(to);
62
+ return { from: a.value, to: b.value, ...(a.cut || b.cut ? { cut: true } : {}) };
63
+ }
64
+
65
+ // The diff of one write: each field it changes, as from and to. The title
66
+ // and the tags count as fields.
67
+ export function diffOf(current, next, { title, tags } = {}) {
68
+ const change = {};
69
+ for (const [key, value] of Object.entries(next ?? {})) {
70
+ if (NOT_LOGGED.has(key)) continue;
71
+ if (!same(current?.fields?.[key], value)) change[key] = pair(current?.fields?.[key], value);
72
+ }
73
+ if (title !== undefined && title !== current?.title) change.title = pair(current?.title ?? null, title);
74
+ if (tags !== undefined && !same(current?.tags ?? [], tags)) change.tags = pair(current?.tags ?? [], tags);
75
+ return change;
76
+ }
77
+
78
+ export function createActivityVerbs(core) {
79
+ const { client, label, now, typeNamed, requireItem, requireOwner, create, alreadyPatched } = core;
80
+
81
+ // The entry type, or null before the owner has run setup for 0.7.0.
82
+ async function entryType() {
83
+ try { return await typeNamed('entry'); }
84
+ catch (error) { if (error instanceof VerbError) return null; throw error; }
85
+ }
86
+ async function entriesOf(type, itemId) {
87
+ const rows = await client.listAllEntities({ type: type.name, tag: itemTag(itemId) });
88
+ return rows.filter((row) => row.fields?.item === itemId);
89
+ }
90
+ // The newest entries of an item, one page. An item holds at most 100
91
+ // receipts, so only its newest entries can match one.
92
+ async function recentEntries(type, itemId) {
93
+ if (typeof client.listEntities !== 'function') return entriesOf(type, itemId);
94
+ const page = await client.listEntities({ type: type.name, tag: itemTag(itemId), order: 'created_at_desc', limit: 200 });
95
+ return (Array.isArray(page?.entities) ? page.entities : []).filter((row) => row.fields?.item === itemId);
96
+ }
97
+ const changeIds = (rows) => new Set(rows.filter((row) => row.fields?.kind === 'change').map((row) => row.fields.request_id));
98
+ // The request ids that already have a change entry on this item. A failed
99
+ // read never stops the item write: the trim then goes by size only.
100
+ async function loggedIds(itemId) {
101
+ try {
102
+ const type = await entryType();
103
+ return type ? changeIds(await recentEntries(type, itemId)) : new Set();
104
+ } catch { return new Set(); }
105
+ }
106
+
107
+ // Copy the diff of this request into an entry, and repair a few older ones
108
+ // that never landed. Returns a warning, or null. It never throws: the item
109
+ // write already landed, so the verb succeeds.
110
+ async function logEntries(itemId, requestId) {
111
+ const keep = 'The values stay in the item\'s receipt, and the next write on the item tries again.';
112
+ try {
113
+ const type = await entryType();
114
+ if (!type) return `no log entry was written: the entry type is not registered, so the owner runs setup for manifest 0.7.0. ${keep}`;
115
+ const item = await client.getEntity(itemId);
116
+ const receipts = item.fields?.atlas_receipts ?? {};
117
+ if (!receipts[requestId]?.change) return null;
118
+ const have = changeIds(await recentEntries(type, item.id));
119
+ const cutoff = Date.parse(now()) - REPAIR_AFTER_MS;
120
+ const older = Object.entries(receipts).filter(([id, receipt]) => id !== requestId && receipt?.change && !have.has(id) && Date.parse(receipt.at) < cutoff)
121
+ .sort(([, a], [, b]) => String(b.at ?? '').localeCompare(String(a.at ?? ''))).slice(0, MAX_REPAIR);
122
+ const pending = [...(have.has(requestId) ? [] : [[requestId, receipts[requestId]]]), ...older];
123
+ for (const [id, receipt] of pending) {
124
+ await create(type, id, receipt.verb, {
125
+ title: oneLine(`${receipt.verb} · ${item.title ?? item.id}`, 200),
126
+ fields: { item: item.id, ...(item.fields?.product ? { product: item.fields.product } : {}), kind: 'change', verb: receipt.verb, change: receipt.change, ...(receipt.note ? { text: receipt.note } : {}), at: receipt.at },
127
+ tags: [itemTag(item.id), ...(item.fields?.product ? [`product:${item.fields.product}`] : [])],
128
+ readBack: false,
129
+ });
130
+ }
131
+ return null;
132
+ } catch (error) {
133
+ return `the log entry was not written: ${error?.message ?? String(error)}. ${keep}`;
134
+ }
135
+ }
136
+
137
+ const verbs = {
138
+ // A comment on an item, from the lead or in the owner's words.
139
+ async item_comment(args, requestId, meta) {
140
+ const item = await requireItem(args.item_id);
141
+ const voice = oneOf(args.voice, 'voice', VOICES, 'agent');
142
+ if (voice === 'owner') requireOwner('item comment in the owner\'s voice');
143
+ const words = text(args.text, 'text', { max: MAX_COMMENT });
144
+ const type = await typeNamed('entry');
145
+ const entries = await entriesOf(type, item.id);
146
+ const prior = entries.find((row) => row.fields?.request_id === requestId);
147
+ if (prior && alreadyPatched(prior, requestId, meta)) return { verb: 'item_comment', item_id: item.id, entry_id: prior.id, voice: prior.fields.voice, redactions: prior.fields.redactions ?? 0, ...(prior.fields.corrects ? { corrects: prior.fields.corrects } : {}), replayed_from_graph: true };
148
+ if (!LIVE_LIFECYCLES.includes(item.fields?.lifecycle)) refuse(`item ${item.id} is ${item.fields?.lifecycle}; only a proposed, active or paused item takes a comment`);
149
+ let corrects;
150
+ if (args.corrects !== undefined) {
151
+ text(args.corrects, 'corrects', { max: 200 });
152
+ const old = entries.find((row) => row.id === args.corrects && row.fields?.kind === 'comment');
153
+ if (!old) refuse(`corrects must name a comment on item ${item.id}`);
154
+ corrects = old.id;
155
+ }
156
+ const clean = scrubSecrets(words);
157
+ const entity = await create(type, requestId, 'item_comment', {
158
+ digest: meta?.digest, title: oneLine(`comment · ${item.title ?? item.id}`, 200), body: clean.text,
159
+ fields: { item: item.id, ...(item.fields?.product ? { product: item.fields.product } : {}), kind: 'comment', voice, text: clean.text, ...(corrects ? { corrects } : {}), redactions: clean.redactions, at: now() },
160
+ tags: [itemTag(item.id), ...(item.fields?.product ? [`product:${item.fields.product}`] : [])],
161
+ });
162
+ return { verb: 'item_comment', item_id: item.id, entry_id: entity.id, voice, redactions: clean.redactions, ...(corrects ? { corrects } : {}) };
163
+ },
164
+
165
+ // The feed of one item, newest first: its entries, the receipts that have
166
+ // no entry yet, and its questions, decisions, delivery marks, pieces and
167
+ // links.
168
+ async item_activity(args) {
169
+ const item = await requireItem(args.item_id);
170
+ const limit = args.limit === undefined ? 50 : Number(args.limit);
171
+ if (!Number.isInteger(limit) || limit < 1 || limit > 200) refuse('limit must be a whole number from 1 to 200');
172
+ const fields = item.fields ?? {};
173
+ const rows = [];
174
+ const type = await entryType();
175
+ const entries = type ? await entriesOf(type, item.id) : [];
176
+ const logged = new Set();
177
+ for (const row of entries) {
178
+ const f = row.fields ?? {};
179
+ if (f.kind === 'comment') rows.push({ at: f.at, kind: 'comment', entry_id: row.id, voice: f.voice, text: f.text, ...(f.corrects ? { corrects: f.corrects } : {}) });
180
+ else if (f.kind === 'change') {
181
+ // Two writers can copy the same diff; the feed shows it once.
182
+ if (logged.has(f.request_id)) continue;
183
+ logged.add(f.request_id);
184
+ rows.push({ at: f.at, kind: 'change', entry_id: row.id, verb: f.verb, change: f.change ?? {}, ...(f.text ? { note: f.text } : {}), values_kept: true });
185
+ } else rows.push({ at: f.at, kind: 'entry', entry_id: row.id, entry_kind: String(f.kind ?? '') });
186
+ }
187
+ for (const [id, receipt] of Object.entries(fields.atlas_receipts ?? {})) {
188
+ if (logged.has(id) || !receipt?.verb) continue;
189
+ rows.push(receipt.change
190
+ ? { at: receipt.at, kind: 'change', verb: receipt.verb, change: receipt.change, ...(receipt.note ? { note: receipt.note } : {}), values_kept: true, from_receipt: true }
191
+ : { at: receipt.at, kind: 'change', verb: receipt.verb, values_kept: false });
192
+ }
193
+ // The history kept on the item before slice 3 stays readable.
194
+ for (const edit of Array.isArray(fields.edits) ? fields.edits : []) rows.push({ at: edit.at, kind: 'edit', previous: edit.previous ?? {}, ...(edit.reason ? { note: edit.reason } : {}) });
195
+ const optional = async (name) => { try { return await typeNamed(name); } catch (error) { if (error instanceof VerbError) return null; throw error; } };
196
+ const listOf = async (name) => { const t = await optional(name); return t ? client.listAllEntities({ type: t.name }) : []; };
197
+ for (const row of await listOf('question')) {
198
+ if (row.fields?.item !== item.id) continue;
199
+ rows.push({ at: row.fields.asked_at, kind: 'question', question_id: row.id, status: row.fields.status, text: oneLine(row.fields.text ?? '', PREVIEW) });
200
+ }
201
+ for (const row of await listOf('decision')) {
202
+ if (!Array.isArray(row.fields?.items) || !row.fields.items.includes(item.id)) continue;
203
+ // A decision holds a day only; the record's own time places it.
204
+ rows.push({ at: row.created_at ?? row.fields.date, kind: 'decision', decision_id: row.id, decision_kind: row.fields.kind, text: oneLine(row.fields.text ?? '', PREVIEW) });
205
+ }
206
+ for (const row of await listOf('mark')) {
207
+ if (row.fields?.item !== item.id) continue;
208
+ rows.push({ at: row.fields.marked_at, kind: 'mark', mark_id: row.id, mark_kind: row.fields.kind, verdict: row.fields.verdict ?? null });
209
+ }
210
+ const pieces = await Promise.all((Array.isArray(fields.split_children) ? fields.split_children : []).map((id) => client.getEntity(id).catch(() => null)));
211
+ for (const piece of pieces) {
212
+ if (!piece) continue;
213
+ // The piece's date is the split that made it, not its last write.
214
+ const made = Object.values(piece.fields?.atlas_receipts ?? {}).filter((receipt) => receipt?.verb === 'item_split').map((receipt) => receipt.at).sort()[0] ?? piece.created_at ?? null;
215
+ rows.push({ at: made, kind: 'piece', item: piece.id, title: oneLine(piece.title ?? '', 200), split_order: piece.fields?.split_order ?? null });
216
+ }
217
+ for (const link of Array.isArray(fields.links) ? fields.links : []) rows.push({ at: link.added_at, kind: 'link', link_kind: link.kind, url: link.url, ...(link.role ? { role: link.role } : {}) });
218
+ rows.sort((a, b) => String(b.at ?? '').localeCompare(String(a.at ?? '')));
219
+ const shownRows = rows.slice(0, limit);
220
+ return { verb: 'item_activity', item_id: item.id, title: item.title, total: rows.length, rows: shownRows, lines: shownRows.map(lineOf),
221
+ ...(type ? {} : { note: 'the entry type is not registered yet; the owner runs setup for manifest 0.7.0. The feed reads the receipts on the item.' }) };
222
+ },
223
+ };
224
+ return { verbs, logEntries, loggedIds };
225
+ }
226
+
227
+ // One row as one line, for the lead to show.
228
+ function lineOf(row) {
229
+ const when = String(row.at ?? '').replace('T', ' ').slice(0, 16) || 'no date';
230
+ const one = (value) => (value === null || value === undefined ? '—' : typeof value === 'string' ? value : JSON.stringify(value));
231
+ const values = (change) => Object.entries(change ?? {}).map(([key, value]) => (Array.isArray(value?.added)
232
+ ? `${key}: ${[...value.added.map((row) => `+${one(row)}`), ...value.removed.map((row) => `−${one(row)}`)].join(', ')}`
233
+ : `${key}: ${one(value?.from)} → ${one(value?.to)}`)).join('; ');
234
+ switch (row.kind) {
235
+ case 'comment': return oneLine(`${when} comment (${row.voice}) "${row.text}"`);
236
+ case 'change': return oneLine(row.values_kept ? `${when} ${row.verb} ${values(row.change) || 'no field changed'}` : `${when} ${row.verb} no values recorded`);
237
+ case 'edit': return oneLine(`${when} edit was ${Object.entries(row.previous ?? {}).map(([key, value]) => `${key}: ${one(value)}`).join('; ')}${row.note ? ` · ${row.note}` : ''}`);
238
+ case 'entry': return oneLine(`${when} entry of kind ${row.entry_kind}`);
239
+ case 'question': return oneLine(`${when} question (${row.status}) ${row.text}`);
240
+ case 'decision': return oneLine(`${when} decision (${row.decision_kind}) "${row.text}"`);
241
+ case 'mark': return oneLine(`${when} mark ${row.mark_kind}${row.verdict ? `: ${row.verdict}` : ''}`);
242
+ case 'piece': return oneLine(`${when} piece ${row.title} · ${row.item}`);
243
+ case 'link': return oneLine(`${when} link (${row.link_kind}) ${row.url}`);
244
+ default: return oneLine(`${when} ${row.kind}`);
245
+ }
246
+ }
@@ -30,7 +30,10 @@ const MAX_RENDERED = 20_000;
30
30
 
31
31
  export const oneLine = (value, max = ROW_LINE_MAX) => {
32
32
  const flat = String(value ?? '').replace(/\s+/g, ' ').trim();
33
- return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
33
+ // Cut by code point, so a cut never leaves half an emoji: Postgres refuses
34
+ // a lone surrogate, and the whole write with it.
35
+ const chars = Array.from(flat);
36
+ return chars.length > max ? `${chars.slice(0, max - 1).join('')}…` : flat;
34
37
  };
35
38
 
36
39
  // R12: a scrub by pattern in receipt keep. The patterns are the ones the
@@ -11,17 +11,19 @@
11
11
  // lock block L1, L2 and L3. Every write carries request_id and writer. The
12
12
  // server, not the caller, supplies the session. Unknown ids are refused.
13
13
  // M2 slice L1 adds the repo kind in 0.5.0, which carries 0.4.0 whole.
14
- // Stage 1 of the target design adds kinds, a list, in 0.6.0.
15
- import manifestCurrent from '../manifest-0.6.0.json' with { type: 'json' };
14
+ // Stage 1 of the target design adds kinds, a list, in 0.6.0. The work item
15
+ // additions add entry, the log of an item, in 0.7.0.
16
+ import manifestCurrent from '../manifest-0.7.0.json' with { type: 'json' };
16
17
  import { EngramHttpError } from './client.mjs';
17
18
  import { VerbError, refuse, canonicalJson, sha256, text, oneOf, origin, url, stringList, LIVE_LIFECYCLES, REPO_KINDS, LIVE_KINDS, repoKinds, kindsFromArgs } from './verb-fields.mjs';
18
- import { createQuestionVerbs, COSTS, QUESTION_WRITE_VERBS, QUESTION_READ_VERBS } from './questions.mjs';
19
+ import { createQuestionVerbs, COSTS, QUESTION_WRITE_VERBS, QUESTION_READ_VERBS, oneLine, scrubSecrets } from './questions.mjs';
19
20
  import { createDeliveryVerbs, DELIVERY_WRITE_VERBS, DELIVERY_READ_VERBS } from './delivery.mjs';
20
21
  import { createCapabilityVerbs, CAPABILITY_READ_VERBS, hubOf } from './capability.mjs';
21
22
  import { createSweepVerbs, SWEEP_WRITE_VERBS, SWEEP_READ_VERBS } from './sweep.mjs';
23
+ import { createActivityVerbs, diffOf, LOG_VERBS, ENTRY_VERBS, ACTIVITY_WRITE_VERBS, ACTIVITY_READ_VERBS } from './activity.mjs';
22
24
 
23
25
  export { VerbError, canonicalJson, sha256, COSTS, LIVE_LIFECYCLES, REPO_KINDS, LIVE_KINDS, repoKinds };
24
- export const MANIFEST_VERSION = '0.6.0';
26
+ export const MANIFEST_VERSION = '0.7.0';
25
27
  export const PRODUCT_ID = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/;
26
28
  export const REPO_ID = /^[A-Za-z0-9](?:[A-Za-z0-9_.-]{0,99})\/[A-Za-z0-9_.-]{1,100}$/;
27
29
  export const STAGES = Object.freeze(['capture', 'spec', 'build', 'verify', 'learn']);
@@ -33,10 +35,37 @@ export const DRIVERS = Object.freeze(['owner-led', 'agent']);
33
35
  export const WAITS_ON = Object.freeze(['owner-decision', 'owner-action', 'gate', 'nothing']);
34
36
  // How far the loop check of item_block walks before it stops.
35
37
  const MAX_BLOCK_WALK = 200;
36
- const MAX_EDITS = 20;
38
+ // Work item additions, slice 3. Engram refuses an entity whose fields hold
39
+ // more than 64 KiB of UTF-8 (entity-write-bounds.ts). An item keeps its
40
+ // newest MAX_RECEIPTS receipts. A diff leaves its receipt once its log entry
41
+ // exists. If the whole record is still past FIELDS_LIMIT, the oldest diffs go
42
+ // first, then the oldest receipts. The receipt of this request and the one
43
+ // that made the item are never dropped: a retry needs them.
44
+ const MAX_RECEIPTS = 100;
45
+ const FIELDS_LIMIT = 56 * 1024;
46
+ export function trimReceipts(fields, receipts, logged = new Set(), keep = null) {
47
+ const pinned = (id, receipt) => id === keep || receipt?.verb === 'item_propose';
48
+ let rows = Object.entries(receipts ?? {}).sort(([, a], [, b]) => String(a?.at ?? '').localeCompare(String(b?.at ?? '')));
49
+ while (rows.length > MAX_RECEIPTS) {
50
+ const index = rows.findIndex(([id, receipt]) => !pinned(id, receipt));
51
+ if (index < 0) break;
52
+ rows.splice(index, 1);
53
+ }
54
+ const bare = ([id, receipt]) => { const { change, note, ...rest } = receipt ?? {}; return [id, rest]; };
55
+ rows = rows.map((row) => (row[1]?.change && logged.has(row[0]) ? bare(row) : row));
56
+ const size = () => Buffer.byteLength(JSON.stringify({ ...fields, atlas_receipts: Object.fromEntries(rows) }));
57
+ for (let index = 0; index < rows.length && size() > FIELDS_LIMIT; index += 1) {
58
+ if (rows[index][1]?.change && rows[index][0] !== keep) rows[index] = bare(rows[index]);
59
+ }
60
+ for (let index = 0; index < rows.length && size() > FIELDS_LIMIT;) {
61
+ if (pinned(...rows[index])) index += 1;
62
+ else rows.splice(index, 1);
63
+ }
64
+ return Object.fromEntries(rows);
65
+ }
37
66
  // The tags that other verbs own (work item additions, slice 1). item_edit
38
67
  // never adds or removes them; item_propose drops them from the caller's list.
39
- export const OWNED_TAG_PREFIXES = Object.freeze(['product:', 'repo:', 'tier:']);
68
+ export const OWNED_TAG_PREFIXES = Object.freeze(['product:', 'repo:', 'tier:', 'item:']);
40
69
  export const OWNED_TAGS = Object.freeze(['atlas-work', 'no-model', 'atlas-registry']);
41
70
  // Engram reads no-model with the spaces trimmed and in any case, so the check
42
71
  // does the same: No-Model is owned too.
@@ -166,20 +195,32 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
166
195
  // recomputes it from the newer record. guard, when given, is checked again on
167
196
  // that newer record: a write that another writer made wrong is refused, not
168
197
  // forced through (release 0.2.3).
169
- async function patch(entity, verb, requestId, fields, { tags, title, digest, guard } = {}) {
198
+ // An item verb (LOG_VERBS) puts the diff of its write into its own receipt,
199
+ // in the same write; run() then copies it into a log entry. note is a
200
+ // reason the log keeps beside the diff (work item additions, slice 3).
201
+ async function patch(entity, verb, requestId, fields, { tags, title, digest, guard, note, log = LOG_VERBS.includes(verb) } = {}) {
170
202
  let current = entity;
203
+ const logged = log ? await activity.loggedIds(entity.id) : undefined;
171
204
  for (let attempt = 0; attempt < 2; attempt += 1) {
172
205
  if (attempt > 0 && guard) guard(current);
173
206
  const next = typeof fields === 'function' ? fields(current) : fields;
174
207
  // A fields function returns null when the newest record already holds
175
208
  // the change; then nothing is written.
176
209
  if (next === null) return current;
177
- const receipts = { ...(current.fields?.atlas_receipts ?? {}), [requestId]: { verb, at: now(), ...(digest ? { digest } : {}) } };
178
- const body = { version: current.version, fields: { ...(current.fields ?? {}), ...next, writer: label, request_id: requestId, atlas_receipts: receipts } };
179
210
  // tags as a function replaces the list with what it returns, computed
180
211
  // from the newest record; a list is added to the tags already there.
181
- if (typeof tags === 'function') body.tags = [...new Set(tags(current))];
182
- else if (tags) body.tags = [...new Set([...(current.tags ?? []), ...tags])];
212
+ let nextTags;
213
+ if (typeof tags === 'function') nextTags = [...new Set(tags(current))];
214
+ else if (tags) nextTags = [...new Set([...(current.tags ?? []), ...tags])];
215
+ const change = log ? diffOf(current, next, { title: title || undefined, tags: nextTags }) : null;
216
+ // A logged write that changes no field is not written: no receipt, no
217
+ // entry, no new version.
218
+ if (change && !Object.keys(change).length) return current;
219
+ const receipt = { verb, at: now(), ...(digest ? { digest } : {}), ...(change ? { change, ...(note ? { note: scrubSecrets(note).text } : {}) } : {}) };
220
+ const { atlas_receipts: _old, ...rest } = { ...(current.fields ?? {}), ...next, writer: label, request_id: requestId };
221
+ const receipts = trimReceipts(rest, { ...(current.fields?.atlas_receipts ?? {}), [requestId]: receipt }, logged, requestId);
222
+ const body = { version: current.version, fields: { ...rest, atlas_receipts: receipts } };
223
+ if (nextTags) body.tags = nextTags;
183
224
  if (title) body.title = title;
184
225
  try { await client.updateEntity(current.id, body); return client.getEntity(current.id); }
185
226
  catch (error) {
@@ -189,9 +230,9 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
189
230
  }
190
231
  }
191
232
  }
192
- async function create(type, requestId, verb, { title, body, fields, tags, digest }) {
193
- const receipt = await client.createEntity({ title, ...(body ? { body } : {}), type_id: type.id, fields: { ...fields, writer: label, request_id: requestId, atlas_receipts: { [requestId]: { verb, at: now(), ...(digest ? { digest } : {}) } } }, tags: [...new Set(['atlas-work', 'no-model', ...(tags ?? [])])] });
194
- return client.getEntity(receipt.id);
233
+ async function create(type, requestId, verb, { title, body, fields, tags, digest, change, readBack = true }) {
234
+ const receipt = await client.createEntity({ title, ...(body ? { body } : {}), type_id: type.id, fields: { ...fields, writer: label, request_id: requestId, atlas_receipts: { [requestId]: { verb, at: now(), ...(digest ? { digest } : {}), ...(change ? { change } : {}) } } }, tags: [...new Set(['atlas-work', 'no-model', ...(tags ?? [])])] });
235
+ return readBack ? client.getEntity(receipt.id) : receipt;
195
236
  }
196
237
  async function edge(src, dst, suffix, requestId, props = {}) {
197
238
  const intent = await edgeIntent(suffix);
@@ -395,7 +436,7 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
395
436
  const type = await typeNamed('container');
396
437
  const prior = await landed(type, requestId, meta);
397
438
  if (prior) return { verb: 'item_propose', item_id: prior.id, version: prior.version, lifecycle: prior.fields.lifecycle, stage: prior.fields.stage, product: prior.fields.product, repos: prior.fields.repos, replayed_from_graph: true };
398
- const entity = await create(type, requestId, 'item_propose', { digest: meta?.digest, title, body: args.body ? text(args.body, 'body') : undefined, fields, tags: [`product:${product.fields.registry_id}`, ...repos.map((id) => `repo:${id}`), `tier:${fields.tier}`, ...(args.tags ? stringList(args.tags, 'tags', { max: 16 }).filter((tag) => !isOwnedTag(tag)) : [])] });
439
+ const entity = await create(type, requestId, 'item_propose', { change: { created: { from: null, to: scrubSecrets(oneLine(title, 300)).text } }, digest: meta?.digest, title, body: args.body ? text(args.body, 'body') : undefined, fields, tags: [`product:${product.fields.registry_id}`, ...repos.map((id) => `repo:${id}`), `tier:${fields.tier}`, ...(args.tags ? stringList(args.tags, 'tags', { max: 16 }).filter((tag) => !isOwnedTag(tag)) : [])] });
399
440
  return { verb: 'item_propose', item_id: entity.id, version: entity.version, lifecycle: 'proposed', stage: 'capture', product: fields.product, repos };
400
441
  },
401
442
 
@@ -554,7 +595,6 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
554
595
  }
555
596
  if (args.next_step !== undefined) {
556
597
  change.next_step = text(args.next_step, 'next_step', { max: 1000 });
557
- previous.next_step = fields.next_step ?? '';
558
598
  }
559
599
  if (args.waits_on !== undefined) {
560
600
  const given = args.waits_on;
@@ -566,7 +606,6 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
566
606
  if (given.item !== undefined) refuse('waits_on.item is retired; link the items with item_block');
567
607
  if (wait.on !== 'nothing' && !wait.note) refuse(`waits_on.note is required for on: ${wait.on}; say what exactly it waits on`);
568
608
  change.waits_on = wait;
569
- previous.waits_on = fields.waits_on ?? null;
570
609
  }
571
610
  const tagsAdd = args.tags_add === undefined ? [] : tagList(args.tags_add, 'tags_add');
572
611
  const tagsRemove = args.tags_remove === undefined ? [] : tagList(args.tags_remove, 'tags_remove');
@@ -599,15 +638,14 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
599
638
  if (tagEdit && sameList(tagsOf(item), item.tags ?? [])) tagEdit = false;
600
639
  if (args.title === undefined && !Object.keys(change).length && !tagEdit && !planGiven) return { verb: 'item_edit', item_id: item.id, version: item.version, title: item.title, next_step: fields.next_step ?? '', waits_on: fields.waits_on ?? null, tags: item.tags ?? [], plan: fields.plan ?? null, changed: [], unchanged: true };
601
640
  const reason = text(args.reason, 'reason', { optional: true, max: 1000 });
602
- const at = now();
603
- // The history keeps the last MAX_EDITS entries. The first title the
604
- // owner gave is kept on its own, so a title never falls out of it. The
605
- // old tags and dates come from the record the write lands on.
606
- const entryOf = (current) => ({ at, by: label.agent, previous: { ...previous, ...(tagEdit ? { tags: current.tags ?? [] } : {}), ...(planGiven ? { plan: current.fields?.plan ?? null } : {}) }, ...(reason ? { reason } : {}) });
641
+ // Slice 3: the old values go into the diff in the receipt and then into
642
+ // a log entry, taken from the record the write lands on. edits stops
643
+ // growing and stays readable. The first title the owner gave is still
644
+ // kept on its own.
607
645
  const entity = await patch(item, 'item_edit', requestId, (current) => ({
608
- ...change, ...(planGiven ? { plan: planOf(current) } : {}), edits: [...(Array.isArray(current.fields?.edits) ? current.fields.edits : []), entryOf(current)].slice(-MAX_EDITS),
646
+ ...change, ...(planGiven ? { plan: planOf(current) } : {}),
609
647
  ...(previous.title !== undefined && current.fields?.first_title === undefined ? { first_title: previous.title } : {}),
610
- }), { digest: meta?.digest, guard: live, ...(title ? { title } : {}), ...(tagEdit ? { tags: tagsOf } : {}) });
648
+ }), { digest: meta?.digest, guard: live, note: reason, ...(title ? { title } : {}), ...(tagEdit ? { tags: tagsOf } : {}) });
611
649
  return { verb: 'item_edit', item_id: entity.id, version: entity.version, title: entity.title, next_step: entity.fields?.next_step ?? '', waits_on: entity.fields?.waits_on ?? null, tags: entity.tags ?? [], plan: entity.fields?.plan ?? null, changed: [...(args.title !== undefined ? ['title'] : []), ...Object.keys(change), ...(tagEdit ? ['tags'] : []), ...(planGiven ? ['plan'] : [])] };
612
650
  },
613
651
 
@@ -668,13 +706,11 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
668
706
  if (!listOf(current).some((entry) => entry.item === args.blocked_by)) refuse(`item ${current.id} is not blocked by ${args.blocked_by}`);
669
707
  };
670
708
  guard(item);
671
- const at = now();
672
- // The removed link goes into the edits, so the history keeps it. It is
673
- // taken from the record the write lands on.
709
+ // The diff keeps the removed link, taken from the record the write
710
+ // lands on, so the log shows it (slice 3).
674
711
  const entity = await patch(item, 'item_unblock', requestId, (current) => ({
675
712
  blocked_by: listOf(current).filter((row) => row.item !== args.blocked_by),
676
- edits: [...(Array.isArray(current.fields?.edits) ? current.fields.edits : []), { at, by: label.agent, previous: { blocked_by: listOf(current).find((row) => row.item === args.blocked_by) }, ...(reason ? { reason } : {}) }].slice(-MAX_EDITS),
677
- }), { digest: meta?.digest, guard });
713
+ }), { digest: meta?.digest, guard, note: reason });
678
714
  return { verb: 'item_unblock', item_id: entity.id, version: entity.version, blocked_by: listOf(entity).map((row) => row.item), removed: args.blocked_by };
679
715
  },
680
716
 
@@ -715,9 +751,11 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
715
751
  Object.assign(verbs, createDeliveryVerbs({ ...core, ownerPresent, readGitHub, readLive }));
716
752
  Object.assign(verbs, createCapabilityVerbs({ ...core, readGitHub }));
717
753
  Object.assign(verbs, createSweepVerbs({ ...core, requireRepos, inFlight: (args) => verbs.in_flight(args), stages: STAGES, tiers: TIERS, drivers: DRIVERS }));
754
+ const activity = createActivityVerbs(core);
755
+ Object.assign(verbs, activity.verbs);
718
756
 
719
- const WRITE_VERBS = Object.freeze(['setup', 'product_add', 'product_edit', 'repo_add', 'repo_edit', 'item_propose', 'item_lock', 'item_split', 'item_park', 'item_resume', 'item_drop', 'item_edit', 'item_block', 'item_unblock', 'item_stage', 'item_link', ...QUESTION_WRITE_VERBS, ...DELIVERY_WRITE_VERBS, ...SWEEP_WRITE_VERBS]);
720
- const READ_VERBS = Object.freeze(['registry_list', ...QUESTION_READ_VERBS, ...DELIVERY_READ_VERBS, ...CAPABILITY_READ_VERBS, ...SWEEP_READ_VERBS]);
757
+ const WRITE_VERBS = Object.freeze(['setup', 'product_add', 'product_edit', 'repo_add', 'repo_edit', 'item_propose', 'item_lock', 'item_split', 'item_park', 'item_resume', 'item_drop', 'item_edit', 'item_block', 'item_unblock', 'item_stage', 'item_link', ...QUESTION_WRITE_VERBS, ...DELIVERY_WRITE_VERBS, ...SWEEP_WRITE_VERBS, ...ACTIVITY_WRITE_VERBS]);
758
+ const READ_VERBS = Object.freeze(['registry_list', ...QUESTION_READ_VERBS, ...DELIVERY_READ_VERBS, ...CAPABILITY_READ_VERBS, ...SWEEP_READ_VERBS, ...ACTIVITY_READ_VERBS]);
721
759
 
722
760
  async function run(verb, args = {}) {
723
761
  if (!verbs[verb]) refuse(`unknown verb: ${verb}`);
@@ -744,6 +782,12 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
744
782
  await store.put(requestId, { ...record, phase: 'interrupted', error: error.message });
745
783
  throw error;
746
784
  }
785
+ // The item write landed. Its diff goes into a log entry now, best
786
+ // effort: a failure is a warning on the result (slice 3, step 6).
787
+ if (ENTRY_VERBS.includes(verb) && typeof result?.item_id === 'string') {
788
+ const warning = await activity.logEntries(result.item_id, requestId);
789
+ if (warning) result = { ...result, warning };
790
+ }
747
791
  await store.put(requestId, { ...record, phase: 'complete', result, completed_at: now() });
748
792
  return { ...copy(result), request_id: requestId, writer: label };
749
793
  });