@xmanrui/dsh-im 4.9.1 → 4.11.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/PROACTIVE_DELIVERY.en.md +23 -1
- package/PROACTIVE_DELIVERY.md +23 -1
- package/README.en.md +5 -1
- package/README.md +6 -2
- package/lib/client.js +2077 -673
- package/lib/index.js +277 -272
- package/package.json +13 -1
- package/plugin-src/client/channel-card-meta.js +9 -1
- package/plugin-src/client/channels/dingtalk/api.js +4 -0
- package/plugin-src/client/channels/dingtalk/index.js +25 -2
- package/plugin-src/client/channels/feishu/api.js +6 -0
- package/plugin-src/client/channels/feishu/group-settings.js +581 -0
- package/plugin-src/client/channels/feishu/index.js +26 -150
- package/plugin-src/client/channels/qq/api.js +4 -0
- package/plugin-src/client/channels/qq/index.js +25 -2
- package/plugin-src/client/channels/shared/token-api.js +4 -0
- package/plugin-src/client/channels/shared/token-channel.js +25 -3
- package/plugin-src/client/channels/wecom/api.js +4 -0
- package/plugin-src/client/channels/wecom/index.js +25 -2
- package/plugin-src/client/channels/weixin/api.js +4 -0
- package/plugin-src/client/channels/weixin/index.js +25 -2
- package/plugin-src/client/channels/whatsapp/api.js +4 -0
- package/plugin-src/client/channels/whatsapp/index.js +24 -2
- package/plugin-src/client/delivery-settings.js +73 -5
- package/plugin-src/client/global-settings.js +341 -0
- package/plugin-src/client/i18n.js +49 -0
- package/plugin-src/client/index.js +47 -6
- package/plugin-src/client/model-setting.js +104 -0
- package/plugin-src/client/styles.js +99 -0
- package/plugin-src/host/channels/dingtalk/production.mjs +15 -0
- package/plugin-src/host/channels/dingtalk/rpc.mjs +11 -0
- package/plugin-src/host/channels/feishu/production.mjs +17 -0
- package/plugin-src/host/channels/feishu/rpc.mjs +32 -0
- package/plugin-src/host/channels/qq/production.mjs +15 -0
- package/plugin-src/host/channels/qq/rpc.mjs +11 -0
- package/plugin-src/host/channels/shared/model-setting-rpc.mjs +19 -0
- package/plugin-src/host/channels/shared/production.mjs +15 -0
- package/plugin-src/host/channels/shared/rpc.mjs +9 -0
- package/plugin-src/host/channels/shared/workspace-rpc.mjs +2 -0
- package/plugin-src/host/channels/slack/production.mjs +15 -0
- package/plugin-src/host/channels/slack/rpc.mjs +9 -0
- package/plugin-src/host/channels/wecom/production.mjs +15 -0
- package/plugin-src/host/channels/wecom/rpc.mjs +11 -0
- package/plugin-src/host/channels/weixin/production.mjs +15 -0
- package/plugin-src/host/channels/weixin/rpc.mjs +11 -0
- package/plugin-src/host/channels/whatsapp/production.mjs +15 -0
- package/plugin-src/host/channels/whatsapp/rpc.mjs +11 -0
- package/plugin-src/host/delivery-adapter.mjs +142 -3
- package/plugin-src/host/delivery-rpc.mjs +12 -0
- package/plugin-src/host/delivery-service.mjs +125 -3
- package/plugin-src/host/delivery-suggestions.mjs +46 -0
- package/plugin-src/host/harness-session-coordinator.mjs +32 -10
- package/plugin-src/host/inbound-ttl-rpc.mjs +83 -0
- package/plugin-src/host/inbound-ttl-runtime.mjs +85 -0
- package/plugin-src/host/inbound-ttl-service.mjs +179 -0
- package/plugin-src/host/index.mjs +26 -1
- package/plugin-src/host/modern-harness-api.mjs +58 -2
- package/plugin-src/host/session-sync-coordinator.mjs +217 -0
- package/scripts/verify-package.mjs +12 -6
- package/src/channels/feishu/bridge.mjs +164 -4
- package/src/channels/feishu/feishu-channel.mjs +45 -10
- package/src/channels/feishu/feishu-runtime.mjs +9 -0
- package/src/channels/feishu/message-utils.mjs +13 -0
- package/src/channels/feishu/multi-bot-controller.mjs +16 -0
- package/src/channels/feishu/plugin-config-store.mjs +1 -0
- package/src/channels/feishu/state-store.mjs +25 -0
- package/src/channels/shared/bot-workspace-store.mjs +291 -38
- package/src/channels/shared/harness-client.mjs +124 -43
- package/src/channels/shared/inbound-file.mjs +117 -2
- package/src/channels/shared/inbound-ttl-store.mjs +110 -0
- package/src/channels/shared/inbound-ttl.mjs +22 -0
- package/src/channels/shared/model-setting.mjs +110 -0
|
@@ -24,6 +24,8 @@ import { watchHarnessMux } from './harness-mux.mjs';
|
|
|
24
24
|
const interactionRegistries = new Map();
|
|
25
25
|
const hostInteractionRegistries = new WeakMap();
|
|
26
26
|
const MAX_ERROR_CLASSIFICATION_BYTES = 64;
|
|
27
|
+
const IM_INPUT_ORIGIN_TTL_MS = 30 * 60 * 1000;
|
|
28
|
+
const MAX_IM_INPUT_ORIGINS = 4096;
|
|
27
29
|
|
|
28
30
|
async function smallResponseText(response) {
|
|
29
31
|
const stream = response?.body;
|
|
@@ -91,6 +93,7 @@ function interactionRegistry(scope) {
|
|
|
91
93
|
ownerships: new Map(),
|
|
92
94
|
claims: new Map(),
|
|
93
95
|
controls: new WeakMap(),
|
|
96
|
+
imInputOrigins: new Map(),
|
|
94
97
|
nextOrder: 0,
|
|
95
98
|
};
|
|
96
99
|
registries.set(scope, registry);
|
|
@@ -98,6 +101,34 @@ function interactionRegistry(scope) {
|
|
|
98
101
|
return registry;
|
|
99
102
|
}
|
|
100
103
|
|
|
104
|
+
function pruneImInputOrigins(origins, now = Date.now()) {
|
|
105
|
+
for (const [rpcId, expiresAt] of origins) {
|
|
106
|
+
if (expiresAt > now) continue;
|
|
107
|
+
origins.delete(rpcId);
|
|
108
|
+
}
|
|
109
|
+
while (origins.size > MAX_IM_INPUT_ORIGINS) {
|
|
110
|
+
origins.delete(origins.keys().next().value);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function registerImInputOrigin(registry, rpcId) {
|
|
115
|
+
const origins = registry.imInputOrigins;
|
|
116
|
+
pruneImInputOrigins(origins);
|
|
117
|
+
origins.delete(rpcId);
|
|
118
|
+
origins.set(rpcId, Date.now() + IM_INPUT_ORIGIN_TTL_MS);
|
|
119
|
+
pruneImInputOrigins(origins);
|
|
120
|
+
return () => origins.delete(rpcId);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Consume a dsh-im prompt id while handling its durable user/message event. */
|
|
124
|
+
export function consumeDshImInputOrigin(scope, rpcId) {
|
|
125
|
+
if (!scope || !['object', 'function', 'string'].includes(typeof scope)
|
|
126
|
+
|| typeof rpcId !== 'string' || !rpcId) return false;
|
|
127
|
+
const origins = interactionRegistry(scope).imInputOrigins;
|
|
128
|
+
pruneImInputOrigins(origins);
|
|
129
|
+
return origins.delete(rpcId);
|
|
130
|
+
}
|
|
131
|
+
|
|
101
132
|
// A Host RPC may not accept cancellation itself. Bound the caller's wait
|
|
102
133
|
// without retrying an operation that the Host may already have accepted.
|
|
103
134
|
function callWithSignal(call, signal) {
|
|
@@ -311,14 +342,59 @@ function sleep(ms, signal) {
|
|
|
311
342
|
});
|
|
312
343
|
}
|
|
313
344
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
345
|
+
/** Join only visible text blocks from one Harness message payload. */
|
|
346
|
+
export function textFromHarnessContent(content) {
|
|
347
|
+
return (Array.isArray(content) ? content : [])
|
|
348
|
+
.filter((part) => part?.type === 'text' && typeof part.text === 'string')
|
|
317
349
|
.map((part) => part.text)
|
|
318
350
|
.join('\n')
|
|
319
351
|
.trim();
|
|
320
352
|
}
|
|
321
353
|
|
|
354
|
+
function assistantMessageText(event) {
|
|
355
|
+
return textFromHarnessContent(event?.data?.message?.content);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Aggregate assistant text in stable step/index order for one Harness Turn. */
|
|
359
|
+
export class AssistantTextAccumulator {
|
|
360
|
+
#steps = new Map();
|
|
361
|
+
#legacyText = '';
|
|
362
|
+
|
|
363
|
+
appendDelta(step, index, text) {
|
|
364
|
+
if (typeof text !== 'string' || !text) return;
|
|
365
|
+
const stepNumber = Number.isSafeInteger(step) ? step : 0;
|
|
366
|
+
const partIndex = Number.isSafeInteger(index) ? index : 0;
|
|
367
|
+
this.#legacyText = '';
|
|
368
|
+
const parts = this.#steps.get(stepNumber) ?? new Map();
|
|
369
|
+
parts.set(partIndex, (parts.get(partIndex) ?? '') + text);
|
|
370
|
+
this.#steps.set(stepNumber, parts);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
setCanonical(step, text) {
|
|
374
|
+
if (typeof text !== 'string' || !text.trim()) return;
|
|
375
|
+
if (!Number.isSafeInteger(step)) {
|
|
376
|
+
this.#steps.clear();
|
|
377
|
+
this.#legacyText = text.trim();
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
this.#legacyText = '';
|
|
381
|
+
this.#steps.set(step, new Map([[0, text.trim()]]));
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
get text() {
|
|
385
|
+
if (this.#steps.size === 0) return this.#legacyText;
|
|
386
|
+
return [...this.#steps.entries()]
|
|
387
|
+
.sort(([left], [right]) => left - right)
|
|
388
|
+
.map(([, parts]) => [...parts.entries()]
|
|
389
|
+
.sort(([left], [right]) => left - right)
|
|
390
|
+
.map(([, text]) => text)
|
|
391
|
+
.join('\n')
|
|
392
|
+
.trim())
|
|
393
|
+
.filter(Boolean)
|
|
394
|
+
.join('\n\n');
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
322
398
|
function nonEmptyText(value) {
|
|
323
399
|
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
324
400
|
}
|
|
@@ -418,7 +494,7 @@ export class HarnessReplyTracker {
|
|
|
418
494
|
#lastSeq;
|
|
419
495
|
#openTurn = null;
|
|
420
496
|
#targetTurn = null;
|
|
421
|
-
#
|
|
497
|
+
#assistantText = new AssistantTextAccumulator();
|
|
422
498
|
#latestText = '';
|
|
423
499
|
#finished = false;
|
|
424
500
|
#reason = null;
|
|
@@ -455,6 +531,12 @@ export class HarnessReplyTracker {
|
|
|
455
531
|
return this.#targetTurn;
|
|
456
532
|
}
|
|
457
533
|
|
|
534
|
+
#commitText(text, pushUpdate) {
|
|
535
|
+
if (!text || text === this.#latestText) return;
|
|
536
|
+
this.#latestText = text;
|
|
537
|
+
pushUpdate({ type: 'text', text });
|
|
538
|
+
}
|
|
539
|
+
|
|
458
540
|
consumeAll(entries) {
|
|
459
541
|
const updates = [];
|
|
460
542
|
// 同一批轮询内的 text 帧只保留最新累积,其余事件逐帧透出,
|
|
@@ -499,28 +581,16 @@ export class HarnessReplyTracker {
|
|
|
499
581
|
if (event.type === 'assistant/chunk' && event.data?.chunk?.type === 'text-delta') {
|
|
500
582
|
const step = event.data?.step ?? 0;
|
|
501
583
|
const index = event.data.chunk.index ?? 0;
|
|
502
|
-
|
|
503
|
-
this.#
|
|
504
|
-
const prefix = `${step}:`;
|
|
505
|
-
const text = [...this.#stepText.entries()]
|
|
506
|
-
.filter(([partKey]) => partKey.startsWith(prefix))
|
|
507
|
-
.sort(([left], [right]) => Number(left.split(':')[1]) - Number(right.split(':')[1]))
|
|
508
|
-
.map(([, part]) => part)
|
|
509
|
-
.join('\n')
|
|
510
|
-
.trim();
|
|
511
|
-
if (text && text !== this.#latestText) {
|
|
512
|
-
this.#latestText = text;
|
|
513
|
-
pushUpdate({ type: 'text', text });
|
|
514
|
-
}
|
|
584
|
+
this.#assistantText.appendDelta(step, index, event.data.chunk.text);
|
|
585
|
+
this.#commitText(this.#assistantText.text, pushUpdate);
|
|
515
586
|
continue;
|
|
516
587
|
}
|
|
517
588
|
|
|
518
589
|
if (event.type === 'assistant/message') {
|
|
519
590
|
const text = assistantMessageText(event);
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
}
|
|
591
|
+
const step = Number.isSafeInteger(event.data?.step) ? event.data.step : null;
|
|
592
|
+
this.#assistantText.setCanonical(step, text);
|
|
593
|
+
this.#commitText(this.#assistantText.text, pushUpdate);
|
|
524
594
|
continue;
|
|
525
595
|
}
|
|
526
596
|
|
|
@@ -1188,31 +1258,40 @@ export class HarnessClient {
|
|
|
1188
1258
|
const ownership = await this.#refreshControlOwnership(sessionId, control, options);
|
|
1189
1259
|
if (!ownership || ownership.stopRequested) return false;
|
|
1190
1260
|
if (this.#activeControlOwnership(sessionId, control) !== ownership) return false;
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
if (typeof accepted
|
|
1204
|
-
throw new TypeError('controlExecutor must return
|
|
1261
|
+
const inputRpcId = `${this.#rpcIdPrefix}-steer-${randomUUID()}`;
|
|
1262
|
+
const releaseInputOrigin = registerImInputOrigin(this.#interactionRegistry, inputRpcId);
|
|
1263
|
+
try {
|
|
1264
|
+
if (this.#controlExecutor) {
|
|
1265
|
+
const accepted = this.#controlExecutor({
|
|
1266
|
+
sessionId,
|
|
1267
|
+
expectedTurn: ownership.turn,
|
|
1268
|
+
promptRpcId: ownership.promptRpcId,
|
|
1269
|
+
inputRpcId,
|
|
1270
|
+
action: 'steer',
|
|
1271
|
+
text,
|
|
1272
|
+
});
|
|
1273
|
+
if (accepted && typeof accepted.then === 'function') {
|
|
1274
|
+
throw new TypeError('controlExecutor must return synchronously');
|
|
1275
|
+
}
|
|
1276
|
+
if (accepted !== undefined) {
|
|
1277
|
+
if (typeof accepted !== 'boolean') {
|
|
1278
|
+
throw new TypeError('controlExecutor must return a boolean or undefined');
|
|
1279
|
+
}
|
|
1280
|
+
if (!accepted) releaseInputOrigin();
|
|
1281
|
+
return accepted;
|
|
1205
1282
|
}
|
|
1206
|
-
return accepted;
|
|
1207
1283
|
}
|
|
1284
|
+
await this.rpc('session.prompt', {
|
|
1285
|
+
sessionId,
|
|
1286
|
+
mode: 'steer',
|
|
1287
|
+
content: [{ type: 'text', text }],
|
|
1288
|
+
clientTimeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
1289
|
+
}, 30_000, { ...options, rpcId: inputRpcId });
|
|
1290
|
+
return true;
|
|
1291
|
+
} catch (error) {
|
|
1292
|
+
releaseInputOrigin();
|
|
1293
|
+
throw error;
|
|
1208
1294
|
}
|
|
1209
|
-
await this.rpc('session.prompt', {
|
|
1210
|
-
sessionId,
|
|
1211
|
-
mode: 'steer',
|
|
1212
|
-
content: [{ type: 'text', text }],
|
|
1213
|
-
clientTimeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
1214
|
-
}, 30_000, options);
|
|
1215
|
-
return true;
|
|
1216
1295
|
}
|
|
1217
1296
|
|
|
1218
1297
|
#consumeInteractionOwnerships(sessionId, entries) {
|
|
@@ -1302,6 +1381,7 @@ export class HarnessClient {
|
|
|
1302
1381
|
);
|
|
1303
1382
|
const baselineSeq = Math.max(-1, ...(before.events ?? []).map(({ event }) => event.seq ?? -1));
|
|
1304
1383
|
const promptRpcId = `${this.#rpcIdPrefix}-${randomUUID()}`;
|
|
1384
|
+
const releasePromptInputOrigin = registerImInputOrigin(this.#interactionRegistry, promptRpcId);
|
|
1305
1385
|
const tracker = new HarnessReplyTracker({ promptRpcId, afterSeq: baselineSeq });
|
|
1306
1386
|
const interactionController = onInteraction || onInteractionResolved
|
|
1307
1387
|
? new AbortController()
|
|
@@ -1518,6 +1598,7 @@ export class HarnessClient {
|
|
|
1518
1598
|
throw turnStoppedError();
|
|
1519
1599
|
}
|
|
1520
1600
|
} finally {
|
|
1601
|
+
releasePromptInputOrigin();
|
|
1521
1602
|
if (!promptAccepted || turnFinished) {
|
|
1522
1603
|
for (const staged of stagedBatches) {
|
|
1523
1604
|
try {
|
|
@@ -1,11 +1,40 @@
|
|
|
1
1
|
import { createWriteStream } from 'node:fs';
|
|
2
|
-
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { mkdir, mkdtemp, readdir, realpath, rm, writeFile } from 'node:fs/promises';
|
|
3
3
|
import { isAbsolute, join, relative, resolve } from 'node:path';
|
|
4
4
|
import { pipeline } from 'node:stream/promises';
|
|
5
5
|
|
|
6
6
|
import { t } from './i18n.mjs';
|
|
7
7
|
|
|
8
8
|
const FILES_DIRECTORY = join('.dsh-im', 'inbound');
|
|
9
|
+
const RETENTIONS = new Set(['turn', 'persistent']);
|
|
10
|
+
const DIRECTORY_TIMESTAMP_PATTERN = /^(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})/;
|
|
11
|
+
|
|
12
|
+
function padded(value) {
|
|
13
|
+
return String(value).padStart(2, '0');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Timestamp prefix (`yyyyMMdd-HHmmss-`) handed to mkdtemp for random suffixes. */
|
|
17
|
+
function inboundDirectoryPrefix(now = new Date()) {
|
|
18
|
+
return `${now.getFullYear()}${padded(now.getMonth() + 1)}${padded(now.getDate())}`
|
|
19
|
+
+ `-${padded(now.getHours())}${padded(now.getMinutes())}${padded(now.getSeconds())}-`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Parse the local-time instant encoded in a staged inbound directory name,
|
|
24
|
+
* returning null for legacy `turn-` names and anything unparseable.
|
|
25
|
+
*/
|
|
26
|
+
export function parseInboundDirectoryName(name) {
|
|
27
|
+
if (typeof name !== 'string') return null;
|
|
28
|
+
const match = DIRECTORY_TIMESTAMP_PATTERN.exec(name);
|
|
29
|
+
if (!match) return null;
|
|
30
|
+
const [year, month, day, hour, minute, second] = match.slice(1).map(Number);
|
|
31
|
+
const date = new Date(year, month - 1, day, hour, minute, second);
|
|
32
|
+
if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day
|
|
33
|
+
|| date.getHours() !== hour || date.getMinutes() !== minute || date.getSeconds() !== second) {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
return date;
|
|
37
|
+
}
|
|
9
38
|
|
|
10
39
|
export class InboundFileError extends Error {
|
|
11
40
|
constructor(code, message, userMessage = t('文件接收失败,请重新发送后再试。'), options = {}) {
|
|
@@ -97,7 +126,18 @@ export function prefetchInboundFiles(message, { signal } = {}) {
|
|
|
97
126
|
export async function stageInboundFiles(message, {
|
|
98
127
|
workspace,
|
|
99
128
|
signal,
|
|
129
|
+
retention = 'turn',
|
|
130
|
+
onStagedDirectory = null,
|
|
100
131
|
} = {}) {
|
|
132
|
+
if (!RETENTIONS.has(retention)) {
|
|
133
|
+
throw new InboundFileError(
|
|
134
|
+
'inbound-file-retention-invalid',
|
|
135
|
+
`Unknown inbound file retention: ${String(retention)}`,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
if (onStagedDirectory !== null && typeof onStagedDirectory !== 'function') {
|
|
139
|
+
throw new TypeError('onStagedDirectory must be a function');
|
|
140
|
+
}
|
|
101
141
|
const sources = fileSources(message);
|
|
102
142
|
if (sources.length === 0) return null;
|
|
103
143
|
if (typeof workspace !== 'string' || !isAbsolute(workspace)) {
|
|
@@ -110,7 +150,10 @@ export async function stageInboundFiles(message, {
|
|
|
110
150
|
signal?.throwIfAborted();
|
|
111
151
|
const root = resolve(workspace, FILES_DIRECTORY);
|
|
112
152
|
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
113
|
-
const directory = await mkdtemp(join(root,
|
|
153
|
+
const directory = await mkdtemp(join(root, inboundDirectoryPrefix()));
|
|
154
|
+
// Register the batch before any file is written so a concurrent TTL=0
|
|
155
|
+
// sweep can never observe the directory as untracked mid-staging.
|
|
156
|
+
onStagedDirectory?.(directory);
|
|
114
157
|
const files = [];
|
|
115
158
|
|
|
116
159
|
try {
|
|
@@ -177,7 +220,11 @@ export async function stageInboundFiles(message, {
|
|
|
177
220
|
}
|
|
178
221
|
return Object.freeze({
|
|
179
222
|
files: Object.freeze(files),
|
|
223
|
+
directory,
|
|
224
|
+
retention,
|
|
180
225
|
async cleanup() {
|
|
226
|
+
// Persistent retention leaves the directory to the global TTL sweeper.
|
|
227
|
+
if (retention !== 'turn') return;
|
|
181
228
|
await rm(directory, { recursive: true, force: true });
|
|
182
229
|
},
|
|
183
230
|
});
|
|
@@ -187,6 +234,74 @@ export async function stageInboundFiles(message, {
|
|
|
187
234
|
}
|
|
188
235
|
}
|
|
189
236
|
|
|
237
|
+
/**
|
|
238
|
+
* Delete expired staged inbound directories under one workspace. Only
|
|
239
|
+
* directories whose names parse as `yyyyMMdd-HHmmss` timestamps are managed;
|
|
240
|
+
* legacy `turn-` names and anything unparseable stay untouched. Directories
|
|
241
|
+
* reported as tracked by an in-flight turn are always skipped.
|
|
242
|
+
*/
|
|
243
|
+
export async function sweepInboundAttachments(workspace, ttlHours, {
|
|
244
|
+
now = () => new Date(),
|
|
245
|
+
isTracked,
|
|
246
|
+
} = {}) {
|
|
247
|
+
if (typeof workspace !== 'string' || !workspace) {
|
|
248
|
+
throw new TypeError('sweepInboundAttachments requires a workspace');
|
|
249
|
+
}
|
|
250
|
+
// An unparsable TTL must never widen into unconditional deletion.
|
|
251
|
+
if (ttlHours !== 0 && !(Number.isInteger(ttlHours) && ttlHours > 0)) {
|
|
252
|
+
return { deleted: 0 };
|
|
253
|
+
}
|
|
254
|
+
const root = resolve(workspace, FILES_DIRECTORY);
|
|
255
|
+
let canonicalWorkspace;
|
|
256
|
+
let canonicalRoot;
|
|
257
|
+
try {
|
|
258
|
+
[canonicalWorkspace, canonicalRoot] = await Promise.all([
|
|
259
|
+
realpath(workspace),
|
|
260
|
+
realpath(root),
|
|
261
|
+
]);
|
|
262
|
+
} catch (error) {
|
|
263
|
+
if (error?.code === 'ENOENT') return { deleted: 0 };
|
|
264
|
+
throw error;
|
|
265
|
+
}
|
|
266
|
+
const rootInWorkspace = relative(canonicalWorkspace, canonicalRoot);
|
|
267
|
+
if (!rootInWorkspace || rootInWorkspace.startsWith('..') || isAbsolute(rootInWorkspace)) {
|
|
268
|
+
throw new InboundFileError(
|
|
269
|
+
'inbound-file-root-outside-workspace',
|
|
270
|
+
'The inbound attachment directory does not resolve inside the Harness Session workspace.',
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
let entries;
|
|
274
|
+
try {
|
|
275
|
+
entries = await readdir(canonicalRoot, { withFileTypes: true });
|
|
276
|
+
} catch (error) {
|
|
277
|
+
if (error?.code === 'ENOENT') return { deleted: 0 };
|
|
278
|
+
throw error;
|
|
279
|
+
}
|
|
280
|
+
const current = now();
|
|
281
|
+
let deleted = 0;
|
|
282
|
+
for (const entry of entries) {
|
|
283
|
+
if (!entry.isDirectory()) continue;
|
|
284
|
+
const stagedAt = parseInboundDirectoryName(entry.name);
|
|
285
|
+
if (stagedAt === null) continue;
|
|
286
|
+
const directory = join(root, entry.name);
|
|
287
|
+
const canonicalDirectory = join(canonicalRoot, entry.name);
|
|
288
|
+
if (typeof isTracked === 'function'
|
|
289
|
+
&& (isTracked(directory) || (directory !== canonicalDirectory && isTracked(canonicalDirectory)))) {
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
const ageHours = (current - stagedAt) / 3_600_000;
|
|
293
|
+
if (ttlHours === 0 || ageHours >= ttlHours) {
|
|
294
|
+
try {
|
|
295
|
+
await rm(canonicalDirectory, { recursive: true, force: true });
|
|
296
|
+
deleted += 1;
|
|
297
|
+
} catch {
|
|
298
|
+
// One undeletable directory must not abort the remaining sweep.
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return { deleted };
|
|
303
|
+
}
|
|
304
|
+
|
|
190
305
|
export function appendInboundFilesToPrompt(prompt, staged) {
|
|
191
306
|
if (!staged?.files?.length) return prompt;
|
|
192
307
|
const manifest = [
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdir, readdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { basename, dirname, join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { DEFAULT_INBOUND_TTL_HOURS, normalizeInboundTtlHours } from './inbound-ttl.mjs';
|
|
6
|
+
|
|
7
|
+
const DOCUMENT_VERSION = 1;
|
|
8
|
+
// Fail-safe TTL for documents that exist but cannot be trusted (damage or an
|
|
9
|
+
// unknown future version, e.g. after a rollback). TTL 0 would sweep every
|
|
10
|
+
// untracked directory on the next pass, so keep-forever is the safe fallback.
|
|
11
|
+
const UNREADABLE_DOCUMENT_TTL_HOURS = -1;
|
|
12
|
+
|
|
13
|
+
function invalidTtlError() {
|
|
14
|
+
const error = new Error('Invalid inbound attachment TTL hours.');
|
|
15
|
+
error.code = 'inbound-ttl-invalid';
|
|
16
|
+
return error;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Mirrors the atomic settings writes of the bot workspace store and the
|
|
20
|
+
// update service: create a private temporary file, then rename it into place.
|
|
21
|
+
async function writeSettingsDocument(path, document) {
|
|
22
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
23
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
24
|
+
try {
|
|
25
|
+
await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, {
|
|
26
|
+
encoding: 'utf8',
|
|
27
|
+
mode: 0o600,
|
|
28
|
+
flag: 'wx',
|
|
29
|
+
});
|
|
30
|
+
await rename(temporary, path);
|
|
31
|
+
} finally {
|
|
32
|
+
await unlink(temporary).catch((error) => {
|
|
33
|
+
if (error.code !== 'ENOENT') throw error;
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Durable host-side settings for the global inbound attachment TTL. */
|
|
39
|
+
export class InboundTtlStore {
|
|
40
|
+
#path;
|
|
41
|
+
#ttlHours = DEFAULT_INBOUND_TTL_HOURS;
|
|
42
|
+
|
|
43
|
+
constructor(path) {
|
|
44
|
+
if (typeof path !== 'string' || !path) throw new TypeError('inbound TTL store path is required');
|
|
45
|
+
this.#path = path;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A missing file means first run (default TTL). A damaged or future
|
|
50
|
+
* document falls back to keep-forever so an unreadable intent can never
|
|
51
|
+
* widen into deletion; the value stays visible in the settings UI.
|
|
52
|
+
*/
|
|
53
|
+
async load() {
|
|
54
|
+
let raw;
|
|
55
|
+
try {
|
|
56
|
+
raw = await readFile(this.#path, 'utf8');
|
|
57
|
+
} catch (error) {
|
|
58
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
59
|
+
this.#ttlHours = DEFAULT_INBOUND_TTL_HOURS;
|
|
60
|
+
await this.#removeStaleTemporaries();
|
|
61
|
+
return this;
|
|
62
|
+
}
|
|
63
|
+
this.#ttlHours = this.#readTtlHours(raw) ?? UNREADABLE_DOCUMENT_TTL_HOURS;
|
|
64
|
+
await this.#removeStaleTemporaries();
|
|
65
|
+
return this;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Crash leftovers from an interrupted atomic write are unreferenced by
|
|
69
|
+
// anyone; remove them so the settings directory stays clean.
|
|
70
|
+
async #removeStaleTemporaries() {
|
|
71
|
+
const directory = dirname(this.#path);
|
|
72
|
+
const prefix = `${basename(this.#path)}.`;
|
|
73
|
+
try {
|
|
74
|
+
const entries = await readdir(directory);
|
|
75
|
+
await Promise.all(entries
|
|
76
|
+
.filter((name) => name.startsWith(prefix) && name.endsWith('.tmp'))
|
|
77
|
+
.map((name) => unlink(join(directory, name)).catch(() => {})));
|
|
78
|
+
} catch {
|
|
79
|
+
// A missing directory or concurrent removal is fine; cleanup is best-effort.
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
#readTtlHours(raw) {
|
|
84
|
+
let document;
|
|
85
|
+
try {
|
|
86
|
+
document = JSON.parse(raw);
|
|
87
|
+
} catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
if (!document || typeof document !== 'object' || Array.isArray(document)) return null;
|
|
91
|
+
if (document.version !== DOCUMENT_VERSION) return null;
|
|
92
|
+
if (document.inboundAttachmentTtlHours === undefined) return DEFAULT_INBOUND_TTL_HOURS;
|
|
93
|
+
return normalizeInboundTtlHours(document.inboundAttachmentTtlHours);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
getTtlHours() {
|
|
97
|
+
return this.#ttlHours;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async setTtlHours(value) {
|
|
101
|
+
const ttlHours = normalizeInboundTtlHours(value);
|
|
102
|
+
if (ttlHours === null) throw invalidTtlError();
|
|
103
|
+
await writeSettingsDocument(this.#path, {
|
|
104
|
+
version: DOCUMENT_VERSION,
|
|
105
|
+
inboundAttachmentTtlHours: ttlHours,
|
|
106
|
+
});
|
|
107
|
+
this.#ttlHours = ttlHours;
|
|
108
|
+
return ttlHours;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export const DEFAULT_INBOUND_TTL_HOURS = 168;
|
|
2
|
+
export const INBOUND_TTL_MAX_HOURS = 8760;
|
|
3
|
+
|
|
4
|
+
const INTEGER_STRING = /^[+-]?\d+$/;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Normalize a global inbound attachment TTL. Valid values are -1 (keep
|
|
8
|
+
* forever), 0 (delete once the owning turn ends), and whole hours 1..8760.
|
|
9
|
+
* Integer strings are tolerated; every other input returns null.
|
|
10
|
+
*/
|
|
11
|
+
export function normalizeInboundTtlHours(value) {
|
|
12
|
+
let candidate = value;
|
|
13
|
+
if (typeof candidate === 'string') {
|
|
14
|
+
const trimmed = candidate.trim();
|
|
15
|
+
if (!INTEGER_STRING.test(trimmed)) return null;
|
|
16
|
+
candidate = Number(trimmed);
|
|
17
|
+
}
|
|
18
|
+
if (typeof candidate !== 'number' || !Number.isInteger(candidate)) return null;
|
|
19
|
+
if (candidate === -1) return -1;
|
|
20
|
+
if (candidate >= 0 && candidate <= INBOUND_TTL_MAX_HOURS) return candidate;
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
const MAX_PROVIDER_ID_LENGTH = 256;
|
|
2
|
+
const MAX_MODEL_ID_LENGTH = 1_024;
|
|
3
|
+
const MAX_LABEL_LENGTH = 256;
|
|
4
|
+
const UNSAFE_DISPLAY_TEXT = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]+/gu;
|
|
5
|
+
const UNSAFE_ID_TEXT = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u;
|
|
6
|
+
|
|
7
|
+
function cleanId(value, maxLength) {
|
|
8
|
+
if (typeof value !== 'string') return '';
|
|
9
|
+
const id = value.trim();
|
|
10
|
+
if (!id || id.length > maxLength || UNSAFE_ID_TEXT.test(id)) return '';
|
|
11
|
+
return id;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function cleanLabel(value, fallback) {
|
|
15
|
+
if (typeof value !== 'string') return fallback;
|
|
16
|
+
const label = value.replace(UNSAFE_DISPLAY_TEXT, ' ').replace(/\s+/gu, ' ').trim();
|
|
17
|
+
return label ? label.slice(0, MAX_LABEL_LENGTH) : fallback;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const EMPTY_MODEL_CATALOG = Object.freeze({
|
|
21
|
+
groups: Object.freeze([]),
|
|
22
|
+
failures: Object.freeze([]),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
export function normalizeModelSelection(value) {
|
|
26
|
+
if (value == null) return null;
|
|
27
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
28
|
+
const provider = cleanId(value.provider, MAX_PROVIDER_ID_LENGTH);
|
|
29
|
+
const model = cleanId(value.model, MAX_MODEL_ID_LENGTH);
|
|
30
|
+
return provider && model ? { provider, model } : null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function validateModelSelection(value) {
|
|
34
|
+
if (value == null || value === '') return null;
|
|
35
|
+
const selection = normalizeModelSelection(value);
|
|
36
|
+
if (!selection) {
|
|
37
|
+
const error = new Error('模型无效。');
|
|
38
|
+
error.code = 'model-selection-invalid';
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
return selection;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function sameModelSelection(left, right) {
|
|
45
|
+
return left?.provider === right?.provider && left?.model === right?.model;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function modelSelectionId(value) {
|
|
49
|
+
const selection = normalizeModelSelection(value);
|
|
50
|
+
return selection ? `${selection.provider}/${selection.model}` : '';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function normalizeModelCatalog(value) {
|
|
54
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
55
|
+
return { groups: [], failures: [] };
|
|
56
|
+
}
|
|
57
|
+
const groups = [];
|
|
58
|
+
const seen = new Set();
|
|
59
|
+
for (const candidate of Array.isArray(value.groups) ? value.groups : []) {
|
|
60
|
+
if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) continue;
|
|
61
|
+
const provider = cleanId(candidate.id, MAX_PROVIDER_ID_LENGTH);
|
|
62
|
+
if (!provider) continue;
|
|
63
|
+
const models = [];
|
|
64
|
+
for (const entry of Array.isArray(candidate.models) ? candidate.models : []) {
|
|
65
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue;
|
|
66
|
+
const model = cleanId(entry.id, MAX_MODEL_ID_LENGTH);
|
|
67
|
+
if (!model) continue;
|
|
68
|
+
const key = `${provider}\u0000${model}`;
|
|
69
|
+
if (seen.has(key)) continue;
|
|
70
|
+
seen.add(key);
|
|
71
|
+
models.push({
|
|
72
|
+
id: model,
|
|
73
|
+
name: cleanLabel(entry.name, model),
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
if (models.length > 0) {
|
|
77
|
+
groups.push({
|
|
78
|
+
id: provider,
|
|
79
|
+
name: cleanLabel(candidate.name, provider),
|
|
80
|
+
models,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const failures = [];
|
|
85
|
+
for (const candidate of Array.isArray(value.failures) ? value.failures : []) {
|
|
86
|
+
if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) continue;
|
|
87
|
+
const id = cleanId(candidate.id, MAX_PROVIDER_ID_LENGTH);
|
|
88
|
+
if (!id) continue;
|
|
89
|
+
failures.push({ id, name: cleanLabel(candidate.name, id) });
|
|
90
|
+
}
|
|
91
|
+
return { groups, failures };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function modelCatalogHas(catalog, selection) {
|
|
95
|
+
const normalized = normalizeModelSelection(selection);
|
|
96
|
+
if (!normalized) return false;
|
|
97
|
+
return normalizeModelCatalog(catalog).groups.some(
|
|
98
|
+
(group) => group.id === normalized.provider
|
|
99
|
+
&& group.models.some((model) => model.id === normalized.model),
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function listModelCatalog(harness, options = {}) {
|
|
104
|
+
try {
|
|
105
|
+
if (typeof harness?.listModels !== 'function') return normalizeModelCatalog(null);
|
|
106
|
+
return normalizeModelCatalog(await harness.listModels(options));
|
|
107
|
+
} catch {
|
|
108
|
+
return normalizeModelCatalog(null);
|
|
109
|
+
}
|
|
110
|
+
}
|