@dxos/plugin-automation 0.8.4-staging.60fe92afc8 → 0.9.1-main.c7dcc2e112

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 (131) hide show
  1. package/dist/lib/neutral/{AutomationArticle-GN36NUX2.mjs → AutomationArticle-CG4ZML3C.mjs} +3 -2
  2. package/dist/lib/neutral/{AutomationCompanion-M26WR6VP.mjs → AutomationCompanion-67LW2WZS.mjs} +12 -13
  3. package/dist/lib/neutral/AutomationCompanion-67LW2WZS.mjs.map +7 -0
  4. package/dist/lib/neutral/AutomationPlugin.mjs +1 -1
  5. package/dist/lib/neutral/AutomationPlugin.node.mjs +4 -4
  6. package/dist/lib/neutral/AutomationPlugin.node.mjs.map +3 -3
  7. package/dist/lib/neutral/{AutomationSettings-YXUJDRQA.mjs → AutomationSettings-2XCCFX6X.mjs} +5 -5
  8. package/dist/lib/neutral/{AutomationSettings-YXUJDRQA.mjs.map → AutomationSettings-2XCCFX6X.mjs.map} +3 -3
  9. package/dist/lib/neutral/{TriggerSettings-XCHIZPOR.mjs → TriggerSettings-ABOTKRUA.mjs} +2 -2
  10. package/dist/lib/neutral/{app-graph-builder-BTTHS4VK.mjs → app-graph-builder-VX54SXD6.mjs} +6 -6
  11. package/dist/lib/neutral/app-graph-builder-VX54SXD6.mjs.map +7 -0
  12. package/dist/lib/neutral/capabilities/index.mjs +3 -3
  13. package/dist/lib/neutral/capabilities/node.mjs +1 -1
  14. package/dist/lib/neutral/{chunk-2JP77CMN.mjs → chunk-73DGSL37.mjs} +2 -2
  15. package/dist/lib/neutral/chunk-73DGSL37.mjs.map +7 -0
  16. package/dist/lib/neutral/{chunk-HPUHQ3L6.mjs → chunk-77QU5RSC.mjs} +3 -3
  17. package/dist/lib/neutral/chunk-77QU5RSC.mjs.map +7 -0
  18. package/dist/lib/neutral/chunk-D4RCXOP4.mjs +13 -0
  19. package/dist/lib/neutral/chunk-D4RCXOP4.mjs.map +7 -0
  20. package/dist/lib/neutral/{chunk-SONGOJRB.mjs → chunk-EJ5J22XS.mjs} +2 -2
  21. package/dist/lib/neutral/chunk-HHIFH3N3.mjs +45 -0
  22. package/dist/lib/neutral/chunk-HHIFH3N3.mjs.map +7 -0
  23. package/dist/lib/neutral/chunk-HQU5QWF4.mjs +349 -0
  24. package/dist/lib/neutral/chunk-HQU5QWF4.mjs.map +7 -0
  25. package/dist/lib/neutral/{chunk-FE7YFBX7.mjs → chunk-LVUL7GIN.mjs} +151 -60
  26. package/dist/lib/neutral/chunk-LVUL7GIN.mjs.map +7 -0
  27. package/dist/lib/neutral/{chunk-GARB7S5R.mjs → chunk-YHK7FWGV.mjs} +5 -3
  28. package/dist/lib/neutral/chunk-YHK7FWGV.mjs.map +7 -0
  29. package/dist/lib/neutral/components/index.mjs +36 -2
  30. package/dist/lib/neutral/components/index.mjs.map +3 -3
  31. package/dist/lib/neutral/containers/index.mjs +4 -4
  32. package/dist/lib/neutral/{create-automation-OE3TNXYE.mjs → create-automation-AGYTF62E.mjs} +5 -9
  33. package/dist/lib/neutral/create-automation-AGYTF62E.mjs.map +7 -0
  34. package/dist/lib/neutral/{create-trigger-from-template-TERHKWJM.mjs → create-trigger-from-template-TYGCSR3Z.mjs} +5 -5
  35. package/dist/lib/neutral/create-trigger-from-template-TYGCSR3Z.mjs.map +7 -0
  36. package/dist/lib/neutral/index.mjs +10 -2
  37. package/dist/lib/neutral/meta.json +1 -1
  38. package/dist/lib/neutral/meta.mjs +1 -1
  39. package/dist/lib/neutral/{navigation-resolver-I3L5FHJO.mjs → navigation-resolver-FSJNF3DQ.mjs} +3 -3
  40. package/dist/lib/neutral/navigation-resolver-FSJNF3DQ.mjs.map +7 -0
  41. package/dist/lib/neutral/operations/index.mjs +1 -1
  42. package/dist/lib/neutral/plugin.mjs +2 -2
  43. package/dist/lib/neutral/{react-surface-IBRWUKPC.mjs → react-surface-W3J3CDHA.mjs} +2 -2
  44. package/dist/lib/neutral/{react-surface-IBRWUKPC.mjs.map → react-surface-W3J3CDHA.mjs.map} +3 -3
  45. package/dist/lib/neutral/testing.mjs +1 -1
  46. package/dist/lib/neutral/translations.mjs +6 -2
  47. package/dist/lib/neutral/translations.mjs.map +3 -3
  48. package/dist/lib/neutral/types/index.mjs +1 -1
  49. package/dist/types/dx.config.d.ts +28 -0
  50. package/dist/types/dx.config.d.ts.map +1 -0
  51. package/dist/types/src/capabilities/index.d.ts +8 -13
  52. package/dist/types/src/capabilities/index.d.ts.map +1 -1
  53. package/dist/types/src/capabilities/navigation-resolver.d.ts.map +1 -1
  54. package/dist/types/src/capabilities/node.d.ts +5 -5
  55. package/dist/types/src/capabilities/node.d.ts.map +1 -1
  56. package/dist/types/src/capabilities/react-surface.d.ts +2 -2
  57. package/dist/types/src/capabilities/react-surface.d.ts.map +1 -1
  58. package/dist/types/src/components/CronBuilder/CronBuilder.d.ts +16 -0
  59. package/dist/types/src/components/CronBuilder/CronBuilder.d.ts.map +1 -0
  60. package/dist/types/src/components/CronBuilder/CronBuilder.stories.d.ts +194 -0
  61. package/dist/types/src/components/CronBuilder/CronBuilder.stories.d.ts.map +1 -0
  62. package/dist/types/src/components/CronBuilder/cron.d.ts +17 -0
  63. package/dist/types/src/components/CronBuilder/cron.d.ts.map +1 -0
  64. package/dist/types/src/components/CronBuilder/cron.test.d.ts +2 -0
  65. package/dist/types/src/components/CronBuilder/cron.test.d.ts.map +1 -0
  66. package/dist/types/src/components/CronBuilder/index.d.ts +4 -0
  67. package/dist/types/src/components/CronBuilder/index.d.ts.map +1 -0
  68. package/dist/types/src/components/CronBuilder/schema.d.ts +96 -0
  69. package/dist/types/src/components/CronBuilder/schema.d.ts.map +1 -0
  70. package/dist/types/src/components/index.d.ts +1 -0
  71. package/dist/types/src/components/index.d.ts.map +1 -1
  72. package/dist/types/src/containers/AutomationArticle/AutomationArticle.d.ts +6 -0
  73. package/dist/types/src/containers/AutomationArticle/AutomationArticle.d.ts.map +1 -1
  74. package/dist/types/src/containers/AutomationArticle/AutomationArticle.stories.d.ts +120 -0
  75. package/dist/types/src/containers/AutomationArticle/AutomationArticle.stories.d.ts.map +1 -0
  76. package/dist/types/src/containers/AutomationCompanion/AutomationCompanion.d.ts.map +1 -1
  77. package/dist/types/src/index.d.ts +1 -0
  78. package/dist/types/src/index.d.ts.map +1 -1
  79. package/dist/types/src/meta.d.ts +28 -2
  80. package/dist/types/src/meta.d.ts.map +1 -1
  81. package/dist/types/src/paths.d.ts +2 -0
  82. package/dist/types/src/paths.d.ts.map +1 -1
  83. package/dist/types/src/translations.d.ts +9 -1
  84. package/dist/types/src/translations.d.ts.map +1 -1
  85. package/dist/types/src/types/AutomationCapabilities.d.ts +6 -1
  86. package/dist/types/src/types/AutomationCapabilities.d.ts.map +1 -1
  87. package/dist/types/tsconfig.tsbuildinfo +1 -1
  88. package/dx.config.ts +34 -0
  89. package/package.json +40 -38
  90. package/src/AutomationPlugin.test.ts +1 -1
  91. package/src/AutomationPlugin.tsx +1 -1
  92. package/src/capabilities/app-graph-builder.ts +5 -5
  93. package/src/capabilities/navigation-resolver.ts +2 -2
  94. package/src/capabilities/react-surface.tsx +1 -1
  95. package/src/commands/trigger/create/queue.ts +2 -2
  96. package/src/commands/trigger/update/queue.ts +2 -2
  97. package/src/components/CreateAutomationPanel/CreateAutomationPanel.tsx +1 -1
  98. package/src/components/CronBuilder/CronBuilder.stories.tsx +72 -0
  99. package/src/components/CronBuilder/CronBuilder.tsx +94 -0
  100. package/src/components/CronBuilder/cron.test.ts +131 -0
  101. package/src/components/CronBuilder/cron.ts +138 -0
  102. package/src/components/CronBuilder/index.ts +7 -0
  103. package/src/components/CronBuilder/schema.ts +119 -0
  104. package/src/components/index.ts +1 -0
  105. package/src/containers/AutomationArticle/AutomationArticle.stories.tsx +73 -0
  106. package/src/containers/AutomationArticle/AutomationArticle.tsx +186 -56
  107. package/src/containers/AutomationCompanion/AutomationCompanion.tsx +79 -72
  108. package/src/containers/AutomationSettings/AutomationSettings.tsx +3 -3
  109. package/src/containers/TriggerSettings/TriggerSettings.tsx +1 -1
  110. package/src/index.ts +1 -0
  111. package/src/meta.ts +2 -26
  112. package/src/operations/create-trigger-from-template.ts +3 -3
  113. package/src/paths.ts +7 -2
  114. package/src/translations.ts +7 -2
  115. package/src/types/AutomationCapabilities.ts +9 -1
  116. package/src/types/AutomationOperation.ts +1 -1
  117. package/src/types/schema.ts +1 -1
  118. package/dist/lib/neutral/AutomationCompanion-M26WR6VP.mjs.map +0 -7
  119. package/dist/lib/neutral/app-graph-builder-BTTHS4VK.mjs.map +0 -7
  120. package/dist/lib/neutral/chunk-2JP77CMN.mjs.map +0 -7
  121. package/dist/lib/neutral/chunk-DUGOIM7G.mjs +0 -36
  122. package/dist/lib/neutral/chunk-DUGOIM7G.mjs.map +0 -7
  123. package/dist/lib/neutral/chunk-FE7YFBX7.mjs.map +0 -7
  124. package/dist/lib/neutral/chunk-GARB7S5R.mjs.map +0 -7
  125. package/dist/lib/neutral/chunk-HPUHQ3L6.mjs.map +0 -7
  126. package/dist/lib/neutral/create-automation-OE3TNXYE.mjs.map +0 -7
  127. package/dist/lib/neutral/create-trigger-from-template-TERHKWJM.mjs.map +0 -7
  128. package/dist/lib/neutral/navigation-resolver-I3L5FHJO.mjs.map +0 -7
  129. /package/dist/lib/neutral/{AutomationArticle-GN36NUX2.mjs.map → AutomationArticle-CG4ZML3C.mjs.map} +0 -0
  130. /package/dist/lib/neutral/{TriggerSettings-XCHIZPOR.mjs.map → TriggerSettings-ABOTKRUA.mjs.map} +0 -0
  131. /package/dist/lib/neutral/{chunk-SONGOJRB.mjs.map → chunk-EJ5J22XS.mjs.map} +0 -0
