glm-coding-router 1.1.2 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +542 -426
- package/dist/bin/glm-review.js +28 -3
- package/dist/bin/glm-worker.js +30 -4
- package/dist/budget/estimator.js +218 -0
- package/dist/budget/manager.js +223 -0
- package/dist/cli.js +46 -3
- package/dist/commands/dashboard.js +348 -0
- package/dist/commands/doctor-auth.js +107 -0
- package/dist/commands/doctor-command.js +171 -41
- package/dist/commands/landing.js +47 -0
- package/dist/commands/runs.js +568 -0
- package/dist/commands/status.js +28 -15
- package/dist/commands/usage.js +34 -58
- package/dist/commands/watch.js +289 -0
- package/dist/core/config.js +61 -0
- package/dist/core/errors.js +24 -0
- package/dist/core/key-inspector.js +45 -0
- package/dist/core/paths.js +32 -0
- package/dist/core/process.js +83 -0
- package/dist/core/prompt.js +18 -5
- package/dist/core/routing-flags.js +59 -0
- package/dist/core/user-env.js +17 -7
- package/dist/core/zai-quota.js +148 -0
- package/dist/events/bus.js +64 -0
- package/dist/events/claude-adapter.js +416 -0
- package/dist/events/types.js +9 -0
- package/dist/handoff/bundle.js +203 -0
- package/dist/handoff/parent-handoff.js +48 -0
- package/dist/mcp/server.js +45 -1
- package/dist/routing/glm-routing.js +131 -0
- package/dist/runs/checkpoint.js +204 -0
- package/dist/runs/drain.js +165 -0
- package/dist/runs/heartbeat.js +45 -0
- package/dist/runs/registry.js +350 -0
- package/dist/runs/store.js +186 -0
- package/dist/runs/ulid.js +112 -0
- package/dist/runs/worker-run.js +672 -0
- package/dist/templates/agents-block.js +53 -44
- package/dist/templates/claude-block.js +56 -47
- package/dist/templates/glm-delegation-skill.js +76 -65
- package/dist/tui/command-ui.js +158 -0
- package/dist/tui/progress.js +338 -0
- package/dist/tui/render.js +144 -0
- package/package.json +1 -1
package/dist/core/process.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { Errors } from "./errors.js";
|
|
3
|
+
import { logger } from "./logging.js";
|
|
3
4
|
/**
|
|
4
5
|
* Spawn a child agent process (spec §18, §32, §39):
|
|
5
6
|
* - argument array, never a shell string
|
|
@@ -64,6 +65,88 @@ export function spawnAgentCapture(binPath, options) {
|
|
|
64
65
|
});
|
|
65
66
|
});
|
|
66
67
|
}
|
|
68
|
+
/**
|
|
69
|
+
* Like spawnAgentCapture, but delivers every complete stdout/stderr line the
|
|
70
|
+
* moment it arrives (v2 spec Phase D) instead of buffering the whole output
|
|
71
|
+
* until exit — that buffering is exactly what makes live progress impossible.
|
|
72
|
+
* Same no-shell argv rule and signal forwarding. A chunk boundary can fall
|
|
73
|
+
* inside a JSON object, so each stream carries its own partial-line buffer;
|
|
74
|
+
* blank lines are dropped, and a trailing line without a newline is flushed
|
|
75
|
+
* before the promise resolves — a crashed agent's last line is still evidence.
|
|
76
|
+
* A throwing callback is reported at debug level and never kills the run.
|
|
77
|
+
*/
|
|
78
|
+
export function spawnAgentStream(binPath, options) {
|
|
79
|
+
const { args, cwd, env, onStdoutLine, onStderrLine, onSpawn } = options;
|
|
80
|
+
return new Promise((resolve, reject) => {
|
|
81
|
+
const child = spawn(binPath, args, {
|
|
82
|
+
cwd,
|
|
83
|
+
env,
|
|
84
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
85
|
+
shell: false,
|
|
86
|
+
windowsHide: false,
|
|
87
|
+
});
|
|
88
|
+
if (onSpawn) {
|
|
89
|
+
try {
|
|
90
|
+
onSpawn(child);
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
logger.debug(`spawnAgentStream: onSpawn callback failed: ${errorMessage(error)}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const stdout = createLineSplitter(onStdoutLine);
|
|
97
|
+
const stderr = createLineSplitter(onStderrLine);
|
|
98
|
+
child.stdout?.setEncoding("utf8");
|
|
99
|
+
child.stderr?.setEncoding("utf8");
|
|
100
|
+
child.stdout?.on("data", (chunk) => stdout.push(chunk));
|
|
101
|
+
child.stderr?.on("data", (chunk) => stderr.push(chunk));
|
|
102
|
+
const handlers = forwardSignals(child);
|
|
103
|
+
child.on("error", (error) => {
|
|
104
|
+
removeSignals(handlers);
|
|
105
|
+
reject(Errors.childAgentFailed(errorMessage(error)));
|
|
106
|
+
});
|
|
107
|
+
// "close", not "exit": it fires once the stdio streams are drained, so the
|
|
108
|
+
// trailing-line flush below cannot race data still sitting in the pipe.
|
|
109
|
+
child.on("close", (code) => {
|
|
110
|
+
removeSignals(handlers);
|
|
111
|
+
stdout.flush();
|
|
112
|
+
stderr.flush();
|
|
113
|
+
resolve({ code: code ?? 1 });
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
/** Per-stream line buffer for spawnAgentStream: emits each complete line (\n or \r\n) as it arrives. */
|
|
118
|
+
function createLineSplitter(deliver) {
|
|
119
|
+
let buffer = "";
|
|
120
|
+
const emit = (line) => {
|
|
121
|
+
if (line.length === 0)
|
|
122
|
+
return;
|
|
123
|
+
try {
|
|
124
|
+
deliver(line);
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
logger.debug(`spawnAgentStream: line callback failed: ${errorMessage(error)}`);
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
return {
|
|
131
|
+
push(chunk) {
|
|
132
|
+
buffer += chunk;
|
|
133
|
+
let newline = buffer.indexOf("\n");
|
|
134
|
+
while (newline >= 0) {
|
|
135
|
+
const line = buffer.slice(0, newline);
|
|
136
|
+
buffer = buffer.slice(newline + 1);
|
|
137
|
+
emit(line.endsWith("\r") ? line.slice(0, -1) : line);
|
|
138
|
+
newline = buffer.indexOf("\n");
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
flush() {
|
|
142
|
+
if (buffer.length > 0) {
|
|
143
|
+
const line = buffer;
|
|
144
|
+
buffer = "";
|
|
145
|
+
emit(line);
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
}
|
|
67
150
|
/** Install SIGINT/SIGTERM forwarding; returns the handlers for cleanup. */
|
|
68
151
|
function forwardSignals(child) {
|
|
69
152
|
const handlers = new Map();
|
package/dist/core/prompt.js
CHANGED
|
@@ -17,16 +17,29 @@ export function readStdin() {
|
|
|
17
17
|
}
|
|
18
18
|
/**
|
|
19
19
|
* Resolve the task prompt (spec §15, §40):
|
|
20
|
-
*
|
|
20
|
+
* arguments → stdin text → error
|
|
21
|
+
*
|
|
22
|
+
* Arguments are checked FIRST, and when they carry a prompt stdin is never
|
|
23
|
+
* awaited. The original order was stdin-first, which hangs forever whenever
|
|
24
|
+
* stdin is an open pipe that never reaches EOF — the normal shape under an
|
|
25
|
+
* agent harness, a CI runner or `nohup`. Measured on Windows: with stdin held
|
|
26
|
+
* open, `glm-worker "Reply exactly with X"` did nothing at all — no run
|
|
27
|
+
* directory, no API call, no output — until the pipe closed 25 s later, then
|
|
28
|
+
* completed in 7 s. A prompt in argv is an explicit instruction and must not
|
|
29
|
+
* wait on a stream that may never close.
|
|
30
|
+
*
|
|
31
|
+
* Piping a task packet still works exactly as before: with no arguments,
|
|
32
|
+
* blocking until EOF is the correct thing to do, because there is nothing
|
|
33
|
+
* else to run.
|
|
21
34
|
*/
|
|
22
35
|
export async function resolvePrompt(argv, readStdinFn = readStdin, command = "glm-worker") {
|
|
23
|
-
const stdinText = await readStdinFn();
|
|
24
|
-
if (stdinText !== undefined && stdinText.trim().length > 0) {
|
|
25
|
-
return stdinText;
|
|
26
|
-
}
|
|
27
36
|
const argText = argv.join(" ").trim();
|
|
28
37
|
if (argText.length > 0) {
|
|
29
38
|
return argText;
|
|
30
39
|
}
|
|
40
|
+
const stdinText = await readStdinFn();
|
|
41
|
+
if (stdinText !== undefined && stdinText.trim().length > 0) {
|
|
42
|
+
return stdinText;
|
|
43
|
+
}
|
|
31
44
|
throw Errors.promptRequired(command);
|
|
32
45
|
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { Errors } from "./errors.js";
|
|
2
|
+
/** The only two values `--model` takes; the router routes between config slots, not model names. */
|
|
3
|
+
const MODEL_SLOTS = ["main", "fast"];
|
|
4
|
+
/**
|
|
5
|
+
* Remove the router's Phase E flags from argv (specs/v2-architecture.md).
|
|
6
|
+
*
|
|
7
|
+
* Every occurrence is consumed, including repeats, because whatever is left in
|
|
8
|
+
* `rest` becomes the PROMPT — `resolvePrompt` joins it — so a flag that
|
|
9
|
+
* survives this function does not reach Claude as a flag, it silently becomes
|
|
10
|
+
* task text. That is why an unknown `--model` value is an error rather than a
|
|
11
|
+
* pass-through: quietly appending "--model gpt-4" to someone's prompt is worse
|
|
12
|
+
* than telling them the flag takes main or fast.
|
|
13
|
+
*
|
|
14
|
+
* Like `--profile` (specs/glm-fast-profiles.md), our `--model` deliberately
|
|
15
|
+
* shadows Claude Code's own flag of that name inside these binaries; callers
|
|
16
|
+
* who need Claude's version can call `claude` directly.
|
|
17
|
+
*/
|
|
18
|
+
export function extractRoutingFlags(argv) {
|
|
19
|
+
const rest = [];
|
|
20
|
+
let model;
|
|
21
|
+
let force = false;
|
|
22
|
+
let refreshQuota = false;
|
|
23
|
+
for (let i = 0; i < argv.length; i++) {
|
|
24
|
+
const arg = argv[i];
|
|
25
|
+
if (arg === "--force") {
|
|
26
|
+
force = true;
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
if (arg === "--refresh-quota") {
|
|
30
|
+
refreshQuota = true;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (arg === "--model") {
|
|
34
|
+
// The first occurrence wins, but later ones are still consumed so they
|
|
35
|
+
// cannot leak into the prompt.
|
|
36
|
+
model ??= parseSlot(argv[i + 1]);
|
|
37
|
+
i++;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (arg.startsWith("--model=")) {
|
|
41
|
+
model ??= parseSlot(arg.slice("--model=".length));
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
rest.push(arg);
|
|
45
|
+
}
|
|
46
|
+
return { rest, model, force, refreshQuota };
|
|
47
|
+
}
|
|
48
|
+
function parseSlot(value) {
|
|
49
|
+
if (value === undefined || !MODEL_SLOTS.includes(value)) {
|
|
50
|
+
throw Errors.invalidArgs(`--model expects "main" or "fast"${value === undefined ? "" : `, got "${value}"`}.`, [
|
|
51
|
+
"Pick the configured slot, not a model name:",
|
|
52
|
+
"",
|
|
53
|
+
" glm-worker --model fast \"<task>\"",
|
|
54
|
+
"",
|
|
55
|
+
"The names behind the slots come from models.main / models.fast in config.",
|
|
56
|
+
]);
|
|
57
|
+
}
|
|
58
|
+
return value;
|
|
59
|
+
}
|
package/dist/core/user-env.js
CHANGED
|
@@ -63,12 +63,17 @@ export function describeKeyStore(store) {
|
|
|
63
63
|
}
|
|
64
64
|
}
|
|
65
65
|
/**
|
|
66
|
-
* Read a variable from the per-user store
|
|
67
|
-
*
|
|
66
|
+
* Read a variable from the per-user store, distinguishing "no store" / "not
|
|
67
|
+
* set" (readable: true, value: undefined) from "the read itself failed"
|
|
68
|
+
* (readable: false). `readUserEnv` below is a thin wrapper that collapses
|
|
69
|
+
* both to `undefined` for existing callers.
|
|
68
70
|
*/
|
|
69
|
-
export function
|
|
71
|
+
export function readUserEnvDiagnostic(name, deps = {}) {
|
|
70
72
|
assertEnvVarName(name);
|
|
71
73
|
const store = detectUserEnvStore(deps);
|
|
74
|
+
if (store === "none") {
|
|
75
|
+
return { readable: true, value: undefined };
|
|
76
|
+
}
|
|
72
77
|
const run = deps.run ?? defaultRun;
|
|
73
78
|
try {
|
|
74
79
|
let value;
|
|
@@ -87,16 +92,21 @@ export function readUserEnv(name, deps = {}) {
|
|
|
87
92
|
case "libsecret":
|
|
88
93
|
value = run("secret-tool", ["lookup", "service", KEY_STORE_SERVICE, "account", name], { capture: true });
|
|
89
94
|
break;
|
|
90
|
-
case "none":
|
|
91
|
-
return undefined;
|
|
92
95
|
}
|
|
93
96
|
const trimmed = value.trim();
|
|
94
|
-
return trimmed || undefined;
|
|
97
|
+
return { readable: true, value: trimmed || undefined };
|
|
95
98
|
}
|
|
96
99
|
catch {
|
|
97
|
-
return undefined;
|
|
100
|
+
return { readable: false, value: undefined };
|
|
98
101
|
}
|
|
99
102
|
}
|
|
103
|
+
/**
|
|
104
|
+
* Read a variable from the per-user store. Returns undefined on any failure —
|
|
105
|
+
* callers fall back or raise their own error.
|
|
106
|
+
*/
|
|
107
|
+
export function readUserEnv(name, deps = {}) {
|
|
108
|
+
return readUserEnvDiagnostic(name, deps).value;
|
|
109
|
+
}
|
|
100
110
|
/**
|
|
101
111
|
* Write a variable to the per-user store. Throws when there is no store —
|
|
102
112
|
* callers print platform-appropriate guidance instead.
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Z.ai monitor endpoint lives in core because four readers need it —
|
|
3
|
+
* usage, dashboard, the MCP server and the Phase E budget manager — and a
|
|
4
|
+
* budget module must not import from a command module: commands sit on top
|
|
5
|
+
* of core, never underneath it.
|
|
6
|
+
*/
|
|
7
|
+
/** Z.ai monitor API used by their own dashboard (specs/usage.md). */
|
|
8
|
+
export const ZAI_QUOTA_URL = "https://api.z.ai/api/monitor/usage/quota/limit";
|
|
9
|
+
/** Window labels for the observed enum values (specs/usage.md); unknown values stay generic. */
|
|
10
|
+
export function describeWindow(limit) {
|
|
11
|
+
if (limit.unit === 3 && typeof limit.number === "number") {
|
|
12
|
+
return `${limit.number}-hour window`;
|
|
13
|
+
}
|
|
14
|
+
if (limit.unit === 6 && limit.number === 1) {
|
|
15
|
+
return "weekly";
|
|
16
|
+
}
|
|
17
|
+
return `window unit=${String(limit.unit)} x ${String(limit.number)}`;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Thrown by `fetchZaiQuota` instead of a plain `Error`. `message` is always a
|
|
21
|
+
* safe, locally-constructed string — it never contains the raw response body,
|
|
22
|
+
* a provider `msg`, headers, or a URL with credentials (spec §D "Security").
|
|
23
|
+
*/
|
|
24
|
+
export class ZaiQuotaError extends Error {
|
|
25
|
+
kind;
|
|
26
|
+
httpStatus;
|
|
27
|
+
/** Only meaningful when kind === "network": distinguishes a timeout from any other network failure. */
|
|
28
|
+
timeout;
|
|
29
|
+
constructor(kind, message, options = {}) {
|
|
30
|
+
super(message);
|
|
31
|
+
this.name = "ZaiQuotaError";
|
|
32
|
+
this.kind = kind;
|
|
33
|
+
this.httpStatus = options.httpStatus;
|
|
34
|
+
this.timeout = options.timeout ?? false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function isPlainObject(value) {
|
|
38
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
39
|
+
}
|
|
40
|
+
function isFiniteNumberOrAbsent(value) {
|
|
41
|
+
return value === undefined || (typeof value === "number" && Number.isFinite(value));
|
|
42
|
+
}
|
|
43
|
+
/** Every quota field is optional, but any field that IS present must be well-typed. */
|
|
44
|
+
function isValidLimit(entry) {
|
|
45
|
+
if (!isPlainObject(entry))
|
|
46
|
+
return false;
|
|
47
|
+
if (entry.type !== undefined && typeof entry.type !== "string")
|
|
48
|
+
return false;
|
|
49
|
+
return (isFiniteNumberOrAbsent(entry.unit) &&
|
|
50
|
+
isFiniteNumberOrAbsent(entry.number) &&
|
|
51
|
+
isFiniteNumberOrAbsent(entry.usage) &&
|
|
52
|
+
isFiniteNumberOrAbsent(entry.currentValue) &&
|
|
53
|
+
isFiniteNumberOrAbsent(entry.remaining) &&
|
|
54
|
+
isFiniteNumberOrAbsent(entry.percentage) &&
|
|
55
|
+
isFiniteNumberOrAbsent(entry.nextResetTime));
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Validate the monitor response shape (spec §D "Success validation"). Empty
|
|
59
|
+
* `limits` is a valid, fully-authenticated response meaning "no windows
|
|
60
|
+
* reported" — never treated as zero quota. A missing or wrong-type `limits`
|
|
61
|
+
* field means the schema did not match, which is unverified, not rejected.
|
|
62
|
+
*/
|
|
63
|
+
function parseQuotaBody(body) {
|
|
64
|
+
if (!isPlainObject(body)) {
|
|
65
|
+
return { ok: false, kind: "invalid-response", message: "Z.ai monitor endpoint returned an unexpected response shape." };
|
|
66
|
+
}
|
|
67
|
+
if (typeof body.code !== "number") {
|
|
68
|
+
return { ok: false, kind: "invalid-response", message: "Z.ai monitor endpoint response is missing a status code." };
|
|
69
|
+
}
|
|
70
|
+
if (body.code !== 200) {
|
|
71
|
+
return { ok: false, kind: "provider", message: `Z.ai monitor endpoint rejected the request (code ${body.code}).` };
|
|
72
|
+
}
|
|
73
|
+
if (body.success !== undefined && body.success !== true) {
|
|
74
|
+
return { ok: false, kind: "provider", message: "Z.ai monitor endpoint reported an unsuccessful request." };
|
|
75
|
+
}
|
|
76
|
+
if (!isPlainObject(body.data)) {
|
|
77
|
+
return { ok: false, kind: "invalid-response", message: "Z.ai monitor endpoint response is missing usage data." };
|
|
78
|
+
}
|
|
79
|
+
const data = body.data;
|
|
80
|
+
if (data.level !== undefined && typeof data.level !== "string") {
|
|
81
|
+
return { ok: false, kind: "invalid-response", message: "Z.ai monitor endpoint response has an invalid level field." };
|
|
82
|
+
}
|
|
83
|
+
if (!Array.isArray(data.limits)) {
|
|
84
|
+
return { ok: false, kind: "invalid-response", message: "Z.ai monitor endpoint response is missing usage limits." };
|
|
85
|
+
}
|
|
86
|
+
for (const entry of data.limits) {
|
|
87
|
+
if (!isValidLimit(entry)) {
|
|
88
|
+
return { ok: false, kind: "invalid-response", message: "Z.ai monitor endpoint response contains a malformed limit entry." };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
ok: true,
|
|
93
|
+
data: { level: data.level, limits: data.limits },
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Fetch and validate the Z.ai quota snapshot. Never logs the Authorization
|
|
98
|
+
* header. Throws `ZaiQuotaError` on any failure — callers branch on `.kind`.
|
|
99
|
+
* `redirect: "error"` refuses to forward the bearer token to a redirect
|
|
100
|
+
* target (spec §D "Security").
|
|
101
|
+
*/
|
|
102
|
+
export async function fetchZaiQuota(key, fetchImpl) {
|
|
103
|
+
let response;
|
|
104
|
+
try {
|
|
105
|
+
response = await fetchImpl(ZAI_QUOTA_URL, {
|
|
106
|
+
method: "GET",
|
|
107
|
+
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
|
|
108
|
+
signal: AbortSignal.timeout(10_000),
|
|
109
|
+
redirect: "error",
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
const isTimeout = error instanceof Error && error.name === "TimeoutError";
|
|
114
|
+
throw new ZaiQuotaError("network", isTimeout ? "Z.ai monitor endpoint timed out." : "Z.ai monitor endpoint was unreachable.", { timeout: isTimeout });
|
|
115
|
+
}
|
|
116
|
+
if (response.status === 401) {
|
|
117
|
+
throw new ZaiQuotaError("unauthorized", "Z.ai monitor endpoint rejected the key (HTTP 401).", {
|
|
118
|
+
httpStatus: 401,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
if (response.status === 403) {
|
|
122
|
+
throw new ZaiQuotaError("forbidden", "Z.ai monitor endpoint denied access (HTTP 403).", {
|
|
123
|
+
httpStatus: 403,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
if (response.status === 429) {
|
|
127
|
+
throw new ZaiQuotaError("rate-limited", "Z.ai monitor endpoint is rate-limiting this key (HTTP 429).", {
|
|
128
|
+
httpStatus: 429,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
if (!response.ok) {
|
|
132
|
+
throw new ZaiQuotaError("http", `Z.ai monitor endpoint returned HTTP ${response.status}.`, {
|
|
133
|
+
httpStatus: response.status,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
let body;
|
|
137
|
+
try {
|
|
138
|
+
body = await response.json();
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
throw new ZaiQuotaError("invalid-response", "Z.ai monitor endpoint returned a non-JSON body.");
|
|
142
|
+
}
|
|
143
|
+
const parsed = parseQuotaBody(body);
|
|
144
|
+
if (!parsed.ok) {
|
|
145
|
+
throw new ZaiQuotaError(parsed.kind, parsed.message);
|
|
146
|
+
}
|
|
147
|
+
return parsed.data;
|
|
148
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { logger } from "../core/logging.js";
|
|
2
|
+
/**
|
|
3
|
+
* Not a `createEventBus` parameter: v2 has exactly one provider, and a
|
|
4
|
+
* parameter would invite a wrong value into the persisted history (H2).
|
|
5
|
+
*/
|
|
6
|
+
const PROVIDER = "zai.zcode";
|
|
7
|
+
/**
|
|
8
|
+
* Build a bus for one run. `taskId` defaults to the `runId` while there is no
|
|
9
|
+
* task graph (hedge H1); `role` defaults to `"worker"`. `deps.now` exists so
|
|
10
|
+
* tests can pin `ts` deterministically; production omits it and gets the real
|
|
11
|
+
* clock.
|
|
12
|
+
*/
|
|
13
|
+
export function createEventBus(runId, deps) {
|
|
14
|
+
const taskId = deps?.taskId ?? runId;
|
|
15
|
+
const role = deps?.role ?? "worker";
|
|
16
|
+
const now = deps?.now;
|
|
17
|
+
const listeners = new Set();
|
|
18
|
+
let seq = 0;
|
|
19
|
+
let closed = false;
|
|
20
|
+
return {
|
|
21
|
+
runId,
|
|
22
|
+
taskId,
|
|
23
|
+
emit(event) {
|
|
24
|
+
const stamped = {
|
|
25
|
+
...event,
|
|
26
|
+
runId,
|
|
27
|
+
taskId,
|
|
28
|
+
provider: PROVIDER,
|
|
29
|
+
role,
|
|
30
|
+
seq: ++seq,
|
|
31
|
+
ts: (now?.() ?? new Date()).toISOString(),
|
|
32
|
+
};
|
|
33
|
+
if (!closed) {
|
|
34
|
+
for (const listener of listeners) {
|
|
35
|
+
try {
|
|
36
|
+
listener(stamped);
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
// A broken renderer must never kill a run (specs/v2-architecture.md,
|
|
40
|
+
// Phase A): swallow, keep dispatching, leave a debug trace.
|
|
41
|
+
logger.debug(`event subscriber threw on ${stamped.type} seq ${stamped.seq}: ${errorMessage(error)}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return stamped;
|
|
46
|
+
},
|
|
47
|
+
subscribe(listener) {
|
|
48
|
+
if (closed) {
|
|
49
|
+
return () => { };
|
|
50
|
+
}
|
|
51
|
+
listeners.add(listener);
|
|
52
|
+
return () => {
|
|
53
|
+
listeners.delete(listener);
|
|
54
|
+
};
|
|
55
|
+
},
|
|
56
|
+
close() {
|
|
57
|
+
closed = true;
|
|
58
|
+
listeners.clear();
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
function errorMessage(error) {
|
|
63
|
+
return error instanceof Error ? error.message : String(error);
|
|
64
|
+
}
|