mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-15 17:32:37 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39f750e831 |
@@ -206,11 +206,7 @@ jobs:
|
||||
needs: cache-check
|
||||
if: needs.cache-check.outputs.hit != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
# 20 (was 15): shard 4 runs ~14.5 min on master (dream.test.ts ~29s/test
|
||||
# dominates it) and hits the 15-min ceiling on slower runners, cancelling
|
||||
# mid-run with 0 test failures. Rebalancing via
|
||||
# scripts/mine-shard-weights.ts is the real fix; this stops the bleeding.
|
||||
timeout-minutes: 20
|
||||
timeout-minutes: 15
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
|
||||
@@ -438,6 +438,13 @@ export async function runApplyMigrations(args: string[]): Promise<void> {
|
||||
const result = await m.orchestrator(orchestratorOptsFrom(cli));
|
||||
if (result.status === 'failed') {
|
||||
console.error(`Migration v${m.version} reported status=failed.`);
|
||||
// Surface each failed phase's detail — the ledger records it, but
|
||||
// the operator needs it on stderr to act (#921).
|
||||
for (const p of result.phases) {
|
||||
if (p.status === 'failed') {
|
||||
console.error(` phase ${p.name}: ${p.detail ?? '(no detail)'}`);
|
||||
}
|
||||
}
|
||||
// Record the attempt as 'partial' (not 'complete') so the cap counts
|
||||
// it. Don't let a failed orchestrator look like it never ran.
|
||||
try {
|
||||
|
||||
@@ -186,17 +186,6 @@ async function phaseBFenceFacts(
|
||||
const localPathById = new Map<string, string | null>();
|
||||
for (const s of sources) localPathById.set(s.id, s.local_path);
|
||||
|
||||
// Dirty-tree refusal: check every source's local_path before writing.
|
||||
for (const [id, localPath] of localPathById) {
|
||||
if (localPath && isLocalPathDirty(localPath)) {
|
||||
return {
|
||||
name: 'fence_facts',
|
||||
status: 'failed',
|
||||
detail: `source "${id}" has uncommitted changes in ${localPath}. Commit or stash, then re-run.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Walk legacy rows in (source_id, entity_slug) groups for per-page
|
||||
// atomic writes.
|
||||
const legacy = await engine.executeRaw<LegacyFactRow>(
|
||||
@@ -235,6 +224,21 @@ async function phaseBFenceFacts(
|
||||
groups.set(key, list);
|
||||
}
|
||||
|
||||
// Dirty-tree refusal: check ONLY the sources we are about to write
|
||||
// into. A dirty tree in an unrelated source (or zero fenceable rows
|
||||
// at all) must not block a no-op or a targeted backfill (#927).
|
||||
const targetSourceIds = new Set([...groups.keys()].map(k => k.split('\0')[0]));
|
||||
for (const id of targetSourceIds) {
|
||||
const localPath = localPathById.get(id);
|
||||
if (localPath && isLocalPathDirty(localPath)) {
|
||||
return {
|
||||
name: 'fence_facts',
|
||||
status: 'failed',
|
||||
detail: `source "${id}" has uncommitted changes in ${localPath}. Commit or stash, then re-run.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, group] of groups) {
|
||||
const [sourceId, entitySlug] = key.split('\0');
|
||||
const localPath = localPathById.get(sourceId)!;
|
||||
|
||||
@@ -23,15 +23,6 @@
|
||||
* hold conventions and shared rule files, not skills. Files like
|
||||
* `_brain-filing-rules.md` live at the root and are not considered
|
||||
* skills by either loader.
|
||||
*
|
||||
* ClawHub-installed workspace skills (#1767): a skill dir carrying
|
||||
* `.clawhub/origin.json` is an externally-managed runtime integration
|
||||
* (e.g. an email or catalog skill), not a gbrain-routable skill. The
|
||||
* derive path SKIPS those so `gbrain doctor` resolver_health doesn't
|
||||
* hard-fail on them — UNLESS the skill's SKILL.md frontmatter declares
|
||||
* `triggers:`, which is the explicit opt-in to gbrain routing (and the
|
||||
* same surface that makes it reachable). An explicit manifest.json that
|
||||
* lists a ClawHub skill also keeps strict checking (verbatim path).
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
|
||||
@@ -69,27 +60,9 @@ function parseSkillName(skillMdPath: string): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Does the SKILL.md frontmatter declare a `triggers:` key? A ClawHub-
|
||||
* installed skill that ships gbrain `triggers:` has explicitly opted in
|
||||
* to gbrain routing and gets full resolver checks (#1767).
|
||||
*/
|
||||
function declaresTriggers(skillMdPath: string): boolean {
|
||||
try {
|
||||
const content = readFileSync(skillMdPath, 'utf-8');
|
||||
const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
||||
if (!fmMatch) return false;
|
||||
return /^triggers:/m.test(fmMatch[1]);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk skillsDir, return every `<skillsDir>/<dir>/SKILL.md` as a
|
||||
* ManifestEntry. Dotfile and underscore-prefixed dirs are skipped, as
|
||||
* are ClawHub-installed external skills that haven't opted in to gbrain
|
||||
* routing via `triggers:` frontmatter (#1767).
|
||||
* ManifestEntry. Dotfile and underscore-prefixed dirs are skipped.
|
||||
*/
|
||||
function deriveManifest(skillsDir: string): ManifestEntry[] {
|
||||
const out: ManifestEntry[] = [];
|
||||
@@ -120,12 +93,6 @@ function deriveManifest(skillsDir: string): ManifestEntry[] {
|
||||
const skillMd = join(subdirAbs, 'SKILL.md');
|
||||
if (!existsSync(skillMd)) continue;
|
||||
|
||||
// ClawHub-installed external skill (#1767): skip unless it opts in
|
||||
// to gbrain routing by declaring `triggers:` in its frontmatter.
|
||||
if (existsSync(join(subdirAbs, '.clawhub', 'origin.json')) && !declaresTriggers(skillMd)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const frontmatterName = parseSkillName(skillMd);
|
||||
const name = frontmatterName && frontmatterName !== '' ? frontmatterName : entry;
|
||||
out.push({ name, path: `${entry}/SKILL.md` });
|
||||
|
||||
@@ -180,3 +180,16 @@ describe('runApplyMigrations exit codes (v0.36.1.x #1062)', () => {
|
||||
expect(src).toMatch(/All migrations up to date[\s\S]{0,80}process\.exit\(0\)/);
|
||||
});
|
||||
});
|
||||
|
||||
// #921: a failed orchestrator must print each failed phase's detail to
|
||||
// stderr — not just "reported status=failed" — so the operator can act
|
||||
// without digging through the ledger.
|
||||
describe('failed migration prints phase detail (#921)', () => {
|
||||
test('runner loops result.phases and console.errors failed phase details', async () => {
|
||||
const { readFileSync } = await import('fs');
|
||||
const src = readFileSync('src/commands/apply-migrations.ts', 'utf8');
|
||||
expect(src).toMatch(
|
||||
/reported status=failed[\s\S]{0,400}for \(const p of result\.phases\)[\s\S]{0,200}p\.status === 'failed'[\s\S]{0,200}console\.error\([\s\S]{0,80}p\.name[\s\S]{0,80}p\.detail/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -382,35 +382,6 @@ describe("DRY detection — checkResolvable", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("#1767 — ClawHub workspace skills are not resolver-required", () => {
|
||||
let dir: string;
|
||||
afterEachCleanup(() => dir && rmSync(dir, { recursive: true, force: true }));
|
||||
|
||||
test("ClawHub skill without gbrain metadata produces no unreachable/mece_gap", () => {
|
||||
dir = mkdtempSync(join(tmpdir(), "gbrain-clawhub-"));
|
||||
// Native gbrain skill: routable via frontmatter triggers. No manifest.json
|
||||
// (the OpenClaw derive path from the issue repro).
|
||||
mkdirSync(join(dir, "query"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(dir, "query", "SKILL.md"),
|
||||
`---\nname: query\ndescription: test\ntriggers:\n - "what do we know"\n---\n\n# query\n`
|
||||
);
|
||||
// ClawHub-installed integration: no triggers, no resolver row.
|
||||
mkdirSync(join(dir, "agentmail", ".clawhub"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(dir, "agentmail", ".clawhub", "origin.json"),
|
||||
JSON.stringify({ registry: "https://clawhub.ai", slug: "agentmail" })
|
||||
);
|
||||
writeFileSync(join(dir, "agentmail", "SKILL.md"), `---\nname: agentmail\ndescription: email integration\n---\n\n# agentmail\n`);
|
||||
|
||||
const report = checkResolvable(dir);
|
||||
const agentmailIssues = report.issues.filter(i => i.skill === "agentmail");
|
||||
expect(agentmailIssues).toEqual([]);
|
||||
expect(report.ok).toBe(true);
|
||||
expect(report.summary.total_skills).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("v0.22.4 regression — actual repo skills/ has 0 errors", () => {
|
||||
test("repo skills/ pass check-resolvable cleanly (zero errors AND zero warnings)", () => {
|
||||
// The v0.22.4 (Part A) contract was zero warnings AND zero errors.
|
||||
|
||||
@@ -10,10 +10,11 @@
|
||||
* __setTestEngineOverride so we don't need a configured brain.
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
|
||||
import { describe, test, expect, beforeAll, afterAll, beforeEach, afterEach } from 'bun:test';
|
||||
import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
|
||||
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
||||
import { v0_32_2, __setTestEngineOverride, __testing } from '../src/commands/migrations/v0_32_2.ts';
|
||||
@@ -238,6 +239,52 @@ describe('phaseBFenceFacts — happy path backfill', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('phaseBFenceFacts — dirty-tree refusal scoping (#927)', () => {
|
||||
let dirtyDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
// A second source whose local_path is a git repo with uncommitted changes.
|
||||
dirtyDir = mkdtempSync(join(tmpdir(), 'mig-v0_32_2-dirty-'));
|
||||
execFileSync('git', ['-C', dirtyDir, 'init', '-q']);
|
||||
writeFileSync(join(dirtyDir, 'uncommitted.md'), 'dirty', 'utf-8');
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await (engine as any).db.query(
|
||||
`INSERT INTO sources (id, name, local_path) VALUES ('other', 'other', $1)`,
|
||||
[dirtyDir],
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await (engine as any).db.query(`DELETE FROM sources WHERE id = 'other'`);
|
||||
rmSync(dirtyDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('no legacy facts at all → complete, dirty unrelated source ignored', async () => {
|
||||
const r = await __testing.phaseBFenceFacts(engine, OPTS);
|
||||
expect(r.status).toBe('complete');
|
||||
expect(r.detail).toContain('scanned=0');
|
||||
});
|
||||
|
||||
test('facts scoped to a clean source fence despite dirty unrelated source', async () => {
|
||||
await seedLegacyFact({ entity_slug: 'people/alice', fact: 'Founded Acme' });
|
||||
|
||||
const r = await __testing.phaseBFenceFacts(engine, OPTS);
|
||||
expect(r.status).toBe('complete');
|
||||
expect(r.detail).toContain('fenced=1');
|
||||
expect(existsSync(join(brainDir, 'people/alice.md'))).toBe(true);
|
||||
});
|
||||
|
||||
test('still refuses when the TARGETED source is dirty', async () => {
|
||||
await seedLegacyFact({ entity_slug: 'people/alice', fact: 'F1', source_id: 'other' });
|
||||
|
||||
const r = await __testing.phaseBFenceFacts(engine, OPTS);
|
||||
expect(r.status).toBe('failed');
|
||||
expect(r.detail).toContain('"other"');
|
||||
expect(r.detail).toContain('uncommitted changes');
|
||||
});
|
||||
});
|
||||
|
||||
describe('phaseCVerify', () => {
|
||||
test('returns complete when fence + DB row counts match', async () => {
|
||||
await seedLegacyFact({ entity_slug: 'people/alice', fact: 'F1' });
|
||||
|
||||
@@ -166,55 +166,6 @@ describe('loadOrDeriveManifest', () => {
|
||||
expect(r.skills.map(s => s.name)).toEqual(['apple', 'mango', 'zebra']);
|
||||
});
|
||||
|
||||
// #1767 — ClawHub-installed workspace skills are external integrations,
|
||||
// not gbrain-routable skills. The derive path skips them unless they
|
||||
// opt in via `triggers:` frontmatter.
|
||||
it('skips ClawHub-origin skills without triggers frontmatter (#1767)', () => {
|
||||
const dir = scratch();
|
||||
writeSkill(dir, 'query', 'query');
|
||||
writeSkill(dir, 'agentmail', 'agentmail');
|
||||
mkdirSync(join(dir, 'agentmail', '.clawhub'), { recursive: true });
|
||||
writeFileSync(
|
||||
join(dir, 'agentmail', '.clawhub', 'origin.json'),
|
||||
JSON.stringify({ registry: 'https://clawhub.ai', slug: 'agentmail' })
|
||||
);
|
||||
const r = loadOrDeriveManifest(dir);
|
||||
expect(r.derived).toBe(true);
|
||||
expect(r.skills.map(s => s.name)).toEqual(['query']);
|
||||
});
|
||||
|
||||
it('includes ClawHub-origin skills that opt in via triggers frontmatter (#1767)', () => {
|
||||
const dir = scratch();
|
||||
writeSkill(dir, 'agentmail', 'agentmail');
|
||||
mkdirSync(join(dir, 'agentmail', '.clawhub'), { recursive: true });
|
||||
writeFileSync(
|
||||
join(dir, 'agentmail', '.clawhub', 'origin.json'),
|
||||
JSON.stringify({ registry: 'https://clawhub.ai', slug: 'agentmail' })
|
||||
);
|
||||
writeFileSync(
|
||||
join(dir, 'agentmail', 'SKILL.md'),
|
||||
`---\nname: agentmail\ndescription: test\ntriggers:\n - "send email"\n---\n\n# agentmail\n`
|
||||
);
|
||||
const r = loadOrDeriveManifest(dir);
|
||||
expect(r.derived).toBe(true);
|
||||
expect(r.skills.map(s => s.name)).toEqual(['agentmail']);
|
||||
});
|
||||
|
||||
it('keeps ClawHub-origin skills listed in an explicit manifest.json (#1767)', () => {
|
||||
// Explicit manifest.json is a deliberate declaration — strict checking stays.
|
||||
const dir = scratch();
|
||||
writeSkill(dir, 'agentmail', 'agentmail');
|
||||
mkdirSync(join(dir, 'agentmail', '.clawhub'), { recursive: true });
|
||||
writeFileSync(
|
||||
join(dir, 'agentmail', '.clawhub', 'origin.json'),
|
||||
JSON.stringify({ registry: 'https://clawhub.ai', slug: 'agentmail' })
|
||||
);
|
||||
writeManifest(dir, { skills: [{ name: 'agentmail', path: 'agentmail/SKILL.md' }] });
|
||||
const r = loadOrDeriveManifest(dir);
|
||||
expect(r.derived).toBe(false);
|
||||
expect(r.skills.map(s => s.name)).toEqual(['agentmail']);
|
||||
});
|
||||
|
||||
it('treats dirs without SKILL.md as not-a-skill', () => {
|
||||
const dir = scratch();
|
||||
writeSkill(dir, 'query', 'query');
|
||||
|
||||
Reference in New Issue
Block a user