@flowdular/sandbox 0.2.8 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,193 @@
1
+ import { useState } from 'octane';
2
+ import { Alert, Button, Icon, Tag } from '@flowdular/sdk/ui';
3
+ import {
4
+ MAX_ANSWER_LENGTH,
5
+ type PendingQuestion,
6
+ type PendingQuestions,
7
+ } from '../server/questions.ts';
8
+ import type { SubmittedAnswer } from './api.ts';
9
+ import { useTranslation } from './i18n.ts';
10
+
11
+ export interface PendingQuestionsCardProps {
12
+ readonly pending: PendingQuestions | null;
13
+ /* A turn is still in flight, so nothing here is answerable yet. */
14
+ readonly loading: boolean;
15
+ /* The session no longer accepts work: archived, or already delivered. */
16
+ readonly denied: boolean;
17
+ readonly busy: boolean;
18
+ /* Why the last submission was refused. It renders above the state branch,
19
+ because a refusal that already cleared the record leaves no questions to
20
+ render it beside. */
21
+ readonly error: string;
22
+ readonly onSubmit: (answers: readonly SubmittedAnswer[], message: string) => void;
23
+ }
24
+
25
+ /* One operator's unsent decisions, stamped with the turn that asked. A newer
26
+ question set makes the draft stale without an effect or a reset pass. */
27
+ interface AnswerDraft {
28
+ readonly sequence: number;
29
+ readonly mode: Readonly<Record<string, 'option' | 'free'>>;
30
+ readonly choice: Readonly<Record<string, string>>;
31
+ readonly typed: Readonly<Record<string, string>>;
32
+ readonly note: string;
33
+ }
34
+
35
+ const EMPTY: AnswerDraft = {
36
+ sequence: -1,
37
+ mode: {},
38
+ choice: {},
39
+ typed: {},
40
+ note: '',
41
+ };
42
+
43
+ /* The decisions a specialist is waiting for: one radio group per question, the
44
+ recommendation preselected, and a free-text answer where the specialist
45
+ allowed one. Submitting starts the next turn in the role that asked. */
46
+ export function PendingQuestionsCard(props: PendingQuestionsCardProps) @{
47
+ const { t } = useTranslation();
48
+ const questions = props.pending?.questions ?? [];
49
+ const sequence = props.pending?.sequence ?? -1;
50
+ const [stored, setStored] = useState<AnswerDraft>(EMPTY);
51
+ const draft =
52
+ stored.sequence === sequence ? stored : EMPTY;
53
+ const patch = (change: Partial<AnswerDraft>) => setStored({
54
+ ...draft,
55
+ ...change,
56
+ sequence,
57
+ });
58
+
59
+ const modeOf = (question: PendingQuestion): 'option' | 'free' | '' =>
60
+ draft.mode[question.id] ??
61
+ (question.recommended
62
+ ? 'option'
63
+ : question.options.length === 0
64
+ ? 'free'
65
+ : '');
66
+ const choiceOf = (question: PendingQuestion) =>
67
+ draft.choice[question.id] ?? question.recommended ?? '';
68
+ const answerOf = (question: PendingQuestion) => {
69
+ const mode = modeOf(question);
70
+ if (mode === 'free') return (draft.typed[question.id] ?? '').trim();
71
+ return mode === 'option' ? choiceOf(question) : '';
72
+ };
73
+ const pick = (question: PendingQuestion, option: string) => patch({
74
+ mode: { ...draft.mode, [question.id]: 'option' },
75
+ choice: { ...draft.choice, [question.id]: option },
76
+ });
77
+ const complete = questions.every((question) => answerOf(question).length > 0);
78
+
79
+ <section class="ui-form">
80
+ @if (props.error) {
81
+ <Alert tone="danger">{props.error}</Alert>
82
+ }
83
+ @if (props.loading) {
84
+ <p class="ui-note">
85
+ <Icon name="refresh" size={14} />
86
+ {t('sandbox.questions.loading')}
87
+ </p>
88
+ } @else if (props.denied) {
89
+ <Alert tone="warning">{t('sandbox.questions.denied')}</Alert>
90
+ } @else if (questions.length === 0) {
91
+ <p class="ui-note">
92
+ <Icon name="help" size={14} />
93
+ {t('sandbox.questions.empty')}
94
+ </p>
95
+ } @else {
96
+ <>
97
+ <div class="ui-form__section">
98
+ <div class="ui-form__section-head">
99
+ <b>{t('sandbox.questions.title')}</b>
100
+ <small>{t('sandbox.questions.help')}</small>
101
+ </div>
102
+ @for (const question of questions; key question.id) {
103
+ <div class="ui-field">
104
+ <span class="ui-label">
105
+ <span class="ui-mono">{question.id}</span>
106
+ {' ' + question.question}
107
+ </span>
108
+ @for (const option of question.options; key option) {
109
+ <label class="ui-checkbox">
110
+ <input
111
+ type="radio"
112
+ name={'answer-' + sequence + '-' + question.id}
113
+ value={option}
114
+ checked={modeOf(question) === 'option' &&
115
+ choiceOf(question) === option}
116
+ disabled={props.busy}
117
+ onChange={() => pick(question, option)}
118
+ />
119
+ <span>{option}</span>
120
+ @if (option === question.recommended) {
121
+ <Tag tone="info">{t('sandbox.questions.recommended')}</Tag>
122
+ }
123
+ </label>
124
+ }
125
+ @if (question.allowFreeText) {
126
+ <>
127
+ <label class="ui-checkbox">
128
+ <input
129
+ type="radio"
130
+ name={'answer-' + sequence + '-' + question.id}
131
+ value=""
132
+ checked={modeOf(question) === 'free'}
133
+ disabled={props.busy}
134
+ onChange={() => patch({
135
+ mode: { ...draft.mode, [question.id]: 'free' },
136
+ })}
137
+ />
138
+ <span>{t('sandbox.questions.freeText')}</span>
139
+ </label>
140
+ <input
141
+ class="ui-input"
142
+ type="text"
143
+ maxlength={MAX_ANSWER_LENGTH}
144
+ aria-label={t('sandbox.questions.freeText')}
145
+ placeholder={t('sandbox.questions.freeTextPlaceholder')}
146
+ value={draft.typed[question.id] ?? ''}
147
+ disabled={props.busy || modeOf(question) !== 'free'}
148
+ onInput={(event) => patch({
149
+ typed: {
150
+ ...draft.typed,
151
+ [question.id]: event.currentTarget.value,
152
+ },
153
+ })}
154
+ />
155
+ </>
156
+ }
157
+ </div>
158
+ }
159
+ </div>
160
+ <div class="ui-field">
161
+ <span class="ui-label">{t('sandbox.questions.note')}</span>
162
+ <textarea
163
+ class="ui-textarea"
164
+ rows={2}
165
+ placeholder={t('sandbox.questions.notePlaceholder')}
166
+ value={draft.note}
167
+ disabled={props.busy}
168
+ onInput={(event) => patch({ note: event.currentTarget.value })}
169
+ ></textarea>
170
+ </div>
171
+ <div class="ui-form__actions">
172
+ <Button
173
+ size="sm"
174
+ variant="primary"
175
+ disabled={props.busy || !complete}
176
+ onClick={() => props.onSubmit(
177
+ questions.map(
178
+ (question) => ({
179
+ id: question.id,
180
+ answer: answerOf(question),
181
+ }),
182
+ ),
183
+ draft.note.trim(),
184
+ )}
185
+ >
186
+ <Icon name="check" size={14} />
187
+ {t('sandbox.questions.submit')}
188
+ </Button>
189
+ </div>
190
+ </>
191
+ }
192
+ </section>
193
+ }
package/src/client/api.ts CHANGED
@@ -701,34 +701,25 @@ async function consumeTurnStream(
701
701
  });
