@flowdular/sandbox 0.3.2 → 0.4.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.
Files changed (71) hide show
  1. package/internal/coding-agent/src/drivers/byok.ts +16 -1
  2. package/internal/coding-agent/src/index.ts +1 -0
  3. package/internal/coding-agent/src/roles/defaults.ts +9 -0
  4. package/internal/coding-agent/src/roles/skills.ts +1 -0
  5. package/internal/coding-agent/src/types.ts +11 -0
  6. package/package.json +2 -2
  7. package/src/App.tsrx +77 -56
  8. package/src/client/DiffPane.tsrx +3 -7
  9. package/src/client/EjectModal.tsrx +7 -9
  10. package/src/client/GitHubSettingsModal.tsrx +8 -8
  11. package/src/client/ModelSettingsModal.tsrx +5 -5
  12. package/src/client/NewSessionForm.tsrx +1 -1
  13. package/src/client/PendingQuestionsCard.tsrx +1 -1
  14. package/src/client/SessionPicker.tsrx +49 -36
  15. package/src/client/SpecEditor.tsrx +1 -1
  16. package/src/client/locales/en.json +30 -16
  17. package/src/client/locales/pl.json +78 -28
  18. package/src/server/delivery/configuration.ts +16 -0
  19. package/src/server/delivery/git-pr.ts +51 -22
  20. package/src/server/delivery/types.ts +2 -0
  21. package/src/server/gates.ts +34 -8
  22. package/src/server/preview-modules.ts +43 -0
  23. package/src/server/preview-revision.ts +1 -0
  24. package/src/server/preview-runtime.ts +41 -4
  25. package/src/server/preview-seed.ts +115 -0
  26. package/src/server/recorded-adapters.ts +132 -0
  27. package/src/server/sample-data.ts +403 -0
  28. package/src/server/turns.ts +16 -1
  29. package/dist/client/assets/App-ClMOrpyg.js +0 -11
  30. package/dist/client/assets/PreviewHost-BHiHrfbQ.js +0 -1
  31. package/dist/client/assets/auto-buGEq6de.js +0 -3
  32. package/dist/client/assets/ibm-plex-mono-cyrillic-400-normal-BSMlKf0J.woff2 +0 -0
  33. package/dist/client/assets/ibm-plex-mono-cyrillic-400-normal-CEL4l2ZJ.woff +0 -0
  34. package/dist/client/assets/ibm-plex-mono-cyrillic-500-normal-Ael50iVv.woff +0 -0
  35. package/dist/client/assets/ibm-plex-mono-cyrillic-500-normal-Bq9vWWag.woff2 +0 -0
  36. package/dist/client/assets/ibm-plex-mono-cyrillic-ext-400-normal-DMdlQ8Kv.woff +0 -0
  37. package/dist/client/assets/ibm-plex-mono-cyrillic-ext-400-normal-xuaO2J-f.woff2 +0 -0
  38. package/dist/client/assets/ibm-plex-mono-cyrillic-ext-500-normal-BIfNGwUT.woff +0 -0
  39. package/dist/client/assets/ibm-plex-mono-cyrillic-ext-500-normal-BqneJy0T.woff2 +0 -0
  40. package/dist/client/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
  41. package/dist/client/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
  42. package/dist/client/assets/ibm-plex-mono-latin-500-normal-CB9ihrfo.woff +0 -0
  43. package/dist/client/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
  44. package/dist/client/assets/ibm-plex-mono-latin-ext-400-normal-BmRBH3aV.woff2 +0 -0
  45. package/dist/client/assets/ibm-plex-mono-latin-ext-400-normal-D3D2R8hC.woff +0 -0
  46. package/dist/client/assets/ibm-plex-mono-latin-ext-500-normal-CAhNIIs5.woff2 +0 -0
  47. package/dist/client/assets/ibm-plex-mono-latin-ext-500-normal-CZ70TYgx.woff +0 -0
  48. package/dist/client/assets/ibm-plex-mono-vietnamese-400-normal-BulugwFq.woff2 +0 -0
  49. package/dist/client/assets/ibm-plex-mono-vietnamese-400-normal-DDuiU_S-.woff +0 -0
  50. package/dist/client/assets/ibm-plex-mono-vietnamese-500-normal-C8zxqsMH.woff +0 -0
  51. package/dist/client/assets/ibm-plex-mono-vietnamese-500-normal-DZ4AoWbu.woff2 +0 -0
  52. package/dist/client/assets/ibm-plex-sans-cyrillic-ext-wght-normal-d45eAU9y.woff2 +0 -0
  53. package/dist/client/assets/ibm-plex-sans-cyrillic-wght-normal-BAAhND-U.woff2 +0 -0
  54. package/dist/client/assets/ibm-plex-sans-greek-wght-normal-CmyJS8uq.woff2 +0 -0
  55. package/dist/client/assets/ibm-plex-sans-latin-ext-wght-normal-CIII54If.woff2 +0 -0
  56. package/dist/client/assets/ibm-plex-sans-latin-wght-normal-IvpUvPa2.woff2 +0 -0
  57. package/dist/client/assets/ibm-plex-sans-vietnamese-wght-normal-Dg1JeJN0.woff2 +0 -0
  58. package/dist/client/assets/index-CvwhlQ87.js +0 -4
  59. package/dist/client/assets/styles-BPWaGf55.js +0 -3
  60. package/dist/client/assets/styles-Dx1_KOag.css +0 -1
  61. package/dist/client/favicon.svg +0 -1
  62. package/dist/server/assets/auto-CVGNYyb3.js +0 -12785
  63. package/dist/server/assets/chunk-DDJLRBDX-3OUNo51g.js +0 -4125
  64. package/dist/server/assets/nodefs-DVSI7kG0.js +0 -24
  65. package/dist/server/assets/opfs-ahp-D56iokt1.js +0 -388
  66. package/dist/server/assets/token-Bvaq7Anz.js +0 -55
  67. package/dist/server/assets/token-util-BqSvdIPQ.js +0 -3
  68. package/dist/server/assets/token-util-CktFFT4z.js +0 -494
  69. package/dist/server/entry.js +0 -91671
  70. package/dist/server/favicon.svg +0 -1
  71. package/dist/server/index.html +0 -161
