Skip to content

v2 overhaul: surface UI, workflows, templates, teams, Slack - #259

Merged
xBalbinus merged 76 commits into
dev-v2from
feat/v2-overhaul
Aug 17, 2026
Merged

v2 overhaul: surface UI, workflows, templates, teams, Slack#259
xBalbinus merged 76 commits into
dev-v2from
feat/v2-overhaul

Conversation

@xBalbinus

Copy link
Copy Markdown

What

One branch consolidating six v2 workstreams that were previously separate PRs (#216, #229, #233, #236, #251, #254). Each is merged in full; nothing was dropped to make the merge compile.

  • Surface UI — workspace switcher, assistants rail scoped to one workspace, attention ping with sound, events/logs surfaces, workflow triggers, team ownership across sessions/workflows/skills, and the pre-connect disclosure screen for integrations.
  • Batch fan-out phase 2 — per-node onError, completion events at settle, and a real listRuns endpoint replacing an N+1.
  • Keycloak team sync — identity-provider groups decide team membership, declared in valet.yaml.
  • Slack agent surface — webhook ingress, streaming bridge, durable stream state with boot and orphan sweeps.
  • Workflow templates — plugin-owned templates with a gallery, install flow, and 8 seeded templates.
  • Dead code removal — deletions only, across the v2 stack.

Why one PR

These six branches touched the same files repeatedly — wire/types.ts, the workflows routes, the integrations surface, and 0000_app.sql. Reviewed separately, each conflicts with the others and the reviewer sees a shape that will not exist after the next merge. Consolidated, the diff is what actually ships.

Notable merge decisions

  • Provenance on teams is one concept. The instance config marked its teams with a deterministic id prefix, which its own code notes is unreliable once an existing team of the same name is adopted. The origin column replaces that for both config-owned and group-owned teams.
  • A tool node carries both onError and the approval-gate fields. They answer different questions: onError decides what a node failure does to the run, onDeny/approvalTimeout decide what a policy gate's refusal or expiry does. A denial is not an error.
  • Two components were invented in parallel — both sides extracted the same brand-colour table out of integration-row. ServiceIcon is the superset and the connect dialog depends on its slug shape, so the template gallery moved onto it and the duplicate was deleted.

Test plan

  • pnpm typecheck clean across all packages
  • api 2771 passing, web 1092, engine 664, workflow 329, plugin-slack 142, store-postgres 128
  • The 5 red api rows are the documented environmental baseline (workspace-prep*.docker, k8s-builder.cluster) and reproduce on a clean dev-v2 checkout

Run the web suite with --no-file-parallelism. Under parallelism a varying handful fail and each passes in isolation — pre-existing cross-file pollution, which vitest.config.ts already documents.

Migration

0000_app.sql gains columns from several branches. rm -rf ~/.valet/pg is required after checkout.

Not covered

  • Slack account linking does not exist yet (identity-links.ts mints codes for Telegram only), so a Slack DM stops at unlinked_sender. Everything downstream is built and tested.
  • 6 of 8 templates are unproven at runtime — they need a connected integration. The 2 with no external service calls were driven to a terminal state; every template's tool contracts and all 52 template paths were verified.

# Conflicts:
#	packages/web/src/api/client.ts
- Validate schedule name (trim, reject blank) and input (must be an
  object) instead of persisting junk into every scheduled run's trigger
  payload.
- Delete a workflow's schedules when the workflow itself is deleted,
  matching the existing webhook cleanup.
- Scope the schedule DELETE route's ownership check to a WHERE predicate
  instead of an org-wide scan-and-.some().
- Dedupe the webhook URL builder (webhookUrl in actions.ts duplicated
  workflowWebhookUrl in webhook-service.ts); the HTTP routes now return
  the same absolute url the agent-facing tool already computed, with the
  request's own origin as a fallback when no public origin is configured.
PATCH/DELETE on a personal (user-owned) subscription now 404 for any
caller but its creator, matching the cross-owner "not found" convention
used elsewhere. Previously any org member could enable/disable or delete
a colleague's personal subscription through the org-scoped routes. An
org-owned subscription stays mutable by any org member.
dev-seed-linear.ts passed PGlite straight to PgCredentialStore, whose
PgQueryable contract requires query() to return rowCount — PGlite's
result type doesn't have one, so this broke the repo's `tsc --build`.
Adapts through a thin query wrapper instead.

dev-events-smoke.ts probed three guessed shapes for the webhook mint
response (hookId/secret/parsed-from-url); imports the real
WorkflowWebhookResponse type instead.
New reusable pieces, extracted after the events+triggers+teams panels
independently reinvented the same patterns:

- OwnerPicker: the caller-or-team select used by the workflow and skill
  editors. Filters to teams the caller can see AND is a member of
  (callerRole !== null) — the prior two copies each offered every team an
  org admin's useTeams() returns, including ones they aren't a member of,
  which the create routes then 404 on.
- SelectMenu: the trigger-button-plus-option-list pattern behind the
  event feed's service/key filters and the subscription dialog's
  workflow picker (three near-identical DropdownMenu blocks before this).
- useCopyToClipboard: extracted from tool-shell's CopyButton (which now
  uses it too) so the workflow webhook section doesn't hand-roll its own
  copy-state timer, and so both handle a denied/unavailable clipboard.
- TabBar: adds the ARIA tabs keyboard contract (arrow keys move focus and
  selection with roving tabindex, Home/End jump to the ends) and an
  aria-controls/tabPanelId pairing for consumers to wire a real tabpanel.
- new-workflow-dialog and skill-editor now share OwnerPicker instead of
  each keeping its own copy-pasted <select>, and route their create
  errors through errorText instead of showing raw error.message.
- Webhook rotate/delete: the ConfirmDialogs now receive the mutation
  error (previously invisible, rendered below the modal overlay) and
  the initial "Create webhook URL" failure has its own error line.
  useMintWorkflowWebhook seeds the query cache from its response so a
  rotate doesn't keep showing the just-revoked URL until the refetch
  lands; useDeleteWorkflowWebhook invalidates onSettled so a failed
  delete still reconciles.
- Schedule delete now confirms (it was one click, unlike every other
  destructive control added this pass) and surfaces its error; the
  empty-state message no longer renders alongside the error message on
  a failed fetch; useDeleteWorkflowSchedule invalidates onSettled.
mutations by owner, close remaining error/reset gaps

- Feed's two filter dropdowns and the create dialog's workflow picker
  now use SelectMenu instead of three copies of the same DropdownMenu
  block. Added a truncation notice when the feed is at the server's
  50-event page size (no pagination yet) instead of silently looking
  complete. The load-failure message now names the Refresh button as
  the corrective action.
- listEvents built its query string with a URLSearchParams.size check —
  unsupported by some engines, which would silently drop the
  service/key filters; switched to .toString().
- The subscriptions list disables the enable/disable switch and hides
  the actions menu for a subscription the caller isn't allowed to
  mutate (mirrors the new server-side gate), and the switch surfaces a
  failed PATCH instead of silently snapping back.
- The create dialog now resets its form and error state on any close
  path (Cancel, overlay, Escape), not just a successful submit — it
  stays mounted between opens.
- teams-panel: dropped a dead Spinner import and routed its two raw
  error.message displays through errorText.

Also adds the ARIA tabpanel half of the /events tab strip (role,
aria-labelledby) to match TabBar's new aria-controls wiring.
…ent-native run status

Splits the 522-line node inspector into one form file per node type
(mirrors the tool-renderer registry pattern). Consolidates four duplicate
date formatters onto lib/format-when.ts and three hand-rolled clipboard
handlers onto lib/use-copy.ts (upgraded with the execCommand fallback one
of them had, so nothing regresses). Upgrades the run-detail checkpoint
list from a plain-text status list to color/glyph-coded rows sharing the
canvas's own NodeRunStatus vocabulary, with failed nodes auto-expanding
their result and everything else collapsed by default. Gives the pending-
approval card real visual prominence instead of a plain bordered box.
Demotes the editor toolbar's least-used action (version history) into an
overflow menu, cutting the primary row from 4 buttons to 3.
engineToWireParts previously dropped thinking parts alongside
attachment/error. Mirror them onto the wire and render a
collapsed-by-default disclosure in the transcript.
Consolidates markdown-fenced code onto one component: selective Prism
language registration (a few KB, not the ~290-language bundle) and
CSS-token-based coloring so blocks track the app's light/dark theme
instead of a fixed prebuilt Prism theme.

Also removes @fontsource/newsreader, dead since the brand-refresh
swapped the display font family; corrects that spec's earlier (wrong)
claim that the font was never loaded.
Stamp turnStartedAt in the stream store on the idle -> non-idle
transition (cleared on turn_end/init), and render it as a live
ticking counter on the agent status badge while a turn is running.
Reuses the existing OwnerPicker component (already used for workflow
ownership) so a skill source can be imported as team-owned, with a
badge on the row showing which team owns it.
createWorkflowSchedule always wrote ownerType "user" regardless of who
actually owned the workflow being scheduled. Derive it from the
workflow's real owner instead (team ownership maps to org, since the
schedule owner enum has no team value).
Team orchestrators were structurally real (workflow dispatch could
already wake one) but had no way to view them: nothing created the
app-row on first access, and the read routes only checked direct
session ownership.

Adds POST /api/teams/:id/orchestrator (get-or-create, reusing the
existing ensureOrchestratorSession helper) and widens GET
/api/sessions/:id plus the messages/threads/decisions routes and the
WS handshake to also allow a caller who can view the owning team.
Read-only and team-scoped — no mutating session route changes, no org-
level access.

TeamsPanel gets an "Assistant" button that opens the team's
orchestrator session, visible to any member.
A team-owned session's `agent_sessions.userId` records whoever opened the
team's assistant first — `ensureOrchestratorSession` stamps the actor. The
model, pause and delete routes read that stamp as ownership, so the first
member to open a shared agent could pause or delete it, and every other
member got a 404.

Adds `canAdministerSession` (team-owned => team admin or org admin) as the
deliberate mirror of `canViewSession`, and extracts `canAdministerTeam` so
the team mutation routes and session administration share one definition.

Auto-title had the same filter, which left a member's threads permanently
untitled. Titling is part of prompting, so it moves to `canViewSession`.
A team's assistant was reachable only at Settings > Organization > Teams,
behind an org-admin gate most members fail. Teams are not a setting, so
each team you belong to now gets a permanent row in the /chat sidebar
beside your own assistant, addressed by `?team=`.

The session id is derived client-side, so browsing the rail creates
nothing; /chat ensures the session only when you open the conversation. A
strip above the composer names who else can read what you type.

Also fixes the header titling a team assistant with the VIEWER's own
assistant name, and hides the lifecycle controls the API now refuses to
plain members. The settings button stays as a cross-link, not a second
door. A dashboard card covers the collapsed-sidebar case; the usage card's
"Team" heading becomes "Organization", which is what it always listed.

Nothing renders for a caller with no teams.
Workflows, skill cards, skill docs and skill repositories each resolved
the owning team's name for themselves. Four copies had drifted into four
looks, and the skill card never resolved the name at all — it rendered the
literal word "Team" and never called useTeams, a bug skill-doc and the
skill detail page both inherited through the same helper.

The badge links to that team's assistant, so the flat lists people already
open become a second way in. The skill card's whole-card link becomes a
stretched overlay so the badge stays clickable without nesting anchors.
A subtraction pass over the whole product. Every candidate was searched
for independently and then argued against before it was cut, so roughly
forty proposals were talked back down — the /primitives showcase is the
brand-refresh verification surface, the /orchestrator redirect still
serves bookmarks from the deployed client, and the pause control's 409
only fires on the docker dev backend.

What went, ~620 lines:

- The legacy prebuild/image-catalog wire block, whose routes were
  unmounted long ago. docs/api-reference.md still advertised three of
  them; two service doc comments and an e2e header named others.
- WorkflowRunDetail.signals, fetched on every five-second run-detail
  poll and read by nobody.
- The EditorModel class and applyAutoLayout; the sidebar drag-to-resize
  machinery (its pseudo-element also drew the aside's only border, now
  a real one); nine Hono RPC type aliases with no RPC client.
- SessionViewVariant, a three-value enum branched on twice for the same
  value, now a boolean.
- Header count strips on Skills and Integrations, the model picker's
  keyboard legend, a read-only Created row, and a run status rendered
  twice beside its own badge.

Also folds two copies of the blank workflow definition into one. The
surviving copy seeds node positions, which the live one did not.

Deferred deliberately: the app messages table (its migration forces a
local database wipe), the sdk analytics export (published, so an
out-of-repo consumer could break), and repo-wide noUnusedLocals.
`badge.tsx` styled two of its variants with `bg-success-500/15` and
`bg-danger-500/15`. Those scales are raw `oklch(...)` strings in the
Tailwind config, so the slash modifier produced NO rule at all — checked
against the running stylesheet, neither class exists — and both badges
rendered as bare coloured text with no pill.

theme.css already documents this trap and the way out: bake the alpha
into a pre-mixed wash token. The `accent` variant was fixed that way;
these two were missed. Adds --success-wash, --danger-wash and their dark
values, and points the variants at them.

Adds a `warning` variant on the same footing. Amber is the palette's
documented "waiting" colour, so it carries the states that are blocked on
a person — which the run-state work needs and would otherwise hand-roll.
`handleDecisionGate` returned early unless the gate came from a child
session, so a gate raised in a standalone session or in a user or team
orchestrator routed no attention at all. The session blocked on a person
and told nobody, indefinitely.

The child-to-parent routing was right and is unchanged — a child has no
independent audience. It was just not the only case. Every other session
now notifies its own owner, the same way `handleSubmissionStuck` directly
above already did. A child whose parent row is missing falls back to its
own owner: a weaker audience beats silence.

The replaced test asserted the old behavior as if it were correct.
The Sessions list could only report the row's lifecycle (active,
hibernated), never what a session was DOING. Five surfaces were each about
to invent their own word for "working".

Adds `SessionRunState` to the wire — needs_you > working > failed >
sleeping > idle, one precedence, derived in one pure module and used by
the list, the detail routes and the WS init frame so no two surfaces can
disagree. `RunStateBadge` renders it; "Needs you" names who is blocking
rather than the mechanism, and takes the palette's waiting colour.

The list stays at three round trips whatever the session count — one
cross-session read of unsettled submissions, grouped by id. A per-row
query would have made an ordinary list cost one query per session.

Two limits are real and documented rather than papered over: a pending
gate is read from the `blocked_on_decision_gate` submission status
because no cross-session gate read exists, and `failed` only shows while
a failed turn is terminalizing — settled outcomes are readable one
session at a time, so a session whose failure already settled reads idle.

`initialPrompt` shipped on the wire documented as "server enqueues
immediately after creation" and was read nowhere. It now enqueues through
the same path the messages route uses, extracted rather than duplicated.
A failed enqueue does not fail the create: the row exists and the caller
has its id.
The engine already accepts a mid-turn message; the composer refused to
send one. It now sends in every state, and the label comes from the live
queue mode rather than an assumption.

That distinction matters more than it looks. `EngineHost` gives a user's
own orchestrator the `steer` mode and every other principal `followup` —
team orchestrators included — so a fixed "Steer" label would promise an
interrupt that never happens on exactly the team assistants the sidebar
just made prominent.

The steer copy is literal. A steer admission supersedes the running
submission and aborts the live run, so it says the turn stops and the new
message starts, not that it joins the turn in progress.

Also keeps `ev.error` on a settled submission. The wire carried the
reason and the store dropped it, leaving a grey "failed" with nothing
behind it.
A background agent that blocks on a person and cannot reach them is this
product's core failure mode: the work stops, and the only way to find out
is to go looking. The bell already collected these, but a badge helps
nobody who tabbed away — which is the entire point of a background agent.

Only the kinds that block on a person ring: question, escalation,
approval. A general update goes to the bell and the title, never the
speaker. A sound that fires when nothing is needed is a sound people
switch off, and then the one that mattered is silent too.

It also stays quiet when you are already looking at the page the
notification points to — the decision card is on screen and a chime tells
you nothing you cannot see. One ping per burst, and nothing on first load,
so a refresh does not replay the backlog as fresh alarms.

The tone is synthesised rather than shipped as an asset: two short sine
tones rising a fifth, low gain, smooth envelope. Browsers block audio
until a real gesture, so the context resumes on the first interaction and
the document title carries the count regardless — that is the signal that
survives a muted tab, blocked autoplay, or headphones out.

The device-local toggle lives in Settings, on by default. Whether a
machine may make noise depends on where it is, not on who signed in.

Also clears a close-on-blur timer in the new-session combobox on unmount.
It set state on an unmounted tree and threw after teardown, which had been
raising a permanent error in the suite.
Creating the Slack app by hand means transcribing scopes, events and two
request URLs into a form, where one wrong entry fails silently later.
`GET /api/org/slack` returns a manifest an org admin pastes into Slack's
app-creation form instead. Slack's `apps.manifest.create` needs an
app-configuration token this deployment does not hold, so the flow is
paste-in rather than API-driven.

The scopes, the bot events and the ingress path live in one module
because three call sites must agree or the integration breaks with no
visible error: this route hands them to the operator, the credential save
checks the installed app really granted them, and the webhook route only
ever receives the events declared here. A scope in the manifest but not
in the save-time check surfaces weeks later as an opaque `missing_scope`
from one API method.

The manifest targets Slack's current agent messaging experience. The
feature key is `agent_view`; the older `assistant_view` is deprecated and
its nested description is renamed. `messages_tab_read_only_enabled` is
forced false — Slack's own scaffolding defaults it true, which disables
the composer so the user cannot type to the agent, and nothing reports
why.

A deployment with no public URL gets a Socket Mode manifest, because
there is no URL Slack could reach. The request URL is composed from the
same constants `app.ts` mounts, so what an operator installs cannot drift
from what the api serves. The response also reports connection state and
any requested scope the install withheld, read from the scope list
recorded at connect time rather than a fresh call to Slack.
Every way of misconfiguring Slack fails silently and late. A wrong
signing secret only shows up as 401s on an unauthenticated webhook nobody
watches. A user token pasted instead of a bot token posts as that person.
A workspace that never granted `assistant:write` fails on the first
`assistant.threads.*` call, hours after the operator left the settings
page.

`PUT /api/credentials/slack?scope=org` now pays one round trip to
`auth.test` and turns each of those into an error the operator reads
immediately, each naming the fix. A user-scoped Slack credential is a
personal token for the action plugin and is left alone.

The same call records the identity the rest of the integration needs. The
workspace id is the security boundary the webhook route enforces: a Slack
app's signing secret is valid for every workspace that installs the app,
so a valid signature alone does not prove an update is ours. The bot user
id is what the transport suppresses its own echo on. The granted scopes
are stored so the setup route can report missing ones without calling
Slack again.

A missing scope header is treated as unknown rather than as nothing
granted, so a proxy that strips it cannot break every save.
The host derived a conversation key from a thread key by assuming the
whole address fits in the thread key: `${channelType}:dm:${rest}`. That
holds for Telegram, where the chat id is the address. It is false when
the key also names a workspace, and the cost is silent: every gate
prompt, command result and attention DM for such a transport is
addressed with a key the transport never minted, so a reply posts
somewhere plausible instead of failing.

The host now asks the transport for both directions of the mapping when
it owns them, and stops rather than guess when the transport disowns a
key. The inbound half uses the same hook, so the two cannot drift apart.

Attention DMs get the same treatment. A sender id is not always an
address — Slack's `U…` names a person while `D…` names the DM with them —
so the host asks the transport to open the direct conversation when it
knows the difference.

A transport factory may now reject a credential it cannot serve. That
rejection is contained per transport: one bad credential must not stop
the transports after it, nor the outbound queue. The message names the
fix and lands in the startup log.

`surface_opened` ends without a drop-log row. Someone opened the
conversation and said nothing, so there is no turn to start, but a row
per DM open would bury the reasons that matter.
The engine publishes `text_delta` through `publishEphemeral`: live-only,
no offset, no replay. A provider stream is the opposite — a long-lived
object with no documented timeout. Between those two facts sits the ugly
case. If the api dies between start and stop, nothing in the event log
says a stream was ever open, and the reader keeps a message that shimmers
until they give up on it.

`channel_active_streams` holds one row per stream the api has started and
not yet stopped, so the next boot can close what a previous one left
open. The deltas are not recoverable and this does not pretend otherwise:
`message_end` is durable and the web session shows the full answer.

The table is keyed on the provider's own message id, which is unique per
conversation, so a conflicting insert is a retry rather than a second
stream and overwrites instead of failing the turn. Nothing here is
Slack-specific — any transport that implements the start/append/stop
triple is swept by the same code.

The store is an interface with a Postgres and an in-memory
implementation, so the bridge's own tests need no database.
Replies arrived as one finished message after the whole turn. On a
surface that renders text as it is produced, the reader watches nothing
happen for as long as the model runs. `ChannelStreamBridge` subscribes to
the engine bus and drives the provider's stream instead: open on the
first `message_start`, append batched text, close on `message_end`.

Most of the code is the other paths, because each one ends with a real
person looking at a message that never stops shimmering. The turn fails
or is aborted. The reader presses stop. The api restarts mid-stream. The
provider rate-limits an append. The turn parks on an approval gate. Every
abnormal ending closes the stream with a line saying what happened and
what to do next.

Two engine facts shape the design. `text_delta` carries no messageId, so
the active message is tracked off the preceding `message_start`. And
deltas are ephemeral — live-only, no replay — so an open stream is
recorded durably and swept at boot.

Deltas are batched behind a cadence floor rather than posted one per
call, which would breach the provider's one-per-second guidance within
the first second of a turn. A flush stops at a clean markdown boundary so
the reader does not watch an unterminated code fence, but only up to a
bound: text that renders untidily for one interval beats text that never
arrives. Long answers roll over to a continuation message before the
provider's per-message ceiling turns into an error with no way back.

Failures are classified, never guessed. A rate limit keeps every
character and waits. A dead stream drops the row and finishes through
ordinary messages. A reader pressing stop aborts the turn too, because
continuing spends tokens on output with nowhere to go. Anything
unclassified is fatal for the stream and the remaining text still goes
out. Whatever happens, the answer reaches the reader by some route.

Two sweeps cover what the normal path cannot. The boot sweep closes
streams a previous api left open. The orphan sweep catches rows this
process wrote and then lost, and never touches a stream it is still
writing to. A close that keeps failing keeps its row, because the row is
the only record the message is open — bounded so a permanently failing
close does not leave an immortal row.

The host skips its own delivery of a streamed message, or the reader
sees the same answer twice. It also closes the stream before an approval
card, so the card lands after the text that led to it.
Slack delivers everything an app receives to one app-level URL: Events
API envelopes as JSON, interactivity as a form-encoded field. The generic
`/:channelType/webhook` route cannot serve it, because one delivery has
two consumers and the secret is provider-issued rather than host-minted.

This route verifies the signing-secret HMAC once and fans each update out
to both: the channel host for the agent surface and approval callbacks,
and the trigger definitions plus ingest for workflow subscriptions. It is
mounted before the auth middleware because the caller is Slack, and
before `channelsRouter` so the more specific path wins.

Slack expects a response inside three seconds and redelivers when it does
not get one. A turn takes far longer, so the fan-out runs after the
response. A redelivery is logged and then processed like any other
delivery: Slack redelivers because the first attempt produced no 2xx, so
dropping one would turn every transient failure on this route into a
message the user typed and Valet silently lost. Processing twice is safe
because both consumers dedupe durably on ids Slack repeats across
retries.

A valid signature is not enough on its own. A Slack app's signing secret
is valid for every workspace that installs the app, so an update must
also name the workspace recorded at connect time; anything else is
dropped. A half-configured org is acked rather than 401'd, so Slack does
not retry against an endpoint that will keep failing. The handshake echo
answers before verification, because that is how the endpoint is enabled
before any credential exists.

Ingest gains match-gated persistence for keys marked ephemeral.
`slack.message` is a firehose, and subscribing is now what turns it on —
an unsubscribed key never touches the events table.
…verhaul

# Conflicts:
#	Makefile
#	docs/specs/2026-07-13-assistant-centered-web-ui-design.md
#	docs/specs/2026-07-16-workflows-overhaul-design.md
#	packages/api/src/routes/messages.ts
#	packages/api/src/routes/orchestrator.ts
#	packages/api/src/routes/plugins.test.ts
#	packages/api/src/routes/plugins.ts
#	packages/api/src/routes/workflows.test.ts
#	packages/api/src/routes/workflows.ts
#	packages/api/src/wire/types.ts
#	packages/api/src/workflows/service.ts
#	packages/engine/src/plugin-catalog.ts
#	packages/web/src/api/client.ts
#	packages/web/src/components/session/composer.test.tsx
#	packages/web/src/components/session/message-item.tsx
#	packages/web/src/components/session/session-header.test.tsx
#	packages/web/src/components/session/session-header.tsx
#	packages/web/src/components/session/thread-tree.tsx
#	packages/web/src/components/session/tool-renderers/tool-shell.tsx
#	packages/web/src/components/workflows/editor/inspector.tsx
#	packages/web/src/routes/-integrations.test.tsx
#	packages/web/src/routes/-workflows.runs.$runId.test.tsx
#	packages/web/src/routes/workflows.$workflowId.tsx
#	packages/web/src/routes/workflows.index.tsx
#	packages/web/src/routes/workflows.runs.$runId.tsx
#	packages/web/src/stores/stream.ts
…feat/v2-overhaul

# Conflicts:
#	packages/api/src/providers/node.ts
#	packages/api/src/routes/workflows.ts
#	packages/api/src/wire/types.ts
#	packages/api/src/workflows/actions.ts
#	packages/api/src/workflows/service.ts
#	packages/web/src/api/workflows.ts
#	packages/web/src/components/workflows/editor/inspector.tsx
#	packages/web/src/routes/-workflows.runs.$runId.test.tsx
#	packages/web/src/routes/workflows.index.tsx
#	packages/web/src/routes/workflows.runs.$runId.tsx
#	packages/workflow/src/dag/nodes.ts
#	packages/workflow/src/interpreter.ts
#	packages/workflow/src/local-host.ts
The workflows page offered nothing but an empty state. Templates are the
starting points, ported from the V1 gallery that was removed from main.

Architecture, not code, came across. V1 was Workers, D1 and
IntegrationPackage with a draft/publish split; v2 is Hono, Postgres and
ValetPlugin with one definition row and immutable version snapshots. What
survives is the shape: a plugin owns the templates for the integrations it
owns, so enabling a plugin adds its templates and disabling one removes
them, and a template is installed by the same create path a user's own
workflow takes.

Eight templates, each drawn from a use case the team actually runs: a
nightly memory sweep, a daily triage digest, weekly meeting prep, one
instruction over a batch of rows, a daily development digest, a nudge on
quiet pull requests, an inbox sweeper, and weekly issue triage.

Install is atomic. Everything after the definition row sits in a rollback,
so a validation failure or a trigger collision leaves nothing behind, and
webhook paths carry a uuid-derived suffix because they are globally
unique.

A template whose integration is not connected says so instead of offering
an install that would fail.

WorkflowInputDefinition regains label, placeholder and hidden, so a
template declares each input once in the trigger schema and the run form
is derived from it rather than duplicated.

Verified by running, not by reading: two templates were installed through
the real route on an isolated api and driven to settled/completed, and all
52 template paths across every shipped template were resolved against the
real template context. Six templates remain unproven at run time because
they need live GitHub, Gmail, Linear or Slack credentials.

web 808 passing, workflow 303, engine 39, plugin suites 132, api template
suites 155, typecheck clean.
…/v2-overhaul

# Conflicts:
#	packages/api/src/routes/teams.ts
#	packages/web/src/components/settings/teams-panel.tsx
#	packages/web/src/routes/-settings.organization.test.tsx
…overhaul

# Conflicts:
#	packages/api/src/channels/host.ts
…v2-overhaul

# Conflicts:
#	packages/api/src/channels/host.ts
#	packages/api/src/routes/workflows.ts
#	packages/api/src/wire/types.ts
#	packages/engine/src/session.ts
#	packages/web/src/api/queries.ts
#	packages/web/src/components/session/tool-renderers/tool-shell.tsx
Sixth branch into the overhaul. Templates were built off dev-v2 before the
five-branch consolidation, so they arrive as their own merge.

Two components were invented in parallel on both sides: both pulled the
brand-colour table and monogram tile out of integration-row into their own
file. They are not one superseding the other — the colour table, fallback
palette and hash are byte-identical. ServiceIcon is the superset (real
brand marks for 13 services with a monogram fallback, three tones, and the
slug threaded from the wire), and the connect dialog already depends on
its slug shape, so the template gallery moves onto it and ServiceMark is
deleted rather than left as a second way to draw the same thing.

A tool node now carries both onError and the approval-gate fields. They
answer different questions: onError decides what a node FAILURE does to
the rest of the run, onDeny and approvalTimeout decide what a policy
gate's refusal or expiry does. A denial is not an error, so one field
cannot serve both.

Two files merged without a conflict and still needed fixing: the workflows
index test kept both sides' halves and produced a type annotation missing
the owner fields, and two new tests rendered the page bare while a team row
now needs a tooltip provider.

api, web, workflow and engine typecheck clean; web 1092 passing, workflow
332, api template suites 155.
Three changes to the template surface and its tests.

The gallery is scanned, not read, and each card said the same thing three
times: the service chain drew the marks, a footer line spelled the same
services out in text, and a sentence above the button repeated the button's
own label. Two of those go. A card now carries the marks, the name, two
lines of description, the cadence, and one control; the full text and the
step list stay in the install dialog, where the reader has already chosen.
A template whose services are not connected still cannot be installed, and
the button names the service to connect.

Every template also carried an icon field holding an emoji. That field was
dead — not on the WorkflowTemplate type, never mapped to the wire, never
read by the client — so it is deleted rather than swapped for an icon name.
No emoji remain in the client.

Separately, vitest 4's jsdom environment provides window, document and a
real origin but no localStorage. The workspace scope persists there and its
provider reads it during the first render, so about forty tests failed
before asserting anything. The setup file already shims four other jsdom
gaps; this is the fifth.

Three tests asserted the removed card strings. They now assert the
affordance that survived rather than being deleted.

Web suite 1092 passing across 123 files, run sequentially. Under file
parallelism a varying handful fail and each passes in isolation — pre-
existing cross-file pollution, the reason the vitest config already
documents reverting isolate: false.

@valet-valet-turnkey-dev valet-valet-turnkey-dev Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The diff is enormous (446 files, 37k+ additions) but is architecturally coherent and the test coverage is substantial. A handful of real issues are worth calling out.

  1. packages/api/src/assistants/service.ts:262 — createAssistant retry path has a silent data-loss window

    When the first onConflictDoNothing insert loses the default-slot race it retries with isDefault: false. But the retry itself uses plain .insert(...).values({...row, isDefault: false}).returning() with no onConflictDoNothing. If two concurrent callers both tried to create the very first assistant for a principal, one wins the isDefault: true slot, and the other's retry inserts isDefault: false — fine. But if a third concurrent call (or a fast retry loop) races the retry on the same id (row.id is pre-generated), the second insert will throw a primary-key conflict rather than return cleanly. The error propagates as an unhandled exception instead of surfacing a useful row.

    • The retry should also use .onConflictDoNothing() and then re-read on an empty result, same pattern as resolveDefaultAssistant.
  2. packages/api/src/channels/stream-bridge.ts:~590 — closeRows drops the durable row after STREAM_ABANDON_AFTER_MS even when tryStop returned "retry"

    The loop is:

    const outcome = await this.tryStop(...);
    if (outcome === "done") { closed += 1; }
    else if (this.now() - row.startedAt < STREAM_ABANDON_AFTER_MS) { continue; }
    else { console.error(...); }
    await this.deleteRecordFor(...); // always reached when not continue'd

    A row older than 24 h that still fails stopStream (e.g. Slack is down) gets its durable row deleted without closed incrementing and without stopStream succeeding. The shimmer is then permanent: no row left for future sweeps, no successful close. The continue should encompass the give-up branch too, or the delete should be guarded by outcome === "done".

  3. packages/api/src/routes/events.ts:~370 — double-canMutateSubscription definition / dead comment block

    Two consecutive JSDoc blocks appear at the same position (lines ~37-63 in the patch) — an unused first comment followed immediately by the real one. The first ("A subscription is mutable by...") was not removed when the second was written. No runtime impact but worth cleaning to avoid confusion about which comment describes the function.

  4. packages/api/src/channels/host.ts:~800 — ensureDefaultAssistantSession called in handleGateCallback without eviction guard

    In the gate-resolution path, the code calls ensureDefaultAssistantSession to get the session, relying on the invariant that channel-keyed threads only exist on the default assistant's session. The comment acknowledges this invariant but also notes it would fail safe if violated. However, ensureDefaultAssistantSession creates the assistant row and session on first use. If a user's default was promoted to a different assistant after a gate was opened, the callback resolves a different (newly created?) default, and resolveDecision throws on a gate id that doesn't exist — caught by the outer try/catch but silently swallowed, leaving the gate permanently pending. This is acknowledged as a known edge case but worth a // TODO at minimum, since it produces a hung approval with no user-visible error.

  5. packages/api/scripts/dev-seed-linear.ts:45 — DEV_WEBHOOK_SECRET exported from a script

    export const DEV_WEBHOOK_SECRET = "dev-webhook-secret";

    Scripts under scripts/ are dev-only but the export keyword means this constant can be imported by application code. Given the constant is hardcoded to "dev-webhook-secret", the risk is low, but the export is unnecessary for a dev seed script and should be removed or the file should not be importable from application paths.


Created on behalf of Xiangan He xiangan@turnkey.io

Brings the triggers UI and tabbed Workflows hub, DinD sandboxes, and
per-profile sandbox sources onto the consolidated branch.

The hub and our template gallery both put tabs on /workflows. Upstream's
hub primitive wins — it keeps tab state in the URL — and the gallery is
mounted as a fourth tab inside it rather than as a second tab system. Our
zero state made the gallery the whole page with no tabs; Runs and Triggers
must stay reachable with no workflows, so the tabs always render and the
gallery shows inline when the list is empty.

Two files git merged without a conflict and produced broken behaviour.
Upstream added GET /workflows/runs in a second router that mounts first,
so its unfiltered handler silently shadowed ours and our filters, the 404
on an unreadable workflow, and the cursor validation all stopped working;
the two are now one handler that also stamps the workflow name upstream's
Runs tab needs. A router mock in the workflows index test ended up with a
duplicate key, so the second won and returned an empty search forever,
disabling upstream's tab tests.

Two schedule UIs also survived the merge — a panel in the editor drawer
and a strip below the canvas, two lists over one resource. The drawer now
holds upstream's TriggerList, which does everything the removed section
did and more.

