Public: anyone can read this; the crew can change it.

When an admin lands a vision pull, only the fenced vision block of the direction is rewritten, and the owner's own words are kept word for word #6230

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/5834-when-an-admin-lands-a-vision-pull-only-t → main 1 commit fork 2 threads · 2 unresolved

  • Merged. scout/5834-when-an-admin-lands-a-vision-pull-only-t was merged into main as 8ed2e83.

Review

Sign in to review this change.
6 files changed +284 −0
src/actions/pulls.ts +3
@@ -6,6 +6,7 @@
66
77 import { resolveOpen } from "../core/decisions.ts";
88 import type { App } from "../app.ts";
9 import { landVision } from "../crew/vision.ts";
910 import { isCrew } from "../core/agents.ts";
1011 import {
1112 getIssueById,
@@ -435,6 +436,8 @@ export async function mergePull(
435436 mergeOid: merged.value.mergeOid,
436437 },
437438 });
439 // A vision pull, landed by an admin, rewrites the direction's fenced block.
440 await landVision(app, writer.value, repo, pull);
438441 // What the pull said it closes, closes: the issue is done when the change
439442 // lands, as the person who landed it — which is what frees the map. Not
440443 // the plan's own issues: no change finishes an umbrella or opens the
src/core/event_audience.ts +1
@@ -112,6 +112,7 @@ const READER_KINDS: ReadonlySet<string> = new Set([
112112 "board.saved",
113113 "board.deleted",
114114 "repo.created",
115 "repo.vision_kept",
115116 "repo.forked",
116117 "repo.deleted",
117118 "label.updated",
src/core/placements.ts +8
@@ -65,6 +65,14 @@ export function placementOf(db: Database, thoughtId: string): Placement | undefi
6565 return row ? toPlacement(row) : undefined;
6666 }
6767
68 /** Whether a placement of kind `vision` proposed pull `number` of `repo` (owner/name): the one mark of a vision pull. */
69 export function isVisionPull(db: Database, repo: string, number: number): boolean {
70 const rows = db

LIKE over all placements on every merge; fine today, narrow it if the table grows.

71 .query<Row, [string]>("SELECT * FROM placement WHERE kind = 'vision' AND placed LIKE ?1")
72 .all(`%"pull":${number}%`);
73 return rows.some((r) => toPlacement(r).placed.some((p) => p.action === "proposed" && p.repo === repo && p.pull === number));
74 }
75
6876 export function placementsOf(db: Database, thoughtIds: readonly string[]): Map<string, Placement> {
6977 const out = new Map<string, Placement>();
7078 for (const id of thoughtIds) {
src/crew/vision.ts +91
@@ -7,6 +7,10 @@
77 */
88
99 import type { App } from "../app.ts";
10 import { isVisionPull } from "../core/placements.ts";
11 import { parseVision, type Vision } from "../core/vision.ts";
12 import { canAdmin, getRepoById, updateRepoSettings } from "../core/repos.ts";
13 import { createLog } from "../kernel/log.ts";
1014 import { editFiles } from "../actions/contents.ts";
1115 import type { Pull } from "../core/pulls.ts";
1216 import type { Repo } from "../core/repos.ts";
@@ -14,6 +18,8 @@ import type { User } from "../core/users.ts";
1418 import { listTree, readerFor } from "../git/read.ts";
1519 import { repoDir } from "../git/repos.ts";
1620
21 const log = createLog("vision");
22
1723 export const VISION_PATH = "docs/VISION.md";
1824 export const SECTION = "Changes of direction";
1925 const LINE_SHOWN = 240;
@@ -77,3 +83,88 @@ export async function proposeVision(
7783 if (!edited.ok || !edited.value.pull) return { ok: false, why: "the edit could not be opened" };
7884 return { ok: true, pull: edited.value.pull };
7985 }
86
87 /** The fenced block's markers; the owner's words outside them are never touched. */
88 export const BLOCK_START = "<!-- forge:vision:start -->";
89 export const BLOCK_END = "<!-- forge:vision:end -->";
90 export const DIRECTION_LIMIT = 2000;
91
92 const clip = (s: string, n: number): string => (s.length > n ? `${s.slice(0, n - 1).trimEnd()}…` : s);

Share this limit with the settings validator instead of repeating 2000.

93
94 /** The block for a vision, markers included, no longer than `room` characters; null when not even its head fits. */
95 export function visionBlock(vision: Vision, room = DIRECTION_LIMIT): string | null {
96 const flat = (s: string): string => s.replace(/\s+/g, " ").trim();
97 const parts: string[] = [];
98 if (vision.purpose) parts.push(`Purpose: ${clip(flat(vision.purpose), 400)}`);
99 if (vision.order.length > 0) parts.push(`Order of work: ${vision.order.map((o) => `${o.n}. ${o.name}`).join("; ")}.`);
100 for (const rule of vision.rules) parts.push(`Rule: ${clip(flat(rule), 160)}`);
101 const kept: string[] = [];
102 for (const part of parts) {
103 const next = `${BLOCK_START}\n${[...kept, part].join("\n")}\n${BLOCK_END}`;
104 if (next.length > room) break;
105 kept.push(part);
106 }
107 return kept.length === 0 ? null : `${BLOCK_START}\n${kept.join("\n")}\n${BLOCK_END}`;
108 }
109
110 const count = (s: string, needle: string): number => s.split(needle).length - 1;
111
112 /**
113 * `direction` with the fenced block regenerated, everything outside it kept
114 * byte for byte. No block yet: it is appended after the owner's words. An
115 * orphan, doubled or reversed marker changes nothing and says why.
116 */
117 export function withVisionBlock(
118 direction: string,
119 vision: Vision,
120 ): { ok: true; text: string } | { ok: false; why: string } {
121 const starts = count(direction, BLOCK_START);
122 const ends = count(direction, BLOCK_END);
123 const from = direction.indexOf(BLOCK_START);
124 const to = direction.indexOf(BLOCK_END);
125 if (starts !== ends || starts > 1) return { ok: false, why: "the start and end markers do not pair up" };
126 if (starts === 1 && to < from) return { ok: false, why: "the end marker comes before the start marker" };
127 if (starts === 0) {
128 const sep = direction === "" ? "" : direction.endsWith("\n\n") ? "" : direction.endsWith("\n") ? "\n" : "\n\n";
129 const block = visionBlock(vision, DIRECTION_LIMIT - direction.length - sep.length);
130 return block === null ? { ok: false, why: "the owner's words leave no room for the block" } : { ok: true, text: `${direction}${sep}${block}` };
131 }
132 const before = direction.slice(0, from);
133 const after = direction.slice(to + BLOCK_END.length);
134 const block = visionBlock(vision, DIRECTION_LIMIT - before.length - after.length);
135 return block === null ? { ok: false, why: "the owner's words leave no room for the block" } : { ok: true, text: `${before}${block}${after}` };
136 }
137
138 /**
139 * After a pull lands: when it is a vision pull (its placement says so, never
140 * its branch name) and the merger administers the repository, regenerate the
141 * fenced block of the direction from the landed VISION.md. Anyone else's merge
142 * leaves the direction alone and writes a `repo` event the pull page shows.
143 */
144 export async function landVision(app: App, merger: User, repo: Repo, pull: Pull): Promise<void> {
145 if (!isVisionPull(app.db, `${repo.ownerName}/${repo.name}`, pull.number)) return;
146 if (!canAdmin(app.db, repo, merger)) {
147 app.bus.append({
148 kind: "repo.vision_kept",
149 repoId: repo.id,
150 payload: { pull: pull.number, why: "an admin's merge rewrites it" },
151 });
152 return;
153 }
154 const fresh = getRepoById(app.db, repo.id) ?? repo;
155 const md = await readVision(app, fresh);
156 if (md === null) return;
157 const next = withVisionBlock(fresh.autonomyDirection, parseVision(md));
158 if (!next.ok) {
159 log.warn("left the direction unchanged", { repo: `${repo.ownerName}/${repo.name}`, pull: pull.number, why: next.why });
160 return;
161 }
162 if (next.text === fresh.autonomyDirection) return;
163 updateRepoSettings(app.db, fresh, { autonomyDirection: next.text });
164 app.bus.append({
165 kind: "repo.settings_updated",
166 repoId: repo.id,
167 actorId: merger.id,
168 payload: { changed: ["autonomyDirection"], pull: pull.number },
169 });
170 }
src/web/pages/thread.ts +15
@@ -231,6 +231,20 @@ function interruptionEntries(ctx: Ctx, issue: Issue): Entry[] {
231231 }));
232232 }
233233
234 /** The forge's own line where a vision pull landed without an admin: the direction was kept as it was. */
235 function forgeEntries(ctx: Ctx, repo: Repo, issue: Issue): Entry[] {
236 if (issue.kind !== "pull") return [];
237 return listEvents(ctx.app.db, { repoId: repo.id, kind: "repo.vision_kept", limit: 50 })
238 .filter((e) => e.payload["pull"] === issue.number)
239 .map((e) => ({
240 at: e.createdAt,
241 node: tlEvent({
242 icon: "merge",
243 body: html`<span>the forge kept the repository's direction as it was: only ${String(e.payload["why"] ?? "an admin's merge rewrites it")}</span> ${time(e.createdAt)}`,
244 }),
245 }));
246 }
247
234248 export interface ConversationOptions {
235249 /** The verb on the opening card ("opened this", "wants to merge …"). */
236250 openVerb: Html | string;
@@ -317,6 +331,7 @@ export function conversation(
317331 }));
318332 entries.push(...stateEvents(ctx, repo, issue));
319333 entries.push(...interruptionEntries(ctx, issue));
334 entries.push(...forgeEntries(ctx, repo, issue));
320335 entries.push(...(options.extra ?? []));
321336 entries.sort((a, b) => (a.at < b.at ? -1 : a.at > b.at ? 1 : 0));
322337 // The opening post and the newest THREAD_WINDOW comments with the state
test/vision-land.test.ts added +166
@@ -0,0 +1,166 @@
1 /**
2 * Landing a vision pull (#5834): an admin's merge regenerates the fenced
3 * block of the direction and nothing else; anyone else's merge leaves it and
4 * the pull page says so in the forge's own line.
5 */
6 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createApp, type App } from "../src/app.ts";
11 import { addTeamMember, createOrg, createTeam } from "../src/actions/orgs.ts";
12 import { mergePull, openPull } from "../src/actions/pulls.ts";
13 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
14 import { listEvents } from "../src/core/events.ts";
15 import { getOrg } from "../src/core/orgs.ts";
16 import { savePlacement } from "../src/core/placements.ts";
17 import { getRepoById, updateRepoSettings, type Repo } from "../src/core/repos.ts";
18 import type { User } from "../src/core/users.ts";
19 import { BLOCK_END, BLOCK_START, DIRECTION_LIMIT, withVisionBlock } from "../src/crew/vision.ts";
20 import { parseVision } from "../src/core/vision.ts";
21 import { serve, type Servers } from "../src/http/server.ts";
22 import { promote } from "./helpers/members.ts";
23 import { signUpProven as signUp } from "./helpers/verified.ts";
24
25 const MD = `# Vision
26
27 The forge is a social place for building.
28
29 ## Order of work
30 0. **Trust, always first.** Agents are contained.
31 1. **One front door: the feed.** Home is a timeline.
32
33 ## How we build
34 - A deterministic tool with deliberate noise: actions are explicit.
35 - Done means reachable.
36 `;
37 const vision = parseVision(MD);
38
39 describe("withVisionBlock", () => {
40 test("appends after the owner's words, then regenerates in place, byte for byte outside", () => {
41 const first = withVisionBlock("Mine, first.", vision);
42 if (!first.ok) throw new Error(first.why);
43 expect(first.text.startsWith("Mine, first.\n\n" + BLOCK_START)).toBe(true);
44 expect(first.text).toContain("Order of work: 0. Trust, always first; 1. One front door: the feed.");
45 const wrapped = `before é\n${first.text.slice("Mine, first.\n\n".length)}\n after `;
46 const again = withVisionBlock(wrapped, parseVision(MD.replace("Done means reachable.", "Done means seen.")));
47 if (!again.ok) throw new Error(again.why);
48 expect(again.text.startsWith("before é\n" + BLOCK_START)).toBe(true);
49 expect(again.text.endsWith(BLOCK_END + "\n after ")).toBe(true);
50 expect(again.text).toContain("Rule: Done means seen.");
51 });
52 test("an orphan start marker changes nothing", () => {
53 const r = withVisionBlock(`mine\n${BLOCK_START}\nhalf`, vision);
54 expect(r.ok).toBe(false);
55 });
56 test("an end marker before the start changes nothing", () => {
57 expect(withVisionBlock(`${BLOCK_END} ${BLOCK_START}`, vision).ok).toBe(false);
58 });
59 test("the whole direction stays under 2,000 characters", () => {
60 const long = parseVision(`# V\n\n${"word ".repeat(1000)}\n\n## How we build\n${"- rule ".repeat(10).split(" - ").map((_, i) => `- ${"r".repeat(300)}${i}`).join("\n")}\n`);
61 const r = withVisionBlock("x".repeat(1500), long);
62 if (!r.ok) throw new Error(r.why);
63 expect(r.text.length).toBeLessThanOrEqual(DIRECTION_LIMIT);
64 expect(r.text.startsWith("x".repeat(1500))).toBe(true);
65 expect(withVisionBlock("x".repeat(1990), long).ok).toBe(false);
66 });
67 });
68
69 describe("landing a vision pull", () => {
70 let dataDir: string;
71 let app: App;
72 let servers: Servers;
73 let owner: User;
74 let bob: User;
75 let repo: Repo;
76 let n = 0;
77
78 beforeAll(async () => {
79 dataDir = await mkdtemp(join(tmpdir(), "forge-vision-land-"));
80 app = createApp({ dataDir, port: 0 });
81 const made = await Promise.all(
82 ["alice", "bob"].map((username) => signUp(app, { username, email: `${username}@forge.test`, password: "a-long-passphrase" })),
83 );
84 if (!made.every((m) => m.ok)) throw new Error("sign-up failed");
85 [owner, bob] = made.map((m) => (m.ok ? m.value : undefined!)) as [User, User];
86 promote(app.db, owner, bob);
87 expect(createOrg(app, owner, "acme").ok).toBe(true);
88 const acme = getOrg(app.db, "acme")!;
89 const devs = createTeam(app, owner, acme, "devs", "write");
90 if (!devs.ok) throw new Error("team");
91 expect(addTeamMember(app, owner, acme, devs.value, "bob").ok).toBe(true);
92 const r = await createRepo(app, owner, { owner: "acme", name: "site", visibility: "public" });
93 if (!r.ok) throw new Error(String(r.error));
94 repo = r.value;
95 expect((await commitToBranch(app, owner, repo, { message: "seed", files: [{ path: "docs/VISION.md", content: MD }] })).ok).toBe(true);
96 servers = await serve(app);
97 }, 30_000);
98
99 afterAll(async () => {
100 servers.stop();
101 app.close();
102 await rm(dataDir, { recursive: true, force: true });
103 });
104
105 const direction = (): string => getRepoById(app.db, repo.id)!.autonomyDirection;
106 const setDirection = (text: string): void => {
107 updateRepoSettings(app.db, getRepoById(app.db, repo.id)!, { autonomyDirection: text });
108 };
109
110 /** A pull that changes VISION.md; marked as a vision pull by its placement unless `marked` is false. */
111 async function visionPull(marked: boolean, branch = `vision-${++n}`) {
112 const md = MD.replace("Done means reachable.", `Done means reachable ${n}.`);
113 expect((await commitToBranch(app, owner, repo, { branch, message: "v", files: [{ path: "docs/VISION.md", content: md }] })).ok).toBe(true);
114 const pull = await openPull(app, owner, repo, { title: "Note a change of direction", body: "", baseBranch: "main", headBranch: branch });
115 if (!pull.ok) throw new Error(String(pull.error));
116 if (marked)
117 savePlacement(app.db, {
118 thoughtId: `th${n}`, kind: "vision", reply: "", asked: false, decidedBy: "t", placedAt: new Date().toISOString(),
119 placed: [{ action: "proposed", repo: "acme/site", number: pull.value.number, pull: pull.value.number }],
120 });
121 return pull.value;
122 }
123
124 test("an admin's merge writes the block, keeps the owner's words either side, and says so in settings_updated", async () => {
125 setDirection(`Ship small.\n\n${BLOCK_START}\nold\n${BLOCK_END}\n\nNever page me.`);
126 const pull = await visionPull(true);
127 expect((await mergePull(app, owner, repo, pull, "squash")).ok).toBe(true);
128 const text = direction();
129 expect(text.startsWith(`Ship small.\n\n${BLOCK_START}\nPurpose: The forge is a social place`)).toBe(true);
130 expect(text.endsWith(`${BLOCK_END}\n\nNever page me.`)).toBe(true);
131 expect(text).toContain(`Done means reachable ${n}.`);
132 expect(text.length).toBeLessThanOrEqual(DIRECTION_LIMIT);
133 const e = listEvents(app.db, { repoId: repo.id, kind: "repo.settings_updated", limit: 1 })[0]!;
134 expect(e.payload["changed"]).toEqual(["autonomyDirection"]);
135 expect(e.actorId).toBe(owner.id);
136 });
137
138 test("a branch named vision-… is not a vision pull", async () => {
139 setDirection("mine");
140 const pull = await visionPull(false);
141 expect((await mergePull(app, owner, repo, pull, "squash")).ok).toBe(true);
142 expect(direction()).toBe("mine");
143 });
144
145 test("an orphan start marker leaves the direction unchanged", async () => {
146 const orphan = `mine\n${BLOCK_START}\nunfinished`;
147 setDirection(orphan);
148 const pull = await visionPull(true);
149 expect((await mergePull(app, owner, repo, pull, "squash")).ok).toBe(true);
150 expect(direction()).toBe(orphan);
151 });
152
153 test("a writer's merge leaves the direction, comments nobody, and the pull page shows the forge's line", async () => {
154 setDirection("mine");
155 const pull = await visionPull(true);
156 const comments = app.db.query<{ c: number }, []>("SELECT count(*) AS c FROM comment").get()!.c;
157 expect((await mergePull(app, bob, repo, pull, "squash")).ok).toBe(true);
158 expect(direction()).toBe("mine");
159 expect(app.db.query<{ c: number }, []>("SELECT count(*) AS c FROM comment").get()!.c).toBe(comments);
160 const e = listEvents(app.db, { repoId: repo.id, kind: "repo.vision_kept", limit: 1 })[0]!;
161 expect(e.payload["pull"]).toBe(pull.number);
162 expect(e.actorId).toBeNull();
163 const html = await (await fetch(`${servers.url}/acme/site/pulls/${pull.number}`)).text();
164 expect(html).toContain("the forge kept the repository's direction as it was: only an admin&#39;s merge rewrites it");
165 });
166 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs