@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
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
|
+
}
|