@team-semicolon/semo-cli 4.3.0 → 4.4.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.
@@ -51,11 +51,11 @@ function registerGetCommands(program) {
51
51
  // ── semo get projects ───────────────────────────────────────
52
52
  getCmd
53
53
  .command("projects")
54
- .description("프로젝트 목록 조회 (KB 기반)")
55
- .option("--active", "활성 프로젝트만 (metadata.status='active')")
54
+ .description("서비스 인스턴스 목록 조회 (온톨로지 기반)")
55
+ .option("--active", "활성 서비스만 (status='active')")
56
56
  .option("--format <type>", "출력 형식 (table|json|md)", "table")
57
57
  .action(async (options) => {
58
- const spinner = (0, ora_1.default)("프로젝트 조회 중...").start();
58
+ const spinner = (0, ora_1.default)("서비스 인스턴스 조회 중...").start();
59
59
  const connected = await (0, database_1.isDbConnected)();
60
60
  if (!connected) {
61
61
  spinner.fail("DB 연결 실패");
@@ -64,29 +64,43 @@ function registerGetCommands(program) {
64
64
  }
65
65
  try {
66
66
  const pool = (0, database_1.getPool)();
67
- let entries = await (0, kb_1.kbList)(pool, { domain: "project", limit: 100 });
67
+ const client = await pool.connect();
68
+ // Query service instances from ontology + their status from KB
69
+ const result = await client.query(`
70
+ SELECT o.domain, o.description,
71
+ ks.content as status,
72
+ (SELECT COUNT(*)::int FROM semo.knowledge_base k WHERE k.domain = o.domain) as entry_count,
73
+ (SELECT k2.content FROM semo.knowledge_base k2 WHERE k2.domain = o.domain AND k2.key = 'po' LIMIT 1) as po
74
+ FROM semo.ontology o
75
+ LEFT JOIN semo.knowledge_base ks ON ks.domain = o.domain AND ks.key = 'status'
76
+ WHERE o.entity_type = 'service'
77
+ ORDER BY o.domain
78
+ `);
79
+ client.release();
80
+ let rows = result.rows;
68
81
  if (options.active) {
69
- entries = entries.filter(e => {
70
- const meta = e.metadata;
71
- return meta?.status === "active";
72
- });
82
+ rows = rows.filter((r) => r.status === "active");
73
83
  }
74
84
  spinner.stop();
75
85
  if (options.format === "json") {
76
- console.log(JSON.stringify(entries, null, 2));
86
+ console.log(JSON.stringify(rows, null, 2));
77
87
  }
78
88
  else if (options.format === "md") {
79
- for (const e of entries) {
80
- console.log(`\n## ${e.key}\n`);
81
- console.log(e.content);
89
+ for (const r of rows) {
90
+ console.log(`\n## ${r.domain}\n`);
91
+ console.log(`상태: ${r.status || "-"} | 담당: ${r.po || "-"}`);
92
+ if (r.description)
93
+ console.log(r.description);
82
94
  }
83
95
  }
84
96
  else {
85
- printTable(["key", "content", "updated_at"], entries.map(e => [
86
- e.key,
87
- (e.content || "").substring(0, 60),
88
- e.updated_at ? new Date(e.updated_at).toLocaleString("ko-KR") : "-",
89
- ]), "📁 프로젝트 (KB)");
97
+ printTable(["service", "status", "po", "entries", "description"], rows.map((r) => [
98
+ r.domain,
99
+ r.status || "-",
100
+ r.po || "-",
101
+ String(r.entry_count || 0),
102
+ (r.description || "").substring(0, 40),
103
+ ]), "📁 서비스 인스턴스");
90
104
  }
91
105
  }
92
106
  catch (err) {
@@ -148,7 +148,8 @@ async function syncMemories(candidates, state, force, dryRun) {
148
148
  }
149
149
  try {
150
150
  const domain = "memory";
151
- const key = stateKey;
151
+ const flatKey = "memory";
152
+ const subKey = stateKey; // sourceId/date
152
153
  const metadata = {
153
154
  source_type: candidate.sourceType,
154
155
  source_id: candidate.sourceId,
@@ -158,19 +159,20 @@ async function syncMemories(candidates, state, force, dryRun) {
158
159
  synced_at: new Date().toISOString(),
159
160
  };
160
161
  // Generate embedding
161
- const text = `${key}: ${candidate.content}`;
162
+ const text = `memory/${stateKey}: ${candidate.content}`;
162
163
  const embedding = await (0, kb_1.generateEmbedding)(text);
163
164
  const embeddingStr = embedding ? `[${embedding.join(",")}]` : null;
164
165
  const client = await pool.connect();
165
166
  try {
166
- await client.query(`INSERT INTO semo.knowledge_base (domain, key, content, metadata, created_by, embedding)
167
- VALUES ($1, $2, $3, $4, $5, $6::vector)
168
- ON CONFLICT (domain, key) DO UPDATE SET
167
+ await client.query(`INSERT INTO semo.knowledge_base (domain, key, sub_key, content, metadata, created_by, embedding)
168
+ VALUES ($1, $2, $3, $4, $5, $6, $7::vector)
169
+ ON CONFLICT (domain, key, sub_key) DO UPDATE SET
169
170
  content = EXCLUDED.content,
170
171
  metadata = EXCLUDED.metadata,
171
172
  embedding = EXCLUDED.embedding`, [
172
173
  domain,
173
- key,
174
+ flatKey,
175
+ subKey,
174
176
  candidate.content,
175
177
  JSON.stringify(metadata),
176
178
  "semo-memory-sync",
@@ -273,8 +275,8 @@ function registerMemoryCommands(program) {
273
275
  }
274
276
  // Group by source
275
277
  const grouped = {};
276
- for (const [key, val] of entries) {
277
- const [sourceId, date] = key.split("/");
278
+ for (const [stKey, val] of entries) {
279
+ const [sourceId, date] = stKey.split("/");
278
280
  if (specificBot && sourceId !== specificBot)
279
281
  continue;
280
282
  if (!grouped[sourceId])
@@ -88,7 +88,7 @@ function scanSkills(semoSystemDir) {
88
88
  async function syncSkillsToDB(client, semoSystemDir) {
89
89
  const skills = scanSkills(semoSystemDir);
90
90
  for (const skill of skills) {
91
- await client.query(`INSERT INTO skill_definitions (name, prompt, package, metadata, is_active, office_id)
91
+ await client.query(`INSERT INTO semo.skill_definitions (name, prompt, package, metadata, is_active, office_id)
92
92
  VALUES ($1, $2, $3, $4, true, NULL)
93
93
  ON CONFLICT (name, office_id) DO UPDATE SET
94
94
  prompt = EXCLUDED.prompt,
@@ -0,0 +1,11 @@
1
+ /**
2
+ * semo test — 테스트 관리
3
+ *
4
+ * semo test list — 등록된 스위트 + 최근 실행 상태
5
+ * semo test run [suite] — 스위트 실행 + DB 기록
6
+ * semo test run --all — 전체 스위트 실행
7
+ * semo test run --notify — 실패 시 Slack 알림
8
+ * semo test history [suite] — 실행 이력
9
+ */
10
+ import { Command } from "commander";
11
+ export declare function registerTestCommands(program: Command): void;
@@ -0,0 +1,520 @@
1
+ "use strict";
2
+ /**
3
+ * semo test — 테스트 관리
4
+ *
5
+ * semo test list — 등록된 스위트 + 최근 실행 상태
6
+ * semo test run [suite] — 스위트 실행 + DB 기록
7
+ * semo test run --all — 전체 스위트 실행
8
+ * semo test run --notify — 실패 시 Slack 알림
9
+ * semo test history [suite] — 실행 이력
10
+ */
11
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
12
+ if (k2 === undefined) k2 = k;
13
+ var desc = Object.getOwnPropertyDescriptor(m, k);
14
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
15
+ desc = { enumerable: true, get: function() { return m[k]; } };
16
+ }
17
+ Object.defineProperty(o, k2, desc);
18
+ }) : (function(o, m, k, k2) {
19
+ if (k2 === undefined) k2 = k;
20
+ o[k2] = m[k];
21
+ }));
22
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
23
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
24
+ }) : function(o, v) {
25
+ o["default"] = v;
26
+ });
27
+ var __importStar = (this && this.__importStar) || (function () {
28
+ var ownKeys = function(o) {
29
+ ownKeys = Object.getOwnPropertyNames || function (o) {
30
+ var ar = [];
31
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
32
+ return ar;
33
+ };
34
+ return ownKeys(o);
35
+ };
36
+ return function (mod) {
37
+ if (mod && mod.__esModule) return mod;
38
+ var result = {};
39
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
40
+ __setModuleDefault(result, mod);
41
+ return result;
42
+ };
43
+ })();
44
+ var __importDefault = (this && this.__importDefault) || function (mod) {
45
+ return (mod && mod.__esModule) ? mod : { "default": mod };
46
+ };
47
+ Object.defineProperty(exports, "__esModule", { value: true });
48
+ exports.registerTestCommands = registerTestCommands;
49
+ const chalk_1 = __importDefault(require("chalk"));
50
+ const ora_1 = __importDefault(require("ora"));
51
+ const child_process_1 = require("child_process");
52
+ const crypto_1 = require("crypto");
53
+ const path = __importStar(require("path"));
54
+ const os = __importStar(require("os"));
55
+ const database_1 = require("../database");
56
+ const slack_notify_1 = require("../slack-notify");
57
+ const workspace_audit_1 = require("../test-runners/workspace-audit");
58
+ // ============================================================
59
+ // JSONL Parser
60
+ // ============================================================
61
+ function parseTestOutputLine(line) {
62
+ const trimmed = line.trim();
63
+ if (!trimmed || !trimmed.startsWith("{"))
64
+ return null;
65
+ try {
66
+ const obj = JSON.parse(trimmed);
67
+ if (obj.type === "case" || obj.type === "summary")
68
+ return obj;
69
+ return null;
70
+ }
71
+ catch {
72
+ return null;
73
+ }
74
+ }
75
+ // ============================================================
76
+ // Runner Path Resolution
77
+ // ============================================================
78
+ function resolveRunnerPath(runnerPath) {
79
+ // Expand ~ to HOME
80
+ if (runnerPath.startsWith("~/")) {
81
+ return path.join(os.homedir(), runnerPath.slice(2));
82
+ }
83
+ // Relative path → resolve from project root (cwd)
84
+ if (!path.isAbsolute(runnerPath)) {
85
+ return path.resolve(process.cwd(), runnerPath);
86
+ }
87
+ return runnerPath;
88
+ }
89
+ // ============================================================
90
+ // Test Suite Execution
91
+ // ============================================================
92
+ async function executeTestSuite(suite, triggeredBy) {
93
+ const pool = (0, database_1.getPool)();
94
+ const runId = (0, crypto_1.randomUUID)();
95
+ const startedAt = new Date();
96
+ // Create run record
97
+ await pool.query(`INSERT INTO semo.test_runs (run_id, suite_id, triggered_by, started_at, status)
98
+ VALUES ($1, $2, $3, $4, 'running')`, [runId, suite.suite_id, triggeredBy, startedAt.toISOString()]);
99
+ // ── Declarative runner: DB에서 규칙 로드 → 동적 TC 생성 ──
100
+ if (suite.runner_type === "declarative") {
101
+ return executeDeclarativeSuite(pool, suite, runId);
102
+ }
103
+ // ── Script runner: 외부 프로세스 spawn ──
104
+ const resolvedPath = resolveRunnerPath(suite.runner_path || "");
105
+ const cases = [];
106
+ let pass = 0;
107
+ let fail = 0;
108
+ let warn = 0;
109
+ const failedLabels = [];
110
+ return new Promise((resolve) => {
111
+ let cmd;
112
+ let args;
113
+ let env;
114
+ if (suite.runner_type === "shell") {
115
+ cmd = "bash";
116
+ args = [resolvedPath];
117
+ env = { ...process.env, OUTPUT_FORMAT: "json" };
118
+ }
119
+ else {
120
+ cmd = "npx";
121
+ args = ["tsx", resolvedPath, "--json"];
122
+ env = { ...process.env };
123
+ }
124
+ const child = (0, child_process_1.spawn)(cmd, args, {
125
+ cwd: process.cwd(),
126
+ env,
127
+ stdio: ["ignore", "pipe", "pipe"],
128
+ });
129
+ let buffer = "";
130
+ child.stdout.on("data", (chunk) => {
131
+ buffer += chunk.toString();
132
+ const lines = buffer.split("\n");
133
+ buffer = lines.pop() || ""; // Keep incomplete last line
134
+ for (const line of lines) {
135
+ const parsed = parseTestOutputLine(line);
136
+ if (!parsed)
137
+ continue;
138
+ if (parsed.type === "case") {
139
+ cases.push(parsed);
140
+ if (parsed.status === "pass")
141
+ pass++;
142
+ else if (parsed.status === "fail") {
143
+ fail++;
144
+ if (parsed.label)
145
+ failedLabels.push(parsed.label);
146
+ }
147
+ else if (parsed.status === "warn")
148
+ warn++;
149
+ }
150
+ else if (parsed.type === "summary") {
151
+ // Use summary counts as authoritative if provided
152
+ if (typeof parsed.pass === "number")
153
+ pass = parsed.pass;
154
+ if (typeof parsed.fail === "number")
155
+ fail = parsed.fail;
156
+ if (typeof parsed.warn === "number")
157
+ warn = parsed.warn;
158
+ }
159
+ }
160
+ });
161
+ child.stderr.on("data", () => {
162
+ /* discard stderr */
163
+ });
164
+ const timeout = setTimeout(() => {
165
+ child.kill("SIGTERM");
166
+ }, 600000); // 10 minute timeout
167
+ child.on("close", async (code) => {
168
+ clearTimeout(timeout);
169
+ // Process remaining buffer
170
+ if (buffer.trim()) {
171
+ const parsed = parseTestOutputLine(buffer);
172
+ if (parsed?.type === "case") {
173
+ cases.push(parsed);
174
+ if (parsed.status === "pass")
175
+ pass++;
176
+ else if (parsed.status === "fail") {
177
+ fail++;
178
+ if (parsed.label)
179
+ failedLabels.push(parsed.label);
180
+ }
181
+ else if (parsed.status === "warn")
182
+ warn++;
183
+ }
184
+ else if (parsed?.type === "summary") {
185
+ if (typeof parsed.pass === "number")
186
+ pass = parsed.pass;
187
+ if (typeof parsed.fail === "number")
188
+ fail = parsed.fail;
189
+ if (typeof parsed.warn === "number")
190
+ warn = parsed.warn;
191
+ }
192
+ }
193
+ const status = code !== 0 || fail > 0 ? "failed" : cases.length === 0 ? "error" : "passed";
194
+ const summary = `${pass} passed, ${fail} failed, ${warn} warn`;
195
+ try {
196
+ // Insert individual results
197
+ for (const c of cases) {
198
+ if (c.type !== "case")
199
+ continue;
200
+ await pool.query(`INSERT INTO semo.test_results (run_id, case_id, suite_id, label, status, detail, duration_ms)
201
+ VALUES ($1, $2, $3, $4, $5, $6, $7)`, [
202
+ runId,
203
+ c.id || c.label || "unknown",
204
+ suite.suite_id,
205
+ c.label || c.id || "unknown",
206
+ c.status || "skip",
207
+ c.detail || null,
208
+ c.duration_ms || null,
209
+ ]);
210
+ }
211
+ // Update run record
212
+ await pool.query(`UPDATE semo.test_runs
213
+ SET finished_at = NOW(), total_pass = $1, total_fail = $2, total_warn = $3,
214
+ status = $4, summary = $5
215
+ WHERE run_id = $6`, [pass, fail, warn, status, summary, runId]);
216
+ }
217
+ catch (err) {
218
+ console.error(chalk_1.default.red(` DB 기록 오류: ${err.message}`));
219
+ }
220
+ resolve({ runId, suiteId: suite.suite_id, status, pass, fail, warn, failedLabels });
221
+ });
222
+ });
223
+ }
224
+ // ============================================================
225
+ // Declarative Runner
226
+ // ============================================================
227
+ async function executeDeclarativeSuite(pool, suite, runId) {
228
+ let outputs = [];
229
+ // Dispatch by rule_source
230
+ if (suite.rule_source === "bot_workspace_standard") {
231
+ outputs = await (0, workspace_audit_1.runDeclarativeWorkspaceAudit)(pool);
232
+ }
233
+ else {
234
+ return {
235
+ runId,
236
+ suiteId: suite.suite_id,
237
+ status: "error",
238
+ pass: 0,
239
+ fail: 0,
240
+ warn: 0,
241
+ failedLabels: [`unknown rule_source: ${suite.rule_source}`],
242
+ };
243
+ }
244
+ let pass = 0;
245
+ let fail = 0;
246
+ let warn = 0;
247
+ const failedLabels = [];
248
+ for (const o of outputs) {
249
+ if (o.type === "summary") {
250
+ pass = o.pass ?? pass;
251
+ fail = o.fail ?? fail;
252
+ warn = o.warn ?? warn;
253
+ continue;
254
+ }
255
+ if (o.type !== "case")
256
+ continue;
257
+ // Record to DB
258
+ await pool.query(`INSERT INTO semo.test_results (run_id, case_id, suite_id, label, status, detail)
259
+ VALUES ($1, $2, $3, $4, $5, $6)`, [
260
+ runId,
261
+ o.id || o.label || "unknown",
262
+ suite.suite_id,
263
+ o.label || o.id || "unknown",
264
+ o.status || "skip",
265
+ o.detail || null,
266
+ ]);
267
+ if (o.status === "fail")
268
+ failedLabels.push(o.label || "");
269
+ }
270
+ const status = fail > 0 ? "failed" : "passed";
271
+ const summary = `${pass} passed, ${fail} failed, ${warn} warn`;
272
+ await pool.query(`UPDATE semo.test_runs
273
+ SET finished_at = NOW(), total_pass = $1, total_fail = $2, total_warn = $3,
274
+ status = $4, summary = $5
275
+ WHERE run_id = $6`, [pass, fail, warn, status, summary, runId]);
276
+ return { runId, suiteId: suite.suite_id, status, pass, fail, warn, failedLabels };
277
+ }
278
+ // ============================================================
279
+ // Commands
280
+ // ============================================================
281
+ function registerTestCommands(program) {
282
+ const testCmd = program
283
+ .command("test")
284
+ .description("테스트 관리 — 실행, 이력 조회");
285
+ // ── semo test list ──
286
+ testCmd
287
+ .command("list")
288
+ .description("등록된 테스트 스위트 목록 + 최근 실행 상태")
289
+ .action(async () => {
290
+ const pool = (0, database_1.getPool)();
291
+ try {
292
+ const { rows } = await pool.query(`
293
+ SELECT s.suite_id, s.name, s.layer, s.runner_type, s.enabled,
294
+ r.status AS last_status,
295
+ r.started_at::text AS last_run_at,
296
+ r.total_pass AS last_pass,
297
+ r.total_fail AS last_fail,
298
+ r.total_warn AS last_warn
299
+ FROM semo.test_suites s
300
+ LEFT JOIN LATERAL (
301
+ SELECT * FROM semo.test_runs
302
+ WHERE suite_id = s.suite_id
303
+ ORDER BY started_at DESC LIMIT 1
304
+ ) r ON true
305
+ ORDER BY s.suite_id
306
+ `);
307
+ if (rows.length === 0) {
308
+ console.log(chalk_1.default.yellow("등록된 테스트 스위트가 없습니다."));
309
+ return;
310
+ }
311
+ console.log(chalk_1.default.bold("\n 테스트 스위트 목록\n"));
312
+ // Header
313
+ const hdr = padR("Suite ID", 22) +
314
+ padR("Layer", 14) +
315
+ padR("Runner", 8) +
316
+ padR("Last Run", 22) +
317
+ padR("Status", 10) +
318
+ "Result";
319
+ console.log(chalk_1.default.gray(` ${hdr}`));
320
+ console.log(chalk_1.default.gray(` ${"─".repeat(90)}`));
321
+ for (const row of rows) {
322
+ const statusColor = row.last_status === "passed"
323
+ ? chalk_1.default.green
324
+ : row.last_status === "failed"
325
+ ? chalk_1.default.red
326
+ : chalk_1.default.gray;
327
+ const lastRun = row.last_run_at
328
+ ? timeAgo(new Date(row.last_run_at))
329
+ : "-";
330
+ const result = row.last_pass !== null
331
+ ? `${row.last_pass}/${(row.last_pass || 0) + (row.last_fail || 0) + (row.last_warn || 0)}`
332
+ : "-";
333
+ console.log(` ${padR(row.suite_id, 22)}${padR(row.layer, 14)}${padR(row.runner_type, 8)}${padR(lastRun, 22)}${statusColor(padR(row.last_status || "no runs", 10))}${result}`);
334
+ }
335
+ console.log();
336
+ }
337
+ finally {
338
+ await (0, database_1.closeConnection)();
339
+ }
340
+ });
341
+ // ── semo test run [suite] ──
342
+ testCmd
343
+ .command("run [suite]")
344
+ .description("테스트 스위트 실행 + DB 기록")
345
+ .option("--all", "비활성 포함 전체 실행")
346
+ .option("--notify", "실패 시 Slack 알림")
347
+ .option("--triggered-by <who>", "트리거 주체", "manual")
348
+ .action(async (suiteArg, options) => {
349
+ const pool = (0, database_1.getPool)();
350
+ try {
351
+ let suites;
352
+ if (suiteArg) {
353
+ // Single suite
354
+ const { rows } = await pool.query("SELECT * FROM semo.test_suites WHERE suite_id = $1", [suiteArg]);
355
+ if (rows.length === 0) {
356
+ console.error(chalk_1.default.red(`스위트 '${suiteArg}'를 찾을 수 없습니다.`));
357
+ const allSuites = await pool.query("SELECT suite_id FROM semo.test_suites");
358
+ console.log(chalk_1.default.gray(`등록된 스위트: ${allSuites.rows.map((r) => r.suite_id).join(", ")}`));
359
+ return;
360
+ }
361
+ suites = rows;
362
+ }
363
+ else {
364
+ // All suites
365
+ const where = options.all ? "" : "WHERE enabled = true";
366
+ const { rows } = await pool.query(`SELECT * FROM semo.test_suites ${where} ORDER BY suite_id`);
367
+ suites = rows;
368
+ }
369
+ if (suites.length === 0) {
370
+ console.log(chalk_1.default.yellow("실행할 스위트가 없습니다."));
371
+ return;
372
+ }
373
+ console.log(chalk_1.default.bold(`\n ${suites.length}개 스위트 실행\n`));
374
+ const results = [];
375
+ for (const suite of suites) {
376
+ const spinner = (0, ora_1.default)(`${suite.suite_id} (${suite.name})`).start();
377
+ try {
378
+ const result = await executeTestSuite(suite, options.triggeredBy || "manual");
379
+ results.push(result);
380
+ if (result.status === "passed") {
381
+ spinner.succeed(`${suite.suite_id}: ${chalk_1.default.green("PASSED")} (${result.pass}/${result.pass + result.fail + result.warn})`);
382
+ }
383
+ else if (result.status === "failed") {
384
+ spinner.fail(`${suite.suite_id}: ${chalk_1.default.red("FAILED")} (pass: ${result.pass}, fail: ${result.fail}, warn: ${result.warn})`);
385
+ }
386
+ else {
387
+ spinner.warn(`${suite.suite_id}: ${chalk_1.default.yellow("ERROR")} (출력 파싱 실패)`);
388
+ }
389
+ }
390
+ catch (err) {
391
+ spinner.fail(`${suite.suite_id}: ${chalk_1.default.red("ERROR")} — ${err.message}`);
392
+ results.push({
393
+ runId: "error",
394
+ suiteId: suite.suite_id,
395
+ status: "error",
396
+ pass: 0,
397
+ fail: 0,
398
+ warn: 0,
399
+ failedLabels: [],
400
+ });
401
+ }
402
+ }
403
+ // Summary
404
+ const totalPass = results.reduce((s, r) => s + r.pass, 0);
405
+ const totalFail = results.reduce((s, r) => s + r.fail, 0);
406
+ const totalWarn = results.reduce((s, r) => s + r.warn, 0);
407
+ const allPassed = results.every((r) => r.status === "passed");
408
+ console.log(`\n ${allPassed ? chalk_1.default.green("ALL PASSED") : chalk_1.default.red("FAILURES DETECTED")} — ${totalPass} pass, ${totalFail} fail, ${totalWarn} warn\n`);
409
+ // Slack notification
410
+ if (options.notify && !allPassed) {
411
+ const failedResults = results.filter((r) => r.status === "failed");
412
+ for (const fr of failedResults) {
413
+ const msg = (0, slack_notify_1.formatTestFailureMessage)(fr.suiteId, fr.runId, fr.pass, fr.fail, fr.warn, fr.failedLabels);
414
+ const sent = await (0, slack_notify_1.sendSlackNotification)(msg);
415
+ if (sent) {
416
+ console.log(chalk_1.default.gray(` Slack 알림 전송: ${fr.suiteId}`));
417
+ }
418
+ }
419
+ }
420
+ }
421
+ finally {
422
+ await (0, database_1.closeConnection)();
423
+ }
424
+ });
425
+ // ── semo test history [suite] ──
426
+ testCmd
427
+ .command("history [suite]")
428
+ .description("테스트 실행 이력 조회")
429
+ .option("--limit <n>", "최근 N건", "10")
430
+ .action(async (suiteArg, options) => {
431
+ const pool = (0, database_1.getPool)();
432
+ const limit = parseInt(options.limit) || 10;
433
+ try {
434
+ let query;
435
+ let params;
436
+ if (suiteArg) {
437
+ query = `
438
+ SELECT r.run_id, r.suite_id, s.name, r.triggered_by,
439
+ r.started_at::text, r.finished_at::text,
440
+ r.total_pass, r.total_fail, r.total_warn, r.status, r.summary
441
+ FROM semo.test_runs r
442
+ JOIN semo.test_suites s ON s.suite_id = r.suite_id
443
+ WHERE r.suite_id = $1
444
+ ORDER BY r.started_at DESC
445
+ LIMIT $2
446
+ `;
447
+ params = [suiteArg, limit];
448
+ }
449
+ else {
450
+ query = `
451
+ SELECT r.run_id, r.suite_id, s.name, r.triggered_by,
452
+ r.started_at::text, r.finished_at::text,
453
+ r.total_pass, r.total_fail, r.total_warn, r.status, r.summary
454
+ FROM semo.test_runs r
455
+ JOIN semo.test_suites s ON s.suite_id = r.suite_id
456
+ ORDER BY r.started_at DESC
457
+ LIMIT $1
458
+ `;
459
+ params = [limit];
460
+ }
461
+ const { rows } = await pool.query(query, params);
462
+ if (rows.length === 0) {
463
+ console.log(chalk_1.default.yellow("실행 이력이 없습니다."));
464
+ return;
465
+ }
466
+ console.log(chalk_1.default.bold(`\n 실행 이력${suiteArg ? ` — ${suiteArg}` : ""}\n`));
467
+ const hdr = padR("Run ID", 10) +
468
+ padR("Suite", 22) +
469
+ padR("Triggered", 10) +
470
+ padR("Started", 22) +
471
+ padR("Status", 10) +
472
+ "Result";
473
+ console.log(chalk_1.default.gray(` ${hdr}`));
474
+ console.log(chalk_1.default.gray(` ${"─".repeat(90)}`));
475
+ for (const row of rows) {
476
+ const statusColor = row.status === "passed"
477
+ ? chalk_1.default.green
478
+ : row.status === "failed"
479
+ ? chalk_1.default.red
480
+ : chalk_1.default.yellow;
481
+ const started = row.started_at
482
+ ? new Date(row.started_at).toLocaleString("ko-KR", {
483
+ timeZone: "Asia/Seoul",
484
+ month: "2-digit",
485
+ day: "2-digit",
486
+ hour: "2-digit",
487
+ minute: "2-digit",
488
+ })
489
+ : "-";
490
+ console.log(` ${padR(row.run_id.substring(0, 8), 10)}${padR(row.suite_id, 22)}${padR(row.triggered_by, 10)}${padR(started, 22)}${statusColor(padR(row.status, 10))}${row.summary || "-"}`);
491
+ }
492
+ console.log();
493
+ }
494
+ finally {
495
+ await (0, database_1.closeConnection)();
496
+ }
497
+ });
498
+ }
499
+ // ============================================================
500
+ // Helpers
501
+ // ============================================================
502
+ function padR(s, len) {
503
+ if (s.length >= len)
504
+ return s.substring(0, len);
505
+ return s + " ".repeat(len - s.length);
506
+ }
507
+ function timeAgo(date) {
508
+ const now = Date.now();
509
+ const diff = now - date.getTime();
510
+ const mins = Math.floor(diff / 60000);
511
+ if (mins < 1)
512
+ return "just now";
513
+ if (mins < 60)
514
+ return `${mins}m ago`;
515
+ const hours = Math.floor(mins / 60);
516
+ if (hours < 24)
517
+ return `${hours}h ago`;
518
+ const days = Math.floor(hours / 24);
519
+ return `${days}d ago`;
520
+ }
package/dist/database.js CHANGED
@@ -172,7 +172,7 @@ async function getActiveSkills() {
172
172
  ARRAY[]::text[]
173
173
  ) AS bot_ids,
174
174
  category, package, is_active, is_required, install_order, version
175
- FROM skill_definitions
175
+ FROM semo.skill_definitions
176
176
  WHERE is_active = true AND office_id IS NULL
177
177
  ORDER BY install_order
178
178
  `);
@@ -209,7 +209,7 @@ async function getActiveSkillsForBot(botId) {
209
209
  ) AS bot_ids,
210
210
  sd.category, sd.package, sd.is_active, sd.is_required,
211
211
  sd.install_order, sd.version
212
- FROM skill_definitions sd
212
+ FROM semo.skill_definitions sd
213
213
  WHERE sd.is_active = true
214
214
  AND sd.office_id IS NULL
215
215
  AND (NOT sd.metadata ? 'bot_ids' OR sd.metadata->'bot_ids' ? $1)