@principles/codex-adapter 0.1.1 → 0.2.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/index.d.ts +8 -2
- package/dist/index.js +5 -1
- package/dist/ingestion/admission.d.ts +54 -0
- package/dist/ingestion/admission.js +137 -0
- package/dist/ingestion/catch-up.d.ts +41 -0
- package/dist/ingestion/catch-up.js +94 -0
- package/dist/ingestion/ingestion-fields.d.ts +17 -0
- package/dist/ingestion/ingestion-fields.js +28 -0
- package/dist/ingestion/ingestion.d.ts +25 -0
- package/dist/ingestion/ingestion.js +47 -33
- package/dist/ingestion/transcript-locate.d.ts +13 -0
- package/dist/ingestion/transcript-locate.js +75 -0
- package/dist/ingestion/transcript-path.d.ts +2 -0
- package/dist/ingestion/transcript-path.js +1 -1
- package/dist/pd-hook.js +21 -5
- package/dist/worker/workspace-worker.d.ts +57 -0
- package/dist/worker/workspace-worker.js +231 -0
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -13,7 +13,13 @@ export { resolveCodexHome, canonicalizePath } from './ingestion/codex-home.js';
|
|
|
13
13
|
export { validateCodexTranscriptPath } from './ingestion/transcript-path.js';
|
|
14
14
|
export type { TranscriptPathValidation, TranscriptFileIdentity } from './ingestion/transcript-path.js';
|
|
15
15
|
export { classifyCodexVersion, CODEX_INGESTION_MIN_VERSION, CODEX_INGESTION_VERIFIED_VERSION } from './ingestion/codex-version.js';
|
|
16
|
-
export { ingestCodexConversation, setCodexTranscriptPortForTest } from './ingestion/ingestion.js';
|
|
17
|
-
export type { CodexIngestionOptions, CodexIngestionOutcome } from './ingestion/ingestion.js';
|
|
16
|
+
export { ingestCodexConversation, setCodexTranscriptPortForTest, ingestCodexTranscriptFromPath } from './ingestion/ingestion.js';
|
|
17
|
+
export type { CodexIngestionOptions, CodexIngestionOutcome, CodexTranscriptFromPathArgs } from './ingestion/ingestion.js';
|
|
18
18
|
export { decodeTranscriptWindow, createNodeTranscriptPort, TranscriptReplacedError, CODEX_INGESTION_MAX_BATCH_BYTES, CODEX_INGESTION_MAX_BATCH_RECORDS } from './ingestion/transcript-decoder.js';
|
|
19
19
|
export type { TranscriptPort, TranscriptExpectedIdentity, DecodedDelta, TranscriptDecodeStop } from './ingestion/transcript-decoder.js';
|
|
20
|
+
export { catchUpCodexIngestion, CODEX_CATCH_UP_NEXT_ACTION } from './ingestion/catch-up.js';
|
|
21
|
+
export type { CodexCatchUpOptions, CodexCatchUpResult, CodexCatchUpRolloutResult } from './ingestion/catch-up.js';
|
|
22
|
+
export { locateCodexTranscriptByRolloutIdentity } from './ingestion/transcript-locate.js';
|
|
23
|
+
export type { CodexTranscriptLookup } from './ingestion/transcript-locate.js';
|
|
24
|
+
export { runCodexWorkspaceWorkerCycle, computeCodexWorkerStatusMode } from './worker/workspace-worker.js';
|
|
25
|
+
export type { CodexWorkerMode, CodexWorkerCycleResult, CodexWorkerCycleStepReport, CodexWorkerCycleOptions, CodexWorkerStatusEvaluation } from './worker/workspace-worker.js';
|
package/dist/index.js
CHANGED
|
@@ -11,5 +11,9 @@ export * from './codec/index.js';
|
|
|
11
11
|
export { resolveCodexHome, canonicalizePath } from './ingestion/codex-home.js';
|
|
12
12
|
export { validateCodexTranscriptPath } from './ingestion/transcript-path.js';
|
|
13
13
|
export { classifyCodexVersion, CODEX_INGESTION_MIN_VERSION, CODEX_INGESTION_VERIFIED_VERSION } from './ingestion/codex-version.js';
|
|
14
|
-
export { ingestCodexConversation, setCodexTranscriptPortForTest } from './ingestion/ingestion.js';
|
|
14
|
+
export { ingestCodexConversation, setCodexTranscriptPortForTest, ingestCodexTranscriptFromPath } from './ingestion/ingestion.js';
|
|
15
15
|
export { decodeTranscriptWindow, createNodeTranscriptPort, TranscriptReplacedError, CODEX_INGESTION_MAX_BATCH_BYTES, CODEX_INGESTION_MAX_BATCH_RECORDS } from './ingestion/transcript-decoder.js';
|
|
16
|
+
// PRI-624 Slice C: bounded catch-up + per-workspace worker cycle.
|
|
17
|
+
export { catchUpCodexIngestion, CODEX_CATCH_UP_NEXT_ACTION } from './ingestion/catch-up.js';
|
|
18
|
+
export { locateCodexTranscriptByRolloutIdentity } from './ingestion/transcript-locate.js';
|
|
19
|
+
export { runCodexWorkspaceWorkerCycle, computeCodexWorkerStatusMode } from './worker/workspace-worker.js';
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex governance admission orchestration (Codex Governance Closure Slice B,
|
|
3
|
+
* PRI-623; SPEC §12/§13).
|
|
4
|
+
*
|
|
5
|
+
* Owns ONLY the Codex facts: mapping live hook payloads and decoded transcript
|
|
6
|
+
* observations onto the host-neutral admission candidates of
|
|
7
|
+
* `@principles/host-runtime` (`admitGovernanceSignals`), then driving the
|
|
8
|
+
* admitted pains through the existing continuation seams —
|
|
9
|
+
* `ensureGovernanceDiagnosticianTask` (Runtime V2 async enqueue, never an
|
|
10
|
+
* LLM) and `promoteAdmittedGovernanceEvidence` (Slice A's ≤12 + trigger +
|
|
11
|
+
* next-assistant window with its durable pending tail).
|
|
12
|
+
*
|
|
13
|
+
* No Codex JSONL knowledge crosses into host-runtime; no second pain or task
|
|
14
|
+
* authority exists here.
|
|
15
|
+
*/
|
|
16
|
+
import { type GovernanceSignalCandidate } from '@principles/host-runtime';
|
|
17
|
+
import type { GovernanceObservationInput } from '@principles/host-runtime';
|
|
18
|
+
import type { PayloadFields } from './ingestion-fields.js';
|
|
19
|
+
export interface GovernanceAdmissionDegradation {
|
|
20
|
+
readonly reason: string;
|
|
21
|
+
readonly nextAction: string;
|
|
22
|
+
}
|
|
23
|
+
export interface GovernanceAdmissionRun {
|
|
24
|
+
/** Bounded structured degradations (only real failures; duplicate / rate-limited / ordinary conversation are normal outcomes and stay silent, SPEC §20/§21). */
|
|
25
|
+
readonly degradations: readonly GovernanceAdmissionDegradation[];
|
|
26
|
+
}
|
|
27
|
+
/** Live UserPromptSubmit payload → correction candidate (logical key mirrors the observation). */
|
|
28
|
+
export declare function buildLiveCorrectionCandidate(args: {
|
|
29
|
+
fields: PayloadFields;
|
|
30
|
+
rolloutIdentity: string;
|
|
31
|
+
}): GovernanceSignalCandidate | null;
|
|
32
|
+
/** Live PostToolUse payload → tool-failure candidate (logical key mirrors the observation). */
|
|
33
|
+
export declare function buildLiveToolCandidate(args: {
|
|
34
|
+
fields: PayloadFields;
|
|
35
|
+
rolloutIdentity: string;
|
|
36
|
+
}): GovernanceSignalCandidate | null;
|
|
37
|
+
/**
|
|
38
|
+
* Decoded transcript observations → candidates. User turns carry visibleText;
|
|
39
|
+
* tool calls carry the CommandExecution facts projection. The logical keys are
|
|
40
|
+
* the decoded ones, so a replay of an already-admitted live event is an
|
|
41
|
+
* observation-level no-op (SPEC §10).
|
|
42
|
+
*/
|
|
43
|
+
export declare function buildTranscriptCandidates(observations: readonly GovernanceObservationInput[]): readonly GovernanceSignalCandidate[];
|
|
44
|
+
/**
|
|
45
|
+
* Run admission for the given candidates and drive every admitted pain
|
|
46
|
+
* through task-ensure + evidence promotion. Exactly-once is owned by the
|
|
47
|
+
* host-runtime seams; this orchestrator only sequences them and surfaces
|
|
48
|
+
* bounded degradations (duplicate / rate-limited / ordinary conversation are
|
|
49
|
+
* normal outcomes and produce no noise, SPEC §20/§21).
|
|
50
|
+
*/
|
|
51
|
+
export declare function runGovernanceAdmission(args: {
|
|
52
|
+
workspaceDir: string;
|
|
53
|
+
candidates: readonly GovernanceSignalCandidate[];
|
|
54
|
+
}): Promise<GovernanceAdmissionRun>;
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex governance admission orchestration (Codex Governance Closure Slice B,
|
|
3
|
+
* PRI-623; SPEC §12/§13).
|
|
4
|
+
*
|
|
5
|
+
* Owns ONLY the Codex facts: mapping live hook payloads and decoded transcript
|
|
6
|
+
* observations onto the host-neutral admission candidates of
|
|
7
|
+
* `@principles/host-runtime` (`admitGovernanceSignals`), then driving the
|
|
8
|
+
* admitted pains through the existing continuation seams —
|
|
9
|
+
* `ensureGovernanceDiagnosticianTask` (Runtime V2 async enqueue, never an
|
|
10
|
+
* LLM) and `promoteAdmittedGovernanceEvidence` (Slice A's ≤12 + trigger +
|
|
11
|
+
* next-assistant window with its durable pending tail).
|
|
12
|
+
*
|
|
13
|
+
* No Codex JSONL knowledge crosses into host-runtime; no second pain or task
|
|
14
|
+
* authority exists here.
|
|
15
|
+
*/
|
|
16
|
+
import { admitGovernanceSignals, ensureGovernanceContinuation, } from '@principles/host-runtime';
|
|
17
|
+
import { isRecord, own } from './ingestion-fields.js';
|
|
18
|
+
/** The live host-event source used by the identity derivation (parity with the dispatch handler). */
|
|
19
|
+
const CODEX_POST_TOOL_USE_SOURCE = 'codex:post_tool_use';
|
|
20
|
+
/** Live UserPromptSubmit payload → correction candidate (logical key mirrors the observation). */
|
|
21
|
+
export function buildLiveCorrectionCandidate(args) {
|
|
22
|
+
const { fields } = args;
|
|
23
|
+
if (fields.turnId === null || fields.prompt === null)
|
|
24
|
+
return null;
|
|
25
|
+
return {
|
|
26
|
+
kind: 'user_correction',
|
|
27
|
+
hostKind: 'codex',
|
|
28
|
+
logicalObservationKey: `codex|${args.rolloutIdentity}|${fields.turnId}|user`,
|
|
29
|
+
rolloutIdentity: args.rolloutIdentity,
|
|
30
|
+
rootSessionId: fields.sessionId,
|
|
31
|
+
hostTurnId: fields.turnId,
|
|
32
|
+
text: fields.prompt,
|
|
33
|
+
observedAt: new Date().toISOString(),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/** Live PostToolUse payload → tool-failure candidate (logical key mirrors the observation). */
|
|
37
|
+
export function buildLiveToolCandidate(args) {
|
|
38
|
+
const { fields } = args;
|
|
39
|
+
if (fields.turnId === null || fields.toolUseId === null)
|
|
40
|
+
return null;
|
|
41
|
+
return {
|
|
42
|
+
kind: 'tool_failure',
|
|
43
|
+
hostKind: 'codex',
|
|
44
|
+
logicalObservationKey: `codex|${args.rolloutIdentity}|${fields.toolUseId}`,
|
|
45
|
+
rolloutIdentity: args.rolloutIdentity,
|
|
46
|
+
rootSessionId: fields.sessionId,
|
|
47
|
+
hostTurnId: fields.turnId,
|
|
48
|
+
toolName: fields.toolName ?? '',
|
|
49
|
+
source: CODEX_POST_TOOL_USE_SOURCE,
|
|
50
|
+
...(fields.toolInput !== undefined ? { toolInput: fields.toolInput } : {}),
|
|
51
|
+
...(fields.toolResponse !== undefined ? { toolOutput: fields.toolResponse } : {}),
|
|
52
|
+
observedAt: new Date().toISOString(),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Decoded transcript observations → candidates. User turns carry visibleText;
|
|
57
|
+
* tool calls carry the CommandExecution facts projection. The logical keys are
|
|
58
|
+
* the decoded ones, so a replay of an already-admitted live event is an
|
|
59
|
+
* observation-level no-op (SPEC §10).
|
|
60
|
+
*/
|
|
61
|
+
export function buildTranscriptCandidates(observations) {
|
|
62
|
+
const candidates = [];
|
|
63
|
+
for (const observation of observations) {
|
|
64
|
+
if (observation.kind === 'user_turn') {
|
|
65
|
+
if (observation.visibleText === undefined)
|
|
66
|
+
continue;
|
|
67
|
+
candidates.push({
|
|
68
|
+
kind: 'user_correction',
|
|
69
|
+
hostKind: 'codex',
|
|
70
|
+
logicalObservationKey: observation.logicalObservationKey,
|
|
71
|
+
rolloutIdentity: observation.rolloutIdentity,
|
|
72
|
+
rootSessionId: observation.rootSessionId,
|
|
73
|
+
hostTurnId: observation.hostTurnId,
|
|
74
|
+
text: observation.visibleText,
|
|
75
|
+
observedAt: observation.observedAt,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
else if (observation.kind === 'tool_call') {
|
|
79
|
+
const facts = observation.toolFacts;
|
|
80
|
+
if (!isRecord(facts))
|
|
81
|
+
continue;
|
|
82
|
+
const toolName = own(facts, 'toolName');
|
|
83
|
+
const exitCode = own(facts, 'exitCode');
|
|
84
|
+
candidates.push({
|
|
85
|
+
kind: 'tool_failure',
|
|
86
|
+
hostKind: 'codex',
|
|
87
|
+
logicalObservationKey: observation.logicalObservationKey,
|
|
88
|
+
rolloutIdentity: observation.rolloutIdentity,
|
|
89
|
+
rootSessionId: observation.rootSessionId,
|
|
90
|
+
hostTurnId: observation.hostTurnId,
|
|
91
|
+
toolName: typeof toolName === 'string' ? toolName : '',
|
|
92
|
+
source: CODEX_POST_TOOL_USE_SOURCE,
|
|
93
|
+
toolInput: own(facts, 'command') ?? null,
|
|
94
|
+
...(exitCode !== undefined ? { toolOutput: { exitCode, stdout: own(facts, 'stdout') ?? null, stderr: own(facts, 'stderr') ?? null } } : {}),
|
|
95
|
+
observedAt: observation.observedAt,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return candidates;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Run admission for the given candidates and drive every admitted pain
|
|
103
|
+
* through task-ensure + evidence promotion. Exactly-once is owned by the
|
|
104
|
+
* host-runtime seams; this orchestrator only sequences them and surfaces
|
|
105
|
+
* bounded degradations (duplicate / rate-limited / ordinary conversation are
|
|
106
|
+
* normal outcomes and produce no noise, SPEC §20/§21).
|
|
107
|
+
*/
|
|
108
|
+
export async function runGovernanceAdmission(args) {
|
|
109
|
+
const { workspaceDir, candidates } = args;
|
|
110
|
+
if (candidates.length === 0)
|
|
111
|
+
return { degradations: [] };
|
|
112
|
+
const degradations = [];
|
|
113
|
+
const push = (reason, nextAction) => {
|
|
114
|
+
if (degradations.length < 4)
|
|
115
|
+
degradations.push({ reason: reason.slice(0, 300), nextAction: nextAction.slice(0, 300) });
|
|
116
|
+
};
|
|
117
|
+
const admitted = admitGovernanceSignals({ workspaceDir, candidates });
|
|
118
|
+
if (!admitted.ok) {
|
|
119
|
+
push(admitted.reason, admitted.nextAction);
|
|
120
|
+
return { degradations };
|
|
121
|
+
}
|
|
122
|
+
for (const outcome of admitted.outcomes) {
|
|
123
|
+
if (!('disposition' in outcome))
|
|
124
|
+
continue;
|
|
125
|
+
if (outcome.disposition !== 'admitted' && outcome.disposition !== 'already_admitted')
|
|
126
|
+
continue;
|
|
127
|
+
const cont = await ensureGovernanceContinuation({
|
|
128
|
+
workspaceDir,
|
|
129
|
+
logicalObservationKey: outcome.logicalObservationKey,
|
|
130
|
+
canonicalPainId: outcome.canonicalPainId,
|
|
131
|
+
});
|
|
132
|
+
if (!cont.ok) {
|
|
133
|
+
push(cont.reason, cont.nextAction);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return { degradations };
|
|
137
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { type CodexIngestionOutcome } from './ingestion.js';
|
|
2
|
+
import type { TranscriptPort } from './transcript-decoder.js';
|
|
3
|
+
export interface CodexCatchUpOptions {
|
|
4
|
+
readonly workspaceDir: string;
|
|
5
|
+
readonly env?: {
|
|
6
|
+
CODEX_HOME?: string | undefined;
|
|
7
|
+
};
|
|
8
|
+
readonly now?: Date;
|
|
9
|
+
readonly port?: TranscriptPort;
|
|
10
|
+
/** Bounded per invocation (default 8 rollouts × the 1 MiB batch bound). */
|
|
11
|
+
readonly maxRollouts?: number;
|
|
12
|
+
}
|
|
13
|
+
export interface CodexCatchUpRolloutResult {
|
|
14
|
+
readonly rolloutIdentity: string;
|
|
15
|
+
readonly outcome: CodexIngestionOutcome;
|
|
16
|
+
/** Admission degradations from this rollout's candidates (bounded by runGovernanceAdmission). */
|
|
17
|
+
readonly admissionDegradations: readonly {
|
|
18
|
+
reason: string;
|
|
19
|
+
nextAction: string;
|
|
20
|
+
}[];
|
|
21
|
+
}
|
|
22
|
+
export type CodexCatchUpResult = {
|
|
23
|
+
status: 'skipped';
|
|
24
|
+
reason: string;
|
|
25
|
+
nextAction: string;
|
|
26
|
+
} | {
|
|
27
|
+
status: 'ok' | 'degraded';
|
|
28
|
+
/** Rollouts processed this invocation (bounded). */
|
|
29
|
+
readonly rollouts: readonly CodexCatchUpRolloutResult[];
|
|
30
|
+
/** Processed rollouts that still carry lag (degraded or lagBytes > 0). */
|
|
31
|
+
readonly remainingLagRollouts: readonly string[];
|
|
32
|
+
/** Checkpoints not examined this invocation because of the bound (rotation converges them over repeated passes). */
|
|
33
|
+
readonly unexaminedRollouts: readonly string[];
|
|
34
|
+
};
|
|
35
|
+
export declare const CODEX_CATCH_UP_NEXT_ACTION = "Set features.codex_conversation_ingestion.enabled=true in the selected Workspace .pd/config.yaml to enable bounded conversation ingestion.";
|
|
36
|
+
/**
|
|
37
|
+
* Perform one bounded catch-up pass. `remainingLagRollouts` is derived from
|
|
38
|
+
* each processed rollout's reported lag plus the checkpoints left unprocessed
|
|
39
|
+
* by the bound — callers (worker cycle / CLI) repeat passes to converge.
|
|
40
|
+
*/
|
|
41
|
+
export declare function catchUpCodexIngestion(options: CodexCatchUpOptions): Promise<CodexCatchUpResult>;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex ingestion catch-up (PRI-624 Slice C; SPEC §13/§15).
|
|
3
|
+
*
|
|
4
|
+
* Bounded non-destructive catch-up of transcript lag for rollouts the
|
|
5
|
+
* authenticated Workspace hook already delivered: every checkpoint in the
|
|
6
|
+
* workspace trajectory database names a rollout that once arrived with an
|
|
7
|
+
* authenticated transcript_path. For each (bounded) checkpoint we resolve the
|
|
8
|
+
* rollout's transcript by EXACT uuid (never a session scan), re-authorize the
|
|
9
|
+
* path through the same validator the hook uses, run the same bounded
|
|
10
|
+
* incremental delta from the checkpoint, and feed any admission candidates
|
|
11
|
+
* through the same Slice B admission/continuation pass the hook runs.
|
|
12
|
+
*
|
|
13
|
+
* Gated by `codex_conversation_ingestion`: flag-off performs ZERO transcript
|
|
14
|
+
* filesystem I/O — no checkpoint-to-path resolution, no sessions-tree lookup,
|
|
15
|
+
* no reads (SPEC §10 hard privacy invariant; the port spy test covers the
|
|
16
|
+
* lookup path too).
|
|
17
|
+
*/
|
|
18
|
+
import { computeFeatureFlagsFromConfig } from '@principles/core/runtime-v2';
|
|
19
|
+
import { loadPdConfigForPlugin, listGovernanceCheckpoints } from '@principles/host-runtime';
|
|
20
|
+
import { resolveCodexHome } from './codex-home.js';
|
|
21
|
+
import { locateCodexTranscriptByRolloutIdentity } from './transcript-locate.js';
|
|
22
|
+
import { ingestCodexTranscriptFromPath } from './ingestion.js';
|
|
23
|
+
import { runGovernanceAdmission } from './admission.js';
|
|
24
|
+
const DEFAULT_MAX_ROLLOUTS = 8;
|
|
25
|
+
export const CODEX_CATCH_UP_NEXT_ACTION = 'Set features.codex_conversation_ingestion.enabled=true in the selected Workspace .pd/config.yaml to enable bounded conversation ingestion.';
|
|
26
|
+
/**
|
|
27
|
+
* Perform one bounded catch-up pass. `remainingLagRollouts` is derived from
|
|
28
|
+
* each processed rollout's reported lag plus the checkpoints left unprocessed
|
|
29
|
+
* by the bound — callers (worker cycle / CLI) repeat passes to converge.
|
|
30
|
+
*/
|
|
31
|
+
export async function catchUpCodexIngestion(options) {
|
|
32
|
+
const { workspaceDir } = options;
|
|
33
|
+
// Flag gate FIRST — before resolveCodexHome, checkpoint listing, any FS I/O.
|
|
34
|
+
const config = loadPdConfigForPlugin(workspaceDir);
|
|
35
|
+
if (!config.ok) {
|
|
36
|
+
const [first] = config.errors;
|
|
37
|
+
return { status: 'skipped', reason: `pd_config_invalid:${first?.reason ?? 'unknown'}`, nextAction: first?.nextAction ?? 'Repair .pd/config.yaml.' };
|
|
38
|
+
}
|
|
39
|
+
const { flags } = computeFeatureFlagsFromConfig(config.effective);
|
|
40
|
+
if (flags['host.codex']?.enabled !== true) {
|
|
41
|
+
return { status: 'skipped', reason: 'host.codex_disabled', nextAction: 'Set features.host.codex.enabled=true in the selected Workspace to enable PD.' };
|
|
42
|
+
}
|
|
43
|
+
if (flags.codex_conversation_ingestion?.enabled !== true) {
|
|
44
|
+
return { status: 'skipped', reason: 'feature_disabled', nextAction: CODEX_CATCH_UP_NEXT_ACTION };
|
|
45
|
+
}
|
|
46
|
+
const home = resolveCodexHome(options.env);
|
|
47
|
+
if (!home.ok)
|
|
48
|
+
return { status: 'skipped', reason: home.reason, nextAction: home.nextAction };
|
|
49
|
+
const listed = listGovernanceCheckpoints({ workspaceDir, hostKind: 'codex' });
|
|
50
|
+
if (!listed.ok)
|
|
51
|
+
return { status: 'skipped', reason: listed.reason, nextAction: listed.nextAction };
|
|
52
|
+
const maxRollouts = Math.max(1, Math.min(options.maxRollouts ?? DEFAULT_MAX_ROLLOUTS, 32));
|
|
53
|
+
const checkpoints = listed.checkpoints.slice(0, maxRollouts);
|
|
54
|
+
const unprocessed = listed.checkpoints.slice(maxRollouts);
|
|
55
|
+
const rollouts = [];
|
|
56
|
+
const remainingLagRollouts = [];
|
|
57
|
+
for (const checkpoint of checkpoints) {
|
|
58
|
+
const located = locateCodexTranscriptByRolloutIdentity(home.home, checkpoint.rolloutIdentity);
|
|
59
|
+
if (!located.ok) {
|
|
60
|
+
remainingLagRollouts.push(checkpoint.rolloutIdentity);
|
|
61
|
+
rollouts.push({
|
|
62
|
+
rolloutIdentity: checkpoint.rolloutIdentity,
|
|
63
|
+
outcome: { status: 'degraded', reason: located.reason, nextAction: located.nextAction, warnings: [] },
|
|
64
|
+
admissionDegradations: [],
|
|
65
|
+
});
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const outcome = ingestCodexTranscriptFromPath({
|
|
69
|
+
workspaceDir,
|
|
70
|
+
transcriptPath: located.transcriptPath,
|
|
71
|
+
fallbackRootSessionId: checkpoint.rootSessionId,
|
|
72
|
+
env: options.env,
|
|
73
|
+
now: options.now,
|
|
74
|
+
port: options.port,
|
|
75
|
+
});
|
|
76
|
+
let admissionDegradations = [];
|
|
77
|
+
if (outcome.status === 'ok' && outcome.admissionCandidates.length > 0) {
|
|
78
|
+
const admission = await runGovernanceAdmission({ workspaceDir, candidates: outcome.admissionCandidates });
|
|
79
|
+
admissionDegradations = [...admission.degradations];
|
|
80
|
+
}
|
|
81
|
+
if (outcome.status !== 'ok' || outcome.lagBytes > 0) {
|
|
82
|
+
remainingLagRollouts.push(checkpoint.rolloutIdentity);
|
|
83
|
+
}
|
|
84
|
+
rollouts.push({ rolloutIdentity: checkpoint.rolloutIdentity, outcome, admissionDegradations });
|
|
85
|
+
}
|
|
86
|
+
const unexaminedRollouts = unprocessed.map((leftover) => leftover.rolloutIdentity);
|
|
87
|
+
const anyDegraded = rollouts.some((entry) => entry.outcome.status === 'degraded' || entry.admissionDegradations.length > 0);
|
|
88
|
+
return {
|
|
89
|
+
status: anyDegraded ? 'degraded' : 'ok',
|
|
90
|
+
rollouts,
|
|
91
|
+
remainingLagRollouts,
|
|
92
|
+
unexaminedRollouts,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex hook payload field extraction (Slice A) — shared by the live
|
|
3
|
+
* observation ingestor and the Slice B admission orchestrator.
|
|
4
|
+
*/
|
|
5
|
+
export interface PayloadFields {
|
|
6
|
+
transcriptPath: string | null;
|
|
7
|
+
sessionId: string;
|
|
8
|
+
turnId: string | null;
|
|
9
|
+
prompt: string | null;
|
|
10
|
+
toolUseId: string | null;
|
|
11
|
+
toolName: string | null;
|
|
12
|
+
toolInput: unknown;
|
|
13
|
+
toolResponse: unknown;
|
|
14
|
+
}
|
|
15
|
+
export declare function isRecord(value: unknown): value is Record<string, unknown>;
|
|
16
|
+
export declare function own(value: Record<string, unknown>, key: string): unknown;
|
|
17
|
+
export declare function extractFields(raw: unknown): PayloadFields | null;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export function isRecord(value) {
|
|
2
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
3
|
+
}
|
|
4
|
+
export function own(value, key) {
|
|
5
|
+
return Object.hasOwn(value, key) ? Object.getOwnPropertyDescriptor(value, key)?.value : undefined;
|
|
6
|
+
}
|
|
7
|
+
export function extractFields(raw) {
|
|
8
|
+
if (!isRecord(raw))
|
|
9
|
+
return null;
|
|
10
|
+
const transcriptPath = own(raw, 'transcript_path');
|
|
11
|
+
if (transcriptPath !== null && typeof transcriptPath !== 'string')
|
|
12
|
+
return null;
|
|
13
|
+
const sessionId = own(raw, 'session_id');
|
|
14
|
+
const turnId = own(raw, 'turn_id');
|
|
15
|
+
const prompt = own(raw, 'prompt');
|
|
16
|
+
const toolUseId = own(raw, 'tool_use_id');
|
|
17
|
+
const toolName = own(raw, 'tool_name');
|
|
18
|
+
return {
|
|
19
|
+
transcriptPath: transcriptPath ?? null,
|
|
20
|
+
sessionId: typeof sessionId === 'string' ? sessionId : '',
|
|
21
|
+
turnId: typeof turnId === 'string' ? turnId : null,
|
|
22
|
+
prompt: typeof prompt === 'string' ? prompt : null,
|
|
23
|
+
toolUseId: typeof toolUseId === 'string' ? toolUseId : null,
|
|
24
|
+
toolName: typeof toolName === 'string' ? toolName : null,
|
|
25
|
+
toolInput: own(raw, 'tool_input'),
|
|
26
|
+
toolResponse: own(raw, 'tool_response'),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
* the transcript replay later converges with (SPEC §10 source precedence).
|
|
20
20
|
*/
|
|
21
21
|
import type { HostEventKind } from '@principles/core/host';
|
|
22
|
+
import { type GovernanceSignalCandidate } from '@principles/host-runtime';
|
|
22
23
|
import { type TranscriptPort } from './transcript-decoder.js';
|
|
23
24
|
export interface CodexIngestionOptions {
|
|
24
25
|
readonly workspaceDir: string;
|
|
@@ -35,6 +36,9 @@ export type CodexIngestionOutcome = {
|
|
|
35
36
|
duplicates: number;
|
|
36
37
|
warnings: readonly string[];
|
|
37
38
|
lagBytes: number;
|
|
39
|
+
/** Slice B: normalized admission candidates for the committed observations (host-neutral; consumed by pd-hook's admission pass). */
|
|
40
|
+
readonly admissionCandidates: readonly GovernanceSignalCandidate[];
|
|
41
|
+
readonly rolloutIdentity: string;
|
|
38
42
|
} | {
|
|
39
43
|
status: 'degraded';
|
|
40
44
|
reason: string;
|
|
@@ -44,3 +48,24 @@ export type CodexIngestionOutcome = {
|
|
|
44
48
|
/** Test seam: inject/inspect the transcript filesystem boundary (zero-read proofs). */
|
|
45
49
|
export declare function setCodexTranscriptPortForTest(port: TranscriptPort | null): void;
|
|
46
50
|
export declare function ingestCodexConversation(rawPayload: unknown, kind: HostEventKind, options: CodexIngestionOptions): CodexIngestionOutcome;
|
|
51
|
+
export interface CodexTranscriptFromPathArgs {
|
|
52
|
+
readonly workspaceDir: string;
|
|
53
|
+
/** Absolute transcript path (catch-up resolves it by exact rollout identity before calling). */
|
|
54
|
+
readonly transcriptPath: string;
|
|
55
|
+
/** Fallback root session when neither the transcript nor the checkpoint carries one. */
|
|
56
|
+
readonly fallbackRootSessionId: string;
|
|
57
|
+
readonly env?: {
|
|
58
|
+
CODEX_HOME?: string | undefined;
|
|
59
|
+
};
|
|
60
|
+
readonly now?: Date;
|
|
61
|
+
readonly port?: TranscriptPort;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* PRI-624 (Slice C): bounded incremental ingest of one known rollout from its
|
|
65
|
+
* durable checkpoint, driven by an explicit transcript path instead of a live
|
|
66
|
+
* hook payload. The path is re-authorized through the same
|
|
67
|
+
* `validateCodexTranscriptPath` containment + post-open identity contract the
|
|
68
|
+
* hook path uses (SPEC §9) — callers MUST only pass paths resolved from
|
|
69
|
+
* previously-authenticated rollouts (checkpoints), never discovered sessions.
|
|
70
|
+
*/
|
|
71
|
+
export declare function ingestCodexTranscriptFromPath(args: CodexTranscriptFromPathArgs): CodexIngestionOutcome;
|
|
@@ -3,6 +3,8 @@ import { resolveCodexHome } from './codex-home.js';
|
|
|
3
3
|
import { validateCodexTranscriptPath } from './transcript-path.js';
|
|
4
4
|
import { classifyCodexVersion, CODEX_VERSION_NEXT_ACTION } from './codex-version.js';
|
|
5
5
|
import { decodeTranscriptWindow, createNodeTranscriptPort, CODEX_INGESTION_MAX_BATCH_BYTES, TranscriptReplacedError } from './transcript-decoder.js';
|
|
6
|
+
import { extractFields } from './ingestion-fields.js';
|
|
7
|
+
import { buildLiveCorrectionCandidate, buildLiveToolCandidate, buildTranscriptCandidates } from './admission.js';
|
|
6
8
|
let activePort = null;
|
|
7
9
|
/** Test seam: inject/inspect the transcript filesystem boundary (zero-read proofs). */
|
|
8
10
|
export function setCodexTranscriptPortForTest(port) {
|
|
@@ -11,34 +13,6 @@ export function setCodexTranscriptPortForTest(port) {
|
|
|
11
13
|
function activeTranscriptPort(options) {
|
|
12
14
|
return options.port ?? activePort ?? createNodeTranscriptPort();
|
|
13
15
|
}
|
|
14
|
-
function isRecord(value) {
|
|
15
|
-
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
16
|
-
}
|
|
17
|
-
function own(value, key) {
|
|
18
|
-
return Object.hasOwn(value, key) ? Object.getOwnPropertyDescriptor(value, key)?.value : undefined;
|
|
19
|
-
}
|
|
20
|
-
function extractFields(raw) {
|
|
21
|
-
if (!isRecord(raw))
|
|
22
|
-
return null;
|
|
23
|
-
const transcriptPath = own(raw, 'transcript_path');
|
|
24
|
-
if (transcriptPath !== null && typeof transcriptPath !== 'string')
|
|
25
|
-
return null;
|
|
26
|
-
const sessionId = own(raw, 'session_id');
|
|
27
|
-
const turnId = own(raw, 'turn_id');
|
|
28
|
-
const prompt = own(raw, 'prompt');
|
|
29
|
-
const toolUseId = own(raw, 'tool_use_id');
|
|
30
|
-
const toolName = own(raw, 'tool_name');
|
|
31
|
-
return {
|
|
32
|
-
transcriptPath: transcriptPath ?? null,
|
|
33
|
-
sessionId: typeof sessionId === 'string' ? sessionId : '',
|
|
34
|
-
turnId: typeof turnId === 'string' ? turnId : null,
|
|
35
|
-
prompt: typeof prompt === 'string' ? prompt : null,
|
|
36
|
-
toolUseId: typeof toolUseId === 'string' ? toolUseId : null,
|
|
37
|
-
toolName: typeof toolName === 'string' ? toolName : null,
|
|
38
|
-
toolInput: own(raw, 'tool_input'),
|
|
39
|
-
toolResponse: own(raw, 'tool_response'),
|
|
40
|
-
};
|
|
41
|
-
}
|
|
42
16
|
function ingestLiveObservation({ fields, rolloutIdentity, workspaceDir, now }) {
|
|
43
17
|
const nowIso = now.toISOString();
|
|
44
18
|
let observation = null;
|
|
@@ -85,9 +59,18 @@ function ingestLiveObservation({ fields, rolloutIdentity, workspaceDir, now }) {
|
|
|
85
59
|
});
|
|
86
60
|
if (!result.ok)
|
|
87
61
|
return { status: 'degraded', reason: result.reason ?? 'governance_write_failed', nextAction: result.nextAction ?? 'inspect the workspace trajectory database.', warnings: result.warnings };
|
|
88
|
-
|
|
62
|
+
// Slice B: hand the live observation to the admission pass as a normalized
|
|
63
|
+
// candidate (SPEC §12) — the caller (pd-hook) runs detection/admission.
|
|
64
|
+
const candidate = observation.kind === 'user_turn'
|
|
65
|
+
? buildLiveCorrectionCandidate({ fields, rolloutIdentity })
|
|
66
|
+
: buildLiveToolCandidate({ fields, rolloutIdentity });
|
|
67
|
+
return {
|
|
68
|
+
status: 'ok', inserted: result.inserted, enriched: result.enriched, duplicates: result.duplicates, warnings: result.warnings, lagBytes: 0,
|
|
69
|
+
admissionCandidates: candidate !== null ? [candidate] : [],
|
|
70
|
+
rolloutIdentity,
|
|
71
|
+
};
|
|
89
72
|
}
|
|
90
|
-
function ingestTranscriptDelta({
|
|
73
|
+
function ingestTranscriptDelta({ fallbackRootSessionId, canonicalPath, identity, rolloutIdentity, workspaceDir, now, port }) {
|
|
91
74
|
const checkpoint = readGovernanceCheckpoint({ workspaceDir, hostKind: 'codex', rolloutIdentity });
|
|
92
75
|
if (checkpoint !== null && !('byteOffset' in checkpoint) && 'ok' in checkpoint && checkpoint.ok === false) {
|
|
93
76
|
return { status: 'degraded', reason: checkpoint.reason, nextAction: checkpoint.nextAction, warnings: [] };
|
|
@@ -118,7 +101,7 @@ function ingestTranscriptDelta({ fields, canonicalPath, identity, rolloutIdentit
|
|
|
118
101
|
fileOffset: offset,
|
|
119
102
|
byteBoundReached,
|
|
120
103
|
rolloutIdentity,
|
|
121
|
-
fallbackRootSessionId: existing !== null ? existing.rootSessionId :
|
|
104
|
+
fallbackRootSessionId: existing !== null ? existing.rootSessionId : fallbackRootSessionId,
|
|
122
105
|
nowIso: now.toISOString(),
|
|
123
106
|
});
|
|
124
107
|
// Supported-version guard (SPEC §9): the version signal lives in the
|
|
@@ -142,7 +125,7 @@ function ingestTranscriptDelta({ fields, canonicalPath, identity, rolloutIdentit
|
|
|
142
125
|
else if (decoded.stop.kind === 'oversized_record') {
|
|
143
126
|
degradations.push({ reason: 'transcript_record_too_large', nextAction: 'a single transcript record exceeds the bounded-read window; inspect the rollout file.' });
|
|
144
127
|
}
|
|
145
|
-
const rootSessionId = decoded.rolloutMeta.rootSessionId ?? existing?.rootSessionId ??
|
|
128
|
+
const rootSessionId = decoded.rolloutMeta.rootSessionId ?? existing?.rootSessionId ?? fallbackRootSessionId;
|
|
146
129
|
const result = ingestGovernanceObservations({
|
|
147
130
|
workspaceDir,
|
|
148
131
|
rollout: {
|
|
@@ -200,6 +183,11 @@ function ingestTranscriptDelta({ fields, canonicalPath, identity, rolloutIdentit
|
|
|
200
183
|
duplicates: result.duplicates,
|
|
201
184
|
warnings,
|
|
202
185
|
lagBytes: Math.max(0, window.fileSize - decoded.nextByteOffset),
|
|
186
|
+
// Slice B: transcript-only user turns / tool calls that were never seen
|
|
187
|
+
// live get their admission evaluation here (SPEC §10/§12) — already
|
|
188
|
+
// admitted logical keys no-op on the marker.
|
|
189
|
+
admissionCandidates: buildTranscriptCandidates(decoded.observations),
|
|
190
|
+
rolloutIdentity,
|
|
203
191
|
};
|
|
204
192
|
}
|
|
205
193
|
export function ingestCodexConversation(rawPayload, kind, options) {
|
|
@@ -222,7 +210,7 @@ export function ingestCodexConversation(rawPayload, kind, options) {
|
|
|
222
210
|
return ingestLiveObservation({ fields, rolloutIdentity: validated.rolloutIdentity, workspaceDir: options.workspaceDir, now });
|
|
223
211
|
}
|
|
224
212
|
return ingestTranscriptDelta({
|
|
225
|
-
fields,
|
|
213
|
+
fallbackRootSessionId: fields.sessionId,
|
|
226
214
|
canonicalPath: validated.canonicalPath,
|
|
227
215
|
identity: validated.identity,
|
|
228
216
|
rolloutIdentity: validated.rolloutIdentity,
|
|
@@ -232,3 +220,29 @@ export function ingestCodexConversation(rawPayload, kind, options) {
|
|
|
232
220
|
port: activeTranscriptPort(options),
|
|
233
221
|
});
|
|
234
222
|
}
|
|
223
|
+
/**
|
|
224
|
+
* PRI-624 (Slice C): bounded incremental ingest of one known rollout from its
|
|
225
|
+
* durable checkpoint, driven by an explicit transcript path instead of a live
|
|
226
|
+
* hook payload. The path is re-authorized through the same
|
|
227
|
+
* `validateCodexTranscriptPath` containment + post-open identity contract the
|
|
228
|
+
* hook path uses (SPEC §9) — callers MUST only pass paths resolved from
|
|
229
|
+
* previously-authenticated rollouts (checkpoints), never discovered sessions.
|
|
230
|
+
*/
|
|
231
|
+
export function ingestCodexTranscriptFromPath(args) {
|
|
232
|
+
const home = resolveCodexHome(args.env);
|
|
233
|
+
if (!home.ok)
|
|
234
|
+
return { status: 'degraded', reason: home.reason, nextAction: home.nextAction, warnings: [] };
|
|
235
|
+
const validated = validateCodexTranscriptPath(args.transcriptPath, home.home);
|
|
236
|
+
if (!validated.ok)
|
|
237
|
+
return { status: 'degraded', reason: validated.reason, nextAction: validated.nextAction, warnings: [] };
|
|
238
|
+
return ingestTranscriptDelta({
|
|
239
|
+
fallbackRootSessionId: args.fallbackRootSessionId,
|
|
240
|
+
canonicalPath: validated.canonicalPath,
|
|
241
|
+
identity: validated.identity,
|
|
242
|
+
rolloutIdentity: validated.rolloutIdentity,
|
|
243
|
+
workspaceDir: args.workspaceDir,
|
|
244
|
+
env: args.env ?? {},
|
|
245
|
+
now: args.now ?? new Date(),
|
|
246
|
+
port: args.port ?? activeTranscriptPort({ workspaceDir: args.workspaceDir }),
|
|
247
|
+
});
|
|
248
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type CodexTranscriptLookup = {
|
|
2
|
+
ok: true;
|
|
3
|
+
transcriptPath: string;
|
|
4
|
+
} | {
|
|
5
|
+
ok: false;
|
|
6
|
+
reason: 'catch_up_rollout_identity_invalid' | 'catch_up_sessions_root_missing' | 'catch_up_transcript_missing' | 'catch_up_transcript_ambiguous' | 'catch_up_lookup_exhausted';
|
|
7
|
+
nextAction: string;
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Resolve one previously-authenticated rollout identity to its transcript
|
|
11
|
+
* path by exact-uuid filename match under `<codexHome>/sessions`.
|
|
12
|
+
*/
|
|
13
|
+
export declare function locateCodexTranscriptByRolloutIdentity(codexHome: string, rolloutIdentity: string): CodexTranscriptLookup;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex transcript locator for catch-up (PRI-624 Slice C).
|
|
3
|
+
*
|
|
4
|
+
* The durable checkpoint stores only the rollout uuid (SPEC §18 scenario 9
|
|
5
|
+
* forbids raw paths in the DB), so catch-up must resolve a checkpointed
|
|
6
|
+
* rollout back to its transcript file. This is NOT session discovery: the
|
|
7
|
+
* lookup searches for the EXACT rollout uuid of a rollout the authenticated
|
|
8
|
+
* Workspace hook previously delivered (only hooks write checkpoints). It
|
|
9
|
+
* never guesses a "latest session", never returns a partial match, and
|
|
10
|
+
* refuses ambiguities (ADR-0020 §11.2 / SPEC §9).
|
|
11
|
+
*/
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { parseRolloutFileName } from './transcript-path.js';
|
|
15
|
+
/** Bounded walk: hard cap on visited directory entries so a pathological sessions tree cannot stall the worker. */
|
|
16
|
+
const MAX_LOOKUP_ENTRIES = 5000;
|
|
17
|
+
const ROLLOUT_IDENTITY_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
18
|
+
/**
|
|
19
|
+
* Resolve one previously-authenticated rollout identity to its transcript
|
|
20
|
+
* path by exact-uuid filename match under `<codexHome>/sessions`.
|
|
21
|
+
*/
|
|
22
|
+
export function locateCodexTranscriptByRolloutIdentity(codexHome, rolloutIdentity) {
|
|
23
|
+
if (!ROLLOUT_IDENTITY_PATTERN.test(rolloutIdentity)) {
|
|
24
|
+
return { ok: false, reason: 'catch_up_rollout_identity_invalid', nextAction: 'the checkpointed rollout identity is not a rollout uuid; inspect the workspace trajectory database.' };
|
|
25
|
+
}
|
|
26
|
+
const sessionsRoot = path.join(codexHome, 'sessions');
|
|
27
|
+
let rootStats;
|
|
28
|
+
try {
|
|
29
|
+
rootStats = fs.statSync(sessionsRoot);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return { ok: false, reason: 'catch_up_sessions_root_missing', nextAction: 'the configured CODEX_HOME has no sessions root; verify the Codex home used by the hook and by catch-up matches.' };
|
|
33
|
+
}
|
|
34
|
+
if (!rootStats.isDirectory()) {
|
|
35
|
+
return { ok: false, reason: 'catch_up_sessions_root_missing', nextAction: 'the configured CODEX_HOME sessions path is not a directory; verify the Codex home configuration.' };
|
|
36
|
+
}
|
|
37
|
+
const matches = [];
|
|
38
|
+
let visited = 0;
|
|
39
|
+
const stack = [sessionsRoot];
|
|
40
|
+
while (stack.length > 0 && matches.length < 2) {
|
|
41
|
+
const dir = stack.pop();
|
|
42
|
+
if (dir === undefined)
|
|
43
|
+
break;
|
|
44
|
+
let entries;
|
|
45
|
+
try {
|
|
46
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
continue; // unreadable subtree — other subtrees may still hold the rollout
|
|
50
|
+
}
|
|
51
|
+
for (const entry of entries) {
|
|
52
|
+
visited += 1;
|
|
53
|
+
if (visited > MAX_LOOKUP_ENTRIES) {
|
|
54
|
+
return { ok: false, reason: 'catch_up_lookup_exhausted', nextAction: 'the sessions tree exceeded the bounded catch-up lookup; keep CODEX_HOME/sessions pruned or catch up rollouts manually.' };
|
|
55
|
+
}
|
|
56
|
+
if (entry.isDirectory()) {
|
|
57
|
+
stack.push(path.join(dir, entry.name));
|
|
58
|
+
}
|
|
59
|
+
else if (entry.isFile() && entry.name.endsWith('.jsonl')) {
|
|
60
|
+
if (parseRolloutFileName(entry.name) === rolloutIdentity) {
|
|
61
|
+
matches.push(path.join(dir, entry.name));
|
|
62
|
+
if (matches.length >= 2)
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (matches.length === 0) {
|
|
69
|
+
return { ok: false, reason: 'catch_up_transcript_missing', nextAction: 'the checkpointed rollout has no transcript under the Codex sessions root (rotated or cleaned by Codex); its committed observations remain, the pending lag cannot be recovered.' };
|
|
70
|
+
}
|
|
71
|
+
if (matches.length > 1) {
|
|
72
|
+
return { ok: false, reason: 'catch_up_transcript_ambiguous', nextAction: 'multiple transcripts match the rollout identity; refuse to guess — inspect the Codex sessions tree.' };
|
|
73
|
+
}
|
|
74
|
+
return { ok: true, transcriptPath: matches[0] };
|
|
75
|
+
}
|
|
@@ -20,4 +20,6 @@ export type TranscriptPathValidation = {
|
|
|
20
20
|
reason: 'transcript_path_invalid' | 'transcript_path_outside_codex_home';
|
|
21
21
|
nextAction: string;
|
|
22
22
|
};
|
|
23
|
+
/** rollout-<timestamp>-<uuid>.jsonl — returns the rollout uuid, or null when the name is off-contract. */
|
|
24
|
+
export declare function parseRolloutFileName(fileName: string): string | null;
|
|
23
25
|
export declare function validateCodexTranscriptPath(transcriptPath: string, codexHome: string): TranscriptPathValidation;
|
|
@@ -21,7 +21,7 @@ function isHex(value) {
|
|
|
21
21
|
return UUID_HEX.test(value);
|
|
22
22
|
}
|
|
23
23
|
/** rollout-<timestamp>-<uuid>.jsonl — returns the rollout uuid, or null when the name is off-contract. */
|
|
24
|
-
function parseRolloutFileName(fileName) {
|
|
24
|
+
export function parseRolloutFileName(fileName) {
|
|
25
25
|
if (!fileName.startsWith('rollout-') || !fileName.endsWith('.jsonl'))
|
|
26
26
|
return null;
|
|
27
27
|
const stem = fileName.slice('rollout-'.length, -'.jsonl'.length);
|
package/dist/pd-hook.js
CHANGED
|
@@ -7,6 +7,7 @@ import { computeFeatureFlagsFromConfig } from '@principles/core/runtime-v2';
|
|
|
7
7
|
import { CodexHooksHostAdapter } from './host-adapter.js';
|
|
8
8
|
import { CodexDecoderError, CodexEncoderError } from './codec/index.js';
|
|
9
9
|
import { ingestCodexConversation } from './ingestion/ingestion.js';
|
|
10
|
+
import { runGovernanceAdmission } from './ingestion/admission.js';
|
|
10
11
|
const MAX_DIAGNOSTIC = 500;
|
|
11
12
|
function diagnostic(reason, nextAction) {
|
|
12
13
|
const boundedReason = reason.replace(/\s+/g, ' ').trim().slice(0, MAX_DIAGNOSTIC);
|
|
@@ -17,11 +18,16 @@ function errorMessage(error) {
|
|
|
17
18
|
return error instanceof Error ? error.message.slice(0, MAX_DIAGNOSTIC) : 'unknown_error';
|
|
18
19
|
}
|
|
19
20
|
// Bounded governance-observation ingestion (Codex Governance Closure Slice
|
|
20
|
-
// A)
|
|
21
|
+
// A) followed by the Slice B signal-admission pass (SPEC §12/§13): detection
|
|
22
|
+
// → canonical pain → evidence promotion → one pending Diagnostician task.
|
|
23
|
+
// Runs only when BOTH host.codex and codex_conversation_ingestion are
|
|
21
24
|
// enabled — the flag gate below happens BEFORE any transcript path
|
|
22
25
|
// validation or filesystem I/O, so flag-off means the transcript boundary
|
|
23
|
-
// receives zero calls (SPEC §10 hard privacy invariant).
|
|
24
|
-
|
|
26
|
+
// receives zero calls (SPEC §10 hard privacy invariant). Admission runs
|
|
27
|
+
// BEFORE dispatch so a live tool failure is admitted through the same
|
|
28
|
+
// canonical derivation first and the production handler's duplicate probe
|
|
29
|
+
// then converges to a no-op (exactly one pain per real tool call).
|
|
30
|
+
async function runConversationIngestion(args) {
|
|
25
31
|
const { rawPayload, kind, workspaceDir, env } = args;
|
|
26
32
|
if (kind !== 'turn_complete' && kind !== 'before_prompt_build' && kind !== 'after_tool_call')
|
|
27
33
|
return [];
|
|
@@ -35,6 +41,16 @@ function runConversationIngestion(args) {
|
|
|
35
41
|
for (const warning of outcome.warnings.slice(0, 2)) {
|
|
36
42
|
diagnostics.push(diagnostic(warning, 'Inspect PD Workspace governance-observation state; ingestion continued.'));
|
|
37
43
|
}
|
|
44
|
+
// Slice B admission: hook awaits only admission + durable enqueue —
|
|
45
|
+
// never an LLM (SPEC §12/§13). Ordinary conversation returns no
|
|
46
|
+
// candidates/no admissions and stays completely silent.
|
|
47
|
+
const admission = await runGovernanceAdmission({
|
|
48
|
+
workspaceDir,
|
|
49
|
+
candidates: outcome.admissionCandidates,
|
|
50
|
+
});
|
|
51
|
+
for (const degradation of admission.degradations.slice(0, 2)) {
|
|
52
|
+
diagnostics.push(diagnostic(degradation.reason, degradation.nextAction));
|
|
53
|
+
}
|
|
38
54
|
}
|
|
39
55
|
}
|
|
40
56
|
catch (error) {
|
|
@@ -87,7 +103,7 @@ export async function processHookInvocation(rawStdin, _env = process.env, cwd =
|
|
|
87
103
|
// turn on stderr — never stdout, and never on the per-tool events (that
|
|
88
104
|
// would be per-event noise).
|
|
89
105
|
const stderr = ingestionEnabled
|
|
90
|
-
? runConversationIngestion({ rawPayload: parsed, kind: event.kind, workspaceDir: resolution.workspaceDir, env: _env })
|
|
106
|
+
? await runConversationIngestion({ rawPayload: parsed, kind: event.kind, workspaceDir: resolution.workspaceDir, env: _env })
|
|
91
107
|
: [diagnostic('feature_disabled', 'Set features.codex_conversation_ingestion.enabled=true in the selected Workspace .pd/config.yaml to enable bounded conversation ingestion.')];
|
|
92
108
|
return { stdout: {}, exitCode: 0, stderr };
|
|
93
109
|
}
|
|
@@ -99,7 +115,7 @@ export async function processHookInvocation(rawStdin, _env = process.env, cwd =
|
|
|
99
115
|
return { stdout: adapter.encodeOutput({ decision: 'allow', source: event.source }, 'session_start'), exitCode: 0, stderr: [] };
|
|
100
116
|
}
|
|
101
117
|
const ingestionDiagnostics = ingestionEnabled
|
|
102
|
-
? runConversationIngestion({ rawPayload: parsed, kind: event.kind, workspaceDir: resolution.workspaceDir, env: _env })
|
|
118
|
+
? await runConversationIngestion({ rawPayload: parsed, kind: event.kind, workspaceDir: resolution.workspaceDir, env: _env })
|
|
103
119
|
: [];
|
|
104
120
|
const result = await createProductionHostRuntime().dispatch(event);
|
|
105
121
|
const stderr = [...(result.warnings ?? []).slice(0, 16).map((warning) => diagnostic(warning, 'Inspect PD Workspace state and retry; the hook failed open.')), ...ingestionDiagnostics];
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { type InternalizationConsumerCycleOutcome, type ConsumerCycleLogger, type ReconcileGovernanceContinuationResult } from '@principles/host-runtime';
|
|
2
|
+
import { type CodexCatchUpResult } from '../ingestion/catch-up.js';
|
|
3
|
+
import type { TranscriptPort } from '../ingestion/transcript-decoder.js';
|
|
4
|
+
export type CodexWorkerMode = 'ready' | 'manual_action_required' | 'paused' | 'degraded';
|
|
5
|
+
export interface CodexWorkerCycleStepReport {
|
|
6
|
+
readonly catchUp: CodexCatchUpResult;
|
|
7
|
+
readonly reconcile: ReconcileGovernanceContinuationResult;
|
|
8
|
+
/** At most ONE diagnostician execution per cycle (bounded work). */
|
|
9
|
+
readonly diagnostician: {
|
|
10
|
+
readonly taskId: string;
|
|
11
|
+
readonly status: 'succeeded' | 'failed' | 'retried' | 'skipped' | 'degraded';
|
|
12
|
+
readonly message?: string;
|
|
13
|
+
readonly errorCategory?: string;
|
|
14
|
+
} | null;
|
|
15
|
+
readonly downstream: InternalizationConsumerCycleOutcome | null;
|
|
16
|
+
}
|
|
17
|
+
export interface CodexWorkerCycleResult {
|
|
18
|
+
readonly workspaceDir: string;
|
|
19
|
+
readonly mode: CodexWorkerMode;
|
|
20
|
+
readonly reason?: string;
|
|
21
|
+
readonly nextAction?: string;
|
|
22
|
+
readonly report?: CodexWorkerCycleStepReport;
|
|
23
|
+
}
|
|
24
|
+
export interface CodexWorkerCycleOptions {
|
|
25
|
+
readonly workspaceDir: string;
|
|
26
|
+
readonly env?: {
|
|
27
|
+
CODEX_HOME?: string | undefined;
|
|
28
|
+
};
|
|
29
|
+
readonly now?: Date;
|
|
30
|
+
readonly logger?: ConsumerCycleLogger;
|
|
31
|
+
readonly emitEvent?: (event: string, payloadJson: string) => void;
|
|
32
|
+
readonly port?: TranscriptPort;
|
|
33
|
+
/** Cap on diagnostician candidates inspected per cycle (default 5). */
|
|
34
|
+
readonly diagnosticianCandidateLimit?: number;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Run ONE bounded worker cycle for a canonical workspace. Never throws:
|
|
38
|
+
* every failure becomes a structured degraded result with a nextAction.
|
|
39
|
+
*/
|
|
40
|
+
export declare function runCodexWorkspaceWorkerCycle(options: CodexWorkerCycleOptions): Promise<CodexWorkerCycleResult>;
|
|
41
|
+
export interface CodexWorkerStatusEvaluation {
|
|
42
|
+
readonly mode: CodexWorkerMode;
|
|
43
|
+
readonly reason?: string;
|
|
44
|
+
readonly nextAction?: string;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* SPEC §15 worker mode, evaluated WITHOUT executing anything (no lease, no
|
|
48
|
+
* LLM, no transcript I/O). `manual_action_required` means no
|
|
49
|
+
* Companion-registered worker serves this workspace — the manual CLI path
|
|
50
|
+
* (catch-up / diagnose / run-once) is the recovery route. 'ready' here means
|
|
51
|
+
* "an automatic worker would run and hold the workspace task leases";
|
|
52
|
+
* live-worker liveness surfacing belongs to the Slice D health surface.
|
|
53
|
+
*/
|
|
54
|
+
export declare function computeCodexWorkerStatusMode(input: {
|
|
55
|
+
workspaceDir: string;
|
|
56
|
+
registeredInInstallManifest: boolean;
|
|
57
|
+
}): CodexWorkerStatusEvaluation;
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex Workspace worker cycle (PRI-624 Slice C; SPEC §13/§15; ADR-0020 §11.1).
|
|
3
|
+
*
|
|
4
|
+
* ONE Workspace-scoped worker cycle with exactly three background
|
|
5
|
+
* responsibilities (the Owner-approved MVP exception — NOT a general daemon):
|
|
6
|
+
*
|
|
7
|
+
* 1. catch up transcript lag (gated by codex_conversation_ingestion)
|
|
8
|
+
* 2. run the Slice B idempotent reconciliation pass
|
|
9
|
+
* 3. lease + run one Diagnostician task, then ONE bounded downstream
|
|
10
|
+
* consumer cycle through the SHARED host-neutral executor
|
|
11
|
+
* (internalization_auto_consumer = workspace execution authority)
|
|
12
|
+
*
|
|
13
|
+
* Every step reuses existing authority: catch-up reuses the Slice A/B
|
|
14
|
+
* ingestion + admission seams, reconciliation reuses
|
|
15
|
+
* `reconcileGovernanceContinuation`, the diagnostician reuses the
|
|
16
|
+
* PainSignalBridge lease/runner contract, downstream reuses the same
|
|
17
|
+
* `runInternalizationConsumerCycle` the OpenClaw auto-consumer runs. No new
|
|
18
|
+
* task store, no private retry queue, no second scheduler state.
|
|
19
|
+
*
|
|
20
|
+
* Correctness under concurrent consumers (OpenClaw auto-consumer + this
|
|
21
|
+
* worker on one workspace) is owned by the durable Runtime V2 task lease —
|
|
22
|
+
* never by process-local state.
|
|
23
|
+
*/
|
|
24
|
+
import fs from 'node:fs';
|
|
25
|
+
import path from 'node:path';
|
|
26
|
+
import { computeFeatureFlagsFromConfig, createRuntimeStateHandle, createPainSignalBridge, isRetryWaitBackoffElapsed, PrincipleTreeLedgerAdapter, } from '@principles/core/runtime-v2';
|
|
27
|
+
import { loadPdConfigForPlugin, loadFeatureFlagFromConfig, reconcileGovernanceContinuation, runInternalizationConsumerCycle, } from '@principles/host-runtime';
|
|
28
|
+
import { catchUpCodexIngestion } from '../ingestion/catch-up.js';
|
|
29
|
+
const WORKER_OWNER = 'companion-worker';
|
|
30
|
+
const DEFAULT_DIAG_CANDIDATE_LIMIT = 5;
|
|
31
|
+
function workerLogger(logger) {
|
|
32
|
+
return logger ?? {
|
|
33
|
+
info: () => undefined,
|
|
34
|
+
warn: () => undefined,
|
|
35
|
+
error: () => undefined,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
function directoryExists(dir) {
|
|
39
|
+
try {
|
|
40
|
+
return fs.statSync(dir).isDirectory();
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Oldest pending diagnostician first; retry_wait candidates are filtered to
|
|
48
|
+
* those whose backoff window has ELAPSED, so an old-but-still-waiting task
|
|
49
|
+
* cannot starve a younger eligible one (review P1). The candidate set stays
|
|
50
|
+
* bounded (`limit`), and executePendingDiagnosis re-enforces the backoff
|
|
51
|
+
* window on the chosen task — no double bookkeeping.
|
|
52
|
+
*/
|
|
53
|
+
async function pickDiagnosticianCandidate(stateManager, limit) {
|
|
54
|
+
const pending = await stateManager.listTasks({ taskKind: 'diagnostician', status: 'pending', orderBy: 'updated_at_asc', limit });
|
|
55
|
+
const [first] = pending;
|
|
56
|
+
if (first !== undefined)
|
|
57
|
+
return first;
|
|
58
|
+
const retrying = await stateManager.listTasks({ taskKind: 'diagnostician', status: 'retry_wait', orderBy: 'updated_at_asc', limit });
|
|
59
|
+
for (const candidate of retrying) {
|
|
60
|
+
if (isRetryWaitBackoffElapsed(candidate.status, candidate.leaseExpiresAt)) {
|
|
61
|
+
return candidate;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Run ONE bounded worker cycle for a canonical workspace. Never throws:
|
|
68
|
+
* every failure becomes a structured degraded result with a nextAction.
|
|
69
|
+
*/
|
|
70
|
+
export async function runCodexWorkspaceWorkerCycle(options) {
|
|
71
|
+
const workspaceDir = path.resolve(options.workspaceDir);
|
|
72
|
+
const logger = workerLogger(options.logger);
|
|
73
|
+
const emitEvent = options.emitEvent ?? ((_event, _payload) => undefined);
|
|
74
|
+
const base = { workspaceDir };
|
|
75
|
+
// Step 1 — Workspace eligibility.
|
|
76
|
+
if (!directoryExists(workspaceDir)) {
|
|
77
|
+
return { ...base, mode: 'degraded', reason: 'workspace_missing', nextAction: 'The workspace directory no longer exists; remove it from the install manifest or restore it. No PD state was mutated.' };
|
|
78
|
+
}
|
|
79
|
+
const config = loadPdConfigForPlugin(workspaceDir);
|
|
80
|
+
if (!config.ok) {
|
|
81
|
+
const [first] = config.errors;
|
|
82
|
+
return { ...base, mode: 'degraded', reason: `pd_config_invalid:${first?.reason ?? 'unknown'}`, nextAction: first?.nextAction ?? 'Repair .pd/config.yaml.' };
|
|
83
|
+
}
|
|
84
|
+
const { flags } = computeFeatureFlagsFromConfig(config.effective);
|
|
85
|
+
if (flags['host.codex']?.enabled !== true) {
|
|
86
|
+
return { ...base, mode: 'paused', reason: 'host.codex_disabled', nextAction: 'Set features.host.codex.enabled=true in the Workspace .pd/config.yaml to enable Codex PD behavior.' };
|
|
87
|
+
}
|
|
88
|
+
const consumerFlag = loadFeatureFlagFromConfig(workspaceDir, 'internalization_auto_consumer', { info: (m) => logger.info(m), warn: (m) => logger.warn(m) });
|
|
89
|
+
// Step 2 — Catch-up transcript lag (SPEC §13: gated by the ingestion flag,
|
|
90
|
+
// NOT by the consumer flag; catchUpCodexIngestion re-checks and returns a
|
|
91
|
+
// zero-I/O skip when ingestion is off).
|
|
92
|
+
const catchUp = await catchUpCodexIngestion({
|
|
93
|
+
workspaceDir,
|
|
94
|
+
env: options.env,
|
|
95
|
+
now: options.now,
|
|
96
|
+
port: options.port,
|
|
97
|
+
});
|
|
98
|
+
// Step 3 — Slice B idempotent reconciliation (always runs; creates no LLM work).
|
|
99
|
+
const reconcile = await reconcileGovernanceContinuation({ workspaceDir });
|
|
100
|
+
// Steps 4–6 — execution authority: internalization_auto_consumer.
|
|
101
|
+
let diagnostician = null;
|
|
102
|
+
if (!consumerFlag.enabled) {
|
|
103
|
+
// paused: execution pause, NOT evidence freeze. Catch-up + reconcile
|
|
104
|
+
// already ran above; manual CLI remains allowed (SPEC §13).
|
|
105
|
+
return {
|
|
106
|
+
...base,
|
|
107
|
+
mode: 'paused',
|
|
108
|
+
reason: 'internalization_auto_consumer_disabled',
|
|
109
|
+
nextAction: 'Automatic internalization execution is paused for this Workspace; manual commands remain available: pd diagnose, pd runtime internalization run-once.',
|
|
110
|
+
report: { catchUp, reconcile, diagnostician: null, downstream: null },
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
// Step 4 — expired-lease recovery sweep, then Step 5/6: at most one
|
|
114
|
+
// Diagnostician task via the existing bridge lease/runner contract.
|
|
115
|
+
try {
|
|
116
|
+
const handle = await createRuntimeStateHandle({ workspaceDir, readonly: false });
|
|
117
|
+
try {
|
|
118
|
+
const sweep = await handle.stateManager.runRecoverySweep();
|
|
119
|
+
if (sweep.recovered > 0 || sweep.errors.length > 0) {
|
|
120
|
+
emitEvent('CODEX_WORKER_RECOVERY_SWEEP', JSON.stringify({ recovered: sweep.recovered, failed: sweep.errors.length }));
|
|
121
|
+
}
|
|
122
|
+
const candidate = await pickDiagnosticianCandidate(handle.stateManager, options.diagnosticianCandidateLimit ?? DEFAULT_DIAG_CANDIDATE_LIMIT);
|
|
123
|
+
if (candidate !== null) {
|
|
124
|
+
const stateDir = path.join(workspaceDir, '.state');
|
|
125
|
+
const bridge = await createPainSignalBridge({
|
|
126
|
+
workspaceDir,
|
|
127
|
+
stateDir,
|
|
128
|
+
ledgerAdapter: new PrincipleTreeLedgerAdapter({ stateDir }),
|
|
129
|
+
owner: WORKER_OWNER,
|
|
130
|
+
effectiveConfig: config.effective,
|
|
131
|
+
getEnvVar: (name) => process.env[name],
|
|
132
|
+
});
|
|
133
|
+
const executed = await bridge.executePendingDiagnosis({ taskId: candidate.taskId });
|
|
134
|
+
diagnostician = {
|
|
135
|
+
taskId: candidate.taskId,
|
|
136
|
+
status: executed.status,
|
|
137
|
+
...(executed.message !== undefined ? { message: executed.message.slice(0, 200) } : {}),
|
|
138
|
+
...(executed.errorCategory !== undefined ? { errorCategory: executed.errorCategory } : {}),
|
|
139
|
+
};
|
|
140
|
+
// The bridge stays cached per workspace (bounded: one per workspace
|
|
141
|
+
// per worker process, exactly like the OpenClaw plugin host) — no
|
|
142
|
+
// per-cycle dispose, so a concurrent cycle can never hit a disposed
|
|
143
|
+
// bridge. The factory self-disposes losing concurrent constructions.
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
// No eligible candidate, but a retry_wait task may still be inside
|
|
147
|
+
// its backoff window — report it so the cycle is observable instead
|
|
148
|
+
// of looking like "nothing pending" (review P1: head-of-line).
|
|
149
|
+
const waiting = await handle.stateManager.listTasks({ taskKind: 'diagnostician', status: 'retry_wait', orderBy: 'updated_at_asc', limit: 1 });
|
|
150
|
+
const [oldestWaiting] = waiting;
|
|
151
|
+
if (oldestWaiting !== undefined && !isRetryWaitBackoffElapsed(oldestWaiting.status, oldestWaiting.leaseExpiresAt)) {
|
|
152
|
+
diagnostician = { taskId: oldestWaiting.taskId, status: 'skipped', message: 'retry_wait_pending' };
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
finally {
|
|
157
|
+
await handle.close().catch(() => undefined);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
catch (error) {
|
|
161
|
+
const detail = error instanceof Error ? error.message.slice(0, 200) : String(error).slice(0, 200);
|
|
162
|
+
return {
|
|
163
|
+
...base,
|
|
164
|
+
mode: 'degraded',
|
|
165
|
+
reason: `diagnostician_execution_failed:${detail}`,
|
|
166
|
+
nextAction: 'Inspect the Workspace runtime profile and provider configuration; the task keeps its pending/retry state and no evidence was mutated.',
|
|
167
|
+
report: { catchUp, reconcile, diagnostician: null, downstream: null },
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
// Step 7 — ONE bounded downstream consumer cycle via the shared executor
|
|
171
|
+
// (same implementation the OpenClaw auto-consumer runs).
|
|
172
|
+
const downstream = await runInternalizationConsumerCycle(workspaceDir, {
|
|
173
|
+
owner: WORKER_OWNER,
|
|
174
|
+
logLabel: 'CodexWorker',
|
|
175
|
+
logger,
|
|
176
|
+
emitEvent,
|
|
177
|
+
// No hostToolCatalog: PD has not declared a Codex tool catalog; a wrong
|
|
178
|
+
// (OpenClaw) catalog would be worse than none (PRI-630 follow-up).
|
|
179
|
+
});
|
|
180
|
+
// Aggregated mode: degraded overrides all (review P1). Any component
|
|
181
|
+
// degrading — catch-up, reconciliation, diagnostician or downstream —
|
|
182
|
+
// surfaces the workspace as degraded, with the highest-priority reason.
|
|
183
|
+
// A lease_conflict diagnostician failure is contention, not degradation:
|
|
184
|
+
// another consumer owns the task, which is normal operation.
|
|
185
|
+
const degradedReason = catchUp.status === 'degraded' ? `catch_up:${catchUp.remainingLagRollouts[0] ?? 'unknown'}`
|
|
186
|
+
: !reconcile.ok ? `reconcile:${reconcile.reason ?? 'failed'}`
|
|
187
|
+
: diagnostician?.status === 'failed' && diagnostician.errorCategory !== 'lease_conflict' ? `diagnostician_failed:${diagnostician.message ?? 'max_attempts_exceeded'}`
|
|
188
|
+
: diagnostician?.status === 'degraded' ? `diagnostician_degraded:${diagnostician.message ?? 'unknown'}`
|
|
189
|
+
: downstream.skipReason === 'runtime_config_error' || downstream.skipReason === 'cycle_error' || downstream.skipReason === 'config_malformed' ? `downstream:${downstream.skipReason}`
|
|
190
|
+
: undefined;
|
|
191
|
+
return {
|
|
192
|
+
...base,
|
|
193
|
+
mode: degradedReason !== undefined ? 'degraded' : 'ready',
|
|
194
|
+
...(degradedReason !== undefined ? { reason: degradedReason, nextAction: 'Inspect the Workspace .pd/config.yaml runtime profile and the per-step report above; the worker retries automatically on the next cycle.' } : {}),
|
|
195
|
+
report: { catchUp, reconcile, diagnostician, downstream },
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* SPEC §15 worker mode, evaluated WITHOUT executing anything (no lease, no
|
|
200
|
+
* LLM, no transcript I/O). `manual_action_required` means no
|
|
201
|
+
* Companion-registered worker serves this workspace — the manual CLI path
|
|
202
|
+
* (catch-up / diagnose / run-once) is the recovery route. 'ready' here means
|
|
203
|
+
* "an automatic worker would run and hold the workspace task leases";
|
|
204
|
+
* live-worker liveness surfacing belongs to the Slice D health surface.
|
|
205
|
+
*/
|
|
206
|
+
export function computeCodexWorkerStatusMode(input) {
|
|
207
|
+
const workspaceDir = path.resolve(input.workspaceDir);
|
|
208
|
+
if (!directoryExists(workspaceDir)) {
|
|
209
|
+
return { mode: 'degraded', reason: 'workspace_missing', nextAction: 'The workspace directory does not exist; restore it or remove it from the install manifest.' };
|
|
210
|
+
}
|
|
211
|
+
const config = loadPdConfigForPlugin(workspaceDir);
|
|
212
|
+
if (!config.ok) {
|
|
213
|
+
const [first] = config.errors;
|
|
214
|
+
return { mode: 'degraded', reason: `pd_config_invalid:${first?.reason ?? 'unknown'}`, nextAction: first?.nextAction ?? 'Repair .pd/config.yaml.' };
|
|
215
|
+
}
|
|
216
|
+
const { flags } = computeFeatureFlagsFromConfig(config.effective);
|
|
217
|
+
if (flags['host.codex']?.enabled !== true) {
|
|
218
|
+
return { mode: 'paused', reason: 'host.codex_disabled', nextAction: 'Set features.host.codex.enabled=true in the Workspace .pd/config.yaml to enable Codex PD behavior.' };
|
|
219
|
+
}
|
|
220
|
+
if (flags.internalization_auto_consumer?.enabled !== true) {
|
|
221
|
+
return { mode: 'paused', reason: 'internalization_auto_consumer_disabled', nextAction: 'Automatic execution is paused; manual commands remain available: pd diagnose, pd runtime internalization run-once.' };
|
|
222
|
+
}
|
|
223
|
+
if (!input.registeredInInstallManifest) {
|
|
224
|
+
return {
|
|
225
|
+
mode: 'manual_action_required',
|
|
226
|
+
reason: 'workspace_not_in_install_manifest',
|
|
227
|
+
nextAction: `No Companion worker is registered for this workspace. Manual path: pd codex ingest catch-up --workspace "${workspaceDir}", then pd diagnose / pd runtime internalization run-once.`,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
return { mode: 'ready' };
|
|
231
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@principles/codex-adapter",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Codex CLI host adapter for Principles Disciple — implements HostAdapter interface for OpenAI Codex CLI's stdin/stdout JSON hook model (ADR-0020).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|