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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/feature-status.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ inputs, prerequisites and supported schedule modes.
| Website changes | `website.change` puts an approved article, answer page or public article on the site at the founder's chosen route, fixes what the latest audit found (`source: audit`, site-fix-v5's rules), makes the redirects and noindex changes page decisions made (`planned`), and applies the copy fix `qa.feedback_to_fix` planned from public feedback once approved (`feedback`). Each audit fix, planned change or plan is a change row approved or declined once, in Decisions or over MCP. A page approved with commit to main, or approved rows, publish (Tin merges the PR once the repository's required checks pass, then checks the live site for fixes and redirects); anything else opens a PR. `preflight_website_change` previews each source and its judgment calls. See [website.change](website-change.md). | organic.technical_fix is hidden and kept for pinned runs; the traffic system's recipe still starts it. Deleting a page stays with the founder. organic.site_architecture, content.blog_index and the `blog_index` source refuse new starts and are kept for pinned runs. Agent-only. Protected paths are the shared auth pages, the project's protected pages (a setting with its history) and a per-run input. Fixture-tested; no live merge yet. |
| Content review | Read the draft, request changes in text, review a new revision and approve through dashboard or MCP. Generation notes stay separate from publishable copy. | Approval applies to the reviewed revision. PR delivery neither merges the PR nor publishes the website. |
| Saved schedules | Eligible definitions support daily or selected-weekday execution at a local time in an IANA timezone, with skip-overlap and bounded catch-up. | Not every workflow is schedulable. Paid scheduled occurrences need standing spending authority, not just a positive balance; hosted default policies include $50 per scheduled run ($10 for projects created before October 2, 2026). |
| Locked dashboard for browser sign-ups | A project with no workflow or run yet shows a locked dashboard: rail dimmed, one page that sends the person to their coding agent with the install line. Agent connection links and callbacks can finish the requested account and resource setup while the dashboard stays locked. Unlocks on the next reload once a workflow or run exists. `TIN_LITE_BROWSER_LOCK_ENABLED=false` turns it off. | Browser onboarding does not exist yet; the lock stands in for it. |
| Locked dashboard for browser sign-ups | A project with no workflow or run yet shows a locked dashboard: rail dimmed, one page that sends the person to their coding agent. Three steps follow the agent while the page is open (`GET /api/projects/{id}/agent-setup`, polled every 5 s while visible): the install line, which also starts the agent with "Set up Tin"; the agent's sign-in, ticked by its first project call or MCP-started run; and setup, ticked by the person's latest `growth.onboarding` run in any project they belong to. When that plan waits for picks or has finished, the tab moves to its project. Agent connection links and callbacks can finish the requested account and resource setup while the dashboard stays locked. `TIN_LITE_BROWSER_LOCK_ENABLED=false` turns it off. | Browser onboarding does not exist yet; the lock stands in for it. |
| One-off tasks | `project.task` has its own conversation, questions, pause/resume and exact-diff review. | It is not a reusable private code workflow or a replacement for the shared workflow engine. |

See [architecture](architecture.md), [content delivery](repository-aware-content-delivery.md), and the
Expand Down
33 changes: 28 additions & 5 deletions src/tin_lite/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,18 +169,23 @@ async def safe_handler(request: Request):
WEEK_START = Query(description="The first of seven local days, usually a Monday.")


LOCK_PAGE_ACTIONS = frozenset(
{"viewed", "install_copied", "agent_connected", "setup_started", "plan_ready"}
)


@router.post("/api/events/lock-page", status_code=204)
async def record_lock_page_event(request: Request, user: AuthContext = AUTHENTICATED_USER):
"""A browser sign-up met the locked dashboard: opened it, or copied the install line.
"""A browser sign-up met the locked dashboard and how far it watched the agent get.

The lock stands in for browser onboarding until it exists; these two events show how
many people it sends to their coding agent. The body is a fixed vocabulary, never
free text.
The lock stands in for browser onboarding until it exists. Opened and copied show how
many people it sends to their coding agent; the other three are its steps ticking while
the page was open. The body is a fixed vocabulary, never free text.
"""
body = await request.json() if await request.body() else {}
body = body if isinstance(body, dict) else {}
action = body.get("action")
if action not in {"viewed", "install_copied"}:
if action not in LOCK_PAGE_ACTIONS:
raise HTTPException(status_code=400, detail="Unknown lock page action.")
agent = body.get("agent")
project_id = body.get("project_id")
Expand All @@ -196,6 +201,24 @@ async def record_lock_page_event(request: Request, user: AuthContext = AUTHENTIC
return Response(status_code=204)


@router.get("/api/projects/{project_id}/agent-setup")
async def get_agent_setup(
project_id: UUID, request: Request, user: AuthContext = AUTHENTICATED_USER
) -> dict[str, Any]:
"""What the locked dashboard shows while the coding agent sets Tin up.

Asked from the project the browser has open, usually the empty personal one, and
answered for the person: the agent often creates and onboards a separate project for
the business. Every project named in the answer is one they belong to.
"""
database = request.app.state.runtime.database
if not await database.has_project_access(
project_id=project_id, clerk_user_id=user.clerk_user_id
):
raise HTTPException(status_code=404, detail="project not found")
return await database.agent_setup_status(user.clerk_user_id)


BILLING_QUOTE_HEADER = Header(default=None, alias="Tin-Billing-Quote")


Expand Down
70 changes: 70 additions & 0 deletions src/tin_lite/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -1942,6 +1942,76 @@ async def record_mcp_usage(
tool_name[:100],
)

async def agent_setup_status(self, clerk_user_id: str) -> dict[str, Any]:
"""How far this person's coding agent has come, across the projects they belong to.

The locked dashboard follows along with it. The agent counts as connected once a
project call (project_mcp_usage) or an MCP-started run carries this person's identity.
project_mcp_usage keeps the latest call per project, so the time can be later than
the very first call. Setup is this person's latest growth.onboarding run that was not
superseded; its plan is ready once it waits for picks or has finished.
"""
row = await self.pool.fetchrow(
"""
WITH mine AS (
SELECT projects.id, projects.name
FROM project_memberships
JOIN projects ON projects.id = project_memberships.project_id
WHERE project_memberships.clerk_user_id = $1 AND projects.deleted_at IS NULL
),
setup AS (
SELECT runs.id, runs.project_id, mine.name AS project_name, runs.status,
runs.created_at, runs.trigger_client
FROM workflow_runs AS runs
JOIN mine ON mine.id = runs.project_id
WHERE runs.executor = 'growth.onboarding'
AND runs.started_by_clerk_user_id = $1
AND runs.status <> 'superseded'
ORDER BY runs.created_at DESC, runs.id DESC
LIMIT 1
),
agent_runs AS (
SELECT runs.created_at, runs.trigger_client
FROM workflow_runs AS runs
JOIN mine ON mine.id = runs.project_id
WHERE runs.started_by_clerk_user_id = $1 AND runs.trigger_source = 'mcp'
)
SELECT
(SELECT min(usage.used_at) FROM project_mcp_usage AS usage
JOIN mine ON mine.id = usage.project_id
WHERE usage.clerk_user_id = $1) AS usage_at,
(SELECT min(created_at) FROM agent_runs) AS agent_run_at,
(SELECT trigger_client FROM agent_runs
WHERE trigger_client IS NOT NULL
ORDER BY created_at DESC LIMIT 1) AS client,
setup.id AS setup_run_id,
setup.project_id AS setup_project_id,
setup.project_name AS setup_project_name,
setup.status AS setup_status,
setup.created_at AS setup_started_at
FROM (SELECT 1) AS one
LEFT JOIN setup ON true
""",
clerk_user_id,
)
assert row is not None
connected = [value for value in (row["usage_at"], row["agent_run_at"]) if value]
status = row["setup_status"]
return {
"agent": {
"connected_at": min(connected) if connected else None,
"client": row["client"],
},
"setup": {
"run_id": row["setup_run_id"],
"project_id": row["setup_project_id"],
"project_name": row["setup_project_name"],
"started_at": row["setup_started_at"],
"status": status,
"plan_ready": status in {"needs_input", "succeeded"},
},
}

async def create_project_workflow(
self,
*,
Expand Down
Loading
Loading