@linxin666/dsh-client-ui-task-board 0.4.5 → 0.4.6

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 (57) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +20 -12
  3. package/README.zh.md +20 -12
  4. package/lib/client.js +1327 -229
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +1909 -348
  7. package/lib/types/client/TaskBoardSettingsCard.d.ts +12 -0
  8. package/lib/types/client/index.d.ts +4 -5
  9. package/lib/types/client/locales.d.ts +65 -0
  10. package/lib/types/client/plugin-card-seat.d.ts +5 -0
  11. package/lib/types/client/schedule-zone.d.ts +50 -0
  12. package/lib/types/core/controller.d.ts +10 -11
  13. package/lib/types/core/poll-cadence.d.ts +31 -0
  14. package/lib/types/core/tasks.d.ts +144 -14
  15. package/lib/types/core/use-cases/task-schedule.d.ts +68 -10
  16. package/lib/types/core/verification-budget.d.ts +52 -0
  17. package/lib/types/core/verification.d.ts +376 -9
  18. package/lib/types/host/verification-gate.d.ts +24 -0
  19. package/lib/types/host/verification-runner.d.ts +38 -7
  20. package/lib/types/host-ledger.d.ts +82 -7
  21. package/lib/types/host-runner.d.ts +32 -9
  22. package/lib/types/host-service.d.ts +117 -9
  23. package/lib/types/index.d.ts +54 -0
  24. package/lib/types/protocol.d.ts +50 -12
  25. package/package.json +2 -6
  26. package/src/client/TaskBoardSettingsCard.tsx +74 -1
  27. package/src/client/board/NewTaskModal.tsx +151 -20
  28. package/src/client/board/TaskCard.tsx +3 -3
  29. package/src/client/board/TaskDetail.tsx +219 -18
  30. package/src/client/board/VerificationReport.tsx +132 -13
  31. package/src/client/board/status-key.ts +3 -0
  32. package/src/client/index.ts +43 -50
  33. package/src/client/locales.ts +137 -4
  34. package/src/client/plugin-card-seat.ts +7 -0
  35. package/src/client/schedule-zone.ts +100 -2
  36. package/src/core/controller.ts +12 -8
  37. package/src/core/poll-cadence.ts +41 -0
  38. package/src/core/store.ts +64 -15
  39. package/src/core/tasks.ts +193 -39
  40. package/src/core/use-cases/task-create.ts +30 -9
  41. package/src/core/use-cases/task-schedule.ts +190 -25
  42. package/src/core/use-cases/task-update.ts +3 -3
  43. package/src/core/verification-budget.ts +115 -0
  44. package/src/core/verification.ts +785 -17
  45. package/src/host/agent-tools.ts +106 -21
  46. package/src/host/verification-gate.ts +172 -16
  47. package/src/host/verification-runner.ts +207 -40
  48. package/src/host-ledger.ts +328 -42
  49. package/src/host-runner.ts +52 -19
  50. package/src/host-service.ts +299 -36
  51. package/src/index.ts +101 -5
  52. package/src/protocol.ts +132 -28
  53. package/lib/invariant.js +0 -6
  54. package/lib/types/client/board-card-seat.d.ts +0 -34
  55. package/lib/types/invariant.d.ts +0 -3
  56. package/src/client/board-card-seat.ts +0 -102
  57. package/src/invariant.ts +0 -4
