From 671210329811ae44af57f144a47afef7f221a4ca Mon Sep 17 00:00:00 2001 From: Phillip Cunliffe Date: Mon, 29 Jun 2026 20:42:27 -0700 Subject: [PATCH] Codex adapter .hypignore capture-seam drop (LLP 0050) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Symmetric to the @hypaware/claude T2 drop: enforce the `.hypignore` folder-scoped `ignore` usage policy at the Codex capture seam, the only place that resolves a `cwd`, so an ignored exchange never reaches the cache. - `codex/src/exchange-projector.js`: `createCodexExchangeProjector` holds one shared `createUsagePolicyResolver()` (per listener; injectable for tests). Once the exchange `cwd` is resolved, an ancestor `.hypignore` of class `ignore` returns no projection, so the gateway source's `messageRows.length > 0` write guard persists nothing. The response has already streamed, so the live LLM call is untouched (LLP 0049 R1/R2). Emits a structured `usage_policy_drop` log (hashed cwd, governing path). - `codex/src/backfill.js`: `createCodexBackfillProvider` holds one resolver and skips a session whose recorded `cwd` is ignored before projecting/yielding, so `hyp backfill` never re-imports sessions ignored live (LLP 0049 R1). Adds `sessions_ignored` to the scan-complete telemetry. The projector returns `undefined` (its established skip signal — the dispatcher maps it to an empty rows array, the "return []" of LLP 0050 §Live); returning a literal `[]` would trip the gateway's invalid-projection warning. No cache schema, export driver, gateway, or settlement change — capture-seam only, per LLP 0050. Tests mirror T2: ignored cwd -> no rows/skip, clean cwd unaffected, drop telemetry emitted; the real shared matcher is exercised via injected fs. @ref LLP 0050 [implements] Task-Id: T3 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plugins-workspace/codex/src/backfill.js | 31 ++++++- .../codex/src/exchange-projector.js | 37 ++++++++- test/plugins/codex-backfill.test.js | 67 +++++++++++++++ test/plugins/codex-exchange-projector.test.js | 81 +++++++++++++++++++ 4 files changed, 212 insertions(+), 4 deletions(-) diff --git a/hypaware-core/plugins-workspace/codex/src/backfill.js b/hypaware-core/plugins-workspace/codex/src/backfill.js index 111bca7..ba816de 100644 --- a/hypaware-core/plugins-workspace/codex/src/backfill.js +++ b/hypaware-core/plugins-workspace/codex/src/backfill.js @@ -3,11 +3,13 @@ import fs from 'node:fs/promises' import path from 'node:path' +import { createUsagePolicyResolver } from '../../../../src/core/usage-policy/index.js' import { redactRemoteUserinfo } from './git-remote.js' /** * @import { AiGatewayProjectedExchange, AiGatewayProjectedMessage, BackfillContribution, BackfillEvent, BackfillItem, BackfillProvenance, BackfillRunContext, JsonObject, JsonValue, PluginLogger } from '../../../../collectivus-plugin-kernel-types.js' * @import { CodexRolloutItem, CodexRolloutSession } from './types.js' + * @import { UsagePolicyResolver } from '../../../../src/core/usage-policy/types.js' */ /** @@ -74,6 +76,7 @@ const DAY_MS = 24 * 60 * 60 * 1000 * unsupportedLocations?: Array<{ kind: string, path: string }>, * clientName?: string, * pluginName?: string, + * resolver?: UsagePolicyResolver, * }} opts * @returns {BackfillContribution} */ @@ -83,6 +86,9 @@ export function createCodexBackfillProvider(opts) { const codexHome = opts.codexHome ?? defaultCodexHome(opts.homeDir) const sessionsDir = opts.sessionsDir ?? path.join(codexHome, 'sessions') const unsupportedLocations = opts.unsupportedLocations ?? defaultUnsupportedLocations(opts.homeDir) + // One `.hypignore` resolver per backfill run, holding its per-cwd cache for + // the whole scan (LLP 0049 R6). + const resolver = opts.resolver ?? createUsagePolicyResolver() return { name: clientName, @@ -90,7 +96,7 @@ export function createCodexBackfillProvider(opts) { datasets: [AI_GATEWAY_MESSAGES_DATASET], summary: 'Import local Codex session rollouts into ai_gateway_messages', async *run(ctx) { - yield* runCodexBackfill({ ctx, codexHome, sessionsDir, unsupportedLocations, clientName }) + yield* runCodexBackfill({ ctx, codexHome, sessionsDir, unsupportedLocations, clientName, resolver }) }, } } @@ -131,11 +137,12 @@ function defaultUnsupportedLocations(homeDir) { * sessionsDir: string, * unsupportedLocations: Array<{ kind: string, path: string }>, * clientName: string, + * resolver: UsagePolicyResolver, * }} args * @returns {AsyncGenerator} */ async function* runCodexBackfill(args) { - const { ctx, codexHome, sessionsDir, unsupportedLocations, clientName } = args + const { ctx, codexHome, sessionsDir, unsupportedLocations, clientName, resolver } = args const log = ctx.log const window = resolveWindow(ctx) @@ -158,6 +165,7 @@ async function* runCodexBackfill(args) { let filesSeen = 0 let sessionsProjected = 0 + let sessionsIgnored = 0 let messagesProjected = 0 for (const filePath of await listRolloutFiles(sessionsDir)) { @@ -180,6 +188,24 @@ async function* runCodexBackfill(args) { } for (const session of sessions) { + // @ref LLP 0050 [implements]: capture-seam drop for backfill, symmetric + // to the @hypaware/claude backfill skip. A session whose recorded cwd has + // an ancestor `.hypignore` of class `ignore` is skipped before projecting + // or yielding any row, so `hyp backfill` never re-imports the exact + // sessions ignored live (LLP 0049 R1). + if (session.cwd && resolver.resolve(session.cwd).class === 'ignore') { + sessionsIgnored += 1 + log.info('codex.backfill.usage_policy_drop', { + component: COMPONENT, + operation: 'usage_policy_drop', + conversation_id: session.sessionId, + class: 'ignore', + governed_by: resolver.resolve(session.cwd).governedBy, + status: 'skipped', + }) + continue + } + const exchange = projectedExchangeFromSession({ session, items: filterByWindow(session.items, window), @@ -211,6 +237,7 @@ async function* runCodexBackfill(args) { operation: 'backfill.scan', files_seen: filesSeen, sessions_projected: sessionsProjected, + sessions_ignored: sessionsIgnored, messages_projected: messagesProjected, status: 'ok', }) diff --git a/hypaware-core/plugins-workspace/codex/src/exchange-projector.js b/hypaware-core/plugins-workspace/codex/src/exchange-projector.js index 986a6cf..28fb2e6 100644 --- a/hypaware-core/plugins-workspace/codex/src/exchange-projector.js +++ b/hypaware-core/plugins-workspace/codex/src/exchange-projector.js @@ -2,11 +2,13 @@ import { createHash } from 'node:crypto' +import { createUsagePolicyResolver } from '../../../../src/core/usage-policy/index.js' import { redactRemoteUserinfo } from './git-remote.js' /** * @import { AiGatewayExchangeInput, AiGatewayExchangeProjector, AiGatewayProjectedExchange, AiGatewayProjectedMessage, JsonObject } from '../../../../collectivus-plugin-kernel-types.js' * @import { CodexLogReader } from './types.js' + * @import { UsagePolicyResolver } from '../../../../src/core/usage-policy/types.js' */ /** @@ -27,6 +29,7 @@ import { redactRemoteUserinfo } from './git-remote.js' * @param {{ * logReaders?: CodexLogReader[], * env?: Record, + * resolver?: UsagePolicyResolver, * }} [opts] * @returns {AiGatewayExchangeProjector} */ @@ -36,6 +39,10 @@ export function createCodexExchangeProjector(opts = {}) { const logReaders = sqliteReadsEnabled && Array.isArray(opts.logReaders) ? opts.logReaders : [] + // One `.hypignore` resolver per projector instance (one per started + // listener): the per-cwd cache then spans the listener's lifetime so the + // capture hot path adds no unbounded fs work (LLP 0049 R6). + const resolver = opts.resolver ?? createUsagePolicyResolver() return { name: 'codex-exchange', @@ -54,14 +61,40 @@ export function createCodexExchangeProjector(opts = {}) { return false }, - /** @param {AiGatewayExchangeInput} input */ - project(input) { + /** + * @param {AiGatewayExchangeInput} input + * @param {{ log?: { info?: (m: string, f?: Record) => void } }} [ctx] + */ + project(input, ctx) { const reqBody = parseMaybeJson(input.request_body) if (!isPlainObject(reqBody)) return undefined const path = input.path ?? '' const provider = resolveProvider(input, reqBody, path) const codexContext = resolveCodexContext(input, provider, path, reqBody) + + // @ref LLP 0050 [implements]: capture-seam drop, symmetric to the + // @hypaware/claude projector. Once this exchange's cwd is resolved, an + // ancestor `.hypignore` of class `ignore` drops the row by returning no + // projection (the gateway source's `messageRows.length > 0` write guard + // then persists nothing). The response has already streamed, so the live + // call is untouched — only persistence is suppressed (LLP 0049 R1/R2). + // This is the same cwd `resolveRecordedContext` would stamp on the row. + const cwd = firstString(codexContext?.cwd, readRecordedCwd(reqBody)) + if (cwd) { + const policy = resolver.resolve(cwd) + if (policy.class === 'ignore') { + ctx?.log?.info?.('plugin.codex.usage_policy_drop', { + component: 'codex', + operation: 'usage_policy_drop', + class: policy.class, + governed_by: policy.governedBy, + cwd_sha256: sha256Hex(cwd).slice(0, 16), + }) + return undefined + } + } + const responseBody = parseMaybeJson(input.response_body) const streamEvents = Array.isArray(input.stream_events) ? input.stream_events : [] const messages = messagesForTransport({ provider, path, reqBody, responseBody, streamEvents }) diff --git a/test/plugins/codex-backfill.test.js b/test/plugins/codex-backfill.test.js index e117c20..d65bdf5 100644 --- a/test/plugins/codex-backfill.test.js +++ b/test/plugins/codex-backfill.test.js @@ -12,6 +12,23 @@ import { aiGatewayBackfillMaterializer, } from '../../hypaware-core/plugins-workspace/ai-gateway/src/dataset.js' import { createCodexBackfillProvider } from '../../hypaware-core/plugins-workspace/codex/src/backfill.js' +import { createUsagePolicyResolver } from '../../src/core/usage-policy/index.js' + +/** + * A real usage-policy resolver wired to an injected fs that reports exactly one + * governing `.hypignore` (class `ignore`) at `ignoredDir`. Mirrors the T2 + * @hypaware/claude backfill drop test: exercise the shared matcher, not a stub. + * @ref LLP 0050 [tests]: the codex backfill capture-seam skip + * + * @param {string} ignoredDir + */ +function ignoringResolver(ignoredDir) { + const hypignore = path.join(ignoredDir, '.hypignore') + return createUsagePolicyResolver({ + existsSync: (p) => p === hypignore, + readFileSync: () => 'ignore\n', + }) +} /** * End-to-end tests for the `@hypaware/codex` backfill provider. They run the @@ -318,6 +335,56 @@ test('modern rollout projects into canonical ai_gateway_messages rows', async () } }) +// @ref LLP 0050 [tests]: capture-seam drop for backfill — a session whose +// recorded cwd is .hypignore-ignored is skipped before projecting or yielding +// any row, so `hyp backfill` never re-imports sessions ignored live (R1). +test('backfill skips a session whose cwd is .hypignore-ignored', async () => { + const env = await stageEnv() + try { + // modernConversation stamps meta.cwd = '/work/repo'. + await writeModernRollout(env, '2026/05/25/rollout-1.jsonl', modernConversation('sess-ignored')) + + const provider = createCodexBackfillProvider({ + homeDir: env.homeDir, + resolver: ignoringResolver('/work/repo'), + }) + const { ctx, entries: logs } = runContext() + const { items } = await collect(provider.run(ctx)) + + assert.equal(items.length, 0, 'no rows yielded for an ignored session') + const drop = logs.find((e) => e.message === 'codex.backfill.usage_policy_drop') + assert.ok(drop, 'expected a usage_policy_drop log entry') + assert.equal(drop?.fields?.operation, 'usage_policy_drop') + assert.equal(drop?.fields?.conversation_id, 'sess-ignored') + assert.equal(drop?.fields?.governed_by, '/work/repo/.hypignore') + const scanDone = logs.find((e) => e.message === 'codex.backfill.scan_complete') + assert.equal(scanDone?.fields?.sessions_projected, 0) + assert.equal(scanDone?.fields?.sessions_ignored, 1) + } finally { + await env.cleanup() + } +}) + +test('backfill is unaffected when a different cwd is ignored', async () => { + const env = await stageEnv() + try { + await writeModernRollout(env, '2026/05/25/rollout-1.jsonl', modernConversation('sess-clean')) + + const provider = createCodexBackfillProvider({ + homeDir: env.homeDir, + // Ignore an unrelated directory; the session's '/work/repo' is clean. + resolver: ignoringResolver('/work/other'), + }) + const { ctx } = runContext() + const { items } = await collect(provider.run(ctx)) + + assert.equal(items.length, 1, 'a non-ignored session still backfills') + assert.equal(value(items[0]).conversation_id, 'sess-clean') + } finally { + await env.cleanup() + } +}) + test('token_count event folds per-turn usage (net of cache) onto the turn assistant message', async () => { const env = await stageEnv() try { diff --git a/test/plugins/codex-exchange-projector.test.js b/test/plugins/codex-exchange-projector.test.js index e4b0526..42b992d 100644 --- a/test/plugins/codex-exchange-projector.test.js +++ b/test/plugins/codex-exchange-projector.test.js @@ -1,11 +1,92 @@ // @ts-check import assert from 'node:assert/strict' +import path from 'node:path' import test from 'node:test' import { createCodexExchangeProjector, } from '../../hypaware-core/plugins-workspace/codex/src/exchange-projector.js' +import { createUsagePolicyResolver } from '../../src/core/usage-policy/index.js' + +/** + * A real usage-policy resolver wired to an injected fs that reports exactly one + * governing `.hypignore` (class `ignore`) at `ignoredDir`. Mirrors how the + * @hypaware/claude projector's drop is tested (T2): exercise the actual shared + * matcher, not a hand-rolled stub. + * @ref LLP 0050 [tests]: the codex live projector's capture-seam drop + * + * @param {string} ignoredDir + */ +function ignoringResolver(ignoredDir) { + const hypignore = path.join(ignoredDir, '.hypignore') + return createUsagePolicyResolver({ + existsSync: (p) => p === hypignore, + readFileSync: () => 'ignore\n', + }) +} + +// @ref LLP 0050 [tests]: capture-seam drop — an ignored cwd yields no rows so +// the gateway write guard persists nothing; a clean cwd is unaffected (R1/R2). +test('project() returns no projection when the exchange cwd is .hypignore-ignored', () => { + const projector = createCodexExchangeProjector({ + env: {}, + resolver: ignoringResolver('/work/ignored'), + }) + const projection = projector.project(exchange({ + path: '/v1/chat/completions', + request_body: JSON.stringify({ + cwd: '/work/ignored/sub', + messages: [{ role: 'user', content: 'secret' }], + }), + response_body: JSON.stringify({ choices: [{ message: { role: 'assistant', content: 'ok' } }] }), + }), context()) + assert.equal(projection, undefined) +}) + +test('project() is unaffected when the exchange cwd is not ignored', () => { + const projector = createCodexExchangeProjector({ + env: {}, + resolver: ignoringResolver('/work/ignored'), + }) + const projection = /** @type {any} */ (projector.project(exchange({ + path: '/v1/chat/completions', + request_body: JSON.stringify({ + cwd: '/work/clean', + messages: [{ role: 'user', content: 'hi' }], + }), + response_body: JSON.stringify({ choices: [{ message: { role: 'assistant', content: 'ok' } }] }), + }), context())) + assert.ok(projection) + assert.deepEqual(projection.messages.map((/** @type {any} */ m) => m.role), ['user', 'assistant']) +}) + +test('project() emits a usage_policy_drop log on an ignored cwd', () => { + /** @type {Array<{ message: string, fields?: Record }>} */ + const infos = [] + const projector = createCodexExchangeProjector({ + env: {}, + resolver: ignoringResolver('/work/ignored'), + }) + const log = { + debug() {}, + warn() {}, + error() {}, + /** @param {string} message @param {Record=} fields */ + info: (message, fields) => { infos.push({ message, fields }) }, + } + projector.project(exchange({ + path: '/v1/chat/completions', + request_body: JSON.stringify({ + cwd: '/work/ignored', + messages: [{ role: 'user', content: 'secret' }], + }), + }), { log }) + const drop = infos.find((e) => e.message === 'plugin.codex.usage_policy_drop') + assert.ok(drop, 'expected a usage_policy_drop log entry') + assert.equal(drop.fields?.operation, 'usage_policy_drop') + assert.equal(drop.fields?.governed_by, '/work/ignored/.hypignore') +}) test('match() accepts the three transports it owns and rejects others', () => { const projector = createCodexExchangeProjector({ env: {} })