@vertesia/common 1.4.0 → 1.5.0-dev.20260713.113008Z

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 (90) hide show
  1. package/lib/access-control.d.ts +31 -6
  2. package/lib/access-control.d.ts.map +1 -1
  3. package/lib/access-control.js +12 -2
  4. package/lib/access-control.js.map +1 -1
  5. package/lib/apikey.d.ts +29 -5
  6. package/lib/apikey.d.ts.map +1 -1
  7. package/lib/apikey.js.map +1 -1
  8. package/lib/apps.d.ts +78 -4
  9. package/lib/apps.d.ts.map +1 -1
  10. package/lib/apps.js +2 -0
  11. package/lib/apps.js.map +1 -1
  12. package/lib/audit-trail.d.ts +15 -2
  13. package/lib/audit-trail.d.ts.map +1 -1
  14. package/lib/audit-trail.js +20 -0
  15. package/lib/audit-trail.js.map +1 -1
  16. package/lib/environment.d.ts +7 -0
  17. package/lib/environment.d.ts.map +1 -1
  18. package/lib/index.d.ts +3 -0
  19. package/lib/index.d.ts.map +1 -1
  20. package/lib/index.js +2 -0
  21. package/lib/index.js.map +1 -1
  22. package/lib/integrations.d.ts +13 -1
  23. package/lib/integrations.d.ts.map +1 -1
  24. package/lib/integrations.js.map +1 -1
  25. package/lib/platform-event-validation.d.ts +45 -0
  26. package/lib/platform-event-validation.d.ts.map +1 -0
  27. package/lib/platform-event-validation.js +323 -0
  28. package/lib/platform-event-validation.js.map +1 -0
  29. package/lib/platform-event.d.ts +665 -0
  30. package/lib/platform-event.d.ts.map +1 -0
  31. package/lib/platform-event.js +2 -0
  32. package/lib/platform-event.js.map +1 -0
  33. package/lib/project.d.ts +32 -0
  34. package/lib/project.d.ts.map +1 -1
  35. package/lib/project.js.map +1 -1
  36. package/lib/prompt.d.ts +1 -0
  37. package/lib/prompt.d.ts.map +1 -1
  38. package/lib/prompt.js.map +1 -1
  39. package/lib/query.d.ts +1 -0
  40. package/lib/query.d.ts.map +1 -1
  41. package/lib/rate-limiter.d.ts +72 -0
  42. package/lib/rate-limiter.d.ts.map +1 -1
  43. package/lib/roles/types.d.ts +1 -1
  44. package/lib/roles/types.js +1 -1
  45. package/lib/secrets.d.ts +59 -0
  46. package/lib/secrets.d.ts.map +1 -1
  47. package/lib/store/agent-run.d.ts +17 -1
  48. package/lib/store/agent-run.d.ts.map +1 -1
  49. package/lib/store/process-validation.js +19 -0
  50. package/lib/store/process-validation.js.map +1 -1
  51. package/lib/store/store.d.ts +234 -10
  52. package/lib/store/store.d.ts.map +1 -1
  53. package/lib/store/store.js +3 -1
  54. package/lib/store/store.js.map +1 -1
  55. package/lib/store/workflow.d.ts +6 -5
  56. package/lib/store/workflow.d.ts.map +1 -1
  57. package/lib/store/workflow.js.map +1 -1
  58. package/lib/sts-token-types.d.ts +1 -0
  59. package/lib/sts-token-types.d.ts.map +1 -1
  60. package/lib/sts-token-types.js.map +1 -1
  61. package/lib/user.d.ts +36 -1
  62. package/lib/user.d.ts.map +1 -1
  63. package/lib/user.js +39 -0
  64. package/lib/user.js.map +1 -1
  65. package/lib/vertesia-common.js +3 -2
  66. package/lib/vertesia-common.js.map +1 -1
  67. package/package.json +4 -7
  68. package/src/access-control.ts +31 -6
  69. package/src/apikey.ts +29 -5
  70. package/src/apps.ts +87 -4
  71. package/src/audit-trail.ts +34 -18
  72. package/src/environment.ts +7 -0
  73. package/src/index.ts +25 -0
  74. package/src/integrations.ts +14 -1
  75. package/src/platform-event-validation.test.ts +225 -0
  76. package/src/platform-event-validation.ts +359 -0
  77. package/src/platform-event.ts +771 -0
  78. package/src/project.ts +38 -0
  79. package/src/prompt.ts +1 -0
  80. package/src/query.ts +1 -0
  81. package/src/rate-limiter.ts +78 -0
  82. package/src/roles/types.ts +1 -1
  83. package/src/secrets.ts +69 -0
  84. package/src/store/agent-run.ts +22 -1
  85. package/src/store/process-validation.test.ts +62 -0
  86. package/src/store/process-validation.ts +18 -0
  87. package/src/store/store.ts +263 -10
  88. package/src/store/workflow.ts +7 -5
  89. package/src/sts-token-types.ts +1 -0
  90. package/src/user.ts +49 -1