@@ -2,7 +2,14 @@ import { sandboxDirectory } from './config.ts';
2
2
  import { readFile, realpath } from 'node:fs/promises';
3
3
  import { dirname, join, resolve, sep } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
+ import { parse as parseYaml } from 'yaml';
6
+ import {
7
+ RESEARCH_MODULE_ID,
8
+ assertRecordedAdapters,
9
+ readSessionAdapters,
10
+ } from './recorded-adapters.ts';
5
11
  import type { SandboxSession } from './sessions.ts';
12
+ import { readSpecText } from './spec.ts';
6
13
 
7
14
  export interface PreviewModuleSource {
8
15
  readonly id: string;
@@ -16,6 +23,29 @@ export const PREVIEW_SUPPORT_ROOT = resolve(
16
23
  '../../../../modules',
17
24
  );
18
25
  const IDENTIFIER = /^[a-z][a-z0-9-]*\.core$/;
26
+ export const DOCUMENTS_MODULE_ID = 'documents.core';
27
+
28
+ /* A draft whose spec declares templates previews them through documents.core
29
+ and the same renderer a deployment runs; nothing extra is seeded for it. The
30
+ spec-schema gate stays the authority on the section's shape. */
31
+ async function declaresTemplates(
32
+ modules: Iterable<{ readonly directory: string }>,
33
+ draftRoot: string,
34
+ ): Promise<boolean> {
35
+ for (const module of modules) {
36
+ const text = await readSpecText(join(draftRoot, module.directory));
37
+ if (text === null) continue;
38
+ try {
39
+ const spec = parseYaml(text) as { templates?: unknown } | null;
40
+ if (Array.isArray(spec?.templates) && spec.templates.length > 0) {
41
+ return true;
42
+ }
43
+ } catch {
44
+ continue;
45
+ }
46
+ }
47
+ return false;
48
+ }
19
49
 
20
50
  async function manifest(
21
51
  path: string,
@@ -78,6 +108,19 @@ export async function resolvePreviewModules(
78
108
  visited.add(id);
79
109
  ordered.push({ id, directory, path, support: !draft });
80
110
  };
111
+ /* A session whose spec declares research previews it through research.core on
112
+ recorded fixtures, and never composes one that names a live adapter. */
113
+ const adapters = await readSessionAdapters(
114
+ session.modules.map((module) => ({
115
+ directory: module.directory,
116
+ path: join(draftRoot, module.directory),
117
+ })),
118
+ );
119
+ assertRecordedAdapters(adapters);
120
+ if (adapters.research) await visit(RESEARCH_MODULE_ID);
121
+ if (await declaresTemplates(drafts.values(), draftRoot)) {
122
+ await visit(DOCUMENTS_MODULE_ID);
123
+ }
81
124
  for (const module of session.modules) await visit(module.id);
82
125
  return ordered;
83
126
  }
@@ -39,6 +39,7 @@ export async function previewRevision(
39
39
  await file(join(source.path, 'package.json'), 'package.json');
40
40
  await directory(join(source.path, 'src'), 'src');
41
41
  await directory(join(source.path, 'migrations'), 'migrations');
42
+ await directory(join(source.path, 'preview'), 'preview');
42
43
  }
43
44
  return hash.digest('hex');
44
45
  }
