dsh-jenkins-plugin 0.1.0 → 0.2.0
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.md +21 -8
- package/README.zh.md +20 -7
- package/lib/client/Panel.js +4 -2
- package/lib/client/api.d.ts +13 -2
- package/lib/client/api.js +14 -3
- package/lib/client.js +4 -4
- package/lib/connection.d.ts +7 -1
- package/lib/connection.js +10 -3
- package/lib/follow.d.ts +35 -6
- package/lib/follow.js +88 -19
- package/lib/handoff.d.ts +72 -0
- package/lib/handoff.js +45 -0
- package/lib/routes.js +23 -16
- package/lib/settings.d.ts +10 -0
- package/lib/settings.js +2 -1
- package/lib/watch.d.ts +40 -0
- package/lib/watch.js +45 -0
- package/package.json +23 -22
package/README.md
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
# dsh-jenkins-plugin
|
|
2
2
|
|
|
3
|
+
[](https://github.com/Endless-zby/dsh-jenkins-plugin/actions/workflows/ci.yml)
|
|
4
|
+
|
|
3
5
|
Jenkins integration for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness): **a live build-progress panel in the Web GUI's right sidebar**, plus a set of model-facing Jenkins tools.
|
|
4
6
|
|
|
5
7
|
Once installed you stop switching to the Jenkins page to watch a build: the jobs you follow become cards in the sidebar with a live progress bar, a notice when a build finishes, and — when a build fails — one button that hands the failure to the model for a diagnosis.
|
|
6
8
|
|
|
7
|
-

|
|
8
10
|
|
|
9
11
|
## Install
|
|
10
12
|
|
|
@@ -16,7 +18,7 @@ dsh plugin --profile web add dsh-jenkins-plugin
|
|
|
16
18
|
|
|
17
19
|
Restart `dsh web`, open **Settings → Jenkins**, add an instance (URL, account, password/API token) and save; the **Jenkins** panel then appears in the right sidebar.
|
|
18
20
|
|
|
19
|
-

|
|
20
22
|
|
|
21
23
|
Other sources:
|
|
22
24
|
|
|
@@ -44,8 +46,14 @@ That is permission for this package's code to run on your machine at install tim
|
|
|
44
46
|
- **Following** — one card per followed job: state dot, build number, outcome tag, **live progress bar**, the running stage's name, the change description of the current build, and the Maven project version when the controller reports one. Cards are fed by an SSE stream, not by a polling page.
|
|
45
47
|
- **All jobs** — collapsed by default; ☆/★ at the end of each row follows or unfollows it.
|
|
46
48
|
|
|
49
|
+

|
|
50
|
+
|
|
47
51
|
Clicking a job or a card drills down without ever leaving the panel: build history (paged) → build detail (stage bar, embedded console log, test summary, change set, artifacts). Downloading an artifact is the only action that opens a Jenkins page.
|
|
48
52
|
|
|
53
|
+

|
|
54
|
+
|
|
55
|
+

|
|
56
|
+
|
|
49
57
|
**Writes** happen in the panel as *button → inline confirmation → run → result in place*:
|
|
50
58
|
|
|
51
59
|
| Action | Notes |
|
|
@@ -71,6 +79,8 @@ Jobs on `denyJobs` are refused whatever the switches say, and **not a single req
|
|
|
71
79
|
|
|
72
80
|
**Completion notices**: a followed job's finished build is announced once through the platform's own background-job mechanism; the same build is never announced twice.
|
|
73
81
|
|
|
82
|
+
**Failure wake** (`notifyWakeOnFailure`, off by default): when a *followed* job fails, the conversation it was followed from gets the failure handed to it — the same log tail, stage and commits the button sends — so the model can explain it without anyone watching the build list. The favorite remembers which session followed it, so the answer lands where the person is. Only a real failure wakes anyone (`unstable` and `aborted` do not), each build wakes at most once, and a session that has closed is skipped silently. `allowAnalyze: false` turns it off too, since it sends the same log.
|
|
83
|
+
|
|
74
84
|
## Permissions and risks
|
|
75
85
|
|
|
76
86
|
Please take these as read before installing:
|
|
@@ -84,7 +94,7 @@ Please take these as read before installing:
|
|
|
84
94
|
|
|
85
95
|
## Compatibility
|
|
86
96
|
|
|
87
|
-
- **Harness**: built and verified against `0.1.
|
|
97
|
+
- **Harness**: built and verified against `0.2.1-rc.3`. `engines.dsh` accepts the `0.2.1-rc.3` line and later `0.2.x` releases (`>=0.2.1-rc.3 <0.3.0-0`), and the `@deepseek-ai/dsh-*` peer dependencies use `^0.2.1-rc.3`. Older harness lines (`0.1.x`) are **not** supported: the plugin uses harness APIs that changed between them.
|
|
88
98
|
- **Node**: matches the harness, `^22.19.0 || >=24.0.0`.
|
|
89
99
|
- **Surface**: the panel is a Web GUI surface (`dsh web`), verified on the Web composition.
|
|
90
100
|
- **Jenkins**: verified against a real **Jenkins 2.176.2** (FreeStyle and Maven jobs, an instance with 777 jobs). Several honest degradations:
|
|
@@ -92,13 +102,13 @@ Please take these as read before installing:
|
|
|
92
102
|
- **Workspace reads** need the Jenkins Workspace API plugin; without it `jenkins_workspace` says so instead of pretending.
|
|
93
103
|
- **Maven versions** come from `mavenArtifacts/api/json`, which only Maven jobs answer — and on a real 2.176.2 it is usually produced when the build *ends*, so a running Maven build's card may have no version. When there is none, nothing is shown; a version is never guessed.
|
|
94
104
|
- **Artifacts carry no size**: Jenkins' `tree` query cannot fetch artifact sizes.
|
|
95
|
-
- **Not finished yet**: `
|
|
105
|
+
- **Not finished yet**: `uiAutoOpenOnTrigger` (open the panel on trigger) is accepted but **not active**; `maxTrackedBuilds` is advisory only — the followed list is the user's own choice, so nothing is hard-capped.
|
|
96
106
|
|
|
97
107
|
## Configuration
|
|
98
108
|
|
|
99
109
|
Static configuration is only the **fallback layer**: instances configured in the settings page win, and static configuration applies when no panel settings exist (as an instance with id `config`). `baseUrl`/`username` may be left empty — that is exactly the "configure everything in the panel" setup.
|
|
100
110
|
|
|
101
|
-

