biz-a-cli 2.3.80-15390 → 2.3.80-15397

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.
Files changed (40) hide show
  1. package/bin/hubEvent.js +62 -0
  2. package/engine/bpm/workflow-runtime.js +3 -3
  3. package/engine/cc/aiTurns.js +237 -10
  4. package/engine/cc/appMenus.d.ts +2 -0
  5. package/engine/cc/appMenus.js +14 -0
  6. package/engine/cc/ccGate.js +15 -0
  7. package/engine/cc/configTools.js +627 -0
  8. package/engine/cc/configValidate.js +85 -0
  9. package/engine/cc/currentConfig.js +31 -0
  10. package/engine/cc/derive.js +178 -0
  11. package/engine/cc/draftStore.js +2 -8
  12. package/engine/cc/drift.js +256 -0
  13. package/engine/cc/gapRepo.js +54 -0
  14. package/engine/cc/layer3Behavior.js +54 -0
  15. package/engine/cc/layer3Schema.d.ts +39 -0
  16. package/engine/cc/layer3Schema.js +153 -0
  17. package/engine/cc/mapCommit.js +77 -0
  18. package/engine/cc/mapPrint.d.ts +53 -0
  19. package/engine/cc/mapPrint.js +519 -0
  20. package/engine/cc/mapRepo.js +80 -0
  21. package/engine/cc/mapStore.js +190 -0
  22. package/engine/cc/mapTools.js +334 -0
  23. package/engine/cc/phaseA.js +164 -0
  24. package/engine/cc/phaseB.js +228 -0
  25. package/engine/cc/processMap.d.ts +79 -0
  26. package/engine/cc/processMap.js +470 -0
  27. package/engine/cc/prompts/derive.js +49 -0
  28. package/engine/cc/prompts/phaseA.js +41 -0
  29. package/engine/cc/prompts/phaseB.js +30 -0
  30. package/engine/cc/traceRepo.js +102 -0
  31. package/engine/cc/traceTargets.js +66 -0
  32. package/engine/cc/translate.js +96 -0
  33. package/engine/cc/turnCommit.js +157 -0
  34. package/engine/cc/turnLoop.js +87 -0
  35. package/engine/cc/turnRepo.js +34 -2
  36. package/engine/orm/apiRoute.js +2 -1
  37. package/engine/orm/principalGuards.js +2 -1
  38. package/engine/orm/userPreferences.js +2 -1
  39. package/migrations/postgres/1790300000000__cc_trace_baseline.sql +20 -0
  40. package/package.json +1 -1
package/bin/hubEvent.js CHANGED
@@ -28,6 +28,11 @@ import { authorizeRequest, principalForCliScript } from "../security/internalPri
28
28
  import { whoami } from "../security/whoami.js";
29
29
  import { runDraftAction } from "../engine/cc/draftStore.js";
30
30
  import { createPgDraftRepo } from "../engine/cc/pgRepo.js";
31
+ import { runMapAction } from "../engine/cc/mapStore.js";
32
+ import { createMapRepo } from "../engine/cc/mapRepo.js";
33
+ import { createGapRepo } from "../engine/cc/gapRepo.js";
34
+ import { createTraceRepo } from "../engine/cc/traceRepo.js";
35
+ import { createConfigReader } from "../engine/cc/currentConfig.js";
31
36
  import { runAiAction } from "../engine/cc/aiTurns.js";
32
37
  import { createTurnRepo } from "../engine/cc/turnRepo.js";
33
38
  import { createAiGatewayClient } from "../security/aiGatewayClient.js";
@@ -458,6 +463,31 @@ const EngineRegistry = {
458
463
  import("../engine/domain/applicationConfig.js"),
459
464
  };
460
465
 
