dsh-jenkins-plugin 0.1.1 → 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 CHANGED
@@ -1,5 +1,7 @@
1
1
  # dsh-jenkins-plugin
2
2
 
3
+ [![check](https://img.shields.io/github/actions/workflow/status/Endless-zby/dsh-jenkins-plugin/ci.yml?branch=main&label=check)](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.
@@ -77,6 +79,8 @@ Jobs on `denyJobs` are refused whatever the switches say, and **not a single req
77
79
 
78
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.
79
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
+
80
84
  ## Permissions and risks
81
85
 
82
86
  Please take these as read before installing:
@@ -90,7 +94,7 @@ Please take these as read before installing:
90
94
 
91
95
  ## Compatibility
92
96
 
93
- - **Harness**: built and verified against `0.1.6-alpha.2` (both `engines.dsh` and the peer dependencies pin that version).
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.
94
98
  - **Node**: matches the harness, `^22.19.0 || >=24.0.0`.
95
99
  - **Surface**: the panel is a Web GUI surface (`dsh web`), verified on the Web composition.
96
100
  - **Jenkins**: verified against a real **Jenkins 2.176.2** (FreeStyle and Maven jobs, an instance with 777 jobs). Several honest degradations:
@@ -98,7 +102,7 @@ Please take these as read before installing:
98
102
  - **Workspace reads** need the Jenkins Workspace API plugin; without it `jenkins_workspace` says so instead of pretending.
99
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.
100
104
  - **Artifacts carry no size**: Jenkins' `tree` query cannot fetch artifact sizes.
101
- - **Not finished yet**: `notifyWakeOnFailure` (wake the model when a build fails) and `uiAutoOpenOnTrigger` (open the panel on trigger) are accepted but **not active**; `maxTrackedBuilds` is advisory only — the followed list is the user's own choice, so nothing is hard-capped.
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.
102
106
 
103
107
  ## Configuration
104
108
 
@@ -142,7 +146,7 @@ Static configuration is only the **fallback layer**: instances configured in the
142
146
  | `maxWorkspaceEntries` | `500` | entry cap on one workspace directory listing |
143
147
  | `maxReadFileBytes` | `1048576` | byte cap on one workspace file read |
144
148
  | `notifyOnComplete` | `true` | announce a followed build when it finishes |
145
- | `notifyWakeOnFailure` | `false` | wake the model on failure (**not active yet**) |
149
+ | `notifyWakeOnFailure` | `false` | hand a failed followed build to the session that followed it |
146
150
  | `allowAnalyze` | `true` | allow handing a failed build to the model |
147
151
  | `analyzeLogBytes` | `16384` | log-tail bytes handed to the model |
148
152
  | `uiAutoOpenOnTrigger` | `true` | open the panel on trigger (**not active yet**) |
@@ -154,7 +158,7 @@ Static configuration is only the **fallback layer**: instances configured in the
154
158
  ```sh
155
159
  npm install # runs prepare (= build) once
156
160
  npm run typecheck
157
- npm test # 235 assertions, no test framework: every tests/*.ts runs on its own
161
+ npm test # 415 assertions, no test framework: every tests/*.ts runs on its own
158
162
  npm run build # tsc (Host half) + esbuild (browser half → lib/client.js)
159
163
  npm run check # test + secret scan + packed-payload audit
160
164
  ```
@@ -163,7 +167,7 @@ The browser half is bundled into the client module system's lazy factory (`windo
163
167
 
164
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.
165
169
 
166
- Three gates run before a release (and are what CI should run):
170
+ Three gates run before a release (and are what CI runs):
167
171
 
168
172
  ```sh
169
173
  npm run check:secrets # credential shapes in everything a commit would carry (private hostnames / user paths warn only)
@@ -171,6 +175,9 @@ npm run check:payload # reads npm pack's manifest: artifacts present, no src/
171
175
  npm pack # produce the tarball, install it into a clean profile, then publish
172
176
  ```
173
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
+
174
181
  ## License
175
182
 
176
183
  [MIT](LICENSE)
package/README.zh.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # dsh-jenkins-plugin
2
2
 
3
+ [![check](https://img.shields.io/github/actions/workflow/status/Endless-zby/dsh-jenkins-plugin/ci.yml?branch=main&label=check)](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 变成侧栏里的卡片,进度条实时走,构建结束发通知,失败了一键交给模型分析原因。
@@ -77,6 +79,8 @@ allowBuilds:
77
79
 
78
80
  **完成通知**:关注的 job 一有构建结束就用平台的后台作业机制发一次通知,同一次构建只通知一次。
79
81
 
82
+ **失败唤醒**(`notifyWakeOnFailure`,默认关):**关注的** job 一旦失败,就把它交给「你当初关注它的那个会话」——发的是和按钮相同的那份日志尾部、失败阶段与提交,所以模型能在没人盯着构建列表的时候先说明原因,答案出现在你正在读的对话里。关注项记着是哪个会话关注的,所以通知不会跑错地方。只有真正的失败才唤醒(`unstable`、`aborted` 不算),同一次构建最多唤醒一次,会话已经关闭就静默跳过。`allowAnalyze: false` 也会关掉它——两者发给模型的是同一份日志。
83
+
80
84
  ## 权限与风险
81
85
 
82
86
  装之前请如实了解这个插件会做什么:
@@ -90,7 +94,7 @@ allowBuilds:
90
94
 
91
95
  ## 兼容性
92
96
 
93
- - **Harness**:针对 `0.1.6-alpha.2` 构建并验证(`package.json` 的 `engines.dsh` 与 peerDependencies 都钉在这个版本)。
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 在其间有变化。
94
98
  - **Node**:与 harness 一致,`^22.19.0 || >=24.0.0`。
95
99
  - **载体**:面板是 Web GUI 的界面(`dsh web`),在 Web 组合上验证过。
96
100
  - **Jenkins**:在 **Jenkins 2.176.2** 上实测(FreeStyle 与 Maven job,777 个 job 的实例)。几条如实降级:
@@ -98,7 +102,7 @@ allowBuilds:
98
102
  - **读工作空间**需要 Jenkins Workspace API 插件;缺失时 `jenkins_workspace` 会如实说明该 Jenkins 不支持。
99
103
  - **Maven 版本号**取自 `mavenArtifacts/api/json`,只有 Maven job 有,且真实 2.176.2 上通常在构建**结束后**才产生,所以正在跑的 Maven 构建卡片上可能没有版本号(拿不到就不显示,绝不猜)。
100
104
  - **产物没有大小**:Jenkins 的 `tree` 查询取不到 artifact size。
101
- - **已知未完成**:`notifyWakeOnFailure`(失败时唤醒模型)与 `uiAutoOpenOnTrigger`(触发后自动展开面板)两个配置项已接受但**尚未生效**;`maxTrackedBuilds` 只是建议值,不硬性限制跟踪数量(关注列表是用户自己挑的)。
105
+ - **已知未完成**:`uiAutoOpenOnTrigger`(触发后自动展开面板)已接受但**尚未生效**;`maxTrackedBuilds` 只是建议值,不硬性限制跟踪数量(关注列表是用户自己挑的)。
102
106
 
103
107
  ## 配置
104
108
 
@@ -142,7 +146,7 @@ allowBuilds:
142
146
  | `maxWorkspaceEntries` | `500` | 单次工作空间目录列出的条目上限 |
143
147
  | `maxReadFileBytes` | `1048576` | 单个工作空间文件的读取上限 |
144
148
  | `notifyOnComplete` | `true` | 关注的构建结束时是否发通知 |
145
- | `notifyWakeOnFailure` | `false` | 失败时唤醒模型(**尚未生效**) |
149
+ | `notifyWakeOnFailure` | `false` | 关注的构建失败时,交给当初关注它的会话 |
146
150
  | `allowAnalyze` | `true` | 是否允许把失败构建交给模型分析 |
147
151
  | `analyzeLogBytes` | `16384` | 交给模型分析的日志尾部字节数 |
148
152
  | `uiAutoOpenOnTrigger` | `true` | 触发后自动展开面板(**尚未生效**) |
@@ -154,7 +158,7 @@ allowBuilds:
154
158
  ```sh
155
159
  npm install # 会跑一次 prepare(=构建)
156
160
  npm run typecheck
157
- npm test # 235 项断言,无测试框架:每个 tests/*.ts 都是可直接运行的脚本
161
+ npm test # 415 项断言,无测试框架:每个 tests/*.ts 都是可直接运行的脚本
158
162
  npm run build # tsc(Host 半)+ esbuild(浏览器半 → lib/client.js)
159
163
  npm run check # test + 密钥扫描 + 打包内容审计
160
164
  ```
@@ -171,6 +175,9 @@ npm run check:payload # 读 npm pack 的清单:产物齐不齐、有没有
171
175
  npm pack # 出 tarball,先在一个干净 profile 上装一遍再 publish
172
176
  ```
173
177
 
178
+ 完整的发布步骤(含实测脚本、陷阱速查、以及"只有人能做的三件事")见 [RELEASING.md](RELEASING.md);
179
+ `npm run release:check` 是发布前预检,会挡住重复使用版本号或 tag 这类事故。
180
+
174
181
  ## 许可证
175
182
 
176
183
  [MIT](LICENSE)
@@ -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
- const answer = await toggleFavorite(instanceId, job.path, job.name, wantFollowed);
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: {
@@ -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
- /** Add or remove one favorite. */
206
- export declare function toggleFavorite(instanceId: string, path: string, name: string, favorited: boolean): Promise<{
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
- /** Add or remove one favorite. */
107
- export async function toggleFavorite(instanceId, path, name, favorited) {
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: [
@@ -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 service.
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 the operator turned completion notices off or when the
46
- * composition mounts no job service — both are ordinary compositions, not
47
- * errors, and a watcher with nowhere to deliver would only burn requests.
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 { NoticeLog, noticeLabel, pendingNotices } from './watch.js';
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 service.
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 the operator turned completion notices off or when the
52
- * composition mounts no job service — both are ordinary compositions, not
53
- * errors, and a watcher with nowhere to deliver would only burn requests.
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.config.notifyOnComplete)
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 jobs = await this.followedStates();
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.deliver(noticeLabel(notice));
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 out = [];
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 jobs = await resolved.client.listJobs();
119
- const byPath = new Map(jobs.map(job => [job.path, job]));
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
- out.push({
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 out;
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.
@@ -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 rejection = trustOf(ctx)?.requestRejection(req);
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 agents = ctx.get('agents');
791
- if (agents === undefined) {
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
- const agent = agents.get(sessionId);
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 message factory lives here rather than in the hand-off, so the
830
- // hand-off stays a pure decision about *what* to say and this stays
831
- // the plumbing that says it in this composition's session.
832
- followup: (prompt) => {
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
- return { path, name, addedAt };
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.1.1",
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.6-alpha.2"
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.2",
85
- "@deepseek-ai/dsh-credentials": "0.1.6-alpha.2",
86
- "@deepseek-ai/dsh-host-webserver": "0.1.6-alpha.2",
87
- "@deepseek-ai/dsh-llm": "0.1.6-alpha.2",
88
- "@deepseek-ai/dsh-tools": "0.1.6-alpha.2"
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.2",
92
- "@deepseek-ai/dsh-client-locale": "^0.1.6-alpha.2",
93
- "@deepseek-ai/dsh-client-ui-conversation": "0.1.6-alpha.2",
94
- "@deepseek-ai/dsh-client-ui-dockkit": "^0.1.6-alpha.2",
95
- "@deepseek-ai/dsh-client-ui-layout": "^0.1.6-alpha.2",
96
- "@deepseek-ai/dsh-client-ui-primitives": "^0.1.6-alpha.2",
97
- "@deepseek-ai/dsh-client-ui-renderer": "0.1.6-alpha.2",
98
- "@deepseek-ai/dsh-client-ui-session": "^0.1.6-alpha.2",
99
- "@deepseek-ai/dsh-client-ui-settings": "^0.1.6-alpha.2",
100
- "@deepseek-ai/dsh-client-ui-sidebar-right": "^0.1.6-alpha.2",
101
- "@deepseek-ai/dsh-client-ui-slots": "0.1.6-alpha.2",
102
- "@deepseek-ai/dsh-credentials": "0.1.6-alpha.2",
103
- "@deepseek-ai/dsh-host-webserver": "0.1.6-alpha.2",
104
- "@deepseek-ai/dsh-llm": "0.1.6-alpha.2",
105
- "@deepseek-ai/dsh-tools": "0.1.6-alpha.2",
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",