@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.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +257 -0
  3. package/client.js +619 -0
  4. package/cordis.patch.yml +38 -0
  5. package/lib/dag-Bqx-sl71.d.ts +55 -0
  6. package/lib/dag-Bqx-sl71.d.ts.map +1 -0
  7. package/lib/dag-DVhoBjBG.js +48 -0
  8. package/lib/dag-DVhoBjBG.js.map +1 -0
  9. package/lib/dag.d.ts +3 -0
  10. package/lib/dag.js +3 -0
  11. package/lib/index.d.ts +121 -0
  12. package/lib/index.d.ts.map +1 -0
  13. package/lib/index.js +190 -0
  14. package/lib/index.js.map +1 -0
  15. package/lib/serial-Cu7usHjI.js +65 -0
  16. package/lib/serial-Cu7usHjI.js.map +1 -0
  17. package/lib/serial-D8ZJCBKL.d.ts +55 -0
  18. package/lib/serial-D8ZJCBKL.d.ts.map +1 -0
  19. package/lib/serial.d.ts +5 -0
  20. package/lib/serial.js +6 -0
  21. package/lib/service-base-CCmIBwnB.d.ts +100 -0
  22. package/lib/service-base-CCmIBwnB.d.ts.map +1 -0
  23. package/lib/service-base-a5vKg62S.js +139 -0
  24. package/lib/service-base-a5vKg62S.js.map +1 -0
  25. package/lib/service-base.d.ts +4 -0
  26. package/lib/service-base.js +5 -0
  27. package/lib/topo-sort-BZ1fFcTs.d.ts +54 -0
  28. package/lib/topo-sort-BZ1fFcTs.d.ts.map +1 -0
  29. package/lib/topo-sort-CfwYPY4U.js +83 -0
  30. package/lib/topo-sort-CfwYPY4U.js.map +1 -0
  31. package/lib/topo-sort.d.ts +2 -0
  32. package/lib/topo-sort.js +3 -0
  33. package/lib/waterfall-Bu6m9gYc.js +83 -0
  34. package/lib/waterfall-Bu6m9gYc.js.map +1 -0
  35. package/lib/waterfall-_5HkptkS.d.ts +87 -0
  36. package/lib/waterfall-_5HkptkS.d.ts.map +1 -0
  37. package/lib/waterfall.d.ts +5 -0
  38. package/lib/waterfall.js +6 -0
  39. package/package.json +117 -0
  40. package/src/dag.ts +92 -0
  41. package/src/dsh.ts +181 -0
  42. package/src/index.ts +54 -0
  43. package/src/serial.ts +108 -0
  44. package/src/service-base.ts +179 -0
  45. package/src/settings.ts +97 -0
  46. package/src/topo-sort.ts +118 -0
  47. package/src/waterfall.ts +153 -0
