@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.
- package/.claude-plugin/plugin.json +1 -1
- package/door/kit-releases.json +6 -1
- package/door/lib/proof.mjs +25 -1
- package/package.json +10 -2
- package/skills/sdlc-task/SKILL.md +1 -0
- package/tests/contract.mjs +48 -9
- package/tests/drivers/browser.mjs +364 -0
- package/tests/drivers/index.mjs +11 -4
- package/tests/evidence.mjs +6 -2
- package/tests/index.mjs +1 -1
- package/tests/link-check.mjs +1 -1
- package/tests/scenario.mjs +19 -0
- package/work/lib/activity.mjs +246 -0
- package/work/lib/questions.mjs +4 -1
- package/work/lib/verbs.mjs +75 -31
- package/work/{manifest-0.6.0.json → manifest-0.7.0.json} +74 -2
- package/work/mcp.mjs +5 -3
package/tests/evidence.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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';
|
package/tests/link-check.mjs
CHANGED
|
@@ -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
|
}
|
package/tests/scenario.mjs
CHANGED
|
@@ -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
|
+
}
|
package/work/lib/questions.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
package/work/lib/verbs.mjs
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
182
|
-
|
|
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
|
-
|
|
603
|
-
//
|
|
604
|
-
//
|
|
605
|
-
//
|
|
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) } : {}),
|
|
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
|
-
|
|
672
|
-
//
|
|
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
|
-
|
|
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
|
});
|