@flowdular/sandbox 0.2.9 → 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.
- package/README.md +25 -0
- package/internal/coding-agent/src/index.ts +6 -1
- package/internal/coding-agent/src/roles/defaults.ts +5 -1
- package/internal/coding-agent/src/roles/skills.ts +42 -6
- package/internal/coding-agent/src/workspace.ts +4 -0
- package/package.json +2 -2
- package/src/App.tsrx +46 -0
- package/src/client/ChatPane.tsrx +73 -12
- package/src/client/PendingQuestionsCard.tsrx +193 -0
- package/src/client/api.ts +55 -23
- package/src/client/locales/en.json +11 -0
- package/src/client/locales/pl.json +11 -0
- package/src/client/state.ts +21 -0
- package/src/server/preview-runtime.ts +58 -6
- package/src/server/preview-worker-manager.ts +1 -0
- package/src/server/preview-worker.ts +6 -3
- package/src/server/questions.ts +356 -0
- package/src/server/reference.ts +27 -1
- package/src/server/routes.ts +70 -0
- package/src/server/sessions.ts +7 -0
- package/src/server/turns.ts +44 -2
package/src/client/api.ts
CHANGED
|
@@ -701,34 +701,25 @@ async function consumeTurnStream(
|
|
|
701
701
|
});
|
|
702
702
|
}
|
|
703
703
|
|
|
704
|
-
/*
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
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
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
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.",
|
package/src/client/state.ts
CHANGED
|
@@ -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
|
-
|
|
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: () =>
|
|
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
|
-
|
|
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) => {
|