mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
feat(code): cli sandboxes, enterprise timeouts, secrets projections, resolver lift, workflow exec cancellations (#6247)
* feat(code): cli sandboxes, enterprise timeouts, secrets projections, resolver lift * fix(execution): harden compatibility and secret diagnostics * fix(execution): harden generated JavaScript literals * fix(execution): align timeout cleanup semantics * fix(tables): decouple stale job cleanup * fix(execution): drain stale workflow backlog * test(sandbox): make deadline assertions timing-safe * fix(execution): lock cleanup candidate batches * fix(execution): preserve cleanup failure metrics * cancel route fixes * separate out mship template and func template * fix * fix(execution): harden secret projection and block runs * fix(workflow): validate draft execution state * run from block ui disabling * feat(copilot): expose Sim sandboxes to mothership * feat(copilot): expose sandbox capability catalog in VFS * Updates * fix legacy logs showing up * fix(copilot): keep sandbox config visible * fix model provenance issues * fix lint' * more lint * more * test(files): align provenance copy query order * consolidate migrations, rollout compat * integration projections * update skills * fix * add provenance linters * fix: address review and compatibility regressions * fix: make tool boundary audit Bun 1.3 compatible --------- Co-authored-by: Siddharth Ganesan <siddharthganesan@gmail.com>
This commit is contained in:
co-authored by
Siddharth Ganesan
parent
5baa7a41ec
commit
117fe3137b
@@ -0,0 +1,124 @@
|
||||
-- Replay-safety: this file ends in a concurrent index build after an embedded COMMIT, so a
|
||||
-- failure there replays the preceding schema work. Every statement before and after the COMMIT
|
||||
-- is therefore idempotent.
|
||||
-- migration-safe: all application columns are additive; existing durable rows retain NULL as
|
||||
-- their legacy provenance marker, and the new private sidecar tables do not rewrite user data.
|
||||
CREATE TABLE IF NOT EXISTS "document_secret_provenance" (
|
||||
"document_id" text PRIMARY KEY NOT NULL,
|
||||
"source_hash" text NOT NULL,
|
||||
"status" text NOT NULL,
|
||||
"entries" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "document_secret_provenance_status_check" CHECK ("document_secret_provenance"."status" IN ('exact', 'unknown')),
|
||||
CONSTRAINT "document_secret_provenance_document_id_document_id_fk" FOREIGN KEY ("document_id") REFERENCES "public"."document"("id") ON DELETE cascade ON UPDATE no action
|
||||
);--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "embedding_secret_provenance" (
|
||||
"embedding_id" text PRIMARY KEY NOT NULL,
|
||||
"content_hash" text NOT NULL,
|
||||
"status" text NOT NULL,
|
||||
"entries" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "embedding_secret_provenance_status_check" CHECK ("embedding_secret_provenance"."status" IN ('exact', 'unknown')),
|
||||
CONSTRAINT "embedding_secret_provenance_embedding_id_embedding_id_fk" FOREIGN KEY ("embedding_id") REFERENCES "public"."embedding"("id") ON DELETE cascade ON UPDATE no action
|
||||
);--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "memory_secret_provenance" (
|
||||
"memory_id" text PRIMARY KEY NOT NULL,
|
||||
"content_hash" text NOT NULL,
|
||||
"status" text NOT NULL,
|
||||
"entries" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "memory_secret_provenance_status_check" CHECK ("memory_secret_provenance"."status" IN ('exact', 'unknown')),
|
||||
CONSTRAINT "memory_secret_provenance_memory_id_memory_id_fk" FOREIGN KEY ("memory_id") REFERENCES "public"."memory"("id") ON DELETE cascade ON UPDATE no action
|
||||
);--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "user_table_row_secret_provenance" (
|
||||
"row_id" text PRIMARY KEY NOT NULL,
|
||||
"content_updated_at" timestamp NOT NULL,
|
||||
"status" text NOT NULL,
|
||||
"entries" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "user_table_row_secret_provenance_status_check" CHECK ("user_table_row_secret_provenance"."status" IN ('exact', 'unknown')),
|
||||
CONSTRAINT "user_table_row_secret_provenance_row_id_user_table_rows_id_fk" FOREIGN KEY ("row_id") REFERENCES "public"."user_table_rows"("id") ON DELETE cascade ON UPDATE no action
|
||||
);--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "workspace_file_secret_provenance" (
|
||||
"file_id" text PRIMARY KEY NOT NULL,
|
||||
"content_updated_at" timestamp NOT NULL,
|
||||
"status" text NOT NULL,
|
||||
"entries" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "workspace_file_secret_provenance_status_check" CHECK ("workspace_file_secret_provenance"."status" IN ('exact', 'unknown')),
|
||||
CONSTRAINT "workspace_file_secret_provenance_file_id_workspace_files_id_fk" FOREIGN KEY ("file_id") REFERENCES "public"."workspace_files"("id") ON DELETE cascade ON UPDATE no action
|
||||
);--> statement-breakpoint
|
||||
ALTER TABLE "document" ADD COLUMN IF NOT EXISTS "secret_provenance_version" integer;--> statement-breakpoint
|
||||
ALTER TABLE "embedding" ADD COLUMN IF NOT EXISTS "secret_provenance_version" integer;--> statement-breakpoint
|
||||
ALTER TABLE "memory" ADD COLUMN IF NOT EXISTS "secret_provenance_version" integer;--> statement-breakpoint
|
||||
ALTER TABLE "sandbox_image" ADD COLUMN IF NOT EXISTS "materialization_generation" bigint;--> statement-breakpoint
|
||||
ALTER TABLE "user_table_rows" ADD COLUMN IF NOT EXISTS "secret_provenance_version" integer;--> statement-breakpoint
|
||||
ALTER TABLE "workflow_execution_logs" ADD COLUMN IF NOT EXISTS "execution_deadline_at" timestamp;--> statement-breakpoint
|
||||
ALTER TABLE "workspace_files" ADD COLUMN IF NOT EXISTS "secret_provenance_version" integer;--> statement-breakpoint
|
||||
ALTER TABLE "workspace_sandbox" ADD COLUMN IF NOT EXISTS "cli_tools" jsonb DEFAULT '[]'::jsonb NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "workspace_sandbox" ADD COLUMN IF NOT EXISTS "system_packages" jsonb DEFAULT '[]'::jsonb NOT NULL;--> statement-breakpoint
|
||||
-- During a rolling deploy, a legacy application process can still mutate durable content without
|
||||
-- updating the new sidecar. Demoting the marker makes the new application use the established
|
||||
-- legacy-read behavior instead of trusting a stale sidecar. Provenance-aware writers restore
|
||||
-- version 1 only after writing the matching sidecar in the same transaction.
|
||||
CREATE OR REPLACE FUNCTION "demote_secret_provenance_version"()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
NEW."secret_provenance_version" := NULL;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;--> statement-breakpoint
|
||||
CREATE OR REPLACE FUNCTION "demote_user_table_row_secret_provenance"()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
NEW."secret_provenance_version" := NULL;
|
||||
NEW."updated_at" := GREATEST(
|
||||
date_trunc('milliseconds', NEW."updated_at"),
|
||||
date_trunc('milliseconds', OLD."updated_at") + interval '1 millisecond'
|
||||
);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;--> statement-breakpoint
|
||||
DROP TRIGGER IF EXISTS "document_secret_provenance_demote" ON "document";--> statement-breakpoint
|
||||
CREATE TRIGGER "document_secret_provenance_demote"
|
||||
BEFORE UPDATE OF "filename", "file_url", "content_hash", "source_url", "tag1", "tag2", "tag3", "tag4", "tag5", "tag6", "tag7", "number1", "number2", "number3", "number4", "number5", "date1", "date2", "boolean1", "boolean2", "boolean3" ON "document"
|
||||
FOR EACH ROW
|
||||
WHEN (ROW(OLD."filename", OLD."file_url", OLD."content_hash", OLD."source_url", OLD."tag1", OLD."tag2", OLD."tag3", OLD."tag4", OLD."tag5", OLD."tag6", OLD."tag7", OLD."number1", OLD."number2", OLD."number3", OLD."number4", OLD."number5", OLD."date1", OLD."date2", OLD."boolean1", OLD."boolean2", OLD."boolean3") IS DISTINCT FROM ROW(NEW."filename", NEW."file_url", NEW."content_hash", NEW."source_url", NEW."tag1", NEW."tag2", NEW."tag3", NEW."tag4", NEW."tag5", NEW."tag6", NEW."tag7", NEW."number1", NEW."number2", NEW."number3", NEW."number4", NEW."number5", NEW."date1", NEW."date2", NEW."boolean1", NEW."boolean2", NEW."boolean3"))
|
||||
EXECUTE FUNCTION "demote_secret_provenance_version"();--> statement-breakpoint
|
||||
DROP TRIGGER IF EXISTS "embedding_secret_provenance_demote" ON "embedding";--> statement-breakpoint
|
||||
CREATE TRIGGER "embedding_secret_provenance_demote"
|
||||
BEFORE UPDATE OF "content", "chunk_hash" ON "embedding"
|
||||
FOR EACH ROW
|
||||
WHEN (ROW(OLD."content", OLD."chunk_hash") IS DISTINCT FROM ROW(NEW."content", NEW."chunk_hash"))
|
||||
EXECUTE FUNCTION "demote_secret_provenance_version"();--> statement-breakpoint
|
||||
DROP TRIGGER IF EXISTS "memory_secret_provenance_demote" ON "memory";--> statement-breakpoint
|
||||
CREATE TRIGGER "memory_secret_provenance_demote"
|
||||
BEFORE UPDATE OF "data" ON "memory"
|
||||
FOR EACH ROW
|
||||
WHEN (OLD."data" IS DISTINCT FROM NEW."data")
|
||||
EXECUTE FUNCTION "demote_secret_provenance_version"();--> statement-breakpoint
|
||||
DROP TRIGGER IF EXISTS "user_table_rows_secret_provenance_demote" ON "user_table_rows";--> statement-breakpoint
|
||||
CREATE TRIGGER "user_table_rows_secret_provenance_demote"
|
||||
BEFORE UPDATE OF "data" ON "user_table_rows"
|
||||
FOR EACH ROW
|
||||
WHEN (OLD."data" IS DISTINCT FROM NEW."data")
|
||||
EXECUTE FUNCTION "demote_user_table_row_secret_provenance"();--> statement-breakpoint
|
||||
DROP TRIGGER IF EXISTS "workspace_files_secret_provenance_demote" ON "workspace_files";--> statement-breakpoint
|
||||
CREATE TRIGGER "workspace_files_secret_provenance_demote"
|
||||
BEFORE UPDATE OF "content_updated_at" ON "workspace_files"
|
||||
FOR EACH ROW
|
||||
WHEN (OLD."content_updated_at" IS DISTINCT FROM NEW."content_updated_at")
|
||||
EXECUTE FUNCTION "demote_secret_provenance_version"();--> statement-breakpoint
|
||||
|
||||
-- The execution-log table is live and can be large. End the migration transaction before the
|
||||
-- index build so writes remain available. A failed build leaves this migration unjournaled; the
|
||||
-- replay-safe drop removes any INVALID same-name index before rebuilding it.
|
||||
COMMIT;--> statement-breakpoint
|
||||
SET lock_timeout = 0;--> statement-breakpoint
|
||||
DROP INDEX CONCURRENTLY IF EXISTS "workflow_execution_logs_running_deadline_idx";--> statement-breakpoint
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS "workflow_execution_logs_running_deadline_idx" ON "workflow_execution_logs" USING btree ("execution_deadline_at") WHERE "workflow_execution_logs"."status" = 'running' AND "workflow_execution_logs"."execution_deadline_at" IS NOT NULL;--> statement-breakpoint
|
||||
SET lock_timeout = '5s';
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1975,6 +1975,13 @@
|
||||
"when": 1785968181949,
|
||||
"tag": "0282_chubby_psylocke",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 283,
|
||||
"version": "7",
|
||||
"when": 1785978020563,
|
||||
"tag": "0283_military_fabian_cortez",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -412,6 +412,8 @@ export const workflowExecutionLogs = pgTable(
|
||||
trigger: text('trigger').notNull(), // 'api' | 'webhook' | 'schedule' | 'manual' | 'chat'
|
||||
|
||||
startedAt: timestamp('started_at').notNull(),
|
||||
/** Absolute deadline for the current active attempt; cleared while paused or terminal. */
|
||||
executionDeadlineAt: timestamp('execution_deadline_at'),
|
||||
endedAt: timestamp('ended_at'),
|
||||
totalDurationMs: integer('total_duration_ms'),
|
||||
|
||||
@@ -475,6 +477,9 @@ export const workflowExecutionLogs = pgTable(
|
||||
runningStartedAtIdx: index('workflow_execution_logs_running_started_at_idx')
|
||||
.on(table.startedAt)
|
||||
.where(sql`status = 'running'`),
|
||||
runningExecutionDeadlineIdx: index('workflow_execution_logs_running_deadline_idx')
|
||||
.on(table.executionDeadlineAt)
|
||||
.where(sql`${table.status} = 'running' AND ${table.executionDeadlineAt} IS NOT NULL`),
|
||||
completedEndedAtIdx: index('workflow_execution_logs_completed_ended_at_idx')
|
||||
.on(table.endedAt, table.workspaceId, table.executionId)
|
||||
.where(
|
||||
@@ -1936,6 +1941,13 @@ export const workspaceFiles = pgTable(
|
||||
* is covered without per-call plumbing. Only a content write (upload / overwrite) advances it.
|
||||
*/
|
||||
contentUpdatedAt: timestamp('content_updated_at').notNull().defaultNow(),
|
||||
/**
|
||||
* Durable cutover marker for content secret provenance. NULL is reserved for legacy rows and
|
||||
* writes from app versions that predate tracking. Provenance-aware writers set version 1 in the
|
||||
* same transaction as the matching sidecar. A tracked version without a matching sidecar fails
|
||||
* closed.
|
||||
*/
|
||||
secretProvenanceVersion: integer('secret_provenance_version'),
|
||||
},
|
||||
(table) => ({
|
||||
keyActiveUniqueIdx: uniqueIndex('workspace_files_key_active_unique')
|
||||
@@ -1972,6 +1984,51 @@ export const workspaceFiles = pgTable(
|
||||
})
|
||||
)
|
||||
|
||||
export interface WorkspaceFileSecretProvenanceEntry extends DurableSecretProvenanceEntry {
|
||||
name: string
|
||||
sourceUserId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Private, durable provenance for bytes stored in `workspace_files`.
|
||||
*
|
||||
* Absence is reserved for legacy files that predate provenance tracking. `exact` records carry the
|
||||
* encrypted values found in one content version (including an empty set); `unknown` records fail
|
||||
* closed at model attachment boundaries. Keeping this one-to-one state outside `workspace_files`
|
||||
* prevents private metadata from leaking through broad workspace-file record projections.
|
||||
*/
|
||||
export const workspaceFileSecretProvenance = pgTable(
|
||||
'workspace_file_secret_provenance',
|
||||
{
|
||||
fileId: text('file_id')
|
||||
.primaryKey()
|
||||
.references(() => workspaceFiles.id, { onDelete: 'cascade' }),
|
||||
contentUpdatedAt: timestamp('content_updated_at').notNull(),
|
||||
status: text('status').notNull(),
|
||||
entries: jsonb('entries').$type<WorkspaceFileSecretProvenanceEntry[]>().notNull().default([]),
|
||||
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
statusCheck: check(
|
||||
'workspace_file_secret_provenance_status_check',
|
||||
sql`${table.status} IN ('exact', 'unknown')`
|
||||
),
|
||||
})
|
||||
)
|
||||
|
||||
export interface DurableSecretProvenanceEntry {
|
||||
encryptedValue: string
|
||||
name?: string
|
||||
sourceUserId?: string
|
||||
sourceWorkspaceId?: string
|
||||
/** Optional canonical hash of the exact persisted sub-value that contributed this entry. */
|
||||
sourceValueHash?: string
|
||||
}
|
||||
|
||||
export interface TableRowSecretProvenanceEntry extends DurableSecretProvenanceEntry {
|
||||
columnId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Cached collaborative-document state for a workspace markdown file: the last-persisted Yjs binary and
|
||||
* a hash of the markdown it was derived from. On a cold room open the seed loads this binary directly
|
||||
@@ -2122,6 +2179,8 @@ export const memory = pgTable(
|
||||
.references(() => workspace.id, { onDelete: 'cascade' }),
|
||||
key: text('key').notNull(),
|
||||
data: jsonb('data').notNull(),
|
||||
/** NULL is a legacy/untracked record; version 1 requires a fresh private sidecar. */
|
||||
secretProvenanceVersion: integer('secret_provenance_version'),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
||||
deletedAt: timestamp('deleted_at'),
|
||||
@@ -2141,6 +2200,26 @@ export const memory = pgTable(
|
||||
}
|
||||
)
|
||||
|
||||
/** Private provenance bound to one exact canonical hash of the persisted memory data. */
|
||||
export const memorySecretProvenance = pgTable(
|
||||
'memory_secret_provenance',
|
||||
{
|
||||
memoryId: text('memory_id')
|
||||
.primaryKey()
|
||||
.references(() => memory.id, { onDelete: 'cascade' }),
|
||||
contentHash: text('content_hash').notNull(),
|
||||
status: text('status').notNull(),
|
||||
entries: jsonb('entries').$type<DurableSecretProvenanceEntry[]>().notNull().default([]),
|
||||
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
statusCheck: check(
|
||||
'memory_secret_provenance_status_check',
|
||||
sql`${table.status} IN ('exact', 'unknown')`
|
||||
),
|
||||
})
|
||||
)
|
||||
|
||||
export const knowledgeBase = pgTable(
|
||||
'knowledge_base',
|
||||
{
|
||||
@@ -2256,6 +2335,8 @@ export const document = pgTable(
|
||||
externalId: text('external_id'),
|
||||
contentHash: text('content_hash'),
|
||||
sourceUrl: text('source_url'),
|
||||
/** NULL is a legacy/untracked source; version 1 requires a matching source sidecar. */
|
||||
secretProvenanceVersion: integer('secret_provenance_version'),
|
||||
|
||||
/** User who uploaded the document, for usage attribution. Null for
|
||||
* connector/cron-synced docs (and pre-migration rows) → indexing billing
|
||||
@@ -2315,6 +2396,26 @@ export const document = pgTable(
|
||||
})
|
||||
)
|
||||
|
||||
/** Private provenance for a document ingestion source, bound by a deterministic source hash. */
|
||||
export const documentSecretProvenance = pgTable(
|
||||
'document_secret_provenance',
|
||||
{
|
||||
documentId: text('document_id')
|
||||
.primaryKey()
|
||||
.references(() => document.id, { onDelete: 'cascade' }),
|
||||
sourceHash: text('source_hash').notNull(),
|
||||
status: text('status').notNull(),
|
||||
entries: jsonb('entries').$type<DurableSecretProvenanceEntry[]>().notNull().default([]),
|
||||
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
statusCheck: check(
|
||||
'document_secret_provenance_status_check',
|
||||
sql`${table.status} IN ('exact', 'unknown')`
|
||||
),
|
||||
})
|
||||
)
|
||||
|
||||
export const knowledgeBaseTagDefinitions = pgTable(
|
||||
'knowledge_base_tag_definitions',
|
||||
{
|
||||
@@ -2361,6 +2462,8 @@ export const embedding = pgTable(
|
||||
chunkIndex: integer('chunk_index').notNull(),
|
||||
chunkHash: text('chunk_hash').notNull(),
|
||||
content: text('content').notNull(),
|
||||
/** NULL is a legacy/untracked chunk; version 1 requires a fresh private sidecar. */
|
||||
secretProvenanceVersion: integer('secret_provenance_version'),
|
||||
contentLength: integer('content_length').notNull(),
|
||||
tokenCount: integer('token_count').notNull(),
|
||||
|
||||
@@ -2462,6 +2565,26 @@ export const embedding = pgTable(
|
||||
})
|
||||
)
|
||||
|
||||
/** Private provenance bound to one exact SHA-256 hash of the persisted chunk content. */
|
||||
export const embeddingSecretProvenance = pgTable(
|
||||
'embedding_secret_provenance',
|
||||
{
|
||||
embeddingId: text('embedding_id')
|
||||
.primaryKey()
|
||||
.references(() => embedding.id, { onDelete: 'cascade' }),
|
||||
contentHash: text('content_hash').notNull(),
|
||||
status: text('status').notNull(),
|
||||
entries: jsonb('entries').$type<DurableSecretProvenanceEntry[]>().notNull().default([]),
|
||||
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
statusCheck: check(
|
||||
'embedding_secret_provenance_status_check',
|
||||
sql`${table.status} IN ('exact', 'unknown')`
|
||||
),
|
||||
})
|
||||
)
|
||||
|
||||
export const docsEmbeddings = pgTable(
|
||||
'docs_embeddings',
|
||||
{
|
||||
@@ -3894,6 +4017,7 @@ export const userTableRows = pgTable(
|
||||
* express column collation, so the collation lives only in the migration.
|
||||
*/
|
||||
orderKey: text('order_key'),
|
||||
secretProvenanceVersion: integer('secret_provenance_version'),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
||||
createdBy: text('created_by').references(() => user.id, { onDelete: 'set null' }),
|
||||
@@ -3932,6 +4056,30 @@ export const userTableRows = pgTable(
|
||||
})
|
||||
)
|
||||
|
||||
/**
|
||||
* Encrypted secret provenance for a table row's current JSONB payload.
|
||||
* The sidecar is bound to `user_table_rows.updated_at`; a missing or stale
|
||||
* sidecar on a tracked row is treated as unknown at model re-entry.
|
||||
*/
|
||||
export const userTableRowSecretProvenance = pgTable(
|
||||
'user_table_row_secret_provenance',
|
||||
{
|
||||
rowId: text('row_id')
|
||||
.primaryKey()
|
||||
.references(() => userTableRows.id, { onDelete: 'cascade' }),
|
||||
contentUpdatedAt: timestamp('content_updated_at').notNull(),
|
||||
status: text('status').notNull(),
|
||||
entries: jsonb('entries').$type<TableRowSecretProvenanceEntry[]>().notNull().default([]),
|
||||
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
statusCheck: check(
|
||||
'user_table_row_secret_provenance_status_check',
|
||||
sql`${table.status} IN ('exact', 'unknown')`
|
||||
),
|
||||
})
|
||||
)
|
||||
|
||||
/**
|
||||
* Saved presets for a user-defined table — a named filter + sort + column layout.
|
||||
* Workspace-shared: anyone who can read the table sees every view, and `write` is
|
||||
@@ -4364,6 +4512,8 @@ export const workspaceSandbox = pgTable(
|
||||
name: text('name').notNull(),
|
||||
language: sandboxLanguageEnum('language').notNull(),
|
||||
dependencies: jsonb('dependencies').$type<string[]>().notNull().default(sql`'[]'::jsonb`),
|
||||
cliTools: jsonb('cli_tools').$type<string[]>().notNull().default(sql`'[]'::jsonb`),
|
||||
systemPackages: jsonb('system_packages').$type<string[]>().notNull().default(sql`'[]'::jsonb`),
|
||||
specHash: text('spec_hash').notNull(),
|
||||
createdBy: text('created_by').references(() => user.id, { onDelete: 'set null' }),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
@@ -4397,6 +4547,8 @@ export const sandboxImage = pgTable(
|
||||
/** Provider-side image identifier, when it differs from `imageRef`. */
|
||||
providerImageId: text('provider_image_id'),
|
||||
buildId: text('build_id'),
|
||||
/** Monotonic target release for this provider materialization; legacy rows are generation 0. */
|
||||
materializationGeneration: bigint('materialization_generation', { mode: 'number' }),
|
||||
/** Classified taxonomy code; see lib/execution/remote-sandbox/build-errors.ts. */
|
||||
errorCode: text('error_code'),
|
||||
/** User-facing copy rendered from the code at classification time. */
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { type ChipSelectOption, chipSelectOptionMatchesSearch } from './chip-select'
|
||||
|
||||
const GOOGLE_CLOUD_OPTION: ChipSelectOption = {
|
||||
label: 'Google Cloud CLI',
|
||||
value: 'google-cloud-cli',
|
||||
searchTerms: ['gcloud', 'bq', 'gsutil'],
|
||||
}
|
||||
|
||||
describe('chipSelectOptionMatchesSearch', () => {
|
||||
it('matches labels with normalized casing and whitespace', () => {
|
||||
expect(chipSelectOptionMatchesSearch(GOOGLE_CLOUD_OPTION, ' CLOUD ')).toBe(true)
|
||||
})
|
||||
|
||||
it.each(['gcloud', 'bq', 'gsu'])('matches the search-only alias %s', (query) => {
|
||||
expect(chipSelectOptionMatchesSearch(GOOGLE_CLOUD_OPTION, query)).toBe(true)
|
||||
})
|
||||
|
||||
it('normalizes whitespace and casing in search-only aliases', () => {
|
||||
expect(
|
||||
chipSelectOptionMatchesSearch(
|
||||
{ label: 'Google Cloud CLI', value: 'google', searchTerms: [' BIGQUERY '] },
|
||||
'bigquery'
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('does not expose unrelated options through another option alias', () => {
|
||||
expect(
|
||||
chipSelectOptionMatchesSearch(
|
||||
{ label: 'GitHub CLI', value: 'github', searchTerms: ['gh'] },
|
||||
'bq'
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('treats an empty normalized query as an unfiltered option list', () => {
|
||||
expect(chipSelectOptionMatchesSearch(GOOGLE_CLOUD_OPTION, ' ')).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
export interface ChipSelectOption {
|
||||
label: string
|
||||
value: string
|
||||
/** Additional search-only terms. These are never rendered in the option label. */
|
||||
searchTerms?: readonly string[]
|
||||
/** Optional leading icon. */
|
||||
icon?: React.ComponentType<{ className?: string }>
|
||||
/** Whether this option is non-selectable. */
|
||||
@@ -71,6 +73,12 @@ export interface ChipSelectProps {
|
||||
dropdownWidth?: 'trigger' | number
|
||||
/** Max height of the menu in px (defaults to the menu's 240px). */
|
||||
maxHeight?: number
|
||||
/**
|
||||
* Keep the menu below its trigger and shrink it to the remaining viewport
|
||||
* height instead of allowing collision handling to flip it above. Use this
|
||||
* for long form-field menus that would otherwise obscure preceding fields.
|
||||
*/
|
||||
stayBelow?: boolean
|
||||
/** Forwarded to the trigger button. */
|
||||
className?: string
|
||||
/** Forwarded to the menu content. */
|
||||
@@ -87,6 +95,15 @@ export interface ChipSelectProps {
|
||||
modal?: boolean
|
||||
}
|
||||
|
||||
/** Matches an option label or one of its search-only aliases. */
|
||||
export function chipSelectOptionMatchesSearch(option: ChipSelectOption, query: string): boolean {
|
||||
const normalizedQuery = query.trim().toLowerCase()
|
||||
if (!normalizedQuery) return true
|
||||
return [option.label, ...(option.searchTerms ?? [])].some((term) =>
|
||||
term.trim().toLowerCase().includes(normalizedQuery)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The platform filter dropdown: a `filled` chip trigger with a trailing
|
||||
* chevron that opens a `DropdownMenu`. This is the same pattern the
|
||||
@@ -126,6 +143,7 @@ export function ChipSelect({
|
||||
fullWidth = false,
|
||||
dropdownWidth,
|
||||
maxHeight,
|
||||
stayBelow = false,
|
||||
className,
|
||||
contentClassName,
|
||||
'aria-label': ariaLabel,
|
||||
@@ -157,9 +175,8 @@ export function ChipSelect({
|
||||
|
||||
const filteredSections = React.useMemo(() => {
|
||||
if (!searchable || !query.trim()) return sections
|
||||
const q = query.toLowerCase()
|
||||
return sections
|
||||
.map((g) => ({ ...g, items: g.items.filter((o) => o.label.toLowerCase().includes(q)) }))
|
||||
.map((g) => ({ ...g, items: g.items.filter((o) => chipSelectOptionMatchesSearch(o, query)) }))
|
||||
.filter((g) => g.items.length > 0)
|
||||
}, [searchable, query, sections])
|
||||
|
||||
@@ -182,7 +199,12 @@ export function ChipSelect({
|
||||
if (dropdownWidth === 'trigger') contentStyle.width = 'var(--radix-dropdown-menu-trigger-width)'
|
||||
else if (typeof dropdownWidth === 'number') contentStyle.width = dropdownWidth
|
||||
if (dropdownWidth != null) contentStyle.maxWidth = 'none'
|
||||
if (typeof maxHeight === 'number') contentStyle.maxHeight = maxHeight
|
||||
if (stayBelow) {
|
||||
const preferredMaxHeight = typeof maxHeight === 'number' ? `${maxHeight}px` : '240px'
|
||||
contentStyle.maxHeight = `min(${preferredMaxHeight}, var(--radix-dropdown-menu-content-available-height))`
|
||||
} else if (typeof maxHeight === 'number') {
|
||||
contentStyle.maxHeight = maxHeight
|
||||
}
|
||||
|
||||
const renderOption = (opt: ChipSelectOption) => {
|
||||
const Icon = opt.icon
|
||||
@@ -246,6 +268,8 @@ export function ChipSelect({
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align={align}
|
||||
side={stayBelow ? 'bottom' : undefined}
|
||||
avoidCollisions={stayBelow ? false : undefined}
|
||||
onOpenAutoFocus={searchable ? (e) => e.preventDefault() : undefined}
|
||||
style={contentStyle}
|
||||
className={cn('min-w-[160px]', contentClassName)}
|
||||
|
||||
@@ -24,7 +24,7 @@ type PrismModule = typeof import('./prism')
|
||||
/**
|
||||
* Module-level singleton promise for the lazily-loaded Prism module.
|
||||
*
|
||||
* Prism (core + the side-effectful JS/Python/JSON grammar registrations) is kept
|
||||
* Prism (core + the side-effectful JS/Python/JSON/Bash grammar registrations) is kept
|
||||
* out of this module's static import graph so it never lands in bundles that only
|
||||
* pull `Code` through the shared `@sim/emcn` barrel. It is loaded once per
|
||||
* session on the first highlight and cached here for all subsequent viewers.
|
||||
@@ -802,7 +802,7 @@ interface CodeViewerProps {
|
||||
/** Whether to show line numbers gutter */
|
||||
showGutter?: boolean
|
||||
/** Language for syntax highlighting (default: 'json') */
|
||||
language?: 'javascript' | 'json' | 'python'
|
||||
language?: 'javascript' | 'json' | 'python' | 'bash'
|
||||
/** Additional CSS classes for the container */
|
||||
className?: string
|
||||
/** Left padding offset (useful for terminal alignment) */
|
||||
@@ -888,7 +888,7 @@ type ViewerInnerProps = {
|
||||
/** Whether to show line numbers gutter */
|
||||
showGutter: boolean
|
||||
/** Language for syntax highlighting */
|
||||
language: 'javascript' | 'json' | 'python'
|
||||
language: 'javascript' | 'json' | 'python' | 'bash'
|
||||
/** Additional CSS classes for the container */
|
||||
className?: string
|
||||
/** Left padding offset in pixels */
|
||||
|
||||
@@ -2,6 +2,7 @@ import { type Grammar, languages, highlight as prismHighlight } from 'prismjs'
|
||||
import 'prismjs/components/prism-javascript'
|
||||
import 'prismjs/components/prism-python'
|
||||
import 'prismjs/components/prism-json'
|
||||
import 'prismjs/components/prism-bash'
|
||||
|
||||
/**
|
||||
* Prism.js highlighting utilities isolated in a dedicated module.
|
||||
|
||||
@@ -43,7 +43,7 @@ SimStudioClient(api_key: str, base_url: str = "https://sim.ai")
|
||||
|
||||
#### Methods
|
||||
|
||||
##### execute_workflow(workflow_id, input=None, *, timeout=30.0, stream=None, selected_outputs=None, async_execution=None)
|
||||
##### execute_workflow(workflow_id, input=None, *, timeout=30.0, stream=None, selected_outputs=None, async_execution=None, execution_timeout_seconds=None)
|
||||
|
||||
Execute a workflow with optional input data.
|
||||
|
||||
@@ -55,7 +55,13 @@ result = client.execute_workflow("workflow-id", {"message": "Hello, world!"})
|
||||
result = client.execute_workflow("workflow-id", "NVDA")
|
||||
|
||||
# With options (keyword-only arguments)
|
||||
result = client.execute_workflow("workflow-id", {"message": "Hello"}, timeout=60.0)
|
||||
result = client.execute_workflow(
|
||||
"workflow-id",
|
||||
{"message": "Hello"},
|
||||
timeout=60.0,
|
||||
async_execution=True,
|
||||
execution_timeout_seconds=3600,
|
||||
)
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
@@ -65,6 +71,7 @@ result = client.execute_workflow("workflow-id", {"message": "Hello"}, timeout=60
|
||||
- `stream` (bool, keyword-only): Enable streaming responses
|
||||
- `selected_outputs` (list, keyword-only): Block outputs to stream (e.g., `["agent1.content"]`)
|
||||
- `async_execution` (bool, keyword-only): Execute asynchronously and return execution ID
|
||||
- `execution_timeout_seconds` (int, keyword-only): Server-side async execution cap from 1 to 604800 seconds. Requires `async_execution=True` and cannot extend the account policy.
|
||||
|
||||
**Returns:** `WorkflowExecutionResult` or `AsyncExecutionResult`
|
||||
|
||||
@@ -527,4 +534,4 @@ isort simstudio/
|
||||
|
||||
## License
|
||||
|
||||
Apache-2.0
|
||||
Apache-2.0
|
||||
|
||||
@@ -12,6 +12,7 @@ import os
|
||||
|
||||
import requests
|
||||
|
||||
MAX_EXECUTION_TIMEOUT_SECONDS = 604_800
|
||||
|
||||
__version__ = "0.1.2"
|
||||
__all__ = [
|
||||
@@ -155,7 +156,8 @@ class SimStudioClient:
|
||||
timeout: float = 30.0,
|
||||
stream: Optional[bool] = None,
|
||||
selected_outputs: Optional[list] = None,
|
||||
async_execution: Optional[bool] = None
|
||||
async_execution: Optional[bool] = None,
|
||||
execution_timeout_seconds: Optional[int] = None
|
||||
) -> Union[WorkflowExecutionResult, AsyncExecutionResult]:
|
||||
"""
|
||||
Execute a workflow with optional input data.
|
||||
@@ -172,6 +174,7 @@ class SimStudioClient:
|
||||
stream: Enable streaming responses (default: None)
|
||||
selected_outputs: Block outputs to stream (e.g., ["agent1.content"])
|
||||
async_execution: Execute asynchronously (default: None)
|
||||
execution_timeout_seconds: Server-side async execution cap in seconds (1-604800)
|
||||
|
||||
Returns:
|
||||
WorkflowExecutionResult or AsyncExecutionResult object
|
||||
@@ -181,10 +184,29 @@ class SimStudioClient:
|
||||
"""
|
||||
url = f"{self.base_url}/api/workflows/{workflow_id}/execute"
|
||||
|
||||
if execution_timeout_seconds is not None:
|
||||
if not async_execution:
|
||||
raise SimStudioError(
|
||||
'execution_timeout_seconds is supported only for async executions',
|
||||
'INVALID_EXECUTION_TIMEOUT'
|
||||
)
|
||||
if (
|
||||
isinstance(execution_timeout_seconds, bool)
|
||||
or not isinstance(execution_timeout_seconds, int)
|
||||
or execution_timeout_seconds < 1
|
||||
or execution_timeout_seconds > MAX_EXECUTION_TIMEOUT_SECONDS
|
||||
):
|
||||
raise SimStudioError(
|
||||
f'execution_timeout_seconds must be an integer between 1 and {MAX_EXECUTION_TIMEOUT_SECONDS}',
|
||||
'INVALID_EXECUTION_TIMEOUT'
|
||||
)
|
||||
|
||||
# Build headers - async execution uses X-Execution-Mode header
|
||||
headers = self._session.headers.copy()
|
||||
if async_execution:
|
||||
headers['X-Execution-Mode'] = 'async'
|
||||
if execution_timeout_seconds is not None:
|
||||
headers['X-Execution-Timeout-Seconds'] = str(execution_timeout_seconds)
|
||||
|
||||
try:
|
||||
# Build JSON body - spread dict inputs at root level, wrap primitives/lists in 'input' field
|
||||
@@ -421,6 +443,7 @@ class SimStudioClient:
|
||||
stream: Optional[bool] = None,
|
||||
selected_outputs: Optional[list] = None,
|
||||
async_execution: Optional[bool] = None,
|
||||
execution_timeout_seconds: Optional[int] = None,
|
||||
max_retries: int = 3,
|
||||
initial_delay: float = 1.0,
|
||||
max_delay: float = 30.0,
|
||||
@@ -436,6 +459,7 @@ class SimStudioClient:
|
||||
stream: Enable streaming responses
|
||||
selected_outputs: Block outputs to stream
|
||||
async_execution: Execute asynchronously
|
||||
execution_timeout_seconds: Server-side async execution cap in seconds (1-604800)
|
||||
max_retries: Maximum number of retries (default: 3)
|
||||
initial_delay: Initial delay in seconds (default: 1.0)
|
||||
max_delay: Maximum delay in seconds (default: 30.0)
|
||||
@@ -458,7 +482,8 @@ class SimStudioClient:
|
||||
timeout=timeout,
|
||||
stream=stream,
|
||||
selected_outputs=selected_outputs,
|
||||
async_execution=async_execution
|
||||
async_execution=async_execution,
|
||||
execution_timeout_seconds=execution_timeout_seconds,
|
||||
)
|
||||
except SimStudioError as e:
|
||||
if e.code != 'RATE_LIMIT_EXCEEDED':
|
||||
@@ -565,4 +590,4 @@ class SimStudioClient:
|
||||
|
||||
|
||||
# For backward compatibility
|
||||
Client = SimStudioClient
|
||||
Client = SimStudioClient
|
||||
|
||||
@@ -171,6 +171,79 @@ def test_async_header_not_set_when_false(mock_post):
|
||||
assert "X-Execution-Mode" not in call_args[1]["headers"]
|
||||
|
||||
|
||||
@patch('simstudio.requests.Session.post')
|
||||
def test_async_execution_timeout_header(mock_post):
|
||||
mock_response = Mock()
|
||||
mock_response.ok = True
|
||||
mock_response.status_code = 202
|
||||
mock_response.json.return_value = {
|
||||
"success": True,
|
||||
"jobId": "job-123",
|
||||
"statusUrl": "/api/jobs/job-123",
|
||||
"async": True,
|
||||
}
|
||||
mock_response.headers.get.return_value = None
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
client = SimStudioClient(api_key="test-api-key")
|
||||
client.execute_workflow(
|
||||
"workflow-id",
|
||||
{},
|
||||
async_execution=True,
|
||||
execution_timeout_seconds=90,
|
||||
)
|
||||
|
||||
headers = mock_post.call_args[1]["headers"]
|
||||
assert headers["X-Execution-Timeout-Seconds"] == "90"
|
||||
|
||||
|
||||
def test_sync_execution_rejects_execution_timeout():
|
||||
client = SimStudioClient(api_key="test-api-key")
|
||||
|
||||
with pytest.raises(SimStudioError) as exc_info:
|
||||
client.execute_workflow("workflow-id", {}, execution_timeout_seconds=90)
|
||||
|
||||
assert exc_info.value.code == "INVALID_EXECUTION_TIMEOUT"
|
||||
|
||||
|
||||
def test_execution_timeout_rejects_more_than_seven_days():
|
||||
client = SimStudioClient(api_key="test-api-key")
|
||||
|
||||
with pytest.raises(SimStudioError) as exc_info:
|
||||
client.execute_workflow(
|
||||
"workflow-id",
|
||||
{},
|
||||
async_execution=True,
|
||||
execution_timeout_seconds=604_801,
|
||||
)
|
||||
|
||||
assert exc_info.value.code == "INVALID_EXECUTION_TIMEOUT"
|
||||
|
||||
|
||||
def test_execute_with_retry_forwards_execution_timeout():
|
||||
client = SimStudioClient(api_key="test-api-key")
|
||||
expected = Mock()
|
||||
|
||||
with patch.object(client, "execute_workflow", return_value=expected) as execute_workflow:
|
||||
result = client.execute_with_retry(
|
||||
"workflow-id",
|
||||
{"message": "hello"},
|
||||
async_execution=True,
|
||||
execution_timeout_seconds=90,
|
||||
)
|
||||
|
||||
assert result is expected
|
||||
execute_workflow.assert_called_once_with(
|
||||
"workflow-id",
|
||||
{"message": "hello"},
|
||||
timeout=30.0,
|
||||
stream=None,
|
||||
selected_outputs=None,
|
||||
async_execution=True,
|
||||
execution_timeout_seconds=90,
|
||||
)
|
||||
|
||||
|
||||
@patch('simstudio.requests.Session.get')
|
||||
def test_get_job_status_success(mock_get):
|
||||
"""Test getting job status."""
|
||||
@@ -534,4 +607,4 @@ def test_execute_workflow_with_dict_input_spreads_at_root(mock_post):
|
||||
|
||||
assert request_body["ticker"] == "NVDA"
|
||||
assert request_body["quantity"] == 100
|
||||
assert "input" not in request_body # Should not wrap in input field
|
||||
assert "input" not in request_body # Should not wrap in input field
|
||||
|
||||
@@ -39,6 +39,7 @@ export interface EnvFlagsMockState {
|
||||
isSessionPoliciesEnabled: boolean
|
||||
isForkingEnabled: boolean
|
||||
isRemoteSandboxEnabled: boolean
|
||||
isMothershipSandboxEnabled: boolean
|
||||
isDocSandboxEnabled: boolean
|
||||
isOllamaConfigured: boolean
|
||||
isAzureConfigured: boolean
|
||||
@@ -88,6 +89,7 @@ const defaultEnvFlagsState: EnvFlagsMockState = {
|
||||
isDataDrainsEnabled: false,
|
||||
isForkingEnabled: false,
|
||||
isRemoteSandboxEnabled: false,
|
||||
isMothershipSandboxEnabled: false,
|
||||
isDocSandboxEnabled: false,
|
||||
isOllamaConfigured: false,
|
||||
isAzureConfigured: false,
|
||||
|
||||
@@ -5,8 +5,8 @@ import { vi } from 'vitest'
|
||||
* `@/lib/logs/execution/logging-session`. Every instance method is backed by a
|
||||
* shared `vi.fn()` so tests that construct multiple sessions observe identical
|
||||
* mock state. `mockSafeStart` defaults to `true` because callers branch on the
|
||||
* boolean result. Projection methods return their input; other methods resolve
|
||||
* to `undefined`.
|
||||
* boolean result. Display projection methods return their input, diagnostic projection
|
||||
* fails closed to structural metadata, and other methods resolve to `undefined`.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
@@ -26,9 +26,14 @@ export const loggingSessionMockFns = {
|
||||
mockWaitForCompletion: vi.fn().mockResolvedValue(undefined),
|
||||
mockWaitForPostExecution: vi.fn().mockResolvedValue(undefined),
|
||||
mockSetTrustedExecutionCorrelation: vi.fn(),
|
||||
mockSetExecutionDeadlineAt: vi.fn(),
|
||||
mockProjectBlockLogsForDisplay: vi.fn(async (logs: unknown) => logs),
|
||||
mockProjectDisplayContent: vi.fn(async (content: unknown) => content),
|
||||
mockProjectLiveDisplayText: vi.fn(async (_field: string, value: string) => ({ value })),
|
||||
mockProjectDiagnosticError: vi.fn((error: unknown, _details: Record<string, unknown> = {}) => ({
|
||||
errorType: error instanceof Error ? 'error' : error === null ? 'null' : typeof error,
|
||||
hasStack: error instanceof Error && typeof error.stack === 'string',
|
||||
})),
|
||||
mockSafeComplete: vi.fn().mockResolvedValue(undefined),
|
||||
mockSafeCompleteWithError: vi.fn().mockResolvedValue(undefined),
|
||||
mockSafeCompleteWithCancellation: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -53,9 +58,11 @@ function buildLoggingSessionInstance() {
|
||||
waitForCompletion: loggingSessionMockFns.mockWaitForCompletion,
|
||||
waitForPostExecution: loggingSessionMockFns.mockWaitForPostExecution,
|
||||
setTrustedExecutionCorrelation: loggingSessionMockFns.mockSetTrustedExecutionCorrelation,
|
||||
setExecutionDeadlineAt: loggingSessionMockFns.mockSetExecutionDeadlineAt,
|
||||
projectBlockLogsForDisplay: loggingSessionMockFns.mockProjectBlockLogsForDisplay,
|
||||
projectDisplayContent: loggingSessionMockFns.mockProjectDisplayContent,
|
||||
projectLiveDisplayText: loggingSessionMockFns.mockProjectLiveDisplayText,
|
||||
projectDiagnosticError: loggingSessionMockFns.mockProjectDiagnosticError,
|
||||
safeComplete: loggingSessionMockFns.mockSafeComplete,
|
||||
safeCompleteWithError: loggingSessionMockFns.mockSafeCompleteWithError,
|
||||
safeCompleteWithCancellation: loggingSessionMockFns.mockSafeCompleteWithCancellation,
|
||||
|
||||
@@ -594,6 +594,16 @@ export const schemaMock = {
|
||||
size: 'size',
|
||||
deletedAt: 'deletedAt',
|
||||
uploadedAt: 'uploadedAt',
|
||||
updatedAt: 'updatedAt',
|
||||
contentUpdatedAt: 'contentUpdatedAt',
|
||||
secretProvenanceVersion: 'secretProvenanceVersion',
|
||||
},
|
||||
workspaceFileSecretProvenance: {
|
||||
fileId: 'fileId',
|
||||
contentUpdatedAt: 'contentUpdatedAt',
|
||||
status: 'status',
|
||||
entries: 'entries',
|
||||
updatedAt: 'updatedAt',
|
||||
},
|
||||
permissionTypeEnum: 'permissionTypeEnum',
|
||||
workspaceInvitationStatusEnum: 'workspaceInvitationStatusEnum',
|
||||
@@ -625,10 +635,18 @@ export const schemaMock = {
|
||||
workspaceId: 'workspaceId',
|
||||
key: 'key',
|
||||
data: 'data',
|
||||
secretProvenanceVersion: 'secretProvenanceVersion',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt',
|
||||
deletedAt: 'deletedAt',
|
||||
},
|
||||
memorySecretProvenance: {
|
||||
memoryId: 'memoryId',
|
||||
contentHash: 'contentHash',
|
||||
status: 'status',
|
||||
entries: 'entries',
|
||||
updatedAt: 'updatedAt',
|
||||
},
|
||||
knowledgeBase: {
|
||||
id: 'id',
|
||||
userId: 'userId',
|
||||
@@ -682,8 +700,16 @@ export const schemaMock = {
|
||||
externalId: 'externalId',
|
||||
contentHash: 'contentHash',
|
||||
sourceUrl: 'sourceUrl',
|
||||
secretProvenanceVersion: 'secretProvenanceVersion',
|
||||
uploadedAt: 'uploadedAt',
|
||||
},
|
||||
documentSecretProvenance: {
|
||||
documentId: 'documentId',
|
||||
sourceHash: 'sourceHash',
|
||||
status: 'status',
|
||||
entries: 'entries',
|
||||
updatedAt: 'updatedAt',
|
||||
},
|
||||
knowledgeBaseTagDefinitions: {
|
||||
id: 'id',
|
||||
knowledgeBaseId: 'knowledgeBaseId',
|
||||
@@ -700,6 +726,7 @@ export const schemaMock = {
|
||||
chunkIndex: 'chunkIndex',
|
||||
chunkHash: 'chunkHash',
|
||||
content: 'content',
|
||||
secretProvenanceVersion: 'secretProvenanceVersion',
|
||||
contentLength: 'contentLength',
|
||||
tokenCount: 'tokenCount',
|
||||
embedding: 'embedding',
|
||||
@@ -728,6 +755,13 @@ export const schemaMock = {
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt',
|
||||
},
|
||||
embeddingSecretProvenance: {
|
||||
embeddingId: 'embeddingId',
|
||||
contentHash: 'contentHash',
|
||||
status: 'status',
|
||||
entries: 'entries',
|
||||
updatedAt: 'updatedAt',
|
||||
},
|
||||
docsEmbeddings: {
|
||||
chunkId: 'chunkId',
|
||||
chunkText: 'chunkText',
|
||||
@@ -1146,10 +1180,18 @@ export const schemaMock = {
|
||||
workspaceId: 'workspaceId',
|
||||
data: 'data',
|
||||
position: 'position',
|
||||
secretProvenanceVersion: 'secretProvenanceVersion',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt',
|
||||
createdBy: 'createdBy',
|
||||
},
|
||||
userTableRowSecretProvenance: {
|
||||
rowId: 'rowId',
|
||||
contentUpdatedAt: 'contentUpdatedAt',
|
||||
status: 'status',
|
||||
entries: 'entries',
|
||||
updatedAt: 'updatedAt',
|
||||
},
|
||||
tableJobs: {
|
||||
id: 'id',
|
||||
tableId: 'tableId',
|
||||
|
||||
@@ -19,6 +19,7 @@ export const storageServiceMockFns = {
|
||||
mockDownloadFile: vi.fn(),
|
||||
mockDeleteFile: vi.fn(),
|
||||
mockHeadObject: vi.fn(),
|
||||
mockVerifyPresignedUploadReceipt: vi.fn(),
|
||||
mockGeneratePresignedUploadUrl: vi.fn(),
|
||||
mockGenerateBatchPresignedUploadUrls: vi.fn(),
|
||||
mockGeneratePresignedDownloadUrl: vi.fn(),
|
||||
@@ -39,6 +40,7 @@ export const storageServiceMock = {
|
||||
downloadFile: storageServiceMockFns.mockDownloadFile,
|
||||
deleteFile: storageServiceMockFns.mockDeleteFile,
|
||||
headObject: storageServiceMockFns.mockHeadObject,
|
||||
verifyPresignedUploadReceipt: storageServiceMockFns.mockVerifyPresignedUploadReceipt,
|
||||
generatePresignedUploadUrl: storageServiceMockFns.mockGeneratePresignedUploadUrl,
|
||||
generateBatchPresignedUploadUrls: storageServiceMockFns.mockGenerateBatchPresignedUploadUrls,
|
||||
generatePresignedDownloadUrl: storageServiceMockFns.mockGeneratePresignedDownloadUrl,
|
||||
|
||||
@@ -62,7 +62,9 @@ const result = await client.executeWorkflow('workflow-id', 'NVDA');
|
||||
|
||||
// With options
|
||||
const result = await client.executeWorkflow('workflow-id', { message: 'Hello' }, {
|
||||
timeout: 60000
|
||||
timeout: 60000,
|
||||
async: true,
|
||||
executionTimeoutSeconds: 3600
|
||||
});
|
||||
```
|
||||
|
||||
@@ -74,6 +76,7 @@ const result = await client.executeWorkflow('workflow-id', { message: 'Hello' },
|
||||
- `stream` (boolean): Enable streaming responses
|
||||
- `selectedOutputs` (string[]): Block outputs to stream (e.g., `["agent1.content"]`)
|
||||
- `async` (boolean): Execute asynchronously and return execution ID
|
||||
- `executionTimeoutSeconds` (number): Server-side async execution cap from 1 to 604800 seconds. Requires `async: true` and cannot extend the account policy.
|
||||
|
||||
**Returns:** `Promise<WorkflowExecutionResult | AsyncExecutionResult>`
|
||||
|
||||
@@ -323,6 +326,7 @@ interface ExecutionOptions {
|
||||
stream?: boolean;
|
||||
selectedOutputs?: string[];
|
||||
async?: boolean;
|
||||
executionTimeoutSeconds?: number;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -533,4 +537,4 @@ bun run dev
|
||||
|
||||
## License
|
||||
|
||||
Apache-2.0
|
||||
Apache-2.0
|
||||
|
||||
@@ -174,6 +174,43 @@ describe('SimStudioClient', () => {
|
||||
const calls = vi.mocked(mockFetch).mock.calls
|
||||
expect(calls[0][1]?.headers).not.toHaveProperty('X-Execution-Mode')
|
||||
})
|
||||
|
||||
it('sets the server-side timeout header for async execution', async () => {
|
||||
vi.mocked(mockFetch).mockResolvedValue({
|
||||
ok: true,
|
||||
status: 202,
|
||||
json: vi.fn().mockResolvedValue({ success: true, jobId: 'job-1', async: true }),
|
||||
headers: { get: vi.fn().mockReturnValue(null) },
|
||||
} as any)
|
||||
|
||||
await client.executeWorkflow('workflow-id', {}, { async: true, executionTimeoutSeconds: 90 })
|
||||
|
||||
expect(vi.mocked(mockFetch).mock.calls[0][1]?.headers).toMatchObject({
|
||||
'X-Execution-Mode': 'async',
|
||||
'X-Execution-Timeout-Seconds': '90',
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a server-side timeout for sync execution', async () => {
|
||||
await expect(
|
||||
client.executeWorkflow('workflow-id', {}, { executionTimeoutSeconds: 90 })
|
||||
).rejects.toMatchObject({ code: 'INVALID_EXECUTION_TIMEOUT' })
|
||||
expect(mockFetch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a server-side timeout above seven days', async () => {
|
||||
await expect(
|
||||
client.executeWorkflow(
|
||||
'workflow-id',
|
||||
{},
|
||||
{
|
||||
async: true,
|
||||
executionTimeoutSeconds: 604_801,
|
||||
}
|
||||
)
|
||||
).rejects.toMatchObject({ code: 'INVALID_EXECUTION_TIMEOUT' })
|
||||
expect(mockFetch).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getJobStatus', () => {
|
||||
|
||||
@@ -37,12 +37,24 @@ export interface WorkflowStatus {
|
||||
}
|
||||
|
||||
export interface ExecutionOptions {
|
||||
/** Client-side HTTP timeout in milliseconds. */
|
||||
timeout?: number
|
||||
stream?: boolean
|
||||
selectedOutputs?: string[]
|
||||
async?: boolean
|
||||
/** Server-side async execution cap in seconds (1–604800). */
|
||||
executionTimeoutSeconds?: number
|
||||
}
|
||||
|
||||
export type SyncExecutionOptions = Omit<ExecutionOptions, 'async' | 'executionTimeoutSeconds'> & {
|
||||
/** Async mode is controlled by executeWorkflowSync and cannot be overridden. */
|
||||
async?: never
|
||||
/** Server-side execution timeout overrides are async-only. */
|
||||
executionTimeoutSeconds?: never
|
||||
}
|
||||
|
||||
const MAX_EXECUTION_TIMEOUT_SECONDS = 604_800
|
||||
|
||||
export interface AsyncExecutionResult {
|
||||
success: boolean
|
||||
jobId: string
|
||||
@@ -216,7 +228,26 @@ export class SimStudioClient {
|
||||
options: ExecutionOptions = {}
|
||||
): Promise<WorkflowExecutionResult | AsyncExecutionResult> {
|
||||
const url = `${this.baseUrl}/api/workflows/${workflowId}/execute`
|
||||
const { timeout = 30000, stream, selectedOutputs, async } = options
|
||||
const { timeout = 30000, stream, selectedOutputs, async, executionTimeoutSeconds } = options
|
||||
|
||||
if (executionTimeoutSeconds !== undefined) {
|
||||
if (!async) {
|
||||
throw new SimStudioError(
|
||||
'executionTimeoutSeconds is supported only for async executions',
|
||||
'INVALID_EXECUTION_TIMEOUT'
|
||||
)
|
||||
}
|
||||
if (
|
||||
!Number.isSafeInteger(executionTimeoutSeconds) ||
|
||||
executionTimeoutSeconds < 1 ||
|
||||
executionTimeoutSeconds > MAX_EXECUTION_TIMEOUT_SECONDS
|
||||
) {
|
||||
throw new SimStudioError(
|
||||
`executionTimeoutSeconds must be an integer between 1 and ${MAX_EXECUTION_TIMEOUT_SECONDS}`,
|
||||
'INVALID_EXECUTION_TIMEOUT'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
@@ -230,6 +261,9 @@ export class SimStudioClient {
|
||||
if (async) {
|
||||
headers['X-Execution-Mode'] = 'async'
|
||||
}
|
||||
if (executionTimeoutSeconds !== undefined) {
|
||||
headers['X-Execution-Timeout-Seconds'] = executionTimeoutSeconds.toString()
|
||||
}
|
||||
|
||||
let jsonBody: any = {}
|
||||
if (input !== undefined && input !== null) {
|
||||
@@ -341,7 +375,7 @@ export class SimStudioClient {
|
||||
async executeWorkflowSync(
|
||||
workflowId: string,
|
||||
input?: any,
|
||||
options: ExecutionOptions = {}
|
||||
options: SyncExecutionOptions = {}
|
||||
): Promise<WorkflowExecutionResult> {
|
||||
const syncOptions = { ...options, async: false }
|
||||
return this.executeWorkflow(workflowId, input, syncOptions) as Promise<WorkflowExecutionResult>
|
||||
|
||||
@@ -53,6 +53,10 @@
|
||||
"./sso-domain": {
|
||||
"types": "./src/sso-domain.ts",
|
||||
"default": "./src/sso-domain.ts"
|
||||
},
|
||||
"./sandbox-references": {
|
||||
"types": "./src/sandbox-references.ts",
|
||||
"default": "./src/sandbox-references.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import {
|
||||
immutableE2BTemplateRef,
|
||||
isImmutableDaytonaSnapshotRef,
|
||||
isImmutableE2BTemplateRef,
|
||||
isValidE2BTemplateName,
|
||||
isValidE2BTemplateReferenceName,
|
||||
isValidSandboxReleaseGeneration,
|
||||
normalizeSandboxProvider,
|
||||
} from '@sim/utils/sandbox-references'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const BUILD_ID = 'f47ac10b-58cc-4372-a567-0e02b2c3d479'
|
||||
|
||||
describe('immutable sandbox references', () => {
|
||||
it('normalizes only registered sandbox providers', () => {
|
||||
expect(normalizeSandboxProvider('E2B')).toBe('e2b')
|
||||
expect(normalizeSandboxProvider('Daytona')).toBe('daytona')
|
||||
expect(normalizeSandboxProvider('modal')).toBeUndefined()
|
||||
expect(normalizeSandboxProvider(undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
it.each([
|
||||
`sim-function:${BUILD_ID}`,
|
||||
`team_sim-function:${BUILD_ID}`,
|
||||
`acme/sim-function:${BUILD_ID}`,
|
||||
])('accepts an exact E2B build reference: %s', (value) => {
|
||||
expect(isImmutableE2BTemplateRef(value)).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
'sim-function',
|
||||
'sim-function:default',
|
||||
'sim-function:latest',
|
||||
'sim-function:v1',
|
||||
`sim-function:stable:${BUILD_ID}`,
|
||||
`team/child/sim-function:${BUILD_ID}`,
|
||||
`Sim-function:${BUILD_ID}`,
|
||||
`sim.function:${BUILD_ID}`,
|
||||
BUILD_ID,
|
||||
])('rejects a movable or incomplete E2B reference: %s', (value) => {
|
||||
expect(isImmutableE2BTemplateRef(value)).toBe(false)
|
||||
})
|
||||
|
||||
it('creates an exact E2B build reference', () => {
|
||||
expect(immutableE2BTemplateRef('sim-function', BUILD_ID)).toBe(`sim-function:${BUILD_ID}`)
|
||||
})
|
||||
|
||||
it.each(['sim-function', 'team_sim-function'])(
|
||||
'accepts an untagged E2B template family: %s',
|
||||
(value) => {
|
||||
expect(isValidE2BTemplateName(value)).toBe(true)
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['sim-function:latest', 'team/sim-function', 'Sim-function', 'sim.function', ''])(
|
||||
'rejects an invalid E2B template family: %s',
|
||||
(value) => {
|
||||
expect(isValidE2BTemplateName(value)).toBe(false)
|
||||
}
|
||||
)
|
||||
|
||||
it('accepts one namespace only for an exact reference name', () => {
|
||||
expect(isValidE2BTemplateReferenceName('acme/sim-function')).toBe(true)
|
||||
expect(isValidE2BTemplateName('acme/sim-function')).toBe(false)
|
||||
expect(isValidE2BTemplateReferenceName('acme/team/sim-function')).toBe(false)
|
||||
})
|
||||
|
||||
it('requires a Daytona snapshot ID rather than a name', () => {
|
||||
expect(isImmutableDaytonaSnapshotRef(BUILD_ID)).toBe(true)
|
||||
expect(isImmutableDaytonaSnapshotRef('sim-function:2026-08-03')).toBe(false)
|
||||
expect(isImmutableDaytonaSnapshotRef('sim-function')).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts only positive safe release generations', () => {
|
||||
expect(isValidSandboxReleaseGeneration('1785792000000')).toBe(true)
|
||||
expect(isValidSandboxReleaseGeneration(1785792000000)).toBe(true)
|
||||
expect(isValidSandboxReleaseGeneration('0')).toBe(false)
|
||||
expect(isValidSandboxReleaseGeneration('1.5')).toBe(false)
|
||||
expect(isValidSandboxReleaseGeneration('01')).toBe(false)
|
||||
expect(isValidSandboxReleaseGeneration(Number.MAX_SAFE_INTEGER)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,76 @@
|
||||
import { isValidUuid } from '@sim/utils/id'
|
||||
|
||||
export const IMMUTABLE_E2B_TEMPLATE_REF_ERROR =
|
||||
'must be an immutable E2B build reference in the form <template>:<build-id>'
|
||||
|
||||
export const IMMUTABLE_DAYTONA_SNAPSHOT_REF_ERROR =
|
||||
'must be an immutable Daytona snapshot ID, not a snapshot name'
|
||||
|
||||
export const SANDBOX_RELEASE_GENERATION_ERROR = 'must be a positive safe integer release generation'
|
||||
|
||||
export const SANDBOX_PROVIDER_IDS = ['e2b', 'daytona'] as const
|
||||
export type SandboxProviderName = (typeof SANDBOX_PROVIDER_IDS)[number]
|
||||
|
||||
/** Leaves three decimal digits for a materializer revision in one safe integer. */
|
||||
export const MAX_SANDBOX_RELEASE_GENERATION = Math.floor((Number.MAX_SAFE_INTEGER - 999) / 1000)
|
||||
|
||||
const E2B_TEMPLATE_NAME_PATTERN = /^[a-z0-9][a-z0-9_-]{0,62}$/
|
||||
const E2B_TEMPLATE_REFERENCE_NAME_PATTERN =
|
||||
/^(?:[a-z0-9][a-z0-9_-]{0,62}\/)?[a-z0-9][a-z0-9_-]{0,62}$/
|
||||
|
||||
/** Normalizes a configured provider and rejects values outside the supported registry. */
|
||||
export function normalizeSandboxProvider(
|
||||
value: string | undefined
|
||||
): SandboxProviderName | undefined {
|
||||
if (!value) return undefined
|
||||
const normalized = value.toLowerCase()
|
||||
return SANDBOX_PROVIDER_IDS.find((provider) => provider === normalized)
|
||||
}
|
||||
|
||||
/** E2B template families use the provider's untagged, lowercase name grammar. */
|
||||
export function isValidE2BTemplateName(value: string): boolean {
|
||||
return E2B_TEMPLATE_NAME_PATTERN.test(value)
|
||||
}
|
||||
|
||||
/** Exact E2B refs may include one documented lowercase namespace segment. */
|
||||
export function isValidE2BTemplateReferenceName(value: string): boolean {
|
||||
return E2B_TEMPLATE_REFERENCE_NAME_PATTERN.test(value)
|
||||
}
|
||||
|
||||
/** Validates the monotonic release identity operators deploy with a Function base. */
|
||||
export function isValidSandboxReleaseGeneration(value: string | number): boolean {
|
||||
const generation = typeof value === 'number' ? value : Number(value)
|
||||
return (
|
||||
Number.isSafeInteger(generation) &&
|
||||
generation > 0 &&
|
||||
generation <= MAX_SANDBOX_RELEASE_GENERATION &&
|
||||
String(generation) === String(value)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* E2B resolves `<template>:<build-id>` directly to one immutable template build.
|
||||
* Human tags are deliberately rejected because E2B permits reassigning them.
|
||||
*/
|
||||
export function isImmutableE2BTemplateRef(value: string): boolean {
|
||||
const separator = value.lastIndexOf(':')
|
||||
if (separator <= 0 || separator === value.length - 1) return false
|
||||
|
||||
const template = value.slice(0, separator)
|
||||
const buildId = value.slice(separator + 1)
|
||||
return isValidE2BTemplateReferenceName(template) && isValidUuid(buildId)
|
||||
}
|
||||
|
||||
/** Daytona accepts a snapshot ID anywhere it accepts a name; only the ID is immutable. */
|
||||
export function isImmutableDaytonaSnapshotRef(value: string): boolean {
|
||||
return isValidUuid(value)
|
||||
}
|
||||
|
||||
/** Builds the exact E2B reference operators should deploy after a successful build. */
|
||||
export function immutableE2BTemplateRef(templateName: string, buildId: string): string {
|
||||
const ref = `${templateName}:${buildId}`
|
||||
if (!isImmutableE2BTemplateRef(ref)) {
|
||||
throw new Error(`E2B template reference ${IMMUTABLE_E2B_TEMPLATE_REF_ERROR}`)
|
||||
}
|
||||
return ref
|
||||
}
|
||||
Reference in New Issue
Block a user