Compare commits

...
Author SHA1 Message Date
Garry TanandClaude Opus 5 2012b51220 fix(jobs): unify-types worker defaults to dry-run, matching its handler contract (#1575)
The worker registration flipped apply to true while the handler
interface documents 'Default false (dry-run)' — so the canonical
operator invocation ran a one-shot destructive taxonomy migration on
first submit with no dry-run checkpoint. The worker now honors the
documented default; every consented apply surface carries apply:true
explicitly (onboard pack-upgrade remediation params, the migration
command printed by onboard --check --explain, and both architecture
docs' examples).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 19:42:35 -07:00
6 changed files with 85 additions and 8 deletions
+2 -1
View File
@@ -56,7 +56,8 @@ that tuple lights up the `pack_upgrade_available` onboard check.
│ gbrain onboard --check --explain shows per-cluster narrative │
│ User reviews; if OK, runs: │
│ gbrain jobs submit unify-types --allow-protected \ │
│ --params '{"target_pack":"gbrain-base-v2"}'
│ --params '{"target_pack":"gbrain-base-v2","apply":true}'
│ (omit "apply":true for a dry-run; that is the default) │
│ (Autopilot never auto-fires this; manual_only) │
└──────────────────────────┬─────────────────────────────────────┘
+4 -2
View File
@@ -76,7 +76,8 @@ gbrain onboard --check --explain # per-cluster narrative dry-run
gbrain jobs submit unify-types \ # PROTECTED + manual_only
--allow-protected \
--params '{"target_pack":"gbrain-base-v2"}'
--params '{"target_pack":"gbrain-base-v2","apply":true}'
# omit "apply":true → dry-run (default)
Handler runs 4 phases:
┌─────────────────────────────────────┐
@@ -127,7 +128,8 @@ For brains with substantial custom types that deserve their own canonical
2. Edit your fork to add page_types + mapping_rules covering your
custom domain.
3. Target your fork: `gbrain jobs submit unify-types --allow-protected
--params '{"target_pack":"my-pack"}'`
--params '{"target_pack":"my-pack","apply":true}'` (omit `"apply":true`
for a dry-run preview — that is the default)
Your fork can also declare `migration_from: {pack: gbrain-base-v2,
version: "1.x"}` to register itself as a successor — future agents
+8 -3
View File
@@ -2194,8 +2194,9 @@ export async function registerBuiltinHandlers(
// migration that retypes 25K+ pages, creates alias rows, converts edge-
// shaped pages to link rows, AND flips the active pack at end of run.
// manual_only via src/core/onboard/render.ts:MANUAL_ONLY_PROTECTED_JOBS.
// Operator path: `gbrain jobs submit unify-types --allow-protected --params
// '{"target_pack":"gbrain-base-v2"}'`.
// Dry-run preview: `gbrain jobs submit unify-types --allow-protected
// --params '{"target_pack":"gbrain-base-v2"}'`; apply with
// '{"target_pack":"gbrain-base-v2","apply":true}'.
worker.register('unify-types', async (job) => {
const { runUnifyTypes } = await import('../core/schema-pack/unify-types-handler.ts');
const data = (job.data ?? {}) as {
@@ -2213,7 +2214,11 @@ export async function registerBuiltinHandlers(
} as unknown as import('../core/operations.ts').OperationContext;
return await runUnifyTypes(ctx, {
target_pack: data.target_pack,
apply: data.apply ?? true,
// #1575: default matches the handler interface's "Default false
// (dry-run)" — a destructive one-shot migration must be opted into
// with apply:true (the onboard remediation + the printed migration
// command both carry it explicitly).
apply: data.apply ?? false,
sourceId: data.sourceId,
onProgress: (msg: string) => {
job.updateProgress({ phase: 'unify-types', message: msg }).catch(() => {});
+1 -1
View File
@@ -251,7 +251,7 @@ async function renderPackUpgradeExplain(
` Page-to-link: ${result.per_phase.page_to_link.would_convert} edges across ${result.per_phase.page_to_link.rules} rules\n` +
` Page-to-alias: ${result.per_phase.page_to_alias.would_alias} aliases across ${result.per_phase.page_to_alias.rules} rules\n` +
`\nRun the migration with:\n` +
` gbrain jobs submit unify-types --allow-protected --params '${JSON.stringify({ target_pack: targetPack })}'\n`,
` gbrain jobs submit unify-types --allow-protected --params '${JSON.stringify({ target_pack: targetPack, apply: true })}'\n`,
);
if (result.warnings.length > 0) {
process.stdout.write(`\nWarnings:\n`);
+3 -1
View File
@@ -426,7 +426,9 @@ export async function checkPackUpgradeAvailable(
makeRemediationStep({
id: 'onboard.pack_upgrade_' + successor.manifest.name,
job: 'unify-types',
params: { target_pack: successor.manifest.name },
// #1575: the worker defaults `apply` to false (dry-run); a
// remediation step is a consented apply, so carry it explicitly.
params: { target_pack: successor.manifest.name, apply: true },
severity: 'medium',
est_seconds: 600, // ~10min on 186K-page brain (production proxy)
est_usd_cost: 0, // pure SQL; no LLM spend
@@ -0,0 +1,67 @@
/**
* #1575 — the unify-types WORKER registration defaulted `apply` to true,
* while the handler interface documents "Default false (dry-run)". The
* canonical operator invocation —
* gbrain jobs submit unify-types --allow-protected --params '{"target_pack":"X"}'
* — therefore applied a one-shot destructive taxonomy migration on first
* invocation, with no dry-run checkpoint.
*
* Behavioral pin: invoking the registered worker handler with job.data that
* omits `apply` runs a DRY-RUN (no page mutation, active pack not flipped).
* Consented apply paths pass `apply: true` explicitly (onboard remediation +
* the printed migration command carry it).
*/
import { describe, it, expect, beforeAll, afterAll } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { MinionWorker } from '../src/core/minions/worker.ts';
import { registerBuiltinHandlers } from '../src/commands/jobs.ts';
import { _resetPackCacheForTests } from '../src/core/schema-pack/registry.ts';
let engine: PGLiteEngine;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
_resetPackCacheForTests();
});
afterAll(async () => {
await engine.disconnect();
});
describe('unify-types worker default (#1575)', () => {
it('omitting apply in job.data runs a dry-run, not a destructive apply', async () => {
await engine.putPage('tweets/default-check', {
title: 'tweets/default-check',
type: 'tweet-single' as never,
compiled_truth: 'body that is sufficiently long for any backstop guards we have in the codebase',
timeline: '',
frontmatter: {},
source_path: 'tweets/default-check.md',
});
const worker = new MinionWorker(engine, { concurrency: 1 });
await registerBuiltinHandlers(worker, engine);
const handler = (worker as unknown as {
handlers: Map<string, (j: unknown) => Promise<unknown>>;
}).handlers.get('unify-types');
if (!handler) throw new Error('unify-types handler not registered');
const result = (await handler({
id: 1,
data: { target_pack: 'gbrain-base-v2' }, // no `apply` — the #1575 trap
updateProgress: async () => {},
})) as { apply: boolean; active_pack_flipped: boolean };
// Handler interface: "Apply mutations. Default false (dry-run)."
expect(result.apply).toBe(false);
expect(result.active_pack_flipped).toBe(false);
// The page was NOT retyped.
const rows = await engine.executeRaw<{ type: string }>(
`SELECT type FROM pages WHERE slug = 'tweets/default-check'`,
);
expect(rows[0]!.type).toBe('tweet-single');
}, 60_000);
});