466
+ /* Doc 1 step 3 — what a Phase B turn needs: the draft store, the gap list, a draft opener that applies the
467
+ draft store's own rules (Layer 3, owned by Control Center) for the VERIFIED caller, one transaction, and
468
+ the publisher's DDL preview. */
469
+ async function phaseBDeps(argv, headers) {
470
+ const exec = (sql) => execPostgres(sql, argv["dbindex"]);
471
+ const execStatements = (statements) => execPostgresStatements(statements, argv["dbindex"]);
472
+ const draftRepo = createPgDraftRepo({ exec, execStatements });
473
+ const principal = principalForCliScript(headers, { mode: argv["principalMode"] || "observe" });
474
+ const { buildDomainDdlPreview } = await import("../engine/domain/appConfig.js");
475
+ return {
476
+ draftRepo,
477
+ gapRepo: createGapRepo({ exec }),
478
+ traceRepo: createTraceRepo({ exec }),
479
+ openDraft: (kind, name) => runDraftAction({ repo: draftRepo, principal, isPostgres: true, action: "open", args: { kind, name } }),
480
+ execStatements,
481
+ ddlWarnings: (content) => buildDomainDdlPreview(JSON.stringify(content), argv["dbindex"]).warnings ?? [],
482
+ };
483
+ }
484
+
485
+ /* Doc 1 step 4d — what a derive turn reads: the configs as they are now (the OPEN draft, else published). */
486
+ function deriveDeps(argv) {
487
+ const draftRepo = createPgDraftRepo({ exec: (sql) => execPostgres(sql, argv["dbindex"]) });
488
+ return { draftRepo, readConfig: createConfigReader(draftRepo) };
489
+ }
490
+
461
491
  const handleCliCommand = async (data, cb, argv) => {
462
492
  try {
463
493
  const command = data.command.trim().toLowerCase();
@@ -660,6 +690,13 @@ const handleCliCommand = async (data, cb, argv) => {
660
690
  case "ccai": {
661
691
  const aiResult = await runAiAction({
662
692
  repo: createTurnRepo({ exec: (sql) => execPostgres(sql, argv["dbindex"]) }),
693
+ /* Doc 1 step 2: an interview turn commits the process map. Step 4d: a derive turn commits it WITH its
694
+ trace links, in one transaction. */
695
+ mapRepo: createMapRepo({ exec: (sql) => execPostgres(sql, argv["dbindex"]), execStatements: (statements) => execPostgresStatements(statements, argv["dbindex"]) }),
696
+ /* Doc 1 step 4: an interview turn never removes what config implements. */
697
+ traceRepo: createTraceRepo({ exec: (sql) => execPostgres(sql, argv["dbindex"]) }),
698
+ /* Doc 1 step 3: a Phase B turn writes drafts, gaps and trace links in ONE transaction. */
699
+ config: ["generate", "refine"].includes(data.action) ? await phaseBDeps(argv, data.headers) : data.action === "derive" ? deriveDeps(argv) : undefined,
663
700
  gateway: createAiGatewayClient({ baseUrl: argv["aiGateway"] || argv["server"], subdomain: argv["subdomain"] }),
664
701
  principal: principalForCliScript(data.headers, { mode: argv["principalMode"] || "observe" }),
665
702
  isPostgres: isPostgresIndex(argv["dbindex"]),
@@ -698,6 +735,31 @@ const handleCliCommand = async (data, cb, argv) => {
698
735
  });
699
736
  break;
700
737
  }
738
+ /*
739
+ * Doc 1 step 1 — the Business Process Map behind Process Studio (engine/cc/mapStore.js):
740
+ * get / versions / save. A save is a new SYS$CC_PROCESS_MAP version, merged per element.
741
+ */
742
+ case "ccmap": {
743
+ const mapResult = await runMapAction({
744
+ repo: createMapRepo({ exec: (sql) => execPostgres(sql, argv["dbindex"]) }),
745
+ gapRepo: createGapRepo({ exec: (sql) => execPostgres(sql, argv["dbindex"]) }),
746
+ /* Doc 1 step 4: the trace panel — the links, the turns that told the story, the config they point at. */
747
+ traceRepo: createTraceRepo({ exec: (sql) => execPostgres(sql, argv["dbindex"]) }),
748
+ turnRepo: createTurnRepo({ exec: (sql) => execPostgres(sql, argv["dbindex"]) }),
749
+ readConfig: createConfigReader(createPgDraftRepo({ exec: (sql) => execPostgres(sql, argv["dbindex"]) })),
750
+ principal: principalForCliScript(data.headers, { mode: argv["principalMode"] || "observe" }),
751
+ isPostgres: isPostgresIndex(argv["dbindex"]),
752
+ action: data.action,
753
+ args: data.args,
754
+ });
755
+ cb(null, {
756
+ success: mapResult.ok,
757
+ ...(mapResult.code ? { code: mapResult.code } : {}),
758
+ problems: mapResult.problems ?? [],
759
+ data: mapResult.data ?? null,
760
+ });
761
+ break;
762
+ }
701
763
  case "runextcliscript": {
702
764
  // External-access op invoker — enforces the capability boundary (dispatch.js),
703
765
  // unlike raw `runcliscript`. Safe to expose: no valid token -> UNAUTHENTICATED.
@@ -218,14 +218,14 @@ export async function initiate(payload) {
218
218
  current_state_code: def.initial_state_code,
219
219
  status: "ACTIVE",
220
220
  version_no: def.version,
221
- created_by: userId || null,
221
+ created_by: userId ?? null,
222
222
  SYS$BPM_STATE_HISTORY: [
223
223
  {
224
224
  from_state_code: null,
225
225
  to_state_code: def.initial_state_code,
226
226
  trigger_type: "SYSTEM",
227
227
  actor_type: "USER",
228
- actor_id: userId || null,
228
+ actor_id: userId ?? null,
229
229
  request_id: `START_${entityType}_${entityId}_${new Date().getTime()}`,
230
230
  },
231
231
  ],
@@ -773,7 +773,7 @@ export async function terminate(payload) {
773
773
  to_state_code: currentStateCode,
774
774
  trigger_type: "USER_EVENT",
775
775
  actor_type: "USER",
776
- actor_id: userId || null,
776
+ actor_id: userId ?? null,
777
777
  request_id: `TERMINATE_${instanceId}_${new Date().getTime()}`,
778
778
  },
779
779
  };
@@ -16,12 +16,43 @@
16
16
  * usage this company's month — limit and spend — from the gateway.
17
17
  * settle the AI Assist panel reports what it did with a turn's tool calls (COMMITTED / ROLLED_BACK).
18
18
  *
19
+ * interview Doc 1 step 2 — a Phase A turn: the CLI runs the whole model <-> map-tools loop and commits the
20
+ * map (phaseA.js). Answers AT ONCE with {turnId}; the user is told the outcome when it ends.
21
+ * session the caller's turns of one interview session, oldest first — the chat to show.
22
+ * generate Doc 1 step 3 — Phase B, the initial generation of ONE ready function into Layer 3 drafts of the
23
+ * domains the implementor names (phaseB.js). Refused unless the function is ready (§5.6).
24
+ * refine a Phase B refinement turn on the same drafts.
25
+ * derive Doc 1 step 4d — reverse-engineering (Doc 1 §5.7): the map derived from Layer 3 configs ({configs}),
26
+ * or — scoped — the implementor's "config is right" for selected elements ({elementIds}, §7.4 item 4):
27
+ * they are updated from the configs they link to (derive.js). A phase A turn of kind "derive".
28
+ * translate Doc 1 step 4c — a printout's texts translated (§4.8: for the printout only, never written back):
29
+ * collected HERE from the map version (mapPrint.collectStrings), translated in chunks (translate.js);
30
+ * the same printout (version, scope, language) is answered from the caller's earlier turn.
31
+ *
19
32
  * Administrators only (ruling 2026-10-01): every call spends the company's AI budget.
20
33
  */
21
- import { requirePrincipal } from "../orm/principalGuards.js";
34
+ import { ccGate } from "./ccGate.js";
35
+ import { buildHistory, runInterviewTurn } from "./phaseA.js";
36
+ import { readiness, tenKCompleteness } from "./processMap.js";
37
+ import { runConfigTurn } from "./phaseB.js";
38
+ import { runDeriveTurn } from "./derive.js";
39
+ import { runTranslateTurn } from "./translate.js";
40
+ import { collectStrings } from "./mapPrint.js";
41
+ import { domainOfEntity, layerOf, tryParse } from "./published.js";
42
+
43
+ const DOMAIN_NAME = /^[A-Za-z][A-Za-z0-9_-]{0,99}$/;
44
+ const MAX_DOMAINS = 5;
45
+ /* Doc 1 step 4d: what one derive turn reads. */
46
+ const MAX_DERIVE_CONFIGS = 10;
47
+ /* Turns that run many rounds (a normal one ends in a few minutes). */
48
+ const LONG_TURNS = ["interview", "generate", "refine", "derive", "translate"];
22
49
 
23
50
  /* The gateway gives a job 5 minutes before calling it lost; a turn gets a little longer. */
24
51
  export const AI_TURN_LOST_AFTER_MS = 6 * 60 * 1000;
52
+ /* An interview turn runs up to 12 rounds of at most 4 minutes each, though a normal one ends in a few minutes. */
53
+ export const INTERVIEW_TURN_LOST_AFTER_MS = 20 * 60 * 1000;
54
+ const MAX_PROMPT = 20000;
55
+ const MAX_SELECTED = 50;
25
56
  const MAX_TOKENS_CAP = 32000;
26
57
 
27
58
  const refuse = (message, code, data) => ({ ok: false, code, problems: [message], ...(data !== undefined ? { data } : {}) });
@@ -136,7 +167,8 @@ const actions = {
136
167
  /* Another user's turn is indistinguishable from no turn. */
137
168
  if (!turn || turn.userId !== userId) return refuse("There is no such AI turn.", "AI_TURN_NOT_FOUND");
138
169
  const startedAt = turn.startedAt ? new Date(turn.startedAt).getTime() : now();
139
- if (turn.outcome === "RUNNING" && !turn.toolCalls && now() - startedAt > AI_TURN_LOST_AFTER_MS) {
170
+ const lostAfter = LONG_TURNS.includes(turn.context?.kind) ? INTERVIEW_TURN_LOST_AFTER_MS : AI_TURN_LOST_AFTER_MS;
171
+ if (turn.outcome === "RUNNING" && !turn.toolCalls && now() - startedAt > lostAfter) {
140
172
  return done((await repo.failTurn(turn.id, "AI_TURN_LOST")) ?? (await repo.getTurn(turn.id)));
141
173
  }
142
174
  return done(turn);
@@ -144,6 +176,137 @@ const actions = {
144
176
 
145
177
  usage: async ({ gateway }) => done(await gateway.usage()),
146
178
 
179
+ interview: async ({ repo, mapRepo, traceRepo, gateway, userId, args, notify, schedule, now, logger }) => {
180
+ const { sessionId, functionId = null, prompt, selectedIds = [] } = args;
181
+ if (typeof sessionId !== "string" || !/^[A-Za-z0-9_.:-]{1,100}$/.test(sessionId)) return refuse("sessionId is required (letters, digits, . _ : -, at most 100).", "AI_BAD_REQUEST");
182
+ if (typeof prompt !== "string" || !prompt.trim() || prompt.length > MAX_PROMPT) return refuse(`Write a message (at most ${MAX_PROMPT} characters).`, "AI_BAD_REQUEST");
183
+ if (functionId !== null && typeof functionId !== "string") return refuse("functionId is a FUNCTION element id.", "AI_BAD_REQUEST");
184
+ if (!Array.isArray(selectedIds) || selectedIds.length > MAX_SELECTED || !selectedIds.every((id) => typeof id === "string")) {
185
+ return refuse(`selectedIds is a list of at most ${MAX_SELECTED} element ids.`, "AI_BAD_REQUEST");
186
+ }
187
+ /* Doc 1 §5.3 (ruling 2026-10-03): no function goes to 5,000 ft before the 10,000 ft map is complete —
188
+ unless the implementor chooses to go on anyway (override). */
189
+ if (functionId && args.override !== true) {
190
+ const { complete, missing, open } = tenKCompleteness(((await mapRepo.latest()) ?? { map: { elements: {} } }).map);
191
+ if (!complete) {
192
+ return refuse(
193
+ `The 10,000 ft map is not complete yet (missing: ${missing.map((m) => m.type).join(", ") || "—"}${open.length ? `; open: ${open.join(", ")}` : ""}). Complete it first, or choose to detail this function anyway.`,
194
+ "AI_TEN_K_INCOMPLETE",
195
+ { missing, open },
196
+ );
197
+ }
198
+ }
199
+ /* The conversation so far, rebuilt here — never taken from the caller (Doc 1 §2.4: the map is the memory). */
200
+ const history = buildHistory(await repo.listTurns({ userId, sessionId, limit: 20 }), now());
201
+ const turn = await repo.insertTurn({ sessionId, phase: "A", functionId, userId, prompt: prompt.trim(), context: { kind: "interview" } });
202
+
203
+ schedule(async () => {
204
+ const outcome = await runInterviewTurn({ turnRepo: repo, mapRepo, traceRepo, gateway, userId, turnId: turn.id, functionId, prompt: prompt.trim(), selectedIds, history, logger });
205
+ try {
206
+ await notify(userId, `CC_AI_TURN_${turn.id}`, { turnId: turn.id, outcome });
207
+ } catch (error) {
208
+ logger?.error?.(`[ccai] could not tell user ${userId} turn ${turn.id} ended: ${error?.message ?? error}`);
209
+ }
210
+ });
211
+ return done({ turnId: turn.id });
212
+ },
213
+
214
+ translate: async ({ repo, mapRepo, gateway, userId, args, notify, schedule, logger }) => {
215
+ const { sessionId, version = null, functionIds = [], to } = args;
216
+ if (typeof sessionId !== "string" || !/^[A-Za-z0-9_.:-]{1,100}$/.test(sessionId)) return refuse("sessionId is required (letters, digits, . _ : -, at most 100).", "AI_BAD_REQUEST");
217
+ if (!["en", "id"].includes(to)) return refuse('to is "en" (English) or "id" (Indonesian).', "AI_BAD_REQUEST");
218
+ if (!Array.isArray(functionIds) || functionIds.length > MAX_SELECTED || !functionIds.every((id) => typeof id === "string")) return refuse("functionIds is a list of FUNCTION element ids.", "AI_BAD_REQUEST");
219
+ const row = version === null ? await mapRepo.latest() : await mapRepo.get(version);
220
+ if (!row) return refuse(`Map version ${version} does not exist.`, "AI_BAD_REQUEST");
221
+ const scope = [...new Set(functionIds)].sort();
222
+ const cacheKey = `${row.version}|${to}|${scope.join(",")}`;
223
+ /* A map version never changes: the same printout in the same language is translated once. */
224
+ const cached = await repo.findTranslation?.(cacheKey, userId);
225
+ if (cached) return done({ turnId: cached, cached: true });
226
+ const context = { kind: "translate", cacheKey, version: row.version, functionIds: scope, to };
227
+ const turn = await repo.insertTurn({ sessionId, phase: "A", functionId: null, userId, prompt: `Translate the printout of map version ${row.version} into ${to === "en" ? "English" : "Indonesian"}.`, context });
228
+ const strings = collectStrings(row.map, scope);
229
+ schedule(async () => {
230
+ const outcome = await runTranslateTurn({ turnRepo: repo, gateway, turnId: turn.id, strings, to, context, logger });
231
+ try {
232
+ await notify(userId, `CC_AI_TURN_${turn.id}`, { turnId: turn.id, outcome });
233
+ } catch (error) {
234
+ logger?.error?.(`[ccai] could not tell user ${userId} turn ${turn.id} ended: ${error?.message ?? error}`);
235
+ }
236
+ });
237
+ return done({ turnId: turn.id, cached: false });
238
+ },
239
+
240
+ generate: (ctx) => configTurn(ctx, "generate"),
241
+ refine: (ctx) => configTurn(ctx, "refine"),
242
+
243
+ derive: async ({ repo, mapRepo, traceRepo, config, gateway, userId, args, notify, schedule, now, logger }) => {
244
+ const { sessionId, configs = null, elementIds = null, prompt = null } = args;
245
+ if (typeof sessionId !== "string" || !/^[A-Za-z0-9_.:-]{1,100}$/.test(sessionId)) return refuse("sessionId is required (letters, digits, . _ : -, at most 100).", "AI_BAD_REQUEST");
246
+ if ((configs === null) === (elementIds === null)) return refuse("Name the configs to derive the map from, or the elements whose configuration is right — one of the two.", "AI_BAD_REQUEST");
247
+ if (prompt !== null && (typeof prompt !== "string" || prompt.length > MAX_PROMPT)) return refuse(`The message is at most ${MAX_PROMPT} characters.`, "AI_BAD_REQUEST");
248
+ const latest = (await mapRepo.latest()) ?? { map: { elements: {} } };
249
+
250
+ let wanted;
251
+ if (configs !== null) {
252
+ const ok = Array.isArray(configs) && configs.length > 0 && configs.length <= MAX_DERIVE_CONFIGS && configs.every((c) => isPlainObject(c) && ["DOMAIN", "WORKFLOW"].includes(c.kind) && typeof c.name === "string" && c.name);
253
+ if (!ok) return refuse(`configs is a list of 1 to ${MAX_DERIVE_CONFIGS} {kind: DOMAIN | WORKFLOW, name}.`, "AI_BAD_REQUEST");
254
+ wanted = configs.map(({ kind, name }) => ({ kind, name }));
255
+ } else {
256
+ const ok = Array.isArray(elementIds) && elementIds.length > 0 && elementIds.length <= MAX_SELECTED && elementIds.every((id) => typeof id === "string" && latest.map.elements?.[id]);
257
+ if (!ok) return refuse(`elementIds lists 1 to ${MAX_SELECTED} elements of the map.`, "AI_BAD_REQUEST");
258
+ /* "Config is right": what the elements link to is what they are updated from. */
259
+ wanted = [];
260
+ for (const link of (await Promise.all(elementIds.map((id) => traceRepo.forElement(id)))).flat()) {
261
+ if (!wanted.some((w) => w.kind === link.configKind && w.name === link.configName)) wanted.push({ kind: link.configKind, name: link.configName });
262
+ }
263
+ if (!wanted.length) return refuse("No configuration implements these elements yet — there is nothing to update them from.", "AI_NOTHING_LINKED");
264
+ }
265
+ const { problems, configs: read } = await deriveConfigs(wanted, config);
266
+ if (problems.length) return refuse(problems.join(" "), "AI_CONFIG_NOT_DERIVABLE", { problems });
267
+
268
+ const scoped = elementIds !== null;
269
+ const said =
270
+ prompt?.trim() ||
271
+ (scoped
272
+ ? `The configuration is right for ${elementIds.map((id) => `"${latest.map.elements[id].name}" (${id})`).join(", ")}: update ${elementIds.length === 1 ? "it" : "them"} from it.`
273
+ : `Derive the map from ${wanted.map((c) => `${c.kind} ${c.name}`).join(", ")}.`);
274
+ const history = buildHistory(await repo.listTurns({ userId, sessionId, limit: 20 }), now());
275
+ const turn = await repo.insertTurn({ sessionId, phase: "A", functionId: null, userId, prompt: said, context: { kind: "derive" } });
276
+ schedule(async () => {
277
+ const outcome = await runDeriveTurn({ turnRepo: repo, mapRepo, traceRepo, gateway, userId, turnId: turn.id, configs: read, selectedIds: elementIds ?? [], scoped, prompt: said, history, logger });
278
+ try {
279
+ await notify(userId, `CC_AI_TURN_${turn.id}`, { turnId: turn.id, outcome });
280
+ } catch (error) {
281
+ logger?.error?.(`[ccai] could not tell user ${userId} turn ${turn.id} ended: ${error?.message ?? error}`);
282
+ }
283
+ });
284
+ return done({ turnId: turn.id });
285
+ },
286
+
287
+ session: async ({ repo, userId, args }) => {
288
+ const turns = await repo.listTurns({ userId, sessionId: String(args.sessionId ?? ""), limit: 50 });
289
+ return done(
290
+ [...turns].reverse().map((t) => ({
291
+ id: t.id,
292
+ prompt: t.prompt,
293
+ modelText: t.modelText,
294
+ outcome: t.outcome,
295
+ error: t.error ?? null,
296
+ costUsd: t.costUsd ?? null,
297
+ startedAt: t.startedAt ?? null,
298
+ finishedAt: t.finishedAt ?? null,
299
+ mapVersion: t.context?.result?.mapVersion ?? null,
300
+ changed: t.context?.result?.changed ?? null,
301
+ /* Doc 1 step 3 — a Phase B turn's function summary (§6.3 item 4). */
302
+ kind: t.context?.kind ?? null,
303
+ drafts: t.context?.result?.drafts ?? null,
304
+ gaps: t.context?.result?.gaps ?? null,
305
+ unimplemented: t.context?.result?.unimplemented ?? null,
306
+ })),
307
+ );
308
+ },
309
+
147
310
  settle: async ({ repo, userId, args }) => {
148
311
  const settled = await repo.settleTurn(Number(args.turnId), userId, args.outcome);
149
312
  return settled ? done(settled) : refuse("Only your own turn that is waiting on its tool calls can be settled.", "AI_TURN_NOT_SETTLEABLE");
@@ -152,16 +315,80 @@ const actions = {
152
315
 
153
316
  export const AI_ACTIONS = Object.freeze(Object.keys(actions));
154
317
 
155
- export const runAiAction = async ({ repo, gateway, principal, isPostgres, action, args = {}, notify, schedule = (fn) => setImmediate(fn), now = Date.now, logger }) => {
156
- if (!isPostgres) {
157
- throw new Error("Control Center AI needs a PostgreSQL tenant; this database index is not one.");
318
+ /* A workflow is Layer 3 when the domain it belongs to is: its own `domain` key, else the published domain of its
319
+ entity (published.js domainOfEntity — the draft store's rule). */
320
+ const workflowLayer = async (content, { readConfig, draftRepo }) => {
321
+ if (content?.domain) return layerOf((await readConfig("DOMAIN", content.domain)).content);
322
+ const published = ((await draftRepo?.publishedDomains?.()) ?? []).map((r) => ({ name: r.name, content: tryParse("DOMAIN", r.text) })).filter((d) => d.content);
323
+ const owner = domainOfEntity(content?.entityType, published);
324
+ return owner ? layerOf(owner.content) : null;
325
+ };
326
+
327
+ /* Doc 1 step 4d — what a derive turn reads: each config as it is now (the OPEN draft, else what is published),
328
+ checked Layer 3 HERE — never trusted to the picker — and the application config, for its menus. */
329
+ const deriveConfigs = async (wanted, config) => {
330
+ const problems = [];
331
+ const read = [];
332
+ for (const { kind, name } of wanted) {
333
+ const { content } = await config.readConfig(kind, name);
334
+ if (!content) {
335
+ problems.push(`${kind} ${name} is neither an open draft nor published.`);
336
+ continue;
337
+ }
338
+ if (kind !== "APPLICATION") {
339
+ const layer = kind === "DOMAIN" ? layerOf(content) : await workflowLayer(content, config);
340
+ if (layer !== "3") {
341
+ problems.push(`${kind} ${name} is not Layer 3 configuration (layer ${layer ?? "unknown"}): only Layer 3 is derived into the map.`);
342
+ continue;
343
+ }
344
+ }
345
+ read.push({ kind, name, content });
158
346
  }
159
- const userId = requirePrincipal(principal, "Control Center AI requires a verified principal. Sign in again.");
160
- /* ⚠️ Authority first, before anything is read or spent. */
161
- if (!(await repo.isAdministrator(userId))) {
162
- throw new Error(`user ${userId} is not an administrator; only administrators may use Control Center AI.`);
347
+ if (!read.some((c) => c.kind === "APPLICATION")) {
348
+ const app = (await config.readConfig("APPLICATION", "APPLICATION_CONFIG")).content;
349
+ if (app) read.push({ kind: "APPLICATION", name: "APPLICATION_CONFIG", content: app });
350
+ }
351
+ return { problems, configs: read };
352
+ };
353
+
354
+ /* Doc 1 step 3 — a Phase B turn (generate / refine). */
355
+ async function configTurn({ repo, mapRepo, config, gateway, userId, args, notify, schedule, logger }, mode) {
356
+ const { sessionId, functionId, domains, prompt = null } = args;
357
+ if (typeof sessionId !== "string" || !/^[A-Za-z0-9_.:-]{1,100}$/.test(sessionId)) return refuse("sessionId is required (letters, digits, . _ : -, at most 100).", "AI_BAD_REQUEST");
358
+ if (!Array.isArray(domains) || domains.length === 0 || domains.length > MAX_DOMAINS || !domains.every((d) => typeof d === "string" && DOMAIN_NAME.test(d))) {
359
+ return refuse(`Name 1 to ${MAX_DOMAINS} Layer 3 domains to write (letters, digits, _ and -).`, "AI_BAD_REQUEST");
163
360
  }
361
+ if (mode === "refine" && (typeof prompt !== "string" || !prompt.trim() || prompt.length > MAX_PROMPT)) return refuse("Write what to change.", "AI_BAD_REQUEST");
362
+ const latest = (await mapRepo.latest()) ?? { map: { elements: {} } };
363
+ if (latest.map.elements?.[functionId]?.type !== "FUNCTION") return refuse(`${JSON.stringify(functionId)} is not a function of the map.`, "AI_BAD_REQUEST");
364
+ if (mode === "generate") {
365
+ /* Doc 1 §5.6: readiness is a hard gate — no override. */
366
+ const ready = readiness(latest.map, functionId);
367
+ if (!ready.ready) return refuse("The function is not ready for build yet.", "AI_NOT_READY", { blocking: ready.blocking });
368
+ }
369
+ /* A domain that is published must be editable here (Layer 3, owned by Control Center). */
370
+ for (const name of domains) {
371
+ if (!(await config.draftRepo.publishedDomain(name))) continue;
372
+ const opened = await config.openDraft("DOMAIN", name);
373
+ if (!opened?.ok) return refuse(`Domain ${name}: ${(opened?.problems ?? ["it cannot be edited in Control Center."]).join(" ")}`, "AI_DOMAIN_NOT_WRITABLE");
374
+ }
375
+ const history = mode === "refine" ? buildHistory(await repo.listTurns({ userId, sessionId, limit: 20 })) : [];
376
+ const turn = await repo.insertTurn({ sessionId, phase: "B", functionId, userId, prompt: prompt?.trim() ?? `generate ${functionId}`, context: { kind: mode } });
377
+ schedule(async () => {
378
+ const outcome = await runConfigTurn({ turnRepo: repo, mapRepo, ...config, gateway, userId, turnId: turn.id, functionId, domains, mode, prompt: prompt?.trim() ?? null, history, logger });
379
+ try {
380
+ await notify(userId, `CC_AI_TURN_${turn.id}`, { turnId: turn.id, outcome });
381
+ } catch (error) {
382
+ logger?.error?.(`[ccai] could not tell user ${userId} turn ${turn.id} ended: ${error?.message ?? error}`);
383
+ }
384
+ });
385
+ return done({ turnId: turn.id });
386
+ }
387
+
388
+ export const runAiAction = async ({ repo, mapRepo, traceRepo, config, gateway, principal, isPostgres, action, args = {}, notify, schedule = (fn) => setImmediate(fn), now = Date.now, logger }) => {
389
+ /* ⚠️ Authority first, before anything is read or spent. */
390
+ const userId = await ccGate({ repo, principal, isPostgres, feature: "Control Center AI" });
164
391
  const handler = actions[action];
165
392
  if (!handler) throw new Error(`Unknown ccai action "${action}". Use one of ${AI_ACTIONS.join(", ")}.`);
166
- return handler({ repo, gateway, userId, args: args ?? {}, notify, schedule, now, logger });
393
+ return handler({ repo, mapRepo, traceRepo, config, gateway, userId, args: args ?? {}, notify, schedule, now, logger });
167
394
  };
@@ -21,5 +21,7 @@ export function linkFor(type: ScreenType, entryPoint: string): (string | null)[]
21
21
  export function resolveDomainKey(ref: unknown, domains: Record<string, { source?: string; alias?: string }> | undefined): string | null;
22
22
  export function menuProblems(app: Record<string, any>, index: DomainIndex): MenuProblem[];
23
23
  export function menuUsages(app: Record<string, any>, index: DomainIndex): Record<string, number>;
24
+ export function menuKeyOf(node: unknown): string;
25
+ export function findMenuNode(menus: unknown, key: string): { node: Record<string, any>; path: string; domain: string | null } | null;
24
26
  export function entryPointsOf(domainContent: unknown): Record<string, ScreenType>;
25
27
  export function domainIndexOf(domains: Record<string, any> | undefined, contentOf: (name: string) => unknown): DomainIndex;
@@ -117,6 +117,20 @@ export function menuProblems(app, index) {
117
117
  return problems;
118
118
  }
119
119
 
120
+ /* Doc 1 step 3 — a menu node's key (Doc 7 §4.4, Supertail's defaultMenuKey): its `menuKey`, else its caption
121
+ upper-cased with `_` for spaces. */
122
+ export const menuKeyOf = (node) => node?.menuKey ?? String(node?.caption ?? "").trim().toUpperCase().replace(/\s+/g, "_");
123
+
124
+ /* Doc 1 step 4 — the node a key names (a trace link's `menu:<KEY>`), with its pointer and the domain it
125
+ inherits; null when no node has that key. */
126
+ export function findMenuNode(menus, key) {
127
+ let found = null;
128
+ walk(menus, (node, path, domain) => {
129
+ if (!found && menuKeyOf(node) === key) found = { node, path, domain: domain ?? null };
130
+ });
131
+ return found;
132
+ }
133
+
120
134
  /* How many menus use each domain and each domain's entry point (keys "domain" and "domain/entryPoint"). */
121
135
  export function menuUsages(app, index) {
122
136
  const out = {};
@@ -0,0 +1,15 @@
1
+ /*
2
+ * The one door every Control Center command goes through (ccdraft, ccai, ccmap): a PostgreSQL tenant, a
3
+ * VERIFIED principal (never an id from the request), and an administrator — checked before anything is
4
+ * read, so a refusal reveals nothing. These are refusals of the caller itself, so they are thrown.
5
+ */
6
+ import { requirePrincipal } from "../orm/principalGuards.js";
7
+
8
+ export const ccGate = async ({ repo, principal, isPostgres, feature }) => {
9
+ if (!isPostgres) throw new Error(`${feature} needs a PostgreSQL tenant; this database index is not one.`);
10
+ const userId = requirePrincipal(principal, `${feature} requires a verified principal. Sign in again.`);
11
+ if (!(await repo.isAdministrator(userId))) {
12
+ throw new Error(`user ${userId} is not an administrator; only administrators may use ${feature}.`);
13
+ }
14
+ return userId;
15
+ };