之前我們已經講過,編纂的內容規則不寫在程式裡,寫在知識庫的 schema/ 那一層,使用者自己就能改。
那第一份規則得有人給。新註冊的人打開一座空的知識庫,schema/ 裡什麼都沒有,agent 呼叫 get_instructions 拿到的是「這一層還沒有任何規則頁」。技術上沒錯,體驗上等於沒做。
這一篇講模版,以及一個我一開始沒想到的問題:規則改進了,已經開好的工作區怎麼辦。
先界定範圍。這裡的模版只影響知識庫的內容,不影響任何介面。選研究者模版不會讓你多出一個分頁或少掉一顆按鈕,它只是在你的 schema/ 與 wiki/ 裡預先放幾頁。
這個限制是刻意的。一旦模版可以改介面,它就變成「方案」或「版本」,每加一種就要多測一輪;而現在它就是一疊 Markdown 檔案,多一種模版等於多一個資料夾。
內建的有四種:通用、研究者、專案管理、讀書。每種各有中英兩份。
for (const path of files) {
const content = await readFile(join(dir, path), 'utf8');
try {
await createNote(workspaceId, path, content, actor);
created.push(path);
} catch (e) {
if (e instanceof NoteError && e.code === 'CONFLICT') skipped.push(path);
else throw e;
}
}
await recordApplied(workspaceId, info, lang, dir, files);
它用的就是 create_note 背後的 createNote,路徑已存在就拋 CONFLICT。這裡把那個錯誤當成正常結果接住,記進 skipped。
所以套用模版永遠不會蓋掉任何東西。這帶來兩個好處:同一個模版可以重複套用(第二次全部 skip),而且兩個模版可以疊起來用(研究者疊在通用上面,重複的頁保留先來的那份)。
回傳值裡 created 跟 skipped 都給出去,前端才能告訴使用者「已建立 6 頁,跳過已存在的 2 頁」。
上線一段時間之後,我改寫了通用模版的規則頁,補上三層架構的說明、index 與 log 的角色、三個操作的定義。新註冊的人拿到新版,已經開好的工作區什麼都沒發生,因為套用是加不覆蓋,那幾頁早就存在,再套一次全部 skip。
舊使用者的 agent 行為跟新使用者不一樣,而這種差異在測試環境完全看不出來,測試每次都是從空的工作區開始。
直覺的修法是「提供一顆更新按鈕,把規則頁換成新版」。但這會直接踩到另一件事:有些人已經改過那一頁了。那是整個產品的賣點:規則是你的,你自己能改。一顆會覆蓋的按鈕,等於把賣點收回去。
解法是套用的時候把交付的內容記下來:
async function recordApplied(ws, info, lang, dir, files): Promise<void> {
const hashes: Record<string, string> = {}, contents: Record<string, string> = {};
for (const path of files.filter(f => f.startsWith('schema/'))) {
const text = await readFile(join(dir, path), 'utf8');
hashes[path] = sha(text);
contents[path] = text; // the base for a later three-way merge
}
if (Object.keys(hashes).length === 0) return;
await pool.query(
`INSERT INTO template_applied (workspace_id, template_id, lang, version, files, contents)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (workspace_id, template_id) DO UPDATE
SET lang = EXCLUDED.lang, version = EXCLUDED.version, files = EXCLUDED.files,
contents = EXCLUDED.contents, applied_at = now()`,
[ws, info.id, lang, info.version ?? 1, JSON.stringify(hashes), JSON.stringify(contents)]);
}
記的是當初交出去的那幾頁:雜湊用來判斷有沒有被改過,整份內容留著當之後三方合併的共同祖先,再加上模版版本號。只記 schema/ 底下的,因為那是 agent 會讀的規則,也是唯一可能需要更新的東西。wiki/ 的起始頁是給使用者寫的,我沒有立場去更新它。
有了這份紀錄,就分得出四種狀態:
| 現在的內容 | 判斷 | 能不能動 |
|---|---|---|
| 跟交付時的雜湊一樣 | untouched |
可以換成新版,不會失去任何東西 |
| 改過,但跟新版合得起來 | merged |
可以寫合併後的結果 |
| 改過,而且合不起來 | edited |
不能碰,只能顯示差異 |
| 頁面不見了 | missing |
可以補回來 |
if (!note) { pages.push({ path, state: 'missing', current: '', next, merged: null }); continue; }
if (sha(note.content_md) === sha(next)) continue; // already identical to the new version
if (sha(note.content_md) === deliveredHash) { pages.push({ path, state: 'untouched', current: note.content_md, next, merged: null }); continue; }
// Edited. With the delivered text as the base, their edits and ours can often both be kept.
const base = r.contents?.[path];
const m = base ? merge3(base, note.content_md, next) : { clean: false, text: null, conflicts: 1 };
pages.push({ path, state: m.clean ? 'merged' : 'edited', current: note.content_md, next, merged: m.text });
merged 是後來才加的(commit 83d693bf)。原本只有「沒動過」跟「改過」兩種,而「改過」的結局是永遠拿不到新版。使用者只是在規則頁尾巴加了一行自己的慣例,從此所有改進都與他無關。這樣太重了。
有了交付當下的完整內容當共同祖先,這就是一個標準的三方合併:我們改的、他改的、共同的起點。只有雙方都動到同一個區域才算衝突。
而使用者自己寫的規則頁(沒有任何交付紀錄的那些)根本不會出現在這份清單裡。系統對它們沒有意見。
上面那行 await recordApplied(workspaceId, info, lang, dir, files) 傳的是 files,也就是這個模版的全部檔案,不是剛剛真的建立的 created。
大部分情況下沒差,但疊模版的時候就有差了。研究者疊在通用上面,兩邊都有 schema/instructions.md,這一頁會被 skip,使用者看到的是通用版的內容,交付紀錄裡記的卻是研究者版的雜湊。下次研究者模版改版,這一頁跟交付雜湊對不起來,於是走進合併那條路,結局要看兩份模版有多像:合不起來判成 edited,這一頁從此收不到更新;合得起來判成 merged,研究者版的新內容被縫進使用者以為是通用版的那一頁。這兩份規則頁本來就有大半相同,三十五行裡二十四行一字不差,所以第二種結局一點都不難發生。
寫這篇的時候才發現這件事,repo 裡目前就是這樣。修法不難,改傳 created 就好,那一頁本來就歸先來的模版管。但它說明了一件事:這種「記指紋」的機制,記錯的時候不會有任何警訊,只是安靜地把之後的每個判斷都帶偏。
for (const page of updates.pages) {
if (page.state === 'edited' && mode === 'safe') { kept.push(page.path); continue; }
if (page.state === 'missing') { await createNote(ws, page.path, page.next, actor); updated.push(page.path); continue; }
const note = await readNote(ws, page.path);
const text = page.state === 'merged' && page.merged !== null ? page.merged : page.next;
await updateNote(ws, page.path, text, note.version, actor);
if (page.state === 'merged') merged.push(page.path);
else if (page.state === 'edited') overwritten.push(page.path);
else updated.push(page.path);
}
更新走的是 updateNote 帶 if_version,也就是之前我們已經講過的樂觀鎖,所以就算使用者正在編輯那一頁也不會被蓋掉。
有兩個模式。safe 寫沒動過的跟合得起來的,其餘原封不動退回去;overwrite 連衝突的那幾頁也換成新版。後者不是破壞性的操作,因為每一次寫入都是一個新版本,舊的內容留在頁面歷史裡,一鍵就回得來。但這件事不講,使用者就不知道自己有退路,所以覆蓋按鈕旁邊寫著「覆蓋之後你原本的內容會留在該頁的版本歷史裡,隨時可以看差異並一鍵復原」(RuleUpdates.tsx:25)。
merge3 有一個跟一般三方合併不一樣的決定:
/* Conflict markers are deliberately never produced. The result of a merge is a page an agent reads as instructions,
and `<<<<<<<` in that page would be read as part of the rules. A conflict means "do not merge", not "merge messily". */
這就是這個產品跟一般編輯器的差別。Git 把衝突標記塞進檔案裡是安全的,因為讀那個檔案的是人,人看到 <<<<<<< 就知道要處理。但這裡讀規則頁的是 agent,它會把那七個角括號連同兩邊的版本一起當成規則的一部分。
所以合不起來就回報合不起來,不交出一份髒的結果。
await pool.query(`UPDATE template_applied SET version = $3, files = $4, contents = $5, applied_at = now() WHERE workspace_id = $1 AND template_id = $2`,
[ws, templateId, kept.length ? updates.appliedVersion : updates.currentVersion, JSON.stringify(files), JSON.stringify(contents)]);
兩個細節都是為了「下一次還能正確判斷」而存在。
只要有任何一頁被留下(kept.length),版本號就不前進。 那一頁的更新還沒處理完,如果版本號跳上去,pendingRuleUpdates 的 currentVersion <= r.version 就會讓整批更新從清單上消失,使用者再也看不到它。
只有真的被寫入的頁才換新的基準。 被留下的那一頁保留當初交付的文字當共同祖先。如果順手把它的基準換成使用者現在的內容,下次比對就會判成「沒動過」,然後直接覆蓋掉他的修改;而且未來的合併也失去了那個起點。
版本號這個欄位是跟這套機制一起出生的,四個模版從 1 開始。它第一次真的派上用場,是之前我們已經講過的那次規則頁修改:補上 create_note 與 NOT_FOUND 那幾句的同時,四個模版一起加到 2,已套用的工作區第一次收到更新通知。
一顆「更新規則」的按鈕,暴力做法是十行,這個版本大概一百行。多出來的九十行全部花在同一件事上:證明這一頁使用者沒有動過。
答案只能是「跟交付的那一刻比對」,而那要求你在交付的當下就把證據存下來。這類需求幾乎不可能事後補。如果當初沒記雜湊,現在你手上只有「使用者的內容」跟「最新的模版」,永遠分不出中間那一頁是被改過還是從來沒改。
模版要能更新,就得在交出去的那一刻留下證據;事後補不回來,因為那時候手上只剩使用者的版本跟最新的版本,中間那一份已經沒有了。疊模版那個洞也是這個形狀:記錯不會發出任何聲音,它只是讓某一頁從此收不到更新,或者收下不屬於它的內容。