Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .changeset/row-uid-fill-default-on.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
---
id: row-uid-fill-default-on
tasks: [T13305, T12341]
kind: feat
summary: "Row uids fill by default; CLEO_ROW_UID_FILL=0 is the kill switch (canary only)"
---

The open-time row-identity pass (T12341) now runs by default. Opening a
project or global `cleo.db` gives every syncing row a stable `row_uid` and
stamps the `cleo/row-identity/v2` recipe marker. **Kill switch:** set
`CLEO_ROW_UID_FILL=0` to turn the pass off. Any other value, or no value,
leaves it on. Ship this through the canary channel first.

A store with a stale recipe whose share state is not `unshared` (for example
one with pre-release uids that is linked to Nexus) is refused a full refill,
as before (T13231). The refusal is now cheap and quiet:

- The refusal is saved in the store as a local-only `row_identity_refused`
marker, recording the share state, recipe, a digest of the share signals,
the reasons and the time of the last warning.
- Later opens skip the pass and log nothing. A warning is logged once a day
at most.
- The pass is re-evaluated only when the recipe or the share signals change,
or on `cleo doctor row-identity --refill` (the dry run too), which clears
the marker and reports `refusalCleared`.
- `cleo doctor` shows the standing refusal as a finding.

Two follow-ups from the #1952 review:

