@xmanrui/dsh-im 0.13.0 → 0.14.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.en.md +25 -10
- package/README.md +26 -11
- package/assets/logo-dsh-im-connecting-readme-3x2.png +0 -0
- package/assets/logo-dsh-im-connecting-square-v4.png +0 -0
- package/assets/logo-plugin-phone.png +0 -0
- package/lib/client.js +803 -171
- package/lib/index.js +142 -130
- package/package.json +4 -3
- package/plugin-src/client/build.mjs +1 -6
- package/plugin-src/client/channel-logos.js +12 -0
- package/plugin-src/client/channels/office/api.js +44 -0
- package/plugin-src/client/channels/office/index.js +152 -0
- package/plugin-src/client/channels/office/styles.js +50 -0
- package/plugin-src/client/channels/shared/token-api.js +5 -1
- package/plugin-src/client/channels/shared/token-channel.js +16 -1
- package/plugin-src/client/channels/telegram/api.js +17 -2
- package/plugin-src/client/channels/telegram/index.js +112 -0
- package/plugin-src/client/channels/telegram/styles.js +26 -0
- package/plugin-src/client/i18n.js +68 -0
- package/plugin-src/client/index.js +21 -11
- package/plugin-src/client/styles.js +5 -3
- package/plugin-src/host/channels/office/index.mjs +10 -0
- package/plugin-src/host/channels/office/production.mjs +45 -0
- package/plugin-src/host/channels/office/rpc.mjs +48 -0
- package/plugin-src/host/channels/shared/production.mjs +9 -1
- package/plugin-src/host/channels/telegram/production.mjs +2 -0
- package/plugin-src/host/channels/telegram/rpc.mjs +58 -8
- package/plugin-src/host/index.mjs +3 -0
- package/src/channels/discord/discord-api.mjs +1 -1
- package/src/channels/office/config-store.mjs +107 -0
- package/src/channels/office/office-controller.mjs +167 -0
- package/src/channels/office/office-job-executor.mjs +390 -0
- package/src/channels/office/office-runtime.mjs +171 -0
- package/src/channels/office/office-transport.mjs +170 -0
- package/src/channels/office/protocol.mjs +43 -0
- package/src/channels/shared/token-bot-controller.mjs +28 -0
- package/src/channels/shared/token-config-store.mjs +14 -1
- package/src/channels/telegram/config-store.mjs +62 -1
- package/src/channels/telegram/telegram-controller.mjs +25 -1
- package/src/channels/telegram/telegram-runtime.mjs +26 -1
- package/src/channels/weixin/weixin-api.mjs +1 -1
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
import { normalizeOfficeBaseUrl, officeHookUrls } from './protocol.mjs';
|
|
4
|
+
import { normalizeOfficeConfig } from './config-store.mjs';
|
|
5
|
+
import { OfficeRuntime } from './office-runtime.mjs';
|
|
6
|
+
|
|
7
|
+
function clean(value) { return typeof value === 'string' && value.trim() ? value.trim() : null; }
|
|
8
|
+
|
|
9
|
+
export function officeTokenRef(baseUrl, deviceId) {
|
|
10
|
+
const digest = createHash('sha256').update(`${baseUrl}\n${deviceId}`).digest('hex').slice(0, 24).toUpperCase();
|
|
11
|
+
return `DSH_OFFICE_DEVICE_TOKEN_${digest}`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class OfficeController {
|
|
15
|
+
#credentials;
|
|
16
|
+
#store;
|
|
17
|
+
#logger;
|
|
18
|
+
#createRuntime;
|
|
19
|
+
#runtime = null;
|
|
20
|
+
#transition = Promise.resolve();
|
|
21
|
+
|
|
22
|
+
constructor({ credentials, configStore, logger = console, createRuntime }) {
|
|
23
|
+
if (!credentials?.resolve || !credentials?.set || !credentials?.unset) {
|
|
24
|
+
throw new TypeError('AI Office requires the Harness credential provider');
|
|
25
|
+
}
|
|
26
|
+
if (!configStore?.get || !configStore?.save || !configStore?.clear) {
|
|
27
|
+
throw new TypeError('AI Office requires a config store');
|
|
28
|
+
}
|
|
29
|
+
this.#credentials = credentials;
|
|
30
|
+
this.#store = configStore;
|
|
31
|
+
this.#logger = logger;
|
|
32
|
+
this.#createRuntime = createRuntime ?? ((options) => new OfficeRuntime(options));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async initialize() {
|
|
36
|
+
const config = this.#store.get();
|
|
37
|
+
if (config) {
|
|
38
|
+
const credential = await this.#credentials.resolve(config.deviceTokenRef).catch(() => undefined);
|
|
39
|
+
if (credential?.value) await this.#start(config, credential.value);
|
|
40
|
+
}
|
|
41
|
+
return this.status();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async configure(input = {}) {
|
|
45
|
+
return this.#serial(async () => {
|
|
46
|
+
const previous = this.#store.get();
|
|
47
|
+
const requestedBaseUrl = clean(input.baseUrl);
|
|
48
|
+
const deviceId = clean(input.deviceId);
|
|
49
|
+
if (!requestedBaseUrl || !deviceId) throw new TypeError('Office URL and Device ID are required');
|
|
50
|
+
const baseUrl = normalizeOfficeBaseUrl(requestedBaseUrl).origin;
|
|
51
|
+
const tokenRef = officeTokenRef(baseUrl, deviceId);
|
|
52
|
+
const suppliedToken = clean(input.deviceToken);
|
|
53
|
+
const priorCredential = await this.#credentials.resolve(tokenRef).catch(() => undefined);
|
|
54
|
+
const token = suppliedToken ?? priorCredential?.value;
|
|
55
|
+
if (!token || token.length < 32) throw new TypeError('Device Token must contain at least 32 characters');
|
|
56
|
+
const now = new Date().toISOString();
|
|
57
|
+
const config = normalizeOfficeConfig({
|
|
58
|
+
version: 1,
|
|
59
|
+
baseUrl,
|
|
60
|
+
deviceId,
|
|
61
|
+
deviceTokenRef: tokenRef,
|
|
62
|
+
maxConcurrency: input.maxConcurrency,
|
|
63
|
+
heartbeatSeconds: input.heartbeatSeconds,
|
|
64
|
+
workspaces: input.workspaces,
|
|
65
|
+
instructionPresets: input.instructionPresets,
|
|
66
|
+
createdAt: previous?.createdAt ?? now,
|
|
67
|
+
updatedAt: now,
|
|
68
|
+
});
|
|
69
|
+
if (!config) throw new TypeError('AI Office connector configuration is invalid');
|
|
70
|
+
|
|
71
|
+
await this.#credentials.set(tokenRef, token);
|
|
72
|
+
try {
|
|
73
|
+
await this.#store.save(config);
|
|
74
|
+
} catch (error) {
|
|
75
|
+
if (priorCredential?.value) await this.#credentials.set(tokenRef, priorCredential.value).catch(() => undefined);
|
|
76
|
+
else await this.#credentials.unset(tokenRef).catch(() => undefined);
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
if (previous?.deviceTokenRef && previous.deviceTokenRef !== tokenRef) {
|
|
80
|
+
await this.#credentials.unset(previous.deviceTokenRef).catch(() => undefined);
|
|
81
|
+
}
|
|
82
|
+
await this.#start(config, token);
|
|
83
|
+
return this.status();
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async reconnect() {
|
|
88
|
+
return this.#serial(async () => {
|
|
89
|
+
const config = this.#store.get();
|
|
90
|
+
if (!config) throw new Error('AI Office connector is not configured');
|
|
91
|
+
await this.#start(config);
|
|
92
|
+
return this.status();
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async test() {
|
|
97
|
+
const config = this.#store.get();
|
|
98
|
+
if (!config) throw new Error('AI Office connector is not configured');
|
|
99
|
+
const token = await this.#resolveToken(config);
|
|
100
|
+
const runtime = this.#createRuntime({ config, token, logger: this.#logger });
|
|
101
|
+
try { await runtime.testConnection(AbortSignal.timeout(10_000)); } finally { await runtime.stop().catch(() => undefined); }
|
|
102
|
+
return { tested: true, snapshot: await this.status() };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async remove() {
|
|
106
|
+
return this.#serial(async () => {
|
|
107
|
+
const config = this.#store.get();
|
|
108
|
+
await this.#stop();
|
|
109
|
+
if (config?.deviceTokenRef) await this.#credentials.unset(config.deviceTokenRef).catch(() => undefined);
|
|
110
|
+
await this.#store.clear();
|
|
111
|
+
return this.status();
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async status() {
|
|
116
|
+
const config = this.#store.get();
|
|
117
|
+
if (!config) return { schemaVersion: 1, configured: false, connected: false, state: 'unconfigured' };
|
|
118
|
+
const credential = await this.#credentials.resolve(config.deviceTokenRef).catch(() => undefined);
|
|
119
|
+
const runtime = this.#runtime?.status ?? null;
|
|
120
|
+
return {
|
|
121
|
+
schemaVersion: 1,
|
|
122
|
+
configured: true,
|
|
123
|
+
connected: runtime?.connected === true,
|
|
124
|
+
state: runtime?.state ?? (credential?.value ? 'idle' : 'missing-token'),
|
|
125
|
+
config: {
|
|
126
|
+
protocolVersion: config.protocolVersion,
|
|
127
|
+
baseUrl: config.baseUrl,
|
|
128
|
+
deviceId: config.deviceId,
|
|
129
|
+
maxConcurrency: config.maxConcurrency,
|
|
130
|
+
heartbeatSeconds: config.heartbeatSeconds,
|
|
131
|
+
workspaces: config.workspaces,
|
|
132
|
+
instructionPresets: config.instructionPresets,
|
|
133
|
+
hooks: officeHookUrls(config.baseUrl),
|
|
134
|
+
},
|
|
135
|
+
health: runtime,
|
|
136
|
+
tokenConfigured: Boolean(credential?.value),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async close() { await this.#transition.catch(() => undefined); await this.#stop(); }
|
|
141
|
+
|
|
142
|
+
async #resolveToken(config) {
|
|
143
|
+
const credential = await this.#credentials.resolve(config.deviceTokenRef).catch(() => undefined);
|
|
144
|
+
if (!credential?.value) throw new Error('AI Office Device Token is missing');
|
|
145
|
+
return credential.value;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async #start(config, knownToken) {
|
|
149
|
+
await this.#stop();
|
|
150
|
+
const token = knownToken ?? await this.#resolveToken(config);
|
|
151
|
+
const runtime = this.#createRuntime({ config, token, logger: this.#logger });
|
|
152
|
+
this.#runtime = runtime;
|
|
153
|
+
runtime.start();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async #stop() {
|
|
157
|
+
const runtime = this.#runtime;
|
|
158
|
+
this.#runtime = null;
|
|
159
|
+
if (runtime) await runtime.stop();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
#serial(operation) {
|
|
163
|
+
const run = this.#transition.then(operation, operation);
|
|
164
|
+
this.#transition = run.then(() => undefined, () => undefined);
|
|
165
|
+
return run;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
2
|
+
|
|
3
|
+
import { harnessApprovalText } from '../shared/harness-approval.mjs';
|
|
4
|
+
import {
|
|
5
|
+
harnessAnswerForQuestion,
|
|
6
|
+
harnessQuestionText,
|
|
7
|
+
validHarnessQuestion,
|
|
8
|
+
} from '../shared/harness-question.mjs';
|
|
9
|
+
|
|
10
|
+
const JOB_ID = /^job-[a-f0-9]{32}$/;
|
|
11
|
+
const RENEW_MS = 30_000;
|
|
12
|
+
const COMPLETED_LIMIT = 2_048;
|
|
13
|
+
|
|
14
|
+
function abortError() {
|
|
15
|
+
return new DOMException('Office Job was cancelled', 'AbortError');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function clean(value, max = 8_000) {
|
|
19
|
+
return typeof value === 'string' ? value.trim().slice(0, max) : '';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function safeFailure(error) {
|
|
23
|
+
if (error?.code === 'office-job-alias-invalid') return error.message;
|
|
24
|
+
if (error?.code === 'turn-stopped') return '本机 Harness 已停止这次执行。';
|
|
25
|
+
if (error?.code === 'office-job-conflict') return 'Office Job 已被领取、取消或结束。';
|
|
26
|
+
return '本机 Harness 未能完成任务;请检查 Harness 会话后重试。';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function renderPrompt(job, preset) {
|
|
30
|
+
return [
|
|
31
|
+
'# AI Office Handoff',
|
|
32
|
+
'',
|
|
33
|
+
'你正在本机 DeepSeek Harness 中继续一个来自 AI Office 的任务。',
|
|
34
|
+
'只在当前 Workspace 内行动。完成后必须返回:结果摘要、改动文件、验证证据、未解决风险。',
|
|
35
|
+
'',
|
|
36
|
+
'## 本机 Instruction Preset',
|
|
37
|
+
preset,
|
|
38
|
+
'',
|
|
39
|
+
...(clean(job.instruction) ? ['## 本轮补充指令', clean(job.instruction), ''] : []),
|
|
40
|
+
'## Office 时间线',
|
|
41
|
+
clean(job.markdown, 200_000),
|
|
42
|
+
].join('\n');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function approvalRequest(interaction) {
|
|
46
|
+
const id = clean(interaction?.interactionId || interaction?.rpcId, 180);
|
|
47
|
+
if (!id || typeof interaction?.respond !== 'function') return null;
|
|
48
|
+
if (interaction.kind === 'approval') {
|
|
49
|
+
const prompt = harnessApprovalText(interaction.payload, {
|
|
50
|
+
toolCall: interaction.toolCall,
|
|
51
|
+
maxArgumentsLength: 10_000,
|
|
52
|
+
});
|
|
53
|
+
if (!prompt) return null;
|
|
54
|
+
return {
|
|
55
|
+
id,
|
|
56
|
+
kind: 'approval',
|
|
57
|
+
title: `批准 ${clean(interaction.payload?.toolName, 120) || 'Harness 工具'}`,
|
|
58
|
+
prompt,
|
|
59
|
+
toolName: clean(interaction.payload?.toolName, 120),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
if (interaction.kind !== 'question') return null;
|
|
63
|
+
const questions = interaction.payload?.questions;
|
|
64
|
+
if (!Array.isArray(questions) || questions.length === 0
|
|
65
|
+
|| questions.some((question) => !validHarnessQuestion(question))) return null;
|
|
66
|
+
return {
|
|
67
|
+
id,
|
|
68
|
+
kind: 'question',
|
|
69
|
+
title: questions.length > 1 ? `Harness 需要 ${questions.length} 项补充信息` : clean(questions[0].header || questions[0].question, 160),
|
|
70
|
+
prompt: questions.map((question, index) => harnessQuestionText(question, index, questions.length)).join('\n\n---\n\n'),
|
|
71
|
+
questions,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function waitForReply(entry, approvalId) {
|
|
76
|
+
return new Promise((resolve, reject) => {
|
|
77
|
+
if (entry.controller.signal.aborted) return reject(entry.controller.signal.reason ?? abortError());
|
|
78
|
+
const onAbort = () => {
|
|
79
|
+
entry.approvals.delete(approvalId);
|
|
80
|
+
reject(entry.controller.signal.reason ?? abortError());
|
|
81
|
+
};
|
|
82
|
+
entry.controller.signal.addEventListener('abort', onAbort, { once: true });
|
|
83
|
+
entry.approvals.set(approvalId, {
|
|
84
|
+
resolve(value) {
|
|
85
|
+
entry.controller.signal.removeEventListener('abort', onAbort);
|
|
86
|
+
entry.approvals.delete(approvalId);
|
|
87
|
+
resolve(value);
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export class OfficeJobExecutor {
|
|
94
|
+
#config;
|
|
95
|
+
#transport;
|
|
96
|
+
#createHarness;
|
|
97
|
+
#logger;
|
|
98
|
+
#sleep;
|
|
99
|
+
#active = new Map();
|
|
100
|
+
#queued = new Set();
|
|
101
|
+
#completed = new Set();
|
|
102
|
+
#closed = false;
|
|
103
|
+
#status = {
|
|
104
|
+
running: 0,
|
|
105
|
+
completed: 0,
|
|
106
|
+
failed: 0,
|
|
107
|
+
lastJobId: null,
|
|
108
|
+
lastJobAt: null,
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
constructor({
|
|
112
|
+
config,
|
|
113
|
+
transport,
|
|
114
|
+
createHarness,
|
|
115
|
+
logger = console,
|
|
116
|
+
sleepImpl = sleep,
|
|
117
|
+
}) {
|
|
118
|
+
if (!config || !transport || typeof createHarness !== 'function') {
|
|
119
|
+
throw new TypeError('OfficeJobExecutor requires config, transport, and createHarness');
|
|
120
|
+
}
|
|
121
|
+
this.#config = config;
|
|
122
|
+
this.#transport = transport;
|
|
123
|
+
this.#createHarness = createHarness;
|
|
124
|
+
this.#logger = logger;
|
|
125
|
+
this.#sleep = sleepImpl;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
get status() { return structuredClone(this.#status); }
|
|
129
|
+
|
|
130
|
+
offer(jobId) {
|
|
131
|
+
if (this.#closed || !JOB_ID.test(jobId) || this.#active.has(jobId)
|
|
132
|
+
|| this.#queued.has(jobId) || this.#completed.has(jobId)) return false;
|
|
133
|
+
this.#queued.add(jobId);
|
|
134
|
+
this.#drain();
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
handleEvent(event) {
|
|
139
|
+
const jobId = clean(event?.data?.jobId, 80);
|
|
140
|
+
if (!JOB_ID.test(jobId)) return false;
|
|
141
|
+
if (event.type === 'job.available') return this.offer(jobId);
|
|
142
|
+
if (event.type === 'job.cancel') return this.cancel(jobId);
|
|
143
|
+
if (event.type === 'approval.reply') {
|
|
144
|
+
const entry = this.#active.get(jobId);
|
|
145
|
+
const approvalId = clean(event.data?.approvalId, 180);
|
|
146
|
+
const pending = entry?.approvals.get(approvalId);
|
|
147
|
+
if (!pending) return false;
|
|
148
|
+
pending.resolve({
|
|
149
|
+
decision: event.data?.decision === 'approved' ? 'approved' : 'rejected',
|
|
150
|
+
answer: clean(event.data?.answer),
|
|
151
|
+
});
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
cancel(jobId) {
|
|
158
|
+
this.#queued.delete(jobId);
|
|
159
|
+
const entry = this.#active.get(jobId);
|
|
160
|
+
if (!entry) return false;
|
|
161
|
+
entry.cancelled = true;
|
|
162
|
+
entry.controller.abort(abortError());
|
|
163
|
+
if (entry.sessionId && entry.harness) {
|
|
164
|
+
void entry.harness.rpc('session.cancel', {
|
|
165
|
+
sessionId: entry.sessionId,
|
|
166
|
+
keepInbox: true,
|
|
167
|
+
}, 10_000).catch(() => undefined);
|
|
168
|
+
}
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async close() {
|
|
173
|
+
this.#closed = true;
|
|
174
|
+
this.#queued.clear();
|
|
175
|
+
for (const entry of this.#active.values()) entry.controller.abort(abortError());
|
|
176
|
+
await Promise.allSettled([...this.#active.values()].map((entry) => entry.task));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
#drain() {
|
|
180
|
+
while (!this.#closed && this.#active.size < this.#config.maxConcurrency && this.#queued.size > 0) {
|
|
181
|
+
const jobId = this.#queued.values().next().value;
|
|
182
|
+
this.#queued.delete(jobId);
|
|
183
|
+
const entry = {
|
|
184
|
+
controller: new AbortController(),
|
|
185
|
+
approvals: new Map(),
|
|
186
|
+
harness: null,
|
|
187
|
+
sessionId: null,
|
|
188
|
+
leaseToken: null,
|
|
189
|
+
cancelled: false,
|
|
190
|
+
lastProgressAt: 0,
|
|
191
|
+
lastProgress: '',
|
|
192
|
+
task: null,
|
|
193
|
+
};
|
|
194
|
+
entry.task = this.#run(jobId, entry).finally(() => {
|
|
195
|
+
this.#active.delete(jobId);
|
|
196
|
+
this.#status.running = this.#active.size;
|
|
197
|
+
this.#drain();
|
|
198
|
+
});
|
|
199
|
+
this.#active.set(jobId, entry);
|
|
200
|
+
this.#status.running = this.#active.size;
|
|
201
|
+
void entry.task.catch((error) => this.#logger.warn?.('[dsh-im:office] Job task ended:', error.message));
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async #run(jobId, entry) {
|
|
206
|
+
const signal = entry.controller.signal;
|
|
207
|
+
let renewTask = null;
|
|
208
|
+
try {
|
|
209
|
+
const fetched = await this.#transport.getJob(jobId, { signal });
|
|
210
|
+
const job = fetched?.job;
|
|
211
|
+
if (!job || job.id !== jobId) throw new Error('Office returned an invalid Job payload');
|
|
212
|
+
const accepted = await this.#transport.acceptJob(jobId, { signal });
|
|
213
|
+
if (!clean(accepted?.leaseToken, 200)) throw new Error('Office returned an invalid Job lease');
|
|
214
|
+
entry.leaseToken = accepted.leaseToken;
|
|
215
|
+
renewTask = this.#renew(jobId, entry);
|
|
216
|
+
const workspace = this.#config.workspaces[job.workspaceAlias];
|
|
217
|
+
const preset = this.#config.instructionPresets[job.instructionPreset];
|
|
218
|
+
if (!workspace || !preset) {
|
|
219
|
+
const error = new Error('Office Job 引用了本机未配置的 Workspace/Preset alias。');
|
|
220
|
+
error.code = 'office-job-alias-invalid';
|
|
221
|
+
throw error;
|
|
222
|
+
}
|
|
223
|
+
entry.harness = this.#createHarness({ workspace });
|
|
224
|
+
await this.#progress(jobId, entry, { kind: 'status', message: `已领取 Job,准备 Workspace alias:${job.workspaceAlias}` }, true);
|
|
225
|
+
entry.sessionId = await entry.harness.createSession({ signal, workspace });
|
|
226
|
+
await this.#progress(jobId, entry, { kind: 'status', message: 'Harness Session 已创建。', sessionId: entry.sessionId }, true);
|
|
227
|
+
const answer = await entry.harness.ask(entry.sessionId, renderPrompt(job, preset), {
|
|
228
|
+
timeoutMs: 30 * 60_000,
|
|
229
|
+
signal,
|
|
230
|
+
onUpdate: (update) => this.#handleUpdate(jobId, entry, update),
|
|
231
|
+
onInteraction: (interaction) => this.#handleInteraction(jobId, entry, interaction),
|
|
232
|
+
onInteractionResolved: (resolution) => {
|
|
233
|
+
const id = clean(resolution?.interactionId, 180);
|
|
234
|
+
entry.approvals.get(id)?.resolve({ decision: 'rejected', answer: '' });
|
|
235
|
+
},
|
|
236
|
+
});
|
|
237
|
+
if (signal.aborted) throw signal.reason ?? abortError();
|
|
238
|
+
await this.#transport.completeJob(jobId, entry.leaseToken, {
|
|
239
|
+
resultMarkdown: answer,
|
|
240
|
+
sessionId: entry.sessionId,
|
|
241
|
+
}, { signal });
|
|
242
|
+
this.#status.completed += 1;
|
|
243
|
+
this.#rememberCompleted(jobId);
|
|
244
|
+
} catch (error) {
|
|
245
|
+
if (!entry.cancelled && entry.leaseToken) {
|
|
246
|
+
await this.#transport.failJob(jobId, entry.leaseToken, {
|
|
247
|
+
error: safeFailure(error),
|
|
248
|
+
...(entry.sessionId ? { sessionId: entry.sessionId } : {}),
|
|
249
|
+
}).catch(() => undefined);
|
|
250
|
+
this.#status.failed += 1;
|
|
251
|
+
}
|
|
252
|
+
if (error?.code === 'office-job-conflict' || error?.code === 'office-hook-unavailable') {
|
|
253
|
+
this.#rememberCompleted(jobId);
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
if (!entry.cancelled && !signal.aborted) this.#logger.warn?.(`[dsh-im:office] Job ${jobId} failed:`, error.message);
|
|
257
|
+
} finally {
|
|
258
|
+
entry.controller.abort(abortError());
|
|
259
|
+
await renewTask?.catch(() => undefined);
|
|
260
|
+
this.#status.lastJobId = jobId;
|
|
261
|
+
this.#status.lastJobAt = new Date().toISOString();
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async #renew(jobId, entry) {
|
|
266
|
+
while (!entry.controller.signal.aborted) {
|
|
267
|
+
try { await this.#sleep(RENEW_MS, undefined, { signal: entry.controller.signal }); }
|
|
268
|
+
catch { return; }
|
|
269
|
+
try {
|
|
270
|
+
await this.#transport.renewJob(jobId, entry.leaseToken, { signal: entry.controller.signal });
|
|
271
|
+
} catch (error) {
|
|
272
|
+
if (entry.controller.signal.aborted) return;
|
|
273
|
+
entry.cancelled = true;
|
|
274
|
+
entry.controller.abort(error);
|
|
275
|
+
if (entry.sessionId && entry.harness) {
|
|
276
|
+
void entry.harness.rpc('session.cancel', {
|
|
277
|
+
sessionId: entry.sessionId,
|
|
278
|
+
keepInbox: true,
|
|
279
|
+
}, 10_000).catch(() => undefined);
|
|
280
|
+
}
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
try {
|
|
284
|
+
const snapshot = await this.#transport.getJob(jobId, { signal: entry.controller.signal });
|
|
285
|
+
const approval = snapshot?.job?.approval;
|
|
286
|
+
if (approval && (approval.status === 'approved' || approval.status === 'rejected')) {
|
|
287
|
+
entry.approvals.get(approval.id)?.resolve({
|
|
288
|
+
decision: approval.status,
|
|
289
|
+
answer: clean(approval.answer),
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
} catch (error) {
|
|
293
|
+
if (entry.controller.signal.aborted) return;
|
|
294
|
+
this.#logger.warn?.(
|
|
295
|
+
`[dsh-im:office] Job ${jobId} approval poll failed; will retry after the next renewal:`,
|
|
296
|
+
error.message,
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async #handleUpdate(jobId, entry, update) {
|
|
303
|
+
const message = update?.type === 'tool' ? `正在使用 ${clean(update.name, 160) || 'Harness 工具'}…`
|
|
304
|
+
: clean(update?.text, 4_000);
|
|
305
|
+
if (!message) return;
|
|
306
|
+
const key = `${update.type}:${message}`;
|
|
307
|
+
if (key === entry.lastProgress) return;
|
|
308
|
+
const now = Date.now();
|
|
309
|
+
if (update.type === 'text' && now - entry.lastProgressAt < 1_000) return;
|
|
310
|
+
entry.lastProgress = key;
|
|
311
|
+
entry.lastProgressAt = now;
|
|
312
|
+
await this.#progress(jobId, entry, {
|
|
313
|
+
kind: update.type === 'tool' ? 'tool' : update.type === 'text' ? 'text' : 'status',
|
|
314
|
+
message,
|
|
315
|
+
...(entry.sessionId ? { sessionId: entry.sessionId } : {}),
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
#progress(jobId, entry, value, required = false) {
|
|
320
|
+
const request = this.#transport.progressJob(jobId, entry.leaseToken, value, {
|
|
321
|
+
signal: entry.controller.signal,
|
|
322
|
+
});
|
|
323
|
+
return required ? request : request.catch((error) => {
|
|
324
|
+
this.#logger.debug?.('[dsh-im:office] ignored a progress delivery failure:', error.message);
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async #handleInteraction(jobId, entry, interaction) {
|
|
329
|
+
const request = approvalRequest(interaction);
|
|
330
|
+
if (!request) {
|
|
331
|
+
if (interaction?.kind === 'approval' && typeof interaction.respond === 'function') {
|
|
332
|
+
await interaction.respond({
|
|
333
|
+
ok: true,
|
|
334
|
+
value: {
|
|
335
|
+
sessionId: interaction.sessionId,
|
|
336
|
+
approvalId: interaction.payload?.approvalId,
|
|
337
|
+
outcome: 'rejected',
|
|
338
|
+
},
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
const reply = waitForReply(entry, request.id);
|
|
344
|
+
try {
|
|
345
|
+
await this.#transport.requestApproval(jobId, entry.leaseToken, request, {
|
|
346
|
+
signal: entry.controller.signal,
|
|
347
|
+
});
|
|
348
|
+
const decision = await reply;
|
|
349
|
+
if (request.kind === 'approval') {
|
|
350
|
+
await interaction.respond({
|
|
351
|
+
ok: true,
|
|
352
|
+
value: {
|
|
353
|
+
sessionId: interaction.sessionId,
|
|
354
|
+
approvalId: interaction.payload.approvalId,
|
|
355
|
+
outcome: decision.decision === 'approved' ? 'allowed-once' : 'rejected',
|
|
356
|
+
},
|
|
357
|
+
});
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
if (decision.decision !== 'approved') {
|
|
361
|
+
await interaction.respond({
|
|
362
|
+
ok: false,
|
|
363
|
+
error: { code: 'cancelled', message: 'The AI Office user rejected this question.', details: {} },
|
|
364
|
+
});
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
const answers = clean(decision.answer).split(/\r?\n/);
|
|
368
|
+
await interaction.respond({
|
|
369
|
+
ok: true,
|
|
370
|
+
value: {
|
|
371
|
+
sessionId: interaction.sessionId,
|
|
372
|
+
answer: {
|
|
373
|
+
answers: request.questions.map((question, index) => (
|
|
374
|
+
harnessAnswerForQuestion(question, answers[index] || answers[0] || '')
|
|
375
|
+
)),
|
|
376
|
+
},
|
|
377
|
+
},
|
|
378
|
+
});
|
|
379
|
+
} catch (error) {
|
|
380
|
+
entry.approvals.delete(request.id);
|
|
381
|
+
throw error;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
#rememberCompleted(jobId) {
|
|
386
|
+
this.#completed.add(jobId);
|
|
387
|
+
if (this.#completed.size <= COMPLETED_LIMIT) return;
|
|
388
|
+
this.#completed.delete(this.#completed.values().next().value);
|
|
389
|
+
}
|
|
390
|
+
}
|