@@ -11,6 +11,7 @@ import {
11
11
  createPlatformAgentRegistry,
12
12
  createPlatformCapabilityRegistry,
13
13
  createPlatformToolRegistry,
14
+ ModuleSettingsError,
14
15
  type ModuleSettingRecord,
15
16
  type ModuleSettingValue,
16
17
  type ModuleSettingsRuntime,
@@ -47,6 +48,12 @@ import {
47
48
  type PreviewModuleSource,
48
49
  } from './preview-modules.ts';
49
50
  import { previewRevision } from './preview-revision.ts';
51
+ import { seedPreviewDrafts } from './preview-seed.ts';
52
+ import {
53
+ LIVE_ADAPTER_REFUSED,
54
+ readSessionAdapters,
55
+ recordedAdapterSettings,
56
+ } from './recorded-adapters.ts';
50
57
 
51
58
  export const PREVIEW_COOKIE = 'coreloom_preview';
52
59
  const PREVIEW_TENANT = 'Preview workspace';
@@ -99,8 +106,12 @@ interface DraftModule {
99
106
 
100
107
  /* Preview settings live in memory for the life of the composition: a draft
101
108
  reads and writes them like the platform would, and nothing outlives the
102
- session. */
103
- function memorySettings(): ModuleSettingsRuntime {
109
+ session. Pinned values hold for every workspace and cannot be changed. */
110
+ export function memorySettings(
111
+ pinned: Readonly<
112
+ Record<string, Readonly<Record<string, ModuleSettingValue>>>
113
+ > = {},
114
+ ): ModuleSettingsRuntime {
104
115
  const records = new Map<string, ModuleSettingRecord>();
105
116
  const keyOf = (tenantId: string, moduleId: string, key: string) =>
106
117
  `${tenantId}\0${moduleId}\0${key}`;
@@ -112,9 +123,17 @@ function memorySettings(): ModuleSettingsRuntime {
112
123
  values[record.key] = record.value;
113
124
  }
114
125
  }
115
- return values;
126
+ return { ...values, ...pinned[moduleId] };
116
127
  },
117
128
  save: async (record) => {
129
+ const fixed = pinned[record.moduleId]?.[record.key];
130
+ if (fixed !== undefined && fixed !== record.value) {
131
+ throw new ModuleSettingsError(
132
+ LIVE_ADAPTER_REFUSED,
133
+ `The sandbox preview holds ${record.moduleId}.${record.key} at its recorded value.`,
134
+ 409,
135
+ );
136
+ }
118
137
  records.set(keyOf(record.tenantId, record.moduleId, record.key), record);
119
138
  },
120
139
  clear: async (tenantId, moduleId, key) => {
@@ -346,6 +365,9 @@ export function createInProcessPreviewRuntime(
346
365
  const revision = await previewRevision(sources);
347
366
  const current = compositions.get(session.id);
348
367
  if (current && current.revision === revision) return current;
368
+ const adapters = await readSessionAdapters(
369
+ sources.filter((source) => !source.support),
370
+ );
349
371
 
350
372
  const { auth, credentials } = current
351
373
  ? { auth: current.auth, credentials: current.credentials }
@@ -370,7 +392,7 @@ export function createInProcessPreviewRuntime(
370
392
  const context: Omit<PlatformServerContext, 'workspaceRoot'> = {
371
393
  environment: process.env,
372
394
  auth,
373
- settings: memorySettings(),
395
+ settings: memorySettings(recordedAdapterSettings(adapters)),
374
396
  agentTools: createPlatformToolRegistry() as PlatformToolRegistry,
375
397
  agentDefinitions,
376
398
  capabilities: createPlatformCapabilityRegistry(),
@@ -442,6 +464,21 @@ export function createInProcessPreviewRuntime(
442
464
  if (!current) auth.dispose();
443
465
  throw error;
444
466
  }
467
+ const tenant = account?.tenants[0];
468
+ if (account && tenant) {
469
+ errors.push(
470
+ ...(await seedPreviewDrafts({
471
+ modules: sources,
472
+ revision,
473
+ dataPath: paths.data,
474
+ context: {
475
+ tenantId: tenant.tenantId,
476
+ accountId: account.accountId,
477
+ databases,
478
+ },
479
+ })),
480
+ );
481
+ }
445
482
  const all = [...routes, ...createAuthRoutes(auth)];
446
483
  const composition: PreviewComposition = {
447
484
  sessionId: session.id,
@@ -0,0 +1,115 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { lstat, mkdir, readFile, writeFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { pathToFileURL } from 'node:url';
5
+ import type { DatabaseProvider } from '@flowdular/sdk/database';
6
+ import type { PreviewModuleSource } from './preview-modules.ts';
7
+
8
+ export const PREVIEW_SEED_FILE = 'preview/seed.json';
9
+ export const PREVIEW_SEED_ENTRY = 'src/preview.ts';
10
+ export const MAX_PREVIEW_SEED_BYTES = 1024 * 1024;
11
+ const SEED_MARKER = 'preview-seeds.json';
12
+
13
+ /* What `seed()` in a draft's src/preview.ts receives: the preview workspace and
14
+ account, the parsed preview/seed.json, and the preview's own database
15
+ provider, the one the module's composition already writes through. */
16
+ export interface PreviewSeedContext {
17
+ readonly tenantId: string;
18
+ readonly accountId: string;
19
+ readonly data: unknown;
20
+ readonly databases: DatabaseProvider;
21
+ }
22
+
23
+ interface PreviewEntry {
24
+ readonly seed?: unknown;
25
+ }
26
+
27
+ async function readRegular(
28
+ modulePath: string,
29
+ file: string,
30
+ ): Promise<Buffer | null> {
31
+ const path = join(modulePath, file);
32
+ const info = await lstat(path).catch((error: NodeJS.ErrnoException) => {
33
+ if (error.code === 'ENOENT') return null;
34
+ throw error;
35
+ });
36
+ if (!info) return null;
37
+ if (!info.isFile() || info.size > MAX_PREVIEW_SEED_BYTES) {
38
+ throw new Error(
39
+ `${file} must be a regular file of at most ${MAX_PREVIEW_SEED_BYTES} bytes.`,
40
+ );
41
+ }
42
+ return readFile(path);
43
+ }
44
+
45
+ async function readMarker(path: string): Promise<Record<string, string>> {
46
+ try {
47
+ const value = JSON.parse(await readFile(path, 'utf8')) as unknown;
48
+ return value && typeof value === 'object' && !Array.isArray(value)
49
+ ? (value as Record<string, string>)
50
+ : {};
51
+ } catch {
52
+ return {};
53
+ }
54
+ }
55
+
56
+ /* Seeds the ephemeral database once per content of preview/seed.json and
57
+ src/preview.ts, after the generation started. A changed file seeds again, so
58
+ seed() must be idempotent. Failures are reported per module and never stop
59
+ the preview. */
60
+ export async function seedPreviewDrafts(input: {
61
+ readonly modules: readonly PreviewModuleSource[];
62
+ readonly revision: string;
63
+ readonly dataPath: string;
64
+ readonly context: Omit<PreviewSeedContext, 'data'>;
65
+ }): Promise<readonly string[]> {
66
+ const markerPath = join(input.dataPath, SEED_MARKER);
67
+ const marker = await readMarker(markerPath);
68
+ const errors: string[] = [];
69
+ for (const module of input.modules) {
70
+ if (module.support) continue;
71
+ try {
72
+ const seedFile = await readRegular(module.path, PREVIEW_SEED_FILE);
73
+ const entryFile = await readRegular(module.path, PREVIEW_SEED_ENTRY);
74
+ if (!seedFile || !entryFile) continue;
75
+ const digest = createHash('sha256')
76
+ .update(seedFile)
77
+ .update('\0')
78
+ .update(entryFile)
79
+ .digest('hex');
80
+ if (marker[module.id] === digest) continue;
81
+ let data: unknown;
82
+ try {
83
+ data = JSON.parse(seedFile.toString('utf8'));
84
+ } catch (error) {
85
+ throw new Error(
86
+ `${PREVIEW_SEED_FILE} is not valid JSON: ${(error as Error).message}`,
87
+ );
88
+ }
89
+ const entry = (await import(
90
+ `${pathToFileURL(join(module.path, PREVIEW_SEED_ENTRY)).href}?revision=${input.revision}`
91
+ )) as PreviewEntry;
92
+ if (typeof entry.seed !== 'function') {
93
+ throw new Error(
94
+ `${PREVIEW_SEED_ENTRY} does not export a seed function, so ${PREVIEW_SEED_FILE} was not loaded.`,
95
+ );
96
+ }
97
+ await entry.seed({ ...input.context, data });
98
+ marker[module.id] = digest;
99
+ await mkdir(input.dataPath, { recursive: true, mode: 0o700 });
100
+ await writeFile(markerPath, JSON.stringify(marker), {
101
+ encoding: 'utf8',
102
+ mode: 0o600,
103
+ });
104
+ } catch (error) {
105
+ errors.push(
106
+ `${module.id}: the preview seed failed: ${
107
+ error instanceof Error
108
+ ? error.message.slice(0, 400)
109
+ : 'seed() threw a non-error value.'
110
+ }`,
111
+ );
112
+ }
113
+ }
114
+ return errors;
115
+ }
@@ -0,0 +1,132 @@
1
+ import { join } from 'node:path';
2
+ import type { ModuleSettingValue } from '@flowdular/sdk/kernel';
3
+ import { parse as parseYaml } from 'yaml';
4
+ import { readSpecText } from './spec.ts';
5
+ import { SandboxSetupError } from './workspace-root.ts';
6
+
7
+ export const RESEARCH_MODULE_ID = 'research.core';
8
+ export const RESEARCH_FIXTURES_FILE = 'research-fixtures.json';
9
+ export const LIVE_ADAPTER_REFUSED = 'SANDBOX_LIVE_ADAPTER_REFUSED';
10
+ const LIVE_RESEARCH_ADAPTERS = new Set([
11
+ 'model-native',
12
+ 'searxng',
13
+ 'firecrawl',
14
+ 'connector',
15
+ ]);
16
+
17
+ export interface DraftModuleLocation {
18
+ readonly directory: string;
19
+ readonly path: string;
20
+ }
21
+
22
+ export interface LiveAdapterDeclaration {
23
+ readonly module: string;
24
+ readonly field: string;
25
+ readonly value: string;
26
+ }
27
+
28
+ export interface SessionAdapters {
29
+ /* The first draft whose spec has a research section; the preview answers
30
+ research from that module's recorded fixtures. */
31
+ readonly research: {
32
+ readonly module: string;
33
+ readonly fixturesPath: string;
34
+ } | null;
35
+ readonly live: readonly LiveAdapterDeclaration[];
36
+ }
37
+
38
+ function objectOf(value: unknown): Record<string, unknown> | null {
39
+ return value && typeof value === 'object' && !Array.isArray(value)
40
+ ? (value as Record<string, unknown>)
41
+ : null;
42
+ }
43
+
44
+ /* Reads the research and adapters sections as plain YAML. The spec-schema gate
45
+ stays the authority on their shape, so a spec this cannot read declares
46
+ nothing here. */
47
+ export async function readSessionAdapters(
48
+ modules: readonly DraftModuleLocation[],
49
+ ): Promise<SessionAdapters> {
50
+ let research: SessionAdapters['research'] = null;
51
+ const live: LiveAdapterDeclaration[] = [];
52
+ for (const module of modules) {
53
+ const text = await readSpecText(module.path);
54
+ if (text === null) continue;
55
+ let spec: Record<string, unknown> | null;
56
+ try {
57
+ spec = objectOf(parseYaml(text));
58
+ } catch {
59
+ continue;
60
+ }
61
+ const section = objectOf(spec?.research);
62
+ if (section) {
63
+ research ??= {
64
+ module: module.directory,
65
+ fixturesPath: join(module.path, RESEARCH_FIXTURES_FILE),
66
+ };
67
+ if (
68
+ typeof section.adapter === 'string' &&
69
+ LIVE_RESEARCH_ADAPTERS.has(section.adapter)
70
+ ) {
71
+ live.push({
72
+ module: module.directory,
73
+ field: 'research.adapter',
74
+ value: section.adapter,
75
+ });
76
+ }
77
+ }
78
+ for (const entry of Array.isArray(spec?.adapters) ? spec.adapters : []) {
79
+ const adapter = objectOf(entry);
80
+ if (
81
+ adapter &&
82
+ (typeof adapter.recorded !== 'string' || !adapter.recorded.trim())
83
+ ) {
84
+ live.push({
85
+ module: module.directory,
86
+ field: `adapters[${String(adapter.id ?? '?').slice(0, 96)}].recorded`,
87
+ value: 'missing, so the adapter could only call its connector',
88
+ });
89
+ }
90
+ }
91
+ }
92
+ return { research, live };
93
+ }
94
+
95
+ export function liveAdapterRefusal(
96
+ live: readonly LiveAdapterDeclaration[],
97
+ ): string | null {
98
+ if (live.length === 0) return null;
99
+ return [
100
+ `${LIVE_ADAPTER_REFUSED}: a sandbox session may declare only recorded adapters. An owner connects a live search or connector instance after delivery.`,
101
+ ...live.map(
102
+ (entry) =>
103
+ `- modules/${entry.module}/spec/module.yaml ${entry.field}: ${entry.value}`,
104
+ ),
105
+ `Use research.adapter recorded with ${RESEARCH_FIXTURES_FILE} in the module, and a recorded fixture adapters/<id>.recorded.json for every adapter.`,
106
+ ].join('\n');
107
+ }
108
+
109
+ export function assertRecordedAdapters(adapters: SessionAdapters): void {
110
+ const refusal = liveAdapterRefusal(adapters.live);
111
+ if (refusal) throw new SandboxSetupError(LIVE_ADAPTER_REFUSED, refusal);
112
+ }
113
+
114
+ /* Setting values the preview holds fixed for every workspace, keyed by module.
115
+ The adapter chain is held at the recorded adapter alone and switched on, and
116
+ a chain holding recorded reads pages from the same fixtures before it looks
117
+ at the fetch order, which stays at the default so Firecrawl is never added. */
118
+ export function recordedAdapterSettings(
119
+ adapters: SessionAdapters,
120
+ ): Readonly<Record<string, Readonly<Record<string, ModuleSettingValue>>>> {
121
+ return adapters.research
122
+ ? {
123
+ [RESEARCH_MODULE_ID]: {
124
+ adapter: 'recorded',
125
+ searchOrder: 'recorded',
126
+ recordedEnabled: true,
127
+ fetchOrder: 'direct',
128
+ recordedFixturesPath: adapters.research.fixturesPath,
129
+ },
130
+ }
131
+ : {};
132
+ }