* ✨ feat(docker): run self-hosted Compose chats in Gateway Mode by default
- Add the lobehub-gateway service to docker-compose and wire the lobe service
to it; AGENT_GATEWAY_INTERNAL_URL keeps server pushes on the Compose network
while browsers use AGENT_GATEWAY_URL.
- S3_INTERNAL_ENDPOINT routes the server's own S3 requests to rustfs:9000 while
presigned URLs keep the browser-facing S3_ENDPOINT, which local mode sets to
localhost and the container cannot reach.
- setup.sh generates a per-deployment JWKS_KEY and gateway token and writes the
gateway URL for each deploy mode; the .env template drops the public JWKS key.
- Warn loudly at container startup when an upgraded stack lacks the gateway
settings or still uses the public example JWKS_KEY, and document the upgrade.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* 🔒 fix(docker): pass only the public JWK to the gateway container
The gateway only needs the public half of JWKS_KEY to verify browser
sessions, but it received the full private key set, so anyone able to read
the gateway environment could sign tokens LobeHub trusts. setup.sh now also
writes JWKS_PUBLIC_KEY (the same key without d/p/q/dp/dq/qi), Compose passes
only that to the gateway, and the startup check warns when it is missing,
still contains private fields, or does not match JWKS_KEY.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* ✨ feat(goal): add the process-control surface to the Goal detail page
Move the prototyped process-control UI into the product: the frontier list
(what can move now), the exploration map on react-flow, the findings list and
a per-node activity feed.
Data comes from one `goal.graph` read. The server has no frontier projection,
no per-node attempt roll-up and no artifact hydration, so the client
reproduces the coordinator's own selection rule and derives the attempt ledger
from the `goal_events` trail. Anything that cannot be derived honestly is
omitted rather than invented.
A Goal Graph goal has no carrier task, so the detail route falls back to a
graph-only page when the identifier resolves to no task.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* ♻️ refactor(goal): make the Goal Graph the only goal model
A goal used to be a `goals` row bound to one carrier task, driven by an outer
loop in the verify settle path. That was the early attempt; the Goal Graph
supersedes it — the graph owns the decomposition and dispatches a Task per
Work node, each with its own acceptance contract, and stops on a decision gate
when a Work runs out of attempts.
Server
- `GoalModel.list` is goal-centric: it reads `goals` and derives the roll-up
(work done/total, findings, pending gates, cost) from the graph, so a goal
without a carrier task is no longer invisible.
- Delete the task-carried outer loop: `verify/goalLoop`, `verify/goalBudget`,
the goal branches in `settle` / `acceptanceService` / `planInstantiation`,
and the goal binding in `TaskService.createTask` and `task.create`.
- `/goal` now creates a Goal Graph and advances it once; the coordinator
creates the responsible task and its verifier.
- New `goal.delete`; `task.deleteGoal`, `hasGoal` filters and `TaskItem.goal`
are gone with the flow that needed them.
Client
- Goal list, detail, home rail and the conversation card read the graph.
- The goal detail route is keyed by the `goals` row id.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* 🐛 fix(goal): read the attempt ledger from real attempt boundaries
Found while driving a goal end to end: the frontier row of a running task
showed no clock, and a finished task's attempts were labelled from a
bookkeeping event.
`goal_events` writes `updated` for housekeeping too ("Attached Work version
…"), and every `updated` was closing the open attempt. That ended the live
attempt one event early — so `startedAt` was never set and the running clock
disappeared — and stamped the housekeeping reason onto the previous attempt.
Only `activated` now opens an attempt, and only a boundary closes it: the next
`activated` (the coordinator started another attempt, so this one did not
deliver) or a terminal event. A Work parked at a decision gate leaves its
attempt open, since the gate is written as `updated`, so the node's own status
closes it.
A gate row also showed an empty ledger, because attempts live on the Work and
the row is the decision node. The gate now carries the Work it was opened for
and renders that Work's ledger — which is the case the user is deciding.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* 💄 style(goal): quiet the exploration map and make one press run the goal
Review feedback on the acceptance round, item by item.
The map was shouting the same thing in three places at once — a pill floating
on the card's outline, an emphasis border, and a drop shadow — which left no
room for the edges routed around it:
- cards go flat; state becomes a dot plus a word on the header's trailing edge,
where the "you took part" mark now sits too;
- edges become bezier curves instead of right-angled steps;
- the frontier emphasis border is gone. It was drawing a heavy outline around a
*completed* card, which is the wrong signal entirely; selection keeps a single
light border;
- inline, the map is a picture: no zoom controls, no pan, no zoom. Fullscreen is
where it becomes navigable.
The advance button ran a single coordinator tick. That is an implementation
unit, not one a person cares about — dispatching a work takes two ticks, so one
press usually looked like it did nothing. It now advances until the goal is
achieved, needs a decision, or has nothing ready, the way `lh goal run` does.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* ✨ feat(goal): drive goals from the server instead of a client tick loop
A Goal Graph only moved while something kept calling `goal.tick` — a browser
loop or `lh goal run`. Close the tab and a long-horizon goal stopped, which is
the opposite of what "long-horizon" is supposed to mean.
Goals now advance on their own, event-first with a sweep behind it:
- `advanceGoal` runs the coordinator until it is waiting on something that is
not another tick. `waiting_external` is a stop, not a poll: a Work Task is
executing, and that task's own settle is the next event.
- Something that may have unblocked a goal queues an advance — creating it,
resolving a decision gate, resuming it, raising a budget, and a Work Task
settling in the verify path.
- `/api/workflows/goal/sweep` is the safety net a purely event-driven chain
needs: it picks up goals with no fresh active Work and no pending gate, so a
dropped queue message, a runner that died mid-dispatch, or a Work that
outlived its operation lease cannot strand a goal forever.
- The scheduler mirrors `taskScheduler`: QStash in queue mode, in-process
timers for dev and desktop, so both exercise the same runner.
The page's advance button hands off to the server in one call rather than
holding a tick loop open, and `goal.advance` is the nudge for "don't wait for
the next event". The loop that used to live in the browser now lives where it
can keep running without one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* 🐛 fix(goal): actually queue the advance when a goal is created
The create procedure was the one enqueue site that never landed, so a goal sat
in `planning` until something else poked it — the exact failure the automatic
driver exists to remove. Caught by running a goal end to end with no client
attached: creation published nothing to the queue.
A dispatch failure is now logged loudly rather than only through `debug`. This
is the only thing that makes a goal move on its own; when it breaks, the
symptom is goals that quietly stop, which is close to impossible to attribute
from the outside.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* ♻️ refactor(goal): import Text/Tag/ActionIcon from base-ui
The base-ui migration (#18729) landed on canary while this branch was open,
so the Goal process-control components still pulled the deprecated antd
wrappers. Move them over and drop the acceptance imports the Goal detail
page no longer uses.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* 🐛 fix(goal): close the gaps the review found in the graph-only migration
Seven problems, all from making the Goal Graph the only goal model:
The create modal still showed a visibility control whose value went nowhere,
so a goal marked private dispatched Work Tasks that fell back to the assignee
agent's visibility and published its output to the workspace. Visibility now
rides on the goal's `config` — no new column — and the coordinator stamps every
task it creates.
Goals from the earlier task-carried flow have no `goal_nodes`, and the new
goal-centric list returned them alongside graph goals: a goal page with no
work, no frontier and no way forward. They stay out of both the list and the
sweep's work list until something backfills them.
`task.create` no longer accepts a `goal`, but Zod was stripping the field
rather than rejecting it, so a released client would report that a goal started
when only an ordinary task existed. It now fails with a message that says to
reload.
The sweep is the safety net for an event-driven chain, and self-hosted
deployments only ever provisioned the task dispatcher schedule, so nothing
called it. `startServer.js` now registers both.
Two advances can overlap — an event hook, a manual nudge, the sweep — and
`runTask` decides whether a run is in flight by reading topics before creating
one, so both could dispatch the same Work and bill it twice. The coordinator
claims the task with a conditional status update first, and releases it if the
dispatch throws.
Deleting a goal cascaded its graph away while leaving its Work Tasks and their
agent operations running, spending budget with nothing left on screen to stop
them. Deletion now cancels running topics and pauses those tasks first.
Project goal cards had no `aid` in the route, so every card on the project
Goals page rendered as a link that did nothing. They fall back to the goal's
own agent.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* 🐛 fix(goal): stop a public goal on a private agent from wedging its Work
Two problems with the visibility the previous commit added.
`GoalService.create` still declared its own narrower config type, so the
shared `GoalConfig` field did not type-check on the server — which the CI
type-check caught and the runtime tests could not.
Forwarding an explicit `public` to `createTask` also buys nothing: it is what
the service derives anyway, and stating it outright trips the "a public task
can never run on a private agent" invariant, leaving the Work throwing on
every tick. Only `private` — the value that actually has to be preserved — is
passed through now.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* 🐛 fix(goal): keep the page live, claim recovery retries, close terminal goals
Three more from the review, all consequences of the server now driving goals.
The graph snapshot only revalidated on focus. That was fine when the client
ticked the goal itself and refreshed after every step; now the server advances
it, so an open page sat on its first snapshot — a spinner still turning on a
goal that had already finished. It polls while the goal is in a status the
server can move, and stops for `review`, `paused` and the terminal ones, where
the next change comes from a person and their action refreshes it anyway.
The dispatch claim added in the previous commit only covered starting a backlog
Work. `WorkRecoveryCoordinator.recover` spawns its own run for the two
automatic-retry paths, so overlapping advances could still each pay for an
attempt. The claim now lives in `recover`, where the spawn is. Both callers
hand it a task row read before they paused it, so it compares against what the
row says now — and treats an already-`running` task as someone else's claim
rather than its own.
The goal page still offered Advance and Add Task on an achieved, failed or
canceled goal. The coordinator returns immediately for those, so Advance
reported nothing happened and a Work added there stayed `proposed` with no way
to run it. Those controls are gone once a goal is closed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* 🐛 fix(goal): stop the client racing the queued driver, free orphaned claims
Three more from the review.
Creating a goal queued an advance on the server and then fired a single tick
from the client. A tick is one step: the one that binds the Work's task, not
the one that starts it. If that tick won the node claim, the queued driver saw
the claim, reported `waiting_external` and stopped — leaving the task in
backlog with nobody to start it until the sweep noticed. Both creation paths
now run the full driver, so whichever one claims the Work carries it through to
starting it and the loser stops harmlessly.
The dispatch claim is taken just before `runTask` creates the task's topic. A
worker that died in that sliver left the task `running` with no operation
behind it, and `recoverAbandonedWork` returned immediately because there was
nothing to reclaim — so every later advance reported `waiting_external` and the
Work never ran again. A claim with no run behind it is now handed back once it
is older than the operation lease, which is also long enough that a run about
to start is never mistaken for an abandoned one.
The Advance tooltip still told users graph goals have no server-side driver and
only move while a client advances them — the opposite of what this branch
implements, and an invitation to sit on the page pressing the button.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* 🐛 fix(goal): scope private goals, and stop attempts capping the whole graph
Five more from the review.
A goal marked private only made its Work Tasks private. The goal row itself has
no visibility column, so workspace scoping alone left its requirement, its
graph, its findings and its whole event log readable by every member — the one
thing the private toggle exists to prevent. `buildGoalScopeWhere` now enforces
the stored `config.visibility` on every goal and graph read. A real column that
`buildWorkspaceWhere` can take directly is the better long-term shape; this
holds the guarantee without a migration.
The creation form's only iteration field means "attempts on one Work", and it
was also being sent as `maxRounds` — which the coordinator counts across every
Work in the graph. With the default of three, three finished tasks exhausted
the budget and a fourth never started despite having run nothing. The two
limits are separate now, and the same conflation is gone from both `/goal`
paths.
`null` in that field is the manifest's documented "no user-specified cap", but
it was folded into the default of three, so clearing it quietly capped the goal
anyway. It now leaves the setting off and lets the recovery policy default
apply.
Raising the budget of a goal the budget had stopped left it `paused`, and
`tick` refuses to move a paused goal — so the advance this mutation queues
returned immediately and the user had to find Resume as a second gesture. A
goal whose budget was actually binding is reopened; one somebody paused
deliberately is left alone.
Goals created from a project page have no responsible agent, so the earlier
`goal.agentId` fallback was still empty and every card there was a dead link.
They route to a bare `/goal/:goalId`, mirroring how tasks already fall back to
`/task/:taskId`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* 🔒 fix(goal): gate goal deletion and inherit a private agent's visibility
Four more from the review.
Deleting a goal only checked that the caller holds `agent:update` in the
workspace — which says a member may change goals, not whose. Any member could
delete a colleague's goal and cascade its whole graph away. It now asserts the
same creator-or-workspace-owner rule that task deletion already applies.
`/goal` runs as an agent and never states a visibility, so a goal created by a
private agent was workspace-public even though the task it dispatched was
inferred private. `GoalService.create` derives it from the responsible agent
the way `createTask` does, which covers both the client executor and the server
runtime instead of asking each to remember.
Deleting a goal cancelled its running work but swallowed a cancellation
failure. If the runtime or a device gateway was unreachable the graph was
deleted anyway, leaving an operation spending with nothing left that could stop
it. That failure now aborts the deletion and says why.
The bare `/goal/:goalId` route rendered only a title, and the actions menu it
omitted is the only place a goal can be deleted — so a goal created from a
project could never be removed. The menu is no longer nested under the agent
breadcrumb, and both it and the post-delete destination fall back to the
project scope.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* 🐛 fix(goal): park unmovable goals and keep a failed kickoff from looking unsaved
Two more from the review.
A goal with no Work — or whose every remaining Work is blocked with nothing
running to unblock it — can only ever report `no_progress`, but it stayed
`running`. The sweep takes the newest stalled goals up to a limit, so enough of
these permanently occupy that window and every older stalled goal stops being
swept at all. They are now parked the same way a goal waiting on a decision
gate is: the row says what it is, and the scan moves on to goals that can
actually move.
Creating a goal through `/goal` reported a kickoff failure as `GoalCreateFailed`
even though the goal was already committed — so the agent, told nothing was
created, would create a second one and both would do the same paid work. Once
creation succeeds the goal id is always reported; a failure to start it now
says so and points out that the server will pick it up. Both the client
executor and the server runtime had the same boundary.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* 🐛 fix(goal): queue the advance for goals created by the server runtime
The previous commit stopped reporting a failed kickoff as a failed creation,
telling the agent the server would pick the goal up instead. That is true for
`/goal` from a client, which goes through the `goal.create` route, but the
server-side tool runtime calls `GoalService.create` directly and nothing
scheduled anything. Outside queue mode there is no local timer for the goal and
no recurring sweep either, so a kickoff failure left it stranded in `planning`
while the agent had been told not to create it again.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* ⚡️ perf(goal): stop every goal card refetching the whole graph each second
The card's timer drove both the elapsed clock and a full graph revalidation,
from when the client had to keep a goal moving itself. The store already polls
the snapshot while the goal is one the server can move, so this was a second,
five-times-faster copy of that: ten cards in a conversation pulled the whole
node and event history six hundred times a minute. Worse, a card whose fetch
had failed had no status to read, resolved as unsettled, and kept asking
forever. The timer now only ticks the clock.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* ⏪️ revert(goal): drop the goal visibility control
Goals do not carry a privacy promise for now, so the whole mechanism comes out
rather than sitting half-wired: the `config.visibility` field, the creation
input that set it, the agent-derived default, the stamp the coordinator put on
each Work Task, and the `buildGoalScopeWhere` predicate that enforced it on
reads. Goal and graph reads are back to plain workspace scoping.
The chooser goes with it. Leaving the control on the creation form while
nothing acts on it is exactly the state this branch was reported for: a user
marks a goal private and it is not.
Deleting a goal still requires being its creator or a workspace owner — that is
about who may destroy someone else's work, not about who may read it, and it
matches what tasks already enforce.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Arvin Xu <arvin.x@lobehub.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* ✨ feat(task): auto-ensure qstash schedule
chore: cleanup code
chore: cleanup code
chore: cleanup code
* chore: migrate qstash init workflow to startServer
chore: migrate qstash init workflow to startServer
* fix: set default QSTASH_URL to eu region, same as SDK
fix: set default QSTASH_URL to eu region, same as SDK
* v2 init
* chore: update eslint suppressions and package dependencies
- Removed several eslint suppressions related to array sorting and reversing from eslint-suppressions.json to clean up the configuration.
- Updated @lobehub/lint package version from 2.0.0-beta.6 to 2.0.0-beta.7 in package.json for improvements and bug fixes.
- Made minor formatting adjustments in vitest.config.mts and various SKILL.md files for better readability and consistency.
Signed-off-by: Innei <tukon479@gmail.com>
* fix: clean up import statements and formatting
- Removed unnecessary whitespace in replaceComponentImports.ts for improved readability.
- Standardized import statements in contextEngineering.ts and createAgentExecutors.ts by adding missing spaces for consistency.
Signed-off-by: Innei <tukon479@gmail.com>
* chore: update eslint suppressions and clean up code formatting
* 🐛 fix: use vi.hoisted for mock variable initialization
Fix TDZ error in persona service test by using vi.hoisted() to ensure
mock variables are available when vi.mock factory runs.
---------
Signed-off-by: Innei <tukon479@gmail.com>