@tangle-network/create-agent-app 0.46.40 → 0.46.42

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tangle-network/create-agent-app",
3
- "version": "0.46.40",
3
+ "version": "0.46.42",
4
4
  "description": "Create a tested Tangle agent application with either a tool loop or a complete chat interface.",
5
5
  "keywords": [
6
6
  "tangle",
@@ -24,15 +24,15 @@
24
24
  "@tangle-network/agent-eval": "0.172.1",
25
25
  "@tangle-network/agent-integrations": ">=0.53.55 <0.54.0",
26
26
  "@tangle-network/agent-interface": "2.2.0",
27
- "@tangle-network/agent-runtime": "0.188.1"
27
+ "@tangle-network/agent-runtime": "0.189.0"
28
28
  },
29
29
  "devDependencies": {
30
30
  "@tangle-network/agent-eval": "0.172.1",
31
31
  "@tangle-network/agent-integrations": "0.53.55",
32
32
  "@tangle-network/agent-interface": "2.2.0",
33
33
  "@tangle-network/agent-knowledge": "13.0.0",
34
- "@tangle-network/agent-runtime": "0.188.1",
35
- "@tangle-network/sandbox": "0.36.3",
34
+ "@tangle-network/agent-runtime": "0.189.0",
35
+ "@tangle-network/sandbox": "0.36.4",
36
36
  "@types/node": "^22.20.1",
37
37
  "typescript": "^7.0.2",
38
38
  "viem": "^2.0.0",
@@ -46,6 +46,8 @@ Discovery: **Where does this app live and what may it spend?**
46
46
  - [ ] `pnpm db:migrate:local` — applies `migrations/0001_init.sql` (auth +
47
47
  chat + turn buffer). The e2e test executes this same file, so it cannot
48
48
  silently drift from the schema.
49
+ - [ ] Existing app only: add `sandbox_prewarm_claims` in a new migration.
50
+ Do not edit an applied `0001_init.sql`; Wrangler will not run it again.
49
51
  - [ ] R2 stays commented out unless the product stores artifacts.
50
52
 
51
53
  ## ④ Prove the loop — `pnpm dev`
@@ -21,8 +21,8 @@
21
21
  "dependencies": {
22
22
  "@tangle-network/agent-app": "__AGENT_APP_VERSION__",
23
23
  "@tangle-network/agent-interface": "2.2.0",
24
- "@tangle-network/agent-runtime": "0.188.1",
25
- "@tangle-network/sandbox": "0.36.3",
24
+ "@tangle-network/agent-runtime": "0.189.0",
25
+ "@tangle-network/sandbox": "0.36.4",
26
26
  "better-auth": "^1.7.2",
27
27
  "drizzle-orm": "^0.45.2",
28
28
  "viem": "^2.0.0"
@@ -96,6 +96,16 @@ CREATE TABLE IF NOT EXISTS message (
96
96
  CREATE INDEX IF NOT EXISTS idx_message_thread ON message (thread_id);
97
97
  CREATE INDEX IF NOT EXISTS idx_message_thread_created ON message (thread_id, created_at);
98
98
 
99
+ -- ── sandbox provisioning claim (PREWARM_CLAIM_TABLE_DDL from
100
+ -- @tangle-network/agent-app/sandbox) ──────────────────────────────────────
101
+ -- A positive value is a live fenced lease. A negative value is the retained
102
+ -- release marker that prevents an expired owner from releasing its successor.
103
+
104
+ CREATE TABLE IF NOT EXISTS sandbox_prewarm_claims (
105
+ key TEXT PRIMARY KEY,
106
+ expires_at INTEGER NOT NULL
107
+ );
108
+
99
109
  -- ── turn buffer (TURN_EVENTS_MIGRATION_SQL from @tangle-network/agent-app/stream)
100
110
  -- Byte-for-byte the constant the D1 turn-event store expects; the e2e test
101
111
  -- asserts this block matches the exported SQL so it cannot drift silently.
@@ -26,7 +26,10 @@ import {
26
26
  } from '@tangle-network/agent-app/chat-routes'
27
27
  import type { SidecarInteractionsConnection } from '@tangle-network/agent-app/interactions'
28
28
  import {
29
+ createD1PrewarmClaimStore,
29
30
  ensureWorkspaceSandbox,
31
+ peekWorkspaceSandbox,
32
+ runForegroundSandboxSingleFlight,
30
33
  streamSandboxPrompt,
31
34
  type SandboxRuntimeConfig,
32
35
  } from '@tangle-network/agent-app/sandbox'
@@ -72,6 +75,26 @@ export function createSandboxShell(env: AppEnv): SandboxRuntimeConfig {
72
75
  }
73
76
  }
74
77
 
78
+ /**
79
+ * Provision once across Worker isolates. A turn, upload, and interaction can
80
+ * arrive together for one new workspace. They must adopt one box, not create
81
+ * three boxes with the same name.
82
+ */
83
+ async function ensureForegroundWorkspaceSandbox(
84
+ env: AppEnv,
85
+ shell: SandboxRuntimeConfig,
86
+ scope: { workspaceId: string; userId: string },
87
+ ) {
88
+ return runForegroundSandboxSingleFlight({
89
+ claim: createD1PrewarmClaimStore(env.DB),
90
+ workspaceId: scope.workspaceId,
91
+ harness: config.harness,
92
+ provision: () => ensureWorkspaceSandbox(shell, { ...scope, harness: config.harness }),
93
+ peek: () => peekWorkspaceSandbox(shell, scope),
94
+ adopt: ({ box }) => box,
95
+ })
96
+ }
97
+
75
98
  /**
76
99
  * The `produce` seam for `createChatTurnRoutes`: one call per turn. The
77
100
  * chat thread id doubles as the agent session id, so follow-up turns land in
@@ -85,10 +108,9 @@ export function createSandboxProduce(env: AppEnv) {
85
108
  prompt,
86
109
  executionId,
87
110
  }: ChatTurnProduceArgs<void>): Promise<ChatTurnRouteProducer> => {
88
- const box = await ensureWorkspaceSandbox(shell, {
111
+ const box = await ensureForegroundWorkspaceSandbox(env, shell, {
89
112
  workspaceId: identity.tenantId,
90
113
  userId: identity.userId,
91
- harness: config.harness,
92
114
  })
93
115
  const model = body.model ?? env.MODEL_NAME ?? config.model.default
94
116
  return createSandboxChatProducer({
@@ -133,7 +155,7 @@ export async function resolveUploadSink(
133
155
  ): Promise<SandboxUploadSink | null> {
134
156
  if (!env.SANDBOX_API_KEY?.trim() || !env.SANDBOX_GATEWAY_URL?.trim()) return null
135
157
  const shell = createSandboxShell(env)
136
- const box = await ensureWorkspaceSandbox(shell, { ...scope, harness: config.harness })
158
+ const box = await ensureForegroundWorkspaceSandbox(env, shell, scope)
137
159
  return box.fs
138
160
  }
139
161
 
@@ -145,10 +167,9 @@ export async function resolveSidecarConnection(
145
167
  ): Promise<SidecarInteractionsConnection | null> {
146
168
  if (!env.SANDBOX_API_KEY?.trim() || !env.SANDBOX_GATEWAY_URL?.trim()) return null
147
169
  const shell = createSandboxShell(env)
148
- const box = await ensureWorkspaceSandbox(shell, {
170
+ const box = await ensureForegroundWorkspaceSandbox(env, shell, {
149
171
  workspaceId: scope.workspaceId,
150
172
  userId: scope.userId,
151
- harness: config.harness,
152
173
  })
153
174
  const connection = box.connection
154
175
  if (!connection?.runtimeUrl) return null
@@ -29,6 +29,7 @@ import {
29
29
  type ChatTurnRouteProducer,
30
30
  } from '@tangle-network/agent-app/chat-routes'
31
31
  import type { ChatDatabase } from '@tangle-network/agent-app/chat-store'
32
+ import { PREWARM_CLAIM_TABLE_DDL } from '@tangle-network/agent-app/sandbox'
32
33
  import {
33
34
  createMemoryTurnEventStore,
34
35
  TURN_EVENTS_MIGRATION_SQL,
@@ -274,6 +275,11 @@ describe('e2e: fake sandbox producer → streamed turn → persisted transcript'
274
275
  expect(normalize(readFileSync(MIGRATION, 'utf8'))).toContain(normalize(TURN_EVENTS_MIGRATION_SQL))
275
276
  })
276
277
 
278
+ it('the migration carries the fenced sandbox claim table, verbatim', () => {
279
+ const normalize = (sql: string) => sql.replace(/\s+/g, ' ').replace(/;$/, '').trim()
280
+ expect(normalize(readFileSync(MIGRATION, 'utf8'))).toContain(normalize(PREWARM_CLAIM_TABLE_DDL))
281
+ })
282
+
277
283
  it('agent.config carries a real system prompt (prompts/system.md is wired)', () => {
278
284
  expect(config.systemPrompt.length).toBeGreaterThan(0)
279
285
  expect(config.name.length).toBeGreaterThan(0)
@@ -0,0 +1,76 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
2
+
3
+ const sandboxMocks = vi.hoisted(() => ({
4
+ createClaimStore: vi.fn(),
5
+ ensureWorkspace: vi.fn(),
6
+ peekWorkspace: vi.fn(),
7
+ }))
8
+
9
+ vi.mock('@tangle-network/agent-app/sandbox', async (importOriginal) => {
10
+ const actual = await importOriginal<typeof import('@tangle-network/agent-app/sandbox')>()
11
+ return {
12
+ ...actual,
13
+ createD1PrewarmClaimStore: sandboxMocks.createClaimStore,
14
+ ensureWorkspaceSandbox: sandboxMocks.ensureWorkspace,
15
+ peekWorkspaceSandbox: sandboxMocks.peekWorkspace,
16
+ }
17
+ })
18
+
19
+ import { createSandboxProduce } from '../src/sandbox'
20
+ import type { AppEnv } from '../src/env'
21
+
22
+ describe('sandbox foreground provisioning', () => {
23
+ beforeEach(() => {
24
+ vi.clearAllMocks()
25
+ })
26
+
27
+ it('runs createSandboxProduce through the shared fenced claim', async () => {
28
+ const lease = { key: 'workspace-1::opencode', expiresAt: 123 }
29
+ const claim = {
30
+ acquire: vi.fn(async () => true),
31
+ release: vi.fn(async () => undefined),
32
+ isHeld: vi.fn(async () => false),
33
+ inspect: vi.fn(async () => ({ status: 'absent' as const })),
34
+ acquireLease: vi.fn(async () => lease),
35
+ releaseLease: vi.fn(async () => undefined),
36
+ }
37
+ const box = { id: 'box-1' }
38
+ const db = {} as D1Database
39
+ const env: AppEnv = {
40
+ DB: db,
41
+ BETTER_AUTH_URL: 'http://localhost:8787',
42
+ BETTER_AUTH_SECRET: 'test-secret',
43
+ SANDBOX_API_KEY: 'sandbox-key',
44
+ SANDBOX_GATEWAY_URL: 'https://sandbox.example.test',
45
+ }
46
+ sandboxMocks.createClaimStore.mockReturnValue(claim)
47
+ sandboxMocks.ensureWorkspace.mockResolvedValue(box)
48
+
49
+ const producer = await createSandboxProduce(env)({
50
+ request: new Request('http://localhost:8787/api/chat', { method: 'POST' }),
51
+ body: { threadId: 'thread-1', content: 'Hello' },
52
+ identity: {
53
+ tenantId: 'workspace-1',
54
+ sessionId: 'thread-1',
55
+ userId: 'user-1',
56
+ turnIndex: 0,
57
+ },
58
+ context: undefined,
59
+ prompt: 'Hello',
60
+ executionId: 'execution-1',
61
+ turnStreamId: 'turn-1',
62
+ priorMessages: [],
63
+ userMessageId: 'message-1',
64
+ })
65
+
66
+ expect(producer.stream).toBeDefined()
67
+ expect(sandboxMocks.createClaimStore).toHaveBeenCalledWith(db)
68
+ expect(claim.acquireLease).toHaveBeenCalledWith('workspace-1::opencode', 180)
69
+ expect(sandboxMocks.ensureWorkspace).toHaveBeenCalledWith(
70
+ expect.objectContaining({ name: expect.any(Function) }),
71
+ { workspaceId: 'workspace-1', userId: 'user-1', harness: 'opencode' },
72
+ )
73
+ expect(claim.releaseLease).toHaveBeenCalledWith(lease)
74
+ expect(sandboxMocks.peekWorkspace).not.toHaveBeenCalled()
75
+ })
76
+ })