@xmanrui/dsh-im 4.9.0 → 4.10.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 +8 -0
- package/PROACTIVE_DELIVERY.md +8 -0
- package/lib/client.js +679 -73
- package/lib/index.js +276 -275
- package/package.json +21 -6
- package/plugin-src/client/channels/dingtalk/api.js +23 -6
- package/plugin-src/client/channels/dingtalk/index.js +33 -6
- package/plugin-src/client/channels/dingtalk/styles.js +2 -0
- package/plugin-src/client/channels/feishu/api.js +2 -0
- package/plugin-src/client/channels/feishu/index.js +54 -0
- package/plugin-src/client/global-settings.js +341 -0
- package/plugin-src/client/i18n.js +33 -0
- package/plugin-src/client/index.js +47 -6
- package/plugin-src/client/styles.js +49 -0
- package/plugin-src/host/channels/dingtalk/production.mjs +12 -0
- package/plugin-src/host/channels/dingtalk/rpc.mjs +24 -0
- package/plugin-src/host/channels/feishu/production.mjs +14 -0
- package/plugin-src/host/channels/feishu/rpc.mjs +16 -0
- package/plugin-src/host/channels/qq/production.mjs +12 -0
- package/plugin-src/host/channels/shared/production.mjs +12 -0
- package/plugin-src/host/channels/slack/production.mjs +12 -0
- package/plugin-src/host/channels/wecom/production.mjs +12 -0
- package/plugin-src/host/channels/weixin/production.mjs +12 -0
- package/plugin-src/host/channels/whatsapp/production.mjs +12 -0
- package/plugin-src/host/delivery-adapter.mjs +1 -0
- package/plugin-src/host/delivery-service.mjs +15 -0
- package/plugin-src/host/harness-session-coordinator.mjs +26 -5
- 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 +8 -0
- package/plugin-src/host/modern-harness-api.mjs +75 -10
- package/src/channels/dingtalk/connection-error.mjs +299 -0
- package/src/channels/dingtalk/dingtalk-controller.mjs +79 -34
- package/src/channels/dingtalk/dingtalk-runtime.mjs +9 -2
- 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 +6 -0
- package/src/channels/shared/i18n-en/dingtalk.mjs +23 -0
- 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
|
@@ -22,7 +22,9 @@ function agentsFromContext(ctx) {
|
|
|
22
22
|
|
|
23
23
|
function currentOwnedTurn(agent, expectedTurn, promptRpcId) {
|
|
24
24
|
if (agent?.status !== 'running') return false;
|
|
25
|
-
const events = agent?.session?.
|
|
25
|
+
const events = typeof agent?.session?.snapshotEvents === 'function'
|
|
26
|
+
? agent.session.snapshotEvents()
|
|
27
|
+
: agent?.session?.events;
|
|
26
28
|
if (!Array.isArray(events)) return false;
|
|
27
29
|
|
|
28
30
|
let openTurn = null;
|
|
@@ -113,7 +115,7 @@ function createSessionMaintenanceExecutor(agents) {
|
|
|
113
115
|
};
|
|
114
116
|
}
|
|
115
117
|
|
|
116
|
-
function createFileIngressExecutor(agents) {
|
|
118
|
+
function createFileIngressExecutor(agents, { inboundTtlService } = {}) {
|
|
117
119
|
return ({ sessionId, workspace, files, signal }) => {
|
|
118
120
|
const agent = agents.get(sessionId);
|
|
119
121
|
const attachedWorkspace = agent?.session?.header?.cwd;
|
|
@@ -126,7 +128,19 @@ function createFileIngressExecutor(agents) {
|
|
|
126
128
|
'The Harness Session workspace is unavailable for inbound files.',
|
|
127
129
|
);
|
|
128
130
|
}
|
|
129
|
-
|
|
131
|
+
const staged = stageInboundFiles({ files }, {
|
|
132
|
+
workspace: exactWorkspace,
|
|
133
|
+
signal,
|
|
134
|
+
...(inboundTtlService ? { retention: inboundTtlService.stagingRetention() } : {}),
|
|
135
|
+
// Registration at creation time closes the mid-staging sweep window;
|
|
136
|
+
// trackStaged later re-registers idempotently and owns the release.
|
|
137
|
+
...(typeof inboundTtlService?.trackDirectory === 'function'
|
|
138
|
+
? { onStagedDirectory: (directory) => inboundTtlService.trackDirectory(directory) }
|
|
139
|
+
: {}),
|
|
140
|
+
});
|
|
141
|
+
return inboundTtlService
|
|
142
|
+
? staged.then((result) => (result ? inboundTtlService.trackStaged(result) : result))
|
|
143
|
+
: staged;
|
|
130
144
|
};
|
|
131
145
|
}
|
|
132
146
|
|
|
@@ -136,7 +150,9 @@ function createFileIngressExecutor(agents) {
|
|
|
136
150
|
* when an Agent is attached, its live Session header remains authoritative.
|
|
137
151
|
*/
|
|
138
152
|
export function createHarnessSessionExecutors(ctx, provided = {}) {
|
|
139
|
-
const {
|
|
153
|
+
const {
|
|
154
|
+
controlExecutor, sessionMaintenanceExecutor, fileIngressExecutor, inboundTtlService,
|
|
155
|
+
} = provided;
|
|
140
156
|
if (controlExecutor !== undefined && typeof controlExecutor !== 'function') {
|
|
141
157
|
throw new TypeError('controlExecutor must be a function');
|
|
142
158
|
}
|
|
@@ -147,6 +163,11 @@ export function createHarnessSessionExecutors(ctx, provided = {}) {
|
|
|
147
163
|
if (fileIngressExecutor !== undefined && typeof fileIngressExecutor !== 'function') {
|
|
148
164
|
throw new TypeError('fileIngressExecutor must be a function');
|
|
149
165
|
}
|
|
166
|
+
if (inboundTtlService !== undefined
|
|
167
|
+
&& (typeof inboundTtlService?.stagingRetention !== 'function'
|
|
168
|
+
|| typeof inboundTtlService?.trackStaged !== 'function')) {
|
|
169
|
+
throw new TypeError('inboundTtlService must expose stagingRetention() and trackStaged()');
|
|
170
|
+
}
|
|
150
171
|
|
|
151
172
|
const agents = controlExecutor && sessionMaintenanceExecutor && fileIngressExecutor
|
|
152
173
|
? undefined
|
|
@@ -156,6 +177,6 @@ export function createHarnessSessionExecutors(ctx, provided = {}) {
|
|
|
156
177
|
sessionMaintenanceExecutor: sessionMaintenanceExecutor
|
|
157
178
|
?? (agents ? createSessionMaintenanceExecutor(agents) : undefined),
|
|
158
179
|
fileIngressExecutor: fileIngressExecutor
|
|
159
|
-
?? createFileIngressExecutor(agents ?? { get: () => undefined }),
|
|
180
|
+
?? createFileIngressExecutor(agents ?? { get: () => undefined }, { inboundTtlService }),
|
|
160
181
|
};
|
|
161
182
|
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { normalizeInboundTtlHours } from '../../src/channels/shared/inbound-ttl.mjs';
|
|
2
|
+
import { getInboundTtlRuntime } from './inbound-ttl-runtime.mjs';
|
|
3
|
+
|
|
4
|
+
export const INBOUND_TTL_RPC_CHANNEL = '/dsh-im-settings';
|
|
5
|
+
export const INBOUND_TTL_ENDPOINTS = Object.freeze({
|
|
6
|
+
get: 'settings.inbound-ttl.get',
|
|
7
|
+
set: 'settings.inbound-ttl.set',
|
|
8
|
+
sweep: 'settings.inbound-ttl.sweep',
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
const ENDPOINTS = new Set(Object.values(INBOUND_TTL_ENDPOINTS));
|
|
12
|
+
|
|
13
|
+
export function validInboundTtlPayload(endpoint, payload) {
|
|
14
|
+
if (!ENDPOINTS.has(endpoint)) return false;
|
|
15
|
+
if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) return false;
|
|
16
|
+
const keys = Object.keys(payload);
|
|
17
|
+
if (endpoint === INBOUND_TTL_ENDPOINTS.set) {
|
|
18
|
+
return keys.length === 1 && keys[0] === 'ttlHours'
|
|
19
|
+
&& normalizeInboundTtlHours(payload.ttlHours) !== null;
|
|
20
|
+
}
|
|
21
|
+
return keys.length === 0;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function createInboundTtlRpcHandler({ store, service, logger = null } = {}) {
|
|
25
|
+
if (!store || typeof store.getTtlHours !== 'function'
|
|
26
|
+
|| typeof store.setTtlHours !== 'function'
|
|
27
|
+
|| !service || typeof service.sweepNow !== 'function') {
|
|
28
|
+
throw new TypeError('createInboundTtlRpcHandler requires an inbound TTL store and service');
|
|
29
|
+
}
|
|
30
|
+
return async (endpoint, payload, signal) => {
|
|
31
|
+
if (!validInboundTtlPayload(endpoint, payload)) {
|
|
32
|
+
return { ok: false, error: { code: 'bad-request', message: 'Invalid inbound TTL request.' } };
|
|
33
|
+
}
|
|
34
|
+
if (signal?.aborted) {
|
|
35
|
+
return { ok: false, error: { code: 'cancelled', message: 'Request cancelled.' } };
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
if (endpoint === INBOUND_TTL_ENDPOINTS.get) {
|
|
39
|
+
return { ok: true, value: { ttlHours: store.getTtlHours() } };
|
|
40
|
+
}
|
|
41
|
+
if (endpoint === INBOUND_TTL_ENDPOINTS.set) {
|
|
42
|
+
const ttlHours = await store.setTtlHours(payload.ttlHours);
|
|
43
|
+
// Re-arm the sweep interval so the saved value gets a full window
|
|
44
|
+
// before its first scheduled enforcement.
|
|
45
|
+
service.resetSweepSchedule?.();
|
|
46
|
+
return { ok: true, value: { ttlHours } };
|
|
47
|
+
}
|
|
48
|
+
const summary = await service.sweepNow();
|
|
49
|
+
// The UI shows no result text, so the host log is the only place a
|
|
50
|
+
// manual sweep can be diagnosed (TTL applied, coverage, deletions).
|
|
51
|
+
logger?.info?.(
|
|
52
|
+
`[dsh-im] manual inbound attachment sweep: ${summary.deletedDirectories} deleted`
|
|
53
|
+
+ ` across ${summary.sweptWorkspaces} workspaces (ttl ${store.getTtlHours()}h)`,
|
|
54
|
+
);
|
|
55
|
+
return { ok: true, value: summary };
|
|
56
|
+
} catch (error) {
|
|
57
|
+
if (error?.code === 'inbound-ttl-invalid') {
|
|
58
|
+
return {
|
|
59
|
+
ok: false,
|
|
60
|
+
error: {
|
|
61
|
+
code: 'inbound-ttl-invalid',
|
|
62
|
+
message: '附件保留时长无效:请输入 -1、0 或 1-8760 之间的整数小时数。',
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
return { ok: false, error: { code: 'inbound-ttl-unavailable', message: 'inbound-ttl-unavailable' } };
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function installInboundTtlRpc(ctx, options = {}) {
|
|
72
|
+
if (!ctx?.connection?.rpc || typeof ctx.connection.rpc.handle !== 'function') {
|
|
73
|
+
throw new TypeError('DSH Host Connection RPC is required');
|
|
74
|
+
}
|
|
75
|
+
const runtime = options.runtime ?? getInboundTtlRuntime(ctx, options.config);
|
|
76
|
+
const logger = typeof ctx?.logger === 'function'
|
|
77
|
+
? ctx.logger('dsh-im:inbound-ttl') : (ctx?.logger ?? null);
|
|
78
|
+
return ctx.connection.rpc.handle(
|
|
79
|
+
INBOUND_TTL_RPC_CHANNEL,
|
|
80
|
+
createInboundTtlRpcHandler({ ...runtime, logger }),
|
|
81
|
+
{ authority: 'loopback' },
|
|
82
|
+
);
|
|
83
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { InboundTtlStore } from '../../src/channels/shared/inbound-ttl-store.mjs';
|
|
5
|
+
import { createInboundTtlService } from './inbound-ttl-service.mjs';
|
|
6
|
+
|
|
7
|
+
const runtimes = new Map();
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Resolve the global settings file shared by every channel. The dshHome
|
|
11
|
+
* resolution mirrors pluginPaths: config.dshHome, then DSH_HOME, then the
|
|
12
|
+
* user's home directory. Channel-specific dataDir values never apply.
|
|
13
|
+
*/
|
|
14
|
+
export function inboundTtlSettingsPath(config = {}) {
|
|
15
|
+
const dshHome = resolve(config.dshHome ?? process.env.DSH_HOME ?? join(homedir(), '.dsh'));
|
|
16
|
+
return resolve(dshHome, 'integrations', 'dsh-im', 'settings.json');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Per-process inbound TTL runtime, cached by resolved settings path so every
|
|
21
|
+
* channel shares one store, one staged registry, and one sweep timer. The
|
|
22
|
+
* entry is dropped (and the sweeper stopped) when the owning context is
|
|
23
|
+
* disposed, letting a later activation rebuild from disk.
|
|
24
|
+
*/
|
|
25
|
+
export function getInboundTtlRuntime(ctx, config = {}) {
|
|
26
|
+
const settingsPath = inboundTtlSettingsPath(config);
|
|
27
|
+
const existing = runtimes.get(settingsPath);
|
|
28
|
+
if (existing) return existing.runtime;
|
|
29
|
+
const logger = typeof ctx?.logger === 'function'
|
|
30
|
+
? ctx.logger('dsh-im:inbound-ttl') : (ctx?.logger ?? console);
|
|
31
|
+
const store = new InboundTtlStore(settingsPath);
|
|
32
|
+
const service = createInboundTtlService({ store, logger });
|
|
33
|
+
const runtime = Object.freeze({ store, service });
|
|
34
|
+
const entry = { runtime };
|
|
35
|
+
runtimes.set(settingsPath, entry);
|
|
36
|
+
// Load persisted settings before the first sweep so a keep-forever TTL can
|
|
37
|
+
// never be overtaken by the in-memory default. An unreadable store keeps
|
|
38
|
+
// sweeping disabled rather than deleting files of unknown intent.
|
|
39
|
+
void store.load().then(
|
|
40
|
+
() => service.start(),
|
|
41
|
+
(error) => logger.error?.('[dsh-im] unable to load inbound attachment settings; sweeping stays disabled', error),
|
|
42
|
+
);
|
|
43
|
+
if (typeof ctx?.effect === 'function') {
|
|
44
|
+
ctx.effect(() => () => {
|
|
45
|
+
if (runtimes.get(settingsPath) !== entry) return;
|
|
46
|
+
runtimes.delete(settingsPath);
|
|
47
|
+
service.stop();
|
|
48
|
+
}, 'dsh-im: stop inbound attachment TTL sweeper');
|
|
49
|
+
}
|
|
50
|
+
return runtime;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Advertise one channel's bot workspaces to the global sweeper. The provider
|
|
55
|
+
* is consulted at sweep time, so bots added or removed later are always
|
|
56
|
+
* current. The default workspace is always included.
|
|
57
|
+
*/
|
|
58
|
+
export function registerInboundTtlWorkspaces(ctx, service, {
|
|
59
|
+
workspaces,
|
|
60
|
+
configStore,
|
|
61
|
+
defaultWorkspace,
|
|
62
|
+
botIdFrom = (bot) => bot?.botId,
|
|
63
|
+
} = {}) {
|
|
64
|
+
if (!service || typeof service.registerWorkspaceProvider !== 'function') return undefined;
|
|
65
|
+
if (!workspaces || typeof workspaces.workspaceFor !== 'function'
|
|
66
|
+
|| typeof defaultWorkspace !== 'string' || !defaultWorkspace) {
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
const unregister = service.registerWorkspaceProvider(() => {
|
|
70
|
+
const bots = typeof configStore?.list === 'function' ? configStore.list() : [];
|
|
71
|
+
const paths = [defaultWorkspace];
|
|
72
|
+
for (const bot of Array.isArray(bots) ? bots : []) {
|
|
73
|
+
try {
|
|
74
|
+
paths.push(workspaces.workspaceFor(botIdFrom(bot)));
|
|
75
|
+
} catch {
|
|
76
|
+
// A malformed config entry must not hide the remaining workspaces.
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return paths;
|
|
80
|
+
});
|
|
81
|
+
if (typeof ctx?.effect === 'function') {
|
|
82
|
+
ctx.effect(() => unregister, 'dsh-im: unregister inbound TTL workspaces');
|
|
83
|
+
}
|
|
84
|
+
return unregister;
|
|
85
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { realpathSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { sweepInboundAttachments } from '../../src/channels/shared/inbound-file.mjs';
|
|
5
|
+
|
|
6
|
+
const DEFAULT_SWEEP_INTERVAL_MS = 30 * 60_000;
|
|
7
|
+
|
|
8
|
+
// A tracked directory is shielded from sweeps while its turn may still be
|
|
9
|
+
// running. Aborted or stalled turns never call cleanup(), so protection has
|
|
10
|
+
// an upper bound — far above the default reply timeout — after which the
|
|
11
|
+
// directory falls back under TTL control instead of leaking forever.
|
|
12
|
+
const TRACKED_PROTECTION_MS = 24 * 60 * 60_000;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Host-process coordinator for the global inbound attachment TTL. The staged
|
|
16
|
+
* registry marks directories owned by in-flight turns so a slow turn can
|
|
17
|
+
* outlive any TTL window without the sweeper deleting its files.
|
|
18
|
+
*/
|
|
19
|
+
export function createInboundTtlService({
|
|
20
|
+
store,
|
|
21
|
+
logger = console,
|
|
22
|
+
intervalMs = DEFAULT_SWEEP_INTERVAL_MS,
|
|
23
|
+
} = {}) {
|
|
24
|
+
if (!store || typeof store.getTtlHours !== 'function') {
|
|
25
|
+
throw new TypeError('createInboundTtlService requires a store exposing getTtlHours()');
|
|
26
|
+
}
|
|
27
|
+
const tracked = new Map();
|
|
28
|
+
const providers = new Set();
|
|
29
|
+
let timer = null;
|
|
30
|
+
|
|
31
|
+
const runSweep = () => {
|
|
32
|
+
sweepNow().catch((error) => {
|
|
33
|
+
logger.error?.('[dsh-im] inbound attachment sweep failed', error);
|
|
34
|
+
});
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
function registerWorkspaceProvider(provider) {
|
|
38
|
+
if (typeof provider !== 'function') {
|
|
39
|
+
throw new TypeError('inbound TTL workspace provider must be a function');
|
|
40
|
+
}
|
|
41
|
+
const wasEmpty = providers.size === 0;
|
|
42
|
+
providers.add(provider);
|
|
43
|
+
// The startup sweep races channel activation and usually runs before any
|
|
44
|
+
// provider exists; re-sweep on the first registration so crash leftovers
|
|
45
|
+
// do not wait a full interval.
|
|
46
|
+
if (wasEmpty && timer) runSweep();
|
|
47
|
+
return () => {
|
|
48
|
+
providers.delete(provider);
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function stagingRetention() {
|
|
53
|
+
// TTL 0 deletes at turn end; every other mode keeps files for the sweeper.
|
|
54
|
+
return store.getTtlHours() === 0 ? 'turn' : 'persistent';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function canonicalPath(directory) {
|
|
58
|
+
try {
|
|
59
|
+
return realpathSync(directory);
|
|
60
|
+
} catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function trackDirectory(directory, { now = () => Date.now() } = {}) {
|
|
66
|
+
if (typeof directory !== 'string' || !directory) return;
|
|
67
|
+
const trackedAt = now();
|
|
68
|
+
tracked.set(directory, trackedAt);
|
|
69
|
+
// The harness-reported cwd and workspaceFor() may normalize symlinks
|
|
70
|
+
// differently; track both spellings so either side finds the entry.
|
|
71
|
+
const canonical = canonicalPath(directory);
|
|
72
|
+
if (canonical && canonical !== directory) tracked.set(canonical, trackedAt);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function releaseDirectory(directory) {
|
|
76
|
+
tracked.delete(directory);
|
|
77
|
+
const canonical = canonicalPath(directory);
|
|
78
|
+
if (canonical && canonical !== directory) tracked.delete(canonical);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function isTrackedNow(directory) {
|
|
82
|
+
const trackedAt = tracked.get(directory);
|
|
83
|
+
if (trackedAt === undefined) return false;
|
|
84
|
+
if (Date.now() - trackedAt >= TRACKED_PROTECTION_MS) {
|
|
85
|
+
tracked.delete(directory);
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function trackStaged(staged) {
|
|
92
|
+
if (!staged || typeof staged !== 'object') return staged;
|
|
93
|
+
const { directory } = staged;
|
|
94
|
+
if (typeof directory !== 'string' || !directory) return staged;
|
|
95
|
+
trackDirectory(directory);
|
|
96
|
+
const cleanup = typeof staged.cleanup === 'function' ? staged.cleanup : null;
|
|
97
|
+
return Object.freeze({
|
|
98
|
+
...staged,
|
|
99
|
+
async cleanup() {
|
|
100
|
+
try {
|
|
101
|
+
await cleanup?.call(staged);
|
|
102
|
+
} finally {
|
|
103
|
+
releaseDirectory(directory);
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function sweepNow() {
|
|
110
|
+
const ttlHours = store.getTtlHours();
|
|
111
|
+
// Keep-forever never deletes, so no workspace is even inspected.
|
|
112
|
+
if (ttlHours === -1) return { deletedDirectories: 0, sweptWorkspaces: 0 };
|
|
113
|
+
const workspaces = new Set();
|
|
114
|
+
for (const provider of providers) {
|
|
115
|
+
try {
|
|
116
|
+
const provided = await provider();
|
|
117
|
+
for (const workspace of Array.isArray(provided) ? provided : []) {
|
|
118
|
+
if (typeof workspace === 'string' && workspace) workspaces.add(resolve(workspace));
|
|
119
|
+
}
|
|
120
|
+
} catch (error) {
|
|
121
|
+
logger.warn?.('[dsh-im] inbound TTL workspace provider failed', error);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const isTracked = (directory) => isTrackedNow(directory)
|
|
125
|
+
|| (canonicalPath(directory) !== null && isTrackedNow(canonicalPath(directory)));
|
|
126
|
+
let deletedDirectories = 0;
|
|
127
|
+
let sweptWorkspaces = 0;
|
|
128
|
+
for (const workspace of workspaces) {
|
|
129
|
+
try {
|
|
130
|
+
const result = await sweepInboundAttachments(workspace, ttlHours, { isTracked });
|
|
131
|
+
deletedDirectories += result.deleted;
|
|
132
|
+
sweptWorkspaces += 1;
|
|
133
|
+
} catch (error) {
|
|
134
|
+
logger.warn?.(`[dsh-im] inbound attachment sweep failed under ${workspace}`, error);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (deletedDirectories > 0) {
|
|
138
|
+
logger.info?.(
|
|
139
|
+
`[dsh-im] inbound attachment sweep deleted ${deletedDirectories} directories`
|
|
140
|
+
+ ` across ${sweptWorkspaces} workspaces (ttl ${ttlHours}h)`,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
return { deletedDirectories, sweptWorkspaces };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function start() {
|
|
147
|
+
if (timer) return;
|
|
148
|
+
runSweep();
|
|
149
|
+
timer = setInterval(runSweep, intervalMs);
|
|
150
|
+
timer.unref?.();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// A freshly saved TTL postpones the next scheduled sweep by a full
|
|
154
|
+
// interval so the new value is never enforced by a sweep firing moments
|
|
155
|
+
// after the change, and is not starved of one either.
|
|
156
|
+
function resetSweepSchedule() {
|
|
157
|
+
if (!timer) return;
|
|
158
|
+
clearInterval(timer);
|
|
159
|
+
timer = setInterval(runSweep, intervalMs);
|
|
160
|
+
timer.unref?.();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function stop() {
|
|
164
|
+
if (!timer) return;
|
|
165
|
+
clearInterval(timer);
|
|
166
|
+
timer = null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return Object.freeze({
|
|
170
|
+
registerWorkspaceProvider,
|
|
171
|
+
stagingRetention,
|
|
172
|
+
trackDirectory,
|
|
173
|
+
trackStaged,
|
|
174
|
+
sweepNow,
|
|
175
|
+
start,
|
|
176
|
+
resetSweepSchedule,
|
|
177
|
+
stop,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
@@ -13,6 +13,7 @@ import { setImHostLanguage } from '../../src/channels/shared/i18n.mjs';
|
|
|
13
13
|
import { installDeliveryRpc } from './delivery-rpc.mjs';
|
|
14
14
|
import { installDeliveryHttp } from './delivery-http.mjs';
|
|
15
15
|
import { createDeliveryService } from './delivery-service.mjs';
|
|
16
|
+
import { installInboundTtlRpc } from './inbound-ttl-rpc.mjs';
|
|
16
17
|
import { installUpdateRpc } from './update-rpc.mjs';
|
|
17
18
|
|
|
18
19
|
export const name = 'dsh-im-host';
|
|
@@ -32,6 +33,7 @@ function channelConfig(config, name, deliveryService) {
|
|
|
32
33
|
|
|
33
34
|
export function createImHostPlugin(internals = {}) {
|
|
34
35
|
const startUpdate = internals.installUpdateRpc ?? installUpdateRpc;
|
|
36
|
+
const startInboundTtl = internals.installInboundTtlRpc ?? installInboundTtlRpc;
|
|
35
37
|
const startDelivery = internals.installDeliveryRpc ?? installDeliveryRpc;
|
|
36
38
|
const startDeliveryHttp = internals.installDeliveryHttp ?? installDeliveryHttp;
|
|
37
39
|
const makeDeliveryService = internals.createDeliveryService ?? createDeliveryService;
|
|
@@ -68,6 +70,7 @@ export function createImHostPlugin(internals = {}) {
|
|
|
68
70
|
deliveryService.send(botId, targetId, text, options)
|
|
69
71
|
),
|
|
70
72
|
listTargets: async (botId) => (await deliveryService.listTargets(botId)).targets,
|
|
73
|
+
listBots: () => deliveryService.listBots(),
|
|
71
74
|
}));
|
|
72
75
|
}
|
|
73
76
|
const activate = async (readyCtx) => {
|
|
@@ -109,6 +112,11 @@ export function createImHostPlugin(internals = {}) {
|
|
|
109
112
|
} catch (error) {
|
|
110
113
|
logger.error?.('[dsh-im] failed to activate update management; continuing with channels', error);
|
|
111
114
|
}
|
|
115
|
+
try {
|
|
116
|
+
startInboundTtl(ctx, { config });
|
|
117
|
+
} catch (error) {
|
|
118
|
+
logger.error?.('[dsh-im] failed to activate inbound TTL settings; continuing with channels', error);
|
|
119
|
+
}
|
|
112
120
|
try {
|
|
113
121
|
startDelivery(ctx, deliveryService, { authority: config.rpcAuthority });
|
|
114
122
|
} catch (error) {
|
|
@@ -52,6 +52,15 @@ function questionError(message, code) {
|
|
|
52
52
|
return error;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
function sessionEvents(session) {
|
|
56
|
+
if (typeof session?.snapshotEvents === 'function') {
|
|
57
|
+
const events = session.snapshotEvents();
|
|
58
|
+
if (Array.isArray(events)) return events;
|
|
59
|
+
}
|
|
60
|
+
const events = session?.events;
|
|
61
|
+
return Array.isArray(events) ? events : null;
|
|
62
|
+
}
|
|
63
|
+
|
|
55
64
|
function matchesQuestions(value, pending) {
|
|
56
65
|
if (!value || typeof value !== 'object'
|
|
57
66
|
|| value.sessionId !== pending.sessionId
|
|
@@ -191,6 +200,7 @@ class ModernHarnessApi {
|
|
|
191
200
|
#pendingQuestions = new Map();
|
|
192
201
|
#pendingApprovals = new Map();
|
|
193
202
|
#sessionCursors = new Map();
|
|
203
|
+
#assistantStreams = new Map();
|
|
194
204
|
#disposers = [];
|
|
195
205
|
#disposed = false;
|
|
196
206
|
|
|
@@ -252,6 +262,13 @@ class ModernHarnessApi {
|
|
|
252
262
|
if (Number.isSafeInteger(event.seq)) this.#rememberCursor(sessionId, event.seq);
|
|
253
263
|
this.#broadcast({ type: 'session/event', sessionId, event });
|
|
254
264
|
}, { global: true }));
|
|
265
|
+
this.#disposers.push(ctx.on('agent/assistant-stream', ({ agent, frame }) => {
|
|
266
|
+
this.#acceptAssistantStream(agent, frame);
|
|
267
|
+
}, { global: true }));
|
|
268
|
+
this.#disposers.push(ctx.on('agent/disposed', ({ agent }) => {
|
|
269
|
+
const sessionId = agent?.session?.id;
|
|
270
|
+
if (typeof sessionId === 'string') this.#assistantStreams.delete(sessionId);
|
|
271
|
+
}, { global: true }));
|
|
255
272
|
this.#disposers.push(ctx.on(
|
|
256
273
|
'approval/request',
|
|
257
274
|
(request, next) => this.#requestApproval(request, next),
|
|
@@ -319,7 +336,7 @@ class ModernHarnessApi {
|
|
|
319
336
|
cursor = this.#sessionCursors.get(sessionId) ?? snapshot.cursor;
|
|
320
337
|
if (beforeSeq === undefined && cursor === snapshot.cursor) {
|
|
321
338
|
return {
|
|
322
|
-
events: historyEntries(snapshot.records),
|
|
339
|
+
events: this.#withAssistantStream(sessionId, historyEntries(snapshot.records)),
|
|
323
340
|
hasMore: snapshot.hasMore === true,
|
|
324
341
|
...(snapshot.projections === undefined ? {} : { projections: snapshot.projections }),
|
|
325
342
|
};
|
|
@@ -333,7 +350,54 @@ class ModernHarnessApi {
|
|
|
333
350
|
...(beforeSeq === undefined ? {} : { beforeSeq }),
|
|
334
351
|
},
|
|
335
352
|
}, signal);
|
|
336
|
-
return {
|
|
353
|
+
return {
|
|
354
|
+
events: beforeSeq === undefined
|
|
355
|
+
? this.#withAssistantStream(sessionId, historyEntries(page.records))
|
|
356
|
+
: historyEntries(page.records),
|
|
357
|
+
hasMore: page.hasMore === true,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
#acceptAssistantStream(agent, frame) {
|
|
362
|
+
const sessionId = agent?.session?.id;
|
|
363
|
+
if (typeof sessionId !== 'string' || !frame || typeof frame !== 'object') return;
|
|
364
|
+
if (frame.type === 'start') {
|
|
365
|
+
const nextSeq = agent?.session?.seq;
|
|
366
|
+
const startedAfterSeq = Number.isSafeInteger(nextSeq) && nextSeq >= 0
|
|
367
|
+
? nextSeq - 1
|
|
368
|
+
: this.#sessionCursors.get(sessionId) ?? -1;
|
|
369
|
+
this.#assistantStreams.set(sessionId, {
|
|
370
|
+
attemptId: frame.attemptId,
|
|
371
|
+
turn: frame.turn,
|
|
372
|
+
step: frame.step,
|
|
373
|
+
startedAfterSeq,
|
|
374
|
+
entries: [],
|
|
375
|
+
});
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
const stream = this.#assistantStreams.get(sessionId);
|
|
379
|
+
if (!stream || stream.attemptId !== frame.attemptId) return;
|
|
380
|
+
if (frame.type === 'end') {
|
|
381
|
+
this.#assistantStreams.delete(sessionId);
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
if (frame.type !== 'chunk' || frame.index !== stream.entries.length) return;
|
|
385
|
+
stream.entries.push({
|
|
386
|
+
type: 'transient',
|
|
387
|
+
event: {
|
|
388
|
+
type: 'assistant/chunk',
|
|
389
|
+
// Match DSH's Web client ordering: live chunks sit in the numeric gap
|
|
390
|
+
// before the durable assistant/message that replaces them.
|
|
391
|
+
seq: stream.startedAfterSeq + 1 - 1 / (frame.index + 2),
|
|
392
|
+
time: frame.time,
|
|
393
|
+
data: { turn: stream.turn, step: stream.step, chunk: frame.chunk },
|
|
394
|
+
},
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
#withAssistantStream(sessionId, entries) {
|
|
399
|
+
const transient = this.#assistantStreams.get(sessionId)?.entries ?? [];
|
|
400
|
+
return transient.length === 0 ? entries : [...entries, ...transient];
|
|
337
401
|
}
|
|
338
402
|
|
|
339
403
|
#rememberCursor(sessionId, cursor) {
|
|
@@ -385,15 +449,15 @@ class ModernHarnessApi {
|
|
|
385
449
|
}
|
|
386
450
|
|
|
387
451
|
#claimableAgent(agent) {
|
|
388
|
-
const
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
452
|
+
const session = agent?.session;
|
|
453
|
+
const sessionId = session?.id ?? agent?.id;
|
|
454
|
+
const events = sessionEvents(session);
|
|
455
|
+
if (typeof sessionId !== 'string' || !events) return null;
|
|
392
456
|
return hasActiveHarnessInteractionOwner(
|
|
393
457
|
this.#scope,
|
|
394
458
|
sessionId,
|
|
395
|
-
|
|
396
|
-
) ? { sessionId,
|
|
459
|
+
events,
|
|
460
|
+
) ? { sessionId, events } : null;
|
|
397
461
|
}
|
|
398
462
|
|
|
399
463
|
#questionFrame(pending) {
|
|
@@ -464,8 +528,8 @@ class ModernHarnessApi {
|
|
|
464
528
|
const claimed = new Set([...this.#pendingApprovals.values()].map((entry) => entry.approvalId));
|
|
465
529
|
const decided = new Set();
|
|
466
530
|
let approvalId;
|
|
467
|
-
for (let index = owner.
|
|
468
|
-
const event = owner.
|
|
531
|
+
for (let index = owner.events.length - 1; index >= 0; index -= 1) {
|
|
532
|
+
const event = owner.events[index];
|
|
469
533
|
if (event.type === 'approval/decided') {
|
|
470
534
|
decided.add(event.data?.id);
|
|
471
535
|
} else if (event.type === 'approval/asked') {
|
|
@@ -547,6 +611,7 @@ class ModernHarnessApi {
|
|
|
547
611
|
if (this.#disposed) return;
|
|
548
612
|
this.#disposed = true;
|
|
549
613
|
for (const subscription of [...this.#mux]) subscription.close();
|
|
614
|
+
this.#assistantStreams.clear();
|
|
550
615
|
for (const pending of [...this.#pendingApprovals.values()]) pending.settle('cancelled');
|
|
551
616
|
for (const pending of [...this.#pendingQuestions.values()]) pending.settle(
|
|
552
617
|
'cancelled',
|