|
|
102
112
|
|
|
103
113
|
```yaml
|
|
104
114
|
- id: jenkins
|
|
@@ -136,7 +146,7 @@ Static configuration is only the **fallback layer**: instances configured in the
|
|
|
136
146
|
| `maxWorkspaceEntries` | `500` | entry cap on one workspace directory listing |
|
|
137
147
|
| `maxReadFileBytes` | `1048576` | byte cap on one workspace file read |
|
|
138
148
|
| `notifyOnComplete` | `true` | announce a followed build when it finishes |
|
|
139
|
-
| `notifyWakeOnFailure` | `false` |
|
|
149
|
+
| `notifyWakeOnFailure` | `false` | hand a failed followed build to the session that followed it |
|
|
140
150
|
| `allowAnalyze` | `true` | allow handing a failed build to the model |
|
|
141
151
|
| `analyzeLogBytes` | `16384` | log-tail bytes handed to the model |
|
|
142
152
|
| `uiAutoOpenOnTrigger` | `true` | open the panel on trigger (**not active yet**) |
|
|
@@ -148,7 +158,7 @@ Static configuration is only the **fallback layer**: instances configured in the
|
|
|
148
158
|
```sh
|
|
149
159
|
npm install # runs prepare (= build) once
|
|
150
160
|
npm run typecheck
|
|
151
|
-
npm test #
|
|
161
|
+
npm test # 415 assertions, no test framework: every tests/*.ts runs on its own
|
|
152
162
|
npm run build # tsc (Host half) + esbuild (browser half → lib/client.js)
|
|
153
163
|
npm run check # test + secret scan + packed-payload audit
|
|
154
164
|
```
|
|
@@ -157,7 +167,7 @@ The browser half is bundled into the client module system's lazy factory (`windo
|
|
|
157
167
|
|
|
158
168
|
`tests/stub-jenkins.ts` is a zero-dependency stub Jenkins (`node tests/stub-jenkins.ts`) with stages, a failing build, a parameterized Maven job, write endpoints and a queue delay; it exercises the panel's live path and write path without touching a real controller.
|
|
159
169
|
|
|
160
|
-
Three gates run before a release (and are what CI
|
|
170
|
+
Three gates run before a release (and are what CI runs):
|
|
161
171
|
|
|
162
172
|
```sh
|
|
163
173
|
npm run check:secrets # credential shapes in everything a commit would carry (private hostnames / user paths warn only)
|
|
@@ -165,6 +175,9 @@ npm run check:payload # reads npm pack's manifest: artifacts present, no src/
|
|
|
165
175
|
npm pack # produce the tarball, install it into a clean profile, then publish
|
|
166
176
|
```
|
|
167
177
|
|
|
178
|
+
Releasing is scripted end to end in [RELEASING.md](RELEASING.md) (`npm run release:check` is the
|
|
179
|
+
pre-flight that refuses to reuse a version or a tag).
|
|
180
|
+
|
|
168
181
|
## License
|
|
169
182
|
|
|
170
183
|
[MIT](LICENSE)
|
package/README.zh.md
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
# dsh-jenkins-plugin
|
|
2
2
|
|
|
3
|
+
[](https://github.com/Endless-zby/dsh-jenkins-plugin/actions/workflows/ci.yml)
|
|
4
|
+
|
|
3
5
|
[DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 的 Jenkins 插件:**Web GUI 右侧栏里的实时构建面板**,加上一组面向模型的 Jenkins 工具。
|
|
4
6
|
|
|
5
7
|
装完之后,你不再需要为了看一次构建进度而切到 Jenkins 页面:关注的 job 变成侧栏里的卡片,进度条实时走,构建结束发通知,失败了一键交给模型分析原因。
|
|
6
8
|
|
|
7
|
-

|
|
8
10
|
|
|
9
11
|
## 安装
|
|
10
12
|
|
|
@@ -16,7 +18,7 @@ dsh plugin --profile web add dsh-jenkins-plugin
|
|
|
16
18
|
|
|
17
19
|
装完重启 `dsh web`,打开**设置 → Jenkins** 填一个实例(地址 + 账号 + 密码/API token),Save 之后右栏就会出现 **Jenkins** 面板。
|
|
18
20
|
|
|
19
|
-

|
|
20
22
|
|
|
21
23
|
其他安装来源:
|
|
22
24
|
|
|
@@ -44,8 +46,14 @@ allowBuilds:
|
|
|
44
46
|
- **我关注的** —— 每个关注的 job 一张卡片:状态点、构建号、状态标签、**实时进度条**、当前阶段名、本次构建的变更说明、Maven 项目的版本号(构建结束后可取到时)。卡片直接来自 SSE 推送,不是轮询页面。
|
|
45
47
|
- **所有 job** —— 默认折叠,展开后每行末尾的 ☆/★ 一键关注。
|
|
46
48
|
|
|
49
|
+

|
|
50
|
+
|
|
47
51
|
点 job 或卡片继续下钻,全程不跳出面板:构建历史(可翻页)→ 构建详情(阶段条、内嵌控制台日志、测试摘要、变更集、产物列表)。产物下载是唯一会打开 Jenkins 页面的动作。
|
|
48
52
|
|
|
53
|
+

|
|
54
|
+
|
|
55
|
+

|
|
56
|
+
|
|
49
57
|
**写操作**都在面板内完成,并且是「按钮 → 行内二次确认 → 执行 → 就地显示结果」:
|
|
50
58
|
|
|
51
59
|
| 动作 | 说明 |
|
|
@@ -71,6 +79,8 @@ allowBuilds:
|
|
|
71
79
|
|
|
72
80
|
**完成通知**:关注的 job 一有构建结束就用平台的后台作业机制发一次通知,同一次构建只通知一次。
|
|
73
81
|
|
|
82
|
+
**失败唤醒**(`notifyWakeOnFailure`,默认关):**关注的** job 一旦失败,就把它交给「你当初关注它的那个会话」——发的是和按钮相同的那份日志尾部、失败阶段与提交,所以模型能在没人盯着构建列表的时候先说明原因,答案出现在你正在读的对话里。关注项记着是哪个会话关注的,所以通知不会跑错地方。只有真正的失败才唤醒(`unstable`、`aborted` 不算),同一次构建最多唤醒一次,会话已经关闭就静默跳过。`allowAnalyze: false` 也会关掉它——两者发给模型的是同一份日志。
|
|
83
|
+
|
|
74
84
|
## 权限与风险
|
|
75
85
|
|
|
76
86
|
装之前请如实了解这个插件会做什么:
|
|
@@ -84,7 +94,7 @@ allowBuilds:
|
|
|
84
94
|
|
|
85
95
|
## 兼容性
|
|
86
96
|
|
|
87
|
-
- **Harness**:针对 `0.1
|
|
97
|
+
- **Harness**:针对 `0.2.1-rc.3` 构建并验证。`engines.dsh` 接受 `0.2.1-rc.3` 及后续 `0.2.x`(`>=0.2.1-rc.3 <0.3.0-0`),`@deepseek-ai/dsh-*` 的 peerDependencies 用 `^0.2.1-rc.3`。**不再支持**旧 `0.1.x` 线:插件使用的 harness API 在其间有变化。
|
|
88
98
|
- **Node**:与 harness 一致,`^22.19.0 || >=24.0.0`。
|
|
89
99
|
- **载体**:面板是 Web GUI 的界面(`dsh web`),在 Web 组合上验证过。
|
|
90
100
|
- **Jenkins**:在 **Jenkins 2.176.2** 上实测(FreeStyle 与 Maven job,777 个 job 的实例)。几条如实降级:
|
|
@@ -92,13 +102,13 @@ allowBuilds:
|
|
|
92
102
|
- **读工作空间**需要 Jenkins Workspace API 插件;缺失时 `jenkins_workspace` 会如实说明该 Jenkins 不支持。
|
|
93
103
|
- **Maven 版本号**取自 `mavenArtifacts/api/json`,只有 Maven job 有,且真实 2.176.2 上通常在构建**结束后**才产生,所以正在跑的 Maven 构建卡片上可能没有版本号(拿不到就不显示,绝不猜)。
|
|
94
104
|
- **产物没有大小**:Jenkins 的 `tree` 查询取不到 artifact size。
|
|
95
|
-
- **已知未完成**:`
|
|
105
|
+
- **已知未完成**:`uiAutoOpenOnTrigger`(触发后自动展开面板)已接受但**尚未生效**;`maxTrackedBuilds` 只是建议值,不硬性限制跟踪数量(关注列表是用户自己挑的)。
|
|
96
106
|
|
|
97
107
|
## 配置
|
|
98
108
|
|
|
99
109
|
静态配置只是**回退层**:面板设置页里配的实例优先,静态配置在没有面板设置时生效(实例 id 为 `config`)。`baseUrl`/`username` 可以留空——那正是"全部在面板里配"的用法。
|
|
100
110
|
|
|
101
|
-

|
|
102
112
|
|
|
103
113
|
```yaml
|
|
104
114
|
- id: jenkins
|
|
@@ -136,7 +146,7 @@ allowBuilds:
|
|
|
136
146
|
| `maxWorkspaceEntries` | `500` | 单次工作空间目录列出的条目上限 |
|
|
137
147
|
| `maxReadFileBytes` | `1048576` | 单个工作空间文件的读取上限 |
|
|
138
148
|
| `notifyOnComplete` | `true` | 关注的构建结束时是否发通知 |
|
|
139
|
-
| `notifyWakeOnFailure` | `false` |
|
|
149
|
+
| `notifyWakeOnFailure` | `false` | 关注的构建失败时,交给当初关注它的会话 |
|
|
140
150
|
| `allowAnalyze` | `true` | 是否允许把失败构建交给模型分析 |
|
|
141
151
|
| `analyzeLogBytes` | `16384` | 交给模型分析的日志尾部字节数 |
|
|
142
152
|
| `uiAutoOpenOnTrigger` | `true` | 触发后自动展开面板(**尚未生效**) |
|
|
@@ -148,7 +158,7 @@ allowBuilds:
|
|
|
148
158
|
```sh
|
|
149
159
|
npm install # 会跑一次 prepare(=构建)
|
|
150
160
|
npm run typecheck
|
|
151
|
-
npm test #
|
|
161
|
+
npm test # 415 项断言,无测试框架:每个 tests/*.ts 都是可直接运行的脚本
|
|
152
162
|
npm run build # tsc(Host 半)+ esbuild(浏览器半 → lib/client.js)
|
|
153
163
|
npm run check # test + 密钥扫描 + 打包内容审计
|
|
154
164
|
```
|
|
@@ -165,6 +175,9 @@ npm run check:payload # 读 npm pack 的清单:产物齐不齐、有没有
|
|
|
165
175
|
npm pack # 出 tarball,先在一个干净 profile 上装一遍再 publish
|
|
166
176
|
```
|
|
167
177
|
|
|
178
|
+
完整的发布步骤(含实测脚本、陷阱速查、以及"只有人能做的三件事")见 [RELEASING.md](RELEASING.md);
|
|
179
|
+
`npm run release:check` 是发布前预检,会挡住重复使用版本号或 tag 这类事故。
|
|
180
|
+
|
|
168
181
|
## 许可证
|
|
169
182
|
|
|
170
183
|
[MIT](LICENSE)
|
package/lib/client/Panel.js
CHANGED
|
@@ -133,7 +133,9 @@ function Home({ t, state, busy, instanceId, sessionId, onReload, onOpenJob, onSw
|
|
|
133
133
|
/** Follow or unfollow one job, then refresh the cards. */
|
|
134
134
|
const toggle = useCallback(async (job) => {
|
|
135
135
|
const wantFollowed = !followed.has(job.path);
|
|
136
|
-
|
|
136
|
+
// The session goes along with the follow: a failure of this job wakes that
|
|
137
|
+
// conversation, and the host records it on the favorite for the watcher.
|
|
138
|
+
const answer = await toggleFavorite(instanceId, job.path, job.name, wantFollowed, sessionId);
|
|
137
139
|
if (isFailure(answer)) {
|
|
138
140
|
setToast(answer.message);
|
|
139
141
|
setTimeout(() => { setToast(undefined); }, 4000);
|
|
@@ -142,7 +144,7 @@ function Home({ t, state, busy, instanceId, sessionId, onReload, onOpenJob, onSw
|
|
|
142
144
|
setToast(wantFollowed ? t('favorites.added', { name: job.name }) : t('favorites.removed', { name: job.name }));
|
|
143
145
|
setTimeout(() => { setToast(undefined); }, 2500);
|
|
144
146
|
await loadFavorites();
|
|
145
|
-
}, [followed, instanceId, loadFavorites, t]);
|
|
147
|
+
}, [followed, instanceId, loadFavorites, sessionId, t]);
|
|
146
148
|
// The followed list itself is only re-read when a followed build ends, so a
|
|
147
149
|
// card's own numbers never go stale while it is running.
|
|
148
150
|
return (_jsxs("div", { style: { display: 'flex', flexDirection: 'column', height: '100%', minHeight: 0 }, children: [_jsxs("div", { style: { padding: '8px 12px', borderBottom: '1px solid var(--dsw-alias-border-l2)' }, children: [_jsxs("div", { style: { display: 'flex', alignItems: 'center', gap: '8px' }, children: [_jsx("span", { style: { fontWeight: 600, fontSize: 'var(--dsw-font-sm-13)' }, children: t('panel.title') }), _jsxs("span", { title: `${state.connection.name} — ${state.connection.baseUrl}`, style: {
|
package/lib/client/api.d.ts
CHANGED
|
@@ -202,8 +202,19 @@ export interface FavoritesPayload {
|
|
|
202
202
|
}
|
|
203
203
|
/** Read one instance's favorited jobs, with their current build and progress. */
|
|
204
204
|
export declare function fetchFavorites(instanceId?: string): Promise<FavoritesPayload | ApiFailure>;
|
|
205
|
-
/**
|
|
206
|
-
|
|
205
|
+
/**
|
|
206
|
+
* Add or remove one favorite.
|
|
207
|
+
*
|
|
208
|
+
* The session is sent so the host can remember who followed the job: that is
|
|
209
|
+
* where a failure wake goes, and the watcher has no other way to know it.
|
|
210
|
+
* @param instanceId - the instance the job belongs to.
|
|
211
|
+
* @param path - job path.
|
|
212
|
+
* @param name - display name to record.
|
|
213
|
+
* @param favorited - the desired state.
|
|
214
|
+
* @param sessionId - the conversation following it, when the panel knows one.
|
|
215
|
+
* @returns the instance's favorites after the change, or a failure.
|
|
216
|
+
*/
|
|
217
|
+
export declare function toggleFavorite(instanceId: string, path: string, name: string, favorited: boolean, sessionId?: string): Promise<{
|
|
207
218
|
ok: true;
|
|
208
219
|
instanceId: string;
|
|
209
220
|
favorites: FavoriteRow[];
|
package/lib/client/api.js
CHANGED
|
@@ -103,11 +103,22 @@ export async function fetchFavorites(instanceId) {
|
|
|
103
103
|
: `?instance=${encodeURIComponent(instanceId)}`;
|
|
104
104
|
return await request(`/jenkins-plugin/favorites${query}`);
|
|
105
105
|
}
|
|
106
|
-
/**
|
|
107
|
-
|
|
106
|
+
/**
|
|
107
|
+
* Add or remove one favorite.
|
|
108
|
+
*
|
|
109
|
+
* The session is sent so the host can remember who followed the job: that is
|
|
110
|
+
* where a failure wake goes, and the watcher has no other way to know it.
|
|
111
|
+
* @param instanceId - the instance the job belongs to.
|
|
112
|
+
* @param path - job path.
|
|
113
|
+
* @param name - display name to record.
|
|
114
|
+
* @param favorited - the desired state.
|
|
115
|
+
* @param sessionId - the conversation following it, when the panel knows one.
|
|
116
|
+
* @returns the instance's favorites after the change, or a failure.
|
|
117
|
+
*/
|
|
118
|
+
export async function toggleFavorite(instanceId, path, name, favorited, sessionId) {
|
|
108
119
|
return await request('/jenkins-plugin/favorites/toggle', {
|
|
109
120
|
method: 'POST',
|
|
110
|
-
body: { instance: instanceId, path, name, favorited },
|
|
121
|
+
body: { instance: instanceId, path, name, favorited, ...sessionId === undefined ? {} : { session: sessionId } },
|
|
111
122
|
});
|
|
112
123
|
}
|
|
113
124
|
/**
|
package/lib/client.js
CHANGED
|
@@ -100,10 +100,10 @@ async function fetchFavorites(instanceId) {
|
|
|
100
100
|
const query = instanceId === void 0 || instanceId.length === 0 ? "" : `?instance=${encodeURIComponent(instanceId)}`;
|
|
101
101
|
return await request(`/jenkins-plugin/favorites${query}`);
|
|
102
102
|
}
|
|
103
|
-
async function toggleFavorite(instanceId, path, name, favorited) {
|
|
103
|
+
async function toggleFavorite(instanceId, path, name, favorited, sessionId) {
|
|
104
104
|
return await request("/jenkins-plugin/favorites/toggle", {
|
|
105
105
|
method: "POST",
|
|
106
|
-
body: { instance: instanceId, path, name, favorited }
|
|
106
|
+
body: { instance: instanceId, path, name, favorited, ...sessionId === void 0 ? {} : { session: sessionId } }
|
|
107
107
|
});
|
|
108
108
|
}
|
|
109
109
|
function parseParameters(text) {
|
|
@@ -1171,7 +1171,7 @@ function Home({ t, state, busy, instanceId, sessionId, onReload, onOpenJob, onSw
|
|
|
1171
1171
|
const moving = subscribed.length > 0 || [...live.values()].some((record) => record.state === "queued");
|
|
1172
1172
|
const toggle = (0, import_react3.useCallback)(async (job) => {
|
|
1173
1173
|
const wantFollowed = !followed.has(job.path);
|
|
1174
|
-
const answer = await toggleFavorite(instanceId, job.path, job.name, wantFollowed);
|
|
1174
|
+
const answer = await toggleFavorite(instanceId, job.path, job.name, wantFollowed, sessionId);
|
|
1175
1175
|
if (isFailure(answer)) {
|
|
1176
1176
|
setToast(answer.message);
|
|
1177
1177
|
setTimeout(() => {
|
|
@@ -1184,7 +1184,7 @@ function Home({ t, state, busy, instanceId, sessionId, onReload, onOpenJob, onSw
|
|
|
1184
1184
|
setToast(void 0);
|
|
1185
1185
|
}, 2500);
|
|
1186
1186
|
await loadFavorites();
|
|
1187
|
-
}, [followed, instanceId, loadFavorites, t]);
|
|
1187
|
+
}, [followed, instanceId, loadFavorites, sessionId, t]);
|
|
1188
1188
|
return /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("div", { style: { display: "flex", flexDirection: "column", height: "100%", minHeight: 0 }, children: [
|
|
1189
1189
|
/* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("div", { style: { padding: "8px 12px", borderBottom: "1px solid var(--dsw-alias-border-l2)" }, children: [
|
|
1190
1190
|
/* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("div", { style: { display: "flex", alignItems: "center", gap: "8px" }, children: [
|
package/lib/connection.d.ts
CHANGED
|
@@ -113,13 +113,19 @@ export declare class InstanceRegistry {
|
|
|
113
113
|
* The whole list is rewritten because the settings file has no update-in-place
|
|
114
114
|
* path; a read-modify-write is safe here because this plugin is the only
|
|
115
115
|
* writer and the call is awaited before the cache advances.
|
|
116
|
+
*
|
|
117
|
+
* Re-favoriting a job that is already followed refreshes the recorded name and
|
|
118
|
+
* session instead of leaving them stale: following a job from another
|
|
119
|
+
* conversation has to move where its failure wake goes, or the notice would
|
|
120
|
+
* keep arriving in a session the person has left.
|
|
116
121
|
* @param instanceId - the instance the path belongs to.
|
|
117
122
|
* @param jobPath - the job path to toggle.
|
|
118
123
|
* @param name - display name to record when adding.
|
|
119
124
|
* @param favorited - the desired state.
|
|
125
|
+
* @param sessionId - session that followed it, when the caller knows one.
|
|
120
126
|
* @returns the instance's favorites after the change.
|
|
121
127
|
*/
|
|
122
|
-
setFavorite(instanceId: string, jobPath: string, name: string, favorited: boolean): Promise<FavoriteJob[]>;
|
|
128
|
+
setFavorite(instanceId: string, jobPath: string, name: string, favorited: boolean, sessionId?: string): Promise<FavoriteJob[]>;
|
|
123
129
|
/**
|
|
124
130
|
* Persist a new instance list, keeping every other setting.
|
|
125
131
|
*
|
package/lib/connection.js
CHANGED
|
@@ -139,18 +139,25 @@ export class InstanceRegistry {
|
|
|
139
139
|
* The whole list is rewritten because the settings file has no update-in-place
|
|
140
140
|
* path; a read-modify-write is safe here because this plugin is the only
|
|
141
141
|
* writer and the call is awaited before the cache advances.
|
|
142
|
+
*
|
|
143
|
+
* Re-favoriting a job that is already followed refreshes the recorded name and
|
|
144
|
+
* session instead of leaving them stale: following a job from another
|
|
145
|
+
* conversation has to move where its failure wake goes, or the notice would
|
|
146
|
+
* keep arriving in a session the person has left.
|
|
142
147
|
* @param instanceId - the instance the path belongs to.
|
|
143
148
|
* @param jobPath - the job path to toggle.
|
|
144
149
|
* @param name - display name to record when adding.
|
|
145
150
|
* @param favorited - the desired state.
|
|
151
|
+
* @param sessionId - session that followed it, when the caller knows one.
|
|
146
152
|
* @returns the instance's favorites after the change.
|
|
147
153
|
*/
|
|
148
|
-
async setFavorite(instanceId, jobPath, name, favorited) {
|
|
154
|
+
async setFavorite(instanceId, jobPath, name, favorited, sessionId) {
|
|
149
155
|
const forInstance = await this.favorites(instanceId);
|
|
156
|
+
const recorded = { ...sessionId === undefined ? {} : { sessionId } };
|
|
150
157
|
const next = favorited
|
|
151
158
|
? forInstance.some(entry => entry.path === jobPath)
|
|
152
|
-
? forInstance
|
|
153
|
-
: [...forInstance, { path: jobPath, name, addedAt: Date.now() }]
|
|
159
|
+
? forInstance.map(entry => (entry.path === jobPath ? { ...entry, name, ...recorded } : entry))
|
|
160
|
+
: [...forInstance, { path: jobPath, name, addedAt: Date.now(), ...recorded }]
|
|
154
161
|
: forInstance.filter(entry => entry.path !== jobPath);
|
|
155
162
|
await this.storeFavorites(instanceId, next);
|
|
156
163
|
return next;
|
package/lib/follow.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Completion notifications for followed jobs.
|
|
2
|
+
* Completion notifications for followed jobs, and the failure wake.
|
|
3
3
|
*
|
|
4
4
|
* The operator follows jobs in the panel to hear when they finish, so this is
|
|
5
5
|
* the one place that watches Jenkins on its own initiative. Its rules, in order
|
|
@@ -15,6 +15,11 @@
|
|
|
15
15
|
* notification the model and the user both see, exactly once (`reported`).
|
|
16
16
|
* That mechanism resolves the job on the tick it is registered, so the job
|
|
17
17
|
* exists only to carry the notice.
|
|
18
|
+
* - **A failure also wakes the session that followed the job.** The wake rides
|
|
19
|
+
* the same once-per-build decision, so it cannot arrive twice for one build;
|
|
20
|
+
* it goes to the session recorded on the favorite, and it stays silent when
|
|
21
|
+
* that session has no live agent (a closed conversation is not an error). The
|
|
22
|
+
* log hand-off itself is the same one the panel's button performs.
|
|
18
23
|
* @module dsh-jenkins-plugin/follow
|
|
19
24
|
*/
|
|
20
25
|
import type { Context } from '@deepseek-ai/cordis';
|
|
@@ -34,7 +39,7 @@ export declare class FollowWatcher {
|
|
|
34
39
|
private timer;
|
|
35
40
|
private running;
|
|
36
41
|
/**
|
|
37
|
-
* @param ctx - plugin context, used to reach the optional job
|
|
42
|
+
* @param ctx - plugin context, used to reach the optional job and agent services.
|
|
38
43
|
* @param registry - reads the instances, the followed set, and their jobs.
|
|
39
44
|
* @param config - validated plugin configuration.
|
|
40
45
|
*/
|
|
@@ -42,12 +47,25 @@ export declare class FollowWatcher {
|
|
|
42
47
|
/**
|
|
43
48
|
* Arm the watcher.
|
|
44
49
|
*
|
|
45
|
-
* Does nothing when
|
|
46
|
-
*
|
|
47
|
-
*
|
|
50
|
+
* Does nothing when neither channel can deliver anything — notices switched
|
|
51
|
+
* off, no job service, wake switched off, no agent runtime — because a watcher
|
|
52
|
+
* that would poll Jenkins and then drop what it found only burns requests.
|
|
48
53
|
* @returns a disposer stopping the watcher.
|
|
49
54
|
*/
|
|
50
55
|
start(): () => void;
|
|
56
|
+
/**
|
|
57
|
+
* Whether a finished build should become a platform completion notice.
|
|
58
|
+
* @returns true when the configuration and the composition both allow it.
|
|
59
|
+
*/
|
|
60
|
+
private wantsNotices;
|
|
61
|
+
/**
|
|
62
|
+
* Whether a failed build should wake the session that followed it.
|
|
63
|
+
*
|
|
64
|
+
* `allowAnalyze` gates this too: it is the switch that says "build logs may be
|
|
65
|
+
* sent to a model", and a wake sends the same log tail the panel's button does.
|
|
66
|
+
* @returns true when the configuration and the composition both allow it.
|
|
67
|
+
*/
|
|
68
|
+
private wantsWake;
|
|
51
69
|
/**
|
|
52
70
|
* One poll: read every instance's followed jobs and announce what finished.
|
|
53
71
|
*
|
|
@@ -59,9 +77,20 @@ export declare class FollowWatcher {
|
|
|
59
77
|
tick(): Promise<void>;
|
|
60
78
|
/**
|
|
61
79
|
* Read the current build state of every followed job, across every instance.
|
|
62
|
-
* @returns one row per followed job.
|
|
80
|
+
* @returns one row per followed job, plus the routing a wake needs.
|
|
63
81
|
*/
|
|
64
82
|
private followedStates;
|
|
83
|
+
/**
|
|
84
|
+
* Wake the session that followed a failed build.
|
|
85
|
+
*
|
|
86
|
+
* Every failure mode here is silent by design: a favorite with no recorded
|
|
87
|
+
* session, a session with no live agent, a controller that stopped answering.
|
|
88
|
+
* The build is already recorded as announced, so a wake that could not be
|
|
89
|
+
* delivered is dropped rather than retried on every tick.
|
|
90
|
+
* @param notice - the build that failed.
|
|
91
|
+
* @param followed - this tick's routing.
|
|
92
|
+
*/
|
|
93
|
+
private wake;
|
|
65
94
|
/**
|
|
66
95
|
* Hand one finished build to the platform's background-job mechanism.
|
|
67
96
|
*
|
package/lib/follow.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Completion notifications for followed jobs.
|
|
2
|
+
* Completion notifications for followed jobs, and the failure wake.
|
|
3
3
|
*
|
|
4
4
|
* The operator follows jobs in the panel to hear when they finish, so this is
|
|
5
5
|
* the one place that watches Jenkins on its own initiative. Its rules, in order
|
|
@@ -15,9 +15,16 @@
|
|
|
15
15
|
* notification the model and the user both see, exactly once (`reported`).
|
|
16
16
|
* That mechanism resolves the job on the tick it is registered, so the job
|
|
17
17
|
* exists only to carry the notice.
|
|
18
|
+
* - **A failure also wakes the session that followed the job.** The wake rides
|
|
19
|
+
* the same once-per-build decision, so it cannot arrive twice for one build;
|
|
20
|
+
* it goes to the session recorded on the favorite, and it stays silent when
|
|
21
|
+
* that session has no live agent (a closed conversation is not an error). The
|
|
22
|
+
* log hand-off itself is the same one the panel's button performs.
|
|
18
23
|
* @module dsh-jenkins-plugin/follow
|
|
19
24
|
*/
|
|
20
|
-
import {
|
|
25
|
+
import { handOverFailure } from './analyze.js';
|
|
26
|
+
import { followupFor } from './handoff.js';
|
|
27
|
+
import { NoticeLog, jobNoticeKey, noticeLabel, pendingNotices, shouldWake, wakeRoutes } from './watch.js';
|
|
21
28
|
/** The plugin's kind in the platform's job list. */
|
|
22
29
|
const JOB_KIND = 'jenkins';
|
|
23
30
|
/** A build never takes longer than this to be worth waiting for as a job. */
|
|
@@ -36,7 +43,7 @@ export class FollowWatcher {
|
|
|
36
43
|
timer;
|
|
37
44
|
running = false;
|
|
38
45
|
/**
|
|
39
|
-
* @param ctx - plugin context, used to reach the optional job
|
|
46
|
+
* @param ctx - plugin context, used to reach the optional job and agent services.
|
|
40
47
|
* @param registry - reads the instances, the followed set, and their jobs.
|
|
41
48
|
* @param config - validated plugin configuration.
|
|
42
49
|
*/
|
|
@@ -48,15 +55,13 @@ export class FollowWatcher {
|
|
|
48
55
|
/**
|
|
49
56
|
* Arm the watcher.
|
|
50
57
|
*
|
|
51
|
-
* Does nothing when
|
|
52
|
-
*
|
|
53
|
-
*
|
|
58
|
+
* Does nothing when neither channel can deliver anything — notices switched
|
|
59
|
+
* off, no job service, wake switched off, no agent runtime — because a watcher
|
|
60
|
+
* that would poll Jenkins and then drop what it found only burns requests.
|
|
54
61
|
* @returns a disposer stopping the watcher.
|
|
55
62
|
*/
|
|
56
63
|
start() {
|
|
57
|
-
if (!this.
|
|
58
|
-
return () => { };
|
|
59
|
-
if (this.ctx.get('jobs') === undefined)
|
|
64
|
+
if (!this.wantsNotices() && !this.wantsWake())
|
|
60
65
|
return () => { };
|
|
61
66
|
this.timer = setInterval(() => { void this.tick(); }, this.config.progressIntervalMs);
|
|
62
67
|
// A first tick runs immediately so a build that finished while the plugin
|
|
@@ -68,6 +73,25 @@ export class FollowWatcher {
|
|
|
68
73
|
this.timer = undefined;
|
|
69
74
|
};
|
|
70
75
|
}
|
|
76
|
+
/**
|
|
77
|
+
* Whether a finished build should become a platform completion notice.
|
|
78
|
+
* @returns true when the configuration and the composition both allow it.
|
|
79
|
+
*/
|
|
80
|
+
wantsNotices() {
|
|
81
|
+
return this.config.notifyOnComplete && this.ctx.get('jobs') !== undefined;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Whether a failed build should wake the session that followed it.
|
|
85
|
+
*
|
|
86
|
+
* `allowAnalyze` gates this too: it is the switch that says "build logs may be
|
|
87
|
+
* sent to a model", and a wake sends the same log tail the panel's button does.
|
|
88
|
+
* @returns true when the configuration and the composition both allow it.
|
|
89
|
+
*/
|
|
90
|
+
wantsWake() {
|
|
91
|
+
return this.config.notifyWakeOnFailure
|
|
92
|
+
&& this.config.allowAnalyze
|
|
93
|
+
&& this.ctx.get('agents') !== undefined;
|
|
94
|
+
}
|
|
71
95
|
/**
|
|
72
96
|
* One poll: read every instance's followed jobs and announce what finished.
|
|
73
97
|
*
|
|
@@ -81,15 +105,20 @@ export class FollowWatcher {
|
|
|
81
105
|
return;
|
|
82
106
|
this.running = true;
|
|
83
107
|
try {
|
|
84
|
-
const
|
|
85
|
-
if (jobs.length === 0)
|
|
108
|
+
const followed = await this.followedStates();
|
|
109
|
+
if (followed.jobs.length === 0)
|
|
86
110
|
return;
|
|
87
|
-
for (const notice of pendingNotices(jobs, this.announced.snapshot())) {
|
|
111
|
+
for (const notice of pendingNotices(followed.jobs, this.announced.snapshot())) {
|
|
88
112
|
// Recorded before delivery: the record must not depend on a delivery
|
|
89
113
|
// succeeding, or a throwing job service would announce the same build
|
|
90
114
|
// again on the next tick.
|
|
91
115
|
this.announced.add(notice.key);
|
|
92
|
-
this.
|
|
116
|
+
if (this.wantsNotices())
|
|
117
|
+
this.deliver(noticeLabel(notice));
|
|
118
|
+
// The wake rides the same decision, so one build can never wake twice —
|
|
119
|
+
// not even when the completion notice could not be delivered.
|
|
120
|
+
if (this.wantsWake() && shouldWake(notice.outcome))
|
|
121
|
+
await this.wake(notice, followed);
|
|
93
122
|
}
|
|
94
123
|
}
|
|
95
124
|
catch {
|
|
@@ -103,11 +132,13 @@ export class FollowWatcher {
|
|
|
103
132
|
}
|
|
104
133
|
/**
|
|
105
134
|
* Read the current build state of every followed job, across every instance.
|
|
106
|
-
* @returns one row per followed job.
|
|
135
|
+
* @returns one row per followed job, plus the routing a wake needs.
|
|
107
136
|
*/
|
|
108
137
|
async followedStates() {
|
|
109
138
|
const { instances } = await this.registry.list();
|
|
110
|
-
const
|
|
139
|
+
const jobs = [];
|
|
140
|
+
const sessions = new Map();
|
|
141
|
+
const sources = new Map();
|
|
111
142
|
for (const instance of instances) {
|
|
112
143
|
const favorites = await this.registry.favorites(instance.id);
|
|
113
144
|
if (favorites.length === 0)
|
|
@@ -115,21 +146,59 @@ export class FollowWatcher {
|
|
|
115
146
|
// One listing per instance answers every followed job on it, which is the
|
|
116
147
|
// bound that keeps this cheap on a large controller.
|
|
117
148
|
const resolved = await this.registry.require(instance.id);
|
|
118
|
-
const
|
|
119
|
-
const byPath = new Map(
|
|
149
|
+
const jobsOnInstance = await resolved.client.listJobs();
|
|
150
|
+
const byPath = new Map(jobsOnInstance.map(job => [job.path, job]));
|
|
120
151
|
for (const favorite of favorites) {
|
|
152
|
+
for (const [route, sessionId] of wakeRoutes(instance.id, [favorite]))
|
|
153
|
+
sessions.set(route, sessionId);
|
|
121
154
|
const job = byPath.get(favorite.path);
|
|
122
155
|
if (job === undefined)
|
|
123
156
|
continue;
|
|
157
|
+
sources.set(instance.id, { client: resolved.client, instanceName: resolved.instance.name });
|
|
124
158
|
const build = job.lastBuild;
|
|
125
|
-
|
|
159
|
+
jobs.push({
|
|
126
160
|
instanceId: instance.id,
|
|
127
161
|
jobPath: favorite.path,
|
|
128
162
|
...build === undefined ? {} : { buildNumber: build.number, outcome: build.outcome, building: build.building },
|
|
129
163
|
});
|
|
130
164
|
}
|
|
131
165
|
}
|
|
132
|
-
return
|
|
166
|
+
return { jobs, sessions, sources };
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Wake the session that followed a failed build.
|
|
170
|
+
*
|
|
171
|
+
* Every failure mode here is silent by design: a favorite with no recorded
|
|
172
|
+
* session, a session with no live agent, a controller that stopped answering.
|
|
173
|
+
* The build is already recorded as announced, so a wake that could not be
|
|
174
|
+
* delivered is dropped rather than retried on every tick.
|
|
175
|
+
* @param notice - the build that failed.
|
|
176
|
+
* @param followed - this tick's routing.
|
|
177
|
+
*/
|
|
178
|
+
async wake(notice, followed) {
|
|
179
|
+
const sessionId = followed.sessions.get(jobNoticeKey(notice.instanceId, notice.jobPath));
|
|
180
|
+
if (sessionId === undefined)
|
|
181
|
+
return;
|
|
182
|
+
const source = followed.sources.get(notice.instanceId);
|
|
183
|
+
if (source === undefined)
|
|
184
|
+
return;
|
|
185
|
+
const sink = followupFor(this.ctx, sessionId);
|
|
186
|
+
if (sink.kind !== 'ok')
|
|
187
|
+
return;
|
|
188
|
+
try {
|
|
189
|
+
await handOverFailure({
|
|
190
|
+
client: source.client,
|
|
191
|
+
jobPath: notice.jobPath,
|
|
192
|
+
buildNumber: notice.buildNumber,
|
|
193
|
+
logBytes: this.config.analyzeLogBytes,
|
|
194
|
+
instanceName: source.instanceName,
|
|
195
|
+
followup: sink.followup,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
// A build that failed to hand over stays announced: the alternative is a
|
|
200
|
+
// wake attempt against a broken controller on every single tick.
|
|
201
|
+
}
|
|
133
202
|
}
|
|
134
203
|
/**
|
|
135
204
|
* Hand one finished build to the platform's background-job mechanism.
|
package/lib/handoff.d.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Delivering a plugin-authored message into a session.
|
|
3
|
+
*
|
|
4
|
+
* Two callers want the same thing: the panel's "hand it to the AI" route, where
|
|
5
|
+
* a person clicked, and the failure watcher, which wakes the session that
|
|
6
|
+
* followed the job. Both need "reach this session's live agent, or say precisely
|
|
7
|
+
* why not", so the lookup and the message construction live here rather than
|
|
8
|
+
* being written twice — a hand-rolled message shape is exactly the kind of thing
|
|
9
|
+
* that drifts from what the platform actually accepts.
|
|
10
|
+
*
|
|
11
|
+
* The three outcomes are distinct on purpose: a composition without an agent
|
|
12
|
+
* runtime is a different situation from a session whose agent has gone away, and
|
|
13
|
+
* the route answers them with different statuses.
|
|
14
|
+
* @module dsh-jenkins-plugin/handoff
|
|
15
|
+
*/
|
|
16
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
17
|
+
/**
|
|
18
|
+
* Declare this plugin's own message provenance kind.
|
|
19
|
+
*
|
|
20
|
+
* The harness has no shared catch-all `plugin` source kind: each producer
|
|
21
|
+
* declares its own `kind` in its own module, and consumers fall through kinds
|
|
22
|
+
* they do not know. Declaring one keeps the conversation able to tell a
|
|
23
|
+
* Jenkins-plugin message apart from something the operator typed, without
|
|
24
|
+
* pretending to be `user`.
|
|
25
|
+
*/
|
|
26
|
+
declare module '@deepseek-ai/dsh-llm' {
|
|
27
|
+
interface MessageSourceMap {
|
|
28
|
+
/** A message the Jenkins plugin authored for a conversation it was asked to wake. */
|
|
29
|
+
'jenkins-plugin': {
|
|
30
|
+
kind: 'jenkins-plugin';
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** The part of one agent this plugin uses. */
|
|
35
|
+
export interface AgentLike {
|
|
36
|
+
/** Queue a message in this agent's conversation. */
|
|
37
|
+
followup(message: unknown): void;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The agent runtime, as seen through `ctx.get('agents')`.
|
|
41
|
+
*
|
|
42
|
+
* Declared structurally rather than imported so a composition that mounts no
|
|
43
|
+
* agent runtime still loads this plugin: the lookup then reports `no-service`.
|
|
44
|
+
*/
|
|
45
|
+
export interface AgentService {
|
|
46
|
+
/**
|
|
47
|
+
* The live agent for a session.
|
|
48
|
+
* @param sessionId - durable session identity.
|
|
49
|
+
* @returns the agent, or undefined when that session has none.
|
|
50
|
+
*/
|
|
51
|
+
get(sessionId: string): AgentLike | undefined;
|
|
52
|
+
}
|
|
53
|
+
/** Whether a session can be reached, and how. */
|
|
54
|
+
export type FollowupLookup = {
|
|
55
|
+
kind: 'ok';
|
|
56
|
+
followup: (prompt: string) => void;
|
|
57
|
+
} | {
|
|
58
|
+
kind: 'no-service';
|
|
59
|
+
} | {
|
|
60
|
+
kind: 'no-session';
|
|
61
|
+
};
|
|
62
|
+
/**
|
|
63
|
+
* Resolve the message sink for one session.
|
|
64
|
+
*
|
|
65
|
+
* A missing session is not an error: a favorite may have been added in a
|
|
66
|
+
* conversation that has since been closed, and waking nothing is the correct
|
|
67
|
+
* answer there.
|
|
68
|
+
* @param ctx - plugin context, read for the optional agent runtime.
|
|
69
|
+
* @param sessionId - the session to deliver into.
|
|
70
|
+
* @returns the sink, or which half of the chain is missing.
|
|
71
|
+
*/
|
|
72
|
+
export declare function followupFor(ctx: Context, sessionId: string): FollowupLookup;
|
package/lib/handoff.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Delivering a plugin-authored message into a session.
|
|
3
|
+
*
|
|
4
|
+
* Two callers want the same thing: the panel's "hand it to the AI" route, where
|
|
5
|
+
* a person clicked, and the failure watcher, which wakes the session that
|
|
6
|
+
* followed the job. Both need "reach this session's live agent, or say precisely
|
|
7
|
+
* why not", so the lookup and the message construction live here rather than
|
|
8
|
+
* being written twice — a hand-rolled message shape is exactly the kind of thing
|
|
9
|
+
* that drifts from what the platform actually accepts.
|
|
10
|
+
*
|
|
11
|
+
* The three outcomes are distinct on purpose: a composition without an agent
|
|
12
|
+
* runtime is a different situation from a session whose agent has gone away, and
|
|
13
|
+
* the route answers them with different statuses.
|
|
14
|
+
* @module dsh-jenkins-plugin/handoff
|
|
15
|
+
*/
|
|
16
|
+
import { createUserMessage } from '@deepseek-ai/dsh-llm';
|
|
17
|
+
/**
|
|
18
|
+
* Resolve the message sink for one session.
|
|
19
|
+
*
|
|
20
|
+
* A missing session is not an error: a favorite may have been added in a
|
|
21
|
+
* conversation that has since been closed, and waking nothing is the correct
|
|
22
|
+
* answer there.
|
|
23
|
+
* @param ctx - plugin context, read for the optional agent runtime.
|
|
24
|
+
* @param sessionId - the session to deliver into.
|
|
25
|
+
* @returns the sink, or which half of the chain is missing.
|
|
26
|
+
*/
|
|
27
|
+
export function followupFor(ctx, sessionId) {
|
|
28
|
+
const agents = ctx.get('agents');
|
|
29
|
+
if (agents === undefined)
|
|
30
|
+
return { kind: 'no-service' };
|
|
31
|
+
const agent = agents.get(sessionId);
|
|
32
|
+
if (agent === undefined)
|
|
33
|
+
return { kind: 'no-session' };
|
|
34
|
+
return {
|
|
35
|
+
kind: 'ok',
|
|
36
|
+
followup: (prompt) => {
|
|
37
|
+
agent.followup(createUserMessage({
|
|
38
|
+
content: [{ type: 'text', text: prompt }],
|
|
39
|
+
// Tagged as this plugin's own message, so the conversation shows where
|
|
40
|
+
// it came from and the model can tell it apart from the operator.
|
|
41
|
+
source: { kind: 'jenkins-plugin' },
|
|
42
|
+
}));
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
package/lib/routes.js
CHANGED
|
@@ -18,10 +18,10 @@
|
|
|
18
18
|
* - `POST /jenkins-plugin/analyze` — hand a failed build to the model.
|
|
19
19
|
* @module dsh-jenkins-plugin/routes
|
|
20
20
|
*/
|
|
21
|
-
import { createUserMessage } from '@deepseek-ai/dsh-llm';
|
|
22
21
|
import { credentialRef } from '@deepseek-ai/dsh-credentials';
|
|
23
22
|
import { analysisKey, handOverFailure } from './analyze.js';
|
|
24
23
|
import { InstanceUnavailable } from './connection.js';
|
|
24
|
+
import { followupFor } from './handoff.js';
|
|
25
25
|
import { JenkinsClient, JenkinsError } from './jenkins/client.js';
|
|
26
26
|
import { buildProgress } from './jenkins/types.js';
|
|
27
27
|
import { checkWritePolicy } from './policy.js';
|
|
@@ -257,7 +257,15 @@ export function registerJenkinsRoutes(ctx, registry, config, tracker) {
|
|
|
257
257
|
const selected = new Map();
|
|
258
258
|
/** Reject an untrusted caller; true when the response is already written. */
|
|
259
259
|
const rejected = (req, res) => {
|
|
260
|
-
const
|
|
260
|
+
const trust = trustOf(ctx);
|
|
261
|
+
// No trust service means no browser can be authenticated at all, so the
|
|
262
|
+
// route is closed rather than open: these handlers serve controller data and
|
|
263
|
+
// perform controller writes, and a composition that mounted them without the
|
|
264
|
+
// fence must not hand either to whoever can reach the port. Reading the
|
|
265
|
+
// service through `ctx.get` returns `undefined` rather than throwing, which
|
|
266
|
+
// is exactly why the door has to be closed here instead of being assumed
|
|
267
|
+
// shut by the lookup.
|
|
268
|
+
const rejection = trust === undefined ? 401 : trust.requestRejection(req);
|
|
261
269
|
if (rejection === undefined)
|
|
262
270
|
return false;
|
|
263
271
|
res.statusCode = rejection;
|
|
@@ -632,6 +640,11 @@ export function registerJenkinsRoutes(ctx, registry, config, tracker) {
|
|
|
632
640
|
? record.name.trim()
|
|
633
641
|
: jobPath.split('/').at(-1) ?? jobPath;
|
|
634
642
|
const instanceId = typeof record.instance === 'string' ? record.instance : undefined;
|
|
643
|
+
// Recorded so a failure of this job can wake the conversation it was
|
|
644
|
+
// followed from; the panel sends the session it is rendering.
|
|
645
|
+
const sessionId = typeof record.session === 'string' && record.session.trim().length > 0
|
|
646
|
+
? record.session.trim()
|
|
647
|
+
: undefined;
|
|
635
648
|
const resolved = await registry.require(instanceId);
|
|
636
649
|
// Verified before it is stored: favoriting a path that does not exist
|
|
637
650
|
// would produce a card that can never resolve.
|
|
@@ -646,7 +659,7 @@ export function registerJenkinsRoutes(ctx, registry, config, tracker) {
|
|
|
646
659
|
return;
|
|
647
660
|
}
|
|
648
661
|
}
|
|
649
|
-
const favorites = await registry.setFavorite(resolved.instance.id, jobPath, name, favorited);
|
|
662
|
+
const favorites = await registry.setFavorite(resolved.instance.id, jobPath, name, favorited, sessionId);
|
|
650
663
|
sendJson(res, { ok: true, instanceId: resolved.instance.id, favorites });
|
|
651
664
|
}
|
|
652
665
|
catch (error) {
|
|
@@ -787,8 +800,8 @@ export function registerJenkinsRoutes(ctx, registry, config, tracker) {
|
|
|
787
800
|
}, 403);
|
|
788
801
|
return;
|
|
789
802
|
}
|
|
790
|
-
const
|
|
791
|
-
if (
|
|
803
|
+
const sink = followupFor(ctx, sessionId);
|
|
804
|
+
if (sink.kind === 'no-service') {
|
|
792
805
|
sendJson(res, {
|
|
793
806
|
ok: false,
|
|
794
807
|
code: 'unavailable',
|
|
@@ -796,8 +809,7 @@ export function registerJenkinsRoutes(ctx, registry, config, tracker) {
|
|
|
796
809
|
}, 503);
|
|
797
810
|
return;
|
|
798
811
|
}
|
|
799
|
-
|
|
800
|
-
if (agent === undefined) {
|
|
812
|
+
if (sink.kind === 'no-session') {
|
|
801
813
|
sendJson(res, {
|
|
802
814
|
ok: false,
|
|
803
815
|
code: 'no-session',
|
|
@@ -826,15 +838,10 @@ export function registerJenkinsRoutes(ctx, registry, config, tracker) {
|
|
|
826
838
|
buildNumber,
|
|
827
839
|
logBytes: config.analyzeLogBytes,
|
|
828
840
|
instanceName: resolved.instance.name,
|
|
829
|
-
// The
|
|
830
|
-
//
|
|
831
|
-
// the
|
|
832
|
-
followup:
|
|
833
|
-
agent.followup(createUserMessage({
|
|
834
|
-
content: [{ type: 'text', text: prompt }],
|
|
835
|
-
source: { kind: 'plugin', plugin: 'jenkins-plugin' },
|
|
836
|
-
}));
|
|
837
|
-
},
|
|
841
|
+
// The hand-off stays a pure decision about *what* to say; the sink
|
|
842
|
+
// that says it in this composition's session is `handoff.ts`, shared
|
|
843
|
+
// with the failure watcher.
|
|
844
|
+
followup: sink.followup,
|
|
838
845
|
});
|
|
839
846
|
sendJson(res, {
|
|
840
847
|
ok: true,
|
package/lib/settings.d.ts
CHANGED
|
@@ -41,6 +41,16 @@ export interface FavoriteJob {
|
|
|
41
41
|
name: string;
|
|
42
42
|
/** When it was favorited, epoch milliseconds. */
|
|
43
43
|
addedAt: number;
|
|
44
|
+
/**
|
|
45
|
+
* Session that followed this job, when the panel knew one.
|
|
46
|
+
*
|
|
47
|
+
* This is what makes a failure wake go to the right conversation: the person
|
|
48
|
+
* who followed the job is the person who wants to hear that it broke, and the
|
|
49
|
+
* watcher is unowned background work that cannot infer that from anywhere else.
|
|
50
|
+
* Favorites stored before this field existed have none, and those stay silent
|
|
51
|
+
* rather than guessing an owner.
|
|
52
|
+
*/
|
|
53
|
+
sessionId?: string;
|
|
44
54
|
}
|
|
45
55
|
/** The whole persisted configuration. */
|
|
46
56
|
export interface JenkinsSettings {
|
package/lib/settings.js
CHANGED
|
@@ -87,7 +87,8 @@ function parseFavorite(value) {
|
|
|
87
87
|
const addedAt = typeof value.addedAt === 'number'
|
|
88
88
|
? value.addedAt
|
|
89
89
|
: 0;
|
|
90
|
-
|
|
90
|
+
const sessionId = stringField(value, 'sessionId');
|
|
91
|
+
return { path, name, addedAt, ...sessionId === undefined ? {} : { sessionId } };
|
|
91
92
|
}
|
|
92
93
|
/**
|
|
93
94
|
* Read the favorites map, dropping anything unusable.
|
package/lib/watch.d.ts
CHANGED
|
@@ -46,6 +46,46 @@ export interface BuildNotice {
|
|
|
46
46
|
* @returns the announcement key.
|
|
47
47
|
*/
|
|
48
48
|
export declare function noticeKey(instanceId: string, jobPath: string, buildNumber: number): string;
|
|
49
|
+
/**
|
|
50
|
+
* The part of a notice key that names the job rather than the build.
|
|
51
|
+
*
|
|
52
|
+
* Routing a failure wake needs "which session followed this job", while a notice
|
|
53
|
+
* names one build. Both sides derive their key from this function so the pair
|
|
54
|
+
* cannot drift apart — a hand-written `${instanceId}/${jobPath}` in one place and
|
|
55
|
+
* `noticeKey` in the other is a silent mismatch that only shows up as a wake that
|
|
56
|
+
* never arrives.
|
|
57
|
+
* @param instanceId - instance id.
|
|
58
|
+
* @param jobPath - job path.
|
|
59
|
+
* @returns the job's route key.
|
|
60
|
+
*/
|
|
61
|
+
export declare function jobNoticeKey(instanceId: string, jobPath: string): string;
|
|
62
|
+
/**
|
|
63
|
+
* Whether a finished build should wake the session that followed it.
|
|
64
|
+
*
|
|
65
|
+
* Only a real failure wakes anyone. `unstable` is a build that finished with
|
|
66
|
+
* warnings and `aborted` is usually the operator's own doing, so waking a model
|
|
67
|
+
* for either would spend a turn on news the person already has.
|
|
68
|
+
* @param outcome - terminal outcome.
|
|
69
|
+
* @returns true when the outcome deserves a wake.
|
|
70
|
+
*/
|
|
71
|
+
export declare function shouldWake(outcome: string): boolean;
|
|
72
|
+
/** One followed job, as the wake routing needs to see it. */
|
|
73
|
+
export interface WakeFavorite {
|
|
74
|
+
/** Job path. */
|
|
75
|
+
path: string;
|
|
76
|
+
/** Session that followed it, when the favorite recorded one. */
|
|
77
|
+
sessionId?: string;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Map followed jobs to the session that should hear about their failures.
|
|
81
|
+
*
|
|
82
|
+
* Favorites added before this feature existed carry no session id and are simply
|
|
83
|
+
* absent from the map: guessing an owner would be worse than staying quiet.
|
|
84
|
+
* @param instanceId - the instance every favorite belongs to.
|
|
85
|
+
* @param favorites - that instance's favorites.
|
|
86
|
+
* @returns route key to session id.
|
|
87
|
+
*/
|
|
88
|
+
export declare function wakeRoutes(instanceId: string, favorites: readonly WakeFavorite[]): Map<string, string>;
|
|
49
89
|
/**
|
|
50
90
|
* Whether a build outcome is terminal.
|
|
51
91
|
* @param outcome - the outcome to test.
|
package/lib/watch.js
CHANGED
|
@@ -24,6 +24,51 @@ const TERMINAL = new Set(['success', 'failure', 'unstable', 'aborted', 'not-buil
|
|
|
24
24
|
export function noticeKey(instanceId, jobPath, buildNumber) {
|
|
25
25
|
return `${instanceId}/${jobPath}#${buildNumber}`;
|
|
26
26
|
}
|
|
27
|
+
/**
|
|
28
|
+
* The part of a notice key that names the job rather than the build.
|
|
29
|
+
*
|
|
30
|
+
* Routing a failure wake needs "which session followed this job", while a notice
|
|
31
|
+
* names one build. Both sides derive their key from this function so the pair
|
|
32
|
+
* cannot drift apart — a hand-written `${instanceId}/${jobPath}` in one place and
|
|
33
|
+
* `noticeKey` in the other is a silent mismatch that only shows up as a wake that
|
|
34
|
+
* never arrives.
|
|
35
|
+
* @param instanceId - instance id.
|
|
36
|
+
* @param jobPath - job path.
|
|
37
|
+
* @returns the job's route key.
|
|
38
|
+
*/
|
|
39
|
+
export function jobNoticeKey(instanceId, jobPath) {
|
|
40
|
+
return `${instanceId}/${jobPath}`;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Whether a finished build should wake the session that followed it.
|
|
44
|
+
*
|
|
45
|
+
* Only a real failure wakes anyone. `unstable` is a build that finished with
|
|
46
|
+
* warnings and `aborted` is usually the operator's own doing, so waking a model
|
|
47
|
+
* for either would spend a turn on news the person already has.
|
|
48
|
+
* @param outcome - terminal outcome.
|
|
49
|
+
* @returns true when the outcome deserves a wake.
|
|
50
|
+
*/
|
|
51
|
+
export function shouldWake(outcome) {
|
|
52
|
+
return outcome === 'failure';
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Map followed jobs to the session that should hear about their failures.
|
|
56
|
+
*
|
|
57
|
+
* Favorites added before this feature existed carry no session id and are simply
|
|
58
|
+
* absent from the map: guessing an owner would be worse than staying quiet.
|
|
59
|
+
* @param instanceId - the instance every favorite belongs to.
|
|
60
|
+
* @param favorites - that instance's favorites.
|
|
61
|
+
* @returns route key to session id.
|
|
62
|
+
*/
|
|
63
|
+
export function wakeRoutes(instanceId, favorites) {
|
|
64
|
+
const routes = new Map();
|
|
65
|
+
for (const favorite of favorites) {
|
|
66
|
+
if (favorite.sessionId === undefined || favorite.sessionId.length === 0)
|
|
67
|
+
continue;
|
|
68
|
+
routes.set(jobNoticeKey(instanceId, favorite.path), favorite.sessionId);
|
|
69
|
+
}
|
|
70
|
+
return routes;
|
|
71
|
+
}
|
|
27
72
|
/**
|
|
28
73
|
* Whether a build outcome is terminal.
|
|
29
74
|
* @param outcome - the outcome to test.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-jenkins-plugin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Jenkins integration for DeepSeek Harness: a live build-progress panel in the Web GUI plus model-facing tools for triggering builds, reading stages and console logs, and browsing a build's workspace",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh",
|
|
@@ -62,7 +62,7 @@
|
|
|
62
62
|
},
|
|
63
63
|
"engines": {
|
|
64
64
|
"node": "^22.19.0 || >=24.0.0",
|
|
65
|
-
"dsh": "0.1.
|
|
65
|
+
"dsh": ">=0.2.1-rc.3 <0.3.0-0"
|
|
66
66
|
},
|
|
67
67
|
"publishConfig": {
|
|
68
68
|
"access": "public"
|
|
@@ -75,34 +75,35 @@
|
|
|
75
75
|
"check:secrets": "node scripts/check-secrets.mjs",
|
|
76
76
|
"check:payload": "node scripts/check-payload.mjs",
|
|
77
77
|
"check": "npm test && npm run build && npm run check:secrets && npm run check:payload",
|
|
78
|
+
"release:check": "node scripts/release-check.mjs",
|
|
78
79
|
"prepare": "npm run build"
|
|
79
80
|
},
|
|
80
81
|
"dependencies": {
|
|
81
82
|
"@deepseek-ai/schemastery": "^3.18.2"
|
|
82
83
|
},
|
|
83
84
|
"peerDependencies": {
|
|
84
|
-
"@deepseek-ai/cordis": "^4.0.
|
|
85
|
-
"@deepseek-ai/dsh-credentials": "0.1
|
|
86
|
-
"@deepseek-ai/dsh-host-webserver": "0.1
|
|
87
|
-
"@deepseek-ai/dsh-llm": "0.1
|
|
88
|
-
"@deepseek-ai/dsh-tools": "0.1
|
|
85
|
+
"@deepseek-ai/cordis": "^4.0.5",
|
|
86
|
+
"@deepseek-ai/dsh-credentials": "^0.2.1-rc.3",
|
|
87
|
+
"@deepseek-ai/dsh-host-webserver": "^0.2.1-rc.3",
|
|
88
|
+
"@deepseek-ai/dsh-llm": "^0.2.1-rc.3",
|
|
89
|
+
"@deepseek-ai/dsh-tools": "^0.2.1-rc.3"
|
|
89
90
|
},
|
|
90
91
|
"devDependencies": {
|
|
91
|
-
"@deepseek-ai/cordis": "4.0.
|
|
92
|
-
"@deepseek-ai/dsh-client-locale": "
|
|
93
|
-
"@deepseek-ai/dsh-client-ui-conversation": "0.1
|
|
94
|
-
"@deepseek-ai/dsh-client-ui-dockkit": "
|
|
95
|
-
"@deepseek-ai/dsh-client-ui-layout": "
|
|
96
|
-
"@deepseek-ai/dsh-client-ui-primitives": "
|
|
97
|
-
"@deepseek-ai/dsh-client-ui-renderer": "0.1
|
|
98
|
-
"@deepseek-ai/dsh-client-ui-session": "
|
|
99
|
-
"@deepseek-ai/dsh-client-ui-settings": "
|
|
100
|
-
"@deepseek-ai/dsh-client-ui-sidebar-right": "
|
|
101
|
-
"@deepseek-ai/dsh-client-ui-slots": "0.1
|
|
102
|
-
"@deepseek-ai/dsh-credentials": "0.1
|
|
103
|
-
"@deepseek-ai/dsh-host-webserver": "0.1
|
|
104
|
-
"@deepseek-ai/dsh-llm": "0.1
|
|
105
|
-
"@deepseek-ai/dsh-tools": "0.1
|
|
92
|
+
"@deepseek-ai/cordis": "4.0.5",
|
|
93
|
+
"@deepseek-ai/dsh-client-locale": "0.2.1-rc.3",
|
|
94
|
+
"@deepseek-ai/dsh-client-ui-conversation": "0.2.1-rc.3",
|
|
95
|
+
"@deepseek-ai/dsh-client-ui-dockkit": "0.2.1-rc.3",
|
|
96
|
+
"@deepseek-ai/dsh-client-ui-layout": "0.2.1-rc.3",
|
|
97
|
+
"@deepseek-ai/dsh-client-ui-primitives": "0.2.1-rc.3",
|
|
98
|
+
"@deepseek-ai/dsh-client-ui-renderer": "0.2.1-rc.3",
|
|
99
|
+
"@deepseek-ai/dsh-client-ui-session": "0.2.1-rc.3",
|
|
100
|
+
"@deepseek-ai/dsh-client-ui-settings": "0.2.1-rc.3",
|
|
101
|
+
"@deepseek-ai/dsh-client-ui-sidebar-right": "0.2.1-rc.3",
|
|
102
|
+
"@deepseek-ai/dsh-client-ui-slots": "0.2.1-rc.3",
|
|
103
|
+
"@deepseek-ai/dsh-credentials": "0.2.1-rc.3",
|
|
104
|
+
"@deepseek-ai/dsh-host-webserver": "0.2.1-rc.3",
|
|
105
|
+
"@deepseek-ai/dsh-llm": "0.2.1-rc.3",
|
|
106
|
+
"@deepseek-ai/dsh-tools": "0.2.1-rc.3",
|
|
106
107
|
"@types/node": "^24.0.0",
|
|
107
108
|
"@types/react": "^19.0.0",
|
|
108
109
|
"clsx": "^2.1.1",
|