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 +5 -0
- package/bin/hubEvent.js +56 -53
- package/bin/tunnelTarget.js +60 -7
- package/engine/cc/aiTurns.js +167 -0
- package/engine/cc/turnRepo.js +109 -0
- package/engine/orm/registryClient.js +18 -6
- package/envs/env.dev.js +4 -5
- package/envs/env.js +9 -5
- package/package.json +2 -3
- package/readme.md +17 -1
- package/scheduler/configController.js +57 -98
- package/scheduler/timer.js +4 -3
- package/security/aiGatewayClient.js +9 -1
- package/config/databases.json +0 -20
- package/config/ext-oidc.json +0 -7
- package/config/secrets.json +0 -27
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 {
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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({
|
package/bin/tunnelTarget.js
CHANGED
|
@@ -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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
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
|
-
|
|
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({
|
|
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
|
-
|
|
50
|
-
|
|
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-
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
} else {
|
|
103
|
-
throw { code: 'ENOENT' };
|
|
104
|
-
}
|
|
59
|
+
return addUrlValue([doc])[0];
|
|
60
|
+
},
|
|
61
|
+
};
|
|
105
62
|
};
|
|
106
63
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
+
};
|
package/scheduler/timer.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
-
|
|
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 ?? {};
|
package/config/databases.json
DELETED
|
@@ -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
|
-
}
|
package/config/ext-oidc.json
DELETED
package/config/secrets.json
DELETED
|
@@ -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
|
-
}
|