sync: update from private repo (e6a4dc8)
Some checks failed
CI / build-and-test (push) Has been cancelled

This commit is contained in:
oss-sync 2026-06-17 06:55:25 +00:00
parent 643232caba
commit 6a2f2cc736
9 changed files with 140 additions and 10 deletions

View File

@ -706,6 +706,30 @@ describe('POST /api/local/tasks/:taskId/comments and /cancel ownership', () => {
expect(res.status).toBe(201);
});
it('records attachment filenames on the comment and saves them to input/', async () => {
tempDir = mkdtempSync(join(tmpdir(), 'lt-cmt-att-'));
repo = new Repository(join(tempDir, 'db.sqlite'));
const alice = repo.createUser({ email: 'a@x.com', name: 'a', role: 'user', status: 'active' });
const aliceUser: Express.User = { ...alice, orgIds: [], defaultVisibility: 'private', defaultVisibilityOrgId: null };
const wsPath = join(tempDir, 'ws-alice');
const task = await repo.createLocalTask({ title: 't', body: 'b', ownerId: alice.id, visibility: 'private', workspacePath: wsPath });
const res = await request(buildAppForUser(aliceUser))
.post(`/api/local/tasks/${task.id}/comments`)
.send({
body: 'with files',
author: 'user',
attachments: [{ name: 'report.pdf', contentBase64: Buffer.from('pdf').toString('base64') }],
});
expect(res.status).toBe(201);
expect(res.body.comment.attachments).toEqual(['report.pdf']);
// Persisted on the comment row.
const [listed] = await repo.listLocalTaskComments(task.id);
expect(listed.attachments).toEqual(['report.pdf']);
// File landed in input/.
expect(existsSync(join(wsPath, 'input', 'report.pdf'))).toBe(true);
});
// Regression: two comments in quick succession to an idle task used to spawn
// two queued jobs; the newest became latestJob so the task showed "Inbox"
// while an older job ran. Now the second comment reuses the still-pending job.

View File

