@intent-driven/runtime-local 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Standalone Fold-runtime для локального quickstart'а. Принимает ontology.js (сгенерированный `idf full-bootstrap`), поднимает HTTP-сервер с эндпоинтами, которые ожидает `@intent-driven/mcp-server` для подключения к Claude Desktop / Cursor / Zed.
4
4
 
5
- > **Статус:** v0.3stateful Φ через `@intent-driven/engine`. Поддерживает `/api/health`, `/api/typemap`, `/api/agent/:domain/schema` (discovery), `/api/state/current`, `/api/agent/:domain/world` (fold снимок). agent/exec + approvals lifecycle — в следующих релизах (см. roadmap fold-15min).
5
+ > **Статус:** v0.5full approve_pending cycle. Поддерживает `/api/health`, `/api/typemap`, `/api/agent/:domain/schema`, `/api/state/current`, `/api/agent/:domain/world`, `POST /api/agent/:domain/exec/:intentId` (с auto-injection ApprovalRequest для lifecycle.requiresApproval), `/api/approvals/pending`, `/api/approvals/:id/approve|reject`. Time-travel state/at, SSE-стрим, JWT auth — в следующих релизах.
6
6
 
7
7
  ## Зачем
8
8
 
@@ -59,13 +59,43 @@ Peer-зависимость: `@intent-driven/core@>=0.49.0` (через `@intent
59
59
  | `GET` | `/api/agent/:domain/schema` | Full ontology snapshot для MCP discovery. 503 если domain не совпадает. |
60
60
  | `GET` | `/api/state/current` | Folded world snapshot — `{domain, world: {products: [...], orders: [...]}}`. |
61
61
  | `GET` | `/api/agent/:domain/world` | Тот же world, что и `/api/state/current`. Этот endpoint вызывает `@intent-driven/mcp-server` для resource-чтения. 503 при mismatch domain. |
62
+ | `POST` | `/api/agent/:domain/exec/:intentId` | Ingest intent через `engine.submit`. Generic builder для add/replace/remove. Возвращает `200 {status:'confirmed', effect}` для intent'ов без lifecycle, `202 {status:'pending_approval', approvalRequestId, ...}` для intent'ов с `lifecycle.requiresApproval`, или `409` при invariant violation. `400` для read-intents, `404` для unknown intent, `503` для wrong domain. |
63
+ | `GET` | `/api/approvals/pending?domain=NAME&as=ROLE` | Pending ApprovalRequest'ы видимые caller'у (фильтр по `fromRole`). Lazy-expiry: requests с `now > expiresAt` автоматически переводятся в `expired` до возврата. |
64
+ | `POST` | `/api/approvals/:id/approve` (body `{reason?}`) | Approver одобряет; `effectsPlanned` ingest'ятся через `engine.submit`. AR получает `status='approved'` + `approvedBy` + `approvedAt`. 403 если caller'а роль не в `fromRole`. 409 если уже terminal status / expired. |
65
+ | `POST` | `/api/approvals/:id/reject` (body `{reason?}`) | Аналогично approve, но без ingest'а. `status='rejected'`. |
66
+
67
+ ### POST /api/agent/:domain/exec/:intentId
68
+
69
+ Тело запроса: `{ params: { ... } }`.
70
+
71
+ `buildEffect()` строит effect по generic-семантике (идентично host'овскому `genericBuildEffects`):
72
+
73
+ - `alpha=add` → `context = {...params, id: params.id || uuid}`, `value = null`
74
+ - `alpha=replace` target=`Entity.field` → `context = {id: params.id}`, `value = JSON.stringify(params[field])`
75
+ - `alpha=replace` target=`Entity` → `context = {...params}`, `value = null`
76
+ - `alpha=remove` → `context = {id: params.id || params['<entity>Id']}`, `value = null`
77
+ - `alpha=read` → 400 (читай через GET `/api/state/current`)
78
+
79
+ Лежит на `engine.submit`, поэтому invariants всех 6 kinds автоматически проверяются (role-capability / referential / transition / cardinality / aggregate / expression).
80
+
81
+ > **Approval lifecycle:** intents с `lifecycle.requiresApproval = { from: ['admin'], timeoutMs: 60_000 }` автоматически работают через ApprovalRequest. `lifecycleAugmenter` добавляет `ApprovalRequest` entity и augment'ит approver/proposer visibleFields на старте. POST exec возвращает 202 + `approvalRequestId`; реальный ingest происходит в `/approvals/:id/approve`. RBAC: `?as=ROLE` query (без auth в v0.5; JWT — будущая итерация).
82
+
83
+ ## Caller role
84
+
85
+ В v0.5 caller's role резолвится через `?as=ROLE` query parameter. Без этого — `'agent'` по умолчанию. JWT/Bearer-token auth — в будущих релизах.
86
+
87
+ ```bash
88
+ # Approver одобряет от имени admin'а
89
+ curl -X POST http://localhost:3001/api/approvals/ar_abc.../approve?as=admin \
90
+ -d '{"reason":"validated by IT"}'
91
+ ```
62
92
 
63
93
  ## Roadmap (следующие PR)
64
94
 
65
- - v0.4: `POST /api/agent/:domain/exec/:intentId` — ingest через `engine.submit` с invariant validation, ApprovalRequest auto-injection через `lifecycleAugmenter`
66
- - v0.5: `GET /api/approvals/pending` + `POST /api/approvals/:id/approve|reject` + timer-driven expiry
67
95
  - v0.6: `GET /api/state/at?t=ISO` + `/api/state/diff?from=&to=` — time-travel
68
96
  - v0.7: SSE-стрим `/api/approvals/stream` для `idf approvals --watch`
97
+ - v0.8: timer-driven approval expiry (вместо lazy-expiry в /pending)
98
+ - v0.9: JWT auth + per-request viewer.scope
69
99
 
70
100
  ## Лицензия
71
101
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intent-driven/runtime-local",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Standalone Fold-runtime для local quickstart: stateful Φ + agent/exec + approvals + state — то, что делает host idf/server, но как npm-пакет",
5
5
  "type": "module",
6
6
  "exports": {
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Pure validation для approval-action — port host'овской
3
+ * `server/schema/approvalAction.cjs` в ESM.
4
+ *
5
+ * `validateApprovalAction` — null если action разрешён, иначе
6
+ * `{ httpStatus, error, ...details }` с failure-payload'ом.
7
+ */
8
+
9
+ export function parseFromRoles(value) {
10
+ if (!value) return [];
11
+ if (Array.isArray(value)) return value;
12
+ return String(value).split(",").map(s => s.trim()).filter(Boolean);
13
+ }
14
+
15
+ export function validateApprovalAction(action, ar, callerRole, now = Date.now()) {
16
+ if (action !== "approve" && action !== "reject") {
17
+ return { httpStatus: 400, error: "invalid_action", action };
18
+ }
19
+ if (!ar) {
20
+ return { httpStatus: 404, error: "approval_not_found" };
21
+ }
22
+ const allowedRoles = parseFromRoles(ar.fromRole);
23
+ if (!allowedRoles.includes(callerRole)) {
24
+ return {
25
+ httpStatus: 403,
26
+ error: "role_not_allowed",
27
+ callerRole,
28
+ allowedRoles,
29
+ message: `Approval requires role(s) [${allowedRoles.join(", ")}]; you are '${callerRole}'`,
30
+ };
31
+ }
32
+ if (ar.status !== "pending") {
33
+ return {
34
+ httpStatus: 409,
35
+ error: "already_resolved",
36
+ currentStatus: ar.status,
37
+ message: `ApprovalRequest already in terminal status '${ar.status}'`,
38
+ };
39
+ }
40
+ if (ar.expiresAt && Number(ar.expiresAt) < now) {
41
+ return {
42
+ httpStatus: 409,
43
+ error: "expired",
44
+ expiresAt: ar.expiresAt,
45
+ message: "ApprovalRequest expired before action",
46
+ };
47
+ }
48
+ return null;
49
+ }
@@ -0,0 +1,82 @@
1
+ import { randomUUID } from "node:crypto";
2
+
3
+ /**
4
+ * Generic effect-builder для intents без custom builder'а — закрывает
5
+ * 80%+ кейсов после `idf full-bootstrap`. Идентичен host'овскому
6
+ * `genericBuildEffects` (server/schema/effectBuildersRegistry.cjs):
7
+ *
8
+ * alpha=add → ctx={...params, id: params.id || uuid}
9
+ * alpha=replace target='Entity.field' → ctx={id}, value=params[field] || params.value
10
+ * alpha=replace target='Entity' → ctx={...params}, value=null
11
+ * alpha=remove → ctx={id: params.id || params['<entity>Id']}
12
+ *
13
+ * Возвращает unsubmitted effect (без status/created_at — engine.submit
14
+ * проставит их сам).
15
+ */
16
+ export function buildEffect({ intentId, intent, params = {}, viewerId = null }) {
17
+ if (!intent) {
18
+ throw new Error(`buildEffect: intent '${intentId}' not provided`);
19
+ }
20
+
21
+ const alpha = intent.alpha;
22
+ if (alpha === "read") {
23
+ throw new Error(`buildEffect: read intent '${intentId}' is not executable`);
24
+ }
25
+
26
+ const target = intent.target;
27
+ if (!target) {
28
+ throw new Error(`buildEffect: intent '${intentId}' has no target`);
29
+ }
30
+
31
+ const id = randomUUID();
32
+ const baseEffect = {
33
+ id,
34
+ intent_id: intentId,
35
+ alpha,
36
+ target,
37
+ scope: intent.scope || "account",
38
+ };
39
+ if (viewerId) baseEffect.user_id = viewerId;
40
+
41
+ switch (alpha) {
42
+ case "add": {
43
+ return {
44
+ ...baseEffect,
45
+ context: { ...params, id: params.id || id },
46
+ value: null,
47
+ };
48
+ }
49
+ case "replace": {
50
+ const segments = target.split(".");
51
+ if (segments.length > 1) {
52
+ const field = segments[segments.length - 1];
53
+ const raw = params[field] !== undefined ? params[field] : params.value;
54
+ // Engine validator JSON-парсит string values (host-convention для
55
+ // SQLite JSONB columns). Любое native JS value JSON-encode'ится.
56
+ const value = raw === undefined || raw === null ? null : JSON.stringify(raw);
57
+ return {
58
+ ...baseEffect,
59
+ context: { id: params.id },
60
+ value,
61
+ };
62
+ }
63
+ return {
64
+ ...baseEffect,
65
+ context: { ...params },
66
+ value: null,
67
+ };
68
+ }
69
+ case "remove": {
70
+ const entityName = target.split(".")[0];
71
+ const idKey = `${entityName.charAt(0).toLowerCase()}${entityName.slice(1)}Id`;
72
+ const entityId = params.id || params[idKey];
73
+ return {
74
+ ...baseEffect,
75
+ context: { id: entityId },
76
+ value: null,
77
+ };
78
+ }
79
+ default:
80
+ throw new Error(`buildEffect: unsupported alpha '${alpha}'`);
81
+ }
82
+ }
@@ -1,9 +1,13 @@
1
1
  import express from "express";
2
2
  import cors from "cors";
3
+ import { randomUUID } from "node:crypto";
3
4
  import { readFileSync } from "node:fs";
4
5
  import { fileURLToPath } from "node:url";
5
6
  import { dirname, join } from "node:path";
6
7
  import { createEngine, createInMemoryPersistence } from "@intent-driven/engine";
8
+ import { buildEffect } from "./buildEffect.js";
9
+ import { augmentOntologyForLifecycle, hasApprovalLifecycle } from "./lifecycleAugmenter.js";
10
+ import { validateApprovalAction, parseFromRoles } from "./approvalAction.js";
7
11
 
8
12
  /**
9
13
  * Engine validator JSON-парсит ef.value если оно string (host-convention для
@@ -28,14 +32,36 @@ const PKG_VERSION = (() => {
28
32
  }
29
33
  })();
30
34
 
35
+ const DEFAULT_APPROVAL_TIMEOUT_MS = 5 * 60 * 1000;
36
+
37
+ function resolveCallerRole(req, ontology) {
38
+ const candidates = [];
39
+ if (typeof req.query?.as === "string") candidates.push(req.query.as);
40
+ candidates.push("agent");
41
+ const roles = ontology?.roles || {};
42
+ for (const c of candidates) {
43
+ if (c && roles[c]) return c;
44
+ }
45
+ return null;
46
+ }
47
+
48
+ function findApprovalRequest(world, id) {
49
+ const all = world?.approvalRequests || [];
50
+ return all.find(r => r.id === id) || null;
51
+ }
52
+
31
53
  export function createRuntime(opts = {}) {
32
- const { ontology, seed = [], port = 3001, host = "127.0.0.1" } = opts;
54
+ const { ontology: rawOntology, seed = [], port = 3001, host = "127.0.0.1" } = opts;
33
55
 
34
- if (!ontology || typeof ontology !== "object") {
56
+ if (!rawOntology || typeof rawOntology !== "object") {
35
57
  throw new Error("createRuntime: ontology is required");
36
58
  }
37
59
 
60
+ // augmenter добавляет ApprovalRequest entity + правит approver/proposer
61
+ // visibleFields когда есть intent с lifecycle.requiresApproval.
62
+ const ontology = augmentOntologyForLifecycle(rawOntology);
38
63
  const domain = ontology.name || "default";
64
+ const hasApproval = hasApprovalLifecycle(ontology.intents || {});
39
65
 
40
66
  const persistence = createInMemoryPersistence();
41
67
  const engine = createEngine({
@@ -106,6 +132,269 @@ export function createRuntime(opts = {}) {
106
132
  }
107
133
  });
108
134
 
135
+ app.post("/api/agent/:domain/exec/:intentId", async (req, res, next) => {
136
+ if (req.params.domain !== domain) {
137
+ return res.status(503).json({
138
+ error: "ontology_unavailable",
139
+ domain: req.params.domain,
140
+ message: `Ontology for '${req.params.domain}' is not registered. Runtime serves '${domain}'.`,
141
+ });
142
+ }
143
+ const intentId = req.params.intentId;
144
+ const intent = (ontology.intents || {})[intentId];
145
+ if (!intent) {
146
+ return res.status(404).json({
147
+ error: "intent_not_found",
148
+ intentId,
149
+ domain,
150
+ });
151
+ }
152
+ if (intent.alpha === "read") {
153
+ return res.status(400).json({
154
+ error: "read_intent_not_executable",
155
+ intentId,
156
+ message: `Read-intents must be executed via GET /api/state/current or /api/agent/${domain}/world.`,
157
+ });
158
+ }
159
+ try {
160
+ const params = (req.body && typeof req.body === "object" ? req.body.params : null) || {};
161
+ const effect = buildEffect({ intentId, intent, params });
162
+
163
+ // Gate 2: human-approval lifecycle. Если intent.lifecycle.requiresApproval
164
+ // активен — НЕ ingest'им effect напрямую. Создаём ApprovalRequest в Φ;
165
+ // фактический ingest происходит в /api/approvals/:id/approve.
166
+ const lifecycle = intent.lifecycle?.requiresApproval;
167
+ if (lifecycle) {
168
+ const approvalRequestId = `ar_${randomUUID()}`;
169
+ const expiresAt = Date.now() + (Number(lifecycle.timeoutMs) || DEFAULT_APPROVAL_TIMEOUT_MS);
170
+ const fromRoles = Array.isArray(lifecycle.from)
171
+ ? lifecycle.from
172
+ : (typeof lifecycle.from === "string" ? [lifecycle.from] : []);
173
+ const callerRole = resolveCallerRole(req, ontology) || "agent";
174
+
175
+ const arEffect = {
176
+ id: randomUUID(),
177
+ intent_id: "_request_approval",
178
+ alpha: "add",
179
+ target: "approvalRequests",
180
+ value: null,
181
+ scope: "global",
182
+ parent_id: null,
183
+ context: {
184
+ id: approvalRequestId,
185
+ intentId,
186
+ domain,
187
+ proposedBy: callerRole,
188
+ proposedByRole: callerRole,
189
+ fromRole: fromRoles.join(","),
190
+ params: JSON.stringify(params),
191
+ effectsPlanned: JSON.stringify([effect]),
192
+ status: "pending",
193
+ expiresAt,
194
+ createdAt: Date.now(),
195
+ approvedBy: null,
196
+ approvedAt: null,
197
+ reason: null,
198
+ },
199
+ created_at: Date.now(),
200
+ };
201
+ const arResult = await engine.submit(arEffect);
202
+ if (arResult.status !== "confirmed") {
203
+ return res.status(500).json({
204
+ error: "approval_creation_failed",
205
+ reason: arResult.reason,
206
+ message: "Не удалось зарегистрировать ApprovalRequest в Φ",
207
+ });
208
+ }
209
+
210
+ return res.status(202).json({
211
+ status: "pending_approval",
212
+ approvalRequestId,
213
+ intentId,
214
+ domain,
215
+ fromRole: fromRoles,
216
+ expiresAt,
217
+ timeoutMs: lifecycle.timeoutMs,
218
+ effectsPlanned: [{
219
+ alpha: effect.alpha,
220
+ target: effect.target,
221
+ context: effect.context,
222
+ value: effect.value,
223
+ }],
224
+ });
225
+ }
226
+
227
+ const result = await engine.submit(effect);
228
+ if (result.status === "confirmed") {
229
+ return res.json({ status: "confirmed", effect });
230
+ }
231
+ return res.status(409).json({
232
+ status: "rejected",
233
+ error: "invariant_violation",
234
+ reason: result.reason,
235
+ cascaded: result.cascaded || [],
236
+ effect,
237
+ });
238
+ } catch (err) {
239
+ next(err);
240
+ }
241
+ });
242
+
243
+ // ─── /api/approvals/* — approve / reject / pending list ──────────────
244
+ if (hasApproval) {
245
+ async function emitExpire(approvalRequestId) {
246
+ const expireEffect = {
247
+ id: randomUUID(),
248
+ intent_id: "_expire_approval",
249
+ alpha: "replace",
250
+ target: "approvalRequests.status",
251
+ value: JSON.stringify("expired"),
252
+ scope: "global",
253
+ parent_id: null,
254
+ context: { id: approvalRequestId },
255
+ created_at: Date.now(),
256
+ };
257
+ await engine.submit(expireEffect);
258
+ }
259
+
260
+ app.get("/api/approvals/pending", async (req, res, next) => {
261
+ try {
262
+ const reqDomain = req.query?.domain;
263
+ if (!reqDomain) {
264
+ return res.status(400).json({
265
+ error: "domain_required",
266
+ message: "Provide ?domain=NAME",
267
+ });
268
+ }
269
+ if (reqDomain !== domain) {
270
+ return res.status(404).json({ error: "domain_unknown", domain: reqDomain });
271
+ }
272
+ const callerRole = resolveCallerRole(req, ontology);
273
+ if (!callerRole) {
274
+ return res.status(400).json({ error: "role_unknown", domain });
275
+ }
276
+
277
+ let world = await engine.foldWorld();
278
+ const allRequests = (world.approvalRequests || []).filter(r => r.domain === domain);
279
+
280
+ const now = Date.now();
281
+ let didExpire = false;
282
+ for (const r of allRequests) {
283
+ if (r.status === "pending" && r.expiresAt && Number(r.expiresAt) < now) {
284
+ await emitExpire(r.id);
285
+ didExpire = true;
286
+ }
287
+ }
288
+ if (didExpire) world = await engine.foldWorld();
289
+
290
+ const filtered = (world.approvalRequests || [])
291
+ .filter(r => r.domain === domain)
292
+ .filter(r => r.status === "pending")
293
+ .filter(r => parseFromRoles(r.fromRole).includes(callerRole));
294
+
295
+ res.json({ domain, callerRole, now, requests: filtered });
296
+ } catch (err) {
297
+ next(err);
298
+ }
299
+ });
300
+
301
+ async function handleAction(req, res, action) {
302
+ const id = req.params.id;
303
+ const reason = (req.body?.reason || "").toString().slice(0, 500) || null;
304
+ const world = await engine.foldWorld();
305
+ const ar = findApprovalRequest(world, id);
306
+ if (!ar) {
307
+ return res.status(404).json({ error: "approval_not_found", id });
308
+ }
309
+ if (ar.domain !== domain) {
310
+ return res.status(404).json({ error: "domain_unknown", domain: ar.domain });
311
+ }
312
+ const callerRole = resolveCallerRole(req, ontology);
313
+ if (!callerRole) {
314
+ return res.status(400).json({ error: "role_unknown", domain });
315
+ }
316
+ const error = validateApprovalAction(action, ar, callerRole);
317
+ if (error) {
318
+ if (error.error === "expired") {
319
+ await emitExpire(id);
320
+ }
321
+ const { httpStatus, ...body } = error;
322
+ return res.status(httpStatus).json({ ...body, ...(error.error === "approval_not_found" ? { id } : {}) });
323
+ }
324
+
325
+ const now = Date.now();
326
+ const newStatus = action === "approve" ? "approved" : "rejected";
327
+ const callerId = `runtime-local:${callerRole}`;
328
+
329
+ const arUpdate = {
330
+ id: randomUUID(),
331
+ intent_id: action === "approve" ? "approve_request" : "reject_request",
332
+ alpha: "replace",
333
+ target: "approvalRequests",
334
+ value: null,
335
+ scope: "global",
336
+ parent_id: null,
337
+ context: {
338
+ id,
339
+ status: newStatus,
340
+ approvedBy: callerId,
341
+ approvedAt: now,
342
+ reason,
343
+ },
344
+ created_at: now,
345
+ };
346
+ const updateResult = await engine.submit(arUpdate);
347
+ if (updateResult.status !== "confirmed") {
348
+ return res.status(500).json({
349
+ error: "approval_update_failed",
350
+ reason: updateResult.reason,
351
+ });
352
+ }
353
+
354
+ let appliedEffects = [];
355
+ if (action === "approve") {
356
+ let planned;
357
+ try {
358
+ planned = JSON.parse(ar.effectsPlanned || "[]");
359
+ } catch {
360
+ return res.status(500).json({
361
+ error: "effects_corrupt",
362
+ message: "ApprovalRequest.effectsPlanned не парсится",
363
+ });
364
+ }
365
+ if (!Array.isArray(planned)) planned = [];
366
+ for (const e of planned) {
367
+ const fresh = {
368
+ ...e,
369
+ id: randomUUID(),
370
+ parent_id: arUpdate.id,
371
+ created_at: Date.now(),
372
+ };
373
+ const r = await engine.submit(fresh);
374
+ appliedEffects.push({
375
+ id: fresh.id,
376
+ intent_id: fresh.intent_id,
377
+ alpha: fresh.alpha,
378
+ target: fresh.target,
379
+ status: r.status,
380
+ });
381
+ }
382
+ }
383
+
384
+ return res.json({
385
+ status: newStatus,
386
+ approvalRequestId: id,
387
+ approvedBy: callerId,
388
+ approvedAt: now,
389
+ reason,
390
+ appliedEffects,
391
+ });
392
+ }
393
+
394
+ app.post("/api/approvals/:id/approve", (req, res) => handleAction(req, res, "approve"));
395
+ app.post("/api/approvals/:id/reject", (req, res) => handleAction(req, res, "reject"));
396
+ }
397
+
109
398
  let server = null;
110
399
 
111
400
  async function loadSeed() {
@@ -0,0 +1,144 @@
1
+ /**
2
+ * lifecycleAugmenter — автоматическое обогащение ontology mechanism'ом
3
+ * approval-lifecycle. Port host'овской `server/schema/lifecycleAugmenter.cjs`
4
+ * в ESM. Принцип «augment, don't override»: авторские declarations имеют
5
+ * приоритет.
6
+ *
7
+ * Когда хоть один intent объявляет `lifecycle.requiresApproval`, augmenter:
8
+ * - добавляет ApprovalRequest entity (если автор не объявил свой)
9
+ * - augmenting visibleFields на approver/proposer ролях
10
+ * - canExecute approve_request/reject_request на approver-роли
11
+ *
12
+ * Pure: возвращает новый ontology-объект.
13
+ */
14
+
15
+ const APPROVAL_REQUEST_ENTITY = {
16
+ ownerField: null,
17
+ fields: {
18
+ id: { type: "text" },
19
+ intentId: { type: "text", label: "Intent" },
20
+ domain: { type: "text", label: "Domain" },
21
+ proposedBy: { type: "text", label: "Proposed by", fieldRole: "name" },
22
+ proposedByRole: { type: "text", label: "Proposed by role" },
23
+ fromRole: { type: "text", label: "Approver role" },
24
+ params: { type: "textarea", label: "Params (JSON)" },
25
+ effectsPlanned: { type: "textarea", label: "Effects planned (JSON)" },
26
+ status: {
27
+ type: "select",
28
+ label: "Status",
29
+ options: ["pending", "approved", "rejected", "expired"],
30
+ fieldRole: "status",
31
+ },
32
+ expiresAt: { type: "datetime", label: "Expires", fieldRole: "datetime" },
33
+ createdAt: { type: "datetime", fieldRole: "datetime" },
34
+ approvedBy: { type: "text", label: "Approved by" },
35
+ approvedAt: { type: "datetime", label: "Approved at", fieldRole: "datetime" },
36
+ reason: { type: "textarea", label: "Reason" },
37
+ },
38
+ };
39
+
40
+ const APPROVER_VISIBLE_FIELDS = Object.keys(APPROVAL_REQUEST_ENTITY.fields);
41
+ const PROPOSER_VISIBLE_FIELDS = ["id", "intentId", "status", "expiresAt", "createdAt"];
42
+
43
+ export function augmentOntologyForLifecycle(ontology) {
44
+ if (!ontology) return ontology;
45
+ const intents = ontology.intents || {};
46
+
47
+ const approvalIntents = collectApprovalIntents(intents);
48
+ if (approvalIntents.length === 0) return ontology;
49
+
50
+ const approverRoles = collectApproverRoles(approvalIntents);
51
+ const proposerRoles = mapProposerRoles(ontology, approvalIntents);
52
+
53
+ return {
54
+ ...ontology,
55
+ entities: ensureApprovalEntity(ontology.entities),
56
+ roles: augmentRoles(ontology.roles || {}, approverRoles, proposerRoles),
57
+ };
58
+ }
59
+
60
+ export function hasApprovalLifecycle(intents) {
61
+ return collectApprovalIntents(intents).length > 0;
62
+ }
63
+
64
+ function collectApprovalIntents(intents) {
65
+ if (!intents || typeof intents !== "object") return [];
66
+ return Object.entries(intents)
67
+ .filter(([_, intent]) => intent?.lifecycle?.requiresApproval)
68
+ .map(([id, intent]) => [id, intent.lifecycle.requiresApproval]);
69
+ }
70
+
71
+ function collectApproverRoles(approvalIntents) {
72
+ const set = new Set();
73
+ for (const [_, lifecycle] of approvalIntents) {
74
+ const from = lifecycle.from;
75
+ if (!from) continue;
76
+ const roles = Array.isArray(from) ? from : [from];
77
+ for (const r of roles) if (typeof r === "string") set.add(r);
78
+ }
79
+ return set;
80
+ }
81
+
82
+ function mapProposerRoles(ontology, approvalIntents) {
83
+ const intentIds = new Set(approvalIntents.map(([id]) => id));
84
+ const proposers = new Set();
85
+ for (const [roleName, role] of Object.entries(ontology.roles || {})) {
86
+ const ce = role.canExecute || [];
87
+ if (ce.some(id => intentIds.has(id))) proposers.add(roleName);
88
+ }
89
+ return proposers;
90
+ }
91
+
92
+ function ensureApprovalEntity(entities) {
93
+ if (entities?.ApprovalRequest) return entities;
94
+ return { ...(entities || {}), ApprovalRequest: APPROVAL_REQUEST_ENTITY };
95
+ }
96
+
97
+ function augmentRoles(roles, approverRoles, proposerRoles) {
98
+ const out = {};
99
+ for (const [name, role] of Object.entries(roles)) {
100
+ let augmented = role;
101
+ if (approverRoles.has(name)) {
102
+ augmented = augmentApproverRole(augmented);
103
+ } else if (proposerRoles.has(name)) {
104
+ augmented = augmentProposerRole(augmented);
105
+ }
106
+ out[name] = augmented;
107
+ }
108
+ return out;
109
+ }
110
+
111
+ function augmentApproverRole(role) {
112
+ return {
113
+ ...role,
114
+ visibleFields: ensureVisibleFields(role.visibleFields, APPROVER_VISIBLE_FIELDS),
115
+ canExecute: ensureCanExecuteHas(role.canExecute, ["approve_request", "reject_request"]),
116
+ };
117
+ }
118
+
119
+ function augmentProposerRole(role) {
120
+ return {
121
+ ...role,
122
+ visibleFields: ensureVisibleFields(role.visibleFields, PROPOSER_VISIBLE_FIELDS),
123
+ };
124
+ }
125
+
126
+ function ensureVisibleFields(visibleFields, defaultFields) {
127
+ const vf = visibleFields || {};
128
+ if (vf.ApprovalRequest) return vf;
129
+ return { ...vf, ApprovalRequest: defaultFields };
130
+ }
131
+
132
+ function ensureCanExecuteHas(canExecute, mustInclude) {
133
+ const ce = Array.isArray(canExecute) ? [...canExecute] : [];
134
+ for (const id of mustInclude) {
135
+ if (!ce.includes(id)) ce.push(id);
136
+ }
137
+ return ce;
138
+ }
139
+
140
+ export {
141
+ APPROVAL_REQUEST_ENTITY,
142
+ APPROVER_VISIBLE_FIELDS,
143
+ PROPOSER_VISIBLE_FIELDS,
144
+ };