@linxin666/dsh-client-ui-task-board 0.1.15 → 0.1.17
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.i18n.yaml +2 -2
- package/README.md +8 -4
- package/README.zh.md +8 -4
- package/lib/client.js +499 -43
- package/lib/client.js.map +1 -1
- package/lib/index.js +1 -1
- package/lib/types/client/locales.d.ts +14 -0
- package/lib/types/client/settings-form.d.ts +62 -0
- package/lib/types/core/controller.d.ts +30 -1
- package/lib/types/core/execution.d.ts +49 -4
- package/lib/types/core/store.d.ts +1 -1
- package/lib/types/core/tasks.d.ts +27 -0
- package/lib/types/core/use-cases/task-update.d.ts +9 -4
- package/lib/types/index.d.ts +1 -1
- package/package.json +1 -1
- package/src/client/PluginSettingsCard.tsx +7 -1
- package/src/client/board/NewTaskModal.tsx +68 -3
- package/src/client/board/TaskDetail.tsx +71 -1
- package/src/client/board.module.css +16 -0
- package/src/client/index.ts +68 -2
- package/src/client/locales.ts +28 -0
- package/src/client/settings-card.module.css +3 -0
- package/src/client/settings-form.ts +114 -12
- package/src/core/controller.ts +38 -2
- package/src/core/execution.ts +140 -12
- package/src/core/store.ts +15 -2
- package/src/core/tasks.ts +41 -0
- package/src/core/use-cases/task-update.ts +34 -5
- package/src/index.ts +1 -1
package/src/client/index.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* plugin must not take the GUI down.
|
|
10
10
|
*/
|
|
11
11
|
import type { ClientContext, SessionId, SettingsScope, SettingsScopeSpec, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
|
|
12
|
-
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
|
|
12
|
+
import type { ConnectionHandle, PromptContentPart } from '@deepseek-ai/dsh-client-connection/client'
|
|
13
13
|
import type {} from '@deepseek-ai/dsh-client-ui-slots'
|
|
14
14
|
// Type-only: pulls the locale plugin's Context merge (ctx.locale) and its
|
|
15
15
|
// LocaleNamespaceMap merge table.
|
|
@@ -116,12 +116,40 @@ export function apply(ctx: ClientContext): void {
|
|
|
116
116
|
const exec = new ExecutionService({
|
|
117
117
|
sessions: {
|
|
118
118
|
list: sessions.list,
|
|
119
|
-
binding: id =>
|
|
119
|
+
binding: id => {
|
|
120
|
+
const binding = sessions.binding(id as SessionId)
|
|
121
|
+
if (binding === undefined) return undefined
|
|
122
|
+
const { session } = binding
|
|
123
|
+
return {
|
|
124
|
+
session: {
|
|
125
|
+
rename: title => session.rename(title),
|
|
126
|
+
prompt: (content, mode) =>
|
|
127
|
+
session.prompt(content as PromptContentPart[], mode).then(result =>
|
|
128
|
+
result.ok ? { ok: true as const } : { ok: false as const, error: result.error }),
|
|
129
|
+
command: line =>
|
|
130
|
+
session.command(line).then(result =>
|
|
131
|
+
result.ok ? { ok: true as const, matched: result.value.matched } : { ok: false as const, error: result.error }),
|
|
132
|
+
getSnapshot: () => session.getSnapshot(),
|
|
133
|
+
subscribe: fn => session.subscribe(fn),
|
|
134
|
+
},
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
noteAgentPreset: (sessionId, agentPreset) => sessions.noteAgentPreset(sessionId as SessionId, agentPreset),
|
|
120
138
|
},
|
|
121
139
|
workspaces: {
|
|
122
140
|
list: workspaces.list,
|
|
123
141
|
connectWorkspace: id => workspaces.connectWorkspace(id as WorkspaceId),
|
|
124
142
|
},
|
|
143
|
+
presets: {
|
|
144
|
+
select: async (sessionId, agentPreset) => {
|
|
145
|
+
try {
|
|
146
|
+
const response = await connection.api.agentPresets.select({ sessionId: sessionId as SessionId, agentPreset })
|
|
147
|
+
return response.result.ok ? { ok: true as const } : { ok: false as const, error: response.result.error }
|
|
148
|
+
} catch (error) {
|
|
149
|
+
return { ok: false as const, error }
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
},
|
|
125
153
|
history: {
|
|
126
154
|
loadTail: async sessionId => {
|
|
127
155
|
const response = await connection.api.sessions.history({
|
|
@@ -163,6 +191,44 @@ export function apply(ctx: ClientContext): void {
|
|
|
163
191
|
scheduler.start()
|
|
164
192
|
|
|
165
193
|
const disposers: Array<() => void> = []
|
|
194
|
+
|
|
195
|
+
// Execution-target option feeds: the workspace list drives the workspace
|
|
196
|
+
// picker, and the agent-preset roster drives the mode picker. Both are
|
|
197
|
+
// runtime facts (not ledger state), so the wiring pushes them into the
|
|
198
|
+
// controller on change; the preset roster is re-read after reconnects
|
|
199
|
+
// because a reconnect may serve a different deployment.
|
|
200
|
+
const pushWorkspaceOptions = (): void => {
|
|
201
|
+
const snapshot = workspaces.list.getSnapshot()
|
|
202
|
+
controller.setExecutionOptions({
|
|
203
|
+
workspaces: snapshot.items.map(item => ({
|
|
204
|
+
workspaceId: item.workspaceId,
|
|
205
|
+
title: item.title !== '' ? item.title : item.path,
|
|
206
|
+
})),
|
|
207
|
+
})
|
|
208
|
+
}
|
|
209
|
+
pushWorkspaceOptions()
|
|
210
|
+
disposers.push(workspaces.list.subscribe(pushWorkspaceOptions))
|
|
211
|
+
const pushPresetOptions = async (): Promise<void> => {
|
|
212
|
+
try {
|
|
213
|
+
const response = await connection.api.agentPresets.list({})
|
|
214
|
+
if (!response.result.ok) return
|
|
215
|
+
controller.setExecutionOptions({
|
|
216
|
+
presets: response.result.value.presets.map(preset => ({
|
|
217
|
+
id: preset.id,
|
|
218
|
+
name: preset.name,
|
|
219
|
+
description: preset.description,
|
|
220
|
+
broken: preset.broken,
|
|
221
|
+
isDefault: preset.isDefault,
|
|
222
|
+
})),
|
|
223
|
+
})
|
|
224
|
+
} catch (error) {
|
|
225
|
+
// A failed roster read leaves the previous options in place; the
|
|
226
|
+
// picker stays usable and the next reconnect retries the read.
|
|
227
|
+
console.error('[dsh-task-board] agent preset roster read failed', error)
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
void pushPresetOptions()
|
|
231
|
+
disposers.push(ctx.on('connection/reset', () => { void pushPresetOptions() }))
|
|
166
232
|
try {
|
|
167
233
|
disposers.push(mountSidebarEntry(controller))
|
|
168
234
|
disposers.push(mountBoard(controller))
|
package/src/client/locales.ts
CHANGED
|
@@ -73,6 +73,20 @@ export const zh = {
|
|
|
73
73
|
'detail.schedule.notScheduled': '尚未排程',
|
|
74
74
|
'detail.schedule.dueSoon': '即将运行',
|
|
75
75
|
'card.scheduled': '定时',
|
|
76
|
+
'new.workspace': '工作区',
|
|
77
|
+
'new.mode': '模式',
|
|
78
|
+
'new.permission': '权限',
|
|
79
|
+
'exec.workspace.recent': '最近使用(默认)',
|
|
80
|
+
'exec.mode.default': '部署默认',
|
|
81
|
+
'exec.mode.defaultSuffix': '(默认)',
|
|
82
|
+
'exec.mode.brokenSuffix': '(不可用)',
|
|
83
|
+
'exec.mode.removed': '(已移除)',
|
|
84
|
+
'exec.permission.default': '会话默认',
|
|
85
|
+
'exec.permission.read-only': '只读',
|
|
86
|
+
'exec.permission.workspace-write': '工作区可写',
|
|
87
|
+
'exec.permission.danger-full-access': '完全访问',
|
|
88
|
+
'detail.executionSettings': '执行设置',
|
|
89
|
+
'exec.hint': '执行时生效:工作区决定执行会话落在哪个工作区;模式决定会话的 agent 预设;权限经 /permission 命令应用到会话。留空则使用运行时默认。',
|
|
76
90
|
// 插件设置卡片(settings.plugin.item 席位)。
|
|
77
91
|
'settings.title': '任务看板',
|
|
78
92
|
'settings.description': '控制看板在 agent 系统提示中的播报行为。',
|
|
@@ -165,6 +179,20 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
165
179
|
'detail.schedule.notScheduled': 'Not scheduled yet',
|
|
166
180
|
'detail.schedule.dueSoon': 'Due soon',
|
|
167
181
|
'card.scheduled': 'scheduled',
|
|
182
|
+
'new.workspace': 'Workspace',
|
|
183
|
+
'new.mode': 'Mode',
|
|
184
|
+
'new.permission': 'Permission',
|
|
185
|
+
'exec.workspace.recent': 'Most recent (default)',
|
|
186
|
+
'exec.mode.default': 'Deployment default',
|
|
187
|
+
'exec.mode.defaultSuffix': ' (default)',
|
|
188
|
+
'exec.mode.brokenSuffix': ' (unavailable)',
|
|
189
|
+
'exec.mode.removed': ' (removed)',
|
|
190
|
+
'exec.permission.default': 'Session default',
|
|
191
|
+
'exec.permission.read-only': 'Read-only',
|
|
192
|
+
'exec.permission.workspace-write': 'Workspace Write',
|
|
193
|
+
'exec.permission.danger-full-access': 'Full Access',
|
|
194
|
+
'detail.executionSettings': 'Execution Settings',
|
|
195
|
+
'exec.hint': 'Applied when the task runs: the workspace decides where the execution session lands; the mode composes the session\'s agent preset; the permission is applied through the /permission command. Blank = runtime default.',
|
|
168
196
|
// Plugin settings card (the `settings.plugin.item` seat).
|
|
169
197
|
'settings.title': 'Task Board',
|
|
170
198
|
'settings.description': 'How the board announces itself in each agent system prompt.',
|
|
@@ -20,6 +20,14 @@ export type FieldWrite =
|
|
|
20
20
|
export interface FieldSpec {
|
|
21
21
|
/** Field name inside the namespace section. */
|
|
22
22
|
field: string
|
|
23
|
+
/**
|
|
24
|
+
* Whether the Host treats this field as a secret and redacts its value from
|
|
25
|
+
* the read-back (role('secret') in the section schema). Redacted secrets are
|
|
26
|
+
* never compared against the draft on save; the field lands when the scope
|
|
27
|
+
* reports the write succeeded (its secret-set marker under the bridge), so
|
|
28
|
+
* a successful secret save is not misreported as failed.
|
|
29
|
+
*/
|
|
30
|
+
secret?: boolean
|
|
23
31
|
/** Render a stored value as draft text; the empty string when the section carries none. */
|
|
24
32
|
format: (value: unknown) => string
|
|
25
33
|
/**
|
|
@@ -61,6 +69,12 @@ export interface CardShell {
|
|
|
61
69
|
saving: boolean
|
|
62
70
|
/** Whether the last save did not land as staged; cleared by the next edit or save. */
|
|
63
71
|
failed: boolean
|
|
72
|
+
/**
|
|
73
|
+
* The rejection code/message the Host returned for the last failed save,
|
|
74
|
+
* surfaced next to the generic failure text. Undefined while no save has
|
|
75
|
+
* failed (or the failure carried no server reason).
|
|
76
|
+
*/
|
|
77
|
+
failedReason?: string
|
|
64
78
|
}
|
|
65
79
|
|
|
66
80
|
/** The write actions the card's slot entry injects. */
|
|
@@ -87,10 +101,52 @@ interface StagedEdit {
|
|
|
87
101
|
interface PlannedWrite {
|
|
88
102
|
/** Field this entry writes. */
|
|
89
103
|
field: string
|
|
104
|
+
/** The durable write this entry performs, described for a batched scope. */
|
|
105
|
+
op: BatchedWrite
|
|
90
106
|
/** Perform the write and report whether the Host holds the staged value afterwards. */
|
|
91
107
|
run: (() => Promise<boolean>) | undefined
|
|
92
108
|
}
|
|
93
109
|
|
|
110
|
+
/** One durable write a batched settings scope performs. */
|
|
111
|
+
export interface BatchedWrite {
|
|
112
|
+
/** Field this entry writes. */
|
|
113
|
+
field: string
|
|
114
|
+
/** set stores a value; unset drops the leaf. */
|
|
115
|
+
op: 'set' | 'unset'
|
|
116
|
+
/** Value for op set (absent for unset). */
|
|
117
|
+
value?: unknown
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Per-field outcome of one batched scope write. */
|
|
121
|
+
export interface BatchedFieldResult {
|
|
122
|
+
/** Field this entry writes. */
|
|
123
|
+
field: string
|
|
124
|
+
/** Whether the Host accepted this field's write (per the read-back view). */
|
|
125
|
+
landed: boolean
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Result of a batched scope write. The bridge scope posts every planned write
|
|
130
|
+
* in one /mutate so the Host validate hook judges baseURL+model together; a
|
|
131
|
+
* batched refusal fails the whole save rather than per-field.
|
|
132
|
+
*/
|
|
133
|
+
export interface BatchResult {
|
|
134
|
+
/** Whether the whole mutate was accepted. */
|
|
135
|
+
ok: boolean
|
|
136
|
+
/** Per-field success, in the request order (always present when ok). */
|
|
137
|
+
fields: BatchedFieldResult[]
|
|
138
|
+
/** Host rejection code (mutate refused). */
|
|
139
|
+
code?: string
|
|
140
|
+
/** Host rejection message (mutate refused). */
|
|
141
|
+
message?: string
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The optional batch surface the bridge scope adds over the SettingsScope contract. */
|
|
145
|
+
interface BatchedSettingsScope {
|
|
146
|
+
/** Write every operation in one scope mutation, reporting per-field success. */
|
|
147
|
+
mutate: (writes: BatchedWrite[]) => Promise<BatchResult>
|
|
148
|
+
}
|
|
149
|
+
|
|
94
150
|
/** Constraints a numeric field's accepted drafts must satisfy, mirroring the host schema. */
|
|
95
151
|
export interface NumberConstraints {
|
|
96
152
|
/** The accepted value must be a whole number. */
|
|
@@ -129,6 +185,16 @@ export function textField(field: string): FieldSpec {
|
|
|
129
185
|
}
|
|
130
186
|
}
|
|
131
187
|
|
|
188
|
+
/**
|
|
189
|
+
* A free-text field the Host treats as a secret and redacts from the read-back
|
|
190
|
+
* (role('secret') in the section schema). The card still edits it like text,
|
|
191
|
+
* but a save never compares the redacted value back and relies on the scope
|
|
192
|
+
* reporting the write landed.
|
|
193
|
+
*/
|
|
194
|
+
export function secretField(field: string): FieldSpec {
|
|
195
|
+
return { ...textField(field), secret: true }
|
|
196
|
+
}
|
|
197
|
+
|
|
132
198
|
/** A boolean field, edited through true/false draft text. */
|
|
133
199
|
export function booleanField(field: string): FieldSpec {
|
|
134
200
|
return {
|
|
@@ -170,6 +236,7 @@ export class CardForm<T> {
|
|
|
170
236
|
private readonly listeners = new Set<() => void>()
|
|
171
237
|
private saving = false
|
|
172
238
|
private failed = false
|
|
239
|
+
private failedReason: string | undefined
|
|
173
240
|
|
|
174
241
|
/** @param scope - the bound settings scope for this card's namespace. */
|
|
175
242
|
constructor(
|
|
@@ -199,6 +266,7 @@ export class CardForm<T> {
|
|
|
199
266
|
invalid: plan.some(item => item.run === undefined),
|
|
200
267
|
saving: this.saving,
|
|
201
268
|
failed: this.failed,
|
|
269
|
+
...this.failedReason === undefined ? {} : { failedReason: this.failedReason },
|
|
202
270
|
}
|
|
203
271
|
}
|
|
204
272
|
|
|
@@ -229,6 +297,7 @@ export class CardForm<T> {
|
|
|
229
297
|
if (this.staged.size === 0 && !this.failed) return
|
|
230
298
|
this.staged.clear()
|
|
231
299
|
this.failed = false
|
|
300
|
+
this.failedReason = undefined
|
|
232
301
|
this.publish()
|
|
233
302
|
},
|
|
234
303
|
}
|
|
@@ -236,30 +305,57 @@ export class CardForm<T> {
|
|
|
236
305
|
|
|
237
306
|
/**
|
|
238
307
|
* Write every staged edit, then re-seed from what the Host accepted.
|
|
308
|
+
*
|
|
309
|
+
* When the scope carries the optional batch surface (the dsh-web-ui
|
|
310
|
+
* bridge scope), every planned write rides one mutation so cross-field
|
|
311
|
+
* validate hooks (baseURL+model) judge the batch as a unit instead of
|
|
312
|
+
* deadlocking on per-field writes. Otherwise the per-field loop runs.
|
|
313
|
+
* A field lands only when the Host reports it held the staged value; a
|
|
314
|
+
* landed field's draft is dropped, a failed one stays staged for the user.
|
|
239
315
|
* @returns settlement after every write and the read-back.
|
|
240
316
|
*/
|
|
241
317
|
async save(): Promise<void> {
|
|
242
318
|
const plan = this.plan()
|
|
243
|
-
const
|
|
244
|
-
if (plan.length === 0 || this.saving ||
|
|
319
|
+
const valid = plan.filter(item => item.run !== undefined)
|
|
320
|
+
if (plan.length === 0 || this.saving || valid.length !== plan.length) return
|
|
321
|
+
const plannedWrites = valid.map(item => item.op)
|
|
245
322
|
// Snapshot the fields this save writes, so edits staged while it is in
|
|
246
323
|
// flight survive: only the staged keys this save actually wrote are cleared.
|
|
247
324
|
const fields = new Set(plan.map(item => item.field))
|
|
248
325
|
this.saving = true
|
|
249
326
|
this.failed = false
|
|
327
|
+
this.failedReason = undefined
|
|
250
328
|
this.publish()
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
329
|
+
const landed = new Set<string>()
|
|
330
|
+
const batch = this.batchedScope()
|
|
331
|
+
if (batch !== undefined) {
|
|
332
|
+
const result = await batch.mutate(plannedWrites)
|
|
333
|
+
if (result.ok) {
|
|
334
|
+
for (const field of result.fields) {
|
|
335
|
+
if (field.landed) landed.add(field.field)
|
|
336
|
+
}
|
|
337
|
+
} else {
|
|
338
|
+
this.failedReason = result.message
|
|
339
|
+
}
|
|
340
|
+
} else {
|
|
341
|
+
for (const item of valid) {
|
|
342
|
+
if (await item.run!()) landed.add(item.field)
|
|
343
|
+
}
|
|
254
344
|
}
|
|
255
|
-
|
|
256
|
-
|
|
345
|
+
for (const field of fields) {
|
|
346
|
+
if (landed.has(field)) this.staged.delete(field)
|
|
257
347
|
}
|
|
258
348
|
this.saving = false
|
|
259
|
-
this.failed =
|
|
349
|
+
this.failed = landed.size !== fields.size
|
|
260
350
|
this.publish()
|
|
261
351
|
}
|
|
262
352
|
|
|
353
|
+
/** The scope's batch surface when it supports one; undefined conservatively otherwise. */
|
|
354
|
+
private batchedScope(): BatchedSettingsScope | undefined {
|
|
355
|
+
const candidate = this.scope as unknown as BatchedSettingsScope | undefined
|
|
356
|
+
return typeof candidate?.mutate === 'function' ? candidate : undefined
|
|
357
|
+
}
|
|
358
|
+
|
|
263
359
|
/**
|
|
264
360
|
* Every staged edit a save would write. An entry whose draft is not a value
|
|
265
361
|
* its field accepts carries no write: the form is still dirty, and the save
|
|
@@ -272,14 +368,14 @@ export class CardForm<T> {
|
|
|
272
368
|
for (const [field, staged] of this.staged) {
|
|
273
369
|
const spec = this.specOf(field)
|
|
274
370
|
if (staged.clear) {
|
|
275
|
-
if (this.stored(field)) plan.push({ field, run: () => this.clear(field) })
|
|
371
|
+
if (this.stored(field)) plan.push({ field, op: { field, op: 'unset' }, run: () => this.clear(field) })
|
|
276
372
|
continue
|
|
277
373
|
}
|
|
278
374
|
if (staged.text === spec.format(this.sectionValue(field))) continue
|
|
279
375
|
const write = spec.parse(staged.text)
|
|
280
|
-
if (write === undefined) plan.push({ field, run: undefined })
|
|
281
|
-
else if (write.kind === 'clear') plan.push({ field, run: () => this.clear(field) })
|
|
282
|
-
else plan.push({ field, run: () => this.store(field, write.value) })
|
|
376
|
+
if (write === undefined) plan.push({ field, op: { field, op: 'unset' }, run: undefined })
|
|
377
|
+
else if (write.kind === 'clear') plan.push({ field, op: { field, op: 'unset' }, run: () => this.clear(field) })
|
|
378
|
+
else plan.push({ field, op: { field, op: 'set', value: write.value }, run: () => this.store(field, write.value) })
|
|
283
379
|
}
|
|
284
380
|
return plan
|
|
285
381
|
}
|
|
@@ -291,12 +387,18 @@ export class CardForm<T> {
|
|
|
291
387
|
|
|
292
388
|
private async store(field: string, value: unknown): Promise<boolean> {
|
|
293
389
|
await this.scope.set(field, value)
|
|
390
|
+
// A redacted secret never appears in the user layer read-back; judging it
|
|
391
|
+
// by value would misreport a successful secret save as failed. The bridge
|
|
392
|
+
// reports secret writes through its secret-set markers (batch path); on
|
|
393
|
+
// the per-field path the scope resolved, so the write is landed.
|
|
394
|
+
if (this.specOf(field).secret) return true
|
|
294
395
|
return this.userLayer()?.[field] === value
|
|
295
396
|
}
|
|
296
397
|
|
|
297
398
|
private stage(field: string, edit: StagedEdit): void {
|
|
298
399
|
this.staged.set(field, edit)
|
|
299
400
|
this.failed = false
|
|
401
|
+
this.failedReason = undefined
|
|
300
402
|
this.publish()
|
|
301
403
|
}
|
|
302
404
|
|
package/src/core/controller.ts
CHANGED
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
import { applyCreateTask } from './use-cases/task-create.ts'
|
|
23
23
|
import { applyDeleteTask } from './use-cases/task-delete.ts'
|
|
24
24
|
import { applyScheduleNextRun as applyScheduleRollForward, applySetSchedule } from './use-cases/task-schedule.ts'
|
|
25
|
-
import { applyUpdateTask } from './use-cases/task-update.ts'
|
|
25
|
+
import { applyUpdateTask, type TaskUpdatePatch } from './use-cases/task-update.ts'
|
|
26
26
|
|
|
27
27
|
/** The sessions face the controller needs for navigation awareness. */
|
|
28
28
|
export interface SessionsControllerFace {
|
|
@@ -47,11 +47,36 @@ export interface ControllerDeps {
|
|
|
47
47
|
reconcileDebounceMs?: number
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
/** One workspace option the execution-target pickers offer. */
|
|
51
|
+
export interface ExecutionWorkspaceOption {
|
|
52
|
+
workspaceId: string
|
|
53
|
+
/** Display label (workspace title; the wiring falls back to the path). */
|
|
54
|
+
title: string
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** One agent-preset option the execution-target pickers offer. */
|
|
58
|
+
export interface ExecutionPresetOption {
|
|
59
|
+
id: string
|
|
60
|
+
name?: string
|
|
61
|
+
description?: string
|
|
62
|
+
/** Why this preset cannot compose a session; the pickers disable it. */
|
|
63
|
+
broken?: string
|
|
64
|
+
isDefault: boolean
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The execution-target option sets the UI feeds into the controller. */
|
|
68
|
+
export interface ExecutionOptionsSnapshot {
|
|
69
|
+
workspaces: readonly ExecutionWorkspaceOption[]
|
|
70
|
+
presets: readonly ExecutionPresetOption[]
|
|
71
|
+
}
|
|
72
|
+
|
|
50
73
|
/** Immutable controller snapshot for UI subscriptions. */
|
|
51
74
|
export interface ControllerSnapshot {
|
|
52
75
|
tasks: readonly TaskRecord[]
|
|
53
76
|
boardOpen: boolean
|
|
54
77
|
selectedTaskId: string | undefined
|
|
78
|
+
/** Picker option sets (workspace list + agent-preset roster). */
|
|
79
|
+
executionOptions: ExecutionOptionsSnapshot
|
|
55
80
|
}
|
|
56
81
|
|
|
57
82
|
/** The selected task (resolved from the ledger), or undefined. */
|
|
@@ -85,6 +110,7 @@ export class BoardController {
|
|
|
85
110
|
private tasks: TaskRecord[] = []
|
|
86
111
|
private boardOpen = false
|
|
87
112
|
private selectedTaskId: string | undefined
|
|
113
|
+
private executionOptions: ExecutionOptionsSnapshot = { workspaces: [], presets: [] }
|
|
88
114
|
private listeners = new Set<() => void>()
|
|
89
115
|
private disposers: Array<() => void> = []
|
|
90
116
|
private readonly now: () => number
|
|
@@ -132,6 +158,7 @@ export class BoardController {
|
|
|
132
158
|
tasks: this.tasks,
|
|
133
159
|
boardOpen: this.boardOpen,
|
|
134
160
|
selectedTaskId: this.selectedTaskId,
|
|
161
|
+
executionOptions: this.executionOptions,
|
|
135
162
|
}
|
|
136
163
|
}
|
|
137
164
|
|
|
@@ -186,11 +213,20 @@ export class BoardController {
|
|
|
186
213
|
return task
|
|
187
214
|
}
|
|
188
215
|
|
|
189
|
-
updateTask(id: string, patch:
|
|
216
|
+
updateTask(id: string, patch: TaskUpdatePatch): void {
|
|
190
217
|
this.tasks = [...applyUpdateTask(this.tasks, id, patch, this.now())]
|
|
191
218
|
this.persistAndNotify()
|
|
192
219
|
}
|
|
193
220
|
|
|
221
|
+
/**
|
|
222
|
+
* Replace (a part of) the picker option sets the UI feeds (workspace list
|
|
223
|
+
* and agent-preset roster come from the runtime, not the ledger).
|
|
224
|
+
*/
|
|
225
|
+
setExecutionOptions(patch: Partial<ExecutionOptionsSnapshot>): void {
|
|
226
|
+
this.executionOptions = { ...this.executionOptions, ...patch }
|
|
227
|
+
this.notify()
|
|
228
|
+
}
|
|
229
|
+
|
|
194
230
|
moveTask(id: string, status: TaskStatus): void {
|
|
195
231
|
this.tasks = this.tasks.map(task => task.id === id ? withStatus(task, status, this.now()) : task)
|
|
196
232
|
this.persistAndNotify()
|
package/src/core/execution.ts
CHANGED
|
@@ -15,17 +15,35 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import type { ExecutionRecord, TaskRecord } from './tasks.ts'
|
|
17
17
|
|
|
18
|
+
/** One list-row summary the execution service reads (the narrow slice of a SessionSummary). */
|
|
19
|
+
export interface ExecutionSessionSummary {
|
|
20
|
+
running: boolean
|
|
21
|
+
completed?: boolean
|
|
22
|
+
/** Empty-log bit: the preset can only be recomposed while the session is blank. */
|
|
23
|
+
blank?: boolean
|
|
24
|
+
/** The preset the session currently runs (absent on deployments without presets). */
|
|
25
|
+
agentPreset?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
18
28
|
/** The narrow sessions face the service needs. */
|
|
19
29
|
export interface SessionsExecutionFace {
|
|
20
30
|
list: {
|
|
21
31
|
getSnapshot(): {
|
|
22
32
|
/** Baseline arrival lifecycle — 'pending' until the host list has loaded. */
|
|
23
33
|
phase: 'pending' | 'ready'
|
|
24
|
-
byId: Record<string,
|
|
34
|
+
byId: Record<string, ExecutionSessionSummary>
|
|
25
35
|
}
|
|
26
36
|
subscribe(fn: () => void): () => void
|
|
27
37
|
}
|
|
28
38
|
binding(id: string): { session: SessionDriver } | undefined
|
|
39
|
+
/** Record a host-confirmed preset switch so the list label moves immediately. */
|
|
40
|
+
noteAgentPreset?(sessionId: string, agentPreset: string): void
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The narrow agent-preset wire face the service needs (`agentPreset.select`). */
|
|
44
|
+
export interface PresetsExecutionFace {
|
|
45
|
+
/** Recompose a blank session's agent from a preset. */
|
|
46
|
+
select(sessionId: string, agentPreset: string): Promise<{ ok: true } | { ok: false; error: unknown }>
|
|
29
47
|
}
|
|
30
48
|
|
|
31
49
|
/** The narrow workspaces face the service needs. */
|
|
@@ -57,6 +75,12 @@ export interface SessionDriver {
|
|
|
57
75
|
content: readonly unknown[],
|
|
58
76
|
mode: 'queue',
|
|
59
77
|
): Promise<{ ok: true } | { ok: false; error: unknown }>
|
|
78
|
+
/**
|
|
79
|
+
* Admit one slash-command line against the session's agent (the
|
|
80
|
+
* `/permission <id>` mechanism). `matched` reports whether a command
|
|
81
|
+
* claimed the line.
|
|
82
|
+
*/
|
|
83
|
+
command(line: string): Promise<{ ok: true; matched: boolean } | { ok: false; error: unknown }>
|
|
60
84
|
getSnapshot(): { running: boolean; lastAgentError: string | null; turnEnds: ReadonlyMap<number, number> }
|
|
61
85
|
subscribe(fn: () => void): () => void
|
|
62
86
|
}
|
|
@@ -65,6 +89,8 @@ export interface SessionDriver {
|
|
|
65
89
|
export interface ExecutionEnvironment {
|
|
66
90
|
sessions: SessionsExecutionFace
|
|
67
91
|
workspaces: WorkspacesExecutionFace
|
|
92
|
+
/** Agent-preset wire face; absent on deployments without preset support. */
|
|
93
|
+
presets?: PresetsExecutionFace
|
|
68
94
|
/** Raw-history reader for failure detection of never-opened sessions. */
|
|
69
95
|
history?: HistoryExecutionFace
|
|
70
96
|
}
|
|
@@ -80,6 +106,19 @@ function messageOf(error: unknown): string {
|
|
|
80
106
|
return String(error)
|
|
81
107
|
}
|
|
82
108
|
|
|
109
|
+
/**
|
|
110
|
+
* Whether a rejected preset switch actually means "the session already runs
|
|
111
|
+
* this preset" (the host's agent-preset-conflict with a matching
|
|
112
|
+
* existingPreset). A blank-session reuse race can produce this even though
|
|
113
|
+
* the requested composition is in place.
|
|
114
|
+
*/
|
|
115
|
+
function presetAlreadyRuns(error: unknown, mode: string): boolean {
|
|
116
|
+
if (typeof error !== 'object' || error === null) return false
|
|
117
|
+
const details = (error as { details?: unknown }).details
|
|
118
|
+
if (typeof details !== 'object' || details === null) return false
|
|
119
|
+
return (details as { existingPreset?: unknown }).existingPreset === mode
|
|
120
|
+
}
|
|
121
|
+
|
|
83
122
|
/** Whether a `turn/end` payload closed the turn with an error reason. */
|
|
84
123
|
function isErrorTurnEnd(data: unknown): boolean {
|
|
85
124
|
if (typeof data !== 'object' || data === null) return false
|
|
@@ -106,14 +145,24 @@ export class ExecutionService {
|
|
|
106
145
|
execution: ExecutionRecord,
|
|
107
146
|
onEvent: (event: ExecutionEvent) => void,
|
|
108
147
|
): Promise<void> {
|
|
148
|
+
const settleFailed = (error: string): void => {
|
|
149
|
+
onEvent({ kind: 'settled', taskId: task.id, executionId: execution.id, outcome: 'failed', error })
|
|
150
|
+
}
|
|
109
151
|
try {
|
|
110
|
-
const sessionId = await this.connectSession()
|
|
152
|
+
const sessionId = await this.connectSession(task.workspaceId)
|
|
111
153
|
onEvent({ kind: 'started', taskId: task.id, executionId: execution.id, sessionId })
|
|
112
154
|
const driver = this.driverOf(sessionId)
|
|
113
155
|
if (driver === undefined) {
|
|
114
|
-
|
|
156
|
+
settleFailed('execution session is not ready')
|
|
115
157
|
return
|
|
116
158
|
}
|
|
159
|
+
// Task-pinned execution targets are applied BEFORE any prompt: a preset
|
|
160
|
+
// can only be recomposed while the session is blank, and the permission
|
|
161
|
+
// command must run before the task's turn starts. A rejected target
|
|
162
|
+
// fails the run without sending the prompt — running the task under
|
|
163
|
+
// different settings than it declared would be worse than not running.
|
|
164
|
+
if (!await this.applyMode(driver, task, sessionId, settleFailed)) return
|
|
165
|
+
if (!await this.applyPermission(driver, task, settleFailed)) return
|
|
117
166
|
// Best-effort rename so the execution is recognizable in the session list.
|
|
118
167
|
await driver.rename(task.title).catch(() => { /* rename is cosmetic */ })
|
|
119
168
|
// Baseline the turn counter BEFORE the prompt round-trip: a turn that
|
|
@@ -122,21 +171,92 @@ export class ExecutionService {
|
|
|
122
171
|
const baseline = driver.getSnapshot().turnEnds.size
|
|
123
172
|
const accepted = await this.sendPrompt(driver, task)
|
|
124
173
|
if (!accepted.ok) {
|
|
125
|
-
|
|
126
|
-
kind: 'settled', taskId: task.id, executionId: execution.id, outcome: 'failed',
|
|
127
|
-
error: messageOf(accepted.error),
|
|
128
|
-
})
|
|
174
|
+
settleFailed(messageOf(accepted.error))
|
|
129
175
|
return
|
|
130
176
|
}
|
|
131
177
|
this.watchForSettlement(driver, task.id, execution.id, onEvent, baseline)
|
|
132
178
|
} catch (error) {
|
|
133
|
-
|
|
134
|
-
kind: 'settled', taskId: task.id, executionId: execution.id, outcome: 'failed',
|
|
135
|
-
error: messageOf(error),
|
|
136
|
-
})
|
|
179
|
+
settleFailed(messageOf(error))
|
|
137
180
|
}
|
|
138
181
|
}
|
|
139
182
|
|
|
183
|
+
/**
|
|
184
|
+
* Recompose the execution session's agent from the task-pinned preset.
|
|
185
|
+
* No-op when the task pins none or the session already runs it; fails the
|
|
186
|
+
* run when the session is no longer blank, the preset face is missing, or
|
|
187
|
+
* the wire refuses.
|
|
188
|
+
*/
|
|
189
|
+
private async applyMode(
|
|
190
|
+
driver: SessionDriver,
|
|
191
|
+
task: TaskRecord,
|
|
192
|
+
sessionId: string,
|
|
193
|
+
settleFailed: (error: string) => void,
|
|
194
|
+
): Promise<boolean> {
|
|
195
|
+
const mode = task.mode
|
|
196
|
+
if (mode === undefined || mode === '') return true
|
|
197
|
+
const summary = this.env.sessions.list.getSnapshot().byId[sessionId]
|
|
198
|
+
if (summary?.blank === false) {
|
|
199
|
+
settleFailed(`cannot switch agent preset to ${mode}: the execution session is not blank`)
|
|
200
|
+
return false
|
|
201
|
+
}
|
|
202
|
+
if (summary?.agentPreset === mode) return true
|
|
203
|
+
if (this.env.presets === undefined) {
|
|
204
|
+
settleFailed(`this deployment does not support agent presets (task asks for ${mode})`)
|
|
205
|
+
return false
|
|
206
|
+
}
|
|
207
|
+
try {
|
|
208
|
+
const result = await this.env.presets.select(sessionId, mode)
|
|
209
|
+
if (!result.ok) {
|
|
210
|
+
// A list race can leave the summary without the preset label even
|
|
211
|
+
// though the blank session already runs it; the wire answers that
|
|
212
|
+
// case with agent-preset-conflict (existingPreset === requested).
|
|
213
|
+
// The requested composition is already in place, so count it as
|
|
214
|
+
// applied instead of failing the run.
|
|
215
|
+
if (presetAlreadyRuns(result.error, mode)) {
|
|
216
|
+
this.env.sessions.noteAgentPreset?.(sessionId, mode)
|
|
217
|
+
return true
|
|
218
|
+
}
|
|
219
|
+
settleFailed(`agent preset switch to ${mode} rejected: ${messageOf(result.error)}`)
|
|
220
|
+
return false
|
|
221
|
+
}
|
|
222
|
+
} catch (error) {
|
|
223
|
+
settleFailed(`agent preset switch to ${mode} failed: ${messageOf(error)}`)
|
|
224
|
+
return false
|
|
225
|
+
}
|
|
226
|
+
this.env.sessions.noteAgentPreset?.(sessionId, mode)
|
|
227
|
+
return true
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Apply the task-pinned permission preset through the `/permission <id>`
|
|
232
|
+
* slash command. No-op when the task pins none; fails the run when the
|
|
233
|
+
* admission is rejected or no command claimed the line.
|
|
234
|
+
*/
|
|
235
|
+
private async applyPermission(
|
|
236
|
+
driver: SessionDriver,
|
|
237
|
+
task: TaskRecord,
|
|
238
|
+
settleFailed: (error: string) => void,
|
|
239
|
+
): Promise<boolean> {
|
|
240
|
+
const permission = task.permission
|
|
241
|
+
if (permission === undefined) return true
|
|
242
|
+
const line = `/permission ${permission}`
|
|
243
|
+
try {
|
|
244
|
+
const result = await driver.command(line)
|
|
245
|
+
if (!result.ok) {
|
|
246
|
+
settleFailed(`permission command rejected: ${messageOf(result.error)}`)
|
|
247
|
+
return false
|
|
248
|
+
}
|
|
249
|
+
if (!result.matched) {
|
|
250
|
+
settleFailed(`permission command not recognized: ${line}`)
|
|
251
|
+
return false
|
|
252
|
+
}
|
|
253
|
+
} catch (error) {
|
|
254
|
+
settleFailed(`permission command failed: ${messageOf(error)}`)
|
|
255
|
+
return false
|
|
256
|
+
}
|
|
257
|
+
return true
|
|
258
|
+
}
|
|
259
|
+
|
|
140
260
|
/**
|
|
141
261
|
* Inspect a reloaded/background task that was left 'running' and emit a
|
|
142
262
|
* settled event when its session already finished.
|
|
@@ -198,8 +318,16 @@ export class ExecutionService {
|
|
|
198
318
|
}
|
|
199
319
|
}
|
|
200
320
|
|
|
201
|
-
private async connectSession(): Promise<string> {
|
|
321
|
+
private async connectSession(taskWorkspaceId: string | undefined): Promise<string> {
|
|
202
322
|
const workspace = this.env.workspaces.list.getSnapshot()
|
|
323
|
+
if (taskWorkspaceId !== undefined && taskWorkspaceId !== '') {
|
|
324
|
+
// A task-pinned workspace must exist in the list: connecting an
|
|
325
|
+
// unknown id would only defer the failure into the wire.
|
|
326
|
+
if (!workspace.items.some(item => item.workspaceId === taskWorkspaceId)) {
|
|
327
|
+
throw new Error(`task workspace is not available: ${taskWorkspaceId}`)
|
|
328
|
+
}
|
|
329
|
+
return this.env.workspaces.connectWorkspace(taskWorkspaceId)
|
|
330
|
+
}
|
|
203
331
|
const workspaceId = workspace.recentWorkspaceId ?? workspace.items[0]?.workspaceId
|
|
204
332
|
if (workspaceId === undefined) {
|
|
205
333
|
throw new Error('no workspace available to run the task in')
|