@sparkelf/dsh-task-bus 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -1,5 +1,5 @@
1
- import { mkdir, writeFile } from "node:fs/promises";
2
- import { basename, isAbsolute, join } from "node:path";
1
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
2
+ import { basename, dirname, isAbsolute, join } from "node:path";
3
3
  import { credentialRef } from "@deepseek-ai/dsh-credentials";
4
4
  import { createUserMessage } from "@deepseek-ai/dsh-llm";
5
5
  import { defineTool } from "@deepseek-ai/dsh-tools";
@@ -81,6 +81,38 @@ const open = {
81
81
  type: "object",
82
82
  additionalProperties: true
83
83
  };
84
+ /** Where this workspace stores the connector's own enabled state. */
85
+ const CONNECTOR_STATE_PATH = join(process.env.DSH_HOME?.trim() || "/workspace/.dataops/dsh", "task-bus", "connector-state.json");
86
+ const DEFAULT_CONNECTOR_STATE = {
87
+ enabled: false,
88
+ capabilities: { "approval.push": true }
89
+ };
90
+ /** Read the workspace connector state; a missing/corrupt file is a safe disabled state. */
91
+ async function readConnectorState(path = CONNECTOR_STATE_PATH) {
92
+ try {
93
+ const text = await readFile(path, "utf8");
94
+ const value = JSON.parse(text);
95
+ return {
96
+ enabled: value.enabled === true,
97
+ capabilities: {
98
+ ...DEFAULT_CONNECTOR_STATE.capabilities,
99
+ ...typeof value.capabilities === "object" && value.capabilities !== null ? value.capabilities : {}
100
+ }
101
+ };
102
+ } catch {
103
+ return {
104
+ ...DEFAULT_CONNECTOR_STATE,
105
+ capabilities: { ...DEFAULT_CONNECTOR_STATE.capabilities }
106
+ };
107
+ }
108
+ }
109
+ /** Persist the connector state atomically; the host and UI read the same file. */
110
+ async function writeConnectorState(next, path = CONNECTOR_STATE_PATH) {
111
+ await mkdir(dirname(path), { recursive: true });
112
+ const temporary = path + ".tmp-" + process.pid.toString();
113
+ await writeFile(temporary, JSON.stringify(next, null, 2), "utf8");
114
+ await rename(temporary, path);
115
+ }
84
116
  const renderJson = (_args, value) => [{
85
117
  type: "text",
86
118
  text: JSON.stringify(value, null, 2)
@@ -107,16 +139,45 @@ const apply = (ctx) => {
107
139
  const url = new URL(request.url ?? "/", "http://dsh.internal");
108
140
  const route = url.pathname.slice(17).replace(/^\/+/u, "");
109
141
  try {
142
+ if (route === "connector") {
143
+ if (request.method === "GET") {
144
+ writeJson(response, 200, await readConnectorState());
145
+ syncTools().catch(() => {
146
+ disposeTools();
147
+ });
148
+ return;
149
+ }
150
+ if (request.method === "POST") {
151
+ const body = await readJsonBody(request);
152
+ if (typeof body?.enabled !== "boolean") throw new Error("connector state requires enabled:boolean");
153
+ const current = await readConnectorState();
154
+ const capabilities = typeof body.capabilities === "object" && body.capabilities !== null ? {
155
+ ...current.capabilities,
156
+ ...body.capabilities
157
+ } : current.capabilities;
158
+ await writeConnectorState({
159
+ enabled: body.enabled,
160
+ capabilities
161
+ });
162
+ await syncTools();
163
+ writeJson(response, 200, await readConnectorState());
164
+ return;
165
+ }
166
+ writeJson(response, 405, { error: "connector state supports GET and POST" });
167
+ return;
168
+ }
110
169
  if (request.method === "GET" && route === "health") {
111
170
  resolveBase();
112
171
  writeJson(response, 200, {
113
172
  ok: true,
114
173
  source: "dataops_approval"
115
174
  });
175
+ syncTools();
116
176
  return;
117
177
  }
118
178
  if (request.method === "GET" && route === "overview") {
119
179
  writeJson(response, 200, absoluteApprovalLinks(await client("/overview")));
180
+ syncTools();
120
181
  return;
121
182
  }
122
183
  if (request.method === "GET" && route === "attachment-materialize") {
@@ -237,107 +298,188 @@ const apply = (ctx) => {
237
298
  }
238
299
  }
239
300
  }), "task-bus: browser API");
240
- ctx.effect(() => host.tools.register(tool("task_center_list", "List DataOps Task Center cards visible to the current workspace user, including approval tasks and generic Task Bus tasks. Approval cards are read-only projections of DataOps tasks.", {
241
- status: {
242
- type: "string",
243
- enum: [
244
- "all",
245
- "pending",
246
- "done"
247
- ]
301
+ /**
302
+ * Tool definitions are static; only their registration is permission-dependent.
303
+ *
304
+ * This is the plugin-side disclosure boundary: the model never sees a tool the
305
+ * current DataOps principal cannot use. Authorization itself remains at the
306
+ * DataOps Task Center API; this layer only adjusts what the connector contributes.
307
+ */
308
+ const toolDefinitions = [
309
+ {
310
+ permission: "workflow:task:read",
311
+ definition: tool("task_center_list", "List DataOps Task Center cards visible to the current workspace user, including approval tasks and generic Task Bus tasks. Approval cards are read-only projections of DataOps tasks.", {
312
+ status: {
313
+ type: "string",
314
+ enum: [
315
+ "all",
316
+ "pending",
317
+ "done"
318
+ ]
319
+ },
320
+ scope: {
321
+ type: "string",
322
+ enum: [
323
+ "mine",
324
+ "created",
325
+ "all"
326
+ ]
327
+ },
328
+ limit: { type: "number" }
329
+ }, async (args) => {
330
+ const status = typeof args.status === "string" ? args.status : "all";
331
+ const limit = typeof args.limit === "number" ? Math.max(1, Math.min(50, args.limit)) : 20;
332
+ const result = absoluteApprovalLinks(await client("/overview"));
333
+ const items = (result.items ?? []).filter((item) => {
334
+ if (status === "pending") return item.status !== "completed" && item.status !== "cancelled";
335
+ if (status === "done") return item.status === "completed" || item.status === "cancelled";
336
+ return true;
337
+ }).slice(0, limit);
338
+ return {
339
+ ...result,
340
+ items,
341
+ total: items.length
342
+ };
343
+ })
248
344
  },
249
- scope: {
250
- type: "string",
251
- enum: [
252
- "mine",
253
- "created",
254
- "all"
255
- ]
345
+ {
346
+ permission: "workflow:task:read",
347
+ definition: tool("task_center_get", "Read one Task Center card by its stable id. Use approval:<id> for a DataOps approval task or task:<uuid> for a generic Task Bus task.", { id: {
348
+ type: "string",
349
+ required: true
350
+ } }, async (args) => {
351
+ const id = String(args.id ?? "");
352
+ if (id.startsWith("approval:")) return client(`/items/${encodeURIComponent(id)}`);
353
+ if (id.startsWith("task:")) return client(`/generic/${encodeURIComponent(id.slice(5))}`);
354
+ throw new Error("Task Center id must start with approval: or task:");
355
+ })
256
356
  },
257
- limit: { type: "number" }
258
- }, async (args) => {
259
- const status = typeof args.status === "string" ? args.status : "all";
260
- typeof args.scope === "string" && args.scope;
261
- const limit = typeof args.limit === "number" ? Math.max(1, Math.min(50, args.limit)) : 20;
262
- const result = absoluteApprovalLinks(await client("/overview"));
263
- const items = (result.items ?? []).filter((item) => {
264
- if (status === "pending") return item.status !== "completed" && item.status !== "cancelled";
265
- if (status === "done") return item.status === "completed" || item.status === "cancelled";
266
- return true;
267
- }).slice(0, limit);
268
- return {
269
- ...result,
270
- items,
271
- total: items.length
272
- };
273
- })), "task-bus: list tool");
274
- ctx.effect(() => host.tools.register(tool("task_center_get", "Read one Task Center card by its stable id. Use approval:<id> for a DataOps approval task or task:<uuid> for a generic Task Bus task.", { id: {
275
- type: "string",
276
- required: true
277
- } }, async (args) => {
278
- const id = String(args.id ?? "");
279
- if (id.startsWith("approval:")) return client(`/items/${encodeURIComponent(id)}`);
280
- if (id.startsWith("task:")) return client(`/generic/${encodeURIComponent(id.slice(5))}`);
281
- throw new Error("Task Center id must start with approval: or task:");
282
- })), "task-bus: get tool");
283
- ctx.effect(() => host.tools.register(tool("task_center_create", [
284
- "Create a generic Task Center card ONLY when the user explicitly asks you to create a task/card in this turn.",
285
- "Never call this tool proactively, speculatively, as a side effect of another request, or merely because work could be tracked.",
286
- "If the user did not explicitly ask for a new card, do not call it; ask for confirmation instead.",
287
- "This never creates or mutates a DataOps approval task."
288
- ].join(" "), {
289
- title: {
290
- type: "string",
291
- required: true
357
+ {
358
+ permission: "workflow:task:create",
359
+ definition: tool("task_center_create", [
360
+ "Create a generic Task Center card ONLY when the user explicitly asks you to create a task/card in this turn.",
361
+ "Never call this tool proactively, speculatively, as a side effect of another request, or merely because work could be tracked.",
362
+ "If the user did not explicitly ask for a new card, do not call it; ask for confirmation instead.",
363
+ "This never creates or mutates a DataOps approval task."
364
+ ].join(" "), {
365
+ title: {
366
+ type: "string",
367
+ required: true
368
+ },
369
+ description: { type: "string" },
370
+ source_type: { type: "string" },
371
+ source_id: { type: "string" },
372
+ assignee_user_id: { type: "string" },
373
+ priority: { type: "string" },
374
+ user_requested: {
375
+ type: "boolean",
376
+ required: true,
377
+ description: "Must be true only when the user explicitly requested creation of this card in the current turn."
378
+ }
379
+ }, async (args) => {
380
+ if (args.user_requested !== true) throw new Error("task_center_create requires an explicit user request; do not create Task Center cards proactively");
381
+ return client("/generic", "POST", {
382
+ title: String(args.title ?? ""),
383
+ description: typeof args.description === "string" ? args.description : void 0,
384
+ sourceType: typeof args.source_type === "string" ? args.source_type : "agent",
385
+ sourceId: typeof args.source_id === "string" ? args.source_id : void 0,
386
+ assigneeUserId: typeof args.assignee_user_id === "string" ? args.assignee_user_id : void 0,
387
+ priority: typeof args.priority === "string" ? args.priority : void 0
388
+ });
389
+ })
292
390
  },
293
- description: { type: "string" },
294
- source_type: { type: "string" },
295
- source_id: { type: "string" },
296
- assignee_user_id: { type: "string" },
297
- priority: { type: "string" },
298
- user_requested: {
299
- type: "boolean",
300
- required: true,
301
- description: "Must be true only when the user explicitly requested creation of this card in the current turn."
391
+ {
392
+ permission: "workflow:task:execute",
393
+ definition: tool("task_center_transition", "Advance a generic Task Bus task. Approval tasks must be handled through the existing DataOps approval UI and APIs; this tool refuses approval ids.", {
394
+ id: {
395
+ type: "string",
396
+ required: true
397
+ },
398
+ action: {
399
+ type: "string",
400
+ required: true,
401
+ enum: [
402
+ "accept",
403
+ "start",
404
+ "submit",
405
+ "complete",
406
+ "cancel",
407
+ "block",
408
+ "unblock"
409
+ ]
410
+ },
411
+ comment: { type: "string" }
412
+ }, async (args) => {
413
+ const id = String(args.id ?? "");
414
+ if (!id.startsWith("task:")) throw new Error("Only generic task:<uuid> cards can be transitioned here");
415
+ return client(`/generic/${encodeURIComponent(id.slice(5))}/transition`, "POST", {
416
+ action: String(args.action ?? ""),
417
+ comment: typeof args.comment === "string" ? args.comment : void 0
418
+ });
419
+ })
302
420
  }
303
- }, async (args) => {
304
- if (args.user_requested !== true) throw new Error("task_center_create requires an explicit user request; do not create Task Center cards proactively");
305
- return client("/generic", "POST", {
306
- title: String(args.title ?? ""),
307
- description: typeof args.description === "string" ? args.description : void 0,
308
- sourceType: typeof args.source_type === "string" ? args.source_type : "agent",
309
- sourceId: typeof args.source_id === "string" ? args.source_id : void 0,
310
- assigneeUserId: typeof args.assignee_user_id === "string" ? args.assignee_user_id : void 0,
311
- priority: typeof args.priority === "string" ? args.priority : void 0
312
- });
313
- })), "task-bus: create tool");
314
- ctx.effect(() => host.tools.register(tool("task_center_transition", "Advance a generic Task Bus task. Approval tasks must be handled through the existing DataOps approval UI and APIs; this tool refuses approval ids.", {
315
- id: {
316
- type: "string",
317
- required: true
318
- },
319
- action: {
320
- type: "string",
321
- required: true,
322
- enum: [
323
- "accept",
324
- "start",
325
- "submit",
326
- "complete",
327
- "cancel",
328
- "block",
329
- "unblock"
330
- ]
331
- },
332
- comment: { type: "string" }
333
- }, async (args) => {
334
- const id = String(args.id ?? "");
335
- if (!id.startsWith("task:")) throw new Error("Only generic task:<uuid> cards can be transitioned here");
336
- return client(`/generic/${encodeURIComponent(id.slice(5))}/transition`, "POST", {
337
- action: String(args.action ?? ""),
338
- comment: typeof args.comment === "string" ? args.comment : void 0
421
+ ];
422
+ const toolDisposers = /* @__PURE__ */ new Map();
423
+ let syncGeneration = 0;
424
+ const disposeTools = () => {
425
+ for (const dispose of toolDisposers.values()) dispose();
426
+ toolDisposers.clear();
427
+ };
428
+ /** Read the current principal's DataOps permission keys without caching them per tool call. */
429
+ const loadPermissionKeys = async () => {
430
+ try {
431
+ const token = (await host.credentials.resolve(credentialRef(DATAOPS_CREDENTIAL_REF)))?.value?.trim();
432
+ if (!token) return null;
433
+ const response = await fetch(resolveBase() + "/api/ai/review/context", { headers: {
434
+ authorization: `Bearer ${token}`,
435
+ accept: "application/json"
436
+ } });
437
+ if (!response.ok) return null;
438
+ const body = await response.json();
439
+ const values = Array.isArray(body.permissions) ? body.permissions : Array.isArray(body.permissionKeys) ? body.permissionKeys : null;
440
+ return values === null ? null : new Set(values.filter((item) => typeof item === "string"));
441
+ } catch {
442
+ return null;
443
+ }
444
+ };
445
+ /**
446
+ * Reconcile connector state, principal permissions and the registered tool set.
447
+ *
448
+ * Called at plugin start, after every browser API request, and after a connector
449
+ * enable/disable change. The tool execution path never repeats this work.
450
+ */
451
+ const syncTools = async () => {
452
+ const generation = ++syncGeneration;
453
+ const state = await readConnectorState();
454
+ if (!state.enabled || state.capabilities["approval.push"] === false) {
455
+ disposeTools();
456
+ return;
457
+ }
458
+ const permissions = await loadPermissionKeys();
459
+ if (permissions === null) {
460
+ disposeTools();
461
+ return;
462
+ }
463
+ if (generation !== syncGeneration) return;
464
+ for (const item of toolDefinitions) {
465
+ const existing = toolDisposers.get(item.definition.name);
466
+ if (permissions.has(item.permission)) {
467
+ if (existing === void 0) toolDisposers.set(item.definition.name, host.tools.register(item.definition));
468
+ } else if (existing !== void 0) {
469
+ existing();
470
+ toolDisposers.delete(item.definition.name);
471
+ }
472
+ }
473
+ };
474
+ ctx.effect(() => {
475
+ syncTools().catch(() => {
476
+ disposeTools();
339
477
  });
340
- })), "task-bus: transition tool");
478
+ return () => {
479
+ syncGeneration += 1;
480
+ disposeTools();
481
+ };
482
+ }, "task-bus: permission-aware tool disclosure");
341
483
  };
342
484
  //#endregion
343
- export { TASK_BUS_ROUTE, apply, inject, name };
485
+ export { CONNECTOR_STATE_PATH, TASK_BUS_ROUTE, apply, inject, name, readConnectorState, writeConnectorState };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sparkelf/dsh-task-bus",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "DataOps Task Center and Task Bus for the official DSH Web shell.",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -37,6 +37,10 @@
37
37
  "cordis.patch.yml",
38
38
  "README.md"
39
39
  ],
40
+ "scripts": {
41
+ "build": "tsdown",
42
+ "typecheck": "tsc --noEmit -p tsconfig.json"
43
+ },
40
44
  "engines": {
41
45
  "node": "^22.19.0 || >=24.0.0"
42
46
  },
@@ -81,9 +85,5 @@
81
85
  "@deepseek-ai/dsh-credentials": "0.1.7-rc.2",
82
86
  "@deepseek-ai/dsh-client-ui-conversation": "0.1.7-rc.2",
83
87
  "@deepseek-ai/dsh-client-ui-primitives": "0.2.0-rc.2"
84
- },
85
- "scripts": {
86
- "build": "tsdown",
87
- "typecheck": "tsc --noEmit -p tsconfig.json"
88
88
  }
89
- }
89
+ }