mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-16 09:52:22 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b8676be3e | ||
|
|
b8376f7327 |
@@ -206,7 +206,11 @@ jobs:
|
||||
needs: cache-check
|
||||
if: needs.cache-check.outputs.hit != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
# 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
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
|
||||
@@ -3,11 +3,7 @@
|
||||
*
|
||||
* Usage:
|
||||
* gbrain migrate --to supabase [--url <connection_string>]
|
||||
* (--url is persisted to config.json, mode 0600, so the migrated brain
|
||||
* works without env — #1271)
|
||||
* gbrain migrate --to pglite [--path <db_path>]
|
||||
* (an explicit --path destination is bootstrapped with its own
|
||||
* <path>/.gbrain/config.json so GBRAIN_HOME=<path> just works — #1271)
|
||||
* gbrain migrate --to <engine> --force (overwrite non-empty target)
|
||||
*/
|
||||
|
||||
@@ -15,9 +11,9 @@ import { createEngine } from '../core/engine-factory.ts';
|
||||
import { loadConfig, saveConfig, toEngineConfig, gbrainPath, effectiveEnvDatabaseUrl, type GBrainConfig } from '../core/config.ts';
|
||||
import type { BrainEngine } from '../core/engine.ts';
|
||||
import type { EngineConfig } from '../core/types.ts';
|
||||
import { writeFileSync, readFileSync, existsSync, unlinkSync, mkdirSync, chmodSync } from 'fs';
|
||||
import { writeFileSync, readFileSync, existsSync, unlinkSync } from 'fs';
|
||||
import { createHash } from 'crypto';
|
||||
import { resolve, join } from 'path';
|
||||
import { resolve } from 'path';
|
||||
import { createProgress } from '../core/progress.ts';
|
||||
import { getCliOptions, cliOptsToProgressOptions } from '../core/cli-options.ts';
|
||||
|
||||
@@ -63,31 +59,6 @@ export interface MigrateManifest {
|
||||
started_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* #1271 Finding 1: make an explicit `--to pglite --path P` destination usable
|
||||
* as a standalone brain. Writes `P/.gbrain/config.json` (mode 0600, plus a
|
||||
* `*` .gitignore) so `GBRAIN_HOME=P` resolves without a manual `gbrain init`.
|
||||
* Never clobbers an existing config at the destination. Returns the written
|
||||
* config path, or null when skipped.
|
||||
*/
|
||||
export function bootstrapDestinationConfig(dbPath: string): string | null {
|
||||
const abs = resolve(dbPath);
|
||||
const dir = join(abs, '.gbrain');
|
||||
const file = join(dir, 'config.json');
|
||||
if (existsSync(file)) return null;
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const cfg: GBrainConfig = { engine: 'pglite', database_path: abs };
|
||||
writeFileSync(file, JSON.stringify(cfg, null, 2) + '\n', { mode: 0o600 });
|
||||
try { chmodSync(file, 0o600); } catch { /* platform-specific */ }
|
||||
// Same worktree-safety pattern as saveConfig()'s ensureGitignore, scoped
|
||||
// to the destination home. Don't clobber a user-customized .gitignore.
|
||||
const gitignore = join(dir, '.gitignore');
|
||||
if (!existsSync(gitignore)) {
|
||||
writeFileSync(gitignore, '*\n', { mode: 0o600 });
|
||||
}
|
||||
return file;
|
||||
}
|
||||
|
||||
export function migrationTargetId(config: EngineConfig): string {
|
||||
const locator = config.engine === 'postgres'
|
||||
? config.database_url ?? ''
|
||||
@@ -381,25 +352,6 @@ export async function runMigrateEngine(sourceEngine: BrainEngine, args: string[]
|
||||
};
|
||||
saveConfig(newConfig);
|
||||
|
||||
// #1271 Finding 2 (by design, but say it out loud): the connection string
|
||||
// is persisted so the migrated brain works without env. Mode 0600.
|
||||
if (opts.targetEngine === 'postgres' && opts.targetUrl) {
|
||||
console.error('Note: the --url connection string (including credentials) is persisted to config.json (mode 0600).');
|
||||
}
|
||||
|
||||
// #1271 Finding 1: an explicit --path destination doubles as a standalone
|
||||
// GBRAIN_HOME. Best-effort — never fail a completed migration over it.
|
||||
if (opts.targetEngine === 'pglite' && opts.targetPath) {
|
||||
try {
|
||||
const written = bootstrapDestinationConfig(opts.targetPath);
|
||||
if (written) {
|
||||
console.log(`Destination bootstrapped: ${written} (usable via GBRAIN_HOME=${resolve(opts.targetPath)})`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(` WARN could not bootstrap destination config: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up
|
||||
clearManifest();
|
||||
|
||||
|
||||
@@ -23,6 +23,15 @@
|
||||
* 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';
|
||||
@@ -60,9 +69,27 @@ 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.
|
||||
* 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).
|
||||
*/
|
||||
function deriveManifest(skillsDir: string): ManifestEntry[] {
|
||||
const out: ManifestEntry[] = [];
|
||||
@@ -93,6 +120,12 @@ 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` });
|
||||
|
||||
@@ -382,6 +382,35 @@ 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.
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { mkdtempSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join, resolve } from 'path';
|
||||
import { bootstrapDestinationConfig } from '../src/commands/migrate-engine.ts';
|
||||
import { loadConfigFileOnly } from '../src/core/config.ts';
|
||||
import { withEnv } from './helpers/with-env.ts';
|
||||
|
||||
describe('migrate --to pglite destination bootstrap (#1271)', () => {
|
||||
test('writes <path>/.gbrain/config.json so GBRAIN_HOME=<path> resolves a brain', async () => {
|
||||
const dest = mkdtempSync(join(tmpdir(), 'gbrain-dest-'));
|
||||
const written = bootstrapDestinationConfig(dest);
|
||||
const file = join(dest, '.gbrain', 'config.json');
|
||||
expect(written).toBe(file);
|
||||
|
||||
const cfg = JSON.parse(readFileSync(file, 'utf-8'));
|
||||
expect(cfg.engine).toBe('pglite');
|
||||
expect(cfg.database_path).toBe(resolve(dest));
|
||||
expect(statSync(file).mode & 0o777).toBe(0o600);
|
||||
// worktree safety: destination home is git-ignored like saveConfig()'s home
|
||||
expect(readFileSync(join(dest, '.gbrain', '.gitignore'), 'utf-8')).toBe('*\n');
|
||||
|
||||
// The exact failure mode from #1271: config resolution under
|
||||
// GBRAIN_HOME=<path> used to find nothing ("No brain configured").
|
||||
await withEnv({ GBRAIN_HOME: dest }, () => {
|
||||
const loaded = loadConfigFileOnly();
|
||||
expect(loaded?.engine).toBe('pglite');
|
||||
expect(loaded?.database_path).toBe(resolve(dest));
|
||||
});
|
||||
});
|
||||
|
||||
test('never clobbers an existing destination config', () => {
|
||||
const dest = mkdtempSync(join(tmpdir(), 'gbrain-dest-'));
|
||||
mkdirSync(join(dest, '.gbrain'), { recursive: true });
|
||||
writeFileSync(join(dest, '.gbrain', 'config.json'), '{"engine":"postgres"}\n');
|
||||
|
||||
expect(bootstrapDestinationConfig(dest)).toBe(null);
|
||||
expect(JSON.parse(readFileSync(join(dest, '.gbrain', 'config.json'), 'utf-8')).engine).toBe('postgres');
|
||||
});
|
||||
});
|
||||
@@ -166,6 +166,55 @@ 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