* 🗃️ feat(acceptance): a table for collaboration on an acceptance
The first of three layers behind commenting on an acceptance: the schema,
the model, and the shared types. Nothing calls them yet — the server and
the UI arrive in the PRs stacked on this one — so this can land, or be
reverted, on its own.
One table carries every human row on an acceptance: a remark in the
discussion, a region circled on an evidence image, a reply, a reviewer's
approval, an emoji, and the note an agent ships a round with. They differ
by `kind` and `anchor_type` rather than by table, because they share a
thread shape, an author, permissions and deletion, and splitting them
would have meant maintaining that four times.
Two things the shape buys, both load-bearing:
`evidence_id` and `context_run_id` are separate columns. Someone reading
round 4 may circle a screenshot round 4 inherited from round 2; both
facts decide whether that note is still about something real.
`(acceptance_id, author_user_id, client_id)` is unique, so every write is
idempotent from a caller-derived key and no path needs a read before it
writes — a reaction, an agent's round note and a retried post all rely on
it.
Ids are twelve characters rather than a uuid: this one is the addressable
part of a comment's URL, and 62^12 leaves a one-in-a-billion collision
chance at ~2.5M comments, against a primary key that would turn one into
a failed insert rather than two rows sharing an address.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* 🐛 fix(acceptance): regenerate the comment migration metadata and harden deletion
Folding three draft migrations into one edited the SQL by hand and left the
generated artifacts describing the pre-fold shape: the snapshot and the DBML
still recorded a uuid `id` with a database default, a uuid `parent_comment_id`,
and no `author_agent_id` / `editor_data` / `attachments`. The next generated
migration would have read that as its baseline and tried to convert types and
re-add columns on databases where 0161 already created the final shape. Deleted
the draft artifacts and regenerated from the schema; the SQL comes back
identical apart from index order, so only the metadata was ever wrong.
Two fixes in the deletion path:
- A tombstoned root cleared only `content`, leaving the editor tree and the
attached files readable through the list endpoint. Clear all three.
- Deleting the last two replies at once left the empty tombstone standing
forever, because each transaction locked only its own reply and counted the
other's uncommitted sibling. Lock the thread root first, in a fixed root-
before-child order that also rules out a deadlock with a root deletion.
The ordering test spaced its rows apart in time: five writes in a loop can share
a microsecond, and the tie then breaks on a random id.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* 🐛 fix(bot): read Feishu chat history, decode WeChat voice and gate personal-file visibility toggles
- Feishu/Lark (LOBE-13803): inject the current `{ platform, channelId }` into the
bot platform prompt so `readMessages` no longer asks the user for a group ID;
explain `230027` and friends with the exact scope to add (`im:message.group_msg`);
surface Lark `permission_violations` in API errors; map unicode reactions to
Feishu `emoji_type` and track `reaction_id` so step swaps replace instead of
stacking; decide the no-@mention exemption from real chat membership
(`user_count`/`bot_count`, fail closed) instead of who has spoken.
- WeChat (LOBE-13407): decode SILK voice to WAV on download via `silk-wasm` so
the attachment is playable in the browser and usable by audio models.
- ResourceManager: only offer "Make private" / "Publish to workspace" for the
creator's own files inside a workspace; personal-mode rows default to
`visibility = 'public'` and were wrongly offered the toggle.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V5Mi6LJCFiMnej3MPAjMpJ
* 🐛 fix(feishu): read the newest history page and bound solo-chat cache staleness
Address the Codex review on #19323:
- `readMessages` and the chat-SDK `fetchMessages` asked Feishu for history
without `sort_type`, which defaults to ascending — in any chat past the
page cap the model got the chat's oldest messages instead of what was just
said. Request `ByCreateTimeDesc` (kept on every cursor page) and flip the
page so the documented chronological order still holds; `forward` maps to
ascending.
- A cached "solo" verdict kept the bot answering every unmentioned message for
10 minutes after someone joined the chat. Positive verdicts now expire after
60 s; only "shared" verdicts keep the 10-minute TTL.
- Without agent-runtime Redis the verdict was never cached, costing a blocking
Feishu request per inbound message. Fall back to a bounded process-local
cache with the same TTLs.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V5Mi6LJCFiMnej3MPAjMpJ
* 🐛 fix(bot): skip membership lookups for addressed messages and keep the reaction id until deletion succeeds
Second Codex round on #19323:
- `onSubscribedMessage` only consults `isSoloBotConversation` when a DM or
@mention has not already settled the question, so a lapsed cache never puts
a platform round-trip in front of a direct message.
- The Feishu final reaction clear forgot the tracked `reaction_id` before the
remote delete ran; a transient failure left the reaction visible with
nothing to retry with. Delete the tracker entry only after removal succeeds.
- Fix the `result.messages` possibly-undefined type error CI caught in
`service.test.ts`.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V5Mi6LJCFiMnej3MPAjMpJ
* 🐛 fix(bot): keep every placed Feishu reaction id until removal succeeds and scope Slack history injection
Third Codex round on #19323:
- Feishu reaction tracker keeps a list of ids per message, in Redis or a
bounded process-local cache when agent-runtime Redis is absent (local runs
used to stack every step reaction). A swap that adds the next reaction but
fails to remove the previous one keeps both ids; the final clear retries
removal in place before handing leftovers back to the tracker, since neither
the bridge nor the queue callback retries it. Legacy bare-id keys still read.
- `chatComposition` shares the same bounded memory cache helper.
- New optional `PlatformClient.extractConversationId`: Slack declines for reply
threads, so the injected current conversation never points `readMessages`
(channel history only) at the parent channel of a thread.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V5Mi6LJCFiMnej3MPAjMpJ
---------
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Reassigning a task only rewrites a column on `tasks`, so nothing records
who moved it or when. Add an append-only event table alongside the other
task-child tables: one row per change, carrying the actor (user or agent)
and the ids the slot moved between.
`type` is a plain text column typed through `.$type<>()`, so onboarding a
new event kind — status, priority, … — is a type-only change rather than
a migration. `visibility` mirrors the parent task the same way
`task_topics` / `task_comments` do, so ownership filters without joining
back to `tasks`, and the public → private demotion cascade covers it too.
Claude-Session: https://claude.ai/code/session_015wKZMihFpqsChKSb5YR23P
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* 🐛 fix(file): reserve storage before upload
* 🐛 fix(file): gate storage quota on upload paths that skip reservations
The reservation flow only covers pre-signed tRPC uploads. Three surfaces write
`files` rows without ever consulting the quota: the OpenAPI upload endpoint,
server-side image generation, and bot-channel attachment ingest.
- OpenAPI upload checks the quota once per path that actually adds bytes, and
maps the rejection to a 403 instead of a generic 500. Re-requesting a record
the caller already owns adds nothing, so it stays exempt.
- Image generation admits at submission rather than at persist — blocking after
the model call is already paid for is worse than not blocking.
- Attachment ingest charges the post-compression buffer, and a quota skip now
says so instead of leaving the sender to guess.
Claude-Session: https://claude.ai/code/session_014iyxmqssnrPopmKV1xKJ7k
* 🐛 fix(file): release reservations on every dataset import failure
Claude-Session: https://claude.ai/code/session_01Xmacr8RYT7rZ3zmjZrrENm
* 🐛 fix(file): stop reservations from deleting pre-existing objects
Reject a reservation whose pathname already holds an object, so aborting one
can only ever delete a key it created. Serialize server-owned uploads with the
owner-row lock by threading the transaction through the quota check and the
file row it authorizes.
Claude-Session: https://claude.ai/code/session_01Xmacr8RYT7rZ3zmjZrrENm
* 🐛 fix(file): keep the S3 write out of the quota transaction
Holding the owner-row lock across the upload blocked every other write to that
user for the length of the transfer, and Postgres kills the transaction outright
wherever DATABASE_STATEMENT_TIMEOUT is set, orphaning the object it had already
written. Check the quota once before spending the transfer, upload outside any
transaction, then re-check under the lock the file row is written with.
Claude-Session: https://claude.ai/code/session_01Xmacr8RYT7rZ3zmjZrrENm
Two tables split as definition vs data, designed so one query endpoint can
power every future chart:
- `metrics` — one row per series ("one row = one chartable line"). Polymorphic
subjectType/subjectId with no FK (mirroring `goals`), unique on
(subject_type, subject_id, key). Carries what a renderer cannot infer from
the data: aggregation semantics (`kind`: gauge/counter), formatting
(`unit`), and display/evaluation hints (`config`: target, direction,
precision).
- `metric_points` — append-only, strictly numeric (t, v) observations with
`observedAt` distinct from `createdAt` so backfills land on the right spot.
Provenance mirrors goal_events (actorType/actorId/operationId + sourceType);
ownership is denormalized so buildWorkspaceWhere works without a join. Hot
path is the (metric_id, observed_at) index.
Goals are the first intended consumer (numeric acceptance criteria and
observation probes for LOBE-13597 gap 2), but the tables know nothing about
goals — the same schema serves agent cost curves or workspace dashboards.
Schema only; write/read services and TRPC endpoints come in follow-up PRs.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* ✨ feat: add document_likes table for workspace document likes
Introduce the `document_likes` storage layer so workspace members can like
a document: one row per (document, user), unique on that pair, with
cascading FKs to documents / users / workspaces and paging-friendly
indexes. Ships the Drizzle schema, relations, and an idempotent migration
(0155); the model, router, and UI land in a follow-up PR.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HNfPCfAEiWyuvTbXMoqNTZ
* 🐛 fix: keep document_likes consistent across document scope transfers
DocumentModel.transferTo now rehomes like rows to the target workspace and
deletes them on a workspace→personal transfer, mirroring how comments and
mention rows are handled. Adds a regression test covering both directions.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HNfPCfAEiWyuvTbXMoqNTZ
* 🐛 fix: count teammates' likes as foreign rows in document transfer guard
subtreeHasForeignRows now also detects document_likes rows owned by other
users, so a non-owner member cannot transfer a subtree that would rehome or
delete teammates' reactions. Covered by a regression test asserting the
caller's own like stays transferable while a teammate's blocks it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HNfPCfAEiWyuvTbXMoqNTZ
* ✨ feat: add DocumentLikeModel with transfer-serialized like creation
Ships the model half of the likes storage layer in the same PR as the
schema so the serialization contract stays reviewable in one place:
like() runs in a transaction that locks the document row FOR UPDATE
(mirroring DocumentCommentModel.create), so a like racing a concurrent
scope transfer blocks on the transfer's row lock and re-validates the
workspace after it commits instead of stamping the stale source
workspace_id — the transfer-side foreign-row guard and rehoming from the
previous commits therefore see a consistent row set. Includes the
DocumentLikeSummary types and model unit tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HNfPCfAEiWyuvTbXMoqNTZ
* 🐛 fix: recheck foreign rows inside the transfer transaction
The non-owner foreign-row guard used to run as a router preflight outside
the transfer transaction, so a teammate's like (or comment) committed
between the preflight and the transfer could still be rehomed or deleted.
transferTo now locks every subtree document row FOR UPDATE and, when
called with forbidForeignRows, re-runs the shared foreign-row predicate
inside the transaction, throwing DOCUMENT_TRANSFER_FOREIGN_ROWS which the
router maps to the existing OwnerOnly error. Concurrent like/comment
writers lock the same document row before stamping a workspace, so they
either commit before the recheck (and are seen) or block until the
transfer commits and then re-validate their scope.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HNfPCfAEiWyuvTbXMoqNTZ
* 🐛 fix: serialize unlike and summary against document scope transfers
unlike() and summary() shared like()'s validate-then-act shape without its
serialization, so a transfer committing between the workspace validation
and the delete/reads let a stale source-workspace caller mutate or read
rehomed rows. All three operations now run through a shared lockDocument
helper inside a transaction — writes lock the document row FOR UPDATE,
summary takes FOR SHARE so the validation stays true for the duration of
its reads while concurrent transfers wait.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HNfPCfAEiWyuvTbXMoqNTZ
* 🐛 fix: route resource-manager transfers through the in-transaction guard
file.transferEntity was the remaining caller still running the foreign-row
check as a pre-transaction preflight, leaving the same TOCTOU window the
document router just closed. It now passes forbidForeignRows to
transferTo and translates DOCUMENT_TRANSFER_FOREIGN_ROWS to the OwnerOnly
error exactly like document.transferDocument.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HNfPCfAEiWyuvTbXMoqNTZ
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* ✨ feat(agent-tracing): add the goal trajectory trace format
A goal is one complete goal execution, the way an operation is one complete
agent execution — so it gets one trajectory object with the same shape as an
`ExecutionSnapshot`, whose steps are advances instead of LLM calls. The leaf
links out rather than copying: an advance records the `operationId`s it put in
flight, and `inspect <opId>` continues from there.
What the format keeps is the decision *input*, not the result. Every tick
carries the graph it read (as a delta against the previously recorded state),
the budget it evaluated, the responsible task's state, and every frontier
candidate it ranked — including the ones it passed over, which is the only way
a trace can answer "why not that node" and the only way a replay can be exact.
Deltas sit on ticks rather than advances because an advance runs a loop of
ticks and each one re-reads the graph; folding the baseline through them
reproduces any tick's decision input. `replayTrajectory` feeds those inputs
back to a decider and reports where it now decides differently, which turns a
recorded run into regression material for coordinator rule changes.
`buildGoalTraceRollup` derives the analytics row — advances bucketed by
trigger and by the outcome each stopped on, gates, human waiting time, child
operations — leaving per-tick detail in the object, the same split as
`agent_operations` and its snapshot.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* ♻️ refactor(goal): separate the coordinator's decision from its execution
`tick` interleaved choosing and doing, so what the coordinator decided existed
only for the length of the call: the frontier it ranked, the budget it read and
the branch it took were all local variables that a `GoalTickResult` could not
carry. Nothing downstream could record them, and nothing could replay them.
`decideNextMove` is now a pure function of the graph, the responsible task and
the budget, returning the branch and the whole ranked frontier — losers
included, because "why not that node" is unanswerable from the winner alone.
`tick` calls it once and switches on the result; every arm keeps the code it
had, moved into a named method. The existing suite passes unchanged, which is
the point: this changes where the decision is made, not what it decides.
Two things follow. `onDecision` hands the caller what was read and what was
done, on a side channel rather than on `GoalTickResult`, which crosses tRPC to
the client — the same reason the context engine snapshot stays off the agent
runtime's event array. And the budget is read only when the branch could
actually start a run, so a goal waiting on running Work no longer pays for a
budget query on every sweep.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* ✨ feat(goal): record a trajectory for every goal advance
A goal now writes what the coordinator did, in the format an operation already
uses for what an agent did: one object per goal, accumulated through a partial
and finalized when the goal itself is terminal. `advanceGoal` is the only
funnel every advance passes through, so the envelope is written there and every
caller — create, decide, settle, sweep, resume, budget, the manual nudge —
labels its own trigger, which survives the queue hop in the QStash body. That
alone answers a question the last run could only answer from HTTP logs: which
of those 402 advances came from an event and which from the safety net.
Effects sit on ticks rather than on the advance, so an advance that ran several
of them keeps the order in which it created the task, opened the gate, or
started the run. Where a run or a completed Work carries an operation id, that
id goes on the effect — those are the leaves that hand off to
`agent-tracing inspect <opId>`.
Storage follows the operation switch exactly, so a deployment that keeps one
keeps the other and a goal trace never points at operation traces that were
dropped. The S3 key is derivable from the goal id alone, so nothing has to
carry it.
Recording is best-effort throughout: a store that cannot be built, or a write
that fails, degrades observability and never stalls a goal. An advance that
throws still flushes the ticks it completed with the failure attached — losing
that is exactly the blind spot this replaces. A goal that is merely parked
keeps its partial, which readers serve: an unfinished long-horizon goal is the
normal thing to inspect, not an error.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* ✨ feat(agent-tracing): read and replay a goal trajectory
`agent-tracing goal <goalId>` renders the run: how it ended, which triggers
drove its advances, what the graph became, how long it sat on a person, and how
many operations it put in flight. `-a <n>` opens one advance in full — the
branch each tick took, the budget and responsible task it read, and the whole
ranked frontier with the candidates it passed over, which is the question a
summary can never answer.
`replayGoalAgainstCurrentCoordinator` closes the other half. It lifts each
recorded graph back into the shape the coordinator reads and runs the real
`decideNextMove` over it — not a model of the coordinator, the function
production calls. Divergences come back addressed to the advance and tick that
produced them, so a rule change is a diff against history rather than a
surprise on the next live goal.
Two limits worth stating plainly, because an over-claimed replay gets trusted:
this reproduces the coordinator's *decisions*, exactly, and says nothing about
whether the dispatched work would have gone the same way. And the trajectory is
only as good as what the coordinator actually read — the lift fills
descriptions, events and work versions with empty values rather than inventing
them, since `decideNextMove` never looks at those and a replay that fabricated
them would be testing something the original run never saw.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* ✨ feat(goal): write the observation row from each goal's trajectory
`goal_traces` landed on canary as schema and migration only (#18852). This is
what fills it: `GoalTraceModel` plus the write path from `GoalAdvanceRecorder`,
which re-derives the whole row from the accumulated trajectory on every advance
rather than incrementing counters — exact however many times a queued advance
retries, and queryable while the goal is still running, which is when a goal
quietly churning on the sweep is worth finding.
Reads join `goals` for ownership; the row deliberately has no `user_id` of its
own, because it is 1:1 with a goal and a copy would be a second place for the
answer to drift.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* 📝 docs(agent-tracing): document goal trajectories in the skill
The skill is how the next agent finds this. Records the format, the storage
layout, the CLI, and the two limits of goal-level replay.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* ✨ feat(cli): move goal trajectories onto `lh trace goal`
`lh trace op` landed the surface for a single operation and reserved sibling
scopes for goal and task, so the goal trajectory reader belongs there rather
than on the standalone `agent-tracing` bin — which it now leaves entirely, so a
brand-new command has one home instead of two that drift.
That move brings the thing the standalone bin could not do. An operation's key
is derivable from its id, so `agent-tracing` could reach one with a configured
`TRACING_BASE_URL`; a goal's key lives on `goal_traces` and is derivable from
nothing, so the server is the only way in. `agentTrace.getGoalTrajectoryUrl`
signs it against the caller's own scope — a goal belonging to someone else
reads as absent rather than forbidden, so this cannot be used to probe ids —
and inspecting a production goal needs a LobeHub login and nothing else. Local
`.goal-tracing/` files are still read first, so dev needs no session at all.
`list` is local-first with `--server` for the rest, matching `lh trace op
list`, and the server listing leads with the trigger split: `sweep=38
settle=112` is the number that says how much of a run was the safety net.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* ♻️ refactor(goal): say task where the thing is a task
A Goal Graph node of kind `work` owns exactly one task — the schema enforces
it with a unique index and a check constraint literally named
`goal_nodes_task_requires_work_kind`. So "how many attempts does a Work take"
is asking about a task, and every metric, branch and line of report copy this
branch introduced now says so: `tasksReady` / `tasksBlocked` /
`tasksCompleted`, `task_running` / `task_paused`, "tasks 1 ready / 1 blocked /
0 done".
The word was already carrying two meanings in one file. `GoalService` imports
`WorkModel` — the works/work_versions registry, which is agent *output*, not
work to be done — and calls `registerTask` on it inside the method that
handles a work node. Adding a third reading of the same word to the reporting
surface would have made that permanent.
What is deliberately NOT renamed: `goal_nodes.kind = 'work'` and the
`goal_traces` columns behind it. Those are one decision, not two — the columns
count what the nodes are — and the node kind is shipped with production data
and a dozen UI call sites. `writeObservationRow` carries the seam explicitly,
with a test pinning it so it cannot drift silently in the meantime.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* ♻️ refactor(goal): rename the Goal Graph node kind from work to task
A node of this kind owns exactly one task — the schema says so itself, with a
unique index on `task_id` and a check constraint named
`goal_nodes_task_requires_work_kind`. The UI has been rendering it as "Task" /
"任务" since the graph shipped. Only the code still said `work`, and it said it
next to `WorkModel` — the works/work_versions artifact registry, a different
thing entirely, called from the same methods. After this, `work` in the goal
domain means the registry and nothing else.
`GoalNodeKind` is now `problem | task | finding | decision`, with the constant,
model method, service method and config-field names following it
(`GOAL_ACCEPTANCE_TASK_TITLE`, `claimTaskNode`, `consumeCompletedTask`,
`maxAttemptsPerTask`, `TaskRecoveryCoordinator`). The i18n key moves with them;
its value was already "Task".
The migration backfills before it re-adds the constraint, because a row that
already has a task and the old kind would violate the new check. The
`goal_traces` columns keep the names they shipped with — renaming three columns
buys nothing the reporting names do not already give, and the mapping sits in
one place in `writeObservationRow`.
Two effects were restored on the way through: `opened_decision` and the
finding's `created_node` had been silently dropped in an earlier rebase, which
would have left `gatesOpened`, `gatesResolved` and `humanWaitingMs` reading
zero forever. The unit tests missed it because they feed synthetic effects that
always agree with themselves, so the new test drives the real gate and the real
finding through `onDecision`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* 🗃️ chore(db): drop the goal node kind CHECK instead of moving it
Renaming the node kind meant either changing this constraint or removing it,
and it should be removed: `bindTask` is the only writer of `task_id` and its
WHERE already requires the kind, so the CHECK was redundant with the write path
while pinning the kind vocabulary into DDL — an ALTER for every future kind,
which is exactly what this rename just cost.
The unique index on `task_id` stays. One task belonging to one node is an
invariant no single write can check for itself; which kinds may own one is a
rule the write path can, and does.
So the rule moved rather than disappeared, and its test moved with it: the
schema test now covers only the uniqueness the database still owns, and a model
test covers `bindTask` refusing a node of the wrong kind. Without that second
test the rename would have quietly deleted an invariant.
The migration drops before it backfills — the old constraint reads
`kind = 'work'`, so updating a row that owns a task would violate it on the way
past.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* 🐛 fix(goal): close the four gaps Codex found in the trajectory recorder
**Overlapping advances could erase each other's record (P1).** Appending is
read-modify-write on one object, and advances for the same goal genuinely
overlap — an event hook, a manual nudge and the sweep, which the coordinator
handles everywhere else by claiming rows. Two of them read the same partial,
picked the same `seq`, and the second write dropped the first; the stale rollup
then overwrote the row, so `advancesTotal` could go backwards. The write now
runs under a transaction-scoped per-goal advisory lock, held across the object
write because reading the partial and writing it back have to be one critical
section.
**Gate metrics were structurally unreachable.** `gatesResolved` could never be
anything but zero: a person answers a gate through `goal.decide`, outside any
advance, so no tick is running to emit the effect. Both transitions are derived
from the recorded graph now — the decision rows are in every state either way,
so this no longer depends on the coordinator remembering to instrument two
lifecycle points, one of which it cannot reach. Waiting time is measured
between the tick that saw the gate open and the tick that saw it answered,
which is the wall time the goal actually stood still.
**`lh trace goal list` hid every running goal.** It listed finalized objects
only, so a machine with active traces and no finished goals printed "nothing
here" while inspecting any of them by id worked. Partials are merged in, newest
first, with the finalized copy winning when a stale partial outlived it.
**Recovery claimed runs it had not started.** `recover()` returned `continued`
both when it spawned a retry and when another advance already owned one, so the
trajectory recorded a `started_run` for work it did not do — and discarded the
operation id when it had really started something, losing the drill-down link.
The outcome now distinguishes `started` from `already-running` and carries the
operation id.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* ✅ test(goal): assert the trigger the `/goal` runtime queues its advance with
Adding `trigger` to `scheduleGoalAdvance` broke this exact-argument assertion.
Pinning the value rather than loosening the matcher: the label is what lets a
recorded run be sliced by what drove it, and a `/goal` kickoff that queued an
unlabelled advance would be a real regression.
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>
#18414 (recycle bin), #18801 (goal traces) and #18809 (optional dataset
benchmark) each carried their own migration, and all three claimed a number
that canary has since taken. They touch disjoint tables, so the three deltas
are combined into a single `0152` here and the feature PRs can drop their
schema layer and rebase on this.
- trash_items + is_deleted/deleted_at on 18 content tables (#18414)
- goal_traces (#18801)
- agent_eval_datasets.benchmark_id nullable (#18809)
The only call sites included are the ones the columns force: null-handling for
the now-optional benchmarkId, and fixture padding for the new row columns.
* ✨ feat(database): make resource permissions subject-aware with per-member grants
Add a nullable `user_id` subject column to `resource_permissions`, so one
table carries both the workspace-wide access level (`user_id IS NULL`) and
per-member grants that only ever raise a member above it.
The workspace-wide row stays unique via a `NULLS NOT DISTINCT` unique
constraint over (workspace_id, resource_type, resource_id, user_id), which
replaces the old three-column unique index and lets both row shapes upsert
through one conflict target. `ResourcePermissionModel` centralizes the
`user_id IS NULL` filter on every workspace-wide read so a per-member grant
can never leak into workspace-wide semantics.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* 🐛 fix(database): use partial unique indexes for the permission subject split
`UNIQUE NULLS NOT DISTINCT` requires PostgreSQL 15, but self-hosting
documents PostgreSQL 14 as the minimum, so migrations would fail to apply
on those installations and the server could not boot.
Switch to the pattern the rest of the schema already uses for a nullable
discriminator (`agents`, `agent_skills`, `ai_models`, `devices`): one
partial unique index per subject — `(workspace_id, resource_type,
resource_id) WHERE user_id IS NULL` for the workspace-wide row and the
four-column index `WHERE user_id IS NOT NULL` for per-member grants. Both
upserts repeat the matching predicate in `targetWhere` so PostgreSQL can
infer the arbiter index.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* 🐛 fix(server): pin knowledge-base permission scans to the workspace-wide subject
`getUseLevelKnowledgeBaseIds` and `getRestrictedKnowledgeBaseIds` read
`resource_permissions` directly and matched every row of a resource. Now
that the table is polymorphic on `user_id`, a per-member collaborator grant
would satisfy those scans and mark the knowledge base restricted for the
whole workspace — a below-baseline grant is meant to be inert, not a
workspace-wide demotion.
Both scans now pin `user_id IS NULL`. The regression test records the
rendered `where` clause, because the existing fake db ignores predicates
and would return the canned rows either way.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* 🐛 fix(database): revoke a member's resource grants when they leave the workspace
`removeMember` soft-deletes the membership and `addMember` reactivates that
same row, so a per-member grant left behind would become effective again the
moment the member is re-invited — silently restoring access that removal was
supposed to take away.
Revoke the member's grants on removal, the same lifecycle the method already
applies to their device enrollments. Workspace-wide rows carry no subject and
are untouched, as are the member's grants in other workspaces.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* 🐛 fix(database): give collaborator listing a deterministic order
`now()` is the transaction timestamp, so every grant inserted by one
`upsertCollaborators` statement shares a `created_at`. Ordering by that
column alone left the tie to the query plan, which could reorder a
collaborator list between reads.
Break the tie on `userId` so the order is specified rather than incidental.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Agent identity is outgrowing the columns it has, and a column per trait means a
migration per trait. Three additions instead:
- `profile` (jsonb) — the character sheet: MBTI, zodiac, gender, persona
traits, the artwork that depicts the character, and the style/direction it
was generated from, so reopening the studio resumes where the user left off
- `metadata` (jsonb) — the default extension bag for anything with no column
and no home on the character sheet. Untyped on purpose
- `society_id` (text) — the owning agent org. A real column, not a bag key,
because agents get listed and filtered by it; a jsonb key could not take an
index or a foreign key later without a rewrite. No foreign key until the
societies table exists, no index until the first query needs one
`MBTI_TYPES` / `ZODIAC_SIGNS` / `AGENT_GENDERS` are closed literal sets in
`@lobechat/types`, so the schema and every consumer read one source;
`personaTraits` stays open text because the set is whatever users invent.
The artwork studio is the first consumer and ships separately.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* ✨ feat(database): add resource transfer requests schema and migration
* ♻️ refactor(database): move transfer enums to @lobechat/types and drop schema-level enum typing
* 📝 docs(skill): add schema conventions (no pg enums for growing domains, no const exports from schemas) to db-migrations
* 🐛 fix(database): preserve transfer audit rows when actors are deleted (set null instead of cascade)
* 🐛 fix(database): exclude null-recipient rows from the pending-transfer unique index
* 📝 docs(skill): scope the schema-constant rule to domain literals in new/modified schemas
* ✨ feat(acceptance): propose review verdicts and record the reviewer's answer
Adds an automated second opinion on acceptance checks: a vision model re-judges
a check the verifier already passed, circles the region at fault, and the
reviewer answers with one of three verdicts.
The shape follows the offline baseline (LOBE-13035, 187 real samples across
kimi-k3 / gemini-3.6-flash / gemini-3.1-pro):
- Predictions live in their own table, never in `user_decision` — the human
decision stays the single ground truth, which is what makes the two
comparable at all.
- The judging prompt carries an explicit zero-tolerance rule. In the baseline
the models perceived defects correctly and then forgave them ("仅约 17px 的
轻微右偏,在可接受容差内"); the rule recovered every one of those.
- Adjudication is three-way. `misidentified` carries the OPPOSITE signal on the
judgement from `not-an-issue`, and merging them would teach the model that
speaking up is risky — the wrong lesson for a reviewer whose measured failure
mode is leniency.
- A reject now records which of three jobs it is doing. Three models converged
on the same "wrong" answer for 24 checks because the reject button is the only
outbound channel and carries three unrelated meanings at once.
- 20% of checks are deliberately shown with no proposal, so missed defects stay
observable instead of silently reading as perfect.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* 📝 docs(acceptance): record the SSRF gate on server-side local-S3 evidence reads
Any feature where the SERVER reads back an uploaded artifact fails silently in
the local env: the request never leaves the process, so it reads as the model
declining to act rather than as a blocked fetch.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* 💄 fix(acceptance): quiet the proposal card and stop duplicating its evidence image
Review feedback on round 1: the card shouted, repeated the screenshot already
below it, and had no visible story for more than one marked region.
- Collapsed by default behind a dashed, untinted summary line. A proposal is a
suggestion sitting beside real verdicts; a solid tinted panel competed with
the check's own status colour for the same glance.
- No image of its own. Opening the card draws the regions onto the evidence
image that is already in the row, so one screenshot appears once.
- Badge numbers run across the whole proposal rather than restarting per image,
or "region 2" would name a different box on each screenshot.
Also fixes an edit-classification defect the multi-region case exposed:
`classifyProposalEdit` paired proposed regions to kept ones by `evidenceId`
alone, so several regions on ONE screenshot all matched the first — an
untouched two-region proposal reported `region-moved`, feeding a grounding
error that never happened into the training signal.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* 🔧 refactor(acceptance): split the prediction's model reference and disable the blind slice
Review feedback on #18199.
`model_id` was one glued string; split into `provider` + `model` to match how
every other model reference in the repo is carried, so an opinion stays
filterable by provider on its own when comparing versions.
The blind control slice now ships disabled. Its stated purpose was overstated:
missed defects are already observable without it, because a proposal is only
written when the model says `reject` — a check the model passed shows no card,
so a human rejecting it is a visible miss on its own. What a blind slice
uniquely measures is whether the labels stayed INDEPENDENT of the model, and
that only matters once labels are being assembled for training. Until then it
costs a fifth of reviewers their assistance for a guarantee nobody is reading,
so the rate defaults to 0 with the mechanism (and its tests) intact.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* 🔥 refactor(acceptance): drop the blind control slice
Removed rather than kept-but-disabled. Its stated purpose did not survive
scrutiny: missed defects are already observable without it, because a proposal
is only written when the model says `reject` — a check the model passed shows no
card, so a human rejecting it is a visible miss on its own.
What a blind slice uniquely measures is whether the labels stayed INDEPENDENT of
the model. That question only exists once labels are being assembled for
training, and carrying a hash, a rate, and their tests until then is dead weight
in the read path.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* 💄 fix(acceptance): tighten the proposal card's collapsed copy and its provenance
Review feedback on round 4.
The collapsed line read as speech — "AI 认为这条可能没做到", "标了 N 处" — where
the card is the product stating a review finding. Now "AI review · may not be
met" and "{{count}} marked region(s)": source first, conclusion second.
The model label moved from the far end of the action row to sit beside the title
it qualifies. Parked at the end it read as a stray footnote, and it now appears
exactly once whether the card is open or closed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* 💄 fix(acceptance): hide the model label while the proposal card is collapsed
Review feedback on round 5. Collapsed, the row's job is the finding and its
one-line summary; a model id wedged between the title and that summary pushed
the summary out of the visible width. It now appears only once the card is open,
still beside the title it qualifies.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* 💄 fix(acceptance): drop the checklist card chrome and align the predict trigger's gate
Review feedback: the check list read as a boxed-in panel. Its border, radius and
the 16px inline padding on every row are gone — each row reclaims ~32px of
width, and the existing row separators carry the structure that the frame was
repeating.
Also fixes the predict entry point's visibility. It gated on `counts.pending`,
which counts any check without a standing review — including ones that never
ran. That disagreed with the set the server actually processes, and failed both
ways: a list whose only unreviewed item is un-executed showed a button that does
nothing, while an acceptance whose executed checks are all reviewed lost the
button even though the progress bar still said items remained. It now uses the
server's own predicate: has a result, undecided, and carries a frame to look at.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* 🐛 fix(acceptance): bound and defer proposal generation, and close five review findings
Requested: the batch was synchronous with no ceiling. Each check is a 10-25s
multimodal generation, so a thirty-check acceptance ran past any gateway timeout
and lost every proposal it had already paid for, while `Promise.all` opened one
generation per check at once — a single click becoming a rate-limit burst. It
now dispatches after the response with at most 4 in flight; the client polls the
bundle so the button still reflects real progress.
Codex review, all five reproduced against the code:
- A stale review is last round's rejection kept as history while THIS round's
result is undecided. Gating on `Boolean(userReview)` hid the proposal on
exactly the repair round that needs it. (P1)
- Re-running the same model + prompt version upserted over an adjudicated row
and destroyed the reviewer's answer — reachable through ordinary UI, since
`not-an-issue` leaves the check pending and the request button available. The
predictor now skips answered proposals before spending the call. (P1)
- The focused surface rejects straight from its text area, bypassing the
annotation modal, so every reject typed there was stored unclassified — the
exact conflation the intent split exists to undo. It has its own selector now. (P1)
- Predictions were looked up by the union row's id, which is
`sourceCriterionId ?? checkItemId`; a plan using distinct snapshot ids
generated proposals that could never render. Keyed by the result id instead. (P2)
- The decision and the proposal's answer were two writes: a failure between them
left the check rejected, its prediction unanswered, and the decision then hid
the proposal so nothing could reconcile them. One transaction now. (P2)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* 🗃️ chore(acceptance): record every prediction attempt, not just the rejections
`action` was `notNull`, but the predictor only ever wrote a row when the model
rejected — an `accept`, a check with no screenshot to look at, and a provider
failure all left the same trace: nothing. So the column presupposed its own
value, and the four cases collapsed into "no row", which is the one shape that
cannot be counted. Miss rate had no denominator, and a silent provider outage
was indistinguishable from the model approving everything.
Split it the way `verify_check_results` already splits `status` / `verdict`: a
row now records that a review was ATTEMPTED, and only `judged` carries an
opinion. `action` is nullable, `status` (judged/skipped/errored) is not, and a
CHECK constraint enforces the pairing — the column enums are type-level only in
drizzle, so this is the one place it is actually enforced.
Nothing changes on screen: `shouldSurfaceProposal` gates on `action === 'reject'`
and is now a type predicate, so `CheckProposal.action` narrows to `'reject'` and
widening the gate stops the UI compiling.
Also regenerates the DBML doc, which the original commit missed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* ✨ feat(next): unify asset base URL and add crossOrigin
- Derive assetPrefix from ASSET_BASE_URL, falling back to the
deprecated NEXT_PUBLIC_ASSET_PREFIX so existing deploys keep working
- Set crossOrigin: 'anonymous' so a cross-origin asset host doesn't
degrade module preload or turn Sentry frames into opaque errors
* ✅ test(next): cover trailing-slash strip on legacy asset prefix fallback
* ♻️ refactor(pwa): serve app icons from /app-icons instead of /icons
The PWA manifest icons and the default user avatar sat under the generic
/icons prefix. Namespacing them under /app-icons makes ownership explicit
and keeps them clear of any other path a deployment may serve at the root.
Every reference and the Cache-Control header rule move with the directory.
* 🐛 fix(next): gate crossOrigin on a configured asset prefix
crossOrigin: 'anonymous' was set unconditionally, so any self-hosted deployment whose asset host does not return Access-Control-Allow-Origin would go from working script tags to ones the browser refuses to execute. Apply it only when an asset prefix is actually configured, and document the ACAO requirement alongside the new ASSET_BASE_URL key.
* ♻️ refactor(assets): serve app images from /app-images instead of /images
Same change as /icons, for the same reason: /images is a generic root
prefix, and namespacing the App's own assets under /app-images makes
ownership explicit. Every reference goes through the imageUrl helper or a
literal path, and the Cache-Control header rule moves with them so the
images do not silently lose immutable caching.
* 🗃️ chore(db): add agent history job groups junction table
Add `agent_history_job_groups`, the group-side twin of
`agent_history_job_agents`, so a chat group can resolve its in-flight
history job directly:
- `agent_history_jobs.group_ids` is an unindexed jsonb array that
completed jobs keep accumulating into, which the progress poll cannot
use as a lookup key;
- the member-agent junction is not a substitute — a group with an empty
roster registers no agent rows at all, so an "already migrating" guard
has nothing to match on.
Same shape rules as the agents junction: cascade on group delete, unique
on (job_id, group_id), and no unique constraint on `group_id` alone
since history keeps completed jobs.
Also widen the `copy` job payload types for group copies: the job
payload gains an optional `group` discriminator, and the queue-row
payload's `newAgentId`/`sourceAgentId` become optional, because a group
topic remaps through the job-level `agents` map rather than one pair.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* 🗃️ chore(db): keep copy queue-row coordinates required
The queue-row payload's `newAgentId`/`sourceAgentId` were widened to
optional in anticipation of group copies, but the drain that resolves a
group remap from `job.payload.group` lands in a follow-up. Until then
`AgentCopyJobModel.processNextTopic` still treats a unit missing either
id as unrecoverable — it skips the copy, then deletes the queue row and
advances progress regardless — so a row enqueued against the widened
type would complete with no messages copied and an empty target shell.
Restore both as required and keep the relaxation with the drain change
that makes it safe. The additive `AgentHistoryJobPayload.group`
discriminator stays: it has no writer yet and cannot silently drop work.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* 🗃️ chore(db): add payload column to agent history job tables
Carries type-specific data for jobs on the agent_history_jobs framework:
`transfer` jobs leave it null, while an upcoming `copy` job type stores
its source→target agent map on the job row and the per-topic copy
coordinates on each queue row.
Schema + migration only, so the column is deployed before the code that
writes it. Additive and idempotent — no backfill.
* 🔨 chore(db): narrow job type + payload shapes
Review follow-ups on the schema itself (no SQL change — both are TS-level
narrowings; db:generate reports no schema changes):
- `type` becomes an enum ('transfer' | 'copy') like `status` above it, so a
drain path cannot silently ignore a kind it does not handle.
- payload fields become required: the column is nullable and NULL already
encodes "no payload for this job type", so a payload that exists must be
complete — a copy queue row missing sourceTopicId is now a type error.
- corrected two comments: the table header no longer claims transfer is the
only kind, and the job payload no longer claims the per-topic copy
resolves against it (queue rows are self-contained; the job map serves
the pending-copy guard).
* ✨ feat(db): add capability scopes column to api_keys
Adds a nullable `scopes text[]` column to `api_keys` so each key can carry
a capability scope list. `NULL` keeps every existing key on full access,
`['*']` is an explicit full-access selection, and any other value is a
restricted scope list enforced at the API layers (shipped separately).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* ✅ test(trpc): carry the new api_keys.scopes column in context fixtures
The fixtures pin the row shape with `satisfies`, so the schema-only column
addition needs `scopes: null` here to keep the standalone type-check green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* ♻️ refactor(db): store api_keys.scopes as jsonb instead of text[]
jsonb lets the scope payload evolve beyond a flat string list later (e.g.
per-scope status or metadata) without another column migration. Storage
semantics are unchanged: NULL = full access, ["*"] = explicit full access,
otherwise a restricted scope list.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* ✨ feat(db): add agent history job tables for async transfer backfill
Generic per-topic job queue (agent_history_jobs / _job_agents / _job_topics)
with a type discriminator, backing the upcoming fast/slow split of agent
transfer: heavy message-scope rewrites drain asynchronously topic-by-topic
instead of inside the transfer transaction. Schema + migration only; the
model/worker/UI land in follow-up PRs.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* 🐛 fix(db): surrogate PKs for job junction tables + idempotent migration DDL
Per review: new tables must not use composite primary keys (surrogate uuid id
+ unique index instead, so the uniqueness scope can evolve without a PK
rebuild), and generated DDL is hardened for replay — CREATE TABLE/INDEX IF NOT
EXISTS, FKs drop-then-add. Verified by running the migration twice against a
dev database (second pass converges as a no-op).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>