@zhin.js/host-http 1.2.2 → 1.4.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/lib/access-control.d.ts +41 -3
- package/lib/access-control.js +112 -10
- package/lib/console-rest-pages-helpers.js +24 -2
- package/lib/console-rest-pages.d.ts +4 -1
- package/lib/console-rest-pages.js +49 -23
- package/lib/console-rpc-extended/contracts.d.ts +22 -0
- package/lib/console-rpc-extended/dispatcher.js +1 -1
- package/lib/console-rpc-extended/endpoint-rpc.js +21 -4
- package/lib/console-rpc-extended/index.d.ts +1 -1
- package/lib/console-rpc-extended/schedule-rpc.d.ts +1 -1
- package/lib/console-rpc-extended/schedule-rpc.js +78 -12
- package/lib/console-rpc-handlers.d.ts +9 -1
- package/lib/console-rpc-handlers.js +108 -12
- package/lib/console-rpc-types.d.ts +2 -0
- package/lib/console-rpc-types.js +43 -0
- package/lib/console-rpc.d.ts +29 -1
- package/lib/console-rpc.js +13 -5
- package/lib/host-installer.js +10 -5
- package/lib/http-host.js +5 -4
- package/lib/index.d.ts +6 -3
- package/lib/index.js +4 -1
- package/lib/node/listener.js +2 -1
- package/lib/port.d.ts +27 -0
- package/lib/port.js +68 -0
- package/lib/project-files.d.ts +7 -0
- package/lib/project-files.js +88 -11
- package/lib/unknown-rpc.d.ts +12 -0
- package/lib/unknown-rpc.js +33 -0
- package/package.json +4 -4
- package/src/access-control.ts +151 -11
- package/src/console-rest-pages-helpers.ts +27 -3
- package/src/console-rest-pages.ts +52 -15
- package/src/console-rpc-extended/contracts.ts +18 -0
- package/src/console-rpc-extended/dispatcher.ts +1 -1
- package/src/console-rpc-extended/endpoint-rpc.ts +28 -4
- package/src/console-rpc-extended/index.ts +1 -0
- package/src/console-rpc-extended/schedule-rpc.ts +67 -3
- package/src/console-rpc-handlers.ts +110 -11
- package/src/console-rpc-types.ts +45 -0
- package/src/console-rpc.ts +39 -5
- package/src/host-installer.ts +10 -4
- package/src/http-host.ts +6 -2
- package/src/index.ts +21 -0
- package/src/node/listener.ts +2 -1
- package/src/port.ts +60 -0
- package/src/project-files.ts +95 -11
- package/src/unknown-rpc.ts +44 -0
|
@@ -25,10 +25,17 @@ const INTROSPECTION_PAGE_SIZES = {
|
|
|
25
25
|
endpoints: 30,
|
|
26
26
|
bindings: 30,
|
|
27
27
|
mcp: 30,
|
|
28
|
+
'recent-turns': 20,
|
|
28
29
|
} as const;
|
|
29
30
|
|
|
30
31
|
type IntrospectionKind = keyof typeof INTROSPECTION_PAGE_SIZES;
|
|
31
32
|
|
|
33
|
+
/** Owning plugin of a capability row (`ConsoleIntrospectionOwner`, #1250): searchable by `q`. */
|
|
34
|
+
const OWNER_FIELDS: ReadonlyArray<(item: unknown) => string | undefined> = [
|
|
35
|
+
(item) => stringProp(item, 'pluginInstance'),
|
|
36
|
+
(item) => stringProp(item, 'pluginPackage'),
|
|
37
|
+
];
|
|
38
|
+
|
|
32
39
|
// ---------------------------------------------------------------------------
|
|
33
40
|
// Runtime snapshot introspection
|
|
34
41
|
// ---------------------------------------------------------------------------
|
|
@@ -41,7 +48,8 @@ export function registerIntrospectionRoutes(
|
|
|
41
48
|
): void {
|
|
42
49
|
const collectors: Array<{
|
|
43
50
|
kind: IntrospectionKind;
|
|
44
|
-
collect: (introspection: ConsoleAgentIntrospection | undefined) =>
|
|
51
|
+
collect: (introspection: ConsoleAgentIntrospection | undefined) =>
|
|
52
|
+
{ rows: readonly unknown[]; note?: string } | Promise<{ rows: readonly unknown[]; note?: string }>;
|
|
45
53
|
fields: Array<(item: unknown) => string | undefined>;
|
|
46
54
|
}> = [
|
|
47
55
|
{
|
|
@@ -51,6 +59,7 @@ export function registerIntrospectionRoutes(
|
|
|
51
59
|
(c) => stringProp(c, 'pattern'),
|
|
52
60
|
(c) => stringProp(c, 'desc'),
|
|
53
61
|
(c) => stringProp(c, 'plugin'),
|
|
62
|
+
...OWNER_FIELDS,
|
|
54
63
|
],
|
|
55
64
|
},
|
|
56
65
|
{
|
|
@@ -62,6 +71,7 @@ export function registerIntrospectionRoutes(
|
|
|
62
71
|
(m) => stringProp(m, 'phase'),
|
|
63
72
|
(m) => stringProp(m, 'target'),
|
|
64
73
|
(m) => stringProp(m, 'source'),
|
|
74
|
+
...OWNER_FIELDS,
|
|
65
75
|
],
|
|
66
76
|
},
|
|
67
77
|
{
|
|
@@ -71,6 +81,7 @@ export function registerIntrospectionRoutes(
|
|
|
71
81
|
(c) => stringProp(c, 'name'),
|
|
72
82
|
(c) => stringProp(c, 'owner'),
|
|
73
83
|
(c) => stringProp(c, 'source'),
|
|
84
|
+
...OWNER_FIELDS,
|
|
74
85
|
],
|
|
75
86
|
},
|
|
76
87
|
{
|
|
@@ -87,11 +98,22 @@ export function registerIntrospectionRoutes(
|
|
|
87
98
|
},
|
|
88
99
|
{
|
|
89
100
|
kind: 'bindings',
|
|
90
|
-
collect: (value) => ({ rows: value?.bindings?.() ?? [] }),
|
|
101
|
+
collect: async (value) => ({ rows: await value?.bindings?.() ?? [] }),
|
|
91
102
|
fields: [
|
|
92
103
|
(a) => stringProp(a, 'name'),
|
|
93
104
|
(a) => stringProp(a, 'provider'),
|
|
94
105
|
(a) => stringProp(a, 'model'),
|
|
106
|
+
(a) => stringProp(a, 'displayName'),
|
|
107
|
+
],
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
// #1250: the Agent workbench's recent conversations come from the turn trace archive, newest first.
|
|
111
|
+
kind: 'recent-turns',
|
|
112
|
+
collect: async (value) => ({ rows: await value?.recentTurns?.() ?? [] }),
|
|
113
|
+
fields: [
|
|
114
|
+
(t) => stringProp(t, 'sessionDisplayName'),
|
|
115
|
+
(t) => stringProp(t, 'sessionKey'),
|
|
116
|
+
(t) => stringProp(t, 'status'),
|
|
95
117
|
],
|
|
96
118
|
},
|
|
97
119
|
{
|
|
@@ -101,6 +123,7 @@ export function registerIntrospectionRoutes(
|
|
|
101
123
|
(t) => stringProp(t, 'name'),
|
|
102
124
|
(t) => stringProp(t, 'source'),
|
|
103
125
|
(t) => stringProp(t, 'description'),
|
|
126
|
+
...OWNER_FIELDS,
|
|
104
127
|
],
|
|
105
128
|
},
|
|
106
129
|
{
|
|
@@ -114,6 +137,7 @@ export function registerIntrospectionRoutes(
|
|
|
114
137
|
(section) => stringProp(section, 'layer'),
|
|
115
138
|
(section) => stringProp(section, 'retention'),
|
|
116
139
|
(section) => stringProp(section, 'source'),
|
|
140
|
+
...OWNER_FIELDS,
|
|
117
141
|
],
|
|
118
142
|
},
|
|
119
143
|
{
|
|
@@ -136,7 +160,7 @@ export function registerIntrospectionRoutes(
|
|
|
136
160
|
const lease = kind === 'endpoints' ? null : ctx.acquireAgentRuntime?.() ?? null;
|
|
137
161
|
try {
|
|
138
162
|
const introspection = lease?.value.introspection;
|
|
139
|
-
const { rows, note } = collect(introspection);
|
|
163
|
+
const { rows, note } = await collect(introspection);
|
|
140
164
|
const query = url.searchParams;
|
|
141
165
|
const filter = query.get('filter')?.trim() || undefined;
|
|
142
166
|
const filtered = filterByFields([...rows], filter, fields);
|
|
@@ -17,6 +17,15 @@ import {
|
|
|
17
17
|
isValidNpmPackageName,
|
|
18
18
|
} from './marketplace-registry.js';
|
|
19
19
|
import type { ConsoleMessageEventData } from '@zhin.js/console-protocol';
|
|
20
|
+
import {
|
|
21
|
+
informativeKeywords,
|
|
22
|
+
isLocale,
|
|
23
|
+
normalizePluginCategory,
|
|
24
|
+
packageAuthorName,
|
|
25
|
+
pickLocalizedText,
|
|
26
|
+
readPluginMarketplaceMetadata,
|
|
27
|
+
type PluginLocalizedText,
|
|
28
|
+
} from '@zhin.js/plugin-runtime';
|
|
20
29
|
import {
|
|
21
30
|
clampPositiveInt,
|
|
22
31
|
decodePathParam,
|
|
@@ -65,7 +74,10 @@ export interface ConsoleAgentIntrospection {
|
|
|
65
74
|
commands?(): readonly unknown[];
|
|
66
75
|
middlewares?(): readonly unknown[];
|
|
67
76
|
components?(): readonly unknown[];
|
|
68
|
-
bindings
|
|
77
|
+
/** Agent bindings with the provider / model they actually run on (may read the provider graph). */
|
|
78
|
+
bindings?(): readonly unknown[] | Promise<readonly unknown[]>;
|
|
79
|
+
/** Recent Agent turns from the trace archive, newest first (#1250). */
|
|
80
|
+
recentTurns?(): readonly unknown[] | Promise<readonly unknown[]>;
|
|
69
81
|
tools?(): readonly unknown[];
|
|
70
82
|
promptSections?(): readonly unknown[];
|
|
71
83
|
mcp?(): { rows: readonly unknown[]; note?: string } | readonly unknown[];
|
|
@@ -356,6 +368,11 @@ function registerLogsRoutes(
|
|
|
356
368
|
}, { summary: 'Cleanup old system logs', tags: ['console', 'logs'] });
|
|
357
369
|
}
|
|
358
370
|
|
|
371
|
+
/** Locale-keyed texts from an untrusted registry entry (reuses the published-manifest reader). */
|
|
372
|
+
function localizedTextOf(value: unknown): PluginLocalizedText | undefined {
|
|
373
|
+
return readPluginMarketplaceMetadata({ metadata: { displayNameI18n: value } }).displayNameI18n;
|
|
374
|
+
}
|
|
375
|
+
|
|
359
376
|
// ---------------------------------------------------------------------------
|
|
360
377
|
// marketplace(legacy marketplace-rest-api.ts;plugins.json + npmmirror)
|
|
361
378
|
// ---------------------------------------------------------------------------
|
|
@@ -390,10 +407,17 @@ function registerMarketplaceRoutes(
|
|
|
390
407
|
const versions = meta.versions as Record<string, Record<string, unknown>> | undefined;
|
|
391
408
|
const latestInfo = latest && versions ? versions[latest] : undefined;
|
|
392
409
|
const time = meta.time as Record<string, string> | undefined;
|
|
410
|
+
// Marketplace fields come from the published manifest (`zhin.metadata`); the author is the
|
|
411
|
+
// package's author / maintainers, never the publishing account (`_npmUser`, a release bot).
|
|
412
|
+
const market = readPluginMarketplaceMetadata(latestInfo?.zhin);
|
|
393
413
|
return {
|
|
394
414
|
name: meta.name,
|
|
395
415
|
version: latest,
|
|
396
|
-
|
|
416
|
+
...(market.displayName ? { displayName: market.displayName } : {}),
|
|
417
|
+
...(market.displayNameI18n ? { displayNameI18n: market.displayNameI18n } : {}),
|
|
418
|
+
...(market.descriptionI18n ? { descriptionI18n: market.descriptionI18n } : {}),
|
|
419
|
+
...(market.category ? { category: market.category } : {}),
|
|
420
|
+
description: (latestInfo?.description as string | undefined) || meta.description || '',
|
|
397
421
|
readme: meta.readme || '',
|
|
398
422
|
license: meta.license || latestInfo?.license || '',
|
|
399
423
|
homepage: meta.homepage || latestInfo?.homepage || '',
|
|
@@ -401,11 +425,8 @@ function registerMarketplaceRoutes(
|
|
|
401
425
|
(meta.repository as { url?: string } | undefined)?.url ||
|
|
402
426
|
(latestInfo?.repository as { url?: string } | undefined)?.url ||
|
|
403
427
|
'',
|
|
404
|
-
author:
|
|
405
|
-
|
|
406
|
-
? meta.author
|
|
407
|
-
: (meta.author as { name?: string } | undefined)?.name || '',
|
|
408
|
-
keywords: (latestInfo?.keywords as string[]) || [],
|
|
428
|
+
author: packageAuthorName(latestInfo ?? {}) ?? packageAuthorName(meta) ?? '',
|
|
429
|
+
keywords: informativeKeywords(latestInfo?.keywords),
|
|
409
430
|
engines: latestInfo?.engines || {},
|
|
410
431
|
peerDependencies: latestInfo?.peerDependencies || {},
|
|
411
432
|
versions: Object.keys(versions || {}),
|
|
@@ -421,32 +442,48 @@ function registerMarketplaceRoutes(
|
|
|
421
442
|
100,
|
|
422
443
|
Math.max(1, parseInt(url.searchParams.get('size') ?? url.searchParams.get('limit') ?? '20', 10) || 20),
|
|
423
444
|
);
|
|
424
|
-
const
|
|
445
|
+
const rawCategory = url.searchParams.get('category') || undefined;
|
|
446
|
+
const category = rawCategory === undefined ? undefined : normalizePluginCategory(rawCategory) ?? rawCategory;
|
|
425
447
|
const official = url.searchParams.get('official') ?? undefined;
|
|
448
|
+
const rawLocale = url.searchParams.get('locale');
|
|
449
|
+
const locale = isLocale(rawLocale) ? rawLocale : undefined;
|
|
426
450
|
const searchKeyword = q.trim().toLowerCase();
|
|
427
451
|
|
|
428
452
|
try {
|
|
429
453
|
const allPlugins = (await registry.list()) as readonly Record<string, unknown>[];
|
|
430
|
-
let plugins = allPlugins.map((p) =>
|
|
454
|
+
let plugins = allPlugins.map((p) => {
|
|
455
|
+
const displayNameI18n = localizedTextOf(p.displayNameI18n);
|
|
456
|
+
const descriptionI18n = localizedTextOf(p.descriptionI18n);
|
|
457
|
+
const displayName = typeof p.displayName === 'string' ? p.displayName : '';
|
|
458
|
+
const description = typeof p.description === 'string' ? p.description : '';
|
|
459
|
+
return {
|
|
431
460
|
name: p.name,
|
|
432
|
-
|
|
461
|
+
// `locale` resolves the display texts for the caller; translations stay available either way.
|
|
462
|
+
displayName: locale ? pickLocalizedText(displayNameI18n, locale, displayName) ?? '' : displayName,
|
|
463
|
+
...(displayNameI18n ? { displayNameI18n } : {}),
|
|
433
464
|
version: p.version || '',
|
|
434
|
-
description:
|
|
465
|
+
description: locale ? pickLocalizedText(descriptionI18n, locale, description) ?? '' : description,
|
|
466
|
+
...(descriptionI18n ? { descriptionI18n } : {}),
|
|
435
467
|
author: p.author || '',
|
|
436
468
|
isOfficial: typeof p.name === 'string' && /^@zhin\.js\/[a-z0-9][a-z0-9._-]*$/.test(p.name),
|
|
437
469
|
official: typeof p.name === 'string' && /^@zhin\.js\/[a-z0-9][a-z0-9._-]*$/.test(p.name),
|
|
438
|
-
category: p.category
|
|
439
|
-
keywords: (p.tags
|
|
470
|
+
category: normalizePluginCategory(p.category) ?? 'utility',
|
|
471
|
+
keywords: informativeKeywords(p.tags),
|
|
440
472
|
npm: p.npm || `https://www.npmjs.com/package/${p.name}`,
|
|
441
473
|
date: p.lastUpdate || '',
|
|
442
474
|
downloads: p.downloads || { weekly: 0, monthly: 0 },
|
|
443
475
|
readme: p.readme || '',
|
|
444
476
|
license: p.license || '',
|
|
445
|
-
|
|
477
|
+
};
|
|
478
|
+
});
|
|
446
479
|
|
|
447
480
|
if (searchKeyword) {
|
|
448
481
|
plugins = plugins.filter((p) => {
|
|
449
|
-
const haystack = [
|
|
482
|
+
const haystack = [
|
|
483
|
+
p.name, p.displayName, p.description,
|
|
484
|
+
...Object.values(p.displayNameI18n ?? {}), ...Object.values(p.descriptionI18n ?? {}),
|
|
485
|
+
...p.keywords,
|
|
486
|
+
]
|
|
450
487
|
.join(' ')
|
|
451
488
|
.toLowerCase();
|
|
452
489
|
return haystack.includes(searchKeyword);
|
|
@@ -22,6 +22,11 @@ export interface ConsoleRpcExtendedCtx {
|
|
|
22
22
|
databaseHost?: { models: { get(name: string): unknown } };
|
|
23
23
|
/** Narrow generation-bound persistent-job port; null when Assistant is disabled. */
|
|
24
24
|
resolveScheduleEngine?: () => ConsoleScheduleEngine | null | undefined;
|
|
25
|
+
/**
|
|
26
|
+
* Resolves a persistent job's delivery target (#1250): `{ resolved, display?, reason? }`, where
|
|
27
|
+
* `resolved: false` means the job's results cannot be delivered (no target / unknown account).
|
|
28
|
+
*/
|
|
29
|
+
describeScheduleTarget?: (notify: unknown) => ConsoleScheduleTarget;
|
|
25
30
|
/** LoginAssist list/submit/cancel(刷新后仍可消费扫码/滑块待办);ADR 0058 flow methods are optional. */
|
|
26
31
|
loginAssist?: ConsoleLoginAssistPort;
|
|
27
32
|
/** ADR 0058: the Endpoint's login declaration and lease-scoped login control (`null` = unknown Endpoint). */
|
|
@@ -160,6 +165,11 @@ export interface ScheduleJobRow {
|
|
|
160
165
|
id: string;
|
|
161
166
|
cron: string;
|
|
162
167
|
description?: string;
|
|
168
|
+
/** Host display metadata (`ScheduleJobDisplay` in `@zhin.js/plugin-runtime`). */
|
|
169
|
+
title?: string;
|
|
170
|
+
titleI18n?: Readonly<Partial<Record<string, string>>>;
|
|
171
|
+
kind?: 'plugin-internal' | 'user-task';
|
|
172
|
+
lastRun?: { startedAt: number; finishedAt: number; status: 'succeeded' | 'failed'; error?: string };
|
|
163
173
|
}
|
|
164
174
|
|
|
165
175
|
export interface InboxSelection {
|
|
@@ -219,3 +229,11 @@ export interface EndpointManagementPort {
|
|
|
219
229
|
setGroupAdmin?(request: EndpointAdminCommand): Promise<void>;
|
|
220
230
|
deleteFriend?(request: EndpointUserCommand): Promise<void>;
|
|
221
231
|
}
|
|
232
|
+
|
|
233
|
+
/** `target` of a `schedule:list` persistent row (#1250). */
|
|
234
|
+
export interface ConsoleScheduleTarget {
|
|
235
|
+
readonly resolved: boolean;
|
|
236
|
+
readonly display?: string;
|
|
237
|
+
/** `missing_target` | `incomplete_target` | `endpoint_not_found` */
|
|
238
|
+
readonly reason?: string;
|
|
239
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ConsoleRpcExtendedCtx, EndpointManagementPort, ExtendedRpcResult } from './contracts.js';
|
|
2
|
+
import type { EndpointManagementCapability } from '@zhin.js/console-protocol';
|
|
2
3
|
import { boolField, numField, strField, withLiveEndpoint } from './rpc-values.js';
|
|
3
4
|
|
|
4
5
|
export async function listFriends(
|
|
@@ -12,7 +13,7 @@ export async function listFriends(
|
|
|
12
13
|
const friends = [...await management.listFriends()];
|
|
13
14
|
return { data: { friends, count: friends.length } };
|
|
14
15
|
}
|
|
15
|
-
return
|
|
16
|
+
return unsupportedList('friends', 'listFriends', endpoint.adapter, '好友列表');
|
|
16
17
|
});
|
|
17
18
|
}
|
|
18
19
|
|
|
@@ -27,7 +28,7 @@ export async function listGroups(
|
|
|
27
28
|
const groups = [...await management.listGroups()];
|
|
28
29
|
return { data: { groups, count: groups.length } };
|
|
29
30
|
}
|
|
30
|
-
return
|
|
31
|
+
return unsupportedList('groups', 'listGroups', endpoint.adapter, '群列表');
|
|
31
32
|
});
|
|
32
33
|
}
|
|
33
34
|
|
|
@@ -42,7 +43,7 @@ export async function listChannels(
|
|
|
42
43
|
const channels = [...await management.listChannels()];
|
|
43
44
|
return { data: { channels, count: channels.length } };
|
|
44
45
|
}
|
|
45
|
-
return
|
|
46
|
+
return unsupportedList('channels', 'listChannels', endpoint.adapter, '频道列表');
|
|
46
47
|
});
|
|
47
48
|
}
|
|
48
49
|
|
|
@@ -59,13 +60,36 @@ export async function listGroupMembers(
|
|
|
59
60
|
return withLiveEndpoint(ctx, adapter, endpointKey, async (management) => {
|
|
60
61
|
const method = management.listGroupMembers;
|
|
61
62
|
if (!method) {
|
|
62
|
-
return
|
|
63
|
+
return unsupportedList('members', 'listGroupMembers', adapter, '群成员列表');
|
|
63
64
|
}
|
|
64
65
|
const members = [...await method.call(management, { groupId })];
|
|
65
66
|
return { data: { members, count: members.length } };
|
|
66
67
|
});
|
|
67
68
|
}
|
|
68
69
|
|
|
70
|
+
/**
|
|
71
|
+
* A read the Adapter does not declare is not a failure (#1250): reply with an empty list plus
|
|
72
|
+
* `status: 'unsupported'` (`ConsoleEndpointCapabilityUnsupported`) instead of `{ error }`, so the
|
|
73
|
+
* Console can render "该平台不提供" rather than a red read error. Older clients still see an empty
|
|
74
|
+
* list with `count: 0`.
|
|
75
|
+
*/
|
|
76
|
+
function unsupportedList(
|
|
77
|
+
key: 'friends' | 'groups' | 'channels' | 'members',
|
|
78
|
+
capability: EndpointManagementCapability,
|
|
79
|
+
adapter: string,
|
|
80
|
+
label: string,
|
|
81
|
+
): ExtendedRpcResult {
|
|
82
|
+
return {
|
|
83
|
+
data: {
|
|
84
|
+
[key]: [],
|
|
85
|
+
count: 0,
|
|
86
|
+
status: 'unsupported',
|
|
87
|
+
capability,
|
|
88
|
+
message: `当前适配器(${adapter})不支持${label}`,
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
69
93
|
// ---------------------------------------------------------------- 群管/好友写操作
|
|
70
94
|
|
|
71
95
|
interface GroupWriteSpec {
|
|
@@ -6,7 +6,32 @@ const CRON_NOT_WIRED =
|
|
|
6
6
|
'持久化调度未接线:当前 Plugin Runtime ScheduleHost 仅支持插件注册的内存任务(list),' +
|
|
7
7
|
'不支持 add/remove/pause/resume(需要 @zhin.js/agent 持久化调度引擎)';
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
/** `titleI18n[locale]` → `titleI18n[language]` → `title`. */
|
|
10
|
+
function localizedTitle(job: ScheduleJobRow, locale: string | undefined): string | undefined {
|
|
11
|
+
const map = job.titleI18n && typeof job.titleI18n === 'object' ? job.titleI18n : undefined;
|
|
12
|
+
if (locale && map) {
|
|
13
|
+
const exact = map[locale];
|
|
14
|
+
if (typeof exact === 'string' && exact.trim()) return exact.trim();
|
|
15
|
+
const language = locale.split('-')[0]!;
|
|
16
|
+
const byLanguage = map[language];
|
|
17
|
+
if (typeof byLanguage === 'string' && byLanguage.trim()) return byLanguage.trim();
|
|
18
|
+
}
|
|
19
|
+
return typeof job.title === 'string' && job.title.trim() ? job.title.trim() : undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function lastRunFields(job: ScheduleJobRow): Record<string, unknown> {
|
|
23
|
+
const run = job.lastRun;
|
|
24
|
+
if (!run || typeof run !== 'object' || typeof run.startedAt !== 'number') return {};
|
|
25
|
+
return {
|
|
26
|
+
lastExecutedAt: run.startedAt,
|
|
27
|
+
...(typeof run.finishedAt === 'number' ? { lastFinishedAt: run.finishedAt, lastDurationMs: Math.max(0, run.finishedAt - run.startedAt) } : {}),
|
|
28
|
+
lastStatus: run.status === 'failed' ? 'failed' : 'succeeded',
|
|
29
|
+
...(run.status === 'failed' && typeof run.error === 'string' ? { lastError: run.error } : {}),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function listSchedule(ctx: ConsoleScheduleListCtx, d: Record<string, unknown> = {}): Promise<ExtendedRpcResult> | ExtendedRpcResult {
|
|
34
|
+
const locale = typeof d.locale === 'string' && d.locale.trim() ? d.locale.trim() : undefined;
|
|
10
35
|
const host = ctx.scheduleHost as { list?: () => unknown } | undefined;
|
|
11
36
|
if (!host || typeof host.list !== 'function') {
|
|
12
37
|
return { error: '调度服务未配置(scheduleHost 未挂载)' };
|
|
@@ -18,17 +43,26 @@ export function listSchedule(ctx: ConsoleScheduleListCtx): Promise<ExtendedRpcRe
|
|
|
18
43
|
!!job && typeof job === 'object'
|
|
19
44
|
&& typeof (job as ScheduleJobRow).id === 'string'
|
|
20
45
|
&& typeof (job as ScheduleJobRow).cron === 'string')
|
|
21
|
-
.map((job) =>
|
|
46
|
+
.map((job) => {
|
|
47
|
+
const title = localizedTitle(job, locale);
|
|
48
|
+
return {
|
|
22
49
|
id: job.id,
|
|
23
50
|
cron: job.cron,
|
|
24
51
|
...(typeof job.description === 'string' ? { description: job.description } : {}),
|
|
52
|
+
// #1250: plugin polling is not a user reminder; title is localized, displayName never falls back to a bare id when a title exists.
|
|
53
|
+
kind: job.kind === 'user-task' ? 'user-task' : 'plugin-internal',
|
|
54
|
+
...(title ? { title } : {}),
|
|
55
|
+
...(job.titleI18n && typeof job.titleI18n === 'object' ? { titleI18n: job.titleI18n } : {}),
|
|
56
|
+
displayName: title ?? (typeof job.description === 'string' && job.description ? job.description : job.id),
|
|
57
|
+
...lastRunFields(job),
|
|
25
58
|
// console cron 页渲染字段(由 schedule host 提供时透传)
|
|
26
59
|
expression: (job as { expression?: string }).expression ?? job.cron,
|
|
27
60
|
running: (job as { running?: boolean }).running ?? true,
|
|
28
61
|
...((job as { plugin?: string }).plugin ? { plugin: (job as { plugin?: string }).plugin } : {}),
|
|
29
62
|
...((job as { nextExecution?: number }).nextExecution
|
|
30
63
|
? { nextExecution: (job as { nextExecution?: number }).nextExecution } : {}),
|
|
31
|
-
|
|
64
|
+
};
|
|
65
|
+
});
|
|
32
66
|
return listPersistentJobs(ctx).then((persistent) => ({ data: { memory, persistent, capabilities: { persistent: !!ctx.resolveScheduleEngine?.() && ctx.fullScope } } })).catch((error) => ({ error: errorMessage(error) }));
|
|
33
67
|
} catch (error) {
|
|
34
68
|
return { error: errorMessage(error) };
|
|
@@ -50,10 +84,14 @@ async function listPersistentJobs(ctx: ConsoleRpcExtendedCtx): Promise<Record<st
|
|
|
50
84
|
prompt: (job.action as { prompt?: string } | undefined)?.prompt ?? '',
|
|
51
85
|
enabled: job.enabled !== false,
|
|
52
86
|
source: job.source ?? 'manual',
|
|
87
|
+
// Persistent jobs are created by a person or on their behalf by the Agent.
|
|
88
|
+
kind: 'user-task',
|
|
53
89
|
createdAt: (job as { createdAt?: number }).createdAt,
|
|
54
90
|
nextExecution: (job.state as { nextRunAtMs?: number } | undefined)?.nextRunAtMs,
|
|
55
91
|
...((job.notify as { target?: unknown } | undefined)?.target
|
|
56
92
|
? { context: { target: (job.notify as { target?: unknown }).target } } : {}),
|
|
93
|
+
// #1250: the resolved delivery target; `resolved: false` = results cannot be delivered.
|
|
94
|
+
...(ctx.describeScheduleTarget ? { target: ctx.describeScheduleTarget(job.notify) } : {}),
|
|
57
95
|
lastExecutedAt: (job.state as { lastExecutedAt?: number } | undefined)?.lastExecutedAt,
|
|
58
96
|
lastStatus: (job.state as { lastStatus?: string } | undefined)?.lastStatus,
|
|
59
97
|
}));
|
|
@@ -62,6 +100,22 @@ async function listPersistentJobs(ctx: ConsoleRpcExtendedCtx): Promise<Record<st
|
|
|
62
100
|
}
|
|
63
101
|
}
|
|
64
102
|
|
|
103
|
+
/** The account (`adapter` / `endpoint`) a schedule delivery target sends from, if it names one. */
|
|
104
|
+
function deliveryAccount(target: unknown): { adapter: string; endpoint: string } | undefined {
|
|
105
|
+
if (typeof target === 'string') {
|
|
106
|
+
// `<adapter>:<endpoint>:<kind>:<id>`
|
|
107
|
+
const [adapter, endpoint] = target.split(':');
|
|
108
|
+
return adapter && endpoint ? { adapter, endpoint } : undefined;
|
|
109
|
+
}
|
|
110
|
+
if (!target || typeof target !== 'object') return undefined;
|
|
111
|
+
const scene = (target as { scene?: unknown }).scene;
|
|
112
|
+
if (!scene || typeof scene !== 'object') return undefined;
|
|
113
|
+
const { platform, endpointKey } = scene as { platform?: unknown; endpointKey?: unknown };
|
|
114
|
+
return typeof platform === 'string' && platform && typeof endpointKey === 'string' && endpointKey
|
|
115
|
+
? { adapter: platform, endpoint: endpointKey }
|
|
116
|
+
: undefined;
|
|
117
|
+
}
|
|
118
|
+
|
|
65
119
|
export async function addCron(
|
|
66
120
|
d: Record<string, unknown>,
|
|
67
121
|
ctx: ConsoleRpcExtendedCtx,
|
|
@@ -80,6 +134,16 @@ export async function addCron(
|
|
|
80
134
|
const label = typeof d.label === 'string' && d.label.trim() ? d.label.trim() : undefined;
|
|
81
135
|
const context = (d.context ?? {}) as Record<string, unknown>;
|
|
82
136
|
const target = context.target ?? context.channel;
|
|
137
|
+
// A delivery target sends to that account on every run: `endpoint.operate` there, like
|
|
138
|
+
// `/message/send`, on top of `ops.schedule.write` (absent check = trusted in-process caller).
|
|
139
|
+
if (target && ctx.canOperateEndpoint) {
|
|
140
|
+
const account = deliveryAccount(target);
|
|
141
|
+
if (!account || !ctx.canOperateEndpoint(account.adapter, account.endpoint)) {
|
|
142
|
+
return { error: account
|
|
143
|
+
? `没有向该账号投递的权限:需要 ${account.adapter} / ${account.endpoint} 上的 endpoint.operate`
|
|
144
|
+
: '投递目标需要指明账号(scene.platform 与 scene.endpointKey)' };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
83
147
|
const job = {
|
|
84
148
|
id: `console-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`,
|
|
85
149
|
...(label ? { label } : {}),
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import {
|
|
2
2
|
CONFIG_PATCH_LIMITS,
|
|
3
|
+
CONSOLE_SEND_FAILURE_MESSAGES,
|
|
3
4
|
ENDPOINT_RPC,
|
|
5
|
+
classifyConsoleSendFailure,
|
|
6
|
+
endpointTransportLabel,
|
|
4
7
|
type ConsoleConfigApplicationStatus,
|
|
8
|
+
type ConsoleSendFailure,
|
|
9
|
+
type ConsoleSendFailureStatus,
|
|
5
10
|
} from '@zhin.js/console-protocol';
|
|
6
11
|
import type {
|
|
7
12
|
ConversationAddress,
|
|
@@ -30,6 +35,43 @@ export function wireConversation(
|
|
|
30
35
|
};
|
|
31
36
|
}
|
|
32
37
|
|
|
38
|
+
const SEND_FAILURE_STATUSES: ReadonlySet<string> = new Set(['failed', 'rejected', 'unsupported', 'suppressed']);
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Structural read of an outbound failure (#1250): Core's `EndpointDeliveryError` carries `code`,
|
|
42
|
+
* `disposition` and the canonical receipt; host/http must not import Core, so only those fields are
|
|
43
|
+
* read. `endpoint not found` is the Endpoint directory's plain error. An error counts as a send
|
|
44
|
+
* failure only when its `code` is a known delivery code or it carries a `disposition` / `receipt`;
|
|
45
|
+
* anything else (a Node `ENOENT`, a database error, …) is unclassified and keeps its own message.
|
|
46
|
+
*/
|
|
47
|
+
export function consoleSendFailure(error: unknown): ConsoleSendFailure | undefined {
|
|
48
|
+
if (!(error instanceof Error)) return undefined;
|
|
49
|
+
const record = error as Error & { code?: unknown; disposition?: unknown; receipt?: unknown };
|
|
50
|
+
if (typeof record.code !== 'string' || !record.code) {
|
|
51
|
+
return error.message === 'endpoint not found'
|
|
52
|
+
? { code: 'endpoint_not_found', reason: 'endpoint_not_found', status: 'failed' }
|
|
53
|
+
: undefined;
|
|
54
|
+
}
|
|
55
|
+
// Every known delivery code maps to a reason other than the catch-all `failed`.
|
|
56
|
+
const knownCode = classifyConsoleSendFailure(record.code) !== 'failed';
|
|
57
|
+
const delivery = typeof record.disposition === 'string' || (!!record.receipt && typeof record.receipt === 'object');
|
|
58
|
+
if (!knownCode && !delivery) return undefined;
|
|
59
|
+
const receipt = record.receipt && typeof record.receipt === 'object'
|
|
60
|
+
? record.receipt as { status?: unknown; failure?: { retryable?: unknown; deliveryUnknown?: unknown } }
|
|
61
|
+
: undefined;
|
|
62
|
+
const status: ConsoleSendFailureStatus = record.disposition === 'unknown' || receipt?.failure?.deliveryUnknown === true
|
|
63
|
+
? 'unknown'
|
|
64
|
+
: typeof receipt?.status === 'string' && SEND_FAILURE_STATUSES.has(receipt.status)
|
|
65
|
+
? receipt.status as ConsoleSendFailureStatus
|
|
66
|
+
: record.disposition === 'rejected' ? 'rejected' : 'failed';
|
|
67
|
+
return {
|
|
68
|
+
code: record.code,
|
|
69
|
+
reason: classifyConsoleSendFailure(record.code, status),
|
|
70
|
+
status,
|
|
71
|
+
...(receipt?.failure?.retryable === true ? { retryable: true } : {}),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
33
75
|
const FORBIDDEN_PATCH_SEGMENTS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
34
76
|
|
|
35
77
|
/**
|
|
@@ -145,12 +187,12 @@ export async function dispatchHostResourceConsoleRpc(
|
|
|
145
187
|
emit({ requestId, error: 'filename is required' });
|
|
146
188
|
return true;
|
|
147
189
|
}
|
|
148
|
-
|
|
190
|
+
// Masked only (issue #1250): the file as written is never answered here.
|
|
191
|
+
if (!ctx.readEnvFileView) {
|
|
149
192
|
emit({ requestId, error: 'Env read is not configured' });
|
|
150
193
|
return true;
|
|
151
194
|
}
|
|
152
|
-
|
|
153
|
-
emit({ requestId, data: { content } });
|
|
195
|
+
emit({ requestId, data: await ctx.readEnvFileView(filename) });
|
|
154
196
|
} catch (error) {
|
|
155
197
|
emit({
|
|
156
198
|
requestId,
|
|
@@ -159,23 +201,70 @@ export async function dispatchHostResourceConsoleRpc(
|
|
|
159
201
|
}
|
|
160
202
|
return true;
|
|
161
203
|
}
|
|
204
|
+
case 'env:reveal': {
|
|
205
|
+
try {
|
|
206
|
+
const filename = String(message.filename ?? '');
|
|
207
|
+
const key = message.key;
|
|
208
|
+
if (!filename) {
|
|
209
|
+
emit({ requestId, error: 'filename is required' });
|
|
210
|
+
return true;
|
|
211
|
+
}
|
|
212
|
+
if (typeof key !== 'string' || !key) {
|
|
213
|
+
emit({ requestId, error: 'key is required' });
|
|
214
|
+
return true;
|
|
215
|
+
}
|
|
216
|
+
if (!ctx.revealEnvVariable) {
|
|
217
|
+
emit({ requestId, error: 'Env reveal is not configured' });
|
|
218
|
+
return true;
|
|
219
|
+
}
|
|
220
|
+
emit({ requestId, data: await ctx.revealEnvVariable(filename, key) });
|
|
221
|
+
} catch (error) {
|
|
222
|
+
emit({
|
|
223
|
+
requestId,
|
|
224
|
+
error: `Failed to reveal env variable: ${error instanceof Error ? error.message : String(error)}`,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
return true;
|
|
228
|
+
}
|
|
162
229
|
case 'env:save': {
|
|
163
230
|
try {
|
|
164
231
|
const filename = String(message.filename ?? '');
|
|
165
|
-
const content = message.content;
|
|
166
232
|
if (!filename) {
|
|
167
233
|
emit({ requestId, error: 'filename is required' });
|
|
168
234
|
return true;
|
|
169
235
|
}
|
|
170
|
-
|
|
171
|
-
|
|
236
|
+
const hasChanges = message.changes !== undefined;
|
|
237
|
+
const hasContent = message.content !== undefined;
|
|
238
|
+
if (hasChanges && hasContent) {
|
|
239
|
+
emit({ requestId, error: 'changes 与 content 只能提供一个' });
|
|
240
|
+
return true;
|
|
241
|
+
}
|
|
242
|
+
if (hasChanges) {
|
|
243
|
+
if (!ctx.updateEnvVariables) {
|
|
244
|
+
emit({ requestId, error: 'Env write is not configured' });
|
|
245
|
+
return true;
|
|
246
|
+
}
|
|
247
|
+
const { changed } = await ctx.updateEnvVariables(filename, message.changes);
|
|
248
|
+
emit({ requestId, data: { success: true, message: `环境文件已保存: ${filename}`, changed } });
|
|
172
249
|
return true;
|
|
173
250
|
}
|
|
174
|
-
if (
|
|
251
|
+
if (typeof message.content !== 'string') {
|
|
252
|
+
emit({ requestId, error: 'changes field is required' });
|
|
253
|
+
return true;
|
|
254
|
+
}
|
|
255
|
+
// The whole-file form must be asked for: an older Console sends back what `env:get` showed it.
|
|
256
|
+
if (message.mode !== 'replace') {
|
|
257
|
+
emit({
|
|
258
|
+
requestId,
|
|
259
|
+
error: '此 Host 只接受逐键保存(changes);整文件保存需显式声明 mode: "replace"。请升级 Console 后重试',
|
|
260
|
+
});
|
|
261
|
+
return true;
|
|
262
|
+
}
|
|
263
|
+
if (!ctx.replaceEnvFile) {
|
|
175
264
|
emit({ requestId, error: 'Env write is not configured' });
|
|
176
265
|
return true;
|
|
177
266
|
}
|
|
178
|
-
await ctx.
|
|
267
|
+
await ctx.replaceEnvFile(filename, message.content);
|
|
179
268
|
emit({ requestId, data: { success: true, message: `环境文件已保存: ${filename}` } });
|
|
180
269
|
} catch (error) {
|
|
181
270
|
emit({
|
|
@@ -441,7 +530,7 @@ export async function dispatchHostResourceConsoleRpc(
|
|
|
441
530
|
case 'endpoint.list': {
|
|
442
531
|
try {
|
|
443
532
|
const endpoints = ctx.listEndpoints ? await ctx.listEndpoints() : [];
|
|
444
|
-
emit({ requestId, data: { endpoints } });
|
|
533
|
+
emit({ requestId, data: { endpoints: endpoints.map(withTransportLabel) } });
|
|
445
534
|
} catch (error) {
|
|
446
535
|
emit({
|
|
447
536
|
requestId,
|
|
@@ -468,7 +557,7 @@ export async function dispatchHostResourceConsoleRpc(
|
|
|
468
557
|
emit({ requestId, error: 'endpoint not found' });
|
|
469
558
|
return true;
|
|
470
559
|
}
|
|
471
|
-
emit({ requestId, data: endpoint });
|
|
560
|
+
emit({ requestId, data: withTransportLabel(endpoint) });
|
|
472
561
|
} catch (error) {
|
|
473
562
|
emit({
|
|
474
563
|
requestId,
|
|
@@ -555,9 +644,11 @@ export async function dispatchHostResourceConsoleRpc(
|
|
|
555
644
|
});
|
|
556
645
|
emit({ requestId, data: { messageId: result.messageId } });
|
|
557
646
|
} catch (error) {
|
|
647
|
+
const failure = consoleSendFailure(error);
|
|
558
648
|
emit({
|
|
559
649
|
requestId,
|
|
560
|
-
error: error instanceof Error ? error.message : String(error),
|
|
650
|
+
error: failure ? CONSOLE_SEND_FAILURE_MESSAGES[failure.reason] : error instanceof Error ? error.message : String(error),
|
|
651
|
+
...(failure ? { data: failure } : {}),
|
|
561
652
|
});
|
|
562
653
|
}
|
|
563
654
|
return true;
|
|
@@ -674,3 +765,11 @@ export function configurationSaveMessage(result: { restartRequired: boolean; rev
|
|
|
674
765
|
return result.restartRequired ? '配置已保存,需重启进程才能生效' : '配置已保存';
|
|
675
766
|
}
|
|
676
767
|
|
|
768
|
+
|
|
769
|
+
/** Adds the readable `transportLabel` next to the raw `transportState` of an Endpoint row (#1250). */
|
|
770
|
+
function withTransportLabel<T>(endpoint: T): T {
|
|
771
|
+
if (!endpoint || typeof endpoint !== 'object') return endpoint;
|
|
772
|
+
const state = (endpoint as { transportState?: unknown }).transportState;
|
|
773
|
+
if (typeof state !== 'string') return endpoint;
|
|
774
|
+
return { ...endpoint, transportLabel: endpointTransportLabel(state) };
|
|
775
|
+
}
|