@@ -0,0 +1,225 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { CreateEventSubscriptionPayload } from './platform-event.js';
3
+ import {
4
+ EVENT_CONDITION_JSON_LOGIC_OPERATORS,
5
+ getCreateEventSubscriptionValidationResult,
6
+ getUpdateEventSubscriptionValidationResult,
7
+ } from './platform-event-validation.js';
8
+ import { SystemRoles } from './project.js';
9
+
10
+ function validCreate(overrides: Partial<CreateEventSubscriptionPayload> = {}): CreateEventSubscriptionPayload {
11
+ return {
12
+ name: 'My automation',
13
+ filter: { event_category: ['content'], action: ['create'] },
14
+ target: { type: 'agent', interaction_ref: 'sys:GeneralAgent' },
15
+ run_as_role: SystemRoles.automation,
16
+ ...overrides,
17
+ };
18
+ }
19
+
20
+ describe('event subscription input validation', () => {
21
+ it('accepts a valid create payload', () => {
22
+ expect(getCreateEventSubscriptionValidationResult(validCreate()).valid).toBe(true);
23
+ });
24
+
25
+ it('requires a name', () => {
26
+ const result = getCreateEventSubscriptionValidationResult(validCreate({ name: ' ' }));
27
+ expect(result.valid).toBe(false);
28
+ expect(result.errors).toContain('name is required');
29
+ });
30
+
31
+ it('requires a run_as_role on create', () => {
32
+ const { run_as_role: _omitted, ...withoutRunAsRole } = validCreate();
33
+ const result = getCreateEventSubscriptionValidationResult(withoutRunAsRole as CreateEventSubscriptionPayload);
34
+ expect(result.valid).toBe(false);
35
+ expect(result.errors).toContain('run_as_role is required');
36
+ });
37
+
38
+ it('does not require run_as_role on update', () => {
39
+ expect(getUpdateEventSubscriptionValidationResult({ enabled: true }).valid).toBe(true);
40
+ });
41
+
42
+ it('rejects an unknown target type', () => {
43
+ const result = getCreateEventSubscriptionValidationResult(
44
+ validCreate({ target: { type: 'unsupported-target' } as never }),
45
+ );
46
+ expect(result.valid).toBe(false);
47
+ expect(result.errors.join(' ')).toContain('target.type');
48
+ });
49
+
50
+ it('requires a url for webhook targets', () => {
51
+ const result = getCreateEventSubscriptionValidationResult(
52
+ validCreate({ target: { type: 'webhook' } as never }),
53
+ );
54
+ expect(result.valid).toBe(false);
55
+ expect(result.errors.join(' ')).toContain('url');
56
+ });
57
+
58
+ it('bounds the webhook timeout', () => {
59
+ const result = getCreateEventSubscriptionValidationResult(
60
+ validCreate({ target: { type: 'webhook', url: 'https://example.com/hook', timeout_ms: 999_999 } }),
61
+ );
62
+ expect(result.valid).toBe(false);
63
+ expect(result.errors.join(' ')).toContain('timeout_ms');
64
+ });
65
+
66
+ it('requires endpoint for workflow targets', () => {
67
+ const result = getCreateEventSubscriptionValidationResult(
68
+ validCreate({ target: { type: 'workflow' } as never }),
69
+ );
70
+ expect(result.valid).toBe(false);
71
+ expect(result.errors.join(' ')).toContain('endpoint');
72
+ });
73
+
74
+ it('requires process_ref or process_definition for process targets', () => {
75
+ const result = getCreateEventSubscriptionValidationResult(validCreate({ target: { type: 'process' } }));
76
+ expect(result.valid).toBe(false);
77
+ expect(result.errors.join(' ')).toContain('process_ref');
78
+ });
79
+
80
+ it('accepts a valid agent_signal target', () => {
81
+ const result = getCreateEventSubscriptionValidationResult(
82
+ validCreate({
83
+ target: {
84
+ type: 'agent',
85
+ on_match: 'signal',
86
+ message_path: 'details.payload.comment.body',
87
+ client_message_id_path: 'details.payload.comment.node_id',
88
+ require_command_prefixes: ['/vertesia'],
89
+ on_terminal: 'skip',
90
+ missing_thread: 'retry',
91
+ },
92
+ }),
93
+ );
94
+ expect(result.valid).toBe(true);
95
+ });
96
+
97
+ it('requires message_path for agent_signal targets', () => {
98
+ const result = getCreateEventSubscriptionValidationResult(
99
+ validCreate({ target: { type: 'agent', on_match: 'signal' } as never }),
100
+ );
101
+ expect(result.valid).toBe(false);
102
+ expect(result.errors.join(' ')).toContain('message_path');
103
+ });
104
+
105
+ it('rejects a non-string agent_signal message_path', () => {
106
+ const result = getCreateEventSubscriptionValidationResult(
107
+ validCreate({ target: { type: 'agent', on_match: 'signal', message_path: 123 } as never }),
108
+ );
109
+ expect(result.valid).toBe(false);
110
+ expect(result.errors.join(' ')).toContain('message_path');
111
+ });
112
+
113
+ it('rejects an invalid agent_signal missing_thread', () => {
114
+ const result = getCreateEventSubscriptionValidationResult(
115
+ validCreate({
116
+ target: { type: 'agent', on_match: 'signal', message_path: 'm', missing_thread: 'nope' } as never,
117
+ }),
118
+ );
119
+ expect(result.valid).toBe(false);
120
+ expect(result.errors.join(' ')).toContain('missing_thread');
121
+ });
122
+
123
+ it('accepts agent_signal on_terminal:restart', () => {
124
+ const result = getCreateEventSubscriptionValidationResult(
125
+ validCreate({ target: { type: 'agent', on_match: 'signal', message_path: 'm', on_terminal: 'restart' } }),
126
+ );
127
+ expect(result.valid).toBe(true);
128
+ });
129
+
130
+ it('rejects an invalid agent_signal on_terminal value', () => {
131
+ const result = getCreateEventSubscriptionValidationResult(
132
+ validCreate({
133
+ target: { type: 'agent', on_match: 'signal', message_path: 'm', on_terminal: 'nope' } as never,
134
+ }),
135
+ );
136
+ expect(result.valid).toBe(false);
137
+ expect(result.errors.join(' ')).toContain('on_terminal');
138
+ });
139
+
140
+ it('rejects an agent_signal signal_name other than UserInput', () => {
141
+ const result = getCreateEventSubscriptionValidationResult(
142
+ validCreate({
143
+ target: { type: 'agent', on_match: 'signal', message_path: 'm', signal_name: 'Stop' } as never,
144
+ }),
145
+ );
146
+ expect(result.valid).toBe(false);
147
+ expect(result.errors.join(' ')).toContain('signal_name');
148
+ });
149
+
150
+ it('rejects agent_signal statuses that are not valid run statuses', () => {
151
+ const result = getCreateEventSubscriptionValidationResult(
152
+ validCreate({
153
+ target: {
154
+ type: 'agent',
155
+ on_match: 'signal',
156
+ message_path: 'm',
157
+ statuses: ['running', 'bogus'],
158
+ } as never,
159
+ }),
160
+ );
161
+ expect(result.valid).toBe(false);
162
+ expect(result.errors.join(' ')).toContain('statuses');
163
+ expect(result.errors.join(' ')).toContain('bogus');
164
+ });
165
+
166
+ it('rejects agent_signal statuses that are terminal (not signalable)', () => {
167
+ const result = getCreateEventSubscriptionValidationResult(
168
+ validCreate({
169
+ target: { type: 'agent', on_match: 'signal', message_path: 'm', statuses: ['completed'] } as never,
170
+ }),
171
+ );
172
+ expect(result.valid).toBe(false);
173
+ expect(result.errors.join(' ')).toContain('statuses');
174
+ expect(result.errors.join(' ')).toContain('on_terminal');
175
+ });
176
+
177
+ it('rejects non-string agent_signal optional path fields', () => {
178
+ const result = getCreateEventSubscriptionValidationResult(
179
+ validCreate({
180
+ target: { type: 'agent', on_match: 'signal', message_path: 'm', author_path: 123 } as never,
181
+ }),
182
+ );
183
+ expect(result.valid).toBe(false);
184
+ expect(result.errors.join(' ')).toContain('author_path');
185
+ });
186
+
187
+ it('accepts agent_signal with interaction_ref and valid statuses', () => {
188
+ const result = getCreateEventSubscriptionValidationResult(
189
+ validCreate({
190
+ target: {
191
+ type: 'agent',
192
+ on_match: 'signal',
193
+ message_path: 'details.payload.comment.body',
194
+ interaction_ref: 'sys:SoftwareEngineeringAgent',
195
+ statuses: ['running'],
196
+ signal_name: 'UserInput',
197
+ on_terminal: 'skip',
198
+ },
199
+ }),
200
+ );
201
+ expect(result.valid).toBe(true);
202
+ });
203
+
204
+ it('rejects unsupported event categories', () => {
205
+ const result = getCreateEventSubscriptionValidationResult(
206
+ validCreate({ filter: { event_category: ['nope'] as never } }),
207
+ );
208
+ expect(result.valid).toBe(false);
209
+ expect(result.errors.join(' ')).toContain('event_category');
210
+ });
211
+
212
+ it('accepts a partial update', () => {
213
+ expect(getUpdateEventSubscriptionValidationResult({ enabled: false }).valid).toBe(true);
214
+ });
215
+
216
+ it('validates a target supplied in an update', () => {
217
+ const result = getUpdateEventSubscriptionValidationResult({ target: { type: 'webhook' } as never });
218
+ expect(result.valid).toBe(false);
219
+ expect(result.errors.join(' ')).toContain('url');
220
+ });
221
+
222
+ it('does not expose side-effect JSONLogic operators for event conditions', () => {
223
+ expect(EVENT_CONDITION_JSON_LOGIC_OPERATORS).not.toContain('log');
224
+ });
225
+ });
@@ -0,0 +1,359 @@
1
+ import type {
2
+ CreateEventSubscriptionPayload,
3
+ EventCategory,
4
+ EventDeliveryTargetInput,
5
+ EventPriority,
6
+ EventSubscriptionFilter,
7
+ UpdateEventSubscriptionPayload,
8
+ } from './platform-event.js';
9
+ import { SystemRoles } from './project.js';
10
+ import type { AgentRunStatus } from './store/index.js';
11
+
12
+ /**
13
+ * Statuses an `agent_signal` target may list in `statuses` — the run statuses that can be **directly
14
+ * signalled**. Only a live (`running`, possibly idle) run has an active Temporal workflow that accepts a
15
+ * signal; `created` runs have no workflow bound yet, and terminal runs are handled separately by
16
+ * `on_terminal` (`restart` re-activates the newest terminal run, then signals). Restricting the set keeps
17
+ * config from silently trying to signal a closed workflow.
18
+ */
19
+ const SIGNALABLE_AGENT_RUN_STATUS_VALUES: readonly AgentRunStatus[] = ['running'];
20
+
21
+ export const DEFAULT_WEBHOOK_TIMEOUT_MS = 30_000;
22
+ export const MAX_WEBHOOK_TIMEOUT_MS = 50_000;
23
+
24
+ export const EVENT_CATEGORIES: readonly EventCategory[] = [
25
+ 'content',
26
+ 'workflow',
27
+ 'security',
28
+ 'billing',
29
+ 'system',
30
+ 'external',
31
+ ];
32
+ export const EVENT_PRIORITIES: readonly EventPriority[] = ['high', 'normal', 'low'];
33
+
34
+ /** Default agent-evaluator iteration budget for a semantic condition. */
35
+ export const DEFAULT_SEMANTIC_AGENT_MAX_ITERATIONS = 10;
36
+ /** Maximum agent-evaluator iteration budget a user may request. */
37
+ export const MAX_SEMANTIC_AGENT_MAX_ITERATIONS = 30;
38
+ /** Default content-excerpt size (chars) included in the interaction classifier prompt. */
39
+ export const DEFAULT_SEMANTIC_MAX_CONTENT_CHARS = 4000;
40
+ /** Maximum content-excerpt size (chars) a user may request. */
41
+ export const MAX_SEMANTIC_MAX_CONTENT_CHARS = 20_000;
42
+ /** Maximum length of a semantic condition instruction. */
43
+ export const MAX_SEMANTIC_CONDITION_INSTRUCTION_LENGTH = 2000;
44
+
45
+ /** Default action stamped on an ingested external event when none is provided/mapped. */
46
+ export const DEFAULT_EXTERNAL_EVENT_ACTION = 'received';
47
+ /** Default resource_type stamped on an ingested external event when none is provided/mapped. */
48
+ export const DEFAULT_EXTERNAL_EVENT_RESOURCE_TYPE = 'external_event';
49
+ /** Prefix combined with a channel `source` to form the event `source` (`external:<source>`). */
50
+ export const EXTERNAL_EVENT_SOURCE_PREFIX = 'external:';
51
+
52
+ /** Role an event subscription runs as when none is specified. */
53
+ export const DEFAULT_EVENT_SUBSCRIPTION_RUN_AS_ROLE = SystemRoles.automation;
54
+
55
+ /**
56
+ * Roles a user may select for an event subscription's run_as_role. System subscriptions may use
57
+ * additional roles (e.g. content_processor); this list gates user input in the API, agent tools,
58
+ * and UI — keep all three consuming this constant.
59
+ */
60
+ export const EVENT_SUBSCRIPTION_RUN_AS_ROLES: readonly SystemRoles[] = [
61
+ SystemRoles.automation,
62
+ SystemRoles.executor,
63
+ SystemRoles.reader,
64
+ ];
65
+
66
+ /**
67
+ * JSONLogic operators accepted in event subscription filter conditions. The event-bus evaluator
68
+ * rejects any operator not in this list (fail closed); the UI condition editor uses it for
69
+ * completion and validation.
70
+ */
71
+ export const EVENT_CONDITION_JSON_LOGIC_OPERATORS: readonly string[] = [
72
+ '!',
73
+ '!!',
74
+ '!=',
75
+ '!==',
76
+ '%',
77
+ '*',
78
+ '+',
79
+ '-',
80
+ '/',
81
+ '<',
82
+ '<=',
83
+ '==',
84
+ '===',
85
+ '>',
86
+ '>=',
87
+ '?:',
88
+ 'all',
89
+ 'and',
90
+ 'cat',
91
+ 'filter',
92
+ 'if',
93
+ 'in',
94
+ 'map',
95
+ 'max',
96
+ 'merge',
97
+ 'min',
98
+ 'missing',
99
+ 'missing_some',
100
+ 'none',
101
+ 'or',
102
+ 'reduce',
103
+ 'some',
104
+ 'substr',
105
+ 'var',
106
+ ];
107
+
108
+ export interface EventSubscriptionValidationResult {
109
+ valid: boolean;
110
+ errors: string[];
111
+ }
112
+
113
+ function isRecord(value: unknown): value is Record<string, unknown> {
114
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
115
+ }
116
+
117
+ function isStringArray(value: unknown): value is string[] {
118
+ return Array.isArray(value) && value.every((item) => typeof item === 'string');
119
+ }
120
+
121
+ function validateFilter(filter: unknown, errors: string[]): void {
122
+ if (!isRecord(filter)) {
123
+ errors.push('filter must be an object');
124
+ return;
125
+ }
126
+ const f = filter as EventSubscriptionFilter;
127
+ if (f.event_category !== undefined) {
128
+ if (!isStringArray(f.event_category)) {
129
+ errors.push('filter.event_category must be an array of strings');
130
+ } else {
131
+ for (const c of f.event_category) {
132
+ if (c !== '*' && !EVENT_CATEGORIES.includes(c as EventCategory)) {
133
+ errors.push(`filter.event_category contains unsupported value: ${c}`);
134
+ }
135
+ }
136
+ }
137
+ }
138
+ if (f.exclude_event_category !== undefined) {
139
+ if (!isStringArray(f.exclude_event_category)) {
140
+ errors.push('filter.exclude_event_category must be an array of strings');
141
+ } else {
142
+ for (const c of f.exclude_event_category) {
143
+ if (!EVENT_CATEGORIES.includes(c)) {
144
+ errors.push(`filter.exclude_event_category contains unsupported value: ${c}`);
145
+ }
146
+ }
147
+ }
148
+ }
149
+ if (f.action !== undefined && !isStringArray(f.action)) {
150
+ errors.push('filter.action must be an array of strings');
151
+ }
152
+ if (f.resource_type !== undefined && !isStringArray(f.resource_type)) {
153
+ errors.push('filter.resource_type must be an array of strings');
154
+ }
155
+ if (f.condition !== undefined && !isRecord(f.condition)) {
156
+ errors.push('filter.condition must be a JSON Logic object');
157
+ }
158
+ }
159
+
160
+ function validateTarget(target: unknown, errors: string[]): void {
161
+ if (!isRecord(target)) {
162
+ errors.push('target must be an object');
163
+ return;
164
+ }
165
+ const type = target.type;
166
+ if (type !== 'workflow' && type !== 'webhook' && type !== 'agent' && type !== 'process') {
167
+ errors.push('target.type must be one of workflow, webhook, agent, process');
168
+ return;
169
+ }
170
+ const t = target as EventDeliveryTargetInput;
171
+ switch (t.type) {
172
+ case 'workflow':
173
+ if (typeof t.endpoint !== 'string' || t.endpoint.length === 0) {
174
+ errors.push('workflow target requires a non-empty "endpoint"');
175
+ }
176
+ break;
177
+ case 'webhook': {
178
+ if (typeof t.url !== 'string' || t.url.length === 0) {
179
+ errors.push('webhook target requires a non-empty "url"');
180
+ }
181
+ if (t.timeout_ms !== undefined) {
182
+ if (!Number.isInteger(t.timeout_ms) || t.timeout_ms <= 0 || t.timeout_ms > MAX_WEBHOOK_TIMEOUT_MS) {
183
+ errors.push(`webhook target.timeout_ms must be an integer between 1 and ${MAX_WEBHOOK_TIMEOUT_MS}`);
184
+ }
185
+ }
186
+ if (t.signing_mode !== undefined && t.signing_mode !== 'signed' && t.signing_mode !== 'legacy_unsigned') {
187
+ errors.push('webhook target.signing_mode must be "signed" or "legacy_unsigned"');
188
+ }
189
+ if (
190
+ t.payload_mode !== undefined &&
191
+ t.payload_mode !== 'event_envelope' &&
192
+ t.payload_mode !== 'legacy_notify_endpoint' &&
193
+ t.payload_mode !== 'workflow_notification'
194
+ ) {
195
+ errors.push(
196
+ 'webhook target.payload_mode must be "event_envelope", "legacy_notify_endpoint" or "workflow_notification"',
197
+ );
198
+ }
199
+ if (t.encrypted_headers === true) {
200
+ errors.push('webhook target.encrypted_headers is not supported');
201
+ }
202
+ if (t.headers !== undefined && !isRecord(t.headers)) {
203
+ errors.push('webhook target.headers must be an object of strings');
204
+ }
205
+ break;
206
+ }
207
+ case 'process':
208
+ if ((typeof t.process_ref !== 'string' || t.process_ref.length === 0) && !isRecord(t.process_definition)) {
209
+ errors.push('process target requires "process_ref" or an inline "process_definition"');
210
+ }
211
+ break;
212
+ case 'agent': {
213
+ // One target with three behaviors selected by `on_match` (default `start`): start a run,
214
+ // signal an existing run, or ensure (signal-or-start). `signal`/`ensure` need `message_path`
215
+ // and use the gate/signal fields; `missing_thread` is only meaningful for `signal`.
216
+ const onMatch = t.on_match ?? 'start';
217
+ if (onMatch !== 'start' && onMatch !== 'signal' && onMatch !== 'ensure') {
218
+ errors.push('agent target.on_match must be "start", "signal", or "ensure"');
219
+ }
220
+ const needsMessage = onMatch === 'signal' || onMatch === 'ensure';
221
+ if (needsMessage && (typeof t.message_path !== 'string' || t.message_path.length === 0)) {
222
+ errors.push(`agent target.message_path is required when on_match is "${onMatch}"`);
223
+ }
224
+ // Only the UserInput signal is implemented (the dispatcher always sends a UserInput-shaped
225
+ // payload), so reject other names rather than silently mis-deliver.
226
+ if (t.signal_name !== undefined && t.signal_name !== 'UserInput') {
227
+ errors.push('agent target.signal_name must be "UserInput"');
228
+ }
229
+ // Optional dot-path / ref fields are read dynamically at runtime — reject non-strings early.
230
+ for (const field of [
231
+ 'interaction_ref',
232
+ 'message_path',
233
+ 'client_message_id_path',
234
+ 'skip_if_path_exists',
235
+ 'author_path',
236
+ ]) {
237
+ const value = (t as unknown as Record<string, unknown>)[field];
238
+ if (value !== undefined && typeof value !== 'string') {
239
+ errors.push(`agent target.${field} must be a string`);
240
+ }
241
+ }
242
+ if (t.metadata !== undefined && !isRecord(t.metadata)) {
243
+ errors.push('agent target.metadata must be an object');
244
+ }
245
+ if (t.statuses !== undefined) {
246
+ if (!isStringArray(t.statuses)) {
247
+ errors.push('agent target.statuses must be an array of strings');
248
+ } else {
249
+ const invalid = t.statuses.filter(
250
+ (s) => !SIGNALABLE_AGENT_RUN_STATUS_VALUES.includes(s as AgentRunStatus),
251
+ );
252
+ if (invalid.length > 0) {
253
+ errors.push(
254
+ `agent target.statuses may only contain signalable statuses: ${invalid.join(', ')} ` +
255
+ `not allowed. Allowed: ${SIGNALABLE_AGENT_RUN_STATUS_VALUES.join(', ')} ` +
256
+ `(terminal runs are handled by on_terminal).`,
257
+ );
258
+ }
259
+ }
260
+ }
261
+ for (const field of ['ignore_author_patterns', 'require_command_prefixes', 'require_mentions']) {
262
+ const value = (t as unknown as Record<string, unknown>)[field];
263
+ if (value !== undefined && !isStringArray(value)) {
264
+ errors.push(`agent target.${field} must be an array of strings`);
265
+ }
266
+ }
267
+ if (t.missing_thread !== undefined) {
268
+ if (onMatch !== 'signal') {
269
+ errors.push('agent target.missing_thread is only valid when on_match is "signal"');
270
+ } else if (t.missing_thread !== 'retry' && t.missing_thread !== 'skip') {
271
+ errors.push('agent target.missing_thread must be "retry" or "skip"');
272
+ }
273
+ }
274
+ if (t.on_terminal !== undefined && t.on_terminal !== 'skip' && t.on_terminal !== 'restart') {
275
+ errors.push('agent target.on_terminal must be "skip" or "restart"');
276
+ }
277
+ break;
278
+ }
279
+ }
280
+ }
281
+
282
+ function validateCommonFields(payload: Partial<CreateEventSubscriptionPayload>, errors: string[]): void {
283
+ if (payload.description !== undefined && typeof payload.description !== 'string') {
284
+ errors.push('description must be a string');
285
+ }
286
+ if (payload.run_as_role !== undefined && !Object.values(SystemRoles).includes(payload.run_as_role)) {
287
+ errors.push(`run_as_role contains unsupported value: ${payload.run_as_role}`);
288
+ }
289
+ if (payload.enabled !== undefined && typeof payload.enabled !== 'boolean') {
290
+ errors.push('enabled must be a boolean');
291
+ }
292
+ if (payload.priority !== undefined && !EVENT_PRIORITIES.includes(payload.priority)) {
293
+ errors.push(`priority must be one of ${EVENT_PRIORITIES.join(', ')}`);
294
+ }
295
+ }
296
+
297
+ export function getCreateEventSubscriptionValidationResult(
298
+ payload: CreateEventSubscriptionPayload,
299
+ ): EventSubscriptionValidationResult {
300
+ const errors: string[] = [];
301
+ if (typeof payload?.name !== 'string' || payload.name.trim().length === 0) {
302
+ errors.push('name is required');
303
+ }
304
+ if (payload?.scope !== undefined && payload.scope !== 'account' && payload.scope !== 'project') {
305
+ errors.push('scope must be "account" or "project"');
306
+ }
307
+ if (payload?.filter === undefined) {
308
+ errors.push('filter is required');
309
+ } else {
310
+ validateFilter(payload.filter, errors);
311
+ }
312
+ if (payload?.target === undefined) {
313
+ errors.push('target is required');
314
+ } else {
315
+ validateTarget(payload.target, errors);
316
+ }
317
+ // run_as_role is required at creation: every subscription must declare an explicit run-as
318
+ // identity so its delivery never falls back to running as the originating (possibly deleted)
319
+ // user. validateCommonFields validates the value when present; this enforces presence.
320
+ if (payload?.run_as_role === undefined) {
321
+ errors.push('run_as_role is required');
322
+ }
323
+ validateCommonFields(payload ?? {}, errors);
324
+ return { valid: errors.length === 0, errors };
325
+ }
326
+
327
+ export function getUpdateEventSubscriptionValidationResult(
328
+ payload: UpdateEventSubscriptionPayload,
329
+ ): EventSubscriptionValidationResult {
330
+ const errors: string[] = [];
331
+ if (!isRecord(payload)) {
332
+ return { valid: false, errors: ['update payload must be an object'] };
333
+ }
334
+ if (payload.name !== undefined && (typeof payload.name !== 'string' || payload.name.trim().length === 0)) {
335
+ errors.push('name must be a non-empty string');
336
+ }
337
+ if (payload.filter !== undefined) {
338
+ validateFilter(payload.filter, errors);
339
+ }
340
+ if (payload.target !== undefined) {
341
+ validateTarget(payload.target, errors);
342
+ }
343
+ validateCommonFields(payload, errors);
344
+ return { valid: errors.length === 0, errors };
345
+ }
346
+
347
+ export function validateCreateEventSubscriptionInput(payload: CreateEventSubscriptionPayload): void {
348
+ const result = getCreateEventSubscriptionValidationResult(payload);
349
+ if (!result.valid) {
350
+ throw new Error(result.errors.join('; '));
351
+ }
352
+ }
353
+
354
+ export function validateUpdateEventSubscriptionInput(payload: UpdateEventSubscriptionPayload): void {
355
+ const result = getUpdateEventSubscriptionValidationResult(payload);
356
+ if (!result.valid) {
357
+ throw new Error(result.errors.join('; '));
358
+ }
359
+ }