@ -352,7 +352,12 @@ export function mountLocalTasksApi(app: Application, opts: LocalTasksApiOptions)
const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
if (!checkTaskOwnership(req, res, task)) return;
// Save attachments to input/
// Save attachments to input/. The resulting (sanitized) filenames are
// stored on the comment so the UI can render download links, and reused
// below to tell the agent which files landed in input/.
const savedFileNames = (attachments ?? [])
.filter(att => att.name && att.contentBase64)
.map(att => att.name.replace(/[\\/]/g, '_'));
if (attachments && attachments.length > 0 && task?.workspacePath) {
const inputDir = join(task.workspacePath, 'input');
mkdirSync(inputDir, { recursive: true });
@ -369,7 +374,7 @@ export function mountLocalTasksApi(app: Application, opts: LocalTasksApiOptions)
// it as an interjection and don't touch the job queue.
const isRunning = prevJob && (prevJob.status === 'running' || prevJob.status === 'dispatching' || prevJob.status === 'waiting_subtasks');
const commentKind = isRunning ? 'interjection' : 'comment';
const comment = await repo.addLocalTaskComment(taskId, author, body, commentKind);
const comment = await repo.addLocalTaskComment(taskId, author, body, commentKind, savedFileNames);
if (isRunning) {
logger.info(`[local-tasks-api] interjection: comment ${comment.id} saved for ${prevJob!.status} job ${prevJob!.id} on task ${taskId}`);
@ -380,10 +385,7 @@ export function mountLocalTasksApi(app: Application, opts: LocalTasksApiOptions)
const askCount = prevJob?.status === 'waiting_human' ? prevJob.askCount : 0;
const resumeMovement = prevJob?.status === 'waiting_human' ? prevJob.resumeMovement : null;
// Build instruction with attachment info
const savedFileNames = (attachments ?? [])
.filter(att => att.name && att.contentBase64)
.map(att => att.name.replace(/[\\/]/g, '_'));
// Build instruction with attachment info (savedFileNames computed above)
const instruction = savedFileNames.length > 0
? `${body}\n\n添付ファイルinput/ に保存済み): ${savedFileNames.join(', ')}`
: body;

View File

@ -101,6 +101,29 @@ describe('runMigrations', () => {
const columnsAfter = db.prepare("PRAGMA table_info('jobs')").all() as Array<{ name: string }>;
expect(columnsAfter.filter(c => c.name === 'last_backend_id').length).toBe(1);
});
it('adds attachments column to an existing local_task_comments table (idempotent)', () => {
// Simulate a DB created before the attachments column existed.
db.exec(`
CREATE TABLE local_task_comments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id INTEGER NOT NULL,
author TEXT NOT NULL,
kind TEXT NOT NULL DEFAULT 'comment',
body TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
injected_at TEXT
);
`);
runMigrations(db);
const cols = db.prepare("PRAGMA table_info('local_task_comments')").all() as Array<{ name: string }>;
expect(cols.some(c => c.name === 'attachments')).toBe(true);
// 2nd run is a no-op (single column, no error).
expect(() => runMigrations(db)).not.toThrow();
const colsAfter = db.prepare("PRAGMA table_info('local_task_comments')").all() as Array<{ name: string }>;
expect(colsAfter.filter(c => c.name === 'attachments').length).toBe(1);
});
});
describe('MCP table migrations', () => {

View File

@ -85,6 +85,12 @@ export function runMigrations(db: Database.Database): void {
db.exec("ALTER TABLE local_task_comments ADD COLUMN injected_at TEXT");
});
// Attachment filenames (JSON array) saved to the task's input/ dir for a
// user comment, so the UI can show download links under the comment bubble.
addColumnIfMissing(db, 'local_task_comments', 'attachments', () => {
db.exec("ALTER TABLE local_task_comments ADD COLUMN attachments TEXT");
});
// Per-task options (JSON blob): controls runtime toggles like mcpDisabled / skillsDisabled.
addColumnIfMissing(db, 'local_tasks', 'options', () => {
db.exec("ALTER TABLE local_tasks ADD COLUMN options TEXT DEFAULT '{}'");

View File

@ -1491,6 +1491,32 @@ describe('Repository.getLatestResultComment', () => {
repo.close();
}
});
it('persists and returns comment attachment filenames', async () => {
const repo = makeRepo();
try {
const task = await repo.createLocalTask({ title: 't', body: 'b', pieceName: 'chat' });
const saved = await repo.addLocalTaskComment(task.id, 'user', 'see files', 'comment', ['a.pdf', 'b.png']);
expect(saved.attachments).toEqual(['a.pdf', 'b.png']);
const [listed] = await repo.listLocalTaskComments(task.id);
expect(listed.attachments).toEqual(['a.pdf', 'b.png']);
} finally {
repo.close();
}
});
it('defaults attachments to an empty array when none are provided', async () => {
const repo = makeRepo();
try {
const task = await repo.createLocalTask({ title: 't', body: 'b', pieceName: 'chat' });
const saved = await repo.addLocalTaskComment(task.id, 'user', 'no files', 'comment');
expect(saved.attachments).toEqual([]);
const [listed] = await repo.listLocalTaskComments(task.id);
expect(listed.attachments).toEqual([]);
} finally {
repo.close();
}
});
});
describe('Repository browser notifications V2', () => {

View File

@ -466,6 +466,8 @@ export interface LocalTaskComment {
author: string;
kind: string;
body: string;
/** Filenames saved to the task's input/ dir for this comment (UI download links). */
attachments: string[];
createdAt: string;
injectedAt: string | null;
}
@ -751,6 +753,7 @@ interface LocalTaskCommentRow {
author: string;
kind: string;
body: string;
attachments: string | null;
created_at: string;
injected_at: string | null;
}
@ -935,11 +938,23 @@ function rowToLocalTaskComment(row: LocalTaskCommentRow): LocalTaskComment {
author: row.author,
kind: row.kind,
body: row.body,
attachments: parseCommentAttachments(row.attachments),
createdAt: utc(row.created_at),
injectedAt: row.injected_at ? utc(row.injected_at) : null,
};
}
/** Parse the JSON attachments column to a string[]; tolerate null/legacy/garbage. */
function parseCommentAttachments(raw: string | null): string[] {
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed.filter((n): n is string => typeof n === 'string') : [];
} catch {
return [];
}
}
function rowToWorkerNode(row: WorkerNodeRow): WorkerNode {
return {
workerId: row.worker_id,
@ -1856,10 +1871,11 @@ export class Repository {
});
}
async addLocalTaskComment(taskId: number, author: string, body: string, kind: string = 'comment'): Promise<LocalTaskComment> {
async addLocalTaskComment(taskId: number, author: string, body: string, kind: string = 'comment', attachments: string[] = []): Promise<LocalTaskComment> {
const attachmentsJson = attachments.length > 0 ? JSON.stringify(attachments) : null;
const result = this.db
.prepare('INSERT INTO local_task_comments (task_id, author, kind, body) VALUES (?, ?, ?, ?)')
.run(taskId, author, kind, body);
.prepare('INSERT INTO local_task_comments (task_id, author, kind, body, attachments) VALUES (?, ?, ?, ?, ?)')
.run(taskId, author, kind, body, attachmentsJson);
this.db
.prepare("UPDATE local_tasks SET updated_at = datetime('now') WHERE id = ?")
.run(taskId);

View File

@ -81,6 +81,7 @@ CREATE TABLE IF NOT EXISTS local_task_comments (
author TEXT NOT NULL,
kind TEXT NOT NULL DEFAULT 'comment',
body TEXT NOT NULL,
attachments TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
injected_at TEXT,
FOREIGN KEY (task_id) REFERENCES local_tasks(id) ON DELETE CASCADE

View File

@ -183,6 +183,8 @@ export interface LocalTaskComment {
author: string;
kind: CommentKind;
body: string;
/** Filenames attached to this comment, saved under the task's input/ dir. */
attachments?: string[];
createdAt: string;
injectedAt: string | null;
}

View File

@ -1,6 +1,6 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { LocalTaskComment } from '../../api';
import { LocalTaskComment, getLocalFileRawUrl } from '../../api';
import { MarkdownPreview } from '../files/FilePreview';
import { MarkdownText } from '../../lib/markdown-text';
import { ToolCallsSection, parseToolCallComment } from './ToolCallsSection';
@ -333,6 +333,34 @@ function ProgressCard({ comment, isStaleThinking }: { comment: LocalTaskComment;
);
}
/**
* Download chips for files the user attached to a comment. Files live under the
* task's input/ dir; we link to the raw-file endpoint with a download attribute.
*/
function CommentAttachments({ attachments, taskId }: { attachments?: string[]; taskId: number }) {
if (!attachments || attachments.length === 0) return null;
return (
<div className="mt-2 flex flex-wrap gap-1.5">
{attachments.map(name => (
<a
key={name}
href={getLocalFileRawUrl(taskId, 'input', name)}
download={name}
target="_blank"
rel="noopener noreferrer"
title={name}
className="inline-flex items-center gap-1 max-w-[200px] px-2 py-1 rounded-md bg-white/70 dark:bg-slate-900/40 border border-slate-200 dark:border-slate-600 text-2xs text-slate-700 dark:text-slate-200 hover:bg-white dark:hover:bg-slate-800 hover:border-accent transition-colors"
>
<svg className="w-3 h-3 shrink-0 text-slate-400" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M10 2.5 4.2 8.3a2 2 0 0 0 2.8 2.8l5.3-5.3a3.2 3.2 0 0 0-4.5-4.5L2.4 7.2" />
</svg>
<span className="truncate">{name}</span>
</a>
))}
</div>
);
}
export function ChatMessage({ comment, taskId, imageBaseUrl, isStaleThinking }: ChatMessageProps) {
const { t } = useTranslation('chat');
const { kind, author, body, createdAt } = comment;
@ -352,6 +380,7 @@ export function ChatMessage({ comment, taskId, imageBaseUrl, isStaleThinking }:
{author} · {new Date(createdAt).toLocaleString()}
</div>
<MarkdownText text={body} />
<CommentAttachments attachments={comment.attachments} taskId={taskId} />
<div className={`text-[10px] mt-1.5 ${isPending ? 'text-amber-400' : 'text-green-500'}`}>
{isPending ? t('message.waitingAgentAck') : t('message.acked', { time: new Date(comment.injectedAt!).toLocaleTimeString() })}
</div>
@ -369,6 +398,7 @@ export function ChatMessage({ comment, taskId, imageBaseUrl, isStaleThinking }:
{author} · {new Date(createdAt).toLocaleString()}
</div>
<MarkdownText text={body} />
<CommentAttachments attachments={comment.attachments} taskId={taskId} />
</div>
</div>
);