@linxin666/dsh-client-ui-task-board 0.4.2 → 0.4.4

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.
Files changed (73) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +21 -13
  3. package/README.zh.md +21 -13
  4. package/lib/client.js +1566 -929
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +934 -176
  7. package/lib/types/client/board/TaskForm.d.ts +19 -1
  8. package/lib/types/client/board/preset-label.d.ts +22 -0
  9. package/lib/types/client/host-api.d.ts +24 -1
  10. package/lib/types/client/index.d.ts +15 -1
  11. package/lib/types/client/locales.d.ts +38 -2
  12. package/lib/types/client/native-panel.d.ts +62 -0
  13. package/lib/types/client/schedule-zone.d.ts +47 -0
  14. package/lib/types/client/settings-entry-form.d.ts +49 -0
  15. package/lib/types/core/controller.d.ts +42 -2
  16. package/lib/types/core/degraded-reason.d.ts +46 -0
  17. package/lib/types/core/package-name.d.ts +9 -0
  18. package/lib/types/core/schedule.d.ts +95 -20
  19. package/lib/types/core/subtask.d.ts +30 -11
  20. package/lib/types/core/tasks.d.ts +26 -2
  21. package/lib/types/core/use-cases/task-create.d.ts +2 -1
  22. package/lib/types/core/use-cases/task-schedule.d.ts +8 -4
  23. package/lib/types/core/use-cases/task-update.d.ts +1 -1
  24. package/lib/types/host-ledger.d.ts +65 -4
  25. package/lib/types/host-runner.d.ts +76 -3
  26. package/lib/types/host-service.d.ts +77 -5
  27. package/lib/types/index.d.ts +11 -1
  28. package/lib/types/mount-once.d.ts +38 -7
  29. package/lib/types/protocol.d.ts +9 -3
  30. package/package.json +26 -23
  31. package/src/client/board/LinkSubtaskModal.tsx +21 -11
  32. package/src/client/board/NewTaskModal.tsx +271 -150
  33. package/src/client/board/TaskCard.tsx +24 -5
  34. package/src/client/board/TaskDetail.tsx +83 -33
  35. package/src/client/board/TaskForm.tsx +48 -3
  36. package/src/client/board/preset-label.ts +44 -0
  37. package/src/client/board.module.css +130 -136
  38. package/src/client/host-api.ts +109 -6
  39. package/src/client/index.ts +50 -18
  40. package/src/client/locales.ts +82 -8
  41. package/src/client/native-panel.tsx +124 -0
  42. package/src/client/schedule-zone.ts +135 -0
  43. package/src/client/settings-entry-form.ts +142 -0
  44. package/src/core/controller.ts +66 -4
  45. package/src/core/degraded-reason.ts +81 -0
  46. package/src/core/package-name.ts +9 -0
  47. package/src/core/schedule.ts +211 -55
  48. package/src/core/store.ts +10 -1
  49. package/src/core/subtask.ts +77 -22
  50. package/src/core/tasks.ts +38 -4
  51. package/src/core/use-cases/task-create.ts +14 -2
  52. package/src/core/use-cases/task-schedule.ts +17 -6
  53. package/src/core/use-cases/task-update.ts +4 -1
  54. package/src/host/agent-tools.ts +40 -8
  55. package/src/host-ledger.ts +223 -23
  56. package/src/host-routes.ts +37 -1
  57. package/src/host-runner.ts +164 -14
  58. package/src/host-service.ts +232 -39
  59. package/src/index.ts +23 -1
  60. package/src/mount-once.ts +107 -13
  61. package/src/protocol.ts +27 -8
  62. package/lib/types/client/board-mount.d.ts +0 -23
  63. package/lib/types/client/body-mutations.d.ts +0 -45
  64. package/lib/types/client/panel-mount-core.d.ts +0 -56
  65. package/lib/types/client/sidebar-entry-core.d.ts +0 -55
  66. package/lib/types/client/sidebar-entry.d.ts +0 -29
  67. package/lib/types/host/run-guarded.d.ts +0 -39
  68. package/src/client/board-mount.tsx +0 -43
  69. package/src/client/body-mutations.ts +0 -133
  70. package/src/client/panel-mount-core.ts +0 -155
  71. package/src/client/sidebar-entry-core.ts +0 -237
  72. package/src/client/sidebar-entry.ts +0 -53
  73. package/src/host/run-guarded.ts +0 -76
