@principles/pd-cli 1.144.0 → 1.145.1
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/dist/commands/codex-ingest-catchup.d.ts +20 -0
- package/dist/commands/codex-ingest-catchup.d.ts.map +1 -0
- package/dist/commands/codex-ingest-catchup.js +95 -0
- package/dist/commands/codex-ingest-catchup.js.map +1 -0
- package/dist/commands/codex-worker.d.ts +20 -0
- package/dist/commands/codex-worker.d.ts.map +1 -0
- package/dist/commands/codex-worker.js +156 -0
- package/dist/commands/codex-worker.js.map +1 -0
- package/dist/commands/pain-list.d.ts +45 -0
- package/dist/commands/pain-list.d.ts.map +1 -0
- package/dist/commands/pain-list.js +165 -0
- package/dist/commands/pain-list.js.map +1 -0
- package/dist/commands/runtime-init.d.ts.map +1 -1
- package/dist/commands/runtime-init.js +12 -1
- package/dist/commands/runtime-init.js.map +1 -1
- package/dist/index.js +58 -0
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/commands/codex-ingest-catchup.ts +114 -0
- package/src/commands/codex-worker.ts +174 -0
- package/src/commands/pain-list.ts +203 -0
- package/src/commands/runtime-init.ts +12 -1
- package/src/index.ts +62 -0
- package/tests/commands/codex-worker-registration.test.ts +178 -0
- package/tests/commands/health.test.ts +4 -0
- package/tests/commands/pain-list.test.ts +185 -0
- package/tests/commands/runtime-init.test.ts +20 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pd pain list command unit tests — PRI-640 (Host Attribution v0.1).
|
|
3
|
+
*
|
|
4
|
+
* External contract: listPains reads trajectory.db readonly and filters by
|
|
5
|
+
* host (openclaw / codex / unknown); handlePainList preserves the strict
|
|
6
|
+
* --json contract and degraded-failure shapes (cli-1/cli-6).
|
|
7
|
+
*/
|
|
8
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
9
|
+
import * as fs from 'fs';
|
|
10
|
+
import * as os from 'os';
|
|
11
|
+
import * as path from 'path';
|
|
12
|
+
import Database from 'better-sqlite3';
|
|
13
|
+
|
|
14
|
+
vi.mock('../../src/resolve-workspace.js', () => ({
|
|
15
|
+
resolveWorkspaceDir: vi.fn().mockReturnValue('/fake/workspace'),
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
import { handlePainList, listPains } from '../../src/commands/pain-list.js';
|
|
19
|
+
import { resolveWorkspaceDir } from '../../src/resolve-workspace.js';
|
|
20
|
+
|
|
21
|
+
const tempDirs: string[] = [];
|
|
22
|
+
|
|
23
|
+
function openWorkspaceDb(prefix: string): { workspaceDir: string; db: Database.Database } {
|
|
24
|
+
const workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
25
|
+
tempDirs.push(workspaceDir);
|
|
26
|
+
const stateDir = path.join(workspaceDir, '.state');
|
|
27
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
28
|
+
return { workspaceDir, db: new Database(path.join(stateDir, 'trajectory.db')) };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Create a temp workspace whose trajectory.db uses the PRI-640 schema (host_kind column). */
|
|
32
|
+
function makeWorkspace(): { workspaceDir: string; db: Database.Database } {
|
|
33
|
+
const { workspaceDir, db } = openWorkspaceDb('pd-pain-list-');
|
|
34
|
+
db.prepare('CREATE TABLE sessions (session_id TEXT PRIMARY KEY, started_at TEXT NOT NULL, updated_at TEXT NOT NULL)').run();
|
|
35
|
+
db.prepare(`CREATE TABLE pain_events (
|
|
36
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
37
|
+
session_id TEXT NOT NULL, source TEXT NOT NULL, score REAL NOT NULL, reason TEXT,
|
|
38
|
+
severity TEXT, origin TEXT, confidence REAL, text TEXT,
|
|
39
|
+
canonical_pain_id TEXT, runtime_task_id TEXT, host_kind TEXT, created_at TEXT NOT NULL
|
|
40
|
+
)`).run();
|
|
41
|
+
db.prepare('CREATE UNIQUE INDEX idx_pain_events_canonical_pain_id ON pain_events(canonical_pain_id) WHERE canonical_pain_id IS NOT NULL').run();
|
|
42
|
+
return { workspaceDir, db };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Create a temp workspace whose trajectory.db predates PRI-640 (no host_kind column). */
|
|
46
|
+
function makePre640Workspace(): { workspaceDir: string; db: Database.Database } {
|
|
47
|
+
const { workspaceDir, db } = openWorkspaceDb('pd-pain-pre640-');
|
|
48
|
+
db.prepare('CREATE TABLE sessions (session_id TEXT PRIMARY KEY, started_at TEXT NOT NULL, updated_at TEXT NOT NULL)').run();
|
|
49
|
+
db.prepare(`CREATE TABLE pain_events (
|
|
50
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
51
|
+
session_id TEXT NOT NULL, source TEXT NOT NULL, score REAL NOT NULL, reason TEXT,
|
|
52
|
+
severity TEXT, origin TEXT, confidence REAL, text TEXT,
|
|
53
|
+
canonical_pain_id TEXT, runtime_task_id TEXT, created_at TEXT NOT NULL
|
|
54
|
+
)`).run();
|
|
55
|
+
db.prepare('CREATE UNIQUE INDEX idx_pain_events_canonical_pain_id ON pain_events(canonical_pain_id) WHERE canonical_pain_id IS NOT NULL').run();
|
|
56
|
+
return { workspaceDir, db };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function seedPain(db: Database.Database, row: { id: number; source: string; canonical: string; host: string | null; task?: string | null; created: string }): void {
|
|
60
|
+
db.prepare(`INSERT INTO pain_events (id, session_id, source, score, reason, severity, origin, confidence, text, canonical_pain_id, runtime_task_id, host_kind, created_at)
|
|
61
|
+
VALUES (?, 's1', ?, 80, 'r', 'moderate', 'system_infer', NULL, NULL, ?, ?, ?, ?)`)
|
|
62
|
+
.run(row.id, row.source, row.canonical, row.task ?? null, row.host, row.created);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function dbPathOf(db: Database.Database): string {
|
|
66
|
+
return db.name;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
afterEach(() => {
|
|
70
|
+
for (const dir of tempDirs.splice(0)) {
|
|
71
|
+
fs.rmSync(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 20 });
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe('listPains (PRI-640 host filter)', () => {
|
|
76
|
+
it('filters host=openclaw / codex / unknown and reports NULL as unknown', async () => {
|
|
77
|
+
const { workspaceDir, db } = makeWorkspace();
|
|
78
|
+
seedPain(db, { id: 1, source: 'user_correction', canonical: 'pain_oc_1', host: 'openclaw', task: 'diagnosis_pain_oc_1', created: '2026-09-01T10:00:00.000Z' });
|
|
79
|
+
seedPain(db, { id: 2, source: 'tool_failure', canonical: 'pain_cx_1', host: 'codex', created: '2026-09-01T11:00:00.000Z' });
|
|
80
|
+
seedPain(db, { id: 3, source: 'manual', canonical: 'pain_unknown_1', host: null, created: '2026-09-01T12:00:00.000Z' });
|
|
81
|
+
const dbPath = dbPathOf(db);
|
|
82
|
+
db.close();
|
|
83
|
+
|
|
84
|
+
const all = await listPains(dbPath, { limit: 10 });
|
|
85
|
+
expect(all.count).toBe(3);
|
|
86
|
+
expect(all.pains.map((p) => p.host)).toEqual(['unknown', 'codex', 'openclaw']); // newest first
|
|
87
|
+
expect(all.pains.find((p) => p.painId === 'pain_oc_1')).toMatchObject({ host: 'openclaw', source: 'user_correction', runtimeTaskId: 'diagnosis_pain_oc_1', createdAt: '2026-09-01T10:00:00.000Z' });
|
|
88
|
+
expect(all.workspace).toBe(workspaceDir);
|
|
89
|
+
|
|
90
|
+
expect((await listPains(dbPath, { limit: 10, host: 'openclaw' })).pains.map((p) => p.painId)).toEqual(['pain_oc_1']);
|
|
91
|
+
expect((await listPains(dbPath, { limit: 10, host: 'codex' })).pains.map((p) => p.painId)).toEqual(['pain_cx_1']);
|
|
92
|
+
expect((await listPains(dbPath, { limit: 10, host: 'unknown' })).pains.map((p) => p.painId)).toEqual(['pain_unknown_1']);
|
|
93
|
+
expect(all.warnings).toEqual([]);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('reports legacy rows without a canonical id as row:<id>', async () => {
|
|
97
|
+
const { db } = makeWorkspace();
|
|
98
|
+
db.prepare(`INSERT INTO pain_events (session_id, source, score, created_at) VALUES ('s1', 'correction_rejected', 60, '2026-08-01T00:00:00.000Z')`).run();
|
|
99
|
+
const dbPath = dbPathOf(db);
|
|
100
|
+
db.close();
|
|
101
|
+
|
|
102
|
+
const result = await listPains(dbPath, { limit: 10 });
|
|
103
|
+
expect(result.count).toBe(1);
|
|
104
|
+
expect(result.pains[0]?.painId).toBe('row:1');
|
|
105
|
+
expect(result.pains[0]?.host).toBe('unknown');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('degrades observably on a pre-PRI-640 database without the host_kind column (rc-9)', async () => {
|
|
109
|
+
const { db } = makePre640Workspace();
|
|
110
|
+
db.prepare(`INSERT INTO pain_events (session_id, source, score, reason, severity, origin, confidence, text, canonical_pain_id, runtime_task_id, created_at)
|
|
111
|
+
VALUES ('s1', 'tool_failure', 70, 'r', 'moderate', 'system_infer', NULL, NULL, 'pain_legacy', NULL, '2026-08-01T00:00:00.000Z')`).run();
|
|
112
|
+
const dbPath = dbPathOf(db);
|
|
113
|
+
db.close();
|
|
114
|
+
|
|
115
|
+
const all = await listPains(dbPath, { limit: 10 });
|
|
116
|
+
expect(all.count).toBe(1);
|
|
117
|
+
expect(all.pains[0]?.host).toBe('unknown');
|
|
118
|
+
expect(all.warnings).toContain('host_kind_column_missing');
|
|
119
|
+
|
|
120
|
+
// Host filters that cannot match degrade to an explicit empty result.
|
|
121
|
+
expect(await listPains(dbPath, { limit: 10, host: 'codex' })).toMatchObject({ count: 0, pains: [] });
|
|
122
|
+
// unknown still returns the legacy rows.
|
|
123
|
+
expect((await listPains(dbPath, { limit: 10, host: 'unknown' })).count).toBe(1);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe('handlePainList (CLI contract)', () => {
|
|
128
|
+
let exitSpy: ReturnType<typeof vi.spyOn>;
|
|
129
|
+
let logSpy: ReturnType<typeof vi.spyOn>;
|
|
130
|
+
let errSpy: ReturnType<typeof vi.spyOn>;
|
|
131
|
+
let workspaceDir: string;
|
|
132
|
+
|
|
133
|
+
beforeEach(() => {
|
|
134
|
+
exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
|
|
135
|
+
logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
|
136
|
+
errSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
137
|
+
const made = makeWorkspace();
|
|
138
|
+
workspaceDir = made.workspaceDir;
|
|
139
|
+
seedPain(made.db, { id: 1, source: 'user_correction', canonical: 'pain_cli_oc', host: 'openclaw', task: 't1', created: '2026-09-01T09:00:00.000Z' });
|
|
140
|
+
seedPain(made.db, { id: 2, source: 'tool_failure', canonical: 'pain_cli_cx', host: 'codex', created: '2026-09-01T09:30:00.000Z' });
|
|
141
|
+
made.db.close();
|
|
142
|
+
vi.mocked(resolveWorkspaceDir).mockReturnValue(workspaceDir);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
afterEach(() => {
|
|
146
|
+
exitSpy.mockRestore();
|
|
147
|
+
logSpy.mockRestore();
|
|
148
|
+
errSpy.mockRestore();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('--json emits one valid JSON object with stable host values', async () => {
|
|
152
|
+
await handlePainList({ json: true });
|
|
153
|
+
expect(exitSpy).not.toHaveBeenCalled();
|
|
154
|
+
const raw = logSpy.mock.calls.map((args) => String(args[0])).join('\n');
|
|
155
|
+
const parsed = JSON.parse(raw) as { count: number; pains: { host: string; painId: string }[]; hostFilter: unknown; warnings: string[] };
|
|
156
|
+
expect(parsed.count).toBe(2);
|
|
157
|
+
expect(parsed.pains.map((p) => `${p.painId}:${p.host}`).sort()).toEqual(['pain_cli_cx:codex', 'pain_cli_oc:openclaw']);
|
|
158
|
+
expect(parsed.hostFilter).toBeNull();
|
|
159
|
+
expect(parsed.warnings).toEqual([]);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('--host filter is reflected in the JSON result', async () => {
|
|
163
|
+
await handlePainList({ json: true, host: 'codex' });
|
|
164
|
+
const parsed = JSON.parse(logSpy.mock.calls.map((args) => String(args[0])).join('\n')) as { count: number; hostFilter: string; pains: unknown[] };
|
|
165
|
+
expect(parsed).toMatchObject({ count: 1, hostFilter: 'codex' });
|
|
166
|
+
expect(parsed.pains).toHaveLength(1);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('an invalid --host value fails loudly with a structured reason (cli-6)', async () => {
|
|
170
|
+
await handlePainList({ json: true, host: 'claude' });
|
|
171
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
172
|
+
const parsed = JSON.parse(logSpy.mock.calls.map((args) => String(args[0])).join('\n')) as { status: string; reason: string; nextAction: string };
|
|
173
|
+
expect(parsed).toMatchObject({ status: 'failed', reason: 'invalid_host_filter' });
|
|
174
|
+
expect(parsed.nextAction).toContain('--host openclaw');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('a missing trajectory.db degrades with reason and next action (cli-6)', async () => {
|
|
178
|
+
fs.rmSync(path.join(workspaceDir, '.state', 'trajectory.db'));
|
|
179
|
+
await handlePainList({ json: true });
|
|
180
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
181
|
+
const parsed = JSON.parse(logSpy.mock.calls.map((args) => String(args[0])).join('\n')) as { status: string; reason: string; nextAction: string };
|
|
182
|
+
expect(parsed).toMatchObject({ status: 'failed', reason: 'trajectory_db_not_found' });
|
|
183
|
+
expect(parsed.nextAction).toContain('pd runtime init');
|
|
184
|
+
});
|
|
185
|
+
});
|
|
@@ -393,6 +393,26 @@ describe('pd runtime init', () => {
|
|
|
393
393
|
} finally { rmTmpDir(tmp); }
|
|
394
394
|
});
|
|
395
395
|
|
|
396
|
+
it('--confirm marks every generated flag entry as system provenance (PRI-637)', async () => {
|
|
397
|
+
const tmp = mkTmpDir();
|
|
398
|
+
try {
|
|
399
|
+
buildRuntimeInitOutput(tmp, true);
|
|
400
|
+
const configPath = path.join(tmp, '.pd', 'config.yaml');
|
|
401
|
+
const yaml = (await import('js-yaml')).default;
|
|
402
|
+
const parsed = yaml.load(fs.readFileSync(configPath, 'utf8'), { schema: yaml.JSON_SCHEMA }) as {
|
|
403
|
+
features?: Record<string, { source?: unknown }>;
|
|
404
|
+
};
|
|
405
|
+
const features = parsed.features ?? {};
|
|
406
|
+
// Bootstrap snapshot still carries the full registry map.
|
|
407
|
+
expect(Object.keys(features).length).toBeGreaterThan(0);
|
|
408
|
+
// PRI-637: `pd runtime init` is PD machinery — entries carry source:
|
|
409
|
+
// 'system' (NOT owner intent), matching the runtime-init lifecycle label.
|
|
410
|
+
for (const entry of Object.values(features)) {
|
|
411
|
+
expect(entry?.source).toBe('system');
|
|
412
|
+
}
|
|
413
|
+
} finally { rmTmpDir(tmp); }
|
|
414
|
+
});
|
|
415
|
+
|
|
396
416
|
it('--confirm skips when config.yaml already exists (preserves user file)', () => {
|
|
397
417
|
const tmp = mkTmpDir();
|
|
398
418
|
try {
|