package/src/index.ts CHANGED
@@ -21,6 +21,17 @@ import { TaskBoardHostService, type HostTimerFace, type TaskBoardTeamDispatcher
21
21
  import { parseTaskDraft, TaskParseError } from './host-ai.ts'
22
22
  import { TASK_PERMISSIONS, isTaskPermission, type TaskPermission } from './core/tasks.ts'
23
23
  import { DEFAULT_SUBTASK_DEPTH, SUBTASK_DEPTH_MAX, SUBTASK_DEPTH_MIN } from './core/subtask.ts'
24
+ import { DEFAULT_SESSION_POLL_SECONDS, SESSION_POLL_MAX_SECONDS, SESSION_POLL_MIN_SECONDS } from './core/poll-cadence.ts'
25
+ import {
26
+ DEFAULT_VERIFICATION_BUDGET_SECONDS,
27
+ DEFAULT_VERIFICATION_CALL_TIMEOUT_SECONDS,
28
+ VERIFICATION_BUDGET_MAX_SECONDS,
29
+ VERIFICATION_BUDGET_MIN_SECONDS,
30
+ VERIFICATION_CALL_TIMEOUT_MAX_SECONDS,
31
+ VERIFICATION_CALL_TIMEOUT_MIN_SECONDS,
32
+ verificationBudgetMs,
33
+ verificationCallTimeoutMs,
34
+ } from './core/verification-budget.ts'
24
35
  import { DEFAULT_SESSION_PERMISSION } from './core/handover.ts'
25
36
  import { buildTaskBoardTools, TASK_BOARD_TOOL_NAMES } from './host/agent-tools.ts'
26
37
  import { PLUGIN_TOOL_SECTION_ORDERS, visibleToolText } from './tool-surface.ts'
@@ -88,12 +99,35 @@ export interface Config {
88
99
  * of the root task opens, so the ceiling is deliberately small.
89
100
  */
90
101
  maxSubtaskDepth?: Volatile<number>
102
+ /**
103
+ * How often, in seconds, the Host re-reads the DSH session roster while the
104
+ * board has something to reconcile: a running card, an open execution, or an
105
+ * active provider extension. An idle board with none of those does not poll
106
+ * at all, so this cadence only bounds how quickly a running card settles.
107
+ */
108
+ sessionPollSeconds?: Volatile<number>
91
109
  /**
92
110
  * Continuable-subagent provider the Agent Teams service uses to compose a
93
111
  * teammate. Matches the Agent Teams tool plugin's `freshProvider` default;
94
112
  * only team-mode runs use it.
95
113
  */
96
114
  teamProvider?: string
115
+ /**
116
+ * GLOBAL native /goal switch (default OFF), the master control over whether
117
+ * this board starts a run with dsh's built-in /goal at all. A run uses native
118
+ * /goal only when THIS is on AND the task's own `goalRun` is not off; while
119
+ * it is off every task runs one plain turn.
120
+ *
121
+ * The value is frozen when an execution starts, so toggling it only affects
122
+ * executions that begin afterwards and never re-writes a task's stored
123
+ * `goalRun` preference. A run that consequently does not execute native
124
+ * /goal is marked as such (its acceptance applicability is `goal-disabled`)
125
+ * and is never reported as a passed goal acceptance or failed for lacking a
126
+ * completion call. Independent of `goalVerification`; acceptance governs the
127
+ * completing call of a goal-form run, this switch governs whether the run
128
+ * becomes a goal run.
129
+ */
130
+ goalRunEnabled?: Volatile<boolean>
97
131
  /**
98
132
  * Goal acceptance for executions this board starts (default ON). When on,
99
133
  * update_goal(action: complete) inside a task execution is refused until an
@@ -119,6 +153,23 @@ export interface Config {
119
153
  * sent, and the fallback to the model's own default is reported.
120
154
  */
121
155
  goalVerificationReasoningEffort?: Volatile<string>
156
+ /**
157
+ * Ceiling of ONE judge request of a goal acceptance, in seconds
158
+ * (30..600, default 150). One acceptance issues six judge requests (three
159
+ * criteria times two rounds), so a ceiling that is too small turns a slow but
160
+ * healthy judge route into a wall of timeouts, and every timeout was charged
161
+ * as an acceptance anomaly. Raise it for a reasoning model reading a long
162
+ * trace.
163
+ */
164
+ goalVerificationCallTimeoutSeconds?: Volatile<number>
165
+ /**
166
+ * Total ceiling of ONE goal acceptance attempt, in seconds (120..1800,
167
+ * default eight times the per-call default). Keep it at or above
168
+ * `goalVerificationCallTimeoutSeconds` times six or an acceptance can never
169
+ * finish: the attempt then ends early with a budget stop that judges nothing
170
+ * and spends no acceptance budget.
171
+ */
172
+ goalVerificationBudgetSeconds?: Volatile<number>
122
173
  }
123
174
 
124
175
  /**
@@ -152,14 +203,22 @@ export interface ConfigInput {
152
203
  sessionDefaultPermission?: TaskPermission
153
204
  /** Subtask depth limit, 1..3. */
154
205
  maxSubtaskDepth?: number
206
+ /** Roster-poll cadence in seconds while the board has work to reconcile. */
207
+ sessionPollSeconds?: number
155
208
  /** Continuable-subagent provider the Agent Teams service composes a teammate from. */
156
209
  teamProvider?: string
210
+ /** Global native /goal switch; default off, frozen per execution. */
211
+ goalRunEnabled?: boolean
157
212
  /** Goal acceptance switch. */
158
213
  goalVerification?: boolean
159
214
  /** Judge model route for goal acceptance; blank inherits the host default. */
160
215
  goalVerificationModel?: string
161
216
  /** Judge reasoning effort; blank inherits the host default. */
162
217
  goalVerificationReasoningEffort?: string
218
+ /** Ceiling of one judge request of a goal acceptance, in seconds. */
219
+ goalVerificationCallTimeoutSeconds?: number
220
+ /** Total ceiling of one goal acceptance attempt, in seconds. */
221
+ goalVerificationBudgetSeconds?: number
163
222
  }
164
223
 
