sync: update from private repo (0e09596)
Some checks failed
CI / build-and-test (push) Has been cancelled
Some checks failed
CI / build-and-test (push) Has been cancelled
This commit is contained in:
parent
03be80f036
commit
0f75bdfbab
@ -607,7 +607,11 @@ export async function runPiece(
|
|||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
flushAndStageRecording(
|
flushAndStageRecording(
|
||||||
options.taskId,
|
options.taskId,
|
||||||
options.userId,
|
// No-auth mode passes userId=null; fall back to the same 'local'
|
||||||
|
// namespace as the ToolContext below, otherwise
|
||||||
|
// flushAndStageRecording's `if (!ownerId) return` silently discards
|
||||||
|
// every browser recording and self-healing patch in no-auth.
|
||||||
|
options.userId ?? 'local',
|
||||||
cfg.userFolderRoot ?? './data/users',
|
cfg.userFolderRoot ?? './data/users',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -262,6 +262,81 @@ describe('Scheduler.executeScheduledTask: ownership inheritance', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Scheduler.tick: skip-on-in-progress', () => {
|
||||||
|
// Regression: a scheduled task's previous job ending in `waiting_human` (ASK)
|
||||||
|
// used to be treated as "in progress" and block all future scheduled runs
|
||||||
|
// forever — waiting_human has no auto-recovery, and an unattended schedule has
|
||||||
|
// nobody to answer the ASK, so the schedule silently died. Now waiting_human is
|
||||||
|
// excluded from the skip set: each scheduled run is an independent task, so we
|
||||||
|
// leave the stale waiting_human task as-is and start a fresh run.
|
||||||
|
let tempDir: string;
|
||||||
|
let repo: Repository;
|
||||||
|
let scheduler: Scheduler;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tempDir = mkdtempSync(join(tmpdir(), 'agent-sched-skip-'));
|
||||||
|
repo = new Repository(join(tempDir, 'db.sqlite'));
|
||||||
|
scheduler = new Scheduler(repo, join(tempDir, 'workspaces'));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
repo.close();
|
||||||
|
rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Run a first tick to produce a job, force that job into `status`, then re-arm
|
||||||
|
// the schedule (past next_run_at) and return its id + the first job id.
|
||||||
|
async function armWithLastJobStatus(status: string): Promise<{ schedId: number; firstJobId: string }> {
|
||||||
|
const sched = await repo.createScheduledTask({
|
||||||
|
body: 'recurring thing',
|
||||||
|
pieceName: 'chat',
|
||||||
|
profile: 'auto',
|
||||||
|
outputFormat: 'markdown',
|
||||||
|
cronExpression: '0 9 * * *',
|
||||||
|
nextRunAt: '2000-01-01 09:00:00',
|
||||||
|
});
|
||||||
|
const first = await scheduler.tick();
|
||||||
|
expect(first).toBe(1);
|
||||||
|
const after = await repo.getScheduledTask(sched.id);
|
||||||
|
const firstJobId = after!.lastJobId!;
|
||||||
|
await repo.updateJob(firstJobId, { status: status as any });
|
||||||
|
// Re-arm: getScheduledTasksDue advanced next_run_at to the future.
|
||||||
|
await repo.updateScheduledTask(sched.id, { nextRunAt: '2000-01-01 09:00:00' });
|
||||||
|
return { schedId: sched.id, firstJobId };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('starts a NEW run when the previous job is waiting_human (no longer blocks)', async () => {
|
||||||
|
const { schedId, firstJobId } = await armWithLastJobStatus('waiting_human');
|
||||||
|
|
||||||
|
const executed = await scheduler.tick();
|
||||||
|
expect(executed).toBe(1);
|
||||||
|
|
||||||
|
const after = await repo.getScheduledTask(schedId);
|
||||||
|
expect(after?.lastJobId).toBeTruthy();
|
||||||
|
expect(after?.lastJobId).not.toBe(firstJobId); // a fresh job was created
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still skips when the previous job is running (genuinely in progress)', async () => {
|
||||||
|
const { schedId, firstJobId } = await armWithLastJobStatus('running');
|
||||||
|
|
||||||
|
const executed = await scheduler.tick();
|
||||||
|
expect(executed).toBe(0);
|
||||||
|
|
||||||
|
const after = await repo.getScheduledTask(schedId);
|
||||||
|
expect(after?.lastJobId).toBe(firstJobId); // unchanged — skipped
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still skips when the previous job is waiting_subtasks (auto-recovering transient)', async () => {
|
||||||
|
const { schedId, firstJobId } = await armWithLastJobStatus('waiting_subtasks');
|
||||||
|
|
||||||
|
const executed = await scheduler.tick();
|
||||||
|
expect(executed).toBe(0);
|
||||||
|
|
||||||
|
const after = await repo.getScheduledTask(schedId);
|
||||||
|
expect(after?.lastJobId).toBe(firstJobId); // unchanged — skipped
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('Scheduler.executeScheduledTask: task_kind="script"', () => {
|
describe('Scheduler.executeScheduledTask: task_kind="script"', () => {
|
||||||
// Scheduler runs a user-authored script directly (no LLM agent loop) when
|
// Scheduler runs a user-authored script directly (no LLM agent loop) when
|
||||||
// task_kind='script'. The job row gets created in a pre-completed state.
|
// task_kind='script'. The job row gets created in a pre-completed state.
|
||||||
@ -393,6 +468,46 @@ module.exports = main;
|
|||||||
await expect(scheduler.executeById(sched.id)).rejects.toThrow(/script_name is null/);
|
await expect(scheduler.executeById(sched.id)).rejects.toThrow(/script_name is null/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('runs as the "local" user when the schedule has no owner (no-auth mode)', async () => {
|
||||||
|
// Regression: no-auth deployments store scheduled tasks with ownerId=null
|
||||||
|
// (scheduled-tasks-api has no authenticated user). executeScriptScheduledTask
|
||||||
|
// used to throw "requires an owner_id", so no-auth scheduled scripts could
|
||||||
|
// never run. Now it falls back to the 'local' namespace (same as the
|
||||||
|
// RunUserScript tool), resolving the script from data/users/local/scripts/.
|
||||||
|
writeScript('local', 'noauth.js', `async function main() {
|
||||||
|
return { ran: 'no-auth-local' };
|
||||||
|
}
|
||||||
|
module.exports = main;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const sched = await repo.createScheduledTask({
|
||||||
|
body: '',
|
||||||
|
cronExpression: '0 9 * * *',
|
||||||
|
nextRunAt: '2099-01-01 09:00:00',
|
||||||
|
// ownerId intentionally omitted → null, as in no-auth mode
|
||||||
|
taskKind: 'script',
|
||||||
|
scriptName: 'noauth',
|
||||||
|
});
|
||||||
|
|
||||||
|
await scheduler.executeById(sched.id);
|
||||||
|
|
||||||
|
const after = await repo.getScheduledTask(sched.id);
|
||||||
|
expect(after?.lastJobId).toBeTruthy();
|
||||||
|
const job = await repo.getJob(after!.lastJobId!);
|
||||||
|
expect(job?.status).toBe('succeeded');
|
||||||
|
|
||||||
|
const task = await repo.getLocalTask(job!.issueNumber);
|
||||||
|
const output = readFileSync(join(task!.workspacePath!, 'output', 'script-output.txt'), 'utf-8');
|
||||||
|
expect(output).toContain('no-auth-local'); // script's return value was saved
|
||||||
|
|
||||||
|
// The audit row records the resolved 'local' owner, not null.
|
||||||
|
const audits = repo.getDb()
|
||||||
|
.prepare("SELECT detail FROM audit_log WHERE job_id = ? AND action = 'user_script_run'")
|
||||||
|
.all(after!.lastJobId!) as Array<{ detail: string }>;
|
||||||
|
expect(audits).toHaveLength(1);
|
||||||
|
expect((JSON.parse(audits[0].detail) as { userId: string }).userId).toBe('local');
|
||||||
|
});
|
||||||
|
|
||||||
it('refuses to run when the user-script gate is disabled', async () => {
|
it('refuses to run when the user-script gate is disabled', async () => {
|
||||||
const gatedScheduler = new Scheduler(repo, join(tempDir, 'workspaces'), {
|
const gatedScheduler = new Scheduler(repo, join(tempDir, 'workspaces'), {
|
||||||
userFolderRoot,
|
userFolderRoot,
|
||||||
|
|||||||
@ -7,9 +7,16 @@ import { logger } from './logger.js';
|
|||||||
import { loadConfig } from './config.js';
|
import { loadConfig } from './config.js';
|
||||||
import { resolveAndRunUserScript } from './user-folder/script-orchestrator.js';
|
import { resolveAndRunUserScript } from './user-folder/script-orchestrator.js';
|
||||||
|
|
||||||
// 進行中とみなすステータス(これらの場合はスキップ)
|
// 進行中とみなすステータス(これらの場合は次回スケジュール実行をスキップ)。
|
||||||
|
// これらはいずれも「真に実行中」か「自動回復する一時状態」のみ:
|
||||||
|
// queued/dispatching/running … 実行中
|
||||||
|
// waiting_subtasks … 並列サブタスク完了待ち。requeueWaitingSubtasks が自動回復させる
|
||||||
|
// waiting_human (ASK) は意図的に含めない。自動回復もタイムアウトも無いため、
|
||||||
|
// 無人実行のスケジュールタスクが一度 ASK を出すとその後の実行が永久にスキップされ、
|
||||||
|
// スケジュールが事実上死ぬ。各スケジュール実行は独立した新規タスクなので、
|
||||||
|
// 前回の waiting_human タスクは残置し(ユーザーが後から回答 or 放置できる)、新規実行を走らせる。
|
||||||
const IN_PROGRESS_STATUSES = new Set([
|
const IN_PROGRESS_STATUSES = new Set([
|
||||||
'queued', 'dispatching', 'running', 'waiting_human', 'waiting_subtasks',
|
'queued', 'dispatching', 'running', 'waiting_subtasks',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export interface ScheduleInput {
|
export interface ScheduleInput {
|
||||||
@ -295,9 +302,12 @@ export class Scheduler {
|
|||||||
if (!this.userFolderRoot) {
|
if (!this.userFolderRoot) {
|
||||||
throw new Error(`scheduled_task=${item.id}: task_kind='script' but Scheduler.userFolderRoot was not configured`);
|
throw new Error(`scheduled_task=${item.id}: task_kind='script' but Scheduler.userFolderRoot was not configured`);
|
||||||
}
|
}
|
||||||
if (!item.ownerId) {
|
// No-auth mode stores scheduled tasks with ownerId=null (scheduled-tasks-api
|
||||||
throw new Error(`scheduled_task=${item.id}: task_kind='script' requires an owner_id (scripts are per-user)`);
|
// has no authenticated user). Scripts are per-user (data/users/{id}/scripts/),
|
||||||
}
|
// so resolve to the same 'local' namespace the RunUserScript tool uses in
|
||||||
|
// no-auth (ctx.userId='local'). In auth mode item.ownerId is always set, so
|
||||||
|
// scriptOwner === item.ownerId and behaviour is unchanged.
|
||||||
|
const scriptOwner = item.ownerId ?? 'local';
|
||||||
|
|
||||||
// Same security gates as the LLM-facing RunUserScript tool: global config
|
// Same security gates as the LLM-facing RunUserScript tool: global config
|
||||||
// toggle + optional per-user allowlist. A scheduled run is automated, so a
|
// toggle + optional per-user allowlist. A scheduled run is automated, so a
|
||||||
@ -308,9 +318,9 @@ export class Scheduler {
|
|||||||
`scheduled_task=${item.id}: user scripts are disabled (tools.user_scripts_enabled=false)`,
|
`scheduled_task=${item.id}: user scripts are disabled (tools.user_scripts_enabled=false)`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (Array.isArray(gate.allowUserids) && gate.allowUserids.length > 0 && !gate.allowUserids.includes(item.ownerId)) {
|
if (Array.isArray(gate.allowUserids) && gate.allowUserids.length > 0 && !gate.allowUserids.includes(scriptOwner)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`scheduled_task=${item.id}: owner "${item.ownerId}" is not in tools.user_scripts_allow_userids`,
|
`scheduled_task=${item.id}: owner "${scriptOwner}" is not in tools.user_scripts_allow_userids`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -375,7 +385,7 @@ export class Scheduler {
|
|||||||
try {
|
try {
|
||||||
const runResult = await resolveAndRunUserScript({
|
const runResult = await resolveAndRunUserScript({
|
||||||
rootDir: this.userFolderRoot,
|
rootDir: this.userFolderRoot,
|
||||||
userId: item.ownerId,
|
userId: scriptOwner,
|
||||||
name: item.scriptName,
|
name: item.scriptName,
|
||||||
params,
|
params,
|
||||||
sessRepo: this.sessRepo,
|
sessRepo: this.sessRepo,
|
||||||
@ -417,7 +427,7 @@ export class Scheduler {
|
|||||||
|
|
||||||
await this.repo.addAuditLog(job.id, 'user_script_run', 'scheduler', {
|
await this.repo.addAuditLog(job.id, 'user_script_run', 'scheduler', {
|
||||||
scheduledTaskId: item.id,
|
scheduledTaskId: item.id,
|
||||||
userId: item.ownerId,
|
userId: scriptOwner,
|
||||||
scriptName: item.scriptName,
|
scriptName: item.scriptName,
|
||||||
ok: !runFailed,
|
ok: !runFailed,
|
||||||
...(runFailed && errorMessage ? { error: errorMessage.slice(0, 500) } : {}),
|
...(runFailed && errorMessage ? { error: errorMessage.slice(0, 500) } : {}),
|
||||||
|
|||||||
@ -67,15 +67,44 @@ describe('maybeEnqueueReflection', () => {
|
|||||||
expect(await repo.getJobsByStatus('queued')).toHaveLength(0);
|
expect(await repo.getJobsByStatus('queued')).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does nothing when ownerId is null (no user to learn for)', async () => {
|
it('falls back to the "local" namespace when ownerId is null (no-auth mode)', async () => {
|
||||||
|
// No-auth mode runs every job with ownerId=null. Reflection must still run,
|
||||||
|
// keyed to the same 'local' namespace the rest of the no-auth path uses.
|
||||||
const job = await repo.createJob({
|
const job = await repo.createJob({
|
||||||
repo: 'local/task-1', issueNumber: 1,
|
repo: 'local/task-1', issueNumber: 1,
|
||||||
instruction: 'x', pieceName: 'chat',
|
instruction: 'x', pieceName: 'chat',
|
||||||
} as any); // no ownerId
|
} as any); // no ownerId → null in no-auth
|
||||||
await repo.updateJob(job.id, { status: 'succeeded' });
|
await repo.updateJob(job.id, { status: 'succeeded' });
|
||||||
await maybeEnqueueReflection(repo, job, 'succeeded', {
|
await maybeEnqueueReflection(repo, job, 'succeeded', {
|
||||||
enabled: true, workerRequired: false, perUserDailyBudgetTokens: 0,
|
enabled: true, workerRequired: false, perUserDailyBudgetTokens: 0,
|
||||||
});
|
});
|
||||||
|
const queued = await repo.getJobsByStatus('queued');
|
||||||
|
expect(queued).toHaveLength(1);
|
||||||
|
expect(queued[0].taskKind).toBe('reflection');
|
||||||
|
expect(queued[0].ownerId).toBe('local');
|
||||||
|
expect(JSON.parse(queued[0].payload!).userId).toBe('local');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('no-auth budget check is keyed to "local" (not null)', async () => {
|
||||||
|
// A prior null-owner reflection that spent the whole budget must block the
|
||||||
|
// next null-owner enqueue — proving the budget query uses 'local', not null.
|
||||||
|
const job = await repo.createJob({
|
||||||
|
repo: 'local/task-8', issueNumber: 8,
|
||||||
|
instruction: 'x', pieceName: 'chat',
|
||||||
|
} as any); // no ownerId
|
||||||
|
await repo.updateJob(job.id, { status: 'succeeded' });
|
||||||
|
|
||||||
|
const cap = 1_000_000;
|
||||||
|
const todayStartMs = Date.UTC(
|
||||||
|
new Date().getUTCFullYear(),
|
||||||
|
new Date().getUTCMonth(),
|
||||||
|
new Date().getUTCDate(),
|
||||||
|
);
|
||||||
|
seedMetric('local', 600_000, 500_000, todayStartMs + 1_000); // total = 1 100 000 > cap
|
||||||
|
|
||||||
|
await maybeEnqueueReflection(repo, job, 'succeeded', {
|
||||||
|
enabled: true, workerRequired: false, perUserDailyBudgetTokens: cap,
|
||||||
|
});
|
||||||
expect(await repo.getJobsByStatus('queued')).toHaveLength(0);
|
expect(await repo.getJobsByStatus('queued')).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -271,10 +271,12 @@ export async function maybeEnqueueReflection(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!cfg.enabled) return;
|
if (!cfg.enabled) return;
|
||||||
if (job.taskKind === 'reflection') return;
|
if (job.taskKind === 'reflection') return;
|
||||||
if (!job.ownerId) {
|
// No-auth mode runs every job with ownerId=null. Reflection is per-user
|
||||||
logger.warn(`[reflection] skip enqueue job=${job.id} reason=no_owner`);
|
// (memory/pieces live under data/users/{userId}/), so fall back to the same
|
||||||
return;
|
// 'local' namespace the rest of the no-auth path uses (ToolContext, pieces,
|
||||||
}
|
// user-folder). Without this the enqueue gate skipped forever and reflection
|
||||||
|
// silently never ran in no-auth deployments.
|
||||||
|
const reflectionOwner = job.ownerId ?? 'local';
|
||||||
|
|
||||||
// worker_required enforcement: when true, at least one worker must have 'reflection' in its roles
|
// worker_required enforcement: when true, at least one worker must have 'reflection' in its roles
|
||||||
if (cfg.workerRequired) {
|
if (cfg.workerRequired) {
|
||||||
@ -282,7 +284,7 @@ export async function maybeEnqueueReflection(
|
|||||||
(w) => Array.isArray(w.roles) && w.roles.includes('reflection'),
|
(w) => Array.isArray(w.roles) && w.roles.includes('reflection'),
|
||||||
);
|
);
|
||||||
if (!hasReflectionWorker) {
|
if (!hasReflectionWorker) {
|
||||||
logger.warn(`[reflection] enqueue skipped reason=no_reflection_worker user=${job.ownerId}`);
|
logger.warn(`[reflection] enqueue skipped reason=no_reflection_worker user=${reflectionOwner}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -294,19 +296,19 @@ export async function maybeEnqueueReflection(
|
|||||||
// Compute today's start in UTC (00:00:00.000 UTC).
|
// Compute today's start in UTC (00:00:00.000 UTC).
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const todayStartMs = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
|
const todayStartMs = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
|
||||||
const metrics = repo.aggregateReflectionMetrics(job.ownerId, todayStartMs);
|
const metrics = repo.aggregateReflectionMetrics(reflectionOwner, todayStartMs);
|
||||||
const spent = metrics.tokensIn + metrics.tokensOut;
|
const spent = metrics.tokensIn + metrics.tokensOut;
|
||||||
if (spent >= cap) {
|
if (spent >= cap) {
|
||||||
const spentM = (spent / 1_000_000).toFixed(1);
|
const spentM = (spent / 1_000_000).toFixed(1);
|
||||||
const capM = (cap / 1_000_000).toFixed(1);
|
const capM = (cap / 1_000_000).toFixed(1);
|
||||||
logger.info(`[reflection] enqueue skipped reason=budget user=${job.ownerId} spent=${spentM}M cap=${capM}M`);
|
logger.info(`[reflection] enqueue skipped reason=budget user=${reflectionOwner} spent=${spentM}M cap=${capM}M`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = JSON.stringify({
|
const payload = JSON.stringify({
|
||||||
originalJobId: job.id,
|
originalJobId: job.id,
|
||||||
userId: job.ownerId,
|
userId: reflectionOwner,
|
||||||
pieceName: job.pieceName,
|
pieceName: job.pieceName,
|
||||||
outcome,
|
outcome,
|
||||||
});
|
});
|
||||||
@ -316,12 +318,12 @@ export async function maybeEnqueueReflection(
|
|||||||
instruction: '',
|
instruction: '',
|
||||||
pieceName: 'reflection',
|
pieceName: 'reflection',
|
||||||
role: 'reflection',
|
role: 'reflection',
|
||||||
ownerId: job.ownerId,
|
ownerId: reflectionOwner,
|
||||||
visibility: 'private',
|
visibility: 'private',
|
||||||
taskKind: 'reflection',
|
taskKind: 'reflection',
|
||||||
payload,
|
payload,
|
||||||
} as any);
|
} as any);
|
||||||
logger.info(`[reflection] enqueued original=${job.id} owner=${job.ownerId} piece=${job.pieceName} outcome=${outcome}`);
|
logger.info(`[reflection] enqueued original=${job.id} owner=${reflectionOwner} piece=${job.pieceName} outcome=${outcome}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Worker {
|
export class Worker {
|
||||||
|
|||||||
@ -39,7 +39,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
html { background: #ffffff; }
|
html { background: #ffffff; }
|
||||||
html[data-theme="dark"] { background: #0a0a0c; }
|
html[data-theme="dark"] { background: #18181c; }
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|||||||
@ -453,17 +453,24 @@ export function ChatPane({ task, comments, onSubmit, onCancel, onOpenDetail }: C
|
|||||||
<button
|
<button
|
||||||
disabled={submitting || (!draft.trim() && attachments.length === 0)}
|
disabled={submitting || (!draft.trim() && attachments.length === 0)}
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
className="px-3 h-9 bg-amber-500 text-white rounded-md text-xs font-semibold disabled:opacity-50 hover:bg-amber-600 flex-shrink-0 transition-colors"
|
className="inline-flex items-center gap-1.5 px-3 h-9 rounded-md text-xs font-semibold flex-shrink-0 transition-colors bg-amber-100 text-amber-800 border border-amber-300 hover:bg-amber-200 dark:bg-amber-500/15 dark:text-amber-300 dark:border-amber-500/30 dark:hover:bg-amber-500/25 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
|
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
<polyline points="15 10 20 15 15 20" />
|
||||||
|
<path d="M4 4v7a4 4 0 0 0 4 4h12" />
|
||||||
|
</svg>
|
||||||
割り込み
|
割り込み
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
disabled={cancelling}
|
disabled={cancelling}
|
||||||
onClick={() => void handleCancel()}
|
onClick={() => void handleCancel()}
|
||||||
className="px-3 h-9 bg-canvas border border-red-200 text-red-700 dark:text-red-300 rounded-md text-xs font-semibold disabled:opacity-50 hover:bg-red-50 dark:hover:bg-red-500/15 flex-shrink-0 transition-colors"
|
className="inline-flex items-center gap-1.5 px-3 h-9 rounded-md text-xs font-semibold flex-shrink-0 transition-colors bg-canvas border border-red-200 text-red-700 hover:bg-red-50 dark:border-red-500/40 dark:text-red-300 dark:hover:bg-red-500/15 disabled:opacity-50"
|
||||||
title="エージェントの実行を停止"
|
title="エージェントの実行を停止"
|
||||||
>
|
>
|
||||||
|
<svg className="w-3 h-3" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<rect x="6" y="6" width="12" height="12" rx="2.5" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
{cancelling ? '停止中...' : '停止'}
|
{cancelling ? '停止中...' : '停止'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -471,8 +478,12 @@ export function ChatPane({ task, comments, onSubmit, onCancel, onOpenDetail }: C
|
|||||||
<button
|
<button
|
||||||
disabled={submitting || inputLocked || (!draft.trim() && attachments.length === 0)}
|
disabled={submitting || inputLocked || (!draft.trim() && attachments.length === 0)}
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
className="px-3 h-9 bg-accent text-accent-fg rounded-md text-xs font-semibold disabled:opacity-50 hover:bg-accent-deep flex-shrink-0 transition-colors"
|
className="inline-flex items-center gap-1.5 px-3 h-9 bg-accent text-accent-fg rounded-md text-xs font-semibold disabled:opacity-50 hover:bg-accent-deep flex-shrink-0 transition-colors"
|
||||||
>
|
>
|
||||||
|
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
<path d="M22 2 11 13" />
|
||||||
|
<path d="M22 2 15 22 11 13 2 9 22 2Z" />
|
||||||
|
</svg>
|
||||||
送信
|
送信
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -48,6 +48,28 @@ export function BrowserSettingsForm({ config, onChange }: SectionFormProps) {
|
|||||||
|
|
||||||
<h3 className="text-sm font-medium text-slate-600 mt-4 pt-3 border-t border-slate-200">Sessions (CDP)</h3>
|
<h3 className="text-sm font-medium text-slate-600 mt-4 pt-3 border-t border-slate-200">Sessions (CDP)</h3>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<FieldLabel>CAPTCHA Solve Mode</FieldLabel>
|
||||||
|
<select value={browser.captchaSolve ?? 'skip'}
|
||||||
|
onChange={e => onChange('browser.captchaSolve', e.target.value)}
|
||||||
|
className="w-full h-9 px-2 text-[13px] border border-hairline rounded-md">
|
||||||
|
<option value="skip">skip (headless fallback, default)</option>
|
||||||
|
<option value="novnc">novnc (shared CAPTCHA Pool, solve in CAPTCHA tab)</option>
|
||||||
|
</select>
|
||||||
|
<HelpText>
|
||||||
|
<code>novnc</code> にすると WebSearch 等が CAPTCHA を踏んだとき共有 noVNC セッション(CAPTCHA Pool)を立て、
|
||||||
|
<strong>CAPTCHA タブで手動解決</strong>できる。ホストに <code>Xvfb</code> / <code>x11vnc</code> / <code>websockify</code> が必要。
|
||||||
|
未インストールなら自動的に headless にフォールバックする。<code>skip</code>(既定)では CAPTCHA は手動解決されない。
|
||||||
|
</HelpText>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<FieldLabel>Max CAPTCHA Pages</FieldLabel>
|
||||||
|
<FieldInput type="number" value={browser.maxCaptchaPages ?? 5}
|
||||||
|
onChange={v => onChange('browser.maxCaptchaPages', Number(v))} />
|
||||||
|
<HelpText>CAPTCHA Pool が同時に開けるページ数の上限(<code>novnc</code> モード時のみ有効)。デフォルト: 5</HelpText>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<FieldLabel>VNC Base Port</FieldLabel>
|
<FieldLabel>VNC Base Port</FieldLabel>
|
||||||
<FieldInput type="number" value={browser.vncBasePort ?? 5900}
|
<FieldInput type="number" value={browser.vncBasePort ?? 5900}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
|
import { THEME_CHANGE_EVENT } from '../lib/theme';
|
||||||
|
|
||||||
export interface Branding {
|
export interface Branding {
|
||||||
appName: string;
|
appName: string;
|
||||||
@ -115,9 +116,18 @@ function applyBrandColors(primary: string): void {
|
|||||||
const { h, s, l } = rgbToHsl(rgb);
|
const { h, s, l } = rgbToHsl(rgb);
|
||||||
// Hover: 12% darker, clamp
|
// Hover: 12% darker, clamp
|
||||||
const deepL = Math.max(0.05, l - 0.12);
|
const deepL = Math.max(0.05, l - 0.12);
|
||||||
// Soft background: very light but keep some hue. Lower saturation so it stays neutral-ish.
|
// Soft background (selected/active rows). Light tint in light mode; in dark
|
||||||
const softL = Math.min(0.96, Math.max(0.92, 1 - l * 0.05));
|
// mode a DEEP brand tint. Lightness is kept low (~0.15) so BOTH near-white row
|
||||||
const softS = Math.min(s, 0.55);
|
// text (~12:1) AND brand-colored `text-accent` labels (~3.4:1, on par with the
|
||||||
|
// light-mode pairing) stay legible. Saturation is never forced up, so a
|
||||||
|
// neutral brand (white/black/gray → s≈0) yields a neutral gray tint, not red.
|
||||||
|
// Dark softL=0.10 keeps brand `text-accent` on the soft bg at >= the light-mode
|
||||||
|
// pairing (~3.9:1 for the default blue — that pattern is inherently sub-AA in
|
||||||
|
// BOTH themes since a mid-bright brand color can't reach 4.5:1 on any visible
|
||||||
|
// tint; this just ensures dark is not a regression). Near-white row titles ~15:1.
|
||||||
|
const isDark = document.documentElement.dataset.theme === 'dark';
|
||||||
|
const softL = isDark ? 0.1 : Math.min(0.96, Math.max(0.92, 1 - l * 0.05));
|
||||||
|
const softS = isDark ? Math.min(s, 0.5) : Math.min(s, 0.55);
|
||||||
// Luminance for contrast decision (BT.601).
|
// Luminance for contrast decision (BT.601).
|
||||||
const luminance = (0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b) / 255;
|
const luminance = (0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b) / 255;
|
||||||
const fg = luminance > 0.65 ? '#0f172a' : '#ffffff';
|
const fg = luminance > 0.65 ? '#0f172a' : '#ffffff';
|
||||||
@ -145,8 +155,17 @@ export function useBranding(): Branding {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.title = branding.appName;
|
document.title = branding.appName;
|
||||||
applyBrandColors(branding.primaryColor);
|
|
||||||
applyFavicon(branding.faviconUrl);
|
applyFavicon(branding.faviconUrl);
|
||||||
|
const applyColors = () => applyBrandColors(branding.primaryColor);
|
||||||
|
applyColors();
|
||||||
|
// The soft tint is theme-dependent → re-derive when the theme changes.
|
||||||
|
window.addEventListener(THEME_CHANGE_EVENT, applyColors);
|
||||||
|
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
|
mq.addEventListener('change', applyColors);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener(THEME_CHANGE_EVENT, applyColors);
|
||||||
|
mq.removeEventListener('change', applyColors);
|
||||||
|
};
|
||||||
}, [branding.appName, branding.primaryColor, branding.faviconUrl]);
|
}, [branding.appName, branding.primaryColor, branding.faviconUrl]);
|
||||||
|
|
||||||
return branding;
|
return branding;
|
||||||
|
|||||||
@ -25,6 +25,9 @@
|
|||||||
/* Dark accent fallback (used only when /api/branding hasn't set
|
/* Dark accent fallback (used only when /api/branding hasn't set
|
||||||
--brand-primary*). Full runtime-branding + WCAG pairing is Phase 2. */
|
--brand-primary*). Full runtime-branding + WCAG pairing is Phase 2. */
|
||||||
--accent-on-dark: #fafafa; --accent-on-dark-fg: #18181b;
|
--accent-on-dark: #fafafa; --accent-on-dark-fg: #18181b;
|
||||||
|
/* Selected/active soft tint default (no-branding case). useBranding sets
|
||||||
|
--brand-primary-soft inline when configured (theme-aware, see hook). */
|
||||||
|
--brand-primary-soft: #f4f4f5;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] {
|
[data-theme="dark"] {
|
||||||
@ -32,18 +35,21 @@
|
|||||||
/* Inverted neutral ramp (zinc-tuned). TUNE ON A REAL DISPLAY — dense UI
|
/* Inverted neutral ramp (zinc-tuned). TUNE ON A REAL DISPLAY — dense UI
|
||||||
borders rely on hairline; verify surface/surface-2/hairline separation
|
borders rely on hairline; verify surface/surface-2/hairline separation
|
||||||
and WCAG AA text contrast before shipping (spec §4.1 #6). */
|
and WCAG AA text contrast before shipping (spec §4.1 #6). */
|
||||||
--slate-50: #0a0a0c; --slate-100: #131316; --slate-200: #202024;
|
/* Dark backgrounds lifted a notch (was #0a0a0c/#16161a/#202024) for a
|
||||||
/* slate-400/500 lifted to clear WCAG AA on canvas (#0a0a0c): secondary
|
softer, less pitch-black feel per user feedback. */
|
||||||
text (timestamps, version, meta) was ~3:1 at #52525b — too dim. */
|
--slate-50: #18181c; --slate-100: #202024; --slate-200: #2a2a30;
|
||||||
--slate-300: #383840; --slate-400: #7a7a85; --slate-500: #9b9ba5;
|
/* slate-400/500 lifted to clear WCAG AA on the (now brighter) canvas. */
|
||||||
--slate-600: #a1a1aa; --slate-700: #c4c4cc; --slate-800: #dedee2;
|
--slate-300: #44444d; --slate-400: #8a8a95; --slate-500: #a6a6b0;
|
||||||
--slate-900: #f1f1f3; --slate-950: #fafafa;
|
--slate-600: #b4b4bd; --slate-700: #c8c8d0; --slate-800: #e0e0e4;
|
||||||
|
--slate-900: #f2f2f4; --slate-950: #fafafa;
|
||||||
--gray-400: #6b7280; --gray-500: #9ca3af; --gray-700: #d1d5db;
|
--gray-400: #6b7280; --gray-500: #9ca3af; --gray-700: #d1d5db;
|
||||||
--canvas: #0a0a0c; --surface: #16161a; --surface-2: #202024;
|
--canvas: #18181c; --surface: #202024; --surface-2: #2a2a30;
|
||||||
--hairline: #2e2e34; --hairline-soft: #202024;
|
--hairline: #3a3a42; --hairline-soft: #2a2a30;
|
||||||
--ink: #e7e7ea; --muted: #a1a1aa;
|
--ink: #e7e7ea; --muted: #a1a1aa;
|
||||||
--scrollbar-thumb: #3f3f46; --scrollbar-thumb-hover: #52525b;
|
--scrollbar-thumb: #3f3f46; --scrollbar-thumb-hover: #52525b;
|
||||||
--accent-on-dark: #fafafa; --accent-on-dark-fg: #18181b;
|
--accent-on-dark: #fafafa; --accent-on-dark-fg: #18181b;
|
||||||
|
/* Dark default for the selected/active soft tint (no-branding case). */
|
||||||
|
--brand-primary-soft: #2c2c34;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* When stored pref is 'system' the inline script sets data-theme already,
|
/* When stored pref is 'system' the inline script sets data-theme already,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user