@zfdx123/dsh-hooks-ordering 1.0.1
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/LICENSE +21 -0
- package/README.md +257 -0
- package/client.js +619 -0
- package/cordis.patch.yml +38 -0
- package/lib/dag-Bqx-sl71.d.ts +55 -0
- package/lib/dag-Bqx-sl71.d.ts.map +1 -0
- package/lib/dag-DVhoBjBG.js +48 -0
- package/lib/dag-DVhoBjBG.js.map +1 -0
- package/lib/dag.d.ts +3 -0
- package/lib/dag.js +3 -0
- package/lib/index.d.ts +121 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +190 -0
- package/lib/index.js.map +1 -0
- package/lib/serial-Cu7usHjI.js +65 -0
- package/lib/serial-Cu7usHjI.js.map +1 -0
- package/lib/serial-D8ZJCBKL.d.ts +55 -0
- package/lib/serial-D8ZJCBKL.d.ts.map +1 -0
- package/lib/serial.d.ts +5 -0
- package/lib/serial.js +6 -0
- package/lib/service-base-CCmIBwnB.d.ts +100 -0
- package/lib/service-base-CCmIBwnB.d.ts.map +1 -0
- package/lib/service-base-a5vKg62S.js +139 -0
- package/lib/service-base-a5vKg62S.js.map +1 -0
- package/lib/service-base.d.ts +4 -0
- package/lib/service-base.js +5 -0
- package/lib/topo-sort-BZ1fFcTs.d.ts +54 -0
- package/lib/topo-sort-BZ1fFcTs.d.ts.map +1 -0
- package/lib/topo-sort-CfwYPY4U.js +83 -0
- package/lib/topo-sort-CfwYPY4U.js.map +1 -0
- package/lib/topo-sort.d.ts +2 -0
- package/lib/topo-sort.js +3 -0
- package/lib/waterfall-Bu6m9gYc.js +83 -0
- package/lib/waterfall-Bu6m9gYc.js.map +1 -0
- package/lib/waterfall-_5HkptkS.d.ts +87 -0
- package/lib/waterfall-_5HkptkS.d.ts.map +1 -0
- package/lib/waterfall.d.ts +5 -0
- package/lib/waterfall.js +6 -0
- package/package.json +117 -0
- package/src/dag.ts +92 -0
- package/src/dsh.ts +181 -0
- package/src/index.ts +54 -0
- package/src/serial.ts +108 -0
- package/src/service-base.ts +179 -0
- package/src/settings.ts +97 -0
- package/src/topo-sort.ts +118 -0
- package/src/waterfall.ts +153 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared coordinator machinery for the waterfall and serial hook-ordering
|
|
3
|
+
* services. Both manage, per controlled hook, two ordered participant lists
|
|
4
|
+
* (`front`/`back`) plus the disposer for the installed coordinator(s); they
|
|
5
|
+
* differ only in HOW the coordinator listeners are installed and HOW a phase
|
|
6
|
+
* runs (waterfall wraps the native chain via `next()`; serial runs participants
|
|
7
|
+
* ahead of it with bail short-circuiting). Registration, planning, DAG dumping,
|
|
8
|
+
* and optional file logging are identical, so they live here.
|
|
9
|
+
* @module dsh-hooks-ordering/service-base
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { writeFileSync } from 'node:fs'
|
|
13
|
+
import { type Context, Service } from '@deepseek-ai/cordis'
|
|
14
|
+
import { type DagSection, buildDag } from './dag.ts'
|
|
15
|
+
import { type Orderable, topoSort } from './topo-sort.ts'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Where a participant runs relative to the native hook chain.
|
|
19
|
+
* - `front`: ahead of every native listener (and, for waterfall, the built-in default).
|
|
20
|
+
* - `back`: behind the native chain (waterfall) / best-effort last (serial).
|
|
21
|
+
*/
|
|
22
|
+
export type Phase = 'front' | 'back'
|
|
23
|
+
|
|
24
|
+
/** Thrown when registering into, or double-controlling, a hook in an unsupported state. */
|
|
25
|
+
export class HookControlError extends Error {
|
|
26
|
+
/**
|
|
27
|
+
* @param message - the specific control-state violation.
|
|
28
|
+
*/
|
|
29
|
+
constructor(message: string) {
|
|
30
|
+
super(`hooks-ordering: ${message}`)
|
|
31
|
+
this.name = 'HookControlError'
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Optional file logging shared by both services. */
|
|
36
|
+
export interface HookOrderingLogConfig {
|
|
37
|
+
/**
|
|
38
|
+
* When set, the constraint DAG (JSON) is written to this file on every
|
|
39
|
+
* registration change, so it always reflects current state. Write failures
|
|
40
|
+
* are reported via `console.warn` and never thrown back into the fiber.
|
|
41
|
+
*/
|
|
42
|
+
readonly log?: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Per-hook coordinator state: the two ordered phases and the disposer for the installed coordinator(s). */
|
|
46
|
+
export interface ControlledHook<E extends Orderable> {
|
|
47
|
+
readonly front: E[]
|
|
48
|
+
readonly back: E[]
|
|
49
|
+
readonly dispose: () => void
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Common base for the hook-ordering services. Subclasses implement
|
|
54
|
+
* {@link install} (register the coordinator listener(s) for one hook and return
|
|
55
|
+
* their disposer) and expose a typed `register`; everything else is shared.
|
|
56
|
+
* @typeParam E - the participant entry type stored per phase.
|
|
57
|
+
*/
|
|
58
|
+
export abstract class HookOrderingBase<E extends Orderable> extends Service {
|
|
59
|
+
protected readonly hooks = new Map<string, ControlledHook<E>>()
|
|
60
|
+
/** File the DAG is logged to on every registration change, if configured. */
|
|
61
|
+
readonly log: string | undefined
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @param ctx - the Cordis context to register the service in.
|
|
65
|
+
* @param name - the service name exposed on `ctx`.
|
|
66
|
+
* @param config - optional `log` file for the constraint DAG. May be `null`:
|
|
67
|
+
* a loader row whose `config:` key holds only comments parses as YAML null.
|
|
68
|
+
*/
|
|
69
|
+
constructor(ctx: Context, name: string, config: HookOrderingLogConfig | null = {}) {
|
|
70
|
+
super(ctx, name)
|
|
71
|
+
this.log = config?.log
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Install the coordinator listener(s) for one hook, capturing its `front`/
|
|
76
|
+
* `back` lists, and return a disposer removing them. Subclass-specific.
|
|
77
|
+
*/
|
|
78
|
+
protected abstract install(hook: string, front: E[], back: E[]): () => void
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Take control of a hook by installing the coordinator listener(s). Called
|
|
82
|
+
* once per hook; participants then register into it. Controlling twice is
|
|
83
|
+
* rejected — a second coordinator would reintroduce the race this removes.
|
|
84
|
+
*
|
|
85
|
+
* @param hook - the event name to control.
|
|
86
|
+
* @returns a disposer that removes the coordinator(s) and forgets the hook.
|
|
87
|
+
* @throws {HookControlError} when the hook is already controlled.
|
|
88
|
+
*/
|
|
89
|
+
control(hook: string): () => void {
|
|
90
|
+
if (this.hooks.has(hook)) throw new HookControlError(`hook ${JSON.stringify(hook)} is already controlled`)
|
|
91
|
+
|
|
92
|
+
const front: E[] = []
|
|
93
|
+
const back: E[] = []
|
|
94
|
+
const removeListeners = this.install(hook, front, back)
|
|
95
|
+
|
|
96
|
+
const dispose = (): void => {
|
|
97
|
+
removeListeners()
|
|
98
|
+
this.hooks.delete(hook)
|
|
99
|
+
this.refreshLog()
|
|
100
|
+
}
|
|
101
|
+
this.hooks.set(hook, { front, back, dispose })
|
|
102
|
+
this.refreshLog()
|
|
103
|
+
return dispose
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Shared registration: push the entry into the hook's phase list as a fiber
|
|
108
|
+
* effect, refreshing the DAG log on add and remove.
|
|
109
|
+
*
|
|
110
|
+
* @returns a disposer that unregisters this participant.
|
|
111
|
+
* @throws {HookControlError} when the hook has not been controlled.
|
|
112
|
+
*/
|
|
113
|
+
protected registerEntry(hook: string, phase: Phase, entry: E): () => void {
|
|
114
|
+
const controlled = this.hooks.get(hook)
|
|
115
|
+
if (controlled === undefined)
|
|
116
|
+
throw new HookControlError(
|
|
117
|
+
`hook ${JSON.stringify(hook)} is not controlled; call control(${JSON.stringify(hook)}) first`,
|
|
118
|
+
)
|
|
119
|
+
const list = controlled[phase]
|
|
120
|
+
return this.ctx.effect(
|
|
121
|
+
() => {
|
|
122
|
+
list.push(entry)
|
|
123
|
+
this.refreshLog()
|
|
124
|
+
return () => {
|
|
125
|
+
const at = list.indexOf(entry)
|
|
126
|
+
if (at >= 0) list.splice(at, 1)
|
|
127
|
+
this.refreshLog()
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
`${this.name}.register(${JSON.stringify(hook)}, ${JSON.stringify(phase)}, ${JSON.stringify(entry.name)})`,
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Compute the ordered participant names for one hook phase without running
|
|
136
|
+
* them. Reflects current registrations; useful for tests and diagnostics.
|
|
137
|
+
*
|
|
138
|
+
* @throws {HookControlError} when the hook has not been controlled.
|
|
139
|
+
*/
|
|
140
|
+
plan(hook: string, phase: Phase): string[] {
|
|
141
|
+
const controlled = this.hooks.get(hook)
|
|
142
|
+
if (controlled === undefined) throw new HookControlError(`hook ${JSON.stringify(hook)} is not controlled`)
|
|
143
|
+
return topoSort(controlled[phase]).map((entry) => entry.name)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Serialize the constraint DAG of every controlled hook (all phases) as
|
|
148
|
+
* pretty-printed JSON. Pure read: does not write the log file or throw on
|
|
149
|
+
* cycles — the graph is most useful precisely when constraints conflict.
|
|
150
|
+
*/
|
|
151
|
+
dumpDag(): string {
|
|
152
|
+
return JSON.stringify(buildDag(this.dagSections()), null, 2)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Flatten every controlled hook into its `front`/`back` DAG sections. */
|
|
156
|
+
protected dagSections(): DagSection[] {
|
|
157
|
+
const sections: DagSection[] = []
|
|
158
|
+
for (const [hook, controlled] of this.hooks) {
|
|
159
|
+
sections.push({ hook, phase: 'front', entries: controlled.front })
|
|
160
|
+
sections.push({ hook, phase: 'back', entries: controlled.back })
|
|
161
|
+
}
|
|
162
|
+
return sections
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Best-effort write of the current DAG to the configured `log` file. Never
|
|
167
|
+
* throws: a failure (e.g. unwritable path) is reported via `console.warn`,
|
|
168
|
+
* because this runs inside registration effects and disposers, where an
|
|
169
|
+
* exception would corrupt fiber teardown.
|
|
170
|
+
*/
|
|
171
|
+
protected refreshLog(): void {
|
|
172
|
+
if (this.log === undefined) return
|
|
173
|
+
try {
|
|
174
|
+
writeFileSync(this.log, `${this.dumpDag()}\n`)
|
|
175
|
+
} catch (error) {
|
|
176
|
+
console.warn(`hooks-ordering: failed to write DAG log to ${JSON.stringify(this.log)}:`, error)
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
package/src/settings.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The dsh settings namespace for this plugin: the three fields a user can edit
|
|
3
|
+
* from the Settings page instead of a `cordis.patch.yml` row.
|
|
4
|
+
*
|
|
5
|
+
* Two constraints are load-bearing and easy to get wrong:
|
|
6
|
+
*
|
|
7
|
+
* - The schema handed to `settings.register` must be a **callable** schemastery
|
|
8
|
+
* schema. dsh resolves a namespace by calling `schema(merged)` and reads
|
|
9
|
+
* `schema.toJSON()` for the form, so a plain object throws
|
|
10
|
+
* `schema is not a function` — during plugin assembly, which takes the whole
|
|
11
|
+
* profile down rather than failing one form.
|
|
12
|
+
* - That is also why registration is wrapped in `try/catch` here: an optional
|
|
13
|
+
* settings form must never be able to stop the harness from booting.
|
|
14
|
+
*
|
|
15
|
+
* @module dsh-hooks-ordering/settings
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
19
|
+
import Schema from '@deepseek-ai/schemastery'
|
|
20
|
+
|
|
21
|
+
/** The three fields this plugin exposes for configuration. */
|
|
22
|
+
export interface HooksOrderingSettings {
|
|
23
|
+
/** Waterfall hooks to control; `[]` disables the waterfall service entirely. */
|
|
24
|
+
readonly hooks: readonly string[]
|
|
25
|
+
/** Serial hooks to control; `[]` disables the serial service entirely. */
|
|
26
|
+
readonly serialHooks: readonly string[]
|
|
27
|
+
/** Constraint-DAG log file; the empty string means "do not log". */
|
|
28
|
+
readonly log: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Namespace name — dsh requires a lowercase hyphenated identifier. */
|
|
32
|
+
export const SETTINGS_NS = 'hooks-ordering'
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Schema for {@link SETTINGS_NS}.
|
|
36
|
+
*
|
|
37
|
+
* Every field defaults to its "off" value. The *meaningful* defaults — the
|
|
38
|
+
* hooks this plugin controls when nothing overrides them — are supplied by the
|
|
39
|
+
* caller as the composition `base` layer (see {@link resolveSettings}), so a
|
|
40
|
+
* namespace the user has never touched still resolves to a complete object.
|
|
41
|
+
*/
|
|
42
|
+
export const HooksOrderingSettingsSchema = Schema.object({
|
|
43
|
+
hooks: Schema.array(Schema.string()).default([]),
|
|
44
|
+
serialHooks: Schema.array(Schema.string()).default([]),
|
|
45
|
+
log: Schema.string().default(''),
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The slice of dsh's `settings` service this plugin uses.
|
|
50
|
+
*
|
|
51
|
+
* Structural rather than imported from `@deepseek-ai/dsh-settings`: it describes
|
|
52
|
+
* exactly the contract this plugin relies on without adding a build-time
|
|
53
|
+
* dependency on a dsh package, whose internals are still 0.1.x-rc.
|
|
54
|
+
*/
|
|
55
|
+
export interface SettingsServiceLike {
|
|
56
|
+
/**
|
|
57
|
+
* @param ns - the namespace to register; must be a lowercase hyphenated identifier.
|
|
58
|
+
* @param schema - a callable schemastery schema, not a plain object.
|
|
59
|
+
* @param options - `base` is the composition layer, `applies` says whether an
|
|
60
|
+
* edit takes effect live or needs a restart.
|
|
61
|
+
* @returns a scope whose `get()` is the resolved value (schema defaults, then `base`, then the user layer).
|
|
62
|
+
*/
|
|
63
|
+
register(
|
|
64
|
+
ns: string,
|
|
65
|
+
schema: unknown,
|
|
66
|
+
options?: { readonly base?: unknown; readonly applies?: 'live' | 'restart' },
|
|
67
|
+
): { get(): HooksOrderingSettings }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Register the settings namespace and read its resolved value.
|
|
72
|
+
*
|
|
73
|
+
* `base` is the composition layer — what the row (or the built-in defaults)
|
|
74
|
+
* asks for — so the user layer edits *over* it and a reset returns to it rather
|
|
75
|
+
* than to an empty form.
|
|
76
|
+
*
|
|
77
|
+
* `applies: 'restart'` is deliberate. Changing `hooks`/`serialHooks` means
|
|
78
|
+
* installing or removing bracket listeners on live hooks, and the coordinator
|
|
79
|
+
* has no public "release one hook" operation; a restart applies the change
|
|
80
|
+
* cleanly instead of re-wiring the dispatch chain mid-flight.
|
|
81
|
+
*
|
|
82
|
+
* @param ctx - the Cordis context to look the service up on.
|
|
83
|
+
* @param base - the composition layer to register as the base value.
|
|
84
|
+
* @returns the resolved settings, or `undefined` when the provider is missing
|
|
85
|
+
* (which `inject: ['settings']` rules out in dsh — the guard is defensive) or
|
|
86
|
+
* registration failed — the caller then uses `base`.
|
|
87
|
+
*/
|
|
88
|
+
export function resolveSettings(ctx: Context, base: HooksOrderingSettings): HooksOrderingSettings | undefined {
|
|
89
|
+
const settings = ctx.get('settings') as SettingsServiceLike | undefined
|
|
90
|
+
if (settings === undefined) return undefined
|
|
91
|
+
try {
|
|
92
|
+
return settings.register(SETTINGS_NS, HooksOrderingSettingsSchema, { base, applies: 'restart' }).get()
|
|
93
|
+
} catch (error) {
|
|
94
|
+
console.warn('hooks-ordering: settings registration failed; falling back to the composition config:', error)
|
|
95
|
+
return undefined
|
|
96
|
+
}
|
|
97
|
+
}
|
package/src/topo-sort.ts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic topological sort for named entries carrying `before`/`after`
|
|
3
|
+
* ordering constraints. Zero runtime dependencies: the sort is a pure function
|
|
4
|
+
* of its input, independent of any Cordis context or plugin load order.
|
|
5
|
+
* @module dsh-hooks-ordering/topo-sort
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** An entry that can be ordered relative to others by name. */
|
|
9
|
+
export interface Orderable {
|
|
10
|
+
/** Unique identifier within one hook phase. Referenced by other entries' `before`/`after`. */
|
|
11
|
+
readonly name: string
|
|
12
|
+
/** Names this entry must run before. An unknown name imposes no constraint (see {@link topoSort}). */
|
|
13
|
+
readonly before?: readonly string[]
|
|
14
|
+
/** Names this entry must run after. An unknown name imposes no constraint (see {@link topoSort}). */
|
|
15
|
+
readonly after?: readonly string[]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Thrown when `before`/`after` constraints form a cycle, so no total order exists. */
|
|
19
|
+
export class OrderingCycleError extends Error {
|
|
20
|
+
/** The names still blocked when the sort stalled — the entries on and behind the cycle. */
|
|
21
|
+
readonly cycle: readonly string[]
|
|
22
|
+
/**
|
|
23
|
+
* @param cycle - the names left unresolved by the cycle.
|
|
24
|
+
*/
|
|
25
|
+
constructor(cycle: readonly string[]) {
|
|
26
|
+
super(`hooks-ordering: constraints form a cycle among: ${cycle.join(', ')}`)
|
|
27
|
+
this.name = 'OrderingCycleError'
|
|
28
|
+
this.cycle = cycle
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Thrown when two entries in one phase share a `name`, which would make references ambiguous. */
|
|
33
|
+
export class DuplicateNameError extends Error {
|
|
34
|
+
/** The duplicated entry name. */
|
|
35
|
+
readonly duplicate: string
|
|
36
|
+
/**
|
|
37
|
+
* @param duplicate - the name registered more than once.
|
|
38
|
+
*/
|
|
39
|
+
constructor(duplicate: string) {
|
|
40
|
+
super(`hooks-ordering: duplicate entry name ${JSON.stringify(duplicate)}`)
|
|
41
|
+
this.name = 'DuplicateNameError'
|
|
42
|
+
this.duplicate = duplicate
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Order entries so every `after` target precedes the entry and every `before`
|
|
48
|
+
* target follows it. Ties (entries with no constraint between them) keep their
|
|
49
|
+
* input order, so the result is stable and independent of registration timing.
|
|
50
|
+
*
|
|
51
|
+
* An unknown name in `before`/`after` (no registered entry owns it) imposes no
|
|
52
|
+
* constraint rather than failing: cross-vendor entries reference optional peers
|
|
53
|
+
* that may not be loaded, so a missing target is a legitimate no-op, not a
|
|
54
|
+
* misconfiguration. A cycle among present entries is fatal — it has no valid
|
|
55
|
+
* order — and throws {@link OrderingCycleError}.
|
|
56
|
+
*
|
|
57
|
+
* @param entries - the entries to order; each `name` must be unique.
|
|
58
|
+
* @returns a new array of the same entries in a constraint-respecting order.
|
|
59
|
+
* @throws {DuplicateNameError} when two entries share a `name`.
|
|
60
|
+
* @throws {OrderingCycleError} when present entries form an ordering cycle.
|
|
61
|
+
*/
|
|
62
|
+
export function topoSort<T extends Orderable>(entries: readonly T[]): T[] {
|
|
63
|
+
const index = new Map<string, number>()
|
|
64
|
+
entries.forEach((entry, position) => {
|
|
65
|
+
if (index.has(entry.name)) throw new DuplicateNameError(entry.name)
|
|
66
|
+
index.set(entry.name, position)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
// successors[i] = entries that must run after entry i. indegree[i] = number
|
|
70
|
+
// of entries that must run before entry i.
|
|
71
|
+
const successors: number[][] = entries.map(() => [])
|
|
72
|
+
const indegree: number[] = entries.map(() => 0)
|
|
73
|
+
|
|
74
|
+
const addEdge = (fromName: string, toName: string): void => {
|
|
75
|
+
const from = index.get(fromName)
|
|
76
|
+
const to = index.get(toName)
|
|
77
|
+
// Unknown endpoint: the referenced peer is not loaded, so no ordering
|
|
78
|
+
// relation exists to enforce.
|
|
79
|
+
if (from === undefined || to === undefined || from === to) return
|
|
80
|
+
successors[from]!.push(to)
|
|
81
|
+
indegree[to]!++
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
entries.forEach((entry) => {
|
|
85
|
+
for (const target of entry.after ?? []) addEdge(target, entry.name)
|
|
86
|
+
for (const target of entry.before ?? []) addEdge(entry.name, target)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
// Kahn's algorithm. The ready set holds nodes with no unmet predecessor; it
|
|
90
|
+
// is kept in ascending input position so equal candidates emit in input
|
|
91
|
+
// order, giving a deterministic, stable result. Per-phase entry counts are
|
|
92
|
+
// small, so a sort on each insertion is simpler than a hand-rolled heap.
|
|
93
|
+
const ready: number[] = []
|
|
94
|
+
const pushReady = (node: number): void => {
|
|
95
|
+
ready.push(node)
|
|
96
|
+
ready.sort((left, right) => left - right)
|
|
97
|
+
}
|
|
98
|
+
indegree.forEach((count, node) => {
|
|
99
|
+
if (count === 0) pushReady(node)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
const result: T[] = []
|
|
103
|
+
while (ready.length > 0) {
|
|
104
|
+
const node = ready.shift()!
|
|
105
|
+
result.push(entries[node]!)
|
|
106
|
+
for (const successor of successors[node]!) {
|
|
107
|
+
if (--indegree[successor]! === 0) pushReady(successor)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (result.length !== entries.length) {
|
|
112
|
+
// The entries still carrying an unmet predecessor are exactly those on or
|
|
113
|
+
// behind the cycle; naming them all is more useful than one arbitrary loop.
|
|
114
|
+
const blocked = entries.filter((_, node) => indegree[node]! > 0).map((entry) => entry.name)
|
|
115
|
+
throw new OrderingCycleError(blocked)
|
|
116
|
+
}
|
|
117
|
+
return result
|
|
118
|
+
}
|
package/src/waterfall.ts
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `HookOrdering` — deterministic before/after ordering for Cordis waterfall
|
|
3
|
+
* hooks whose participants are contributed by independent plugins.
|
|
4
|
+
*
|
|
5
|
+
* Cordis runs waterfall listeners in registration order (array position, with
|
|
6
|
+
* `prepend` as the only lever), and registration order is driven by
|
|
7
|
+
* inject-dependency activation — non-deterministic between unrelated plugins.
|
|
8
|
+
* A plugin therefore cannot reliably say "run me after that other plugin",
|
|
9
|
+
* especially across vendors that do not depend on each other.
|
|
10
|
+
*
|
|
11
|
+
* This service brackets a chosen waterfall hook with ONE prepended listener
|
|
12
|
+
* that exploits the onion model: code before its `next()` runs ahead of the
|
|
13
|
+
* whole native chain, code after runs behind it. Participants register into
|
|
14
|
+
* this coordinator instead of the raw hook, declaring `before`/`after` names,
|
|
15
|
+
* and the coordinator runs them in a stable topological order it fully
|
|
16
|
+
* controls. The serial-dispatch twin lives in `./serial.ts`.
|
|
17
|
+
*
|
|
18
|
+
* @module dsh-hooks-ordering/waterfall
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { type Context } from '@deepseek-ai/cordis'
|
|
22
|
+
import { HookControlError, HookOrderingBase, type HookOrderingLogConfig, type Phase } from './service-base.ts'
|
|
23
|
+
import { type Orderable, topoSort } from './topo-sort.ts'
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* One ordered participant in a controlled waterfall hook phase.
|
|
27
|
+
* @typeParam A - the hook's payload argument tuple (the dispatched args without Cordis' trailing `next`).
|
|
28
|
+
*/
|
|
29
|
+
export interface HookEntry<A extends readonly unknown[] = readonly unknown[]> extends Orderable {
|
|
30
|
+
/** Run this participant with the hook payload. Awaited before the phase proceeds. */
|
|
31
|
+
readonly run: (...args: A) => void | Promise<void>
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Configuration for the {@link HookOrdering} service. */
|
|
35
|
+
export interface HookOrderingConfig extends HookOrderingLogConfig {
|
|
36
|
+
/**
|
|
37
|
+
* Hook names whose dispatch return value the CALLER consumes without
|
|
38
|
+
* awaiting — dsh's `llm/stream` (an AsyncIterable iterated directly) and
|
|
39
|
+
* `session-telemetry/record` (a record handed straight to a backend) are the
|
|
40
|
+
* two in the shipped build.
|
|
41
|
+
*
|
|
42
|
+
* A participant cannot be ordered on such a hook. The bracket only preserves
|
|
43
|
+
* the native return value while both phases are empty; with a participant it
|
|
44
|
+
* must await, so it returns a Promise where the caller expects a value — a
|
|
45
|
+
* broken stream or a corrupted record, silently. {@link HookOrdering.register}
|
|
46
|
+
* therefore refuses those hooks, and *controlling* them stays allowed: the
|
|
47
|
+
* bracket is a transparent pass-through and may become useful the day the
|
|
48
|
+
* host starts awaiting that hook (then pass `[]` here, or a set without it).
|
|
49
|
+
*/
|
|
50
|
+
readonly syncReturnHooks?: readonly string[]
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
declare module '@deepseek-ai/cordis' {
|
|
54
|
+
interface Context {
|
|
55
|
+
hooksOrdering: HookOrdering
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Coordinator service registered at `ctx.hooksOrdering`. One instance controls
|
|
61
|
+
* any number of waterfall hooks; each controlled hook owns one bracket listener
|
|
62
|
+
* and two ordered participant lists.
|
|
63
|
+
*/
|
|
64
|
+
export class HookOrdering extends HookOrderingBase<HookEntry> {
|
|
65
|
+
/**
|
|
66
|
+
* Hooks that must never carry a participant: their return value is consumed
|
|
67
|
+
* synchronously by the host, so the bracket's await would corrupt it.
|
|
68
|
+
* @see HookOrderingConfig.syncReturnHooks
|
|
69
|
+
*/
|
|
70
|
+
private readonly syncReturnHooks: ReadonlySet<string>
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* @param ctx - the Cordis context to register the service in.
|
|
74
|
+
* @param config - optional `log` file for the constraint DAG (JSON) and the
|
|
75
|
+
* `syncReturnHooks` admission list; `null` means defaults.
|
|
76
|
+
*/
|
|
77
|
+
constructor(ctx: Context, config: HookOrderingConfig | null = {}) {
|
|
78
|
+
super(ctx, 'hooksOrdering', config)
|
|
79
|
+
this.syncReturnHooks = new Set(config?.syncReturnHooks ?? [])
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Install the single bracket listener. `prepend` places it first among
|
|
84
|
+
* current listeners so its `next()` encloses the rest of the native chain;
|
|
85
|
+
* the listener is an effect on this service's fiber and is removed with it or
|
|
86
|
+
* with the disposer {@link control} returns.
|
|
87
|
+
*
|
|
88
|
+
* The bracket is deliberately NOT `async` at its outermost level. Cordis'
|
|
89
|
+
* `waterfall` returns the outermost listener's value *synchronously*, so an
|
|
90
|
+
* `async` bracket would turn the hook's return value into a Promise for every
|
|
91
|
+
* caller — breaking any hook whose result the caller consumes without
|
|
92
|
+
* awaiting (dsh's `llm/stream` returns an AsyncIterable, and wraps with
|
|
93
|
+
* "not async iterable"). While both phases are empty there is nothing to
|
|
94
|
+
* order, so the chain is handed straight through and the hook's return type
|
|
95
|
+
* is exactly what it was before control was taken.
|
|
96
|
+
*/
|
|
97
|
+
protected install(hook: string, front: HookEntry[], back: HookEntry[]): () => void {
|
|
98
|
+
const bracket = (...args: unknown[]): unknown => {
|
|
99
|
+
const next = args[args.length - 1] as () => unknown
|
|
100
|
+
// Transparent fast path: no participants, so no await is needed and the
|
|
101
|
+
// native return value (promise or not) passes through unchanged.
|
|
102
|
+
if (front.length === 0 && back.length === 0) return next()
|
|
103
|
+
const payload = args.slice(0, -1)
|
|
104
|
+
// With participants the phases must be awaited, so the bracket can only
|
|
105
|
+
// return a promise from here on — inherent to ordering, not a choice.
|
|
106
|
+
return (async (): Promise<unknown> => {
|
|
107
|
+
await runPhase(front, payload)
|
|
108
|
+
const result = await next()
|
|
109
|
+
await runPhase(back, payload)
|
|
110
|
+
return result
|
|
111
|
+
})()
|
|
112
|
+
}
|
|
113
|
+
return this.ctx.on(hook as never, bracket as never, { prepend: true })
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Register a participant into a controlled hook phase.
|
|
118
|
+
*
|
|
119
|
+
* @param hook - the controlled waterfall event name.
|
|
120
|
+
* @param phase - `front` to run ahead of the native chain, `back` to run behind it.
|
|
121
|
+
* @param entry - the participant, with optional `before`/`after` names and its `run` callback.
|
|
122
|
+
* @returns a disposer that unregisters this participant.
|
|
123
|
+
* @throws {HookControlError} when the hook has not been {@link control}led, or
|
|
124
|
+
* when it is declared in `syncReturnHooks` — there the participant would work
|
|
125
|
+
* but the host's return value would change type, which is the worse failure.
|
|
126
|
+
*/
|
|
127
|
+
register<A extends readonly unknown[] = readonly unknown[]>(
|
|
128
|
+
hook: string,
|
|
129
|
+
phase: Phase,
|
|
130
|
+
entry: HookEntry<A>,
|
|
131
|
+
): () => void {
|
|
132
|
+
if (this.syncReturnHooks.has(hook)) {
|
|
133
|
+
throw new HookControlError(
|
|
134
|
+
`hook ${JSON.stringify(hook)} returns to a caller that does not await it, so ordering participants would turn its return value into a Promise and break that caller; ` +
|
|
135
|
+
`remove ${JSON.stringify(hook)} from syncReturnHooks only once the host awaits it`,
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
return this.registerEntry(hook, phase, entry as unknown as HookEntry)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Run one phase's participants in stable topological order, awaiting each.
|
|
144
|
+
* @param entries - the phase's registered participants.
|
|
145
|
+
* @param payload - the hook payload passed to each `run` callback.
|
|
146
|
+
*/
|
|
147
|
+
async function runPhase(entries: readonly HookEntry[], payload: readonly unknown[]): Promise<void> {
|
|
148
|
+
for (const entry of topoSort(entries)) {
|
|
149
|
+
await entry.run(...payload)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export default HookOrdering
|