165
224
  export const Config: z<ConfigInput, Config> = z.object({
@@ -170,15 +229,26 @@ export const Config: z<ConfigInput, Config> = z.object({
170
229
  proxyTokenEnv: z.string().min(1).default(DEFAULT_PROXY_TOKEN_ENV),
171
230
  sessionDefaultPermission: z.union(TASK_PERMISSIONS),
172
231
  maxSubtaskDepth: z.number().min(SUBTASK_DEPTH_MIN).max(SUBTASK_DEPTH_MAX).default(DEFAULT_SUBTASK_DEPTH).volatile(),
232
+ sessionPollSeconds: z.number().min(SESSION_POLL_MIN_SECONDS).max(SESSION_POLL_MAX_SECONDS).default(DEFAULT_SESSION_POLL_SECONDS).volatile(),
173
233
  teamProvider: z.string().min(1).default(DEFAULT_TEAM_PROVIDER),
234
+ goalRunEnabled: z.boolean().default(false).volatile(),
174
235
  goalVerification: z.boolean().default(true).volatile(),
175
236
  goalVerificationModel: z.string().default('').volatile(),
176
237
  goalVerificationReasoningEffort: z.string().default('').volatile(),
238
+ goalVerificationCallTimeoutSeconds: z.number().min(VERIFICATION_CALL_TIMEOUT_MIN_SECONDS).max(VERIFICATION_CALL_TIMEOUT_MAX_SECONDS).default(DEFAULT_VERIFICATION_CALL_TIMEOUT_SECONDS).volatile(),
239
+ goalVerificationBudgetSeconds: z.number().min(VERIFICATION_BUDGET_MIN_SECONDS).max(VERIFICATION_BUDGET_MAX_SECONDS).default(DEFAULT_VERIFICATION_BUDGET_SECONDS).volatile(),
177
240
  })
178
241
 
179
242
  /** Schema default for the goal-acceptance switch, re-read for hand-built contexts. */
180
243
  export const DEFAULT_GOAL_VERIFICATION = true
181
244
 
245
+ /**
246
+ * Schema default for the GLOBAL native-/goal switch, re-read for hand-built
247
+ * contexts. Deliberately false: a deployment that never configured it (and
248
+ * every card written before the option existed) runs plain single turns.
249
+ */
250
+ export const DEFAULT_GOAL_RUN = false
251
+
182
252
  declare module '@deepseek-ai/cordis' {
183
253
  interface Events {
184
254
  /**
@@ -419,6 +489,8 @@ function applyImpl(ctx: Context, config?: Config): void {
419
489
  const preventIdleSleep = (): boolean => readConfigField(config?.preventIdleSleep, false)
420
490
  /** Current subtask depth limit (read live: the settings card edits it in place). */
421
491
  const maxSubtaskDepth = (): number => readConfigField(config?.maxSubtaskDepth, DEFAULT_SUBTASK_DEPTH)
492
+ /** Current roster-poll cadence in seconds (read live: the settings card edits it in place). */
493
+ const sessionPollSeconds = (): number => readConfigField(config?.sessionPollSeconds, DEFAULT_SESSION_POLL_SECONDS)
422
494
  /**
423
495
  * The baseline the permission confirmation gate judges against: the row's own
424
496
  * `sessionDefaultPermission` when the deployment pins one, otherwise the Host's
@@ -435,6 +507,15 @@ function applyImpl(ctx: Context, config?: Config): void {
435
507
  model: readConfigField(config?.goalVerificationModel, ''),
436
508
  reasoningEffort: readConfigField(config?.goalVerificationReasoningEffort, ''),
437
509
  })
510
+ /**
511
+ * The acceptance time ceilings, read live: raising one must unblock a card
512
+ * whose acceptance is stuck on a slow judge route without a remount, so
513
+ * these deliberately are NOT frozen into the per-execution contract.
514
+ */
515
+ const verificationCallTimeoutMsLive = (): number =>
516
+ verificationCallTimeoutMs(readConfigField(config?.goalVerificationCallTimeoutSeconds, DEFAULT_VERIFICATION_CALL_TIMEOUT_SECONDS))
517
+ const verificationBudgetMsLive = (): number =>
518
+ verificationBudgetMs(readConfigField(config?.goalVerificationBudgetSeconds, DEFAULT_VERIFICATION_BUDGET_SECONDS))
438
519
  // The catalog reader needs the service, and the service needs the reader;
439
520
  // the reader only runs after start(), so a late holder is enough.
440
521
  let hostForCatalog: TaskBoardHostService | undefined
@@ -442,10 +523,17 @@ function applyImpl(ctx: Context, config?: Config): void {
442
523
  const runner = hostForCatalog?.runner
443
524
  return runner === undefined ? undefined : normalizeCatalog(await runner.modelCatalog())
444
525
  }
526
+ /**
527
+ * Live reader of the GLOBAL native-/goal switch. The service calls it exactly
528
+ * once per execution and carries the value, so a settings edit only reaches
529
+ * executions that start afterwards.
530
+ */
531
+ const goalRunEnabled = (): boolean => readConfigField(config?.goalRunEnabled, DEFAULT_GOAL_RUN)
445
532
  const host = new TaskBoardHostService(ctx.typertGateway, {
446
533
  workspaceRegistry: ctx.workspaceRegistry,
447
534
  sessionDefaultPermission,
448
535
  maxSubtaskDepth: maxSubtaskDepth(),
536
+ goalRunEnabled,
449
537
  verificationSettings,
450
538
  verificationCatalog,
451
539
  timers: resolveHostTimers(ctx),
@@ -461,7 +549,7 @@ function applyImpl(ctx: Context, config?: Config): void {
461
549
  hostForCatalog = host
462
550
  // Configuration before start(): a disabled row must not take the first
463
551
  // scheduler tick, which would roll schedules the board is not running.
464
- host.setConfiguration(enabled(), preventIdleSleep())
552
+ host.setConfiguration(enabled(), preventIdleSleep(), sessionPollSeconds())
465
553
  host.start()
466
554
 
467
555
  // External providers. The board publishes its registration service so any
@@ -543,6 +631,8 @@ function applyImpl(ctx: Context, config?: Config): void {
543
631
  // deployment records it; the acceptance degrades to the trajectory alone
544
632
  // when it does not.
545
633
  workspaceChanges: () => probeWorkspaceChanges(ctx),
634
+ callTimeoutMs: verificationCallTimeoutMsLive,
635
+ budgetMs: verificationBudgetMsLive,
546
636
  logger: {
547
637
  warn: (message: string, ...rest: unknown[]) => {
548
638
  const logger = (ctx as { logger?: { warn?: (...args: unknown[]) => void } }).logger
@@ -586,7 +676,7 @@ function applyImpl(ctx: Context, config?: Config): void {
586
676
  }, 'task-board: host ledger, scheduler, and routes')
587
677
 
588
678
  let disposeSection: (() => void) | undefined
589
- let applied: { enabled: boolean; announceToAgent: boolean; preventIdleSleep: boolean; maxSubtaskDepth: number } | undefined
679
+ let applied: { enabled: boolean; announceToAgent: boolean; preventIdleSleep: boolean; maxSubtaskDepth: number; sessionPollSeconds: number } | undefined
590
680
 
591
681
  // Apply the current values to the host service and the announcement. A
592
682
  // commit that changes nothing visible is a no-op, so following a coarse
@@ -594,13 +684,19 @@ function applyImpl(ctx: Context, config?: Config): void {
594
684
  // under one disposer: re-registering first tears the old one down so a
595
685
  // duplicate-name registration never throws.
596
686
  const sync = (): void => {
597
- const next = { enabled: enabled(), announceToAgent: announceToAgent(), preventIdleSleep: preventIdleSleep(), maxSubtaskDepth: maxSubtaskDepth() }
598
- if (applied !== undefined && applied.enabled === next.enabled && applied.announceToAgent === next.announceToAgent && applied.preventIdleSleep === next.preventIdleSleep && applied.maxSubtaskDepth === next.maxSubtaskDepth) {
687
+ const next = {
688
+ enabled: enabled(),
689
+ announceToAgent: announceToAgent(),
690
+ preventIdleSleep: preventIdleSleep(),
691
+ maxSubtaskDepth: maxSubtaskDepth(),
692
+ sessionPollSeconds: sessionPollSeconds(),
693
+ }
694
+ if (applied !== undefined && applied.enabled === next.enabled && applied.announceToAgent === next.announceToAgent && applied.preventIdleSleep === next.preventIdleSleep && applied.maxSubtaskDepth === next.maxSubtaskDepth && applied.sessionPollSeconds === next.sessionPollSeconds) {
599
695
  return
600
696
  }
601
697
  applied = next
602
698
  host.ledger.setMaxSubtaskDepth(next.maxSubtaskDepth)
603
- host.setConfiguration(next.enabled, next.preventIdleSleep)
699
+ host.setConfiguration(next.enabled, next.preventIdleSleep, next.sessionPollSeconds)
604
700
  setToolsEnabled(next.enabled)
605
701
  if (disposeSection !== undefined) {
606
702
  disposeSection()
package/src/protocol.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { TaskUpdatePatch } from './core/use-cases/task-update.ts'
2
- import { TAG_NAME_MAX_LENGTH, isTaskPermission, isTaskStatus, isTaskTagList, type NewTaskInput, type TaskPermission, type TaskRecord, type TaskStatus } from './core/tasks.ts'
2
+ import type { SetSchedulePatch } from './core/use-cases/task-schedule.ts'
3
+ import { SCHEDULE_STOP_REASONS, TAG_NAME_MAX_LENGTH, isTaskPermission, isTaskStatus, isTaskTagList, type NewTaskInput, type ScheduleStopReason, type TaskPermission, type TaskRecord, type TaskStatus } from './core/tasks.ts'
3
4
  import { parseLedger } from './core/store.ts'
4
5
  import { isValidTimeZone } from './core/schedule.ts'
5
6
  import { sanitizeFreezeSnapshot, type FreezeSnapshot } from './core/freeze-snapshot.ts'
@@ -10,26 +11,42 @@ import { normalizeVerification, type ModelCatalogView, type VerificationContract
10
11
  /** Freeze payload carried by create/update actions after the gate (redacted in place). */
11
12
  type FreezePayload = FreezeSnapshot & { redacted?: boolean; frozenBy?: string }
12
13
 
13
- export const TASK_BOARD_SCHEMA_VERSION = 5 as const
14
- /** Ledger documents written before v5; loaded once and migrated on startup. */
15
- export const TASK_BOARD_LEGACY_SCHEMA_VERSION = 4 as const
14
+ export const TASK_BOARD_SCHEMA_VERSION = 6 as const
15
+ /** Ledger documents written before v6; loaded once and migrated on startup. */
16
+ export const TASK_BOARD_LEGACY_SCHEMA_VERSION = 5 as const
17
+ /** Ledger documents written before v5; migrated through the v5 normalization too. */
18
+ export const TASK_BOARD_PRE_ACCEPTANCE_SCHEMA_VERSION = 4 as const
16
19
  /** Ledger documents written before v4; migrated through the v4 normalization too. */
17
20
  export const TASK_BOARD_OLDER_SCHEMA_VERSION = 3 as const
18
21
  /** Ledger documents written before v3; migrated through every later normalization too. */
19
22
  export const TASK_BOARD_OLDEST_SCHEMA_VERSION = 2 as const
20
23
  /**
21
- * Every older generation the loader migrates in place. v5 only ADDS the
22
- * per-execution acceptance block, so the migration is the normalization pass
23
- * that re-validates each row; a document from any listed generation upgrades
24
- * losslessly.
24
+ * The last generation whose rules still need the Host zone stamped on them at
25
+ * migration: v4 introduced the stored zone but a rule written without one must
26
+ * stop following a later TZ change. A v5 rule already carries its zone (or
27
+ * deliberately follows the Host), so it is NOT stamped again.
28
+ */
29
+ export const TASK_BOARD_ZONE_STAMP_SCHEMA_VERSION = 4 as const
30
+ /**
31
+ * Every older generation the loader migrates in place. v5 only added the
32
+ * per-execution acceptance block and v6 only added the scheduling mode and run
33
+ * budget, so the migration is the normalization pass that re-validates each
34
+ * row; a document from any listed generation upgrades losslessly.
25
35
  */
26
36
  export const TASK_BOARD_MIGRATABLE_SCHEMA_VERSIONS: readonly number[] = [
27
37
  TASK_BOARD_LEGACY_SCHEMA_VERSION,
38
+ TASK_BOARD_PRE_ACCEPTANCE_SCHEMA_VERSION,
28
39
  TASK_BOARD_OLDER_SCHEMA_VERSION,
29
40
  TASK_BOARD_OLDEST_SCHEMA_VERSION,
30
41
  ]
31
42
  export const TASK_BOARD_API_PREFIX = '/api/task-board'
32
43
 
44
+ /** Longest accepted caller identity on an external outcome record. */
45
+ export const EXTERNAL_INITIATOR_MAX_LENGTH = 200
46
+
47
+ /** Longest accepted free-text summary on an external outcome record. */
48
+ export const EXTERNAL_SUMMARY_MAX_LENGTH = 1_000
49
+
33
50
  export type PowerPhase = 'disabled' | 'idle' | 'acquiring' | 'active' | 'error' | 'unsupported'
34
51
 
35
52
  export interface TaskBoardPowerSnapshot {
@@ -68,6 +85,14 @@ export interface TaskBoardSnapshot {
68
85
  * into team execution (Team Lead session plus one teammate per subtask).
69
86
  */
70
87
  teamRunAvailable?: boolean
88
+ /**
89
+ * Live GLOBAL native-/goal switch. The browser uses it to disable the
90
+ * task-level option (and explain that the global switch must be turned on
91
+ * first) WITHOUT rewriting anyone's stored per-task preference; the Host
92
+ * freezes its own read per execution, so this mirror never governs a run in
93
+ * flight.
94
+ */
95
+ goalRunEnabled?: boolean
71
96
  /**
72
97
  * Read-only summaries published by running extensions, keyed by extension id.
73
98
  * The board forwards them verbatim; it never interprets their shape.
@@ -139,7 +164,18 @@ export type TaskBoardAction =
139
164
  | { kind: 'archive'; taskId: string }
140
165
  | { kind: 'restore'; taskId: string }
141
166
  | { kind: 'settle'; taskId: string }
142
- | { kind: 'set-schedule'; taskId: string; patch: { enabled?: boolean; cron?: string; timeZone?: string | null } }
167
+ /** Clear the acceptance anomalies recorded on a card's open execution (issue #1828). */
168
+ | { kind: 'reset-verification'; taskId: string }
169
+ /**
170
+ * Record an outcome an agent OUTSIDE the Host produced (issue #1826).
171
+ *
172
+ * It writes a real execution record, so the terminal column and the run
173
+ * history both derive from that one record instead of a bare column edit.
174
+ * `cancelled` is deliberately absent: nothing outside a DSH session may
175
+ * claim the Host closed a run, and `running` must stay Host-only.
176
+ */
177
+ | { kind: 'record-external-outcome'; taskId: string; result: 'succeeded' | 'failed'; initiatedBy: string; summary?: string }
178
+ | { kind: 'set-schedule'; taskId: string; patch: SetSchedulePatch }
143
179
  | { kind: 'run'; taskId: string }
144
180
  | { kind: 'rerun'; taskId: string }
145
181
  | { kind: 'confirm-permission'; taskId: string }
@@ -187,19 +223,48 @@ function optionalFiniteNumber(value: unknown): boolean {
187
223
  return value === undefined || (typeof value === 'number' && Number.isFinite(value))
188
224
  }
189
225
 
226
+ /** Whether a stop-reason field names a reason this build understands. */
227
+ function validStopReason(value: unknown): boolean {
228
+ return value === undefined || (typeof value === 'string' && SCHEDULE_STOP_REASONS.includes(value as ScheduleStopReason))
229
+ }
230
+
231
+ /**
232
+ * Whether an imported schedule rule is structurally usable. A one-shot must
233
+ * carry its planned instant and a recurring rule its cron expression; the
234
+ * counter and stop record are optional (a rule written before them simply has
235
+ * none), but a value that IS present must be usable so the import cannot smuggle
236
+ * a rule the scheduler would misread.
237
+ */
238
+ function validImportedSchedule(value: unknown): boolean {
239
+ const schedule = record(value)
240
+ if (schedule === undefined || typeof schedule.enabled !== 'boolean') return false
241
+ if (schedule.mode !== undefined && schedule.mode !== 'cron' && schedule.mode !== 'once') return false
242
+ if (schedule.mode === 'once') {
243
+ if (typeof schedule.at !== 'number' || !Number.isFinite(schedule.at)) return false
244
+ } else if (typeof schedule.cron !== 'string') {
245
+ return false
246
+ }
247
+ if (schedule.cron !== undefined && typeof schedule.cron !== 'string') return false
248
+ if (schedule.at !== undefined && (typeof schedule.at !== 'number' || !Number.isFinite(schedule.at))) return false
249
+ if (schedule.maxRuns !== undefined
250
+ && (typeof schedule.maxRuns !== 'number' || !Number.isInteger(schedule.maxRuns) || schedule.maxRuns < 1)) return false
251
+ if (schedule.runCount !== undefined
252
+ && (typeof schedule.runCount !== 'number' || !Number.isInteger(schedule.runCount) || schedule.runCount < 0)) return false
253
+ if (!optionalFiniteNumber(schedule.nextRunAt) || !optionalFiniteNumber(schedule.lastTriggeredAt)) return false
254
+ if (!optionalFiniteNumber(schedule.endedAt) || !optionalFiniteNumber(schedule.skippedAt)) return false
255
+ if (!validStopReason(schedule.endedReason) || !validStopReason(schedule.skippedReason)) return false
256
+ if (schedule.timeZone !== undefined
257
+ && (typeof schedule.timeZone !== 'string' || !isValidTimeZone(schedule.timeZone))) return false
258
+ return true
259
+ }
260
+
190
261
  function validImportedKnownFields(value: Record<string, unknown>): boolean {
191
262
  // Tags are validated (not repaired) on the import path: an imported task
192
263
  // must carry a well-formed list or none at all, so a hand-edited export
193
264
  // cannot smuggle a malformed tag past the gate.
194
265
  if (value.tags !== undefined && !isTaskTagList(value.tags)) return false
195
266
  if (value.integrations !== undefined && normalizeTaskIntegrations(value.integrations) === undefined) return false
196
- if (value.schedule !== undefined) {
197
- const schedule = record(value.schedule)
198
- if (schedule === undefined || typeof schedule.enabled !== 'boolean' || typeof schedule.cron !== 'string') return false
199
- if (!optionalFiniteNumber(schedule.nextRunAt) || !optionalFiniteNumber(schedule.lastTriggeredAt)) return false
200
- if (schedule.timeZone !== undefined
201
- && (typeof schedule.timeZone !== 'string' || !isValidTimeZone(schedule.timeZone))) return false
202
- }
267
+ if (value.schedule !== undefined && !validImportedSchedule(value.schedule)) return false
203
268
  if (value.executions !== undefined) {
204
269
  if (!Array.isArray(value.executions)) return false
205
270
  for (const item of value.executions) {
@@ -209,6 +274,10 @@ function validImportedKnownFields(value: Record<string, unknown>): boolean {
209
274
  if (!optionalFiniteNumber(execution.endedAt) || !optionalString(execution.error)) return false
210
275
  if (execution.result !== undefined && !['succeeded', 'failed', 'cancelled'].includes(String(execution.result))) return false
211
276
  if (execution.initiatedBy !== undefined && typeof execution.initiatedBy !== 'string') return false
277
+ // An imported external marker is accepted (it is a declaration an
278
+ // operator made about their own board), but it is still just a boolean:
279
+ // the acceptance block below is what must never arrive from an import.
280
+ if (execution.external !== undefined && typeof execution.external !== 'boolean') return false
212
281
  if (execution.frozenBy !== undefined && typeof execution.frozenBy !== 'string') return false
213
282
  if (execution.frozenAt !== undefined && typeof execution.frozenAt !== 'number') return false
214
283
  // A well-formed acceptance block may be imported for inspection, but the
@@ -241,20 +310,17 @@ function importedTask(value: unknown): TaskRecord | undefined {
241
310
  result: execution.result,
242
311
  error: execution.error,
243
312
  ...(execution.initiatedBy === undefined ? {} : { initiatedBy: execution.initiatedBy }),
313
+ ...(execution.external === true ? { external: true } : {}),
244
314
  ...(execution.frozenAt === undefined ? {} : { frozenAt: execution.frozenAt }),
245
315
  ...(execution.frozenBy === undefined ? {} : { frozenBy: execution.frozenBy }),
246
316
  // 安全门(对抗场景 d):验收报告是 Host 的判定,不接受 import 携带——
247
317
  // 一条伪造的通过记录会让导入的执行被当成已验收。
248
318
  })),
249
- ...(task.schedule === undefined ? {} : {
250
- schedule: {
251
- enabled: task.schedule.enabled,
252
- cron: task.schedule.cron,
253
- ...(task.schedule.timeZone === undefined ? {} : { timeZone: task.schedule.timeZone }),
254
- nextRunAt: task.schedule.nextRunAt,
255
- lastTriggeredAt: task.schedule.lastTriggeredAt,
256
- },
257
- }),
319
+ // The rule was already reconstructed field by field by parseLedger (which
320
+ // is what drops an unknown or malformed field), so the import keeps that
321
+ // normalized shape: mode, planned instant, run counter and stop record all
322
+ // travel with it.
323
+ ...(task.schedule === undefined ? {} : { schedule: { ...task.schedule } }),
258
324
  // The lineage link rides the import; the Host repairs a link whose parent
259
325
  // is missing from the merged ledger instead of trusting the export.
260
326
  ...(task.parentId === undefined ? {} : { parentId: task.parentId }),
@@ -321,8 +387,13 @@ function createInput(value: unknown): value is NewTaskInput {
321
387
  if (input.handover !== undefined && handoverPayload(input.handover) === undefined) return false
322
388
  if (input.schedule !== undefined) {
323
389
  const schedule = record(input.schedule)
324
- if (schedule === undefined || !exactKeys(schedule, ['enabled', 'cron', 'timeZone'])) return false
325
- if (typeof schedule.enabled !== 'boolean' || typeof schedule.cron !== 'string') return false
390
+ if (schedule === undefined || !exactKeys(schedule, ['enabled', 'mode', 'cron', 'at', 'timeZone', 'maxRuns'])) return false
391
+ if (typeof schedule.enabled !== 'boolean') return false
392
+ if (schedule.mode !== undefined && schedule.mode !== 'cron' && schedule.mode !== 'once') return false
393
+ if (schedule.cron !== undefined && typeof schedule.cron !== 'string') return false
394
+ if (schedule.at !== undefined && (typeof schedule.at !== 'number' || !Number.isInteger(schedule.at))) return false
395
+ if (schedule.maxRuns !== undefined
396
+ && (typeof schedule.maxRuns !== 'number' || !Number.isInteger(schedule.maxRuns) || schedule.maxRuns < 1)) return false
326
397
  if (schedule.timeZone !== undefined
327
398
  && (typeof schedule.timeZone !== 'string' || !isValidTimeZone(schedule.timeZone))) return false
328
399
  }
@@ -365,13 +436,21 @@ function isTagName(value: unknown): value is string {
365
436
  function schedulePatch(value: unknown): boolean {
366
437
  const patch = record(value)
367
438
  return patch !== undefined
368
- && exactKeys(patch, ['enabled', 'cron', 'timeZone'])
439
+ && exactKeys(patch, ['enabled', 'mode', 'cron', 'at', 'timeZone', 'maxRuns'])
369
440
  && (patch.enabled === undefined || typeof patch.enabled === 'boolean')
441
+ && (patch.mode === undefined || patch.mode === 'cron' || patch.mode === 'once')
370
442
  && (patch.cron === undefined || typeof patch.cron === 'string')
443
+ // A one-shot's planned instant is a whole millisecond: a fractional or
444
+ // non-numeric value would make the armed target ambiguous.
445
+ && (patch.at === undefined || (typeof patch.at === 'number' && Number.isInteger(patch.at)))
371
446
  // `null` clears the stored zone back to the Host zone; an unknown name is
372
447
  // rejected here so the Host never has to guess at an unusable zone.
373
448
  && (patch.timeZone === undefined || patch.timeZone === null
374
449
  || (typeof patch.timeZone === 'string' && isValidTimeZone(patch.timeZone)))
450
+ // `null` clears the run cap back to unlimited; any other value must be a
451
+ // positive whole number of runs.
452
+ && (patch.maxRuns === undefined || patch.maxRuns === null
453
+ || (typeof patch.maxRuns === 'number' && Number.isInteger(patch.maxRuns) && patch.maxRuns >= 1))
375
454
  }
376
455
 
377
456
  export function parseActionEnvelope(value: unknown): TaskBoardActionEnvelope | undefined {
@@ -472,11 +551,36 @@ function parseEnvelopeAction(value: unknown): TaskBoardActionEnvelope | undefine
472
551
  },
473
552
  }
474
553
  }
554
+ case 'record-external-outcome': {
555
+ if (!exactKeys(action, ['kind', 'taskId', 'result', 'initiatedBy', 'summary'])) return undefined
556
+ if (taskId === undefined) return undefined
557
+ // Only a real outside verdict is accepted: `running` is Host-only and
558
+ // `cancelled` is the Host's own bookkeeping for a run it closed.
559
+ if (action.result !== 'succeeded' && action.result !== 'failed') return undefined
560
+ if (typeof action.initiatedBy !== 'string') return undefined
561
+ const initiatedBy = action.initiatedBy.trim()
562
+ // The caller must be identifiable: an unattributed external outcome is
563
+ // the same 'state without history' the record exists to avoid.
564
+ if (initiatedBy === '' || initiatedBy.length > EXTERNAL_INITIATOR_MAX_LENGTH) return undefined
565
+ if (action.summary !== undefined && typeof action.summary !== 'string') return undefined
566
+ const summary = typeof action.summary === 'string' ? action.summary.trim().slice(0, EXTERNAL_SUMMARY_MAX_LENGTH) : ''
567
+ return {
568
+ requestId: envelope.requestId,
569
+ action: {
570
+ kind: 'record-external-outcome',
571
+ taskId,
572
+ result: action.result,
573
+ initiatedBy,
574
+ ...(summary === '' ? {} : { summary }),
575
+ },
576
+ }
577
+ }
475
578
  case 'confirm-permission':
476
579
  case 'delete':
477
580
  case 'archive':
478
581
  case 'restore':
479
582
  case 'settle':
583
+ case 'reset-verification':
480
584
  case 'run':
481
585
  case 'rerun':
482
586
  if (!exactKeys(action, ['kind', 'taskId'])) return undefined
package/lib/invariant.js DELETED
@@ -1,6 +0,0 @@
1
- //#region src/invariant.ts
2
- /** Invariant companion plugin (no assertions — nothing to check at runtime). */
3
- /** Provides no assertions: the board owns no cross-package runtime invariants. */
4
- function apply() {}
5
- //#endregion
6
- export { apply };
@@ -1,34 +0,0 @@
1
- /**
2
- * Board-owned variant of the family plugin-card seat that can declare child
3
- * slots on the registration.
4
- *
5
- * The shared `installPluginCard` helper owns seat selection but cannot forward
6
- * a `children` declaration, and the task board's settings card declares the
7
- * `task-board.settings.section` seat a provider renders into. This wrapper
8
- * reuses the shared seat decision (`familyGroupLoaded` and the two seat keys)
9
- * and adds the declaration; it should collapse back into the shared helper once
10
- * that helper accepts children.
11
- *
12
- * @module dsh-task-board/client/board-card-seat
13
- */
14
- import { type PluginCardContext } from './plugin-card-seat.ts';
15
- /** One board card contribution, with the child seats it declares. */
16
- export interface BoardCardSeat {
17
- bundle: string;
18
- id: string;
19
- order?: number;
20
- label?: () => string;
21
- locale: string;
22
- /** Child slots this entry declares and renders. */
23
- children: Record<string, unknown>;
24
- inject?: () => object;
25
- component: unknown;
26
- }
27
- /**
28
- * Contribute the board's settings card to the seat this host renders, declaring
29
- * the child slots it owns. Mirrors the shared helper's reconcile discipline: the
30
- * entry moves when the family group appears, and is never in two seats at once.
31
- * @param ctx - client context (its slot registry decides the seat).
32
- * @param seat - the card contribution and its children declaration.
33
- */
34
- export declare function installBoardCard(ctx: PluginCardContext, seat: BoardCardSeat): void;
@@ -1,3 +0,0 @@
1
- /** Invariant companion plugin (no assertions — nothing to check at runtime). */
2
- /** Provides no assertions: the board owns no cross-package runtime invariants. */
3
- export declare function apply(): void;
@@ -1,102 +0,0 @@
1
- /**
2
- * Board-owned variant of the family plugin-card seat that can declare child
3
- * slots on the registration.
4
- *
5
- * The shared `installPluginCard` helper owns seat selection but cannot forward
6
- * a `children` declaration, and the task board's settings card declares the
7
- * `task-board.settings.section` seat a provider renders into. This wrapper
8
- * reuses the shared seat decision (`familyGroupLoaded` and the two seat keys)
9
- * and adds the declaration; it should collapse back into the shared helper once
10
- * that helper accepts children.
11
- *
12
- * @module dsh-task-board/client/board-card-seat
13
- */
14
- import {
15
- FAMILY_PLUGIN_CARD_SEAT,
16
- OFFICIAL_PLUGIN_CARD_SEAT,
17
- familyGroupLoaded,
18
- type PluginCardContext,
19
- } from './plugin-card-seat.ts'
20
-
21
- /** One board card contribution, with the child seats it declares. */
22
- export interface BoardCardSeat {
23
- bundle: string
24
- id: string
25
- order?: number
26
- label?: () => string
27
- locale: string
28
- /** Child slots this entry declares and renders. */
29
- children: Record<string, unknown>
30
- inject?: () => object
31
- component: unknown
32
- }
33
-
34
- function warnRefusedSeat(seat: string, error: unknown): void {
35
- try {
36
- console.warn(`[dsh-web] plugin card registration into "${seat}" was refused; the card will not render`, error)
37
- } catch {
38
- // Best-effort console write; a failed warning must not break the plugin.
39
- }
40
- }
41
-
42
- /**
43
- * Contribute the board's settings card to the seat this host renders, declaring
44
- * the child slots it owns. Mirrors the shared helper's reconcile discipline: the
45
- * entry moves when the family group appears, and is never in two seats at once.
46
- * @param ctx - client context (its slot registry decides the seat).
47
- * @param seat - the card contribution and its children declaration.
48
- */
49
- export function installBoardCard(ctx: PluginCardContext, seat: BoardCardSeat): void {
50
- const slots = ctx.slots
51
- const component = seat.component as never
52
- const inject = seat.inject as never
53
-
54
- let dispose: (() => void) | undefined
55
- let current: string | undefined
56
- let reconciling = false
57
-
58
- const reconcile = (): void => {
59
- if (reconciling) return
60
- const target = familyGroupLoaded(ctx) ? FAMILY_PLUGIN_CARD_SEAT : OFFICIAL_PLUGIN_CARD_SEAT
61
- if (current === target) return
62
- reconciling = true
63
- const previous = dispose
64
- dispose = undefined
65
- current = undefined
66
- previous?.()
67
- try {
68
- dispose = slots.register((target === FAMILY_PLUGIN_CARD_SEAT
69
- ? {
70
- name: FAMILY_PLUGIN_CARD_SEAT,
71
- id: seat.id,
72
- ...(seat.order === undefined ? {} : { order: seat.order }),
73
- ...(seat.label === undefined ? {} : { label: seat.label }),
74
- locale: seat.locale,
75
- children: seat.children,
76
- ...(seat.inject === undefined ? {} : { inject }),
77
- }
78
- : {
79
- name: OFFICIAL_PLUGIN_CARD_SEAT,
80
- key: seat.bundle,
81
- locale: seat.locale,
82
- children: seat.children,
83
- ...(seat.inject === undefined ? {} : { inject }),
84
- }) as never, component) as () => void
85
- current = target
86
- } catch (error) {
87
- warnRefusedSeat(target, error)
88
- } finally {
89
- reconciling = false
90
- }
91
- }
92
-
93
- if (typeof ctx.on === 'function') {
94
- try {
95
- ctx.on('slots/changed', () => { reconcile() })
96
- } catch {
97
- // An event seat that refuses the subscription leaves the initial
98
- // decision in place; the card still renders in the seat chosen below.
99
- }
100
- }
101
- reconcile()
102
- }
package/src/invariant.ts DELETED
@@ -1,4 +0,0 @@
1
- /** Invariant companion plugin (no assertions — nothing to check at runtime). */
2
-
3
- /** Provides no assertions: the board owns no cross-package runtime invariants. */
4
- export function apply(): void {}