@@ -2,36 +2,49 @@
2
2
  // Copyright 2026 DXOS.org
3
3
  //
4
4
 
5
+ import * as Effect from 'effect/Effect';
5
6
  import * as Schema from 'effect/Schema';
6
- import React, { type ReactNode, useCallback, useMemo } from 'react';
7
+ import React, { type ReactNode, useCallback, useMemo, useState } from 'react';
7
8
 
9
+ import { useProcessManagerRuntime } from '@dxos/app-framework/ui';
8
10
  import { type AppSurface } from '@dxos/app-toolkit/ui';
9
- import { Operation, Routine, Trigger } from '@dxos/compute';
11
+ import { Operation, Routine, ServiceResolver, Trigger, TriggerEvent } from '@dxos/compute';
12
+ import { Context } from '@dxos/context';
10
13
  import { type Database, Entity, Feed, Filter, JsonSchema, Obj, Query, Ref, Scope, Type } from '@dxos/echo';
14
+ import { KEY_FEED_CURSOR, TriggerDispatcher } from '@dxos/functions-runtime';
15
+ import { FunctionsServiceClient } from '@dxos/functions-runtime/edge';
11
16
  import { DXN } from '@dxos/keys';
12
- import { useObject, useQuery } from '@dxos/react-client/echo';
13
- import { Button, Icon, Input, useTranslation } from '@dxos/react-ui';
17
+ import { log } from '@dxos/log';
18
+ import { useClient } from '@dxos/react-client';
19
+ import { type Space, getSpace, useObject, useQuery } from '@dxos/react-client/echo';
20
+ import { DropdownMenu, IconButton, Input, useTranslation } from '@dxos/react-ui';
14
21
  import {
15
22
  Form,
16
- type FormFieldComponentProps,
23
+ type FormFieldRendererProps,
17
24
  type FormFieldMap,
18
25
  RefField,
19
26
  SelectField,
20
27
  Settings,
21
28
  } from '@dxos/react-ui-form';