@@ -60,6 +60,17 @@ function isDefinitionWithdrawn(error: unknown): boolean {
60
60
 
61
61
  const SERVICE_UNAVAILABLE_ATTEMPTS = 5
62
62
  const SERVICE_UNAVAILABLE_BACKOFF_MS = 2_000
63
+ /**
64
+ * Largest number of history pages one inspection walks back. A session whose
65
+ * window is wider than this cannot be resolved from its log, which the caller
66
+ * treats as an unreadable history rather than as pending work.
67
+ */
68
+ const HISTORY_PAGE_LIMIT = 100
69
+
70
+ /** One-line text of a gateway or stream failure, for a decideable reason. */
71
+ function failureText(error: unknown): string {
72
+ return error instanceof Error ? error.message : String(error)
73
+ }
63
74
 
64
75
  function delay(ms: number): Promise<void> {
65
76
  return new Promise(resolve => { setTimeout(resolve, ms) })
@@ -87,7 +98,14 @@ export interface SessionCommandDispatcher {
87
98
  }
88
99
 
89
100
  export type ExecutionInspection =
90
- | { outcome: 'pending' }
101
+ /**
102
+ * No verdict yet. `unreadable` separates "the work may still be in progress"
103
+ * from "this session's history could not be read at all": a reader failure is
104
+ * not progress, and the caller decides how long to keep waiting before it
105
+ * reports the execution as undeterminable instead of holding a card open
106
+ * forever.
107
+ */
108
+ | { outcome: 'pending'; unreadable?: true; reason?: string }
91
109
  | { outcome: 'succeeded' }
92
110
  | { outcome: 'failed'; error: string }
93
111
  | { outcome: 'cancelled'; error: string }
@@ -136,6 +154,13 @@ export interface PromptContext {
136
154
  peers?: readonly PromptPeer[]
137
155
  /** True when those members run as teammates inside this session (Team Lead). */
138
156
  team?: boolean
157
+ /**
158
+ * Scheduled-run provenance: the instant the cron rule actually fired and the
159
+ * IANA zone its wall clock was read in. Present only for a cron-triggered
160
+ * run, so the model can resolve an unqualified date in the run's own terms
161
+ * instead of guessing from the Host clock (issue #1722).
162
+ */
163
+ schedule?: { triggeredAt: number; timeZone: string; cron: string }
139
164
  }
140
165
 
141
166
  /**
@@ -147,13 +172,27 @@ function peerPromptPreamble(context: PromptContext): string | undefined {
147
172
  const peers = context.peers ?? []
148
173
  if (peers.length === 0) return undefined
149
174
  if (context.team === true) {
150
- const lines = peers.map(peer => `- ${escapeProvenanceDelimiter(peer.title)}(teammate: ${peer.name ?? teammateName(peer.title, peer.id)})`)
175
+ const lines = peers.map(peer => `- ${escapeProvenanceDelimiter(peer.title)}(teammate: ${peer.name ?? teammateName(peer.title, peer.id, peer.id)})`)
151
176
  return `本任务是 Agent Team 的 Lead:本次运行不额外开启独立会话,以下 ${peers.length} 个子任务成员已在本会话中作为 teammate 启动。用 list_agents / send_message / wait_agent 协调它们,用任务看板工具读写它们在看板上的卡片:\n${lines.join('\n')}`
152
177
  }
153
178
  const lines = peers.map(peer => `- ${escapeProvenanceDelimiter(peer.title)}(任务 ${peer.id})`)
154
179
  return `本次运行同时并发开启 ${peers.length} 个独立 DSH 会话执行下列子任务成员(可用 task_board_* 工具查看它们的进度):\n${lines.join('\n')}`
155
180
  }
156
181
 
182
+ /**
183
+ * The scheduled-run section: this execution was triggered by the card's cron
184
+ * rule rather than by a person. It states the firing instant, the rule's zone,
185
+ * and the rule text, so the run's own clock is unambiguous — the board's
186
+ * scheduler fires on a wall clock in a specific IANA zone, which is not
187
+ * necessarily the zone of whatever session the run lands in.
188
+ */
189
+ function schedulePromptPreamble(context: PromptContext): string | undefined {
190
+ const schedule = context.schedule
191
+ if (schedule === undefined) return undefined
192
+ const stamp = new Date(schedule.triggeredAt).toISOString()
193
+ return `本次执行由任务看板的定时规则自动触发:触发时间 ${stamp}(UTC),规则时区 ${schedule.timeZone},cron 表达式 ${schedule.cron}。需要判断「今天」「现在」或计算时间窗口时,以该时区的触发时间为准。`
194
+ }
195
+
157
196
  /**
158
197
  * Build the execution prompt for one task.
159
198
  * @param task - the task being launched.
@@ -167,11 +206,14 @@ export function promptText(task: TaskRecord, context: PromptContext = {}): strin
167
206
  ? undefined
168
207
  : `交接包引用(来自任务看板续接卡片,冻结于 ${new Date(handover.bundledAt).toISOString()}):\n${handover.references.map(reference => `- ${reference}`).join('\n')}`
169
208
  // Tag prompts come first: they are the run's standing context (business line,
170
- // output location), the handover preamble is a per-card note, and the task
171
- // body is the instruction itself.
209
+ // output location), the handover preamble is a per-card note, the scheduled
210
+ // section states why this run exists, and the task body is the instruction.
172
211
  const tagPreamble = tagPromptPreamble(task)
212
+ const handoverSection = handoverPreamble
213
+ const schedulePreamble = schedulePromptPreamble(context)
173
214
  const runPreamble = peerPromptPreamble(context)
174
- const preambles = [tagPreamble, handoverPreamble, runPreamble].filter((part): part is string => part !== undefined)
215
+ const preambles = [tagPreamble, handoverSection, schedulePreamble, runPreamble]
216
+ .filter((part): part is string => part !== undefined)
175
217
  const preamble = preambles.length === 0 ? undefined : preambles.join('\n\n')
176
218
  const freeze = task.freeze
177
219
  if (freeze === undefined) {
@@ -182,6 +224,27 @@ export function promptText(task: TaskRecord, context: PromptContext = {}): strin
182
224
  return preamble === undefined ? declaration : `${preamble}\n\n${declaration}`
183
225
  }
184
226
 
227
+ /**
228
+ * The parts of an objective the built-in `/goal` command reads as an operation
229
+ * rather than as an objective: an exact `clear`/`pause`/`resume`/`edit`
230
+ * (case-insensitive) and a leading `edit <objective>`.
231
+ */
232
+ const GOAL_COMMAND_KEYWORD = /^(?:clear|pause|resume|edit)$|^edit\s/i
233
+
234
+ /**
235
+ * Build the objective the built-in `/goal` command receives. A prompt whose
236
+ * text would be read as a goal operation is prefixed with a neutral label: the
237
+ * instruction stays intact, and the command arms an objective instead of
238
+ * clearing, pausing, or editing an unrelated goal (or refusing for a missing
239
+ * one).
240
+ * @param prompt - the composed execution prompt.
241
+ * @returns the objective text for `/goal <objective>`.
242
+ */
243
+ export function goalObjective(prompt: string): string {
244
+ const text = prompt.trim()
245
+ return GOAL_COMMAND_KEYWORD.test(text) ? `Goal: ${text}` : text
246
+ }
247
+
185
248
  /**
186
249
  * Build the tag section of the execution prompt (issue #1521). Only tags with
187
250
  * a non-blank `promptPrefix` contribute; a task whose tags are all bare names
@@ -393,6 +456,82 @@ export class HostExecutionRunner {
393
456
  mode: 'queue' as const,
394
457
  content: [{ type: 'text' as const, text: promptText(task, context) }],
395
458
  })
459
+ await this.armGoal(sessionId, task, context)
460
+ }
461
+
462
+ /**
463
+ * Arm dsh's built-in goal on the session. The task prompt is queued FIRST, so
464
+ * the session's first turn carries the instruction verbatim; `/goal
465
+ * <objective>` then turns the same text into a persistent objective, and
466
+ * dsh's goal-round driver keeps starting continuation rounds until the agent
467
+ * marks it complete (see {@link goalVerdict} for how the run settles).
468
+ *
469
+ * Opt-out: a task whose `goalRun` is an explicit false runs one plain turn.
470
+ * A refusal (no command dispatcher, no `/goal` command in this cohort, an
471
+ * objective the command rejects) is reported and the run continues as that
472
+ * plain turn: the task was asked to run, and a missing goal mode must not
473
+ * lose the work.
474
+ */
475
+ private async armGoal(sessionId: ExecutionSessionId, task: TaskRecord, context: PromptContext): Promise<void> {
476
+ if (task.goalRun === false) return
477
+ if (this.commands === undefined) {
478
+ console.warn('[dsh-task-board] no command dispatcher is available; task ' + task.id + ' runs without /goal')
479
+ return
480
+ }
481
+ try {
482
+ const command = await this.commands.execute(
483
+ sessionId,
484
+ '/goal ' + goalObjective(promptText(task, context)),
485
+ AbortSignal.timeout(30_000),
486
+ )
487
+ if (command === undefined) {
488
+ console.warn('[dsh-task-board] /goal was not acknowledged for session ' + sessionId + '; the run continues as a plain turn')
489
+ return
490
+ }
491
+ if (command.kind !== 'success') {
492
+ console.warn('[dsh-task-board] /goal was refused for session ' + sessionId + ': ' + (command.text ?? 'no reason reported') + '; the run continues as a plain turn')
493
+ }
494
+ } catch (error) {
495
+ console.warn('[dsh-task-board] could not arm /goal on session ' + sessionId + '; the run continues as a plain turn', error)
496
+ }
497
+ }
498
+
499
+ /**
500
+ * The verdict an armed dsh goal imposes on an otherwise completed turn.
501
+ *
502
+ * A goal run is one session working many automatic continuation rounds:
503
+ * dsh's goal-round driver queues the next round whenever the agent goes
504
+ * idle, so the first completed `turn/end` is not the execution's result.
505
+ * Settling there would leave the card in `done` while the session keeps
506
+ * working, and a scheduled task would fire a second, concurrent session.
507
+ * The session projection is the authority: an `active` goal keeps the
508
+ * execution pending, a `blocked` goal fails it with the recorded reason, and
509
+ * any other phase (complete, paused, or no goal at all) leaves the turn
510
+ * verdict in place.
511
+ *
512
+ * An unreadable projection falls back to that turn verdict: a cohort whose
513
+ * gateway withdrew `session/projections` must not hang every execution.
514
+ */
515
+ private async goalVerdict(sessionId: string): Promise<ExecutionInspection | undefined> {
516
+ let projections: { values?: { goal?: unknown } } | null
517
+ try {
518
+ projections = await this.invoke('session', 'projections', { sessionId }) as { values?: { goal?: unknown } } | null
519
+ } catch (error) {
520
+ console.warn('[dsh-task-board] session/projections failed while reading the goal phase; settling from the turn result', error)
521
+ return undefined
522
+ }
523
+ const goal = projections?.values?.goal
524
+ if (goal === undefined || goal === null) return undefined
525
+ const view = (goal as { goal?: { phase?: unknown; blockedReason?: { message?: unknown } } }).goal
526
+ if (view?.phase === 'active') return { outcome: 'pending' }
527
+ if (view?.phase === 'blocked') {
528
+ const message = view.blockedReason?.message
529
+ return {
530
+ outcome: 'failed',
531
+ error: 'goal is blocked: ' + (typeof message === 'string' && message !== '' ? message : 'no reason recorded'),
532
+ }
533
+ }
534
+ return undefined
396
535
  }
397
536
 
398
537
  async listRunning(): Promise<{ known: true; count: number; items: SessionSummary[] } | { known: false }> {
@@ -424,8 +563,17 @@ export class HostExecutionRunner {
424
563
  }
425
564
  }
426
565
 
427
- /** Resolve an execution outcome from the session list and bounded history pages. */
428
- async inspect(sessionId: string, startedAt = 0, sessions?: readonly SessionSummary[]): Promise<ExecutionInspection> {
566
+ /**
567
+ * Resolve an execution outcome from the session list and bounded history pages.
568
+ * @param sessionId - the session the execution runs in.
569
+ * @param startedAt - instant the execution opened; earlier events are not its own.
570
+ * @param sessions - roster from the caller's own poll, when it already has one.
571
+ * @param options.whileRunning - read the first completed turn even while the
572
+ * roster reports the session as running. A durable Agent Teams teammate
573
+ * stays alive after its turn ends, so `running` never clears for it and the
574
+ * turn it completed is the only verdict it will ever expose.
575
+ */
576
+ async inspect(sessionId: string, startedAt = 0, sessions?: readonly SessionSummary[], options: { whileRunning?: boolean } = {}): Promise<ExecutionInspection> {
429
577
  let items: readonly SessionSummary[]
430
578
  if (sessions !== undefined) {
431
579
  items = sessions
@@ -455,7 +603,7 @@ export class HostExecutionRunner {
455
603
  this.scanMemos.delete(sessionId)
456
604
  return { outcome: 'cancelled', error: 'execution session no longer exists' }
457
605
  }
458
- if (summary.running) return { outcome: 'pending' }
606
+ if (summary.running && options.whileRunning !== true) return { outcome: 'pending' }
459
607
 
460
608
  let opening: { cursor: number; records: readonly SessionHistoryRecord[]; hasMore: boolean }
461
609
  try {
@@ -465,12 +613,12 @@ export class HostExecutionRunner {
465
613
  if (typeof iterator.return === 'function') await iterator.return()
466
614
  const follow = next.done === true ? undefined : next.value as { type?: string; cursor?: number; records?: readonly SessionHistoryRecord[]; hasMore?: boolean }
467
615
  if (follow === undefined || follow.type !== 'snapshot' || typeof follow.cursor !== 'number' || follow.records === undefined || typeof follow.hasMore !== 'boolean') {
468
- return { outcome: 'pending' }
616
+ return { outcome: 'pending', unreadable: true, reason: 'session/follow returned no opening frame' }
469
617
  }
470
618
  opening = { cursor: follow.cursor, records: follow.records, hasMore: follow.hasMore }
471
619
  } catch (error) {
472
620
  console.warn('[dsh-task-board] session/follow failed during execution inspection; keeping the outcome pending', error)
473
- return { outcome: 'pending' }
621
+ return { outcome: 'pending', unreadable: true, reason: 'session/follow failed: ' + failureText(error) }
474
622
  }
475
623
  const openingEvents = opening.records.map(record => ({ event: recordEvent(record) }))
476
624
  const newestSeq = openingEvents.reduce<number | undefined>((newest, entry) => newest === undefined ? entry.event.seq : Math.max(newest, entry.event.seq), undefined)
@@ -478,7 +626,7 @@ export class HostExecutionRunner {
478
626
  const events: Array<{ event: { type: string; seq: number; time: number; data: unknown } }> = [...openingEvents]
479
627
  let beforeSeq: number | undefined
480
628
  let reachedExecutionBoundary = !opening.hasMore
481
- for (let page = 0; page < 100 && !reachedExecutionBoundary; page += 1) {
629
+ for (let page = 0; page < HISTORY_PAGE_LIMIT && !reachedExecutionBoundary; page += 1) {
482
630
  let history: SessionPage
483
631
  try {
484
632
  history = await this.invoke('session', 'page', {
@@ -489,7 +637,7 @@ export class HostExecutionRunner {
489
637
  }) as SessionPage
490
638
  } catch (error) {
491
639
  console.warn('[dsh-task-board] session/page failed during execution inspection; keeping the outcome pending', error)
492
- return { outcome: 'pending' }
640
+ return { outcome: 'pending', unreadable: true, reason: 'session/page failed: ' + failureText(error) }
493
641
  }
494
642
  const pageEntries = pageEvents(history)
495
643
  events.push(...pageEntries)
@@ -502,7 +650,9 @@ export class HostExecutionRunner {
502
650
  if (oldestSeq === undefined || oldestSeq === beforeSeq) return { outcome: 'pending' }
503
651
  beforeSeq = oldestSeq
504
652
  }
505
- if (!reachedExecutionBoundary) return { outcome: 'pending' }
653
+ if (!reachedExecutionBoundary) {
654
+ return { outcome: 'pending', unreadable: true, reason: 'history scan did not reach the execution start within ' + HISTORY_PAGE_LIMIT + ' pages' }
655
+ }
506
656
  const turnEnd = events
507
657
  .filter(entry => entry.event.type === 'turn/end' && (startedAt <= 0 || entry.event.time >= startedAt))
508
658
  .sort((a, b) => a.event.seq - b.event.seq)[0]
@@ -512,7 +662,7 @@ export class HostExecutionRunner {
512
662
  }
513
663
  this.scanMemos.delete(sessionId)
514
664
  const reason = nonCompletedTurnEnd(turnEnd.event.data)
515
- if (reason === undefined) return { outcome: 'succeeded' }
665
+ if (reason === undefined) return await this.goalVerdict(sessionId) ?? { outcome: 'succeeded' }
516
666
  // `error` keeps its historical wording; every other non-completed reason
517
667
  // (aborted / blocked / max-tokens / interrupted) now reports instead of
518
668
  // silently counting as success.