Skip to content

Commit bfe30df

Browse files
mchwangclaude
andcommitted
F1e: planning API for lane G and feedback from review actions
Planning endpoints over E3's coordinator and the Store: import, suggestion start/read/cancel/apply, each through Store.userAction; E3's settlement writes use the shutdown capability and the coordinator closes at shutdown. Starting a suggestion is refused until a planning provider is injected. ReviewService.planContext builds the trusted plan context from the base tree. Review actions: change notes, segment accept and assign require an actionId and record their feedback event in the same transaction; a later choice links to the earlier event; choice sources are fixed-size fingerprints. The UI sends an actionId per action. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 5d163f2 commit bfe30df

7 files changed

Lines changed: 290 additions & 12 deletions

File tree

‎runner/review.ts‎

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ import { isDeepStrictEqual } from 'node:util';
44
import { Store, type ReviewState, type SnippetReference } from './store.ts';
55
import type { PlanIdentity } from '../core/identity.ts';
66
import { readHistory } from '../git/history.ts';
7+
import { execFileSync } from 'node:child_process';
8+
import { isolatedGitEnvironment } from '../scripts/git-environment.ts';
9+
import type { BaseEntry, PlanContext } from '../core/plan.ts';
710
import { linkHistory } from '../core/linking.ts';
811
import { applyChoices, approvalStates, approveItem, choiceKeys } from '../core/approvals.ts';
912
import type { GhMergeConfig } from '../github/merge.ts';
@@ -87,7 +90,25 @@ export class ReviewService {
8790
const token = createHash('sha256').update(JSON.stringify({ expected, saved, plan, segments })).digest('hex');
8891
return { repository: basename(repository), demo: this.config.demo ?? false, plan, snapshot, expected, token, items, segments, notes, approved: items.filter(item => item.state === 'approved').length };
8992
}
90-
act(input: unknown) {
93+
/** The trusted plan context for import and Apply: base entries from the snapshot's base tree, and the configured path identity. */
94+
planContext(): PlanContext {
95+
const { identity, repository, pathIdentity } = this.config, plan = this.store.getPlan(identity), snapshot = this.store.getSnapshot(identity);
96+
const pathKey = (path: string) => {
97+
if (!pathIdentity.caseSensitive && /[^\x20-\x7e]/.test(path)) throw new Error('Non-ASCII case-insensitive paths require a filesystem-specific identity adapter.');
98+
const normalized = pathIdentity.unicodeNormalization === 'NFC' ? path.normalize('NFC') : path;
99+
return pathIdentity.caseSensitive ? normalized : normalized.toLowerCase();
100+
};
101+
const listing = execFileSync('git', ['-c', 'core.hooksPath=/dev/null', 'ls-tree', '-rz', snapshot.base], { cwd: repository, env: isolatedGitEnvironment(), encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
102+
const baseEntries: BaseEntry[] = listing.split('\0').filter(Boolean).map(record => {
103+
const split = record.indexOf('\t'), [mode, , oid] = record.slice(0, split).split(' '), path = record.slice(split + 1);
104+
if (mode === '160000') return { path, kind: 'gitlink' };
105+
if (mode === '120000') return { path, kind: 'symlink', target: execFileSync('git', ['cat-file', 'blob', oid!], { cwd: repository, env: isolatedGitEnvironment(), encoding: 'utf8' }) };
106+
return { path, kind: 'file' };
107+
});
108+
return { identity, issue: plan.issue, baseEntries, pathKey, allowedCommands: [] };
109+
}
110+
/** With an actionId (inside Store.userAction), feedback-producing actions record their event in the same transaction. */
111+
act(input: unknown, actionId?: string) {
91112
if (!input || typeof input !== 'object') throw new Error('Invalid review command.');
92113
const command = input as Record<string, unknown>;
93114
const view = this.load();
@@ -105,6 +126,11 @@ export class ReviewService {
105126
const item = command.action === 'assign' && typeof command.item === 'string' ? command.item : null;
106127
const storedKey = choiceKeys(view.segments, identity)[view.segments.indexOf(segment)]!;
107128
this.store.saveReview(identity, view.expected, [], [{ key: storedKey, action: command.action, item }]);
129+
// Choice keys embed segment content and are unbounded; the event's source is a stable fixed-size fingerprint of the key.
130+
const sourceRef = `choice:${createHash('sha256').update(storedKey).digest('hex')}`;
131+
if (actionId) this.store.recordFeedback(identity, actionId, command.action === 'assign'
132+
? { kind: 'segment-assign', item, sourceRef, supersedeLatest: true }
133+
: { kind: 'segment-accept', sourceRef, supersedeLatest: true });
108134
} else if (command.action === 'note' && typeof command.item === 'string' && typeof command.text === 'string' && (command.kind === 'question' || command.kind === 'change')) {
109135
let reference: SnippetReference | undefined;
110136
if (command.reference !== undefined) {
@@ -121,6 +147,7 @@ export class ReviewService {
121147
reference = { key: segment.key, path: segment.operation === '-' ? segment.oldPath ?? segment.path : segment.path, side: segment.operation === '+' ? 'new' : 'old', start, end, text, head: view.snapshot.head, base: view.snapshot.base };
122148
}
123149
createdNoteId = this.store.addReviewNote(identity, view.expected, command.item, command.kind, command.text, reference).id;
150+
if (actionId && command.kind === 'change') this.store.recordFeedback(identity, actionId, { kind: 'change-request', item: command.item, text: command.text.trim(), sourceRef: createdNoteId });
124151
} else throw new Error('Unknown review command.');
125152
return { ...this.load(), createdNoteId };
126153
}

‎runner/store.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -760,13 +760,16 @@ export class Store {
760760
}
761761
}
762762
/** Append one feedback event. Call inside userAction so the event and its action share one transaction. */
763-
recordFeedback(identity: PlanIdentity, actionId: string, event: { kind: Exclude<FeedbackKind, 'task-closed'>; item?: string | null; text?: string | null; sourceRef: string; supersedes?: string | null }): FeedbackEvent {
763+
recordFeedback(identity: PlanIdentity, actionId: string, event: { kind: Exclude<FeedbackKind, 'task-closed'>; item?: string | null; text?: string | null; sourceRef: string; supersedes?: string | null; supersedeLatest?: boolean }): FeedbackEvent {
764764
assertUuidV4(actionId, 'Action ID');
765765
if (this.#depth === 0) throw new Error('Feedback events are written inside their user action.');
766766
if (!FEEDBACK_KINDS.includes(event.kind) || event.kind === ('task-closed' as FeedbackKind)) throw new GuardRefusal('Invalid feedback kind.');
767767
if (event.text != null && (typeof event.text !== 'string' || event.text.length > 4000)) throw new GuardRefusal('Feedback text is limited to 4000 characters.');
768768
if (typeof event.sourceRef !== 'string' || !event.sourceRef || event.sourceRef.length > 200) throw new GuardRefusal('Invalid feedback source.');
769769
const key = identityKey(identity), plan = this.#current(key);
770+
// A changed segment choice links to the latest earlier event for the same choice key.
771+
if (event.supersedeLatest && event.supersedes == null)
772+
event = { ...event, supersedes: (this.#get("SELECT id FROM feedback_events WHERE plan_key=? AND source_ref=? AND kind IN ('segment-accept','segment-assign') ORDER BY rowid DESC LIMIT 1", key, event.sourceRef)?.id as string | undefined) ?? null };
770773
if (event.supersedes != null && !this.#get('SELECT 1 FROM feedback_events WHERE plan_key=? AND id=? AND source_ref=?', key, event.supersedes, event.sourceRef))
771774
throw new GuardRefusal('A superseded event must belong to the same source.');
772775
const id = randomUUID(), createdAt = new Date().toISOString();

‎test/browser/review.spec.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { randomUUID } from 'node:crypto';
12
import { test, expect } from '@playwright/test';
23
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
34
import { tmpdir } from 'node:os';
@@ -138,7 +139,7 @@ test('shows an honest history error and keeps markup in notes as text',async({pa
138139
test('can assign a large foreign change without sending its content back in the command',async({request})=>{
139140
const repository=app.service.config.repository;writeFileSync(join(repository,'debug.log'),'x'.repeat(20000)+'\n');execFileSync('git',['-c','core.hooksPath=/dev/null','commit','-am','Large foreign change'],{cwd:repository,stdio:'pipe'});
140141
const base=app.url.split('#')[0]!,headers={'x-codeboost-token':app.token};const view=await(await request.get(base+'api/review',{headers})).json();const segment=view.segments.find((s:{content:string;row:string})=>s.row==='Unplanned'&&s.content.length>19000);
141-
const response=await request.post(base+'api/action',{headers:{...headers,'Content-Type':'application/json'},data:{action:'assign',item:'P1',key:segment.key,token:view.token}});
142+
const response=await request.post(base+'api/action',{headers:{...headers,'Content-Type':'application/json'},data:{action:'assign',item:'P1',key:segment.key,token:view.token,actionId:randomUUID()}});
142143
expect(response.status()).toBe(200);
143144
});
144145
test('shows bounded raster previews and byte sizes for file-change cards',async({page})=>{

0 commit comments

Comments
 (0)