@cartanova/qgrid-cli 2.4.4 → 2.4.6
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/bundle/dist/application/monit/log-buffer.js +76 -0
- package/bundle/dist/application/monit/monit.frame.js +86 -0
- package/bundle/dist/application/monit/monit.types.js +42 -0
- package/bundle/dist/application/qgrid/oauth.js +1 -1
- package/bundle/dist/application/qgrid/qgrid.dispatcher.js +3 -3
- package/bundle/dist/application/qgrid/qgrid.frame.js +14 -12
- package/bundle/dist/application/qgrid/qgrid.types.js +3 -2
- package/bundle/dist/application/queries.generated.js +7 -2
- package/bundle/dist/application/request-log/request-log.model.js +1 -1
- package/bundle/dist/sonamu.config.js +14 -10
- package/bundle/dist/utils/providers/anthropic/anthropic-dispatcher.js +5 -2
- package/bundle/dist/utils/providers/anthropic/claude-session.js +3 -3
- package/bundle/dist/utils/providers/openai/codex-worker.js +5 -3
- package/bundle/dist/utils/providers/openai/openai-dispatcher.js +179 -15
- package/bundle/src/application/monit/log-buffer.test.ts +121 -0
- package/bundle/src/application/monit/log-buffer.ts +121 -0
- package/bundle/src/application/monit/monit.frame.test.ts +176 -0
- package/bundle/src/application/monit/monit.frame.ts +77 -0
- package/bundle/src/application/monit/monit.types.ts +54 -0
- package/bundle/src/application/qgrid/qgrid.frame.test.ts +24 -0
- package/bundle/src/application/qgrid/qgrid.frame.ts +2 -0
- package/bundle/src/application/qgrid/qgrid.types.ts +1 -0
- package/bundle/src/application/queries.generated.ts +8 -0
- package/bundle/src/application/sonamu.generated.http +12 -0
- package/bundle/src/sonamu.config.ts +5 -2
- package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.ts +5 -0
- package/bundle/src/utils/providers/openai/codex-worker.test.ts +26 -0
- package/bundle/src/utils/providers/openai/codex-worker.ts +3 -1
- package/bundle/src/utils/providers/openai/openai-dispatcher.test.ts +212 -7
- package/bundle/src/utils/providers/openai/openai-dispatcher.ts +297 -17
- package/bundle/web-dist/client/assets/index-BOXhNo0k.js +78 -0
- package/bundle/web-dist/client/assets/index-yZcjDoV0.css +1 -0
- package/bundle/web-dist/client/assets/{logs-vuI-Zgnq.js → logs-BShH_sut.js} +1 -1
- package/bundle/web-dist/client/assets/monit-CoEAp5S0.js +1 -0
- package/bundle/web-dist/client/assets/{routes-0Fki0JEi.js → routes-Dr3cGnV3.js} +2 -2
- package/bundle/web-dist/client/assets/{sd.generated-WA5SGJYZ.js → sd.generated-D2Y9QeiN.js} +1 -1
- package/bundle/web-dist/client/assets/{services.generated-DzZLnb7S.js → services.generated-CmxXl220.js} +1 -1
- package/bundle/web-dist/client/assets/{show-BreAgaFH.js → show-CXII6Iy7.js} +3 -3
- package/bundle/web-dist/client/assets/{tokens-4VgHQtXd.js → tokens-hP1NecIg.js} +1 -1
- package/bundle/web-dist/client/assets/use-update-token-mutation-6J1T9U2-.js +1 -0
- package/bundle/web-dist/client/index.html +3 -3
- package/bundle/web-dist/server/assets/{lazyRouteComponent-BrPAFtBA.js → lazyRouteComponent-BlfOh9S3.js} +1 -1
- package/bundle/web-dist/server/assets/{logs-CKM6W3W6.js → logs-DYly4ipQ.js} +3 -3
- package/bundle/web-dist/server/assets/monit-CYyY2S2f.js +1577 -0
- package/bundle/web-dist/server/assets/{routes-DhIpPgXz.js → routes-Dqu3osXt.js} +14 -6
- package/bundle/web-dist/server/assets/{sd.generated-Cwg2f53z.js → sd.generated-CE6D5clm.js} +1 -1
- package/bundle/web-dist/server/assets/{services.generated-CsotZP44.js → services.generated-DyZUzXiq.js} +39 -2
- package/bundle/web-dist/server/assets/{show-CV2UFjaH.js → show-BDul8xLb.js} +3 -3
- package/bundle/web-dist/server/assets/{tokens-DfGj1y2v.js → tokens-fzMW4G74.js} +4 -4
- package/bundle/web-dist/server/assets/{use-update-token-mutation-BL96w-Pg.js → use-update-token-mutation-BejUW5sw.js} +1 -1
- package/bundle/web-dist/server/entry-server.generated.js +71 -27
- package/package.json +1 -1
- package/skills/qgrid/references/ai-sdk-provider-contract.md +25 -0
- package/bundle/web-dist/client/assets/index-D1ExP77_.js +0 -78
- package/bundle/web-dist/client/assets/index-Dc-qeEPm.css +0 -1
- package/bundle/web-dist/client/assets/use-update-token-mutation-BV6msjuy.js +0 -1
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { type LogRecord } from "@logtape/logtape";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
|
|
4
|
+
import { MonitLogBuffer } from "./log-buffer";
|
|
5
|
+
|
|
6
|
+
function record(partial: Partial<LogRecord> & { message?: readonly unknown[] }): LogRecord {
|
|
7
|
+
return {
|
|
8
|
+
category: ["qgrid"],
|
|
9
|
+
level: "info",
|
|
10
|
+
message: ["hello"],
|
|
11
|
+
rawMessage: "hello",
|
|
12
|
+
timestamp: 1_000,
|
|
13
|
+
properties: {},
|
|
14
|
+
...partial,
|
|
15
|
+
} as LogRecord;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe("MonitLogBuffer", () => {
|
|
19
|
+
it("flattens interleaved template parts and renders non-string values", () => {
|
|
20
|
+
const buffer = new MonitLogBuffer();
|
|
21
|
+
buffer.push(
|
|
22
|
+
record({ message: ["worker ", 3, " ready in ", { ms: 120 }, ""], timestamp: 42 }),
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
const { entries } = buffer.after(undefined, 10);
|
|
26
|
+
expect(entries).toHaveLength(1);
|
|
27
|
+
expect(entries[0]).toMatchObject({
|
|
28
|
+
seq: 1,
|
|
29
|
+
timestamp: 42,
|
|
30
|
+
level: "info",
|
|
31
|
+
category: ["qgrid"],
|
|
32
|
+
text: 'worker 3 ready in {"ms":120}',
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("truncates oversized messages and marks the cut", () => {
|
|
37
|
+
const buffer = new MonitLogBuffer(10, 100);
|
|
38
|
+
buffer.push(record({ message: ["x".repeat(500)] }));
|
|
39
|
+
|
|
40
|
+
const [entry] = buffer.after(undefined, 1).entries;
|
|
41
|
+
expect(entry!.text).toHaveLength(101);
|
|
42
|
+
expect(entry!.text.endsWith("…")).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("evicts oldest entries past capacity while seq stays monotonic", () => {
|
|
46
|
+
const buffer = new MonitLogBuffer(3);
|
|
47
|
+
for (let i = 1; i <= 5; i++) buffer.push(record({ message: [`line ${i}`] }));
|
|
48
|
+
|
|
49
|
+
expect(buffer.oldestSeq).toBe(3);
|
|
50
|
+
expect(buffer.latestSeq).toBe(5);
|
|
51
|
+
const { entries } = buffer.after(undefined, 10);
|
|
52
|
+
expect(entries.map((entry) => entry.seq)).toEqual([3, 4, 5]);
|
|
53
|
+
expect(entries.map((entry) => entry.text)).toEqual(["line 3", "line 4", "line 5"]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("returns the tail up to limit when no cursor is given", () => {
|
|
57
|
+
const buffer = new MonitLogBuffer();
|
|
58
|
+
for (let i = 1; i <= 5; i++) buffer.push(record({ message: [`line ${i}`] }));
|
|
59
|
+
|
|
60
|
+
const chunk = buffer.after(undefined, 2);
|
|
61
|
+
expect(chunk.entries.map((entry) => entry.seq)).toEqual([4, 5]);
|
|
62
|
+
expect(chunk.nextCursor).toBe(5);
|
|
63
|
+
expect(chunk.dropped).toBe(0);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("returns only entries newer than the cursor with no boundary duplicates", () => {
|
|
67
|
+
const buffer = new MonitLogBuffer();
|
|
68
|
+
for (let i = 1; i <= 5; i++) buffer.push(record({ message: [`line ${i}`] }));
|
|
69
|
+
|
|
70
|
+
const chunk = buffer.after(2, 10);
|
|
71
|
+
expect(chunk.entries.map((entry) => entry.seq)).toEqual([3, 4, 5]);
|
|
72
|
+
expect(chunk.nextCursor).toBe(5);
|
|
73
|
+
expect(chunk.dropped).toBe(0);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("signals dropped lines when the cursor fell behind eviction", () => {
|
|
77
|
+
const buffer = new MonitLogBuffer(3);
|
|
78
|
+
for (let i = 1; i <= 6; i++) buffer.push(record({ message: [`line ${i}`] }));
|
|
79
|
+
|
|
80
|
+
// oldest 는 seq 4 — 커서 1 은 seq 2,3 을 유실했다.
|
|
81
|
+
const chunk = buffer.after(1, 10);
|
|
82
|
+
expect(chunk.dropped).toBe(2);
|
|
83
|
+
expect(chunk.entries.map((entry) => entry.seq)).toEqual([4, 5, 6]);
|
|
84
|
+
expect(chunk.nextCursor).toBe(6);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("resyncs a future cursor from a previous process to the current tail", () => {
|
|
88
|
+
const buffer = new MonitLogBuffer();
|
|
89
|
+
buffer.push(record({}));
|
|
90
|
+
|
|
91
|
+
const chunk = buffer.after(999, 10);
|
|
92
|
+
expect(chunk.entries).toEqual([]);
|
|
93
|
+
expect(chunk.nextCursor).toBe(buffer.latestSeq);
|
|
94
|
+
expect(chunk.dropped).toBe(0);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("handles an empty buffer without crashing", () => {
|
|
98
|
+
const buffer = new MonitLogBuffer();
|
|
99
|
+
const chunk = buffer.after(undefined, 10);
|
|
100
|
+
expect(chunk).toEqual({ entries: [], nextCursor: 0, dropped: 0 });
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("keeps processStartedAt fixed across buffer churn (AE1 server half)", () => {
|
|
104
|
+
const buffer = new MonitLogBuffer(2);
|
|
105
|
+
const startedAt = buffer.processStartedAt;
|
|
106
|
+
for (let i = 0; i < 10; i++) buffer.push(record({}));
|
|
107
|
+
expect(buffer.processStartedAt).toBe(startedAt);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("never throws on malformed records", () => {
|
|
111
|
+
const buffer = new MonitLogBuffer();
|
|
112
|
+
expect(() =>
|
|
113
|
+
buffer.push(record({ message: undefined as unknown as readonly unknown[] })),
|
|
114
|
+
).not.toThrow();
|
|
115
|
+
const cyclic: Record<string, unknown> = {};
|
|
116
|
+
cyclic.self = cyclic;
|
|
117
|
+
expect(() => buffer.push(record({ message: ["cyclic ", cyclic, ""] }))).not.toThrow();
|
|
118
|
+
const { entries } = buffer.after(undefined, 10);
|
|
119
|
+
expect(entries.at(-1)?.text.startsWith("cyclic ")).toBe(true);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Monit 로그 ring buffer — 프로세스 생존 기간 동안 최근 로그를 구조화 레코드로 보관한다.
|
|
3
|
+
*
|
|
4
|
+
* 콘솔 sink 와 병렬로 등록되는 두 번째 logtape sink 가 이 버퍼를 채우고,
|
|
5
|
+
* monit.frame 이 커서 기반으로 읽어 대시보드 Monit 탭에 제공한다.
|
|
6
|
+
* 재시작하면 리셋된다 — 영속 기록은 request_log 의 몫.
|
|
7
|
+
*
|
|
8
|
+
* timestamp/processStartedAt 은 epoch ms 숫자로 유지한다. web 의 전역 dateReviver 가
|
|
9
|
+
* ISO 문자열을 Date 로 바꿔버리므로, 숫자만이 재시작 감지의 동등성 비교를 보존한다.
|
|
10
|
+
*/
|
|
11
|
+
import { type LogRecord, type Sink } from "@logtape/logtape";
|
|
12
|
+
|
|
13
|
+
const DEFAULT_CAPACITY = 2_000;
|
|
14
|
+
const DEFAULT_MAX_TEXT_LENGTH = 4_000;
|
|
15
|
+
|
|
16
|
+
// 표시 전용 wire 레코드. properties/원본 값은 버퍼에 싣지 않는다(allowlist).
|
|
17
|
+
export interface MonitLogEntry {
|
|
18
|
+
seq: number;
|
|
19
|
+
timestamp: number;
|
|
20
|
+
level: string;
|
|
21
|
+
category: string[];
|
|
22
|
+
text: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface MonitLogChunk {
|
|
26
|
+
entries: MonitLogEntry[];
|
|
27
|
+
nextCursor: number;
|
|
28
|
+
// 호출자의 커서가 eviction 에 밀려 유실된 라인 수. 0 이면 공백 없음.
|
|
29
|
+
dropped: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// logtape message 배열은 홀수 길이 — 짝수 인덱스가 템플릿 텍스트, 홀수가 보간 값.
|
|
33
|
+
function flattenMessage(message: readonly unknown[]): string {
|
|
34
|
+
return message
|
|
35
|
+
.map((part, index) => (index % 2 === 0 ? String(part ?? "") : renderValue(part)))
|
|
36
|
+
.join("");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function renderValue(value: unknown): string {
|
|
40
|
+
if (typeof value === "string") return value;
|
|
41
|
+
if (value instanceof Error) return value.message;
|
|
42
|
+
try {
|
|
43
|
+
return JSON.stringify(value) ?? String(value);
|
|
44
|
+
} catch {
|
|
45
|
+
return String(value);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export class MonitLogBuffer {
|
|
50
|
+
readonly processStartedAt = Date.now();
|
|
51
|
+
private entries: MonitLogEntry[] = [];
|
|
52
|
+
private nextSeq = 1;
|
|
53
|
+
|
|
54
|
+
constructor(
|
|
55
|
+
private readonly capacity = DEFAULT_CAPACITY,
|
|
56
|
+
private readonly maxTextLength = DEFAULT_MAX_TEXT_LENGTH,
|
|
57
|
+
) {}
|
|
58
|
+
|
|
59
|
+
// logtape sink 본체. logtape 가 sink 오류를 격리하지만, 버퍼 버그가
|
|
60
|
+
// meta logger 소음을 만들지 않도록 방어적으로 한 번 더 감싼다.
|
|
61
|
+
push(record: LogRecord): void {
|
|
62
|
+
try {
|
|
63
|
+
let text = flattenMessage(record.message);
|
|
64
|
+
if (text.length > this.maxTextLength) {
|
|
65
|
+
text = `${text.slice(0, this.maxTextLength)}…`;
|
|
66
|
+
}
|
|
67
|
+
this.entries.push({
|
|
68
|
+
seq: this.nextSeq++,
|
|
69
|
+
timestamp: record.timestamp,
|
|
70
|
+
level: record.level,
|
|
71
|
+
category: [...record.category],
|
|
72
|
+
text,
|
|
73
|
+
});
|
|
74
|
+
if (this.entries.length > this.capacity) {
|
|
75
|
+
this.entries.splice(0, this.entries.length - this.capacity);
|
|
76
|
+
}
|
|
77
|
+
} catch {
|
|
78
|
+
// 로그 캡처 실패는 조용히 무시 — 로깅 경로를 절대 방해하지 않는다.
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
get oldestSeq(): number {
|
|
83
|
+
return this.entries[0]?.seq ?? this.nextSeq;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
get latestSeq(): number {
|
|
87
|
+
return this.nextSeq - 1;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// cursor 이후의 엔트리를 최대 limit 개 반환한다. cursor 미지정이면 tail.
|
|
91
|
+
after(cursor: number | undefined, limit: number): MonitLogChunk {
|
|
92
|
+
if (cursor === undefined) {
|
|
93
|
+
const entries = this.entries.slice(-limit);
|
|
94
|
+
return {
|
|
95
|
+
entries,
|
|
96
|
+
nextCursor: entries.at(-1)?.seq ?? this.latestSeq,
|
|
97
|
+
dropped: 0,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// 이전 프로세스의 커서 등 미래 커서는 방어적으로 현재 tail 로 재동기화한다.
|
|
102
|
+
if (cursor > this.latestSeq) {
|
|
103
|
+
return { entries: [], nextCursor: this.latestSeq, dropped: 0 };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const dropped = Math.max(this.oldestSeq - cursor - 1, 0);
|
|
107
|
+
const startIndex = this.entries.findIndex((entry) => entry.seq > cursor);
|
|
108
|
+
const entries = startIndex === -1 ? [] : this.entries.slice(startIndex, startIndex + limit);
|
|
109
|
+
return {
|
|
110
|
+
entries,
|
|
111
|
+
nextCursor: entries.at(-1)?.seq ?? cursor + dropped,
|
|
112
|
+
dropped,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export const monitLogBuffer = new MonitLogBuffer();
|
|
118
|
+
|
|
119
|
+
export const monitLogSink: Sink = (record) => {
|
|
120
|
+
monitLogBuffer.push(record);
|
|
121
|
+
};
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { type LogRecord } from "@logtape/logtape";
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
const requestLogSaveMock = vi.hoisted(() => vi.fn());
|
|
5
|
+
vi.mock("../request-log/request-log.model", () => ({
|
|
6
|
+
RequestLogModel: { save: requestLogSaveMock, createRun: requestLogSaveMock },
|
|
7
|
+
MICRO_USD: 1_000_000,
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
import { QgridDispatcher } from "../qgrid/qgrid.dispatcher";
|
|
11
|
+
import { monitLogBuffer } from "./log-buffer";
|
|
12
|
+
import { MonitFrame } from "./monit.frame";
|
|
13
|
+
|
|
14
|
+
function push(text: string, extra: Partial<LogRecord> = {}): void {
|
|
15
|
+
monitLogBuffer.push({
|
|
16
|
+
category: ["qgrid"],
|
|
17
|
+
level: "info",
|
|
18
|
+
message: [text],
|
|
19
|
+
rawMessage: text,
|
|
20
|
+
timestamp: 1_000,
|
|
21
|
+
properties: {},
|
|
22
|
+
...extra,
|
|
23
|
+
} as LogRecord);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe("MonitFrame.monitLogs", () => {
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
requestLogSaveMock.mockReset();
|
|
29
|
+
vi.restoreAllMocks();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("returns the tail with a usable nextCursor when no cursor is given", async () => {
|
|
33
|
+
push("first line");
|
|
34
|
+
push("second line");
|
|
35
|
+
|
|
36
|
+
const chunk = await MonitFrame.monitLogs();
|
|
37
|
+
expect(chunk.processStartedAt).toBe(monitLogBuffer.processStartedAt);
|
|
38
|
+
expect(chunk.dropped).toBe(0);
|
|
39
|
+
expect(chunk.nextCursor).toBe(monitLogBuffer.latestSeq);
|
|
40
|
+
expect(chunk.entries.at(-1)?.text).toBe("second line");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("returns only entries newer than the cursor with no boundary duplicates", async () => {
|
|
44
|
+
const baseline = monitLogBuffer.latestSeq;
|
|
45
|
+
push("after baseline 1");
|
|
46
|
+
push("after baseline 2");
|
|
47
|
+
|
|
48
|
+
const chunk = await MonitFrame.monitLogs(baseline);
|
|
49
|
+
expect(chunk.entries.map((entry) => entry.text)).toEqual([
|
|
50
|
+
"after baseline 1",
|
|
51
|
+
"after baseline 2",
|
|
52
|
+
]);
|
|
53
|
+
expect(chunk.nextCursor).toBe(monitLogBuffer.latestSeq);
|
|
54
|
+
|
|
55
|
+
const followUp = await MonitFrame.monitLogs(chunk.nextCursor);
|
|
56
|
+
expect(followUp.entries).toEqual([]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("surfaces dropped counts from the buffer (AE4 server half)", async () => {
|
|
60
|
+
const after = vi.spyOn(monitLogBuffer, "after").mockReturnValue({
|
|
61
|
+
entries: [],
|
|
62
|
+
nextCursor: 10,
|
|
63
|
+
dropped: 3,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const chunk = await MonitFrame.monitLogs(1);
|
|
67
|
+
expect(after).toHaveBeenCalledWith(1, 1_000);
|
|
68
|
+
expect(chunk.dropped).toBe(3);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("handles an empty buffer without crashing", async () => {
|
|
72
|
+
vi.spyOn(monitLogBuffer, "after").mockReturnValue({
|
|
73
|
+
entries: [],
|
|
74
|
+
nextCursor: 0,
|
|
75
|
+
dropped: 0,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
await expect(MonitFrame.monitLogs()).resolves.toMatchObject({
|
|
79
|
+
entries: [],
|
|
80
|
+
nextCursor: 0,
|
|
81
|
+
dropped: 0,
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("ships only allowlisted DTO fields — the text channel is the live path", async () => {
|
|
86
|
+
// 실제 유출 채널 검증: 템플릿 리터럴 로깅에서 secret 은 보간 값으로 text 에 실린다.
|
|
87
|
+
// properties 는 wire 를 절대 건너지 않는다.
|
|
88
|
+
push("token refresh failed: ", {
|
|
89
|
+
message: ["token refresh failed: ", "sk-interpolated-credential", ""],
|
|
90
|
+
properties: { accessToken: "forbidden-property-secret", refreshToken: "also-forbidden" },
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const chunk = await MonitFrame.monitLogs(monitLogBuffer.latestSeq - 1);
|
|
94
|
+
const serialized = JSON.stringify(chunk);
|
|
95
|
+
expect(serialized).not.toContain("forbidden-property-secret");
|
|
96
|
+
expect(serialized).not.toContain("also-forbidden");
|
|
97
|
+
expect(serialized).not.toContain("properties");
|
|
98
|
+
// text 채널은 보간 값을 그대로 실어 나른다 — 이것이 실제 노출 경로임을 문서화한다.
|
|
99
|
+
expect(chunk.entries.at(-1)?.text).toBe("token refresh failed: sk-interpolated-credential");
|
|
100
|
+
expect(Object.keys(chunk.entries.at(-1)!).toSorted()).toEqual([
|
|
101
|
+
"category",
|
|
102
|
+
"level",
|
|
103
|
+
"seq",
|
|
104
|
+
"text",
|
|
105
|
+
"timestamp",
|
|
106
|
+
]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("writes no request logs", async () => {
|
|
110
|
+
push("a line");
|
|
111
|
+
await MonitFrame.monitLogs();
|
|
112
|
+
expect(requestLogSaveMock).not.toHaveBeenCalled();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("piggybacks live vitals on every chunk, zeroed before dispatchers boot", async () => {
|
|
116
|
+
QgridDispatcher.openaiDispatcher = {
|
|
117
|
+
workerCount: 25,
|
|
118
|
+
readyWorkerCount: 24,
|
|
119
|
+
queueLength: 3,
|
|
120
|
+
workerCountsByToken: [
|
|
121
|
+
{ name: "openai/haze", count: 12 },
|
|
122
|
+
{ name: "openai/nk", count: 13 },
|
|
123
|
+
],
|
|
124
|
+
} as never;
|
|
125
|
+
QgridDispatcher.anthropicDispatcher = {
|
|
126
|
+
tokenCount: 8,
|
|
127
|
+
tokenNames: ["anthropic/haze", "anthropic/noa"],
|
|
128
|
+
} as never;
|
|
129
|
+
try {
|
|
130
|
+
const chunk = await MonitFrame.monitLogs();
|
|
131
|
+
expect(chunk.vitals).toEqual({
|
|
132
|
+
openaiWorkerCount: 25,
|
|
133
|
+
openaiReadyWorkerCount: 24,
|
|
134
|
+
openaiQueueLength: 3,
|
|
135
|
+
openaiWorkersByToken: [
|
|
136
|
+
{ name: "openai/haze", count: 12 },
|
|
137
|
+
{ name: "openai/nk", count: 13 },
|
|
138
|
+
],
|
|
139
|
+
anthropicTokenCount: 8,
|
|
140
|
+
anthropicTokenNames: ["anthropic/haze", "anthropic/noa"],
|
|
141
|
+
});
|
|
142
|
+
} finally {
|
|
143
|
+
QgridDispatcher.openaiDispatcher = null;
|
|
144
|
+
QgridDispatcher.anthropicDispatcher = null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const empty = await MonitFrame.monitLogs();
|
|
148
|
+
expect(empty.vitals.openaiWorkerCount).toBe(0);
|
|
149
|
+
expect(empty.vitals.openaiWorkersByToken).toEqual([]);
|
|
150
|
+
expect(empty.vitals.anthropicTokenCount).toBe(0);
|
|
151
|
+
expect(empty.vitals.anthropicTokenNames).toEqual([]);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe("MonitFrame.monitInfo", () => {
|
|
156
|
+
it("returns the active DB connection, server url, and pool bounds without secrets", async () => {
|
|
157
|
+
vi.stubEnv("HOST", "0.0.0.0");
|
|
158
|
+
vi.stubEnv("PORT", "45000");
|
|
159
|
+
vi.stubEnv("QGRID_DB_HOST", "db.internal");
|
|
160
|
+
vi.stubEnv("QGRID_DB_NAME", "qgrid_dev");
|
|
161
|
+
|
|
162
|
+
// 단위 테스트에선 DB 미초기화 → env 폴백 경로. 부팅된 서버에선 활성 knex 설정을 읽는다
|
|
163
|
+
// (dev 서버의 통합 테스트 러너가 env 를 오염시키는 문제 회피 — curl 로 별도 검증).
|
|
164
|
+
const info = await MonitFrame.monitInfo();
|
|
165
|
+
expect(info.serverUrl).toBe("http://0.0.0.0:45000");
|
|
166
|
+
expect(info.dbHost).toBe("db.internal");
|
|
167
|
+
expect(info.dbName).toBe("qgrid_dev");
|
|
168
|
+
expect(info.openai.minWorkersPerToken).toBeGreaterThan(0);
|
|
169
|
+
expect(info.openai.maxWorkersPerToken).toBeGreaterThanOrEqual(info.openai.minWorkersPerToken);
|
|
170
|
+
// allowlist — 연결 객체의 password/user 등은 절대 실리지 않는다.
|
|
171
|
+
expect(Object.keys(info).toSorted()).toEqual(["dbHost", "dbName", "openai", "serverUrl"]);
|
|
172
|
+
expect(JSON.stringify(info)).not.toContain("password");
|
|
173
|
+
|
|
174
|
+
vi.unstubAllEnvs();
|
|
175
|
+
});
|
|
176
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Monit — 대시보드 Monit 탭용 프로세스 로그 관측 API.
|
|
3
|
+
*
|
|
4
|
+
* 읽기 전용. ring buffer 를 얇게 직접 읽으며(범용 쿼리 기계 없이),
|
|
5
|
+
* request log 를 남기지 않는다. 접근 경계는 다른 대시보드 API 와 동일하다.
|
|
6
|
+
*/
|
|
7
|
+
import { api, BaseFrameClass, DB } from "sonamu";
|
|
8
|
+
|
|
9
|
+
import { resolveOpenAIWorkerPoolConfig } from "../../utils/providers/openai/openai-dispatcher";
|
|
10
|
+
import { QgridDispatcher } from "../qgrid/qgrid.dispatcher";
|
|
11
|
+
import { monitLogBuffer } from "./log-buffer";
|
|
12
|
+
import { type MonitLogChunk, type MonitServerInfo, type MonitVitals } from "./monit.types";
|
|
13
|
+
|
|
14
|
+
// 응답당 엔트리 상한 — 폴링 클라이언트는 다음 폴에서 이어서 따라잡는다.
|
|
15
|
+
const RESPONSE_ENTRY_LIMIT = 1_000;
|
|
16
|
+
|
|
17
|
+
class MonitFrameClass extends BaseFrameClass {
|
|
18
|
+
constructor() {
|
|
19
|
+
super("Monit");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
@api({ httpMethod: "GET", clients: ["axios", "tanstack-query"] })
|
|
23
|
+
async monitLogs(cursor?: number): Promise<MonitLogChunk> {
|
|
24
|
+
return {
|
|
25
|
+
processStartedAt: monitLogBuffer.processStartedAt,
|
|
26
|
+
...monitLogBuffer.after(cursor, RESPONSE_ENTRY_LIMIT),
|
|
27
|
+
vitals: currentVitals(),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// 프로세스 정적 정보 — 폴링 불필요, 페이지당 1회 조회.
|
|
32
|
+
@api({ httpMethod: "GET", clients: ["axios", "tanstack-query"] })
|
|
33
|
+
async monitInfo(): Promise<MonitServerInfo> {
|
|
34
|
+
const pool = resolveOpenAIWorkerPoolConfig();
|
|
35
|
+
const host = process.env.HOST ?? "localhost";
|
|
36
|
+
const port = process.env.PORT ?? "44900";
|
|
37
|
+
const conn = activeDbConnection();
|
|
38
|
+
return {
|
|
39
|
+
serverUrl: `http://${host}:${port}`,
|
|
40
|
+
dbHost: conn.host ?? "localhost",
|
|
41
|
+
dbName: conn.database ?? "qgrid",
|
|
42
|
+
openai: {
|
|
43
|
+
minWorkersPerToken: pool.minWorkersPerToken,
|
|
44
|
+
maxWorkersPerToken: pool.maxWorkersPerToken,
|
|
45
|
+
autoscale: pool.autoscale,
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// dispatcher 미초기화(부팅 직후) 시 0 으로 응답한다 — 가벼운 스냅샷이라 오류로 만들지 않는다.
|
|
52
|
+
function currentVitals(): MonitVitals {
|
|
53
|
+
return {
|
|
54
|
+
openaiWorkerCount: QgridDispatcher.openaiDispatcher?.workerCount ?? 0,
|
|
55
|
+
openaiReadyWorkerCount: QgridDispatcher.openaiDispatcher?.readyWorkerCount ?? 0,
|
|
56
|
+
openaiQueueLength: QgridDispatcher.openaiDispatcher?.queueLength ?? 0,
|
|
57
|
+
openaiWorkersByToken: QgridDispatcher.openaiDispatcher?.workerCountsByToken ?? [],
|
|
58
|
+
anthropicTokenCount: QgridDispatcher.anthropicDispatcher?.tokenCount ?? 0,
|
|
59
|
+
anthropicTokenNames: QgridDispatcher.anthropicDispatcher?.tokenNames ?? [],
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// 실제 활성 knex 연결에서 읽는다 — dev 서버의 통합 테스트 러너가 process.env.QGRID_DB_NAME 을
|
|
64
|
+
// 테스트 DB 이름으로 덮어쓰므로 env 는 신뢰할 수 없다. DB 미초기화(부팅 전) 시에만 env 폴백.
|
|
65
|
+
function activeDbConnection(): { host?: string; database?: string } {
|
|
66
|
+
try {
|
|
67
|
+
const connection = DB.getDBConfig("w").connection;
|
|
68
|
+
if (typeof connection === "object" && connection !== null) {
|
|
69
|
+
return connection as { host?: string; database?: string };
|
|
70
|
+
}
|
|
71
|
+
} catch {
|
|
72
|
+
// DB config 미초기화 — env 폴백으로 진행
|
|
73
|
+
}
|
|
74
|
+
return { host: process.env.QGRID_DB_HOST, database: process.env.QGRID_DB_NAME };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export const MonitFrame = new MonitFrameClass();
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
// ─── Monit (프로세스 로그 관측) ───
|
|
4
|
+
//
|
|
5
|
+
// timestamp/processStartedAt 은 epoch ms 숫자다. web 전역 dateReviver 가 ISO 문자열을
|
|
6
|
+
// Date 로 변환하므로, 숫자만이 재시작 감지(processStartedAt 동등성)와 커서 비교를 보존한다.
|
|
7
|
+
// 이 파일은 web 으로 그대로 sync 되므로 서버 전용 import 를 두지 않는다.
|
|
8
|
+
|
|
9
|
+
export const MonitLogEntry = z.object({
|
|
10
|
+
seq: z.number(),
|
|
11
|
+
timestamp: z.number(),
|
|
12
|
+
level: z.string(),
|
|
13
|
+
category: z.array(z.string()),
|
|
14
|
+
text: z.string(),
|
|
15
|
+
});
|
|
16
|
+
export type MonitLogEntry = z.infer<typeof MonitLogEntry>;
|
|
17
|
+
|
|
18
|
+
// 폴링에 편승하는 라이브 카운트 — 깊은 워커/큐 진단은 audit 대시보드(별도)의 몫이고,
|
|
19
|
+
// 여기는 "지금 몇 개 돌고 큐가 쌓이나"만 가볍게 답한다.
|
|
20
|
+
export const MonitVitals = z.object({
|
|
21
|
+
openaiWorkerCount: z.number(),
|
|
22
|
+
openaiReadyWorkerCount: z.number(),
|
|
23
|
+
openaiQueueLength: z.number(),
|
|
24
|
+
// 토큰별 현재 워커 수 — autoscale 로 늘고 주는 것을 토큰 단위로 본다
|
|
25
|
+
openaiWorkersByToken: z.array(z.object({ name: z.string(), count: z.number() })),
|
|
26
|
+
anthropicTokenCount: z.number(),
|
|
27
|
+
// fresh-spawn 이라 워커 수는 없다 — 풀에 어떤 토큰이 있는지만 보여준다
|
|
28
|
+
anthropicTokenNames: z.array(z.string()),
|
|
29
|
+
});
|
|
30
|
+
export type MonitVitals = z.infer<typeof MonitVitals>;
|
|
31
|
+
|
|
32
|
+
export const MonitLogChunk = z.object({
|
|
33
|
+
processStartedAt: z.number(),
|
|
34
|
+
entries: z.array(MonitLogEntry),
|
|
35
|
+
nextCursor: z.number(),
|
|
36
|
+
// 호출자의 커서가 buffer eviction 에 밀려 유실된 라인 수
|
|
37
|
+
dropped: z.number(),
|
|
38
|
+
vitals: MonitVitals,
|
|
39
|
+
});
|
|
40
|
+
export type MonitLogChunk = z.infer<typeof MonitLogChunk>;
|
|
41
|
+
|
|
42
|
+
// 프로세스 단위 정적 환경 정보 — 어느 서버/DB 를 보고 있는지 한눈에 확인하는 용도.
|
|
43
|
+
// 비밀값(DB 비밀번호, 토큰)은 절대 싣지 않는다.
|
|
44
|
+
export const MonitServerInfo = z.object({
|
|
45
|
+
serverUrl: z.string(),
|
|
46
|
+
dbHost: z.string(),
|
|
47
|
+
dbName: z.string(),
|
|
48
|
+
openai: z.object({
|
|
49
|
+
minWorkersPerToken: z.number(),
|
|
50
|
+
maxWorkersPerToken: z.number(),
|
|
51
|
+
autoscale: z.boolean(),
|
|
52
|
+
}),
|
|
53
|
+
});
|
|
54
|
+
export type MonitServerInfo = z.infer<typeof MonitServerInfo>;
|
|
@@ -305,4 +305,28 @@ describe("QgridFrame.usage", () => {
|
|
|
305
305
|
});
|
|
306
306
|
expect(getRateLimitsByTokenIdMock).not.toHaveBeenCalled();
|
|
307
307
|
});
|
|
308
|
+
|
|
309
|
+
it("returns the provider-reported OpenAI window duration", async () => {
|
|
310
|
+
findManyMock.mockResolvedValueOnce({
|
|
311
|
+
rows: [{ ...tokenEntry, provider: "openai", active: true }],
|
|
312
|
+
});
|
|
313
|
+
getRateLimitsByTokenIdMock.mockResolvedValueOnce({
|
|
314
|
+
data: {
|
|
315
|
+
rateLimits: {
|
|
316
|
+
primary: { usedPercent: 11, resetsAt: 1_784_000_000, windowDurationMins: 10_080 },
|
|
317
|
+
secondary: null,
|
|
318
|
+
},
|
|
319
|
+
},
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
await expect(QgridFrame.usage(1)).resolves.toEqual({
|
|
323
|
+
provider: "openai",
|
|
324
|
+
fiveHour: {
|
|
325
|
+
utilization: 11,
|
|
326
|
+
resetsAt: new Date(1_784_000_000 * 1_000).toISOString(),
|
|
327
|
+
windowDurationMins: 10_080,
|
|
328
|
+
},
|
|
329
|
+
sevenDay: null,
|
|
330
|
+
});
|
|
331
|
+
});
|
|
308
332
|
});
|
|
@@ -583,12 +583,14 @@ class QgridFrameClass extends BaseFrameClass {
|
|
|
583
583
|
? {
|
|
584
584
|
utilization: rl.primary.usedPercent,
|
|
585
585
|
resetsAt: unixSecondsToIso(rl.primary.resetsAt),
|
|
586
|
+
windowDurationMins: rl.primary.windowDurationMins,
|
|
586
587
|
}
|
|
587
588
|
: null,
|
|
588
589
|
sevenDay: rl?.secondary
|
|
589
590
|
? {
|
|
590
591
|
utilization: rl.secondary.usedPercent,
|
|
591
592
|
resetsAt: unixSecondsToIso(rl.secondary.resetsAt),
|
|
593
|
+
windowDurationMins: rl.secondary.windowDurationMins,
|
|
592
594
|
}
|
|
593
595
|
: null,
|
|
594
596
|
};
|
|
@@ -92,3 +92,11 @@ export namespace QgridService {
|
|
|
92
92
|
export const health = (): SSRQuery =>
|
|
93
93
|
createSSRQuery("QgridFrame", "health", [], ["Qgrid", "health"]);
|
|
94
94
|
}
|
|
95
|
+
|
|
96
|
+
export namespace MonitService {
|
|
97
|
+
export const monitLogs = (cursor?: number): SSRQuery =>
|
|
98
|
+
createSSRQuery("MonitFrame", "monitLogs", [cursor], ["Monit", "monitLogs"]);
|
|
99
|
+
|
|
100
|
+
export const monitInfo = (): SSRQuery =>
|
|
101
|
+
createSSRQuery("MonitFrame", "monitInfo", [], ["Monit", "monitInfo"]);
|
|
102
|
+
}
|
|
@@ -492,4 +492,16 @@ Content-Type: application/json
|
|
|
492
492
|
|
|
493
493
|
GET {{baseUrl}}/api/qgrid/health
|
|
494
494
|
?
|
|
495
|
+
Content-Type: application/json
|
|
496
|
+
|
|
497
|
+
###
|
|
498
|
+
|
|
499
|
+
GET {{baseUrl}}/api/monit/monitLogs
|
|
500
|
+
?cursor=0
|
|
501
|
+
Content-Type: application/json
|
|
502
|
+
|
|
503
|
+
###
|
|
504
|
+
|
|
505
|
+
GET {{baseUrl}}/api/monit/monitInfo
|
|
506
|
+
?
|
|
495
507
|
Content-Type: application/json
|
|
@@ -7,6 +7,7 @@ import dotenv from "dotenv";
|
|
|
7
7
|
import { CachePresets, defineConfig } from "sonamu";
|
|
8
8
|
import { drivers as cacheDrivers, store } from "sonamu/cache";
|
|
9
9
|
|
|
10
|
+
import { monitLogSink } from "./application/monit/log-buffer";
|
|
10
11
|
import { QgridDispatcher } from "./application/qgrid/qgrid.dispatcher";
|
|
11
12
|
import { QgridFrame } from "./application/qgrid/qgrid.frame";
|
|
12
13
|
import { TokenSubscriber } from "./application/qgrid/token-subscriber";
|
|
@@ -81,12 +82,14 @@ export default defineConfig({
|
|
|
81
82
|
categoryTruncate: "middle",
|
|
82
83
|
}),
|
|
83
84
|
}),
|
|
85
|
+
// Monit 탭용 in-memory ring buffer. 포맷은 sink 별 — 콘솔 출력은 그대로다.
|
|
86
|
+
monit: monitLogSink,
|
|
84
87
|
},
|
|
85
88
|
loggers: [
|
|
86
89
|
// qgrid 로그 (요청 분배, oauth/usage API)
|
|
87
|
-
{ category: ["qgrid"], sinks: ["console"], lowestLevel: "info" },
|
|
90
|
+
{ category: ["qgrid"], sinks: ["console", "monit"], lowestLevel: "info" },
|
|
88
91
|
// sonamu 로그는 warning 이상만
|
|
89
|
-
{ category: ["sonamu"], sinks: ["console"], lowestLevel: "warning" },
|
|
92
|
+
{ category: ["sonamu"], sinks: ["console", "monit"], lowestLevel: "warning" },
|
|
90
93
|
// fastify 로그 차단
|
|
91
94
|
{ category: ["fastify"], sinks: [], lowestLevel: "fatal" },
|
|
92
95
|
],
|
|
@@ -92,6 +92,11 @@ export class AnthropicDispatcher implements ProviderDispatcher {
|
|
|
92
92
|
return this.tokenPool.size;
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
// Monit 표시용 — 풀에 있는 토큰 이름 스냅샷 (이름순 정렬)
|
|
96
|
+
get tokenNames(): string[] {
|
|
97
|
+
return [...this.tokenPool.values()].map((token) => token.name).toSorted();
|
|
98
|
+
}
|
|
99
|
+
|
|
95
100
|
// DB active anthropic 토큰 목록으로 풀을 재동기화한다(periodic reconcile/재연결용).
|
|
96
101
|
// LISTEN/NOTIFY 가 끊긴 동안 유실된 추가/삭제/비활성화를 DB 기준으로 다시 맞춘다.
|
|
97
102
|
// 이벤트 핸들러를 재사용해 풀 갱신이 일관되게 적용되도록 한다:
|