@frockbot/plugin-routines 0.0.0 → 0.1.1

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.
@@ -0,0 +1,601 @@
1
+ <script setup lang="ts">
2
+ // The Bot's Routines: the list, the create/edit form, and one run log per
3
+ // Routine. It renders durable state and submits versioned commands; it decides
4
+ // nothing — "Next run" is the moment the scheduler has actually armed an alarm
5
+ // on, sent down with the Routine, and blank when there is none to promise.
6
+ import { UiAnchor, UiButton, UiField, UiIcon } from "@frockbot/client-ui";
7
+ import { frockBotWebDataKey } from "@frockbot/plugin-shell/shared";
8
+ import { settingsLinkV1 } from "@frockbot/plugin-shell/settings-links";
9
+ import { computed, inject, reactive, ref, watch } from "vue";
10
+ import type { RoutineViewV1 } from "../shared.js";
11
+ import { routinesStateKey } from "./state.js";
12
+
13
+ const providedWeb = inject(frockBotWebDataKey);
14
+ const providedState = inject(routinesStateKey);
15
+ if (!providedWeb || !providedState) {
16
+ throw new Error("Routines client services were not provided");
17
+ }
18
+ const web = providedWeb;
19
+ const routines = providedState;
20
+
21
+ const botId = computed(() => web.value.activeBotId);
22
+ // The section is deep-linkable: an error message or a send payload may cite it.
23
+ const anchorHref = computed(() =>
24
+ settingsLinkV1({ anchor: "bot-routines", botId: botId.value }),
25
+ );
26
+ const formOpen = ref(false);
27
+ const openLog = ref<string>();
28
+ const copied = ref(false);
29
+ const openRun = ref<string>();
30
+
31
+ // An automation Turn never appears in the transcript, so opening a run here is
32
+ // the only way to read one, and it is read-only in both directions: the view
33
+ // carries what happened and no way to act on it.
34
+ function toggleRun(routineId: string, runId: string): void {
35
+ if (openRun.value === runId) {
36
+ openRun.value = undefined;
37
+ return;
38
+ }
39
+ openRun.value = runId;
40
+ if (botId.value && !routines.value.runDetails[runId]) {
41
+ void routines.value.loadRun(botId.value, routineId, runId);
42
+ }
43
+ }
44
+ const form = reactive({
45
+ routineId: undefined as string | undefined,
46
+ name: "",
47
+ prompt: "",
48
+ timing: "schedule" as "schedule" | "webhook",
49
+ schedule: "",
50
+ timezone: "UTC",
51
+ });
52
+
53
+ watch(
54
+ botId,
55
+ (id) => {
56
+ if (!id) return;
57
+ if (routines.value.botId !== id || !routines.value.loaded) {
58
+ void routines.value.load(id);
59
+ }
60
+ },
61
+ { immediate: true },
62
+ );
63
+
64
+ /**
65
+ * The delivery URL, composed here because the browser is the only place that
66
+ * knows the origin the caller will use. The key is appended as the bearer token
67
+ * the route reads, and it is on screen once: nothing can fetch it again.
68
+ */
69
+ function hookUrl(): string {
70
+ const mint = routines.value.mintedHook;
71
+ if (!mint) return "";
72
+ return `${window.location.origin}${mint.path}`;
73
+ }
74
+
75
+ async function copyHook(): Promise<void> {
76
+ const mint = routines.value.mintedHook;
77
+ if (!mint) return;
78
+ try {
79
+ await navigator.clipboard.writeText(
80
+ `curl -X POST ${hookUrl()} -H "Authorization: Bearer ${mint.token}" -d '{}'`,
81
+ );
82
+ copied.value = true;
83
+ } catch {
84
+ // Clipboard access can be refused; the key is on screen either way.
85
+ copied.value = false;
86
+ }
87
+ }
88
+
89
+ function summary(routine: RoutineViewV1): string {
90
+ return routine.schedule
91
+ ? `${routine.schedule} · ${routine.timezone}`
92
+ : "Webhook trigger";
93
+ }
94
+
95
+ function startCreate(): void {
96
+ form.routineId = undefined;
97
+ form.name = "";
98
+ form.prompt = "";
99
+ form.timing = "schedule";
100
+ form.schedule = "0 9 * * *";
101
+ form.timezone = "UTC";
102
+ formOpen.value = true;
103
+ }
104
+
105
+ function startEdit(routine: RoutineViewV1): void {
106
+ form.routineId = routine.routineId;
107
+ form.name = routine.name;
108
+ form.prompt = routine.prompt;
109
+ form.timing = routine.schedule ? "schedule" : "webhook";
110
+ form.schedule = routine.schedule ?? "";
111
+ form.timezone = routine.timezone;
112
+ formOpen.value = true;
113
+ }
114
+
115
+ async function submit(): Promise<void> {
116
+ const id = botId.value;
117
+ if (!id) return;
118
+ try {
119
+ await routines.value.save(id, {
120
+ ...(form.routineId ? { routineId: form.routineId } : {}),
121
+ name: form.name.trim(),
122
+ prompt: form.prompt.trim(),
123
+ ...(form.timing === "schedule"
124
+ ? { schedule: form.schedule.trim() }
125
+ : { trigger: { kind: "webhook" as const } }),
126
+ timezone: form.timezone.trim(),
127
+ });
128
+ formOpen.value = false;
129
+ } catch {
130
+ // The state holds the reason; the form stays open so it can be corrected.
131
+ }
132
+ }
133
+
134
+ async function toggleLog(routineId: string): Promise<void> {
135
+ const id = botId.value;
136
+ if (!id) return;
137
+ if (openLog.value === routineId) {
138
+ openLog.value = undefined;
139
+ return;
140
+ }
141
+ openLog.value = routineId;
142
+ await routines.value.loadRuns(id, routineId);
143
+ }
144
+ </script>
145
+
146
+ <template>
147
+ <UiAnchor
148
+ as="section"
149
+ anchor="bot-routines"
150
+ label="Routines"
151
+ :href="anchorHref"
152
+ class="routines"
153
+ >
154
+ <header class="routines__header">
155
+ <span class="routines__icon" aria-hidden="true"
156
+ ><UiIcon name="history"
157
+ /></span>
158
+ <span class="routines__intro">
159
+ <strong>Routines</strong>
160
+ <small>
161
+ Standing instructions this Bot runs on a schedule or on a delivered
162
+ webhook, as their own Turns.
163
+ </small>
164
+ </span>
165
+ <UiButton type="button" :disabled="!botId" @click="startCreate">
166
+ New Routine
167
+ </UiButton>
168
+ </header>
169
+
170
+ <p v-if="routines.error" class="routines__error" role="alert">
171
+ {{ routines.error }}
172
+ </p>
173
+
174
+ <p
175
+ v-if="routines.loaded && routines.routines.length === 0"
176
+ class="routines__empty"
177
+ >
178
+ No Routines yet. A Routine fires on its own and reports back on this Bot's
179
+ next conversation.
180
+ </p>
181
+
182
+ <article
183
+ v-for="routine in routines.routines"
184
+ :key="routine.routineId"
185
+ class="routine-card"
186
+ >
187
+ <div class="routine-card__head">
188
+ <span class="routine-card__text">
189
+ <strong>{{ routine.name }}</strong>
190
+ <small>{{ summary(routine) }}</small>
191
+ </span>
192
+ <span
193
+ class="routine-card__state"
194
+ :data-enabled="routine.enabled ? 'yes' : 'no'"
195
+ >
196
+ {{ routine.enabled ? "Enabled" : "Paused" }}
197
+ </span>
198
+ </div>
199
+ <div
200
+ v-if="routines.mintedHook?.routineId === routine.routineId"
201
+ class="routine-hook"
202
+ >
203
+ <strong
204
+ >Webhook key, version {{ routines.mintedHook.keyVersion }}</strong
205
+ >
206
+ <small>
207
+ This is the only time it is shown. Copy it now; rotating is the only
208
+ way to see a key again.
209
+ </small>
210
+ <code>{{ hookUrl() }}</code>
211
+ <code class="routine-hook__token">{{ routines.mintedHook.token }}</code>
212
+ <div class="routine-card__actions">
213
+ <UiButton type="button" variant="primary" @click="copyHook">
214
+ {{ copied ? "Copied" : "Copy webhook URL" }}
215
+ </UiButton>
216
+ <UiButton type="button" @click="routines.dismissHook()">
217
+ Done
218
+ </UiButton>
219
+ </div>
220
+ </div>
221
+ <dl class="routine-card__facts">
222
+ <div>
223
+ <dt>Next run</dt>
224
+ <dd>{{ routine.nextRunAt ?? "—" }}</dd>
225
+ </div>
226
+ <div v-if="routine.trigger">
227
+ <dt>Webhook key</dt>
228
+ <dd>
229
+ {{
230
+ routine.hookKeyVersion
231
+ ? `version ${routine.hookKeyVersion}`
232
+ : "none"
233
+ }}
234
+ </dd>
235
+ </div>
236
+ <div>
237
+ <dt>Last run</dt>
238
+ <dd>{{ routine.lastRunAt ?? "Never" }}</dd>
239
+ </div>
240
+ <div>
241
+ <dt>Written by</dt>
242
+ <dd>{{ routine.updatedBy.kind === "bot" ? "the Bot" : "you" }}</dd>
243
+ </div>
244
+ </dl>
245
+ <div class="routine-card__actions">
246
+ <UiButton
247
+ type="button"
248
+ :disabled="routines.busy"
249
+ @click="startEdit(routine)"
250
+ >
251
+ Edit
252
+ </UiButton>
253
+ <UiButton
254
+ type="button"
255
+ :disabled="routines.busy"
256
+ @click="
257
+ botId &&
258
+ routines.setEnabled(botId, routine.routineId, !routine.enabled)
259
+ "
260
+ >
261
+ {{ routine.enabled ? "Pause" : "Resume" }}
262
+ </UiButton>
263
+ <UiButton
264
+ type="button"
265
+ :disabled="routines.busy"
266
+ @click="botId && routines.runNow(botId, routine.routineId)"
267
+ >
268
+ Run now
269
+ </UiButton>
270
+ <UiButton
271
+ v-if="routine.trigger"
272
+ type="button"
273
+ :disabled="routines.busy"
274
+ @click="botId && routines.rotateKey(botId, routine.routineId)"
275
+ >
276
+ {{ routine.hookKeyVersion ? "Rotate key" : "Mint key" }}
277
+ </UiButton>
278
+ <UiButton
279
+ v-if="routine.trigger && routine.hookKeyVersion"
280
+ type="button"
281
+ :disabled="routines.busy"
282
+ @click="botId && routines.revokeKey(botId, routine.routineId)"
283
+ >
284
+ Revoke key
285
+ </UiButton>
286
+ <UiButton type="button" @click="toggleLog(routine.routineId)">
287
+ {{ openLog === routine.routineId ? "Hide runs" : "Run log" }}
288
+ </UiButton>
289
+ <UiButton
290
+ type="button"
291
+ variant="danger"
292
+ :disabled="routines.busy"
293
+ @click="botId && routines.remove(botId, routine.routineId)"
294
+ >
295
+ Delete
296
+ </UiButton>
297
+ </div>
298
+ <div v-if="openLog === routine.routineId" class="routine-card__log">
299
+ <p
300
+ v-if="(routines.runs[routine.routineId] ?? []).length === 0"
301
+ class="routines__empty"
302
+ >
303
+ This Routine has not fired yet.
304
+ </p>
305
+ <ul v-else>
306
+ <li
307
+ v-for="entry in routines.runs[routine.routineId]"
308
+ :key="entry.entryId"
309
+ >
310
+ <button
311
+ type="button"
312
+ class="routine-card__run"
313
+ :aria-expanded="openRun === entry.runId"
314
+ @click="toggleRun(routine.routineId, entry.runId)"
315
+ >
316
+ <span>{{ entry.startedAt }}</span>
317
+ <span>{{ entry.trigger }}</span>
318
+ <span>{{ entry.status }}</span>
319
+ </button>
320
+ <ol
321
+ v-if="openRun === entry.runId && routines.runDetails[entry.runId]"
322
+ class="routine-card__events"
323
+ >
324
+ <li
325
+ v-for="(event, index) in routines.runDetails[entry.runId]!
326
+ .events"
327
+ :key="`${entry.runId}:${index}`"
328
+ >
329
+ {{ event.summary }}
330
+ </li>
331
+ </ol>
332
+ </li>
333
+ </ul>
334
+ </div>
335
+ </article>
336
+
337
+ <div v-if="formOpen" class="routine-form">
338
+ <strong>{{ form.routineId ? "Edit Routine" : "New Routine" }}</strong>
339
+ <UiField label="Name">
340
+ <input v-model="form.name" maxlength="100" required />
341
+ </UiField>
342
+ <UiField label="Prompt" hint="what the Routine does when it fires">
343
+ <textarea v-model="form.prompt" maxlength="8000" rows="5" />
344
+ </UiField>
345
+ <fieldset class="routine-form__timing">
346
+ <legend>Fires on</legend>
347
+ <label>
348
+ <input v-model="form.timing" type="radio" value="schedule" />
349
+ <span>A schedule</span>
350
+ </label>
351
+ <label>
352
+ <input v-model="form.timing" type="radio" value="webhook" />
353
+ <span>A webhook</span>
354
+ </label>
355
+ </fieldset>
356
+ <UiField
357
+ v-if="form.timing === 'schedule'"
358
+ label="Schedule"
359
+ hint="cron, or @daily / @every 15m"
360
+ >
361
+ <input
362
+ v-model="form.schedule"
363
+ maxlength="256"
364
+ placeholder="0 9 * * *"
365
+ />
366
+ </UiField>
367
+ <p v-else class="routines__note">
368
+ A delivery key is minted when the Routine is saved, and shown once.
369
+ </p>
370
+ <UiField label="Time zone">
371
+ <input
372
+ v-model="form.timezone"
373
+ maxlength="64"
374
+ placeholder="Australia/Sydney"
375
+ />
376
+ </UiField>
377
+ <div class="routine-card__actions">
378
+ <UiButton
379
+ type="button"
380
+ variant="primary"
381
+ :disabled="routines.busy"
382
+ @click="submit"
383
+ >
384
+ {{ routines.busy ? "Saving…" : "Save Routine" }}
385
+ </UiButton>
386
+ <UiButton type="button" @click="formOpen = false">Cancel</UiButton>
387
+ </div>
388
+ </div>
389
+ </UiAnchor>
390
+ </template>
391
+
392
+ <style scoped>
393
+ .routines {
394
+ display: flex;
395
+ flex-direction: column;
396
+ gap: 12px;
397
+ }
398
+
399
+ .routines__header {
400
+ display: flex;
401
+ align-items: center;
402
+ gap: 10px;
403
+ }
404
+
405
+ .routines__icon {
406
+ display: grid;
407
+ width: var(--frock-avatar-sm);
408
+ height: var(--frock-avatar-sm);
409
+ flex: 0 0 auto;
410
+ place-items: center;
411
+ border-radius: 8px;
412
+ color: var(--frock-action-primary);
413
+ background: var(--frock-surface);
414
+ box-shadow: inset 0 0 0 1px var(--frock-border);
415
+ }
416
+
417
+ .routines__intro {
418
+ display: flex;
419
+ min-width: 0;
420
+ flex: 1 1 auto;
421
+ flex-direction: column;
422
+ }
423
+
424
+ .routines__intro strong {
425
+ color: var(--frock-text);
426
+ font-size: var(--frock-text-md);
427
+ font-weight: 600;
428
+ }
429
+
430
+ .routines__intro small,
431
+ .routines__empty,
432
+ .routines__note {
433
+ color: var(--frock-text-muted);
434
+ font-size: var(--frock-text-sm);
435
+ }
436
+
437
+ .routines__error {
438
+ color: var(--frock-danger-text);
439
+ font-size: var(--frock-text-sm);
440
+ }
441
+
442
+ .routine-card,
443
+ .routine-form {
444
+ display: flex;
445
+ flex-direction: column;
446
+ gap: 10px;
447
+ border: 1px solid var(--frock-border);
448
+ border-radius: var(--frock-radius-card);
449
+ padding: 12px;
450
+ background: var(--frock-surface-subtle);
451
+ }
452
+
453
+ .routine-card__head {
454
+ display: flex;
455
+ align-items: center;
456
+ gap: 10px;
457
+ }
458
+
459
+ .routine-card__text {
460
+ display: flex;
461
+ min-width: 0;
462
+ flex: 1 1 auto;
463
+ flex-direction: column;
464
+ }
465
+
466
+ .routine-card__text strong {
467
+ color: var(--frock-text);
468
+ font-size: var(--frock-text-md);
469
+ font-weight: 600;
470
+ }
471
+
472
+ .routine-card__text small {
473
+ color: var(--frock-text-muted);
474
+ font-size: var(--frock-text-sm);
475
+ }
476
+
477
+ .routine-card__state {
478
+ flex: 0 0 auto;
479
+ border-radius: var(--frock-radius-control);
480
+ padding: 2px 8px;
481
+ color: var(--frock-text-muted);
482
+ font-size: var(--frock-text-sm);
483
+ box-shadow: inset 0 0 0 1px var(--frock-border);
484
+ }
485
+
486
+ .routine-card__state[data-enabled="yes"] {
487
+ color: var(--frock-action-primary);
488
+ }
489
+
490
+ .routine-card__facts {
491
+ display: flex;
492
+ flex-wrap: wrap;
493
+ gap: 16px;
494
+ margin: 0;
495
+ }
496
+
497
+ .routine-card__facts dt {
498
+ color: var(--frock-text-subtle);
499
+ font-size: var(--frock-text-sm);
500
+ }
501
+
502
+ .routine-card__facts dd {
503
+ margin: 0;
504
+ color: var(--frock-text);
505
+ font-size: var(--frock-text-sm);
506
+ }
507
+
508
+ .routine-hook {
509
+ display: flex;
510
+ flex-direction: column;
511
+ gap: 6px;
512
+ border: 1px solid var(--frock-border);
513
+ border-radius: var(--frock-radius-card);
514
+ padding: 10px;
515
+ background: var(--frock-surface);
516
+ }
517
+
518
+ .routine-hook strong {
519
+ color: var(--frock-text);
520
+ font-size: var(--frock-text-sm);
521
+ font-weight: 600;
522
+ }
523
+
524
+ .routine-hook code {
525
+ overflow-wrap: anywhere;
526
+ color: var(--frock-text);
527
+ font-size: var(--frock-text-sm);
528
+ }
529
+
530
+ .routine-hook__token {
531
+ color: var(--frock-text-muted);
532
+ }
533
+
534
+ .routine-card__actions {
535
+ display: flex;
536
+ flex-wrap: wrap;
537
+ gap: 8px;
538
+ }
539
+
540
+ .routine-card__log ul {
541
+ display: flex;
542
+ flex-direction: column;
543
+ gap: 6px;
544
+ margin: 0;
545
+ padding: 0;
546
+ list-style: none;
547
+ }
548
+
549
+ .routine-card__log li {
550
+ display: flex;
551
+ flex-direction: column;
552
+ gap: 4px;
553
+ color: var(--frock-text-muted);
554
+ font-size: var(--frock-text-sm);
555
+ }
556
+
557
+ .routine-card__run {
558
+ display: flex;
559
+ justify-content: space-between;
560
+ gap: 8px;
561
+ border: 0;
562
+ padding: 0;
563
+ color: inherit;
564
+ background: transparent;
565
+ font: inherit;
566
+ text-align: left;
567
+ cursor: pointer;
568
+ }
569
+
570
+ .routine-card__events {
571
+ display: flex;
572
+ flex-direction: column;
573
+ gap: 4px;
574
+ margin: 0;
575
+ padding: 6px 0 6px 16px;
576
+ color: var(--frock-text-subtle);
577
+ font-size: var(--frock-text-xs);
578
+ }
579
+
580
+ .routine-form__timing {
581
+ display: flex;
582
+ gap: 12px;
583
+ border: 0;
584
+ margin: 0;
585
+ padding: 0;
586
+ color: var(--frock-text-muted);
587
+ font-size: var(--frock-text-sm);
588
+ }
589
+
590
+ .routine-form__timing legend {
591
+ padding: 0;
592
+ color: var(--frock-text-subtle);
593
+ font-size: var(--frock-text-sm);
594
+ }
595
+
596
+ .routine-form__timing label {
597
+ display: flex;
598
+ align-items: center;
599
+ gap: 6px;
600
+ }
601
+ </style>