@linxin666/dsh-client-ui-task-board 0.4.5 → 0.4.7
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 +34 -18
- package/README.zh.md +34 -18
- package/lib/client.js +2804 -1024
- package/lib/client.js.map +1 -1
- package/lib/index.js +2576 -416
- package/lib/types/client/CardDefaultPresetField.d.ts +35 -0
- package/lib/types/client/TaskBoardSettingsCard.d.ts +61 -0
- package/lib/types/client/board/ReasoningEffortField.d.ts +25 -0
- package/lib/types/client/board/model-options.d.ts +67 -0
- package/lib/types/client/board/preset-label.d.ts +21 -0
- package/lib/types/client/board/preset-roster.d.ts +45 -0
- package/lib/types/client/board/team-run-promotion.d.ts +26 -0
- package/lib/types/client/index.d.ts +4 -5
- package/lib/types/client/locales.d.ts +81 -0
- package/lib/types/client/plugin-card-seat.d.ts +5 -0
- package/lib/types/client/schedule-zone.d.ts +50 -0
- package/lib/types/core/card-defaults.d.ts +78 -0
- package/lib/types/core/controller.d.ts +41 -17
- package/lib/types/core/launch-width.d.ts +31 -0
- package/lib/types/core/poll-cadence.d.ts +31 -0
- package/lib/types/core/reasoning-effort.d.ts +57 -0
- package/lib/types/core/subtask.d.ts +30 -8
- package/lib/types/core/tasks.d.ts +163 -14
- package/lib/types/core/use-cases/task-create.d.ts +10 -5
- package/lib/types/core/use-cases/task-schedule.d.ts +68 -10
- package/lib/types/core/use-cases/task-update.d.ts +1 -1
- package/lib/types/core/verification-budget.d.ts +52 -0
- package/lib/types/core/verification.d.ts +376 -9
- package/lib/types/host/launch-admission.d.ts +72 -0
- package/lib/types/host/verification-gate.d.ts +24 -0
- package/lib/types/host/verification-runner.d.ts +38 -7
- package/lib/types/host-ledger.d.ts +91 -11
- package/lib/types/host-runner.d.ts +111 -13
- package/lib/types/host-service.d.ts +199 -8
- package/lib/types/index.d.ts +141 -0
- package/lib/types/protocol.d.ts +63 -12
- package/package.json +29 -33
- package/src/client/CardDefaultPresetField.tsx +101 -0
- package/src/client/TaskBoardSettingsCard.tsx +288 -17
- package/src/client/board/LinkSubtaskModal.tsx +8 -0
- package/src/client/board/NewTaskModal.tsx +237 -60
- package/src/client/board/ReasoningEffortField.tsx +50 -0
- package/src/client/board/TaskCard.tsx +3 -3
- package/src/client/board/TaskDetail.tsx +261 -24
- package/src/client/board/VerificationReport.tsx +132 -13
- package/src/client/board/model-options.ts +178 -0
- package/src/client/board/preset-label.ts +32 -0
- package/src/client/board/preset-roster.ts +58 -0
- package/src/client/board/status-key.ts +3 -0
- package/src/client/board/team-run-promotion.ts +39 -0
- package/src/client/index.ts +105 -108
- package/src/client/locales.ts +171 -6
- package/src/client/plugin-card-seat.ts +7 -0
- package/src/client/schedule-zone.ts +100 -2
- package/src/client/settings-entry-form.ts +0 -1
- package/src/core/card-defaults.ts +132 -0
- package/src/core/controller.ts +55 -16
- package/src/core/launch-width.ts +38 -0
- package/src/core/poll-cadence.ts +41 -0
- package/src/core/reasoning-effort.ts +92 -0
- package/src/core/store.ts +69 -15
- package/src/core/subtask.ts +48 -13
- package/src/core/tasks.ts +213 -39
- package/src/core/use-cases/task-create.ts +57 -25
- package/src/core/use-cases/task-schedule.ts +190 -25
- package/src/core/use-cases/task-update.ts +9 -4
- package/src/core/verification-budget.ts +115 -0
- package/src/core/verification.ts +785 -17
- package/src/host/agent-tools.ts +127 -29
- package/src/host/launch-admission.ts +140 -0
- package/src/host/verification-gate.ts +172 -16
- package/src/host/verification-runner.ts +207 -40
- package/src/host-ledger.ts +346 -48
- package/src/host-runner.ts +234 -35
- package/src/host-service.ts +539 -52
- package/src/index.ts +250 -7
- package/src/protocol.ts +160 -32
- package/lib/invariant.js +0 -6
- package/lib/types/client/board-card-seat.d.ts +0 -34
- package/lib/types/invariant.d.ts +0 -3
- package/src/client/board-card-seat.ts +0 -102
- package/src/invariant.ts +0 -4
package/src/core/controller.ts
CHANGED
|
@@ -16,7 +16,7 @@ import { hasOpenExecution, withStatus, type NewTaskInput, type TaskRecord, type
|
|
|
16
16
|
import { applyArchiveTask, applyRestoreTask } from './use-cases/task-archive.ts'
|
|
17
17
|
import { applyCreateTask } from './use-cases/task-create.ts'
|
|
18
18
|
import { applyDeleteTask } from './use-cases/task-delete.ts'
|
|
19
|
-
import { applyScheduleNextRun as applyScheduleRollForward, applySetSchedule } from './use-cases/task-schedule.ts'
|
|
19
|
+
import { applyScheduleNextRun as applyScheduleRollForward, applySetSchedule, type SetSchedulePatch } from './use-cases/task-schedule.ts'
|
|
20
20
|
import { resolveHostTimeZone } from './schedule.ts'
|
|
21
21
|
import { applySetParent } from './use-cases/task-parent.ts'
|
|
22
22
|
import { applyDeleteTag, applyRenameTag } from './use-cases/task-tag.ts'
|
|
@@ -127,6 +127,17 @@ export interface ExecutionModelOption {
|
|
|
127
127
|
name?: string
|
|
128
128
|
label?: string
|
|
129
129
|
provider?: string
|
|
130
|
+
/**
|
|
131
|
+
* Reasoning metadata the adapter declares for this exact route. An effort
|
|
132
|
+
* picker is offered only while `supported`, and its choices are `efforts` —
|
|
133
|
+
* the ids the target model really accepts, so the UI never offers (or keeps)
|
|
134
|
+
* a level the model would reject. Absent means the deployment published no
|
|
135
|
+
* capability for this model; the card value is then left untouched.
|
|
136
|
+
*/
|
|
137
|
+
reasoning?: {
|
|
138
|
+
supported: boolean
|
|
139
|
+
efforts?: readonly string[]
|
|
140
|
+
}
|
|
130
141
|
}
|
|
131
142
|
|
|
132
143
|
/** The execution-target option sets the UI feeds into the controller. */
|
|
@@ -141,7 +152,7 @@ export interface ExecutionOptionsSnapshot {
|
|
|
141
152
|
* (revision/scheduler/power); a full snapshot also carries the deployment
|
|
142
153
|
* constants the UI reads (session default permission, subtask depth).
|
|
143
154
|
*/
|
|
144
|
-
export type HostMirror = Pick<TaskBoardSnapshot, 'revision' | 'scheduler' | 'power' | 'sessionDefaultPermission' | 'maxSubtaskDepth' | 'teamRunAvailable' | 'extensions'>
|
|
155
|
+
export type HostMirror = Pick<TaskBoardSnapshot, 'revision' | 'scheduler' | 'power' | 'sessionDefaultPermission' | 'maxSubtaskDepth' | 'teamRunAvailable' | 'goalRunEnabled' | 'cardDefaults' | 'extensions'>
|
|
145
156
|
|
|
146
157
|
/** Immutable controller snapshot for UI subscriptions. */
|
|
147
158
|
export interface ControllerSnapshot {
|
|
@@ -318,6 +329,22 @@ export class BoardController {
|
|
|
318
329
|
return await this.initializeRemote()
|
|
319
330
|
}
|
|
320
331
|
|
|
332
|
+
/**
|
|
333
|
+
* Re-read the authoritative Host snapshot.
|
|
334
|
+
*
|
|
335
|
+
* The board's volatile deployment configuration — the new-card defaults among
|
|
336
|
+
* it — is republished the moment the settings document is written, but the SSE
|
|
337
|
+
* frame that republish emits carries the value as of BEFORE the write (the
|
|
338
|
+
* loader hands the update to the fiber while the new document is not yet
|
|
339
|
+
* visible to it), so a mirror that followed frames alone would stay one edit
|
|
340
|
+
* behind and the create dialog would prefill from the previous defaults for
|
|
341
|
+
* the rest of the session. The surface that owns the write asks for the
|
|
342
|
+
* snapshot again instead of trusting the frame.
|
|
343
|
+
*/
|
|
344
|
+
refreshHostState(): void {
|
|
345
|
+
void this.refreshRemote()
|
|
346
|
+
}
|
|
347
|
+
|
|
321
348
|
// --- view state -------------------------------------------------------------
|
|
322
349
|
|
|
323
350
|
/**
|
|
@@ -407,7 +434,7 @@ export class BoardController {
|
|
|
407
434
|
|
|
408
435
|
createTask(input: NewTaskInput): TaskRecord | undefined {
|
|
409
436
|
const id = this.uuid()
|
|
410
|
-
const { task, tasks } = applyCreateTask(this.tasks, input, this.now(), id, this.mirrorDepth())
|
|
437
|
+
const { task, tasks } = applyCreateTask(this.tasks, input, this.now(), id, this.mirrorDepth(), undefined, this.hostState?.cardDefaults)
|
|
411
438
|
if (task === undefined) return undefined
|
|
412
439
|
this.tasks = [...tasks]
|
|
413
440
|
this.persistAndNotify()
|
|
@@ -421,7 +448,7 @@ export class BoardController {
|
|
|
421
448
|
// The preview must obey the SAME lineage gate the Host will: a browser-side
|
|
422
449
|
// default of one level would reject a legal subtask-of-subtask before the
|
|
423
450
|
// request is ever sent.
|
|
424
|
-
const preview = applyCreateTask(this.tasks, input, this.now(), id, this.mirrorDepth()).task
|
|
451
|
+
const preview = applyCreateTask(this.tasks, input, this.now(), id, this.mirrorDepth(), undefined, this.hostState?.cardDefaults).task
|
|
425
452
|
if (preview === undefined) return undefined
|
|
426
453
|
return await this.commitRemote({ kind: 'create', id, input }, id)
|
|
427
454
|
? this.tasks.find(task => task.id === id)
|
|
@@ -597,15 +624,17 @@ export class BoardController {
|
|
|
597
624
|
// --- scheduling ---------------------------------------------------------------
|
|
598
625
|
|
|
599
626
|
/**
|
|
600
|
-
* Update a task's schedule rule.
|
|
601
|
-
*
|
|
602
|
-
*
|
|
603
|
-
*
|
|
627
|
+
* Update a task's schedule rule. An unusable plan (blank or invalid cron, a
|
|
628
|
+
* one-shot with no future instant, a non-positive run cap) is rejected
|
|
629
|
+
* (returns false, state untouched). When the rule ends up enabled the next
|
|
630
|
+
* run instant is computed immediately — a one-shot's next instant IS its
|
|
631
|
+
* planned instant — and a disabled rule carries none. Delegates the domain
|
|
632
|
+
* transition to the schedule use case.
|
|
604
633
|
* @param id - the task to schedule.
|
|
605
634
|
* @param patch - fields to change (absent fields keep their current value).
|
|
606
|
-
* @returns true when applied, false when rejected (
|
|
635
|
+
* @returns true when applied, false when rejected (unknown task / unusable plan).
|
|
607
636
|
*/
|
|
608
|
-
setSchedule(id: string, patch:
|
|
637
|
+
setSchedule(id: string, patch: SetSchedulePatch): boolean {
|
|
609
638
|
// The Host zone is the fallback a rule with no stored zone follows: the
|
|
610
639
|
// mirrored snapshot carries it, and a controller without a Host mirror
|
|
611
640
|
// (pure client tests) falls back to the process zone.
|
|
@@ -802,17 +831,25 @@ export class BoardController {
|
|
|
802
831
|
}
|
|
803
832
|
|
|
804
833
|
/**
|
|
805
|
-
* Project a Host snapshot onto the browser's mirror.
|
|
806
|
-
*
|
|
807
|
-
* deployment constants the UI reads
|
|
808
|
-
*
|
|
809
|
-
*
|
|
810
|
-
*
|
|
834
|
+
* Project a Host snapshot onto the browser's mirror. Every snapshot the
|
|
835
|
+
* transport serves is a full document (bootstrap/state/action all answer
|
|
836
|
+
* `TaskBoardHostService.snapshot()`), so the deployment constants the UI reads
|
|
837
|
+
* — the session-default permission the confirmation banner compares against,
|
|
838
|
+
* the subtask depth limit, the platform switches and the extensions a provider
|
|
839
|
+
* published — are taken as published, carried over only where the snapshot
|
|
840
|
+
* itself omits the key.
|
|
811
841
|
*/
|
|
812
842
|
private mirrorOf(snapshot: TaskBoardSnapshot): HostMirror {
|
|
813
843
|
const sessionDefaultPermission = snapshot.sessionDefaultPermission ?? this.hostState?.sessionDefaultPermission
|
|
814
844
|
const maxSubtaskDepth = snapshot.maxSubtaskDepth ?? this.hostState?.maxSubtaskDepth
|
|
815
845
|
const teamRunAvailable = snapshot.teamRunAvailable ?? this.hostState?.teamRunAvailable
|
|
846
|
+
const goalRunEnabled = snapshot.goalRunEnabled ?? this.hostState?.goalRunEnabled
|
|
847
|
+
// The new-card defaults are NOT carried over: the Host publishes a target
|
|
848
|
+
// nothing configures by omitting it (protocol.ts states that explicitly), so
|
|
849
|
+
// an absent key means "this deployment configures none". Carrying the last
|
|
850
|
+
// value over would keep a CLEARED default alive in the create dialog, which
|
|
851
|
+
// is the mirror image of the staleness this key is re-read for.
|
|
852
|
+
const cardDefaults = snapshot.cardDefaults
|
|
816
853
|
const extensions = snapshot.extensions ?? this.hostState?.extensions
|
|
817
854
|
return {
|
|
818
855
|
revision: snapshot.revision,
|
|
@@ -821,6 +858,8 @@ export class BoardController {
|
|
|
821
858
|
...(sessionDefaultPermission === undefined ? {} : { sessionDefaultPermission }),
|
|
822
859
|
...(maxSubtaskDepth === undefined ? {} : { maxSubtaskDepth }),
|
|
823
860
|
...(teamRunAvailable === undefined ? {} : { teamRunAvailable }),
|
|
861
|
+
...(goalRunEnabled === undefined ? {} : { goalRunEnabled }),
|
|
862
|
+
...(cardDefaults === undefined ? {} : { cardDefaults }),
|
|
824
863
|
...(extensions === undefined ? {} : { extensions }),
|
|
825
864
|
}
|
|
826
865
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run-launch admission width: the one bound on how many sessions this board
|
|
3
|
+
* starts at the same time.
|
|
4
|
+
*
|
|
5
|
+
* One board action opens a whole subtree, and a team run hands every member of
|
|
6
|
+
* it to the Lead at once, so without a bound the Host asks DSH for dozens of
|
|
7
|
+
* sessions in a single tick. The repository's own measured guidance caps heavy
|
|
8
|
+
* implementation subagents at three (docs/multi-agent-resources.md) and no
|
|
9
|
+
* runtime mechanism enforced it; this setting is that mechanism.
|
|
10
|
+
*
|
|
11
|
+
* The bounds and the default live here rather than in either half, exactly like
|
|
12
|
+
* the roster cadence: the settings card validates a draft against the same
|
|
13
|
+
* range the Host clamps a hand-edited configuration to, and a programmatic
|
|
14
|
+
* mount that names no width gets the default.
|
|
15
|
+
*
|
|
16
|
+
* @module dsh-task-board/core/launch-width
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** Width used when neither the settings document nor the mount names one. */
|
|
20
|
+
export const DEFAULT_RUN_LAUNCH_WIDTH = 3
|
|
21
|
+
|
|
22
|
+
/** Narrowest accepted width: one launch at a time. */
|
|
23
|
+
export const RUN_LAUNCH_WIDTH_MIN = 1
|
|
24
|
+
|
|
25
|
+
/** Widest accepted width. */
|
|
26
|
+
export const RUN_LAUNCH_WIDTH_MAX = 16
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Clamp a configured width into the supported range. A value the schema would
|
|
30
|
+
* refuse (non-finite, below the floor, above the ceiling) still leaves the
|
|
31
|
+
* board launching: an out-of-range setting never stalls a run.
|
|
32
|
+
* @param width - configured width, or undefined for the default.
|
|
33
|
+
* @returns the effective width.
|
|
34
|
+
*/
|
|
35
|
+
export function normalizeRunLaunchWidth(width: number | undefined): number {
|
|
36
|
+
if (width === undefined || !Number.isFinite(width)) return DEFAULT_RUN_LAUNCH_WIDTH
|
|
37
|
+
return Math.min(Math.max(Math.trunc(width), RUN_LAUNCH_WIDTH_MIN), RUN_LAUNCH_WIDTH_MAX)
|
|
38
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Roster-poll cadence: the one recurring Host timer the board holds.
|
|
3
|
+
*
|
|
4
|
+
* The cadence is a user setting (seconds), so the bounds and the seconds to
|
|
5
|
+
* milliseconds conversion live here rather than in either half: the settings
|
|
6
|
+
* card validates a draft against the same range the Host clamps a hand-edited
|
|
7
|
+
* configuration to, and a programmatic mount that passes no cadence gets the
|
|
8
|
+
* default.
|
|
9
|
+
*
|
|
10
|
+
* @module dsh-task-board/core/poll-cadence
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Cadence used when neither the settings document nor the mount names one. */
|
|
14
|
+
export const DEFAULT_SESSION_POLL_SECONDS = 5
|
|
15
|
+
|
|
16
|
+
/** Shortest accepted cadence: a faster poll only burns CPU on the Host. */
|
|
17
|
+
export const SESSION_POLL_MIN_SECONDS = 1
|
|
18
|
+
|
|
19
|
+
/** Longest accepted cadence, five minutes. */
|
|
20
|
+
export const SESSION_POLL_MAX_SECONDS = 300
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Clamp a configured cadence into the supported range. A value the schema
|
|
24
|
+
* would refuse (non-finite, below the floor, above the ceiling) still leaves
|
|
25
|
+
* the board polling: an out-of-range setting never disables the timer.
|
|
26
|
+
* @param seconds - configured cadence in seconds, or undefined for the default.
|
|
27
|
+
* @returns the effective cadence in seconds.
|
|
28
|
+
*/
|
|
29
|
+
export function normalizeSessionPollSeconds(seconds: number | undefined): number {
|
|
30
|
+
if (seconds === undefined || !Number.isFinite(seconds)) return DEFAULT_SESSION_POLL_SECONDS
|
|
31
|
+
return Math.min(Math.max(Math.trunc(seconds), SESSION_POLL_MIN_SECONDS), SESSION_POLL_MAX_SECONDS)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The cadence in the milliseconds the Host timer face takes.
|
|
36
|
+
* @param seconds - configured cadence in seconds, or undefined for the default.
|
|
37
|
+
* @returns the effective cadence in milliseconds.
|
|
38
|
+
*/
|
|
39
|
+
export function sessionPollMs(seconds: number | undefined): number {
|
|
40
|
+
return normalizeSessionPollSeconds(seconds) * 1_000
|
|
41
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which reasoning effort one execution may actually send.
|
|
3
|
+
*
|
|
4
|
+
* The rule lives here, in one pure place, so the launch path cannot drift from
|
|
5
|
+
* what the picker promises: an effort is sent ONLY for the exact model route
|
|
6
|
+
* whose adapter declares that id. An unsupported value is dropped rather than
|
|
7
|
+
* passed through — the Host validates the selection against the same
|
|
8
|
+
* capability (`resolveCallConfig` rejects an unsupported effort) and the whole
|
|
9
|
+
* model selection would fail with it. An absent effort stays absent: the SDK
|
|
10
|
+
* then materializes the model's own default, which is exactly what "inherit
|
|
11
|
+
* the model" means.
|
|
12
|
+
*
|
|
13
|
+
* A card that pins a bare model id also gets its provider resolved here: the
|
|
14
|
+
* session wire contract requires `provider` (ModelSelection), so the catalog is
|
|
15
|
+
* the only place that can complete the documented `provider/model` or model-id
|
|
16
|
+
* pin.
|
|
17
|
+
*/
|
|
18
|
+
import type { CatalogModel, ModelCatalogView } from './verification.ts'
|
|
19
|
+
|
|
20
|
+
/** One execution route an effort resolves against: `provider/model`, or a bare model id. */
|
|
21
|
+
export interface ReasoningRoute {
|
|
22
|
+
provider?: string
|
|
23
|
+
model: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The catalog entry a route names, once the catalog can complete it. */
|
|
27
|
+
export interface DeclaredReasoningRoute {
|
|
28
|
+
provider: string
|
|
29
|
+
model: string
|
|
30
|
+
/** The adapter's reasoning metadata; absent when it exposes none. */
|
|
31
|
+
reasoning?: CatalogModel['reasoning']
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** What one card's effort resolves to for one route. */
|
|
35
|
+
export interface ReasoningEffortDecision {
|
|
36
|
+
/** The effort to send; absent means "send none". */
|
|
37
|
+
effort?: string
|
|
38
|
+
/** The card's own value when it will NOT be sent. */
|
|
39
|
+
dropped?: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Trim one optional effort: blank text means "not set", like every other pinned target. */
|
|
43
|
+
export function normalizeReasoningEffort(value: string | undefined): string | undefined {
|
|
44
|
+
const trimmed = value?.trim()
|
|
45
|
+
return trimmed === undefined || trimmed === '' ? undefined : trimmed
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Complete one route through the host catalog: a qualified route must match its
|
|
50
|
+
* provider group, and a bare model id matches the first group carrying it —
|
|
51
|
+
* which is the provider the request then names.
|
|
52
|
+
* @param catalog - the host model catalog, when this deployment serves one.
|
|
53
|
+
* @param route - the route as the card pins it.
|
|
54
|
+
* @returns the completed route with its reasoning metadata, or undefined when
|
|
55
|
+
* the catalog declares no such model.
|
|
56
|
+
*/
|
|
57
|
+
export function declaredRoute(
|
|
58
|
+
catalog: ModelCatalogView | undefined,
|
|
59
|
+
route: ReasoningRoute,
|
|
60
|
+
): DeclaredReasoningRoute | undefined {
|
|
61
|
+
const groups = catalog?.groups ?? []
|
|
62
|
+
const matched = route.provider === undefined || route.provider === ''
|
|
63
|
+
? groups.find(group => group.models.some(model => model.id === route.model))
|
|
64
|
+
: groups.find(group => group.id === route.provider)
|
|
65
|
+
const model = matched?.models.find(candidate => candidate.id === route.model)
|
|
66
|
+
if (matched === undefined || model === undefined) return undefined
|
|
67
|
+
return {
|
|
68
|
+
provider: matched.id,
|
|
69
|
+
model: model.id,
|
|
70
|
+
...(model.reasoning === undefined ? {} : { reasoning: model.reasoning }),
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Resolve the effort one execution sends.
|
|
76
|
+
* @param requested - the card's stored effort (absent or blank = inherit).
|
|
77
|
+
* @param catalog - the host model catalog, when this deployment serves one.
|
|
78
|
+
* @param route - the route the execution will actually run at.
|
|
79
|
+
* @returns the effort to send, or the dropped value that will not be sent.
|
|
80
|
+
*/
|
|
81
|
+
export function resolveReasoningEffort(
|
|
82
|
+
requested: string | undefined,
|
|
83
|
+
catalog: ModelCatalogView | undefined,
|
|
84
|
+
route: ReasoningRoute | undefined,
|
|
85
|
+
): ReasoningEffortDecision {
|
|
86
|
+
const effort = normalizeReasoningEffort(requested)
|
|
87
|
+
if (effort === undefined) return {}
|
|
88
|
+
if (route === undefined) return { dropped: effort }
|
|
89
|
+
const declared = declaredRoute(catalog, route)
|
|
90
|
+
const supported = declared?.reasoning?.efforts.some(option => option.id === effort) === true
|
|
91
|
+
return supported ? { effort } : { dropped: effort }
|
|
92
|
+
}
|
package/src/core/store.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* localStorage backend.
|
|
11
11
|
*/
|
|
12
12
|
import { isValidCron, isValidTimeZone } from './schedule.ts'
|
|
13
|
-
import { isTaskPermission, isTaskStatus, normalizeTags, normalizeTargetId, type ScheduleRule, type TaskFreeze, type TaskRecord, type TaskPermission, type TaskStatus } from './tasks.ts'
|
|
13
|
+
import { SCHEDULE_STOP_REASONS, isTaskPermission, isTaskStatus, normalizeTags, normalizeTargetId, type ScheduleRule, type ScheduleStopReason, type TaskFreeze, type TaskRecord, type TaskPermission, type TaskStatus } from './tasks.ts'
|
|
14
14
|
import { isExecutionOutcome } from './subtask.ts'
|
|
15
15
|
import type { TaskHandover } from './handover.ts'
|
|
16
16
|
import { sanitizeFreezeSnapshot } from './freeze-snapshot.ts'
|
|
@@ -68,6 +68,7 @@ function isTaskRecordShape(value: unknown): value is Omit<TaskRecord, 'status'>
|
|
|
68
68
|
if (record.parentId !== undefined && typeof record.parentId !== 'string') return false
|
|
69
69
|
if (record.workspaceId !== undefined && typeof record.workspaceId !== 'string') return false
|
|
70
70
|
if (record.mode !== undefined && typeof record.mode !== 'string') return false
|
|
71
|
+
if (record.reasoningEffort !== undefined && typeof record.reasoningEffort !== 'string') return false
|
|
71
72
|
if (record.permission !== undefined && typeof record.permission !== 'string') return false
|
|
72
73
|
if (record.integrations !== undefined && (typeof record.integrations !== 'object' || record.integrations === null || Array.isArray(record.integrations))) return false
|
|
73
74
|
if (record.hidden !== undefined && typeof record.hidden !== 'boolean') return false
|
|
@@ -87,6 +88,10 @@ function isTaskRecordShape(value: unknown): value is Omit<TaskRecord, 'status'>
|
|
|
87
88
|
if (entry.initiatedBy !== undefined && typeof entry.initiatedBy !== 'string') return false
|
|
88
89
|
if (entry.frozenBy !== undefined && typeof entry.frozenBy !== 'string') return false
|
|
89
90
|
if (entry.frozenAt !== undefined && typeof entry.frozenAt !== 'number') return false
|
|
91
|
+
// An external-outcome marker is optional in BOTH directions: a ledger
|
|
92
|
+
// written before the field existed carries none, and a non-boolean value
|
|
93
|
+
// is dropped by the repair below rather than trusted as provenance.
|
|
94
|
+
if (entry.external !== undefined && typeof entry.external !== 'boolean') return false
|
|
90
95
|
// The acceptance block is repaired like every other optional field: an
|
|
91
96
|
// unusable one is dropped, which can only make the board MORE strict.
|
|
92
97
|
if (entry.verification !== undefined && normalizeVerification(entry.verification) === undefined) return false
|
|
@@ -104,29 +109,73 @@ function normalizeStatus(status: unknown): TaskStatus {
|
|
|
104
109
|
return isTaskStatus(status) ? status : 'todo'
|
|
105
110
|
}
|
|
106
111
|
|
|
112
|
+
/** A persisted ms timestamp, kept only when it is a usable finite number. */
|
|
113
|
+
function optionalInstant(value: unknown): number | undefined {
|
|
114
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : undefined
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** A persisted positive-integer run cap, or undefined for unlimited/repair. */
|
|
118
|
+
function optionalRunCap(value: unknown): number | undefined {
|
|
119
|
+
return typeof value === 'number' && Number.isInteger(value) && value >= 1 ? value : undefined
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** A persisted stop reason, kept only when it names a reason this build knows. */
|
|
123
|
+
function optionalStopReason(value: unknown): ScheduleStopReason | undefined {
|
|
124
|
+
return typeof value === 'string' && SCHEDULE_STOP_REASONS.includes(value as ScheduleStopReason)
|
|
125
|
+
? value as ScheduleStopReason
|
|
126
|
+
: undefined
|
|
127
|
+
}
|
|
128
|
+
|
|
107
129
|
/**
|
|
108
|
-
* Repair a persisted schedule rule
|
|
109
|
-
*
|
|
110
|
-
*
|
|
130
|
+
* Repair a persisted schedule rule. Both modes are repaired field by field and
|
|
131
|
+
* a rule that carries no usable plan is dropped (the task row survives):
|
|
132
|
+
* a recurring rule without a well-formed 5-field cron, or a one-shot without a
|
|
133
|
+
* finite planned instant. A rule written before `mode` existed is a cron rule.
|
|
111
134
|
*/
|
|
112
135
|
function normalizeSchedule(schedule: unknown): ScheduleRule | undefined {
|
|
113
136
|
if (typeof schedule !== 'object' || schedule === null) return undefined
|
|
114
137
|
const rule = schedule as Record<string, unknown>
|
|
138
|
+
// A stored zone survives only when this runtime can resolve it; an unusable
|
|
139
|
+
// name is dropped (the rule then follows the Host zone) instead of being
|
|
140
|
+
// kept to fail every later resolve.
|
|
141
|
+
const timeZone = typeof rule.timeZone === 'string' && isValidTimeZone(rule.timeZone) ? rule.timeZone : undefined
|
|
142
|
+
const runCountValue = rule.runCount
|
|
143
|
+
const runCount = typeof runCountValue === 'number' && Number.isInteger(runCountValue) && runCountValue >= 0
|
|
144
|
+
? runCountValue
|
|
145
|
+
: 0
|
|
146
|
+
const endedAt = optionalInstant(rule.endedAt)
|
|
147
|
+
const endedReason = optionalStopReason(rule.endedReason)
|
|
148
|
+
const skippedAt = optionalInstant(rule.skippedAt)
|
|
149
|
+
const skippedReason = optionalStopReason(rule.skippedReason)
|
|
150
|
+
const common = {
|
|
151
|
+
enabled: rule.enabled === true,
|
|
152
|
+
...(timeZone === undefined ? {} : { timeZone }),
|
|
153
|
+
nextRunAt: optionalInstant(rule.nextRunAt),
|
|
154
|
+
lastTriggeredAt: optionalInstant(rule.lastTriggeredAt),
|
|
155
|
+
runCount,
|
|
156
|
+
...(endedAt === undefined ? {} : { endedAt }),
|
|
157
|
+
...(endedReason === undefined ? {} : { endedReason }),
|
|
158
|
+
...(skippedAt === undefined ? {} : { skippedAt }),
|
|
159
|
+
...(skippedReason === undefined ? {} : { skippedReason }),
|
|
160
|
+
}
|
|
161
|
+
if (rule.mode === 'once') {
|
|
162
|
+
const at = optionalInstant(rule.at)
|
|
163
|
+
// A one-shot with no instant can never fire; drop it rather than keep a
|
|
164
|
+
// rule the scheduler would have to interpret.
|
|
165
|
+
if (at === undefined) return undefined
|
|
166
|
+
return { ...common, mode: 'once', at }
|
|
167
|
+
}
|
|
115
168
|
// Reject (drop) a schedule whose cron is not a well-formed 5-field
|
|
116
169
|
// expression: a malformed rule would otherwise linger as a never-firing
|
|
117
170
|
// schedule instead of being dropped for later repair.
|
|
118
171
|
if (typeof rule.cron !== 'string') return undefined
|
|
119
172
|
if (rule.cron.trim() === '' || !isValidCron(rule.cron)) return undefined
|
|
120
|
-
|
|
121
|
-
// name is dropped (the rule then follows the Host zone) instead of being
|
|
122
|
-
// kept to fail every later resolve.
|
|
123
|
-
const timeZone = typeof rule.timeZone === 'string' && isValidTimeZone(rule.timeZone) ? rule.timeZone : undefined
|
|
173
|
+
const maxRuns = optionalRunCap(rule.maxRuns)
|
|
124
174
|
return {
|
|
125
|
-
|
|
175
|
+
...common,
|
|
176
|
+
mode: 'cron',
|
|
126
177
|
cron: rule.cron,
|
|
127
|
-
...(
|
|
128
|
-
nextRunAt: typeof rule.nextRunAt === 'number' ? rule.nextRunAt : undefined,
|
|
129
|
-
lastTriggeredAt: typeof rule.lastTriggeredAt === 'number' ? rule.lastTriggeredAt : undefined,
|
|
178
|
+
...(maxRuns === undefined ? {} : { maxRuns }),
|
|
130
179
|
}
|
|
131
180
|
}
|
|
132
181
|
|
|
@@ -210,18 +259,23 @@ export function parseLedger(raw: string | null): TaskRecord[] {
|
|
|
210
259
|
// A malformed acceptance block is dropped rather than dropping the
|
|
211
260
|
// execution record: the board then treats that run as unverified.
|
|
212
261
|
verification: normalizeVerification(execution.verification),
|
|
262
|
+
external: execution.external === true ? true : undefined,
|
|
213
263
|
}))
|
|
214
264
|
// Execution targets are normalized like the schedule: blank strings
|
|
215
265
|
// clear the pin and unknown permission strings from a future version
|
|
216
266
|
// fall back to the session default instead of dropping the row.
|
|
217
267
|
task.workspaceId = normalizeTargetId(row.workspaceId)
|
|
218
268
|
task.mode = normalizeTargetId(row.mode)
|
|
269
|
+
// The effort is repaired like the other execution-target strings: blank text
|
|
270
|
+
// from a hand-edited document clears the pin instead of dropping the row,
|
|
271
|
+
// and the card then runs at its model's own default level.
|
|
272
|
+
task.reasoningEffort = normalizeTargetId(row.reasoningEffort)
|
|
219
273
|
task.archivedAt = typeof row.archivedAt === 'number' && Number.isFinite(row.archivedAt) ? row.archivedAt : undefined
|
|
220
274
|
task.permission = isTaskPermission(row.permission) ? row.permission as TaskPermission : undefined
|
|
221
275
|
task.reuseSession = row.reuseSession === true ? true : undefined
|
|
222
|
-
// The goal opt-in is stored only when it is
|
|
223
|
-
// (
|
|
224
|
-
task.goalRun = row.goalRun ===
|
|
276
|
+
// The goal opt-in is stored only when it is ON: absent means the default
|
|
277
|
+
// (one plain turn per run), so a hand-edited false normalizes back to it.
|
|
278
|
+
task.goalRun = row.goalRun === true ? true : undefined
|
|
225
279
|
// The acceptance opt-out is stored only when it is ON: absent inherits the
|
|
226
280
|
// board-wide switch, so a hand-edited false normalizes back to inherit.
|
|
227
281
|
task.skipVerification = row.skipVerification === true ? true : undefined
|
package/src/core/subtask.ts
CHANGED
|
@@ -183,9 +183,12 @@ export function checkParentLink(
|
|
|
183
183
|
|
|
184
184
|
/**
|
|
185
185
|
* The tasks one run request opens executions for: the requested task first,
|
|
186
|
-
* then its on-board descendants within the configured depth.
|
|
187
|
-
*
|
|
188
|
-
* the
|
|
186
|
+
* then its on-board descendants within the configured depth. This is the
|
|
187
|
+
* SHAPE of a cascade — every walk that must see the whole tree (settlement,
|
|
188
|
+
* archive/restore, the running lock) reads it. The run path filters it with
|
|
189
|
+
* {@link joinsCascadeRun}: archived tasks never run, a participant that already
|
|
190
|
+
* has an open execution is skipped by the caller, which cannot start it twice,
|
|
191
|
+
* and a finished descendant is left alone.
|
|
189
192
|
*/
|
|
190
193
|
export function cascadeTargets(
|
|
191
194
|
tasks: readonly TaskRecord[],
|
|
@@ -197,6 +200,27 @@ export function cascadeTargets(
|
|
|
197
200
|
return [root, ...descendantTasks(tasks, rootId, maxSubtaskDepth)]
|
|
198
201
|
}
|
|
199
202
|
|
|
203
|
+
/**
|
|
204
|
+
* Whether a cascade member takes part in a new run.
|
|
205
|
+
*
|
|
206
|
+
* The task the user ran is always a member: the request names that card, and
|
|
207
|
+
* the operator asked for it to run whatever its column says. A descendant does
|
|
208
|
+
* not join once it sits in the `done` column — its work is finished, so running
|
|
209
|
+
* the parent again is a run of the parent, never a second execution of work
|
|
210
|
+
* that is already done. The rule keys on the column alone, so it covers both a
|
|
211
|
+
* subtask that ran and one an operator moved to done by hand, and it holds for
|
|
212
|
+
* every trigger: a manual run, a re-run, and a due occurrence of a schedule.
|
|
213
|
+
* A card whose own schedule is enabled settles back into `todo`, so a scheduled
|
|
214
|
+
* subtask stays a participant; to make a finished one run again, move it back
|
|
215
|
+
* to `todo` or run it on its own.
|
|
216
|
+
* @param task - the candidate member.
|
|
217
|
+
* @param rootId - the task the run request named.
|
|
218
|
+
* @returns false when this member is left alone by the run.
|
|
219
|
+
*/
|
|
220
|
+
export function joinsCascadeRun(task: TaskRecord, rootId: string): boolean {
|
|
221
|
+
return task.id === rootId || task.status !== 'done'
|
|
222
|
+
}
|
|
223
|
+
|
|
200
224
|
/** One still-open execution belonging to a cascade run group. */
|
|
201
225
|
export interface OpenCascadeChild {
|
|
202
226
|
taskId: string
|
|
@@ -263,11 +287,12 @@ export function effectiveTaskPermission(task: TaskRecord): TaskRecord['permissio
|
|
|
263
287
|
/**
|
|
264
288
|
* Resolve the execution targets a (sub)task actually runs with: its own value
|
|
265
289
|
* wins, and an unset target inherits the nearest ancestor up to the root.
|
|
266
|
-
* Workspace, agent preset and
|
|
267
|
-
* handover bundle is a per-card continuation artefact and is never
|
|
268
|
-
* The permission inherits the ancestor's EFFECTIVE binding, and its
|
|
269
|
-
* confirmation travels with it, so a subtask of an already-confirmed
|
|
270
|
-
* card is runnable while a subtask's own unconfirmed binding stays
|
|
290
|
+
* Workspace, agent preset, model and reasoning effort inherit the ancestor's
|
|
291
|
+
* PIN only — a handover bundle is a per-card continuation artefact and is never
|
|
292
|
+
* inherited. The permission inherits the ancestor's EFFECTIVE binding, and its
|
|
293
|
+
* human confirmation travels with it, so a subtask of an already-confirmed
|
|
294
|
+
* elevated card is runnable while a subtask's own unconfirmed binding stays
|
|
295
|
+
* gated.
|
|
271
296
|
*/
|
|
272
297
|
/**
|
|
273
298
|
* The immutable teammate name for one team-mode member. Teammate names are
|
|
@@ -317,13 +342,22 @@ export function resolveExecutionTargets(
|
|
|
317
342
|
): TaskRecord {
|
|
318
343
|
if (task.parentId === undefined) return task
|
|
319
344
|
const chain = [task, ...ancestorChain(tasks, task).slice(0, maxSubtaskDepth)]
|
|
320
|
-
const first = (key: 'workspaceId' | 'mode' | 'model'): string | undefined => {
|
|
321
|
-
for (const candidate of
|
|
345
|
+
const first = (key: 'workspaceId' | 'mode' | 'model' | 'reasoningEffort', candidates: readonly TaskRecord[]): string | undefined => {
|
|
346
|
+
for (const candidate of candidates) {
|
|
322
347
|
const value = candidate[key]
|
|
323
348
|
if (value !== undefined) return value
|
|
324
349
|
}
|
|
325
350
|
return undefined
|
|
326
351
|
}
|
|
352
|
+
// The model pin is the boundary of the effort: an effort is only ever taken
|
|
353
|
+
// from the card the model came from, or from the card that pinned it below
|
|
354
|
+
// that one. A card with a model of its own thus sends NO effort when it stores
|
|
355
|
+
// none — the model's own default — instead of quietly running at an ancestor
|
|
356
|
+
// level chosen for a different model, even when both models declare that
|
|
357
|
+
// level. A card with no model of its own still inherits the nearest model
|
|
358
|
+
// together with the nearest level pinned at or below it.
|
|
359
|
+
const boundary = chain.findIndex(candidate => candidate.model !== undefined)
|
|
360
|
+
const effortChain = boundary === -1 ? chain : chain.slice(0, boundary + 1)
|
|
327
361
|
const own = effectiveTaskPermission(task)
|
|
328
362
|
// An own binding (pin or handover) keeps its own confirmation stamp; only an
|
|
329
363
|
// absent one borrows the ancestor's binding AND that ancestor's stamp.
|
|
@@ -332,9 +366,10 @@ export function resolveExecutionTargets(
|
|
|
332
366
|
: undefined
|
|
333
367
|
return {
|
|
334
368
|
...task,
|
|
335
|
-
workspaceId: first('workspaceId'),
|
|
336
|
-
mode: first('mode'),
|
|
337
|
-
model: first('model'),
|
|
369
|
+
workspaceId: first('workspaceId', chain),
|
|
370
|
+
mode: first('mode', chain),
|
|
371
|
+
model: first('model', chain),
|
|
372
|
+
reasoningEffort: first('reasoningEffort', effortChain),
|
|
338
373
|
permission: own ?? (owner === undefined ? undefined : effectiveTaskPermission(owner)),
|
|
339
374
|
permissionConfirmedAt: own === undefined ? owner?.permissionConfirmedAt : task.permissionConfirmedAt,
|
|
340
375
|
}
|