29
+ import { Accordion } from '@dxos/react-ui-list';
22
30
  import { ParentLabelAnnotation } from '@dxos/schema';
23
31
 
24
32
  import { meta } from '#meta';
25
33
  import { Automation } from '#types';
26
34
 
35
+ import { describeCron, fromCron, toCron } from '../../components/CronBuilder/cron';
36
+ import { CronBuilder } from '../../components/CronBuilder/CronBuilder';
37
+ import { type CronSpecType, FrequencyDefaults } from '../../components/CronBuilder/schema';
38
+
27
39
  const RUN_ROUTINE_DXN = 'org.dxos.function.prompt';
28
40
 
29
41
  export type AutomationArticleProps = AppSurface.ObjectArticleProps<Automation.Automation>;
30
42
 
31
43
  export const AutomationArticle = ({ role, subject }: AutomationArticleProps) => {
32
- const { t } = useTranslation(meta.id);
44
+ const { t } = useTranslation(meta.profile.key);
33
45
  const db = Obj.getDatabase(subject);
34
46
  const trigger = usePrimaryTrigger(subject);
47
+ const space = getSpace(subject);
35
48
 
36
49
  if (!db) {
37
50
  return null;
@@ -48,6 +61,7 @@ export const AutomationArticle = ({ role, subject }: AutomationArticleProps) =>
48
61
  <Settings.Section title={t('trigger-picker.title')} description={t('trigger-picker.description')}>
49
62
  <Settings.Panel>
50
63
  <TriggerSection db={db} automation={subject} trigger={trigger} />
64
+ {space && trigger && <TriggerTestingSection space={space} trigger={trigger} />}
51
65
  </Settings.Panel>
52
66
  </Settings.Section>
53
67
 
@@ -104,8 +118,8 @@ const EnabledField = ({
104
118
  canEnable,
105
119
  messageKey,
106
120
  ...props
107
- }: FormFieldComponentProps & { canEnable: boolean; messageKey?: string }) => {
108
- const { t } = useTranslation(meta.id);
121
+ }: FormFieldRendererProps & { canEnable: boolean; messageKey?: string }) => {
122
+ const { t } = useTranslation(meta.profile.key);
109
123
  return (
110
124
  <div className='flex items-center gap-2 pt-form-padding'>
111
125
  <Input.Root>
@@ -121,6 +135,113 @@ const EnabledField = ({
121
135
  );
122
136
  };
123
137
 
138
+ //
139
+ // Trigger testing section
140
+ //
141
+
142
+ type TriggerTestingSectionProps = {
143
+ space: Space;
144
+ trigger: Trigger.Trigger;
145
+ };
146
+
147
+ const TESTING_ITEM = { id: 'testing' };
148
+
149
+ /**
150
+ * Collapsed "Testing" section within the Trigger panel. Provides kind-specific manual
151
+ * affordances: force-run for timer triggers, cursor reset for feed triggers.
152
+ * Not rendered for trigger kinds with no applicable action (email, webhook, subscription).
153
+ */
154
+ const TriggerTestingSection = ({ space, trigger }: TriggerTestingSectionProps) => {
155
+ const { t } = useTranslation(meta.profile.key);
156
+ const client = useClient();
157
+ const processManagerRuntime = useProcessManagerRuntime();
158
+ const [properties] = useObject(space.properties);
159
+ const computeEnvironment = properties.computeEnvironment ?? 'local';
160
+ const functionsServiceClient = useMemo(() => FunctionsServiceClient.fromClient(client), [client]);
161
+
162
+ const spec = Obj.getSnapshot(trigger).spec;
163
+ const kind = spec?.kind;
164
+
165
+ const cursor = kind === 'feed' ? Obj.getKeys(trigger, KEY_FEED_CURSOR).at(0)?.id : undefined;
166
+
167
+ const handleForceRun = useCallback(async () => {
168
+ if (computeEnvironment === 'disabled') {
169
+ return;
170
+ }
171
+ if (computeEnvironment === 'local') {
172
+ await processManagerRuntime
173
+ .runPromise(
174
+ Effect.gen(function* () {
175
+ const dispatcher = yield* TriggerDispatcher;
176
+ yield* dispatcher.invokeTrigger({
177
+ trigger,
178
+ event: { tick: Date.now() } satisfies TriggerEvent.TimerEvent,
179
+ });
180
+ }).pipe(Effect.provide(ServiceResolver.provide({ space: space.id }, TriggerDispatcher))),
181
+ )
182
+ .catch((error: unknown) => log.catch(error));
183
+ return;
184
+ }
185
+ await functionsServiceClient
186
+ .forceRunCronTrigger(Context.default(), space.id, trigger.id)
187
+ .catch((error: unknown) => log.catch(error));
188
+ }, [computeEnvironment, functionsServiceClient, processManagerRuntime, space.id, trigger]);
189
+
190
+ const handleResetCursor = useCallback(async () => {
191
+ Obj.update(trigger, (trigger) => {
192
+ Obj.deleteKeys(trigger, KEY_FEED_CURSOR);
193
+ });
194
+ await space.db.flush({ indexes: true });
195
+ }, [space.db, trigger]);
196
+
197
+ if (kind !== 'timer' && kind !== 'feed') {
198
+ return null;
199
+ }
200
+
201
+ return (
202
+ <Accordion.Root items={[TESTING_ITEM]}>
203
+ {({ items }) =>
204
+ items.map((item) => (
205
+ <Accordion.Item key={item.id} item={item}>
206
+ <Accordion.ItemHeader>{t('testing.title')}</Accordion.ItemHeader>
207
+ <Accordion.ItemBody>
208
+ {kind === 'timer' && (
209
+ <IconButton
210
+ icon='ph--play--regular'
211
+ label={t('force-run.label')}
212
+ disabled={computeEnvironment === 'disabled'}
213
+ onClick={handleForceRun}
214
+ />
215
+ )}
216
+ {kind === 'feed' && (
217
+ <DropdownMenu.Root>
218
+ <DropdownMenu.Trigger asChild>
219
+ <IconButton
220
+ icon='ph--arrow-clockwise--regular'
221
+ label={t('reset-cursor.label')}
222
+ disabled={!cursor}
223
+ />
224
+ </DropdownMenu.Trigger>
225
+ <DropdownMenu.Portal>
226
+ <DropdownMenu.Content side='top'>
227
+ <DropdownMenu.Viewport>
228
+ <DropdownMenu.Item onClick={handleResetCursor}>
229
+ {t('reset-cursor-confirm.label')}
230
+ </DropdownMenu.Item>
231
+ </DropdownMenu.Viewport>
232
+ <DropdownMenu.Arrow />
233
+ </DropdownMenu.Content>
234
+ </DropdownMenu.Portal>
235
+ </DropdownMenu.Root>
236
+ )}
237
+ </Accordion.ItemBody>
238
+ </Accordion.Item>
239
+ ))
240
+ }
241
+ </Accordion.Root>
242
+ );
243
+ };
244
+
124
245
  //
125
246
  // Action
126
247
  //
@@ -191,7 +312,7 @@ const ActionInputEditor = ({
191
312
  operation: Operation.PersistentOperation;
192
313
  trigger: Trigger.Trigger;
193
314
  }) => {
194
- const { t } = useTranslation(meta.id);
315
+ const { t } = useTranslation(meta.profile.key);
195
316
  const effectSchema = useMemo(
196
317
  () => (operation.inputSchema ? JsonSchema.toEffectSchema(operation.inputSchema) : undefined),
197
318
  [operation.inputSchema],
@@ -217,7 +338,7 @@ const ActionInputEditor = ({
217
338
 
218
339
  return (
219
340
  <>
220
- <Form.Label label={t('action-input.label')} asChild />
341
+ <Form.Label label={t('action-input.label')} standalone />
221
342
  <Form.Root
222
343
  key={operation.id}
223
344
  schema={effectSchema}
@@ -269,11 +390,14 @@ const triggerFormValues = (spec?: Trigger.Spec): TriggerFormInput =>
269
390
  ? { kind: 'feed', feed: spec.feed }
270
391
  : { kind: 'timer', cron: spec?.kind === 'timer' ? spec.cron : '' };
271
392
 
393
+ // Fallback cron used when no schedule has been set yet.
394
+ const DEFAULT_TIMER_CRON = toCron(FrequencyDefaults.daily);
395
+
272
396
  // Build a trigger spec from the form's values. Returned as just the two specs we construct (not the full
273
397
  // `Trigger.Spec` union) so the subscription spec's deep readonly query AST never enters the type and the
274
398
  // result stays assignable to the mutable `trigger.spec`.
275
399
  const triggerFormSpec = (values: TriggerFormInput): Trigger.TimerSpec | Trigger.FeedSpec =>
276
- values.kind === 'feed' ? { kind: 'feed', feed: values.feed } : Trigger.specTimer(values.cron ?? '');
400
+ values.kind === 'feed' ? { kind: 'feed', feed: values.feed } : Trigger.specTimer(values.cron || DEFAULT_TIMER_CRON);
277
401
 
278
402
  export const TriggerSection = ({
279
403
  db,
@@ -284,31 +408,22 @@ export const TriggerSection = ({
284
408
  automation: Automation.Automation;
285
409
  trigger?: Trigger.Trigger;
286
410
  }) => {
287
- const { t } = useTranslation(meta.id);
288
- const { defaultValues, fieldMap, handleValuesChanged, handleRemove } = useTriggerForm(db, automation, trigger);
411
+ const { defaultValues, fieldMap, handleValuesChanged } = useTriggerForm(db, automation, trigger);
289
412
 
290
413
  return (
291
- <div className='flex flex-col gap-2'>
292
- <Form.Root
293
- // Remount when the bound trigger changes so the uncontrolled form picks up its spec.
294
- key={trigger?.id ?? 'new'}
295
- schema={TriggerForm}
296
- defaultValues={defaultValues}
297
- db={db}
298
- fieldMap={fieldMap}
299
- onValuesChanged={handleValuesChanged}
300
- >
301
- <Form.Content>
302
- <Form.FieldSet />
303
- </Form.Content>
304
- </Form.Root>
305
- {trigger && (
306
- <Button variant='ghost' classNames='gap-1 self-start' onClick={handleRemove}>
307
- <Icon icon='ph--trash--regular' size={4} />
308
- <span>{t('remove-trigger.label')}</span>
309
- </Button>
310
- )}
311
- </div>
414
+ <Form.Root
415
+ // Remount when the bound trigger changes so the uncontrolled form picks up its spec.
416
+ key={trigger?.id ?? 'new'}
417
+ schema={TriggerForm}
418
+ defaultValues={defaultValues}
419
+ db={db}
420
+ fieldMap={fieldMap}
421
+ onValuesChanged={handleValuesChanged}
422
+ >
423
+ <Form.Content>
424
+ <Form.FieldSet />
425
+ </Form.Content>
426
+ </Form.Root>
312
427
  );
313
428
  };
314
429
 
@@ -335,7 +450,7 @@ export const AutomationInlineForm = ({
335
450
  automation: Automation.Automation;
336
451
  db: Database.Database;
337
452
  }) => {
338
- const { t } = useTranslation(meta.id);
453
+ const { t } = useTranslation(meta.profile.key);
339
454
  const trigger = usePrimaryTrigger(automation);
340
455
 
341
456
  return (
@@ -353,6 +468,34 @@ export const AutomationInlineForm = ({
353
468
  );
354
469
  };
355
470
 
471
+ //
472
+ // Cron field
473
+ //
474
+
475
+ /** Renders the CronBuilder with a live cronstrue description below it. */
476
+ const CronField = (props: FormFieldRendererProps) => {
477
+ const existingCron = props.getValue() as string | undefined;
478
+ const initialSpec = useMemo(() => (existingCron ? fromCron(existingCron) : FrequencyDefaults.daily), []);
479
+ const [description, setDescription] = useState(() => describeCron(existingCron ?? toCron(initialSpec)));
480
+
481
+ const handleChange = useCallback(
482
+ (spec: CronSpecType, cron: string) => {
483
+ setDescription(describeCron(cron));
484
+ props.onValueChange(props.type, cron);
485
+ },
486
+ [props.type, props.onValueChange],
487
+ );
488
+
489
+ return (
490
+ <div className='flex flex-col gap-1 mbs-2'>
491
+ <CronBuilder value={initialSpec} onChange={handleChange} />
492
+ <p className='text-sm text-description pli-1 text-right'>{description}</p>
493
+ </div>
494
+ );
495
+ };
496
+
497
+ CronField.displayName = 'AutomationArticle.CronField';
498
+
356
499
  //
357
500
  // Hooks
358
501
  //
@@ -389,7 +532,6 @@ const useGeneralForm = (automation: Automation.Automation, trigger?: Trigger.Tri
389
532
  // Read once per trigger identity; the uncontrolled form owns edits after mount.
390
533
  const defaultValues = useMemo<Partial<GeneralFormValues>>(
391
534
  () => ({ name: auto.name, enabled: (trigger?.enabled ?? false) && canEnable }),
392
- // eslint-disable-next-line react-hooks/exhaustive-deps
393
535
  [automation, trigger],
394
536
  );
395
537
 
@@ -412,7 +554,7 @@ const useGeneralForm = (automation: Automation.Automation, trigger?: Trigger.Tri
412
554
 
413
555
  /** Form state for the Action section: pick an operation|routine and bind it to the trigger's function/input. */
414
556
  const useActionForm = (db: Database.Database, automation: Automation.Automation, trigger?: Trigger.Trigger) => {
415
- const { t } = useTranslation(meta.id);
557
+ const { t } = useTranslation(meta.profile.key);
416
558
  const [auto, updateAuto] = useObject(automation);
417
559
  // Query by typename DXN so results stay untyped (`Entity.Any[]`), as RefField.useResults expects.
418
560
  const operations = useQuery(
@@ -458,7 +600,6 @@ const useActionForm = (db: Database.Database, automation: Automation.Automation,
458
600
  return { kind: 'operation', operation: auto.runnable };
459
601
  }
460
602
  return { kind: 'operation' };
461
- // eslint-disable-next-line react-hooks/exhaustive-deps
462
603
  }, [automation, trigger]);
463
604
 
464
605
  const handleValuesChanged = useCallback(
@@ -503,7 +644,7 @@ const useActionForm = (db: Database.Database, automation: Automation.Automation,
503
644
 
504
645
  /** Form state for the Trigger section: the timer|feed spec, plus create-on-first-edit and remove handlers. */
505
646
  const useTriggerForm = (db: Database.Database, automation: Automation.Automation, trigger?: Trigger.Trigger) => {
506
- const { t } = useTranslation(meta.id);
647
+ const { t } = useTranslation(meta.profile.key);
507
648
  const kindOptions = useMemo(
508
649
  () => [
509
650
  { value: 'timer', label: t('trigger-kind.timer.label') },
@@ -512,15 +653,14 @@ const useTriggerForm = (db: Database.Database, automation: Automation.Automation
512
653
  [t],
513
654
  );
514
655
  const fieldMap = useMemo<FormFieldMap>(
515
- () => ({ kind: (props) => <SelectField {...props} options={kindOptions} /> }),
656
+ () => ({
657
+ kind: (props) => <SelectField {...props} options={kindOptions} />,
658
+ cron: (props) => <CronField {...props} />,
659
+ }),
516
660
  [kindOptions],
517
661
  );
518
662
  // Read once per trigger identity (uncontrolled Form); default to an empty timer spec.
519
- const defaultValues = useMemo<Partial<TriggerFormValues>>(
520
- () => triggerFormValues(trigger?.spec),
521
- // eslint-disable-next-line react-hooks/exhaustive-deps
522
- [trigger],
523
- );
663
+ const defaultValues = useMemo<Partial<TriggerFormValues>>(() => triggerFormValues(trigger?.spec), [trigger]);
524
664
 
525
665
  const handleValuesChanged = useCallback(
526
666
  (values: Partial<TriggerFormValues>) => {
@@ -543,17 +683,7 @@ const useTriggerForm = (db: Database.Database, automation: Automation.Automation
543
683
  [db, automation, trigger],
544
684
  );
545
685
 
546
- const handleRemove = useCallback(() => {
547
- if (!trigger) {
548
- return;
549
- }
550
- Obj.update(automation, (automation) => {
551
- automation.triggers = automation.triggers.filter((ref) => ref.target?.id !== trigger.id);
552
- });
553
- db.remove(trigger);
554
- }, [db, automation, trigger]);
555
-
556
- return { defaultValues, fieldMap, handleValuesChanged, handleRemove };
686
+ return { defaultValues, fieldMap, handleValuesChanged };
557
687
  };
558
688
 
559
689
  //
@@ -7,7 +7,7 @@ import React, { useCallback, useMemo, useRef, useState } from 'react';
7
7
  import { useCapabilities, useOperationInvoker } from '@dxos/app-framework/ui';
8
8
  import { type Database, Filter, Obj, Type } from '@dxos/echo';
9
9
  import { useQuery } from '@dxos/react-client/echo';
10
- import { DropdownMenu, Icon, Tooltip, useTranslation } from '@dxos/react-ui';
10
+ import { DropdownMenu, Icon, Panel, Toolbar, Tooltip, useTranslation } from '@dxos/react-ui';
11
11
  import { Accordion } from '@dxos/react-ui-list';
12
12
 
13
13
  import { meta } from '#meta';
@@ -16,21 +16,22 @@ import { Automation, AutomationCapabilities, AutomationOperation } from '#types'
16
16
  import { AutomationInlineForm } from '../../containers/AutomationArticle';
17
17
  import { connectedAutomationsQuery } from '../../util/automations-for-object';
18
18
 
19
+ /** Association state of a row relative to the companion's object. */
20
+ type Status = 'associated' | 'pending' | 'detached';
21
+
22
+ // TODO(burdon): Use type.
19
23
  export type AutomationCompanionProps = {
20
24
  db: Database.Database;
21
25
  object: Obj.Unknown;
22
26
  };
23
27
 
24
- /** Association state of a row relative to the companion's object. */
25
- type Status = 'associated' | 'pending' | 'detached';
26
-
27
28
  /**
28
29
  * Renders the automations connected to an object as an accordion (see `useConnectedAutomations` for the
29
30
  * session-stable list it draws from), flagging non-associated rows with a warning badge. New automations are
30
31
  * created from a template dropdown (no dialog).
31
32
  */
32
33
  export const AutomationCompanion = ({ db, object }: AutomationCompanionProps) => {
33
- const { t } = useTranslation(meta.id);
34
+ const { t } = useTranslation(meta.profile.key);
34
35
  const templates = useCapabilities(AutomationCapabilities.Template);
35
36
  // Only offer templates applicable to this companion's subject (e.g. a CRM template needs a Mailbox).
36
37
  const applicableTemplates = useMemo(
@@ -40,73 +41,79 @@ export const AutomationCompanion = ({ db, object }: AutomationCompanionProps) =>
40
41
  const { items, statusFor, open, setOpen, handleCreate } = useConnectedAutomations(db, object);
41
42
 
42
43
  return (
43
- <div className='flex flex-col'>
44
- {items.length === 0 ? (
45
- <p className='text-sm text-description p-2'>{t('no-automations.message')}</p>
46
- ) : (
47
- <Accordion.Root<Automation.Automation> items={items} value={open} onValueChange={setOpen}>
48
- {({ items }) => (
49
- <div className='flex flex-col divide-y divide-separator'>
50
- {items.map((automation) => {
51
- const status = statusFor(automation.id);
52
- return (
53
- <Accordion.Item key={automation.id} item={automation}>
54
- <Accordion.ItemHeader>
55
- <Icon icon='ph--lightning--regular' size={4} classNames='mr-2 shrink-0' />
56
- <span className='flex-1 truncate'>
57
- {Obj.getLabel(automation) ??
58
- t('object-name.placeholder', { ns: Type.getTypename(Automation.Automation) })}
59
- </span>
60
- {status !== 'associated' && (
61
- <Tooltip.Trigger
62
- asChild
63
- side='bottom'
64
- content={t(
65
- status === 'pending' ? 'automation-not-associated.message' : 'automation-detached.message',
66
- )}
67
- >
68
- <Icon icon='ph--warning--regular' size={4} classNames='text-warning-text shrink-0 mr-2' />
69
- </Tooltip.Trigger>
70
- )}
71
- </Accordion.ItemHeader>
72
- <Accordion.ItemBody>
73
- <AutomationInlineForm automation={automation} db={db} />
74
- </Accordion.ItemBody>
75
- </Accordion.Item>
76
- );
77
- })}
78
- </div>
79
- )}
80
- </Accordion.Root>
81
- )}
82
-
83
- <div className='border-t border-separator'>
84
- <DropdownMenu.Root>
85
- <DropdownMenu.Trigger asChild>
86
- {/* Mirror the accordion item header layout (p-2 + icon mr-2) so the icon aligns with the rows above. */}
87
- <button type='button' className='group flex items-center p-2 dx-focus-ring-inset w-full text-start'>
88
- <Icon icon='ph--plus--regular' size={4} classNames='mr-2 shrink-0' />
89
- <span className='flex-1 truncate'>
90
- {t('add-object.label', { ns: Type.getTypename(Automation.Automation) })}
91
- </span>
92
- </button>
93
- </DropdownMenu.Trigger>
94
- <DropdownMenu.Portal>
95
- <DropdownMenu.Content>
96
- <DropdownMenu.Viewport>
97
- {applicableTemplates.map((template) => (
98
- <DropdownMenu.Item key={template.id} onClick={() => void handleCreate(template.id)}>
99
- <Icon icon={template.icon ?? 'ph--lightning--regular'} size={4} />
100
- <span>{template.label}</span>
101
- </DropdownMenu.Item>
102
- ))}
103
- </DropdownMenu.Viewport>
104
- <DropdownMenu.Arrow />
105
- </DropdownMenu.Content>
106
- </DropdownMenu.Portal>
107
- </DropdownMenu.Root>
108
- </div>
109
- </div>
44
+ <Panel.Root>
45
+ <Panel.Toolbar asChild>
46
+ <Toolbar.Root />
47
+ </Panel.Toolbar>
48
+ <Panel.Content>
49
+ {items.length === 0 ? (
50
+ <p className='text-sm text-description p-2'>{t('no-automations.message')}</p>
51
+ ) : (
52
+ <Accordion.Root<Automation.Automation> items={items} value={open} onValueChange={setOpen}>
53
+ {({ items }) => (
54
+ <div className='flex flex-col divide-y divide-separator'>
55
+ {items.map((automation) => {
56
+ const status = statusFor(automation.id);
57
+ return (
58
+ <Accordion.Item key={automation.id} item={automation}>
59
+ <Accordion.ItemHeader icon='ph--lightning--regular'>
60
+ <span className='flex-1 truncate'>
61
+ {Obj.getLabel(automation) ??
62
+ t('object-name.placeholder', { ns: Type.getTypename(Automation.Automation) })}
63
+ </span>
64
+ {status !== 'associated' && (
65
+ <Tooltip.Trigger
66
+ asChild
67
+ side='bottom'
68
+ content={t(
69
+ status === 'pending'
70
+ ? 'automation-not-associated.message'
71
+ : 'automation-detached.message',
72
+ )}
73
+ >
74
+ <Icon icon='ph--warning--regular' size={4} classNames='text-warning-text shrink-0 mr-2' />
75
+ </Tooltip.Trigger>
76
+ )}
77
+ </Accordion.ItemHeader>
78
+ <Accordion.ItemBody>
79
+ <AutomationInlineForm automation={automation} db={db} />
80
+ </Accordion.ItemBody>
81
+ </Accordion.Item>
82
+ );
83
+ })}
84
+ </div>
85
+ )}
86
+ </Accordion.Root>
87
+ )}
88
+
89
+ <div className='border-t border-separator'>
90
+ <DropdownMenu.Root>
91
+ <DropdownMenu.Trigger asChild>
92
+ {/* Mirror the accordion item header layout (p-2 + icon mr-2) so the icon aligns with the rows above. */}
93
+ <button type='button' className='group flex items-center p-2 dx-focus-ring-inset w-full text-start'>
94
+ <Icon icon='ph--plus--regular' size={4} classNames='mr-2 shrink-0' />
95
+ <span className='flex-1 truncate'>
96
+ {t('add-object.label', { ns: Type.getTypename(Automation.Automation) })}
97
+ </span>
98
+ </button>
99
+ </DropdownMenu.Trigger>
100
+ <DropdownMenu.Portal>
101
+ <DropdownMenu.Content>
102
+ <DropdownMenu.Viewport>
103
+ {applicableTemplates.map((template) => (
104
+ <DropdownMenu.Item key={template.id} onClick={() => void handleCreate(template.id)}>
105
+ <Icon icon={template.icon ?? 'ph--lightning--regular'} size={4} />
106
+ <span>{template.label}</span>
107
+ </DropdownMenu.Item>
108
+ ))}
109
+ </DropdownMenu.Viewport>
110
+ <DropdownMenu.Arrow />
111
+ </DropdownMenu.Content>
112
+ </DropdownMenu.Portal>
113
+ </DropdownMenu.Root>
114
+ </div>
115
+ </Panel.Content>
116
+ </Panel.Root>
110
117
  );
111
118
  };
112
119
 
@@ -20,12 +20,12 @@ export type AutomationSettingsProps = AppSurface.SpaceArticleProps;
20
20
  * automations execute.
21
21
  */
22
22
  export const AutomationSettings = ({ space }: AutomationSettingsProps) => {
23
- const { t } = useTranslation(meta.id);
23
+ const { t } = useTranslation(meta.profile.key);
24
24
  return (
25
25
  <Settings.Viewport>
26
26
  <Settings.Section
27
- title={t('automation-verbose.label', { ns: meta.id })}
28
- description={t('automation.description', { ns: meta.id })}
27
+ title={t('automation-verbose.label', { ns: meta.profile.key })}
28
+ description={t('automation.description', { ns: meta.profile.key })}
29
29
  >
30
30
  <TriggersSettings space={space} />
31
31
  </Settings.Section>
@@ -13,7 +13,7 @@ import { Settings } from '@dxos/react-ui-form';
13
13
  import { meta } from '#meta';
14
14
 
15
15
  export const TriggersSettings = ({ space }: { space: Space }) => {
16
- const { t } = useTranslation(meta.id);
16
+ const { t } = useTranslation(meta.profile.key);
17
17
  const [properties, changeProperties] = useObject(space.properties);
18
18
  const selected = properties.computeEnvironment ?? 'local';
19
19
 
package/src/index.ts CHANGED
@@ -3,4 +3,5 @@
3
3
  //
4
4
 
5
5
  export * from './meta';
6
+ export * from './paths';
6
7
  export * from './types';
package/src/meta.ts CHANGED
@@ -3,31 +3,7 @@
3
3
  //
4
4
 
5
5
  import { Plugin } from '@dxos/app-framework';
6
- import { DXN } from '@dxos/keys';
7
- import { trim } from '@dxos/util';
8
6
 
9
- export const meta = Plugin.makeMeta({
10
- key: DXN.make('org.dxos.plugin.automation'),
11
- name: 'Automation',
12
- author: 'DXOS',
13
- description: trim`
14
- Event-driven workflow automation engine for DXOS Composer.
15
- An Automation is a user-facing object pairing a trigger ("when" — a timer schedule or an
16
- incoming feed) with an action ("then" — a persistent compute operation or routine), enabling
17
- automated pipelines that react to changes in real time without manual intervention.
7
+ import config from '../dx.config';
18
8
 
19
- Automations are configured in their own article and surfaced on a per-object "Automations"
20
- companion that lists the automations connected to each object. A per-space TriggerDispatcher
21
- manages execution: running locally in the browser when computeEnvironment is "local", or
22
- delegating to the DXOS edge for server-side reliability; the space settings page chooses the
23
- runtime location.
24
-
25
- Operation handlers and blueprints contributed by any plugin in the application are
26
- automatically merged and made available to every space's OperationRegistry, so new
27
- capabilities registered by other plugins become instantly invocable from automations.
28
- `,
29
- icon: 'ph--atom--regular',
30
- source: 'https://github.com/dxos/dxos/tree/main/packages/plugins/plugin-automation',
31
- spec: 'PLUGIN.mdl',
32
- tags: ['system'],
33
- });
9
+ export const meta = Plugin.getMetaFromConfig(config);
@@ -4,7 +4,7 @@
4
4
 
5
5
  import * as Effect from 'effect/Effect';
6
6
 
7
- import { LayoutOperation, getSpacePath } from '@dxos/app-toolkit';
7
+ import { LayoutOperation, Paths } from '@dxos/app-toolkit';
8
8
  import { Operation, Script, Trigger } from '@dxos/compute';
9
9
  import { type Feed, Filter, Obj, Ref } from '@dxos/echo';
10
10
  import { SpaceOperation } from '@dxos/plugin-space';
@@ -60,8 +60,8 @@ const handler: Operation.WithHandler<typeof AutomationOperation.CreateTriggerFro
60
60
  hidden: true,
61
61
  });
62
62
  yield* Operation.invoke(LayoutOperation.Open, {
63
- subject: [`${getSpacePath(db.spaceId)}/settings/${meta.id}.automations`],
64
- workspace: getSpacePath(db.spaceId),
63
+ subject: [`${Paths.getSpacePath(db.spaceId)}/settings/${meta.profile.key}.automations`],
64
+ workspace: Paths.getSpacePath(db.spaceId),
65
65
  });
66
66
  }),
67
67
  ),