- Importing or vault-restoring a portable bundle whose project store carries
row uids now marks the placed store as having received them. This includes
bundles made before T13250, which carry no marker, so a later refill never
re-derives uids the bundle shares. The import opens the placed file
directly and never migrates it.
- When a machine export cannot mark one project (busy or restoring), it fails
with `E_PROJECT_STORE_UNAVAILABLE` and names that project.
2 changes: 1 addition & 1 deletion packages/cleo/src/cli/commands/doctor-row-identity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export const doctorRowIdentityCommand = defineCommand({
type: 'boolean',
description:
'With --refill: snapshot the store, then re-derive every identity value from scratch. ' +
'Refused unless the verdict is unshared and CLEO_ROW_UID_FILL=1',
'Refused unless the verdict is unshared, and while CLEO_ROW_UID_FILL=0',
},
json: { type: 'boolean', description: 'Output as JSON' },
human: { type: 'boolean', description: 'Force human-readable output' },
Expand Down
2 changes: 2 additions & 0 deletions packages/contracts/src/row-identity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,4 +227,6 @@ export interface RowIdentityRefillReport {
readonly snapshot: string | null;
/** `--apply`: how to undo the refill. */
readonly undo: string | null;
/** Whether this run cleared a recorded refusal, so the next open re-evaluates (T13305). */
readonly refusalCleared: boolean;
}
10 changes: 10 additions & 0 deletions packages/core/src/__tests__/pipeline-e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,16 @@ async function makeTmpEnv(suffix: string): Promise<TmpEnv> {
// Suite 1 — End-to-end pipeline for all 5 worker roles
// ---------------------------------------------------------------------------

// Row uids are on by default since T13305 (C2). With the fill on, this
// vi.resetModules + vi.doMock harness hits a TDZ on dual-scope-db's cache
// (T13313); the built CLI is unaffected. Pinned off until T13313 lands.
beforeEach(() => {
vi.stubEnv('CLEO_ROW_UID_FILL', '0');
});
afterEach(() => {
vi.unstubAllEnvs();
});

describe('Pipeline E2E — all 5 worker role templates', () => {
let env: TmpEnv;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ describe('cleo doctor row-identity --refill (T13231)', () => {
});

it('--apply is refused while row uids are off in the process', async () => {
delete process.env.CLEO_ROW_UID_FILL;
process.env.CLEO_ROW_UID_FILL = '0';
const report = await rowIdentityRefill(env.tempDir, {
apply: true,
probe: async () => [answer({})],
Expand Down
16 changes: 15 additions & 1 deletion packages/core/src/doctor/row-identity-refill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,11 @@ import {
} from '../store/dual-scope-db.js';
import { openCleoDbSnapshot } from '../store/open-cleo-db.js';
import {
clearRowIdentityRefusal,
fullRefillPlan,
ROW_IDENTITY_META_TABLE,
ROW_IDENTITY_REFILL_SNAPSHOT_KEY,
readRowIdentityRefusal,
rowIdentityRecipeCurrent,
rowIdentityShareState,
shareStateOf,
Expand Down Expand Up @@ -135,6 +137,7 @@ function readLocal(dbPath: string): {
recipeCurrent: boolean;
planned: Record<string, number>;
priorSnapshot: string | null;
refused: boolean;
} {
const snap = openCleoDbSnapshot(dbPath, { readOnly: true });
try {
Expand All @@ -143,6 +146,7 @@ function readLocal(dbPath: string): {
recipeCurrent: rowIdentityRecipeCurrent(snap.db),
planned: fullRefillPlan(snap.db),
priorSnapshot: lastRefillSnapshot(snap.db),
refused: readRowIdentityRefusal(snap.db)?.state === 'refused',
};
} finally {
snap.close();
Expand Down Expand Up @@ -179,9 +183,18 @@ export async function rowIdentityRefill(
applied: false,
snapshot: null,
undo: null,
refusalCleared: false,
};
}
const { local, recipeCurrent, planned, priorSnapshot } = readLocal(dbPath);
const { local, recipeCurrent, planned, priorSnapshot, refused } = readLocal(dbPath);
// T13305: an explicit --refill re-evaluates: a recorded refusal is cleared,
// so the next open (or the --apply below) decides afresh.
let refusalCleared = false;
if (refused) {
const handle = getDualScopeNativeDb(await openDualScopeDb('project', projectRoot));
await import('../store/sqlite-data-accessor.js');
refusalCleared = clearRowIdentityRefusal(handle);
}
const probe =
options.probe ??
(async (root: string) =>
Expand All @@ -203,6 +216,7 @@ export async function rowIdentityRefill(
applied: false,
snapshot: null,
undo: null,
refusalCleared,
};
if (options.apply !== true || action !== 'refill' || !fillEnabled) return base;
const nexusCheckedNone = nexus.length > 0 && nexus.every((a) => a.answer === 'none');
Expand Down
10 changes: 9 additions & 1 deletion packages/core/src/doctor/row-identity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ import {
ROW_IDENTITY,
type RowIdentityFindings,
type RowIdentityHealReceipt,
type RowIdentityRefusal,
readRowIdentityHealReceipt,
readRowIdentityRefusal,
rowIdentityFindings,
UID_COLUMN,
} from '../store/row-identity.js';
Expand Down Expand Up @@ -84,6 +86,7 @@ export function rowIdentityDoctorCheck(projectRoot: string): RowIdentityDoctorCh
let unfilled: Record<string, number>;
let findings: RowIdentityFindings;
let held: Record<string, number> = {};
let refusal: RowIdentityRefusal | null = null;
try {
const snap = openCleoDbSnapshot(dbPath, { readOnly: true });
try {
Expand All @@ -103,6 +106,7 @@ export function rowIdentityDoctorCheck(projectRoot: string): RowIdentityDoctorCh
}
findings = rowIdentityFindings(snap.db, 'project');
held = heldRowCounts(snap.db);
refusal = readRowIdentityRefusal(snap.db);
} finally {
snap.close();
}
Expand All @@ -120,6 +124,10 @@ export function rowIdentityDoctorCheck(projectRoot: string): RowIdentityDoctorCh
const heldTotal = Object.values(held).reduce((a, n) => a + n, 0);
const parts = [
missing.length > 0 ? schemaNote : '',
// T13305: a refused full refill is reported here, not on every open.
refusal?.state === 'refused'
? `identity refill refused (${refusal.shareState}: ${refusal.reasons.join('; ')}); run \`cleo doctor row-identity --refill\``
: '',
// A row the store refused (invalid) needs a person; the rest wait for sync.
(held.invalid ?? 0) > 0 ? `received rows refused as invalid: ${held.invalid}` : '',
Object.keys(unfilled).length > 0
Expand All @@ -135,7 +143,7 @@ export function rowIdentityDoctorCheck(projectRoot: string): RowIdentityDoctorCh
? `symmetric relations stored in both directions (duplicates to drop): ${list(findings.mirrorEdges)}`
: '',
].filter(Boolean);
const details = { dbPath, unfilled, findings, held, ...schemaDetails };
const details = { dbPath, unfilled, findings, held, refusal, ...schemaDetails };
const uids =
heldTotal > 0
? `every row has a uid; rows held for sync: ${list(held)}`
Expand Down
10 changes: 10 additions & 0 deletions packages/core/src/orchestration/__tests__/spawn.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,16 @@ const BASE_TASK: Task = {
// Suite
// ---------------------------------------------------------------------------

// Row uids are on by default since T13305 (C2). With the fill on, this
// vi.resetModules + vi.doMock harness hits a TDZ on dual-scope-db's cache
// (T13313); the built CLI is unaffected. Pinned off until T13313 lands.
beforeEach(() => {
vi.stubEnv('CLEO_ROW_UID_FILL', '0');
});
afterEach(() => {
vi.unstubAllEnvs();
});

describe('composeSpawnPayload — full envelope', () => {
let env: TmpEnv;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { DatabaseSync } from 'node:sqlite';
import { pathToFileURL } from 'node:url';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
_resetDualScopeDbCache,
getDualScopeNativeDb,
Expand All @@ -43,6 +43,9 @@ const captureTriggers = (db: DatabaseSync): number =>
);

beforeEach(() => {
// Written against row uids off; on by default since T13305 (C2). The
// capture + fill-on interplay (K captures alongside I/U/D) is T13311.
vi.stubEnv('CLEO_ROW_UID_FILL', '0');
dir = mkdtempSync(join(tmpdir(), 'cleo-exodus-capture-'));
projectDbPath = join(dir, 'cleo.db');
const legacy = new DatabaseSync(join(dir, 'tasks.db'));
Expand All @@ -54,6 +57,7 @@ beforeEach(() => {
});

afterEach(() => {
vi.unstubAllEnvs();
_resetDualScopeDbCache();
rmSync(dir, { recursive: true, force: true });
});
Expand Down
85 changes: 83 additions & 2 deletions packages/core/src/store/__tests__/identity-share.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,13 @@
* @task T13250
*/

import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type { DatabaseSync } from 'node:sqlite';
import { DatabaseSync } from 'node:sqlite';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { exportPortableBundle } from '../portable-bundle.js';
import { exportPortableBundle, PortableBundleError } from '../portable-bundle.js';
import { importPortableBundle } from '../portable-bundle-import.js';
import {
prepareRowIdentity,
ROW_IDENTITY_RECIPE_KEY,
Expand Down Expand Up @@ -89,13 +92,91 @@ describe('portable bundle exports mark identity shared (T13250)', () => {
await exportBundle(false);
expect(synced()).toBeUndefined();
});

it('importing a bundle that carries uids but no marker marks the placed store received (#1952 LOW-1)', async () => {
// A bundle made before T13250 has the uids and no synced marker; the
// unmarked export reproduces that shape exactly.
const bundle = (await exportBundle(false)).bundlePath;
expect(synced()).toBeUndefined();
const dest = mkdtempSync(join(tmpdir(), 'cleo-t13305-import-'));
try {
const target = join(dest, 'moved');
const imported = await importPortableBundle({
bundlePath: bundle,
cwd: '/',
target,
cleoHome: join(dest, 'home'),
configHome: join(dest, 'config'),
});
expect(imported.lossless).toBe(true);
const placed = new DatabaseSync(join(target, '.cleo', 'cleo.db'), { readOnly: true });
try {
const row = placed
.prepare('SELECT value FROM tasks_row_identity_meta WHERE key = ?')
.get(ROW_IDENTITY_SYNCED_KEY) as { value: string } | undefined;
expect(JSON.parse(String(row?.value)).first).toBe('receive');
} finally {
placed.close();
}
} finally {
rmSync(dest, { recursive: true, force: true });
}
});

it('a machine export that cannot mark a project names that project (#1952 LOW-2)', async () => {
const share = await import('../identity-share.js');
const spy = vi
.spyOn(share, 'markProjectIdentityShared')
.mockRejectedValue(new Error('store is restoring'));
try {
const err = await exportPortableBundle({
scope: 'machine',
outputPath: join(env.tempDir, 'out', 'm.cleobundle.tar.gz'),
label: 'm',
isTempPath: () => false,
}).catch((e: Error) => e);
expect(err).toBeInstanceOf(PortableBundleError);
expect((err as PortableBundleError).code).toBe('E_PROJECT_STORE_UNAVAILABLE');
expect((err as Error).message).toContain(env.tempDir);
expect((err as Error).message).toContain('store is restoring');
} finally {
spy.mockRestore();
}
});
});

describe('a store with no identity is never touched (T13250)', () => {
beforeEach(async () => {
await setup(false);
});

it('importing a bundle from a store with no uids marks nothing', async () => {
const bundle = (await exportBundle()).bundlePath;
const dest = mkdtempSync(join(tmpdir(), 'cleo-t13305-import-'));
try {
const target = join(dest, 'moved');
await importPortableBundle({
bundlePath: bundle,
cwd: '/',
target,
cleoHome: join(dest, 'home'),
configHome: join(dest, 'config'),
});
const placed = new DatabaseSync(join(target, '.cleo', 'cleo.db'), { readOnly: true });
try {
expect(
placed
.prepare('SELECT value FROM tasks_row_identity_meta WHERE key = ?')
.get(ROW_IDENTITY_SYNCED_KEY),
).toBeUndefined();
} finally {
placed.close();
}
} finally {
rmSync(dest, { recursive: true, force: true });
}
});

it('a bundle export of a fill-off store writes no identity meta', async () => {
const before = db.prepare('SELECT count(*) AS n FROM tasks_row_identity_meta').get();
await exportBundle();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -339,7 +339,10 @@ async function runMigration(
getGlobalSalt: () => Buffer.alloc(32, 0xab),
__clearGlobalSaltCache: vi.fn(),
}));
vi.doMock('../agent-registry-store.js', () => ({
// Partial: the open pass (row uids on by default, T13305) loads the store
// writers, which import the rest of this module.
vi.doMock('../agent-registry-store.js', async (importOriginal) => ({
...(await importOriginal<typeof import('../agent-registry-store.js')>()),
ensureGlobalSignaldockDb: vi.fn(async () => ({
action: 'exists',
path: join(home, 'signaldock.db'),
Expand Down
32 changes: 32 additions & 0 deletions packages/core/src/store/__tests__/row-identity-flag.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/**
* T13305 (C2): row uids are ON by default; `CLEO_ROW_UID_FILL=0` is the kill
* switch, and any other value (or none) leaves them on.
*
* @task T13305
*/

import { afterEach, describe, expect, it, vi } from 'vitest';
import { defaultRowUid, ROW_UID_FILL_FLAG, rowUidFillEnabled } from '../row-identity-flag.js';

afterEach(() => {
vi.unstubAllEnvs();
});

describe('row uid default (T13305)', () => {
it('unset: on, and new minted rows get a uid', () => {
vi.stubEnv(ROW_UID_FILL_FLAG, undefined);
expect(rowUidFillEnabled()).toBe(true);
expect(typeof defaultRowUid()).toBe('string');
});

it('CLEO_ROW_UID_FILL=0 is the kill switch: off, and new rows get NULL', () => {
vi.stubEnv(ROW_UID_FILL_FLAG, '0');
expect(rowUidFillEnabled()).toBe(false);
expect(typeof defaultRowUid()).not.toBe('string');
});

it.each(['1', 'true', ''])('CLEO_ROW_UID_FILL=%j leaves them on', (value) => {
vi.stubEnv(ROW_UID_FILL_FLAG, value);
expect(rowUidFillEnabled()).toBe(true);
});
});
Loading
Loading