@principles/pd-cli 1.143.0 → 1.145.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/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-reconcile.d.ts +22 -0
- package/dist/commands/codex-reconcile.d.ts.map +1 -0
- package/dist/commands/codex-reconcile.js +61 -0
- package/dist/commands/codex-reconcile.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/console.d.ts.map +1 -1
- package/dist/commands/console.js +34 -1
- package/dist/commands/console.js.map +1 -1
- package/dist/commands/pain-retry.d.ts.map +1 -1
- package/dist/commands/pain-retry.js +4 -2
- package/dist/commands/pain-retry.js.map +1 -1
- package/dist/index.js +70 -0
- package/dist/index.js.map +1 -1
- package/dist/services/console-launcher.d.ts +8 -2
- package/dist/services/console-launcher.d.ts.map +1 -1
- package/dist/services/console-launcher.js +34 -4
- package/dist/services/console-launcher.js.map +1 -1
- package/package.json +1 -1
- package/src/commands/codex-ingest-catchup.ts +114 -0
- package/src/commands/codex-reconcile.ts +84 -0
- package/src/commands/codex-worker.ts +174 -0
- package/src/commands/console.ts +35 -2
- package/src/commands/pain-retry.ts +4 -2
- package/src/index.ts +75 -0
- package/src/services/console-launcher.ts +49 -6
- package/tests/commands/codex-worker-registration.test.ts +178 -0
- package/tests/commands/console-open.test.ts +83 -2
- package/tests/commands/health.test.ts +4 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pd codex ingest catch-up command implementation (Codex Governance Closure
|
|
3
|
+
* Slice C, PRI-624; SPEC §13/§15 recovery command).
|
|
4
|
+
*
|
|
5
|
+
* Performs ONE bounded non-destructive catch-up pass over the workspace's
|
|
6
|
+
* durable Codex checkpoints: resolves each previously-authenticated rollout
|
|
7
|
+
* by exact uuid, re-authorizes the transcript path, and resumes the bounded
|
|
8
|
+
* incremental ingestion from the checkpoint — then feeds any admission
|
|
9
|
+
* candidates through the same Slice B admission/continuation pass the hook
|
|
10
|
+
* uses. Flag-off (`codex_conversation_ingestion=false`) performs ZERO
|
|
11
|
+
* transcript filesystem I/O and reports a structured skip.
|
|
12
|
+
*
|
|
13
|
+
* This is the manual-mode counterpart of the Companion worker's catch-up
|
|
14
|
+
* step (worker cycle step 2); it creates no LLM execution.
|
|
15
|
+
*
|
|
16
|
+
* CLI gate compliance:
|
|
17
|
+
* - cli-1: --json outputs exactly one parseable JSON object on stdout.
|
|
18
|
+
* - cli-2: exit paths stop execution.
|
|
19
|
+
* - cli-5/6: skipped/degraded results carry reason + nextAction; nothing is
|
|
20
|
+
* mutated on failure (the ingestion seam is transactional per rollout).
|
|
21
|
+
*/
|
|
22
|
+
import { resolveWorkspaceDir } from '../resolve-workspace.js';
|
|
23
|
+
import { catchUpCodexIngestion, type CodexCatchUpResult } from '@principles/codex-adapter';
|
|
24
|
+
|
|
25
|
+
interface CodexIngestCatchUpOptions {
|
|
26
|
+
workspace?: string;
|
|
27
|
+
json?: boolean;
|
|
28
|
+
maxRollouts?: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface CodexIngestCatchUpReport {
|
|
32
|
+
generatedAt: string;
|
|
33
|
+
host: 'codex';
|
|
34
|
+
workspace: string;
|
|
35
|
+
status: 'ok' | 'degraded' | 'skipped';
|
|
36
|
+
reason?: string;
|
|
37
|
+
nextAction?: string;
|
|
38
|
+
rolloutsProcessed: number;
|
|
39
|
+
remainingLagRollouts: string[];
|
|
40
|
+
unexaminedRollouts: string[];
|
|
41
|
+
degradations: string[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function buildReport(generatedAt: string, workspace: string, result: CodexCatchUpResult): CodexIngestCatchUpReport {
|
|
45
|
+
if (result.status === 'skipped') {
|
|
46
|
+
return {
|
|
47
|
+
generatedAt,
|
|
48
|
+
host: 'codex',
|
|
49
|
+
workspace,
|
|
50
|
+
status: 'skipped',
|
|
51
|
+
reason: result.reason,
|
|
52
|
+
nextAction: result.nextAction,
|
|
53
|
+
rolloutsProcessed: 0,
|
|
54
|
+
remainingLagRollouts: [],
|
|
55
|
+
unexaminedRollouts: [],
|
|
56
|
+
degradations: [],
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
const degradations: string[] = [];
|
|
60
|
+
for (const rollout of result.rollouts) {
|
|
61
|
+
for (const degradation of rollout.admissionDegradations) {
|
|
62
|
+
degradations.push(`${rollout.rolloutIdentity}: ${degradation.reason}`);
|
|
63
|
+
}
|
|
64
|
+
if (rollout.outcome.status === 'degraded') {
|
|
65
|
+
degradations.push(`${rollout.rolloutIdentity}: ${rollout.outcome.reason}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
generatedAt,
|
|
70
|
+
host: 'codex',
|
|
71
|
+
workspace,
|
|
72
|
+
status: result.status,
|
|
73
|
+
rolloutsProcessed: result.rollouts.length,
|
|
74
|
+
remainingLagRollouts: [...result.remainingLagRollouts],
|
|
75
|
+
unexaminedRollouts: [...result.unexaminedRollouts],
|
|
76
|
+
degradations: degradations.slice(0, 10),
|
|
77
|
+
...(degradations.length > 0 && result.status === 'degraded'
|
|
78
|
+
? { reason: 'one or more rollouts degraded; committed observations remain durable', nextAction: 'Inspect the per-rollout degradations; re-run catch-up after fixing the underlying condition (Slice D adds the audited quarantine command).' }
|
|
79
|
+
: {}),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function handleCodexIngestCatchUp(options: CodexIngestCatchUpOptions): Promise<void> {
|
|
84
|
+
const generatedAt = new Date().toISOString();
|
|
85
|
+
const workspace = resolveWorkspaceDir(options.workspace);
|
|
86
|
+
|
|
87
|
+
const result = await catchUpCodexIngestion({
|
|
88
|
+
workspaceDir: workspace,
|
|
89
|
+
env: { CODEX_HOME: process.env.CODEX_HOME },
|
|
90
|
+
...(typeof options.maxRollouts === 'number' ? { maxRollouts: options.maxRollouts } : {}),
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const report = buildReport(generatedAt, workspace, result);
|
|
94
|
+
if (options.json) {
|
|
95
|
+
console.log(JSON.stringify(report));
|
|
96
|
+
if (report.status === 'degraded') process.exitCode = 1;
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const lines = [
|
|
101
|
+
`Codex ingestion catch-up (${workspace})`,
|
|
102
|
+
` status: ${report.status}`,
|
|
103
|
+
` rollouts processed: ${report.rolloutsProcessed}`,
|
|
104
|
+
` remaining lag: ${report.remainingLagRollouts.length}`,
|
|
105
|
+
` unexamined (bound): ${report.unexaminedRollouts.length}`,
|
|
106
|
+
];
|
|
107
|
+
for (const degradation of report.degradations) {
|
|
108
|
+
lines.push(` degradation: ${degradation}`);
|
|
109
|
+
}
|
|
110
|
+
if (report.reason !== undefined) lines.push(` reason: ${report.reason}`);
|
|
111
|
+
if (report.nextAction !== undefined) lines.push(` next action: ${report.nextAction}`);
|
|
112
|
+
console.log(lines.join('\n'));
|
|
113
|
+
if (report.status === 'degraded') process.exitCode = 1;
|
|
114
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pd codex reconcile command implementation (Codex Governance Closure
|
|
3
|
+
* Slice B, PRI-623; SPEC §13/§20).
|
|
4
|
+
*
|
|
5
|
+
* Runs the narrow idempotent reconciliation pass between the governance
|
|
6
|
+
* admission markers (trajectory.db) and the Runtime V2 task store
|
|
7
|
+
* (.pd/state.db): creates missing Diagnostician tasks for admitted pains,
|
|
8
|
+
* repairs task links, retries pending promotion tails, and reports stale
|
|
9
|
+
* tails — without a background worker (the Slice C Companion worker will
|
|
10
|
+
* call the same seam).
|
|
11
|
+
*
|
|
12
|
+
* CLI gate compliance:
|
|
13
|
+
* - cli-1: --json outputs exactly one parseable JSON object on stdout.
|
|
14
|
+
* - cli-2: exit paths stop execution.
|
|
15
|
+
* - cli-6: degraded results include reason + nextAction.
|
|
16
|
+
*/
|
|
17
|
+
import { resolveWorkspaceDir } from '../resolve-workspace.js';
|
|
18
|
+
import { reconcileGovernanceContinuation } from '@principles/host-runtime';
|
|
19
|
+
|
|
20
|
+
interface CodexReconcileOptions {
|
|
21
|
+
workspace?: string;
|
|
22
|
+
json?: boolean;
|
|
23
|
+
limit?: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface CodexReconcileReport {
|
|
27
|
+
generatedAt: string;
|
|
28
|
+
host: 'codex';
|
|
29
|
+
workspace: string;
|
|
30
|
+
ok: boolean;
|
|
31
|
+
reason?: string;
|
|
32
|
+
nextAction?: string;
|
|
33
|
+
tasksEnsured: number;
|
|
34
|
+
linksRepaired: number;
|
|
35
|
+
pendingTails: number;
|
|
36
|
+
completedTails: number;
|
|
37
|
+
staleTails: number;
|
|
38
|
+
degradations: string[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function handleCodexReconcile(options: CodexReconcileOptions): Promise<void> {
|
|
42
|
+
const generatedAt = new Date().toISOString();
|
|
43
|
+
const workspace = resolveWorkspaceDir(options.workspace);
|
|
44
|
+
|
|
45
|
+
const result = await reconcileGovernanceContinuation({
|
|
46
|
+
workspaceDir: workspace,
|
|
47
|
+
...(typeof options.limit === 'number' ? { limit: options.limit } : {}),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const report: CodexReconcileReport = {
|
|
51
|
+
generatedAt,
|
|
52
|
+
host: 'codex',
|
|
53
|
+
workspace,
|
|
54
|
+
ok: result.ok,
|
|
55
|
+
tasksEnsured: result.tasksEnsured,
|
|
56
|
+
linksRepaired: result.linksRepaired,
|
|
57
|
+
pendingTails: result.pendingTails,
|
|
58
|
+
completedTails: result.completedTails,
|
|
59
|
+
staleTails: result.staleTails,
|
|
60
|
+
degradations: [...result.degradations],
|
|
61
|
+
...(result.ok ? {} : { reason: result.reason ?? 'reconciliation_failed', nextAction: result.nextAction ?? 'Inspect the listed degradations; admitted pains and evidence remain durable.' }),
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
if (options.json) {
|
|
65
|
+
console.log(JSON.stringify(report));
|
|
66
|
+
if (!report.ok) process.exitCode = 1;
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const lines = [
|
|
71
|
+
`Codex governance reconciliation (${workspace})`,
|
|
72
|
+
` tasks ensured: ${report.tasksEnsured} (links repaired: ${report.linksRepaired})`,
|
|
73
|
+
` promotion tails: pending=${report.pendingTails} completed-now=${report.completedTails} stale=${report.staleTails}`,
|
|
74
|
+
];
|
|
75
|
+
for (const degradation of report.degradations) {
|
|
76
|
+
lines.push(` degradation: ${degradation}`);
|
|
77
|
+
}
|
|
78
|
+
if (!report.ok) {
|
|
79
|
+
lines.push(` reason: ${report.reason}`);
|
|
80
|
+
lines.push(` next action: ${report.nextAction}`);
|
|
81
|
+
}
|
|
82
|
+
console.log(lines.join('\n'));
|
|
83
|
+
if (!report.ok) process.exitCode = 1;
|
|
84
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pd codex worker command implementation (Codex Governance Closure Slice C,
|
|
3
|
+
* PRI-624; SPEC §13/§15; ADR-0020 §11.1).
|
|
4
|
+
*
|
|
5
|
+
* Runs the Workspace-scoped worker cycle — catch-up → reconciliation → one
|
|
6
|
+
* Diagnostician execution → one bounded downstream consumer cycle — either
|
|
7
|
+
* once (manual / Companion-supervised restart mode) or continuously.
|
|
8
|
+
*
|
|
9
|
+
* `status` answers the SPEC §15 worker-mode question without executing
|
|
10
|
+
* anything: `manual_action_required` means no Companion-registered worker
|
|
11
|
+
* serves this workspace (it is not in the install manifest) and the manual
|
|
12
|
+
* commands below are the recovery path.
|
|
13
|
+
*
|
|
14
|
+
* CLI gate compliance:
|
|
15
|
+
* - cli-1: --json is restricted to the single-output forms (--once / --status)
|
|
16
|
+
* so stdout is exactly one parseable JSON object; continuous mode streams
|
|
17
|
+
* human-readable reports instead (review P2).
|
|
18
|
+
* - cli-2: exit paths stop execution.
|
|
19
|
+
* - cli-6: paused/degraded modes carry reason + nextAction.
|
|
20
|
+
*/
|
|
21
|
+
import fs from 'node:fs';
|
|
22
|
+
import os from 'node:os';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
import { resolveWorkspaceDir } from '../resolve-workspace.js';
|
|
25
|
+
import { runCodexWorkspaceWorkerCycle, type CodexWorkerCycleResult } from '@principles/codex-adapter';
|
|
26
|
+
import { getInstallLayoutPaths, parseInstallManifest } from '@principles/install-layout';
|
|
27
|
+
|
|
28
|
+
const DEFAULT_INTERVAL_MS = 120_000;
|
|
29
|
+
|
|
30
|
+
interface CodexWorkerOptions {
|
|
31
|
+
workspace?: string;
|
|
32
|
+
json?: boolean;
|
|
33
|
+
once?: boolean;
|
|
34
|
+
intervalMs?: number;
|
|
35
|
+
status?: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface CodexWorkerStatusReport {
|
|
39
|
+
generatedAt: string;
|
|
40
|
+
host: 'codex';
|
|
41
|
+
workspace: string;
|
|
42
|
+
mode: 'ready' | 'manual_action_required' | 'paused' | 'degraded';
|
|
43
|
+
registeredInInstallManifest: boolean;
|
|
44
|
+
reason?: string;
|
|
45
|
+
nextAction?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function printStatusReport(workspace: string, json: boolean): Promise<void> {
|
|
49
|
+
const generatedAt = new Date().toISOString();
|
|
50
|
+
const paths = getInstallLayoutPaths(os.homedir());
|
|
51
|
+
let registered = false;
|
|
52
|
+
let manifestError: string | undefined;
|
|
53
|
+
try {
|
|
54
|
+
const raw = JSON.parse(fs.readFileSync(paths.manifest, 'utf8')) as unknown;
|
|
55
|
+
const parsed = parseInstallManifest(raw);
|
|
56
|
+
if (parsed.manifest !== undefined) {
|
|
57
|
+
registered = parsed.manifest.workspaces?.some((entry) => path.resolve(entry) === path.resolve(workspace)) ?? false;
|
|
58
|
+
} else {
|
|
59
|
+
manifestError = parsed.error;
|
|
60
|
+
}
|
|
61
|
+
} catch {
|
|
62
|
+
manifestError = 'install_manifest_unreadable';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// The status scan itself delegates mode computation to the cycle module's
|
|
66
|
+
// flag/config semantics by running a paused-compatible evaluation: a real
|
|
67
|
+
// cycle is NOT run (no execution, no lease, no transcript I/O).
|
|
68
|
+
const { computeCodexWorkerStatusMode } = await import('@principles/codex-adapter');
|
|
69
|
+
const evaluation = computeCodexWorkerStatusMode({ workspaceDir: workspace, registeredInInstallManifest: registered });
|
|
70
|
+
|
|
71
|
+
const report: CodexWorkerStatusReport = {
|
|
72
|
+
generatedAt,
|
|
73
|
+
host: 'codex',
|
|
74
|
+
workspace,
|
|
75
|
+
mode: evaluation.mode,
|
|
76
|
+
registeredInInstallManifest: registered,
|
|
77
|
+
...(evaluation.reason !== undefined ? { reason: evaluation.reason } : {}),
|
|
78
|
+
...(evaluation.nextAction !== undefined ? { nextAction: evaluation.nextAction } : {}),
|
|
79
|
+
...(manifestError !== undefined ? { reason: `${evaluation.reason ?? manifestError}` } : {}),
|
|
80
|
+
};
|
|
81
|
+
if (json) {
|
|
82
|
+
console.log(JSON.stringify(report));
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const lines = [
|
|
86
|
+
`Codex workspace worker status (${workspace})`,
|
|
87
|
+
` mode: ${report.mode}`,
|
|
88
|
+
` registered in install manifest: ${registered}`,
|
|
89
|
+
];
|
|
90
|
+
if (report.reason !== undefined) lines.push(` reason: ${report.reason}`);
|
|
91
|
+
if (report.nextAction !== undefined) lines.push(` next action: ${report.nextAction}`);
|
|
92
|
+
console.log(lines.join('\n'));
|
|
93
|
+
if (report.mode === 'degraded') process.exitCode = 1;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function printCycleReport(result: CodexWorkerCycleResult, json: boolean): void {
|
|
97
|
+
if (json) {
|
|
98
|
+
console.log(JSON.stringify(result));
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const lines = [
|
|
102
|
+
`Codex workspace worker (${result.workspaceDir})`,
|
|
103
|
+
` mode: ${result.mode}${result.reason !== undefined ? ` (${result.reason})` : ''}`,
|
|
104
|
+
];
|
|
105
|
+
if (result.report !== undefined) {
|
|
106
|
+
const {catchUp} = result.report;
|
|
107
|
+
const catchUpSummary = catchUp.status === 'skipped' ? `skipped (${catchUp.reason})` : `${catchUp.status} — processed=${catchUp.rollouts.length} lag=${catchUp.remainingLagRollouts.length}`;
|
|
108
|
+
lines.push(` catch-up: ${catchUpSummary}`);
|
|
109
|
+
lines.push(` reconcile: ok=${result.report.reconcile.ok} ensured=${result.report.reconcile.tasksEnsured} linksRepaired=${result.report.reconcile.linksRepaired}`);
|
|
110
|
+
if (result.report.diagnostician !== null) {
|
|
111
|
+
lines.push(` diagnostician: ${result.report.diagnostician.status} (${result.report.diagnostician.taskId})`);
|
|
112
|
+
} else {
|
|
113
|
+
lines.push(' diagnostician: none pending');
|
|
114
|
+
}
|
|
115
|
+
if (result.report.downstream !== null) {
|
|
116
|
+
lines.push(` downstream: ran=${result.report.downstream.ran}${result.report.downstream.taskKind !== undefined ? ` kind=${result.report.downstream.taskKind}` : ''}${result.report.downstream.skipReason !== undefined ? ` skip=${result.report.downstream.skipReason}` : ''}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (result.nextAction !== undefined) lines.push(` next action: ${result.nextAction}`);
|
|
120
|
+
console.log(lines.join('\n'));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function handleCodexWorker(options: CodexWorkerOptions): Promise<void> {
|
|
124
|
+
// cli-1: continuous mode streams one report per cycle — that is NOT one
|
|
125
|
+
// parseable JSON document. --json is only valid for the single-output
|
|
126
|
+
// forms (--once / --status), enforced here (review P2).
|
|
127
|
+
if (options.json === true && options.once !== true && options.status !== true) {
|
|
128
|
+
const error = {
|
|
129
|
+
generatedAt: new Date().toISOString(),
|
|
130
|
+
host: 'codex',
|
|
131
|
+
error: 'cli_contract',
|
|
132
|
+
reason: 'json_without_once_or_status',
|
|
133
|
+
nextAction: 'Use --json with --once (single cycle) or --status (no-op scan); continuous mode streams human-readable reports instead.',
|
|
134
|
+
};
|
|
135
|
+
console.log(JSON.stringify(error));
|
|
136
|
+
process.exitCode = 1;
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const workspace = resolveWorkspaceDir(options.workspace);
|
|
141
|
+
|
|
142
|
+
if (options.status === true) {
|
|
143
|
+
await printStatusReport(workspace, options.json === true);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (options.once === true) {
|
|
148
|
+
const result = await runCodexWorkspaceWorkerCycle({ workspaceDir: workspace, env: { CODEX_HOME: process.env.CODEX_HOME } });
|
|
149
|
+
printCycleReport(result, options.json === true);
|
|
150
|
+
if (result.mode === 'degraded') process.exitCode = 1;
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const interval = typeof options.intervalMs === 'number' && options.intervalMs >= 1000 ? options.intervalMs : DEFAULT_INTERVAL_MS;
|
|
155
|
+
let stopped = false;
|
|
156
|
+
const onSignal = (): void => {
|
|
157
|
+
stopped = true;
|
|
158
|
+
};
|
|
159
|
+
process.on('SIGINT', onSignal);
|
|
160
|
+
process.on('SIGTERM', onSignal);
|
|
161
|
+
try {
|
|
162
|
+
while (!stopped) {
|
|
163
|
+
const result = await runCodexWorkspaceWorkerCycle({ workspaceDir: workspace, env: { CODEX_HOME: process.env.CODEX_HOME } });
|
|
164
|
+
printCycleReport(result, false);
|
|
165
|
+
if (stopped) break;
|
|
166
|
+
await new Promise((resolve) => setTimeout(resolve, interval));
|
|
167
|
+
}
|
|
168
|
+
} finally {
|
|
169
|
+
process.off('SIGINT', onSignal);
|
|
170
|
+
process.off('SIGTERM', onSignal);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** SPEC §15 worker mode, evaluated without executing anything. */
|
package/src/commands/console.ts
CHANGED
|
@@ -369,7 +369,8 @@ export async function handleConsoleOpen(opts: ConsoleOpenOptions = {}): Promise<
|
|
|
369
369
|
}
|
|
370
370
|
|
|
371
371
|
// 4) Read auth token for health probes (PD_CONSOLE_TOKEN)
|
|
372
|
-
const
|
|
372
|
+
const rawToken = opts.token ?? process.env.PD_CONSOLE_TOKEN;
|
|
373
|
+
const token = !opts.noAuth && rawToken?.trim() ? rawToken.trim() : undefined;
|
|
373
374
|
|
|
374
375
|
// 5) Plan the launch (reuse or fresh bind)
|
|
375
376
|
let plan;
|
|
@@ -461,6 +462,7 @@ export async function handleConsoleOpen(opts: ConsoleOpenOptions = {}): Promise<
|
|
|
461
462
|
workspaceDir,
|
|
462
463
|
reused: true,
|
|
463
464
|
browserOpened,
|
|
465
|
+
...(health.authenticationMode ? { authenticationMode: health.authenticationMode } : {}),
|
|
464
466
|
nextAction: browserOpened
|
|
465
467
|
? 'Browser opened to the running Console.'
|
|
466
468
|
: `Open ${plan.url} in your browser to access the Console.`,
|
|
@@ -535,10 +537,15 @@ export async function handleConsoleOpen(opts: ConsoleOpenOptions = {}): Promise<
|
|
|
535
537
|
// 7) Wait for console ready (bounded poll)
|
|
536
538
|
const readyDeadline = Date.now() + 15_000;
|
|
537
539
|
let ready = false;
|
|
540
|
+
let readyAuthenticationMode: 'authenticated' | 'no_auth' | undefined;
|
|
538
541
|
while (Date.now() < readyDeadline) {
|
|
539
542
|
if (child.exitCode !== null) break;
|
|
540
543
|
const h = await probeConsoleHealth({ host: plan.host, port: plan.port, timeoutMs: 1000, token });
|
|
541
|
-
if (h.healthy) {
|
|
544
|
+
if (h.healthy) {
|
|
545
|
+
ready = true;
|
|
546
|
+
readyAuthenticationMode = h.authenticationMode;
|
|
547
|
+
break;
|
|
548
|
+
}
|
|
542
549
|
await sleep(250);
|
|
543
550
|
}
|
|
544
551
|
|
|
@@ -587,6 +594,31 @@ export async function handleConsoleOpen(opts: ConsoleOpenOptions = {}): Promise<
|
|
|
587
594
|
return;
|
|
588
595
|
}
|
|
589
596
|
|
|
597
|
+
const expectedAuthenticationMode = token?.trim() ? 'authenticated' : 'no_auth';
|
|
598
|
+
if (readyAuthenticationMode !== expectedAuthenticationMode) {
|
|
599
|
+
try { child.kill('SIGTERM'); } catch { /* child may already have exited */ }
|
|
600
|
+
const result: ConsoleLaunchResult = {
|
|
601
|
+
status: 'refused',
|
|
602
|
+
url: '',
|
|
603
|
+
port: plan.port,
|
|
604
|
+
host: plan.host,
|
|
605
|
+
workspaceDir,
|
|
606
|
+
reused: false,
|
|
607
|
+
browserOpened: false,
|
|
608
|
+
authenticationMode: readyAuthenticationMode,
|
|
609
|
+
reason: 'console_authentication_mode_mismatch',
|
|
610
|
+
nextAction: 'Stop the Console, verify PD_CONSOLE_TOKEN propagation, and retry.',
|
|
611
|
+
};
|
|
612
|
+
if (opts.json) {
|
|
613
|
+
console.log(JSON.stringify(result, null, 2));
|
|
614
|
+
} else {
|
|
615
|
+
console.error(`error: ${result.reason}`);
|
|
616
|
+
console.error(`next: ${result.nextAction}`);
|
|
617
|
+
}
|
|
618
|
+
process.exit(1);
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
|
|
590
622
|
// 7) Console is ready → optionally open browser, emit result, then keep child running
|
|
591
623
|
let browserOpened = false;
|
|
592
624
|
let browserWarning: string | undefined;
|
|
@@ -604,6 +636,7 @@ export async function handleConsoleOpen(opts: ConsoleOpenOptions = {}): Promise<
|
|
|
604
636
|
workspaceDir,
|
|
605
637
|
reused: false,
|
|
606
638
|
browserOpened,
|
|
639
|
+
...(readyAuthenticationMode ? { authenticationMode: readyAuthenticationMode } : {}),
|
|
607
640
|
nextAction: browserOpened
|
|
608
641
|
? 'Browser opened to the Console. Press Ctrl+C to stop.'
|
|
609
642
|
: `Open ${plan.url} in your browser. Press Ctrl+C to stop.`,
|
|
@@ -183,10 +183,12 @@ function parsePainDetectedData(value: unknown):
|
|
|
183
183
|
}
|
|
184
184
|
if (Object.hasOwn(obj, 'provenance')) {
|
|
185
185
|
const prov = obj.provenance;
|
|
186
|
-
|
|
186
|
+
// host_context_bound is the current value (SPEC §12); the legacy
|
|
187
|
+
// openclaw_context_bound spelling remains valid for replay of old dead letters.
|
|
188
|
+
if (prov !== 'host_context_bound' && prov !== 'openclaw_context_bound' && prov !== 'owner_reported_no_host_trace' && prov !== 'automatic_hook') {
|
|
187
189
|
return { valid: false, error: `painData.provenance is not a known literal (got ${JSON.stringify(prov)})` };
|
|
188
190
|
}
|
|
189
|
-
data.provenance = prov;
|
|
191
|
+
data.provenance = prov === 'openclaw_context_bound' ? 'host_context_bound' : prov;
|
|
190
192
|
}
|
|
191
193
|
if (Object.hasOwn(obj, 'evidence')) {
|
|
192
194
|
if (!Array.isArray(obj.evidence)) {
|
package/src/index.ts
CHANGED
|
@@ -563,6 +563,81 @@ runtimeHealthCmd
|
|
|
563
563
|
await handleRuntimeHealthSnapshot({ workspace: opts.workspace, json: opts.json });
|
|
564
564
|
});
|
|
565
565
|
|
|
566
|
+
// ── pd codex reconcile — Codex governance continuation recovery ───────────────
|
|
567
|
+
// Codex Governance Closure Slice B (PRI-623, SPEC §13/§20): runs the idempotent
|
|
568
|
+
// cross-store reconciliation pass (admitted pain → missing Diagnostician task,
|
|
569
|
+
// task-before-link, pending promotion tails) exposed for the CLI now and the
|
|
570
|
+
// Slice C Companion worker later.
|
|
571
|
+
const codexCmd = program
|
|
572
|
+
.command('codex')
|
|
573
|
+
.description('Codex host governance operations');
|
|
574
|
+
|
|
575
|
+
codexCmd
|
|
576
|
+
.command('reconcile')
|
|
577
|
+
.description('Reconcile admitted Codex pains with Diagnostician tasks and promotion tails (idempotent)')
|
|
578
|
+
.option('-w, --workspace <path>', 'Workspace directory')
|
|
579
|
+
.option('--limit <n>', 'Maximum admitted pains to reconcile per pass (1-200, default 50)')
|
|
580
|
+
.option('--json', 'Output raw JSON')
|
|
581
|
+
.action(async (opts) => {
|
|
582
|
+
const { handleCodexReconcile } = await import('./commands/codex-reconcile.js');
|
|
583
|
+
const limit = typeof opts.limit === 'string' ? Number.parseInt(opts.limit, 10) : undefined;
|
|
584
|
+
await handleCodexReconcile({
|
|
585
|
+
workspace: opts.workspace,
|
|
586
|
+
json: opts.json === true,
|
|
587
|
+
...(limit !== undefined && Number.isInteger(limit) ? { limit } : {}),
|
|
588
|
+
});
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
// ── pd codex ingest catch-up — bounded manual transcript lag recovery ────────
|
|
592
|
+
// Codex Governance Closure Slice C (PRI-624, SPEC §15): the manual-mode
|
|
593
|
+
// counterpart of the Companion worker's catch-up step. Zero transcript I/O
|
|
594
|
+
// when codex_conversation_ingestion is disabled.
|
|
595
|
+
const codexIngestCmd = codexCmd
|
|
596
|
+
.command('ingest')
|
|
597
|
+
.description('Codex conversation-ingestion operations');
|
|
598
|
+
|
|
599
|
+
codexIngestCmd
|
|
600
|
+
.command('catch-up')
|
|
601
|
+
.description('Catch up transcript lag from durable checkpoints (bounded, non-destructive, no LLM)')
|
|
602
|
+
.option('-w, --workspace <path>', 'Workspace directory')
|
|
603
|
+
.option('--max-rollouts <n>', 'Maximum rollouts to catch up per pass (1-32, default 8)')
|
|
604
|
+
.option('--json', 'Output raw JSON')
|
|
605
|
+
.action(async (opts) => {
|
|
606
|
+
const { handleCodexIngestCatchUp } = await import('./commands/codex-ingest-catchup.js');
|
|
607
|
+
const maxRollouts = typeof opts.maxRollouts === 'string' ? Number.parseInt(opts.maxRollouts, 10) : undefined;
|
|
608
|
+
await handleCodexIngestCatchUp({
|
|
609
|
+
workspace: opts.workspace,
|
|
610
|
+
json: opts.json === true,
|
|
611
|
+
...(maxRollouts !== undefined && Number.isInteger(maxRollouts) ? { maxRollouts } : {}),
|
|
612
|
+
});
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
// ── pd codex worker — Workspace-scoped governance worker ─────────────────────
|
|
616
|
+
// Codex Governance Closure Slice C (PRI-624, SPEC §13; ADR-0020 §11.1): the
|
|
617
|
+
// one Owner-approved background worker (catch-up → reconciliation → one
|
|
618
|
+
// Diagnostician execution → one bounded downstream consumer cycle). The
|
|
619
|
+
// Companion spawns this per registered workspace; --once is the manual and
|
|
620
|
+
// supervised-restart form.
|
|
621
|
+
codexCmd
|
|
622
|
+
.command('worker')
|
|
623
|
+
.description('Run the Codex workspace governance worker (catch-up, reconciliation, diagnostician, downstream)')
|
|
624
|
+
.option('-w, --workspace <path>', 'Workspace directory')
|
|
625
|
+
.option('--once', 'Run exactly one bounded cycle and exit')
|
|
626
|
+
.option('--interval <ms>', 'Cycle interval for continuous mode (default 120000, minimum 1000)')
|
|
627
|
+
.option('--status', 'Report the SPEC §15 worker mode without executing anything')
|
|
628
|
+
.option('--json', 'Output raw JSON')
|
|
629
|
+
.action(async (opts) => {
|
|
630
|
+
const { handleCodexWorker } = await import('./commands/codex-worker.js');
|
|
631
|
+
const intervalMs = typeof opts.interval === 'string' ? Number.parseInt(opts.interval, 10) : undefined;
|
|
632
|
+
await handleCodexWorker({
|
|
633
|
+
workspace: opts.workspace,
|
|
634
|
+
json: opts.json === true,
|
|
635
|
+
once: opts.once === true,
|
|
636
|
+
status: opts.status === true,
|
|
637
|
+
...(intervalMs !== undefined && Number.isInteger(intervalMs) ? { intervalMs } : {}),
|
|
638
|
+
});
|
|
639
|
+
});
|
|
640
|
+
|
|
566
641
|
runtimeHealthCmd
|
|
567
642
|
.command('gfi')
|
|
568
643
|
.description('GFI workspace snapshot — active vs stale session breakdown')
|
|
@@ -21,6 +21,7 @@ import * as http from 'http';
|
|
|
21
21
|
// ─── Public types ────────────────────────────────────────────────────────────
|
|
22
22
|
|
|
23
23
|
export type ConsoleStatus = 'reused' | 'started' | 'failed' | 'refused';
|
|
24
|
+
export type ConsoleAuthenticationMode = 'authenticated' | 'no_auth';
|
|
24
25
|
|
|
25
26
|
export interface ConsoleLaunchResult {
|
|
26
27
|
status: ConsoleStatus;
|
|
@@ -34,6 +35,8 @@ export interface ConsoleLaunchResult {
|
|
|
34
35
|
reused: boolean;
|
|
35
36
|
/** True when a browser should/has been opened (skipped in --json mode). */
|
|
36
37
|
browserOpened: boolean;
|
|
38
|
+
/** Verified for successful launch/reuse; omitted when no server was reached. */
|
|
39
|
+
authenticationMode?: ConsoleAuthenticationMode;
|
|
37
40
|
/**
|
|
38
41
|
* PID of the freshly spawned console server process. Present only when
|
|
39
42
|
* status === 'started'; absent on 'reused' (the server was started by
|
|
@@ -58,6 +61,10 @@ const DEFAULT_PORT = 3100;
|
|
|
58
61
|
const DEFAULT_HOST = '127.0.0.1';
|
|
59
62
|
const PORT_FALLBACK_LIMIT = 20; // try 3100..3119 before giving up
|
|
60
63
|
|
|
64
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
65
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
66
|
+
}
|
|
67
|
+
|
|
61
68
|
// ─── Loopback safety (ERR-049) ──────────────────────────────────────────────
|
|
62
69
|
|
|
63
70
|
/** Returns true if the host resolves to a loopback address. */
|
|
@@ -138,13 +145,20 @@ export interface HealthProbeOptions {
|
|
|
138
145
|
}
|
|
139
146
|
|
|
140
147
|
/** Probe a port to see if it serves a healthy PD Console. */
|
|
141
|
-
export
|
|
148
|
+
export interface ConsoleHealthProbeResult {
|
|
149
|
+
healthy: boolean;
|
|
150
|
+
authenticationMode?: ConsoleAuthenticationMode;
|
|
151
|
+
reason?: string;
|
|
152
|
+
failureKind?: 'unauthorized' | 'invalid_response' | 'unreachable';
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export async function probeConsoleHealth(opts: HealthProbeOptions): Promise<ConsoleHealthProbeResult> {
|
|
142
156
|
const { host, port, timeoutMs = 1500, token } = opts;
|
|
143
157
|
|
|
144
158
|
if (Object.hasOwn(globalThis, '__mockProbeConsoleHealth')) {
|
|
145
159
|
const mock = Reflect.get(globalThis, '__mockProbeConsoleHealth') as (
|
|
146
160
|
o: HealthProbeOptions
|
|
147
|
-
) => Promise<
|
|
161
|
+
) => Promise<ConsoleHealthProbeResult>;
|
|
148
162
|
return mock(opts);
|
|
149
163
|
}
|
|
150
164
|
return new Promise((resolve) => {
|
|
@@ -157,7 +171,7 @@ export async function probeConsoleHealth(opts: HealthProbeOptions): Promise<{ he
|
|
|
157
171
|
(res) => {
|
|
158
172
|
// 401 means auth required — treat as unhealthy, not a generic error
|
|
159
173
|
if (res.statusCode === 401) {
|
|
160
|
-
resolve({ healthy: false, reason: 'console health endpoint returned 401 (unauthorized) — check PD_CONSOLE_TOKEN' });
|
|
174
|
+
resolve({ healthy: false, failureKind: 'unauthorized', reason: 'console health endpoint returned 401 (unauthorized) — check PD_CONSOLE_TOKEN' });
|
|
161
175
|
return;
|
|
162
176
|
}
|
|
163
177
|
if (res.statusCode !== 200) {
|
|
@@ -170,13 +184,19 @@ export async function probeConsoleHealth(opts: HealthProbeOptions): Promise<{ he
|
|
|
170
184
|
});
|
|
171
185
|
res.on('end', () => {
|
|
172
186
|
try {
|
|
173
|
-
const body = JSON.parse(data)
|
|
174
|
-
if (body
|
|
187
|
+
const body: unknown = JSON.parse(data);
|
|
188
|
+
if (isRecord(body)) {
|
|
175
189
|
const isHealthy =
|
|
176
190
|
(Object.hasOwn(body, 'healthy') && Reflect.get(body, 'healthy') === true) ||
|
|
177
191
|
(Object.hasOwn(body, 'success') && Reflect.get(body, 'success') === true);
|
|
178
192
|
if (isHealthy) {
|
|
179
|
-
|
|
193
|
+
const dataValue = Object.hasOwn(body, 'data') ? Reflect.get(body, 'data') : undefined;
|
|
194
|
+
const payloadRecord = isRecord(dataValue) ? dataValue : body;
|
|
195
|
+
const mode = Reflect.get(payloadRecord, 'authenticationMode');
|
|
196
|
+
resolve({
|
|
197
|
+
healthy: true,
|
|
198
|
+
...(mode === 'authenticated' || mode === 'no_auth' ? { authenticationMode: mode } : {}),
|
|
199
|
+
});
|
|
180
200
|
} else {
|
|
181
201
|
resolve({ healthy: false, reason: 'console health JSON was missing healthy/success markers' });
|
|
182
202
|
}
|
|
@@ -387,6 +407,17 @@ export async function planConsoleLaunch(input: OrchestratorInput): Promise<Orche
|
|
|
387
407
|
// Step 1: Is there already a healthy console on the preferred port?
|
|
388
408
|
const health = await probeConsoleHealth({ host, port: preferredPort, token });
|
|
389
409
|
if (health.healthy) {
|
|
410
|
+
if (token && health.authenticationMode !== 'authenticated') {
|
|
411
|
+
return {
|
|
412
|
+
status: 'refused',
|
|
413
|
+
url: '',
|
|
414
|
+
port: preferredPort,
|
|
415
|
+
host,
|
|
416
|
+
reused: false,
|
|
417
|
+
reason: 'console_authentication_mode_mismatch',
|
|
418
|
+
nextAction: `Stop the Console on port ${preferredPort}, then retry so Companion can start an authenticated Console.`,
|
|
419
|
+
};
|
|
420
|
+
}
|
|
390
421
|
return {
|
|
391
422
|
status: 'reused',
|
|
392
423
|
url: buildConsoleUrl(host, preferredPort),
|
|
@@ -396,6 +427,18 @@ export async function planConsoleLaunch(input: OrchestratorInput): Promise<Orche
|
|
|
396
427
|
};
|
|
397
428
|
}
|
|
398
429
|
|
|
430
|
+
if (token && health.failureKind === 'unauthorized') {
|
|
431
|
+
return {
|
|
432
|
+
status: 'refused',
|
|
433
|
+
url: '',
|
|
434
|
+
port: preferredPort,
|
|
435
|
+
host,
|
|
436
|
+
reused: false,
|
|
437
|
+
reason: 'console_authentication_failed',
|
|
438
|
+
nextAction: 'Verify PD_CONSOLE_TOKEN matches the running Console, stop that Console, then retry.',
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
|
|
399
442
|
// Step 2: Is the preferred port simply occupied by something else?
|
|
400
443
|
const preferredInUse = await isPortInUse(host, preferredPort);
|
|
401
444
|
|