@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.
Files changed (47) hide show
  1. package/lib/access-control.d.ts +41 -3
  2. package/lib/access-control.js +112 -10
  3. package/lib/console-rest-pages-helpers.js +24 -2
  4. package/lib/console-rest-pages.d.ts +4 -1
  5. package/lib/console-rest-pages.js +49 -23
  6. package/lib/console-rpc-extended/contracts.d.ts +22 -0
  7. package/lib/console-rpc-extended/dispatcher.js +1 -1
  8. package/lib/console-rpc-extended/endpoint-rpc.js +21 -4
  9. package/lib/console-rpc-extended/index.d.ts +1 -1
  10. package/lib/console-rpc-extended/schedule-rpc.d.ts +1 -1
  11. package/lib/console-rpc-extended/schedule-rpc.js +78 -12
  12. package/lib/console-rpc-handlers.d.ts +9 -1
  13. package/lib/console-rpc-handlers.js +108 -12
  14. package/lib/console-rpc-types.d.ts +2 -0
  15. package/lib/console-rpc-types.js +43 -0
  16. package/lib/console-rpc.d.ts +29 -1
  17. package/lib/console-rpc.js +13 -5
  18. package/lib/host-installer.js +10 -5
  19. package/lib/http-host.js +5 -4
  20. package/lib/index.d.ts +6 -3
  21. package/lib/index.js +4 -1
  22. package/lib/node/listener.js +2 -1
  23. package/lib/port.d.ts +27 -0
  24. package/lib/port.js +68 -0
  25. package/lib/project-files.d.ts +7 -0
  26. package/lib/project-files.js +88 -11
  27. package/lib/unknown-rpc.d.ts +12 -0
  28. package/lib/unknown-rpc.js +33 -0
  29. package/package.json +4 -4
  30. package/src/access-control.ts +151 -11
  31. package/src/console-rest-pages-helpers.ts +27 -3
  32. package/src/console-rest-pages.ts +52 -15
  33. package/src/console-rpc-extended/contracts.ts +18 -0
  34. package/src/console-rpc-extended/dispatcher.ts +1 -1
  35. package/src/console-rpc-extended/endpoint-rpc.ts +28 -4
  36. package/src/console-rpc-extended/index.ts +1 -0
  37. package/src/console-rpc-extended/schedule-rpc.ts +67 -3
  38. package/src/console-rpc-handlers.ts +110 -11
  39. package/src/console-rpc-types.ts +45 -0
  40. package/src/console-rpc.ts +39 -5
  41. package/src/host-installer.ts +10 -4
  42. package/src/http-host.ts +6 -2
  43. package/src/index.ts +21 -0
  44. package/src/node/listener.ts +2 -1
  45. package/src/port.ts +60 -0
  46. package/src/project-files.ts +95 -11
  47. 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) => { rows: readonly unknown[]; note?: string };
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?(): readonly unknown[];
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
- description: meta.description || '',
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
- typeof meta.author === 'string'
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 category = url.searchParams.get('category') ?? undefined;
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
- displayName: p.displayName || '',
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: p.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 || 'util',
439
- keywords: (p.tags as string[]) || [],
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 = [p.name, p.displayName, p.description, ...(p.keywords || [])]
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
+ }
@@ -21,7 +21,7 @@ export async function dispatchExtendedConsoleRpc(
21
21
  switch (type) {
22
22
  case 'schedule:list':
23
23
  case 'cron:list':
24
- return listSchedule(ctx);
24
+ return listSchedule(ctx, d);
25
25
 
26
26
  case 'cron:validate':
27
27
  return validateCron(d);
@@ -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 { error: `当前适配器(${endpoint.adapter})不支持好友列表` };
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 { error: `当前适配器(${endpoint.adapter})不支持群列表` };
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 { error: `当前适配器(${endpoint.adapter})不支持频道列表` };
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 { error: `当前适配器(${adapter})不支持群成员列表` };
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 {
@@ -1,6 +1,7 @@
1
1
  export type {
2
2
  ConsoleRpcExtendedCtx,
3
3
  ConsoleScheduleEngine,
4
+ ConsoleScheduleTarget,
4
5
  ConsoleWorkroomKnowledgeControlPort,
5
6
  ConsoleWorkroomProfileControlPort,
6
7
  ExtendedRpcResult,
@@ -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
- export function listSchedule(ctx: ConsoleScheduleListCtx): Promise<ExtendedRpcResult> | ExtendedRpcResult {
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
- if (!ctx.readEnvFile) {
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
- const content = await ctx.readEnvFile(filename);
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
- if (typeof content !== 'string') {
171
- emit({ requestId, error: 'content field is required' });
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 (!ctx.writeEnvFile) {
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.writeEnvFile(filename, content);
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
+ }