From ed7ac45a4dd184edfa9fd2fd0a8de77de4a3e78e Mon Sep 17 00:00:00 2001 From: allsmog Date: Thu, 22 Oct 2026 18:38:57 -0700 Subject: [PATCH 1/8] Goal sessions: goal-bound agent sessions behind a feature flag People sign in as themselves (Google or GitHub; a test sign-in for local use), belong to several workspaces, and work in pods with lead/member roles. Goals are versioned and private, pod or workspace visible. The local agent (packages/align-agent) meters Claude Code and Codex from their own transcripts and rollouts. A user shares a session, or a whole folder (align share --dir), and each shared session is bound to a goal by binding epochs that change only at a prompt boundary. Hooks give the agent the goal once, a short diff when it changes (at the next prompt or tool call), and nothing on unchanged prompts. A stop from the web blocks the next tool call, fail closed. There is no daemon and no telemetry setup: hooks start a short one-pass sync that exits, and the Stop hook carries each turn's usage up. Aligning a session to someone else's goal needs that owner's approval; an approved "work toward" moves future usage and the session's directives to the target goal, and revoking suspends the binding. Spend is reported per goal, labelled as list-price estimates, with no per-person view. v1 and Northstar are unchanged: every v2 route is 404 unless ALIGN_V2=1, and the snapshot gate runs with v2 off and on. Wire contract: docs/v2/WIRE.md. --- .gitignore | 3 + app/api/v2/auth/[...all]/route.ts | 15 + app/api/v2/device/approve/route.ts | 10 + app/api/v2/device/authorize/route.ts | 10 + app/api/v2/device/deny/route.ts | 10 + app/api/v2/device/refresh/route.ts | 10 + app/api/v2/device/requests/[code]/route.ts | 8 + app/api/v2/device/token/route.ts | 12 + app/api/v2/devices/[id]/route.ts | 13 + app/api/v2/devices/route.ts | 8 + app/api/v2/devices/self/route.ts | 9 + app/api/v2/directives/[id]/receipts/route.ts | 10 + app/api/v2/directives/route.ts | 10 + app/api/v2/invites/[token]/accept/route.ts | 8 + app/api/v2/invites/[token]/route.ts | 5 + app/api/v2/me/route.ts | 12 + .../[ws]/alignments/[id]/[action]/route.ts | 10 + .../workspaces/[ws]/alignments/[id]/route.ts | 7 + .../v2/workspaces/[ws]/alignments/route.ts | 12 + .../v2/workspaces/[ws]/goals/[goal]/route.ts | 7 + .../[ws]/goals/[goal]/versions/route.ts | 10 + app/api/v2/workspaces/[ws]/goals/route.ts | 21 + .../v2/workspaces/[ws]/invites/[id]/route.ts | 9 + app/api/v2/workspaces/[ws]/invites/route.ts | 13 + .../[ws]/pods/[pod]/members/[user]/route.ts | 9 + .../[ws]/pods/[pod]/members/route.ts | 11 + app/api/v2/workspaces/[ws]/pods/route.ts | 10 + app/api/v2/workspaces/[ws]/route.ts | 6 + .../[ws]/sessions/[key]/epochs/route.ts | 11 + .../[ws]/sessions/[key]/resume/route.ts | 9 + .../workspaces/[ws]/sessions/[key]/route.ts | 17 + .../[ws]/sessions/[key]/stop-sharing/route.ts | 10 + .../[ws]/sessions/[key]/stop/route.ts | 9 + .../[ws]/sessions/[key]/usage/route.ts | 11 + app/api/v2/workspaces/[ws]/sessions/route.ts | 7 + app/api/v2/workspaces/[ws]/spend/route.ts | 21 + app/api/v2/workspaces/route.ts | 12 + app/app/_ui/goalform.tsx | 65 ++ app/app/_ui/kit.tsx | 245 +++++ app/app/_ui/server.ts | 42 + app/app/_ui/types.ts | 81 ++ app/app/device/page.tsx | 16 + app/app/device/view.tsx | 83 ++ app/app/devices/page.tsx | 16 + app/app/devices/view.tsx | 49 + app/app/invite/[token]/page.tsx | 17 + app/app/invite/[token]/view.tsx | 49 + app/app/page.tsx | 16 + app/app/sign-in/page.tsx | 18 + app/app/sign-in/view.tsx | 97 ++ app/app/view.tsx | 65 ++ app/app/w/[ws]/alignments/page.tsx | 17 + app/app/w/[ws]/alignments/view.tsx | 156 ++++ app/app/w/[ws]/goals/[goal]/page.tsx | 17 + app/app/w/[ws]/goals/[goal]/view.tsx | 175 ++++ app/app/w/[ws]/page.tsx | 17 + app/app/w/[ws]/people/page.tsx | 17 + app/app/w/[ws]/people/view.tsx | 180 ++++ app/app/w/[ws]/sessions/page.tsx | 17 + app/app/w/[ws]/sessions/view.tsx | 109 +++ app/app/w/[ws]/spend/page.tsx | 17 + app/app/w/[ws]/spend/view.tsx | 101 ++ app/app/w/[ws]/view.tsx | 111 +++ docs/v2/WIRE.md | 171 ++++ eslint.config.mjs | 2 + instrumentation.ts | 5 + lib/config.ts | 2 + lib/v2/alignments.ts | 384 ++++++++ lib/v2/auth.ts | 87 ++ lib/v2/config.ts | 46 + lib/v2/db.ts | 421 +++++++++ lib/v2/devices.ts | 266 ++++++ lib/v2/directives.ts | 148 +++ lib/v2/errors.ts | 15 + lib/v2/goals.ts | 299 ++++++ lib/v2/http.ts | 172 ++++ lib/v2/ids.ts | 33 + lib/v2/init.ts | 17 + lib/v2/limits.ts | 15 + lib/v2/node-sqlite.d.ts | 29 + lib/v2/policy.ts | 165 ++++ lib/v2/sessions.ts | 349 +++++++ lib/v2/spend.ts | 111 +++ lib/v2/validate.ts | 83 ++ lib/v2/workspaces.ts | 206 ++++ package-lock.json | 342 ++++++- package.json | 1 + packages/align-agent/.gitignore | 2 + packages/align-agent/HARNESS_FACTS.md | 115 +++ packages/align-agent/README.md | 91 ++ packages/align-agent/package-lock.json | 414 ++++++++ packages/align-agent/package.json | 22 + packages/align-agent/scripts/postbuild.mjs | 5 + packages/align-agent/src/api.ts | 173 ++++ packages/align-agent/src/cli.ts | 630 +++++++++++++ packages/align-agent/src/context.ts | 66 ++ packages/align-agent/src/db.ts | 204 ++++ packages/align-agent/src/directives.ts | 160 ++++ packages/align-agent/src/goals.ts | 106 +++ packages/align-agent/src/hook.ts | 100 ++ packages/align-agent/src/hooks/autostart.ts | 44 + packages/align-agent/src/hooks/handlers.ts | 459 +++++++++ packages/align-agent/src/hot.ts | 93 ++ packages/align-agent/src/install.ts | 223 +++++ packages/align-agent/src/keys.ts | 71 ++ packages/align-agent/src/ledger.ts | 271 ++++++ packages/align-agent/src/paths.ts | 45 + packages/align-agent/src/share.ts | 183 ++++ packages/align-agent/src/shared/anchors.ts | 98 ++ packages/align-agent/src/shared/envelope.ts | 79 ++ packages/align-agent/src/shared/fsx.ts | 69 ++ packages/align-agent/src/shared/layout.ts | 78 ++ packages/align-agent/src/shared/rules.ts | 68 ++ packages/align-agent/src/shared/types.ts | 136 +++ packages/align-agent/src/spend/claude.ts | 121 +++ packages/align-agent/src/spend/codex.ts | 191 ++++ packages/align-agent/src/spend/events.ts | 67 ++ packages/align-agent/src/spend/prices.ts | 133 +++ packages/align-agent/src/sync.ts | 454 +++++++++ packages/align-agent/src/tailer.ts | 189 ++++ packages/align-agent/src/version.ts | 1 + packages/align-agent/test/e2e.test.ts | 350 +++++++ packages/align-agent/test/fake-server.ts | 329 +++++++ .../align-agent/test/fixtures/block-net.mjs | 12 + packages/align-agent/test/helpers.ts | 171 ++++ packages/align-agent/test/hooks.test.ts | 312 +++++++ packages/align-agent/test/install.test.ts | 92 ++ packages/align-agent/test/metering.test.ts | 323 +++++++ packages/align-agent/tsconfig.json | 21 + proxy.ts | 12 +- scripts/test-all.sh | 22 +- scripts/test-v2.mjs | 883 ++++++++++++++++++ tsconfig.json | 2 +- 133 files changed, 13230 insertions(+), 7 deletions(-) create mode 100644 app/api/v2/auth/[...all]/route.ts create mode 100644 app/api/v2/device/approve/route.ts create mode 100644 app/api/v2/device/authorize/route.ts create mode 100644 app/api/v2/device/deny/route.ts create mode 100644 app/api/v2/device/refresh/route.ts create mode 100644 app/api/v2/device/requests/[code]/route.ts create mode 100644 app/api/v2/device/token/route.ts create mode 100644 app/api/v2/devices/[id]/route.ts create mode 100644 app/api/v2/devices/route.ts create mode 100644 app/api/v2/devices/self/route.ts create mode 100644 app/api/v2/directives/[id]/receipts/route.ts create mode 100644 app/api/v2/directives/route.ts create mode 100644 app/api/v2/invites/[token]/accept/route.ts create mode 100644 app/api/v2/invites/[token]/route.ts create mode 100644 app/api/v2/me/route.ts create mode 100644 app/api/v2/workspaces/[ws]/alignments/[id]/[action]/route.ts create mode 100644 app/api/v2/workspaces/[ws]/alignments/[id]/route.ts create mode 100644 app/api/v2/workspaces/[ws]/alignments/route.ts create mode 100644 app/api/v2/workspaces/[ws]/goals/[goal]/route.ts create mode 100644 app/api/v2/workspaces/[ws]/goals/[goal]/versions/route.ts create mode 100644 app/api/v2/workspaces/[ws]/goals/route.ts create mode 100644 app/api/v2/workspaces/[ws]/invites/[id]/route.ts create mode 100644 app/api/v2/workspaces/[ws]/invites/route.ts create mode 100644 app/api/v2/workspaces/[ws]/pods/[pod]/members/[user]/route.ts create mode 100644 app/api/v2/workspaces/[ws]/pods/[pod]/members/route.ts create mode 100644 app/api/v2/workspaces/[ws]/pods/route.ts create mode 100644 app/api/v2/workspaces/[ws]/route.ts create mode 100644 app/api/v2/workspaces/[ws]/sessions/[key]/epochs/route.ts create mode 100644 app/api/v2/workspaces/[ws]/sessions/[key]/resume/route.ts create mode 100644 app/api/v2/workspaces/[ws]/sessions/[key]/route.ts create mode 100644 app/api/v2/workspaces/[ws]/sessions/[key]/stop-sharing/route.ts create mode 100644 app/api/v2/workspaces/[ws]/sessions/[key]/stop/route.ts create mode 100644 app/api/v2/workspaces/[ws]/sessions/[key]/usage/route.ts create mode 100644 app/api/v2/workspaces/[ws]/sessions/route.ts create mode 100644 app/api/v2/workspaces/[ws]/spend/route.ts create mode 100644 app/api/v2/workspaces/route.ts create mode 100644 app/app/_ui/goalform.tsx create mode 100644 app/app/_ui/kit.tsx create mode 100644 app/app/_ui/server.ts create mode 100644 app/app/_ui/types.ts create mode 100644 app/app/device/page.tsx create mode 100644 app/app/device/view.tsx create mode 100644 app/app/devices/page.tsx create mode 100644 app/app/devices/view.tsx create mode 100644 app/app/invite/[token]/page.tsx create mode 100644 app/app/invite/[token]/view.tsx create mode 100644 app/app/page.tsx create mode 100644 app/app/sign-in/page.tsx create mode 100644 app/app/sign-in/view.tsx create mode 100644 app/app/view.tsx create mode 100644 app/app/w/[ws]/alignments/page.tsx create mode 100644 app/app/w/[ws]/alignments/view.tsx create mode 100644 app/app/w/[ws]/goals/[goal]/page.tsx create mode 100644 app/app/w/[ws]/goals/[goal]/view.tsx create mode 100644 app/app/w/[ws]/page.tsx create mode 100644 app/app/w/[ws]/people/page.tsx create mode 100644 app/app/w/[ws]/people/view.tsx create mode 100644 app/app/w/[ws]/sessions/page.tsx create mode 100644 app/app/w/[ws]/sessions/view.tsx create mode 100644 app/app/w/[ws]/spend/page.tsx create mode 100644 app/app/w/[ws]/spend/view.tsx create mode 100644 app/app/w/[ws]/view.tsx create mode 100644 docs/v2/WIRE.md create mode 100644 lib/v2/alignments.ts create mode 100644 lib/v2/auth.ts create mode 100644 lib/v2/config.ts create mode 100644 lib/v2/db.ts create mode 100644 lib/v2/devices.ts create mode 100644 lib/v2/directives.ts create mode 100644 lib/v2/errors.ts create mode 100644 lib/v2/goals.ts create mode 100644 lib/v2/http.ts create mode 100644 lib/v2/ids.ts create mode 100644 lib/v2/init.ts create mode 100644 lib/v2/limits.ts create mode 100644 lib/v2/node-sqlite.d.ts create mode 100644 lib/v2/policy.ts create mode 100644 lib/v2/sessions.ts create mode 100644 lib/v2/spend.ts create mode 100644 lib/v2/validate.ts create mode 100644 lib/v2/workspaces.ts create mode 100644 packages/align-agent/.gitignore create mode 100644 packages/align-agent/HARNESS_FACTS.md create mode 100644 packages/align-agent/README.md create mode 100644 packages/align-agent/package-lock.json create mode 100644 packages/align-agent/package.json create mode 100644 packages/align-agent/scripts/postbuild.mjs create mode 100644 packages/align-agent/src/api.ts create mode 100644 packages/align-agent/src/cli.ts create mode 100644 packages/align-agent/src/context.ts create mode 100644 packages/align-agent/src/db.ts create mode 100644 packages/align-agent/src/directives.ts create mode 100644 packages/align-agent/src/goals.ts create mode 100644 packages/align-agent/src/hook.ts create mode 100644 packages/align-agent/src/hooks/autostart.ts create mode 100644 packages/align-agent/src/hooks/handlers.ts create mode 100644 packages/align-agent/src/hot.ts create mode 100644 packages/align-agent/src/install.ts create mode 100644 packages/align-agent/src/keys.ts create mode 100644 packages/align-agent/src/ledger.ts create mode 100644 packages/align-agent/src/paths.ts create mode 100644 packages/align-agent/src/share.ts create mode 100644 packages/align-agent/src/shared/anchors.ts create mode 100644 packages/align-agent/src/shared/envelope.ts create mode 100644 packages/align-agent/src/shared/fsx.ts create mode 100644 packages/align-agent/src/shared/layout.ts create mode 100644 packages/align-agent/src/shared/rules.ts create mode 100644 packages/align-agent/src/shared/types.ts create mode 100644 packages/align-agent/src/spend/claude.ts create mode 100644 packages/align-agent/src/spend/codex.ts create mode 100644 packages/align-agent/src/spend/events.ts create mode 100644 packages/align-agent/src/spend/prices.ts create mode 100644 packages/align-agent/src/sync.ts create mode 100644 packages/align-agent/src/tailer.ts create mode 100644 packages/align-agent/src/version.ts create mode 100644 packages/align-agent/test/e2e.test.ts create mode 100644 packages/align-agent/test/fake-server.ts create mode 100644 packages/align-agent/test/fixtures/block-net.mjs create mode 100644 packages/align-agent/test/helpers.ts create mode 100644 packages/align-agent/test/hooks.test.ts create mode 100644 packages/align-agent/test/install.test.ts create mode 100644 packages/align-agent/test/metering.test.ts create mode 100644 packages/align-agent/tsconfig.json create mode 100644 scripts/test-v2.mjs diff --git a/.gitignore b/.gitignore index 7a87b3c0..ebb40566 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,9 @@ data/*.tmp data/workspaces/ data/.pid +# Align v2 database and its generated auth secret (ALIGN_V2=1) +data/v2/ + # Sparta local runtime (worlds, dashboard state) .worlds/ .worlds-dashboard/ diff --git a/app/api/v2/auth/[...all]/route.ts b/app/api/v2/auth/[...all]/route.ts new file mode 100644 index 00000000..88a0e62b --- /dev/null +++ b/app/api/v2/auth/[...all]/route.ts @@ -0,0 +1,15 @@ +import { connection } from "next/server"; +import { getAuth } from "@/lib/v2/auth"; +import { ready } from "@/lib/v2/init"; + +// Better Auth's endpoints (sign-in, OAuth callbacks, session, sign-out) under /api/v2/auth. +// A device token is never a browser session: any Authorization header is refused here too. +async function handle(req: Request): Promise { + await connection(); + if (req.headers.get("authorization") !== null) return Response.json({ error: "bearer_not_allowed", message: "Web routes do not accept device tokens" }, { status: 403 }); + await ready(); + return getAuth().handler(req); +} + +export const GET = handle; +export const POST = handle; diff --git a/app/api/v2/device/approve/route.ts b/app/api/v2/device/approve/route.ts new file mode 100644 index 00000000..77299fdd --- /dev/null +++ b/app/api/v2/device/approve/route.ts @@ -0,0 +1,10 @@ +import { decideRequest } from "@/lib/v2/devices"; +import { readJson, route, webWrite } from "@/lib/v2/http"; +import { str, strList } from "@/lib/v2/validate"; + +// POST /api/v2/device/approve (cookie) { user_code, workspace_ids }: grant this device the chosen workspaces. +export const POST = route(async (req) => { + const p = await webWrite(req); + const b = await readJson(req, 8192); + return decideRequest(p.user.id, str(b, "user_code", 20), "approve", strList(b, "workspace_ids", { max: 50, itemMax: 64 })); +}); diff --git a/app/api/v2/device/authorize/route.ts b/app/api/v2/device/authorize/route.ts new file mode 100644 index 00000000..197c051a --- /dev/null +++ b/app/api/v2/device/authorize/route.ts @@ -0,0 +1,10 @@ +import { authorize } from "@/lib/v2/devices"; +import { clientKey, originOf, readJson, rejectBrowser, route } from "@/lib/v2/http"; +import { b64url, str } from "@/lib/v2/validate"; + +// POST /api/v2/device/authorize (no auth; 10 per hour per client): start pairing a device key. +export const POST = route(async (req) => { + rejectBrowser(req); + const b = await readJson(req, 4096); + return authorize({ device_name: str(b, "device_name", 100), public_key: b64url(b, "public_key", 43, 43) }, clientKey(req), originOf(req)); +}); diff --git a/app/api/v2/device/deny/route.ts b/app/api/v2/device/deny/route.ts new file mode 100644 index 00000000..c3c89e44 --- /dev/null +++ b/app/api/v2/device/deny/route.ts @@ -0,0 +1,10 @@ +import { decideRequest } from "@/lib/v2/devices"; +import { readJson, route, webWrite } from "@/lib/v2/http"; +import { str } from "@/lib/v2/validate"; + +// POST /api/v2/device/deny (cookie) { user_code } +export const POST = route(async (req) => { + const p = await webWrite(req); + const b = await readJson(req, 4096); + return decideRequest(p.user.id, str(b, "user_code", 20), "deny", []); +}); diff --git a/app/api/v2/device/refresh/route.ts b/app/api/v2/device/refresh/route.ts new file mode 100644 index 00000000..91c5cd89 --- /dev/null +++ b/app/api/v2/device/refresh/route.ts @@ -0,0 +1,10 @@ +import { refresh } from "@/lib/v2/devices"; +import { readJson, rejectBrowser, route } from "@/lib/v2/http"; +import { b64url, str } from "@/lib/v2/validate"; + +// POST /api/v2/device/refresh: proof of possession (Ed25519 over the exact message), rotating refresh token. +export const POST = route(async (req) => { + rejectBrowser(req); + const b = await readJson(req, 4096); + return refresh({ device_id: str(b, "device_id", 64), refresh_token: str(b, "refresh_token", 200), ts: str(b, "ts", 40), sig: b64url(b, "sig", 86, 88) }); +}); diff --git a/app/api/v2/device/requests/[code]/route.ts b/app/api/v2/device/requests/[code]/route.ts new file mode 100644 index 00000000..80b40594 --- /dev/null +++ b/app/api/v2/device/requests/[code]/route.ts @@ -0,0 +1,8 @@ +import { lookupRequest } from "@/lib/v2/devices"; +import { route, webAuth } from "@/lib/v2/http"; + +// GET /api/v2/device/requests/:user_code (cookie): the pending device behind a code, for /app/device. +export const GET = route<{ code: string }>(async (req, { code }) => { + const p = await webAuth(req); + return lookupRequest(p.user.id, code); +}); diff --git a/app/api/v2/device/token/route.ts b/app/api/v2/device/token/route.ts new file mode 100644 index 00000000..44757856 --- /dev/null +++ b/app/api/v2/device/token/route.ts @@ -0,0 +1,12 @@ +import { token } from "@/lib/v2/devices"; +import { badRequest } from "@/lib/v2/errors"; +import { readJson, rejectBrowser, route } from "@/lib/v2/http"; +import { b64url } from "@/lib/v2/validate"; + +// POST /api/v2/device/token: the agent polls until the user approves; 200 exactly once. +export const POST = route(async (req) => { + rejectBrowser(req); + const b = await readJson(req, 4096); + if (b.grant_type !== "urn:ietf:params:oauth:grant-type:device_code") throw badRequest("unsupported grant_type", "unsupported_grant_type"); + return token(b64url(b, "device_code", 43, 128)); +}); diff --git a/app/api/v2/devices/[id]/route.ts b/app/api/v2/devices/[id]/route.ts new file mode 100644 index 00000000..2b40934d --- /dev/null +++ b/app/api/v2/devices/[id]/route.ts @@ -0,0 +1,13 @@ +import { deviceOwnedBy, revokeDevice } from "@/lib/v2/devices"; +import { notFound } from "@/lib/v2/errors"; +import { idParam, noContent, route, webWrite } from "@/lib/v2/http"; +import { ID_RE } from "@/lib/v2/validate"; + +// DELETE /api/v2/devices/:id (cookie): revoke one of my devices. +export const DELETE = route<{ id: string }>(async (req, params) => { + const p = await webWrite(req); + const id = idParam(params.id, ID_RE); + if (!deviceOwnedBy(id, p.user.id)) throw notFound(); + await revokeDevice(id, "revoked_on_web"); + return noContent(); +}); diff --git a/app/api/v2/devices/route.ts b/app/api/v2/devices/route.ts new file mode 100644 index 00000000..c0ba5c6c --- /dev/null +++ b/app/api/v2/devices/route.ts @@ -0,0 +1,8 @@ +import { listDevices } from "@/lib/v2/devices"; +import { route, webAuth } from "@/lib/v2/http"; + +// GET /api/v2/devices (cookie): my paired devices. +export const GET = route(async (req) => { + const p = await webAuth(req); + return { devices: listDevices(p.user.id) }; +}); diff --git a/app/api/v2/devices/self/route.ts b/app/api/v2/devices/self/route.ts new file mode 100644 index 00000000..e0015bfe --- /dev/null +++ b/app/api/v2/devices/self/route.ts @@ -0,0 +1,9 @@ +import { revokeDevice } from "@/lib/v2/devices"; +import { deviceAuth, noContent, route } from "@/lib/v2/http"; + +// DELETE /api/v2/devices/self (Bearer): log this device out (revokes all its tokens). +export const DELETE = route(async (req) => { + const d = deviceAuth(req); + await revokeDevice(d.deviceId, "logout"); + return noContent(); +}); diff --git a/app/api/v2/directives/[id]/receipts/route.ts b/app/api/v2/directives/[id]/receipts/route.ts new file mode 100644 index 00000000..ba16a3f3 --- /dev/null +++ b/app/api/v2/directives/[id]/receipts/route.ts @@ -0,0 +1,10 @@ +import { RECEIPT_STATES, recordReceipt } from "@/lib/v2/directives"; +import { deviceAuth, idParam, readJson, route } from "@/lib/v2/http"; +import { ID_RE, iso, oneOf, str } from "@/lib/v2/validate"; + +// POST /api/v2/directives/:id/receipts (Bearer, at most 2 KB) { session_key, state, at }: idempotent. +export const POST = route<{ id: string }>(async (req, params) => { + const dev = deviceAuth(req); + const b = await readJson(req, 2048); + return recordReceipt(dev, idParam(params.id, ID_RE), str(b, "session_key", 43), oneOf(b, "state", RECEIPT_STATES), iso(b, "at")); +}); diff --git a/app/api/v2/directives/route.ts b/app/api/v2/directives/route.ts new file mode 100644 index 00000000..e4e41b36 --- /dev/null +++ b/app/api/v2/directives/route.ts @@ -0,0 +1,10 @@ +import { directivesFor, parseCursor } from "@/lib/v2/directives"; +import { deviceAuth, route } from "@/lib/v2/http"; + +// GET /api/v2/directives?cursor= (Bearer): up to 100 directives for this device's sessions. +export const GET = route(async (req) => { + const dev = deviceAuth(req); + const cursor = parseCursor(new URL(req.url).searchParams.get("cursor")); + const directives = directivesFor(dev, cursor, 100); + return { directives, next_cursor: String(directives.length ? directives[directives.length - 1].seq : cursor) }; +}); diff --git a/app/api/v2/invites/[token]/accept/route.ts b/app/api/v2/invites/[token]/accept/route.ts new file mode 100644 index 00000000..d0033231 --- /dev/null +++ b/app/api/v2/invites/[token]/accept/route.ts @@ -0,0 +1,8 @@ +import { route, webWrite } from "@/lib/v2/http"; +import { acceptInvite } from "@/lib/v2/workspaces"; + +// POST /api/v2/invites/:token/accept (cookie): join, if the signed-in email is the invited one. +export const POST = route<{ token: string }>(async (req, { token }) => { + const p = await webWrite(req); + return acceptInvite(token, p.user, p.emailVerified); +}); diff --git a/app/api/v2/invites/[token]/route.ts b/app/api/v2/invites/[token]/route.ts new file mode 100644 index 00000000..d3964e37 --- /dev/null +++ b/app/api/v2/invites/[token]/route.ts @@ -0,0 +1,5 @@ +import { route, webAuth } from "@/lib/v2/http"; +import { previewInvite } from "@/lib/v2/workspaces"; + +// GET /api/v2/invites/:token (cookie): which workspace and role, and whether my email matches. +export const GET = route<{ token: string }>(async (req, { token }) => previewInvite(token, (await webAuth(req)).user)); diff --git a/app/api/v2/me/route.ts b/app/api/v2/me/route.ts new file mode 100644 index 00000000..9f79e993 --- /dev/null +++ b/app/api/v2/me/route.ts @@ -0,0 +1,12 @@ +import { eitherAuth, route } from "@/lib/v2/http"; +import { workspacesFor } from "@/lib/v2/workspaces"; + +// GET /api/v2/me (Bearer or cookie): who this is, the device (Bearer only), and the workspaces in reach. +export const GET = route(async (req) => { + const p = await eitherAuth(req); + return { + user: p.user, + device: p.kind === "device" ? { id: p.deviceId, name: p.deviceName } : null, + workspaces: workspacesFor(p), + }; +}); diff --git a/app/api/v2/workspaces/[ws]/alignments/[id]/[action]/route.ts b/app/api/v2/workspaces/[ws]/alignments/[id]/[action]/route.ts new file mode 100644 index 00000000..7ca8e4da --- /dev/null +++ b/app/api/v2/workspaces/[ws]/alignments/[id]/[action]/route.ts @@ -0,0 +1,10 @@ +import { actOnAlignment, assertAction } from "@/lib/v2/alignments"; +import { idParam, readJson, route, webWrite } from "@/lib/v2/http"; +import { ID_RE } from "@/lib/v2/validate"; +import { enter } from "@/lib/v2/workspaces"; + +// POST .../alignments/:id/{approve|counter|accept_counter|reject|withdraw|revoke} (cookie). approve carries revision. +export const POST = route<{ ws: string; id: string; action: string }>(async (req, params) => { + const pp = enter(await webWrite(req), idParam(params.ws, ID_RE)); + return actOnAlignment(pp, idParam(params.id, ID_RE), assertAction(params.action), await readJson(req, 16 * 1024)); +}); diff --git a/app/api/v2/workspaces/[ws]/alignments/[id]/route.ts b/app/api/v2/workspaces/[ws]/alignments/[id]/route.ts new file mode 100644 index 00000000..13638fe4 --- /dev/null +++ b/app/api/v2/workspaces/[ws]/alignments/[id]/route.ts @@ -0,0 +1,7 @@ +import { getAlignment } from "@/lib/v2/alignments"; +import { idParam, route, webAuth } from "@/lib/v2/http"; +import { ID_RE } from "@/lib/v2/validate"; +import { enter } from "@/lib/v2/workspaces"; + +// GET (cookie): one request I am part of. +export const GET = route<{ ws: string; id: string }>(async (req, params) => getAlignment(enter(await webAuth(req), idParam(params.ws, ID_RE)), idParam(params.id, ID_RE))); diff --git a/app/api/v2/workspaces/[ws]/alignments/route.ts b/app/api/v2/workspaces/[ws]/alignments/route.ts new file mode 100644 index 00000000..ebe6dd4f --- /dev/null +++ b/app/api/v2/workspaces/[ws]/alignments/route.ts @@ -0,0 +1,12 @@ +import { createAlignment, listAlignments } from "@/lib/v2/alignments"; +import { idParam, readJson, route, webAuth, webWrite } from "@/lib/v2/http"; +import { ID_RE } from "@/lib/v2/validate"; +import { enter } from "@/lib/v2/workspaces"; + +// GET (cookie): the requests I am part of. POST { target_goal_id, source_goal_id, choice, session_keys, note }. +export const GET = route<{ ws: string }>(async (req, params) => listAlignments(enter(await webAuth(req), idParam(params.ws, ID_RE)))); + +export const POST = route<{ ws: string }>(async (req, params) => { + const pp = enter(await webWrite(req), idParam(params.ws, ID_RE)); + return Response.json(await createAlignment(pp, await readJson(req, 16 * 1024)), { status: 201 }); +}); diff --git a/app/api/v2/workspaces/[ws]/goals/[goal]/route.ts b/app/api/v2/workspaces/[ws]/goals/[goal]/route.ts new file mode 100644 index 00000000..c55f8f2e --- /dev/null +++ b/app/api/v2/workspaces/[ws]/goals/[goal]/route.ts @@ -0,0 +1,7 @@ +import { goalDetail } from "@/lib/v2/goals"; +import { idParam, route, webAuth } from "@/lib/v2/http"; +import { ID_RE } from "@/lib/v2/validate"; +import { enter } from "@/lib/v2/workspaces"; + +// GET (cookie): one goal, its versions and what contributes to it. +export const GET = route<{ ws: string; goal: string }>(async (req, params) => goalDetail(enter(await webAuth(req), idParam(params.ws, ID_RE)), idParam(params.goal, ID_RE))); diff --git a/app/api/v2/workspaces/[ws]/goals/[goal]/versions/route.ts b/app/api/v2/workspaces/[ws]/goals/[goal]/versions/route.ts new file mode 100644 index 00000000..bd9bcc0b --- /dev/null +++ b/app/api/v2/workspaces/[ws]/goals/[goal]/versions/route.ts @@ -0,0 +1,10 @@ +import { newGoalVersion } from "@/lib/v2/goals"; +import { idParam, readJson, route, webWrite } from "@/lib/v2/http"; +import { ID_RE } from "@/lib/v2/validate"; +import { enter } from "@/lib/v2/workspaces"; + +// POST (cookie, the goal's owner) { version, title?, measures?, anchors?, status? }: a new immutable version. +export const POST = route<{ ws: string; goal: string }>(async (req, params) => { + const pp = enter(await webWrite(req), idParam(params.ws, ID_RE)); + return Response.json(await newGoalVersion(pp, idParam(params.goal, ID_RE), await readJson(req, 32 * 1024)), { status: 201 }); +}); diff --git a/app/api/v2/workspaces/[ws]/goals/route.ts b/app/api/v2/workspaces/[ws]/goals/route.ts new file mode 100644 index 00000000..54ae2173 --- /dev/null +++ b/app/api/v2/workspaces/[ws]/goals/route.ts @@ -0,0 +1,21 @@ +import { createGoal, goalsFeed } from "@/lib/v2/goals"; +import { eitherAuth, idParam, readJson, route, webWrite } from "@/lib/v2/http"; +import { ID_RE } from "@/lib/v2/validate"; +import { enter } from "@/lib/v2/workspaces"; + +// GET (Bearer or cookie): the goals feed, with ETag / If-None-Match -> 304. +export const GET = route<{ ws: string }>(async (req, params) => { + const pp = enter(await eitherAuth(req), idParam(params.ws, ID_RE)); + const feed = goalsFeed(pp); + const etag = `"${feed.version}"`; + const headers = { etag, "cache-control": "private, no-cache" }; + const inm = (req.headers.get("if-none-match") || "").split(",").map((t) => t.trim().replace(/^W\//, "")); + if (inm.includes(etag) || inm.includes("*")) return new Response(null, { status: 304, headers }); + return Response.json(feed, { headers }); +}); + +// POST (cookie) { title, measures, visibility, pod_id, anchors }: a new goal owned by me. +export const POST = route<{ ws: string }>(async (req, params) => { + const pp = enter(await webWrite(req), idParam(params.ws, ID_RE)); + return Response.json(await createGoal(pp, await readJson(req, 32 * 1024)), { status: 201 }); +}); diff --git a/app/api/v2/workspaces/[ws]/invites/[id]/route.ts b/app/api/v2/workspaces/[ws]/invites/[id]/route.ts new file mode 100644 index 00000000..1daa4702 --- /dev/null +++ b/app/api/v2/workspaces/[ws]/invites/[id]/route.ts @@ -0,0 +1,9 @@ +import { idParam, noContent, route, webWrite } from "@/lib/v2/http"; +import { ID_RE } from "@/lib/v2/validate"; +import { deleteInvite, enter } from "@/lib/v2/workspaces"; + +// DELETE (owners): withdraw an invite that hasn't been accepted. +export const DELETE = route<{ ws: string; id: string }>(async (req, params) => { + await deleteInvite(enter(await webWrite(req), idParam(params.ws, ID_RE)), idParam(params.id, ID_RE)); + return noContent(); +}); diff --git a/app/api/v2/workspaces/[ws]/invites/route.ts b/app/api/v2/workspaces/[ws]/invites/route.ts new file mode 100644 index 00000000..de96f762 --- /dev/null +++ b/app/api/v2/workspaces/[ws]/invites/route.ts @@ -0,0 +1,13 @@ +import { idParam, readJson, route, webAuth, webWrite } from "@/lib/v2/http"; +import { WS_ROLES } from "@/lib/v2/policy"; +import { ID_RE, oneOf, str } from "@/lib/v2/validate"; +import { createInvite, enter, listInvites } from "@/lib/v2/workspaces"; + +// GET (owners): open and accepted invites. POST { email, role } (owners): an invite link to send yourself. +export const GET = route<{ ws: string }>(async (req, params) => ({ invites: listInvites(enter(await webAuth(req), idParam(params.ws, ID_RE))) })); + +export const POST = route<{ ws: string }>(async (req, params) => { + const pp = enter(await webWrite(req), idParam(params.ws, ID_RE)); + const b = await readJson(req, 4096); + return Response.json(await createInvite(pp, str(b, "email", 254), oneOf(b, "role", WS_ROLES)), { status: 201 }); +}); diff --git a/app/api/v2/workspaces/[ws]/pods/[pod]/members/[user]/route.ts b/app/api/v2/workspaces/[ws]/pods/[pod]/members/[user]/route.ts new file mode 100644 index 00000000..278e6d5b --- /dev/null +++ b/app/api/v2/workspaces/[ws]/pods/[pod]/members/[user]/route.ts @@ -0,0 +1,9 @@ +import { idParam, noContent, route, webWrite } from "@/lib/v2/http"; +import { ID_RE } from "@/lib/v2/validate"; +import { enter, removePodMember } from "@/lib/v2/workspaces"; + +// DELETE (workspace owners or the pod's lead): remove a pod member. +export const DELETE = route<{ ws: string; pod: string; user: string }>(async (req, params) => { + await removePodMember(enter(await webWrite(req), idParam(params.ws, ID_RE)), idParam(params.pod, ID_RE), idParam(params.user, ID_RE)); + return noContent(); +}); diff --git a/app/api/v2/workspaces/[ws]/pods/[pod]/members/route.ts b/app/api/v2/workspaces/[ws]/pods/[pod]/members/route.ts new file mode 100644 index 00000000..8da1cad0 --- /dev/null +++ b/app/api/v2/workspaces/[ws]/pods/[pod]/members/route.ts @@ -0,0 +1,11 @@ +import { idParam, readJson, route, webWrite } from "@/lib/v2/http"; +import { POD_ROLES } from "@/lib/v2/policy"; +import { ID_RE, oneOf, str } from "@/lib/v2/validate"; +import { enter, setPodMember } from "@/lib/v2/workspaces"; + +// POST (workspace owners or the pod's lead) { user_id, role: lead|member }: add or change a pod member. +export const POST = route<{ ws: string; pod: string }>(async (req, params) => { + const pp = enter(await webWrite(req), idParam(params.ws, ID_RE)); + const b = await readJson(req, 4096); + return setPodMember(pp, idParam(params.pod, ID_RE), str(b, "user_id", 64), oneOf(b, "role", POD_ROLES)); +}); diff --git a/app/api/v2/workspaces/[ws]/pods/route.ts b/app/api/v2/workspaces/[ws]/pods/route.ts new file mode 100644 index 00000000..fabe1493 --- /dev/null +++ b/app/api/v2/workspaces/[ws]/pods/route.ts @@ -0,0 +1,10 @@ +import { idParam, readJson, route, webWrite } from "@/lib/v2/http"; +import { ID_RE, str } from "@/lib/v2/validate"; +import { createPod, enter } from "@/lib/v2/workspaces"; + +// POST /api/v2/workspaces/:ws/pods (owners) { name } +export const POST = route<{ ws: string }>(async (req, params) => { + const pp = enter(await webWrite(req), idParam(params.ws, ID_RE)); + const b = await readJson(req, 4096); + return Response.json(await createPod(pp, str(b, "name", 100)), { status: 201 }); +}); diff --git a/app/api/v2/workspaces/[ws]/route.ts b/app/api/v2/workspaces/[ws]/route.ts new file mode 100644 index 00000000..c4552226 --- /dev/null +++ b/app/api/v2/workspaces/[ws]/route.ts @@ -0,0 +1,6 @@ +import { idParam, route, webAuth } from "@/lib/v2/http"; +import { ID_RE } from "@/lib/v2/validate"; +import { enter, workspaceDetail } from "@/lib/v2/workspaces"; + +// GET /api/v2/workspaces/:ws (cookie): name, my role, members, pods. +export const GET = route<{ ws: string }>(async (req, params) => workspaceDetail(enter(await webAuth(req), idParam(params.ws, ID_RE)))); diff --git a/app/api/v2/workspaces/[ws]/sessions/[key]/epochs/route.ts b/app/api/v2/workspaces/[ws]/sessions/[key]/epochs/route.ts new file mode 100644 index 00000000..b1edfb58 --- /dev/null +++ b/app/api/v2/workspaces/[ws]/sessions/[key]/epochs/route.ts @@ -0,0 +1,11 @@ +import { deviceAuth, idParam, readJson, route } from "@/lib/v2/http"; +import { openBindingEpoch } from "@/lib/v2/sessions"; +import { ID_RE, SESSION_KEY_RE } from "@/lib/v2/validate"; +import { enter } from "@/lib/v2/workspaces"; + +// POST (Bearer): open a binding epoch (closes the previous one at starts_at). +export const POST = route<{ ws: string; key: string }>(async (req, params) => { + const dev = deviceAuth(req); + const pp = enter(dev, idParam(params.ws, ID_RE)); + return openBindingEpoch(pp, dev, idParam(params.key, SESSION_KEY_RE), await readJson(req, 4096)); +}); diff --git a/app/api/v2/workspaces/[ws]/sessions/[key]/resume/route.ts b/app/api/v2/workspaces/[ws]/sessions/[key]/resume/route.ts new file mode 100644 index 00000000..11e6ce5d --- /dev/null +++ b/app/api/v2/workspaces/[ws]/sessions/[key]/resume/route.ts @@ -0,0 +1,9 @@ +import { idParam, route, webWrite } from "@/lib/v2/http"; +import { setAgentStop } from "@/lib/v2/sessions"; +import { ID_RE, SESSION_KEY_RE } from "@/lib/v2/validate"; +import { enter } from "@/lib/v2/workspaces"; + +// POST (cookie; the session's owner or a workspace owner): clear the agent's local stop latch (resume directive). +export const POST = route<{ ws: string; key: string }>(async (req, params) => + setAgentStop(enter(await webWrite(req), idParam(params.ws, ID_RE)), idParam(params.key, SESSION_KEY_RE), false), +); diff --git a/app/api/v2/workspaces/[ws]/sessions/[key]/route.ts b/app/api/v2/workspaces/[ws]/sessions/[key]/route.ts new file mode 100644 index 00000000..ff6a9fe6 --- /dev/null +++ b/app/api/v2/workspaces/[ws]/sessions/[key]/route.ts @@ -0,0 +1,17 @@ +import { deviceAuth, eitherWrite, idParam, noContent, readJson, route } from "@/lib/v2/http"; +import { deleteSession, putSession } from "@/lib/v2/sessions"; +import { ID_RE, SESSION_KEY_RE } from "@/lib/v2/validate"; +import { enter } from "@/lib/v2/workspaces"; + +// PUT (Bearer): share or update a session. DELETE (Bearer or cookie, owner): hard-delete its shared history. +export const PUT = route<{ ws: string; key: string }>(async (req, params) => { + const dev = deviceAuth(req); + const pp = enter(dev, idParam(params.ws, ID_RE)); + return putSession(pp, dev, idParam(params.key, SESSION_KEY_RE), await readJson(req, 8192)); +}); + +export const DELETE = route<{ ws: string; key: string }>(async (req, params) => { + const p = await eitherWrite(req); + await deleteSession(enter(p, idParam(params.ws, ID_RE)), p, idParam(params.key, SESSION_KEY_RE)); + return noContent(); +}); diff --git a/app/api/v2/workspaces/[ws]/sessions/[key]/stop-sharing/route.ts b/app/api/v2/workspaces/[ws]/sessions/[key]/stop-sharing/route.ts new file mode 100644 index 00000000..3c74c7ff --- /dev/null +++ b/app/api/v2/workspaces/[ws]/sessions/[key]/stop-sharing/route.ts @@ -0,0 +1,10 @@ +import { eitherWrite, idParam, route } from "@/lib/v2/http"; +import { stopSharing } from "@/lib/v2/sessions"; +import { ID_RE, SESSION_KEY_RE } from "@/lib/v2/validate"; +import { enter } from "@/lib/v2/workspaces"; + +// POST (Bearer or cookie, owner): stop sharing from now on; no more uploads are accepted. +export const POST = route<{ ws: string; key: string }>(async (req, params) => { + const p = await eitherWrite(req); + return stopSharing(enter(p, idParam(params.ws, ID_RE)), p, idParam(params.key, SESSION_KEY_RE)); +}); diff --git a/app/api/v2/workspaces/[ws]/sessions/[key]/stop/route.ts b/app/api/v2/workspaces/[ws]/sessions/[key]/stop/route.ts new file mode 100644 index 00000000..f3d3aa3a --- /dev/null +++ b/app/api/v2/workspaces/[ws]/sessions/[key]/stop/route.ts @@ -0,0 +1,9 @@ +import { idParam, route, webWrite } from "@/lib/v2/http"; +import { setAgentStop } from "@/lib/v2/sessions"; +import { ID_RE, SESSION_KEY_RE } from "@/lib/v2/validate"; +import { enter } from "@/lib/v2/workspaces"; + +// POST (cookie; the session's owner or a workspace owner): set the agent's local stop latch (stop directive). +export const POST = route<{ ws: string; key: string }>(async (req, params) => + setAgentStop(enter(await webWrite(req), idParam(params.ws, ID_RE)), idParam(params.key, SESSION_KEY_RE), true), +); diff --git a/app/api/v2/workspaces/[ws]/sessions/[key]/usage/route.ts b/app/api/v2/workspaces/[ws]/sessions/[key]/usage/route.ts new file mode 100644 index 00000000..b44188ef --- /dev/null +++ b/app/api/v2/workspaces/[ws]/sessions/[key]/usage/route.ts @@ -0,0 +1,11 @@ +import { deviceAuth, idParam, readJson, route } from "@/lib/v2/http"; +import { ingestUsage } from "@/lib/v2/sessions"; +import { ID_RE, SESSION_KEY_RE } from "@/lib/v2/validate"; +import { enter } from "@/lib/v2/workspaces"; + +// POST (Bearer): an idempotent usage batch, at most 500 rows and 256 KB. +export const POST = route<{ ws: string; key: string }>(async (req, params) => { + const dev = deviceAuth(req); + const pp = enter(dev, idParam(params.ws, ID_RE)); + return ingestUsage(pp, dev, idParam(params.key, SESSION_KEY_RE), await readJson(req, 256 * 1024)); +}); diff --git a/app/api/v2/workspaces/[ws]/sessions/route.ts b/app/api/v2/workspaces/[ws]/sessions/route.ts new file mode 100644 index 00000000..48a37932 --- /dev/null +++ b/app/api/v2/workspaces/[ws]/sessions/route.ts @@ -0,0 +1,7 @@ +import { idParam, route, webAuth } from "@/lib/v2/http"; +import { listSessions } from "@/lib/v2/sessions"; +import { ID_RE } from "@/lib/v2/validate"; +import { enter } from "@/lib/v2/workspaces"; + +// GET (cookie): my shared sessions; for workspace owners also every shared session's alias and state. +export const GET = route<{ ws: string }>(async (req, params) => listSessions(enter(await webAuth(req), idParam(params.ws, ID_RE)))); diff --git a/app/api/v2/workspaces/[ws]/spend/route.ts b/app/api/v2/workspaces/[ws]/spend/route.ts new file mode 100644 index 00000000..24ba2eb6 --- /dev/null +++ b/app/api/v2/workspaces/[ws]/spend/route.ts @@ -0,0 +1,21 @@ +import { badRequest } from "@/lib/v2/errors"; +import { idParam, route, webAuth } from "@/lib/v2/http"; +import { spend, type SpendBy } from "@/lib/v2/spend"; +import { ID_RE } from "@/lib/v2/validate"; +import { enter } from "@/lib/v2/workspaces"; + +const time = (v: string | null, k: string) => { + if (!v) return null; + const t = Date.parse(v); + if (Number.isNaN(t)) throw badRequest(`${k} must be an ISO 8601 time`); + return new Date(t).toISOString(); +}; + +// GET /api/v2/workspaces/:ws/spend?by=goal|pod|session&from&to (cookie) +export const GET = route<{ ws: string }>(async (req, params) => { + const pp = enter(await webAuth(req), idParam(params.ws, ID_RE)); + const q = new URL(req.url).searchParams; + const by = (q.get("by") || "goal") as SpendBy; + if (!["goal", "pod", "session"].includes(by)) throw badRequest("by must be goal, pod or session"); + return spend(pp, by, time(q.get("from"), "from"), time(q.get("to"), "to")); +}); diff --git a/app/api/v2/workspaces/route.ts b/app/api/v2/workspaces/route.ts new file mode 100644 index 00000000..fc42eb98 --- /dev/null +++ b/app/api/v2/workspaces/route.ts @@ -0,0 +1,12 @@ +import { readJson, route, webAuth, webWrite } from "@/lib/v2/http"; +import { str } from "@/lib/v2/validate"; +import { createWorkspace, workspacesFor } from "@/lib/v2/workspaces"; + +// GET /api/v2/workspaces (cookie): mine. POST { name }: create one (I am its owner). +export const GET = route(async (req) => ({ workspaces: workspacesFor(await webAuth(req)) })); + +export const POST = route(async (req) => { + const p = await webWrite(req); + const b = await readJson(req, 4096); + return Response.json(await createWorkspace(p.user.id, str(b, "name", 100)), { status: 201 }); +}); diff --git a/app/app/_ui/goalform.tsx b/app/app/_ui/goalform.tsx new file mode 100644 index 00000000..0c0e5d77 --- /dev/null +++ b/app/app/_ui/goalform.tsx @@ -0,0 +1,65 @@ +"use client"; +import { Btn, Field, Input, TextArea, listOf } from "./kit"; +import type { Goal } from "./types"; + +// Measures and anchors editors, shared by "new goal" and "new version". + +export type MeasureDraft = { id?: string; text: string; target: string }; +export type AnchorDraft = { repos: string; branch_patterns: string; issues: string }; + +export const measuresDraft = (g?: Goal): MeasureDraft[] => (g ? g.measures.map((m) => ({ id: m.id, text: m.text, target: m.target ?? "" })) : [{ text: "", target: "" }]); +export const anchorsDraft = (g?: Goal): AnchorDraft => ({ + repos: g?.anchors.repos.join("\n") ?? "", + branch_patterns: g?.anchors.branch_patterns.join("\n") ?? "", + issues: g?.anchors.issues.join("\n") ?? "", +}); + +export const measuresOut = (m: MeasureDraft[]) => m.filter((x) => x.text.trim()).map((x) => ({ ...(x.id ? { id: x.id } : {}), text: x.text.trim(), target: x.target.trim() || null })); +export const anchorsOut = (a: AnchorDraft) => ({ repos: listOf(a.repos), branch_patterns: listOf(a.branch_patterns), issues: listOf(a.issues) }); + +export function MeasuresEditor({ value, onChange }: { value: MeasureDraft[]; onChange: (v: MeasureDraft[]) => void }) { + return ( +
+ Measures + {value.map((m, i) => ( +
+ onChange(value.map((x, j) => (j === i ? { ...x, text: e.target.value } : x)))} /> + onChange(value.map((x, j) => (j === i ? { ...x, target: e.target.value } : x)))} /> + onChange(value.filter((_, j) => j !== i))}> + Remove + +
+ ))} + {value.length < 10 && ( + onChange([...value, { text: "", target: "" }])}> + Add a measure + + )} +
+ ); +} + +export function AnchorsEditor({ value, onChange }: { value: AnchorDraft; onChange: (v: AnchorDraft) => void }) { + return ( +
+ Anchors (matched on your machine only, to suggest this goal for a session) +
+ +