From a96038d79fe3101518d9ce957010442e9d5acc01 Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 20 Aug 2026 02:17:21 +0900 Subject: [PATCH] feat: add canonical human-key resource routes --- web/workspace/src/lib/generated/ticket-api.ts | 14 +++++- .../src/lib/workspace/resource-links.ts | 49 +++++++++++++++++++ .../src/lib/workspace/sidebar/types.ts | 11 +++++ .../src/lib/workspace/tickets/ticket-panel.ts | 2 +- .../w/[workspaceId]/objectives/+page.svelte | 7 +-- .../objectives/[objectiveId]/+page.svelte | 17 +++++-- .../objectives/[objectiveId]/+page.ts | 20 +++++++- .../w/[workspaceId]/tickets/+page.svelte | 5 +- .../tickets/[ticketId]/+page.svelte | 12 ++--- .../[workspaceId]/tickets/[ticketId]/+page.ts | 16 +++++- .../w/[workspaceId]/workers/+page.svelte | 6 +-- .../workers/[workerRef]/+page.svelte | 32 ++++++++++++ .../workers/[workerRef]/+page.ts | 36 ++++++++++++++ web/workspace/tests/resource-links.test.ts | 26 ++++++++++ 14 files changed, 230 insertions(+), 23 deletions(-) create mode 100644 web/workspace/src/lib/workspace/resource-links.ts create mode 100644 web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/+page.svelte create mode 100644 web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/+page.ts create mode 100644 web/workspace/tests/resource-links.test.ts diff --git a/web/workspace/src/lib/generated/ticket-api.ts b/web/workspace/src/lib/generated/ticket-api.ts index c7294af8..cfa0c459 100644 --- a/web/workspace/src/lib/generated/ticket-api.ts +++ b/web/workspace/src/lib/generated/ticket-api.ts @@ -5,6 +5,7 @@ export type InvalidProjectRecord = { label: string; reason: string }; export type TicketSummary = { id: string; + human_key: string; title: string; state: string; priority: string; @@ -51,7 +52,12 @@ export type TicketEventDetail = { references: Array; }; -export type ObjectiveLinkSummary = { id: string; title: string; state: string }; +export type ObjectiveLinkSummary = { + id: string; + human_key: string; + title: string; + state: string; +}; export type TicketEvidenceEvent = { event_ref: string; @@ -66,6 +72,7 @@ export type TicketAssignmentSummary = { assignment_id: string; runtime_id: string; worker_id: string; + worker_human_key?: string | null; }; export type TicketMergeRequestSummary = { @@ -115,6 +122,7 @@ export type TicketQueryRequest = { export type TicketQueryItem = { id: string; + human_key: string; title: string; state: string; readiness: string | null; @@ -150,6 +158,7 @@ export type TicketRelation = { ticket_id: string; kind: string; target: string; + target_human_key?: string | null; note: string | null; author: string; at: string; @@ -157,6 +166,7 @@ export type TicketRelation = { export type DerivedTicketRelation = { source_ticket: string; + source_human_key?: string | null; inverse_kind: string; forward_kind: string; note: string | null; @@ -166,6 +176,7 @@ export type DerivedTicketRelation = { export type TicketRelationBlocker = { blocking_ticket: string; + blocking_human_key?: string | null; reason_kind: string; relation_kind: string; note: string | null; @@ -187,6 +198,7 @@ export type TicketRelationView = { export type TicketDetail = { id: string; + human_key: string; title: string; state: string; readiness: string | null; diff --git a/web/workspace/src/lib/workspace/resource-links.ts b/web/workspace/src/lib/workspace/resource-links.ts new file mode 100644 index 00000000..0bbc3996 --- /dev/null +++ b/web/workspace/src/lib/workspace/resource-links.ts @@ -0,0 +1,49 @@ +const HUMAN_KEY_PATTERN = /^(T|O|W)-(\d+)/; + +export function resourceHumanKey(reference: string): string { + const match = HUMAN_KEY_PATTERN.exec(reference); + return match ? `${match[1]}-${match[2]}` : reference; +} + +export function slugifyResourceTitle(title: string): string { + const slug = title + .normalize("NFKD") + .replace(/\p{Mark}+/gu, "") + .toLocaleLowerCase("en-US") + .replace(/[^\p{Letter}\p{Number}]+/gu, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 80) + .replace(/-+$/g, ""); + return slug || "resource"; +} + +export function canonicalResourceReference( + humanKey: string, + title: string, +): string { + return `${humanKey}-${slugifyResourceTitle(title)}`; +} + +export function ticketHref( + workspaceId: string, + ticket: { human_key: string; title: string }, +): string { + return `/w/${encodeURIComponent(workspaceId)}/tickets/${encodeURIComponent(canonicalResourceReference(ticket.human_key, ticket.title))}`; +} + +export function objectiveHref( + workspaceId: string, + objective: { human_key: string; title: string }, +): string { + return `/w/${encodeURIComponent(workspaceId)}/objectives/${encodeURIComponent(canonicalResourceReference(objective.human_key, objective.title))}`; +} + +export function workerHref( + workspaceId: string, + worker: { human_key?: string; display_name: string; worker_id: string }, +): string { + const reference = worker.human_key + ? canonicalResourceReference(worker.human_key, worker.display_name) + : worker.worker_id; + return `/w/${encodeURIComponent(workspaceId)}/workers/${encodeURIComponent(reference)}`; +} diff --git a/web/workspace/src/lib/workspace/sidebar/types.ts b/web/workspace/src/lib/workspace/sidebar/types.ts index ddac6f58..267e8961 100644 --- a/web/workspace/src/lib/workspace/sidebar/types.ts +++ b/web/workspace/src/lib/workspace/sidebar/types.ts @@ -76,6 +76,7 @@ export type WorkerCapabilities = { export type Worker = { runtime_id: string; worker_id: string; + human_key?: string; host_id: string; display_name: string; label: string; @@ -403,6 +404,7 @@ export type { export type ObjectiveSummary = { id: string; + human_key: string; title: string; state: string; updated_at?: string | null; @@ -411,13 +413,22 @@ export type ObjectiveSummary = { record_source?: string; }; +export type ObjectiveLinkedTicketSummary = { + id: string; + human_key: string; + title: string; + state: string; +}; + export type ObjectiveDetail = { id: string; + human_key: string; title: string; state: string; created_at?: string | null; updated_at?: string | null; linked_tickets: string[]; + linked_ticket_summaries: ObjectiveLinkedTicketSummary[]; body: string; body_truncated: boolean; record_source: string; diff --git a/web/workspace/src/lib/workspace/tickets/ticket-panel.ts b/web/workspace/src/lib/workspace/tickets/ticket-panel.ts index 6162dc62..9a01568e 100644 --- a/web/workspace/src/lib/workspace/tickets/ticket-panel.ts +++ b/web/workspace/src/lib/workspace/tickets/ticket-panel.ts @@ -53,7 +53,7 @@ export type TicketLaneDefinition = (typeof LANE_DEFINITIONS)[number]; export type TicketLaneId = TicketLaneDefinition["id"]; export type TicketCardSummary = Pick< TicketSummary, - "id" | "title" | "state" | "priority" | "updated_at" + "id" | "human_key" | "title" | "state" | "priority" | "updated_at" >; const STATE_SORT_ORDER = new Map([ diff --git a/web/workspace/src/routes/w/[workspaceId]/objectives/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/objectives/+page.svelte index 60757609..eb923185 100644 --- a/web/workspace/src/routes/w/[workspaceId]/objectives/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/objectives/+page.svelte @@ -1,5 +1,6 @@ + +{data.worker?.human_key ?? 'Worker'} · Yoi + +
+ {#if data.workerError} +

{data.workerError}

+ {:else if data.worker} +
+
+

{data.worker.human_key}

+

{data.worker.display_name}

+
+ Open console +
+
+
Status
{data.worker.state}
+
Profile
{data.worker.profile}
+
Internal ID
{data.worker.worker_id}
+
+ {/if} +
diff --git a/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/+page.ts b/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/+page.ts new file mode 100644 index 00000000..b214605e --- /dev/null +++ b/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/+page.ts @@ -0,0 +1,36 @@ +import { redirect } from "@sveltejs/kit"; +import { loadJson, workspaceApiPath } from "$lib/workspace/api/http"; +import { + canonicalResourceReference, + resourceHumanKey, +} from "$lib/workspace/resource-links"; +import type { Worker } from "$lib/workspace/sidebar/types"; +import type { PageLoad } from "./$types"; + +export const load = (async ({ fetch, params }) => { + const reference = resourceHumanKey(params.workerRef); + const result = await loadJson( + fetch, + workspaceApiPath( + params.workspaceId, + `/workers/${encodeURIComponent(reference)}`, + ), + ); + if (result.data?.human_key) { + const canonical = canonicalResourceReference( + result.data.human_key, + result.data.display_name, + ); + if (params.workerRef !== canonical) { + redirect( + 308, + `/w/${encodeURIComponent(params.workspaceId)}/workers/${encodeURIComponent(canonical)}`, + ); + } + } + return { + workspaceId: params.workspaceId, + worker: result.data, + workerError: result.error, + }; +}) satisfies PageLoad; diff --git a/web/workspace/tests/resource-links.test.ts b/web/workspace/tests/resource-links.test.ts new file mode 100644 index 00000000..d3d53330 --- /dev/null +++ b/web/workspace/tests/resource-links.test.ts @@ -0,0 +1,26 @@ +// @ts-nocheck +import { + canonicalResourceReference, + resourceHumanKey, + slugifyResourceTitle, +} from "../src/lib/workspace/resource-links.ts"; + +function assertEquals(actual: unknown, expected: unknown): void { + if (actual !== expected) { + throw new Error(`expected ${String(expected)}, received ${String(actual)}`); + } +} + +Deno.test("resource links normalize titles and preserve the human key", () => { + assertEquals(slugifyResourceTitle(" Fix stale URL / 日本語 "), "fix-stale-url-日本語"); + assertEquals( + canonicalResourceReference("T-1842", "Fix stale URL / 日本語"), + "T-1842-fix-stale-url-日本語", + ); + assertEquals(resourceHumanKey("T-1842-fix-stale-url-日本語"), "T-1842"); +}); + +Deno.test("resource links use a deterministic fallback for punctuation-only titles", () => { + assertEquals(canonicalResourceReference("O-7", "---"), "O-7-resource"); + assertEquals(resourceHumanKey("01a017internal"), "01a017internal"); +});