@linxin666/dsh-client-ui-task-board 0.4.3 → 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.
- package/README.i18n.yaml +2 -2
- package/README.md +17 -10
- package/README.zh.md +17 -10
- package/lib/client.js +1350 -552
- package/lib/client.js.map +1 -1
- package/lib/index.js +655 -134
- package/lib/types/client/board/TaskForm.d.ts +19 -1
- package/lib/types/client/board/preset-label.d.ts +22 -0
- package/lib/types/client/host-api.d.ts +24 -1
- package/lib/types/client/locales.d.ts +38 -2
- package/lib/types/client/schedule-zone.d.ts +47 -0
- package/lib/types/core/controller.d.ts +1 -0
- package/lib/types/core/degraded-reason.d.ts +46 -0
- package/lib/types/core/package-name.d.ts +9 -0
- package/lib/types/core/schedule.d.ts +95 -20
- package/lib/types/core/subtask.d.ts +30 -11
- package/lib/types/core/tasks.d.ts +26 -2
- package/lib/types/core/use-cases/task-create.d.ts +2 -1
- package/lib/types/core/use-cases/task-schedule.d.ts +8 -4
- package/lib/types/core/use-cases/task-update.d.ts +1 -1
- package/lib/types/host-ledger.d.ts +56 -4
- package/lib/types/host-runner.d.ts +76 -3
- package/lib/types/host-service.d.ts +26 -0
- package/lib/types/protocol.d.ts +9 -3
- package/package.json +25 -25
- package/src/client/board/LinkSubtaskModal.tsx +21 -11
- package/src/client/board/NewTaskModal.tsx +271 -150
- package/src/client/board/TaskCard.tsx +24 -5
- package/src/client/board/TaskDetail.tsx +83 -33
- package/src/client/board/TaskForm.tsx +48 -3
- package/src/client/board/preset-label.ts +44 -0
- package/src/client/board.module.css +114 -5
- package/src/client/host-api.ts +109 -6
- package/src/client/index.ts +5 -1
- package/src/client/locales.ts +82 -8
- package/src/client/schedule-zone.ts +135 -0
- package/src/core/controller.ts +7 -2
- package/src/core/degraded-reason.ts +81 -0
- package/src/core/package-name.ts +9 -0
- package/src/core/schedule.ts +211 -55
- package/src/core/store.ts +10 -1
- package/src/core/subtask.ts +77 -22
- package/src/core/tasks.ts +38 -4
- package/src/core/use-cases/task-create.ts +14 -2
- package/src/core/use-cases/task-schedule.ts +17 -6
- package/src/core/use-cases/task-update.ts +4 -1
- package/src/host/agent-tools.ts +40 -8
- package/src/host-ledger.ts +203 -23
- package/src/host-runner.ts +164 -14
- package/src/host-service.ts +94 -17
- package/src/protocol.ts +27 -8
- package/lib/types/host/run-guarded.d.ts +0 -39
- package/src/host/run-guarded.ts +0 -76
package/src/host-runner.ts
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
171
|
-
// body is the instruction
|
|
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,
|
|
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
|
-
/**
|
|
428
|
-
|
|
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 <
|
|
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)
|
|
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.
|
package/src/host-service.ts
CHANGED
|
@@ -50,6 +50,26 @@ const SESSION_POLL_MS = 5_000
|
|
|
50
50
|
const RECOVERY_TOLERANCE_MS = 60_000
|
|
51
51
|
/** Largest delay a Node timer represents without clamping; longer targets re-arm in segments. */
|
|
52
52
|
const MAX_TIMER_DELAY_MS = 2_147_483_647
|
|
53
|
+
/**
|
|
54
|
+
* Consecutive polls that may report one execution's session history as
|
|
55
|
+
* unreadable before it is reported failed. The roster poll runs every 5 s, so
|
|
56
|
+
* this is two minutes of a session that is NOT running: no turn is executing
|
|
57
|
+
* there and its history cannot be read, which means no verdict will ever
|
|
58
|
+
* arrive. Reporting that as a failure keeps a card — and every ancestor of it —
|
|
59
|
+
* out of the running column, which nothing else can rescue.
|
|
60
|
+
*/
|
|
61
|
+
const UNREADABLE_SETTLE_POLLS = 24
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Provenance of one cron-triggered cascade: when the rule fired and the zone
|
|
65
|
+
* its wall clock was read in. Passed into every launched prompt of that run so
|
|
66
|
+
* a scheduled job knows its own clock rather than inferring one.
|
|
67
|
+
*/
|
|
68
|
+
export interface ScheduledRunContext {
|
|
69
|
+
triggeredAt: number
|
|
70
|
+
timeZone: string
|
|
71
|
+
cron: string
|
|
72
|
+
}
|
|
53
73
|
|
|
54
74
|
/**
|
|
55
75
|
* Actions that can move an armed trigger. Only these re-arm the native timer;
|
|
@@ -102,6 +122,12 @@ export class TaskBoardHostService {
|
|
|
102
122
|
* conversation instead of prompting into a session it cannot see.
|
|
103
123
|
*/
|
|
104
124
|
private idleSessionIds: ReadonlySet<string> | undefined
|
|
125
|
+
/**
|
|
126
|
+
* Consecutive unreadable-history polls per open execution (see
|
|
127
|
+
* {@link noteUnreadableInspection}). Cleared as soon as an inspection
|
|
128
|
+
* resolves, so a transient reader failure never fails a card.
|
|
129
|
+
*/
|
|
130
|
+
private readonly unreadablePolls = new Map<string, number>()
|
|
105
131
|
private preventIdleSleep = false
|
|
106
132
|
private readonly team: TaskBoardTeamDispatcher | undefined
|
|
107
133
|
private readonly timers: HostTimerFace
|
|
@@ -239,7 +265,7 @@ export class TaskBoardHostService {
|
|
|
239
265
|
this.listeners.clear()
|
|
240
266
|
}
|
|
241
267
|
|
|
242
|
-
private async launch(opened: OpenedRun, others: readonly OpenedRun[] = []): Promise<void> {
|
|
268
|
+
private async launch(opened: OpenedRun, others: readonly OpenedRun[] = [], schedule?: ScheduledRunContext): Promise<void> {
|
|
243
269
|
try {
|
|
244
270
|
// A team run always mints a fresh Lead session: teammates are immutable
|
|
245
271
|
// children of that session, so reusing an older one would collide on
|
|
@@ -248,13 +274,17 @@ export class TaskBoardHostService {
|
|
|
248
274
|
const reuseSessionId = team ? undefined : reusableSessionId(opened.task, this.idleSessionIds)
|
|
249
275
|
// Both modes tell the launched agent what else this run opens; only a team
|
|
250
276
|
// run names teammates, because only then does this session own them.
|
|
251
|
-
const
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
277
|
+
const peers = others.length === 0 ? undefined : others.map(other => ({
|
|
278
|
+
id: other.task.id,
|
|
279
|
+
title: other.task.title,
|
|
280
|
+
...(team ? { name: teammateName(other.task.title, other.execution.runGroupId ?? other.task.id, other.task.id) } : {}),
|
|
281
|
+
}))
|
|
282
|
+
// A cron-triggered run additionally states its own firing instant and
|
|
283
|
+
// rule zone, so a scheduled job can resolve "today" without guessing.
|
|
284
|
+
const promptContext = peers === undefined && schedule === undefined ? undefined : {
|
|
285
|
+
...(peers === undefined ? {} : { peers }),
|
|
257
286
|
...(team ? { team: true } : {}),
|
|
287
|
+
...(schedule === undefined ? {} : { schedule }),
|
|
258
288
|
}
|
|
259
289
|
const sessionId = await this.runner.launch(opened.task, {
|
|
260
290
|
...(reuseSessionId === undefined ? {} : { reuseSessionId }),
|
|
@@ -292,7 +322,7 @@ export class TaskBoardHostService {
|
|
|
292
322
|
try {
|
|
293
323
|
const member = await team.spawn({
|
|
294
324
|
leadSessionId,
|
|
295
|
-
name: teammateName(opened.task.title, opened.execution.runGroupId ?? opened.task.id),
|
|
325
|
+
name: teammateName(opened.task.title, opened.execution.runGroupId ?? opened.task.id, opened.task.id),
|
|
296
326
|
description: opened.task.title,
|
|
297
327
|
prompt: promptText(opened.task),
|
|
298
328
|
})
|
|
@@ -321,6 +351,10 @@ export class TaskBoardHostService {
|
|
|
321
351
|
return
|
|
322
352
|
}
|
|
323
353
|
this.idleSessionIds = new Set(running.items.filter(item => !item.running).map(item => item.sessionId))
|
|
354
|
+
// Fold whatever the board can already decide before spending inspection
|
|
355
|
+
// RPCs: a team run whose Lead recorded its verdict, and any lineage whose
|
|
356
|
+
// members are all settled. Idempotent, so an already folded board is free.
|
|
357
|
+
this.ledger.finalizeReadyRuns()
|
|
324
358
|
// Read after the RPC so executions attached while it was in flight are
|
|
325
359
|
// included in this pass, matching the former full-state snapshot timing.
|
|
326
360
|
const runtime = this.ledger.runtimeView()
|
|
@@ -342,13 +376,53 @@ export class TaskBoardHostService {
|
|
|
342
376
|
for (const execution of executions) {
|
|
343
377
|
if (execution.sessionId === undefined) continue
|
|
344
378
|
try {
|
|
345
|
-
|
|
346
|
-
|
|
379
|
+
// A team member's turn is read even while the roster calls its session
|
|
380
|
+
// running: a durable teammate never goes idle for good.
|
|
381
|
+
const result = await this.runner.inspect(execution.sessionId, execution.startedAt, sessions, {
|
|
382
|
+
whileRunning: execution.teamMember,
|
|
383
|
+
})
|
|
384
|
+
if (result.outcome === 'pending') {
|
|
385
|
+
if (result.unreadable === true) this.noteUnreadableInspection(execution, result.reason)
|
|
386
|
+
else this.unreadablePolls.delete(execution.executionId)
|
|
387
|
+
continue
|
|
388
|
+
}
|
|
389
|
+
this.unreadablePolls.delete(execution.executionId)
|
|
347
390
|
this.ledger.settle(execution.taskId, execution.executionId, result.outcome, 'error' in result ? result.error : undefined)
|
|
348
391
|
} catch {
|
|
349
392
|
// A transient inspection failure never settles a running execution.
|
|
350
393
|
}
|
|
351
394
|
}
|
|
395
|
+
const open = new Set(executions.map(execution => execution.executionId))
|
|
396
|
+
for (const executionId of [...this.unreadablePolls.keys()]) {
|
|
397
|
+
if (!open.has(executionId)) this.unreadablePolls.delete(executionId)
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Count one poll whose session history could not be read. A reader failure is
|
|
403
|
+
* not progress: the session is not running (the runner only reads history for
|
|
404
|
+
* one that is idle), so nothing will ever change that verdict. After
|
|
405
|
+
* {@link UNREADABLE_SETTLE_POLLS} consecutive polls the execution is reported
|
|
406
|
+
* failed with the recorded reason, instead of holding its card — and every
|
|
407
|
+
* ancestor of it — in the running column with no way out. The first poll of
|
|
408
|
+
* each streak is logged, so the Host log names the session.
|
|
409
|
+
*/
|
|
410
|
+
private noteUnreadableInspection(execution: OpenExecutionReference, reason: string | undefined): void {
|
|
411
|
+
const polls = (this.unreadablePolls.get(execution.executionId) ?? 0) + 1
|
|
412
|
+
this.unreadablePolls.set(execution.executionId, polls)
|
|
413
|
+
const detail = reason ?? 'no reason reported'
|
|
414
|
+
if (polls === 1) {
|
|
415
|
+
safeConsoleError('[dsh-task-board] execution session ' + (execution.sessionId ?? 'unknown')
|
|
416
|
+
+ ' history is unreadable; it stays pending for up to ' + UNREADABLE_SETTLE_POLLS + ' polls: ' + detail)
|
|
417
|
+
}
|
|
418
|
+
if (polls < UNREADABLE_SETTLE_POLLS) return
|
|
419
|
+
this.unreadablePolls.delete(execution.executionId)
|
|
420
|
+
this.ledger.settle(
|
|
421
|
+
execution.taskId,
|
|
422
|
+
execution.executionId,
|
|
423
|
+
'failed',
|
|
424
|
+
'execution session history is unreadable (' + UNREADABLE_SETTLE_POLLS + ' consecutive polls); the outcome cannot be determined: ' + detail,
|
|
425
|
+
)
|
|
352
426
|
}
|
|
353
427
|
|
|
354
428
|
/** Drop the armed schedule timer and forget its target. */
|
|
@@ -411,8 +485,11 @@ export class TaskBoardHostService {
|
|
|
411
485
|
return
|
|
412
486
|
}
|
|
413
487
|
for (const schedule of this.ledger.dueSchedules(now)) {
|
|
414
|
-
const next = nextRunAtMs(schedule.cron, schedule.nextRunAt)
|
|
415
|
-
this.dispatchRuns(
|
|
488
|
+
const next = nextRunAtMs(schedule.cron, schedule.nextRunAt, schedule.timeZone)
|
|
489
|
+
this.dispatchRuns(
|
|
490
|
+
this.ledger.openScheduled(schedule.taskId, next, now),
|
|
491
|
+
{ triggeredAt: now, timeZone: schedule.timeZone, cron: schedule.cron },
|
|
492
|
+
)
|
|
416
493
|
}
|
|
417
494
|
// The launched run (or the rolled-forward target) moved every due schedule,
|
|
418
495
|
// so the next nearest target has to be recomputed from the ledger.
|
|
@@ -430,18 +507,18 @@ export class TaskBoardHostService {
|
|
|
430
507
|
* others back. A team run's members are spawned inside the root's Lead
|
|
431
508
|
* session instead, once that session exists.
|
|
432
509
|
*/
|
|
433
|
-
private dispatchRuns(runs: readonly OpenedRun[]): void {
|
|
510
|
+
private dispatchRuns(runs: readonly OpenedRun[], schedule?: ScheduledRunContext): void {
|
|
434
511
|
if (runs.length === 0) return
|
|
435
512
|
const root = runs.find(run => run.dispatch !== 'teammate') ?? runs[0]
|
|
436
513
|
const others = runs.filter(run => run !== root)
|
|
437
|
-
this.scheduleLaunch(root, others)
|
|
514
|
+
this.scheduleLaunch(root, others, schedule)
|
|
438
515
|
for (const run of others) {
|
|
439
|
-
if (run.dispatch !== 'teammate') this.scheduleLaunch(run)
|
|
516
|
+
if (run.dispatch !== 'teammate') this.scheduleLaunch(run, [], schedule)
|
|
440
517
|
}
|
|
441
518
|
}
|
|
442
519
|
|
|
443
|
-
private scheduleLaunch(opened: OpenedRun, others: readonly OpenedRun[] = []): void {
|
|
444
|
-
void this.launch(opened, others).catch(error => {
|
|
520
|
+
private scheduleLaunch(opened: OpenedRun, others: readonly OpenedRun[] = [], schedule?: ScheduledRunContext): void {
|
|
521
|
+
void this.launch(opened, others, schedule).catch(error => {
|
|
445
522
|
safeConsoleError('[dsh-task-board] execution launch settlement failed', error)
|
|
446
523
|
})
|
|
447
524
|
}
|
package/src/protocol.ts
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
import type { TaskUpdatePatch } from './core/use-cases/task-update.ts'
|
|
2
2
|
import { isTaskPermission, isTaskStatus, isTaskTagList, type NewTaskInput, type TaskPermission, type TaskRecord, type TaskStatus } from './core/tasks.ts'
|
|
3
3
|
import { parseLedger } from './core/store.ts'
|
|
4
|
+
import { isValidTimeZone } from './core/schedule.ts'
|
|
4
5
|
import { sanitizeFreezeSnapshot, type FreezeSnapshot } from './core/freeze-snapshot.ts'
|
|
5
6
|
import { sanitizeHandover, type TaskHandoverInput } from './core/handover.ts'
|
|
6
7
|
|
|
7
8
|
/** Freeze payload carried by create/update actions after the gate (redacted in place). */
|
|
8
9
|
type FreezePayload = FreezeSnapshot & { redacted?: boolean; frozenBy?: string }
|
|
9
10
|
|
|
10
|
-
export const TASK_BOARD_SCHEMA_VERSION =
|
|
11
|
-
/** Ledger documents written before
|
|
12
|
-
export const TASK_BOARD_LEGACY_SCHEMA_VERSION =
|
|
11
|
+
export const TASK_BOARD_SCHEMA_VERSION = 4 as const
|
|
12
|
+
/** Ledger documents written before v4; loaded once and migrated on startup. */
|
|
13
|
+
export const TASK_BOARD_LEGACY_SCHEMA_VERSION = 3 as const
|
|
14
|
+
/** Ledger documents written before v3; migrated through the v3 normalization too. */
|
|
15
|
+
export const TASK_BOARD_OLDER_SCHEMA_VERSION = 2 as const
|
|
13
16
|
export const TASK_BOARD_API_PREFIX = '/api/task-board'
|
|
14
17
|
|
|
15
18
|
export type PowerPhase = 'disabled' | 'idle' | 'acquiring' | 'active' | 'error' | 'unsupported'
|
|
@@ -101,7 +104,8 @@ export type TaskBoardAction =
|
|
|
101
104
|
| { kind: 'move'; taskId: string; status: TaskStatus }
|
|
102
105
|
| { kind: 'archive'; taskId: string }
|
|
103
106
|
| { kind: 'restore'; taskId: string }
|
|
104
|
-
| { kind: '
|
|
107
|
+
| { kind: 'settle'; taskId: string }
|
|
108
|
+
| { kind: 'set-schedule'; taskId: string; patch: { enabled?: boolean; cron?: string; timeZone?: string | null } }
|
|
105
109
|
| { kind: 'run'; taskId: string }
|
|
106
110
|
| { kind: 'rerun'; taskId: string }
|
|
107
111
|
| { kind: 'confirm-permission'; taskId: string }
|
|
@@ -154,6 +158,8 @@ function validImportedKnownFields(value: Record<string, unknown>): boolean {
|
|
|
154
158
|
const schedule = record(value.schedule)
|
|
155
159
|
if (schedule === undefined || typeof schedule.enabled !== 'boolean' || typeof schedule.cron !== 'string') return false
|
|
156
160
|
if (!optionalFiniteNumber(schedule.nextRunAt) || !optionalFiniteNumber(schedule.lastTriggeredAt)) return false
|
|
161
|
+
if (schedule.timeZone !== undefined
|
|
162
|
+
&& (typeof schedule.timeZone !== 'string' || !isValidTimeZone(schedule.timeZone))) return false
|
|
157
163
|
}
|
|
158
164
|
if (value.executions !== undefined) {
|
|
159
165
|
if (!Array.isArray(value.executions)) return false
|
|
@@ -199,6 +205,7 @@ function importedTask(value: unknown): TaskRecord | undefined {
|
|
|
199
205
|
schedule: {
|
|
200
206
|
enabled: task.schedule.enabled,
|
|
201
207
|
cron: task.schedule.cron,
|
|
208
|
+
...(task.schedule.timeZone === undefined ? {} : { timeZone: task.schedule.timeZone }),
|
|
202
209
|
nextRunAt: task.schedule.nextRunAt,
|
|
203
210
|
lastTriggeredAt: task.schedule.lastTriggeredAt,
|
|
204
211
|
},
|
|
@@ -210,6 +217,7 @@ function importedTask(value: unknown): TaskRecord | undefined {
|
|
|
210
217
|
...(task.mode === undefined ? {} : { mode: task.mode }),
|
|
211
218
|
...(task.permission === undefined ? {} : { permission: task.permission }),
|
|
212
219
|
...(task.reuseSession === undefined ? {} : { reuseSession: task.reuseSession }),
|
|
220
|
+
...(task.goalRun === undefined ? {} : { goalRun: task.goalRun }),
|
|
213
221
|
...(task.archivedAt === undefined ? {} : { archivedAt: task.archivedAt }),
|
|
214
222
|
...(task.freeze === undefined ? {} : { freeze: task.freeze }),
|
|
215
223
|
...(task.handover === undefined ? {} : { handover: task.handover }),
|
|
@@ -247,30 +255,36 @@ function handoverPayload(value: unknown): TaskHandoverInput | undefined {
|
|
|
247
255
|
|
|
248
256
|
function createInput(value: unknown): value is NewTaskInput {
|
|
249
257
|
const input = record(value)
|
|
250
|
-
if (input === undefined || !exactKeys(input, ['title', 'description', 'prompt', 'parentId', 'workspaceId', 'mode', 'permission', 'schedule', 'freeze', 'handover', 'model', 'reuseSession', 'teamRun', 'tags'])) return false
|
|
258
|
+
if (input === undefined || !exactKeys(input, ['title', 'description', 'prompt', 'parentId', 'workspaceId', 'mode', 'permission', 'schedule', 'freeze', 'handover', 'model', 'reuseSession', 'teamRun', 'goalRun', 'tags'])) return false
|
|
251
259
|
if (input.parentId !== undefined && (typeof input.parentId !== 'string' || input.parentId.trim() === '')) return false
|
|
252
260
|
if (typeof input.title !== 'string' || typeof input.description !== 'string' || typeof input.prompt !== 'string') return false
|
|
253
261
|
if (!optionalString(input.workspaceId) || !optionalString(input.mode) || !optionalString(input.model)) return false
|
|
254
262
|
if (input.reuseSession !== undefined && typeof input.reuseSession !== 'boolean') return false
|
|
255
263
|
if (input.teamRun !== undefined && typeof input.teamRun !== 'boolean') return false
|
|
264
|
+
if (input.goalRun !== undefined && typeof input.goalRun !== 'boolean') return false
|
|
256
265
|
if (input.permission !== undefined && !isTaskPermission(input.permission)) return false
|
|
257
266
|
if (input.tags !== undefined && !isTaskTagList(input.tags)) return false
|
|
258
267
|
if (input.freeze !== undefined && freezePayload(input.freeze) === undefined) return false
|
|
259
268
|
if (input.handover !== undefined && handoverPayload(input.handover) === undefined) return false
|
|
260
269
|
if (input.schedule !== undefined) {
|
|
261
270
|
const schedule = record(input.schedule)
|
|
262
|
-
if (schedule === undefined || !exactKeys(schedule, ['enabled', 'cron'])) return false
|
|
271
|
+
if (schedule === undefined || !exactKeys(schedule, ['enabled', 'cron', 'timeZone'])) return false
|
|
263
272
|
if (typeof schedule.enabled !== 'boolean' || typeof schedule.cron !== 'string') return false
|
|
273
|
+
if (schedule.timeZone !== undefined
|
|
274
|
+
&& (typeof schedule.timeZone !== 'string' || !isValidTimeZone(schedule.timeZone))) return false
|
|
264
275
|
}
|
|
265
276
|
return true
|
|
266
277
|
}
|
|
267
278
|
|
|
268
279
|
function updatePatch(value: unknown): boolean {
|
|
269
280
|
const patch = record(value)
|
|
270
|
-
if (patch === undefined || !exactKeys(patch, ['title', 'description', 'prompt', 'workspaceId', 'mode', 'permission', 'freeze', 'handover', 'model', 'reuseSession', 'teamRun', 'tags'])) return false
|
|
281
|
+
if (patch === undefined || !exactKeys(patch, ['title', 'description', 'prompt', 'workspaceId', 'mode', 'permission', 'freeze', 'handover', 'model', 'reuseSession', 'teamRun', 'goalRun', 'tags'])) return false
|
|
271
282
|
// null (or false) clears the reuse opt-in; only a real boolean is accepted.
|
|
272
283
|
if (patch.reuseSession !== undefined && patch.reuseSession !== null && typeof patch.reuseSession !== 'boolean') return false
|
|
273
284
|
if (patch.teamRun !== undefined && patch.teamRun !== null && typeof patch.teamRun !== 'boolean') return false
|
|
285
|
+
// The goal opt-in is tri-state: null/true return the card to its default
|
|
286
|
+
// (goal run), false pins a single plain turn.
|
|
287
|
+
if (patch.goalRun !== undefined && patch.goalRun !== null && typeof patch.goalRun !== 'boolean') return false
|
|
274
288
|
for (const key of ['title', 'description', 'prompt', 'workspaceId', 'mode', 'model'] as const) {
|
|
275
289
|
if (!optionalString(patch[key])) return false
|
|
276
290
|
}
|
|
@@ -286,9 +300,13 @@ function updatePatch(value: unknown): boolean {
|
|
|
286
300
|
function schedulePatch(value: unknown): boolean {
|
|
287
301
|
const patch = record(value)
|
|
288
302
|
return patch !== undefined
|
|
289
|
-
&& exactKeys(patch, ['enabled', 'cron'])
|
|
303
|
+
&& exactKeys(patch, ['enabled', 'cron', 'timeZone'])
|
|
290
304
|
&& (patch.enabled === undefined || typeof patch.enabled === 'boolean')
|
|
291
305
|
&& (patch.cron === undefined || typeof patch.cron === 'string')
|
|
306
|
+
// `null` clears the stored zone back to the Host zone; an unknown name is
|
|
307
|
+
// rejected here so the Host never has to guess at an unusable zone.
|
|
308
|
+
&& (patch.timeZone === undefined || patch.timeZone === null
|
|
309
|
+
|| (typeof patch.timeZone === 'string' && isValidTimeZone(patch.timeZone)))
|
|
292
310
|
}
|
|
293
311
|
|
|
294
312
|
export function parseActionEnvelope(value: unknown): TaskBoardActionEnvelope | undefined {
|
|
@@ -364,6 +382,7 @@ function parseEnvelopeAction(value: unknown): TaskBoardActionEnvelope | undefine
|
|
|
364
382
|
case 'delete':
|
|
365
383
|
case 'archive':
|
|
366
384
|
case 'restore':
|
|
385
|
+
case 'settle':
|
|
367
386
|
case 'run':
|
|
368
387
|
case 'rerun':
|
|
369
388
|
if (!exactKeys(action, ['kind', 'taskId'])) return undefined
|
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Async-boundary guard shared by the plugin family's Host halves: every
|
|
3
|
-
* fire-and-forget promise chain and callback the host runtime does not own
|
|
4
|
-
* (route handlers, timers, event listeners, spawned work) funnels through
|
|
5
|
-
* these helpers. The dsh host installs a process-level fail-loud guard that
|
|
6
|
-
* turns ANY unhandled promise rejection into a whole-process exit — one
|
|
7
|
-
* plugin's stray rejection would otherwise take every plugin down. These
|
|
8
|
-
* helpers exist so that failure mode is structurally impossible in family
|
|
9
|
-
* code: the rejection becomes a logged error at the plugin boundary instead.
|
|
10
|
-
*
|
|
11
|
-
* Complements the aggregate's shell isolation (packages/dsh-web-all): the
|
|
12
|
-
* shell contains import/activation failures at boot; runGuarded contains
|
|
13
|
-
* run-time failures after activation.
|
|
14
|
-
* @module dsh-web-shared/host/run-guarded
|
|
15
|
-
*/
|
|
16
|
-
/**
|
|
17
|
-
* Run one async operation, logging (never propagating) a rejection. Use for
|
|
18
|
-
* promises whose lifecycle the caller does not await: spawned work, retry
|
|
19
|
-
* loops, background flushes. Returns the original promise so callers can
|
|
20
|
-
* still chain when they want to.
|
|
21
|
-
* @param promise - the work to shield; any thenable.
|
|
22
|
-
* @param label - log prefix naming the work site.
|
|
23
|
-
* @param log - error sink; defaults to console.error.
|
|
24
|
-
* @returns the input promise (rejection already consumed).
|
|
25
|
-
*/
|
|
26
|
-
export declare function runGuarded<T>(promise: PromiseLike<T>, label: string, log?: (error: unknown) => void): PromiseLike<T>;
|
|
27
|
-
/**
|
|
28
|
-
* Wrap one callback so every invocation is individually guarded: a rejection
|
|
29
|
-
* inside one call is logged and swallowed instead of escaping into whatever
|
|
30
|
-
* infrastructure invoked the callback (HTTP server, EventEmitter, interval).
|
|
31
|
-
* Sync throws are caught identically; a returned promise is replaced by
|
|
32
|
-
* `undefined` after guarding (callers that need the original rejection should
|
|
33
|
-
* await inside their own try/catch instead).
|
|
34
|
-
* @param label - log prefix naming the callback site.
|
|
35
|
-
* @param handler - the work to guard.
|
|
36
|
-
* @param log - error sink; defaults to console.error.
|
|
37
|
-
* @returns a wrapped callback with the same parameter list.
|
|
38
|
-
*/
|
|
39
|
-
export declare function guardedHandler<A extends unknown[]>(label: string, handler: (...args: A) => unknown, log?: (error: unknown) => void): (...args: A) => unknown;
|