@arjunkhera/atlas 0.0.0 → 0.2.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.
- package/.claude-plugin/plugin.json +18 -0
- package/.mcp.json +18 -0
- package/README.md +32 -1
- package/agents/artifact-format/example.html +71 -0
- package/agents/artifact-format/system.css +273 -0
- package/agents/artifact-renderer.md +132 -0
- package/agents/capability-reader.md +100 -0
- package/agents/code-explorer.md +108 -0
- package/agents/reviewer-architect.md +78 -0
- package/agents/reviewer-pm.md +81 -0
- package/agents/reviewer-security.md +82 -0
- package/bin/atlas +5 -0
- package/door/cli.mjs +280 -0
- package/door/lib/helpers.mjs +139 -0
- package/door/lib/install.mjs +260 -0
- package/door/lib/kind-drift.mjs +76 -0
- package/door/lib/onboarding.mjs +130 -0
- package/door/lib/pull-request.mjs +119 -0
- package/door/lib/releases.mjs +42 -0
- package/door/lib/status.mjs +47 -0
- package/door/lib/tooling.mjs +119 -0
- package/package.json +24 -3
- package/shape/check.mjs +1797 -0
- package/shape/releases.json +15 -0
- package/skills/sdlc-task/SKILL.md +174 -0
- package/skills/sdlc-task/lifecycle.yaml +27 -0
- package/skills/sdlc-task/templates/design-doc.md +118 -0
- package/skills/sdlc-task/templates/resume-anchor.md +25 -0
- package/work/code-read.mjs +65 -0
- package/work/lib/action-store.mjs +84 -0
- package/work/lib/capability.mjs +487 -0
- package/work/lib/child-env.mjs +11 -0
- package/work/lib/client.mjs +214 -0
- package/work/lib/code-read.mjs +238 -0
- package/work/lib/delivery.mjs +624 -0
- package/work/lib/mcp-stdio.mjs +68 -0
- package/work/lib/questions.mjs +278 -0
- package/work/lib/sweep.mjs +189 -0
- package/work/lib/verb-fields.mjs +53 -0
- package/work/lib/verbs.mjs +511 -0
- package/work/manifest-0.5.0.json +609 -0
- package/work/mcp.mjs +216 -0
- package/work/protected-paths.json +38 -0
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
// Atlas M1 slice L1: the registry and item verbs behind the local MCP door.
|
|
2
|
+
// Slice L2 adds the question, decision and receipt verbs and the "Needs me"
|
|
3
|
+
// view from questions.mjs, through the same door and the same journal.
|
|
4
|
+
// Slice L3 adds the done and reopen verbs and the "In flight" view from
|
|
5
|
+
// delivery.mjs, through the same door again. Slice L4 adds the code-read
|
|
6
|
+
// brief, the six-part answer and the "Built, pending, possible" view from
|
|
7
|
+
// capability.mjs: three read verbs that write nothing. Slice L5 adds the
|
|
8
|
+
// sweep from sweep.mjs: item_adopt, which gives an existing item a product,
|
|
9
|
+
// and sweep_check, which reads the sweep record against the "In flight" view.
|
|
10
|
+
// Design: the Atlas M1 study, pages D1, D2, D3 and D6,
|
|
11
|
+
// lock block L1, L2 and L3. Every write carries request_id and writer. The
|
|
12
|
+
// server, not the caller, supplies the session. Unknown ids are refused.
|
|
13
|
+
// M2 slice L1 adds the repo kind in 0.5.0, which carries 0.4.0 whole.
|
|
14
|
+
import manifestCurrent from '../manifest-0.5.0.json' with { type: 'json' };
|
|
15
|
+
import { EngramHttpError } from './client.mjs';
|
|
16
|
+
import { VerbError, refuse, canonicalJson, sha256, text, oneOf, origin, url, stringList } from './verb-fields.mjs';
|
|
17
|
+
import { createQuestionVerbs, COSTS, QUESTION_WRITE_VERBS, QUESTION_READ_VERBS } from './questions.mjs';
|
|
18
|
+
import { createDeliveryVerbs, DELIVERY_WRITE_VERBS, DELIVERY_READ_VERBS } from './delivery.mjs';
|
|
19
|
+
import { createCapabilityVerbs, CAPABILITY_READ_VERBS } from './capability.mjs';
|
|
20
|
+
import { createSweepVerbs, SWEEP_WRITE_VERBS, SWEEP_READ_VERBS } from './sweep.mjs';
|
|
21
|
+
|
|
22
|
+
export { VerbError, canonicalJson, sha256, COSTS };
|
|
23
|
+
export const MANIFEST_VERSION = '0.5.0';
|
|
24
|
+
export const PRODUCT_ID = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/;
|
|
25
|
+
export const REPO_ID = /^[A-Za-z0-9](?:[A-Za-z0-9_.-]{0,99})\/[A-Za-z0-9_.-]{1,100}$/;
|
|
26
|
+
export const STAGES = Object.freeze(['capture', 'spec', 'build', 'verify', 'learn']);
|
|
27
|
+
export const TIERS = Object.freeze(['hotfix', 'standard', 'initiative']);
|
|
28
|
+
export const DRIVERS = Object.freeze(['owner-led', 'agent']);
|
|
29
|
+
// The repo kind of M2 amendment A1.5. The repo-shape check reads it to decide
|
|
30
|
+
// which of the seven files a repo needs.
|
|
31
|
+
export const REPO_KINDS = Object.freeze(['service', 'library', 'schema']);
|
|
32
|
+
export const LINK_KINDS = Object.freeze(['pr', 'issue', 'artifact', 'design-doc', 'conversation', 'research', 'slack', 'drive', 'other']);
|
|
33
|
+
export const AUTHORITY_FIELDS = Object.freeze(['writer', 'session', 'actor', 'owner_present', 'agent', 'token_id']);
|
|
34
|
+
const MAX_CHILDREN = 32;
|
|
35
|
+
const copy = (value) => structuredClone(value);
|
|
36
|
+
// The paths of a product the functional check of item_done may read. Absolute
|
|
37
|
+
// paths only, so a caller cannot smuggle a host or a query in.
|
|
38
|
+
const absolutePaths = (value) => stringList(value, 'check_paths', { max: 16 }).map((path) => {
|
|
39
|
+
text(path, 'check_paths', { max: 500 });
|
|
40
|
+
if (!path.startsWith('/') || /[?#]/.test(path) || path.includes('..')) refuse('each check_paths entry must be an absolute path with no query and no "..", for example /v1/housekeeping');
|
|
41
|
+
return path;
|
|
42
|
+
});
|
|
43
|
+
const renderHandle = (value, handle) => JSON.parse(JSON.stringify(value).replaceAll('{{handle}}', handle));
|
|
44
|
+
// Sortable form of a three-part version, for "is the tenant ahead of us?".
|
|
45
|
+
const semver = (value) => String(value).split('.').map((part) => String(Number(part) || 0).padStart(6, '0')).join('.');
|
|
46
|
+
|
|
47
|
+
export function createVerbs({ client, store, writer, session, agent = 'lead', boundClient = null, ownerPresent = false, readGitHub, readLive, now = () => new Date().toISOString() } = {}) {
|
|
48
|
+
if (!client) throw new TypeError('client is required');
|
|
49
|
+
if (!store) throw new TypeError('store (the request journal) is required');
|
|
50
|
+
if (typeof writer?.token_id !== 'string' || !writer.token_id) throw new TypeError('writer.token_id is required');
|
|
51
|
+
if (typeof session !== 'string' || !session.trim()) throw new TypeError('session is required; the server supplies it');
|
|
52
|
+
const label = Object.freeze({ token_id: writer.token_id, session, agent, ...(boundClient ? { client: String(boundClient) } : {}) });
|
|
53
|
+
let handleCache = null, typesCache = null;
|
|
54
|
+
|
|
55
|
+
async function handle() {
|
|
56
|
+
if (!handleCache) {
|
|
57
|
+
const publisher = await client.getPublisher();
|
|
58
|
+
handleCache = publisher?.publisher?.handle;
|
|
59
|
+
if (!/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(handleCache ?? '')) throw new Error('The tenant has no valid publisher handle.');
|
|
60
|
+
}
|
|
61
|
+
return handleCache;
|
|
62
|
+
}
|
|
63
|
+
async function types() {
|
|
64
|
+
if (!typesCache) {
|
|
65
|
+
const response = await client.listTypes();
|
|
66
|
+
const rows = Array.isArray(response) ? response : response?.types;
|
|
67
|
+
typesCache = Array.isArray(rows) ? rows : [];
|
|
68
|
+
}
|
|
69
|
+
const byName = new Map(typesCache.map((type) => [type.name, type]));
|
|
70
|
+
const names = new Map(typesCache.map((type) => [type.id, type.name]));
|
|
71
|
+
return { byName, names };
|
|
72
|
+
}
|
|
73
|
+
async function typeNamed(suffix) {
|
|
74
|
+
const name = `@${await handle()}/${suffix}`;
|
|
75
|
+
const { byName } = await types();
|
|
76
|
+
const type = byName.get(name);
|
|
77
|
+
if (!type) refuse(`type ${name} is not registered; run setup first`);
|
|
78
|
+
return type;
|
|
79
|
+
}
|
|
80
|
+
async function edgeIntent(suffix) {
|
|
81
|
+
const { byName } = await types();
|
|
82
|
+
const scoped = `@${await handle()}/${suffix}`;
|
|
83
|
+
if (byName.get(scoped)?.kind === 'edge') return scoped;
|
|
84
|
+
if (byName.get(suffix)?.kind === 'edge') return suffix;
|
|
85
|
+
refuse(`edge type ${suffix} is not registered; run setup first`);
|
|
86
|
+
}
|
|
87
|
+
const kindOf = async (entity) => (await types()).names.get(entity?.type_id)?.split('/').at(-1) ?? null;
|
|
88
|
+
|
|
89
|
+
async function registry() {
|
|
90
|
+
const [product, repo] = await Promise.all([typeNamed('product'), typeNamed('repo')]);
|
|
91
|
+
const [products, repos] = await Promise.all([
|
|
92
|
+
client.listAllEntities({ type: product.name, state: 'active' }),
|
|
93
|
+
client.listAllEntities({ type: repo.name, state: 'active' }),
|
|
94
|
+
]);
|
|
95
|
+
const byId = (rows) => {
|
|
96
|
+
const map = new Map();
|
|
97
|
+
for (const row of rows) {
|
|
98
|
+
const id = row.fields?.registry_id;
|
|
99
|
+
if (typeof id !== 'string') continue;
|
|
100
|
+
if (map.has(id)) refuse(`registry id ${id} is ambiguous: two records carry it; reconcile before writing`);
|
|
101
|
+
map.set(id, row);
|
|
102
|
+
}
|
|
103
|
+
return map;
|
|
104
|
+
};
|
|
105
|
+
return { products: byId(products), repos: byId(repos) };
|
|
106
|
+
}
|
|
107
|
+
async function requireProduct(id, reg) {
|
|
108
|
+
text(id, 'product', { max: 64 });
|
|
109
|
+
const record = reg.products.get(id);
|
|
110
|
+
if (!record) refuse(`unknown product id: ${id}. Known: ${[...reg.products.keys()].sort().join(', ') || 'none'}`);
|
|
111
|
+
return record;
|
|
112
|
+
}
|
|
113
|
+
async function requireRepos(ids, reg, productId) {
|
|
114
|
+
const list = stringList(ids, 'repos', { max: 16 });
|
|
115
|
+
if (!list.length) refuse('repos must name at least one registered repo id');
|
|
116
|
+
for (const id of list) {
|
|
117
|
+
const record = reg.repos.get(id);
|
|
118
|
+
if (!record) refuse(`unknown repo id: ${id}. Known: ${[...reg.repos.keys()].sort().join(', ') || 'none'}`);
|
|
119
|
+
if (productId && record.fields?.product !== productId) refuse(`repo ${id} belongs to product ${record.fields?.product}, not ${productId}`);
|
|
120
|
+
}
|
|
121
|
+
return list;
|
|
122
|
+
}
|
|
123
|
+
async function requireItem(id) {
|
|
124
|
+
text(id, 'item_id', { max: 200 });
|
|
125
|
+
let entity;
|
|
126
|
+
try { entity = await client.getEntity(id); }
|
|
127
|
+
catch (error) { if (error instanceof EngramHttpError && error.status === 404) refuse(`unknown item id: ${id}`); throw error; }
|
|
128
|
+
if ((await kindOf(entity)) !== 'container') refuse(`entity ${id} is not a work item (container)`);
|
|
129
|
+
if (entity.state && entity.state !== 'active') refuse(`item ${id} is ${entity.state}, not active in the graph`);
|
|
130
|
+
return entity;
|
|
131
|
+
}
|
|
132
|
+
const requireOwner = (verb) => { if (!ownerPresent) refuse(`${verb} needs the owner-present flag on the server host (ATLAS_WORK_OWNER_PRESENT=1)`); };
|
|
133
|
+
|
|
134
|
+
async function patch(entity, verb, requestId, fields, { tags, title, digest } = {}) {
|
|
135
|
+
let current = entity;
|
|
136
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
137
|
+
const receipts = { ...(current.fields?.atlas_receipts ?? {}), [requestId]: { verb, at: now(), ...(digest ? { digest } : {}) } };
|
|
138
|
+
const body = { version: current.version, fields: { ...(current.fields ?? {}), ...fields, writer: label, request_id: requestId, atlas_receipts: receipts } };
|
|
139
|
+
if (tags) body.tags = [...new Set([...(current.tags ?? []), ...tags])];
|
|
140
|
+
if (title) body.title = title;
|
|
141
|
+
try { await client.updateEntity(current.id, body); return client.getEntity(current.id); }
|
|
142
|
+
catch (error) {
|
|
143
|
+
if (!(error instanceof EngramHttpError) || error.status !== 409 || attempt === 1) throw error;
|
|
144
|
+
current = await client.getEntity(current.id);
|
|
145
|
+
if (current.fields?.atlas_receipts?.[requestId]) return current;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
async function create(type, requestId, verb, { title, body, fields, tags, digest }) {
|
|
150
|
+
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 ?? [])])] });
|
|
151
|
+
return client.getEntity(receipt.id);
|
|
152
|
+
}
|
|
153
|
+
async function edge(src, dst, suffix, requestId, props = {}) {
|
|
154
|
+
const intent = await edgeIntent(suffix);
|
|
155
|
+
const existing = await client.listAllEdges(src.id, { direction: 'out', intent });
|
|
156
|
+
const match = existing.find((row) => row.dst === dst.id);
|
|
157
|
+
if (match) return match;
|
|
158
|
+
return client.createEdge({ src: src.id, dst: dst.id, intent, props: { ...props, writer: label, request_id: requestId } });
|
|
159
|
+
}
|
|
160
|
+
async function findCreated(type, requestId) {
|
|
161
|
+
const rows = await client.listAllEntities({ type: type.name, state: 'any' });
|
|
162
|
+
return rows.filter((row) => row.fields?.request_id === requestId || row.fields?.atlas_receipts?.[requestId]);
|
|
163
|
+
}
|
|
164
|
+
// Idempotency from the graph, not only the journal: a record stamped with this request id already landed.
|
|
165
|
+
async function landed(type, requestId, meta) {
|
|
166
|
+
const rows = await findCreated(type, requestId);
|
|
167
|
+
if (rows.length > 1) refuse(`request_id ${requestId} is stamped on ${rows.length} records; reconcile before retrying`);
|
|
168
|
+
if (rows[0]) sameRequest(rows[0].fields?.atlas_receipts?.[requestId], requestId, meta);
|
|
169
|
+
return rows[0] ?? null;
|
|
170
|
+
}
|
|
171
|
+
// A receipt names the verb and the argument digest. The same id for another request is refused, journal or not (review F11).
|
|
172
|
+
const sameRequest = (receipt, requestId, meta) => {
|
|
173
|
+
if (!receipt) return false;
|
|
174
|
+
if (meta && (receipt.verb !== meta.verb || (receipt.digest && receipt.digest !== meta.digest))) refuse(`request_id ${requestId} was already used on this record for a different request (${receipt.verb}); choose a new id`);
|
|
175
|
+
return true;
|
|
176
|
+
};
|
|
177
|
+
const alreadyPatched = (entity, requestId, meta) => sameRequest(entity.fields?.atlas_receipts?.[requestId], requestId, meta);
|
|
178
|
+
|
|
179
|
+
const verbs = {
|
|
180
|
+
async setup(args) {
|
|
181
|
+
requireOwner('setup');
|
|
182
|
+
const publisherHandle = await handle();
|
|
183
|
+
const rendered = renderHandle(manifestCurrent, publisherHandle);
|
|
184
|
+
if (rendered.name !== 'atlas-work' || rendered.version !== MANIFEST_VERSION) throw new Error(`local manifest must be atlas-work ${MANIFEST_VERSION}`);
|
|
185
|
+
let published = false;
|
|
186
|
+
// Registering is the step that can take a field back, and it needs an
|
|
187
|
+
// enumerable version list to be safe. Without one, setup may still
|
|
188
|
+
// publish, but it must not register (review H22).
|
|
189
|
+
let mayRegister = true;
|
|
190
|
+
try {
|
|
191
|
+
const artifact = await client.getForgeArtifact(publisherHandle, 'atlas-work');
|
|
192
|
+
const listed = artifact?.versions ?? artifact?.artifact?.versions;
|
|
193
|
+
// A guard that cannot read the answer fails closed. If the Forge stops
|
|
194
|
+
// naming its versions, the verb stops rather than register blind and
|
|
195
|
+
// take back what a later version added (review H18). The one exception
|
|
196
|
+
// is an artifact with nothing of ours published yet: there is then no
|
|
197
|
+
// later version to take back, so the first publish goes through
|
|
198
|
+
// (review H18's residual).
|
|
199
|
+
// A 404 on our own version does not prove nothing later exists, so the
|
|
200
|
+
// fallback never registers. It only lets a first publish through.
|
|
201
|
+
if (!Array.isArray(listed)) mayRegister = false;
|
|
202
|
+
const newer = (Array.isArray(listed) ? listed : []).map((row) => String(row?.version ?? '')).filter((version) => semver(version) > semver(MANIFEST_VERSION));
|
|
203
|
+
// Another lane published a later manifest. Registering this one would
|
|
204
|
+
// take back the fields that lane added, so the verb stops instead.
|
|
205
|
+
if (newer.length) refuse(`the tenant already has atlas-work ${newer.sort((a, b) => semver(a).localeCompare(semver(b))).at(-1)}, later than this checkout's ${MANIFEST_VERSION}; registering ${MANIFEST_VERSION} would take back the types that version added. Rebase on the branch that published it.`);
|
|
206
|
+
}
|
|
207
|
+
catch (error) {
|
|
208
|
+
if (error instanceof VerbError) throw error;
|
|
209
|
+
if (!(error instanceof EngramHttpError) || error.status !== 404) throw error;
|
|
210
|
+
try { await client.createForgeArtifact({ name: 'atlas-work', kind: rendered.kind, description: rendered.description, categories: ['work'], tags: ['atlas'] }); }
|
|
211
|
+
catch (createError) { if (!(createError instanceof EngramHttpError) || createError.status !== 409) throw createError; }
|
|
212
|
+
}
|
|
213
|
+
try {
|
|
214
|
+
const stored = await client.getForgeVersion(publisherHandle, 'atlas-work', MANIFEST_VERSION);
|
|
215
|
+
if (canonicalJson(stored?.manifest) !== canonicalJson(rendered)) refuse(`published atlas-work ${MANIFEST_VERSION} differs from the local manifest; a changed manifest needs a new version`);
|
|
216
|
+
} catch (error) {
|
|
217
|
+
if (!(error instanceof EngramHttpError) || error.status !== 404) throw error;
|
|
218
|
+
await client.publishForgeVersion(publisherHandle, 'atlas-work', { version: MANIFEST_VERSION, manifest: rendered });
|
|
219
|
+
published = true;
|
|
220
|
+
}
|
|
221
|
+
if (!mayRegister) {
|
|
222
|
+
return { verb: 'setup', manifest: `atlas-work ${MANIFEST_VERSION}`, published, registered: null, types: rendered.contributes.types.map((type) => type.name),
|
|
223
|
+
note: `the Forge did not list the published versions of atlas-work, so this verb cannot tell whether the tenant is ahead of ${MANIFEST_VERSION}. It published nothing it had to take back, and it registered no type: registering ${MANIFEST_VERSION} could take back what a later version added. Register by hand once the version list reads, or fix the reader.` };
|
|
224
|
+
}
|
|
225
|
+
const registered = await client.registerTypes(publisherHandle, 'atlas-work', MANIFEST_VERSION);
|
|
226
|
+
typesCache = null;
|
|
227
|
+
return { verb: 'setup', manifest: `atlas-work ${MANIFEST_VERSION}`, published, registered, types: rendered.contributes.types.map((type) => type.name) };
|
|
228
|
+
},
|
|
229
|
+
|
|
230
|
+
async registry_list() {
|
|
231
|
+
const reg = await registry();
|
|
232
|
+
const products = [...reg.products.values()].map((row) => ({ registry_id: row.fields.registry_id, name: row.fields.name, entity_id: row.id, project_id: row.fields.project_id ?? null, notes: row.fields.notes ?? '',
|
|
233
|
+
repos: [...reg.repos.values()].filter((repo) => repo.fields.product === row.fields.registry_id).map((repo) => ({ registry_id: repo.fields.registry_id, entity_id: repo.id, kind: repo.fields.kind ?? null, default_branch: repo.fields.default_branch, notes: repo.fields.notes ?? '' })) }));
|
|
234
|
+
return { verb: 'registry_list', products, read_at: now() };
|
|
235
|
+
},
|
|
236
|
+
|
|
237
|
+
async product_add(args, requestId, meta) {
|
|
238
|
+
requireOwner('product add');
|
|
239
|
+
const registryId = text(args.registry_id, 'registry_id', { max: 64 });
|
|
240
|
+
if (!PRODUCT_ID.test(registryId)) refuse('registry_id must be a lowercase slug: letters, digits and hyphens, for example "engram"');
|
|
241
|
+
const name = text(args.name, 'name', { max: 200 });
|
|
242
|
+
const notes = text(args.notes, 'notes', { optional: true, max: 4000 }) ?? '';
|
|
243
|
+
// Where the product's deployed app answers. The done verb reads the
|
|
244
|
+
// health endpoint from this record, never from the caller (D6 R10).
|
|
245
|
+
const healthUrl = args.health_url === undefined ? '' : url(args.health_url, 'health_url');
|
|
246
|
+
const checkPaths = args.check_paths === undefined ? [] : absolutePaths(args.check_paths);
|
|
247
|
+
const reg = await registry();
|
|
248
|
+
if (reg.products.has(registryId) && !alreadyPatched(reg.products.get(registryId), requestId)) refuse(`product id ${registryId} already exists (entity ${reg.products.get(registryId).id}); use product edit`);
|
|
249
|
+
let project = null;
|
|
250
|
+
if (args.project_id !== undefined) {
|
|
251
|
+
text(args.project_id, 'project_id', { max: 200 });
|
|
252
|
+
try { project = await client.getEntity(args.project_id); } catch (error) { if (error instanceof EngramHttpError && error.status === 404) refuse(`unknown project entity: ${args.project_id}`); throw error; }
|
|
253
|
+
if ((await kindOf(project)) !== 'project') refuse(`entity ${args.project_id} is not a project`);
|
|
254
|
+
}
|
|
255
|
+
const type = await typeNamed('product');
|
|
256
|
+
const prior = await landed(type, requestId, meta);
|
|
257
|
+
if (prior) { if (project) await edge(prior, project, 'references', requestId); return { verb: 'product_add', registry_id: prior.fields.registry_id, entity_id: prior.id, version: prior.version, project_id: prior.fields.project_id ?? null, replayed_from_graph: true }; }
|
|
258
|
+
const entity = await create(type, requestId, 'product_add', { digest: meta?.digest, title: name, body: `Atlas product record for ${name}. Registry id: ${registryId}.`, fields: { registry_id: registryId, name, notes, ...(healthUrl ? { health_url: healthUrl } : {}), ...(checkPaths.length ? { check_paths: checkPaths } : {}), ...(project ? { project_id: project.id } : {}) }, tags: ['atlas-registry', `product:${registryId}`] });
|
|
259
|
+
if (project) await edge(entity, project, 'references', requestId);
|
|
260
|
+
return { verb: 'product_add', registry_id: registryId, entity_id: entity.id, version: entity.version, project_id: project?.id ?? null, health_url: healthUrl || null };
|
|
261
|
+
},
|
|
262
|
+
|
|
263
|
+
async product_edit(args, requestId, meta) {
|
|
264
|
+
requireOwner('product edit');
|
|
265
|
+
const registryId = text(args.registry_id, 'registry_id', { max: 64 });
|
|
266
|
+
const reg = await registry();
|
|
267
|
+
const record = await requireProduct(registryId, reg);
|
|
268
|
+
const fields = {};
|
|
269
|
+
if (args.name !== undefined) fields.name = text(args.name, 'name', { max: 200 });
|
|
270
|
+
if (args.notes !== undefined) fields.notes = text(args.notes, 'notes', { max: 4000 });
|
|
271
|
+
if (args.health_url !== undefined) fields.health_url = url(args.health_url, 'health_url');
|
|
272
|
+
if (args.check_paths !== undefined) fields.check_paths = absolutePaths(args.check_paths);
|
|
273
|
+
if (args.project_id !== undefined) {
|
|
274
|
+
let project;
|
|
275
|
+
try { project = await client.getEntity(text(args.project_id, 'project_id', { max: 200 })); } catch (error) { if (error instanceof EngramHttpError && error.status === 404) refuse(`unknown project entity: ${args.project_id}`); throw error; }
|
|
276
|
+
if ((await kindOf(project)) !== 'project') refuse(`entity ${args.project_id} is not a project`);
|
|
277
|
+
fields.project_id = project.id;
|
|
278
|
+
await edge(record, project, 'references', requestId);
|
|
279
|
+
}
|
|
280
|
+
if (alreadyPatched(record, requestId, meta)) return { verb: 'product_edit', registry_id: registryId, entity_id: record.id, version: record.version, changed: [], replayed_from_graph: true };
|
|
281
|
+
if (!Object.keys(fields).length) refuse('product edit needs at least one of name, notes, health_url, check_paths, project_id; registry_id is immutable');
|
|
282
|
+
const entity = await patch(record, 'product_edit', requestId, fields, { title: fields.name, digest: meta?.digest });
|
|
283
|
+
return { verb: 'product_edit', registry_id: registryId, entity_id: entity.id, version: entity.version, changed: Object.keys(fields) };
|
|
284
|
+
},
|
|
285
|
+
|
|
286
|
+
async repo_add(args, requestId, meta) {
|
|
287
|
+
requireOwner('repo add');
|
|
288
|
+
if (typeof readGitHub !== 'function') refuse('repo add needs a GitHub reader for the push-rights check');
|
|
289
|
+
const registryId = text(args.registry_id, 'registry_id', { max: 200 });
|
|
290
|
+
if (!REPO_ID.test(registryId)) refuse('registry_id must be a GitHub "owner/repo" name');
|
|
291
|
+
const kind = oneOf(args.kind, 'kind', REPO_KINDS);
|
|
292
|
+
const notes = text(args.notes, 'notes', { optional: true, max: 4000 }) ?? '';
|
|
293
|
+
const reg = await registry();
|
|
294
|
+
const product = await requireProduct(args.product, reg);
|
|
295
|
+
if (reg.repos.has(registryId) && !alreadyPatched(reg.repos.get(registryId), requestId)) refuse(`repo id ${registryId} already exists (entity ${reg.repos.get(registryId).id}); repo ids are immutable`);
|
|
296
|
+
let github;
|
|
297
|
+
try { github = await readGitHub(`repos/${registryId}`); } catch (error) { refuse(`GitHub could not read ${registryId}: ${error?.message ?? 'unknown error'}`); }
|
|
298
|
+
if (github?.full_name !== registryId) refuse(`GitHub names this repo ${github?.full_name ?? 'nothing'}; use that exact spelling as the registry id`);
|
|
299
|
+
if (github?.permissions?.push !== true) refuse(`the owner has no push rights on ${registryId}; only repos the owner can push to are registered`);
|
|
300
|
+
const defaultBranch = text(github.default_branch, 'GitHub default_branch', { max: 200 });
|
|
301
|
+
const type = await typeNamed('repo');
|
|
302
|
+
const prior = await landed(type, requestId, meta);
|
|
303
|
+
if (prior) { await edge(prior, product, 'part_of', requestId); return { verb: 'repo_add', registry_id: prior.fields.registry_id, product: prior.fields.product, kind: prior.fields.kind ?? null, default_branch: prior.fields.default_branch, entity_id: prior.id, version: prior.version, push_rights: true, replayed_from_graph: true }; }
|
|
304
|
+
const entity = await create(type, requestId, 'repo_add', { digest: meta?.digest, title: registryId, body: `Atlas repo record for ${registryId}, product ${product.fields.registry_id}. Kind: ${kind}. Default branch: ${defaultBranch}.`, fields: { registry_id: registryId, product: product.fields.registry_id, kind, default_branch: defaultBranch, notes, github_url: github.html_url ?? `https://github.com/${registryId}` }, tags: ['atlas-registry', `product:${product.fields.registry_id}`, `repo:${registryId}`] });
|
|
305
|
+
await edge(entity, product, 'part_of', requestId);
|
|
306
|
+
return { verb: 'repo_add', registry_id: registryId, product: product.fields.registry_id, kind, default_branch: defaultBranch, entity_id: entity.id, version: entity.version, push_rights: true };
|
|
307
|
+
},
|
|
308
|
+
|
|
309
|
+
async repo_edit(args, requestId, meta) {
|
|
310
|
+
requireOwner('repo edit');
|
|
311
|
+
const registryId = text(args.registry_id, 'registry_id', { max: 200 });
|
|
312
|
+
const reg = await registry();
|
|
313
|
+
const record = reg.repos.get(registryId);
|
|
314
|
+
if (!record) refuse(`unknown repo id: ${registryId}`);
|
|
315
|
+
await requireProduct(record.fields.product, reg);
|
|
316
|
+
const fields = {};
|
|
317
|
+
if (args.notes !== undefined) fields.notes = text(args.notes, 'notes', { max: 4000 });
|
|
318
|
+
if (args.kind !== undefined) fields.kind = oneOf(args.kind, 'kind', REPO_KINDS);
|
|
319
|
+
if (args.refresh_default_branch === true) {
|
|
320
|
+
if (typeof readGitHub !== 'function') refuse('refreshing the default branch needs a GitHub reader');
|
|
321
|
+
const github = await readGitHub(`repos/${registryId}`);
|
|
322
|
+
if (github?.full_name !== registryId || github?.permissions?.push !== true) refuse(`GitHub no longer reports ${registryId} with push rights`);
|
|
323
|
+
fields.default_branch = text(github.default_branch, 'GitHub default_branch', { max: 200 });
|
|
324
|
+
}
|
|
325
|
+
if (alreadyPatched(record, requestId, meta)) return { verb: 'repo_edit', registry_id: registryId, entity_id: record.id, version: record.version, changed: [], replayed_from_graph: true };
|
|
326
|
+
if (!Object.keys(fields).length) refuse('repo edit needs kind, notes or refresh_default_branch; registry_id and product are immutable');
|
|
327
|
+
// The kind is a field, not a tag: repo edit may change it, and a tag cannot be dropped.
|
|
328
|
+
const entity = await patch(record, 'repo_edit', requestId, fields, { digest: meta?.digest });
|
|
329
|
+
return { verb: 'repo_edit', registry_id: registryId, entity_id: entity.id, version: entity.version, kind: entity.fields.kind ?? null, changed: Object.keys(fields) };
|
|
330
|
+
},
|
|
331
|
+
|
|
332
|
+
async item_propose(args, requestId, meta) {
|
|
333
|
+
requireOwner('item propose');
|
|
334
|
+
const title = text(args.title, 'title', { max: 300 });
|
|
335
|
+
const reg = await registry();
|
|
336
|
+
const product = await requireProduct(args.product, reg);
|
|
337
|
+
const repos = await requireRepos(args.repos, reg, product.fields.registry_id);
|
|
338
|
+
const fields = {
|
|
339
|
+
lifecycle: 'proposed', stage: 'capture', product: product.fields.registry_id, repos, tier: oneOf(args.tier, 'tier', TIERS, 'standard'),
|
|
340
|
+
origin: origin(args.origin), driver: oneOf(args.driver, 'driver', DRIVERS, 'owner-led'), next_step: text(args.next_step, 'next_step', { optional: true, max: 1000 }) ?? '',
|
|
341
|
+
summary: text(args.summary, 'summary', { optional: true, max: 4000 }) ?? '', external_refs: [], links: [],
|
|
342
|
+
};
|
|
343
|
+
const type = await typeNamed('container');
|
|
344
|
+
const prior = await landed(type, requestId, meta);
|
|
345
|
+
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 };
|
|
346
|
+
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) => !/^(?:product|repo|tier):|^atlas-registry$/.test(tag)) : [])] });
|
|
347
|
+
return { verb: 'item_propose', item_id: entity.id, version: entity.version, lifecycle: 'proposed', stage: 'capture', product: fields.product, repos };
|
|
348
|
+
},
|
|
349
|
+
|
|
350
|
+
async item_lock(args, requestId, meta) {
|
|
351
|
+
requireOwner('item lock');
|
|
352
|
+
const item = await requireItem(args.item_id);
|
|
353
|
+
const fields = item.fields ?? {};
|
|
354
|
+
if (alreadyPatched(item, requestId, meta)) return { verb: 'item_lock', item_id: item.id, version: item.version, lifecycle: fields.lifecycle, stage: fields.stage, product: fields.product, repos: fields.repos, scope_hash: fields.scope_hash, locked_at: fields.locked_at, replayed_from_graph: true };
|
|
355
|
+
if (fields.scope_hash) refuse(`item ${item.id} is already locked (scope ${fields.scope_hash.slice(0, 12)}); split it or propose a new item`);
|
|
356
|
+
if (!['proposed', 'active'].includes(fields.lifecycle)) refuse(`item ${item.id} is ${fields.lifecycle}; only a proposed or active item can be locked`);
|
|
357
|
+
const reg = await registry();
|
|
358
|
+
const product = await requireProduct(args.product, reg);
|
|
359
|
+
const repos = await requireRepos(args.repos, reg, product.fields.registry_id);
|
|
360
|
+
if (!args.scope || typeof args.scope !== 'object' || Array.isArray(args.scope)) refuse('scope is required: { text, design_doc?, acceptance_criteria_hash? }');
|
|
361
|
+
const scope = { text: text(args.scope.text, 'scope.text') };
|
|
362
|
+
if (args.scope.design_doc !== undefined) scope.design_doc = url(args.scope.design_doc, 'scope.design_doc');
|
|
363
|
+
if (args.scope.acceptance_criteria_hash !== undefined) {
|
|
364
|
+
text(args.scope.acceptance_criteria_hash, 'scope.acceptance_criteria_hash', { max: 64 });
|
|
365
|
+
if (!/^[a-f0-9]{64}$/.test(args.scope.acceptance_criteria_hash)) refuse('scope.acceptance_criteria_hash must be a 64-hex sha256');
|
|
366
|
+
scope.acceptance_criteria_hash = args.scope.acceptance_criteria_hash;
|
|
367
|
+
}
|
|
368
|
+
const scopeHash = scope.acceptance_criteria_hash ?? sha256(scope.text);
|
|
369
|
+
const stage = oneOf(args.stage, 'stage', STAGES, fields.lifecycle === 'active' && fields.stage ? fields.stage : 'build');
|
|
370
|
+
const change = {
|
|
371
|
+
lifecycle: 'active', stage, product: product.fields.registry_id, repos, tier: oneOf(args.tier, 'tier', TIERS, fields.tier ?? 'standard'),
|
|
372
|
+
scope, scope_hash: scopeHash, origin: origin(args.origin), locked_at: now(),
|
|
373
|
+
driver: oneOf(args.driver, 'driver', DRIVERS, fields.driver ?? 'owner-led'), next_step: text(args.next_step, 'next_step', { optional: true, max: 1000 }) ?? fields.next_step ?? '',
|
|
374
|
+
links: Array.isArray(fields.links) ? fields.links : [],
|
|
375
|
+
};
|
|
376
|
+
const entity = await patch(item, 'item_lock', requestId, change, { digest: meta?.digest, tags: [`product:${product.fields.registry_id}`, ...repos.map((id) => `repo:${id}`), `tier:${change.tier}`] });
|
|
377
|
+
return { verb: 'item_lock', item_id: entity.id, version: entity.version, lifecycle: 'active', stage, product: change.product, repos, scope_hash: scopeHash, locked_at: change.locked_at };
|
|
378
|
+
},
|
|
379
|
+
|
|
380
|
+
async item_split(args, requestId, meta) {
|
|
381
|
+
requireOwner('item split');
|
|
382
|
+
const parent = await requireItem(args.item_id);
|
|
383
|
+
const reason = text(args.reason, 'reason', { max: 2000 });
|
|
384
|
+
if (alreadyPatched(parent, requestId, meta)) return { verb: 'item_split', item_id: parent.id, version: parent.version, reason, children: (parent.fields.split_children ?? []).map((id) => ({ item_id: id })), replayed_from_graph: true };
|
|
385
|
+
if (!['proposed', 'active'].includes(parent.fields?.lifecycle)) refuse(`item ${parent.id} is ${parent.fields?.lifecycle}; only a proposed or active item can be split`);
|
|
386
|
+
if (!Array.isArray(args.children) || !args.children.length) refuse('children must list at least one piece: { title, origin?, repos?, tier?, next_step?, driver? }');
|
|
387
|
+
if (args.children.length > MAX_CHILDREN) refuse(`children holds more than ${MAX_CHILDREN} pieces`);
|
|
388
|
+
const reg = await registry();
|
|
389
|
+
const productId = parent.fields?.product;
|
|
390
|
+
if (!productId) refuse(`item ${parent.id} has no registry product; lock it with product and repos before splitting`);
|
|
391
|
+
const product = await requireProduct(productId, reg);
|
|
392
|
+
const type = await typeNamed('container');
|
|
393
|
+
const specs = args.children.map((child, index) => ({
|
|
394
|
+
title: text(child?.title, `children[${index}].title`, { max: 300 }),
|
|
395
|
+
repos: child?.repos === undefined ? stringList(parent.fields.repos ?? [], 'parent repos') : null,
|
|
396
|
+
givenRepos: child?.repos, tier: oneOf(child?.tier, `children[${index}].tier`, TIERS, parent.fields.tier ?? 'standard'),
|
|
397
|
+
origin: child?.origin === undefined ? parent.fields.origin : origin(child.origin),
|
|
398
|
+
driver: oneOf(child?.driver, `children[${index}].driver`, DRIVERS, parent.fields.driver ?? 'owner-led'),
|
|
399
|
+
next_step: text(child?.next_step, `children[${index}].next_step`, { optional: true, max: 1000 }) ?? '',
|
|
400
|
+
summary: text(child?.summary, `children[${index}].summary`, { optional: true, max: 4000 }) ?? '',
|
|
401
|
+
}));
|
|
402
|
+
for (const spec of specs) {
|
|
403
|
+
if (spec.givenRepos !== undefined) spec.repos = await requireRepos(spec.givenRepos, reg, product.fields.registry_id);
|
|
404
|
+
if (!spec.origin) refuse('each child needs an origin in the owner\'s words when the parent has none');
|
|
405
|
+
}
|
|
406
|
+
const already = await findCreated(type, requestId);
|
|
407
|
+
const children = [];
|
|
408
|
+
for (const [index, spec] of specs.entries()) {
|
|
409
|
+
const existing = already.find((row) => row.fields?.split_index === index);
|
|
410
|
+
const child = existing ?? await create(type, requestId, 'item_split', { digest: meta?.digest, title: spec.title, fields: { lifecycle: 'proposed', stage: 'capture', product: product.fields.registry_id, repos: spec.repos, tier: spec.tier, origin: spec.origin, driver: spec.driver, next_step: spec.next_step, summary: spec.summary, external_refs: [], links: [], split_index: index, split_from: parent.id, split_reason: reason }, tags: [`product:${product.fields.registry_id}`, ...spec.repos.map((id) => `repo:${id}`), `tier:${spec.tier}`] });
|
|
411
|
+
await edge(child, parent, 'spun_out_of', requestId, { reason });
|
|
412
|
+
children.push({ item_id: child.id, title: spec.title, repos: spec.repos });
|
|
413
|
+
}
|
|
414
|
+
const entity = await patch(parent, 'item_split', requestId, { split_children: [...new Set([...(parent.fields.split_children ?? []), ...children.map((child) => child.item_id)])] }, { digest: meta?.digest });
|
|
415
|
+
return { verb: 'item_split', item_id: entity.id, version: entity.version, reason, children };
|
|
416
|
+
},
|
|
417
|
+
|
|
418
|
+
async item_park(args, requestId, meta) {
|
|
419
|
+
requireOwner('item park');
|
|
420
|
+
const item = await requireItem(args.item_id);
|
|
421
|
+
const reason = text(args.reason, 'reason', { max: 2000 });
|
|
422
|
+
if (alreadyPatched(item, requestId, meta)) return { verb: 'item_park', item_id: item.id, version: item.version, lifecycle: item.fields.lifecycle, reason, replayed_from_graph: true };
|
|
423
|
+
if (!['proposed', 'active'].includes(item.fields?.lifecycle)) refuse(`item ${item.id} is ${item.fields?.lifecycle}; only a proposed or active item can be parked`);
|
|
424
|
+
const entity = await patch(item, 'item_park', requestId, { lifecycle: 'paused', pause_reason: reason, parked_at: now(), parked_from: item.fields.lifecycle }, { digest: meta?.digest });
|
|
425
|
+
return { verb: 'item_park', item_id: entity.id, version: entity.version, lifecycle: 'paused', reason };
|
|
426
|
+
},
|
|
427
|
+
|
|
428
|
+
async item_resume(args, requestId, meta) {
|
|
429
|
+
requireOwner('item resume');
|
|
430
|
+
const item = await requireItem(args.item_id);
|
|
431
|
+
const reason = text(args.reason, 'reason', { max: 2000 });
|
|
432
|
+
if (alreadyPatched(item, requestId, meta)) return { verb: 'item_resume', item_id: item.id, version: item.version, lifecycle: item.fields.lifecycle, reason, replayed_from_graph: true };
|
|
433
|
+
if (item.fields?.lifecycle !== 'paused') refuse(`item ${item.id} is ${item.fields?.lifecycle}; only a paused item can be resumed`);
|
|
434
|
+
const lifecycle = item.fields.parked_from === 'proposed' ? 'proposed' : 'active';
|
|
435
|
+
const entity = await patch(item, 'item_resume', requestId, { lifecycle, pause_reason: '', resume_reason: reason, resumed_at: now(), parked_from: null }, { digest: meta?.digest });
|
|
436
|
+
return { verb: 'item_resume', item_id: entity.id, version: entity.version, lifecycle, reason };
|
|
437
|
+
},
|
|
438
|
+
|
|
439
|
+
async item_stage(args, requestId, meta) {
|
|
440
|
+
const item = await requireItem(args.item_id);
|
|
441
|
+
const stage = oneOf(args.stage, 'stage', STAGES);
|
|
442
|
+
if (alreadyPatched(item, requestId, meta)) return { verb: 'item_stage', item_id: item.id, version: item.version, stage: item.fields.stage, replayed_from_graph: true };
|
|
443
|
+
if (item.fields?.lifecycle !== 'active') refuse(`item ${item.id} is ${item.fields?.lifecycle}; only an active item moves stage`);
|
|
444
|
+
if (item.fields.stage === stage) return { verb: 'item_stage', item_id: item.id, version: item.version, stage, unchanged: true };
|
|
445
|
+
const note = text(args.note, 'note', { optional: true, max: 1000 });
|
|
446
|
+
const entity = await patch(item, 'item_stage', requestId, { stage, stage_changed_at: now(), ...(note ? { stage_note: note } : {}) }, { digest: meta?.digest });
|
|
447
|
+
return { verb: 'item_stage', item_id: entity.id, version: entity.version, stage, previous: item.fields.stage };
|
|
448
|
+
},
|
|
449
|
+
|
|
450
|
+
async item_link(args, requestId, meta) {
|
|
451
|
+
const item = await requireItem(args.item_id);
|
|
452
|
+
const link = { kind: oneOf(args.kind, 'kind', LINK_KINDS), url: url(args.url, 'url'), role: text(args.role, 'role', { optional: true, max: 200 }) ?? '', note: text(args.note, 'note', { optional: true, max: 1000 }) ?? '', added_at: now(), added_by: label.agent };
|
|
453
|
+
const links = Array.isArray(item.fields?.links) ? item.fields.links : [];
|
|
454
|
+
if (alreadyPatched(item, requestId, meta)) return { verb: 'item_link', item_id: item.id, version: item.version, link, links: links.length, replayed_from_graph: true };
|
|
455
|
+
if (links.some((row) => row.kind === link.kind && row.url === link.url)) return { verb: 'item_link', item_id: item.id, version: item.version, link, unchanged: true };
|
|
456
|
+
const refs = Array.isArray(item.fields?.external_refs) ? item.fields.external_refs : [];
|
|
457
|
+
const entity = await patch(item, 'item_link', requestId, { links: [...links, link], external_refs: refs.includes(link.url) ? refs : [...refs, link.url] }, { digest: meta?.digest });
|
|
458
|
+
return { verb: 'item_link', item_id: entity.id, version: entity.version, link, links: links.length + 1 };
|
|
459
|
+
},
|
|
460
|
+
};
|
|
461
|
+
|
|
462
|
+
// Slices L2, L3 and L4: the question, decision, receipt, delivery and
|
|
463
|
+
// reopen verbs and the four views share this door, this journal, this
|
|
464
|
+
// registry and this writer stamp. The delivery verbs also get the two
|
|
465
|
+
// read-only readers: GitHub through the gh CLI, and one GET against the
|
|
466
|
+
// deployed app. Slice L4 adds three read verbs, which write nothing. Slice
|
|
467
|
+
// L5 adds item_adopt, the one verb that gives an existing item a product,
|
|
468
|
+
// and sweep_check, which reads the sweep record against the "In flight"
|
|
469
|
+
// view. The sweep record itself is written by decision_record with kind
|
|
470
|
+
// "sweep": slice L5 adds no second way to write a decision.
|
|
471
|
+
const core = { client, label, now, typeNamed, kindOf, registry, requireProduct, requireItem, requireOwner, patch, create, edge, landed, alreadyPatched, findCreated };
|
|
472
|
+
Object.assign(verbs, createQuestionVerbs(core));
|
|
473
|
+
Object.assign(verbs, createDeliveryVerbs({ ...core, ownerPresent, readGitHub, readLive }));
|
|
474
|
+
Object.assign(verbs, createCapabilityVerbs({ ...core, readGitHub }));
|
|
475
|
+
Object.assign(verbs, createSweepVerbs({ ...core, requireRepos, inFlight: (args) => verbs.in_flight(args), stages: STAGES, tiers: TIERS, drivers: DRIVERS }));
|
|
476
|
+
|
|
477
|
+
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_stage', 'item_link', ...QUESTION_WRITE_VERBS, ...DELIVERY_WRITE_VERBS, ...SWEEP_WRITE_VERBS]);
|
|
478
|
+
const READ_VERBS = Object.freeze(['registry_list', ...QUESTION_READ_VERBS, ...DELIVERY_READ_VERBS, ...CAPABILITY_READ_VERBS, ...SWEEP_READ_VERBS]);
|
|
479
|
+
|
|
480
|
+
async function run(verb, args = {}) {
|
|
481
|
+
if (!verbs[verb]) refuse(`unknown verb: ${verb}`);
|
|
482
|
+
if (!args || typeof args !== 'object' || Array.isArray(args)) refuse('arguments must be an object');
|
|
483
|
+
for (const key of AUTHORITY_FIELDS) if (key in args) refuse(`the request cannot supply ${key}; the server stamps the writer and session`);
|
|
484
|
+
if (READ_VERBS.includes(verb)) return verbs[verb](args);
|
|
485
|
+
const requestId = text(args.request_id, 'request_id', { max: 200 });
|
|
486
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(requestId)) refuse('request_id may hold letters, digits, dots, colons, underscores and hyphens');
|
|
487
|
+
const digest = sha256(canonicalJson({ verb, args, token_id: label.token_id }));
|
|
488
|
+
return store.withLock(requestId, async () => {
|
|
489
|
+
const existing = await store.get(requestId);
|
|
490
|
+
if (existing) {
|
|
491
|
+
if (existing.digest !== digest) refuse(`request_id ${requestId} was already used for a different request; choose a new id`);
|
|
492
|
+
if (existing.phase === 'complete') return { ...copy(existing.result), replayed: true };
|
|
493
|
+
if (existing.phase === 'refused') refuse(`request_id ${requestId} was refused before any write (${existing.error}); fix the request and use a new id`);
|
|
494
|
+
// prepared or interrupted: run again. Each verb first looks for its own request id in the graph and replays from it.
|
|
495
|
+
}
|
|
496
|
+
const record = { request: { requestId, verb, args: copy(args) }, digest, writer: label, phase: 'prepared', at: now(), attempts: (existing?.attempts ?? 0) + 1 };
|
|
497
|
+
await store.put(requestId, record);
|
|
498
|
+
let result;
|
|
499
|
+
try { result = await verbs[verb](args, requestId, { verb, digest }); }
|
|
500
|
+
catch (error) {
|
|
501
|
+
if (error instanceof VerbError && record.attempts === 1) { await store.put(requestId, { ...record, phase: 'refused', error: error.message }); throw error; }
|
|
502
|
+
await store.put(requestId, { ...record, phase: 'interrupted', error: error.message });
|
|
503
|
+
throw error;
|
|
504
|
+
}
|
|
505
|
+
await store.put(requestId, { ...record, phase: 'complete', result, completed_at: now() });
|
|
506
|
+
return { ...copy(result), request_id: requestId, writer: label };
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
return Object.freeze({ run, verbs: Object.freeze([...WRITE_VERBS, ...READ_VERBS]), writer: label });
|
|
511
|
+
}
|