mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 00:33:20 +08:00
feat: execute AI Office jobs in Harness
This commit is contained in:
parent
b0606159e0
commit
3fa2cc05b2
14 changed files with 730 additions and 148 deletions
|
|
@ -60,7 +60,9 @@ All nine built-in channels can send JPEG, PNG, and WebP images, plus GIFs sent a
|
|||
|
||||
The **AI Office** page lets the local Harness connect outward to a public Office. The machine needs no public IP, forwarded port, or WebSocket server. The Device Token is written only to the Harness credential provider; the ordinary config file contains only the device ID, Office origin, workspace aliases, and instruction-preset aliases. Office selects aliases and never receives local absolute paths.
|
||||
|
||||
The current protocol is `office-harness.v1`. The connector authenticates and advertises capabilities with `POST /api/harness/connector/heartbeat`, then opens the downstream event plane with `GET /api/harness/connector/stream` over SSE. Job state and results use outbound HTTPS POSTs from Harness. The settings page derives every fixed hook from the Office Base URL and reconnects with backoff after a disconnect. Until the Office hooks are deployed, an HTTP 404 is explicitly reported as a pending hook.
|
||||
The current protocol is `office-harness.v1`. The connector authenticates and advertises capabilities with `POST /api/harness/connector/heartbeat`, then opens the downstream event plane with `GET /api/harness/connector/stream` over SSE. The settings page derives every fixed hook from the Office Base URL and reconnects with backoff after a disconnect.
|
||||
|
||||
A `job.available` event makes the local connector fetch the payload, validate Workspace/Preset aliases, claim a 90-second lease, and renew it every 30 seconds. It creates an isolated Harness Session, reports safe status/tool/text progress, and writes a terminal result exactly once. Tool approvals and follow-up questions surface in Office; approve, reject, and text answers return over SSE to the original Session. Heartbeats and leases recover from dropped connections.
|
||||
|
||||
A successful heartbeat response must be JSON: `{"ok":true,"protocolVersion":"office-harness.v1"}`. This makes a successful connection test proof of a compatible Office Connector instead of any URL that happens to return 200.
|
||||
|
||||
|
|
@ -163,7 +165,7 @@ Example: send `/models`, then `/model 2` to switch to the second model in the li
|
|||
- Maintains the Host, client, and runtime sources for all nine channels and the Office Connector in this repository without external standalone plugins.
|
||||
- Follows the DeepSeek Harness language preference and switches the settings UI live between Chinese and English.
|
||||
- Uses logos for WeChat, Feishu, DingTalk, WeCom, QQ, Slack, Telegram, Discord, WhatsApp, and AI Office navigation without enable/disable switches.
|
||||
- Keeps RPC endpoints, credentials, connection supervision, and session mappings isolated by channel.
|
||||
- Keeps RPC endpoints, credentials, connection supervision, and session mappings isolated by IM channel; the Office Connector separately owns Device credentials, Job leases, approval waits, and concurrency limits.
|
||||
- Returns only QR codes, the public Slack Manifest, and redacted status data to the browser. Manually entered secrets and Tokens travel one way to the local Host; no RPC response returns App Secrets, `bot_token`, DingTalk `client_secret`, WeCom Secrets, QQ `app_secret`, Slack Bot/App Tokens, Telegram/Discord Bot Tokens, WhatsApp linked-device keys, AI Office Device Tokens, or raw user identifiers.
|
||||
|
||||
## Local development
|
||||
|
|
|
|||
|
|
@ -63,7 +63,9 @@ Connect IM bots to DeepSeek Harness by scanning a QR code, using an App Manifest
|
|||
|
||||
「AI Office」页让本机 Harness 主动连接公网 Office,本机无需公网 IP、端口转发或 WebSocket 服务。Device Token 只写入 Harness 凭据存储;普通配置文件仅保存设备 ID、Office Origin、工作区 alias 和 Instruction Preset alias。Office 只能选择 alias,不会收到本机绝对路径。
|
||||
|
||||
当前协议版本为 `office-harness.v1`。连接器使用 `POST /api/harness/connector/heartbeat` 完成鉴权和能力握手,再以 `GET /api/harness/connector/stream` 建立 SSE 下行;任务状态与结果使用 Harness 主动发起的 HTTPS POST。设置页会从 Office Base URL 自动展示全部固定 Hook,并在断线后按退避策略自动重连。Office 端尚未部署时,HTTP 404 会明确显示为 Hook 未就绪。
|
||||
当前协议版本为 `office-harness.v1`。连接器使用 `POST /api/harness/connector/heartbeat` 完成鉴权和能力握手,再以 `GET /api/harness/connector/stream` 建立 SSE 下行;设置页会从 Office Base URL 自动展示全部固定 Hook,并在断线后按退避策略自动重连。
|
||||
|
||||
Office 的 `job.available` 会触发本机拉取任务、校验 Workspace/Preset alias、领取 90 秒租约并每 30 秒续租。连接器创建独立 Harness Session,把状态、工具名和增量文字安全回传 Office,终态只允许写入一次。Harness 发起的工具审批或补充问题会进入 Office 人工面板;批准、拒绝或文字答案再经 SSE 回到原 Session,断线时由租约与 Heartbeat 恢复。
|
||||
|
||||
Heartbeat 成功响应必须是 JSON:`{"ok":true,"protocolVersion":"office-harness.v1"}`。这使「连接测试通过」代表命中了兼容的 Office Connector,而不只是某个碰巧返回 200 的网址。
|
||||
|
||||
|
|
@ -166,7 +168,7 @@ Telegram 默认拒绝所有入站消息。请在 Web profile 的 `cordis.patch.y
|
|||
- 九个渠道及 Office Connector 的 Host、客户端与运行时源码都在本仓库维护,不依赖外部独立插件;
|
||||
- 设置页跟随 DeepSeek Harness 的语言选择,在中文和 English 之间即时切换;
|
||||
- 左侧使用 Logo 切换微信、飞书、钉钉、企业微信、QQ、Slack、Telegram、Discord、WhatsApp 和 AI Office,不使用启用/停用开关;
|
||||
- 九个渠道保持独立的 RPC、凭据、连接监督和会话映射;
|
||||
- 九个 IM 渠道保持独立的 RPC、凭据、连接监督和会话映射;Office Connector 另行维护设备凭据、Job 租约、审批等待与并发上限;
|
||||
- 浏览器只获得二维码、Manifest 和脱敏状态;手动输入的 Secret 或 Token 仅单向提交给本机 Host,任何 RPC 响应都不会返回 App Secret、`bot_token`、钉钉 `client_secret`、企业微信 Secret、QQ `app_secret`、Slack Bot/App Token、Telegram/Discord Bot Token、WhatsApp 关联设备密钥、AI Office Device Token 或原始用户标识。
|
||||
|
||||
## 本地开发
|
||||
|
|
|
|||
|
|
@ -411,6 +411,8 @@ var EN = Object.freeze({
|
|||
"\u6700\u8FD1\u5FC3\u8DF3": "Last heartbeat",
|
||||
"\u6700\u8FD1\u4E8B\u4EF6": "Last event",
|
||||
"\u91CD\u8FDE\u6B21\u6570": "Reconnects",
|
||||
"\u8FD0\u884C Job": "Running Jobs",
|
||||
"\u5B8C\u6210 Job": "Completed Jobs",
|
||||
"\u5C1A\u65E0": "None yet",
|
||||
"\u8BBE\u5907\u8FDE\u63A5": "Device connection",
|
||||
"Token \u53EA\u5199\u5165\u672C\u673A\u51ED\u636E\u5B58\u50A8": "Token is written only to the local credential store",
|
||||
|
|
@ -5749,7 +5751,9 @@ function OfficeSettingsTab({ rpcCall, initialStatus }) {
|
|||
h2("div", { className: "dof-metric" }, h2("span", null, "\u6700\u8FD1\u5FC3\u8DF3"), h2("strong", null, health.lastHeartbeatAt ?? "\u5C1A\u65E0")),
|
||||
h2("div", { className: "dof-metric" }, h2("span", null, "\u6700\u8FD1\u4E8B\u4EF6"), h2("strong", null, health.lastEventType ?? "\u5C1A\u65E0")),
|
||||
h2("div", { className: "dof-metric" }, h2("span", null, "\u91CD\u8FDE\u6B21\u6570"), h2("strong", null, String(health.reconnects ?? 0))),
|
||||
h2("div", { className: "dof-metric" }, h2("span", null, "Job Offer"), h2("strong", null, String(health.jobsOffered ?? 0)))
|
||||
h2("div", { className: "dof-metric" }, h2("span", null, "Job Offer"), h2("strong", null, String(health.jobsOffered ?? 0))),
|
||||
h2("div", { className: "dof-metric" }, h2("span", null, "\u8FD0\u884C Job"), h2("strong", null, String(health.jobs?.running ?? 0))),
|
||||
h2("div", { className: "dof-metric" }, h2("span", null, "\u5B8C\u6210 Job"), h2("strong", null, String(health.jobs?.completed ?? 0)))
|
||||
) : null,
|
||||
h2(
|
||||
"div",
|
||||
|
|
@ -5870,7 +5874,7 @@ var CSS5 = `
|
|||
.dof-error, .dof-notice { margin: 10px 0 0; padding: 9px 11px; border-radius: 9px; font-size: 12px; line-height: 1.5; }
|
||||
.dof-error { color: var(--dsw-alias-state-error-primary, #d54941); background: var(--dsw-alias-state-error-secondary, #fff0ef); }
|
||||
.dof-notice { color: var(--dsw-alias-label-secondary, #646a73); background: var(--dsw-alias-interactive-bg-hover, #f7f8fa); }
|
||||
.dof-metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; margin-top: 12px; }
|
||||
.dof-metrics { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; margin-top: 12px; }
|
||||
.dof-metric { min-width: 0; padding: 9px; border-radius: 10px; background: var(--dsw-alias-interactive-bg-hover, #f7f8fa); }
|
||||
.dof-metric span { display: block; color: var(--dsw-alias-label-tertiary, #8f959e); font-size: 10px; }
|
||||
.dof-metric strong { display: block; overflow: hidden; margin-top: 4px; color: var(--dsw-alias-label-primary, #1f2329); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
|
|
|||
263
lib/index.js
263
lib/index.js
File diff suppressed because one or more lines are too long
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "@xmanrui/dsh-im",
|
||||
"version": "0.13.0",
|
||||
"version": "0.14.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@xmanrui/dsh-im",
|
||||
"version": "0.13.0",
|
||||
"version": "0.14.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tencent-connect/qqbot-connector": "1.2.0",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@xmanrui/dsh-im",
|
||||
"version": "0.13.0",
|
||||
"version": "0.14.0",
|
||||
"description": "把九种 IM 机器人和公网 AI Office 接入本机 DeepSeek Harness。 Connect nine IM channels and a public AI Office to a local DeepSeek Harness.",
|
||||
"keywords": [
|
||||
"deepseek-harness",
|
||||
|
|
|
|||
|
|
@ -106,7 +106,9 @@ export function OfficeSettingsTab({ rpcCall, initialStatus }) {
|
|||
h('div', { className: 'dof-metric' }, h('span', null, '最近心跳'), h('strong', null, health.lastHeartbeatAt ?? '尚无')),
|
||||
h('div', { className: 'dof-metric' }, h('span', null, '最近事件'), h('strong', null, health.lastEventType ?? '尚无')),
|
||||
h('div', { className: 'dof-metric' }, h('span', null, '重连次数'), h('strong', null, String(health.reconnects ?? 0))),
|
||||
h('div', { className: 'dof-metric' }, h('span', null, 'Job Offer'), h('strong', null, String(health.jobsOffered ?? 0)))) : null,
|
||||
h('div', { className: 'dof-metric' }, h('span', null, 'Job Offer'), h('strong', null, String(health.jobsOffered ?? 0))),
|
||||
h('div', { className: 'dof-metric' }, h('span', null, '运行 Job'), h('strong', null, String(health.jobs?.running ?? 0))),
|
||||
h('div', { className: 'dof-metric' }, h('span', null, '完成 Job'), h('strong', null, String(health.jobs?.completed ?? 0)))) : null,
|
||||
h('div', { className: 'dof-card' },
|
||||
h('div', { className: 'dof-cardTitle' }, h('h4', null, '设备连接'), h('span', null, 'Token 只写入本机凭据存储')),
|
||||
h('div', { className: 'dof-grid' },
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ const CSS = `
|
|||
.dof-error, .dof-notice { margin: 10px 0 0; padding: 9px 11px; border-radius: 9px; font-size: 12px; line-height: 1.5; }
|
||||
.dof-error { color: var(--dsw-alias-state-error-primary, #d54941); background: var(--dsw-alias-state-error-secondary, #fff0ef); }
|
||||
.dof-notice { color: var(--dsw-alias-label-secondary, #646a73); background: var(--dsw-alias-interactive-bg-hover, #f7f8fa); }
|
||||
.dof-metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; margin-top: 12px; }
|
||||
.dof-metrics { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; margin-top: 12px; }
|
||||
.dof-metric { min-width: 0; padding: 9px; border-radius: 10px; background: var(--dsw-alias-interactive-bg-hover, #f7f8fa); }
|
||||
.dof-metric span { display: block; color: var(--dsw-alias-label-tertiary, #8f959e); font-size: 10px; }
|
||||
.dof-metric strong { display: block; overflow: hidden; margin-top: 4px; color: var(--dsw-alias-label-primary, #1f2329); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ const EN = Object.freeze({
|
|||
'最近心跳': 'Last heartbeat',
|
||||
'最近事件': 'Last event',
|
||||
'重连次数': 'Reconnects',
|
||||
'运行 Job': 'Running Jobs',
|
||||
'完成 Job': 'Completed Jobs',
|
||||
'尚无': 'None yet',
|
||||
'设备连接': 'Device connection',
|
||||
'Token 只写入本机凭据存储': 'Token is written only to the local credential store',
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import { join, resolve } from 'node:path';
|
|||
import { OfficeConfigStore } from '../../../../src/channels/office/config-store.mjs';
|
||||
import { OfficeController } from '../../../../src/channels/office/office-controller.mjs';
|
||||
import { OfficeRuntime } from '../../../../src/channels/office/office-runtime.mjs';
|
||||
import { HarnessClient } from '../../../../src/channels/shared/harness-client.mjs';
|
||||
import { harnessOrigin } from '../shared/production.mjs';
|
||||
|
||||
export function officePaths(config = {}) {
|
||||
const dshHome = resolve(config.dshHome ?? process.env.DSH_HOME ?? join(homedir(), '.dsh'));
|
||||
|
|
@ -15,14 +17,28 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|||
const Store = internals.ConfigStore ?? OfficeConfigStore;
|
||||
const Controller = internals.Controller ?? OfficeController;
|
||||
const Runtime = internals.Runtime ?? OfficeRuntime;
|
||||
const ResolvedHarness = internals.HarnessClient ?? HarnessClient;
|
||||
const paths = officePaths(config);
|
||||
const configStore = await new Store(paths.config).load();
|
||||
const logger = typeof ctx.logger === 'function' ? ctx.logger('dsh-im:office') : (ctx.logger ?? console);
|
||||
const harnessBaseUrl = harnessOrigin(ctx.webServer, config.harnessBaseUrl);
|
||||
const createHarness = internals.createHarness ?? (({ workspace }) => new ResolvedHarness({
|
||||
baseUrl: harnessBaseUrl,
|
||||
workspace,
|
||||
autostart: false,
|
||||
dshBin: config.dshBin ?? 'dsh',
|
||||
rpcIdPrefix: 'office',
|
||||
logPrefix: 'dsh-im:office',
|
||||
}));
|
||||
const controller = new Controller({
|
||||
credentials: ctx.credentials,
|
||||
configStore,
|
||||
logger,
|
||||
createRuntime: (options) => new Runtime({ ...options, ...(internals.transport ? { transport: internals.transport } : {}) }),
|
||||
createRuntime: (options) => new Runtime({
|
||||
...options,
|
||||
createHarness,
|
||||
...(internals.transport ? { transport: internals.transport } : {}),
|
||||
}),
|
||||
});
|
||||
await controller.initialize();
|
||||
return { controller, close: () => controller.close(), paths };
|
||||
|
|
|
|||
374
src/channels/office/office-job-executor.mjs
Normal file
374
src/channels/office/office-job-executor.mjs
Normal file
|
|
@ -0,0 +1,374 @@
|
|||
import { setTimeout as sleep } from 'node:timers/promises';
|
||||
|
||||
import { harnessApprovalText } from '../shared/harness-approval.mjs';
|
||||
import {
|
||||
harnessAnswerForQuestion,
|
||||
harnessQuestionText,
|
||||
validHarnessQuestion,
|
||||
} from '../shared/harness-question.mjs';
|
||||
|
||||
const JOB_ID = /^job-[a-f0-9]{32}$/;
|
||||
const RENEW_MS = 30_000;
|
||||
const COMPLETED_LIMIT = 2_048;
|
||||
|
||||
function abortError() {
|
||||
return new DOMException('Office Job was cancelled', 'AbortError');
|
||||
}
|
||||
|
||||
function clean(value, max = 8_000) {
|
||||
return typeof value === 'string' ? value.trim().slice(0, max) : '';
|
||||
}
|
||||
|
||||
function safeFailure(error) {
|
||||
if (error?.code === 'office-job-alias-invalid') return error.message;
|
||||
if (error?.code === 'turn-stopped') return '本机 Harness 已停止这次执行。';
|
||||
if (error?.code === 'office-job-conflict') return 'Office Job 已被领取、取消或结束。';
|
||||
return '本机 Harness 未能完成任务;请检查 Harness 会话后重试。';
|
||||
}
|
||||
|
||||
function renderPrompt(job, preset) {
|
||||
return [
|
||||
'# AI Office Handoff',
|
||||
'',
|
||||
'你正在本机 DeepSeek Harness 中继续一个来自 AI Office 的任务。',
|
||||
'只在当前 Workspace 内行动。完成后必须返回:结果摘要、改动文件、验证证据、未解决风险。',
|
||||
'',
|
||||
'## 本机 Instruction Preset',
|
||||
preset,
|
||||
'',
|
||||
...(clean(job.instruction) ? ['## 本轮补充指令', clean(job.instruction), ''] : []),
|
||||
'## Office 时间线',
|
||||
clean(job.markdown, 200_000),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function approvalRequest(interaction) {
|
||||
const id = clean(interaction?.interactionId || interaction?.rpcId, 180);
|
||||
if (!id || typeof interaction?.respond !== 'function') return null;
|
||||
if (interaction.kind === 'approval') {
|
||||
const prompt = harnessApprovalText(interaction.payload, {
|
||||
toolCall: interaction.toolCall,
|
||||
maxArgumentsLength: 10_000,
|
||||
});
|
||||
if (!prompt) return null;
|
||||
return {
|
||||
id,
|
||||
kind: 'approval',
|
||||
title: `批准 ${clean(interaction.payload?.toolName, 120) || 'Harness 工具'}`,
|
||||
prompt,
|
||||
toolName: clean(interaction.payload?.toolName, 120),
|
||||
};
|
||||
}
|
||||
if (interaction.kind !== 'question') return null;
|
||||
const questions = interaction.payload?.questions;
|
||||
if (!Array.isArray(questions) || questions.length === 0
|
||||
|| questions.some((question) => !validHarnessQuestion(question))) return null;
|
||||
return {
|
||||
id,
|
||||
kind: 'question',
|
||||
title: questions.length > 1 ? `Harness 需要 ${questions.length} 项补充信息` : clean(questions[0].header || questions[0].question, 160),
|
||||
prompt: questions.map((question, index) => harnessQuestionText(question, index, questions.length)).join('\n\n---\n\n'),
|
||||
questions,
|
||||
};
|
||||
}
|
||||
|
||||
function waitForReply(entry, approvalId) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (entry.controller.signal.aborted) return reject(entry.controller.signal.reason ?? abortError());
|
||||
const onAbort = () => {
|
||||
entry.approvals.delete(approvalId);
|
||||
reject(entry.controller.signal.reason ?? abortError());
|
||||
};
|
||||
entry.controller.signal.addEventListener('abort', onAbort, { once: true });
|
||||
entry.approvals.set(approvalId, {
|
||||
resolve(value) {
|
||||
entry.controller.signal.removeEventListener('abort', onAbort);
|
||||
entry.approvals.delete(approvalId);
|
||||
resolve(value);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export class OfficeJobExecutor {
|
||||
#config;
|
||||
#transport;
|
||||
#createHarness;
|
||||
#logger;
|
||||
#active = new Map();
|
||||
#queued = new Set();
|
||||
#completed = new Set();
|
||||
#closed = false;
|
||||
#status = {
|
||||
running: 0,
|
||||
completed: 0,
|
||||
failed: 0,
|
||||
lastJobId: null,
|
||||
lastJobAt: null,
|
||||
};
|
||||
|
||||
constructor({ config, transport, createHarness, logger = console }) {
|
||||
if (!config || !transport || typeof createHarness !== 'function') {
|
||||
throw new TypeError('OfficeJobExecutor requires config, transport, and createHarness');
|
||||
}
|
||||
this.#config = config;
|
||||
this.#transport = transport;
|
||||
this.#createHarness = createHarness;
|
||||
this.#logger = logger;
|
||||
}
|
||||
|
||||
get status() { return structuredClone(this.#status); }
|
||||
|
||||
offer(jobId) {
|
||||
if (this.#closed || !JOB_ID.test(jobId) || this.#active.has(jobId)
|
||||
|| this.#queued.has(jobId) || this.#completed.has(jobId)) return false;
|
||||
this.#queued.add(jobId);
|
||||
this.#drain();
|
||||
return true;
|
||||
}
|
||||
|
||||
handleEvent(event) {
|
||||
const jobId = clean(event?.data?.jobId, 80);
|
||||
if (!JOB_ID.test(jobId)) return false;
|
||||
if (event.type === 'job.available') return this.offer(jobId);
|
||||
if (event.type === 'job.cancel') return this.cancel(jobId);
|
||||
if (event.type === 'approval.reply') {
|
||||
const entry = this.#active.get(jobId);
|
||||
const approvalId = clean(event.data?.approvalId, 180);
|
||||
const pending = entry?.approvals.get(approvalId);
|
||||
if (!pending) return false;
|
||||
pending.resolve({
|
||||
decision: event.data?.decision === 'approved' ? 'approved' : 'rejected',
|
||||
answer: clean(event.data?.answer),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
cancel(jobId) {
|
||||
this.#queued.delete(jobId);
|
||||
const entry = this.#active.get(jobId);
|
||||
if (!entry) return false;
|
||||
entry.cancelled = true;
|
||||
entry.controller.abort(abortError());
|
||||
if (entry.sessionId && entry.harness) {
|
||||
void entry.harness.rpc('session.cancel', {
|
||||
sessionId: entry.sessionId,
|
||||
keepInbox: true,
|
||||
}, 10_000).catch(() => undefined);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async close() {
|
||||
this.#closed = true;
|
||||
this.#queued.clear();
|
||||
for (const entry of this.#active.values()) entry.controller.abort(abortError());
|
||||
await Promise.allSettled([...this.#active.values()].map((entry) => entry.task));
|
||||
}
|
||||
|
||||
#drain() {
|
||||
while (!this.#closed && this.#active.size < this.#config.maxConcurrency && this.#queued.size > 0) {
|
||||
const jobId = this.#queued.values().next().value;
|
||||
this.#queued.delete(jobId);
|
||||
const entry = {
|
||||
controller: new AbortController(),
|
||||
approvals: new Map(),
|
||||
harness: null,
|
||||
sessionId: null,
|
||||
leaseToken: null,
|
||||
cancelled: false,
|
||||
lastProgressAt: 0,
|
||||
lastProgress: '',
|
||||
task: null,
|
||||
};
|
||||
entry.task = this.#run(jobId, entry).finally(() => {
|
||||
this.#active.delete(jobId);
|
||||
this.#status.running = this.#active.size;
|
||||
this.#drain();
|
||||
});
|
||||
this.#active.set(jobId, entry);
|
||||
this.#status.running = this.#active.size;
|
||||
void entry.task.catch((error) => this.#logger.warn?.('[dsh-im:office] Job task ended:', error.message));
|
||||
}
|
||||
}
|
||||
|
||||
async #run(jobId, entry) {
|
||||
const signal = entry.controller.signal;
|
||||
let renewTask = null;
|
||||
try {
|
||||
const fetched = await this.#transport.getJob(jobId, { signal });
|
||||
const job = fetched?.job;
|
||||
if (!job || job.id !== jobId) throw new Error('Office returned an invalid Job payload');
|
||||
const accepted = await this.#transport.acceptJob(jobId, { signal });
|
||||
if (!clean(accepted?.leaseToken, 200)) throw new Error('Office returned an invalid Job lease');
|
||||
entry.leaseToken = accepted.leaseToken;
|
||||
renewTask = this.#renew(jobId, entry);
|
||||
const workspace = this.#config.workspaces[job.workspaceAlias];
|
||||
const preset = this.#config.instructionPresets[job.instructionPreset];
|
||||
if (!workspace || !preset) {
|
||||
const error = new Error('Office Job 引用了本机未配置的 Workspace/Preset alias。');
|
||||
error.code = 'office-job-alias-invalid';
|
||||
throw error;
|
||||
}
|
||||
entry.harness = this.#createHarness({ workspace });
|
||||
await this.#progress(jobId, entry, { kind: 'status', message: `已领取 Job,准备 Workspace alias:${job.workspaceAlias}` }, true);
|
||||
entry.sessionId = await entry.harness.createSession({ signal, workspace });
|
||||
await this.#progress(jobId, entry, { kind: 'status', message: 'Harness Session 已创建。', sessionId: entry.sessionId }, true);
|
||||
const answer = await entry.harness.ask(entry.sessionId, renderPrompt(job, preset), {
|
||||
timeoutMs: 30 * 60_000,
|
||||
signal,
|
||||
onUpdate: (update) => this.#handleUpdate(jobId, entry, update),
|
||||
onInteraction: (interaction) => this.#handleInteraction(jobId, entry, interaction),
|
||||
onInteractionResolved: (resolution) => {
|
||||
const id = clean(resolution?.interactionId, 180);
|
||||
entry.approvals.get(id)?.resolve({ decision: 'rejected', answer: '' });
|
||||
},
|
||||
});
|
||||
if (signal.aborted) throw signal.reason ?? abortError();
|
||||
await this.#transport.completeJob(jobId, entry.leaseToken, {
|
||||
resultMarkdown: answer,
|
||||
sessionId: entry.sessionId,
|
||||
}, { signal });
|
||||
this.#status.completed += 1;
|
||||
this.#rememberCompleted(jobId);
|
||||
} catch (error) {
|
||||
if (!entry.cancelled && entry.leaseToken) {
|
||||
await this.#transport.failJob(jobId, entry.leaseToken, {
|
||||
error: safeFailure(error),
|
||||
...(entry.sessionId ? { sessionId: entry.sessionId } : {}),
|
||||
}).catch(() => undefined);
|
||||
this.#status.failed += 1;
|
||||
}
|
||||
if (error?.code === 'office-job-conflict' || error?.code === 'office-hook-unavailable') {
|
||||
this.#rememberCompleted(jobId);
|
||||
return;
|
||||
}
|
||||
if (!entry.cancelled && !signal.aborted) this.#logger.warn?.(`[dsh-im:office] Job ${jobId} failed:`, error.message);
|
||||
} finally {
|
||||
entry.controller.abort(abortError());
|
||||
await renewTask?.catch(() => undefined);
|
||||
this.#status.lastJobId = jobId;
|
||||
this.#status.lastJobAt = new Date().toISOString();
|
||||
}
|
||||
}
|
||||
|
||||
async #renew(jobId, entry) {
|
||||
while (!entry.controller.signal.aborted) {
|
||||
try { await sleep(RENEW_MS, undefined, { signal: entry.controller.signal }); }
|
||||
catch { return; }
|
||||
try {
|
||||
await this.#transport.renewJob(jobId, entry.leaseToken, { signal: entry.controller.signal });
|
||||
const snapshot = await this.#transport.getJob(jobId, { signal: entry.controller.signal });
|
||||
const approval = snapshot?.job?.approval;
|
||||
if (approval && (approval.status === 'approved' || approval.status === 'rejected')) {
|
||||
entry.approvals.get(approval.id)?.resolve({
|
||||
decision: approval.status,
|
||||
answer: clean(approval.answer),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (entry.controller.signal.aborted) return;
|
||||
entry.cancelled = true;
|
||||
entry.controller.abort(error);
|
||||
if (entry.sessionId && entry.harness) {
|
||||
void entry.harness.rpc('session.cancel', {
|
||||
sessionId: entry.sessionId,
|
||||
keepInbox: true,
|
||||
}, 10_000).catch(() => undefined);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async #handleUpdate(jobId, entry, update) {
|
||||
const message = update?.type === 'tool' ? `正在使用 ${clean(update.name, 160) || 'Harness 工具'}…`
|
||||
: clean(update?.text, 4_000);
|
||||
if (!message) return;
|
||||
const key = `${update.type}:${message}`;
|
||||
if (key === entry.lastProgress) return;
|
||||
const now = Date.now();
|
||||
if (update.type === 'text' && now - entry.lastProgressAt < 1_000) return;
|
||||
entry.lastProgress = key;
|
||||
entry.lastProgressAt = now;
|
||||
await this.#progress(jobId, entry, {
|
||||
kind: update.type === 'tool' ? 'tool' : update.type === 'text' ? 'text' : 'status',
|
||||
message,
|
||||
...(entry.sessionId ? { sessionId: entry.sessionId } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
#progress(jobId, entry, value, required = false) {
|
||||
const request = this.#transport.progressJob(jobId, entry.leaseToken, value, {
|
||||
signal: entry.controller.signal,
|
||||
});
|
||||
return required ? request : request.catch((error) => {
|
||||
this.#logger.debug?.('[dsh-im:office] ignored a progress delivery failure:', error.message);
|
||||
});
|
||||
}
|
||||
|
||||
async #handleInteraction(jobId, entry, interaction) {
|
||||
const request = approvalRequest(interaction);
|
||||
if (!request) {
|
||||
if (interaction?.kind === 'approval' && typeof interaction.respond === 'function') {
|
||||
await interaction.respond({
|
||||
ok: true,
|
||||
value: {
|
||||
sessionId: interaction.sessionId,
|
||||
approvalId: interaction.payload?.approvalId,
|
||||
outcome: 'rejected',
|
||||
},
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
const reply = waitForReply(entry, request.id);
|
||||
try {
|
||||
await this.#transport.requestApproval(jobId, entry.leaseToken, request, {
|
||||
signal: entry.controller.signal,
|
||||
});
|
||||
const decision = await reply;
|
||||
if (request.kind === 'approval') {
|
||||
await interaction.respond({
|
||||
ok: true,
|
||||
value: {
|
||||
sessionId: interaction.sessionId,
|
||||
approvalId: interaction.payload.approvalId,
|
||||
outcome: decision.decision === 'approved' ? 'allowed-once' : 'rejected',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (decision.decision !== 'approved') {
|
||||
await interaction.respond({
|
||||
ok: false,
|
||||
error: { code: 'cancelled', message: 'The AI Office user rejected this question.', details: {} },
|
||||
});
|
||||
return;
|
||||
}
|
||||
const answers = clean(decision.answer).split(/\r?\n/);
|
||||
await interaction.respond({
|
||||
ok: true,
|
||||
value: {
|
||||
sessionId: interaction.sessionId,
|
||||
answer: {
|
||||
answers: request.questions.map((question, index) => (
|
||||
harnessAnswerForQuestion(question, answers[index] || answers[0] || '')
|
||||
)),
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
entry.approvals.delete(request.id);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
#rememberCompleted(jobId) {
|
||||
this.#completed.add(jobId);
|
||||
if (this.#completed.size <= COMPLETED_LIMIT) return;
|
||||
this.#completed.delete(this.#completed.values().next().value);
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ import { setTimeout as sleep } from 'node:timers/promises';
|
|||
|
||||
import { OfficeTransport } from './office-transport.mjs';
|
||||
import { OFFICE_PROTOCOL_VERSION } from './protocol.mjs';
|
||||
import { OfficeJobExecutor } from './office-job-executor.mjs';
|
||||
|
||||
const RETRY_DELAYS = Object.freeze([1_000, 3_000, 10_000, 30_000]);
|
||||
|
||||
|
|
@ -24,8 +25,9 @@ export class OfficeRuntime {
|
|||
#controller = null;
|
||||
#task = null;
|
||||
#status;
|
||||
#jobs;
|
||||
|
||||
constructor({ config, token, logger = console, transport }) {
|
||||
constructor({ config, token, logger = console, transport, createHarness, jobExecutor }) {
|
||||
this.#config = config;
|
||||
this.#token = token;
|
||||
this.#logger = logger;
|
||||
|
|
@ -37,9 +39,20 @@ export class OfficeRuntime {
|
|||
lastEventAt: null, lastEventId: null, lastEventType: null, reconnects: 0,
|
||||
jobsOffered: 0, error: null,
|
||||
};
|
||||
this.#jobs = jobExecutor ?? (createHarness ? new OfficeJobExecutor({
|
||||
config,
|
||||
transport: this.#transport,
|
||||
createHarness,
|
||||
logger,
|
||||
}) : null);
|
||||
}
|
||||
|
||||
get status() { return structuredClone(this.#status); }
|
||||
get status() {
|
||||
return structuredClone({
|
||||
...this.#status,
|
||||
...(this.#jobs ? { jobs: this.#jobs.status } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
capabilities() {
|
||||
return {
|
||||
|
|
@ -75,9 +88,10 @@ export class OfficeRuntime {
|
|||
const attemptController = new AbortController();
|
||||
const attemptSignal = AbortSignal.any([signal, attemptController.signal]);
|
||||
try {
|
||||
await this.#transport.heartbeat(this.capabilities(), { signal: attemptSignal });
|
||||
const heartbeat = await this.#transport.heartbeat(this.capabilities(), { signal: attemptSignal });
|
||||
this.#offerJobs(heartbeat?.jobs);
|
||||
this.#status.lastHeartbeatAt = new Date().toISOString();
|
||||
const heartbeat = this.#heartbeatLoop(attemptSignal);
|
||||
const heartbeatTask = this.#heartbeatLoop(attemptSignal);
|
||||
const stream = this.#transport.stream({
|
||||
signal: attemptSignal,
|
||||
lastEventId: this.#status.lastEventId,
|
||||
|
|
@ -92,9 +106,10 @@ export class OfficeRuntime {
|
|||
this.#status.lastEventId = event.id ?? this.#status.lastEventId;
|
||||
this.#status.lastEventType = event.type;
|
||||
if (event.type === 'job.available') this.#status.jobsOffered += 1;
|
||||
this.#jobs?.handleEvent(event);
|
||||
},
|
||||
});
|
||||
await Promise.race([stream, heartbeat]);
|
||||
await Promise.race([stream, heartbeatTask]);
|
||||
} catch (error) {
|
||||
if (signal.aborted) break;
|
||||
this.#status.connected = false;
|
||||
|
|
@ -115,16 +130,25 @@ export class OfficeRuntime {
|
|||
async #heartbeatLoop(signal) {
|
||||
while (!signal.aborted) {
|
||||
await sleep(this.#config.heartbeatSeconds * 1_000, undefined, { signal });
|
||||
await this.#transport.heartbeat(this.capabilities(), { signal });
|
||||
const heartbeat = await this.#transport.heartbeat(this.capabilities(), { signal });
|
||||
this.#offerJobs(heartbeat?.jobs);
|
||||
this.#status.lastHeartbeatAt = new Date().toISOString();
|
||||
}
|
||||
}
|
||||
|
||||
#offerJobs(jobs) {
|
||||
if (!Array.isArray(jobs)) return;
|
||||
for (const job of jobs) {
|
||||
if (typeof job?.id === 'string' && this.#jobs?.offer(job.id)) this.#status.jobsOffered += 1;
|
||||
}
|
||||
}
|
||||
|
||||
async stop() {
|
||||
const task = this.#task;
|
||||
this.#controller?.abort();
|
||||
this.#controller = null;
|
||||
if (task) await task.catch(() => undefined);
|
||||
await this.#jobs?.close();
|
||||
this.#status.connected = false;
|
||||
this.#status.state = 'idle';
|
||||
return this.status;
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@ import { OFFICE_HOOK_PATHS, OFFICE_PROTOCOL_VERSION, officeHookUrls } from './pr
|
|||
function safeTransportError(operation, response) {
|
||||
const error = new Error(`AI Office ${operation} failed: HTTP ${response.status}`);
|
||||
error.code = response.status === 401 ? 'invalid-device-token'
|
||||
: response.status === 404 ? 'office-hook-unavailable' : 'office-transport-failed';
|
||||
: response.status === 404 ? 'office-hook-unavailable'
|
||||
: response.status === 409 ? 'office-job-conflict' : 'office-transport-failed';
|
||||
return error;
|
||||
}
|
||||
|
||||
|
|
@ -80,6 +81,57 @@ export class OfficeTransport {
|
|||
return value;
|
||||
}
|
||||
|
||||
async #jobRequest(path, { method = 'POST', body, leaseToken, signal } = {}) {
|
||||
const headers = this.#headers({ accept: 'application/json' });
|
||||
if (body !== undefined) headers['content-type'] = 'application/json';
|
||||
if (leaseToken) headers['x-harness-lease-token'] = leaseToken;
|
||||
let response;
|
||||
try {
|
||||
response = await this.#fetch(new URL(path, this.#baseUrl), {
|
||||
method,
|
||||
headers,
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
signal,
|
||||
redirect: 'error',
|
||||
cache: 'no-store',
|
||||
});
|
||||
} catch (error) {
|
||||
if (isAbort(error, signal)) throw error;
|
||||
throw transportFailure('AI Office Job request could not be completed', undefined, error);
|
||||
}
|
||||
if (!response.ok) throw safeTransportError('Job request', response);
|
||||
try { return await response.json(); }
|
||||
catch (error) { throw transportFailure('AI Office Job returned invalid JSON', 'office-protocol-mismatch', error); }
|
||||
}
|
||||
|
||||
async getJob(jobId, options = {}) {
|
||||
return this.#jobRequest(OFFICE_HOOK_PATHS.job.replace(':id', jobId), { ...options, method: 'GET' });
|
||||
}
|
||||
|
||||
async acceptJob(jobId, options = {}) {
|
||||
return this.#jobRequest(OFFICE_HOOK_PATHS.accept.replace(':id', jobId), options);
|
||||
}
|
||||
|
||||
async renewJob(jobId, leaseToken, options = {}) {
|
||||
return this.#jobRequest(OFFICE_HOOK_PATHS.renew.replace(':id', jobId), { ...options, leaseToken });
|
||||
}
|
||||
|
||||
async progressJob(jobId, leaseToken, body, options = {}) {
|
||||
return this.#jobRequest(OFFICE_HOOK_PATHS.progress.replace(':id', jobId), { ...options, leaseToken, body });
|
||||
}
|
||||
|
||||
async requestApproval(jobId, leaseToken, body, options = {}) {
|
||||
return this.#jobRequest(OFFICE_HOOK_PATHS.approval.replace(':id', jobId), { ...options, leaseToken, body });
|
||||
}
|
||||
|
||||
async completeJob(jobId, leaseToken, body, options = {}) {
|
||||
return this.#jobRequest(OFFICE_HOOK_PATHS.result.replace(':id', jobId), { ...options, leaseToken, body });
|
||||
}
|
||||
|
||||
async failJob(jobId, leaseToken, body, options = {}) {
|
||||
return this.#jobRequest(OFFICE_HOOK_PATHS.fail.replace(':id', jobId), { ...options, leaseToken, body });
|
||||
}
|
||||
|
||||
async stream({ signal, lastEventId, onOpen, onEvent }) {
|
||||
const headers = this.#headers({ accept: 'text/event-stream' });
|
||||
if (lastEventId) headers['last-event-id'] = lastEventId;
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { renderToStaticMarkup } from 'react-dom/server';
|
|||
import { OfficeConfigStore } from '../../../src/channels/office/config-store.mjs';
|
||||
import { OfficeController } from '../../../src/channels/office/office-controller.mjs';
|
||||
import { OfficeTransport } from '../../../src/channels/office/office-transport.mjs';
|
||||
import { OfficeJobExecutor } from '../../../src/channels/office/office-job-executor.mjs';
|
||||
import {
|
||||
OFFICE_PROTOCOL_VERSION,
|
||||
OFFICE_RPC_ENDPOINTS,
|
||||
|
|
@ -20,6 +21,15 @@ import { OfficeSettingsTab } from '../../../plugin-src/client/channels/office/in
|
|||
|
||||
const TOKEN = 'office-device-token-ABCDEFGHIJKLMNOPQRSTUVWXYZ-123456';
|
||||
|
||||
async function eventually(predicate, timeoutMs = 2_000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (await predicate()) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
assert.fail('condition did not become true');
|
||||
}
|
||||
|
||||
function config(overrides = {}) {
|
||||
return {
|
||||
version: 1,
|
||||
|
|
@ -95,6 +105,28 @@ test('AI Office transport authenticates heartbeat and parses SSE frames', async
|
|||
}]);
|
||||
});
|
||||
|
||||
test('AI Office transport uses fixed Job hooks and keeps the lease outside JSON bodies', async () => {
|
||||
const calls = [];
|
||||
const transport = new OfficeTransport({
|
||||
baseUrl: 'https://fission.gridmind.ai',
|
||||
deviceId: 'mac-a004',
|
||||
token: TOKEN,
|
||||
fetchImpl: async (url, options) => {
|
||||
calls.push({ url: String(url), options });
|
||||
return Response.json({ ok: true });
|
||||
},
|
||||
});
|
||||
const jobId = 'job-1234567890abcdef1234567890abcdef';
|
||||
await transport.getJob(jobId);
|
||||
await transport.acceptJob(jobId);
|
||||
await transport.progressJob(jobId, 'lease-secret', { kind: 'status', message: 'running' });
|
||||
assert.equal(calls[0].url, `https://fission.gridmind.ai/api/harness/connector/jobs/${jobId}`);
|
||||
assert.equal(calls[0].options.method, 'GET');
|
||||
assert.equal(calls[1].url.endsWith(`/${jobId}/accept`), true);
|
||||
assert.equal(calls[2].options.headers['x-harness-lease-token'], 'lease-secret');
|
||||
assert.equal(calls[2].options.body.includes('lease-secret'), false);
|
||||
});
|
||||
|
||||
test('AI Office controller stores the token in credentials and returns only safe status', async () => {
|
||||
let stored = null;
|
||||
const credentialStore = credentials();
|
||||
|
|
@ -196,3 +228,70 @@ test('AI Office settings renders connection fields and fixed hook preview', () =
|
|||
assert.match(markup, /Workspace 映射/);
|
||||
assert.match(markup, /api\/harness\/connector\/stream/);
|
||||
});
|
||||
|
||||
test('AI Office Job executor claims, reports, approves, and returns one Harness result', async () => {
|
||||
const jobId = 'job-1234567890abcdef1234567890abcdef';
|
||||
const progress = [];
|
||||
const approvals = [];
|
||||
const results = [];
|
||||
const responses = [];
|
||||
let executor;
|
||||
const transport = {
|
||||
getJob: async () => ({ job: {
|
||||
id: jobId,
|
||||
workspaceAlias: 'office-project',
|
||||
instructionPreset: 'execute',
|
||||
instruction: 'Return evidence.',
|
||||
markdown: '# Office timeline',
|
||||
} }),
|
||||
acceptJob: async () => ({ leaseToken: 'lease-token-1234567890' }),
|
||||
renewJob: async () => ({ leaseExpiresAt: new Date(Date.now() + 90_000).toISOString() }),
|
||||
progressJob: async (_id, _lease, value) => { progress.push(value); return { ok: true }; },
|
||||
requestApproval: async (_id, _lease, value) => {
|
||||
approvals.push(value);
|
||||
queueMicrotask(() => executor.handleEvent({
|
||||
type: 'approval.reply',
|
||||
data: { jobId, approvalId: value.id, decision: 'approved' },
|
||||
}));
|
||||
return { ok: true };
|
||||
},
|
||||
completeJob: async (_id, _lease, value) => { results.push(value); return { ok: true }; },
|
||||
failJob: async () => { throw new Error('must not fail'); },
|
||||
};
|
||||
const harness = {
|
||||
createSession: async () => 'session-office-one',
|
||||
ask: async (_sessionId, prompt, options) => {
|
||||
assert.match(prompt, /Return evidence/);
|
||||
await options.onUpdate({ type: 'tool', name: 'apply_patch' });
|
||||
await options.onInteraction({
|
||||
kind: 'approval',
|
||||
interactionId: 'approval-one',
|
||||
sessionId: 'session-office-one',
|
||||
payload: {
|
||||
type: 'approval/requested', sessionId: 'session-office-one',
|
||||
approvalId: 'approval-one', toolName: 'apply_patch', callId: 'call-one',
|
||||
},
|
||||
toolCall: { callId: 'call-one', name: 'apply_patch', arguments: '{"patch":"safe"}' },
|
||||
respond: async (value) => { responses.push(value); return { accepted: true }; },
|
||||
});
|
||||
return '# Completed\n\nVerified.';
|
||||
},
|
||||
rpc: async () => ({ ok: true }),
|
||||
};
|
||||
executor = new OfficeJobExecutor({
|
||||
config: {
|
||||
maxConcurrency: 1,
|
||||
workspaces: { 'office-project': '/Users/a004/project' },
|
||||
instructionPresets: { execute: 'Execute carefully.' },
|
||||
},
|
||||
transport,
|
||||
createHarness: () => harness,
|
||||
});
|
||||
assert.equal(executor.offer(jobId), true);
|
||||
await eventually(() => executor.status.completed === 1);
|
||||
assert.equal(approvals[0].kind, 'approval');
|
||||
assert.equal(responses[0].value.outcome, 'allowed-once');
|
||||
assert.ok(progress.some((item) => item.kind === 'tool'));
|
||||
assert.deepEqual(results, [{ resultMarkdown: '# Completed\n\nVerified.', sessionId: 'session-office-one' }]);
|
||||
await executor.close();
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue