Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ jobs:
env:
# tsup generates .d.ts for ~38 entries in a worker thread (the `prepare`
# script runs during install, and again in the build step); that worker
# needs ~4.7GB and OOMs at the default limit. The worker inherits
# needed ~4.7GB and has grown with the subpath count (OOMed at 6144 after the #191/#192 merges); 8192 matches the documented local build requirement. The worker inherits
# NODE_OPTIONS, so set the heap once at the job level.
NODE_OPTIONS: --max-old-space-size=6144
NODE_OPTIONS: --max-old-space-size=8192
steps:
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ jobs:
contents: write # push the version-bump commit + tag
id-token: write # OIDC trusted publish + Sigstore provenance
env:
NODE_OPTIONS: --max-old-space-size=6144
NODE_OPTIONS: --max-old-space-size=8192
steps:
- uses: actions/checkout@v7
with:
Expand Down Expand Up @@ -93,7 +93,7 @@ jobs:
# ERR_WORKER_OUT_OF_MEMORY on the hosted runner. The worker inherits
# NODE_OPTIONS, so a job-level heap covers install (prepare), test, and
# build in one place.
NODE_OPTIONS: --max-old-space-size=6144
NODE_OPTIONS: --max-old-space-size=8192
steps:
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
Expand Down Expand Up @@ -129,7 +129,7 @@ jobs:
env:
# Same DTS-worker heap need: this job runs `pnpm install` (prepare → tsup)
# and `pnpm run build` before publishing.
NODE_OPTIONS: --max-old-space-size=6144
NODE_OPTIONS: --max-old-space-size=8192
permissions:
contents: read
id-token: write
Expand Down
11 changes: 11 additions & 0 deletions src/chat-store/parts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,9 @@ import type { Part as HarnessWirePart } from '@tangle-network/agent-interface'
import type {
ChatInteractionField,
ChatInteractionStatus,
InteractionPersistedPart,
NoticeKind,
NoticePersistedPart,
} from '../web-react/chat-interactions'

/** Start/end wall-clock millis, as normalized by `/stream`'s `normalizeTime`. */
Expand Down Expand Up @@ -168,6 +170,15 @@ export interface ChatNoticePart {
text: string
}

// The "byte-matches" claims above, enforced at compile time: the interaction
// contract's codec output types and the stored part types must stay mutually
// assignable, so a codec field added on one side without the other fails here.
type MutuallyAssignable<A extends B, B> = A
type _CodecEmitsStorableInteractionPart = MutuallyAssignable<InteractionPersistedPart, ChatInteractionPart>
type _StoredInteractionPartFeedsCodec = MutuallyAssignable<ChatInteractionPart, InteractionPersistedPart>
type _CodecEmitsStorableNoticePart = MutuallyAssignable<NoticePersistedPart, ChatNoticePart>
type _StoredNoticePartFeedsCodec = MutuallyAssignable<ChatNoticePart, NoticePersistedPart>

export type ChatMessagePart =
| ChatTextPart
| ChatReasoningPart
Expand Down
4 changes: 3 additions & 1 deletion src/chat-store/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,9 @@ export function createChatStore<TTables extends ChatTables>(

// Access is verified once per workspace the ids touch. Fail-closed: one
// inaccessible workspace rejects the whole request before any delete.
const workspaceIds = [...new Set(rows.map((row) => row.workspaceId))]
// Sorted so the check order (and therefore which denial surfaces) is
// deterministic — row order follows random hex ids and varies per run.
const workspaceIds = [...new Set(rows.map((row) => row.workspaceId))].sort()
for (const workspaceId of workspaceIds) {
await assertAccess(workspaceId)
}
Expand Down
30 changes: 28 additions & 2 deletions src/interactions/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,9 +226,35 @@ export function noticePartKey(id: string): string {

export type NoticeKind = 'warning' | 'auto-declined'

/**
* Persisted-part shapes the codecs below produce — the SAME rows
* `/chat-store`'s `ChatInteractionPart`/`ChatNoticePart` store, typed at the
* source so a product pushing them into a `ChatMessagePart[]` transcript needs
* no cast. Type aliases (not interfaces) on purpose: the implicit index
* signature keeps them assignable to the `Record<string, unknown>` these
* codecs previously returned, so existing consumers stay source-compatible.
*/
export type InteractionPersistedPart = {
type: 'interaction'
id: string
kind: string
title: string
body?: string
answerSpec: { fields: ChatInteractionField[] }
status: ChatInteractionStatus
cancelReason?: string
}

export type NoticePersistedPart = {
type: 'notice'
id: string
noticeKind: NoticeKind
text: string
}

/** Builds the persisted/streamed `notice` part — a one-line transcript notice
* explaining an out-of-band event (warning, auto-declined interaction). */
export function noticePart(noticeKind: NoticeKind, id: string, text: string): Record<string, unknown> {
export function noticePart(noticeKind: NoticeKind, id: string, text: string): NoticePersistedPart {
return { type: 'notice', id, noticeKind, text }
}

Expand All @@ -249,7 +275,7 @@ export function interactionToPersistedPart(
request: InteractionRequestWire,
status: ChatInteractionStatus,
cancelReason?: string,
): Record<string, unknown> {
): InteractionPersistedPart {
return {
type: 'interaction',
id: request.id,
Expand Down
18 changes: 15 additions & 3 deletions src/sandbox/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1035,12 +1035,22 @@ function utf8ByteLength(value: unknown): number {
.byteLength
}

/** Structural slice of the profile the payload gate reads: it only measures
* the profile's serialized size and names `resources.files` in the breakdown,
* so callers composing a payload outside the SDK (products, tests) can pass a
* plain object without casting through `AgentProfile`. */
export interface ProvisionProfileSection {
resources?: { files?: readonly unknown[] }
}

/** The provision-payload sections the size gates need to see. Structural so
* the gate is testable without the SDK's (unexported) create-payload type. */
export interface ProvisionPayloadSections {
env?: Record<string, string>
secrets?: readonly string[]
backend?: { profile?: AgentProfile }
/** `profile` may also be a named-profile string ref (the SDK's
* `BackendConfig` union) — a string ref is tiny and has no files channel. */
backend?: { profile?: string | ProvisionProfileSection }
}

/**
Expand All @@ -1053,7 +1063,7 @@ export function assertProvisionPayloadWithinCap(payload: ProvisionPayloadSection
const total = utf8ByteLength(payload)
if (total <= PROVISION_PAYLOAD_MAX_BYTES) return
const profile = payload.backend?.profile
const files = profile?.resources?.files ?? []
const files = (typeof profile === 'string' ? undefined : profile?.resources?.files) ?? []
const breakdown =
`profile=${utf8ByteLength(profile ?? null)}B ` +
`(files=${utf8ByteLength(files)}B), ` +
Expand Down Expand Up @@ -1549,7 +1559,9 @@ export async function ensureWorkspaceSandbox(
// provision body can never produce a working sandbox — fail loud here,
// before the POST, with the offending section named.
assertEnvWithinLimits(env)
assertProvisionPayloadWithinCap(payload as ProvisionPayloadSections)
// `?? {}` only narrows the SDK parameter's `| undefined`; the literal above
// is always defined. The structural sections type needs no cast.
assertProvisionPayloadWithinCap(payload ?? {})

let box = await client.create(payload)

Expand Down
137 changes: 137 additions & 0 deletions tests/vertical/fake-sidecar.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
/**
* Fake sandbox sidecar for the vertical composition suite: ONE in-memory
* interaction registry that BOTH halves of `/interactions` talk to —
*
* - the producer half: the fake sandbox turn registers an ask via `ask()`
* and blocks on the returned promise (the broker semantics the real
* sidecar's InteractionBroker has);
* - the answer half: `createInteractionAnswerRoute` reaches the same
* registry through `connection.fetchImpl` (GET list / POST respond), so
* answering through the route genuinely releases the blocked turn.
*
* Answer validation is fail-closed like the real sidecar: an `accepted`
* outcome must satisfy the ask's `answerSpec` (a select without `allowCustom`
* only accepts listed option values; required fields must be present) or the
* POST fails 400 INVALID_INTERACTION_ANSWER. Unknown ids fail 404.
*/

import type {
ChatSelectField,
InteractionData,
InteractionRequestWire,
SidecarInteractionsConnection,
} from '../../src/interactions/index'

export interface InteractionResolutionRecord {
outcome: 'accepted' | 'declined'
data?: InteractionData
}

interface Waiter {
resolve: (resolution: InteractionResolutionRecord) => void
promise: Promise<InteractionResolutionRecord>
}

function validateAcceptedAnswer(request: InteractionRequestWire, data: InteractionData | undefined): string | null {
for (const field of request.answerSpec.fields) {
const value = data?.[field.name]
if (value === undefined) {
if (field.required) return `missing required field ${field.name}`
continue
}
if (field.type === 'select') {
const select = field as ChatSelectField
if (select.allowCustom === true) continue
const values = Array.isArray(value) ? value : [String(value)]
const allowed = new Set(select.options.map((option) => option.value))
for (const candidate of values) {
if (!allowed.has(String(candidate))) return `value ${String(candidate)} is not a listed option for ${field.name}`
}
}
}
return null
}

export interface FakeSidecarSession {
/** Producer half: register an ask and get the broker promise the turn
* blocks on. */
ask(request: InteractionRequestWire): Promise<InteractionResolutionRecord>
/** Resolves when every currently-outstanding ask has been answered. */
waitAll(): Promise<Map<string, InteractionResolutionRecord>>
/** Asks still waiting for an answer (the sidecar registry view). */
outstandingIds(): string[]
/** How the answer route reaches this session. */
connection: SidecarInteractionsConnection
/** Every HTTP call the answer route made, for hostile-integrator asserts. */
calls: Array<{ method: string; body?: Record<string, unknown> }>
}

export function createFakeSidecarSession(sessionId = 'session-vertical'): FakeSidecarSession {
const outstanding = new Map<string, InteractionRequestWire>()
const waiters = new Map<string, Waiter>()
const resolutions = new Map<string, InteractionResolutionRecord>()
const calls: FakeSidecarSession['calls'] = []

function ask(request: InteractionRequestWire): Promise<InteractionResolutionRecord> {
outstanding.set(request.id, request)
let resolve!: Waiter['resolve']
const promise = new Promise<InteractionResolutionRecord>((res) => {
resolve = res
})
waiters.set(request.id, { resolve, promise })
return promise
}

const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
const method = init?.method ?? 'GET'
const body = init?.body ? (JSON.parse(String(init.body)) as Record<string, unknown>) : undefined
calls.push({ method, ...(body ? { body } : {}) })
void input

if (method === 'GET') {
return Response.json({ data: { interactions: [...outstanding.values()] } })
}

const id = String(body?.id ?? '')
const request = outstanding.get(id)
if (!request) {
return new Response(
JSON.stringify({ error: { code: 'NOT_FOUND', message: 'interaction not found' } }),
{ status: 404 },
)
}
const outcome = body?.outcome === 'declined' ? 'declined' : 'accepted'
const data = body?.data as InteractionData | undefined
if (outcome === 'accepted') {
const invalid = validateAcceptedAnswer(request, data)
if (invalid) {
return new Response(
JSON.stringify({ error: { code: 'INVALID_INTERACTION_ANSWER', message: invalid } }),
{ status: 400 },
)
}
}
outstanding.delete(id)
const resolution: InteractionResolutionRecord = { outcome, ...(data ? { data } : {}) }
resolutions.set(id, resolution)
waiters.get(id)?.resolve(resolution)
waiters.delete(id)
return Response.json({ data: { ok: true } })
}) as typeof fetch

return {
ask,
async waitAll() {
await Promise.all([...waiters.values()].map((waiter) => waiter.promise))
return new Map(resolutions)
},
outstandingIds: () => [...outstanding.keys()],
connection: {
runtimeUrl: 'https://box.vertical.test/runtime',
authToken: 'tc_vertical-sidecar-bearer',
sessionId,
fetchImpl,
},
calls,
}
}
Loading