dsh-taskboard 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +44 -147
- package/lib/client.js +208 -62
- package/lib/host/execution.js +5 -6
- package/lib/host/execution.js.map +1 -1
- package/lib/index.js +8 -0
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/client/board/AlertModal.tsx +36 -0
- package/src/client/board/TaskBoard.tsx +31 -31
- package/src/client/board/TaskCard.tsx +12 -2
- package/src/client/board/TaskDetail.tsx +12 -7
- package/src/client/board/TaskFormModal.tsx +46 -0
- package/src/client/controller.ts +8 -4
- package/src/client/styles.ts +27 -4
- package/src/host/execution.ts +13 -3
- package/src/index.ts +10 -0
package/lib/host/execution.js
CHANGED
|
@@ -126,6 +126,9 @@ var ExecutionService = class {
|
|
|
126
126
|
};
|
|
127
127
|
}
|
|
128
128
|
await this.deps.workspaces.attach(task.workspaceId, sessionId).catch(() => {});
|
|
129
|
+
try {
|
|
130
|
+
this.deps.renameSession?.(sessionId, task.title);
|
|
131
|
+
} catch {}
|
|
129
132
|
await this.patchExecution(executionId, { sessionId });
|
|
130
133
|
const message = {
|
|
131
134
|
id: this.deps.mintMessageId?.() ?? MessageId(`msg-taskboard-${crypto.randomUUID()}`),
|
|
@@ -134,10 +137,7 @@ var ExecutionService = class {
|
|
|
134
137
|
type: "text",
|
|
135
138
|
text: this.executionPrompt(task)
|
|
136
139
|
}],
|
|
137
|
-
source: {
|
|
138
|
-
kind: "plugin",
|
|
139
|
-
plugin: "dsh-taskboard"
|
|
140
|
-
}
|
|
140
|
+
source: { kind: "user" }
|
|
141
141
|
};
|
|
142
142
|
handle.agent.followup(message);
|
|
143
143
|
const settle = () => {
|
|
@@ -166,10 +166,9 @@ var ExecutionService = class {
|
|
|
166
166
|
}
|
|
167
167
|
/** The prompt text one execution submits (task context + instructions). */
|
|
168
168
|
executionPrompt(task) {
|
|
169
|
-
const head = `【任务看板执行】${task.title}(任务 ID: ${task.id})`;
|
|
170
169
|
const state = "本任务由执行服务启动本会话并已置为 in_progress(你无需再认领,也无需移到 done)。";
|
|
171
170
|
const tail = `完成后请:1) 用 taskboard_get 读取任务 ${task.id} 拿最新 version;2) 用 taskboard_comment_add 留评论(做了什么改动、如何验证、剩余风险);3) 用 taskboard_move 把任务 ${task.id} 移到 in_review(带 ifVersion)。`;
|
|
172
|
-
return
|
|
171
|
+
return `【任务】${task.title}(任务 ID: ${task.id})\n\n${state}\n\n${effectivePrompt(task)}\n\n${tail}`;
|
|
173
172
|
}
|
|
174
173
|
/** Move a task back out of in_progress after a failed start. */
|
|
175
174
|
async revertProgress(taskId) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"execution.js","names":[],"sources":["../../src/host/execution.ts"],"sourcesContent":["/**\n * Host execution service: runs a task through dsh's REAL session machinery —\n * a fresh agent+session inside the task's project workspace (creation carries\n * the pinned model when the task has one), the session is attached to the\n * workspace so it appears in the GUI's project session list, the effective\n * prompt is submitted as an ordinary user message, and the turn settlement\n * (turn/end reason) is folded back into the task's execution record.\n *\n * Every execution is a NEW session: clean context, no reuse of previous runs.\n *\n * @module dsh-taskboard/host/execution\n */\nimport { effectivePrompt, newExecutionId, type ExecutionRecord, type TaskRecord } from '../shared/protocol.ts'\nimport { MessageId } from './sdk.ts'\nimport type { TaskStore } from './store.ts'\n\n/** Narrow agents face (the registry's create, structurally). */\nexport interface AgentsFace {\n create(options: {\n sessionId: string\n meta?: { cwd?: string }\n agentOptions?: { provider?: string; model?: string }\n }): Promise<{\n agent: {\n id: string\n followup(message: unknown): void\n whenIdle(): Promise<void>\n }\n dispose(): Promise<void>\n }>\n}\n\n/** Narrow workspaces face for execution. */\nexport interface ExecutionWorkspaceFace {\n get(id: string): { id: string; path: string } | undefined\n attach(workspaceId: string, sessionId: string): Promise<void>\n}\n\n/** Narrow event-bus face for settlement listening. */\nexport interface EventsFace {\n onSessionEvent(listener: (sessionId: string, event: { type: string; data?: unknown }) => void): () => void\n}\n\n/** Everything the execution service needs. */\nexport interface ExecutionDeps {\n store: TaskStore\n agents: AgentsFace\n workspaces: ExecutionWorkspaceFace\n events: EventsFace\n now: () => number\n /** The deployment default model (fills sessions of unpinned tasks). */\n defaultModel?: () => { provider: string; model: string } | undefined\n /** Mint session ids (injectable for tests). */\n mintSessionId?: () => string\n /** Mint message ids (injectable for tests). */\n mintMessageId?: () => string\n}\n\n/** Outcome of a run request (immediate; the run settles asynchronously). */\nexport type RunRequestResult =\n | { ok: true; executionId: string; sessionId: string }\n | { ok: false; error: string }\n\n/** Whether a turn/end payload closed with an error reason. */\nfunction isErrorTurnEnd(data: unknown): { message: string } | undefined {\n if (typeof data !== 'object' || data === null) return undefined\n const reason = (data as { reason?: unknown }).reason\n if (typeof reason !== 'object' || reason === null) return undefined\n const kind = (reason as { kind?: unknown }).kind\n if (kind !== 'error') return undefined\n const error = (reason as { error?: { message?: unknown } }).error\n const detail = JSON.stringify(error) ?? ''\n const message = typeof error?.message === 'string' ? error.message : 'turn failed'\n console.error('[dsh-taskboard] turn error detail:', detail.slice(0, 2000))\n void detail\n return { message }\n}\n\n/**\n * The execution service.\n */\nexport class ExecutionService {\n /** Execution ids currently settling. */\n private readonly settling = new Map<string, () => void>()\n\n /** @param deps - store + agents + workspaces + events + clock. */\n constructor(private readonly deps: ExecutionDeps) {\n deps.events.onSessionEvent((sessionId, event) => {\n if (event.type !== 'turn/end') return\n const failure = isErrorTurnEnd(event.data)\n if (failure !== undefined) this.noteFailure(sessionId, failure.message)\n })\n }\n\n /** Record a turn failure against the running execution of that session. */\n private noteFailure(sessionId: string, message: string): void {\n void this.deps.store.mutate('execution-recorded', (ledger) => {\n for (const task of ledger.tasks) {\n for (const execution of task.executions) {\n if (execution.sessionId === sessionId && execution.outcome === 'running') {\n execution.outcome = 'failed'\n execution.error = message.slice(0, 500)\n execution.endedAt = this.deps.now()\n return [task]\n }\n }\n }\n return undefined\n })\n }\n\n /** Patch one task's execution record in the ledger. */\n private async patchExecution(executionId: string, patch: Partial<ExecutionRecord>): Promise<void> {\n await this.deps.store.mutate('execution-recorded', (ledger) => {\n for (const task of ledger.tasks) {\n const execution = task.executions.find(e => e.id === executionId)\n if (execution !== undefined) {\n Object.assign(execution, patch)\n return [task]\n }\n }\n return undefined\n })\n }\n\n /**\n * Run one task now (manual button or scheduler tick).\n * @param taskId - the task to run.\n * @param trigger - what started it.\n * @returns the immediate result; settlement lands in the ledger.\n */\n async run(taskId: string, trigger: ExecutionRecord['trigger']): Promise<RunRequestResult> {\n const task = this.deps.store.get(taskId)\n if (task === undefined || task.trashedAt !== undefined) {\n return { ok: false, error: `no task ${taskId}` }\n }\n if (task.status === 'in_progress') {\n return { ok: false, error: 'task is already in progress' }\n }\n const workspace = this.deps.workspaces.get(task.workspaceId)\n if (workspace === undefined) {\n return { ok: false, error: `unknown workspace ${task.workspaceId}` }\n }\n\n const executionId = newExecutionId()\n const sessionId = this.deps.mintSessionId?.() ?? `session-taskboard-${crypto.randomUUID()}`\n\n // 1. Open the execution record and move the card to in_progress in one write.\n await this.deps.store.mutate('execution-recorded', (ledger) => {\n const target = ledger.tasks.find(t => t.id === taskId)\n if (target === undefined) return undefined\n target.executions.push({\n id: executionId,\n trigger,\n startedAt: this.deps.now(),\n outcome: 'running',\n })\n target.status = 'in_progress'\n target.updatedAt = this.deps.now()\n target.updatedBy = { kind: 'user' }\n return [target]\n })\n\n // 2. Create the fresh agent+session inside the task's project, carrying\n // the pinned model — or the deployment default when unpinned (the\n // persona template renders {{model}}, so the session always needs one).\n let handle: Awaited<ReturnType<AgentsFace['create']>>\n try {\n const model = task.model ?? this.deps.defaultModel?.()\n handle = await this.deps.agents.create({\n sessionId,\n meta: { cwd: workspace.path },\n ...(model !== undefined ? { agentOptions: { provider: model.provider, model: model.model } } : {}),\n })\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error)\n await this.patchExecution(executionId, { outcome: 'failed', error: message.slice(0, 500), endedAt: this.deps.now() })\n await this.revertProgress(taskId)\n return { ok: false, error: message }\n }\n\n // 3. Attach the session to the workspace (GUI project session list).\n await this.deps.workspaces.attach(task.workspaceId, sessionId).catch(() => { /* cosmetic */ })\n\n // 4. Record the session id (execution is really started now).\n await this.patchExecution(executionId, { sessionId })\n\n // 5. Submit the effective prompt as an ordinary user message and settle\n // on quiescence (turn/end errors were already folded by the listener).\n const message = {\n id: this.deps.mintMessageId?.() ?? MessageId(`msg-taskboard-${crypto.randomUUID()}`),\n role: 'user' as const,\n content: [{ type: 'text' as const, text: this.executionPrompt(task) }],\n source: { kind: 'plugin' as const, plugin: 'dsh-taskboard' },\n }\n handle.agent.followup(message)\n\n // 6. Settlement watcher.\n const settle = (): void => {\n this.settling.delete(executionId)\n void this.deps.store.mutate('execution-recorded', (ledger) => {\n for (const t of ledger.tasks) {\n const execution = t.executions.find(e => e.id === executionId)\n if (execution !== undefined && execution.outcome === 'running') {\n execution.outcome = 'succeeded'\n execution.endedAt = this.deps.now()\n return [t]\n }\n }\n return undefined\n })\n }\n this.settling.set(executionId, settle)\n void handle.agent.whenIdle().then(settle, () => {\n this.noteFailure(sessionId, 'agent did not reach quiescence')\n settle()\n })\n\n return { ok: true, executionId, sessionId }\n }\n\n /** The prompt text one execution submits (task context + instructions). */\n private executionPrompt(task: TaskRecord): string {\n const head = `【任务看板执行】${task.title}(任务 ID: ${task.id})`\n const state = '本任务由执行服务启动本会话并已置为 in_progress(你无需再认领,也无需移到 done)。'\n const tail = `完成后请:1) 用 taskboard_get 读取任务 ${task.id} 拿最新 version;`\n + `2) 用 taskboard_comment_add 留评论(做了什么改动、如何验证、剩余风险);`\n + `3) 用 taskboard_move 把任务 ${task.id} 移到 in_review(带 ifVersion)。`\n return `${head}\\n\\n${state}\\n\\n${effectivePrompt(task)}\\n\\n${tail}`\n }\n\n /** Move a task back out of in_progress after a failed start. */\n private async revertProgress(taskId: string): Promise<void> {\n await this.deps.store.mutate('execution-recorded', (ledger) => {\n const target = ledger.tasks.find(t => t.id === taskId)\n if (target !== undefined && target.status === 'in_progress') {\n target.status = 'todo'\n target.updatedAt = this.deps.now()\n return [target]\n }\n return undefined\n })\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"execution.js","names":[],"sources":["../../src/host/execution.ts"],"sourcesContent":["/**\n * Host execution service: runs a task through dsh's REAL session machinery —\n * a fresh agent+session inside the task's project workspace (creation carries\n * the pinned model when the task has one), the session is attached to the\n * workspace so it appears in the GUI's project session list, the effective\n * prompt is submitted as an ordinary user message, and the turn settlement\n * (turn/end reason) is folded back into the task's execution record.\n *\n * Every execution is a NEW session: clean context, no reuse of previous runs.\n *\n * @module dsh-taskboard/host/execution\n */\nimport { effectivePrompt, newExecutionId, type ExecutionRecord, type TaskRecord } from '../shared/protocol.ts'\nimport { MessageId } from './sdk.ts'\nimport type { TaskStore } from './store.ts'\n\n/** Narrow agents face (the registry's create, structurally). */\nexport interface AgentsFace {\n create(options: {\n sessionId: string\n meta?: { cwd?: string }\n agentOptions?: { provider?: string; model?: string }\n }): Promise<{\n agent: {\n id: string\n followup(message: unknown): void\n whenIdle(): Promise<void>\n }\n dispose(): Promise<void>\n }>\n}\n\n/** Narrow workspaces face for execution. */\nexport interface ExecutionWorkspaceFace {\n get(id: string): { id: string; path: string } | undefined\n attach(workspaceId: string, sessionId: string): Promise<void>\n}\n\n/** Narrow event-bus face for settlement listening. */\nexport interface EventsFace {\n onSessionEvent(listener: (sessionId: string, event: { type: string; data?: unknown }) => void): () => void\n}\n\n/** Everything the execution service needs. */\nexport interface ExecutionDeps {\n store: TaskStore\n agents: AgentsFace\n workspaces: ExecutionWorkspaceFace\n events: EventsFace\n now: () => number\n /** The deployment default model (fills sessions of unpinned tasks). */\n defaultModel?: () => { provider: string; model: string } | undefined\n /** Mint session ids (injectable for tests). */\n mintSessionId?: () => string\n /** Mint message ids (injectable for tests). */\n mintMessageId?: () => string\n /** Best-effort session rename (pins the session list title to the task title). */\n renameSession?: (sessionId: string, title: string) => void\n}\n\n/** Outcome of a run request (immediate; the run settles asynchronously). */\nexport type RunRequestResult =\n | { ok: true; executionId: string; sessionId: string }\n | { ok: false; error: string }\n\n/** Whether a turn/end payload closed with an error reason. */\nfunction isErrorTurnEnd(data: unknown): { message: string } | undefined {\n if (typeof data !== 'object' || data === null) return undefined\n const reason = (data as { reason?: unknown }).reason\n if (typeof reason !== 'object' || reason === null) return undefined\n const kind = (reason as { kind?: unknown }).kind\n if (kind !== 'error') return undefined\n const error = (reason as { error?: { message?: unknown } }).error\n const detail = JSON.stringify(error) ?? ''\n const message = typeof error?.message === 'string' ? error.message : 'turn failed'\n console.error('[dsh-taskboard] turn error detail:', detail.slice(0, 2000))\n void detail\n return { message }\n}\n\n/**\n * The execution service.\n */\nexport class ExecutionService {\n /** Execution ids currently settling. */\n private readonly settling = new Map<string, () => void>()\n\n /** @param deps - store + agents + workspaces + events + clock. */\n constructor(private readonly deps: ExecutionDeps) {\n deps.events.onSessionEvent((sessionId, event) => {\n if (event.type !== 'turn/end') return\n const failure = isErrorTurnEnd(event.data)\n if (failure !== undefined) this.noteFailure(sessionId, failure.message)\n })\n }\n\n /** Record a turn failure against the running execution of that session. */\n private noteFailure(sessionId: string, message: string): void {\n void this.deps.store.mutate('execution-recorded', (ledger) => {\n for (const task of ledger.tasks) {\n for (const execution of task.executions) {\n if (execution.sessionId === sessionId && execution.outcome === 'running') {\n execution.outcome = 'failed'\n execution.error = message.slice(0, 500)\n execution.endedAt = this.deps.now()\n return [task]\n }\n }\n }\n return undefined\n })\n }\n\n /** Patch one task's execution record in the ledger. */\n private async patchExecution(executionId: string, patch: Partial<ExecutionRecord>): Promise<void> {\n await this.deps.store.mutate('execution-recorded', (ledger) => {\n for (const task of ledger.tasks) {\n const execution = task.executions.find(e => e.id === executionId)\n if (execution !== undefined) {\n Object.assign(execution, patch)\n return [task]\n }\n }\n return undefined\n })\n }\n\n /**\n * Run one task now (manual button or scheduler tick).\n * @param taskId - the task to run.\n * @param trigger - what started it.\n * @returns the immediate result; settlement lands in the ledger.\n */\n async run(taskId: string, trigger: ExecutionRecord['trigger']): Promise<RunRequestResult> {\n const task = this.deps.store.get(taskId)\n if (task === undefined || task.trashedAt !== undefined) {\n return { ok: false, error: `no task ${taskId}` }\n }\n if (task.status === 'in_progress') {\n return { ok: false, error: 'task is already in progress' }\n }\n const workspace = this.deps.workspaces.get(task.workspaceId)\n if (workspace === undefined) {\n return { ok: false, error: `unknown workspace ${task.workspaceId}` }\n }\n\n const executionId = newExecutionId()\n const sessionId = this.deps.mintSessionId?.() ?? `session-taskboard-${crypto.randomUUID()}`\n\n // 1. Open the execution record and move the card to in_progress in one write.\n await this.deps.store.mutate('execution-recorded', (ledger) => {\n const target = ledger.tasks.find(t => t.id === taskId)\n if (target === undefined) return undefined\n target.executions.push({\n id: executionId,\n trigger,\n startedAt: this.deps.now(),\n outcome: 'running',\n })\n target.status = 'in_progress'\n target.updatedAt = this.deps.now()\n target.updatedBy = { kind: 'user' }\n return [target]\n })\n\n // 2. Create the fresh agent+session inside the task's project, carrying\n // the pinned model — or the deployment default when unpinned (the\n // persona template renders {{model}}, so the session always needs one).\n let handle: Awaited<ReturnType<AgentsFace['create']>>\n try {\n const model = task.model ?? this.deps.defaultModel?.()\n handle = await this.deps.agents.create({\n sessionId,\n meta: { cwd: workspace.path },\n ...(model !== undefined ? { agentOptions: { provider: model.provider, model: model.model } } : {}),\n })\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error)\n await this.patchExecution(executionId, { outcome: 'failed', error: message.slice(0, 500), endedAt: this.deps.now() })\n await this.revertProgress(taskId)\n return { ok: false, error: message }\n }\n\n // 3. Attach the session to the workspace (GUI project session list).\n await this.deps.workspaces.attach(task.workspaceId, sessionId).catch(() => { /* cosmetic */ })\n\n // 3b. Best-effort rename: pin the session title to the task title so the\n // session list shows the task name (a user-sourced title also stops\n // automatic first-prompt retitling).\n try {\n this.deps.renameSession?.(sessionId, task.title)\n } catch { /* cosmetic */ }\n\n // 4. Record the session id (execution is really started now).\n await this.patchExecution(executionId, { sessionId })\n\n // 5. Submit the effective prompt as an ordinary user message and settle\n // on quiescence (turn/end errors were already folded by the listener).\n // Source `user` (not `plugin`) so the opening message renders as a\n // normal user bubble in the conversation, exactly like a typed prompt.\n const message = {\n id: this.deps.mintMessageId?.() ?? MessageId(`msg-taskboard-${crypto.randomUUID()}`),\n role: 'user' as const,\n content: [{ type: 'text' as const, text: this.executionPrompt(task) }],\n source: { kind: 'user' as const },\n }\n handle.agent.followup(message)\n\n // 6. Settlement watcher.\n const settle = (): void => {\n this.settling.delete(executionId)\n void this.deps.store.mutate('execution-recorded', (ledger) => {\n for (const t of ledger.tasks) {\n const execution = t.executions.find(e => e.id === executionId)\n if (execution !== undefined && execution.outcome === 'running') {\n execution.outcome = 'succeeded'\n execution.endedAt = this.deps.now()\n return [t]\n }\n }\n return undefined\n })\n }\n this.settling.set(executionId, settle)\n void handle.agent.whenIdle().then(settle, () => {\n this.noteFailure(sessionId, 'agent did not reach quiescence')\n settle()\n })\n\n return { ok: true, executionId, sessionId }\n }\n\n /** The prompt text one execution submits (task context + instructions). */\n private executionPrompt(task: TaskRecord): string {\n const state = '本任务由执行服务启动本会话并已置为 in_progress(你无需再认领,也无需移到 done)。'\n const tail = `完成后请:1) 用 taskboard_get 读取任务 ${task.id} 拿最新 version;`\n + `2) 用 taskboard_comment_add 留评论(做了什么改动、如何验证、剩余风险);`\n + `3) 用 taskboard_move 把任务 ${task.id} 移到 in_review(带 ifVersion)。`\n return `【任务】${task.title}(任务 ID: ${task.id})\\n\\n${state}\\n\\n${effectivePrompt(task)}\\n\\n${tail}`\n }\n\n /** Move a task back out of in_progress after a failed start. */\n private async revertProgress(taskId: string): Promise<void> {\n await this.deps.store.mutate('execution-recorded', (ledger) => {\n const target = ledger.tasks.find(t => t.id === taskId)\n if (target !== undefined && target.status === 'in_progress') {\n target.status = 'todo'\n target.updatedAt = this.deps.now()\n return [target]\n }\n return undefined\n })\n }\n}\n"],"mappings":"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"}
|
package/lib/index.js
CHANGED
|
@@ -48,6 +48,14 @@ function apply(ctx) {
|
|
|
48
48
|
},
|
|
49
49
|
events,
|
|
50
50
|
now,
|
|
51
|
+
renameSession: (sessionId, title) => {
|
|
52
|
+
try {
|
|
53
|
+
const sessions = agentCtx.get("sessions");
|
|
54
|
+
const sessionTitle = agentCtx.get("sessionTitle");
|
|
55
|
+
const session = sessions?.get(sessionId);
|
|
56
|
+
if (session !== void 0 && sessionTitle !== void 0) sessionTitle.rename(session, title);
|
|
57
|
+
} catch {}
|
|
58
|
+
},
|
|
51
59
|
defaultModel: () => {
|
|
52
60
|
try {
|
|
53
61
|
const selection = agentCtx.get("agentDefaultModel");
|
package/lib/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","names":[],"sources":["../src/index.ts"],"sourcesContent":["/**\n * Host loader entry for dsh-taskboard.\n *\n * Wiring: the ledger store (one JSON file under the DSH home), the eight\n * `taskboard_*` agent tools, the agent workflow-protocol system-prompt\n * section, the /taskboard JSON+SSE routes (when a webServer is served),\n * the host execution service (fresh in-project sessions, pinned models), and\n * the host-side cron scheduler for scheduled tasks.\n *\n * Export shape follows the dsh-tool-todo lesson: a function/namespace plugin —\n * `name` / `inject` / `apply`, NO default export.\n *\n * @module dsh-taskboard\n */\nimport type { Context } from '@deepseek-ai/cordis'\n// Type-only module imports: they load the cordis Context augmentations\n// (ctx.tools / ctx.systemPrompt / ctx.agents) and vanish at compile time —\n// the built host half keeps ZERO runtime @deepseek-ai imports.\nimport type {} from '@deepseek-ai/dsh-tools'\nimport type {} from '@deepseek-ai/dsh-system-prompt'\nimport type {} from '@deepseek-ai/dsh-agent'\nimport { PROTOCOL_SECTION_NAME, PROTOCOL_SECTION_ORDER, TASKBOARD_PROTOCOL } from './host/protocol-text.ts'\nimport { ExecutionService, type EventsFace } from './host/execution.ts'\nimport { registerTaskboardRoutes } from './host/routes.ts'\nimport { SchedulerService } from './host/scheduler.ts'\nimport { dshHomePath } from './host/sdk.ts'\nimport { TaskStore } from './host/store.ts'\nimport { registerTaskboardTools, workspaceFace } from './host/tools.ts'\n\n/** Ledger file name under the DSH home. */\nexport const LEDGER_FILE = 'dsh-taskboard.json'\n\n/** Cordis plugin name. */\nexport const name = 'dsh-taskboard'\n\n/** Required host services (tool registry + prompt assembly). */\nexport const inject = ['tools', 'systemPrompt']\n\n/**\n * Mount the host half.\n * @param ctx - the plugin context (tools + systemPrompt injected).\n */\nexport function apply(ctx: Context): void {\n const store = new TaskStore({ file: dshHomePath(LEDGER_FILE) })\n const now = () => Date.now()\n\n // Agent workflow protocol (claim discipline, retry rules, done-gate).\n const disposeSection = ctx.systemPrompt.section({\n name: PROTOCOL_SECTION_NAME,\n order: PROTOCOL_SECTION_ORDER,\n text: TASKBOARD_PROTOCOL,\n })\n ctx.effect(() => disposeSection, 'dsh-taskboard: protocol section')\n\n // Tools, routes, execution, and the scheduler all come up with the\n // workspace registry (claim boundary + project execution need it).\n ctx.inject(['workspaceRegistry'], (wsCtx: Context) => {\n const disposers: Array<() => void> = []\n disposers.push(...registerTaskboardTools(wsCtx, {\n store,\n workspaces: workspaceFace(wsCtx.workspaceRegistry),\n now,\n }))\n\n // Settlement listener over the session event bus.\n const events: EventsFace = {\n onSessionEvent: (listener) => wsCtx.on('session/event', (session, event) => {\n listener(session.id, event as { type: string; data?: unknown })\n }),\n }\n\n wsCtx.inject(['agents'], (agentCtx: Context) => {\n const execution = new ExecutionService({\n store,\n agents: {\n create: (options): Promise<never> => agentCtx.agents.create(options as never) as Promise<never>,\n },\n workspaces: {\n get: id => workspaceFace(wsCtx.workspaceRegistry).get(id),\n attach: async (workspaceId, sessionId) => {\n const ws = wsCtx.workspaceRegistry.get(workspaceId as never)\n if (ws !== undefined) await ws.attachSession(sessionId as never)\n },\n },\n events,\n now,\n defaultModel: () => {\n try {\n const selection = agentCtx.get('agentDefaultModel') as { currentSelection?: () => { provider: string; model: string } | undefined } | undefined\n const read = selection?.currentSelection\n return read === undefined ? undefined : read.call(selection)\n } catch { return undefined }\n },\n })\n\n // /dsh-taskboard routes (the run action reaches the execution service).\n let disposeRoutes: (() => void) | undefined\n agentCtx.inject(['webServer'], (webCtx: Context) => {\n disposeRoutes = registerTaskboardRoutes(webCtx, {\n store,\n workspaces: workspaceFace(wsCtx.workspaceRegistry),\n now,\n run: (taskId: string) => execution.run(taskId, 'manual'),\n })\n return () => disposeRoutes?.()\n })\n\n // Host-side cron scheduler: due scheduled tasks execute even with no\n // browser open.\n const scheduler = new SchedulerService({ store, execution, now })\n scheduler.start()\n disposers.push(() => scheduler.dispose())\n\n return () => {\n disposeRoutes?.()\n for (const dispose of disposers.splice(0)) dispose()\n }\n })\n\n return () => {\n for (const dispose of disposers.splice(0)) dispose()\n }\n })\n}\n"],"mappings":";;;;;;;;;AA8BA,MAAa,cAAc;;AAG3B,MAAa,OAAO;;AAGpB,MAAa,SAAS,CAAC,SAAS,cAAc;;;;;AAM9C,SAAgB,MAAM,KAAoB;CACxC,MAAM,QAAQ,IAAI,UAAU,EAAE,MAAM,YAAY,WAAW,EAAE,CAAC;CAC9D,MAAM,YAAY,KAAK,IAAI;CAG3B,MAAM,iBAAiB,IAAI,aAAa,QAAQ;EAC9C,MAAM;EACN,OAAA;EACA,MAAM;CACR,CAAC;CACD,IAAI,aAAa,gBAAgB,iCAAiC;CAIlE,IAAI,OAAO,CAAC,mBAAmB,IAAI,UAAmB;EACpD,MAAM,YAA+B,CAAC;EACtC,UAAU,KAAK,GAAG,uBAAuB,OAAO;GAC9C;GACA,YAAY,cAAc,MAAM,iBAAiB;GACjD;EACF,CAAC,CAAC;EAGF,MAAM,SAAqB,EACzB,iBAAiB,aAAa,MAAM,GAAG,kBAAkB,SAAS,UAAU;GAC1E,SAAS,QAAQ,IAAI,KAAyC;EAChE,CAAC,EACH;EAEA,MAAM,OAAO,CAAC,QAAQ,IAAI,aAAsB;GAC9C,MAAM,YAAY,IAAI,iBAAiB;IACrC;IACA,QAAQ,EACN,SAAS,YAA4B,SAAS,OAAO,OAAO,OAAgB,EAC9E;IACA,YAAY;KACV,MAAK,OAAM,cAAc,MAAM,iBAAiB,CAAC,CAAC,IAAI,EAAE;KACxD,QAAQ,OAAO,aAAa,cAAc;MACxC,MAAM,KAAK,MAAM,kBAAkB,IAAI,WAAoB;MAC3D,IAAI,OAAO,KAAA,GAAW,MAAM,GAAG,cAAc,SAAkB;KACjE;IACF;IACA;IACA;IACA,oBAAoB;KAClB,IAAI;MACF,MAAM,YAAY,SAAS,IAAI,mBAAmB;MAClD,MAAM,OAAO,WAAW;MACxB,OAAO,SAAS,KAAA,IAAY,KAAA,IAAY,KAAK,KAAK,SAAS;KAC7D,QAAQ;MAAE;KAAiB;IAC7B;GACF,CAAC;GAGD,IAAI;GACJ,SAAS,OAAO,CAAC,WAAW,IAAI,WAAoB;IAClD,gBAAgB,wBAAwB,QAAQ;KAC9C;KACA,YAAY,cAAc,MAAM,iBAAiB;KACjD;KACA,MAAM,WAAmB,UAAU,IAAI,QAAQ,QAAQ;IACzD,CAAC;IACD,aAAa,gBAAgB;GAC/B,CAAC;GAID,MAAM,YAAY,IAAI,iBAAiB;IAAE;IAAO;IAAW;GAAI,CAAC;GAChE,UAAU,MAAM;GAChB,UAAU,WAAW,UAAU,QAAQ,CAAC;GAExC,aAAa;IACX,gBAAgB;IAChB,KAAK,MAAM,WAAW,UAAU,OAAO,CAAC,GAAG,QAAQ;GACrD;EACF,CAAC;EAED,aAAa;GACX,KAAK,MAAM,WAAW,UAAU,OAAO,CAAC,GAAG,QAAQ;EACrD;CACF,CAAC;AACH"}
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../src/index.ts"],"sourcesContent":["/**\n * Host loader entry for dsh-taskboard.\n *\n * Wiring: the ledger store (one JSON file under the DSH home), the eight\n * `taskboard_*` agent tools, the agent workflow-protocol system-prompt\n * section, the /taskboard JSON+SSE routes (when a webServer is served),\n * the host execution service (fresh in-project sessions, pinned models), and\n * the host-side cron scheduler for scheduled tasks.\n *\n * Export shape follows the dsh-tool-todo lesson: a function/namespace plugin —\n * `name` / `inject` / `apply`, NO default export.\n *\n * @module dsh-taskboard\n */\nimport type { Context } from '@deepseek-ai/cordis'\n// Type-only module imports: they load the cordis Context augmentations\n// (ctx.tools / ctx.systemPrompt / ctx.agents) and vanish at compile time —\n// the built host half keeps ZERO runtime @deepseek-ai imports.\nimport type {} from '@deepseek-ai/dsh-tools'\nimport type {} from '@deepseek-ai/dsh-system-prompt'\nimport type {} from '@deepseek-ai/dsh-agent'\nimport { PROTOCOL_SECTION_NAME, PROTOCOL_SECTION_ORDER, TASKBOARD_PROTOCOL } from './host/protocol-text.ts'\nimport { ExecutionService, type EventsFace } from './host/execution.ts'\nimport { registerTaskboardRoutes } from './host/routes.ts'\nimport { SchedulerService } from './host/scheduler.ts'\nimport { dshHomePath } from './host/sdk.ts'\nimport { TaskStore } from './host/store.ts'\nimport { registerTaskboardTools, workspaceFace } from './host/tools.ts'\n\n/** Ledger file name under the DSH home. */\nexport const LEDGER_FILE = 'dsh-taskboard.json'\n\n/** Cordis plugin name. */\nexport const name = 'dsh-taskboard'\n\n/** Required host services (tool registry + prompt assembly). */\nexport const inject = ['tools', 'systemPrompt']\n\n/**\n * Mount the host half.\n * @param ctx - the plugin context (tools + systemPrompt injected).\n */\nexport function apply(ctx: Context): void {\n const store = new TaskStore({ file: dshHomePath(LEDGER_FILE) })\n const now = () => Date.now()\n\n // Agent workflow protocol (claim discipline, retry rules, done-gate).\n const disposeSection = ctx.systemPrompt.section({\n name: PROTOCOL_SECTION_NAME,\n order: PROTOCOL_SECTION_ORDER,\n text: TASKBOARD_PROTOCOL,\n })\n ctx.effect(() => disposeSection, 'dsh-taskboard: protocol section')\n\n // Tools, routes, execution, and the scheduler all come up with the\n // workspace registry (claim boundary + project execution need it).\n ctx.inject(['workspaceRegistry'], (wsCtx: Context) => {\n const disposers: Array<() => void> = []\n disposers.push(...registerTaskboardTools(wsCtx, {\n store,\n workspaces: workspaceFace(wsCtx.workspaceRegistry),\n now,\n }))\n\n // Settlement listener over the session event bus.\n const events: EventsFace = {\n onSessionEvent: (listener) => wsCtx.on('session/event', (session, event) => {\n listener(session.id, event as { type: string; data?: unknown })\n }),\n }\n\n wsCtx.inject(['agents'], (agentCtx: Context) => {\n const execution = new ExecutionService({\n store,\n agents: {\n create: (options): Promise<never> => agentCtx.agents.create(options as never) as Promise<never>,\n },\n workspaces: {\n get: id => workspaceFace(wsCtx.workspaceRegistry).get(id),\n attach: async (workspaceId, sessionId) => {\n const ws = wsCtx.workspaceRegistry.get(workspaceId as never)\n if (ws !== undefined) await ws.attachSession(sessionId as never)\n },\n },\n events,\n now,\n renameSession: (sessionId, title) => {\n // Best-effort: pin the execution session's title to the task title\n // through the log-backed session-title service (user-sourced rename).\n try {\n const sessions = agentCtx.get('sessions') as { get(id: string): unknown } | undefined\n const sessionTitle = agentCtx.get('sessionTitle') as { rename(session: unknown, title: string): unknown } | undefined\n const session = sessions?.get(sessionId)\n if (session !== undefined && sessionTitle !== undefined) sessionTitle.rename(session, title)\n } catch { /* cosmetic */ }\n },\n defaultModel: () => {\n try {\n const selection = agentCtx.get('agentDefaultModel') as { currentSelection?: () => { provider: string; model: string } | undefined } | undefined\n const read = selection?.currentSelection\n return read === undefined ? undefined : read.call(selection)\n } catch { return undefined }\n },\n })\n\n // /dsh-taskboard routes (the run action reaches the execution service).\n let disposeRoutes: (() => void) | undefined\n agentCtx.inject(['webServer'], (webCtx: Context) => {\n disposeRoutes = registerTaskboardRoutes(webCtx, {\n store,\n workspaces: workspaceFace(wsCtx.workspaceRegistry),\n now,\n run: (taskId: string) => execution.run(taskId, 'manual'),\n })\n return () => disposeRoutes?.()\n })\n\n // Host-side cron scheduler: due scheduled tasks execute even with no\n // browser open.\n const scheduler = new SchedulerService({ store, execution, now })\n scheduler.start()\n disposers.push(() => scheduler.dispose())\n\n return () => {\n disposeRoutes?.()\n for (const dispose of disposers.splice(0)) dispose()\n }\n })\n\n return () => {\n for (const dispose of disposers.splice(0)) dispose()\n }\n })\n}\n"],"mappings":";;;;;;;;;AA8BA,MAAa,cAAc;;AAG3B,MAAa,OAAO;;AAGpB,MAAa,SAAS,CAAC,SAAS,cAAc;;;;;AAM9C,SAAgB,MAAM,KAAoB;CACxC,MAAM,QAAQ,IAAI,UAAU,EAAE,MAAM,YAAY,WAAW,EAAE,CAAC;CAC9D,MAAM,YAAY,KAAK,IAAI;CAG3B,MAAM,iBAAiB,IAAI,aAAa,QAAQ;EAC9C,MAAM;EACN,OAAA;EACA,MAAM;CACR,CAAC;CACD,IAAI,aAAa,gBAAgB,iCAAiC;CAIlE,IAAI,OAAO,CAAC,mBAAmB,IAAI,UAAmB;EACpD,MAAM,YAA+B,CAAC;EACtC,UAAU,KAAK,GAAG,uBAAuB,OAAO;GAC9C;GACA,YAAY,cAAc,MAAM,iBAAiB;GACjD;EACF,CAAC,CAAC;EAGF,MAAM,SAAqB,EACzB,iBAAiB,aAAa,MAAM,GAAG,kBAAkB,SAAS,UAAU;GAC1E,SAAS,QAAQ,IAAI,KAAyC;EAChE,CAAC,EACH;EAEA,MAAM,OAAO,CAAC,QAAQ,IAAI,aAAsB;GAC9C,MAAM,YAAY,IAAI,iBAAiB;IACrC;IACA,QAAQ,EACN,SAAS,YAA4B,SAAS,OAAO,OAAO,OAAgB,EAC9E;IACA,YAAY;KACV,MAAK,OAAM,cAAc,MAAM,iBAAiB,CAAC,CAAC,IAAI,EAAE;KACxD,QAAQ,OAAO,aAAa,cAAc;MACxC,MAAM,KAAK,MAAM,kBAAkB,IAAI,WAAoB;MAC3D,IAAI,OAAO,KAAA,GAAW,MAAM,GAAG,cAAc,SAAkB;KACjE;IACF;IACA;IACA;IACA,gBAAgB,WAAW,UAAU;KAGnC,IAAI;MACF,MAAM,WAAW,SAAS,IAAI,UAAU;MACxC,MAAM,eAAe,SAAS,IAAI,cAAc;MAChD,MAAM,UAAU,UAAU,IAAI,SAAS;MACvC,IAAI,YAAY,KAAA,KAAa,iBAAiB,KAAA,GAAW,aAAa,OAAO,SAAS,KAAK;KAC7F,QAAQ,CAAiB;IAC3B;IACA,oBAAoB;KAClB,IAAI;MACF,MAAM,YAAY,SAAS,IAAI,mBAAmB;MAClD,MAAM,OAAO,WAAW;MACxB,OAAO,SAAS,KAAA,IAAY,KAAA,IAAY,KAAK,KAAK,SAAS;KAC7D,QAAQ;MAAE;KAAiB;IAC7B;GACF,CAAC;GAGD,IAAI;GACJ,SAAS,OAAO,CAAC,WAAW,IAAI,WAAoB;IAClD,gBAAgB,wBAAwB,QAAQ;KAC9C;KACA,YAAY,cAAc,MAAM,iBAAiB;KACjD;KACA,MAAM,WAAmB,UAAU,IAAI,QAAQ,QAAQ;IACzD,CAAC;IACD,aAAa,gBAAgB;GAC/B,CAAC;GAID,MAAM,YAAY,IAAI,iBAAiB;IAAE;IAAO;IAAW;GAAI,CAAC;GAChE,UAAU,MAAM;GAChB,UAAU,WAAW,UAAU,QAAQ,CAAC;GAExC,aAAa;IACX,gBAAgB;IAChB,KAAK,MAAM,WAAW,UAAU,OAAO,CAAC,GAAG,QAAQ;GACrD;EACF,CAAC;EAED,aAAa;GACX,KAAK,MAAM,WAAW,UAAU,OAAO,CAAC,GAAG,QAAQ;EACrD;CACF,CAAC;AACH"}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-taskboard",
|
|
3
3
|
"description": "Agent-first task board for the DSH web GUI: host-authoritative task ledger with taskboard_* agent tools, project (= workspace) claim boundaries, per-task model execution in fresh sessions, host-side cron scheduling, and a live SSE kanban view. Mounts via the official dsh plugin system — no DSH source changes.",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.2",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"exports": {
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A lightweight alert modal — replaces native alert() with a themed overlay
|
|
3
|
+
* that matches the shell design tokens.
|
|
4
|
+
*
|
|
5
|
+
* @module dsh-taskboard/client/board/AlertModal
|
|
6
|
+
*/
|
|
7
|
+
import { useState, useEffect } from 'react'
|
|
8
|
+
|
|
9
|
+
/** Show a non-blocking alert modal. Returns true when opened. */
|
|
10
|
+
export function useAlert(): { alert: (msg: string) => void; el: React.ReactNode } {
|
|
11
|
+
const [msg, setMsg] = useState<string | null>(null)
|
|
12
|
+
const show = (m: string) => setMsg(m)
|
|
13
|
+
const close = () => setMsg(null)
|
|
14
|
+
const el = msg !== null
|
|
15
|
+
? <AlertModal message={msg} onClose={close} />
|
|
16
|
+
: null
|
|
17
|
+
return { alert: show, el }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function AlertModal({ message, onClose }: { message: string; onClose: () => void }) {
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
|
|
23
|
+
window.addEventListener('keydown', handler)
|
|
24
|
+
return () => window.removeEventListener('keydown', handler)
|
|
25
|
+
}, [onClose])
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<div className="dsh-atb-alert-backdrop" onClick={onClose}>
|
|
29
|
+
<div className="dsh-atb-alert" onClick={e => e.stopPropagation()}>
|
|
30
|
+
<div className="dsh-atb-alert-icon">⛔</div>
|
|
31
|
+
<div className="dsh-atb-alert-msg">{message}</div>
|
|
32
|
+
<button type="button" className="dsh-atb-btn" data-primary="true" onClick={onClose}>知道了</button>
|
|
33
|
+
</div>
|
|
34
|
+
</div>
|
|
35
|
+
)
|
|
36
|
+
}
|
|
@@ -7,10 +7,11 @@
|
|
|
7
7
|
import { useSyncExternalStore } from 'react'
|
|
8
8
|
import type { BoardController, ControllerState } from '../controller.ts'
|
|
9
9
|
import type { TaskRecord, TaskStatus, Urgency } from '../../shared/protocol.ts'
|
|
10
|
-
import { MAIN_STATUSES } from '../../shared/protocol.ts'
|
|
10
|
+
import { MAIN_STATUSES, canTransition } from '../../shared/protocol.ts'
|
|
11
11
|
import { DRAG_TYPE, TaskCard } from './TaskCard.tsx'
|
|
12
12
|
import { TaskDetail } from './TaskDetail.tsx'
|
|
13
13
|
import { TaskFormModal } from './TaskFormModal.tsx'
|
|
14
|
+
import { useAlert } from './AlertModal.tsx'
|
|
14
15
|
|
|
15
16
|
/** Column labels. */
|
|
16
17
|
const COLUMN_LABELS: Readonly<Record<TaskStatus, string>> = {
|
|
@@ -23,8 +24,6 @@ const COLUMN_LABELS: Readonly<Record<TaskStatus, string>> = {
|
|
|
23
24
|
archived: '已归档',
|
|
24
25
|
}
|
|
25
26
|
|
|
26
|
-
/** The two columns between which cards may be dragged both ways. */
|
|
27
|
-
const DRAGGABLE_STATUSES: ReadonlySet<TaskStatus> = new Set(['backlog', 'todo'])
|
|
28
27
|
|
|
29
28
|
/** Urgency chip labels. */
|
|
30
29
|
const URGENCY_LABELS: Readonly<Record<Urgency, string>> = {
|
|
@@ -59,12 +58,16 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
|
|
|
59
58
|
)
|
|
60
59
|
const live = filterTasks(state, state.ledger.tasks.filter(t => t.trashedAt === undefined))
|
|
61
60
|
const selected = state.selectedId === undefined ? undefined : state.ledger.tasks.find(t => t.id === state.selectedId)
|
|
61
|
+
const { alert: showAlert, el: alertEl } = useAlert()
|
|
62
62
|
|
|
63
63
|
return (
|
|
64
64
|
<div className="dsh-atb-board">
|
|
65
65
|
<div className="dsh-atb-toolbar">
|
|
66
66
|
<h2 className="dsh-atb-title">Agent 任务看板</h2>
|
|
67
67
|
<span className="dsh-atb-count">{live.length} 任务 · rev {state.ledger.revision}</span>
|
|
68
|
+
<button type="button" className="dsh-atb-btn" data-primary="true" onClick={() => controller.setComposer(true)}>
|
|
69
|
+
+ 新建任务
|
|
70
|
+
</button>
|
|
68
71
|
<div className="dsh-atb-spacer" />
|
|
69
72
|
<select
|
|
70
73
|
className="dsh-atb-select"
|
|
@@ -90,9 +93,6 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
|
|
|
90
93
|
<button type="button" className="dsh-atb-btn" onClick={() => controller.toggleSecondary()}>
|
|
91
94
|
{state.secondaryOpen ? '返回看板' : '其它任务'}
|
|
92
95
|
</button>
|
|
93
|
-
<button type="button" className="dsh-atb-btn" data-primary="true" onClick={() => controller.setComposer(true)}>
|
|
94
|
-
+ 新建任务
|
|
95
|
-
</button>
|
|
96
96
|
</div>
|
|
97
97
|
|
|
98
98
|
{state.error !== undefined && <div className="dsh-atb-error">{state.error}</div>}
|
|
@@ -103,34 +103,31 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
|
|
|
103
103
|
<div className="dsh-atb-columns">
|
|
104
104
|
{MAIN_STATUSES.map(status => {
|
|
105
105
|
const columnTasks = live.filter(t => t.status === status)
|
|
106
|
-
const dropTarget = DRAGGABLE_STATUSES.has(status)
|
|
107
106
|
return (
|
|
108
107
|
<div
|
|
109
108
|
className="dsh-atb-column"
|
|
110
109
|
key={status}
|
|
111
|
-
onDragOver={
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
}
|
|
133
|
-
: undefined}
|
|
110
|
+
onDragOver={(e) => {
|
|
111
|
+
if (e.dataTransfer.types.includes(DRAG_TYPE)) {
|
|
112
|
+
e.preventDefault()
|
|
113
|
+
e.dataTransfer.dropEffect = 'move'
|
|
114
|
+
e.currentTarget.dataset.dragover = 'true'
|
|
115
|
+
}
|
|
116
|
+
}}
|
|
117
|
+
onDragLeave={(e) => { delete e.currentTarget.dataset.dragover }}
|
|
118
|
+
onDrop={(e) => {
|
|
119
|
+
e.preventDefault()
|
|
120
|
+
delete e.currentTarget.dataset.dragover
|
|
121
|
+
const id = e.dataTransfer.getData(DRAG_TYPE)
|
|
122
|
+
if (id.length === 0) return
|
|
123
|
+
const task = state.ledger.tasks.find(t => t.id === id)
|
|
124
|
+
if (task === undefined || task.status === status) return
|
|
125
|
+
if (!canTransition(task.status, status)) {
|
|
126
|
+
showAlert(`无法从「${COLUMN_LABELS[task.status]}」拖至「${COLUMN_LABELS[status]}」`)
|
|
127
|
+
return
|
|
128
|
+
}
|
|
129
|
+
void controller.move(id, task.version, status)
|
|
130
|
+
}}
|
|
134
131
|
>
|
|
135
132
|
<div className="dsh-atb-colhead">
|
|
136
133
|
{COLUMN_LABELS[status]}
|
|
@@ -142,7 +139,8 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
|
|
|
142
139
|
key={task.id}
|
|
143
140
|
task={task}
|
|
144
141
|
controller={controller}
|
|
145
|
-
draggable
|
|
142
|
+
draggable
|
|
143
|
+
onAlert={showAlert}
|
|
146
144
|
/>
|
|
147
145
|
))}
|
|
148
146
|
{columnTasks.length === 0 && <div className="dsh-atb-empty">无任务</div>}
|
|
@@ -165,6 +163,8 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
|
|
|
165
163
|
task={state.editingId === undefined ? undefined : state.ledger.tasks.find(t => t.id === state.editingId)}
|
|
166
164
|
/>
|
|
167
165
|
)}
|
|
166
|
+
|
|
167
|
+
{alertEl}
|
|
168
168
|
</div>
|
|
169
169
|
)
|
|
170
170
|
}
|
|
@@ -20,9 +20,10 @@ export const DRAG_TYPE = 'application/x-dsh-atb-task'
|
|
|
20
20
|
* The card view.
|
|
21
21
|
* @param task - the task record.
|
|
22
22
|
* @param controller - the controller.
|
|
23
|
-
* @param draggable - enable dragging
|
|
23
|
+
* @param draggable - enable dragging.
|
|
24
|
+
* @param onAlert - show an alert message (replaces native alert).
|
|
24
25
|
*/
|
|
25
|
-
export function TaskCard({ task, controller, draggable = false }: { task: TaskRecord; controller: BoardController; draggable?: boolean }) {
|
|
26
|
+
export function TaskCard({ task, controller, draggable = false, onAlert }: { task: TaskRecord; controller: BoardController; draggable?: boolean; onAlert?: (msg: string) => void }) {
|
|
26
27
|
const last = task.executions.length > 0 ? task.executions[task.executions.length - 1] : undefined
|
|
27
28
|
return (
|
|
28
29
|
<button
|
|
@@ -31,6 +32,15 @@ export function TaskCard({ task, controller, draggable = false }: { task: TaskRe
|
|
|
31
32
|
data-urgency={task.urgency}
|
|
32
33
|
draggable={draggable}
|
|
33
34
|
onDragStart={(e) => {
|
|
35
|
+
// Block drag if a session is still executing this task
|
|
36
|
+
const running = task.executions.find(ex => ex.outcome === 'running')
|
|
37
|
+
if (running !== undefined) {
|
|
38
|
+
e.preventDefault()
|
|
39
|
+
const msg = `该任务正在由【${task.title}】会话执行,不能拖动`
|
|
40
|
+
if (onAlert !== undefined) onAlert(msg)
|
|
41
|
+
else alert(msg)
|
|
42
|
+
return
|
|
43
|
+
}
|
|
34
44
|
e.dataTransfer.setData(DRAG_TYPE, task.id)
|
|
35
45
|
e.dataTransfer.effectAllowed = 'move'
|
|
36
46
|
e.currentTarget.dataset.dragging = 'true'
|
|
@@ -84,6 +84,16 @@ export function TaskDetail({ task, controller }: { task: TaskRecord; controller:
|
|
|
84
84
|
</div>
|
|
85
85
|
<div className="dsh-atb-detail-topbtns">
|
|
86
86
|
<button type="button" className="dsh-atb-detail-edit" onClick={() => controller.openEditor(task.id)}>✎ 编辑</button>
|
|
87
|
+
{canRun && (
|
|
88
|
+
<button
|
|
89
|
+
type="button"
|
|
90
|
+
className="dsh-atb-detail-run"
|
|
91
|
+
title={task.model !== undefined ? `新会话执行(${task.model.model})` : '新会话执行(默认模型)'}
|
|
92
|
+
onClick={() => void controller.run(task.id)}
|
|
93
|
+
>
|
|
94
|
+
▶ 立即执行
|
|
95
|
+
</button>
|
|
96
|
+
)}
|
|
87
97
|
<button type="button" className="dsh-atb-detail-close" aria-label="关闭" onClick={() => controller.select(undefined)}>✕</button>
|
|
88
98
|
</div>
|
|
89
99
|
</div>
|
|
@@ -103,11 +113,6 @@ export function TaskDetail({ task, controller }: { task: TaskRecord; controller:
|
|
|
103
113
|
)}
|
|
104
114
|
|
|
105
115
|
<div className="dsh-atb-detail-actions">
|
|
106
|
-
{canRun && (
|
|
107
|
-
<button type="button" className="dsh-atb-runbtn" onClick={() => void controller.run(task.id)}>
|
|
108
|
-
▶ 执行 · 新会话{task.model !== undefined ? `(${task.model.model})` : '(默认模型)'}
|
|
109
|
-
</button>
|
|
110
|
-
)}
|
|
111
116
|
<div className="dsh-atb-movebtns">
|
|
112
117
|
{moveTargets(task).map(to => to === 'done'
|
|
113
118
|
? (confirmDone
|
|
@@ -118,10 +123,10 @@ export function TaskDetail({ task, controller }: { task: TaskRecord; controller:
|
|
|
118
123
|
<button type="button" className="dsh-atb-btn" onClick={() => setConfirmDone(false)}>取消</button>
|
|
119
124
|
</span>
|
|
120
125
|
)
|
|
121
|
-
: <button key={to} type="button" className="dsh-atb-movebtn" data-to={to} onClick={() => setConfirmDone(true)}
|
|
126
|
+
: <button key={to} type="button" className="dsh-atb-movebtn" data-to={to} onClick={() => setConfirmDone(true)}>移至→{MOVE_LABEL[to]}</button>)
|
|
122
127
|
: (
|
|
123
128
|
<button key={to} type="button" className="dsh-atb-movebtn" data-to={to} onClick={() => void controller.move(task.id, task.version, to)}>
|
|
124
|
-
{MOVE_LABEL[to]}
|
|
129
|
+
移至→{MOVE_LABEL[to]}
|
|
125
130
|
</button>
|
|
126
131
|
))}
|
|
127
132
|
<button type="button" className="dsh-atb-movebtn" data-to="blocked" onClick={() => void controller.toggleBlocked(task)}>
|
|
@@ -94,6 +94,9 @@ export function TaskFormModal({ controller, task }: { controller: BoardControlle
|
|
|
94
94
|
const cronBad = mode === 'scheduled' && (cronMatch === null || nextRun === null)
|
|
95
95
|
const valid = title.trim().length > 0 && workspaceId !== '' && !cronBad
|
|
96
96
|
|
|
97
|
+
// A task already in progress cannot be run again (host rejects it).
|
|
98
|
+
const runBlocked = editing && task.status === 'in_progress'
|
|
99
|
+
|
|
97
100
|
const submit = (): void => {
|
|
98
101
|
if (!valid) return
|
|
99
102
|
const picked = model !== '' ? (JSON.parse(model) as { provider: string; model: string }) : undefined
|
|
@@ -121,6 +124,39 @@ export function TaskFormModal({ controller, task }: { controller: BoardControlle
|
|
|
121
124
|
}
|
|
122
125
|
}
|
|
123
126
|
|
|
127
|
+
/** Save the form, then immediately trigger a manual run of the task. */
|
|
128
|
+
const submitAndRun = (): void => {
|
|
129
|
+
if (!valid || runBlocked) return
|
|
130
|
+
const picked = model !== '' ? (JSON.parse(model) as { provider: string; model: string }) : undefined
|
|
131
|
+
if (editing) {
|
|
132
|
+
void (async () => {
|
|
133
|
+
const saved = await controller.update(task.id, task.version, {
|
|
134
|
+
title,
|
|
135
|
+
description,
|
|
136
|
+
prompt,
|
|
137
|
+
urgency,
|
|
138
|
+
workspaceId,
|
|
139
|
+
execution: mode === 'scheduled' ? { mode, cron: cron.trim() } : { mode },
|
|
140
|
+
model: picked ?? null,
|
|
141
|
+
})
|
|
142
|
+
if (saved) await controller.run(task.id)
|
|
143
|
+
})()
|
|
144
|
+
} else {
|
|
145
|
+
void (async () => {
|
|
146
|
+
const id = await controller.create({
|
|
147
|
+
title,
|
|
148
|
+
workspaceId,
|
|
149
|
+
urgency,
|
|
150
|
+
description: description.length > 0 ? description : undefined,
|
|
151
|
+
prompt: prompt.length > 0 ? prompt : undefined,
|
|
152
|
+
execution: mode === 'scheduled' ? { mode, cron: cron.trim() } : { mode },
|
|
153
|
+
model: picked,
|
|
154
|
+
})
|
|
155
|
+
if (id !== undefined) await controller.run(id)
|
|
156
|
+
})()
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
124
160
|
const hint = !valid
|
|
125
161
|
? (title.trim().length === 0 ? '请填写标题' : workspaceId === '' ? '请选择项目' : 'Cron 表达式无效(分 时 日 月 周)')
|
|
126
162
|
: mode === 'scheduled' && nextRun !== null
|
|
@@ -233,6 +269,15 @@ export function TaskFormModal({ controller, task }: { controller: BoardControlle
|
|
|
233
269
|
<span className="dsh-atb-modal-hint" data-tone={valid ? undefined : 'bad'}>{hint}</span>
|
|
234
270
|
<span className="dsh-atb-modal-footbtns">
|
|
235
271
|
<button type="button" className="dsh-atb-btn" onClick={() => controller.closeForm()}>取消</button>
|
|
272
|
+
<button
|
|
273
|
+
type="button"
|
|
274
|
+
className="dsh-atb-btn"
|
|
275
|
+
disabled={!valid || runBlocked}
|
|
276
|
+
title={runBlocked ? '任务正在执行中,不能重复发起' : '保存后立即发起执行(新会话)'}
|
|
277
|
+
onClick={submitAndRun}
|
|
278
|
+
>
|
|
279
|
+
⚡ 立即执行
|
|
280
|
+
</button>
|
|
236
281
|
<button type="button" className="dsh-atb-btn" data-primary="true" disabled={!valid} onClick={submit}>
|
|
237
282
|
{editing ? '保存修改' : '创建任务'}
|
|
238
283
|
</button>
|
|
@@ -247,6 +292,7 @@ export function TaskFormModal({ controller, task }: { controller: BoardControlle
|
|
|
247
292
|
interface TaskRecordLike {
|
|
248
293
|
id: string
|
|
249
294
|
version: number
|
|
295
|
+
status?: string
|
|
250
296
|
title: string
|
|
251
297
|
description: string
|
|
252
298
|
prompt: string
|
package/src/client/controller.ts
CHANGED
|
@@ -166,25 +166,29 @@ export class BoardController {
|
|
|
166
166
|
toggleSecondary(): void { this.setState({ secondaryOpen: !this.state.secondaryOpen }) }
|
|
167
167
|
|
|
168
168
|
// ---------------------------------------------------------------- writes
|
|
169
|
-
/** Create a task (composer submit). */
|
|
170
|
-
async create(body: Parameters<TaskboardClient['create']>[0]): Promise<
|
|
169
|
+
/** Create a task (composer submit); returns the new task id, undefined on failure. */
|
|
170
|
+
async create(body: Parameters<TaskboardClient['create']>[0]): Promise<string | undefined> {
|
|
171
171
|
try {
|
|
172
|
-
await this.client.create(body)
|
|
172
|
+
const summary = await this.client.create(body)
|
|
173
173
|
this.setState({ composerOpen: false, error: undefined })
|
|
174
174
|
await this.refresh()
|
|
175
|
+
return summary.id
|
|
175
176
|
} catch (error) {
|
|
176
177
|
this.setState({ error: error instanceof Error ? error.message : String(error) })
|
|
178
|
+
return undefined
|
|
177
179
|
}
|
|
178
180
|
}
|
|
179
181
|
|
|
180
182
|
/** Edit task fields (form modal submit; the GUI is the owner surface). */
|
|
181
|
-
async update(id: string, ifVersion: number, body: Omit<UpdateTaskBody, 'ifVersion'>): Promise<
|
|
183
|
+
async update(id: string, ifVersion: number, body: Omit<UpdateTaskBody, 'ifVersion'>): Promise<boolean> {
|
|
182
184
|
try {
|
|
183
185
|
await this.client.update(id, { ifVersion, ...body })
|
|
184
186
|
this.setState({ composerOpen: false, editingId: undefined, error: undefined })
|
|
185
187
|
await this.refresh()
|
|
188
|
+
return true
|
|
186
189
|
} catch (error) {
|
|
187
190
|
this.setState({ error: error instanceof Error ? error.message : String(error) })
|
|
191
|
+
return false
|
|
188
192
|
}
|
|
189
193
|
}
|
|
190
194
|
|
package/src/client/styles.ts
CHANGED
|
@@ -172,12 +172,12 @@ html[data-dsh-atb-active] .dsh-atb-view { display: flex; flex-direction: column;
|
|
|
172
172
|
.dsh-atb-desc { white-space: pre-wrap; word-break: break-word; font-size: 13px; line-height: 1.55; }
|
|
173
173
|
|
|
174
174
|
.dsh-atb-detail-actions { display: flex; flex-direction: column; gap: 8px; }
|
|
175
|
-
.dsh-atb-
|
|
176
|
-
font: inherit; font-size:
|
|
177
|
-
border: 1px solid transparent; background: var(--dsw-alias-button-primary-fill, var(--dsw-alias-brand-primary, #1f2328)); color: var(--dsw-alias-label-primary-foreground, #fff);
|
|
175
|
+
.dsh-atb-detail-run {
|
|
176
|
+
font: inherit; font-size: 12px; font-weight: 600; padding: 4px 11px; border-radius: 7px; cursor: pointer;
|
|
177
|
+
border: 1px solid transparent; background: var(--dsw-alias-button-primary-fill, var(--dsw-alias-brand-primary, #1f2328)); color: var(--dsw-alias-label-primary-foreground, #fff);
|
|
178
178
|
transition: filter .12s ease;
|
|
179
179
|
}
|
|
180
|
-
.dsh-atb-
|
|
180
|
+
.dsh-atb-detail-run:hover { filter: brightness(1.1); }
|
|
181
181
|
.dsh-atb-movebtns { display: flex; gap: 6px; flex-wrap: wrap; }
|
|
182
182
|
.dsh-atb-movebtn {
|
|
183
183
|
font: inherit; font-size: 12px; padding: 4px 11px; border-radius: 999px; cursor: pointer;
|
|
@@ -376,6 +376,29 @@ html[data-dsh-atb-active] .dsh-atb-view { display: flex; flex-direction: column;
|
|
|
376
376
|
.dsh-atb-secondary { flex: 1; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; gap: 8px; }
|
|
377
377
|
.dsh-atb-link { color: var(--dsw-alias-state-business-primary, #3e63dd); cursor: pointer; text-decoration: none; }
|
|
378
378
|
.dsh-atb-link:hover { text-decoration: underline; }
|
|
379
|
+
|
|
380
|
+
/* ---------- alert modal ---------- */
|
|
381
|
+
.dsh-atb-alert-backdrop {
|
|
382
|
+
position: fixed; inset: 0; z-index: 90;
|
|
383
|
+
background: var(--dsw-alias-bg-mask-drop, rgba(28,30,36,.4)); backdrop-filter: var(--dsw-mask-blur, blur(2px));
|
|
384
|
+
display: flex; align-items: center; justify-content: center;
|
|
385
|
+
animation: dsh-atb-fade .12s ease;
|
|
386
|
+
}
|
|
387
|
+
.dsh-atb-alert {
|
|
388
|
+
min-width: 280px; max-width: 380px; padding: 20px 24px; border-radius: 14px;
|
|
389
|
+
background: var(--dsw-alias-bg-overlay, #fff); color: var(--dsw-alias-label-primary, inherit);
|
|
390
|
+
border: 1px solid var(--dsw-alias-border-l2, rgba(128,128,128,.25));
|
|
391
|
+
box-shadow: var(--dsw-shadow-lv3, 0 12px 32px rgba(0,0,0,.18));
|
|
392
|
+
display: flex; flex-direction: column; align-items: center; gap: 14px;
|
|
393
|
+
animation: dsh-atb-pop .14s ease;
|
|
394
|
+
}
|
|
395
|
+
.dsh-atb-alert-icon { font-size: 28px; line-height: 1; }
|
|
396
|
+
.dsh-atb-alert-msg {
|
|
397
|
+
font-size: 13.5px; line-height: 1.55; text-align: center;
|
|
398
|
+
word-break: break-word; white-space: pre-wrap;
|
|
399
|
+
color: var(--dsw-alias-label-primary, inherit);
|
|
400
|
+
}
|
|
401
|
+
.dsh-atb-alert .dsh-atb-btn { padding: 6px 28px; font-size: 13px; }
|
|
379
402
|
`
|
|
380
403
|
|
|
381
404
|
let injected = false
|
package/src/host/execution.ts
CHANGED
|
@@ -54,6 +54,8 @@ export interface ExecutionDeps {
|
|
|
54
54
|
mintSessionId?: () => string
|
|
55
55
|
/** Mint message ids (injectable for tests). */
|
|
56
56
|
mintMessageId?: () => string
|
|
57
|
+
/** Best-effort session rename (pins the session list title to the task title). */
|
|
58
|
+
renameSession?: (sessionId: string, title: string) => void
|
|
57
59
|
}
|
|
58
60
|
|
|
59
61
|
/** Outcome of a run request (immediate; the run settles asynchronously). */
|
|
@@ -182,16 +184,25 @@ export class ExecutionService {
|
|
|
182
184
|
// 3. Attach the session to the workspace (GUI project session list).
|
|
183
185
|
await this.deps.workspaces.attach(task.workspaceId, sessionId).catch(() => { /* cosmetic */ })
|
|
184
186
|
|
|
187
|
+
// 3b. Best-effort rename: pin the session title to the task title so the
|
|
188
|
+
// session list shows the task name (a user-sourced title also stops
|
|
189
|
+
// automatic first-prompt retitling).
|
|
190
|
+
try {
|
|
191
|
+
this.deps.renameSession?.(sessionId, task.title)
|
|
192
|
+
} catch { /* cosmetic */ }
|
|
193
|
+
|
|
185
194
|
// 4. Record the session id (execution is really started now).
|
|
186
195
|
await this.patchExecution(executionId, { sessionId })
|
|
187
196
|
|
|
188
197
|
// 5. Submit the effective prompt as an ordinary user message and settle
|
|
189
198
|
// on quiescence (turn/end errors were already folded by the listener).
|
|
199
|
+
// Source `user` (not `plugin`) so the opening message renders as a
|
|
200
|
+
// normal user bubble in the conversation, exactly like a typed prompt.
|
|
190
201
|
const message = {
|
|
191
202
|
id: this.deps.mintMessageId?.() ?? MessageId(`msg-taskboard-${crypto.randomUUID()}`),
|
|
192
203
|
role: 'user' as const,
|
|
193
204
|
content: [{ type: 'text' as const, text: this.executionPrompt(task) }],
|
|
194
|
-
source: { kind: '
|
|
205
|
+
source: { kind: 'user' as const },
|
|
195
206
|
}
|
|
196
207
|
handle.agent.followup(message)
|
|
197
208
|
|
|
@@ -221,12 +232,11 @@ export class ExecutionService {
|
|
|
221
232
|
|
|
222
233
|
/** The prompt text one execution submits (task context + instructions). */
|
|
223
234
|
private executionPrompt(task: TaskRecord): string {
|
|
224
|
-
const head = `【任务看板执行】${task.title}(任务 ID: ${task.id})`
|
|
225
235
|
const state = '本任务由执行服务启动本会话并已置为 in_progress(你无需再认领,也无需移到 done)。'
|
|
226
236
|
const tail = `完成后请:1) 用 taskboard_get 读取任务 ${task.id} 拿最新 version;`
|
|
227
237
|
+ `2) 用 taskboard_comment_add 留评论(做了什么改动、如何验证、剩余风险);`
|
|
228
238
|
+ `3) 用 taskboard_move 把任务 ${task.id} 移到 in_review(带 ifVersion)。`
|
|
229
|
-
return
|
|
239
|
+
return `【任务】${task.title}(任务 ID: ${task.id})\n\n${state}\n\n${effectivePrompt(task)}\n\n${tail}`
|
|
230
240
|
}
|
|
231
241
|
|
|
232
242
|
/** Move a task back out of in_progress after a failed start. */
|
package/src/index.ts
CHANGED
|
@@ -84,6 +84,16 @@ export function apply(ctx: Context): void {
|
|
|
84
84
|
},
|
|
85
85
|
events,
|
|
86
86
|
now,
|
|
87
|
+
renameSession: (sessionId, title) => {
|
|
88
|
+
// Best-effort: pin the execution session's title to the task title
|
|
89
|
+
// through the log-backed session-title service (user-sourced rename).
|
|
90
|
+
try {
|
|
91
|
+
const sessions = agentCtx.get('sessions') as { get(id: string): unknown } | undefined
|
|
92
|
+
const sessionTitle = agentCtx.get('sessionTitle') as { rename(session: unknown, title: string): unknown } | undefined
|
|
93
|
+
const session = sessions?.get(sessionId)
|
|
94
|
+
if (session !== undefined && sessionTitle !== undefined) sessionTitle.rename(session, title)
|
|
95
|
+
} catch { /* cosmetic */ }
|
|
96
|
+
},
|
|
87
97
|
defaultModel: () => {
|
|
88
98
|
try {
|
|
89
99
|
const selection = agentCtx.get('agentDefaultModel') as { currentSelection?: () => { provider: string; model: string } | undefined } | undefined
|