702
702
  }
703
703
 
704
- /* The turn runs on the server whatever this stream does. Aborting the
705
- controller only closes this browser's view of it; stopTurn stops the agent. */
706
- export function streamTurn(
707
- sessionId: string,
708
- input: {
709
- readonly message: string;
710
- readonly freshContext?: boolean;
711
- readonly role: string;
712
- /* The draft module directory the turn works in; absent lets the sandbox
713
- decide from the last handoff. */
714
- readonly module?: string;
715
- readonly driver: string;
716
- },
704
+ /* Every route that starts a turn answers with the same stream, so they share
705
+ one transport. The turn runs on the server whatever this stream does:
706
+ aborting the controller only closes this browser's view of it, and stopTurn
707
+ stops the agent. */
708
+ function postTurnStream(
709
+ path: string,
710
+ input: unknown,
717
711
  handlers: TurnHandlers,
718
712
  ): AbortController {
719
713
  const controller = new AbortController();
720
714
  void (async () => {
721
715
  try {
722
- const response = await fetch(
723
- `/sandbox/api/sessions/${encodeURIComponent(sessionId)}/turn`,
724
- {
725
- method: 'POST',
726
- headers: MUTATION_HEADERS,
727
- credentials: 'same-origin',
728
- body: JSON.stringify(input),
729
- signal: controller.signal,
730
- },
731
- );
716
+ const response = await fetch(path, {
717
+ method: 'POST',
718
+ headers: MUTATION_HEADERS,
719
+ credentials: 'same-origin',
720
+ body: JSON.stringify(input),
721
+ signal: controller.signal,
722
+ });
732
723
  if (!response.ok || !response.body) {
733
724
  const value = (await response
734
725
  .json()
@@ -751,6 +742,47 @@ export function streamTurn(
751
742
  return controller;
752
743
  }
753
744
 
745
+ export function streamTurn(
746
+ sessionId: string,
747
+ input: {
748
+ readonly message: string;
749
+ readonly freshContext?: boolean;
750
+ readonly role: string;
751
+ /* The draft module directory the turn works in; absent lets the sandbox
752
+ decide from the last handoff. */
753
+ readonly module?: string;
754
+ readonly driver: string;
755
+ },
756
+ handlers: TurnHandlers,
757
+ ): AbortController {
758
+ return postTurnStream(
759
+ `/sandbox/api/sessions/${encodeURIComponent(sessionId)}/turn`,
760
+ input,
761
+ handlers,
762
+ );
763
+ }
764
+
765
+ export interface SubmittedAnswer {
766
+ readonly id: string;
767
+ readonly answer: string;
768
+ }
769
+
770
+ /* The decisions the operator made on the questions the last turn asked. The
771
+ server prepends them to the optional message and starts the next turn in the
772
+ role that asked, so this is a turn stream like any other. */
773
+ export function submitAnswers(
774
+ sessionId: string,
775
+ answers: readonly SubmittedAnswer[],
776
+ message: string,
777
+ handlers: TurnHandlers,
778
+ ): AbortController {
779
+ return postTurnStream(
780
+ `/sandbox/api/sessions/${encodeURIComponent(sessionId)}/answers`,
781
+ { answers, ...(message ? { message } : {}) },
782
+ handlers,
783
+ );
784
+ }
785
+
754
786
  /* Attach to a turn that is already running, after a reload or from another
755
787
  tab. Resolves with false when nothing is running. */
756
788
  export function followTurn(
@@ -33,6 +33,17 @@
33
33
  "handoff.description.review": "Review the results and decide what to do next.",
34
34
  "handoff.description.blocked": "Work needs your attention. See the reason below.",
35
35
  "handoff.details": "Specialist's message",
36
+ "questions.title": "Decisions the specialist needs",
37
+ "questions.help": "Pick one answer per question. The specialist continues with them.",
38
+ "questions.recommended": "Recommended",
39
+ "questions.freeText": "Something else",
40
+ "questions.freeTextPlaceholder": "Write your own answer",
41
+ "questions.note": "Anything else to add (optional)",
42
+ "questions.notePlaceholder": "Context the specialist should know",
43
+ "questions.submit": "Send decisions",
44
+ "questions.loading": "The specialist is working. The questions can be answered when the turn ends.",
45
+ "questions.empty": "No structured decisions are open. Answer in the message box below.",
46
+ "questions.denied": "This session no longer accepts decisions. Start a new session to change the module again.",
36
47
  "api.error.request": "The sandbox request failed.",
37
48
  "api.error.unreachable": "The sandbox server is not reachable. Start it again with npx @flowdular/sandbox.",
38
49
  "api.error.deliveryStart": "Delivery could not start.",
@@ -33,6 +33,17 @@
33
33
  "handoff.description.review": "Sprawdź rezultat i zdecyduj, co dalej.",
34
34
  "handoff.description.blocked": "Praca wymaga Twojej uwagi. Poniżej znajdziesz przyczynę.",
35
35
  "handoff.details": "Wiadomość specjalisty",
36
+ "questions.title": "Decyzje potrzebne specjaliście",
37
+ "questions.help": "Wybierz jedną odpowiedź na pytanie. Specjalista pracuje dalej z nimi.",
38
+ "questions.recommended": "Rekomendacja",
39
+ "questions.freeText": "Coś innego",
40
+ "questions.freeTextPlaceholder": "Wpisz własną odpowiedź",
41
+ "questions.note": "Coś jeszcze (opcjonalnie)",
42
+ "questions.notePlaceholder": "Kontekst, który specjalista powinien znać",
43
+ "questions.submit": "Wyślij decyzje",
44
+ "questions.loading": "Specjalista pracuje. Na pytania będzie można odpowiedzieć po zakończeniu tury.",
45
+ "questions.empty": "Nie ma otwartych decyzji do wyboru. Odpowiedz w polu wiadomości poniżej.",
46
+ "questions.denied": "Ta sesja nie przyjmuje już decyzji. Zacznij nową sesję, aby ponownie zmienić moduł.",
36
47
  "api.error.request": "Nie udało się wykonać tej operacji.",
37
48
  "api.error.unreachable": "Brak połączenia z pracownią. Uruchom ją ponownie poleceniem npx @flowdular/sandbox.",
38
49
  "api.error.deliveryStart": "Nie udało się rozpocząć publikacji.",
@@ -26,6 +26,27 @@ export interface PendingAttachment {
26
26
  readonly error?: string;
27
27
  }
28
28
 
29
+ /* A refused answer submission, stamped with the session and the question set it
30
+ answered, so it can never be shown beside another form. */
31
+ export interface AnswersRefusal {
32
+ readonly session: string;
33
+ readonly sequence: number;
34
+ readonly message: string;
35
+ }
36
+
37
+ /* A refusal outlives the record it answered, because a submission that already
38
+ consumed the questions still has to say why it failed. It does not outlive
39
+ the session it was refused in, nor a newer question set. */
40
+ export function answersErrorFor(
41
+ refusal: AnswersRefusal | null,
42
+ sessionId: string,
43
+ pending: { readonly sequence: number } | null,
44
+ ): string {
45
+ if (!refusal || refusal.session !== sessionId) return '';
46
+ if (pending && pending.sequence > refusal.sequence) return '';
47
+ return refusal.message;
48
+ }
49
+
29
50
  export function createSandboxClientState() {
30
51
  const store = createStore({
31
52
  state: cell<SandboxState | null>(null),
@@ -5,7 +5,9 @@ import { pathToFileURL } from 'node:url';
5
5
  import type { ServerRoute } from '@octanejs/app-core';
6
6
  import { createRouter, type Router } from '@octanejs/app-core';
7
7
  import {
8
+ createDataClassRegistry,
8
9
  createModuleSettingsRuntime,
10
+ PLATFORM_SETTINGS_TENANT,
9
11
  createPlatformAgentRegistry,
10
12
  createPlatformCapabilityRegistry,
11
13
  createPlatformToolRegistry,
@@ -28,6 +30,17 @@ import {
28
30
  type SessionModule,
29
31
  } from './sessions.ts';
30
32
  import type { DatabaseProvider } from '@flowdular/sdk/database';
33
+ import {
34
+ createStorageKeyring,
35
+ createStoragePort,
36
+ storageConfigFromEnvironment,
37
+ } from '@flowdular/sdk/storage';
38
+ import {
39
+ createMailPort,
40
+ createModuleMetrics,
41
+ mailConfigFromEnvironment,
42
+ serverTracer,
43
+ } from '@flowdular/sdk/server';
31
44
  import { createIsolatedPreviewRuntime } from './preview-worker-manager.ts';
32
45
  import {
33
46
  resolvePreviewModules,
@@ -60,6 +73,9 @@ export interface PreviewComposition {
60
73
  readonly moduleId: string;
61
74
  readonly modules: readonly PreviewModuleComposition[];
62
75
  readonly auth: AuthRuntime;
76
+ /* The drafts' settings runtime; a request primes the principal's workspace
77
+ on it once the authentication middleware resolved the principal. */
78
+ readonly settings: ModuleSettingsRuntime;
63
79
  readonly credentials: PreviewCredentials;
64
80
  readonly router: Router;
65
81
  readonly routes: readonly ServerRoute[];
@@ -89,7 +105,7 @@ function memorySettings(): ModuleSettingsRuntime {
89
105
  const keyOf = (tenantId: string, moduleId: string, key: string) =>
90
106
  `${tenantId}\0${moduleId}\0${key}`;
91
107
  return createModuleSettingsRuntime({
92
- load: (tenantId, moduleId) => {
108
+ load: async (tenantId, moduleId) => {
93
109
  const values: Record<string, ModuleSettingValue> = {};
94
110
  for (const record of records.values()) {
95
111
  if (record.tenantId === tenantId && record.moduleId === moduleId) {
@@ -98,10 +114,10 @@ function memorySettings(): ModuleSettingsRuntime {
98
114
  }
99
115
  return values;
100
116
  },
101
- save: (record) => {
117
+ save: async (record) => {
102
118
  records.set(keyOf(record.tenantId, record.moduleId, record.key), record);
103
119
  },
104
- clear: (tenantId, moduleId, key) => {
120
+ clear: async (tenantId, moduleId, key) => {
105
121
  records.delete(keyOf(tenantId, moduleId, key));
106
122
  },
107
123
  });
@@ -152,7 +168,9 @@ async function createPreviewAuth(
152
168
  } catch {
153
169
  credentials = {
154
170
  email: PREVIEW_EMAIL,
155
- password: `preview-${randomBytes(12).toString('base64url')}`,
171
+ /* Entropy only: the platform password policy refuses a password that
172
+ carries the local part of the account's own email address. */
173
+ password: randomBytes(24).toString('base64url'),
156
174
  };
157
175
  await mkdir(paths.data, { recursive: true, mode: 0o700 });
158
176
  await writeFile(credentialPath, JSON.stringify(credentials), {
@@ -204,6 +222,8 @@ async function loadDraftComposition(
204
222
  const composition = draft.createServerComposition({
205
223
  ...context,
206
224
  agentDefinitions: context.agentDefinitions.forModule(module.id),
225
+ dataClasses: context.dataClasses.forModule(module.id),
226
+ metrics: createModuleMetrics(module.id),
207
227
  workspaceRoot: paths.root,
208
228
  });
209
229
  if (composition.settings) context.settings.declare(composition.settings);
@@ -336,6 +356,17 @@ export function createInProcessPreviewRuntime(
336
356
  const modules: PreviewModuleComposition[] = [];
337
357
  const errors: string[] = [];
338
358
  const agentDefinitions = createPlatformAgentRegistry();
359
+ const dataClasses = createDataClassRegistry();
360
+ /* A preview writes objects under its own session directory, never to a
361
+ configured object store: a draft module must not reach a deployment's
362
+ bucket, and the session directory is removed with the session. */
363
+ const storage = createStoragePort(
364
+ storageConfigFromEnvironment(
365
+ { ...process.env, NODE_ENV: 'test', FD_STORAGE_ADAPTER: 'local' },
366
+ paths.root,
367
+ ),
368
+ { keyring: createStorageKeyring(process.env, paths.root) },
369
+ );
339
370
  const context: Omit<PlatformServerContext, 'workspaceRoot'> = {
340
371
  environment: process.env,
341
372
  auth,
@@ -343,7 +374,22 @@ export function createInProcessPreviewRuntime(
343
374
  agentTools: createPlatformToolRegistry() as PlatformToolRegistry,
344
375
  agentDefinitions,
345
376
  capabilities: createPlatformCapabilityRegistry(),
377
+ dataClasses,
346
378
  databases,
379
+ storage,
380
+ /* In memory for the same reason the object store is session-local: a
381
+ draft module must not reach anyone from a preview. */
382
+ mail: createMailPort(
383
+ mailConfigFromEnvironment({
384
+ ...process.env,
385
+ NODE_ENV: 'test',
386
+ FD_MAIL_TRANSPORT: 'development',
387
+ }),
388
+ ),
389
+ /* Rebound to each draft module as it composes, as the generated
390
+ composition does; this binding is the preview's own. */
391
+ metrics: createModuleMetrics('sandbox.preview'),
392
+ tracer: serverTracer(),
347
393
  };
348
394
  for (const module of sources) {
349
395
  const draft = await loadDraftComposition(
@@ -369,6 +415,8 @@ export function createInProcessPreviewRuntime(
369
415
  });
370
416
  }
371
417
  agentDefinitions.seal();
418
+ dataClasses.seal();
419
+ await context.settings.prime(PLATFORM_SETTINGS_TENANT);
372
420
 
373
421
  const account = await (
374
422
  await auth.service()
@@ -401,6 +449,7 @@ export function createInProcessPreviewRuntime(
401
449
  moduleId: session.moduleId,
402
450
  modules,
403
451
  auth,
452
+ settings: context.settings,
404
453
  credentials,
405
454
  routes: all,
406
455
  router: createRouter([...all]),
@@ -417,8 +466,11 @@ export function createInProcessPreviewRuntime(
417
466
  { status: 500, headers: { 'content-type': 'application/json' } },
418
467
  ),
419
468
  /* The session engine outlives a generation, so retiring one releases
420
- the drafts and nothing else. */
421
- dispose: () => disposeAll(drafts),
469
+ the drafts and the preview object store, and nothing else. */
470
+ dispose: async () => {
471
+ await disposeAll(drafts);
472
+ await storage.dispose();
473
+ },
422
474
  };
423
475
  compositions.set(session.id, composition);
424
476
  return composition;
@@ -408,6 +408,7 @@ export function createIsolatedPreviewRuntime(
408
408
  const composition: PreviewComposition = {
409
409
  ...meta,
410
410
  auth: null as never,
411
+ settings: null as never,
411
412
  router: null as never,
412
413
  routes: Array.from({ length: meta.routes }, () => null as never),
413
414
  request: async (request) => {
@@ -1,5 +1,6 @@
1
1
  import { createServer } from 'node:http';
2
2
  import { createRouter, type ServerRoute } from '@octanejs/app-core';
3
+ import { principalFromContext } from '@flowdular/sdk/modules/auth/server';
3
4
  import { createRemoteDatabaseProvider } from './preview-database-proxy.ts';
4
5
  import { createInProcessPreviewRuntime } from './preview-runtime.ts';
5
6
  import type { SandboxSession } from './sessions.ts';
@@ -89,9 +90,11 @@ async function previewRequest(request: Request): Promise<Response> {
89
90
  url,
90
91
  state: new Map(),
91
92
  };
92
- return composition.auth.middleware(context, async () =>
93
- (match.route as ServerRoute).handler(context),
94
- );
93
+ return composition.auth.middleware(context, async () => {
94
+ const principal = principalFromContext(context);
95
+ if (principal) await composition.settings.prime(principal.tenantId);
96
+ return (match.route as ServerRoute).handler(context);
97
+ });
95
98
  }
96
99
 
97
100
  const server = createServer(async (incoming, outgoing) => {