api 3001 passing with the five environmental failures, web 1109 across 125
files run sequentially, four typechecks clean.
@socket-security

socket-security Bot commented Aug 17, 2026

Copy link
Copy Markdown

Warning

Review the following alerts detected in dependencies.

According to your organization's Security Policy, it is recommended to resolve "Warn" alerts. Learn more about Socket for GitHub.

Action Severity Alert  (click "▶" to expand/collapse)
Warn High
Obfuscated code: npm highlight.js is 90.0% likely obfuscated

Confidence: 0.90

Location: Package overview

From: pnpm-lock.yamlnpm/react-syntax-highlighter@16.1.1npm/highlight.js@10.7.3

ℹ Read more on: This package | This alert | What is obfuscated code?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: Packages should not obfuscate their code. Consider not using packages with obfuscated code.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore npm/highlight.js@10.7.3. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

View full report

plugin-slack declared hono and @hono/node-server as devDependencies and
imported neither — nothing in the package references either name. They
pulled undici 8 into the install, which replaced the global fetch that the
api's own tests use, and undici rejected the oversized content-length the
two body-cap tests deliberately send. Both 413 tests failed inside the
test's own fetch call, before reaching any route.

This is the workspace trap CLAUDE.md already documents: a new dependency
edge silently changing a shared singleton. It was read as a lockfile drift
and treated by running an install, which recorded the new dependency
instead of removing it.

dev-v2 CI is green on the same commit, which is what identified this as
ours rather than pre-existing. Running the suite on this machine could not
have found it — Node 24 fails those tests locally either way.

api back to 3001 passing with only the five environmental failures, web
1109 across 125 files.
Both packages declared the Kubernetes client as "latest", so every fresh
install resolved whatever was newest rather than what the lockfile pinned.
A recent install floated it from 1.4.0 to 2.0.0, which brought undici 8.

undici 8 replaced the global fetch the api's own tests use, and it rejects
the oversized content-length the two body-cap tests deliberately send, so
both 413 tests failed inside the test's own fetch call before reaching a
route. pi-ai pins undici 7; nothing else wanted 8.

This was latent on every branch, not introduced by this one. Any install
would have tripped it, and a lockfile is no defence when the range is
"latest" — it only records which install got there first.

Pinned to ^1.4.0, the version dev-v2 resolves and the deploy has been
tested against. Zero undici 8 edges remain.
Applies the audit of packages/web against the Web Interface Guidelines.

The shared fixes carry the most weight. ErrorRow renders through a live
region, so a load failure that arrives after paint is announced instead of
reading as an empty list — the case that made a 500 on the threads route
look like a workspace with nothing in it. Three memory routes rendered a
second main landmark inside the shell's; they are plain containers now.
Nine native selects set a colour as well as a background, without which
Windows dark mode paints unreadable text.

Destructive actions ask first. Restoring a version overwrites the live
definition, cancelling a run stops it where it stands, and deleting a
workflow takes its versions, schedules, subscriptions and webhook with it;
each now says so and each surfaces its server error beside the button
that caused it rather than behind the dialog.

Two findings did not reproduce and were reverted rather than applied. A
Radix dialog already blocks the scroll chain through react-remove-scroll,
so an overscroll rule there was inert. The tab bar never suppressed its
focus outline, so adding a ring would have swapped a working indicator for
a differently coloured one — a restyle, not a compliance fix.

Left deliberately: focus-visible:ring-accent-500/40 on inputs and dialogs
emits no rule, because an opacity modifier on a raw oklch value produces
nothing. Tailwind's default ring colour still paints, so the ring is
visible but generic blue rather than brand. Fixing it changes the ring on
every input and dialog, which is past this pass.

web 1121 passing across 125 files, typecheck clean.
A quick tunnel changes hostname on every restart, so an App's webhook URL
goes stale and inbound events stop with nothing to see. GitHub exposes
PATCH /app/hook/config under App-JWT auth, which this service already
mints, so the instance can keep the URL current itself.

It only ever touches an app this instance owns. loadAppConfigWithSource
already separates an app created through this instance's manifest flow
from one supplied by GITHUB_APP_*, and the second is how a deployment or a
shared team app arrives. Syncing that one would hand the team's app to
whoever booted last, and two developers would take it from each other on
every start.

The rest is restraint. It reads before it writes, so a matching URL costs
one request and no change. It never clears: an instance with no public URL
leaves the app alone rather than unsetting a URL some deployment set
deliberately. It cannot throw, because it runs at boot and inside the
manifest redirect, where a GitHub outage must not stop the server starting
or strand an admin mid-flow.

This fixes half the symptom. GitHub has no App-JWT endpoint for
callback_urls, so user OAuth still points at the host that existed at
creation and still needs a hand edit when the tunnel moves. The commit
does not pretend otherwise.

api typecheck clean; 453 passing across the github-app route and service
suites, including a test that asserts an environment-sourced app receives
no request at all.
@socket-security

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addednpm/​react-syntax-highlighter@​16.1.18910010087100
Addednpm/​simple-icons@​16.28.010010010099100

View full report

@xBalbinus
xBalbinus merged commit 2ee17fa into dev-v2 Aug 17, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant