@aifabrix/builder 2.49.0 → 2.49.2

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 (35) hide show
  1. package/.cursor/process/README.md +9 -0
  2. package/.cursor/skills/README.md +14 -0
  3. package/lib/api/dev.api.js +1 -0
  4. package/lib/api/host-ops.api.js +69 -0
  5. package/lib/cli/infra-guided-auth.js +8 -6
  6. package/lib/cli/infra-guided.js +11 -2
  7. package/lib/cli/setup-dev-path-commands.js +24 -0
  8. package/lib/cli/setup-dev.js +108 -14
  9. package/lib/commands/dev-cli-handlers.js +64 -3
  10. package/lib/commands/dev-host-ops.js +239 -0
  11. package/lib/commands/teardown.js +3 -5
  12. package/lib/commands/up-common.js +8 -11
  13. package/lib/commands/up-dataplane.js +81 -13
  14. package/lib/commands/up-miso.js +6 -2
  15. package/lib/schema/external-system.schema.json +114 -2
  16. package/lib/utils/health-check-poll-ui.js +63 -28
  17. package/lib/utils/health-check.js +50 -21
  18. package/lib/utils/paths.js +26 -36
  19. package/package.json +1 -1
  20. package/templates/applications/dataplane/application.yaml +1 -1
  21. package/templates/applications/dataplane/env.template +2 -1
  22. package/templates/applications/miso-controller/env.template +2 -0
  23. package/.temp/jest-protection-convert/run-IFN2nv/.protection/hubspot-companies.json +0 -1
  24. package/.temp/jest-protection-convert/run-IvmKLO/.protection/hubspot-companies.yaml +0 -17
  25. package/.temp/jest-protection-convert/run-SdvblF/.protection/hubspot-companies.json +0 -1
  26. package/.temp/jest-protection-convert/run-b51TsW/.protection/hubspot-companies.json +0 -1
  27. package/.temp/jest-protection-convert/run-fiLmb1/.protection/hubspot-companies.json +0 -1
  28. package/.temp/jest-protection-convert/run-qYGHMU/.protection/hubspot-companies.yaml +0 -17
  29. package/.temp/jest-protection-fixtures/hubspot-Mq5re8/hubspot-companies.yaml +0 -17
  30. package/.temp/jest-protection-fixtures/hubspot-f3u4YL/hubspot-companies.yaml +0 -17
  31. package/.temp/jest-protection-fixtures/hubspot-iQtd1O/hubspot-companies.yaml +0 -17
  32. package/.temp/jest-protection-fixtures/hubspot-mdmz5M/hubspot-companies.yaml +0 -17
  33. package/.temp/jest-protection-fixtures/hubspot-qbKYxQ/hubspot-companies.yaml +0 -17
  34. package/.temp/jest-protection-fixtures/hubspot-yzlJy6/hubspot-companies.yaml +0 -17
  35. package/.temp/validation/summary.txt +0 -5
@@ -0,0 +1,239 @@
1
+ /**
2
+ * @fileoverview Dev host-ops CLI handlers (platform-install, platform-update, etc.)
3
+ * @author AI Fabrix Team
4
+ * @version 1.0.0
5
+ */
6
+
7
+ const readline = require('readline');
8
+ const chalk = require('chalk');
9
+ const logger = require('../utils/logger');
10
+ const { formatSuccessLine } = require('../utils/cli-test-layout-chalk');
11
+ const { getRemoteDevAuth } = require('../utils/remote-dev-auth');
12
+ const hostOpsApi = require('../api/host-ops.api');
13
+
14
+ const REMOTE_NOT_CONFIGURED_MSG =
15
+ 'Remote server is not configured. Set remote-server and run "aifabrix dev init" first.';
16
+
17
+ const POLL_INTERVAL_MS = 3000;
18
+ const POLL_TIMEOUT_MS = 3 * 60 * 60 * 1000;
19
+
20
+ function sleep(ms) {
21
+ return new Promise((resolve) => setTimeout(resolve, ms));
22
+ }
23
+
24
+ /**
25
+ * Build job params from Commander options.
26
+ * @param {Object} options
27
+ * @param {string} [developerId]
28
+ * @returns {Object}
29
+ */
30
+ function buildJobParams(options, developerId) {
31
+ const params = {};
32
+ const id = developerId || options.developerId || options['developer-id'];
33
+ if (id) params.developerId = String(id).trim();
34
+ if (options.include) params.include = options.include;
35
+ if (options.exclude) params.exclude = options.exclude;
36
+ if (options.builderVersion || options['builder-version']) {
37
+ params.builderVersion = options.builderVersion || options['builder-version'];
38
+ }
39
+ if (options.adminEmail || options['admin-email']) {
40
+ params.adminEmail = options.adminEmail || options['admin-email'];
41
+ }
42
+ if (options.adminPassword || options['admin-password']) {
43
+ params.adminPassword = options.adminPassword || options['admin-password'];
44
+ }
45
+ if (options.platform) params.platform = options.platform;
46
+ if (options.installation) params.installation = options.installation;
47
+ if (options.skipCert || options['skip-cert']) params.skipCert = true;
48
+ if (options.skipPlatform || options['skip-platform']) params.skipPlatform = true;
49
+ if (options.skipCli || options['skip-cli']) params.skipCli = true;
50
+ if (options.check) params.check = true;
51
+ if (options.failOnBlocked) params.failOnBlocked = true;
52
+ if (options.timeoutMinutes || options['timeout-minutes']) {
53
+ params.timeoutMinutes = parseInt(options.timeoutMinutes || options['timeout-minutes'], 10);
54
+ }
55
+ return params;
56
+ }
57
+
58
+ /**
59
+ * Poll host-ops job until terminal state.
60
+ * @param {Object} auth
61
+ * @param {string} jobId
62
+ * @param {Object} [opts]
63
+ * @param {boolean} [opts.wait=true]
64
+ * @param {boolean} [opts.json=false]
65
+ * @returns {Promise<Object>}
66
+ */
67
+ async function waitForHostOpsJob(auth, jobId, opts = {}) {
68
+ const wait = opts.wait !== false;
69
+ const json = Boolean(opts.json);
70
+ if (!wait) {
71
+ return { jobId, status: 'pending' };
72
+ }
73
+ const started = Date.now();
74
+ while (Date.now() - started < POLL_TIMEOUT_MS) {
75
+ const job = await hostOpsApi.getHostOpsJob(
76
+ auth.serverUrl,
77
+ auth.clientCertPem,
78
+ jobId,
79
+ auth.serverCaPem || undefined
80
+ );
81
+ if (!json) {
82
+ logger.log(chalk.gray(` host-ops job ${jobId}: ${job.status}`));
83
+ }
84
+ if (job.status === 'completed' || job.status === 'failed') {
85
+ if (job.status === 'failed') {
86
+ throw new Error(job.error || `Host-ops job ${jobId} failed`);
87
+ }
88
+ return job;
89
+ }
90
+ await sleep(POLL_INTERVAL_MS);
91
+ }
92
+ throw new Error(`Timed out waiting for host-ops job ${jobId}`);
93
+ }
94
+
95
+ /**
96
+ * @param {string} question
97
+ * @returns {Promise<boolean>}
98
+ */
99
+ function promptYesNo(question) {
100
+ if (!process.stdin.isTTY) {
101
+ return Promise.resolve(false);
102
+ }
103
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
104
+ return new Promise((resolve) => {
105
+ rl.question(`${question} [y/N] `, (answer) => {
106
+ rl.close();
107
+ resolve(/^y(es)?$/i.test(String(answer || '').trim()));
108
+ });
109
+ });
110
+ }
111
+
112
+ function platformAlreadyInstalled(outcomes) {
113
+ if (!Array.isArray(outcomes)) return false;
114
+ return outcomes.some(
115
+ (row) =>
116
+ row &&
117
+ (row.overall === 'SKIP' || row.status === 'SKIP') &&
118
+ String(row.detail || '').toLowerCase().includes('installed')
119
+ );
120
+ }
121
+
122
+ /**
123
+ * Run a host-ops job and optionally wait.
124
+ * @param {string} type
125
+ * @param {Object} params
126
+ * @param {Object} options - Commander options (yes, wait, json)
127
+ * @returns {Promise<Object>}
128
+ */
129
+ async function runHostOpsJob(type, params, options = {}) {
130
+ const auth = await getRemoteDevAuth();
131
+ if (!auth) throw new Error(REMOTE_NOT_CONFIGURED_MSG);
132
+ const { jobId } = await hostOpsApi.createHostOpsJob(
133
+ auth.serverUrl,
134
+ auth.clientCertPem,
135
+ { type, params },
136
+ auth.serverCaPem || undefined
137
+ );
138
+ if (options.json) {
139
+ const job = await waitForHostOpsJob(auth, jobId, { wait: options.wait !== false, json: true });
140
+ logger.log(JSON.stringify(job, null, 2));
141
+ return job;
142
+ }
143
+ const job = await waitForHostOpsJob(auth, jobId, { wait: options.wait !== false, json: false });
144
+ logger.log(formatSuccessLine(`Host-ops job ${jobId} completed (${type}).`));
145
+ return job;
146
+ }
147
+
148
+ async function handlePlatformInstallCli(developerId, options) {
149
+ const params = buildJobParams(options, developerId);
150
+ if (options.check) {
151
+ params.check = true;
152
+ } else if (!params.builderVersion) {
153
+ throw new Error('--builder-version is required unless --check is set');
154
+ }
155
+ await runHostOpsJob('install-builder-cli', params, options);
156
+ }
157
+
158
+ async function handlePlatformUpdate(developerId, options) {
159
+ const params = buildJobParams(options, developerId);
160
+ if (options.check) params.check = true;
161
+ await runHostOpsJob('update-dev-platform', params, options);
162
+ }
163
+
164
+ async function handlePlatformInstall(developerId, options) {
165
+ if (!developerId) {
166
+ throw new Error('Developer ID is required (positional argument).');
167
+ }
168
+ const params = buildJobParams(options, developerId);
169
+
170
+ if (options.check) {
171
+ params.check = true;
172
+ const auth = await getRemoteDevAuth();
173
+ if (!auth) throw new Error(REMOTE_NOT_CONFIGURED_MSG);
174
+ const { jobId } = await hostOpsApi.preflightHostOps(
175
+ auth.serverUrl,
176
+ auth.clientCertPem,
177
+ { type: 'install-dev-platform', params },
178
+ auth.serverCaPem || undefined
179
+ );
180
+ const job = await waitForHostOpsJob(auth, jobId, options);
181
+ if (options.json) {
182
+ logger.log(JSON.stringify(job, null, 2));
183
+ } else if (Array.isArray(job.outcomes)) {
184
+ job.outcomes.forEach((row) => {
185
+ logger.log(` ${row.linuxUser || developerId}: ${row.overall || row.status}${row.detail ? ` — ${row.detail}` : ''}`);
186
+ });
187
+ }
188
+ return job;
189
+ }
190
+
191
+ if (!options.force && !options.yes) {
192
+ const auth = await getRemoteDevAuth();
193
+ if (!auth) throw new Error(REMOTE_NOT_CONFIGURED_MSG);
194
+ const { jobId } = await hostOpsApi.preflightHostOps(
195
+ auth.serverUrl,
196
+ auth.clientCertPem,
197
+ { type: 'install-dev-platform', params: { ...params, developerId } },
198
+ auth.serverCaPem || undefined
199
+ );
200
+ const preflight = await waitForHostOpsJob(auth, jobId, { ...options, json: false });
201
+ if (platformAlreadyInstalled(preflight.outcomes)) {
202
+ const ok = await promptYesNo(
203
+ `Platform already installed for dev${String(developerId).padStart(2, '0')}. Re-install?`
204
+ );
205
+ if (!ok) {
206
+ logger.log(chalk.gray('Cancelled.'));
207
+ return preflight;
208
+ }
209
+ await runHostOpsJob('reinstall-dev-platform', params, options);
210
+ return;
211
+ }
212
+ }
213
+
214
+ const jobType = options.force ? 'reinstall-dev-platform' : 'install-dev-platform';
215
+ await runHostOpsJob(jobType, params, options);
216
+ }
217
+
218
+ async function handleProvisionDevUser(developerId, options) {
219
+ const params = buildJobParams(options, developerId);
220
+ await runHostOpsJob('provision-dev-user', params, options);
221
+ }
222
+
223
+ async function handleRemoveDevUser(developerId, options) {
224
+ const params = buildJobParams(options, developerId);
225
+ if (!params.developerId) {
226
+ throw new Error('Developer ID is required.');
227
+ }
228
+ await runHostOpsJob('remove-dev-user', params, options);
229
+ }
230
+
231
+ module.exports = {
232
+ buildJobParams,
233
+ waitForHostOpsJob,
234
+ handlePlatformInstallCli,
235
+ handlePlatformUpdate,
236
+ handlePlatformInstall,
237
+ handleProvisionDevUser,
238
+ handleRemoveDevUser
239
+ };
@@ -120,17 +120,15 @@ function cleanAifabrixSystemDir() {
120
120
  }
121
121
 
122
122
  /**
123
- * Remove materialized `builder/` trees (aifabrix-work, aifabrix-home / config dir, cwd).
124
- * Complements {@link cleanAifabrixSystemDir} when `builder/` lives under `aifabrix-work` outside
125
- * the config directory.
123
+ * Remove materialized `builder/` trees under home/work and config dir only.
124
+ * Does not remove `cwd/builder` in product repos (e.g. aifabrix-dataplane/builder/dataplane).
126
125
  *
127
126
  * @returns {{ removed: string[], failed: string[] }}
128
127
  */
129
128
  function cleanMaterializedBuilderTrees() {
130
129
  const candidates = [
131
130
  pathsUtil.getBuilderRoot(),
132
- path.join(pathsUtil.getAifabrixSystemDir(), 'builder'),
133
- path.join(process.cwd(), 'builder')
131
+ path.join(pathsUtil.getAifabrixSystemDir(), 'builder')
134
132
  ];
135
133
  const seen = new Set();
136
134
  const removed = [];
@@ -242,26 +242,21 @@ function isUnderAllowedBuilderRoot(resolvedAppPath, allowedRoots) {
242
242
  }
243
243
 
244
244
  /**
245
- * Removes builder app directories for the given app names. Only removes paths under an allowed
246
- * builder root (project `builder/` or `~/.aifabrix/builder/`) to prevent path traversal.
247
- * Uses {@link pathsUtil.getBuilderPath} for each app (system apps may resolve under the config dir).
245
+ * Removes platform app directories under the home/work materialized `builder/` root only.
246
+ * Never deletes `cwd/builder` trees in product repos (e.g. aifabrix-miso/builder/miso-controller).
248
247
  *
249
248
  * @param {string[]} appNames - Application names (e.g. ['keycloak', 'miso-controller', 'dataplane'])
250
249
  * @returns {Promise<void>}
251
- * @throws {Error} If any path is outside allowed builder roots (path traversal attempt)
250
+ * @throws {Error} If any path is outside the home builder root (path traversal attempt)
252
251
  */
253
252
  async function cleanBuilderAppDirs(appNames, opts = {}) {
254
253
  const silent = Boolean(opts.silent);
255
254
  if (!Array.isArray(appNames) || appNames.length === 0) return;
256
- const allowedRoots = uniqueResolvedRoots([
257
- path.join(process.cwd(), 'builder'),
258
- pathsUtil.getBuilderRoot(),
259
- pathsUtil.getSystemBuilderRoot()
260
- ]);
255
+ const allowedRoots = uniqueResolvedRoots([pathsUtil.getSystemBuilderRoot()]);
261
256
  const cleaned = [];
262
257
  for (const appName of appNames) {
263
258
  if (!appName || typeof appName !== 'string') continue;
264
- const appPath = path.resolve(pathsUtil.getBuilderPath(appName));
259
+ const appPath = path.resolve(pathsUtil.getHomeBuilderAppPath(appName));
265
260
  if (!isUnderAllowedBuilderRoot(appPath, allowedRoots)) {
266
261
  throw new Error(
267
262
  `Path ${appPath} is outside allowed builder roots (${allowedRoots.join(', ')}); refusing to clean`
@@ -303,7 +298,9 @@ async function ensureAppFromTemplate(appName, opts = {}) {
303
298
  }
304
299
  const silent = Boolean(opts.silent);
305
300
 
306
- const appPath = pathsUtil.getBuilderPath(appName);
301
+ const appPath = pathsUtil.isSystemBuilderAppName(appName)
302
+ ? pathsUtil.getHomeBuilderAppPath(appName)
303
+ : pathsUtil.getBuilderPath(appName);
307
304
  const primaryCopied = await ensureTemplateAtPath(appName, appPath);
308
305
  if (primaryCopied && !silent) {
309
306
  logger.log(chalk.blue(`Creating builder/${appName} from template...`));
@@ -41,6 +41,17 @@ const {
41
41
  } = require('./up-dataplane-bootstrap');
42
42
 
43
43
  const CONTROLLER_HEALTH_PATH = '/health';
44
+ /** Automation / af-server: wait for edge routing before dataplane (nginx + Traefik). */
45
+ const CONTROLLER_HEALTH_RETRY_ATTEMPTS = 30;
46
+ const CONTROLLER_HEALTH_RETRY_DELAY_MS = 2000;
47
+
48
+ /**
49
+ * @param {number} ms
50
+ * @returns {Promise<void>}
51
+ */
52
+ function sleep(ms) {
53
+ return new Promise((resolve) => setTimeout(resolve, ms));
54
+ }
44
55
 
45
56
  /**
46
57
  * Check if controller is reachable (health endpoint).
@@ -76,19 +87,46 @@ function promptForControllerUrl(currentUrl) {
76
87
  }
77
88
 
78
89
  /**
79
- * Resolve controller URL and ensure it is healthy; if not, prompt once for new URL.
80
- * @returns {Promise<string>} Controller URL to use
81
- * @throws {Error} If controller unavailable and user aborts or new URL still unhealthy
90
+ * Poll controller /health until success or attempts exhausted.
91
+ * @param {string} baseUrl - Controller base URL (no trailing slash)
92
+ * @param {{ attempts?: number, delayMs?: number }} [opts]
93
+ * @returns {Promise<boolean>}
82
94
  */
83
- async function resolveControllerUrlWithHealthCheck() {
84
- let controllerUrl = await resolveControllerUrl();
85
- controllerUrl = controllerUrl.replace(/\/+$/, '');
86
-
87
- let healthy = await isControllerHealthy(controllerUrl);
88
- if (healthy) {
89
- return controllerUrl;
95
+ async function waitForControllerHealthy(baseUrl, opts = {}) {
96
+ const attempts = opts.attempts ?? CONTROLLER_HEALTH_RETRY_ATTEMPTS;
97
+ const delayMs = opts.delayMs ?? CONTROLLER_HEALTH_RETRY_DELAY_MS;
98
+ const normalized = baseUrl.replace(/\/+$/, '');
99
+ for (let i = 0; i < attempts; i += 1) {
100
+ if (await isControllerHealthy(normalized)) {
101
+ return true;
102
+ }
103
+ if (i < attempts - 1) {
104
+ await sleep(delayMs);
105
+ }
90
106
  }
107
+ return false;
108
+ }
109
+
110
+ /**
111
+ * @param {string} controllerUrl
112
+ * @returns {string}
113
+ */
114
+ function buildControllerUnavailableMessage(controllerUrl) {
115
+ return (
116
+ `Controller at ${controllerUrl} is not responding after ${CONTROLLER_HEALTH_RETRY_ATTEMPTS} health checks ` +
117
+ `(~${Math.round((CONTROLLER_HEALTH_RETRY_ATTEMPTS * CONTROLLER_HEALTH_RETRY_DELAY_MS) / 1000)}s). ` +
118
+ 'Miso may be up on localhost before nginx/Traefik routes are ready. ' +
119
+ 'On the builder host run: sudo systemctl start aifabrix-sync-dev-traefik-nginx.service ' +
120
+ 'then retry: aifabrix up-dataplane --force'
121
+ );
122
+ }
91
123
 
124
+ /**
125
+ * Prompt for a new controller URL and verify it responds to health checks.
126
+ * @param {string} controllerUrl - URL that failed the initial health check
127
+ * @returns {Promise<string>} Verified controller URL
128
+ */
129
+ async function promptForHealthyControllerUrl(controllerUrl) {
92
130
  logger.log(chalk.yellow(`\nController at ${controllerUrl} is not responding (health check failed).\n`));
93
131
  const newUrl = await promptForControllerUrl(controllerUrl);
94
132
  if (!newUrl) {
@@ -103,7 +141,7 @@ async function resolveControllerUrlWithHealthCheck() {
103
141
 
104
142
  await setControllerUrl(newUrl);
105
143
  const normalizedNew = newUrl.trim().replace(/\/+$/, '');
106
- healthy = await isControllerHealthy(normalizedNew);
144
+ const healthy = await isControllerHealthy(normalizedNew);
107
145
  if (!healthy) {
108
146
  throw new Error(`Controller at ${normalizedNew} is not responding. Ensure the controller is running and reachable, then run "aifabrix up-dataplane" again.`);
109
147
  }
@@ -112,6 +150,28 @@ async function resolveControllerUrlWithHealthCheck() {
112
150
  return normalizedNew;
113
151
  }
114
152
 
153
+ /**
154
+ * Resolve controller URL and ensure it is healthy; if not, prompt once for new URL (TTY only).
155
+ * @returns {Promise<string>} Controller URL to use
156
+ * @throws {Error} If controller unavailable and user aborts or new URL still unhealthy
157
+ */
158
+ async function resolveControllerUrlWithHealthCheck() {
159
+ let controllerUrl = await resolveControllerUrl();
160
+ controllerUrl = controllerUrl.replace(/\/+$/, '');
161
+
162
+ const healthy = await waitForControllerHealthy(controllerUrl);
163
+ if (healthy) {
164
+ return controllerUrl;
165
+ }
166
+
167
+ const isInteractive = Boolean(process.stdin && process.stdin.isTTY);
168
+ if (!isInteractive) {
169
+ throw new Error(buildControllerUnavailableMessage(controllerUrl));
170
+ }
171
+
172
+ return promptForHealthyControllerUrl(controllerUrl);
173
+ }
174
+
115
175
  async function resolveUpDataplaneAuthContext(options, environmentKey) {
116
176
  if (options.skipInfraCheck !== true) {
117
177
  await assertDevInfraUp();
@@ -327,14 +387,22 @@ async function handleUpDataplane(options = {}) {
327
387
 
328
388
  const { runEnvKey, portOverride } = await computeDataplaneRunOverrides(options);
329
389
 
330
- await app.runApp('dataplane', {
390
+ const runAppOpts = {
331
391
  skipEnvOutputPath: true,
332
392
  env: runEnvKey,
333
393
  port: portOverride,
334
394
  registry: options.registry || undefined,
335
395
  base: options.base !== false,
336
396
  skipManifestMetadataLine: true
337
- });
397
+ };
398
+ if (options.platformInstall === true) {
399
+ if (typeof options.onPollText === 'function') {
400
+ runAppOpts.onPollText = options.onPollText;
401
+ } else {
402
+ runAppOpts.silentPoll = true;
403
+ }
404
+ }
405
+ await app.runApp('dataplane', runAppOpts);
338
406
 
339
407
  if (options.platformInstall !== true) {
340
408
  logger.log(formatSuccessParagraph('up-dataplane complete. Dataplane is registered, deployed in dev, and running locally.'));
@@ -57,11 +57,15 @@ async function runMisoApps(options) {
57
57
  base: useBaseImage,
58
58
  skipManifestMetadataLine: true
59
59
  };
60
- const keycloakRunOpts = { ...common };
60
+ const platformPollOpts =
61
+ options.platformInstall === true
62
+ ? { silentPoll: true }
63
+ : {};
64
+ const keycloakRunOpts = { ...common, ...platformPollOpts };
61
65
  if (imageMap.keycloak) {
62
66
  keycloakRunOpts.image = imageMap.keycloak;
63
67
  }
64
- const misoRunOpts = { ...common };
68
+ const misoRunOpts = { ...common, ...platformPollOpts };
65
69
  if (imageMap['miso-controller']) {
66
70
  misoRunOpts.image = imageMap['miso-controller'];
67
71
  }
@@ -212,7 +212,8 @@
212
212
  "compileCom",
213
213
  "generateEnterpriseDatasources",
214
214
  "generateRestContracts",
215
- "generateMcpContracts"
215
+ "generateMcpContracts",
216
+ "runDigitalWorkerTask"
216
217
  ],
217
218
  "description": "Platform task type executed when the subscription fires."
218
219
  },
@@ -221,6 +222,104 @@
221
222
  "minimum": 1,
222
223
  "default": 1,
223
224
  "description": "Task definition version snapshot for audit."
225
+ },
226
+ "digitalWorkerKey": {
227
+ "type": "string",
228
+ "minLength": 1,
229
+ "description": "Target digital worker system key (runDigitalWorkerTask)."
230
+ },
231
+ "taskType": {
232
+ "type": "string",
233
+ "minLength": 1,
234
+ "description": "Worker task type label (runDigitalWorkerTask)."
235
+ },
236
+ "objectiveTemplate": {
237
+ "type": "string",
238
+ "description": "Objective template with {{recordId}} placeholders."
239
+ },
240
+ "requiresApproval": {
241
+ "type": "boolean",
242
+ "default": false
243
+ },
244
+ "owner": {
245
+ "$ref": "#/$defs/subscriptionTaskOwner"
246
+ }
247
+ },
248
+ "additionalProperties": false
249
+ },
250
+ "subscriptionCondition": {
251
+ "type": "object",
252
+ "required": [
253
+ "field",
254
+ "operator"
255
+ ],
256
+ "properties": {
257
+ "field": {
258
+ "type": "string",
259
+ "minLength": 1,
260
+ "description": "Path against afterSummary (default) or before.* prefix."
261
+ },
262
+ "operator": {
263
+ "type": "string",
264
+ "enum": [
265
+ "equals",
266
+ "notEquals",
267
+ "in",
268
+ "notIn",
269
+ "exists",
270
+ "missing",
271
+ "changed",
272
+ "greaterThan",
273
+ "lessThan"
274
+ ]
275
+ },
276
+ "value": {}
277
+ },
278
+ "additionalProperties": false
279
+ },
280
+ "subscriptionTriggerPolicy": {
281
+ "type": "object",
282
+ "required": [
283
+ "mode"
284
+ ],
285
+ "properties": {
286
+ "mode": {
287
+ "type": "string",
288
+ "enum": [
289
+ "immediate",
290
+ "debounce",
291
+ "scheduled"
292
+ ]
293
+ },
294
+ "windowSeconds": {
295
+ "type": "integer",
296
+ "minimum": 1,
297
+ "description": "Required when mode is debounce."
298
+ }
299
+ },
300
+ "additionalProperties": false
301
+ },
302
+ "subscriptionTaskOwner": {
303
+ "type": "object",
304
+ "required": [
305
+ "mode"
306
+ ],
307
+ "properties": {
308
+ "mode": {
309
+ "type": "string",
310
+ "enum": [
311
+ "explicit",
312
+ "recordField"
313
+ ]
314
+ },
315
+ "userId": {
316
+ "type": "string",
317
+ "minLength": 1
318
+ },
319
+ "field": {
320
+ "type": "string",
321
+ "minLength": 1,
322
+ "description": "Record summary field for recordField mode."
224
323
  }
225
324
  },
226
325
  "additionalProperties": false
@@ -251,11 +350,24 @@
251
350
  "event": {
252
351
  "type": "string",
253
352
  "minLength": 1,
254
- "description": "V1 event namespace (platform.* or schedule.*)."
353
+ "description": "V1 event namespace (platform.*, schedule.*, or record.*)."
255
354
  },
256
355
  "filter": {
257
356
  "$ref": "#/$defs/subscriptionFilter"
258
357
  },
358
+ "conditions": {
359
+ "type": "array",
360
+ "items": {
361
+ "$ref": "#/$defs/subscriptionCondition"
362
+ }
363
+ },
364
+ "triggerPolicy": {
365
+ "$ref": "#/$defs/subscriptionTriggerPolicy"
366
+ },
367
+ "deduplicationKeyTemplate": {
368
+ "type": "string",
369
+ "description": "Default: {{subscriptionId}}:{{resourceType}}:{{recordId}}:{{task.taskType}}"
370
+ },
259
371
  "task": {
260
372
  "$ref": "#/$defs/subscriptionTask"
261
373
  },