package/package.json ADDED
@@ -0,0 +1,117 @@
1
+ {
2
+ "name": "@zfdx123/dsh-hooks-ordering",
3
+ "version": "1.0.1",
4
+ "description": "钩子排序:为 Cordis 的 waterfall / serial 钩子提供确定性的 before/after 排序,让互相独立的插件能声明彼此的先后关系(拓扑排序、环检测、可配置控制集与设置页)。",
5
+ "keywords": [
6
+ "cordis",
7
+ "plugin",
8
+ "hooks",
9
+ "ordering",
10
+ "waterfall",
11
+ "serial",
12
+ "deepseek-harness",
13
+ "dsh",
14
+ "topological-sort"
15
+ ],
16
+ "license": "MIT",
17
+ "homepage": "https://github.com/zfdx123/dsh-atelier/tree/main/packages/dsh-hooks-ordering#readme",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/zfdx123/dsh-atelier.git",
21
+ "directory": "packages/dsh-hooks-ordering"
22
+ },
23
+ "bugs": {
24
+ "url": "https://github.com/zfdx123/dsh-atelier/issues"
25
+ },
26
+ "type": "module",
27
+ "main": "lib/index.js",
28
+ "types": "lib/index.d.ts",
29
+ "exports": {
30
+ ".": {
31
+ "types": "./lib/index.d.ts",
32
+ "default": "./lib/index.js"
33
+ },
34
+ "./topo-sort": {
35
+ "types": "./lib/topo-sort.d.ts",
36
+ "default": "./lib/topo-sort.js"
37
+ },
38
+ "./waterfall": {
39
+ "types": "./lib/waterfall.d.ts",
40
+ "default": "./lib/waterfall.js"
41
+ },
42
+ "./dag": {
43
+ "types": "./lib/dag.d.ts",
44
+ "default": "./lib/dag.js"
45
+ },
46
+ "./serial": {
47
+ "types": "./lib/serial.d.ts",
48
+ "default": "./lib/serial.js"
49
+ },
50
+ "./client": "./client.js",
51
+ "./cordis.patch.yml": "./cordis.patch.yml",
52
+ "./package.json": "./package.json"
53
+ },
54
+ "files": [
55
+ "lib",
56
+ "src",
57
+ "client.js",
58
+ "cordis.patch.yml",
59
+ "README.md",
60
+ "LICENSE"
61
+ ],
62
+ "engines": {
63
+ "node": "^22.19.0 || >=24.0.0",
64
+ "dsh": "^0.1.6-alpha.1"
65
+ },
66
+ "peerDependencies": {
67
+ "@deepseek-ai/cordis": "^4.0.2",
68
+ "@deepseek-ai/dsh-settings": "^0.1.6-alpha.1"
69
+ },
70
+ "peerDependenciesMeta": {
71
+ "@deepseek-ai/dsh-settings": {
72
+ "optional": true
73
+ }
74
+ },
75
+ "dependencies": {
76
+ "@deepseek-ai/schemastery": "^3.18.1"
77
+ },
78
+ "devDependencies": {
79
+ "@deepseek-ai/cordis": "^4.0.2",
80
+ "@types/node": "^22.20.1",
81
+ "@vitest/coverage-v8": "^3.2.4",
82
+ "oxlint": "^1.0.0",
83
+ "tsdown": "^0.15.6",
84
+ "typescript": "^5.9.2",
85
+ "vitest": "^3.2.4"
86
+ },
87
+ "scripts": {
88
+ "build": "tsdown",
89
+ "typecheck": "tsc --noEmit",
90
+ "lint": "oxlint src test client.js",
91
+ "test": "vitest run",
92
+ "test:watch": "vitest",
93
+ "test:coverage": "vitest run --coverage",
94
+ "prepare": "tsdown",
95
+ "prepublishOnly": "tsdown"
96
+ },
97
+ "publishConfig": {
98
+ "access": "public"
99
+ },
100
+ "dsh": {
101
+ "bundle": {
102
+ "patch": "./cordis.patch.yml"
103
+ },
104
+ "client": {
105
+ "platform": "web",
106
+ "inject": [
107
+ "@deepseek-ai/dsh-client-ui-settings"
108
+ ]
109
+ }
110
+ },
111
+ "packageManager": "pnpm@10.10.0",
112
+ "pnpm": {
113
+ "onlyBuiltDependencies": [
114
+ "esbuild"
115
+ ]
116
+ }
117
+ }
package/src/dag.ts ADDED
@@ -0,0 +1,92 @@
1
+ /**
2
+ * JSON rendering of the `before`/`after` constraint graph across controlled
3
+ * hook phases. Pure and dependency-free: the output is a function of the input
4
+ * sections alone. Deliberately NOT a topological sort — a cycle is represented
5
+ * faithfully rather than throwing, because the graph is most useful precisely
6
+ * when the constraints conflict and `topoSort` would fail.
7
+ * @module dsh-hooks-ordering/dag
8
+ */
9
+
10
+ import type { Orderable } from './topo-sort.ts'
11
+
12
+ /** One `(hook, phase)` slice of the constraint graph to render. */
13
+ export interface DagSection {
14
+ /** The controlled hook event name. */
15
+ readonly hook: string
16
+ /** The phase within the hook (e.g. `front`/`back`). */
17
+ readonly phase: string
18
+ /** The participants and their `before`/`after` constraints. */
19
+ readonly entries: readonly Orderable[]
20
+ }
21
+
22
+ /** A directed ordering relation: `from` runs before `to`. */
23
+ export interface DagEdge {
24
+ /** The participant that runs first. */
25
+ readonly from: string
26
+ /** The participant that runs after `from`. */
27
+ readonly to: string
28
+ }
29
+
30
+ /** The constraint graph of one `(hook, phase)` slice. */
31
+ export interface DagSectionGraph {
32
+ /** The controlled hook event name. */
33
+ readonly hook: string
34
+ /** The phase within the hook. */
35
+ readonly phase: string
36
+ /** Every participant name, including ones with no constraint edges. */
37
+ readonly nodes: readonly string[]
38
+ /** The ordering relations; `from` runs before `to`. Deduplicated. */
39
+ readonly edges: readonly DagEdge[]
40
+ }
41
+
42
+ /** The whole constraint graph: one entry per controlled `(hook, phase)`. */
43
+ export interface Dag {
44
+ readonly sections: readonly DagSectionGraph[]
45
+ }
46
+
47
+ /**
48
+ * Build the constraint graph of the given sections as a plain object suitable
49
+ * for `JSON.stringify`.
50
+ *
51
+ * Each section becomes a {@link DagSectionGraph}: `nodes` lists every entry
52
+ * name (constraint-free entries included), and `edges` carries the ordering
53
+ * relations. An edge `from -> to` reads "from runs before to": an entry's
54
+ * `before` target yields `{ from: entry, to: target }`, and its `after` source
55
+ * yields `{ from: source, to: entry }`. A reference whose peer is absent from
56
+ * the section imposes no edge — mirroring {@link topoSort}'s unknown-target
57
+ * no-op — and a relation stated from both ends (e.g. `A.before: ['B']` and
58
+ * `B.after: ['A']`) appears once.
59
+ *
60
+ * @param sections - the `(hook, phase)` slices to render, in output order.
61
+ * @returns the graph object; an empty input yields `{ sections: [] }`.
62
+ */
63
+ export function buildDag(sections: readonly DagSection[]): Dag {
64
+ return {
65
+ sections: sections.map((section) => {
66
+ const present = new Set(section.entries.map((entry) => entry.name))
67
+ // Collect edges keyed by "from->to" to drop the same relation stated from
68
+ // both ends, preserving first-seen order.
69
+ const seen = new Set<string>()
70
+ const edges: DagEdge[] = []
71
+ const addEdge = (from: string, to: string): void => {
72
+ // Unknown endpoint: the referenced peer is not in this section, so
73
+ // there is no relation to record; a self-reference is likewise a no-op.
74
+ if (!present.has(from) || !present.has(to) || from === to) return
75
+ const key = `${from} -> ${to}`
76
+ if (seen.has(key)) return
77
+ seen.add(key)
78
+ edges.push({ from, to })
79
+ }
80
+ for (const entry of section.entries) {
81
+ for (const target of entry.before ?? []) addEdge(entry.name, target)
82
+ for (const source of entry.after ?? []) addEdge(source, entry.name)
83
+ }
84
+ return {
85
+ hook: section.hook,
86
+ phase: section.phase,
87
+ nodes: section.entries.map((entry) => entry.name),
88
+ edges,
89
+ }
90
+ }),
91
+ }
92
+ }
package/src/dsh.ts ADDED
@@ -0,0 +1,181 @@
1
+ /**
2
+ * The DeepSeek-Harness layer: a dsh plugin that mounts the ordering services
3
+ * and takes control of the real dsh hooks that multiple independent packages
4
+ * contribute to, so a profile can opt into deterministic ordering with one row.
5
+ *
6
+ * dsh (deepseek-harness) ships waterfall hooks such as `agent/pre-step`
7
+ * (subscribed by a dozen+ independent packages), `tools/post-execute`,
8
+ * `llm/stream`, and `system-prompt/assemble`, plus the serial hook
9
+ * `agent/turn-stopping`. Their relative listener order is load-bearing yet
10
+ * today decided only by binary `prepend` and registration timing. This plugin
11
+ * controls those hooks up front; controlling an empty hook is a transparent
12
+ * pass-through, so nothing changes until participants register with
13
+ * `before`/`after`.
14
+ *
15
+ * @module dsh-hooks-ordering/dsh
16
+ */
17
+
18
+ import type { Context } from '@deepseek-ai/cordis'
19
+ import HookOrdering from './waterfall.ts'
20
+ import { SerialHookOrdering } from './serial.ts'
21
+ import { type HooksOrderingSettings, resolveSettings } from './settings.ts'
22
+
23
+ /** Cordis plugin name used by loader diagnostics. */
24
+ export const name = 'hooks-ordering'
25
+
26
+ /**
27
+ * `settings` is a **hard** dependency, and it has to be declared rather than
28
+ * probed.
29
+ *
30
+ * cordis activates a plugin as soon as the services it declares exist, and
31
+ * `ctx.get('settings')` reads the service store *without* creating that
32
+ * requirement. Without this line the dsh layer loads in the first wave — before
33
+ * the host has provided `settings` — the probe returns `undefined`, and the
34
+ * settings namespace is never registered. Silently: no form, no error.
35
+ *
36
+ * Declaring it makes cordis wait, which also lands this plugin's prepended
37
+ * brackets later in the boot, exactly where the ordering guarantee wants them.
38
+ *
39
+ * This entry targets dsh only — the hook names below are dsh's, and dsh always
40
+ * provides `settings`, so the dependency costs nothing here. `/waterfall` and
41
+ * `/serial` are the entries that carry no host-service requirement.
42
+ */
43
+ export const inject = ['settings']
44
+
45
+ /**
46
+ * The dsh waterfall hooks this plugin controls by default — the ones multiple
47
+ * independent packages contribute to, where relative order matters. Controlling
48
+ * a hook with no registered participants is a no-op pass-through.
49
+ *
50
+ * Every name here was checked against the events dsh actually declares: each is
51
+ * a `@mode waterfall` event in the installed build, and each is dispatched with
52
+ * `await`, so the caller already handles a Promise and the bracket's async
53
+ * phases cannot change the hook's return type. `tools/code-dispatch-log` used
54
+ * to sit in this list and no dsh package declares it at all — controlling a
55
+ * name nothing dispatches "succeeds" and then does nothing forever, which is
56
+ * exactly the dead configuration this list must not carry.
57
+ *
58
+ * The other admission rule is {@link DEFAULT_SYNC_RETURN_HOOKS}: a hook whose
59
+ * value the caller consumes without awaiting cannot carry ordered participants,
60
+ * so controlling it by default would install a bracket nothing may ever
61
+ * register into — the same dead configuration by a different route.
62
+ */
63
+ export const DEFAULT_WATERFALL_HOOKS: readonly string[] = [
64
+ 'agent/pre-step',
65
+ 'agent/request',
66
+ 'agent/request-error',
67
+ 'system-prompt/assemble',
68
+ 'tools/pre-execute',
69
+ 'tools/execute',
70
+ 'tools/post-execute',
71
+ 'fs/write-intent',
72
+ 'fs/edit-intent',
73
+ 'approval/request',
74
+ ]
75
+
76
+ /**
77
+ * dsh hooks whose dispatch return value is consumed by the caller without
78
+ * awaiting, and which therefore cannot carry ordered participants. Verified
79
+ * against the installed build:
80
+ *
81
+ * - `llm/stream` — `dsh-llm` dispatches it as
82
+ * `return this.ctx.waterfall(this, "llm/stream", options, …)` (no await), and
83
+ * `dsh-session-title-llm` iterates the result with
84
+ * `for await (const chunk of ctx.llm.stream(options))`. A Promise there throws
85
+ * "not async iterable". It is a genuine multi-contributor hook (dsh-agent-loop,
86
+ * dsh-llm's own invariant, dsh-session-checkpoint-policy, dsh-session-title),
87
+ * so it is a real loss — but ordering it requires dsh to await the dispatch,
88
+ * not a plugin-side workaround.
89
+ * - `session-telemetry/record` — `dsh-session-telemetry` returns the record
90
+ * straight out of the waterfall and hands it to the backend, so a Promise
91
+ * would be emitted as a record: silent corruption, no error anywhere.
92
+ * - `compaction/summary-error` — `dsh-compaction-basic` dispatches it as
93
+ * `recover: (…) => this.ctx.waterfall(this, "compaction/summary-error", …, () => false)`
94
+ * (no await) and consumes the boolean synchronously in
95
+ * `if (!dependencies.recover(error, agent, prepared.shadowedSeqs, signal)) throw error`.
96
+ * A Promise is always truthy, so `!recover(…)` is always false and every
97
+ * summarizer failure is swallowed instead of rethrown — the compaction then
98
+ * proceeds as if recovery had succeeded.
99
+ *
100
+ * Override per profile with `syncReturnHooks` once the host awaits one of them.
101
+ */
102
+ export const DEFAULT_SYNC_RETURN_HOOKS: readonly string[] = [
103
+ 'llm/stream',
104
+ 'session-telemetry/record',
105
+ 'compaction/summary-error',
106
+ ]
107
+
108
+ /** The dsh serial hook controlled by default. */
109
+ export const DEFAULT_SERIAL_HOOKS: readonly string[] = ['agent/turn-stopping']
110
+
111
+ /** Plugin config. */
112
+ export interface Config {
113
+ /**
114
+ * Waterfall hooks to control. Defaults to {@link DEFAULT_WATERFALL_HOOKS}.
115
+ * Pass `[]` to disable the waterfall service entirely.
116
+ */
117
+ hooks?: readonly string[]
118
+ /**
119
+ * Serial hooks to control. Defaults to {@link DEFAULT_SERIAL_HOOKS}.
120
+ * Pass `[]` to disable the serial service entirely.
121
+ */
122
+ serialHooks?: readonly string[]
123
+ /**
124
+ * Hooks whose return value the host consumes without awaiting, and which must
125
+ * therefore refuse participants. Defaults to {@link DEFAULT_SYNC_RETURN_HOOKS}.
126
+ * Pass `[]` — or a set without a given hook — to opt in to ordering it, once
127
+ * the host awaits that dispatch.
128
+ */
129
+ syncReturnHooks?: readonly string[]
130
+ /** When set, the constraint DAG (JSON) is logged to this file on every change. */
131
+ log?: string
132
+ }
133
+
134
+ /**
135
+ * Mount {@link HookOrdering} and/or {@link SerialHookOrdering} and control the
136
+ * configured hooks once the services are active.
137
+ *
138
+ * `config` may be `null`, and that is the ordinary case rather than an edge
139
+ * case: a loader row whose `config:` key is followed only by comments parses as
140
+ * YAML null, and a default parameter covers `undefined` but not `null`. This
141
+ * package's own `cordis.patch.yml` is written exactly that way — every option
142
+ * commented out — so a null config must mean "all defaults", not a crash.
143
+ *
144
+ * The row is the *composition* layer of the settings namespace: the Settings
145
+ * page edits a user layer over it, and a namespace the user has not touched
146
+ * resolves back to this row (or to the built-in defaults). Without a settings
147
+ * provider — plain Cordis — the row is the whole answer.
148
+ *
149
+ * @param ctx - the Cordis context.
150
+ * @param config - which hooks to control and an optional DAG `log` file; null means all defaults.
151
+ */
152
+ export function apply(ctx: Context, config: Config | null = {}): void {
153
+ const row = config ?? {}
154
+ const base: HooksOrderingSettings = {
155
+ hooks: row.hooks ?? DEFAULT_WATERFALL_HOOKS,
156
+ serialHooks: row.serialHooks ?? DEFAULT_SERIAL_HOOKS,
157
+ log: row.log ?? '',
158
+ }
159
+ const { hooks, serialHooks, log } = resolveSettings(ctx, base) ?? base
160
+ const serviceConfig = log === '' ? {} : { log }
161
+ // Not part of the settings namespace: the sync-return list describes the
162
+ // HOST's dispatch, not a user preference, so it is composition (row) only.
163
+ const syncReturnHooks = row.syncReturnHooks ?? DEFAULT_SYNC_RETURN_HOOKS
164
+
165
+ const deps: string[] = []
166
+ if (hooks.length > 0) {
167
+ ctx.plugin(HookOrdering, { ...serviceConfig, syncReturnHooks })
168
+ deps.push('hooksOrdering')
169
+ }
170
+ if (serialHooks.length > 0) {
171
+ ctx.plugin(SerialHookOrdering, serviceConfig)
172
+ deps.push('serialHooksOrdering')
173
+ }
174
+ if (deps.length === 0) return
175
+
176
+ // The services activate asynchronously; control the hooks once they exist.
177
+ ctx.inject(deps, (ready) => {
178
+ for (const hook of hooks) ready.hooksOrdering.control(hook)
179
+ for (const hook of serialHooks) ready.serialHooksOrdering.control(hook)
180
+ })
181
+ }
package/src/index.ts ADDED
@@ -0,0 +1,54 @@
1
+ /**
2
+ * `dsh-hooks-ordering` — deterministic before/after ordering for Cordis
3
+ * hooks whose participants are contributed by independent, mutually-unaware
4
+ * plugins.
5
+ *
6
+ * This is the public barrel: it re-exports the whole surface and nothing else.
7
+ * The implementations live in focused modules:
8
+ *
9
+ * - `./topo-sort` — the pure, zero-dependency stable topological sort.
10
+ * - `./dag` — JSON rendering of the constraint graph.
11
+ * - `./service-base` — coordinator machinery shared by both services.
12
+ * - `./waterfall` — {@link HookOrdering}, for `waterfall` hooks (the default export).
13
+ * - `./serial` — {@link SerialHookOrdering}, for `serial` hooks.
14
+ * - `./dsh` — the DeepSeek-Harness layer (separate entry point).
15
+ *
16
+ * @module dsh-hooks-ordering
17
+ */
18
+
19
+ // Pure ordering algebra.
20
+ export { DuplicateNameError, type Orderable, OrderingCycleError, topoSort } from './topo-sort.ts'
21
+
22
+ // Constraint-graph rendering.
23
+ export { type Dag, type DagEdge, type DagSection, type DagSectionGraph, buildDag } from './dag.ts'
24
+
25
+ // Shared service machinery.
26
+ export { HookControlError, type HookOrderingLogConfig, type Phase } from './service-base.ts'
27
+
28
+ // Waterfall service.
29
+ export { type HookEntry, type HookOrderingConfig, HookOrdering } from './waterfall.ts'
30
+
31
+ // Serial service.
32
+ export { type SerialHookEntry, type SerialHookOrderingConfig, SerialHookOrdering } from './serial.ts'
33
+
34
+ // NOTE: the root entry deliberately has NO default export.
35
+ //
36
+ // dsh's loader normalizes an imported module with `exports.default ?? exports`
37
+ // and only then applies it. A default export that is not the plugin therefore
38
+ // HIJACKS the row: naming this package in `cordis.patch.yml` mounted the
39
+ // default (`HookOrdering`) as the plugin, so the waterfall service showed up
40
+ // while `apply` never ran — no serial service, no settings namespace, no
41
+ // controlled hook, and no error anywhere.
42
+ //
43
+ // The root entry IS the dsh plugin; the library surface lives on the subpaths.
44
+ // `HookOrdering` is still exported by name here, and `/waterfall` still carries
45
+ // it as its default.
46
+ export {
47
+ apply,
48
+ type Config,
49
+ DEFAULT_SERIAL_HOOKS,
50
+ DEFAULT_SYNC_RETURN_HOOKS,
51
+ DEFAULT_WATERFALL_HOOKS,
52
+ inject,
53
+ name,
54
+ } from './dsh.ts'
package/src/serial.ts ADDED
@@ -0,0 +1,108 @@
1
+ /**
2
+ * `SerialHookOrdering` — the serial-dispatch twin of {@link HookOrdering}.
3
+ *
4
+ * Cordis `serial` dispatch awaits listeners in registration order until one
5
+ * *bails* (returns a value that is not `null`/`false`/`undefined`); the bail
6
+ * value becomes the dispatch result and the remaining listeners never run.
7
+ * There is no `next()` continuation, so unlike waterfall there is no onion to
8
+ * wrap. This service instead brackets the hook with TWO coordinator listeners:
9
+ *
10
+ * - a `prepend`ed **front** coordinator that runs its participants ahead of
11
+ * every native listener; a bail there short-circuits the whole dispatch.
12
+ * - an appended **back** coordinator that runs its participants best-effort
13
+ * last (see the limits below).
14
+ *
15
+ * Participants register into the coordinator (not the raw hook) with
16
+ * `before`/`after` names and are run in a stable topological order.
17
+ *
18
+ * @module dsh-hooks-ordering/serial
19
+ */
20
+
21
+ import { type Context, isBailed } from '@deepseek-ai/cordis'
22
+ import { 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 serial hook phase. Unlike the
27
+ * waterfall entry, `run` may RETURN a value: a bail value (anything but
28
+ * `null`/`false`/`undefined`) short-circuits the serial dispatch.
29
+ * @typeParam A - the hook's payload argument tuple.
30
+ */
31
+ export interface SerialHookEntry<A extends readonly unknown[] = readonly unknown[]> extends Orderable {
32
+ /** Run this participant with the hook payload. A bail return stops the chain. */
33
+ readonly run: (...args: A) => unknown | Promise<unknown>
34
+ }
35
+
36
+ /** Configuration for the {@link SerialHookOrdering} service. */
37
+ export type SerialHookOrderingConfig = HookOrderingLogConfig
38
+
39
+ declare module '@deepseek-ai/cordis' {
40
+ interface Context {
41
+ serialHooksOrdering: SerialHookOrdering
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Coordinator service registered at `ctx.serialHooksOrdering`. Controls any
47
+ * number of serial hooks; each owns a prepended front coordinator, an appended
48
+ * back coordinator, and two ordered participant lists.
49
+ */
50
+ export class SerialHookOrdering extends HookOrderingBase<SerialHookEntry> {
51
+ /**
52
+ * @param ctx - the Cordis context to register the service in.
53
+ * @param config - optional `log` file for the constraint DAG (JSON); `null` means defaults.
54
+ */
55
+ constructor(ctx: Context, config: SerialHookOrderingConfig | null = {}) {
56
+ super(ctx, 'serialHooksOrdering', config)
57
+ }
58
+
59
+ /**
60
+ * Install the two coordinator listeners. The front one is prepended so it
61
+ * runs ahead of the native chain; the back one is appended so it runs after
62
+ * the listeners present at control time. Both are effects on this service's
63
+ * fiber; the returned disposer removes both.
64
+ */
65
+ protected install(hook: string, front: SerialHookEntry[], back: SerialHookEntry[]): () => void {
66
+ const frontCoordinator = (...args: unknown[]): Promise<unknown> => runSerialPhase(front, args)
67
+ const backCoordinator = (...args: unknown[]): Promise<unknown> => runSerialPhase(back, args)
68
+ const removeFront = this.ctx.on(hook as never, frontCoordinator as never, { prepend: true })
69
+ const removeBack = this.ctx.on(hook as never, backCoordinator as never, { prepend: false })
70
+ return () => {
71
+ removeFront()
72
+ removeBack()
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Register a participant into a controlled hook phase.
78
+ *
79
+ * @param hook - the controlled serial event name.
80
+ * @param phase - `front` to run ahead of the native chain, `back` best-effort last.
81
+ * @param entry - the participant; its `run` may return a bail value to short-circuit.
82
+ * @returns a disposer that unregisters this participant.
83
+ * @throws {HookControlError} when the hook has not been {@link control}led.
84
+ */
85
+ register<A extends readonly unknown[] = readonly unknown[]>(
86
+ hook: string,
87
+ phase: Phase,
88
+ entry: SerialHookEntry<A>,
89
+ ): () => void {
90
+ return this.registerEntry(hook, phase, entry as unknown as SerialHookEntry)
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Run one phase's participants in stable topological order, awaiting each,
96
+ * until one bails.
97
+ * @param entries - the phase's registered participants.
98
+ * @param payload - the hook payload passed to each `run` callback.
99
+ * @returns the first bail value, or `undefined` if no participant bailed (so
100
+ * the serial dispatch continues to the next listener).
101
+ */
102
+ async function runSerialPhase(entries: readonly SerialHookEntry[], payload: readonly unknown[]): Promise<unknown> {
103
+ for (const entry of topoSort(entries)) {
104
+ const result = await entry.run(...payload)
105
+ if (isBailed(result)) return result
106
+ }
107
+ return undefined
108
+ }