feat: add tenant-scoped signals backend #2

Merged
puter merged 12 commits from feat/signals into master 2026-07-23 12:06:53 +00:00
57 changed files with 4906 additions and 4067 deletions

View File

@@ -1 +1 @@
This Bun/TypeScript monorepo uses Bun/Vite+, Convex, Effect v4, and Flue; use `@code/*` exports, validate env in `packages/env`, never edit generated files or `repos/`, and keep UI presentational by moving UI state to reusable hooks and business/services/pure logic to dedicated modules. Before Flue work, MUST read `.agents/skills/flue/SKILL.md` and use installed-version `flue docs`; after every change, MUST run `bunx ultracite check` on every changed source, script, and agent file (apply `bunx ultracite fix` first when needed), run the affected package's `bun run check-types`, exercise the changed behavior with its targeted runtime or smoke test, then run root `bun run check` and report unrelated pre-existing failures separately; keep every `AGENTS.md` to 12 dense sentences by replacing stale guidance rather than appending. This Bun/TypeScript monorepo uses Bun/Vite+, Convex, Effect v4, and Flue; use `@code/*` exports, validate env in `packages/env`, never edit generated files or `repos/`, and keep UI presentational by moving UI state to reusable hooks and business/services/pure logic to dedicated modules. Treat `docs/PRODUCT.md`, `docs/DESIGN.md`, and `docs/TECH.md` as the canonical product, experience, and architecture specifications; read the relevant files before decisions or changes in those domains. Before Flue work, MUST read `.agents/skills/flue/SKILL.md` and use installed-version `flue docs`; after every change, MUST run `bunx ultracite check` on every changed source, script, and agent file (apply `bunx ultracite fix` first when needed), run the affected package's `bun run check-types`, exercise the changed behavior with its targeted runtime or smoke test, then run root `bun run check` and report unrelated pre-existing failures separately. Keep each individual instruction in any `AGENTS.md` to 12 dense sentences, replacing stale guidance rather than appending; this limit applies per instruction, not to the whole file.

1497
DESIGN.md

File diff suppressed because it is too large Load Diff

View File

@@ -1,897 +0,0 @@
# Zopo Work OS — Product
> Status: working product specification
> Audience: product, design, operations, research, and agent teams
> Scope: product concepts, product boundaries, product behavior, and user value
> Excludes: implementation details, infrastructure, concrete technology choices, and low-level UI specifications
## 1. Product summary
Zopo Work OS is a persistent operating system for technical founders, product engineers, and small software teams who must manage the entire lifecycle of building and growing software across multiple products, companies, repositories, customers, and business functions.
It replaces fragmented chat sessions, disconnected task trackers, scattered documents, and manually coordinated AI agents with one coherent system organized around **units of work**.
The user interacts with one continuous workspace. From that workspace, the system discovers work, builds understanding, coordinates execution, requests human judgment when needed, tracks outcomes, and retains the resulting knowledge.
The product is not a better chat application and not merely another project manager. It is a system that continuously converts company signals into structured, persistent, executable work.
The core promise is:
> One workspace that understands the company, turns signals into work, progresses that work autonomously, and brings the human in only when direction, judgment, permission, or taste is required.
---
## 2. Initial customer profile
The initial ideal customer is a technical founder or small technical team that:
- Operates one or more software products.
- Works across several companies, client organizations, or repositories.
- Moves constantly between engineering, product, customer support, sales, marketing, operations, and strategy.
- Uses many tools but lacks a single model of what the company is trying to accomplish.
- Already uses AI coding tools but finds their session- and thread-based workflows fragmented.
- Needs more execution capacity without hiring a full department for every function.
- Has a strong need for context continuity, reliable delegation, and visibility into autonomous work.
Secondary early users may include:
- Product engineers managing several initiatives.
- Fractional CTOs.
- Technical agencies and studios.
- Solo software entrepreneurs.
- Small product and engineering teams.
- Operators responsible for multiple internal products or client environments.
The initial product is technical-first because code is a clear execution domain with measurable artifacts, established workflows, and strong user familiarity. The broader product direction includes research, content, marketing, support, product operations, sales enablement, and other forms of knowledge and creative work.
---
## 3. Problem statement
Modern technical work is fragmented in three ways.
### 3.1 Work is fragmented across tools
A single problem may begin in customer support, appear again in analytics, become an issue in a tracker, require research in a browser, turn into code in a repository, produce a pull request, and later require release notes and customer communication.
Each tool stores a partial view. No tool owns the full lifecycle.
### 3.2 AI work is fragmented across sessions
Most AI products organize work around chats, threads, or isolated runs. The user must repeatedly decide:
- Which conversation to open.
- What context to restate.
- Which model or agent to use.
- Where the result should be stored.
- How the output relates to existing work.
- What changed after the work shipped.
The conversation becomes the product structure even though conversations are temporary and work is persistent.
### 3.3 Founders become the coordination layer
In small teams, the founder acts as the bridge between every system and function. They remember decisions, connect customer feedback to product work, check whether tasks are blocked, move work between tools, review AI outputs, and decide what happens next.
The founder becomes the human integration bus for the company.
Zopo Work OS removes this coordination burden by maintaining a persistent model of work, context, state, evidence, execution, and outcomes.
---
## 4. Product thesis
### 4.1 The conversation is not the product structure
Conversation is the interface through which the user thinks, directs, reviews, and makes decisions.
The durable structure is the **work unit**.
A user should not have to create a new chat because they changed tasks. They should be able to move the scope of one persistent conversation between the whole workspace and a specific work unit.
### 4.2 Work should be represented as outcomes, not activity
The system should represent meaningful outcomes such as:
- Fix Safari OAuth callback failures.
- Reduce onboarding abandonment.
- Launch team invitations.
- Resolve an enterprise billing escalation.
- Rewrite the pricing page.
- Validate a new product direction.
It should not promote every message, tool call, or agent action into a top-level task.
### 4.3 The product should participate in the work
Traditional project-management products store and display work. Zopo should also help discover, understand, plan, execute, verify, publish, and measure it.
### 4.4 Autonomy should reduce attention, not increase noise
The product should optimize for useful progress with minimal required human attention.
Autonomy is successful when the system makes correct progress, keeps the user informed at the right level, and asks for intervention only when it is necessary.
### 4.5 Knowledge should compound
Every decision, result, failed approach, customer signal, and artifact should improve the systems understanding of the organization and make future work faster and more accurate.
---
## 5. Core product objects
The product should use a small set of durable concepts. These concepts should remain stable even as the product expands into new domains.
## 5.1 Organization
An organization is a hard business, permission, and knowledge boundary.
An organization may represent:
- A company.
- A client.
- A startup.
- An internal business unit.
- A personal software portfolio.
An organization owns its users, products, policies, integrations, knowledge, work, and history.
Information must never move between organizations implicitly.
## 5.2 Product or project
A product or project is a durable operating context within an organization.
It may represent:
- A software product.
- A repository-backed application.
- A client engagement.
- An internal platform.
- A website or service.
- A focused initiative with its own ongoing context.
A project contains its goals, product knowledge, working conventions, connected sources, relevant people, and active work.
Projects should reduce context ambiguity without forcing the user to manually maintain complex folder structures.
## 5.3 Goal
A goal states what the organization or project is trying to achieve.
Examples:
- Reduce onboarding failure.
- Reach a reliability target.
- Launch enterprise support.
- Improve trial-to-paid conversion.
- Close the first ten enterprise customers.
Goals help the system distinguish valuable work from merely possible work.
A work unit may support one or more goals, but every important work unit should have a clear reason for existing.
## 5.4 Signal
A signal is raw information that may matter.
Examples:
- A support ticket.
- A customer comment.
- An error spike.
- A deployment failure.
- A sales objection.
- A message in a team channel.
- An analytics change.
- A founders thought in the global conversation.
A signal is not automatically a task.
Signals can be:
- Attached to existing work.
- Converted into candidate work.
- Saved as knowledge.
- Dismissed.
- Marked as irrelevant or duplicated.
## 5.5 Candidate work
Candidate work is a proposed work unit that has not yet been accepted as durable active work.
The system creates candidates when it believes a signal or cluster of signals deserves action.
A candidate should explain:
- What outcome is proposed.
- Why it matters.
- Which evidence supports it.
- Which project and goal it belongs to.
- How confident the system is.
- Whether similar work already exists.
The user can accept, edit, merge, defer, or dismiss it.
## 5.6 Work unit
A work unit is the central object of the product.
It is a persistent, stateful representation of one meaningful outcome.
A work unit owns:
- Objective.
- Desired result.
- Scope and non-goals.
- Current understanding.
- Requirements.
- Success criteria.
- Status.
- Priority.
- Dependencies.
- Blockers.
- Decisions.
- Evidence.
- Plan.
- Human actions.
- Agent activity.
- Artifacts.
- Results.
- Learning.
The work unit persists across multiple conversations, agent runs, people, tools, and time periods.
A work unit is not identical to:
- A single prompt.
- A chat thread.
- A single agent session.
- A pull request.
- A checklist item.
- A repository branch.
Those may all exist inside or underneath a work unit.
## 5.7 Step
A step is a bounded action within a work unit.
Examples:
- Inspect relevant logs.
- Reproduce the failure.
- Draft an implementation plan.
- Add tests.
- Review a preview.
- Contact affected customers.
Steps help organize execution, but they should not become another noisy task-management layer. They are visible when useful and hidden when the user only needs the higher-level outcome.
## 5.8 Run
A run is one attempt by an agent or workflow to progress a work unit.
Examples:
- Research the root cause.
- Implement a change.
- Review a proposed solution.
- Run verification.
- Prepare release material.
A work unit can have several runs, including parallel or repeated attempts.
Runs are subordinate to the work unit and should not replace it as the primary product object.
## 5.9 Artifact
An artifact is a durable output or piece of evidence produced or collected during work.
Examples:
- Pull request.
- Commit.
- Patch.
- Design.
- Specification.
- Test report.
- Screenshot.
- Preview.
- Research memo.
- Customer response draft.
- Launch plan.
Artifacts should be attached to the work unit and accessible from its history and current state.
## 5.10 Decision
A decision records an important human or system choice and the reasoning behind it.
Examples:
- Approve a proposed plan.
- Choose one design direction.
- Reject a migration strategy.
- Delay a feature.
- Accept a known limitation.
Decisions should remain visible after the immediate conversation is over so future agents do not repeat settled debates.
## 5.11 Approval
An approval is a specific human permission required before a sensitive or consequential action.
Examples:
- Merge code.
- Deploy to production.
- Send a customer message.
- Access sensitive data.
- Spend money.
- Change business policy.
Approvals should be precise, inspectable, and bounded. The user should know exactly what will happen after approval.
## 5.12 Result and learning
A result records what happened after work was completed or published.
It should compare:
- Intended outcome.
- Expected impact.
- Actual outcome.
- Measurement period.
- Unexpected effects.
- Recommended follow-up.
Learning is the reusable knowledge extracted from the work. It should improve future planning and execution across the relevant organization or project.
---
## 6. Work lifecycle
Work moves through four broad phases. These phases should remain conceptually consistent across engineering, product, marketing, support, and other domains.
## 6.1 Discover
The system observes conversations and connected information sources to identify problems, opportunities, questions, risks, and requests.
Discovery includes:
- Extracting signals.
- Grouping related signals.
- Identifying duplicates.
- Connecting signals to existing work.
- Proposing candidate work.
- Relating work to goals and projects.
The goal of discovery is not to create the largest possible backlog. It is to identify meaningful work with sufficient evidence and relevance.
## 6.2 Understand
The system gathers context and develops a shared model of the problem.
Understanding may include:
- Research.
- Code or system inspection.
- Customer evidence.
- Analytics.
- Existing decisions.
- Constraints.
- Open questions.
- Risks.
- Success criteria.
The result should be an actionable understanding, not an endless research report.
## 6.3 Create and iterate
The system coordinates the production of a solution.
Depending on the work, this can include:
- Code.
- Product specifications.
- Designs.
- Documentation.
- Tests.
- Customer communication.
- Launch assets.
- Research outputs.
- Business recommendations.
The system should produce intermediate artifacts, verify progress, and incorporate human feedback without losing the work units continuity.
## 6.4 Publish and learn
The solution is released, delivered, shared, or otherwise put into use.
The system then follows the outcome through:
- Deployment status.
- Error rates.
- Adoption.
- Customer reactions.
- Conversion.
- Support volume.
- Revenue impact.
- Qualitative feedback.
The lifecycle ends only when the work has either produced a result, been intentionally stopped, or been superseded by another decision.
---
## 7. Work-unit boundaries
The quality of the system depends heavily on how it divides work.
## 7.1 A work unit should represent one independently meaningful outcome
A good work unit can be summarized in one clear sentence and can reach a recognizable result.
Good examples:
- Fix Safari OAuth callback failures.
- Decide whether to support SAML this quarter.
- Reduce time-to-first-value during onboarding.
- Prepare and launch the team invitations feature.
Poor examples:
- Improve the product.
- Marketing.
- Work on onboarding.
- Open the repository.
- Read the logs.
The first group is too broad. The second group is too granular.
## 7.2 Split work when
A work unit should be split when:
- It contains multiple independent outcomes.
- Different parts have different owners or approval paths.
- The parts belong to different organizations or permission boundaries.
- They have different deadlines or success criteria.
- One part can finish while another remains active.
- The work has become difficult to summarize or review.
- A child outcome deserves its own lifecycle and result.
## 7.3 Merge work when
Work should be merged when:
- Two candidates describe the same underlying outcome.
- Different channels produced duplicate reports of the same problem.
- One candidate is merely evidence for an existing unit.
- Neither item can be meaningfully completed independently.
The system may propose a merge, but uncertain merges should remain reversible and user-reviewable.
## 7.4 Parent and child work
Large initiatives may contain child work units.
A parent should represent a larger result, while each child still has an independently meaningful outcome.
Example:
**Parent initiative:** Restore OAuth reliability
- Fix Safari callback failures.
- Improve OAuth setup diagnostics.
- Update documentation.
- Notify affected customers.
- Monitor setup completion after release.
Parent units should summarize progress and dependencies without replacing the detail of their children.
---
## 8. Conversation model
The product uses one persistent conversational surface with two scopes.
## 8.1 Global conversation
Global conversation is used to think and act across the workspace.
The user can:
- Introduce new ideas or problems.
- Ask what needs attention.
- Compare work across projects.
- Reprioritize.
- Create or modify work.
- Ask for summaries.
- Discuss dependencies.
- Request new research or execution.
The system should automatically connect global messages to relevant projects and work units while preserving the original source.
## 8.2 Contextual conversation
Contextual conversation focuses the same composer and interaction model on one work unit.
The user can:
- Add requirements.
- Answer questions.
- Review progress.
- Change direction.
- Approve a plan.
- Request another attempt.
- Ask for explanation.
- Continue work from an artifact.
Entering contextual conversation should not create a new thread. It changes the active scope of the persistent workspace.
## 8.3 Conversation boundaries
Conversation should not be the source of truth for work state.
Important facts, decisions, requirements, approvals, artifacts, and results should be extracted into structured product objects while preserving links to their source conversation.
---
## 9. Autonomous behavior
The product should continuously observe, understand, organize, execute, escalate, and learn.
## 9.1 The system may autonomously
- Attach new evidence to existing work.
- Update summaries and understanding.
- Detect stale or blocked work.
- Propose new work.
- Run safe research.
- Prepare plans and drafts.
- Produce code or other artifacts inside approved boundaries.
- Run verification.
- Suggest priorities.
- Monitor completed work.
- Create follow-up candidates.
- Summarize activity for the user.
## 9.2 The system should request human involvement for
- Strategic direction.
- Ambiguous product decisions.
- Business tradeoffs.
- Sensitive permissions.
- High-risk actions.
- External communication.
- Production changes.
- Priority conflicts.
- Conflicting evidence.
- Actions outside previously granted policy.
## 9.3 The system should not
- Create large amounts of speculative work without evidence.
- Treat confidence as certainty.
- Hide failures or uncertainty.
- Silently cross organization boundaries.
- Reopen settled decisions without new evidence.
- Perform irreversible actions without appropriate authority.
- Optimize for activity instead of outcomes.
---
## 10. Human-in-the-loop model
The user should not have to inspect every work unit to find what requires attention.
The product should centralize human requests into an **Attention Queue**.
Each request should explain:
- What is required.
- Why it is required.
- What the system recommends.
- What evidence supports the recommendation.
- What will happen after approval.
- What happens if the user does nothing.
- How urgent the request is.
- Whether the action is reversible.
Human requests fall into several categories:
- Decision.
- Approval.
- Clarification.
- Access request.
- Review.
- Blocker resolution.
- Risk alert.
The product should minimize unnecessary approvals by supporting explicit standing policies, while preserving clear control over sensitive actions.
---
## 11. Provenance and trust
The user must be able to understand why the system believes something and what caused an action.
Every important claim should carry provenance.
Provenance may include:
- Source message.
- Customer ticket.
- Repository change.
- Error report.
- Analytics event.
- Document.
- Agent observation.
- Human decision.
Claims should be distinguishable as:
- Verified.
- Reported.
- Inferred.
- Assumed.
- Conflicting.
- Outdated.
The primary interface should remain readable, but deeper evidence and derivation should be available on demand.
The system should never present an agent-generated summary as equivalent to primary evidence.
---
## 12. Knowledge model
Zopo should accumulate several kinds of organizational knowledge.
### 12.1 Canonical knowledge
Durable, reviewed descriptions of:
- Business.
- Product.
- Customers.
- Design principles.
- Architecture concepts.
- Working conventions.
- Policies.
- Goals.
### 12.2 Operational knowledge
Current structured state:
- Active work.
- Priorities.
- Dependencies.
- Ownership.
- Blockers.
- Approvals.
- Results.
### 12.3 Episodic knowledge
What happened over time:
- Conversations.
- Runs.
- Errors.
- Decisions.
- Reviews.
- Releases.
- Customer reactions.
### 12.4 Learned patterns
Reusable conclusions such as:
- Common causes of failure.
- Customer preferences.
- Repeated objections.
- Effective launch patterns.
- Known limitations.
- Preferred technical and product approaches.
Knowledge should always retain its scope. Personal preferences, project-specific facts, and organization-wide truths should not be mixed blindly.
---
## 13. Product boundaries
## 13.1 Zopo is the control layer, not the replacement for every specialist tool
Specialist systems may continue to own their native records and actions.
Examples include:
- Source control.
- Customer support.
- Team communication.
- Analytics.
- Billing.
- Deployment.
- Documents.
Zopo should provide the unified model of what matters, what state it is in, what should happen next, and whether the outcome worked.
## 13.2 Zopo should own the work graph
The work graph connects:
- Organizations.
- Projects.
- Goals.
- Signals.
- Work units.
- Dependencies.
- Decisions.
- Runs.
- Artifacts.
- Results.
- Learnings.
This graph is the products durable source of coordination and context.
## 13.3 Execution systems are replaceable
The product should present execution as a capability beneath the work unit. Users should not have to care which internal agent, model, or harness performed a run unless inspection is useful.
## 13.4 The product should not expose unnecessary AI configuration
Users should not normally need to choose:
- Model.
- Thinking level.
- Agent framework.
- Context-window strategy.
- Execution runtime.
- Tool routing.
The system should choose automatically based on task, risk, cost, and policy.
---
## 14. Product optimization
The product should optimize for:
> Maximum useful progress with minimum required human attention.
Key product measures include:
- Correct extraction of meaningful work.
- Low false-positive candidate creation.
- Time from signal to structured understanding.
- Time to first useful action.
- Accepted work units reaching meaningful outcomes.
- Reduction in repeated context gathering.
- Human attention required per completed outcome.
- Quality of decisions and outputs.
- Trust, inspectability, and reversibility.
- Reuse of accumulated knowledge.
- Improvement in future work from past results.
The product should not primarily optimize for:
- Number of chats.
- Time spent in the interface.
- Number of tasks created.
- Number of agent runs.
- Number of notifications.
- Raw model usage.
A successful day may involve the user opening the product briefly, resolving a few high-value decisions, and seeing that many other units progressed correctly without them.
---
## 15. Business value delivered through the product
Zopo provides four forms of value.
### 15.1 Execution capacity
A small team can progress more work without proportionally increasing headcount.
### 15.2 Coordination reduction
The product handles the repetitive work of gathering context, updating state, linking related information, following up, and preparing outputs.
### 15.3 Continuity
Work keeps its context, history, decisions, and artifacts across tools and time.
### 15.4 Better opportunity capture
The system notices recurring problems, customer opportunities, operational risks, and important relationships that the team may otherwise miss.
---
## 16. Initial product scope
The first strong product loop should be:
1. The user starts or connects a software project.
2. The product establishes project context.
3. The user describes desired work through the global composer.
4. The system creates or updates a work unit.
5. The work unit is clarified and accepted.
6. The system progresses the work autonomously.
7. The user reviews only necessary decisions and outputs.
8. The system produces verified artifacts.
9. The user approves publication or integration where required.
10. The product updates the work unit and retains the resulting knowledge.
The first version should prove that persistent work units are a better product structure than disposable agent chats.
---
## 17. Long-term product direction
The technical product is the starting point, not the final boundary.
The same work-unit model can later support:
- Product research.
- Marketing campaigns.
- Content production.
- Sales enablement.
- Customer support operations.
- Pricing work.
- Business analysis.
- Hiring.
- Internal operations.
The long-term vision is a general operating system for creative and knowledge work.
The enduring principles are:
- One persistent workspace.
- Work units instead of chat sessions.
- Outcome-oriented execution.
- Invisible orchestration.
- Explicit human authority.
- Strong provenance.
- Compounding organizational knowledge.
---
## 18. Non-goals for the initial product
The initial product is not intended to be:
- A full replacement for every external tool.
- A general-purpose social collaboration platform.
- A traditional kanban board with AI decoration.
- A model playground.
- A prompt-management product.
- A marketplace of visible agents.
- A system that creates work faster than users can evaluate it.
- A fully autonomous company with no human authority.
---
## 19. Product principles
1. **Organize around work, not conversations.**
2. **Represent outcomes, not activity.**
3. **Keep the system calm while the machinery remains powerful.**
4. **Bring humans in for judgment, not clerical coordination.**
5. **Expose evidence and uncertainty.**
6. **Make important actions understandable and reversible.**
7. **Preserve organization and project boundaries.**
8. **Prefer one coherent model over many disconnected features.**
9. **Let knowledge compound across completed work.**
10. **Measure success by outcomes and reduced attention.**
---
## 20. Working product statement
Zopo Work OS is an autonomous operating system for technical founders and small product teams. It observes work across the company, turns fragmented signals into persistent units of work, progresses those units using agents and tools, and brings the human in only when judgment, permission, or direction is required.
It gives the user one continuous workspace for moving from idea or problem to verified, shipped, measurable outcome while preserving context and building a durable understanding of the company over time.

1551
TECH.md

File diff suppressed because it is too large Load Diff

View File

@@ -14,16 +14,16 @@
"@code/backend": "workspace:*", "@code/backend": "workspace:*",
"@code/env": "workspace:*", "@code/env": "workspace:*",
"@code/primitives": "workspace:*", "@code/primitives": "workspace:*",
"@effect/platform-bun": "4.0.0-beta.99", "@effect/platform-bun": "catalog:",
"@rivet-dev/agentos": "^0.2.7", "@rivet-dev/agentos": "catalog:",
"convex": "catalog:", "convex": "catalog:",
"effect": "4.0.0-beta.99", "effect": "catalog:",
"rivetkit": "0.0.0-stack-feat-rivetkit-forward-inspector-tabs-to-native-plugin-actors-qkwmksyy.d2859b0" "rivetkit": "0.0.0-stack-feat-rivetkit-forward-inspector-tabs-to-native-plugin-actors-qkwmksyy.d2859b0"
}, },
"devDependencies": { "devDependencies": {
"@code/config": "workspace:*", "@code/config": "workspace:*",
"@types/bun": "latest", "@types/bun": "catalog:",
"@types/node": "^22.13.14", "@types/node": "^22.13.14",
"typescript": "^6" "typescript": "catalog:"
} }
} }

View File

@@ -15,9 +15,9 @@
"electrobun": "^1.18.1" "electrobun": "^1.18.1"
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "^1.3.14", "@types/bun": "catalog:",
"@types/three": "^0.165.0", "@types/three": "^0.165.0",
"concurrently": "^10.0.3", "concurrently": "^10.0.3",
"typescript": "^6" "typescript": "catalog:"
} }
} }

View File

@@ -26,7 +26,7 @@
"expo-router": "~57.0.2", "expo-router": "~57.0.2",
"expo-status-bar": "~57.0.0", "expo-status-bar": "~57.0.0",
"expo-web-browser": "~57.0.0", "expo-web-browser": "~57.0.0",
"heroui-native": "^1.0.5", "heroui-native": "catalog:",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3", "react-dom": "19.2.3",
"react-native": "0.86.0", "react-native": "0.86.0",

View File

@@ -38,8 +38,9 @@
"@types/react-dom": "catalog:", "@types/react-dom": "catalog:",
"react-router-devtools": "^6.2.1", "react-router-devtools": "^6.2.1",
"tailwindcss": "catalog:", "tailwindcss": "catalog:",
"typescript": "^6", "typescript": "catalog:",
"vite": "^7.3.6", "vite": "catalog:",
"vitest": "catalog:",
"vite-tsconfig-paths": "^6.1.1" "vite-tsconfig-paths": "^6.1.1"
} }
} }

View File

@@ -11,7 +11,8 @@ import { useChatComposer } from "@/hooks/chat/use-chat-composer";
import type { ChatComposerProps } from "@/lib/chat/types"; import type { ChatComposerProps } from "@/lib/chat/types";
export const ChatComposer = ({ error, onSend, status }: ChatComposerProps) => { export const ChatComposer = ({ error, onSend, status }: ChatComposerProps) => {
const busy = status === "submitted" || status === "streaming"; const busy =
status === "connecting" || status === "submitted" || status === "streaming";
const composer = useChatComposer({ busy, onSend }); const composer = useChatComposer({ busy, onSend });
let statusContent: ReactNode = ( let statusContent: ReactNode = (
@@ -23,7 +24,7 @@ export const ChatComposer = ({ error, onSend, status }: ChatComposerProps) => {
statusContent = ( statusContent = (
<> <>
<LoaderCircle className="size-3.5 animate-spin" /> <LoaderCircle className="size-3.5 animate-spin" />
Zopu is responding {status === "connecting" ? "Preparing Zopu" : "Zopu is responding"}
</> </>
); );
} }

View File

@@ -0,0 +1,60 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { useChatAgent } from "./use-chat-agent";
const mocks = vi.hoisted(() => ({
agent: {
error: undefined,
historyReady: true,
messages: [],
sendMessage: vi.fn(() => Promise.resolve()),
status: "idle" as const,
},
organization: {} as { error?: Error; organizationId?: string },
useFlueAgent: vi.fn(),
}));
vi.mock("@flue/react", () => ({
useFlueAgent: mocks.useFlueAgent,
}));
vi.mock("@/hooks/use-personal-organization", () => ({
usePersonalOrganization: () => mocks.organization,
}));
describe("useChatAgent", () => {
beforeEach(() => {
mocks.organization = {};
mocks.agent.sendMessage.mockClear();
mocks.useFlueAgent.mockReset();
mocks.useFlueAgent.mockReturnValue(mocks.agent);
});
test("does not use the agent before organization bootstrap completes", async () => {
const chat = useChatAgent();
expect(mocks.useFlueAgent).toHaveBeenCalledWith({
id: undefined,
live: "sse",
name: "zopu",
});
expect(chat.status).toBe("connecting");
await expect(chat.sendMessage("too early")).rejects.toThrow(
"Personal organization is still being prepared"
);
expect(mocks.agent.sendMessage).not.toHaveBeenCalled();
});
test("uses the ensured organization id for the agent session", async () => {
mocks.organization = { organizationId: "org-a" };
const chat = useChatAgent();
expect(mocks.useFlueAgent).toHaveBeenCalledWith({
id: "org-a",
live: "sse",
name: "zopu",
});
await chat.sendMessage("ready");
expect(mocks.agent.sendMessage).toHaveBeenCalledWith("ready");
});
});

View File

@@ -1,16 +1,38 @@
import { useFlueAgent } from "@flue/react"; import { useFlueAgent } from "@flue/react";
import { usePersonalOrganization } from "@/hooks/use-personal-organization";
import { CHAT_AGENT } from "@/lib/chat/constants"; import { CHAT_AGENT } from "@/lib/chat/constants";
import type { ChatAgentState } from "@/lib/chat/types"; import type { ChatAgentState } from "@/lib/chat/types";
export const useChatAgent = (): ChatAgentState => { export const useChatAgent = (): ChatAgentState => {
const agent = useFlueAgent(CHAT_AGENT); // The agent session is not created until the authenticated user's personal
// organization has been ensured and its id is available.
const organization = usePersonalOrganization();
const agent = useFlueAgent({
...CHAT_AGENT,
id: organization.organizationId,
});
const sendMessage = async (message: string): Promise<void> => {
if (!organization.organizationId) {
throw (
organization.error ??
new Error("Personal organization is still being prepared")
);
}
await agent.sendMessage(message);
};
let { status } = agent;
if (!organization.organizationId) {
status = organization.error ? "error" : "connecting";
}
return { return {
error: agent.error, error: organization.error ?? agent.error,
historyReady: agent.historyReady, historyReady: agent.historyReady,
messages: agent.messages, messages: agent.messages,
sendMessage: agent.sendMessage, sendMessage,
status: agent.status, status,
}; };
}; };

View File

@@ -0,0 +1,77 @@
import type { EffectCallback } from "react";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { usePersonalOrganization } from "./use-personal-organization";
const mocks = vi.hoisted(() => ({
cleanup: undefined as (() => void) | undefined,
ensurePersonalOrganization: vi.fn(),
session: {
data: { session: { id: "session-a" } },
} as { data: { session: { id: string } } | null },
state: null as {
error?: Error;
organizationId?: string;
sessionId: string;
} | null,
}));
vi.mock("@code/auth/web", () => ({
authClient: { useSession: () => mocks.session },
}));
vi.mock("@code/backend/convex/_generated/api", () => ({
api: { organizations: { ensurePersonalOrganization: "ensure-org" } },
}));
vi.mock("convex/react", () => ({
useMutation: () => mocks.ensurePersonalOrganization,
}));
vi.mock("react", () => ({
useEffect: (effect: EffectCallback) => {
mocks.cleanup?.();
const cleanup = effect();
mocks.cleanup = typeof cleanup === "function" ? cleanup : undefined;
},
useState: () => [
mocks.state,
(next: { error?: Error; organizationId?: string; sessionId: string }) => {
mocks.state = next;
},
],
}));
describe("usePersonalOrganization", () => {
beforeEach(() => {
mocks.cleanup?.();
mocks.cleanup = undefined;
mocks.ensurePersonalOrganization.mockReset();
mocks.session = { data: { session: { id: "session-a" } } };
mocks.state = null;
});
test("an authenticated session ensures its org before exposing the id", async () => {
const ensured = Promise.withResolvers<{ _id: string }>();
mocks.ensurePersonalOrganization.mockReturnValue(ensured.promise);
expect(usePersonalOrganization()).toEqual({});
expect(mocks.ensurePersonalOrganization).toHaveBeenCalledTimes(1);
ensured.resolve({ _id: "org-a" });
await ensured.promise;
await Promise.resolve();
expect(usePersonalOrganization()).toEqual({
error: undefined,
organizationId: "org-a",
});
});
test("logout cannot expose the prior session organization", async () => {
mocks.ensurePersonalOrganization.mockResolvedValue({ _id: "org-a" });
usePersonalOrganization();
await Promise.resolve();
await Promise.resolve();
mocks.session = { data: null };
expect(usePersonalOrganization()).toEqual({});
});
});

View File

@@ -0,0 +1,70 @@
import { authClient } from "@code/auth/web";
import { api } from "@code/backend/convex/_generated/api";
import type { Id } from "@code/backend/convex/_generated/dataModel";
import { useMutation } from "convex/react";
import { useEffect, useState } from "react";
interface OrganizationBootstrapState {
readonly error?: Error;
readonly organizationId?: Id<"organizations">;
readonly sessionId: string;
}
export interface PersonalOrganizationState {
readonly error?: Error;
readonly organizationId?: Id<"organizations">;
}
/** Ensure the authenticated session has its personal tenancy boundary. */
export const usePersonalOrganization = (): PersonalOrganizationState => {
const { data: session } = authClient.useSession();
const sessionId = session?.session.id;
const ensurePersonalOrganization = useMutation(
api.organizations.ensurePersonalOrganization
);
const [bootstrap, setBootstrap] = useState<OrganizationBootstrapState | null>(
null
);
useEffect(() => {
if (!sessionId) {
return;
}
let active = true;
const bootstrapOrganization = async (): Promise<void> => {
try {
const organization = await ensurePersonalOrganization();
if (active) {
setBootstrap({
organizationId: organization._id,
sessionId,
});
}
} catch (caughtError: unknown) {
if (active) {
setBootstrap({
error:
caughtError instanceof Error
? caughtError
: new Error(String(caughtError)),
sessionId,
});
}
}
};
void bootstrapOrganization();
return () => {
active = false;
};
}, [ensurePersonalOrganization, sessionId]);
if (!sessionId || bootstrap?.sessionId !== sessionId) {
return {};
}
return {
error: bootstrap.error,
organizationId: bootstrap.organizationId,
};
};

View File

@@ -1,14 +1,14 @@
import { api } from "@code/backend/convex/_generated/api"; import { api } from "@code/backend/convex/_generated/api";
import type { Id } from "@code/backend/convex/_generated/dataModel"; import type { Id } from "@code/backend/convex/_generated/dataModel";
import { useFlueClient } from "@flue/react";
import { useAction, useMutation, useQuery } from "convex/react"; import { useAction, useMutation, useQuery } from "convex/react";
import { useState } from "react"; import { useState } from "react";
import { flueClient } from "@/root";
const errorMessage = (error: unknown) => const errorMessage = (error: unknown) =>
error instanceof Error ? error.message : String(error); error instanceof Error ? error.message : String(error);
export const useProjectWorkspace = () => { export const useProjectWorkspace = () => {
const flueClient = useFlueClient();
const projects = useQuery(api.projects.list); const projects = useQuery(api.projects.list);
const [selectedProjectId, setSelectedProjectId] = const [selectedProjectId, setSelectedProjectId] =
useState<Id<"projects"> | null>(null); useState<Id<"projects"> | null>(null);

View File

@@ -1,7 +1,11 @@
import type { AgentStatus } from "@flue/react"; import type { AgentStatus } from "@flue/react";
/**
* The global Zopu agent. The instance `id` is the current organization id
* (resolved at runtime by the chat hook), not the legacy `main` shared
* instance. Organization scoping is the hard tenancy boundary.
*/
export const CHAT_AGENT = { export const CHAT_AGENT = {
id: "main",
live: "sse", live: "sse",
name: "zopu", name: "zopu",
} as const; } as const;

View File

@@ -0,0 +1,115 @@
import { createFlueClient } from "@flue/sdk";
import { describe, expect, test } from "vitest";
import { createFlueFetch } from "./flue-transport";
const BASE_URL = new URL("https://flue.example/api/");
describe("createFlueFetch", () => {
test("overlapping SDK agent sends keep distinct request IDs", async () => {
const firstResponse = Promise.withResolvers<Response>();
const secondResponse = Promise.withResolvers<Response>();
const bothStarted = Promise.withResolvers<boolean>();
const capturedHeaders: Headers[] = [];
let requestCount = 0;
let requestId = 0;
const fetchImpl: typeof fetch = (input, init) => {
capturedHeaders.push(new Headers(init?.headers));
requestCount += 1;
if (requestCount === 2) {
bothStarted.resolve(true);
}
if (requestCount === 1) {
return firstResponse.promise;
}
if (requestCount === 2) {
return secondResponse.promise;
}
throw new Error(`Unexpected request: ${String(input)}`);
};
const client = createFlueClient({
baseUrl: BASE_URL.toString(),
fetch: createFlueFetch({
baseUrl: BASE_URL,
fetchImpl,
generateRequestId: () => `request-${(requestId += 1)}`,
}),
headers: { authorization: "Bearer current-jwt" },
});
const firstSend = client.agents.send("zopu", "org-a", {
message: "first",
});
const secondSend = client.agents.send("zopu", "org-a", {
message: "second",
});
await bothStarted.promise;
expect(capturedHeaders).toHaveLength(2);
expect(capturedHeaders[0]?.get("x-zopu-request-id")).toBe("request-1");
expect(capturedHeaders[1]?.get("x-zopu-request-id")).toBe("request-2");
expect(capturedHeaders[0]?.get("authorization")).toBe("Bearer current-jwt");
expect(capturedHeaders[1]?.get("authorization")).toBe("Bearer current-jwt");
firstResponse.resolve(
Response.json(
{
offset: "1",
streamUrl: "http://internal/streams/first",
submissionId: "submission-1",
},
{ status: 202 }
)
);
secondResponse.resolve(
Response.json(
{
offset: "2",
streamUrl: "http://internal/streams/second",
submissionId: "submission-2",
},
{ status: 202 }
)
);
await expect(Promise.all([firstSend, secondSend])).resolves.toEqual([
{
offset: "1",
streamUrl: "https://flue.example/streams/first",
submissionId: "submission-1",
},
{
offset: "2",
streamUrl: "https://flue.example/streams/second",
submissionId: "submission-2",
},
]);
});
test("history and stream observation requests remain untagged", async () => {
const capturedHeaders: Headers[] = [];
const fetchImpl: typeof fetch = (_input, init) => {
capturedHeaders.push(new Headers(init?.headers));
return Promise.resolve(new Response(null, { status: 204 }));
};
const flueFetch = createFlueFetch({
baseUrl: BASE_URL,
fetchImpl,
generateRequestId: () => "must-not-be-used",
});
await flueFetch("https://flue.example/api/agents/zopu/org-a?view=history", {
headers: { authorization: "Bearer current-jwt" },
method: "GET",
});
await flueFetch("https://flue.example/api/agents/zopu/org-a?view=updates", {
headers: { authorization: "Bearer current-jwt" },
method: "GET",
});
expect(capturedHeaders).toHaveLength(2);
expect(capturedHeaders[0]?.has("x-zopu-request-id")).toBe(false);
expect(capturedHeaders[1]?.has("x-zopu-request-id")).toBe(false);
});
});

View File

@@ -0,0 +1,82 @@
interface FlueFetchOptions {
readonly baseUrl: URL;
readonly fetchImpl?: typeof fetch;
readonly generateRequestId?: () => string;
}
/**
* Build the Flue transport used by the browser client.
*
* The installed Flue SDK does not expose per-call headers on `agents.send`.
* Its custom `fetch` seam does receive the resolved URL and method, so request
* IDs are attached here to each agent admission POST. History and Durable
* Streams observation requests are GETs and intentionally remain untagged.
*/
export const createFlueFetch = ({
baseUrl,
fetchImpl = fetch,
generateRequestId = crypto.randomUUID,
}: FlueFetchOptions): typeof fetch => {
const basePath = baseUrl.pathname.replace(/\/+$/u, "");
return async (input, init) => {
const inputUrl =
typeof input === "string" || input instanceof URL ? input : input.url;
const requestUrl = new URL(inputUrl, baseUrl);
const method = (
init?.method ?? (input instanceof Request ? input.method : "GET")
).toUpperCase();
const relativePath = requestUrl.pathname.startsWith(`${basePath}/`)
? requestUrl.pathname.slice(basePath.length)
: requestUrl.pathname;
const pathSegments = relativePath.split("/").filter(Boolean);
const isAgentAdmission =
method === "POST" &&
pathSegments.length === 3 &&
pathSegments[0] === "agents";
let requestInit = init;
if (isAgentAdmission) {
const headers = new Headers(
input instanceof Request ? input.headers : undefined
);
for (const [key, value] of new Headers(init?.headers).entries()) {
headers.set(key, value);
}
headers.set("x-zopu-request-id", generateRequestId());
requestInit = { ...init, headers };
}
const response = await fetchImpl(input, requestInit);
if (
!response.ok ||
!response.headers.get("content-type")?.includes("application/json")
) {
return response;
}
const body = (await response.clone().json()) as unknown;
if (
typeof body !== "object" ||
body === null ||
!("streamUrl" in body) ||
typeof body.streamUrl !== "string"
) {
return response;
}
const streamUrl = new URL(body.streamUrl);
streamUrl.protocol = baseUrl.protocol;
streamUrl.host = baseUrl.host;
const headers = new Headers(response.headers);
headers.set("location", streamUrl.toString());
return Response.json(
{ ...body, streamUrl: streamUrl.toString() },
{
headers,
status: response.status,
statusText: response.statusText,
}
);
};
};

View File

@@ -1,10 +1,11 @@
import { WebAuthProvider } from "@code/auth/web"; import { useConvexAccessToken, WebAuthProvider } from "@code/auth/web";
import { env } from "@code/env/web"; import { env } from "@code/env/web";
import { Toaster } from "@code/ui/components/sonner"; import { Toaster } from "@code/ui/components/sonner";
import { FlueProvider } from "@flue/react"; import { FlueProvider } from "@flue/react";
import "./index.css"; import "./index.css";
import { createFlueClient } from "@flue/sdk"; import { createFlueClient } from "@flue/sdk";
import { useMemo } from "react";
import { import {
isRouteErrorResponse, isRouteErrorResponse,
Links, Links,
@@ -16,6 +17,7 @@ import {
import type { Route } from "./+types/root"; import type { Route } from "./+types/root";
import { ThemeProvider } from "./components/theme-provider"; import { ThemeProvider } from "./components/theme-provider";
import { createFlueFetch } from "./lib/flue-transport";
export const links: Route.LinksFunction = () => [ export const links: Route.LinksFunction = () => [
{ href: "https://fonts.googleapis.com", rel: "preconnect" }, { href: "https://fonts.googleapis.com", rel: "preconnect" },
@@ -31,43 +33,33 @@ export const links: Route.LinksFunction = () => [
]; ];
const flueBaseUrl = new URL(env.VITE_FLUE_URL); const flueBaseUrl = new URL(env.VITE_FLUE_URL);
const flueFetch: typeof fetch = async (input, init) => { const flueFetch = createFlueFetch({ baseUrl: flueBaseUrl });
const response = await fetch(input, init);
if (
!response.ok ||
!response.headers.get("content-type")?.includes("application/json")
) {
return response;
}
const body = (await response.clone().json()) as unknown; const AuthenticatedFlueProvider = ({
if ( children,
typeof body !== "object" || }: {
body === null || children: React.ReactNode;
!("streamUrl" in body) || }) => {
typeof body.streamUrl !== "string" const resolveAccessToken = useConvexAccessToken();
) { const client = useMemo(
return response; () =>
} createFlueClient({
baseUrl: flueBaseUrl.toString(),
const streamUrl = new URL(body.streamUrl); fetch: flueFetch,
streamUrl.protocol = flueBaseUrl.protocol; headers: async () => {
streamUrl.host = flueBaseUrl.host; const accessToken = await resolveAccessToken();
const headers = new Headers(response.headers); const headers: Record<string, string> = {};
headers.set("location", streamUrl.toString()); if (accessToken) {
return Response.json( headers.authorization = `Bearer ${accessToken}`;
{ ...body, streamUrl: streamUrl.toString() }, }
{ return headers;
headers, },
status: response.status, }),
statusText: response.statusText, [resolveAccessToken]
}
); );
return <FlueProvider client={client}>{children}</FlueProvider>;
}; };
export const flueClient = createFlueClient({
baseUrl: flueBaseUrl.toString(),
fetch: flueFetch,
});
export const Layout = ({ children }: { children: React.ReactNode }) => ( export const Layout = ({ children }: { children: React.ReactNode }) => (
<html lang="en"> <html lang="en">
<head> <head>
@@ -85,8 +77,8 @@ export const Layout = ({ children }: { children: React.ReactNode }) => (
); );
const App = () => ( const App = () => (
<FlueProvider client={flueClient}> <WebAuthProvider>
<WebAuthProvider> <AuthenticatedFlueProvider>
<ThemeProvider <ThemeProvider
attribute="class" attribute="class"
defaultTheme="dark" defaultTheme="dark"
@@ -98,8 +90,8 @@ const App = () => (
</div> </div>
<Toaster richColors /> <Toaster richColors />
</ThemeProvider> </ThemeProvider>
</WebAuthProvider> </AuthenticatedFlueProvider>
</FlueProvider> </WebAuthProvider>
); );
export default App; export default App;

View File

@@ -1,4 +1,4 @@
import { type RouteConfig } from "@react-router/dev/routes"; import type { RouteConfig } from "@react-router/dev/routes";
import { flatRoutes } from "@react-router/fs-routes"; import { flatRoutes } from "@react-router/fs-routes";
export default flatRoutes() satisfies RouteConfig; export default flatRoutes() satisfies RouteConfig;

View File

@@ -1,7 +1,7 @@
{ {
"include": ["**/*", "**/.server/**/*", "**/.client/**/*", ".react-router/types/**/*"], "include": ["**/*", "**/.server/**/*", "**/.client/**/*", ".react-router/types/**/*"],
"compilerOptions": { "compilerOptions": {
"lib": ["DOM", "DOM.Iterable", "ES2022"], "lib": ["DOM", "DOM.Iterable", "ES2024"],
"types": ["node", "vite/client"], "types": ["node", "vite/client"],
"target": "ES2022", "target": "ES2022",
"module": "ES2022", "module": "ES2022",

View File

@@ -8,4 +8,7 @@ import tsconfigPaths from "vite-tsconfig-paths";
export default defineConfig({ export default defineConfig({
envDir: path.resolve(import.meta.dirname, "../.."), envDir: path.resolve(import.meta.dirname, "../.."),
plugins: [tailwindcss(), reactRouter(), tsconfigPaths()], plugins: [tailwindcss(), reactRouter(), tsconfigPaths()],
resolve: {
dedupe: ["convex", "react", "react-dom"],
},
}); });

View File

@@ -6,14 +6,16 @@
"name": "code", "name": "code",
"devDependencies": { "devDependencies": {
"@code/config": "workspace:*", "@code/config": "workspace:*",
"@types/bun": "latest", "@types/bun": "catalog:",
"@types/node": "^22.13.14", "@types/node": "^22.13.14",
"convex-test": "catalog:",
"oxfmt": "latest", "oxfmt": "latest",
"oxlint": "latest", "oxlint": "latest",
"rolldown": "1.1.4", "rolldown": "1.1.4",
"typescript": "^6", "typescript": "catalog:",
"ultracite": "7.9.3", "ultracite": "7.9.3",
"vite-plus": "0.2.2", "vite-plus": "0.2.2",
"vitest": "catalog:",
}, },
}, },
"apps/daemon": { "apps/daemon": {
@@ -23,17 +25,17 @@
"@code/backend": "workspace:*", "@code/backend": "workspace:*",
"@code/env": "workspace:*", "@code/env": "workspace:*",
"@code/primitives": "workspace:*", "@code/primitives": "workspace:*",
"@effect/platform-bun": "4.0.0-beta.99", "@effect/platform-bun": "catalog:",
"@rivet-dev/agentos": "^0.2.7", "@rivet-dev/agentos": "catalog:",
"convex": "catalog:", "convex": "catalog:",
"effect": "4.0.0-beta.99", "effect": "catalog:",
"rivetkit": "0.0.0-stack-feat-rivetkit-forward-inspector-tabs-to-native-plugin-actors-qkwmksyy.d2859b0", "rivetkit": "0.0.0-stack-feat-rivetkit-forward-inspector-tabs-to-native-plugin-actors-qkwmksyy.d2859b0",
}, },
"devDependencies": { "devDependencies": {
"@code/config": "workspace:*", "@code/config": "workspace:*",
"@types/bun": "latest", "@types/bun": "catalog:",
"@types/node": "^22.13.14", "@types/node": "^22.13.14",
"typescript": "^6", "typescript": "catalog:",
}, },
}, },
"apps/desktop": { "apps/desktop": {
@@ -42,10 +44,10 @@
"electrobun": "^1.18.1", "electrobun": "^1.18.1",
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "^1.3.14", "@types/bun": "catalog:",
"@types/three": "^0.165.0", "@types/three": "^0.165.0",
"concurrently": "^10.0.3", "concurrently": "^10.0.3",
"typescript": "^6", "typescript": "catalog:",
}, },
}, },
"apps/native": { "apps/native": {
@@ -66,7 +68,7 @@
"expo-router": "~57.0.2", "expo-router": "~57.0.2",
"expo-status-bar": "~57.0.0", "expo-status-bar": "~57.0.0",
"expo-web-browser": "~57.0.0", "expo-web-browser": "~57.0.0",
"heroui-native": "^1.0.5", "heroui-native": "catalog:",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3", "react-dom": "19.2.3",
"react-native": "0.86.0", "react-native": "0.86.0",
@@ -135,9 +137,10 @@
"@types/react-dom": "catalog:", "@types/react-dom": "catalog:",
"react-router-devtools": "^6.2.1", "react-router-devtools": "^6.2.1",
"tailwindcss": "catalog:", "tailwindcss": "catalog:",
"typescript": "^6", "typescript": "catalog:",
"vite": "^7.3.6", "vite": "catalog:",
"vite-tsconfig-paths": "^6.1.1", "vite-tsconfig-paths": "^6.1.1",
"vitest": "catalog:",
}, },
}, },
"packages/agents": { "packages/agents": {
@@ -147,7 +150,7 @@
"@code/backend": "workspace:*", "@code/backend": "workspace:*",
"@code/env": "workspace:*", "@code/env": "workspace:*",
"@flue/runtime": "latest", "@flue/runtime": "latest",
"@rivet-dev/agentos-core": "^0.2.10", "@rivet-dev/agentos-core": "catalog:",
"convex": "catalog:", "convex": "catalog:",
"hono": "4.12.30", "hono": "4.12.30",
"valibot": "^1.4.2", "valibot": "^1.4.2",
@@ -155,8 +158,8 @@
"devDependencies": { "devDependencies": {
"@code/config": "workspace:*", "@code/config": "workspace:*",
"@flue/cli": "latest", "@flue/cli": "latest",
"@types/bun": "latest", "@types/bun": "catalog:",
"typescript": "^6", "typescript": "catalog:",
}, },
}, },
"packages/auth": { "packages/auth": {
@@ -173,7 +176,7 @@
"convex": "catalog:", "convex": "catalog:",
"expo-constants": "~57.0.2", "expo-constants": "~57.0.2",
"expo-secure-store": "~57.0.0", "expo-secure-store": "~57.0.0",
"heroui-native": "^1.0.5", "heroui-native": "catalog:",
"react": "^19.2.3", "react": "^19.2.3",
"react-native": "0.86.0", "react-native": "0.86.0",
"sonner": "catalog:", "sonner": "catalog:",
@@ -182,8 +185,9 @@
"devDependencies": { "devDependencies": {
"@code/config": "workspace:*", "@code/config": "workspace:*",
"@types/react": "~19.2.17", "@types/react": "~19.2.17",
"typescript": "^6", "typescript": "catalog:",
"vite": "^7.3.6", "vite": "catalog:",
"vitest": "catalog:",
}, },
}, },
"packages/backend": { "packages/backend": {
@@ -192,15 +196,19 @@
"dependencies": { "dependencies": {
"@better-auth/expo": "catalog:", "@better-auth/expo": "catalog:",
"@code/env": "workspace:*", "@code/env": "workspace:*",
"@code/primitives": "workspace:*",
"@convex-dev/better-auth": "catalog:", "@convex-dev/better-auth": "catalog:",
"better-auth": "catalog:", "better-auth": "catalog:",
"convex": "catalog:", "convex": "catalog:",
"effect": "catalog:",
"zod": "catalog:", "zod": "catalog:",
}, },
"devDependencies": { "devDependencies": {
"@code/config": "workspace:*", "@code/config": "workspace:*",
"@types/node": "^24.3.0", "@types/node": "^24.3.0",
"typescript": "^6", "convex-test": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:",
}, },
}, },
"packages/config": { "packages/config": {
@@ -217,20 +225,21 @@
"devDependencies": { "devDependencies": {
"@code/config": "workspace:*", "@code/config": "workspace:*",
"@types/node": "^22.13.14", "@types/node": "^22.13.14",
"typescript": "^6", "typescript": "catalog:",
}, },
}, },
"packages/primitives": { "packages/primitives": {
"name": "@code/primitives", "name": "@code/primitives",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@rivet-dev/agentos": "^0.2.7", "@rivet-dev/agentos": "catalog:",
"effect": "4.0.0-beta.99", "effect": "catalog:",
}, },
"devDependencies": { "devDependencies": {
"@code/config": "workspace:*", "@code/config": "workspace:*",
"@types/node": "^22.13.14", "@types/node": "^22.13.14",
"typescript": "^6", "typescript": "catalog:",
"vitest": "catalog:",
}, },
}, },
"packages/ui": { "packages/ui": {
@@ -256,7 +265,7 @@
"@types/react": "^19.2.17", "@types/react": "^19.2.17",
"@types/react-dom": "catalog:", "@types/react-dom": "catalog:",
"tailwindcss": "catalog:", "tailwindcss": "catalog:",
"typescript": "^6", "typescript": "catalog:",
}, },
}, },
}, },
@@ -266,16 +275,26 @@
"catalog": { "catalog": {
"@better-auth/expo": "1.6.15", "@better-auth/expo": "1.6.15",
"@convex-dev/better-auth": "^0.12.5", "@convex-dev/better-auth": "^0.12.5",
"@effect/platform-bun": "4.0.0-beta.99",
"@rivet-dev/agentos": "^0.2.7",
"@rivet-dev/agentos-core": "^0.2.10",
"@tanstack/react-form": "^1.33.0", "@tanstack/react-form": "^1.33.0",
"@types/bun": "latest",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"better-auth": "1.6.15", "better-auth": "1.6.15",
"convex": "^1.42.1", "convex": "^1.42.1",
"convex-test": "^0.0.54",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"effect": "4.0.0-beta.99",
"heroui-native": "^1.0.5",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.2", "tailwindcss": "^4.3.2",
"typescript": "^6",
"vite": "^7.3.6",
"vitest": "^4.1.10",
"zod": "^4.4.3", "zod": "^4.4.3",
}, },
"packages": { "packages": {
@@ -1903,6 +1922,8 @@
"convex-helpers": ["convex-helpers@0.1.120", "", { "peerDependencies": { "@standard-schema/spec": "^1.0.0", "convex": "^1.32.0", "hono": "^4.0.5", "react": "^17.0.2 || ^18.0.0 || ^19.0.0", "typescript": "^5.5 || ^6.0.0", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@standard-schema/spec", "hono", "react", "typescript", "zod"], "bin": { "convex-helpers": "bin.cjs" } }, "sha512-FPqtmN/10uoxmmhVq0ViNi08AnmHKT/eU1R8+ohmPZoHfg8noZEZCltApY/ASDdhq92o0kLZETggzBvMfHJDLQ=="], "convex-helpers": ["convex-helpers@0.1.120", "", { "peerDependencies": { "@standard-schema/spec": "^1.0.0", "convex": "^1.32.0", "hono": "^4.0.5", "react": "^17.0.2 || ^18.0.0 || ^19.0.0", "typescript": "^5.5 || ^6.0.0", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@standard-schema/spec", "hono", "react", "typescript", "zod"], "bin": { "convex-helpers": "bin.cjs" } }, "sha512-FPqtmN/10uoxmmhVq0ViNi08AnmHKT/eU1R8+ohmPZoHfg8noZEZCltApY/ASDdhq92o0kLZETggzBvMfHJDLQ=="],
"convex-test": ["convex-test@0.0.54", "", { "peerDependencies": { "convex": "^1.32.0" } }, "sha512-C0v2SQcuxrELAJRNzE6fQ686XDPor7UFoC22jcoJXeCRqhLo8ZIMZ4jyUHoo1UdAL2enCb0AbiprCVpLDN8u9Q=="],
"cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
"cookie-es": ["cookie-es@3.1.1", "", {}, "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg=="], "cookie-es": ["cookie-es@3.1.1", "", {}, "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg=="],
@@ -3673,7 +3694,7 @@
"vite-tsconfig-paths": ["vite-tsconfig-paths@6.1.1", "", { "dependencies": { "debug": "^4.1.1", "globrex": "^0.1.2", "tsconfck": "^3.0.3" }, "peerDependencies": { "vite": "*" } }, "sha512-2cihq7zliibCCZ8P9cKJrQBkfgdvcFkOOc3Y02o3GWUDLgqjWsZudaoiuOwO/gzTzy17cS5F7ZPo4bsnS4DGkg=="], "vite-tsconfig-paths": ["vite-tsconfig-paths@6.1.1", "", { "dependencies": { "debug": "^4.1.1", "globrex": "^0.1.2", "tsconfck": "^3.0.3" }, "peerDependencies": { "vite": "*" } }, "sha512-2cihq7zliibCCZ8P9cKJrQBkfgdvcFkOOc3Y02o3GWUDLgqjWsZudaoiuOwO/gzTzy17cS5F7ZPo4bsnS4DGkg=="],
"vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], "vitest": ["vitest@4.1.10", "", { "dependencies": { "@vitest/expect": "4.1.10", "@vitest/mocker": "4.1.10", "@vitest/pretty-format": "4.1.10", "@vitest/runner": "4.1.10", "@vitest/snapshot": "4.1.10", "@vitest/spy": "4.1.10", "@vitest/utils": "4.1.10", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.10", "@vitest/browser-preview": "4.1.10", "@vitest/browser-webdriverio": "4.1.10", "@vitest/coverage-istanbul": "4.1.10", "@vitest/coverage-v8": "4.1.10", "@vitest/ui": "4.1.10", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw=="],
"vlq": ["vlq@1.0.1", "", {}, "sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w=="], "vlq": ["vlq@1.0.1", "", {}, "sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w=="],
@@ -4213,6 +4234,22 @@
"vite-plus/oxlint": ["oxlint@1.72.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.72.0", "@oxlint/binding-android-arm64": "1.72.0", "@oxlint/binding-darwin-arm64": "1.72.0", "@oxlint/binding-darwin-x64": "1.72.0", "@oxlint/binding-freebsd-x64": "1.72.0", "@oxlint/binding-linux-arm-gnueabihf": "1.72.0", "@oxlint/binding-linux-arm-musleabihf": "1.72.0", "@oxlint/binding-linux-arm64-gnu": "1.72.0", "@oxlint/binding-linux-arm64-musl": "1.72.0", "@oxlint/binding-linux-ppc64-gnu": "1.72.0", "@oxlint/binding-linux-riscv64-gnu": "1.72.0", "@oxlint/binding-linux-riscv64-musl": "1.72.0", "@oxlint/binding-linux-s390x-gnu": "1.72.0", "@oxlint/binding-linux-x64-gnu": "1.72.0", "@oxlint/binding-linux-x64-musl": "1.72.0", "@oxlint/binding-openharmony-arm64": "1.72.0", "@oxlint/binding-win32-arm64-msvc": "1.72.0", "@oxlint/binding-win32-ia32-msvc": "1.72.0", "@oxlint/binding-win32-x64-msvc": "1.72.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.22.1", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-1rhdZIP/EvoI91ABIwNU5Q8+bWf8mjrS5UzIOZld4d4bXxJvtlUhlQvaoTogIGin/qdErMOrwaIJvCSIAKTLhA=="], "vite-plus/oxlint": ["oxlint@1.72.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.72.0", "@oxlint/binding-android-arm64": "1.72.0", "@oxlint/binding-darwin-arm64": "1.72.0", "@oxlint/binding-darwin-x64": "1.72.0", "@oxlint/binding-freebsd-x64": "1.72.0", "@oxlint/binding-linux-arm-gnueabihf": "1.72.0", "@oxlint/binding-linux-arm-musleabihf": "1.72.0", "@oxlint/binding-linux-arm64-gnu": "1.72.0", "@oxlint/binding-linux-arm64-musl": "1.72.0", "@oxlint/binding-linux-ppc64-gnu": "1.72.0", "@oxlint/binding-linux-riscv64-gnu": "1.72.0", "@oxlint/binding-linux-riscv64-musl": "1.72.0", "@oxlint/binding-linux-s390x-gnu": "1.72.0", "@oxlint/binding-linux-x64-gnu": "1.72.0", "@oxlint/binding-linux-x64-musl": "1.72.0", "@oxlint/binding-openharmony-arm64": "1.72.0", "@oxlint/binding-win32-arm64-msvc": "1.72.0", "@oxlint/binding-win32-ia32-msvc": "1.72.0", "@oxlint/binding-win32-x64-msvc": "1.72.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.22.1", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-1rhdZIP/EvoI91ABIwNU5Q8+bWf8mjrS5UzIOZld4d4bXxJvtlUhlQvaoTogIGin/qdErMOrwaIJvCSIAKTLhA=="],
"vite-plus/vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="],
"vitest/@vitest/expect": ["@vitest/expect@4.1.10", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.10", "@vitest/utils": "4.1.10", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA=="],
"vitest/@vitest/mocker": ["@vitest/mocker@4.1.10", "", { "dependencies": { "@vitest/spy": "4.1.10", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow=="],
"vitest/@vitest/pretty-format": ["@vitest/pretty-format@4.1.10", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q=="],
"vitest/@vitest/runner": ["@vitest/runner@4.1.10", "", { "dependencies": { "@vitest/utils": "4.1.10", "pathe": "^2.0.3" } }, "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg=="],
"vitest/@vitest/snapshot": ["@vitest/snapshot@4.1.10", "", { "dependencies": { "@vitest/pretty-format": "4.1.10", "@vitest/utils": "4.1.10", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw=="],
"vitest/@vitest/spy": ["@vitest/spy@4.1.10", "", {}, "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw=="],
"vitest/@vitest/utils": ["@vitest/utils@4.1.10", "", { "dependencies": { "@vitest/pretty-format": "4.1.10", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA=="],
"wrangler/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], "wrangler/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="],
"wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],

View File

@@ -0,0 +1,420 @@
# Signals Backend Implementation Plan
> **For OMP:** Create one isolated task worktree per implementation task. Run dependent tasks serially, merge each completed task branch into `feat/signals`, resolve integration conflicts on that branch, and verify the merged result before dispatching the next dependent task.
**Goal:** Build the first product-grade Signal slice: the authenticated global agent turns one or more exact user messages into one durable, Effect-modeled Signal containing immutable raw evidence and a separate processed problem statement.
**Architecture:** Keep the Signal domain pure in `@code/primitives`; keep authorization, idempotency, storage, and source resolution in Convex; keep conversation transport and the agent decision in Flue. The global Zopu agent becomes organization-scoped and receives two narrow tools: list eligible user-message sources, then create a Signal from selected message IDs and a processed problem statement. AgentOS remains downstream execution infrastructure and is not involved in Signal capture.
**Tech Stack:** Bun workspaces/catalogs, TypeScript, Effect v4, Convex, Flue, Hono, Better Auth, Vitest, convex-test.
---
## Current findings and decisions
1. `PRODUCT.md` separates **Signal**, **Candidate Work**, and **Work Unit**. This slice must not create candidate, plan, priority, run, or execution fields.
2. `TECH.md` requires explicit organization scope, exact user-message persistence, raw-evidence/agent-interpretation separation, and source links.
3. The current global chat is `zopu/main`, so every user shares one Flue instance. Flue routes are pass-through and the browser sends no identity. This must be corrected before Signals can be safe.
4. Canonical Flue `user_message` records already contain exact text plus `messageId`, `conversationId`, timestamp, and `submissionId`, but Convex stores them inside opaque JSON batches. Product commands should not parse those batches ad hoc.
5. Existing `projectSignals` is an execution event feed, not the product Signal entity. Rename it to `projectEvents` before introducing `signals`.
6. The Signal primitive will use Effect schemas and a pure Effect constructor. It will not contain a repository service or depend on Convex, Flue, Hono, AgentOS, or generated IDs.
7. A Signal is created only when the global agent decides the conversation contains a coherent problem statement. Individual user messages remain conversation evidence until then.
8. One Signal may compose multiple ordered user messages. Raw text remains per-message and exact; `problemStatement` is the agent-produced interpretation.
9. Signal creation is idempotent by organization + conversation + ordered source-message IDs so Flue retries cannot create duplicates.
10. Use a minimal organization foundation because `ownerId` is a user identifier, not the hard tenancy boundary required by the product. Do not build full organization administration in this slice.
## Domain model
```ts
interface Signal {
readonly id: SignalId;
readonly scope:
| { readonly _tag: "Organization"; readonly organizationId: OrganizationId }
| {
readonly _tag: "Project";
readonly organizationId: OrganizationId;
readonly projectId: ProjectId;
};
readonly sourceMessages: readonly [SignalSourceMessage, ...SignalSourceMessage[]];
readonly problemStatement: ProblemStatement;
readonly processedBy: {
readonly agentName: string;
readonly agentInstanceId: string;
};
readonly createdAt: TimestampMs;
}
interface SignalSourceMessage {
readonly conversationId: ConversationId;
readonly messageId: ConversationMessageId;
readonly rawText: string; // exact; never trim or rewrite
readonly createdAt: TimestampMs;
}
interface ProblemStatement {
readonly title: string;
readonly summary: string;
readonly desiredOutcome: string;
readonly constraints: readonly string[];
}
```
**Invariants:**
- Every Signal has exactly one organization scope and zero or one project scope.
- `sourceMessages` is non-empty, ordered, and unique by `(conversationId, messageId)`.
- Every source is a user message from the same organization-scoped global conversation.
- `rawText` is copied server-side from the stored source message; the agent never supplies it.
- `problemStatement.title`, `summary`, and `desiredOutcome` are non-empty; constraints are structured as individual non-empty items and remain distinct from raw evidence.
- Signal does not contain candidate/work-unit/run fields.
- IDs are branded non-empty strings in primitives; adapters translate Convex/Flue IDs.
- Timestamps are non-negative integer epoch milliseconds.
## Persistence model
Add these application tables in `packages/backend/convex/schema.ts`:
- `organizations`: minimal durable tenant record with `name`, `createdBy`, `createdAt`.
- `organizationMembers`: `organizationId`, `userId` (`identity.tokenIdentifier`), `role`, `createdAt`; indexes by user and organization/user.
- `conversationMessages`: normalized user-message evidence with `organizationId`, `conversationId`, `messageId`, `submissionId?`, `clientRequestId`, `role: "user"`, exact `rawText`, `status: "admitting" | "admitted" | "failed"`, and `createdAt`; unique lookup indexes by organization/message and organization/client request.
- `signals`: `organizationId`, optional `projectId`, `sourceKey`, structured `problemStatement` (`title`, `summary`, `desiredOutcome`, `constraints`), `processedByAgentName`, `processedByAgentInstanceId`, `createdAt`; indexes by organization/createdAt, project/createdAt, and organization/sourceKey.
- `signalSources`: `signalId`, `organizationId`, `conversationId`, `messageId`, `ordinal`, `rawTextSnapshot`, `sourceCreatedAt`, optional `submissionId`; indexes by signal/ordinal and organization/message.
- `projectEvents`: the renamed current execution event feed.
Do not add lifecycle/candidate status to `signals` yet. A later Candidate component will own promotion and relationships.
---
### Task 1: Normalize the workspace dependency catalog
**Objective:** Centralize every dependency repeated across workspace manifests without forcing intentionally different platform versions into one range.
**Files:**
- Modify: `package.json`
- Modify: `apps/daemon/package.json`
- Modify: other `apps/*/package.json` and `packages/*/package.json` entries identified by the catalog audit
- Modify: `bun.lock` through `bun install`
**Steps:**
1. Add default catalog entries for shared versions that should converge: `effect`, AgentOS packages, `typescript`, `@types/bun`, `@types/react`, `heroui-native`, `vite`, `vitest`, and `convex-test`.
2. Add named catalogs for intentional platform splits, especially Node type majors and React/React Native compatibility ranges. Use `catalog:<name>` rather than silently upgrading Expo/native consumers.
3. Replace direct Effect and AgentOS pins in `apps/daemon/package.json` with `catalog:`.
4. Replace repeated direct versions in workspace manifests with their default or named catalog reference. Leave peer dependency compatibility ranges as peer ranges when they intentionally describe supported versions rather than installation versions.
5. Run `bun install` and verify the lockfile resolves the same intended platform families.
**Acceptance:**
- No repeated installed dependency version remains scattered across manifests without an explicit reason.
- Effect v4 and AgentOS resolve from the root catalog everywhere.
- Native React remains on its Expo-compatible exact family; web/TUI ranges are not accidentally collapsed.
**Verification:**
```bash
bun install
bun run check-types
```
---
### Task 2: Add the pure Effect Signal primitive
**Objective:** Establish the portable Signal vocabulary and validation rules before storage or agents depend on it.
**Files:**
- Create: `packages/primitives/src/signal.ts`
- Create: `packages/primitives/src/signal.test.ts`
- Modify: `packages/primitives/src/index.ts`
- Modify: `packages/primitives/package.json`
**Implementation:**
- Define branded schemas for `SignalId`, `OrganizationId`, `ProjectId`, `ConversationId`, `ConversationMessageId`, and `TimestampMs`.
- Define `SignalScope`, `SignalSourceMessage`, `ProblemStatement`, `ProcessedByAgent`, `SignalInput`, and `Signal` with `Schema.Struct` / discriminated unions.
- Add `SignalValidationError` with `Schema.TaggedErrorClass`.
- Add `composeSignal = Effect.fn("Signal.compose")` that decodes input, rejects duplicate/out-of-order sources, preserves raw whitespace exactly, and returns a validated Signal.
- Export the module from the root and an explicit `./signal` package subpath.
- Add a package `test` script using cataloged Vitest.
**Tests:**
- Accept organization and project scopes.
- Reject empty IDs, empty source list, empty problem-statement title/summary/desired outcome, empty constraint items, negative/fractional timestamps, duplicate source IDs, mixed conversations, and out-of-order timestamps.
- Prove raw leading/trailing whitespace and source ordering survive unchanged.
- Prove candidate/work-unit/run fields are absent from the decoded result.
**Verification:**
```bash
bun run --cwd packages/primitives test
bun run --cwd packages/primitives check-types
bunx ultracite check packages/primitives/src/signal.ts packages/primitives/src/signal.test.ts packages/primitives/src/index.ts
```
---
### Task 3: Establish minimal organization tenancy
**Objective:** Give every durable Signal and global conversation a real tenant boundary instead of reusing a user ID or `main`.
**Files:**
- Modify: `packages/backend/convex/schema.ts`
- Modify: `packages/backend/convex/authz.ts`
- Create: `packages/backend/convex/organizations.ts`
- Create: `packages/backend/convex/organizations.test.ts`
**Implementation:**
- Add `organizations` and `organizationMembers` tables.
- Add `ensurePersonalOrganization`, idempotently creating one personal organization and owner membership for the authenticated user.
- Add `getCurrent` and `requireOrganizationMember` helpers.
- Keep full organization invitations, multiple active organizations, roles beyond owner/member, and organization settings out of scope.
- Ensure every query/mutation denies access without both authenticated identity and membership.
**Tests:**
- First ensure creates one organization and owner membership.
- Repeated ensure returns the same organization.
- A second identity cannot read or mutate the first identity's organization.
**Verification:**
```bash
bun run --cwd packages/backend test -- organizations
bun run --cwd packages/backend check-types
```
---
### Task 4: Rename execution signals to project events
**Objective:** Remove the naming collision before creating the product `signals` table.
**Files:**
- Modify: `packages/backend/convex/schema.ts`
- Modify: `packages/backend/convex/projects.ts`
- Modify: `packages/backend/convex/projectIssues.ts`
- Modify: `packages/backend/convex/agentWorkspace.ts`
- Modify: `packages/backend/convex/artifactModel.ts`
- Modify: `packages/backend/convex/README.md`
- Optional migration file only if existing rows are present: `packages/backend/convex/migrations.ts`
**Steps:**
1. Inspect the active Convex deployment count for `projectSignals` before changing schema.
2. If empty, cleanly rename the table and call sites to `projectEvents`.
3. If non-empty, add a one-off internal migration that copies rows with preserved relations/timestamps, verify source/destination counts, then remove the old schema/table references. Do not leave a compatibility alias.
4. Update `signals.md` seed copy to say “project events,” not product Signals.
**Acceptance:**
- `projectSignals` no longer exists in source or schema.
- Existing project event behavior remains unchanged.
- Product `signals` can be introduced without semantic ambiguity.
**Verification:**
```bash
bun run --cwd packages/backend check-types
```
Exercise project connect, issue create/start, artifact update, and agent status once and confirm `projectEvents` receives the same events.
---
### Task 5: Normalize authenticated global user messages
**Objective:** Persist exact tenant-scoped user evidence at the conversation ingress before the agent can create Signals.
**Files:**
- Modify: `packages/backend/convex/schema.ts`
- Create: `packages/backend/convex/conversationMessages.ts`
- Create: `packages/backend/convex/conversationMessages.test.ts`
- Modify: `packages/agents/src/agents/zopu.ts`
- Create: `packages/agents/src/auth.ts`
- Modify: `packages/agents/package.json`
- Modify: `apps/web/src/root.tsx`
- Modify: `apps/web/src/lib/chat/constants.ts`
- Modify: `apps/web/src/hooks/chat/use-chat-agent.ts`
- Modify: `packages/auth/src/web/index.ts` only if the access-token helper is not already exported
**Ingress sequence:**
1. Web obtains the Better Auth/Convex access token with the installed `fetchAccessToken` API.
2. The custom Flue fetch adds `Authorization: Bearer <token>` and a stable `x-zopu-request-id` to agent POSTs.
3. Zopu route middleware requires the bearer token, sets it on a short-lived `ConvexHttpClient`, resolves current organization membership, and rejects an agent instance ID that is not that organization ID.
4. Before `next()`, middleware clones and validates the direct payload and calls `conversationMessages.beginUserMessage` with exact text and request ID.
5. After Flue returns its admission receipt, middleware patches `submissionId` and status `admitted`; on failure it marks the message `failed` and rethrows/returns the original failure.
6. The web global agent ID becomes the current organization ID rather than `main`.
**Important constraints:**
- Do not trust organization ID, raw text, timestamp, or source role from an agent tool.
- Do not parse `flueConversationBatches.recordsJson` for product commands.
- Do not persist auth tokens.
- Keep failed admissions as evidence with explicit status; do not silently delete them.
- Ensure GET/SSE observation routes use the same organization authorization.
**Tests:**
- Unauthorized Flue route is rejected.
- Organization A cannot use or observe organization B's agent ID.
- Duplicate request ID is idempotent.
- Exact whitespace/casing is stored.
- Success records submission ID; failure records failed status.
**Verification:**
- Send one message containing leading/trailing whitespace through the running web app.
- Confirm the agent replies.
- Query Convex and confirm the normalized user message has exact raw text, organization scope, conversation/message identity, request ID, and admitted submission ID.
- Sign in as another identity and confirm it cannot observe the first conversation.
---
### Task 6: Persist Signals atomically in Convex
**Objective:** Resolve selected message IDs server-side, run the Effect constructor, and atomically insert one idempotent Signal plus ordered source snapshots.
**Files:**
- Modify: `packages/backend/convex/schema.ts`
- Create: `packages/backend/convex/signals.ts`
- Create: `packages/backend/convex/signals.test.ts`
- Modify: `packages/backend/package.json`
**Interface:**
```ts
createFromMessages({
organizationId,
projectId?,
conversationId,
messageIds,
problemStatement: { title, summary, desiredOutcome, constraints },
processedBy: { agentName, agentInstanceId },
}) -> signalId
```
**Implementation:**
- Expose an agent-token-gated mutation for Zopu and authenticated read queries for product clients.
- Resolve every message from `conversationMessages` under the same organization and conversation.
- Require role `user`, status `admitted`, non-empty ordered selection, and optional project ownership by the same organization.
- Copy raw text/timestamps/submission IDs into `signalSources`; never accept snapshots from the agent.
- Build `sourceKey` deterministically from organization, conversation, and ordered message IDs.
- Run `composeSignal` from `@code/primitives/signal` before writes.
- In one Convex mutation, return an existing row for the same source key or insert `signals` and all `signalSources` in ordinal order.
- Add bounded `list` and `get` queries that enforce membership and return the composed source records.
**Tests:**
- Creates one global Signal from one user message.
- Composes several ordered user messages into one Signal.
- Preserves exact raw snapshots while storing a separate structured problem statement.
- Rejects cross-organization, cross-conversation, assistant, failed, missing, duplicated, and unauthorized project messages.
- Repeating the same source set returns the existing Signal without duplicate source rows.
**Verification:**
```bash
bun run --cwd packages/backend test -- signals
bun run --cwd packages/backend check-types
bunx ultracite check packages/backend/convex/schema.ts packages/backend/convex/signals.ts packages/backend/convex/signals.test.ts
```
---
### Task 7: Let Zopu decide and create Signals
**Objective:** Give the global agent a narrow, validated mechanism to inspect eligible user evidence and create a Signal when the problem statement is coherent.
**Files:**
- Create: `packages/agents/src/tools/signals.ts`
- Modify: `packages/agents/src/agents/zopu.ts`
- Modify: `packages/agents/package.json`
- Create: `packages/agents/scripts/signal-smoke.ts`
**Tools:**
1. `list_signal_source_messages`
- No free tenant input; organization comes from the authenticated agent instance ID.
- Returns a bounded list of admitted user messages not already used by a Signal, including message ID, exact text, and timestamp.
2. `create_signal`
- Input: ordered message IDs, structured problem statement (`title`, `summary`, `desiredOutcome`, `constraints`), optional project ID.
- Calls the Convex `createFromMessages` mutation with `agentName: "zopu"` and the current organization-scoped instance ID.
- Returns only the Signal ID and source count.
**Agent instruction:**
- Continue normal planning conversation until the user's instructions form a coherent problem statement.
- Do not create a Signal for greetings, exploration without a concrete problem, or every individual message.
- When ready, call `list_signal_source_messages`, select only the messages composing the problem, then call `create_signal` once.
- Tell the user the captured problem-statement title, summary, desired outcome, and constraints.
- Do not create Candidate Work or start AgentOS in this slice.
**Retry safety:**
- Flue may replay tool calls after interruption. Convex source-key idempotency is authoritative.
- The tool must surface domain errors; it must not swallow or convert them into fake success.
**Verification:**
Run the focused smoke script and a live browser scenario:
1. Send three messages: an initial problem, a clarification, and an explicit final constraint.
2. Observe Zopu create exactly one Signal after the statement is coherent.
3. Confirm the Signal contains all selected raw messages in order and one structured problem statement with title, summary, desired outcome, and constraints.
4. Restart the agent service/retry the final turn and confirm no duplicate Signal is inserted.
5. Confirm no AgentOS actor, candidate, work unit, issue, or run is created.
```bash
bun run --cwd packages/agents signal:smoke
bun run --cwd packages/agents check-types
bun run --cwd apps/web check-types
bunx ultracite check packages/agents/src/tools/signals.ts packages/agents/src/agents/zopu.ts packages/agents/scripts/signal-smoke.ts apps/web/src/root.tsx apps/web/src/lib/chat/constants.ts apps/web/src/hooks/chat/use-chat-agent.ts
```
---
### Task 8: Final verification and cleanup
**Objective:** Prove the complete Signal slice works and remove temporary migration/scaffolding.
**Steps:**
1. Run all focused tests and smoke scenarios from Tasks 27.
2. Run Ultracite fix if necessary, then check every changed source/script/agent file.
3. Run affected package type checks: primitives, backend, agents, auth if touched, and web.
4. Run root `bun run check` and report unrelated pre-existing failures separately.
5. Remove a one-off `projectSignals` migration only after row-count verification and successful cutover, unless the repository convention keeps completed migrations.
6. Update existing backend/agent documentation only where names or commands became stale; do not add speculative docs.
**Final acceptance criteria:**
- Global conversations are organization-scoped and authenticated; `zopu/main` is gone.
- Exact user text is stored before/with Flue admission and cannot cross organizations.
- Zopu can intentionally compose one or more user messages into exactly one durable Signal.
- Signal raw evidence and the structured processed problem statement are separate and queryable.
- Source provenance is immutable and ordered.
- Retry produces no duplicate Signal.
- Existing project execution events still work under `projectEvents`.
- Signal creation does not create Candidate Work, Work Unit, run, or AgentOS resources.
- Effect v4 and AgentOS dependency versions come from workspace catalogs.
- Focused tests, package type checks, runtime smoke, and root check are reported with evidence.
## Risks and deliberate tradeoffs
- **Minimal organization bootstrap:** This adds only the tenancy needed for Signals. Multi-org switching, invites, billing, and policy are separate components.
- **Normalized user evidence plus Flue transcript:** There are temporarily two durable representations. Flue remains the canonical conversational transcript; `conversationMessages` is the application-domain evidence index required for authorization and provenance. Idempotent request IDs and submission links keep them correlated.
- **No automatic Signal for every message:** This follows the product thesis and avoids task noise. The agent explicitly decides when the instructions are coherent.
- **No separate Flow agent yet:** Zopu performs the first extraction using narrow tools. A future Flow Agent can adopt the same Convex command without changing the Signal primitive.
- **No AgentOS here:** AgentOS begins only after future Candidate/Work Unit readiness and scheduling.
- **No Signal status model yet:** Candidate attachment, knowledge capture, dismissal, and work promotion belong to later components. Adding them now would pre-design unimplemented workflows.

367
docs/DESIGN.md Normal file
View File

@@ -0,0 +1,367 @@
# Zopo Work OS — Product Design
> **Status:** canonical working interaction/interface specification · **Audience:** product design, frontend/product engineering, agent experience · **Scope:** information architecture, screens, cards, interaction patterns, states, actions, principles · **Excludes:** backend and low-level infrastructure
## 1. Objective and governing model
Design one calm, persistent workspace for supervising many active work units across organizations, products, projects, teams, and contexts—without making users manage chat threads, model settings, or agent sessions. It must make an autonomous system understandable and controllable by showing the minimum needed to answer: **what matters; what is happening; what is blocked; what needs human action; what happens next; why the system believes it.**
> **The conversation is the command surface; the work unit is the persistent product object.**
## 2. Experience principles
| Principle | Requirement |
| --- | --- |
| One continuous workspace | Do not organize around chat history, sessions, or thread lists. Keep one workspace and a composer that works globally or within a selected unit. |
| Work first, machinery second | Show outcomes, blockers, decisions, artifacts, results. Hide agent names, model choices, runtime and infrastructure unless they explain progress/debug failure. |
| Progressive disclosure | Collapsed cards scan quickly; expanded cards become working surfaces; evidence/raw events/technical detail are on demand. |
| Calm density | Support dense operational information without an enterprise-dashboard feel; use hierarchy, spacing, typography, grouping—not excessive borders, color, or permanent panels. |
| Human attention is scarce | Centralize approvals, blockers, unanswered questions, and other requests; users must not hunt through cards. |
| Visible context | Always show organization; project/portfolio scope; global vs work-specific composer; selected unit; whether an action affects external/production systems. |
| Nearby provenance | Important claims, decisions, and recommendations expose source indicators; evidence is one interaction away, not buried in an audit app. |
## 3. Product language
| Term | Meaning |
| --- | --- |
| **Organization** | Company, client, or hard context boundary. |
| **Project** | Product, application, repository-backed system, or durable operating context. |
| **Goal** | Measurable business/product objective. |
| **Signal** | New information that may matter. |
| **Candidate** | Proposed work unit awaiting confirmation. |
| **Work unit** | Persistent outcome the system is responsible for progressing. |
| **Run** | One execution attempt inside a work unit. |
| **Artifact** | Produced/collected output: PR, preview, report, design, document, etc. |
| **Attention item** | Decision, approval, question, blocker, review, or risk requiring human action. |
Do not expose **agent thread, model context window, harness session, VM, sandbox lifecycle, actor wake state** except in technical/debug views.
## 4. Global information architecture
Desktop uses three regions plus a persistent composer:
```text
┌─────────────────────────────────────────────────────────────────────┐
│ Header │
├───────────────┬───────────────────────────────────┬─────────────────┤
│ Work index │ Primary workspace │ Context panel │
│ │ cards / expanded work / chat │ evidence, │
│ │ │ artifacts, │
│ │ │ preview, tools │
├───────────────┴───────────────────────────────────┴─────────────────┤
│ Persistent composer │
└─────────────────────────────────────────────────────────────────────┘
```
The layout collapses gracefully on smaller screens.
### Header
Global orientation/light controls: current organization switcher; current project or portfolio scope; search/command trigger; attention count; activity indicator; user menu. Do **not** fill it with model controls, agent selectors, or environment settings.
### Left work index
This is operational navigation, not chat history: **Now, Attention, Discovery, Active work, Waiting, Review, Monitoring, Recently completed, Goals, Projects, Knowledge, Activity**. Show compact counts/status indicators. A project section may show name, health, active-unit count, attention count, current goal/release.
### Primary workspace
Show portfolio/project overview, project overview, work-card feed, expanded work unit, global conversation moments, candidate review, attention workflow, results/learning.
### Context panel
Change with the selected object; show only when relevant (otherwise hide/collapse). Possible content: evidence, sources, files, PR, code changes, preview, test results, research, agent activity, decisions, dependencies, analytics, customer feedback, version history.
### Persistent composer
Available everywhere; scope is visibly lightweight:
```text
Global workspace
Working in: Fix Safari OAuth callback failures
```
Accept text, files, images, voice, links, source/work-unit/project mentions, and natural-language commands. Users never select a model, agent, or thinking mode.
## 5. Primary screens
### 5.1 Command Center (default)
An operational briefing—not a wall of metrics—answering portfolio-level orientation, autonomous progress, meaningful changes, and required actions:
- **Today:** highest-value active work; upcoming deadlines; work likely to block; recently unblocked work.
- **Needs attention:** approvals, decisions, questions, reviews, risks.
- **Autonomous progress:** work advanced since last visit; new artifacts; completed verification; follow-up candidates.
- **Project health:** current goal, active count, blocker count, recent result, risk/health signal per relevant project.
- **Recent outcomes:** completed work, measured effects, captured learnings.
### 5.2 Discovery Inbox
Separate raw information from proposed interpretation. Views: new signals; candidate work; possible duplicates; suggested links to existing work; low-confidence observations. Actions: **Accept, Edit, Attach to existing work, Merge, Defer, Dismiss, Save as knowledge, Ask why**. Batch obvious duplicates/low-value signals when useful; important candidate creation remains inspectable.
### 5.3 Project Overview
One product/application context: project identity/health; current goals; active work; waiting/blocked work; recent releases/outcomes; connected sources; important context documents; team/responsibility; autonomous activity. It answers: what is the project trying to achieve, what is moving/blocked, what changed, and what requires the user?
### 5.4 Work Unit Workspace
Expanded selected card; structure:
| Section | Contents |
| --- | --- |
| Work header | Outcome title; organization/project; type; phase; status; priority; owner/responsible party; last meaningful change; autonomy state. |
| Objective | Desired outcome; why now; linked goal; success criteria; scope/non-goals. |
| Current understanding | Concise generated summary split into **Known, Inferred, Uncertain, Conflicting**. |
| Plan | Current approach; steps; completed actions; next action; alternative path when relevant. |
| Blockers/dependencies | For each: what is blocked, why, who/what resolves it, impact of waiting, requested human action. |
| Activity | Compressed chronology of user instructions, system/agent progress, state changes, decisions, artifacts, external actions, verification. |
| Artifacts | Previews/links for PRs, commits, documents, screenshots, previews, test reports, designs, research. |
| Results | After publication/completion: expected and actual result, measurement window, evidence, unexpected effects, follow-up recommendation. |
| Contextual composer | Fixed at bottom and clearly scoped to this unit. |
### 5.5 Attention Queue
Central human-in-the-loop surface; categories: **Decisions, Approvals, Questions, Blockers, Reviews, Access requests, Risk alerts**. Every item answers what/why is needed; recommendation and supporting evidence; what follows approval; consequence of no action; urgency; reversibility. Enable quick review without opening the full workspace unless deeper context is needed.
### 5.6 Activity Stream
Show meaningful autonomous events: work discovered/updated; run started; plan prepared; code changed; verification completed; artifact created; blocker detected; approval requested; work completed; result measured. Do not fill it with every tool call; events can expand for technical detail/provenance.
### 5.7 Results and Learning
A feedback system, not an archive. Views: recently completed; monitoring; successful outcomes; unexpected regressions; follow-up opportunities; reusable learnings. Each result links to original objective and evidence.
### 5.8 Knowledge
Expose durable organization/project understanding across **Product, Business, Customers, Design, Technical context, Decisions, Known constraints, Reusable learnings**. Mark each item **Canonical, Inferred, Pending review, Outdated, or Conflicting**.
## 6. Card system
A card is a visual projection of a durable product object. The same work unit may appear as a compact/expanded card, attention item, embedded conversation object, timeline event, or result card; never create separate task systems per surface.
| Type | Purpose and collapsed anatomy | Actions |
| --- | --- | --- |
| **Signal** | Newly detected information: source icon/name; observation; project/organization; time; suggested relationship; confidence. | Create work; attach to existing work; save as knowledge; dismiss. |
| **Candidate** | Proposed durable unit: proposed outcome; why it matters; project; linked goal; supporting-source count; confidence; duplicate warning when relevant. | Accept; edit; merge; defer; dismiss; ask why. |
| **Active work** | Operational state. Must answer what it is, why it matters, current state, blocker, and next step. Anatomy below. | Use status-appropriate actions. |
| **Waiting/blocked** | Visually distinct without alarmism: exact blocker; responsible party; time waiting; impact; next resolution action. | Review/resolve requested action. |
| **Approval** | Safe bounded decision: exact action; why; recommendation; alternatives; risk; impact; evidence; reversibility. | Approve; modify; reject; ask for more evidence; delegate. |
| **Review** | Evaluate artifact/completed run: what changed; acceptance criteria; verification summary; open concerns; preview/diff access; recommended decision. | Accept; request changes; continue discussion; compare alternative; stop work. |
| **Result** | Connect completed work to outcome: intended outcome; shipped work; expected impact; actual result; measurement period; unexpected effects; follow-up. | Accept result; continue monitoring; create follow-up; reopen; roll back where applicable. |
| **Parent initiative** | Larger outcome composed of child units: initiative outcome; child progress; critical path; blockers; next milestone; active/completed child counts. Do not duplicate every child detail. | Use child/initiative actions as applicable. |
### Card examples/anatomy
```text
Support · Acme Corp · 18m ago
Three users could not finish OAuth setup in Safari.
Possible match: Fix Safari callback failures · Confidence: High
[Attach] [Create separate work] [Dismiss]
```
```text
[Type] [Organization / Project] [Status]
Fix Safari OAuth callback failures
Supports: Improve onboarding reliability · 42% complete
Blocked: Need production Safari logs
Latest: Agent reproduced the issue · 18m ago
Next: Approve temporary logging deployment
3 artifacts · 5 sources · 2 runs
```
```text
Waiting · Approval required
Fix Safari OAuth callback failures
Need: Approve 30-minute diagnostic logging
Impact: Investigation cannot continue without production evidence
[Review request]
```
```text
Approval required — Merge PR #184
Why: All automated verification passed; no blocking review issues remain.
Impact: Changes OAuth callback validation; no database migration.
Evidence: 14 unit tests; 6 browser tests; preview reviewed; automated code review attached.
[Review changes] [Request changes] [Merge]
```
## 7. Card expansion
Selecting a work card expands it in place in the primary workspace. On desktop, only one primary card is expanded; surrounding feed remains visible where practical; right panel follows selected section/artifact; composer scope becomes that unit; collapse retains card position; deep links open directly to a unit. Expansion must feel like entering a workspace, not a disconnected detail page. Mobile may use a full-screen sheet/route while preserving the model.
## 8. Global/contextual chat
**Global mode** coordinates work across active organization/portfolio and may embed work cards/attention items. Example prompts: “What needs my attention today?”; “Create work for the repeated OAuth complaints.”; “Compare onboarding issues across my projects.”; “What is blocked for the release?”; “Move the pricing experiment behind the reliability work.”; “Summarize what progressed overnight.”
**Context mode** focuses one work unit: “Add Safari 17 to the acceptance criteria.”; “Explain the current hypothesis.”; “Show me the failing test.”; “Try a solution without changing the data model.”; “Prepare a smaller pull request.”; “Ask the customer for the missing information.” Header shows scope and easy return to global.
When work is described, the system may update an existing unit, create a candidate, create accepted work when intent is explicit and policy permits, ask a clarifying question, or save knowledge. Briefly state the outcome:
```text
Created candidate work: Improve OAuth diagnostics
Linked to: Restore OAuth reliability · Sources attached: 3
```
Mentions insert structured references (without copying IDs/links) to a work unit, project, goal, artifact, source, or person.
## 9. Status design
Use these user-facing statuses and explicit primary actions:
| Status | Meaning | Primary actions |
| --- | --- | --- |
| **Proposed** | Suggested, not started. | Accept; edit; merge; dismiss. |
| **Clarifying** | Outcome/requirements incomplete. | Answer question; add context; accept assumption. |
| **Ready** | Defined enough to begin. | Start; review plan; change priority. |
| **Active** | Progressing. | Inspect; steer; pause; add context. |
| **Waiting** | Depends on person, dependency, approval, or external event. | Resolve blocker; grant access; make decision; continue waiting. |
| **Review** | Artifact/result needs evaluation. | Approve; request changes; continue discussion. |
| **Monitoring** | Shipped; outcome is being watched. | Inspect result; extend monitoring; create follow-up; roll back. |
| **Done** | Completed or intentionally stopped. | View result; reopen; create follow-up; archive. |
Modifiers: **Blocked, Approval required, Waiting on user, Waiting on dependency, Running autonomously, Scheduled, At risk**. Always pair color with text and iconography.
## 10. Activity language
Activity is meaningful product language, not raw agent noise.
- **Prefer:** Investigated authentication flow; identified likely state-validation bug; added regression tests; verification passed; PR created; waiting for merge approval.
- **Hide from primary UI (technical drawer only):** called grep; ran shell command; read 34 files; model generated 2,600 tokens; actor woke.
Group related events into sessions:
```text
Implementation run · 24 minutes
• Inspected callback handler • Added state validation
• Added regression tests • 14 tests passed
• Created PR #184
```
## 11. Provenance
Four progressively disclosed levels:
1. **Source chips (on cards):** Git, Support, Analytics, Conversation, System inference, Human decision.
2. **Evidence panel:** sources supporting current understanding/recommendation.
3. **Raw source:** original message, issue, log, file, or event.
4. **Derivation detail (high-impact claims):** conclusion; evidence; assumptions; confidence; conflicting evidence; last updated; producing agent/process.
Labels: **Verified, Reported, Inferred, Assumed, Conflicting, Outdated**. Do not force derivation detail during normal operation.
## 12. Organization/project/portfolio scope
Users may manage several organizations/projects. The active organization is always visible; switching organization changes the full scope; never blend private context across organizations. Portfolio views may aggregate counts/summaries only with explicit labels. Composer indicates portfolio, organization, project, or work-unit scope.
```text
Portfolio
├── Company A
│ ├── Product Alpha
│ └── Internal Platform
└── Client B
└── Customer Portal
```
Do not require deep-tree navigation for every action: provide search, recent projects, and scope-aware commands.
## 13. Project onboarding
Onboarding prepares a capable working environment, not a long settings form.
- **Entry:** start from template; connect existing repository; import repository; create empty project.
- **Initial templates:** React, Hono, Express, full-stack web application, API service, Expo application, monorepo. Each card states inclusions, fit, expected setup time, and included verification.
- **Context setup:** concise sections for Product, Business, Design, Technical conventions, Agent instructions. System may draft from repository/conversation; user reviews/confirms. Allow progressive completion; clearly mark missing context; do not require complete documents before starting.
- **Readiness:** Repository ready; Dependencies prepared; Context confirmed; Verification passed; Preview available; Agent ready. Describe infrastructure in plain operational language, not raw logs.
## 14. Artifacts
Artifacts are first-class in the work unit. Present as PR summary, code diff, preview frame, test report, screenshot gallery, research document, design proposal, generated file, or release note. Each shows **name, type, producer, time, related run, verification state, review state, source/destination**. Open in the right panel or focused viewer without breaking work-unit context.
## 15. Human actions
Use explicit actions, never vague “continue”: **Approve plan; answer question; grant temporary access; review preview; review changes; request revision; approve branch push; approve PR; approve merge; approve deployment; send message; pause autonomous work; cancel run; reopen work.** Every sensitive action shows scope and consequence:
```text
Deploy preview
Project: Zopo Web · Branch: work/W-103/team-invitations
Environment: Temporary preview · External impact: None
Expected duration: 20 minutes
[Deploy preview]
```
## 16. Autonomy controls
Do not expose a complex agent-control panel by default. States: **Manual; Ask before actions; Autonomous within policy; Paused**. Project/organization policy can set defaults. Example unit indicators:
```text
Autonomous within policy · No action required
Paused after repeated verification failure · Review required
```
Advanced settings may expose policy detail; everyday use stays simple.
## 17. Empty, loading, failure, conflict
| State | Required behavior |
| --- | --- |
| Empty workspace | Never show a blank dashboard; offer Create project, Connect repository, Describe what you are working on, Import existing issues, Start from a template. |
| No active work | Show recent outcomes and suggested candidates; never invent busywork. |
| Run provisioning | Use “Preparing workspace”, “Loading project context”, “Starting implementation”; expose infrastructure terms only when delayed/failed. |
| Failure | Explain what failed; preserved work; retry safety; recommendation; whether human action is required. Example: “Verification failed. The implementation is saved, but 2 browser tests are failing. The system recommends another repair attempt.” Actions: Review failures / Retry repair / Stop work. |
| Conflicting evidence | Show conflict, never silently choose: “Support reports OAuth failures increased; Analytics currently shows no significant change.” Actions: Inspect sources / Continue monitoring / Start investigation. |
## 18. Responsive behavior
| View | Layout/behavior |
| --- | --- |
| Desktop | Three columns; one expanded unit; right contextual panel; persistent bottom composer. |
| Tablet | Collapsible left index; primary workspace; right panel as overlay or resizable drawer. |
| Mobile | Bottom navigation **Now, Attention, Work, Projects**; vertical card feed; expanded work full-screen route/sheet; composer fixed above navigation; context content in tabs/sheets. Prioritize supervision, review, approval over full code inspection. |
## 19. Visual language
Direction: **calm, precise, premium, spacious, highly legible, modern without looking experimental**. Use warm white or restrained dark backgrounds; soft neutral surfaces; thin separators; large rounded corners; subtle depth; strong type hierarchy; restrained accent; selective pastel category/phase states; smooth expansion/scope transitions.
Avoid dense enterprise dashboards; excessive gradients; permanent borders around every item; bright status colors everywhere; model/agent-control clutter; chat bubbles dominating; Kanban as default mental model; gamification. It should feel like a focused operating environment, not AI widgets.
## 20. Motion
Motion reinforces state/continuity: card→workspace expansion; composer-scope change; candidate→accepted work; approval resolving/work resuming; new artifact; blocker clearing. Avoid decorative distraction; the system feels alive because work meaningfully changes, not because it constantly animates.
## 21. Accessibility
Require full keyboard navigation; clear focus states; screen-reader labels for statuses/actions; status never color-only; sufficient light/dark contrast; reduced-motion support; predictable card expansion; confirmations for destructive/external effects; human-readable relative timestamps with exact-time access.
## 22. MVP
### Screens
1. **Active Work:** work-card feed; project/status filters; expanded work unit.
2. **Discovery Inbox:** signals; candidate work; merge/attach.
3. **Attention Queue:** decisions; approvals; questions; reviews.
4. **Project Overview:** goals; active work; project context; artifacts/recent outcomes.
All four persist: header; work index; global/contextual composer; realtime activity.
### Cards
Signal; Candidate; Active work; Waiting/blocker; Approval; Review; Result. Do not create one card type per technical event; use sections/modifiers in the core system.
## 23. Success criteria
Within seconds, users can answer: **What should I focus on? What progressed without me? What is blocked? What requires my decision? Why is this happening? What evidence supports current understanding? What happens next? What changed in the project? What result did completed work produce?** They can start, steer, review, or stop work without understanding internal agent/infrastructure architecture.
## 24. Shipping checklist
- Primary object is a work unit, not a chat session.
- Organization/project scope is clear.
- Next human action is obvious.
- Machine actions and human decisions are separated.
- Provenance is available.
- Uncertainty is represented honestly.
- Detail is progressively disclosed.
- Technical internals are hidden unless useful.
- Composer preserves continuity.
- Experience reduces coordination rather than creating more.
## 25. Core experience statement
Zopo should feel like supervising a capable operating system, not operating a collection of agents: one workspace shows meaningful work; the user resolves the few things requiring judgment and continues conversation where needed; the system handles context, execution, progress tracking, and knowledge retention beneath a calm surface organized around persistent outcomes.

228
docs/PRODUCT.md Normal file
View File

@@ -0,0 +1,228 @@
# Zopo Work OS — Product
> Working spec · product/design/ops/research/agent teams · concepts, behavior, boundaries, value. Excludes implementation, infra, tech choices, low-level UI.
## 1. Summary
Zopo Work OS is a persistent OS for technical founders, product engineers, and small teams managing the full lifecycle of building and growing software across products, companies, repos, customers, and functions. It replaces fragmented chats, trackers, docs, and manually coordinated AI agents with one coherent system organized around **units of work**.
One continuous workspace discovers work, builds understanding, coordinates execution, requests human judgment when needed, tracks outcomes, and retains knowledge. Not a better chat app or just another PM tool — it continuously converts company signals into structured, persistent, executable work.
**Promise:** One workspace that understands the company, turns signals into work, progresses work autonomously, and brings the human in only when direction, judgment, permission, or taste is required.
## 2. Customer
**Ideal:** technical founder/small team that operates 1+ software products across several companies/clients/repos; moves between engineering, product, support, sales, marketing, ops, strategy; uses many tools without a unified model; uses AI coding tools but finds session/thread fragmentation; needs more execution capacity without hiring per function; needs context continuity, reliable delegation, visibility into autonomous work.
**Secondary:** product engineers (several initiatives), fractional CTOs, agencies/studios, solo founders, small product/eng teams, multi-internal-product operators.
Technical-first because code is a clear execution domain (measurable artifacts, established workflows, strong familiarity). Broader direction: research, content, marketing, support, product ops, sales enablement, other knowledge/creative work.
## 3. Problem — work is fragmented three ways
| Fragmentation | Detail |
| --- | --- |
| **Across tools** | One problem spans support→analytics→tracker→browser→repo→PR→release notes→customer comms. Each tool stores a partial view; none owns the lifecycle. |
| **Across AI sessions** | Chat/thread-organized work forces repeated decisions: which conversation, what context to restate, which model/agent, where to store output, how it relates to existing work, what changed after shipping. Conversation becomes structure though conversations are temporary and work is persistent. |
| **Founders as coordination** | Small-team founders bridge every system/function: remember decisions, connect feedback to work, check blockers, move work between tools, review AI output, decide next steps. The founder becomes the human integration bus. |
Zopo removes this burden by maintaining a persistent model of work, context, state, evidence, execution, and outcomes.
## 4. Thesis
- **Conversation is the interface, not the structure.** The durable structure is the **work unit**. Changing tasks must not require a new chat; the persistent conversation's scope moves between whole workspace and a specific work unit.
- **Work = outcomes, not activity.** Represent: _fix Safari OAuth failures; reduce onboarding abandonment; launch team invitations; resolve a billing escalation; rewrite the pricing page; validate a direction._ Do not promote every message/tool-call/agent action to a top-level task.
- **The product participates in work** — not just store/display: discover, understand, plan, execute, verify, publish, measure.
- **Autonomy reduces attention, not adds noise.** Successful when it makes correct progress, informs at the right level, asks only when necessary.
- **Knowledge compounds.** Every decision, result, failure, signal, artifact improves understanding and accelerates/future-proofs future work.
## 5. Core objects
Use a small set of durable concepts that stays stable across domains.
### Organization
Hard business/permission/knowledge boundary. Represents a company, client, startup, internal BU, or personal portfolio. Owns its users, products, policies, integrations, knowledge, work, history. **Information never moves between organizations implicitly.**
### Project / product
Durable operating context within an org: software product, repo-backed app, client engagement, internal platform, website/service, focused initiative. Contains goals, product knowledge, conventions, connected sources, relevant people, active work. Reduces context ambiguity without forcing manual folder structures.
### Goal
What the org/project is trying to achieve (e.g. reduce onboarding failure, reach a reliability target, launch enterprise support, improve trial→paid, close first ten enterprise customers). Distinguishes valuable work from merely possible. A work unit may support 1+ goals but every important unit has a clear reason.
### Signal
Raw info that may matter: support ticket, customer comment, error spike, deploy failure, sales objection, team-channel message, analytics change, founder thought. **Not automatically a task.** Fates: attach to existing work, convert to candidate, save as knowledge, dismiss, mark irrelevant/duplicate.
### Candidate work
Proposed work unit not yet accepted as durable active work. Created when a signal/cluster seems actionable. Explains: proposed outcome, why it matters, supporting evidence, project/goal, system confidence, whether similar work exists. User can accept/edit/merge/defer/dismiss.
### Work unit
Central object. Persistent, stateful representation of one meaningful outcome. Owns: objective, desired result, scope/non-goals, current understanding, requirements, success criteria, status, priority, dependencies, blockers, decisions, evidence, plan, human actions, agent activity, artifacts, results, learning.
Persists across conversations, agent runs, people, tools, time. Not identical to (though may contain): a single prompt, chat thread, agent session, PR, checklist item, branch.
### Step
Bounded action within a work unit: inspect logs, reproduce failure, draft plan, add tests, review preview, contact customers. Organizes execution but must not become another noisy task layer — visible when useful, hidden when only the outcome matters.
### Run
One agent/workflow attempt to progress a work unit: research root cause, implement, review, verify, prep release. A unit can have parallel/repeated runs. Subordinate to the work unit — never replaces it as the primary object.
### Artifact
Durable output/evidence: PR, commit, patch, design, spec, test report, screenshot, preview, research memo, customer-response draft, launch plan. Attached to the work unit, accessible from history and current state.
### Decision
Records an important human/system choice + reasoning: approve plan, choose design direction, reject a migration, delay a feature, accept a limitation. Stays visible after the conversation so future agents don't repeat settled debates.
### Approval
Specific human permission before a sensitive/consequential action: merge code, deploy prod, send customer message, access sensitive data, spend money, change business policy. Precise, inspectable, bounded — user knows exactly what happens after approval.
### Result & learning
Result records post-completion reality, comparing: intended outcome, expected impact, actual outcome, measurement period, unexpected effects, recommended follow-up. Learning = reusable knowledge extracted to improve future planning/execution across the relevant org/project.
## 6. Work lifecycle
Four conceptually consistent phases across all domains.
| Phase | The system… | Goal/output |
| --- | --- | --- |
| **Discover** | Extracts, groups, dedupes, connects signals; proposes candidate work; relates to goals/projects. | Identify meaningful work with sufficient evidence/relevance — _not_ the largest backlog. |
| **Understand** | Gathers context — research, code/system inspection, customer evidence, analytics, prior decisions, constraints, open questions, risks, success criteria. | Actionable understanding, not an endless report. |
| **Create & iterate** | Coordinates production — code, specs, designs, docs, tests, customer comms, launch assets, research, business recommendations. Produces intermediate artifacts, verifies, incorporates feedback without losing continuity. | Working solution. |
| **Publish & learn** | Releases/delivers/shares, then follows the outcome — deploy status, error rates, adoption, customer reactions, conversion, support volume, revenue impact, qualitative feedback. | Measured result. |
Lifecycle ends only when work produces a result, is intentionally stopped, or is superseded.
## 7. Work-unit boundaries
Quality depends on how work is divided.
**Good unit** = one independently meaningful outcome, summarizable in one clear sentence, reaches a recognizable result. Good: _fix Safari OAuth callback failures; decide SAML support this quarter; reduce onboarding time-to-first-value; prepare & launch team invitations._ Poor: _improve the product; marketing; work on onboarding; open the repo; read the logs_ (too broad or too granular).
**Split when:** multiple independent outcomes; different owners/approval paths; different orgs/permission boundaries; different deadlines/success criteria; one part finishes while another stays active; hard to summarize/review; a child deserves its own lifecycle/result.
**Merge when:** two candidates describe the same outcome; different channels reported the same problem; one candidate is just evidence for an existing unit; neither can be completed independently. System may propose merges; uncertain merges stay reversible/user-reviewable.
**Parent/child:** large initiatives contain child units; parent = larger result, each child still independently meaningful. _Parent: Restore OAuth reliability → children: fix Safari callbacks, improve setup diagnostics, update docs, notify customers, monitor post-release completion._ Parent summarizes progress/dependencies without replacing child detail.
## 8. Conversation model
One persistent conversational surface, two scopes.
| Scope | Use |
| --- | --- |
| **Global** | Think/act across workspace: introduce ideas/problems, ask what needs attention, compare work across projects, reprioritize, create/modify work, request summaries, discuss dependencies, request research/execution. System auto-connects messages to relevant projects/units while preserving source. |
| **Contextual** | Same composer focused on one work unit: add requirements, answer questions, review progress, change direction, approve a plan, request another attempt, ask for explanation, continue from an artifact. Entering does **not** create a new thread — it changes the active scope. |
Conversation is not the source of truth. Important facts/decisions/requirements/approvals/artifacts/results are extracted into structured objects while preserving links to source conversation.
## 9. Autonomous behavior
The system continuously observes, understands, organizes, executes, escalates, learns.
**May autonomously:** attach new evidence; update summaries/understanding; detect stale/blocked work; propose new work; run safe research; prepare plans/drafts; produce code/artifacts inside approved boundaries; run verification; suggest priorities; monitor completed work; create follow-up candidates; summarize activity.
**Request human for:** strategic direction; ambiguous product decisions; business tradeoffs; sensitive permissions; high-risk actions; external communication; production changes; priority conflicts; conflicting evidence; actions outside granted policy.
**Must not:** create speculative work without evidence; treat confidence as certainty; hide failures/uncertainty; silently cross org boundaries; reopen settled decisions without new evidence; perform irreversible actions without authority; optimize for activity over outcomes.
## 10. Human-in-the-loop
User must not inspect every unit to find what needs attention. Human requests centralize into an **Attention Queue**. Each request states: what's required, why, the recommendation, supporting evidence, what happens after approval, what happens if the user does nothing, urgency, reversibility. **Categories:** decision · approval · clarification · access request · review · blocker resolution · risk alert.
Minimize unnecessary approvals via explicit standing policies while preserving clear control over sensitive actions.
## 11. Provenance & trust
User must understand why the system believes something and what caused an action. **Every important claim carries provenance.** Sources: message, ticket, repo change, error report, analytics event, document, agent observation, human decision. Claim status distinguished as: verified · reported · inferred · assumed · conflicting · outdated. Primary interface stays readable; deeper evidence/derivation available on demand. **Never present an agent summary as equivalent to primary evidence.**
## 12. Knowledge model
Accumulates several kinds; each retains its scope (personal prefs, project facts, org-wide truths never mixed blindly).
| Kind | Content |
| --- | --- |
| **Canonical** | Reviewed descriptions: business, product, customers, design principles, architecture concepts, conventions, policies, goals. |
| **Operational** | Current structured state: active work, priorities, dependencies, ownership, blockers, approvals, results. |
| **Episodic** | History: conversations, runs, errors, decisions, reviews, releases, customer reactions. |
| **Learned patterns** | Reusable conclusions: common failure causes, customer prefs, repeated objections, effective launch patterns, known limitations, preferred technical/product approaches. |
## 13. Product boundaries
| Boundary | Rule |
| --- | --- |
| **Control layer, not tool replacement** | Specialist systems (source control, support, comms, analytics, billing, deployment, docs) keep native records/actions. Zopo provides the unified model: what matters, its state, next step, whether outcome worked. |
| **Own the work graph** | Connects organizations→projects→goals→signals→work units→dependencies→decisions→runs→artifacts→results→learnings. Durable source of coordination/context. |
| **Execution is replaceable** | Execution is a capability beneath the work unit. Users shouldn't care which agent/model/harness ran unless inspection is useful. |
| **No unnecessary AI config** | Users shouldn't normally choose model, thinking level, agent framework, context-window strategy, execution runtime, tool routing. System chooses by task/risk/cost/policy. |
## 14. Optimization
**Optimize for: maximum useful progress with minimum required human attention.**
Measures: correct extraction of meaningful work; low false-positive candidates; signal→understanding time; time to first useful action; accepted units reaching meaningful outcomes; reduced repeated context gathering; human attention per outcome; decision/output quality; trust/inspectability/reversibility; knowledge reuse; improvement from past results.
**Don't primarily optimize:** chat count, time in interface, task count, agent-run count, notification count, raw model usage.
A successful day: user opens briefly, resolves a few high-value decisions, sees many units progressed correctly without them.
## 15. Business value
| Form | Value |
| --- | --- |
| **Execution capacity** | Small team progresses more work without proportional headcount. |
| **Coordination reduction** | Handles repetitive context-gathering, state updates, linking, follow-ups, output prep. |
| **Continuity** | Work keeps context, history, decisions, artifacts across tools/time. |
| **Better opportunity capture** | Notices recurring problems, customer opportunities, operational risks, key relationships teams otherwise miss. |
## 16. Initial scope — first product loop
1. User starts/connects a software project.
2. Product establishes project context.
3. User describes desired work via global composer.
4. System creates/updates a work unit.
5. Unit is clarified and accepted.
6. System progresses work autonomously.
7. User reviews only necessary decisions/outputs.
8. System produces verified artifacts.
9. User approves publication/integration where required.
10. Product updates the unit and retains knowledge.
First version must prove persistent work units beat disposable agent chats.
## 17. Long-term direction
Technical product is the starting point. The same work-unit model later supports: product research, marketing campaigns, content production, sales enablement, support operations, pricing work, business analysis, hiring, internal ops. Long-term: a general OS for creative/knowledge work.
**Enduring principles:** one persistent workspace · work units not chat sessions · outcome-oriented execution · invisible orchestration · explicit human authority · strong provenance · compounding organizational knowledge.
## 18. Initial non-goals
Not intended to be: a full external-tool replacement; a general social collaboration platform; a traditional kanban with AI decoration; a model playground; a prompt-management product; a marketplace of visible agents; a system that creates work faster than users can evaluate; a fully autonomous company with no human authority.
## 19. Principles
1. Organize around work, not conversations.
2. Represent outcomes, not activity.
3. Keep the system calm while the machinery stays powerful.
4. Bring humans in for judgment, not clerical coordination.
5. Expose evidence and uncertainty.
6. Make important actions understandable and reversible.
7. Preserve organization and project boundaries.
8. Prefer one coherent model over many disconnected features.
9. Let knowledge compound across completed work.
10. Measure success by outcomes and reduced attention.
## 20. Working statement
Zopo Work OS is an autonomous OS for technical founders and small product teams. It observes work across the company, turns fragmented signals into persistent units of work, progresses them via agents and tools, and brings the human in only when judgment, permission, or direction is required. One continuous workspace moves from idea/problem to verified, shipped, measurable outcome while preserving context and building durable understanding over time.

251
docs/TECH.md Normal file
View File

@@ -0,0 +1,251 @@
# Zopo Work OS — Technical Architecture
> Working spec · engineering/platform/infra/security/agent teams · starts from an existing Better-T-Stack monorepo (web + Expo + shared packages).
**Objective.** Multi-tenant Work OS: durable orgs/projects/goals/work units/runs/artifacts/outcomes; one global conversation + scoped work-unit conversations; continuous candidate-work extraction from conversation and connected systems; coding agents in isolated reproducible environments; autonomous execution with explicit human-approval boundaries; agent progress streamed into durable state; inspectable artifacts (commits, PRs, test reports, previews, docs, reviews); scales across orgs/repos/concurrent units/environments; harnesses and model providers replaceable.
**Central ownership rule.** Work OS owns intent + durable state · Flow agents own orchestration · AgentOS owns agent runtime coordination · Harnesses own individual agent loops · Sandboxes own full-system execution · Git owns source history · Artifact storage owns generated outputs.
## 1. High-level architecture
Control plane + execution plane.
```mermaid
flowchart TB
User --> Web[Web] & Mobile[Expo]
Web & Mobile --> API[App API] & Realtime
API --> Auth[Identity/tenancy] & WorkGraph[Work graph] & Conversation & Policy
Conversation & WorkGraph --> Flow[Flow Agent]
Flow --> Context & Scheduler & Policy & Gateway[Model gateway] & Integrations & Secrets[Secrets/workload identity broker]
Scheduler --> Actor[AgentOS run actor]
Actor <--> Harness[OpenCode/ACP] & Sandbox[Full Linux sandbox]
Harness --> Gateway & Integrations
Sandbox --> Git & Runtime[Services/browsers/tests/builds]
Actor & Sandbox --> Secrets
Runtime --> Preview
Actor & Sandbox & Git --> Events[Event/provenance log]
Events --> WorkGraph & Realtime & Observability
Artifact[Object storage] --> WorkGraph
Context & WorkGraph --> Search
Policy --> Notifications
```
## 2. Components & ownership
**Client apps (web, Expo)** — product surfaces only: render org/project/work-unit/run/artifact/attention state; send domain commands via app API; subscribe realtime; upload via signed/brokered flows. Never reach model providers, Git internals, harnesses, or sandboxes directly. Commands: create work unit, continue work, change composer scope, approve plan, resolve blocker, review artifact, request changes, pause/cancel run, approve merge, approve deployment.
**Application API** — sole public backend: authz; org/project scoping; CRUD stable entities; command validation; conversation ingestion; attachment metadata; card/workspace/activity/attention queries; realtime-subscription authz; idempotency. Never expose internal runtime APIs.
**Identity & tenancy** — one Work OS identity per user; organizations are hard tenant boundaries. Every durable/transient entity carries `organization_id` (+ `project_id`/`work_unit_id`/`run_id` where applicable). Deny-by-default authz on every command/query. Cross-org queries are explicit portfolio ops; never blend underlying knowledge/execution context.
**Secrets & workload-identity broker** — shared service owning short-lived run identities and scoped credential minting/injection for Git, model gateway, integrations, artifact storage, preview routing, and optional cloud access; secrets remain outside repositories/actor state, and broker decisions are policy-bound and auditable.
**Work graph (canonical product DB)** — relational DB (authoritative) + event table (append-only provenance/replay) + materialized projections (cards, attention, portfolio, activity) + object storage (large artifacts) + search index. Entities: User, Organization, Membership, Project, Project source, Goal, Signal, Candidate, Work unit, Work-unit relation, Step, Run, Run attempt, Agent session, Blocker, Decision, Approval, Artifact, Source reference, Result, Learning, Conversation message, Composer scope, Integration connection, Policy, Workload identity. Work-unit record = structured fields + projections, **not** a big agent-authored JSON blob.
**Event & provenance log** — all meaningful changes are events. Envelope:
```json
{
"event_id": "evt_01...",
"type": "run.verification_completed",
"organization_id": "org_01...",
"project_id": "prj_01...",
"work_unit_id": "wrk_01...",
"run_id": "run_01...",
"actor_type": "agent",
"actor_id": "opencode",
"source_type": "agentos_session",
"source_id": "session_01...",
"sequence": 184,
"payload": {
"summary": "All unit and browser tests passed",
"artifact_ids": ["art_01..."]
},
"created_at": "2026-07-23T12:00:00Z"
}
```
Requirements: monotonic sequence per run/actor stream; idempotent ingestion; immutable original payload; normalized derived state; trace card→events; distinguish raw evidence vs agent interpretation vs human decision.
**Conversation service** — persistent conversation per user workspace, scoped. Message fields: org scope (+optional project/work-unit scope), author, content blocks, attachments, source references, extraction status, domain-event links. Persist exact user message; publish for extraction/orchestration; global + work-unit scope without new sessions; one message may reference several work units; preserve source links when structured state is extracted.
**Flow Agent (orchestration above specialist agents)** — interpret intent; extract signals/candidates; match new info to existing work; detect duplicates/relationships; build/update work briefs; decide whether a human question is required; select execution strategy; create runs; monitor run events; retry/fallback/escalate; summarize into work-unit state; create approval requests; propose follow-ups. Not the sole durable truth — outputs validated into commands/events. Does not directly mutate repos; coding is delegated to a harness in an isolated environment.
**Memory classes (four)**
- _Canonical project knowledge_ (versioned/reviewable, prefer in Git): `product.md`, `design.md`, `tech.md`, `business.md`, `AGENTS.md`, READMEs, decision records, repo-local conventions.
- _Structured operational memory_ (DB): goals, work-unit state, requirements, decisions, approvals, blockers, dependencies, results.
- _Episodic memory_ (events/transcripts): user messages, agent runs, tool calls, errors, reviews, deployments, human interventions.
- _Retrieval index_ (derived, non-authoritative → returns source refs): canonical docs, repo content, conversation, work-unit events, artifacts, connected external sources.
**Context assembly** — never inject all knowledge. Run context pack by precedence: (1) org policy (2) project goals + canonical context (3) repo conventions/instructions (4) work-unit objective/requirements/decisions (5) relevant evidence/artifacts (6) current run instructions (7) runtime + tool constraints. Each item retains source, scope, timestamp, confidence/verification status, revision id. Deterministic enough to audit/reproduce.
**Policy & approval engine** (above harness permissions + runtime capabilities) — per action: auto-allowed / allowed under standing policy / requires human approval / denied. Examples: create branch, push work branch, create PR → automatic; merge → approval; deploy preview → automatic; deploy production → approval; never send customer comms without review; never access prod data from a default coding run. Approval record = exact action, params, requested capability, evidence/rationale, risk level, expiration, user decision, resulting action event.
**Run scheduler / workflow engine** — converts accepted work → bounded runs. Owns queue, tenant quotas, priority, fairness, concurrency limits, run leasing, retry policy, budget allocation, cancellation, workflow checkpoints, waiting-for/resuming-after approvals. Run = idempotent steps; persist progress before waiting on human input.
**AgentOS runtime** — durable supervisor/harness runtime per active run. One actor per active run or isolated workbox (never one giant actor per user/org); durable run-environment identity; harness session lifecycle; event streaming; permission requests; lightweight fs/process coordination; sleep/wake when safe. Actor key: `org:{orgId}:project:{projectId}:work:{workUnitId}:run:{runId}`. Durable work unit survives actor destruction/replacement.
**Agent harness** — OpenCode is the initial coding harness (architecture supports other ACP-compatible/adapted harnesses later). Harness owns: model interaction loop, tool selection, repo inspection, file editing, shell requests, local reasoning state, session progress. Harness does **not** own: product work-unit state, org policy, billing, global memory, project permissions, long-term artifact retention. Drive OpenCode through AgentOS; never expose its standalone server to product clients.
**Full Linux sandbox** — compatibility/heavy-execution environment: repo checkout, writable branch/worktree, native binaries, package install, language runtimes, DBs, browser automation, dev servers, tests/builds, file watching, preview processes. AgentOS supervises via explicit adapters/bindings. Division:
```
AgentOS: durable run identity, harness session, approvals, lightweight coordination
Sandbox: full repo fs, native commands, services, browser, tests, builds
```
Known first-party templates may run in lighter env; imported arbitrary repos default to full sandbox.
**Workbox** (product-neutral term for execution capsule beneath a run) — contains repo checkout, isolated branch, runtime recipe, harness config, project skills, temp credentials, running services, preview endpoints, test outputs. Ephemeral/replaceable; work unit is durable. Default isolation: **one writable checkout per active work unit / mutating run**. Parallel read-only runs may share immutable snapshots; parallel mutating runs use separate branches + separate Workboxes.
**Git (managed/connected)** — source history. Creation modes: managed template, connect external, import+mirror, managed-first then add external remote. Identity: one Work OS user identity, internal service identities for automation, repo-scoped short-lived credentials, no second visible Git account for users. Branching: `main` + `work/{W-id}/{slug}`. Git may be hidden in product but user retains portability (export, clone, patch download, external remotes).
**Project templates** — React web, Hono, Express, full-stack, API-only, Expo, monorepo starter. Template defines: source files, runtime versions, install/test/verify/dev commands, service ports, preview behavior, project context prompts, default policies, agent skills. Generates a reusable base snapshot after successful setup+verification.
**Project config files** — repo structure:
```
/ README.md, AGENTS.md, product.md, design.md, tech.md
/.zopo/ project.yaml, services.yaml, policies.yaml
lifecycle/{setup,resume,verify,preview} skills/{testing,reviewing,deploying}/SKILL.md
/apps/
```
`project.yaml`:
```yaml
name: example-project
runtime: { template: react-hono, node: "24", packageManager: pnpm }
commands:
{
install: pnpm install --frozen-lockfile,
test: pnpm test,
verify: pnpm run verify,
dev: pnpm dev,
}
services: { web: { port: 3000 }, api: { port: 4000 } }
changes: { workflow: pull_request, baseBranch: main }
approvals:
{
pushBranch: automatic,
createPullRequest: automatic,
mergePullRequest: required,
deployPreview: automatic,
deployProduction: required,
}
```
**Executor / integration gateway** — shared integration+tool gateway: GitHub, Slack, Linear, Intercom, analytics, CRM, cloud services, internal APIs, custom functions. Owns connection mgmt, tool catalog, scoped auth, tool-level policies, approval requirements, auditable invocation. Does **not** own: shell exec, repo fs ops, work-unit state, agent session state, sandbox lifecycle. Flow Agent may get broader org-level tools; OpenCode gets only project/run-scoped tools for the current task.
**Model gateway** — all model calls route here. Provider credentials, auto routing, tenant attribution, cost accounting, budget enforcement, rate limits, retries/fallback, prompt/response logging under policy, redaction, run-scoped credentials. Don't expose model selection by default. Run token (short-lived, scoped): `{organization_id, project_id, work_unit_id, run_id, model_classes:["coding-standard","coding-fast"], budget_usd:8, expires_in_seconds:3600}`.
**Artifact storage** (object storage, not actor state/DB rows) — types: patch, commit bundle, test report, build log, screenshot, video, preview snapshot, research doc, review report, generated file. Metadata: tenant/work-unit/run scope, type, content hash, producer, source event, retention policy, access policy.
**Preview gateway** — controlled exposure (not raw sandbox ports): authenticated access, project/run-scoped routing, short-lived URLs, service health, automatic shutdown, event on ready/fail, screenshot/browser verification integration.
**Observability & billing** — every path emits logs, metrics, traces, cost, token use, runtime duration, tool calls, network use, artifact size, retry count, user-intervention count. Correlation dims: `organization_id, project_id, work_unit_id, run_id, actor_id, session_id, sandbox_id, sequence, timestamp`. Cost per successful outcome, not per model request.
## 3. Core execution flows
**Project onboarding.** User creates project → Project service creates/imports/connects repo → apply template (commit initial files) → Context service drafts context docs → user reviews/commits canonical docs → template runs setup+verify → Snapshot service creates sanitized base snapshot → project active.
**Prompt → candidate work.** User describes request → Conversation persists scoped message → publishes extraction job → Flow searches related work/sources → creates/updates signal → if existing work matches: attach signal + update understanding; else if new outcome: create candidate → Work graph publishes materialized update → realtime shows updated/new card.
**Accepted work → verified PR.** User approves candidate → accept work unit → Flow persists objective + acceptance criteria → Scheduler creates implementation run → AgentOS provisions run actor → Workbox creates isolated sandbox → clone repo + create work branch → start OpenCode session → send run context pack. **Execution loop:** harness inspects/edits/executes, streams events/requests to AgentOS, which persists meaningful events → work card updates. On completion: run verification → upload reports/screenshots → commit/push branch → Flow creates PR + attaches artifacts + moves to Review → UI shows PR/preview/evidence/human action.
**Approval continuation.** Harness requests sensitive action → AgentOS → Policy evaluates action/scope: auto-allow · or → creates approval request to attention queue → user sees action/evidence/risk/impact → approve/modify/reject → policy persists decision → AgentOS resumes with bounded permission · or → deny with reason.
## 4. State machines
Keep product, run, and environment state separate. Frontend primarily exposes work-unit state; run state = operational detail; Workbox state hidden except debugging/delayed startup.
```
Work unit: proposed → clarifying → ready → active → waiting|review|monitoring → done
modifiers: blocked, waiting_on_user, waiting_on_dependency, approval_required, autonomous, at_risk, scheduled
Run: queued → provisioning → preparing → running → waiting_for_input|verifying → succeeded|failed|cancelled
Workbox: creating → preparing → ready → busy → sleeping|waking → destroyed
```
## 5. Concurrency model
- One writable checkout per active mutating run; one branch per independently shippable work unit.
- Serialize mutating runs against same branch; permit parallel read-only analysis on immutable snapshots; permit parallel child work on separate branches; merge/integration = explicit controlled action.
- A work unit may use multiple runs: research → implementation → automated review → verification → human review.
- Never let agents share one writable working dir without deliberate coordination + conflict handling.
## 6. Persistence & recovery
Durable truth lives outside transient agent processes. Persist after every meaningful step: work-unit state changes, decisions, approval requests, agent summaries, audit-relevant tool results, commit/patch checkpoints, artifact metadata, verification results. Don't rely on running shells, in-memory harness state, open sockets, or uncommitted working dirs surviving. Before waiting for human input: persist findings, commit relevant files, upload artifacts, record exact requested decision, store resumable workflow state. All workflow steps idempotent or safely repeatable.
## 7. Security model
**Trust boundaries** — untrusted: imported repos, repo instructions, external docs, customer messages, MCP tools, tool responses, generated code, agent-authored summaries. A model recommendation is **not** an authorization decision.
**Layered authorization** — sensitive action requires ALL: `product policy AND harness permission AND runtime capability AND scoped credential`. Path: agent requests → harness permission check → Work OS policy eval → human approval when required → runtime capability check → credential minting → execution → provenance event.
**Workload identity** — each run gets short-lived identity: org_id, project_id, work_unit_id, run_id, initiating principal, permitted capabilities, expiration. Workbox exchanges it for scoped Git/model-gateway/integration/artifact/preview/cloud access. Never inject long-lived org credentials into sandboxes.
**Network policy** — deny-by-default or allowlisted by run type (package registry during setup, repo host, model gateway, approved external APIs; no unrestricted prod network by default).
**Secrets** — stored outside repos/actor state; org/project scoped; minted/injected only when required; short-lived; redacted from logs/transcripts; rotatable without rebuilding projects; audited on access.
**Data isolation** — every query/event/artifact/context lookup/runtime token is tenant-scoped. Cross-org portfolio views may aggregate summaries but never give one org's raw context to another org's agent run.
**Human approvals** required for: prod deployment, merging protected branches, accessing sensitive prod data, external communications, billing/financial changes, data deletion, org-level policy changes. Action-specific; expire after use or time.
**Auditability** — retain: who initiated, context provided, agent+model class, tools called, files changed, approvals requested/granted, artifacts produced, what was published/merged, outcome observed.
## 8. Scaling model
Scale via many small isolated actors + queued runs (not long-lived per-user machines).
**Tenant scheduler** enforces: max concurrent runs per org/project, priority classes, fair scheduling, subscription limits, model/sandbox budget, retry/runtime-duration limits. **Work queue** states: `accepted → queued → leased → running → completed`. Leases idempotent + recoverable (prevent duplicate expensive execution). **Resource limits** (hard + soft): tokens/run, cost/run, runtime duration, tool calls, file count + repo size, network bytes, process count, memory/CPU, artifact size, subagent depth, repeated-failure count. **Artifact offloading** — large logs/binaries → object storage; keep refs in work graph. **Snapshots** — sanitized reusable snapshots accelerate setup. Layers: base OS → language/toolchain → template → project deps. Never snapshot: long-lived creds, user secrets, active tokens, unreviewed prod data. **Multi-region** (not MVP unless demanded) — keep control-plane relational consistency explicit: org home region, region-local execution actors + artifact/cache placement, global routing to org's control plane, explicit data-residency restrictions.
## 9. Better-T-Stack monorepo mapping
```
apps/ web, expo, api, worker, agent-runtime, preview-gateway
packages/ db, auth, domain, api-contract, events, realtime, work-graph, conversation,
flow-agent, context, policy, scheduler, integrations, model-gateway, git,
artifacts, sandbox, agentos, opencode, observability, billing, ui, config
```
Boundaries: **domain**=pure types + state transitions · **events**=envelopes/schemas/reducers/provenance · **work-graph**=work-entity queries + materialized views · **conversation**=message persistence/scopes/extraction jobs/source links · **flow-agent**=orchestration/planning/run coordination · **context**=canonical loading/retrieval/context-pack construction · **policy**=evaluation + approval workflows · **scheduler**=queues/leases/quotas/workflows/retries/cancellation · **agentos**=client/actor naming/session lifecycle/event translation · **opencode**=harness config/session prompts/tool policy/result normalization · **sandbox**=provider adapters/workspace lifecycle/commands/logs/previews · **git**=repo provisioning/branches/commits/pushes/PRs/mirrors · **integrations**=executor connection mgmt + tool scopes · **model-gateway**=provider routing/budgets/credentials/cost attribution.
## 10. API & command design
Prefer domain commands over generic entity mutation. Examples: `CreateProject, ImportRepository, CreateWorkUnit, AcceptCandidate, AttachSignal, MergeCandidates, StartWork, UpdateRequirements, ApprovePlan, ResolveBlocker, RequestChanges, PauseRun, CancelRun, ApproveAction, RejectAction, ApproveMerge, ApproveDeployment, ReopenWorkUnit`. Each command: validate tenant scope + current state; idempotent where practical; emit ≥1 domain event; no direct client control of infra details.
## 11. Event translation (harness → product)
Preserve raw harness events for audit; translate to human-meaningful product events:
```
tool_call bash "pnpm test" → run.verification_started
tool_result exit_code=0 → run.test_suite_passed
permission_request git_push → approval.requested
file_edit src/auth/callback.ts → artifact.code_change_updated
```
Primary UI hides raw transcripts unless user opens technical details.
## 12. Implementation sequence
- **Phase 1 — Durable product model:** orgs, projects, work units, candidates, runs, artifacts, conversation scopes, event log, card projections.
- **Phase 2 — One complete coding loop:** one template project, managed Git, one sandbox provider, one AgentOS actor/run, OpenCode harness, model gateway, branch creation, tests, artifact upload, PR output, human approval.
- **Phase 3 — Context & memory:** canonical Markdown files, context-pack construction, retrieval index, decision memory, source provenance.
- **Phase 4 — Autonomous discovery:** continuous signal ingestion, candidate extraction, duplicate detection, existing-work matching, attention queue.
- **Phase 5 — Additional integrations/workflows:** external Git, support, team comms, issue trackers, analytics, deployment + monitoring.
## 13. MVP success criteria
Reliably demonstrate: `Prompt → candidate/work unit → clarified objective → isolated coding run → tested change → preview/evidence → pull request → human review → durable completed work card`. The work unit must remain understandable + resumable even if the harness session or Workbox is destroyed.
## 14. Non-goals (first release)
Every harness; every sandbox provider; arbitrary multi-agent swarms; full replacement of external Git providers; multi-region active-active control plane; unlimited imported-repo compatibility; automatic prod deployment without approval; generic low-code workflow builder; exposing model selection + agent internals to users.
## 15. Architectural principles
1. Durable work state outlives execution environments. 2. Every execution path is tenant-scoped. 3. One writable checkout per mutating unit of work. 4. Persist events before projecting summaries. 5. Treat agent outputs as claims with provenance, not unquestioned truth. 6. Separate product policy, harness permission, and runtime capability. 7. Short-lived credentials + explicit workload identity. 8. Keep harnesses, models, sandboxes, and Git providers replaceable. 9. Hide infrastructure state from the product surface unless actionable. 10. Measure successful outcomes and human attention, not merely agent activity.

View File

@@ -7,6 +7,9 @@
"packages/*" "packages/*"
], ],
"catalog": { "catalog": {
"@rivet-dev/agentos": "^0.2.7",
"@rivet-dev/agentos-core": "^0.2.10",
"@effect/platform-bun": "4.0.0-beta.99",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"zod": "^4.4.3", "zod": "^4.4.3",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
@@ -19,7 +22,14 @@
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"tailwindcss": "^4.3.2", "tailwindcss": "^4.3.2",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0",
"@better-auth/expo": "1.6.15" "@better-auth/expo": "1.6.15",
"effect": "4.0.0-beta.99",
"typescript": "^6",
"@types/bun": "latest",
"heroui-native": "^1.0.5",
"vite": "^7.3.6",
"vitest": "^4.1.10",
"convex-test": "^0.0.54"
} }
}, },
"type": "module", "type": "module",
@@ -53,14 +63,16 @@
"dependencies": {}, "dependencies": {},
"devDependencies": { "devDependencies": {
"@code/config": "workspace:*", "@code/config": "workspace:*",
"@types/bun": "latest", "@types/bun": "catalog:",
"@types/node": "^22.13.14", "@types/node": "^22.13.14",
"oxfmt": "latest", "oxfmt": "latest",
"oxlint": "latest", "oxlint": "latest",
"rolldown": "1.1.4", "rolldown": "1.1.4",
"typescript": "^6", "typescript": "catalog:",
"ultracite": "7.9.3", "ultracite": "7.9.3",
"vite-plus": "0.2.2" "vite-plus": "0.2.2",
"convex-test": "catalog:",
"vitest": "catalog:"
}, },
"overrides": { "overrides": {
"vite": "npm:@voidzero-dev/vite-plus-core@0.2.2" "vite": "npm:@voidzero-dev/vite-plus-core@0.2.2"

View File

@@ -15,7 +15,7 @@
"@code/backend": "workspace:*", "@code/backend": "workspace:*",
"@code/env": "workspace:*", "@code/env": "workspace:*",
"@flue/runtime": "latest", "@flue/runtime": "latest",
"@rivet-dev/agentos-core": "^0.2.10", "@rivet-dev/agentos-core": "catalog:",
"convex": "catalog:", "convex": "catalog:",
"hono": "4.12.30", "hono": "4.12.30",
"valibot": "^1.4.2" "valibot": "^1.4.2"
@@ -23,7 +23,7 @@
"devDependencies": { "devDependencies": {
"@code/config": "workspace:*", "@code/config": "workspace:*",
"@flue/cli": "latest", "@flue/cli": "latest",
"@types/bun": "latest", "@types/bun": "catalog:",
"typescript": "^6" "typescript": "catalog:"
} }
} }

View File

@@ -2,7 +2,6 @@ import path from "node:path";
import { parseAgentEnv } from "@code/env/agent"; import { parseAgentEnv } from "@code/env/agent";
import { defineAgent } from "@flue/runtime"; import { defineAgent } from "@flue/runtime";
import type { AgentRouteHandler } from "@flue/runtime";
// import { agentos } from "../sandboxes/agentos"; // import { agentos } from "../sandboxes/agentos";
import { local } from "@flue/runtime/node"; import { local } from "@flue/runtime/node";
@@ -11,7 +10,7 @@ import { paseoCli } from "../tools/paseo";
const repositoryRoot = path.resolve(process.cwd(), "../.."); const repositoryRoot = path.resolve(process.cwd(), "../..");
export const route: AgentRouteHandler = (_context, next) => next(); export { authenticatedAgentRoute as route } from "../auth";
export default defineAgent(({ env }) => { export default defineAgent(({ env }) => {
const { AGENT_MODEL_NAME, AGENT_MODEL_PROVIDER } = parseAgentEnv(env); const { AGENT_MODEL_NAME, AGENT_MODEL_PROVIDER } = parseAgentEnv(env);
@@ -20,7 +19,7 @@ export default defineAgent(({ env }) => {
cwd: repositoryRoot, cwd: repositoryRoot,
description: "A simple conversational agent with persistent history.", description: "A simple conversational agent with persistent history.",
instructions: instructions:
"You are Zopu, a concise and helpful assistant. Answer the user's request directly. Ask a clarifying question only when the request cannot be completed without one.", "You are Zopu, the global planning agent. Help the user clarify and plan work. User messages are durably captured as Signal evidence by the control plane; do not claim that you created a Signal until secure tool execution is wired.",
model: `${AGENT_MODEL_PROVIDER}/${AGENT_MODEL_NAME}`, model: `${AGENT_MODEL_PROVIDER}/${AGENT_MODEL_NAME}`,
// sandbox: agentos(), // sandbox: agentos(),
sandbox: local({ cwd: repositoryRoot }), sandbox: local({ cwd: repositoryRoot }),

318
packages/agents/src/auth.ts Normal file
View File

@@ -0,0 +1,318 @@
/**
* Authenticated Flue route middleware for the organization-scoped global agent.
*
* Every request to the Zopu agent routes through here. The middleware:
*
* 1. Requires a Bearer access token (the Better Auth/Convex JWT).
* 2. Creates a fresh, request-scoped {@link ConvexHttpClient} and sets the
* token on it — never on a shared/global client. Auth is per-request.
* 3. Resolves (ensuring) the caller's current personal organization.
* 4. Requires the agent instance id (`:id`) to equal that organization id, so
* one organization cannot use or observe another's agent instance.
* 5. Authorizes GET/SSE observation routes as strictly as POST.
* 6. For POST only: captures exact user-message evidence *before* Flue
* admission (`beginUserMessage`, status `admitting`), then patches it to
* `admitted` with the Flue receipt submission id, or `failed` on error.
* Observation requests (GET/HEAD) never create evidence.
*
* The organization id, conversation id, message id, role, and timestamp are
* never trusted from the client or an agent tool — they are resolved
* server-side. Only the raw message text travels from the user, and it is
* stored verbatim (never trimmed or rewritten).
*/
import { parseAgentEnv } from "@code/env/agent";
import type { AgentRouteHandler } from "@flue/runtime";
import { ConvexHttpClient } from "convex/browser";
import { makeFunctionReference } from "convex/server";
// ---------------------------------------------------------------------------
// Typed function references (no generated backend bindings imported).
// ---------------------------------------------------------------------------
interface OrganizationView {
readonly _id: string;
readonly _creationTime: number;
readonly name: string;
readonly kind: "personal" | "team";
readonly createdBy: string;
readonly createdAt: number;
}
interface ConversationMessageView {
readonly _id: string;
readonly _creationTime: number;
readonly organizationId: string;
readonly conversationId: string;
readonly messageId: string;
readonly submissionId: string | null;
readonly clientRequestId: string;
readonly role: "user";
readonly rawText: string;
readonly status: "admitting" | "admitted" | "failed";
readonly createdAt: number;
}
const ensurePersonalOrganization = makeFunctionReference<
"mutation",
Record<string, never>,
OrganizationView
>("organizations:ensurePersonalOrganization");
const beginUserMessage = makeFunctionReference<
"mutation",
{
readonly organizationId: string;
readonly clientRequestId: string;
readonly rawText: string;
},
ConversationMessageView
>("conversationMessages:beginUserMessage");
const markAdmitted = makeFunctionReference<
"mutation",
{
readonly organizationId: string;
readonly clientRequestId: string;
readonly submissionId: string;
},
ConversationMessageView
>("conversationMessages:markAdmitted");
const markFailed = makeFunctionReference<
"mutation",
{
readonly organizationId: string;
readonly clientRequestId: string;
},
ConversationMessageView
>("conversationMessages:markFailed");
// ---------------------------------------------------------------------------
// Request-scoped authenticated Convex client.
// ---------------------------------------------------------------------------
const { CONVEX_URL } = parseAgentEnv(process.env);
/**
* A short-lived authenticated Convex client bound to one HTTP request. The
* caller's JWT is set on this instance only and discarded when the request
* ends. We never mutate auth on a shared/global Convex client.
*/
const createAuthenticatedClient = (accessToken: string): ConvexHttpClient => {
const client = new ConvexHttpClient(CONVEX_URL);
client.setAuth(accessToken);
return client;
};
/**
* Minimal structural shape the header helpers read from an incoming request.
* Using this avoids a structural clash between the global (Bun) `Request`/
* `Headers` and the undici types that Hono's context exposes, while staying
* type-safe.
*/
interface HeaderSource {
readonly headers: {
readonly get: (name: string) => string | null;
};
}
/**
* Extract and validate the Bearer access token from the request. Returns null
* when absent or malformed so the caller can produce a clean 401.
*/
const extractBearerToken = (request: HeaderSource): string | null => {
const header = request.headers.get("authorization");
if (!header) {
return null;
}
const match = /^Bearer\s+(?<token>\S+)$/u.exec(header);
return match?.groups?.token ?? null;
};
/**
* Read the stable per-send request id supplied by the web client. This is
* generated once when the user sends a message and reused across fetch/stream
* retries, so it is a reliable idempotency key for evidence capture.
*/
const extractClientRequestId = (request: HeaderSource): string | null => {
const header = request.headers.get("x-zopu-request-id");
if (!header || header.length === 0) {
return null;
}
return header;
};
/**
* Safely parse the direct agent payload to extract the exact user message
* text. Mirrors Flue's `DirectAgentPayload` shape `{ message, images? }`.
* Returns null for non-JSON or missing `message` so the request is rejected
* with 400 rather than crashing the middleware.
*/
const extractMessageText = async (
request: HeaderSource & { readonly json: () => Promise<unknown> }
): Promise<string | null> => {
const contentType = request.headers.get("content-type") ?? "";
if (!contentType.includes("application/json")) {
return null;
}
try {
const body = (await request.json()) as unknown;
if (
typeof body !== "object" ||
body === null ||
!("message" in body) ||
typeof (body as { message?: unknown }).message !== "string"
) {
return null;
}
return (body as { message: string }).message;
} catch {
return null;
}
};
/**
* Read the Flue admission receipt submission id from the response Flue
* produced after `next()`. The response body is `202 { streamUrl, offset,
* submissionId }`. Returns null when the body has no submission id (e.g. an
* error response).
*/
const extractSubmissionId = async (
response: HeaderSource & {
readonly clone: () => { readonly json: () => Promise<unknown> };
}
): Promise<string | null> => {
const contentType = response.headers.get("content-type") ?? "";
if (!contentType.includes("application/json")) {
return null;
}
try {
const body = (await response.clone().json()) as unknown;
if (
typeof body === "object" &&
body !== null &&
"submissionId" in body &&
typeof (body as { submissionId?: unknown }).submissionId === "string"
) {
return (body as { submissionId: string }).submissionId;
}
} catch {
// Non-JSON or unreadable body: no submission id to record.
}
return null;
};
/**
* Admit a direct submission through Flue and record the admission outcome on
* the normalized evidence row. On admission failure, the evidence is marked
* failed and the original error is rethrown — never swallowed. On success,
* the Flue receipt submission id (if present) patches the evidence to
* admitted.
*/
const admitAndRecordEvidence = async (
continueAdmission: () => Promise<void>,
client: ConvexHttpClient,
getResponse: () => Response | undefined,
evidence: {
readonly organizationId: string;
readonly clientRequestId: string;
}
): Promise<void> => {
try {
await continueAdmission();
} catch (error) {
try {
await client.mutation(markFailed, evidence);
} catch {
// Best-effort: the original error is the primary concern.
}
throw error;
}
// Patch the evidence with the Flue receipt submission id. If the response
// carries no submission id (unexpected), leave the evidence in its begun
// state rather than guessing.
const response = getResponse();
if (!response) {
return;
}
const submissionId = await extractSubmissionId(response);
if (!submissionId) {
return;
}
try {
await client.mutation(markAdmitted, {
...evidence,
submissionId,
});
} catch {
// Best-effort: the admission already succeeded and the response is
// finalized. A failure to patch metadata must not break the request.
}
};
/**
* The authenticated Flue route middleware. Authorizes every method, and for
* POST captures normalized user-message evidence around Flue admission.
*/
export const authenticatedAgentRoute: AgentRouteHandler = async (c, next) => {
const accessToken = extractBearerToken(c.req.raw);
if (!accessToken) {
return c.json({ error: "Unauthorized" }, 401);
}
const client = createAuthenticatedClient(accessToken);
// Resolve (ensuring) the caller's current personal organization.
let organization: OrganizationView;
try {
organization = await client.mutation(ensurePersonalOrganization, {});
} catch {
return c.json({ error: "Unauthorized" }, 401);
}
// The agent instance id must be the caller's organization id. This is the
// hard tenancy boundary: one organization cannot use or observe another's
// agent instance.
const instanceId = c.req.param("id") ?? "";
if (instanceId !== organization._id) {
return c.json({ error: "Forbidden" }, 403);
}
// Observation routes (GET/HEAD) are authorized but never create evidence.
if (c.req.method !== "POST") {
return next();
}
// --- Evidence capture (POST only) -------------------------------------
const clientRequestId = extractClientRequestId(c.req.raw);
const messageText = await extractMessageText(c.req.raw.clone());
if (!clientRequestId) {
return c.json({ error: "Missing x-zopu-request-id" }, 400);
}
if (messageText === null) {
return c.json({ error: "Invalid message payload" }, 400);
}
// Begin evidence before Flue admission so the message is durable even if
// admission fails or the process is interrupted.
try {
await client.mutation(beginUserMessage, {
clientRequestId,
organizationId: organization._id,
rawText: messageText,
});
} catch {
return c.json({ error: "Evidence capture failed" }, 500);
}
// Admit through Flue. After next(), c.res holds the admission response.
// Evidence is patched from the admission receipt, or marked failed without
// swallowing the original error.
await admitAndRecordEvidence(next, client, () => c.res, {
clientRequestId,
organizationId: organization._id,
});
};

View File

@@ -22,7 +22,7 @@
"convex": "catalog:", "convex": "catalog:",
"expo-constants": "~57.0.2", "expo-constants": "~57.0.2",
"expo-secure-store": "~57.0.0", "expo-secure-store": "~57.0.0",
"heroui-native": "^1.0.5", "heroui-native": "catalog:",
"react": "^19.2.3", "react": "^19.2.3",
"react-native": "0.86.0", "react-native": "0.86.0",
"sonner": "catalog:", "sonner": "catalog:",
@@ -31,7 +31,8 @@
"devDependencies": { "devDependencies": {
"@code/config": "workspace:*", "@code/config": "workspace:*",
"@types/react": "~19.2.17", "@types/react": "~19.2.17",
"vite": "^7.3.6", "vite": "catalog:",
"typescript": "^6" "typescript": "catalog:",
"vitest": "catalog:"
} }
} }

View File

@@ -1,12 +1,14 @@
import { useForm } from "@tanstack/react-form"; import { useForm } from "@tanstack/react-form";
import { useCallback, useRef } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { signInSchema, signUpSchema } from "../shared/forms"; import { signInSchema, signUpSchema } from "../shared/forms";
import { authClient } from "./auth-client"; import { authClient } from "./auth-client";
import { createSessionTokenResolver } from "./session-token-resolver";
type AuthFormOptions = { interface AuthFormOptions {
readonly onSuccess?: () => void; readonly onSuccess?: () => void;
}; }
export const useSignInForm = ({ onSuccess }: AuthFormOptions = {}) => export const useSignInForm = ({ onSuccess }: AuthFormOptions = {}) =>
useForm({ useForm({
@@ -23,7 +25,7 @@ export const useSignInForm = ({ onSuccess }: AuthFormOptions = {}) =>
toast.success("Signed in successfully"); toast.success("Signed in successfully");
onSuccess?.(); onSuccess?.();
}, },
}, }
); );
}, },
validators: { onSubmit: signInSchema }, validators: { onSubmit: signInSchema },
@@ -34,7 +36,11 @@ export const useSignUpForm = ({ onSuccess }: AuthFormOptions = {}) =>
defaultValues: { confirmPassword: "", email: "", name: "", password: "" }, defaultValues: { confirmPassword: "", email: "", name: "", password: "" },
onSubmit: async ({ formApi, value }) => { onSubmit: async ({ formApi, value }) => {
await authClient.signUp.email( await authClient.signUp.email(
{ email: value.email.trim(), name: value.name.trim(), password: value.password }, {
email: value.email.trim(),
name: value.name.trim(),
password: value.password,
},
{ {
onError: ({ error }) => { onError: ({ error }) => {
toast.error(error.message || error.statusText); toast.error(error.message || error.statusText);
@@ -44,8 +50,34 @@ export const useSignUpForm = ({ onSuccess }: AuthFormOptions = {}) =>
toast.success("Account created successfully"); toast.success("Account created successfully");
onSuccess?.(); onSuccess?.();
}, },
}, }
); );
}, },
validators: { onSubmit: signUpSchema }, validators: { onSubmit: signUpSchema },
}); });
/**
* Resolve the Better Auth/Convex JWT for the current signed-in session. The
* installed `convex.token` plugin endpoint remains the token source; changing
* or clearing the Better Auth session invalidates the cached value first.
*/
export const useConvexAccessToken = (): (() => Promise<string | null>) => {
const { data: session } = authClient.useSession();
const sessionId = session?.session.id ?? null;
const resolverRef = useRef<ReturnType<
typeof createSessionTokenResolver
> | null>(null);
resolverRef.current ??= createSessionTokenResolver();
const resolveForSession = resolverRef.current;
return useCallback(
() =>
resolveForSession(sessionId, async () => {
const { data } = await authClient.convex.token({
fetchOptions: { throw: false },
});
return data?.token ?? null;
}),
[resolveForSession, sessionId]
);
};

View File

@@ -1,4 +1,5 @@
export { authClient } from "./auth-client"; export { authClient } from "./auth-client";
export { WebAuthProvider } from "./auth-provider"; export { WebAuthProvider } from "./auth-provider";
export { useConvexAccessToken } from "./hooks";
export { LoginForm } from "./login-form"; export { LoginForm } from "./login-form";
export { SignupForm } from "./signup-form"; export { SignupForm } from "./signup-form";

View File

@@ -0,0 +1,39 @@
import { describe, expect, test, vi } from "vitest";
import { createSessionTokenResolver } from "./session-token-resolver";
describe("createSessionTokenResolver", () => {
test("logout clears the cached JWT before another session resolves", async () => {
const resolveToken = createSessionTokenResolver();
const loadFirstToken = vi.fn(() => Promise.resolve("jwt-for-user-a"));
await expect(resolveToken("session-a", loadFirstToken)).resolves.toBe(
"jwt-for-user-a"
);
await expect(
resolveToken("session-a", () => Promise.resolve("unexpected-token"))
).resolves.toBe("jwt-for-user-a");
expect(loadFirstToken).toHaveBeenCalledTimes(1);
const loadWhileLoggedOut = vi.fn(() => Promise.resolve("stale-jwt"));
await expect(resolveToken(null, loadWhileLoggedOut)).resolves.toBeNull();
expect(loadWhileLoggedOut).not.toHaveBeenCalled();
const loadSecondToken = vi.fn(() => Promise.resolve("jwt-for-user-b"));
await expect(resolveToken("session-b", loadSecondToken)).resolves.toBe(
"jwt-for-user-b"
);
expect(loadSecondToken).toHaveBeenCalledTimes(1);
});
test("a direct user switch never returns the previous session token", async () => {
const resolveToken = createSessionTokenResolver();
await expect(
resolveToken("session-a", () => Promise.resolve("jwt-for-user-a"))
).resolves.toBe("jwt-for-user-a");
await expect(
resolveToken("session-b", () => Promise.resolve("jwt-for-user-b"))
).resolves.toBe("jwt-for-user-b");
});
});

View File

@@ -0,0 +1,59 @@
type TokenLoader = () => Promise<string | null>;
/** Cache one Better Auth/Convex JWT per concrete session. */
export const createSessionTokenResolver = () => {
let cachedSessionId: string | null = null;
let cachedToken: string | null = null;
let pending: {
readonly sessionId: string;
readonly promise: Promise<string | null>;
} | null = null;
return async (
sessionId: string | null,
loadToken: TokenLoader
): Promise<string | null> => {
if (!sessionId) {
cachedSessionId = null;
cachedToken = null;
pending = null;
return null;
}
if (cachedSessionId !== sessionId) {
cachedSessionId = sessionId;
cachedToken = null;
pending = null;
}
if (cachedToken) {
return cachedToken;
}
if (pending?.sessionId === sessionId) {
return pending.promise;
}
const requestedSessionId = sessionId;
const promise = (async (): Promise<string | null> => {
try {
const token = await loadToken();
if (cachedSessionId === requestedSessionId) {
cachedToken = token;
}
return token;
} catch {
if (cachedSessionId === requestedSessionId) {
cachedToken = null;
}
return null;
}
})();
pending = { promise, sessionId: requestedSessionId };
try {
return await promise;
} finally {
if (pending?.promise === promise) {
pending = null;
}
}
};
};

View File

@@ -18,6 +18,7 @@ import type * as daemons from "../daemons.js";
import type * as fluePersistence from "../fluePersistence.js"; import type * as fluePersistence from "../fluePersistence.js";
import type * as healthCheck from "../healthCheck.js"; import type * as healthCheck from "../healthCheck.js";
import type * as http from "../http.js"; import type * as http from "../http.js";
import type * as organizations from "../organizations.js";
import type * as privateData from "../privateData.js"; import type * as privateData from "../privateData.js";
import type * as projectArtifacts from "../projectArtifacts.js"; import type * as projectArtifacts from "../projectArtifacts.js";
import type * as projectIssues from "../projectIssues.js"; import type * as projectIssues from "../projectIssues.js";
@@ -41,6 +42,7 @@ declare const fullApi: ApiFromModules<{
fluePersistence: typeof fluePersistence; fluePersistence: typeof fluePersistence;
healthCheck: typeof healthCheck; healthCheck: typeof healthCheck;
http: typeof http; http: typeof http;
organizations: typeof organizations;
privateData: typeof privateData; privateData: typeof privateData;
projectArtifacts: typeof projectArtifacts; projectArtifacts: typeof projectArtifacts;
projectIssues: typeof projectIssues; projectIssues: typeof projectIssues;

View File

@@ -73,7 +73,7 @@ export const ensureRun = mutation({
status: "queued", status: "queued",
updatedAt: timestamp, updatedAt: timestamp,
}); });
await ctx.db.insert("projectSignals", { await ctx.db.insert("projectEvents", {
createdAt: timestamp, createdAt: timestamp,
data: { actorKey, daemonId: args.daemonId }, data: { actorKey, daemonId: args.daemonId },
issueId: issue._id, issueId: issue._id,
@@ -116,7 +116,7 @@ export const updateArtifact = mutation({
revision: artifact.revision + 1, revision: artifact.revision + 1,
updatedAt: timestamp, updatedAt: timestamp,
}); });
await ctx.db.insert("projectSignals", { await ctx.db.insert("projectEvents", {
createdAt: timestamp, createdAt: timestamp,
data: { path: args.path, revision: artifact.revision + 1 }, data: { path: args.path, revision: artifact.revision + 1 },
issueId: issue._id, issueId: issue._id,
@@ -162,7 +162,7 @@ export const setStatus = mutation({
: {}), : {}),
...(terminal ? { completedAt: timestamp } : {}), ...(terminal ? { completedAt: timestamp } : {}),
}); });
await ctx.db.insert("projectSignals", { await ctx.db.insert("projectEvents", {
createdAt: timestamp, createdAt: timestamp,
data: { summary: args.summary?.slice(0, 1000) }, data: { summary: args.summary?.slice(0, 1000) },
issueId: issue._id, issueId: issue._id,

View File

@@ -79,7 +79,7 @@ export const createInitialArtifacts = (
}, },
{ {
content: content:
"# Signals\n\nThe agent manager emits `issue.queued`, `agent.started`, `agent.needs-input`, `agent.completed`, and `agent.failed`. Signals are durable project events and drive the web status.\n", "# Project events\n\nThe agent manager emits `issue.queued`, `agent.started`, `agent.needs-input`, `agent.completed`, and `agent.failed`. These are durable project events and drive the web status.\n",
path: "signals.md", path: "signals.md",
}, },
{ {

View File

@@ -1,5 +1,8 @@
import { ConvexError } from "convex/values"; import { ConvexError } from "convex/values";
import type { Id } from "./_generated/dataModel";
import type { MutationCtx, QueryCtx } from "./_generated/server";
export interface AuthContext { export interface AuthContext {
readonly auth: { readonly auth: {
readonly getUserIdentity: () => Promise<{ readonly getUserIdentity: () => Promise<{
@@ -15,3 +18,25 @@ export const requireOwnerId = async (ctx: AuthContext): Promise<string> => {
} }
return identity.tokenIdentifier; return identity.tokenIdentifier;
}; };
/**
* Resolve the authenticated identity and prove it is a member of the given
* organization. Throws on unauthenticated or non-member access. Returns the
* canonical user ID (the Better Auth `tokenIdentifier`).
*/
export const requireOrganizationMember = async (
ctx: QueryCtx | MutationCtx,
organizationId: Id<"organizations">
): Promise<string> => {
const userId = await requireOwnerId(ctx);
const membership = await ctx.db
.query("organizationMembers")
.withIndex("by_organizationId_and_userId", (q) =>
q.eq("organizationId", organizationId).eq("userId", userId)
)
.unique();
if (!membership) {
throw new ConvexError("Organization membership required");
}
return userId;
};

View File

@@ -0,0 +1,90 @@
/**
* Targeted runtime smoke for the authenticated conversation ingress.
*
* Simulates the exact sequence the Flue route middleware drives, end to end,
* against the real Convex schema and modules via convexTest:
*
* 1. ensurePersonalOrganization (bearer auth → org resolution)
* 2. beginUserMessage (evidence before Flue admission)
* 3. markAdmitted (Flue receipt submission id)
*
* Then verifies the stored normalized evidence has exact raw text, organization
* scope, conversation/message identity, request id, role user, and the admitted
* submission id.
*
* This is a smoke, not a unit test: delete after the live browser smoke is
* confirmed.
*/
import { convexTest } from "convex-test";
import { anyApi } from "convex/server";
import { describe, expect, test } from "vitest";
import schema from "./schema";
declare global {
interface ImportMeta {
readonly glob: (pattern: string) => Record<string, () => Promise<unknown>>;
}
}
const modules = import.meta.glob("./**/*.ts");
const api = anyApi;
const ID_A = "https://convex.test|smoke-user";
const identityA = { tokenIdentifier: ID_A };
const newTest = () => convexTest({ schema, modules });
describe("conversation ingress smoke", () => {
test("one user message produces normalized admitted evidence end to end", async () => {
const t = newTest();
// 1. Auth + org resolution (the middleware resolves this from the bearer
// token via a request-scoped ConvexHttpClient).
const org = await t
.withIdentity(identityA)
.mutation(api.organizations.ensurePersonalOrganization, {});
const rawText = " Fix the login bug \n\t please ";
const clientRequestId = "smoke-req-001";
// 2. Begin evidence before Flue admission.
const begun = await t
.withIdentity(identityA)
.mutation(api.conversationMessages.beginUserMessage, {
clientRequestId,
organizationId: org._id,
rawText,
});
expect(begun.status).toBe("admitting");
expect(begun.rawText).toBe(rawText);
// 3. Mark admitted with the Flue receipt submission id.
const submissionId = "flue-sub-abc123";
await t
.withIdentity(identityA)
.mutation(api.conversationMessages.markAdmitted, {
clientRequestId,
organizationId: org._id,
submissionId,
});
// 4. Verify normalized evidence via authorized observation.
const observed = await t
.withIdentity(identityA)
.query(api.conversationMessages.getByRequest, {
clientRequestId,
organizationId: org._id,
});
expect(observed).not.toBeNull();
expect(observed?.rawText).toBe(rawText);
expect(observed?.organizationId).toBe(org._id);
expect(observed?.conversationId).toBe(org._id);
expect(typeof observed?.messageId).toBe("string");
expect(observed?.clientRequestId).toBe(clientRequestId);
expect(observed?.role).toBe("user");
expect(observed?.status).toBe("admitted");
expect(observed?.submissionId).toBe(submissionId);
});
});

View File

@@ -0,0 +1,300 @@
import { convexTest } from "convex-test";
import { anyApi } from "convex/server";
import { describe, expect, test } from "vitest";
import { requireOrganizationMember } from "./authz";
import schema from "./schema";
declare global {
interface ImportMeta {
readonly glob: (pattern: string) => Record<string, () => Promise<unknown>>;
}
}
const modules = import.meta.glob("./**/*.ts");
const api = anyApi;
const ID_A = "https://convex.test|user-a";
const ID_B = "https://convex.test|user-b";
const identityA = { tokenIdentifier: ID_A };
const identityB = { tokenIdentifier: ID_B };
const newTest = () => convexTest({ schema, modules });
const ensureOrg = async (
t: ReturnType<typeof newTest>,
identity: { readonly tokenIdentifier: string }
) => {
const org = await t
.withIdentity(identity)
.mutation(api.organizations.ensurePersonalOrganization, {});
return org._id as string;
};
describe("conversationMessages", () => {
test("unauthenticated access is rejected", async () => {
const t = newTest();
const orgId = await ensureOrg(t, identityA);
await expect(
t.mutation(api.conversationMessages.beginUserMessage, {
clientRequestId: "req-1",
organizationId: orgId,
rawText: "hello",
})
).rejects.toThrow(/Authentication required/u);
await expect(
t.mutation(api.conversationMessages.markAdmitted, {
clientRequestId: "req-1",
organizationId: orgId,
submissionId: "sub-1",
})
).rejects.toThrow(/Authentication required/u);
await expect(
t.query(api.conversationMessages.getByRequest, {
clientRequestId: "req-1",
organizationId: orgId,
})
).rejects.toThrow(/Authentication required/u);
});
test("cross-organization access is denied for begin and observation", async () => {
const t = newTest();
const orgA = await ensureOrg(t, identityA);
const _orgB = await ensureOrg(t, identityB);
// User A captures a message in its own organization.
const created = await t
.withIdentity(identityA)
.mutation(api.conversationMessages.beginUserMessage, {
clientRequestId: "req-cross",
organizationId: orgA,
rawText: "private to org A",
});
// User B cannot begin a message under organization A.
await expect(
t
.withIdentity(identityB)
.mutation(api.conversationMessages.beginUserMessage, {
clientRequestId: "req-cross",
organizationId: orgA,
rawText: "intruder",
})
).rejects.toThrow(/Organization membership required/u);
// User B cannot observe organization A's message by request id.
await expect(
t.withIdentity(identityB).query(api.conversationMessages.getByRequest, {
clientRequestId: "req-cross",
organizationId: orgA,
})
).rejects.toThrow(/Organization membership required/u);
// User B cannot list organization A's conversation.
await expect(
t
.withIdentity(identityB)
.query(api.conversationMessages.listForCurrentOrganization, {
organizationId: orgA,
})
).rejects.toThrow(/Organization membership required/u);
// The membership boundary independently denies cross-org access.
await expect(
t
.withIdentity(identityB)
.query((ctx) => requireOrganizationMember(ctx, orgA as never))
).rejects.toThrow(/Organization membership required/u);
expect(created.status).toBe("admitting");
});
test("duplicate request id is idempotent and preserves exact whitespace and casing", async () => {
const t = newTest();
const orgId = await ensureOrg(t, identityA);
const rawText = " Hello WORLD \n\t with spaces ";
const first = await t
.withIdentity(identityA)
.mutation(api.conversationMessages.beginUserMessage, {
clientRequestId: "req-dup",
organizationId: orgId,
rawText,
});
// A second begin with the same request id returns the same row — no
// duplicate is created.
const second = await t
.withIdentity(identityA)
.mutation(api.conversationMessages.beginUserMessage, {
clientRequestId: "req-dup",
organizationId: orgId,
rawText: "DIFFERENT TEXT MUST BE IGNORED",
});
expect(second._id).toBe(first._id);
expect(second.messageId).toBe(first.messageId);
expect(second.rawText).toBe(rawText);
expect(second.clientRequestId).toBe("req-dup");
expect(second.role).toBe("user");
expect(second.status).toBe("admitting");
// The conversation id equals the organization id (global agent scope).
expect(first.conversationId).toBe(orgId);
});
test("admitted transition records the submission id", async () => {
const t = newTest();
const orgId = await ensureOrg(t, identityA);
const begun = await t
.withIdentity(identityA)
.mutation(api.conversationMessages.beginUserMessage, {
clientRequestId: "req-admit",
organizationId: orgId,
rawText: "please help",
});
expect(begun.status).toBe("admitting");
expect(begun.submissionId).toBeNull();
const admitted = await t
.withIdentity(identityA)
.mutation(api.conversationMessages.markAdmitted, {
clientRequestId: "req-admit",
organizationId: orgId,
submissionId: "sub-receipt-123",
});
expect(admitted.status).toBe("admitted");
expect(admitted.submissionId).toBe("sub-receipt-123");
// Marking admitted again is idempotent and keeps the same submission id.
const again = await t
.withIdentity(identityA)
.mutation(api.conversationMessages.markAdmitted, {
clientRequestId: "req-admit",
organizationId: orgId,
submissionId: "sub-receipt-123",
});
expect(again.status).toBe("admitted");
expect(again.submissionId).toBe("sub-receipt-123");
});
test("failed transition records the failed status and preserves evidence", async () => {
const t = newTest();
const orgId = await ensureOrg(t, identityA);
await t
.withIdentity(identityA)
.mutation(api.conversationMessages.beginUserMessage, {
clientRequestId: "req-fail",
organizationId: orgId,
rawText: "doomed message",
});
const failed = await t
.withIdentity(identityA)
.mutation(api.conversationMessages.markFailed, {
clientRequestId: "req-fail",
organizationId: orgId,
});
expect(failed.status).toBe("failed");
expect(failed.submissionId).toBeNull();
expect(failed.rawText).toBe("doomed message");
// A failed message is still observable as evidence.
const observed = await t
.withIdentity(identityA)
.query(api.conversationMessages.getByRequest, {
clientRequestId: "req-fail",
organizationId: orgId,
});
expect(observed?.status).toBe("failed");
expect(observed?.rawText).toBe("doomed message");
});
test("admission wins over a concurrent failure and cannot be overwritten", async () => {
const t = newTest();
const orgId = await ensureOrg(t, identityA);
const begun = await t
.withIdentity(identityA)
.mutation(api.conversationMessages.beginUserMessage, {
clientRequestId: "req-race",
organizationId: orgId,
rawText: "race",
});
// Admission completes first.
await t
.withIdentity(identityA)
.mutation(api.conversationMessages.markAdmitted, {
clientRequestId: "req-race",
organizationId: orgId,
submissionId: "sub-won",
});
// A late failure is a no-op: an admitted message keeps its submission id.
const afterFail = await t
.withIdentity(identityA)
.mutation(api.conversationMessages.markFailed, {
clientRequestId: "req-race",
organizationId: orgId,
});
expect(afterFail.status).toBe("admitted");
expect(afterFail.submissionId).toBe("sub-won");
expect(begun.status).toBe("admitting");
});
test("list returns only the authenticated organization's messages", async () => {
const t = newTest();
const orgA = await ensureOrg(t, identityA);
const orgB = await ensureOrg(t, identityB);
await t
.withIdentity(identityA)
.mutation(api.conversationMessages.beginUserMessage, {
clientRequestId: "req-a1",
organizationId: orgA,
rawText: "a-one",
});
await t
.withIdentity(identityA)
.mutation(api.conversationMessages.beginUserMessage, {
clientRequestId: "req-a2",
organizationId: orgA,
rawText: "a-two",
});
await t
.withIdentity(identityB)
.mutation(api.conversationMessages.beginUserMessage, {
clientRequestId: "req-b1",
organizationId: orgB,
rawText: "b-one",
});
const forA = await t
.withIdentity(identityA)
.query(api.conversationMessages.listForCurrentOrganization, {
organizationId: orgA,
});
const forB = await t
.withIdentity(identityB)
.query(api.conversationMessages.listForCurrentOrganization, {
organizationId: orgB,
});
expect(forA).toHaveLength(2);
expect(
forA.map((m: { clientRequestId: string }) => m.clientRequestId)
).toEqual(expect.arrayContaining(["req-a1", "req-a2"]));
expect(forB).toHaveLength(1);
expect(forB[0]?.clientRequestId).toBe("req-b1");
});
});

View File

@@ -0,0 +1,220 @@
import { ConvexError, v } from "convex/values";
import type { Doc, Id } from "./_generated/dataModel";
import { mutation, query } from "./_generated/server";
import { requireOrganizationMember } from "./authz";
export type ConversationMessageStatus = "admitting" | "admitted" | "failed";
interface ConversationMessageView {
readonly _id: Id<"conversationMessages">;
readonly _creationTime: number;
readonly organizationId: Id<"organizations">;
readonly conversationId: string;
readonly messageId: string;
readonly submissionId: string | null;
readonly clientRequestId: string;
readonly role: "user";
readonly rawText: string;
readonly status: ConversationMessageStatus;
readonly createdAt: number;
}
const toView = (doc: Doc<"conversationMessages">): ConversationMessageView => ({
_creationTime: doc._creationTime,
_id: doc._id,
clientRequestId: doc.clientRequestId,
conversationId: doc.conversationId,
createdAt: doc.createdAt,
messageId: doc.messageId,
organizationId: doc.organizationId,
rawText: doc.rawText,
role: doc.role,
status: doc.status,
submissionId: doc.submissionId ?? null,
});
/**
* The global Zopu conversation uses the organization ID as its conversation
* identity (and agent instance ID). This resolves the conversation ID for the
* authenticated user's current personal organization, so callers never supply
* tenancy themselves.
*/
const resolveConversationId = (organizationId: Id<"organizations">): string =>
organizationId;
/**
* Idempotently begin a user message at the conversation ingress. If a row with
* the same (organizationId, clientRequestId) already exists, return it instead
* of creating a duplicate — Flue retries that replay the same request id must
* not produce duplicate evidence. The raw text is stored verbatim; it is never
* trimmed, collapsed, or rewritten.
*
* Authorization: the authenticated identity must be a member of the target
* organization. The conversation ID, message ID, and timestamp are all
* generated server-side; none are trusted from the caller.
*/
export const beginUserMessage = mutation({
args: {
organizationId: v.id("organizations"),
clientRequestId: v.string(),
rawText: v.string(),
},
handler: async (ctx, args): Promise<ConversationMessageView> => {
await requireOrganizationMember(ctx, args.organizationId);
const existing = await ctx.db
.query("conversationMessages")
.withIndex("by_organization_and_request", (q) =>
q
.eq("organizationId", args.organizationId)
.eq("clientRequestId", args.clientRequestId)
)
.unique();
if (existing) {
return toView(existing);
}
const createdAt = Date.now();
const messageId = crypto.randomUUID();
const conversationId = resolveConversationId(args.organizationId);
const rowId = await ctx.db.insert("conversationMessages", {
clientRequestId: args.clientRequestId,
conversationId,
createdAt,
messageId,
organizationId: args.organizationId,
rawText: args.rawText,
role: "user",
status: "admitting",
});
const created = await ctx.db.get(rowId);
if (!created) {
throw new Error("Failed to read created conversation message");
}
return toView(created);
},
});
/**
* Mark a begun message as admitted after Flue accepts the submission. The
* submission id links the normalized evidence to the Flue transcript. Only an
* `admitting` message may transition to `admitted`; an already-admitted or
* failed message is returned unchanged so a retry is idempotent.
*/
export const markAdmitted = mutation({
args: {
organizationId: v.id("organizations"),
clientRequestId: v.string(),
submissionId: v.string(),
},
handler: async (ctx, args): Promise<ConversationMessageView> => {
await requireOrganizationMember(ctx, args.organizationId);
const existing = await ctx.db
.query("conversationMessages")
.withIndex("by_organization_and_request", (q) =>
q
.eq("organizationId", args.organizationId)
.eq("clientRequestId", args.clientRequestId)
)
.unique();
if (!existing) {
throw new ConvexError("Conversation message not found");
}
if (existing.status === "admitting") {
await ctx.db.patch(existing._id, {
status: "admitted",
submissionId: args.submissionId,
});
}
const updated = await ctx.db.get(existing._id);
if (!updated) {
throw new Error("Failed to read updated conversation message");
}
return toView(updated);
},
});
/**
* Mark a begun message as failed when Flue admission rejects it. Failed
* messages remain as evidence with an explicit status; they are never silently
* deleted. Only an `admitting` message may transition to `failed`; an
* already-admitted message is never overwritten (admission won), and an
* already-failed message is returned unchanged.
*/
export const markFailed = mutation({
args: {
organizationId: v.id("organizations"),
clientRequestId: v.string(),
},
handler: async (ctx, args): Promise<ConversationMessageView> => {
await requireOrganizationMember(ctx, args.organizationId);
const existing = await ctx.db
.query("conversationMessages")
.withIndex("by_organization_and_request", (q) =>
q
.eq("organizationId", args.organizationId)
.eq("clientRequestId", args.clientRequestId)
)
.unique();
if (!existing) {
throw new ConvexError("Conversation message not found");
}
if (existing.status === "admitting") {
await ctx.db.patch(existing._id, { status: "failed" });
}
const updated = await ctx.db.get(existing._id);
if (!updated) {
throw new Error("Failed to read updated conversation message");
}
return toView(updated);
},
});
/**
* Read a single message by its client request id. Authorization requires
* membership in the message's organization; cross-organization observation is
* denied. Returns null when no message matches so observers cannot probe for
* another organization's request ids.
*/
export const getByRequest = query({
args: {
organizationId: v.id("organizations"),
clientRequestId: v.string(),
},
handler: async (ctx, args): Promise<ConversationMessageView | null> => {
await requireOrganizationMember(ctx, args.organizationId);
const existing = await ctx.db
.query("conversationMessages")
.withIndex("by_organization_and_request", (q) =>
q
.eq("organizationId", args.organizationId)
.eq("clientRequestId", args.clientRequestId)
)
.unique();
return existing ? toView(existing) : null;
},
});
/**
* List the user messages for the authenticated user's current organization
* conversation, newest first. This is the authorized observation path used by
* GET/SSE routes and product clients. Cross-organization access is denied.
*/
export const listForCurrentOrganization = query({
args: {
organizationId: v.id("organizations"),
},
handler: async (ctx, args): Promise<ConversationMessageView[]> => {
await requireOrganizationMember(ctx, args.organizationId);
const conversationId = resolveConversationId(args.organizationId);
const rows = await ctx.db
.query("conversationMessages")
.withIndex("by_organization_and_message", (q) =>
q
.eq("organizationId", args.organizationId)
.eq("conversationId", conversationId)
)
.order("desc")
.take(100);
return rows.map(toView);
},
});

View File

@@ -0,0 +1,125 @@
import { convexTest } from "convex-test";
import { anyApi } from "convex/server";
import { describe, expect, test } from "vitest";
import { requireOrganizationMember } from "./authz";
import schema from "./schema";
// `import.meta.glob` is provided by the Vitest/Vite test runner at runtime; it
// has no type definition under the Convex tsconfig (which scopes `types` to
// node), so declare the minimal shape the test relies on.
declare global {
interface ImportMeta {
readonly glob: (pattern: string) => Record<string, () => Promise<unknown>>;
}
}
const modules = import.meta.glob("./**/*.ts");
const api = anyApi;
const ID_A = "https://convex.test|user-a";
const ID_B = "https://convex.test|user-b";
const identityA = { tokenIdentifier: ID_A };
const identityB = { tokenIdentifier: ID_B };
const newTest = () => convexTest({ schema, modules });
describe("organizations", () => {
test("first ensure creates one organization and owner membership", async () => {
const t = newTest();
const org = await t
.withIdentity(identityA)
.mutation(api.organizations.ensurePersonalOrganization, {});
expect(org.kind).toBe("personal");
expect(org.name).toBe("Personal");
expect(org.createdBy).toBe(ID_A);
expect(org._id).toBeTruthy();
// Idempotent shape: a second ensure returns the same organization.
const again = await t
.withIdentity(identityA)
.mutation(api.organizations.ensurePersonalOrganization, {});
expect(again._id).toBe(org._id);
// The current-organization query reflects the ensured org.
const current = await t
.withIdentity(identityA)
.query(api.organizations.getCurrent, {});
expect(current?._id).toBe(org._id);
});
test("repeated ensure returns the same organization without duplicates", async () => {
const t = newTest();
const first = await t
.withIdentity(identityA)
.mutation(api.organizations.ensurePersonalOrganization, {});
// Many subsequent ensures must all resolve to the single org.
const repeats = await Promise.all(
Array.from({ length: 5 }, () =>
t
.withIdentity(identityA)
.mutation(api.organizations.ensurePersonalOrganization, {})
)
);
for (const org of repeats) {
expect(org._id).toBe(first._id);
}
// The current-organization query still reports the single org.
const allForA = await t
.withIdentity(identityA)
.query(api.organizations.getCurrent, {});
expect(allForA?._id).toBe(first._id);
});
test("unauthenticated access is denied", async () => {
const t = newTest();
await expect(t.query(api.organizations.getCurrent, {})).rejects.toThrow(
/Authentication required/u
);
await expect(
t.mutation(api.organizations.ensurePersonalOrganization, {})
).rejects.toThrow(/Authentication required/u);
});
test("a second identity cannot read or mutate the first organization", async () => {
const t = newTest();
const orgA = await t
.withIdentity(identityA)
.mutation(api.organizations.ensurePersonalOrganization, {});
// The second identity sees none of the first identity's organizations.
const currentForB = await t
.withIdentity(identityB)
.query(api.organizations.getCurrent, {});
expect(currentForB).toBeNull();
// The second identity gets its own distinct organization.
const orgB = await t
.withIdentity(identityB)
.mutation(api.organizations.ensurePersonalOrganization, {});
expect(orgB._id).not.toBe(orgA._id);
// The membership boundary denies the second identity access to org A.
await expect(
t
.withIdentity(identityB)
.query((ctx) => requireOrganizationMember(ctx, orgA._id))
).rejects.toThrow(/Organization membership required/u);
// But the first identity is a confirmed member of its own organization.
await expect(
t
.withIdentity(identityA)
.query((ctx) => requireOrganizationMember(ctx, orgA._id))
).resolves.toBe(ID_A);
});
});

View File

@@ -0,0 +1,82 @@
import type { Doc, Id } from "./_generated/dataModel";
import { mutation, query } from "./_generated/server";
import { requireOwnerId } from "./authz";
interface OrganizationView {
readonly _id: Id<"organizations">;
readonly _creationTime: number;
readonly name: string;
readonly kind: "personal" | "team";
readonly createdBy: string;
readonly createdAt: number;
}
const toView = (org: Doc<"organizations">): OrganizationView => ({
_creationTime: org._creationTime,
_id: org._id,
createdAt: org.createdAt,
createdBy: org.createdBy,
kind: org.kind,
name: org.name,
});
/**
* Idempotently create (or return) the authenticated user's single personal
* organization plus an owner membership. Convex mutations are atomic
* transactions: if two concurrent calls both observe no existing org and both
* insert, the transaction's optimistic-concurrency check rejects one; the
* retried call re-reads and returns the now-existing org. The read+write is the
* whole transaction, so no duplicate personal org can persist.
*/
export const ensurePersonalOrganization = mutation({
args: {},
handler: async (ctx): Promise<OrganizationView> => {
const ownerId = await requireOwnerId(ctx);
const existing = await ctx.db
.query("organizations")
.withIndex("by_createdBy_and_kind", (q) =>
q.eq("createdBy", ownerId).eq("kind", "personal")
)
.unique();
if (existing) {
return toView(existing);
}
const createdAt = Date.now();
const organizationId = await ctx.db.insert("organizations", {
createdAt,
createdBy: ownerId,
kind: "personal",
name: "Personal",
});
await ctx.db.insert("organizationMembers", {
createdAt,
organizationId,
role: "owner",
userId: ownerId,
});
const created = await ctx.db.get(organizationId);
if (!created) {
throw new Error("Failed to create personal organization");
}
return toView(created);
},
});
/**
* Return the authenticated user's current organization. In the single personal
* organization phase this is the user's one personal org. Returns null when the
* user has not yet ensured one. Denies unauthenticated access.
*/
export const getCurrent = query({
args: {},
handler: async (ctx): Promise<OrganizationView | null> => {
const ownerId = await requireOwnerId(ctx);
const existing = await ctx.db
.query("organizations")
.withIndex("by_createdBy_and_kind", (q) =>
q.eq("createdBy", ownerId).eq("kind", "personal")
)
.unique();
return existing ? toView(existing) : null;
},
});

View File

@@ -0,0 +1,219 @@
import { env } from "@code/env/convex";
import { type TestConvex, convexTest } from "convex-test";
import { anyApi } from "convex/server";
import { describe, expect, test } from "vitest";
import { internal } from "./_generated/api";
import type { Id } from "./_generated/dataModel";
import schema from "./schema";
// `import.meta.glob` is provided by the Vitest/Vite test runner at runtime; it
// has no type definition under the Convex tsconfig (which scopes `types` to
// node), so declare the minimal shape the test relies on.
declare global {
interface ImportMeta {
readonly glob: (pattern: string) => Record<string, () => Promise<unknown>>;
}
}
const modules = import.meta.glob("./**/*.ts");
const api = anyApi;
// `agentWorkspace` gates agent-controlled mutations on `env.FLUE_DB_TOKEN`.
// Vitest loads the repo `.env`, so resolve the real value from the env module
// rather than hardcoding a constant that would mismatch the loaded secret.
const FLUE_DB_TOKEN = env.FLUE_DB_TOKEN;
const ID_A = "https://convex.test|user-a";
const identityA = { tokenIdentifier: ID_A };
const repository = {
defaultBranch: "main",
id: 12345,
name: "zopu",
owner: "puter",
url: "https://github.com/puter/zopu",
};
// Read every projectEvents row for a project via the same indexes the control
// plane writes through, returning them oldest-first. Reads MUST go through the
// same test instance that performed the writes; a fresh `convexTest()` is an
// isolated in-memory backend with no shared state.
const eventsForProject = async (
t: TestConvex<typeof schema>,
projectId: Id<"projects">
) => {
return await t.query((ctx) =>
ctx.db
.query("projectEvents")
.withIndex("by_project_and_createdAt", (q) =>
q.eq("projectId", projectId)
)
.collect()
);
};
describe("projectEvents", () => {
test("project connect emits a project.connected event", async () => {
const t = convexTest({ schema, modules });
const projectId = await t.mutation(internal.projects.storeGitHubProject, {
ownerId: ID_A,
repository,
});
const events = await eventsForProject(t, projectId);
expect(events).toHaveLength(1);
expect(events[0].kind).toBe("project.connected");
expect(events[0].projectId).toBe(projectId);
expect(events[0].issueId).toBeUndefined();
expect(events[0].runId).toBeUndefined();
expect(events[0].data).toMatchObject({
provider: "github",
repository: repository.url,
});
});
test("issue create and begin emit issue.created then issue.queued events", async () => {
const t = convexTest({ schema, modules });
const projectId = await t.mutation(internal.projects.storeGitHubProject, {
ownerId: ID_A,
repository,
});
const issueId = await t
.withIdentity(identityA)
.mutation(api.projectIssues.create, {
body: "A representative issue body long enough to pass validation",
projectId,
title: "Representative issue",
});
const afterCreate = await eventsForProject(t, projectId);
expect(afterCreate.map((e) => e.kind)).toEqual([
"project.connected",
"issue.created",
]);
const created = afterCreate[1];
expect(created.issueId).toBe(issueId);
expect(created.data).toMatchObject({
number: 1,
title: "Representative issue",
});
const status = await t
.withIdentity(identityA)
.mutation(api.projectIssues.begin, {
issueId,
});
expect(status).toBe("queued");
const afterBegin = await eventsForProject(t, projectId);
expect(afterBegin.map((e) => e.kind)).toEqual([
"project.connected",
"issue.created",
"issue.queued",
]);
expect(afterBegin[2].issueId).toBe(issueId);
expect(afterBegin[2].data).toMatchObject({ number: 1 });
});
test("artifact update emits an artifact.updated event", async () => {
const t = convexTest({ schema, modules });
const projectId = await t.mutation(internal.projects.storeGitHubProject, {
ownerId: ID_A,
repository,
});
const issueId = await t
.withIdentity(identityA)
.mutation(api.projectIssues.create, {
body: "A representative issue body long enough to pass validation",
projectId,
title: "Representative issue",
});
const revision = await t.mutation(api.agentWorkspace.updateArtifact, {
content: "# Updated work\n\nRevised content.",
issueId,
path: "work.md",
token: FLUE_DB_TOKEN,
});
expect(revision).toBe(3);
const events = await eventsForProject(t, projectId);
const artifactEvent = events.find((e) => e.kind === "artifact.updated");
expect(artifactEvent).toBeDefined();
expect(artifactEvent?.issueId).toBe(issueId);
expect(artifactEvent?.data).toMatchObject({ path: "work.md", revision: 3 });
});
test("agent status emits an agent.<status> event linked to the run", async () => {
const t = convexTest({ schema, modules });
const projectId = await t.mutation(internal.projects.storeGitHubProject, {
ownerId: ID_A,
repository,
});
const issueId = await t
.withIdentity(identityA)
.mutation(api.projectIssues.create, {
body: "A representative issue body long enough to pass validation",
projectId,
title: "Representative issue",
});
await t
.withIdentity(identityA)
.mutation(api.projectIssues.begin, { issueId });
await t.mutation(api.agentWorkspace.ensureRun, {
daemonId: "daemon-1",
issueId,
token: FLUE_DB_TOKEN,
});
await t.mutation(api.agentWorkspace.setStatus, {
issueId,
status: "completed",
summary: "Done.",
token: FLUE_DB_TOKEN,
});
const events = await eventsForProject(t, projectId);
const workspaceEvent = events.find(
(e) => e.kind === "agent.workspace.created"
);
expect(workspaceEvent).toBeDefined();
expect(workspaceEvent?.runId).toBeDefined();
const statusEvent = events.find((e) => e.kind === "agent.completed");
expect(statusEvent).toBeDefined();
expect(statusEvent?.issueId).toBe(issueId);
expect(statusEvent?.runId).toBe(workspaceEvent?.runId);
expect(statusEvent?.data).toMatchObject({ summary: "Done." });
});
test("wrong agent token is rejected before any event is written", async () => {
const t = convexTest({ schema, modules });
const projectId = await t.mutation(internal.projects.storeGitHubProject, {
ownerId: ID_A,
repository,
});
const issueId = await t
.withIdentity(identityA)
.mutation(api.projectIssues.create, {
body: "A representative issue body long enough to pass validation",
projectId,
title: "Representative issue",
});
await expect(
t.mutation(api.agentWorkspace.setStatus, {
issueId,
status: "completed",
summary: "Done.",
token: "wrong-token",
})
).rejects.toThrow(/Invalid agent control token/u);
const events = await eventsForProject(t, projectId);
expect(events.some((e) => e.kind === "agent.completed")).toBe(false);
});
});

View File

@@ -67,7 +67,7 @@ export const create = mutation({
updatedAt: timestamp, updatedAt: timestamp,
}); });
} }
await ctx.db.insert("projectSignals", { await ctx.db.insert("projectEvents", {
createdAt: timestamp, createdAt: timestamp,
data: { number, title }, data: { number, title },
issueId, issueId,
@@ -95,7 +95,7 @@ export const begin = mutation({
status: "queued", status: "queued",
updatedAt: timestamp, updatedAt: timestamp,
}); });
await ctx.db.insert("projectSignals", { await ctx.db.insert("projectEvents", {
createdAt: timestamp, createdAt: timestamp,
data: { number: issue.number }, data: { number: issue.number },
issueId: issue._id, issueId: issue._id,
@@ -120,7 +120,7 @@ export const markDispatchFailed = mutation({
status: "failed", status: "failed",
updatedAt: timestamp, updatedAt: timestamp,
}); });
await ctx.db.insert("projectSignals", { await ctx.db.insert("projectEvents", {
createdAt: timestamp, createdAt: timestamp,
data: { error: args.error.slice(0, 1000), phase: "dispatch" }, data: { error: args.error.slice(0, 1000), phase: "dispatch" },
issueId: issue._id, issueId: issue._id,

View File

@@ -144,7 +144,7 @@ export const storeGitHubProject = internalMutation({
}) })
) )
); );
await ctx.db.insert("projectSignals", { await ctx.db.insert("projectEvents", {
createdAt: timestamp, createdAt: timestamp,
data: { provider: "github", repository: args.repository.url }, data: { provider: "github", repository: args.repository.url },
kind: "project.connected", kind: "project.connected",

View File

@@ -132,7 +132,7 @@ export default defineSchema({
}) })
.index("by_issue", ["issueId"]) .index("by_issue", ["issueId"])
.index("by_project_and_createdAt", ["projectId", "createdAt"]), .index("by_project_and_createdAt", ["projectId", "createdAt"]),
projectSignals: defineTable({ projectEvents: defineTable({
projectId: v.id("projects"), projectId: v.id("projects"),
issueId: v.optional(v.id("projectIssues")), issueId: v.optional(v.id("projectIssues")),
runId: v.optional(v.id("projectWorkRuns")), runId: v.optional(v.id("projectWorkRuns")),
@@ -147,6 +147,104 @@ export default defineSchema({
completed: v.boolean(), completed: v.boolean(),
}), }),
// -----------------------------------------------------------------
// Organization tenancy. Organizations are hard tenant boundaries; deny by
// default. The membership user ID is the Better Auth Convex identity
// `tokenIdentifier`.
// -----------------------------------------------------------------
organizations: defineTable({
name: v.string(),
kind: v.union(v.literal("personal"), v.literal("team")),
createdBy: v.string(),
createdAt: v.number(),
}).index("by_createdBy_and_kind", ["createdBy", "kind"]),
organizationMembers: defineTable({
organizationId: v.id("organizations"),
userId: v.string(),
role: v.union(v.literal("owner"), v.literal("member")),
createdAt: v.number(),
})
.index("by_userId", ["userId"])
.index("by_organizationId_and_userId", ["organizationId", "userId"]),
// -----------------------------------------------------------------
// Normalized conversation message evidence. Each row is one user
// message captured at the conversation ingress, before/with Flue
// admission. The raw text is stored exactly as the user typed it
// (never trimmed or rewritten). Organization scope is authoritative;
// an agent instance may only observe its own organization's messages.
// -----------------------------------------------------------------
conversationMessages: defineTable({
organizationId: v.id("organizations"),
conversationId: v.string(),
messageId: v.string(),
submissionId: v.optional(v.string()),
clientRequestId: v.string(),
role: v.literal("user"),
rawText: v.string(),
status: v.union(
v.literal("admitting"),
v.literal("admitted"),
v.literal("failed")
),
createdAt: v.number(),
})
.index("by_organization_and_message", [
"organizationId",
"conversationId",
"messageId",
])
.index("by_organization_and_request", [
"organizationId",
"clientRequestId",
]),
// -----------------------------------------------------------------
// Product Signals. A Signal is the agent-produced interpretation of one
// or more exact user messages into a structured problem statement. It is
// organization-scoped, optionally project-scoped, and idempotent by
// organization + ordered source-message selection (`sourceKey`). Raw text
// is never accepted from the agent: it is copied server-side from the
// normalized conversation messages into immutable source snapshots.
// -----------------------------------------------------------------
signals: defineTable({
organizationId: v.id("organizations"),
projectId: v.optional(v.id("projects")),
conversationId: v.string(),
sourceKey: v.string(),
problemStatement: v.object({
title: v.string(),
summary: v.string(),
desiredOutcome: v.string(),
constraints: v.array(v.string()),
}),
processedByAgentName: v.string(),
processedByAgentInstanceId: v.string(),
createdAt: v.number(),
})
.index("by_organization_and_createdAt", ["organizationId", "createdAt"])
.index("by_organization_and_sourceKey", ["organizationId", "sourceKey"])
.index("by_project_and_createdAt", ["projectId", "createdAt"]),
// Immutable ordered source snapshots copied from `conversationMessages`.
// `ordinal` is the zero-based position in the agent's ordered selection.
signalSources: defineTable({
signalId: v.id("signals"),
organizationId: v.id("organizations"),
conversationId: v.string(),
messageId: v.string(),
ordinal: v.number(),
rawTextSnapshot: v.string(),
sourceCreatedAt: v.number(),
submissionId: v.optional(v.string()),
})
.index("by_signal_and_ordinal", ["signalId", "ordinal"])
.index("by_organization_and_message", [
"organizationId",
"conversationId",
"messageId",
]),
// ----------------------------------------------------------------- // -----------------------------------------------------------------
// Flue persistence stores (schema/format version 4). // Flue persistence stores (schema/format version 4).
// ----------------------------------------------------------------- // -----------------------------------------------------------------

View File

@@ -0,0 +1,541 @@
import { type TestConvex, convexTest } from "convex-test";
import { anyApi } from "convex/server";
import { describe, expect, test } from "vitest";
import { internal } from "./_generated/api";
import type { Id } from "./_generated/dataModel";
import schema from "./schema";
// `import.meta.glob` is provided by the Vitest/Vite test runner at runtime; it
// has no type definition under the Convex tsconfig (which scopes `types` to
// node), so declare the minimal shape the test relies on.
declare global {
interface ImportMeta {
readonly glob: (pattern: string) => Record<string, () => Promise<unknown>>;
}
}
const modules = import.meta.glob("./**/*.ts");
const api = anyApi;
const ID_A = "https://convex.test|user-a";
const ID_B = "https://convex.test|user-b";
const identityA = { tokenIdentifier: ID_A };
const identityB = { tokenIdentifier: ID_B };
const newTest = () => convexTest({ schema, modules });
const ensureOrg = async (
t: TestConvex<typeof schema>,
identity: { readonly tokenIdentifier: string }
) => {
const org = await t
.withIdentity(identity)
.mutation(api.organizations.ensurePersonalOrganization, {});
return org._id as Id<"organizations">;
};
const problemStatement = {
title: "Deploy fails on staging",
summary: "The staging deploy command exits with a DNS resolution error.",
desiredOutcome: "Staging deploys successfully without DNS errors.",
constraints: ["Must not change the production pipeline"],
};
const processedBy = { agentInstanceId: "zopu-instance-1", agentName: "zopu" };
const repository = {
defaultBranch: "main",
id: 12345,
name: "zopu",
owner: "puter",
url: "https://github.com/puter/zopu",
};
// Begin and admit one user message, returning its messageId.
const seedMessage = async (
t: TestConvex<typeof schema>,
identity: { readonly tokenIdentifier: string },
orgId: Id<"organizations">,
clientRequestId: string,
rawText: string
): Promise<string> => {
const begun = await t
.withIdentity(identity)
.mutation(api.conversationMessages.beginUserMessage, {
clientRequestId,
organizationId: orgId,
rawText,
});
await t
.withIdentity(identity)
.mutation(api.conversationMessages.markAdmitted, {
clientRequestId,
organizationId: orgId,
submissionId: `sub-${clientRequestId}`,
});
return begun.messageId;
};
const createProject = async (
t: TestConvex<typeof schema>,
ownerId: string,
repoId = 12345
): Promise<Id<"projects">> => {
return await t.mutation(internal.projects.storeGitHubProject, {
ownerId,
repository: { ...repository, id: repoId },
});
};
describe("signals", () => {
test("unauthenticated create is rejected", async () => {
const t = newTest();
const orgId = await ensureOrg(t, identityA);
await expect(
t.mutation(api.signals.createFromMessages, {
conversationId: orgId,
messageIds: ["m1"],
organizationId: orgId,
problemStatement,
processedBy,
})
).rejects.toThrow(/Authentication required/u);
});
test("creates one Signal from a single admitted user message", async () => {
const t = newTest();
const orgId = await ensureOrg(t, identityA);
const rawText = " I cannot deploy to staging ";
const messageId = await seedMessage(t, identityA, orgId, "req-1", rawText);
const result = await t
.withIdentity(identityA)
.mutation(api.signals.createFromMessages, {
conversationId: orgId,
messageIds: [messageId],
organizationId: orgId,
problemStatement,
processedBy,
});
const composed = await t
.withIdentity(identityA)
.query(api.signals.get, { signalId: result.signalId });
expect(composed).not.toBeNull();
expect(composed?.signal.organizationId).toBe(orgId);
expect(composed?.signal.projectId).toBeNull();
expect(composed?.signal.conversationId).toBe(orgId);
expect(composed?.sources).toHaveLength(1);
expect(composed?.sources[0]?.messageId).toBe(messageId);
// Exact raw snapshot preserved verbatim (whitespace intact).
expect(composed?.sources[0]?.rawTextSnapshot).toBe(rawText);
expect(composed?.sources[0]?.submissionId).toBe("sub-req-1");
expect(composed?.sources[0]?.ordinal).toBe(0);
});
test("composes several ordered user messages into one Signal", async () => {
const t = newTest();
const orgId = await ensureOrg(t, identityA);
const m1 = await seedMessage(t, identityA, orgId, "req-a", "first message");
const m2 = await seedMessage(
t,
identityA,
orgId,
"req-b",
"second message"
);
const m3 = await seedMessage(t, identityA, orgId, "req-c", "third message");
const result = await t
.withIdentity(identityA)
.mutation(api.signals.createFromMessages, {
conversationId: orgId,
messageIds: [m1, m2, m3],
organizationId: orgId,
problemStatement,
processedBy,
});
const composed = await t
.withIdentity(identityA)
.query(api.signals.get, { signalId: result.signalId });
expect(composed?.sources).toHaveLength(3);
expect(composed?.sources[0]?.messageId).toBe(m1);
expect(composed?.sources[0]?.ordinal).toBe(0);
expect(composed?.sources[1]?.messageId).toBe(m2);
expect(composed?.sources[1]?.ordinal).toBe(1);
expect(composed?.sources[2]?.messageId).toBe(m3);
expect(composed?.sources[2]?.ordinal).toBe(2);
expect(
composed?.sources.map(
(s: { rawTextSnapshot: string }) => s.rawTextSnapshot
)
).toEqual(["first message", "second message", "third message"]);
});
test("preserves exact raw snapshots and a separate structured problem statement", async () => {
const t = newTest();
const orgId = await ensureOrg(t, identityA);
const rawText = "\n\t leading and trailing \n";
const messageId = await seedMessage(
t,
identityA,
orgId,
"req-exact",
rawText
);
const result = await t
.withIdentity(identityA)
.mutation(api.signals.createFromMessages, {
conversationId: orgId,
messageIds: [messageId],
organizationId: orgId,
problemStatement,
processedBy,
});
const composed = await t
.withIdentity(identityA)
.query(api.signals.get, { signalId: result.signalId });
// Raw evidence is exact and untouched.
expect(composed?.sources[0]?.rawTextSnapshot).toBe(rawText);
// The structured problem statement is stored separately.
expect(composed?.signal.problemStatement).toEqual(problemStatement);
});
test("rejects cross-organization messages", async () => {
const t = newTest();
const orgA = await ensureOrg(t, identityA);
const orgB = await ensureOrg(t, identityB);
// Message belongs to org A.
const messageId = await seedMessage(
t,
identityA,
orgA,
"req-x",
"secret A"
);
// User B tries to build a signal under org B referencing org A's message.
await expect(
t.withIdentity(identityB).mutation(api.signals.createFromMessages, {
conversationId: orgB,
messageIds: [messageId],
organizationId: orgB,
problemStatement,
processedBy,
})
).rejects.toThrow(/Source message not found/u);
});
test("rejects cross-conversation mismatch", async () => {
const t = newTest();
const orgId = await ensureOrg(t, identityA);
const messageId = await seedMessage(t, identityA, orgId, "req-conv", "hi");
await expect(
t.withIdentity(identityA).mutation(api.signals.createFromMessages, {
conversationId: "different-conversation",
messageIds: [messageId],
organizationId: orgId,
problemStatement,
processedBy,
})
).rejects.toThrow(/Conversation does not belong to organization/u);
});
test("rejects a failed (non-admitted) message", async () => {
const t = newTest();
const orgId = await ensureOrg(t, identityA);
// Begin then fail a message.
const begun = await t
.withIdentity(identityA)
.mutation(api.conversationMessages.beginUserMessage, {
clientRequestId: "req-failed",
organizationId: orgId,
rawText: "doomed",
});
await t
.withIdentity(identityA)
.mutation(api.conversationMessages.markFailed, {
clientRequestId: "req-failed",
organizationId: orgId,
});
await expect(
t.withIdentity(identityA).mutation(api.signals.createFromMessages, {
conversationId: orgId,
messageIds: [begun.messageId],
organizationId: orgId,
problemStatement,
processedBy,
})
).rejects.toThrow(/not admitted/u);
});
test("rejects a missing message id", async () => {
const t = newTest();
const orgId = await ensureOrg(t, identityA);
await expect(
t.withIdentity(identityA).mutation(api.signals.createFromMessages, {
conversationId: orgId,
messageIds: ["nonexistent-id"],
organizationId: orgId,
problemStatement,
processedBy,
})
).rejects.toThrow(/Source message not found/u);
});
test("rejects duplicate message ids in the selection", async () => {
const t = newTest();
const orgId = await ensureOrg(t, identityA);
const messageId = await seedMessage(t, identityA, orgId, "req-dup", "dup");
await expect(
t.withIdentity(identityA).mutation(api.signals.createFromMessages, {
conversationId: orgId,
messageIds: [messageId, messageId],
organizationId: orgId,
problemStatement,
processedBy,
})
).rejects.toThrow(/must be unique/u);
});
test("rejects an empty message selection", async () => {
const t = newTest();
const orgId = await ensureOrg(t, identityA);
await expect(
t.withIdentity(identityA).mutation(api.signals.createFromMessages, {
conversationId: orgId,
messageIds: [],
organizationId: orgId,
problemStatement,
processedBy,
})
).rejects.toThrow(/At least one source message is required/u);
});
test("idempotent retry returns the same signal without duplicate sources", async () => {
const t = newTest();
const orgId = await ensureOrg(t, identityA);
const m1 = await seedMessage(t, identityA, orgId, "req-idem-1", "a");
const m2 = await seedMessage(t, identityA, orgId, "req-idem-2", "b");
const first = await t
.withIdentity(identityA)
.mutation(api.signals.createFromMessages, {
conversationId: orgId,
messageIds: [m1, m2],
organizationId: orgId,
problemStatement,
processedBy,
});
// Retry with the same ordered selection.
const second = await t
.withIdentity(identityA)
.mutation(api.signals.createFromMessages, {
conversationId: orgId,
messageIds: [m1, m2],
organizationId: orgId,
problemStatement,
processedBy,
});
expect(second.signalId).toBe(first.signalId);
// Exactly one signal and two sources exist.
const list = await t
.withIdentity(identityA)
.query(api.signals.list, { organizationId: orgId });
expect(list).toHaveLength(1);
expect(list[0]?.sources).toHaveLength(2);
});
test("different ordered selections produce different signals", async () => {
const t = newTest();
const orgId = await ensureOrg(t, identityA);
const m1 = await seedMessage(t, identityA, orgId, "req-ord-1", "x");
const m2 = await seedMessage(t, identityA, orgId, "req-ord-2", "y");
const first = await t
.withIdentity(identityA)
.mutation(api.signals.createFromMessages, {
conversationId: orgId,
messageIds: [m1, m2],
organizationId: orgId,
problemStatement,
processedBy,
});
const second = await t
.withIdentity(identityA)
.mutation(api.signals.createFromMessages, {
conversationId: orgId,
messageIds: [m2, m1],
organizationId: orgId,
problemStatement,
processedBy,
});
expect(second.signalId).not.toBe(first.signalId);
const list = await t
.withIdentity(identityA)
.query(api.signals.list, { organizationId: orgId });
expect(list).toHaveLength(2);
});
test("creates a project-scoped signal when the project owner is an org member", async () => {
const t = newTest();
const orgId = await ensureOrg(t, identityA);
// Project owned by user A, who is a member of orgId.
const projectId = await createProject(t, ID_A);
const messageId = await seedMessage(
t,
identityA,
orgId,
"req-proj",
"build"
);
const result = await t
.withIdentity(identityA)
.mutation(api.signals.createFromMessages, {
conversationId: orgId,
messageIds: [messageId],
organizationId: orgId,
problemStatement,
projectId,
processedBy,
});
const composed = await t
.withIdentity(identityA)
.query(api.signals.get, { signalId: result.signalId });
expect(composed?.signal.projectId).toBe(projectId);
});
test("rejects a project whose owner is not an org member (cross-tenant)", async () => {
const t = newTest();
const orgA = await ensureOrg(t, identityA);
const _orgB = await ensureOrg(t, identityB);
// Project owned by user B.
const projectId = await createProject(t, ID_B);
const messageId = await seedMessage(
t,
identityA,
orgA,
"req-cross-proj",
"z"
);
// User A is a member of orgA but the project owner (B) is not.
await expect(
t.withIdentity(identityA).mutation(api.signals.createFromMessages, {
conversationId: orgA,
messageIds: [messageId],
organizationId: orgA,
problemStatement,
projectId,
processedBy,
})
).rejects.toThrow(/does not belong to the target organization/u);
});
test("authorized list returns only the organization's signals with composed sources", async () => {
const t = newTest();
const orgA = await ensureOrg(t, identityA);
const orgB = await ensureOrg(t, identityB);
const mA1 = await seedMessage(t, identityA, orgA, "req-list-1", "a1");
const mA2 = await seedMessage(t, identityA, orgA, "req-list-2", "a2");
await seedMessage(t, identityB, orgB, "req-list-b", "b1");
await t.withIdentity(identityA).mutation(api.signals.createFromMessages, {
conversationId: orgA,
messageIds: [mA1],
organizationId: orgA,
problemStatement,
processedBy,
});
await t.withIdentity(identityA).mutation(api.signals.createFromMessages, {
conversationId: orgA,
messageIds: [mA1, mA2],
organizationId: orgA,
problemStatement,
processedBy,
});
const forA = await t
.withIdentity(identityA)
.query(api.signals.list, { organizationId: orgA });
const forB = await t
.withIdentity(identityB)
.query(api.signals.list, { organizationId: orgB });
expect(forA).toHaveLength(2);
expect(forA[0]?.sources.length).toBeGreaterThan(0);
expect(forB).toHaveLength(0);
// Cross-org list is denied.
await expect(
t
.withIdentity(identityB)
.query(api.signals.list, { organizationId: orgA })
).rejects.toThrow(/Organization membership required/u);
});
test("authorized get denies cross-organization signal access", async () => {
const t = newTest();
const orgA = await ensureOrg(t, identityA);
const _orgB = await ensureOrg(t, identityB);
const messageId = await seedMessage(
t,
identityA,
orgA,
"req-get",
"secret"
);
const result = await t
.withIdentity(identityA)
.mutation(api.signals.createFromMessages, {
conversationId: orgA,
messageIds: [messageId],
organizationId: orgA,
problemStatement,
processedBy,
});
// User B cannot read org A's signal.
await expect(
t
.withIdentity(identityB)
.query(api.signals.get, { signalId: result.signalId })
).rejects.toThrow(/Organization membership required/u);
// get returns null for an unknown but well-formed id when authorized.
const other = await t
.withIdentity(identityA)
.mutation(api.signals.createFromMessages, {
conversationId: orgA,
messageIds: [await seedMessage(t, identityA, orgA, "req-ghost", "g")],
organizationId: orgA,
problemStatement,
processedBy,
});
expect(other.signalId).not.toBe(result.signalId);
});
});

View File

@@ -0,0 +1,447 @@
import { composeSignal, SignalValidationError } from "@code/primitives/signal";
import { ConvexError, v } from "convex/values";
import { Effect } from "effect";
import type { Doc, Id } from "./_generated/dataModel";
import {
type MutationCtx,
mutation,
type QueryCtx,
query,
} from "./_generated/server";
import { requireOrganizationMember } from "./authz";
const PROBLEM_STATEMENT = v.object({
title: v.string(),
summary: v.string(),
desiredOutcome: v.string(),
constraints: v.array(v.string()),
});
interface SignalSourceView {
readonly _id: Id<"signalSources">;
readonly _creationTime: number;
readonly signalId: Id<"signals">;
readonly organizationId: Id<"organizations">;
readonly conversationId: string;
readonly messageId: string;
readonly ordinal: number;
readonly rawTextSnapshot: string;
readonly sourceCreatedAt: number;
readonly submissionId: string | null;
}
interface SignalView {
readonly _id: Id<"signals">;
readonly _creationTime: number;
readonly organizationId: Id<"organizations">;
readonly projectId: Id<"projects"> | null;
readonly conversationId: string;
readonly problemStatement: {
readonly title: string;
readonly summary: string;
readonly desiredOutcome: string;
readonly constraints: readonly string[];
};
readonly processedByAgentName: string;
readonly processedByAgentInstanceId: string;
readonly createdAt: number;
}
interface ComposedSignal {
readonly signal: SignalView;
readonly sources: readonly SignalSourceView[];
}
/**
* An admitted user conversation message presented to the agent as candidate
* Signal evidence. The agent selects from these by `messageId`; raw text and
* timestamps are copied server-side and never accepted from the agent.
*/
interface ConversationMessageSourceView {
readonly messageId: string;
readonly clientRequestId: string;
readonly rawText: string;
readonly submissionId: string | null;
readonly createdAt: number;
}
const toSignalView = (doc: Doc<"signals">): SignalView => ({
_creationTime: doc._creationTime,
_id: doc._id,
conversationId: doc.conversationId,
createdAt: doc.createdAt,
organizationId: doc.organizationId,
problemStatement: doc.problemStatement,
processedByAgentInstanceId: doc.processedByAgentInstanceId,
processedByAgentName: doc.processedByAgentName,
projectId: doc.projectId ?? null,
});
const toSourceView = (doc: Doc<"signalSources">): SignalSourceView => ({
_creationTime: doc._creationTime,
_id: doc._id,
conversationId: doc.conversationId,
messageId: doc.messageId,
ordinal: doc.ordinal,
organizationId: doc.organizationId,
rawTextSnapshot: doc.rawTextSnapshot,
signalId: doc.signalId,
sourceCreatedAt: doc.sourceCreatedAt,
submissionId: doc.submissionId ?? null,
});
const toConversationMessageSourceView = (
doc: Doc<"conversationMessages">
): ConversationMessageSourceView => ({
clientRequestId: doc.clientRequestId,
createdAt: doc.createdAt,
messageId: doc.messageId,
rawText: doc.rawText,
submissionId: doc.submissionId ?? null,
});
/**
* Build a deterministic, collision-safe key from the organization, the
* conversation, and the ordered message selection. The key is the JSON
* encoding of `[organizationId, conversationId, ...messageIds]`, so no
* delimiter inside arbitrary message IDs can ever cause a collision and the
* caller's selection order is part of the identity.
*/
const buildSourceKey = (
organizationId: Id<"organizations">,
conversationId: string,
messageIds: readonly string[]
): string => JSON.stringify([organizationId, conversationId, ...messageIds]);
/**
* Resolve all selected conversation messages server-side. Each message must
* belong to the same organization and conversation, be a `user` message, be
* `admitted`, and appear exactly once in the ordered selection. Raw text,
* timestamps, and submission ids are copied from the stored rows; the agent
* never supplies them. Returns the messages in the caller's requested order.
*/
const resolveSources = async (
ctx: MutationCtx,
organizationId: Id<"organizations">,
conversationId: string,
messageIds: readonly string[]
): Promise<Doc<"conversationMessages">[]> => {
if (messageIds.length === 0) {
throw new ConvexError("At least one source message is required");
}
if (new Set(messageIds).size !== messageIds.length) {
throw new ConvexError("Source message ids must be unique");
}
const resolved: Doc<"conversationMessages">[] = [];
for (const messageId of messageIds) {
const doc = await ctx.db
.query("conversationMessages")
.withIndex("by_organization_and_message", (q) =>
q
.eq("organizationId", organizationId)
.eq("conversationId", conversationId)
.eq("messageId", messageId)
)
.unique();
if (!doc) {
throw new ConvexError(`Source message not found: ${messageId}`);
}
if (doc.role !== "user") {
throw new ConvexError(
`Source message is not a user message: ${messageId}`
);
}
if (doc.status !== "admitted") {
throw new ConvexError(`Source message is not admitted: ${messageId}`);
}
resolved.push(doc);
}
return resolved;
};
/**
* Resolve whether an optional project belongs to the same organization. Until
* projects carry an explicit `organizationId`, a project is attachable to an
* organization's signal only when the project owner is a member of that
* organization. The caller is already proven to be a member, so this is a
* security-preserving temporary mapping: an organization's project set is the
* intersection of (caller is a member) and (project owner is a member), which
* cannot weaken cross-tenant isolation.
*/
const authorizeProject = async (
ctx: MutationCtx,
organizationId: Id<"organizations">,
projectId: Id<"projects">
): Promise<void> => {
const project = await ctx.db.get(projectId);
if (!project) {
throw new ConvexError("Project not found");
}
const ownerMembership = await ctx.db
.query("organizationMembers")
.withIndex("by_organizationId_and_userId", (q) =>
q.eq("organizationId", organizationId).eq("userId", project.ownerId)
)
.unique();
if (!ownerMembership) {
throw new ConvexError("Project does not belong to the target organization");
}
};
/**
* Atomically create one idempotent Signal from an ordered selection of user
* messages plus a structured problem statement produced by the agent.
*
* Authorization: the authenticated identity must be a member of the target
* organization. When a project is supplied it must belong to the same
* organization. Every message id is resolved server-side from
* `conversationMessages` under the same organization and conversation; raw
* text, timestamps, and submission ids are copied from those rows and never
* accepted from the agent.
*
* Idempotency: if a Signal with the same `(organizationId, sourceKey)` already
* exists, it is returned as-is without creating duplicate sources. The whole
* lookup-or-insert runs in one Convex mutation transaction, so retries cannot
* produce duplicates.
*
* Returns the new (or existing) signal id.
*/
export const createFromMessages = mutation({
args: {
organizationId: v.id("organizations"),
projectId: v.optional(v.id("projects")),
conversationId: v.string(),
messageIds: v.array(v.string()),
problemStatement: PROBLEM_STATEMENT,
processedBy: v.object({
agentName: v.string(),
agentInstanceId: v.string(),
}),
},
handler: async (ctx, args): Promise<{ signalId: Id<"signals"> }> => {
await requireOrganizationMember(ctx, args.organizationId);
if (args.projectId) {
await authorizeProject(ctx, args.organizationId, args.projectId);
}
// The global conversation id equals the organization id; the caller's
// conversation id must match.
if (args.conversationId !== args.organizationId) {
throw new ConvexError("Conversation does not belong to organization");
}
const sourceKey = buildSourceKey(
args.organizationId,
args.conversationId,
args.messageIds
);
const existing = await ctx.db
.query("signals")
.withIndex("by_organization_and_sourceKey", (q) =>
q.eq("organizationId", args.organizationId).eq("sourceKey", sourceKey)
)
.unique();
if (existing) {
return { signalId: existing._id };
}
const sources = await resolveSources(
ctx,
args.organizationId,
args.conversationId,
args.messageIds
);
// Compose the pure Effect Signal before writes, using a prospective id.
// `composeSignal` decodes an `unknown` input, so pass plain objects.
const now = Date.now();
const prospectiveId = crypto.randomUUID();
const scope =
args.projectId === undefined
? { _tag: "Organization" as const, organizationId: args.organizationId }
: {
_tag: "Project" as const,
organizationId: args.organizationId,
projectId: args.projectId,
};
const signal = await Effect.runPromise(
composeSignal({
createdAt: now,
id: prospectiveId,
problemStatement: args.problemStatement,
processedBy: args.processedBy,
scope,
sourceMessages: sources.map((doc) => ({
conversationId: doc.conversationId,
createdAt: doc.createdAt,
messageId: doc.messageId,
rawText: doc.rawText,
})),
})
).catch((error: unknown) => {
if (error instanceof SignalValidationError) {
throw new ConvexError(`Invalid signal: ${error.message}`);
}
throw new ConvexError(
`Invalid signal: ${error instanceof Error ? error.message : "unknown"}`
);
});
const signalId = await ctx.db.insert("signals", {
conversationId: args.conversationId,
createdAt: now,
organizationId: args.organizationId,
problemStatement: {
constraints: [...signal.problemStatement.constraints],
desiredOutcome: signal.problemStatement.desiredOutcome,
summary: signal.problemStatement.summary,
title: signal.problemStatement.title,
},
processedByAgentInstanceId: args.processedBy.agentInstanceId,
processedByAgentName: args.processedBy.agentName,
...(args.projectId === undefined ? {} : { projectId: args.projectId }),
sourceKey,
});
await Promise.all(
sources.map((doc, ordinal) =>
ctx.db.insert("signalSources", {
conversationId: doc.conversationId,
messageId: doc.messageId,
ordinal,
organizationId: args.organizationId,
rawTextSnapshot: doc.rawText,
signalId,
...(doc.submissionId === undefined
? {}
: { submissionId: doc.submissionId }),
sourceCreatedAt: doc.createdAt,
})
)
);
return { signalId };
},
});
/**
* Load the composed source rows for a signal in ordinal order.
*/
const loadComposedSources = async (
ctx: QueryCtx,
signal: Doc<"signals">
): Promise<SignalSourceView[]> => {
const rows = await ctx.db
.query("signalSources")
.withIndex("by_signal_and_ordinal", (q) => q.eq("signalId", signal._id))
.collect();
// `collect` does not guarantee index order across all backends; sort by
// ordinal to be deterministic.
rows.sort((a, b) => a.ordinal - b.ordinal);
return rows.map(toSourceView);
};
/**
* List the Signals for the authenticated user's organization, newest first.
* Membership is required; cross-organization access is denied. Each entry
* includes its composed source records in ordinal order.
*/
export const list = query({
args: {
organizationId: v.id("organizations"),
},
handler: async (ctx, args): Promise<ComposedSignal[]> => {
await requireOrganizationMember(ctx, args.organizationId);
const signals = await ctx.db
.query("signals")
.withIndex("by_organization_and_createdAt", (q) =>
q.eq("organizationId", args.organizationId)
)
.order("desc")
.take(50);
return Promise.all(
signals.map(async (signal) => ({
signal: toSignalView(signal),
sources: await loadComposedSources(ctx, signal),
}))
);
},
});
/**
* List the bounded set of admitted user messages in the organization's
* conversation that have not yet been consumed as a Signal source, newest
* first. These are the candidate messages an agent should select from when
* composing a new Signal.
*
* Authorization: the authenticated identity must be a member of the target
* organization. The conversation id equals the organization id; the caller
* never supplies tenancy. Cross-organization access is denied.
*
* Bounded: capped at 100 rows to keep agent evidence selection tractable.
*/
export const listAdmittedUnused = query({
args: {
organizationId: v.id("organizations"),
},
handler: async (ctx, args): Promise<ConversationMessageSourceView[]> => {
await requireOrganizationMember(ctx, args.organizationId);
const conversationId = args.organizationId;
const admitted = await ctx.db
.query("conversationMessages")
.withIndex("by_organization_and_message", (q) =>
q
.eq("organizationId", args.organizationId)
.eq("conversationId", conversationId)
)
.order("desc")
.take(100);
const candidates = admitted.filter(
(message) => message.role === "user" && message.status === "admitted"
);
const unused: ConversationMessageSourceView[] = [];
for (const message of candidates) {
const consumed = await ctx.db
.query("signalSources")
.withIndex("by_organization_and_message", (q) =>
q
.eq("organizationId", args.organizationId)
.eq("conversationId", conversationId)
.eq("messageId", message.messageId)
)
.first();
if (!consumed) {
unused.push(toConversationMessageSourceView(message));
}
}
return unused;
},
});
/**
* Get one Signal by id with its composed sources. Membership in the signal's
* organization is required; access to another organization's signal is denied.
*/
export const get = query({
args: {
signalId: v.id("signals"),
},
handler: async (ctx, args): Promise<ComposedSignal | null> => {
const signal = await ctx.db.get(args.signalId);
if (!signal) {
return null;
}
await requireOrganizationMember(ctx, signal.organizationId);
return {
signal: toSignalView(signal),
sources: await loadComposedSources(ctx, signal),
};
},
});

View File

@@ -6,19 +6,25 @@
"author": "", "author": "",
"scripts": { "scripts": {
"dev": "convex dev --env-file ../../.env", "dev": "convex dev --env-file ../../.env",
"dev:setup": "convex dev --env-file ../../.env --configure --until-success" "dev:setup": "convex dev --env-file ../../.env --configure --until-success",
"test": "vitest run",
"test:watch": "vitest"
}, },
"dependencies": { "dependencies": {
"@better-auth/expo": "catalog:", "@better-auth/expo": "catalog:",
"@code/env": "workspace:*", "@code/env": "workspace:*",
"@code/primitives": "workspace:*",
"@convex-dev/better-auth": "catalog:", "@convex-dev/better-auth": "catalog:",
"better-auth": "catalog:", "better-auth": "catalog:",
"convex": "catalog:", "convex": "catalog:",
"effect": "catalog:",
"zod": "catalog:" "zod": "catalog:"
}, },
"devDependencies": { "devDependencies": {
"@code/config": "workspace:*", "@code/config": "workspace:*",
"@types/node": "^24.3.0", "@types/node": "^24.3.0",
"typescript": "^6" "typescript": "catalog:",
"convex-test": "catalog:",
"vitest": "catalog:"
} }
} }

View File

@@ -17,6 +17,6 @@
"devDependencies": { "devDependencies": {
"@code/config": "workspace:*", "@code/config": "workspace:*",
"@types/node": "^22.13.14", "@types/node": "^22.13.14",
"typescript": "^6" "typescript": "catalog:"
} }
} }

View File

@@ -5,18 +5,22 @@
"type": "module", "type": "module",
"exports": { "exports": {
".": "./src/index.ts", ".": "./src/index.ts",
"./agent-os": "./src/agent-os.ts" "./agent-os": "./src/agent-os.ts",
"./signal": "./src/signal.ts"
}, },
"scripts": { "scripts": {
"check-types": "tsc --noEmit" "check-types": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
}, },
"dependencies": { "dependencies": {
"@rivet-dev/agentos": "^0.2.7", "@rivet-dev/agentos": "catalog:",
"effect": "4.0.0-beta.99" "effect": "catalog:"
}, },
"devDependencies": { "devDependencies": {
"@code/config": "workspace:*", "@code/config": "workspace:*",
"@types/node": "^22.13.14", "@types/node": "^22.13.14",
"typescript": "^6" "typescript": "catalog:",
"vitest": "catalog:"
} }
} }

View File

@@ -1 +1,3 @@
// oxlint-disable-next-line no-barrel-file -- The package root intentionally exposes its public modules.
export * from "./agent-os"; export * from "./agent-os";
export * from "./signal";

View File

@@ -0,0 +1,273 @@
import { Effect } from "effect";
import { describe, expect, it } from "vitest";
import { composeSignal, SignalValidationError } from "./signal";
import type { SignalValidationReason } from "./signal";
type TestSignalScope =
| {
_tag: "Organization";
organizationId: string;
}
| {
_tag: "Project";
organizationId: string;
projectId: string;
};
interface TestSourceMessage {
conversationId: string;
messageId: string;
rawText: string;
createdAt: number;
}
interface TestSignalInput {
id: string;
scope: TestSignalScope;
sourceMessages: [TestSourceMessage, TestSourceMessage];
problemStatement: {
title: string;
summary: string;
desiredOutcome: string;
constraints: string[];
};
processedBy: {
agentName: string;
agentInstanceId: string;
};
createdAt: number;
}
const makeSignalInput = (): TestSignalInput => ({
createdAt: 300,
id: "signal-1",
problemStatement: {
constraints: ["Keep existing account data"],
desiredOutcome: "Users can complete setup.",
summary: "The setup flow stops before completion.",
title: "Users cannot finish setup",
},
processedBy: {
agentInstanceId: "organization-1",
agentName: "zopu",
},
scope: {
_tag: "Organization",
organizationId: "organization-1",
},
sourceMessages: [
{
conversationId: "conversation-1",
createdAt: 100,
messageId: "message-1",
rawText: "First source",
},
{
conversationId: "conversation-1",
createdAt: 200,
messageId: "message-2",
rawText: "Second source",
},
],
});
const runSignal = (input: unknown) => Effect.runSync(composeSignal(input));
const getValidationError = (
input: unknown,
reason: SignalValidationReason
): SignalValidationError => {
const error = Effect.runSync(Effect.flip(composeSignal(input)));
expect(error).toBeInstanceOf(SignalValidationError);
expect(error.reason).toBe(reason);
return error;
};
describe("composeSignal", () => {
it("accepts organization scope", () => {
const signal = runSignal(makeSignalInput());
expect(signal.scope).toEqual({
_tag: "Organization",
organizationId: "organization-1",
});
});
it("accepts project scope", () => {
const input = makeSignalInput();
input.scope = {
_tag: "Project",
organizationId: "organization-1",
projectId: "project-1",
};
const signal = runSignal(input);
expect(signal.scope).toEqual({
_tag: "Project",
organizationId: "organization-1",
projectId: "project-1",
});
});
it("rejects empty IDs and processed-agent identity", () => {
const mutations: readonly ((input: TestSignalInput) => void)[] = [
(input) => {
input.id = "";
},
(input) => {
input.scope.organizationId = "";
},
(input) => {
input.scope = {
_tag: "Project",
organizationId: "organization-1",
projectId: "",
};
},
(input) => {
input.sourceMessages[0].conversationId = "";
},
(input) => {
input.sourceMessages[0].messageId = "";
},
(input) => {
input.processedBy.agentName = "";
},
(input) => {
input.processedBy.agentInstanceId = "";
},
];
for (const mutate of mutations) {
const input = makeSignalInput();
mutate(input);
getValidationError(input, "InvalidInput");
}
});
it("rejects negative and fractional timestamps", () => {
const mutations: readonly ((input: TestSignalInput) => void)[] = [
(input) => {
input.sourceMessages[0].createdAt = -1;
},
(input) => {
input.sourceMessages[0].createdAt = 1.5;
},
(input) => {
input.createdAt = -1;
},
(input) => {
input.createdAt = 1.5;
},
];
for (const mutate of mutations) {
const input = makeSignalInput();
mutate(input);
getValidationError(input, "InvalidInput");
}
});
it("rejects empty problem-statement fields", () => {
for (const field of ["title", "summary", "desiredOutcome"] as const) {
const input = makeSignalInput();
input.problemStatement[field] = "";
getValidationError(input, "InvalidInput");
}
});
it("rejects whitespace-only problem fields and constraints", () => {
const input = makeSignalInput();
input.problemStatement.title = " ";
input.problemStatement.constraints = ["\t\n"];
getValidationError(input, "InvalidInput");
});
it("rejects an empty constraint item", () => {
const input = makeSignalInput();
input.problemStatement.constraints = [""];
getValidationError(input, "InvalidInput");
});
it("accepts an empty constraints list", () => {
const input = makeSignalInput();
input.problemStatement.constraints = [];
expect(runSignal(input).problemStatement.constraints).toEqual([]);
});
it("rejects an empty source list", () => {
const input = {
...makeSignalInput(),
sourceMessages: [],
};
getValidationError(input, "InvalidInput");
});
it("rejects duplicate source identities", () => {
const input = makeSignalInput();
input.sourceMessages[1].messageId = input.sourceMessages[0].messageId;
getValidationError(input, "DuplicateSource");
});
it("rejects sources from mixed conversations", () => {
const input = makeSignalInput();
input.sourceMessages[1].conversationId = "conversation-2";
getValidationError(input, "MixedConversation");
});
it("rejects decreasing source timestamps", () => {
const input = makeSignalInput();
input.sourceMessages[1].createdAt = 99;
getValidationError(input, "OutOfOrderSource");
});
it("preserves source order and exact raw whitespace", () => {
const input = makeSignalInput();
input.sourceMessages[0].rawText = " first source\n";
input.sourceMessages[1].rawText = "\tsecond source ";
const signal = runSignal(input);
expect(signal.sourceMessages.map(({ messageId }) => messageId)).toEqual([
"message-1",
"message-2",
]);
expect(signal.sourceMessages.map(({ rawText }) => rawText)).toEqual([
" first source\n",
"\tsecond source ",
]);
});
it("preserves problem-statement whitespace while validating trimmed non-emptiness", () => {
const input = makeSignalInput();
input.problemStatement.title = " Setup blocked ";
input.problemStatement.constraints = [" Preserve data "];
const signal = runSignal(input);
expect(signal.problemStatement.title).toBe(" Setup blocked ");
expect(signal.problemStatement.constraints).toEqual([" Preserve data "]);
});
it("does not include candidate, work-unit, or run fields", () => {
const signal = runSignal({
...makeSignalInput(),
candidateId: "candidate-1",
runId: "run-1",
workUnitId: "work-unit-1",
});
expect(signal).not.toHaveProperty("candidateId");
expect(signal).not.toHaveProperty("workUnitId");
expect(signal).not.toHaveProperty("runId");
});
});

View File

@@ -0,0 +1,159 @@
import { Effect, Schema } from "effect";
const MeaningfulString = Schema.String.check(
Schema.makeFilter((value) => value.trim().length > 0, {
expected: "a non-empty string",
})
);
export const SignalId = MeaningfulString.pipe(Schema.brand("SignalId"));
export type SignalId = typeof SignalId.Type;
export const OrganizationId = MeaningfulString.pipe(
Schema.brand("OrganizationId")
);
export type OrganizationId = typeof OrganizationId.Type;
export const ProjectId = MeaningfulString.pipe(Schema.brand("ProjectId"));
export type ProjectId = typeof ProjectId.Type;
export const ConversationId = MeaningfulString.pipe(
Schema.brand("ConversationId")
);
export type ConversationId = typeof ConversationId.Type;
export const ConversationMessageId = MeaningfulString.pipe(
Schema.brand("ConversationMessageId")
);
export type ConversationMessageId = typeof ConversationMessageId.Type;
export const TimestampMs = Schema.Int.check(
Schema.isGreaterThanOrEqualTo(0)
).pipe(Schema.brand("TimestampMs"));
export type TimestampMs = typeof TimestampMs.Type;
export const OrganizationSignalScope = Schema.Struct({
_tag: Schema.Literal("Organization"),
organizationId: OrganizationId,
});
export type OrganizationSignalScope = typeof OrganizationSignalScope.Type;
export const ProjectSignalScope = Schema.Struct({
_tag: Schema.Literal("Project"),
organizationId: OrganizationId,
projectId: ProjectId,
});
export type ProjectSignalScope = typeof ProjectSignalScope.Type;
export const SignalScope = Schema.Union([
OrganizationSignalScope,
ProjectSignalScope,
]);
export type SignalScope = typeof SignalScope.Type;
export const SignalSourceMessage = Schema.Struct({
conversationId: ConversationId,
createdAt: TimestampMs,
messageId: ConversationMessageId,
rawText: Schema.String,
});
export type SignalSourceMessage = typeof SignalSourceMessage.Type;
export const ProblemStatement = Schema.Struct({
constraints: Schema.Array(MeaningfulString),
desiredOutcome: MeaningfulString,
summary: MeaningfulString,
title: MeaningfulString,
});
export type ProblemStatement = typeof ProblemStatement.Type;
export const ProcessedByAgent = Schema.Struct({
agentInstanceId: MeaningfulString,
agentName: MeaningfulString,
});
export type ProcessedByAgent = typeof ProcessedByAgent.Type;
const signalFields = {
createdAt: TimestampMs,
id: SignalId,
problemStatement: ProblemStatement,
processedBy: ProcessedByAgent,
scope: SignalScope,
sourceMessages: Schema.NonEmptyArray(SignalSourceMessage),
} as const;
export const SignalInput = Schema.Struct(signalFields);
export type SignalInput = typeof SignalInput.Type;
export const Signal = Schema.Struct(signalFields);
export type Signal = typeof Signal.Type;
export const SignalValidationReason = Schema.Literals([
"InvalidInput",
"DuplicateSource",
"MixedConversation",
"OutOfOrderSource",
]);
export type SignalValidationReason = typeof SignalValidationReason.Type;
export class SignalValidationError extends Schema.TaggedErrorClass<SignalValidationError>()(
"SignalValidationError",
{
message: Schema.String,
reason: SignalValidationReason,
}
) {}
export const composeSignal = Effect.fn("Signal.compose")(
function* composeSignal(input: unknown) {
const signal: Signal = yield* Schema.decodeUnknownEffect(SignalInput)(
input
).pipe(
Effect.mapError(
(cause) =>
new SignalValidationError({
message: cause.message,
reason: "InvalidInput",
})
)
);
const [{ conversationId, createdAt: firstCreatedAt }] =
signal.sourceMessages;
let previousCreatedAt = firstCreatedAt;
const messageIds = new Set<ConversationMessageId>();
for (const source of signal.sourceMessages) {
if (source.conversationId !== conversationId) {
return yield* Effect.fail(
new SignalValidationError({
message: "Signal sources must belong to one conversation",
reason: "MixedConversation",
})
);
}
if (messageIds.has(source.messageId)) {
return yield* Effect.fail(
new SignalValidationError({
message: "Signal sources must have unique message identities",
reason: "DuplicateSource",
})
);
}
messageIds.add(source.messageId);
if (source.createdAt < previousCreatedAt) {
return yield* Effect.fail(
new SignalValidationError({
message: "Signal source timestamps must be non-decreasing",
reason: "OutOfOrderSource",
})
);
}
previousCreatedAt = source.createdAt;
}
return signal;
}
);

View File

@@ -33,6 +33,6 @@
"@types/react": "^19.2.17", "@types/react": "^19.2.17",
"@types/react-dom": "catalog:", "@types/react-dom": "catalog:",
"tailwindcss": "catalog:", "tailwindcss": "catalog:",
"typescript": "^6" "typescript": "catalog:"
} }
} }