biz-a-cli 2.3.80-15385 → 2.3.80-15390

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/bin/hub.js CHANGED
@@ -130,6 +130,11 @@ const argv = yargs(process.argv.slice(2))
130
130
  show its rules behave. */
131
131
  default: "observe",
132
132
  })
133
+ .options("aiGateway", {
134
+ describe:
135
+ "Doc 1 0b.6b: base URL of the central AI gateway (biz-a-server /ai) that ccai calls. Defaults to this hub's server (serverMode).",
136
+ type: "string",
137
+ })
133
138
  .options("services", {
134
139
  describe:
135
140
  "Comma-separated list of engine jobs this agent handles (e.g., 'BPM,EMAIL')",
package/bin/hubEvent.js CHANGED
@@ -28,6 +28,9 @@ 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 { runAiAction } from "../engine/cc/aiTurns.js";
32
+ import { createTurnRepo } from "../engine/cc/turnRepo.js";
33
+ import { createAiGatewayClient } from "../security/aiGatewayClient.js";
31
34
  import {
32
35
  readUserPreferences,
33
36
  writeUserPreferences,
@@ -47,7 +50,7 @@ import {
47
50
  execPostgresTransaction,
48
51
  isPostgresIndex,
49
52
  } from "../engine/domain/dialect.js";
50
- import { resolveTunnelTarget } from "./tunnelTarget.js";
53
+ import { pipeTunnel } from "./tunnelTarget.js";
51
54
  /*
52
55
  * Doc 4 §8 — ⚠️ `runAsPrincipal` VALIDATES SYNCHRONOUSLY, so a browser-supplied active tenant outside
53
56
  * the signed claim throws out of the CALL, not out of the promise it would have returned. On this
@@ -232,59 +235,34 @@ export const streamEvent = async (socket, argv) =>
232
235
  },
233
236
  });
234
237
 
235
- // let client = net.connect(argv['port'], argv['hostname']);
236
- /* Where this pipe ends. A tenant this CLI serves on PostgreSQL is looped back to the
237
- hub's own FINA-compatible endpoint; every other tenant goes to FINA unchanged.
238
- Without this, biz-a's login — which only ever travels this tunnel — could not be
239
- answered at all on a tenant with no FINA running. */
240
- const target = resolveTunnelTarget(argv, isPostgresIndex);
241
- let client = target.secure
242
- ? tls.connect({
243
- port: target.port,
244
- host: target.host,
245
- // Looping back to ourselves, the certificate is our own.
246
- rejectUnauthorized: !target.local,
247
- })
248
- : net.connect(target.port, target.host);
249
- client.on("connect", () => {
250
- // logger.info(`client connected to ${argv['hostname']}:${argv['port']}`)
251
- let s = ss.createStream();
252
-
253
- // s.pipe(client).pipe(s);
254
- s.pipe(client).pipe(addCLIAddressAsResponseHeader).pipe(s);
255
-
256
- // use pipeline for better performance, back pressure, memory management, error handling and clean up
257
- // pipeline(s, client, addCLIAddressAsResponseHeader, s, (err)=>{if (err) {logger.error(err)}}) // Not work well with heroku
258
-
259
- s.on("end", () => {
260
- client.destroy();
261
- });
262
-
263
- socket.once(clientId, () => {
264
- // hub server shall notify us to end pipeline as soon as possible
265
- client.end();
266
- });
267
-
268
- ss(socket).emit(clientId, s);
269
- });
270
-
271
- client.setTimeout(IDLE_SOCKET_TIMEOUT_MILLISECONDS);
272
- client.on("timeout", () => {
273
- // in case client not notify to end pipeline, then inactivity timeout will end it
274
- client.end();
275
- });
276
-
277
- client.on("error", (err) => {
278
- // handle connection refusal (create a stream and immediately close it)
279
- logger.error(
280
- `API Error : tunnel could not reach ${target.local ? "this CLI's own endpoint" : "the API"} at ` +
281
- `${target.host}:${target.port} — `,
282
- err,
283
- );
284
- let s = ss.createStream();
285
- ss(socket).emit(clientId, s);
286
- s.end();
238
+ /*
239
+ * Where this pipe ends is decided by the request's FIRST BYTES (bin/tunnelTarget.js): a tenant
240
+ * this CLI serves on PostgreSQL, and — since 2026-10-01 — a Firebird tenant's LOGIN, are looped
241
+ * back to the hub's own FINA-compatible endpoint; every other request goes to FINA unchanged.
242
+ * Without the loop-back a Firebird login never passed through the CLI, so it never received
243
+ * the signed principal (Doc 6 on Firebird).
244
+ */
245
+ const s = ss.createStream();
246
+ pipeTunnel({
247
+ stream: s,
248
+ argv,
249
+ isPostgresIndex,
250
+ connect: (target) =>
251
+ target.secure
252
+ ? tls.connect({
253
+ port: target.port,
254
+ host: target.host,
255
+ // Looping back to ourselves, the certificate is our own.
256
+ rejectUnauthorized: !target.local,
257
+ })
258
+ : net.connect(target.port, target.host),
259
+ transform: () => addCLIAddressAsResponseHeader,
260
+ idleMs: IDLE_SOCKET_TIMEOUT_MILLISECONDS,
261
+ // hub server shall notify us to end pipeline as soon as possible
262
+ onClient: (client) => socket.once(clientId, () => client.end()),
263
+ logger,
287
264
  });
265
+ ss(socket).emit(clientId, s);
288
266
  };
289
267
 
290
268
  // const cliReqCb = async (data, callback) => {
@@ -673,6 +651,31 @@ const handleCliCommand = async (data, cb, argv) => {
673
651
  * publish may). A refusal the user can act on is returned as `problems`, as companytheme
674
652
  * does; `code` carries CC_DRAFT_STALE for a save made from an old revision.
675
653
  */
654
+ /*
655
+ * Doc 1 0b.6b — an AI turn from Control Center, through the central gateway
656
+ * (engine/cc/aiTurns.js). `send` answers at once; the turn finishes in the background, is
657
+ * recorded in SYS$CC_TURN with its cost, and the VERIFIED user is told {turnId, outcome}.
658
+ * The CLI holds no AI key: the gateway client signs with this company's biza.ai key.
659
+ */
660
+ case "ccai": {
661
+ const aiResult = await runAiAction({
662
+ repo: createTurnRepo({ exec: (sql) => execPostgres(sql, argv["dbindex"]) }),
663
+ gateway: createAiGatewayClient({ baseUrl: argv["aiGateway"] || argv["server"], subdomain: argv["subdomain"] }),
664
+ principal: principalForCliScript(data.headers, { mode: argv["principalMode"] || "observe" }),
665
+ isPostgres: isPostgresIndex(argv["dbindex"]),
666
+ action: data.action,
667
+ args: data.args,
668
+ notify: (userId, eventName, payload) => ClientEmitter.emitUsers([userId], eventName, payload),
669
+ logger,
670
+ });
671
+ cb(null, {
672
+ success: aiResult.ok,
673
+ ...(aiResult.code ? { code: aiResult.code } : {}),
674
+ problems: aiResult.problems ?? [],
675
+ data: aiResult.data ?? null,
676
+ });
677
+ break;
678
+ }
676
679
  case "ccdraft": {
677
680
  const ccResult = await runDraftAction({
678
681
  repo: createPgDraftRepo({
@@ -11,8 +11,35 @@
11
11
  * database index, as it is everywhere else in this port.
12
12
  */
13
13
 
14
+ /*
15
+ * ⚠️ Doc 6 on Firebird (found live 2026-10-01): the LOGIN of a Firebird tenant went straight to FINA
16
+ * through this tunnel, so the CLI never saw FINA's answer and never added the signed principal — and
17
+ * every principal-guarded command (company theme, logo, user preferences) refused on Firebird. The login
18
+ * is now looped back to the CLI's own endpoint too (finaEndpoint.js forwards it to FINA and appends the
19
+ * principal); every other Firebird request still goes straight to FINA, byte for byte.
20
+ *
21
+ * Recognised by its REQUEST LINE only (method + path), never by anything in a body.
22
+ */
23
+ const LOGIN_PATH = /%22get_valid_user_id%22|"get_valid_user_id"/i;
24
+
25
+ export const isLoginRequest = (firstChunk) => {
26
+ if (!firstChunk || firstChunk.length === 0) return false;
27
+ const head = Buffer.from(firstChunk).subarray(0, 2048).toString("latin1");
28
+ const lineEnd = head.indexOf("\r\n");
29
+ const requestLine = lineEnd === -1 ? head : head.slice(0, lineEnd);
30
+ const [method, path] = requestLine.split(" ");
31
+ return method === "POST" && typeof path === "string" && LOGIN_PATH.test(path);
32
+ };
33
+
34
+ const ownEndpoint = (argv) => ({
35
+ host: "127.0.0.1", // ourselves — never the public interface
36
+ port: argv.serverport,
37
+ secure: argv.isHttps === true, // how WE listen; argv.secure describes FINA
38
+ local: true,
39
+ });
40
+
14
41
  /* Kept separate from hubEvent.js so it can be exercised without importing the socket machinery. */
15
- export const resolveTunnelTarget = (argv, isPostgresIndex) => {
42
+ export const resolveTunnelTarget = (argv, isPostgresIndex, firstChunk) => {
16
43
  const fina = {
17
44
  host: argv.hostname,
18
45
  port: argv.port,
@@ -20,6 +47,7 @@ export const resolveTunnelTarget = (argv, isPostgresIndex) => {
20
47
  local: false,
21
48
  };
22
49
 
50
+ if (isLoginRequest(firstChunk)) return ownEndpoint(argv);
23
51
  if (!argv.dbindex) return fina;
24
52
 
25
53
  let servedHere = false;
@@ -32,10 +60,35 @@ export const resolveTunnelTarget = (argv, isPostgresIndex) => {
32
60
  }
33
61
  if (!servedHere) return fina;
34
62
 
35
- return {
36
- host: "127.0.0.1", // ourselves — never the public interface
37
- port: argv.serverport,
38
- secure: argv.isHttps === true, // how WE listen; argv.secure describes FINA
39
- local: true,
40
- };
63
+ return ownEndpoint(argv);
64
+ };
65
+
66
+ /*
67
+ * One tunnelled request: biz-a's bytes arrive on `stream`; the connection is opened only once the first
68
+ * bytes say where it must go (resolveTunnelTarget), that first chunk is written first so nothing is
69
+ * lost, and the rest is piped. The answer flows back through `transform()` (the response-header
70
+ * injection). A connection that fails ends the stream, so biz-a is not left waiting.
71
+ */
72
+ export const pipeTunnel = ({ stream, argv, isPostgresIndex, connect, transform, idleMs, onClient, logger = console }) => {
73
+ stream.once("data", (first) => {
74
+ stream.pause();
75
+ const target = resolveTunnelTarget(argv, isPostgresIndex, first);
76
+ const client = connect(target);
77
+ onClient?.(client);
78
+ if (idleMs) client.setTimeout(idleMs);
79
+ client.on("timeout", () => client.end());
80
+ client.on("connect", () => {
81
+ client.write(first);
82
+ stream.pipe(client).pipe(transform()).pipe(stream);
83
+ stream.resume();
84
+ });
85
+ client.on("error", (err) => {
86
+ logger.error?.(
87
+ `API Error : tunnel could not reach ${target.local ? "this CLI's own endpoint" : "the API"} at ${target.host}:${target.port} — `,
88
+ err,
89
+ );
90
+ stream.end();
91
+ });
92
+ stream.on("end", () => client.destroy());
93
+ });
41
94
  };
@@ -0,0 +1,167 @@
1
+ /*
2
+ * Doc 1 step 0b.6b — `ccai`: an AI turn from Control Center, through the central AI gateway.
3
+ *
4
+ * CC → this CLI → the gateway (biz-a-server /ai; the only holder of the OpenRouter key, the company's
5
+ * limit and the usage ledger — 0b.6a) → OpenRouter. The CLI holds NO AI key: it signs with the
6
+ * company's own `biza.ai.<subdomain>` key (security/aiGatewayClient.js), which proves to the gateway
7
+ * which company is spending.
8
+ *
9
+ * send answers AT ONCE with {turnId, jobId} — a model call can take minutes, CC's command times out
10
+ * at 15 s and the hub relay at 28 s. The turn then finishes in the background: recorded in
11
+ * SYS$CC_TURN with its cost (Doc 1 §10), and the VERIFIED user is told {turnId, outcome} and
12
+ * nothing more — socket rooms come from unverified handshake values, so the answer itself is
13
+ * fetched with `get`, which checks the principal.
14
+ * get the caller's own turn. One still RUNNING long after it started belonged to a CLI that
15
+ * restarted mid-turn: it is reported, and stored, as lost.
16
+ * usage this company's month — limit and spend — from the gateway.
17
+ * settle the AI Assist panel reports what it did with a turn's tool calls (COMMITTED / ROLLED_BACK).
18
+ *
19
+ * Administrators only (ruling 2026-10-01): every call spends the company's AI budget.
20
+ */
21
+ import { requirePrincipal } from "../orm/principalGuards.js";
22
+
23
+ /* The gateway gives a job 5 minutes before calling it lost; a turn gets a little longer. */
24
+ export const AI_TURN_LOST_AFTER_MS = 6 * 60 * 1000;
25
+ const MAX_TOKENS_CAP = 32000;
26
+
27
+ const refuse = (message, code, data) => ({ ok: false, code, problems: [message], ...(data !== undefined ? { data } : {}) });
28
+ const done = (data) => ({ ok: true, data });
29
+
30
+ const isPlainObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
31
+
32
+ /* What the user asked this turn: the last user message (the audit's PROMPT column). */
33
+ const promptOf = (messages) => {
34
+ const last = [...messages].reverse().find((m) => m.role === "user");
35
+ if (!last) return null;
36
+ return typeof last.content === "string" ? last.content : JSON.stringify(last.content);
37
+ };
38
+
39
+ const badRequest = (args) => {
40
+ const { sessionId, phase, messages, tools, maxTokens } = args;
41
+ if (typeof sessionId !== "string" || !/^[A-Za-z0-9_.:-]{1,100}$/.test(sessionId)) return "sessionId is required (letters, digits, . _ : -, at most 100).";
42
+ if (phase !== "A" && phase !== "B") return 'phase must be "A" (story → map) or "B" (map → config).';
43
+ if (!Array.isArray(messages) || messages.length === 0) return "messages must be a non-empty list.";
44
+ if (!messages.every((m) => isPlainObject(m) && typeof m.role === "string")) return "every message needs a role.";
45
+ if (tools !== undefined && !Array.isArray(tools)) return "tools must be a list.";
46
+ if (maxTokens !== undefined && !(Number.isInteger(maxTokens) && maxTokens > 0 && maxTokens <= MAX_TOKENS_CAP)) {
47
+ return `maxTokens must be a whole number from 1 to ${MAX_TOKENS_CAP}.`;
48
+ }
49
+ return null;
50
+ };
51
+
52
+ /* The gateway's refusal, in words an implementor can act on. */
53
+ const describeGatewayRefusal = (error) => {
54
+ const d = error.details ?? {};
55
+ if (error.code === "AI_LIMIT_REACHED") {
56
+ return `This company has used its AI limit for ${d.period} ($${d.spentUsd} of $${d.limitUsd}). Ask Imamatek to raise it.`;
57
+ }
58
+ if (error.code === "UNAUTHORIZED") return "The AI gateway did not accept this CLI's key. Ask Imamatek to provision it again.";
59
+ return `The AI gateway refused the request (${error.code}).`;
60
+ };
61
+
62
+ const actions = {
63
+ send: async ({ repo, gateway, userId, args, notify, schedule, logger }) => {
64
+ const problem = badRequest(args);
65
+ if (problem) return refuse(problem, "AI_BAD_REQUEST");
66
+
67
+ const { sessionId, phase, functionId = null, messages, tools, tool_choice, maxTokens } = args;
68
+ const turn = await repo.insertTurn({
69
+ sessionId,
70
+ phase,
71
+ functionId,
72
+ userId,
73
+ prompt: promptOf(messages),
74
+ context: { messages, ...(tools ? { tools } : {}) },
75
+ });
76
+
77
+ const body = {
78
+ messages,
79
+ ...(tools ? { tools } : {}),
80
+ ...(tool_choice !== undefined ? { tool_choice } : {}),
81
+ ...(maxTokens !== undefined ? { max_tokens: maxTokens } : {}),
82
+ meta: { sessionId, phase, turnId: turn.id },
83
+ };
84
+
85
+ let job;
86
+ try {
87
+ /* ⚠️ The request id is the TURN: a resubmission of this turn is the same gateway job and
88
+ one charge, never a second call. */
89
+ job = await gateway.submit(body, { requestId: `cc-turn-${turn.id}` });
90
+ } catch (error) {
91
+ const code = error?.code && error?.status ? error.code : "AI_UNAVAILABLE";
92
+ await repo.failTurn(turn.id, code === "AI_UNAVAILABLE" ? String(error?.message ?? error) : code);
93
+ return code === "AI_UNAVAILABLE"
94
+ ? refuse(String(error?.message ?? error), code, {})
95
+ : refuse(describeGatewayRefusal(error), code, error.details ?? {});
96
+ }
97
+
98
+ /* ⚠️ After the answer. Never throws: a failure is recorded on the turn and the user is told. */
99
+ schedule(async () => {
100
+ let outcome = "FAILED";
101
+ try {
102
+ const result = await gateway.wait(job.jobId);
103
+ if (result?.status === "DONE") {
104
+ const toolCalls = result.message?.tool_calls?.length ? result.message.tool_calls : null;
105
+ /* A turn that ended with tool calls stays RUNNING until the panel settles it. */
106
+ outcome = toolCalls ? "RUNNING" : "TEXT_ONLY";
107
+ await repo.completeTurn(turn.id, {
108
+ modelText: result.message?.content ?? null,
109
+ toolCalls,
110
+ outcome,
111
+ model: result.model ?? null,
112
+ tokensIn: result.tokensIn ?? null,
113
+ tokensOut: result.tokensOut ?? null,
114
+ costUsd: result.costUsd ?? null,
115
+ gatewayRef: result.generationId ?? null,
116
+ });
117
+ } else {
118
+ await repo.failTurn(turn.id, result?.error ?? `the gateway ended the job as ${result?.status}`);
119
+ }
120
+ } catch (error) {
121
+ logger?.error?.(`[ccai] turn ${turn.id} failed: ${error?.message ?? error}`);
122
+ await repo.failTurn(turn.id, String(error?.message ?? error)).catch(() => {});
123
+ }
124
+ try {
125
+ await notify(userId, `CC_AI_TURN_${turn.id}`, { turnId: turn.id, outcome });
126
+ } catch (error) {
127
+ logger?.error?.(`[ccai] could not tell user ${userId} turn ${turn.id} ended: ${error?.message ?? error}`);
128
+ }
129
+ });
130
+
131
+ return done({ turnId: turn.id, jobId: job.jobId });
132
+ },
133
+
134
+ get: async ({ repo, userId, args, now }) => {
135
+ const turn = await repo.getTurn(Number(args.turnId));
136
+ /* Another user's turn is indistinguishable from no turn. */
137
+ if (!turn || turn.userId !== userId) return refuse("There is no such AI turn.", "AI_TURN_NOT_FOUND");
138
+ const startedAt = turn.startedAt ? new Date(turn.startedAt).getTime() : now();
139
+ if (turn.outcome === "RUNNING" && !turn.toolCalls && now() - startedAt > AI_TURN_LOST_AFTER_MS) {
140
+ return done((await repo.failTurn(turn.id, "AI_TURN_LOST")) ?? (await repo.getTurn(turn.id)));
141
+ }
142
+ return done(turn);
143
+ },
144
+
145
+ usage: async ({ gateway }) => done(await gateway.usage()),
146
+
147
+ settle: async ({ repo, userId, args }) => {
148
+ const settled = await repo.settleTurn(Number(args.turnId), userId, args.outcome);
149
+ return settled ? done(settled) : refuse("Only your own turn that is waiting on its tool calls can be settled.", "AI_TURN_NOT_SETTLEABLE");
150
+ },
151
+ };
152
+
153
+ export const AI_ACTIONS = Object.freeze(Object.keys(actions));
154
+
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.");
158
+ }
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.`);
163
+ }
164
+ const handler = actions[action];
165
+ 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 });
167
+ };
@@ -0,0 +1,109 @@
1
+ /*
2
+ * Doc 1 step 0b.6b — the PostgreSQL repository behind SYS$CC_TURN, the AI turn audit (Doc 1 §10).
3
+ *
4
+ * A turn is inserted RUNNING when it starts and completed ONCE when it ends: every completion is
5
+ * conditional on OUTCOME = 'RUNNING', so a late, repeated or racing completion cannot rewrite the
6
+ * record. There is no method that deletes — the table has no DELETE grant either
7
+ * (migrations/postgres/1790100000000__cc_doc1_revoke.sql).
8
+ */
9
+ import { quoteLiteral } from "../orm/dialect.js";
10
+ import { buildFindAdminForUserSql } from "../orm/userAdmin.js";
11
+
12
+ export const TURN_TABLE = "sys$cc_turn";
13
+ export const TURN_OUTCOMES = Object.freeze(["RUNNING", "COMMITTED", "ROLLED_BACK", "TEXT_ONLY", "FAILED"]);
14
+ const LIST_LIMIT = 100;
15
+
16
+ /* json, not jsonb: what was sent and what came back are kept as written. */
17
+ const jsonText = (value) => (value === null || value === undefined ? "NULL" : `${quoteLiteral(JSON.stringify(value))}::json`);
18
+ const text = (value) => (value === null || value === undefined ? "NULL" : quoteLiteral(String(value)));
19
+ const int = (value) => (Number.isFinite(Number(value)) && value !== null && value !== undefined ? String(Math.trunc(Number(value))) : "NULL");
20
+ const numeric = (value) => (Number.isFinite(Number(value)) && value !== null && value !== undefined ? String(Number(value)) : "NULL");
21
+ const num = (value) => (value === null || value === undefined ? null : Number(value));
22
+ const jsonValue = (value) => (typeof value === "string" ? JSON.parse(value) : (value ?? null));
23
+
24
+ const checkOutcome = (outcome) => {
25
+ if (!TURN_OUTCOMES.includes(outcome)) throw new Error(`outcome must be one of ${TURN_OUTCOMES.join(", ")}`);
26
+ return outcome;
27
+ };
28
+
29
+ /* pg lower-cases column names and hands bigint / numeric back as STRINGS. */
30
+ const toTurn = (row) =>
31
+ row
32
+ ? {
33
+ id: num(row.id),
34
+ sessionId: row.session_id,
35
+ phase: row.phase,
36
+ functionId: row.function_id ?? null,
37
+ userId: num(row.user_id),
38
+ prompt: row.prompt ?? null,
39
+ context: jsonValue(row.context),
40
+ modelText: row.model_text ?? null,
41
+ toolCalls: jsonValue(row.tool_calls),
42
+ outcome: row.outcome,
43
+ error: row.error ?? null,
44
+ model: row.model ?? null,
45
+ tokensIn: num(row.tokens_in),
46
+ tokensOut: num(row.tokens_out),
47
+ costUsd: num(row.cost_usd),
48
+ gatewayRef: row.gateway_ref ?? null,
49
+ startedAt: row.started_at ?? null,
50
+ finishedAt: row.finished_at ?? null,
51
+ }
52
+ : null;
53
+
54
+ const COLUMNS =
55
+ "id, session_id, phase, function_id, user_id, prompt, context, model_text, tool_calls, outcome, error, model, tokens_in, tokens_out, cost_usd, gateway_ref, started_at, finished_at";
56
+
57
+ export const createTurnRepo = ({ exec }) => {
58
+ const first = async (sql) => toTurn((await exec(sql))?.[0] ?? null);
59
+
60
+ return {
61
+ /* The same administrator check the draft store uses (SYS$ADMINISTRATOR). Read-only. */
62
+ isAdministrator: async (userId) => ((await exec(buildFindAdminForUserSql(userId))) ?? []).length > 0,
63
+
64
+ insertTurn: ({ sessionId, phase, functionId = null, userId, prompt, context }) =>
65
+ first(
66
+ `INSERT INTO ${TURN_TABLE} (session_id, phase, function_id, user_id, prompt, context) VALUES ` +
67
+ `(${text(sessionId)}, ${text(phase)}, ${text(functionId)}, ${int(userId)}, ${text(prompt)}, ${jsonText(context)}) ` +
68
+ `RETURNING ${COLUMNS}`,
69
+ ),
70
+
71
+ completeTurn: (id, { modelText = null, toolCalls = null, outcome, model = null, tokensIn = null, tokensOut = null, costUsd = null, gatewayRef = null }) =>
72
+ first(
73
+ `UPDATE ${TURN_TABLE} SET model_text = ${text(modelText)}, tool_calls = ${jsonText(toolCalls)}, ` +
74
+ `outcome = ${quoteLiteral(checkOutcome(outcome))}, model = ${text(model)}, tokens_in = ${int(tokensIn)}, ` +
75
+ `tokens_out = ${int(tokensOut)}, cost_usd = ${numeric(costUsd)}, gateway_ref = ${text(gatewayRef)}` +
76
+ // A turn that ended with tool calls stays open for `settle`; any other is finished now.
77
+ (outcome === "RUNNING" ? "" : ", finished_at = now()") +
78
+ ` WHERE id = ${int(id)} AND outcome = 'RUNNING' RETURNING ${COLUMNS}`,
79
+ ),
80
+
81
+ failTurn: (id, error) =>
82
+ first(
83
+ `UPDATE ${TURN_TABLE} SET outcome = 'FAILED', error = ${text(String(error ?? "").slice(0, 2000))}, finished_at = now() ` +
84
+ `WHERE id = ${int(id)} AND outcome = 'RUNNING' RETURNING ${COLUMNS}`,
85
+ ),
86
+
87
+ /* The AI Assist panel reports what it did with the tool calls: applied (COMMITTED) or not. */
88
+ settleTurn: async (id, userId, outcome) => {
89
+ if (!["COMMITTED", "ROLLED_BACK"].includes(outcome)) throw new Error("outcome must be COMMITTED or ROLLED_BACK");
90
+ return first(
91
+ `UPDATE ${TURN_TABLE} SET outcome = ${quoteLiteral(outcome)}, finished_at = now() ` +
92
+ `WHERE id = ${int(id)} AND user_id = ${int(userId)} AND outcome = 'RUNNING' AND tool_calls IS NOT NULL ` +
93
+ `RETURNING ${COLUMNS}`,
94
+ );
95
+ },
96
+
97
+ getTurn: (id) => first(`SELECT ${COLUMNS} FROM ${TURN_TABLE} WHERE id = ${int(id)}`),
98
+
99
+ listTurns: async ({ userId, sessionId = null, limit = 20 }) => {
100
+ const bounded = Math.max(1, Math.min(LIST_LIMIT, Number(limit) || 20));
101
+ const rows = await exec(
102
+ `SELECT ${COLUMNS} FROM ${TURN_TABLE} WHERE user_id = ${int(userId)}` +
103
+ (sessionId ? ` AND session_id = ${text(sessionId)}` : "") +
104
+ ` ORDER BY id DESC LIMIT ${bounded}`,
105
+ );
106
+ return (rows ?? []).map(toTurn);
107
+ },
108
+ };
109
+ };
@@ -1,8 +1,10 @@
1
1
  /*
2
- * The authenticated call to biz-a for this tenant's database coordinates.
2
+ * The authenticated call to biz-a for this tenant's configuration.
3
3
  *
4
4
  * The CLI must never read Mongo directly — biz-a owns the `configuration` registry and serves it
5
- * over HTTPS. This is the transport half; registry.js holds the caching policy.
5
+ * over HTTPS. This is the transport half; registry.js holds the caching policy, and
6
+ * scheduler/configController.js makes the same call with `scope: "scheduler"` to fetch what the
7
+ * timers need (it used to open MongoClient against the Atlas cluster itself).
6
8
  *
7
9
  * Authentication reuses the scheme biz-a already accepts from the CLI (see
8
10
  * biz-a/src/api/encryptController.js): a base64 JSON payload plus an RSA-PSS signature over it,
@@ -27,9 +29,17 @@ export const REGISTRY_PATH = "/config/resolveBySubdomain";
27
29
  or the `biza` command was launched from. */
28
30
  const CLI_PRIVATE_KEY = path.join(__dirname, "..", "..", "key", "cliPrivate.pem");
29
31
 
30
- export const buildSignedPayload = ({ subdomain, now }) =>
32
+ /* `scope` is OMITTED when absent rather than sent as undefined, so the default payload stays
33
+ byte-identical to what every deployed CLI already signs. biz-a reads the scope from this signed
34
+ payload, never from the request body, so a scope that never got signed would be ignored. */
35
+ export const buildSignedPayload = ({ subdomain, now, scope }) =>
31
36
  Buffer.from(
32
- JSON.stringify({ issuer: "CLI", subdomain: String(subdomain ?? ""), ts: now }),
37
+ JSON.stringify({
38
+ issuer: "CLI",
39
+ subdomain: String(subdomain ?? ""),
40
+ ...(scope ? { scope } : {}),
41
+ ts: now,
42
+ }),
33
43
  ).toString("base64");
34
44
 
35
45
  /* The default signer. Injected in tests so they need no key material. */
@@ -46,8 +56,10 @@ export const makeRegistryClient = ({
46
56
  signPayload = signWithCliKey,
47
57
  now = Date.now,
48
58
  }) => ({
49
- async resolve(subdomain) {
50
- const data = buildSignedPayload({ subdomain, now: now() });
59
+ /* `scope` is per call: the same client serves both the plain coordinate read (dialect.js's
60
+ primeRegistry) and the scheduler's wider read, which also needs methodClass and smtp. */
61
+ async resolve(subdomain, { scope } = {}) {
62
+ const data = buildSignedPayload({ subdomain, now: now(), scope });
51
63
  const res = await http.post(
52
64
  `${serverLink}${REGISTRY_PATH}`,
53
65
  { data, signature: signPayload(data) },
package/envs/env.dev.js CHANGED
@@ -1,12 +1,11 @@
1
+ /*
2
+ * PUBLIC values only — this file ships in the published npm package. See envs/env.js for why the Atlas
3
+ * connection strings that used to live here are gone.
4
+ */
1
5
  import { DEV_SERVER_URL, DEV_HUB_URL } from './hosts.js';
2
6
 
3
7
  export const envDev = {
4
8
  production: false,
5
- BIZA_MONGO_LINK: 'mongodb+srv://imm_biza:' +
6
- encodeURIComponent('imm@2023') + '@cluster0.z2yr8kp.mongodb.net/?retryWrites=true&w=majority',
7
- CDM_MONGO_LINK: 'mongodb+srv://imm_cdm:' +
8
- encodeURIComponent('imm@2019') + '@imm-cdm-dev.rf6wr.mongodb.net/?retryWrites=true&w=majority',
9
- COMPANY_REGISTER: 'companyregister-dev',
10
9
  BIZA_SERVER_LINK: DEV_SERVER_URL,
11
10
  BIZA_HUB_SERVER_LINK: DEV_HUB_URL
12
11
  // Retired values, kept in case biz-a.id (or the old heroku dyno) is used again:
package/envs/env.js CHANGED
@@ -1,12 +1,16 @@
1
+ /*
2
+ * PUBLIC values only. This file ships inside the published npm package (package.json `files` includes
3
+ * `envs/`), so nothing secret may live here.
4
+ *
5
+ * BIZA_MONGO_LINK, CDM_MONGO_LINK and COMPANY_REGISTER used to sit in this object with their Atlas
6
+ * passwords in plaintext. The CLI no longer talks to Mongo at all: biz-a serves each tenant's
7
+ * configuration over HTTPS and performs both database reads itself — see
8
+ * scheduler/configController.js and engine/orm/registryClient.js.
9
+ */
1
10
  import { SERVER_URL, HUB_URL } from './hosts.js';
2
11
 
3
12
  export const env = {
4
13
  production: true,
5
- BIZA_MONGO_LINK: 'mongodb+srv://imm_biza:' +
6
- encodeURIComponent('imm@2023') + '@cluster0.lezywmk.mongodb.net/?retryWrites=true&w=majority',
7
- CDM_MONGO_LINK: 'mongodb+srv://imm_cdm:' +
8
- encodeURIComponent('imm@2019') + '@imm-cdm.ohcqt.mongodb.net/?retryWrites=true&w=majority',
9
- COMPANY_REGISTER: 'companyregister',
10
14
  BIZA_SERVER_LINK: SERVER_URL,
11
15
  BIZA_HUB_SERVER_LINK: HUB_URL
12
16
  // Retired values, kept in case biz-a.id (or the old heroku dyno) is used again:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "biz-a-cli",
3
- "version": "2.3.80-15385",
3
+ "version": "2.3.80-15390",
4
4
  "description": "",
5
5
  "main": "bin/index.js",
6
6
  "type": "module",
@@ -10,7 +10,7 @@
10
10
  },
11
11
  "files": [
12
12
  "bin/**/*",
13
- "config/**/*",
13
+ "config/databases.example.json",
14
14
  "db/**/*",
15
15
  "engine/**/*",
16
16
  "envs/**/*",
@@ -50,7 +50,6 @@
50
50
  "cors": "^2.8.5",
51
51
  "dayjs": "^1.11.10",
52
52
  "express": "^4.18.3",
53
- "mongodb": "^6.5.0",
54
53
  "net": "^1.0.2",
55
54
  "nodemailer": "^6.9.12",
56
55
  "pg": "^8.23.0",
package/readme.md CHANGED
@@ -107,4 +107,20 @@
107
107
  hub --mode agent --dispatcherUrl http://localhost:3000 --services BPM,EMAIL --sp 3002
108
108
 
109
109
  > [!TIP]
110
- > When running multiple Agents on the same server, always define unique ports using --sp to avoid collisions.
110
+ > When running multiple Agents on the same server, always define unique ports using --sp to avoid collisions.
111
+
112
+
113
+ ## V. Watcher (scheduler)
114
+
115
+ watcher -c [company] -p [port, default: 3001]
116
+
117
+ Example :
118
+ watcher -c imamatek
119
+
120
+ `-c` / `--company` is the company name, exactly as before — nothing about the command line changed.
121
+
122
+ > [!NOTE]
123
+ > The CLI holds **no database credentials**. It asks the Biz-A server for this company's configuration
124
+ > over HTTPS, and the server performs the database reads — the company lookup and the configuration
125
+ > itself — so nothing secret needs to be installed alongside the package. An unknown company fails with
126
+ > `ENOENT`.
@@ -1,60 +1,32 @@
1
- import { MongoClient, ObjectId } from 'mongodb';
2
- import { envDev } from '../envs/env.dev.js';
3
- import { env } from '../envs/env.js';
4
- import { logger } from "../logger.js";
1
+ /*
2
+ * The scheduler's configuration read.
3
+ *
4
+ * This module used to open MongoClient TWICE at `watcher` startup — once against the CDM cluster to
5
+ * turn a company name into an ObjectId, once against the biz-a cluster for the configuration document
6
+ * and its SMTP row — using connection strings hardcoded in envs/env.js. That file ships inside the
7
+ * published npm package, so both Atlas passwords were public and one leak was every tenant's database.
8
+ *
9
+ * biz-a owns the `configuration` collection and already serves it over HTTPS to the rest of the CLI
10
+ * (engine/orm/registryClient.js), so both reads moved there and the scheduler asks for the finished
11
+ * config in ONE request. The CLI now holds no database credential at all, and `mongodb` is no longer
12
+ * even a dependency.
13
+ *
14
+ * WHAT DID NOT CHANGE: the returned `config` keeps exactly the shape the Mongo aggregate produced, so
15
+ * datalib.js, watcherController.js and mailController.js are untouched. Everything after this point —
16
+ * SYS$WATCHER, SYS$TIMER, SYS$HISTORY, the CLI scripts and their mail — was never in Mongo; it lives
17
+ * in the tenant's own database, reached over the FINA REST API or Postgres using the coordinates this
18
+ * read returns.
19
+ */
20
+ import { makeRegistryClient } from "../engine/orm/registryClient.js";
21
+ import { env } from "../envs/env.js";
5
22
 
6
- let BIZA_MONGO_LINK;
7
- let CDM_MONGO_LINK;
8
- let COMPANY_REGISTER;
9
-
10
- // if ((process.env.NODE_ENV == undefined) || (process.env.NODE_ENV == 'development')) {
11
- // BIZA_MONGO_LINK = envDev.BIZA_MONGO_LINK;
12
- // CDM_MONGO_LINK = envDev.CDM_MONGO_LINK;
13
- // COMPANY_REGISTER = envDev.COMPANY_REGISTER;
14
-
15
- // logger.info('BIZA_MONGO_LINK =>', BIZA_MONGO_LINK);
16
- // logger.info('CDM_MONGO_LINK =>', CDM_MONGO_LINK);
17
- // logger.info('COMPANY_REGISTER =>', COMPANY_REGISTER);
18
- // } else {
19
- BIZA_MONGO_LINK = env.BIZA_MONGO_LINK;
20
- CDM_MONGO_LINK = env.CDM_MONGO_LINK;
21
- COMPANY_REGISTER = env.COMPANY_REGISTER;
22
- // }
23
-
24
- async function fetchSelectedConfig(aggregateField) {
25
- const client = new MongoClient(BIZA_MONGO_LINK);
26
- try {
27
- await client.connect();
28
- const db = client.db('biz-a');
29
- const config = db.collection('configuration');
30
-
31
- return await config.aggregate(aggregateField).toArray();
32
- } catch (error) {
33
- logger.error(error);
34
- throw new Error(error);
35
- } finally {
36
- client.close()
37
- }
38
- }
39
-
40
- function getSelectedConfigAggregateField(companyObjectId) {
41
- let configAggregate = [];
42
- if (companyObjectId) {
43
- configAggregate.push({ "$match": { companyObjectId: new ObjectId(companyObjectId) } });
44
- };
45
- configAggregate.push({
46
- $lookup: {
47
- from: 'smtp',
48
- localField: '_id',
49
- foreignField: 'configObjectId',
50
- as: 'smtp'
51
- }
52
- });
53
- configAggregate.push({ $unwind: { path: "$smtp" } });
54
-
55
- return configAggregate;
56
- }
23
+ /* biz-a serves `methodClass` and `smtp` only under this scope, so the plain coordinate read that
24
+ `biza publish` and `migrate` perform never starts carrying an SMTP password it has no use for. It
25
+ travels inside the SIGNED payload, so it cannot be widened by a caller. */
26
+ export const SCHEDULER_SCOPE = "scheduler";
57
27
 
28
+ /* The scheduler reads `config.url`; biz-a stores it as API_URL. Unchanged from the Mongo-backed
29
+ version, array in and array out, because that is the shape its tests pin. */
58
30
  function addUrlValue(config) {
59
31
  if (!config[0].url) {
60
32
  config[0].url = config[0].API_URL
@@ -62,56 +34,43 @@ function addUrlValue(config) {
62
34
  return config;
63
35
  }
64
36
 
65
- async function getSelectedConfig(companyObjectId) {
66
- let config = await fetchSelectedConfig(getSelectedConfigAggregateField(companyObjectId));
67
- config = addUrlValue(config);
68
- return config[0];
69
- }
37
+ /* `client` is injected in tests so they need neither a network nor key material. `tenant` is passed
38
+ to biz-a verbatim: the server resolves a subdomain OR a CDM company name, so whatever
39
+ `watcher -c` was given keeps working. */
40
+ export const createConfigReader = ({ client, serverLink = env.BIZA_SERVER_LINK } = {}) => {
41
+ const registry = client ?? makeRegistryClient({ serverLink });
70
42
 
71
- function getCompanyObjectIdFindOneField(companyName) {
72
- return [
73
- { companyID: { $regex: new RegExp(`^${companyName}$`, 'i') } },
74
- {
75
- projection: {
76
- _id: 1,
77
- companyID: 1
78
- }
79
- }
80
- ]
81
- }
43
+ return {
44
+ async getSelectedConfig(tenant) {
45
+ if (!tenant) {
46
+ throw new Error("the scheduler needs a tenant (subdomain or company name)");
47
+ }
82
48
 
83
- async function fetchCompanyObjectId(findOneField) {
84
- const client = new MongoClient(CDM_MONGO_LINK);
85
- try {
86
- await client.connect();
87
- const db = client.db(COMPANY_REGISTER);
88
- const company = db.collection('companies');
49
+ const doc = await registry.resolve(tenant, { scope: SCHEDULER_SCOPE });
89
50
 
90
- return await company.findOne(findOneField[0], findOneField[1]);
91
- } catch (error) {
92
- logger.error(error);
93
- throw new Error(error);
94
- } finally {
95
- client.close()
96
- }
97
- }
51
+ /* biz-a answers an unknown tenant with 404, which resolve() deliberately reports as null
52
+ rather than throwing. Keep the ENOENT signal callers already branch on, and keep it
53
+ distinct from a transport failure — which propagates, so an outage is never mistaken
54
+ for "this tenant does not exist". */
55
+ if (!doc) {
56
+ throw { code: "ENOENT" };
57
+ }
98
58
 
99
- function validateCompanyObjectId(companyObjectId) {
100
- if (companyObjectId?._id) {
101
- return companyObjectId;
102
- } else {
103
- throw { code: 'ENOENT' };
104
- }
59
+ return addUrlValue([doc])[0];
60
+ },
61
+ };
105
62
  };
106
63
 
107
- async function getCompanyObjectId(companyName) {
108
- const companyObjectId = await fetchCompanyObjectId(getCompanyObjectIdFindOneField(companyName));
109
- return validateCompanyObjectId(companyObjectId);
110
- };
64
+ let defaultReader = null;
65
+
66
+ /* Built lazily, so importing this module stays side-effect-free — tests/config.test.js imports it for
67
+ addUrlValue alone. */
68
+ async function getSelectedConfig(tenant) {
69
+ defaultReader = defaultReader ?? createConfigReader();
70
+ return defaultReader.getSelectedConfig(tenant);
71
+ }
111
72
 
112
73
  export {
113
- BIZA_MONGO_LINK,
114
74
  getSelectedConfig,
115
- getCompanyObjectId,
116
75
  addUrlValue
117
- };
76
+ };
@@ -1,7 +1,7 @@
1
1
  import { checkSchedule } from "./datalib.js"
2
2
  import { getWatchers, getHistories } from "./watcherController.js";
3
3
  import { isItTime, loopTimer } from "./watcherlib.js"
4
- import { getCompanyObjectId, getSelectedConfig } from "../scheduler/configController.js";
4
+ import { getSelectedConfig } from "../scheduler/configController.js";
5
5
  import { historyRecordToJson, watcherRecordToJson, setUtcDate } from "./converter.js";
6
6
  import { logger } from "../logger.js";
7
7
 
@@ -66,9 +66,10 @@ export async function* setConstantInterval(interval, start) { //SCY BZ 4169
66
66
  }
67
67
  }
68
68
 
69
+ /* `companyName` reaches biz-a untouched: it resolves a subdomain or a CDM company name, so the
70
+ separate getCompanyObjectId round trip the CLI used to make against the CDM Atlas cluster is gone. */
69
71
  export const runScheduler = async (companyName) => {
70
- const company = await getCompanyObjectId(companyName);
71
- const config = await getSelectedConfig(company._id);
72
+ const config = await getSelectedConfig(companyName);
72
73
 
73
74
  Promise.all([getHistories(config), getWatchers(config)]).then(([historyResult, watcherResult]) => {
74
75
  const histories = historyRecordToJson(historyResult);
@@ -85,7 +85,15 @@ export const createAiGatewayClient = ({
85
85
  init.body = JSON.stringify(body);
86
86
  headers["content-type"] = "application/json";
87
87
  }
88
- const res = await fetchImpl(`${base}${path}`, init);
88
+ let res;
89
+ try {
90
+ res = await fetchImpl(`${base}${path}`, init);
91
+ } catch (error) {
92
+ /* Node's fetch says only "fetch failed"; the reason is in error.cause. Name both. */
93
+ const cause = error?.cause;
94
+ const why = cause ? `${cause.code ?? ""} ${cause.message ?? ""}`.trim() : String(error?.message ?? error);
95
+ throw Object.assign(new Error(`the AI gateway at ${base} could not be reached: ${why}`), { code: "AI_GATEWAY_UNREACHABLE" });
96
+ }
89
97
  const json = await res.json().catch(() => ({}));
90
98
  if (!res.ok) {
91
99
  const { error, ...details } = json ?? {};
@@ -1,20 +0,0 @@
1
- {
2
- "3": {
3
- "host": "127.0.0.1",
4
- "port": 5432,
5
- "database": "studioa",
6
- "user": "postgres",
7
- "password": "M45t3rK3y",
8
- "poolMax": 10,
9
- "ssl": false
10
- },
11
- "4": {
12
- "host": "127.0.0.1",
13
- "port": 5432,
14
- "database": "studiob",
15
- "user": "postgres",
16
- "password": "M45t3rK3y",
17
- "poolMax": 10,
18
- "ssl": false
19
- }
20
- }
@@ -1,7 +0,0 @@
1
- {
2
- "google": {
3
- "clientId": "19528327527-cda778568pel4uc7019bhee117p8t2ad.apps.googleusercontent.com",
4
- "clientSecret": "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",
5
- "redirectUri": "https://test.biz-a.id/auth/google/callback"
6
- }
7
- }
@@ -1,27 +0,0 @@
1
- {
2
- "supertail.ledger": {
3
- "currentKeyId": "st_ledger_v1",
4
- "keys": {
5
- "st_ledger_v1": "CdgBjUShgx7t9Wo/dfSOOJAHjHOD5KwAlRi6xwDMEfs="
6
- }
7
- },
8
- "supertail.session": "3jepiFkSV6HxlbCSWiGqYWpmfapYxLHeHY/YgxyFQ8Q=",
9
- "ext.capability": {
10
- "currentKeyId": "ext_v1",
11
- "keys": {
12
- "ext_v1": "f4PLkG2QLn6OFyv0IlqWhq3tlSGs2VAyaYDM4kDKLZ8="
13
- }
14
- },
15
- "internal.principal": {
16
- "currentKeyId": "ip_v1",
17
- "keys": {
18
- "ip_v1": "GsoRIpPG0D7c0q+AtmMNrtKpZ104Uv4/tfmLHtAtbIM="
19
- }
20
- },
21
- "biza.ai.studiob": {
22
- "currentKeyId": "ai_v1",
23
- "keys": {
24
- "ai_v1": "QxE6YiZdcjVRK1UbWGRMLuEgBKM3zFUte9euFldduZc="
25
- }
26
- }
27
- }