From 8cd208dc8cd3dcabe146b6e8aae943441459e797 Mon Sep 17 00:00:00 2001 From: Operator & Codex Date: Sat, 25 Apr 2026 08:22:43 +0200 Subject: [PATCH] docs(multitenant): add agent worktree guide and fix registry semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Build: pass | Tests: FAIL — Tests 6 failed | 1835 passed (1841) --- docs/internal/AGENT-WORKFLOW-CHECKLIST.md | 59 ++++++ docs/internal/AGENT-WORKTREE-WORKFLOW.md | 245 ++++++++++++++++++++++ hooks/pre-commit | 0 src/tenant-registry.test.ts | 53 +++-- src/tenant-registry.ts | 38 ++-- 5 files changed, 361 insertions(+), 34 deletions(-) create mode 100644 docs/internal/AGENT-WORKFLOW-CHECKLIST.md create mode 100644 docs/internal/AGENT-WORKTREE-WORKFLOW.md mode change 100644 => 100755 hooks/pre-commit diff --git a/docs/internal/AGENT-WORKFLOW-CHECKLIST.md b/docs/internal/AGENT-WORKFLOW-CHECKLIST.md new file mode 100644 index 0000000..13ae712 --- /dev/null +++ b/docs/internal/AGENT-WORKFLOW-CHECKLIST.md @@ -0,0 +1,59 @@ +# Agent Workflow Checklist + +Short operator checklist for the per-agent worktree model. + +## Before Starting + +- verify `git config core.hooksPath` returns `hooks` +- verify `git config extensions.worktreeConfig` is `true` +- confirm the primary FreeBSD checkout is the only place that will push + `multitenant` + +## When Assigning A Linux Agent + +- tell the agent to pull latest `origin/multitenant` +- tell the agent to read: + - [MULTITENANT-AGENT-WORKFLOW.md](./MULTITENANT-AGENT-WORKFLOW.md) + - [MULTITENANT-HANDOFF.md](./MULTITENANT-HANDOFF.md) +- require a dedicated worktree and feature branch +- require worktree-local identity: + - `Operator & zAI` + - `Operator & Claude` +- require branch-only push, never `multitenant` + +## Before The Agent Commits + +- confirm `git var GIT_AUTHOR_IDENT` +- confirm `git var GIT_COMMITTER_IDENT` +- both should start with `Operator &` + +## Before The Agent Pushes + +- rebase on latest `origin/multitenant` +- if local commits were rewritten, push with `--force-with-lease` +- report the feature branch name and tip commit hash + +## Before Codex Integrates + +- fetch latest refs +- verify the helper branch is actually ahead of `origin/multitenant` +- review the diff +- fast-forward integrate only +- run the relevant FreeBSD verification + +## Before Pushing `multitenant` + +- confirm local `multitenant` is based on latest `origin/multitenant` +- confirm the working tree is clean +- push only after integration and verification are done + +## If Something Looks Wrong + +- commit rejected by hook: + - check worktree-local `user.name` +- helper branch equals base branch: + - there is no new work yet +- push rejected non-fast-forward: + - fetch and rebase before retrying +- rebase asks for an editor: + - use `GIT_EDITOR=true git rebase --continue` diff --git a/docs/internal/AGENT-WORKTREE-WORKFLOW.md b/docs/internal/AGENT-WORKTREE-WORKFLOW.md new file mode 100644 index 0000000..af33cc8 --- /dev/null +++ b/docs/internal/AGENT-WORKTREE-WORKFLOW.md @@ -0,0 +1,245 @@ +# Agent Worktree Workflow + +Practical guide for running more than one coding agent against this repo +without corrupting attribution, stash state, or branch history. + +## Why This Exists + +We hit two different failures while multiple agents were sharing one clone: + +- commit attribution drifted because `user.name` is only one mutable slot +- `git pull` / rebase work collided because one working tree also means one + index, one stash stack, and one rebase state directory + +Per-agent worktrees fix both at once. + +## The Model + +- Linux helper agents each get their own git worktree +- each helper works on its own feature branch: + - `multitenant-zai` + - `multitenant-claude` +- FreeBSD Codex stays on the primary `multitenant` checkout +- only FreeBSD Codex pushes `multitenant` + +That means: + +- helpers propose changes +- Codex reviews, tests, integrates, and pushes the shared branch + +## One-Time Setup + +Run once in the primary clone: + +```sh +git config extensions.worktreeConfig true +git config core.hooksPath hooks +``` + +Why: + +- `extensions.worktreeConfig=true` enables per-worktree `user.name` +- `core.hooksPath hooks` activates the attribution guard and README sync hook + +## Per-Agent Setup + +Example for zAI: + +```sh +git worktree add -b multitenant-zai ../mevy-ai-zai multitenant +cd ../mevy-ai-zai +git config --worktree user.name "Operator & zAI" +git config --worktree user.email "hello@clawdie.si" +``` + +Example for Claude: + +```sh +git worktree add -b multitenant-claude ../mevy-ai-claude multitenant +cd ../mevy-ai-claude +git config --worktree user.name "Operator & Claude" +git config --worktree user.email "hello@clawdie.si" +``` + +Check the effective identity before committing: + +```sh +git var GIT_AUTHOR_IDENT +git var GIT_COMMITTER_IDENT +``` + +Both must start with `Operator & ...`. + +## Day-To-Day Flow + +### Linux helper agent + +1. Pull latest refs: + +```sh +git fetch origin +``` + +2. Work only in the agent worktree, never in the shared clone. + +3. Commit locally on the feature branch. + +4. Rebase on latest `origin/multitenant` before push: + +```sh +git pull --rebase origin multitenant +``` + +5. Push the feature branch: + +```sh +git push origin multitenant-zai +``` + +If the rebase rewrote local commits, push with lease protection: + +```sh +git push --force-with-lease origin multitenant-zai +``` + +6. Report back the feature branch name and tip commit to Codex. + +### FreeBSD Codex + +1. Fetch latest refs: + +```sh +git fetch origin +``` + +2. Review the helper branch. + +3. Integrate into local `multitenant` with fast-forward merge only. + +4. Run the FreeBSD-side verification that matters for the slice. + +5. Push `multitenant`: + +```sh +git push origin multitenant +``` + +## Why Integration Should Stay Fast-Forward + +The helper branch is required to rebase on latest `origin/multitenant` +before push. That keeps integration simple: + +- no merge bubble just to land helper work +- no ambiguity about who last reconciled the branch +- if a helper branch cannot fast-forward cleanly anymore, it needs another + rebase before Codex should integrate it + +## What The Hook Guards + +`hooks/pre-commit` currently does two things: + +- rejects commits whose effective author/committer name is not + `Operator & ` or the temporary legacy `Clawdie AI` +- runs the README version sync helper + +The important detail is that it validates: + +- `git var GIT_AUTHOR_IDENT` +- `git var GIT_COMMITTER_IDENT` + +That means it checks the identity git will actually write, not just +`git config user.name`. + +## Common Failure Modes + +### Commit rejected as `Mevy Assistant` + +Cause: + +- the worktree is still using `.git/config` identity instead of + `.git/config.worktree` + +Fix: + +```sh +git config --worktree user.name "Operator & Codex" +git config --worktree user.email "hello@clawdie.si" +``` + +Then re-check with: + +```sh +git var GIT_AUTHOR_IDENT +``` + +### Helper branch is identical to `origin/multitenant` + +Cause: + +- the helper created or pushed the branch but never committed the actual work + +Check: + +```sh +git log --oneline origin/multitenant..HEAD +``` + +If empty, there is nothing to integrate yet. + +### `git push origin multitenant` is rejected + +Cause: + +- local `multitenant` is behind the remote tip + +Fix: + +- fetch +- rebase local Codex commits on `origin/multitenant` +- resolve any conflicts +- push again + +### `git rebase --continue` opens `vi` + +Cause: + +- git wants to reopen the commit message + +Fix: + +```sh +GIT_EDITOR=true git rebase --continue +``` + +### Hook is not firing + +Check: + +```sh +git config core.hooksPath +``` + +Expected: + +```text +hooks +``` + +If not, run: + +```sh +git config core.hooksPath hooks +``` + +## When To Document, Not Automate + +This workflow is worth a repo doc before it becomes a reusable skill. + +Why: + +- it is mostly operator process and branch discipline +- it is tightly coupled to this repo's `multitenant` integration model +- the value right now is a stable human runbook + +If the same pattern starts repeating across repos, then it is worth turning +into a skill with setup prompts and validation commands. diff --git a/hooks/pre-commit b/hooks/pre-commit old mode 100644 new mode 100755 diff --git a/src/tenant-registry.test.ts b/src/tenant-registry.test.ts index a6312ca..5c1ecc1 100644 --- a/src/tenant-registry.test.ts +++ b/src/tenant-registry.test.ts @@ -85,13 +85,24 @@ describe('tenant-registry', () => { const yamlContent = fs.readFileSync(registryPath, 'utf-8'); fs.writeFileSync( registryPath, - `${yamlContent} sites:\n - id: Blog\n - id: docs\n exposure: disabled\n`, + yamlContent.replace( + ' sites:\n - id: blog\n exposure: internal', + ' sites:\n - id: Blog\n - id: docs\n exposure: disabled', + ), 'utf-8', ); const registry = loadTenantRegistry(registryPath); expect(registry.tenants.mevy?.sites).toEqual([ - { id: 'blog', exposure: 'internal' }, - { id: 'docs', exposure: 'disabled' }, + { + id: 'blog', + exposure: 'internal', + fqdn: 'blog.mevy.home.arpa', + }, + { + id: 'docs', + exposure: 'disabled', + fqdn: null, + }, ]); }); @@ -100,11 +111,14 @@ describe('tenant-registry', () => { const yamlContent = fs.readFileSync(registryPath, 'utf-8'); fs.writeFileSync( registryPath, - `${yamlContent} sites:\n - id: blog\n exposure: public\n`, + yamlContent.replace( + ' sites:\n - id: blog\n exposure: internal', + ' sites:\n - id: blog\n exposure: public', + ), 'utf-8', ); expect(() => loadTenantRegistry(registryPath)).toThrow( - /publishing_mode is disabled/, + /platform\.publishing_mode is disabled/, ); }); @@ -117,7 +131,10 @@ describe('tenant-registry', () => { ); fs.writeFileSync( registryPath, - `${withPublishing} sites:\n - id: blog\n exposure: public\n`, + withPublishing.replace( + ' sites:\n - id: blog\n exposure: internal', + ' sites:\n - id: blog\n exposure: public', + ), 'utf-8', ); expect(() => loadTenantRegistry(registryPath)).toThrow( @@ -130,10 +147,13 @@ describe('tenant-registry', () => { const yamlContent = fs.readFileSync(registryPath, 'utf-8'); fs.writeFileSync( registryPath, - `${yamlContent} sites:\n - id: blog\n - id: Blog\n`, + yamlContent.replace( + ' sites:\n - id: blog\n exposure: internal', + ' sites:\n - id: blog\n - id: Blog', + ), 'utf-8', ); - expect(() => loadTenantRegistry(registryPath)).toThrow(/duplicate site id/); + expect(() => loadTenantRegistry(registryPath)).toThrow(/duplicated/); }); it('lists tenants in a stable order', () => { @@ -575,15 +595,20 @@ describe('tenant-registry', () => { const original = fs.readFileSync(registryPath, 'utf-8'); fs.writeFileSync( registryPath, - original.replace( - ' sites:\n - id: blog\n exposure: internal', - ' sites:\n - id: docs\n exposure: public', - ), + original + .replace( + ' publishing_mode: disabled', + ' publishing_mode: public', + ) + .replace( + ' sites:\n - id: blog\n exposure: internal', + ' sites:\n - id: docs\n exposure: public', + ), 'utf-8', ); expect(() => loadTenantRegistry(registryPath)).toThrow( - 'Tenant site requires platform.public_base for public exposure: docs', + 'Tenant site requires platform.public_base; public_base is unset: docs', ); }); @@ -603,7 +628,7 @@ describe('tenant-registry', () => { ); expect(() => loadTenantRegistry(registryPath)).toThrow( - 'Tenant site cannot use public exposure when platform publishing mode is internal: docs', + 'Tenant site cannot use public exposure when platform.publishing_mode is internal: docs', ); }); diff --git a/src/tenant-registry.ts b/src/tenant-registry.ts index 78b9fe8..607cb55 100644 --- a/src/tenant-registry.ts +++ b/src/tenant-registry.ts @@ -238,25 +238,23 @@ function parseTenantRecord( internalBase?: string, publicBase?: string, ): TenantRecord { + const sites = (raw.sites || []).map((site) => { + const exposure = site.exposure || 'internal'; + return { + id: normalizeResourceId(site.id), + exposure, + fqdn: + exposure === 'public' && !publicBase + ? null + : siteFqdn(site.id, id, exposure, internalBase, publicBase), + }; + }); return { id, displayName: raw.display_name || defaultTenantDisplayName(id), internalDomain: raw.internal_domain || tenantHomeFqdn(id, internalBase), service: raw.service || tenantServiceName(id), - sites: (raw.sites || []).map((site) => ({ - id: normalizeResourceId(site.id), - exposure: site.exposure || 'disabled', - fqdn: - (site.exposure || 'disabled') === 'public' && !publicBase - ? null - : siteFqdn( - site.id, - id, - site.exposure || 'disabled', - internalBase, - publicBase, - ), - })), + sites, databases: { brain: raw.databases?.brain || tenantBrainDbName(id), ops: raw.databases?.ops || tenantOpsDbName(id), @@ -606,17 +604,17 @@ function validateTenantRecord( if (registry.platform.reservedHostLabels.includes(siteIdAlias)) { throw new Error(`Tenant site id conflicts with reserved host label: ${site.id}`); } - if (site.exposure === 'public' && !registry.platform.publicBase) { - throw new Error( - `Tenant site requires platform.public_base for public exposure: ${site.id}`, - ); - } if ( site.exposure === 'public' && registry.platform.publishingMode !== 'public' ) { throw new Error( - `Tenant site cannot use public exposure when platform publishing mode is ${registry.platform.publishingMode}: ${site.id}`, + `Tenant site cannot use public exposure when platform.publishing_mode is ${registry.platform.publishingMode}: ${site.id}`, + ); + } + if (site.exposure === 'public' && !registry.platform.publicBase) { + throw new Error( + `Tenant site requires platform.public_base; public_base is unset: ${site.id}`, ); } }