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); 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 // 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" // 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. // 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); const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
if (!checkTaskOwnership(req, res, task)) return; 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) { if (attachments && attachments.length > 0 && task?.workspacePath) {
const inputDir = join(task.workspacePath, 'input'); const inputDir = join(task.workspacePath, 'input');
mkdirSync(inputDir, { recursive: true }); 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. // 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 isRunning = prevJob && (prevJob.status === 'running' || prevJob.status === 'dispatching' || prevJob.status === 'waiting_subtasks');
const commentKind = isRunning ? 'interjection' : 'comment'; 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) { if (isRunning) {
logger.info(`[local-tasks-api] interjection: comment ${comment.id} saved for ${prevJob!.status} job ${prevJob!.id} on task ${taskId}`); 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 askCount = prevJob?.status === 'waiting_human' ? prevJob.askCount : 0;
const resumeMovement = prevJob?.status === 'waiting_human' ? prevJob.resumeMovement : null; const resumeMovement = prevJob?.status === 'waiting_human' ? prevJob.resumeMovement : null;
// Build instruction with attachment info // Build instruction with attachment info (savedFileNames computed above)
const savedFileNames = (attachments ?? [])
.filter(att => att.name && att.contentBase64)
.map(att => att.name.replace(/[\\/]/g, '_'));
const instruction = savedFileNames.length > 0 const instruction = savedFileNames.length > 0
? `${body}\n\n添付ファイルinput/ に保存済み): ${savedFileNames.join(', ')}` ? `${body}\n\n添付ファイルinput/ に保存済み): ${savedFileNames.join(', ')}`
: body; : body;

View File

@ -101,6 +101,29 @@ describe('runMigrations', () => {
const columnsAfter = db.prepare("PRAGMA table_info('jobs')").all() as Array<{ name: string }>; 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); 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', () => { 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"); 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. // Per-task options (JSON blob): controls runtime toggles like mcpDisabled / skillsDisabled.
addColumnIfMissing(db, 'local_tasks', 'options', () => { addColumnIfMissing(db, 'local_tasks', 'options', () => {
db.exec("ALTER TABLE local_tasks ADD COLUMN options TEXT DEFAULT '{}'"); db.exec("ALTER TABLE local_tasks ADD COLUMN options TEXT DEFAULT '{}'");

View File

@ -1491,6 +1491,32 @@ describe('Repository.getLatestResultComment', () => {
repo.close(); 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', () => { describe('Repository browser notifications V2', () => {

View File

@ -466,6 +466,8 @@ export interface LocalTaskComment {
author: string; author: string;
kind: string; kind: string;
body: string; body: string;
/** Filenames saved to the task's input/ dir for this comment (UI download links). */
attachments: string[];
createdAt: string; createdAt: string;
injectedAt: string | null; injectedAt: string | null;
} }
@ -751,6 +753,7 @@ interface LocalTaskCommentRow {
author: string; author: string;
kind: string; kind: string;
body: string; body: string;
attachments: string | null;
created_at: string; created_at: string;
injected_at: string | null; injected_at: string | null;
} }
@ -935,11 +938,23 @@ function rowToLocalTaskComment(row: LocalTaskCommentRow): LocalTaskComment {
author: row.author, author: row.author,
kind: row.kind, kind: row.kind,
body: row.body, body: row.body,
attachments: parseCommentAttachments(row.attachments),
createdAt: utc(row.created_at), createdAt: utc(row.created_at),
injectedAt: row.injected_at ? utc(row.injected_at) : null, 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 { function rowToWorkerNode(row: WorkerNodeRow): WorkerNode {
return { return {
workerId: row.worker_id, 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 const result = this.db
.prepare('INSERT INTO local_task_comments (task_id, author, kind, body) VALUES (?, ?, ?, ?)') .prepare('INSERT INTO local_task_comments (task_id, author, kind, body, attachments) VALUES (?, ?, ?, ?, ?)')
.run(taskId, author, kind, body); .run(taskId, author, kind, body, attachmentsJson);
this.db this.db
.prepare("UPDATE local_tasks SET updated_at = datetime('now') WHERE id = ?") .prepare("UPDATE local_tasks SET updated_at = datetime('now') WHERE id = ?")
.run(taskId); .run(taskId);

View File

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

View File

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

View File

@ -1,6 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { LocalTaskComment } from '../../api'; import { LocalTaskComment, getLocalFileRawUrl } from '../../api';
import { MarkdownPreview } from '../files/FilePreview'; import { MarkdownPreview } from '../files/FilePreview';
import { MarkdownText } from '../../lib/markdown-text'; import { MarkdownText } from '../../lib/markdown-text';
import { ToolCallsSection, parseToolCallComment } from './ToolCallsSection'; 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) { export function ChatMessage({ comment, taskId, imageBaseUrl, isStaleThinking }: ChatMessageProps) {
const { t } = useTranslation('chat'); const { t } = useTranslation('chat');
const { kind, author, body, createdAt } = comment; const { kind, author, body, createdAt } = comment;
@ -352,6 +380,7 @@ export function ChatMessage({ comment, taskId, imageBaseUrl, isStaleThinking }:
{author} · {new Date(createdAt).toLocaleString()} {author} · {new Date(createdAt).toLocaleString()}
</div> </div>
<MarkdownText text={body} /> <MarkdownText text={body} />
<CommentAttachments attachments={comment.attachments} taskId={taskId} />
<div className={`text-[10px] mt-1.5 ${isPending ? 'text-amber-400' : 'text-green-500'}`}> <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() })} {isPending ? t('message.waitingAgentAck') : t('message.acked', { time: new Date(comment.injectedAt!).toLocaleTimeString() })}
</div> </div>
@ -369,6 +398,7 @@ export function ChatMessage({ comment, taskId, imageBaseUrl, isStaleThinking }:
{author} · {new Date(createdAt).toLocaleString()} {author} · {new Date(createdAt).toLocaleString()}
</div> </div>
<MarkdownText text={body} /> <MarkdownText text={body} />
<CommentAttachments attachments={comment.attachments} taskId={taskId} />
</div> </div>
</div> </div>
); );