mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
feat: surface linked repo changes
This commit is contained in:
parent
53fb175855
commit
56a4e97871
12 changed files with 777 additions and 9 deletions
219
apps/daemon/src/repo-changes.ts
Normal file
219
apps/daemon/src/repo-changes.ts
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
import { execFile as execFileCallback } from 'node:child_process';
|
||||
|
||||
import type {
|
||||
LinkedRepoChangeDirectorySummary,
|
||||
LinkedRepoChangeStatus,
|
||||
LinkedRepoChangeSummary,
|
||||
} from '@open-design/contracts';
|
||||
|
||||
const DEFAULT_GIT_TIMEOUT_MS = 2_000;
|
||||
const DEFAULT_MAX_STATUS_LINES = 120;
|
||||
const DEFAULT_MAX_DIFF_STAT_CHARS = 8_000;
|
||||
|
||||
export interface LinkedRepoSnapshotDir {
|
||||
path: string;
|
||||
status: LinkedRepoChangeStatus;
|
||||
branch: string | null;
|
||||
headSha: string | null;
|
||||
statusLines: string[];
|
||||
statusLineCount: number;
|
||||
untrackedFileCount: number;
|
||||
statusTruncated?: boolean;
|
||||
diffStat: string | null;
|
||||
diffStatTruncated?: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface LinkedRepoSnapshot {
|
||||
generatedAt: number;
|
||||
linkedDirs: LinkedRepoSnapshotDir[];
|
||||
}
|
||||
|
||||
export interface RunGitResult {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
export type RunGit = (dir: string, args: string[]) => Promise<RunGitResult>;
|
||||
|
||||
export interface CaptureLinkedRepoSnapshotOptions {
|
||||
runGit?: RunGit;
|
||||
maxStatusLines?: number;
|
||||
maxDiffStatChars?: number;
|
||||
}
|
||||
|
||||
export async function captureLinkedRepoSnapshot(
|
||||
linkedDirs: string[],
|
||||
options: CaptureLinkedRepoSnapshotOptions = {},
|
||||
): Promise<LinkedRepoSnapshot> {
|
||||
const runGit = options.runGit ?? defaultRunGit;
|
||||
const maxStatusLines = options.maxStatusLines ?? DEFAULT_MAX_STATUS_LINES;
|
||||
const maxDiffStatChars = options.maxDiffStatChars ?? DEFAULT_MAX_DIFF_STAT_CHARS;
|
||||
const uniqueDirs = Array.from(new Set(linkedDirs.filter((dir) => typeof dir === 'string' && dir.trim())));
|
||||
const dirs = await Promise.all(
|
||||
uniqueDirs.map((dir) => captureLinkedRepoDir(dir, runGit, maxStatusLines, maxDiffStatChars)),
|
||||
);
|
||||
return {
|
||||
generatedAt: Date.now(),
|
||||
linkedDirs: dirs,
|
||||
};
|
||||
}
|
||||
|
||||
export async function captureLinkedRepoChangeSummary(
|
||||
before: LinkedRepoSnapshot,
|
||||
options: CaptureLinkedRepoSnapshotOptions = {},
|
||||
): Promise<LinkedRepoChangeSummary> {
|
||||
const after = await captureLinkedRepoSnapshot(
|
||||
before.linkedDirs.map((dir) => dir.path),
|
||||
options,
|
||||
);
|
||||
return summarizeLinkedRepoChanges(before, after);
|
||||
}
|
||||
|
||||
export function summarizeLinkedRepoChanges(
|
||||
before: LinkedRepoSnapshot,
|
||||
after: LinkedRepoSnapshot,
|
||||
): LinkedRepoChangeSummary {
|
||||
const beforeByPath = new Map(before.linkedDirs.map((dir) => [dir.path, dir]));
|
||||
const linkedDirs: LinkedRepoChangeDirectorySummary[] = after.linkedDirs.map((dir) => {
|
||||
const baseline = beforeByPath.get(dir.path);
|
||||
const baselineLines = new Set(baseline?.statusLines ?? []);
|
||||
const newStatusLineCount = dir.statusLines.filter((line) => !baselineLines.has(line)).length;
|
||||
const preexistingChangeCount = Math.min(
|
||||
dir.statusLineCount,
|
||||
Math.max(0, dir.statusLineCount - newStatusLineCount),
|
||||
);
|
||||
return {
|
||||
path: dir.path,
|
||||
status: dir.status,
|
||||
branch: dir.branch,
|
||||
headSha: dir.headSha,
|
||||
changedFileCount: dir.statusLineCount,
|
||||
newStatusLineCount,
|
||||
preexistingChangeCount,
|
||||
untrackedFileCount: dir.untrackedFileCount,
|
||||
statusLines: dir.statusLines,
|
||||
...(dir.statusTruncated ? { statusTruncated: true } : {}),
|
||||
diffStat: dir.diffStat,
|
||||
...(dir.diffStatTruncated ? { diffStatTruncated: true } : {}),
|
||||
error: dir.error,
|
||||
};
|
||||
});
|
||||
const changedFileCount = linkedDirs.reduce((sum, dir) => sum + dir.changedFileCount, 0);
|
||||
const newStatusLineCount = linkedDirs.reduce((sum, dir) => sum + dir.newStatusLineCount, 0);
|
||||
const preexistingChangeCount = linkedDirs.reduce((sum, dir) => sum + dir.preexistingChangeCount, 0);
|
||||
const untrackedFileCount = linkedDirs.reduce((sum, dir) => sum + dir.untrackedFileCount, 0);
|
||||
return {
|
||||
generatedAt: after.generatedAt,
|
||||
linkedDirCount: linkedDirs.length,
|
||||
changedFileCount,
|
||||
newStatusLineCount,
|
||||
preexistingChangeCount,
|
||||
untrackedFileCount,
|
||||
hasChanges: changedFileCount > 0,
|
||||
linkedDirs,
|
||||
};
|
||||
}
|
||||
|
||||
async function captureLinkedRepoDir(
|
||||
dir: string,
|
||||
runGit: RunGit,
|
||||
maxStatusLines: number,
|
||||
maxDiffStatChars: number,
|
||||
): Promise<LinkedRepoSnapshotDir> {
|
||||
try {
|
||||
await runGit(dir, ['rev-parse', '--show-toplevel']);
|
||||
} catch (err) {
|
||||
return emptySnapshotDir(dir, 'not_git', errorMessage(err));
|
||||
}
|
||||
|
||||
try {
|
||||
const [branch, headSha, status, diffStat] = await Promise.all([
|
||||
runGit(dir, ['branch', '--show-current']).catch(() => ({ stdout: '', stderr: '' })),
|
||||
runGit(dir, ['rev-parse', '--short', 'HEAD']).catch(() => ({ stdout: '', stderr: '' })),
|
||||
runGit(dir, ['status', '--short', '--untracked-files=all']),
|
||||
runGit(dir, ['diff', '--stat', '--']).catch(() => ({ stdout: '', stderr: '' })),
|
||||
]);
|
||||
const allStatusLines = splitLines(status.stdout);
|
||||
const statusLines = allStatusLines.slice(0, maxStatusLines);
|
||||
const rawDiffStat = diffStat.stdout.trim();
|
||||
const diffStatTruncated = rawDiffStat.length > maxDiffStatChars;
|
||||
const statusValue: LinkedRepoChangeStatus = allStatusLines.length > 0 ? 'changed' : 'clean';
|
||||
return {
|
||||
path: dir,
|
||||
status: statusValue,
|
||||
branch: branch.stdout.trim() || null,
|
||||
headSha: headSha.stdout.trim() || null,
|
||||
statusLines,
|
||||
statusLineCount: allStatusLines.length,
|
||||
untrackedFileCount: allStatusLines.filter((line) => line.startsWith('??')).length,
|
||||
...(allStatusLines.length > statusLines.length ? { statusTruncated: true } : {}),
|
||||
diffStat: rawDiffStat
|
||||
? rawDiffStat.slice(0, maxDiffStatChars)
|
||||
: null,
|
||||
...(diffStatTruncated ? { diffStatTruncated: true } : {}),
|
||||
error: null,
|
||||
};
|
||||
} catch (err) {
|
||||
return emptySnapshotDir(dir, 'error', errorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
function emptySnapshotDir(
|
||||
dir: string,
|
||||
status: LinkedRepoChangeStatus,
|
||||
error: string | null,
|
||||
): LinkedRepoSnapshotDir {
|
||||
return {
|
||||
path: dir,
|
||||
status,
|
||||
branch: null,
|
||||
headSha: null,
|
||||
statusLines: [],
|
||||
statusLineCount: 0,
|
||||
untrackedFileCount: 0,
|
||||
diffStat: null,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
function splitLines(value: string): string[] {
|
||||
return value
|
||||
.split(/\r?\n/g)
|
||||
.map((line) => line.trimEnd())
|
||||
.filter((line) => line.trim().length > 0);
|
||||
}
|
||||
|
||||
function errorMessage(err: unknown): string {
|
||||
if (!err) return 'Unknown git error.';
|
||||
if (err instanceof Error && err.message.trim()) return err.message.trim();
|
||||
return String(err);
|
||||
}
|
||||
|
||||
function defaultRunGit(dir: string, args: string[]): Promise<RunGitResult> {
|
||||
const gitArgs = ['-c', 'core.quotepath=false', '-C', dir, ...args];
|
||||
return new Promise((resolve, reject) => {
|
||||
execFileCallback(
|
||||
'git',
|
||||
gitArgs,
|
||||
{
|
||||
encoding: 'utf8',
|
||||
timeout: DEFAULT_GIT_TIMEOUT_MS,
|
||||
maxBuffer: 512 * 1024,
|
||||
windowsHide: true,
|
||||
},
|
||||
(err, stdout, stderr) => {
|
||||
const result = {
|
||||
stdout: typeof stdout === 'string' ? stdout : String(stdout ?? ''),
|
||||
stderr: typeof stderr === 'string' ? stderr : String(stderr ?? ''),
|
||||
};
|
||||
if (err) {
|
||||
const message = result.stderr.trim() || result.stdout.trim() || err.message;
|
||||
reject(new Error(message));
|
||||
return;
|
||||
}
|
||||
resolve(result);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -75,6 +75,7 @@ export function createChatRunService({
|
|||
signal: null,
|
||||
error: null,
|
||||
errorCode: null,
|
||||
repoChanges: null,
|
||||
cancelRequested: false,
|
||||
eventsLogPath: runsLogDir ? path.join(runsLogDir, id, 'events.jsonl') : null,
|
||||
eventsLogStream: null,
|
||||
|
|
@ -155,8 +156,14 @@ export function createChatRunService({
|
|||
eventsLogPath: run.eventsLogPath ?? null,
|
||||
mediaExecution: run.mediaExecution ?? normalizeMediaExecutionPolicyForRun(null),
|
||||
toolBundle: summarizeRunToolBundle(run.toolBundle),
|
||||
repoChanges: run.repoChanges ?? null,
|
||||
});
|
||||
|
||||
const setRepoChanges = (run, repoChanges) => {
|
||||
run.repoChanges = repoChanges ?? null;
|
||||
run.updatedAt = Date.now();
|
||||
};
|
||||
|
||||
const finish = (run, status, code: number | null = null, signal: string | null = null) => {
|
||||
if (TERMINAL_RUN_STATUSES.has(run.status)) return;
|
||||
run.status = status;
|
||||
|
|
@ -338,6 +345,7 @@ export function createChatRunService({
|
|||
fail,
|
||||
drop,
|
||||
statusBody,
|
||||
setRepoChanges,
|
||||
isTerminal(status) {
|
||||
return TERMINAL_RUN_STATUSES.has(status);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -236,6 +236,10 @@ import { subscribe as subscribeFileEvents } from './project-watchers.js';
|
|||
import { renderDesignSystemPreview } from './design-system-preview.js';
|
||||
import { renderDesignSystemShowcase } from './design-system-showcase.js';
|
||||
import { createChatRunService } from './runs.js';
|
||||
import {
|
||||
captureLinkedRepoChangeSummary,
|
||||
captureLinkedRepoSnapshot,
|
||||
} from './repo-changes.js';
|
||||
import { deriveRunErrorCode, runResultFromStatus } from './run-result.js';
|
||||
import {
|
||||
countDesignSystemPreviewModules,
|
||||
|
|
@ -10856,6 +10860,9 @@ export async function startServer({
|
|||
const v = validateLinkedDirs(projectRecord.metadata.linkedDirs);
|
||||
return v.dirs ?? [];
|
||||
})();
|
||||
const linkedRepoBaseline = linkedDirs.length > 0
|
||||
? await captureLinkedRepoSnapshot(linkedDirs)
|
||||
: null;
|
||||
const cwdHint = cwd
|
||||
? `\n\nYour working directory: ${cwd}\nWrite project files relative to it (e.g. \`index.html\`, \`assets/x.png\`). The user can browse those files in real time.${filesListBlock}`
|
||||
: '';
|
||||
|
|
@ -12482,15 +12489,36 @@ export async function startServer({
|
|||
design.runs.finish(run, 'failed', 1, null);
|
||||
});
|
||||
child.on('close', async (code, signal) => {
|
||||
let linkedRepoChangesCaptured = false;
|
||||
const finishClosedRun = async (status, finalCode, finalSignal) => {
|
||||
if (!linkedRepoChangesCaptured && linkedRepoBaseline) {
|
||||
linkedRepoChangesCaptured = true;
|
||||
try {
|
||||
const summary = await captureLinkedRepoChangeSummary(linkedRepoBaseline);
|
||||
const hasRelevantRepoSignal =
|
||||
summary.hasChanges ||
|
||||
summary.linkedDirs.some((dir) => dir.status === 'error');
|
||||
if (hasRelevantRepoSignal) {
|
||||
design.runs.setRepoChanges(run, summary);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
'[repo-changes] failed to capture linked repo summary:',
|
||||
err && err.message ? err.message : err,
|
||||
);
|
||||
}
|
||||
}
|
||||
design.runs.finish(run, status, finalCode, finalSignal);
|
||||
};
|
||||
try {
|
||||
clearInactivityWatchdog();
|
||||
revokeToolToken('child_exit');
|
||||
unregisterChatAgentEventSink();
|
||||
if (acpSession?.hasFatalError()) {
|
||||
return design.runs.finish(run, 'failed', code ?? 1, signal ?? null);
|
||||
return finishClosedRun('failed', code ?? 1, signal ?? null);
|
||||
}
|
||||
if (agentStreamError) {
|
||||
return design.runs.finish(run, 'failed', code ?? 1, signal ?? null);
|
||||
return finishClosedRun('failed', code ?? 1, signal ?? null);
|
||||
}
|
||||
if (
|
||||
code !== 0 &&
|
||||
|
|
@ -12502,7 +12530,7 @@ export async function startServer({
|
|||
);
|
||||
if (amrFailure) {
|
||||
sendAmrAccountFailure(amrFailure);
|
||||
return design.runs.finish(run, 'failed', code ?? 1, signal ?? null);
|
||||
return finishClosedRun('failed', code ?? 1, signal ?? null);
|
||||
}
|
||||
}
|
||||
const authFailure = classifyAgentAuthFailure(
|
||||
|
|
@ -12515,7 +12543,7 @@ export async function startServer({
|
|||
authFailure.message ?? cursorAuthGuidance(),
|
||||
{ retryable: true },
|
||||
));
|
||||
return design.runs.finish(run, 'failed', code ?? 1, signal ?? null);
|
||||
return finishClosedRun('failed', code ?? 1, signal ?? null);
|
||||
}
|
||||
}
|
||||
// Empty-output guard: a clean `code === 0` exit with no visible
|
||||
|
|
@ -12532,7 +12560,7 @@ export async function startServer({
|
|||
'Agent completed without producing any output. The model or provider may have returned an empty response — check the agent logs for upstream errors.',
|
||||
{ retryable: true },
|
||||
));
|
||||
return design.runs.finish(run, 'failed', code, signal);
|
||||
return finishClosedRun('failed', code, signal);
|
||||
}
|
||||
if (
|
||||
code === 0 &&
|
||||
|
|
@ -12545,7 +12573,7 @@ export async function startServer({
|
|||
'Plugin authoring ended before generating the required generated-plugin artifacts.',
|
||||
{ retryable: true },
|
||||
));
|
||||
return design.runs.finish(run, 'failed', code, signal);
|
||||
return finishClosedRun('failed', code, signal);
|
||||
}
|
||||
// Plain-stream auth-failure guard: plain adapters (today
|
||||
// antigravity, deepseek's TUI variants) may exit cleanly with
|
||||
|
|
@ -12573,7 +12601,7 @@ export async function startServer({
|
|||
authFailure.message ?? `${def.name} authentication required. Please re-authenticate and retry.`,
|
||||
{ retryable: true },
|
||||
));
|
||||
return design.runs.finish(run, 'failed', 0, signal);
|
||||
return finishClosedRun('failed', 0, signal);
|
||||
}
|
||||
}
|
||||
// Plain-stream empty-output guard: plain agents send raw stdout
|
||||
|
|
@ -12635,7 +12663,7 @@ export async function startServer({
|
|||
msg,
|
||||
{ retryable: true },
|
||||
));
|
||||
return design.runs.finish(run, 'failed', 0, signal);
|
||||
return finishClosedRun('failed', 0, signal);
|
||||
}
|
||||
// ACP agents that don't shut down on stdin.end() (e.g. Devin for
|
||||
// Terminal) are forced to exit via SIGTERM from attachAcpSession after
|
||||
|
|
@ -12770,7 +12798,7 @@ export async function startServer({
|
|||
for (const chunk of plaintextStdoutBuffer) {
|
||||
send('stdout', { chunk });
|
||||
}
|
||||
design.runs.finish(run, status, code, signal);
|
||||
await finishClosedRun(status, code, signal);
|
||||
} finally {
|
||||
// Best-effort cleanup of the per-run agy log file on every close
|
||||
// path — successful, failed, cancelled, or non-zero exit — so
|
||||
|
|
|
|||
107
apps/daemon/tests/repo-changes.test.ts
Normal file
107
apps/daemon/tests/repo-changes.test.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
captureLinkedRepoSnapshot,
|
||||
summarizeLinkedRepoChanges,
|
||||
type LinkedRepoSnapshot,
|
||||
type RunGit,
|
||||
} from '../src/repo-changes.js';
|
||||
|
||||
describe('linked repo change summaries', () => {
|
||||
it('captures git status, diff stat, branch, and head for linked dirs', async () => {
|
||||
const runGit: RunGit = async (_dir, args) => {
|
||||
const command = args.join(' ');
|
||||
if (command === 'rev-parse --show-toplevel') return { stdout: '/repo\n', stderr: '' };
|
||||
if (command === 'branch --show-current') return { stdout: 'main\n', stderr: '' };
|
||||
if (command === 'rev-parse --short HEAD') return { stdout: 'abc1234\n', stderr: '' };
|
||||
if (command === 'status --short --untracked-files=all') {
|
||||
return { stdout: ' M src/app.ts\n?? src/new.ts\n', stderr: '' };
|
||||
}
|
||||
if (command === 'diff --stat --') {
|
||||
return { stdout: ' src/app.ts | 8 +++++---\n 1 file changed, 5 insertions(+), 3 deletions(-)\n', stderr: '' };
|
||||
}
|
||||
throw new Error(`unexpected git command: ${command}`);
|
||||
};
|
||||
|
||||
const snapshot = await captureLinkedRepoSnapshot(['/repo'], { runGit });
|
||||
|
||||
expect(snapshot.linkedDirs[0]).toMatchObject({
|
||||
path: '/repo',
|
||||
status: 'changed',
|
||||
branch: 'main',
|
||||
headSha: 'abc1234',
|
||||
statusLines: [' M src/app.ts', '?? src/new.ts'],
|
||||
statusLineCount: 2,
|
||||
untrackedFileCount: 1,
|
||||
diffStat: 'src/app.ts | 8 +++++---\n 1 file changed, 5 insertions(+), 3 deletions(-)',
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('summarizes new and pre-existing status lines against the baseline', () => {
|
||||
const before: LinkedRepoSnapshot = {
|
||||
generatedAt: 1,
|
||||
linkedDirs: [
|
||||
{
|
||||
path: '/repo',
|
||||
status: 'changed',
|
||||
branch: 'main',
|
||||
headSha: 'abc1234',
|
||||
statusLines: [' M README.md'],
|
||||
statusLineCount: 1,
|
||||
untrackedFileCount: 0,
|
||||
diffStat: 'README.md | 2 ++',
|
||||
error: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
const after: LinkedRepoSnapshot = {
|
||||
generatedAt: 2,
|
||||
linkedDirs: [
|
||||
{
|
||||
path: '/repo',
|
||||
status: 'changed',
|
||||
branch: 'main',
|
||||
headSha: 'abc1234',
|
||||
statusLines: [' M README.md', '?? src/new.ts'],
|
||||
statusLineCount: 2,
|
||||
untrackedFileCount: 1,
|
||||
diffStat: 'README.md | 2 ++\n src/new.ts | 4 ++++',
|
||||
error: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const summary = summarizeLinkedRepoChanges(before, after);
|
||||
|
||||
expect(summary).toMatchObject({
|
||||
generatedAt: 2,
|
||||
linkedDirCount: 1,
|
||||
changedFileCount: 2,
|
||||
newStatusLineCount: 1,
|
||||
preexistingChangeCount: 1,
|
||||
untrackedFileCount: 1,
|
||||
hasChanges: true,
|
||||
});
|
||||
expect(summary.linkedDirs[0]).toMatchObject({
|
||||
changedFileCount: 2,
|
||||
newStatusLineCount: 1,
|
||||
preexistingChangeCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('reports a linked dir as not_git when git cannot read it as a repository', async () => {
|
||||
const runGit: RunGit = async () => {
|
||||
throw new Error('fatal: not a git repository');
|
||||
};
|
||||
|
||||
const snapshot = await captureLinkedRepoSnapshot(['/plain-folder'], { runGit });
|
||||
|
||||
expect(snapshot.linkedDirs[0]).toMatchObject({
|
||||
path: '/plain-folder',
|
||||
status: 'not_git',
|
||||
statusLineCount: 0,
|
||||
error: 'fatal: not a git repository',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -119,6 +119,41 @@ describe('chat run service shutdown', () => {
|
|||
expect(JSON.stringify(status)).not.toContain('server.js');
|
||||
});
|
||||
|
||||
it('stores repo changes on the status body without emitting persistence-owned events', () => {
|
||||
const runs = createRuns();
|
||||
const run = runs.create({ projectId: 'project-1', conversationId: 'conv-a' }) as any;
|
||||
const summary = {
|
||||
generatedAt: 1700000000,
|
||||
linkedDirCount: 1,
|
||||
changedFileCount: 1,
|
||||
newStatusLineCount: 1,
|
||||
preexistingChangeCount: 0,
|
||||
untrackedFileCount: 0,
|
||||
hasChanges: true,
|
||||
linkedDirs: [
|
||||
{
|
||||
path: '/repo/app',
|
||||
status: 'changed',
|
||||
branch: 'main',
|
||||
headSha: 'abc1234',
|
||||
changedFileCount: 1,
|
||||
newStatusLineCount: 1,
|
||||
preexistingChangeCount: 0,
|
||||
untrackedFileCount: 0,
|
||||
statusLines: [' M src/app.ts'],
|
||||
diffStat: 'src/app.ts | 2 ++',
|
||||
error: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
runs.setRepoChanges(run, summary);
|
||||
runs.finish(run, 'succeeded', 0, null);
|
||||
|
||||
expect(runs.statusBody(run).repoChanges).toEqual(summary);
|
||||
expect(run.events.map((event: { event: string }) => event.event)).toEqual(['end']);
|
||||
});
|
||||
|
||||
it('cancels active runs and terminates their child process during daemon shutdown', async () => {
|
||||
const runs = createRuns();
|
||||
const child = new FakeChildProcess({ closeOn: 'SIGTERM' });
|
||||
|
|
|
|||
|
|
@ -28,6 +28,10 @@ import {
|
|||
type TrackingFeedbackRatingWithNone,
|
||||
type TrackingProjectKind,
|
||||
} from "@open-design/contracts/analytics";
|
||||
import type {
|
||||
LinkedRepoChangeDirectorySummary,
|
||||
LinkedRepoChangeSummary,
|
||||
} from "@open-design/contracts";
|
||||
import {
|
||||
splitOnQuestionForms,
|
||||
type QuestionForm,
|
||||
|
|
@ -363,6 +367,7 @@ export function AssistantMessage({
|
|||
),
|
||||
);
|
||||
const fileOps = useMemo(() => deriveFileOps(events), [events]);
|
||||
const linkedRepoChanges = useMemo(() => latestLinkedRepoChanges(events), [events]);
|
||||
const produced = message.producedFiles ?? [];
|
||||
const displayedProduced = useMemo(
|
||||
() =>
|
||||
|
|
@ -521,6 +526,9 @@ export function AssistantMessage({
|
|||
onRequestOpenFile={onRequestOpenFile}
|
||||
/>
|
||||
) : null}
|
||||
{linkedRepoChanges ? (
|
||||
<LinkedRepoChanges summary={linkedRepoChanges} />
|
||||
) : null}
|
||||
{blocks.map((b, i) => {
|
||||
if (b.kind === "text")
|
||||
return (
|
||||
|
|
@ -1395,6 +1403,104 @@ function ProducedFiles({
|
|||
);
|
||||
}
|
||||
|
||||
function LinkedRepoChanges({ summary }: { summary: LinkedRepoChangeSummary }) {
|
||||
const rows = summary.linkedDirs.filter(
|
||||
(dir) => dir.changedFileCount > 0 || dir.status === "error",
|
||||
);
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
const parts = [plural(summary.changedFileCount, "changed file")];
|
||||
if (summary.untrackedFileCount > 0) {
|
||||
parts.push(plural(summary.untrackedFileCount, "untracked file"));
|
||||
}
|
||||
if (summary.preexistingChangeCount > 0) {
|
||||
parts.push(`${summary.preexistingChangeCount} pre-existing`);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="linked-repo-changes" data-testid="linked-repo-changes">
|
||||
<div className="linked-repo-changes__head">
|
||||
<span className="linked-repo-changes__icon" aria-hidden>
|
||||
<Icon name="github" size={14} />
|
||||
</span>
|
||||
<div className="linked-repo-changes__copy">
|
||||
<div className="linked-repo-changes__title">Linked repo changes</div>
|
||||
<div className="linked-repo-changes__summary">{parts.join(" · ")}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="linked-repo-changes__list">
|
||||
{rows.map((dir) => (
|
||||
<LinkedRepoChangeRow key={dir.path} dir={dir} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LinkedRepoChangeRow({ dir }: { dir: LinkedRepoChangeDirectorySummary }) {
|
||||
const label = displayLinkedRepoPath(dir.path);
|
||||
const meta = [
|
||||
dir.branch ? dir.branch : null,
|
||||
dir.headSha ? dir.headSha : null,
|
||||
].filter(Boolean);
|
||||
const visibleStatusLines = dir.statusLines.slice(0, 8);
|
||||
return (
|
||||
<div className="linked-repo-change-row">
|
||||
<div className="linked-repo-change-row__top">
|
||||
<code className="linked-repo-change-row__path" title={dir.path}>
|
||||
{label}
|
||||
</code>
|
||||
{meta.length > 0 ? (
|
||||
<span className="linked-repo-change-row__meta">{meta.join(" · ")}</span>
|
||||
) : null}
|
||||
<span className="linked-repo-change-row__count">
|
||||
{plural(dir.changedFileCount, "file")}
|
||||
</span>
|
||||
</div>
|
||||
{dir.error ? (
|
||||
<div className="linked-repo-change-row__error">{dir.error}</div>
|
||||
) : dir.diffStat ? (
|
||||
<pre className="linked-repo-change-row__stat">
|
||||
{dir.diffStat}
|
||||
{dir.diffStatTruncated ? "\n…truncated" : ""}
|
||||
</pre>
|
||||
) : visibleStatusLines.length > 0 ? (
|
||||
<div className="linked-repo-change-row__status-lines">
|
||||
{visibleStatusLines.map((line) => (
|
||||
<code key={line}>{line}</code>
|
||||
))}
|
||||
{dir.statusTruncated ? <code>…truncated</code> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function latestLinkedRepoChanges(events: AgentEvent[]): LinkedRepoChangeSummary | null {
|
||||
for (let i = events.length - 1; i >= 0; i -= 1) {
|
||||
const event = events[i];
|
||||
if (event?.kind === "repo_changes") {
|
||||
if (
|
||||
event.summary.hasChanges ||
|
||||
event.summary.linkedDirs.some((dir) => dir.status === "error")
|
||||
) {
|
||||
return event.summary;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function displayLinkedRepoPath(value: string): string {
|
||||
const segments = value.replace(/\\/g, "/").split("/").filter(Boolean);
|
||||
return segments.slice(-2).join("/") || value;
|
||||
}
|
||||
|
||||
function plural(count: number, singular: string): string {
|
||||
return `${count} ${singular}${count === 1 ? "" : "s"}`;
|
||||
}
|
||||
|
||||
// Pure renderer. State (busyKey, notices) and the action runner live in the
|
||||
// AssistantMessage parent so they survive the panel's unmount/remount cycle
|
||||
// during install (issue #2876).
|
||||
|
|
|
|||
|
|
@ -641,6 +641,7 @@ async function consumeDaemonRun({
|
|||
// no `status` field still surfaces an error banner.
|
||||
let serverDeclaredSuccess = false;
|
||||
let lastEventId: string | null = initialLastEventId ?? null;
|
||||
let sawRepoChanges = false;
|
||||
let canceled = false;
|
||||
const cancelRun = () => {
|
||||
if (canceled) return;
|
||||
|
|
@ -729,6 +730,12 @@ async function consumeDaemonRun({
|
|||
continue;
|
||||
}
|
||||
|
||||
if (event.event === 'repo_changes') {
|
||||
sawRepoChanges = true;
|
||||
handlers.onAgentEvent({ kind: 'repo_changes', summary: event.data });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (event.event === 'start') {
|
||||
const data = event.data as ChatSseStartPayload;
|
||||
onRunStatus?.('running');
|
||||
|
|
@ -769,6 +776,10 @@ async function consumeDaemonRun({
|
|||
endStatus = status.status;
|
||||
exitCode = status.exitCode ?? null;
|
||||
exitSignal = status.signal ?? null;
|
||||
if (status.repoChanges && !sawRepoChanges) {
|
||||
sawRepoChanges = true;
|
||||
handlers.onAgentEvent({ kind: 'repo_changes', summary: status.repoChanges });
|
||||
}
|
||||
// Fallback REST path: `status.status` is explicitly declared by the
|
||||
// daemon's run record (it passed `isChatRunStatus()` above), so an
|
||||
// explicit `'succeeded'` here is just as authoritative as the SSE
|
||||
|
|
|
|||
|
|
@ -226,6 +226,125 @@
|
|||
padding: 3px 9px;
|
||||
}
|
||||
|
||||
.linked-repo-changes {
|
||||
margin-top: 4px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid color-mix(in oklab, var(--accent) 28%, var(--border));
|
||||
border-radius: var(--radius);
|
||||
background: color-mix(in oklab, var(--accent) 5%, var(--bg-panel));
|
||||
}
|
||||
.linked-repo-changes__head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 9px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.linked-repo-changes__icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 7px;
|
||||
background: var(--bg-subtle);
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.linked-repo-changes__copy {
|
||||
min-width: 0;
|
||||
}
|
||||
.linked-repo-changes__title {
|
||||
font-size: 13px;
|
||||
font-weight: 650;
|
||||
color: var(--text);
|
||||
}
|
||||
.linked-repo-changes__summary {
|
||||
margin-top: 2px;
|
||||
font-size: 11.5px;
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.linked-repo-changes__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.linked-repo-change-row {
|
||||
padding: 7px 8px;
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 7px;
|
||||
background: var(--bg-panel);
|
||||
min-width: 0;
|
||||
}
|
||||
.linked-repo-change-row__top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
.linked-repo-change-row__path {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: var(--mono);
|
||||
font-size: 11.5px;
|
||||
color: var(--text);
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
.linked-repo-change-row__meta,
|
||||
.linked-repo-change-row__count {
|
||||
flex-shrink: 0;
|
||||
font-size: 10.5px;
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.linked-repo-change-row__count {
|
||||
padding: 1px 6px;
|
||||
border-radius: 8px;
|
||||
background: var(--bg-subtle);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.linked-repo-change-row__stat,
|
||||
.linked-repo-change-row__error,
|
||||
.linked-repo-change-row__status-lines {
|
||||
margin-top: 7px;
|
||||
}
|
||||
.linked-repo-change-row__stat {
|
||||
max-height: 140px;
|
||||
overflow: auto;
|
||||
padding: 7px 8px;
|
||||
border-radius: 6px;
|
||||
background: var(--bg-subtle);
|
||||
border: 1px solid var(--border-soft);
|
||||
color: var(--text-muted);
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.linked-repo-change-row__status-lines {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
.linked-repo-change-row__status-lines code {
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background: var(--bg-subtle);
|
||||
color: var(--text-muted);
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.linked-repo-change-row__error {
|
||||
color: #c0392b;
|
||||
font-size: 11.5px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.plugin-action-panel {
|
||||
margin-top: 4px;
|
||||
padding: 12px 14px;
|
||||
|
|
|
|||
|
|
@ -302,3 +302,53 @@ describe('AssistantMessage recovered produced files', () => {
|
|||
expect(screen.getByText('iphone-device-reveal.mp4')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('AssistantMessage linked repo changes', () => {
|
||||
it('shows linked repo changes as run output', () => {
|
||||
render(
|
||||
<AssistantMessage
|
||||
message={baseMessage({
|
||||
content: '',
|
||||
events: [
|
||||
{
|
||||
kind: 'repo_changes',
|
||||
summary: {
|
||||
generatedAt: 1700000000,
|
||||
linkedDirCount: 1,
|
||||
changedFileCount: 2,
|
||||
newStatusLineCount: 2,
|
||||
preexistingChangeCount: 0,
|
||||
untrackedFileCount: 1,
|
||||
hasChanges: true,
|
||||
linkedDirs: [
|
||||
{
|
||||
path: '/repo/app',
|
||||
status: 'changed',
|
||||
branch: 'main',
|
||||
headSha: 'abc1234',
|
||||
changedFileCount: 2,
|
||||
newStatusLineCount: 2,
|
||||
preexistingChangeCount: 0,
|
||||
untrackedFileCount: 1,
|
||||
statusLines: [' M src/app.ts', '?? src/new.ts'],
|
||||
diffStat: 'src/app.ts | 8 +++++---',
|
||||
error: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
} as ChatMessage['events'][number],
|
||||
],
|
||||
producedFiles: [],
|
||||
})}
|
||||
streaming={false}
|
||||
projectId="proj-1"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('linked-repo-changes')).toBeTruthy();
|
||||
expect(screen.getByText('Linked repo changes')).toBeTruthy();
|
||||
expect(screen.getByText('2 changed files · 1 untracked file')).toBeTruthy();
|
||||
expect(screen.getByText('repo/app')).toBeTruthy();
|
||||
expect(screen.getByText(/src\/app\.ts/)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1144,6 +1144,56 @@ describe('streamViaDaemon', () => {
|
|||
detail: 'tavily · shallow',
|
||||
});
|
||||
});
|
||||
|
||||
it('forwards linked repo change summaries from daemon SSE', async () => {
|
||||
const handlers = createDaemonHandlers();
|
||||
const summary = {
|
||||
generatedAt: 1700000000,
|
||||
linkedDirCount: 1,
|
||||
changedFileCount: 2,
|
||||
newStatusLineCount: 2,
|
||||
preexistingChangeCount: 0,
|
||||
untrackedFileCount: 1,
|
||||
hasChanges: true,
|
||||
linkedDirs: [
|
||||
{
|
||||
path: '/repo/app',
|
||||
status: 'changed',
|
||||
branch: 'main',
|
||||
headSha: 'abc1234',
|
||||
changedFileCount: 2,
|
||||
newStatusLineCount: 2,
|
||||
preexistingChangeCount: 0,
|
||||
untrackedFileCount: 1,
|
||||
statusLines: [' M src/app.ts', '?? src/new.ts'],
|
||||
diffStat: 'src/app.ts | 8 +++++---',
|
||||
error: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
vi.stubGlobal('fetch', vi.fn()
|
||||
.mockResolvedValueOnce(jsonResponse({ runId: 'run-1' }))
|
||||
.mockResolvedValueOnce(
|
||||
sseResponse(
|
||||
`event: repo_changes\ndata: ${JSON.stringify(summary)}\n\n` +
|
||||
'event: end\ndata: {"code":0,"status":"succeeded"}\n\n',
|
||||
),
|
||||
));
|
||||
|
||||
await streamViaDaemon({
|
||||
agentId: 'mock',
|
||||
history: [{ id: '1', role: 'user', content: 'edit the linked repo' }],
|
||||
systemPrompt: '',
|
||||
signal: new AbortController().signal,
|
||||
handlers,
|
||||
});
|
||||
|
||||
expect(handlers.onAgentEvent).toHaveBeenCalledWith({
|
||||
kind: 'repo_changes',
|
||||
summary,
|
||||
});
|
||||
expect(handlers.onDone).toHaveBeenCalledWith('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('streamMessageOpenAI', () => {
|
||||
|
|
|
|||
|
|
@ -232,6 +232,36 @@ export interface ChatRunCreateResponse {
|
|||
pluginId?: string | null;
|
||||
}
|
||||
|
||||
export type LinkedRepoChangeStatus = 'changed' | 'clean' | 'not_git' | 'error';
|
||||
|
||||
export interface LinkedRepoChangeDirectorySummary {
|
||||
/** Absolute local linked directory path captured by the daemon. */
|
||||
path: string;
|
||||
status: LinkedRepoChangeStatus;
|
||||
branch?: string | null;
|
||||
headSha?: string | null;
|
||||
changedFileCount: number;
|
||||
newStatusLineCount: number;
|
||||
preexistingChangeCount: number;
|
||||
untrackedFileCount: number;
|
||||
statusLines: string[];
|
||||
statusTruncated?: boolean;
|
||||
diffStat?: string | null;
|
||||
diffStatTruncated?: boolean;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export interface LinkedRepoChangeSummary {
|
||||
generatedAt: number;
|
||||
linkedDirCount: number;
|
||||
changedFileCount: number;
|
||||
newStatusLineCount: number;
|
||||
preexistingChangeCount: number;
|
||||
untrackedFileCount: number;
|
||||
hasChanges: boolean;
|
||||
linkedDirs: LinkedRepoChangeDirectorySummary[];
|
||||
}
|
||||
|
||||
export interface ChatRunStatusResponse {
|
||||
id: string;
|
||||
projectId: string | null;
|
||||
|
|
@ -255,6 +285,8 @@ export interface ChatRunStatusResponse {
|
|||
mediaExecution?: MediaExecutionPolicy;
|
||||
/** Run-scoped tool bundle summary with secrets and command details redacted. */
|
||||
toolBundle?: RunScopedToolBundleSummary;
|
||||
/** Best-effort linked-repo change summary captured when the run ended. */
|
||||
repoChanges?: LinkedRepoChangeSummary | null;
|
||||
}
|
||||
|
||||
export interface ChatRunListResponse {
|
||||
|
|
@ -329,6 +361,7 @@ export type PersistedAgentEvent =
|
|||
draftPath?: string | null;
|
||||
}
|
||||
| { kind: 'usage'; inputTokens?: number; outputTokens?: number; costUsd?: number; durationMs?: number }
|
||||
| { kind: 'repo_changes'; summary: LinkedRepoChangeSummary }
|
||||
| { kind: 'raw'; line: string };
|
||||
|
||||
export interface ChatMessage {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { LiveArtifactRefreshStatus } from '../api/live-artifacts.js';
|
||||
import type { LinkedRepoChangeSummary } from '../api/chat.js';
|
||||
import type { SseErrorPayload } from '../errors.js';
|
||||
import type { SseTransportEvent } from './common.js';
|
||||
|
||||
|
|
@ -89,6 +90,7 @@ export type DaemonAgentPayload =
|
|||
export type ChatSseEvent =
|
||||
| SseTransportEvent<'start', ChatSseStartPayload>
|
||||
| SseTransportEvent<'agent', DaemonAgentPayload>
|
||||
| SseTransportEvent<'repo_changes', LinkedRepoChangeSummary>
|
||||
| SseTransportEvent<'stdout', ChatSseChunkPayload>
|
||||
| SseTransportEvent<'stderr', ChatSseChunkPayload>
|
||||
| SseTransportEvent<'error', SseErrorPayload>
|
||||
|
|
|
|||
Loading…
Reference in a new issue