@intent-driven/runtime-local 0.4.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 +18 -4
- package/package.json +1 -1
- package/src/approvalAction.js +49 -0
- package/src/createRuntime.js +247 -2
- package/src/lifecycleAugmenter.js +144 -0
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.
|
|
5
|
+
> **Статус:** v0.5 — full 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,7 +59,10 @@ 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}`
|
|
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'`. |
|
|
63
66
|
|
|
64
67
|
### POST /api/agent/:domain/exec/:intentId
|
|
65
68
|
|
|
@@ -75,13 +78,24 @@ Peer-зависимость: `@intent-driven/core@>=0.49.0` (через `@intent
|
|
|
75
78
|
|
|
76
79
|
Лежит на `engine.submit`, поэтому invariants всех 6 kinds автоматически проверяются (role-capability / referential / transition / cardinality / aggregate / expression).
|
|
77
80
|
|
|
78
|
-
> **
|
|
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
|
+
```
|
|
79
92
|
|
|
80
93
|
## Roadmap (следующие PR)
|
|
81
94
|
|
|
82
|
-
- v0.5: `GET /api/approvals/pending` + `POST /api/approvals/:id/approve|reject` + ApprovalRequest auto-injection (lifecycleAugmenter)
|
|
83
95
|
- v0.6: `GET /api/state/at?t=ISO` + `/api/state/diff?from=&to=` — time-travel
|
|
84
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
|
|
85
99
|
|
|
86
100
|
## Лицензия
|
|
87
101
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intent-driven/runtime-local",
|
|
3
|
-
"version": "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
|
+
}
|
package/src/createRuntime.js
CHANGED
|
@@ -1,10 +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";
|
|
7
8
|
import { buildEffect } from "./buildEffect.js";
|
|
9
|
+
import { augmentOntologyForLifecycle, hasApprovalLifecycle } from "./lifecycleAugmenter.js";
|
|
10
|
+
import { validateApprovalAction, parseFromRoles } from "./approvalAction.js";
|
|
8
11
|
|
|
9
12
|
/**
|
|
10
13
|
* Engine validator JSON-парсит ef.value если оно string (host-convention для
|
|
@@ -29,14 +32,36 @@ const PKG_VERSION = (() => {
|
|
|
29
32
|
}
|
|
30
33
|
})();
|
|
31
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
|
+
|
|
32
53
|
export function createRuntime(opts = {}) {
|
|
33
|
-
const { ontology, seed = [], port = 3001, host = "127.0.0.1" } = opts;
|
|
54
|
+
const { ontology: rawOntology, seed = [], port = 3001, host = "127.0.0.1" } = opts;
|
|
34
55
|
|
|
35
|
-
if (!
|
|
56
|
+
if (!rawOntology || typeof rawOntology !== "object") {
|
|
36
57
|
throw new Error("createRuntime: ontology is required");
|
|
37
58
|
}
|
|
38
59
|
|
|
60
|
+
// augmenter добавляет ApprovalRequest entity + правит approver/proposer
|
|
61
|
+
// visibleFields когда есть intent с lifecycle.requiresApproval.
|
|
62
|
+
const ontology = augmentOntologyForLifecycle(rawOntology);
|
|
39
63
|
const domain = ontology.name || "default";
|
|
64
|
+
const hasApproval = hasApprovalLifecycle(ontology.intents || {});
|
|
40
65
|
|
|
41
66
|
const persistence = createInMemoryPersistence();
|
|
42
67
|
const engine = createEngine({
|
|
@@ -134,6 +159,71 @@ export function createRuntime(opts = {}) {
|
|
|
134
159
|
try {
|
|
135
160
|
const params = (req.body && typeof req.body === "object" ? req.body.params : null) || {};
|
|
136
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
|
+
|
|
137
227
|
const result = await engine.submit(effect);
|
|
138
228
|
if (result.status === "confirmed") {
|
|
139
229
|
return res.json({ status: "confirmed", effect });
|
|
@@ -150,6 +240,161 @@ export function createRuntime(opts = {}) {
|
|
|
150
240
|
}
|
|
151
241
|
});
|
|
152
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
|
+
|
|
153
398
|
let server = null;
|
|
154
399
|
|
|
155
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
|
+
};
|