feat: add canonical human-key resource routes

This commit is contained in:
2026-08-20 02:17:21 +09:00
parent 1ca36d6b66
commit a96038d79f
14 changed files with 230 additions and 23 deletions
+13 -1
View File
@@ -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<string>;
};
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;
@@ -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)}`;
}
@@ -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;
@@ -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<string, number>([
@@ -1,5 +1,6 @@
<script lang="ts">
import { formatDate, workspaceRoute } from '$lib/workspace/api/http';
import { formatDate } from '$lib/workspace/api/http';
import { objectiveHref } from '$lib/workspace/resource-links';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
@@ -18,7 +19,7 @@
{:else}
<div class="objective-list">
{#each data.objectives.items as objective (objective.id)}
<a class="objective-row" href={workspaceRoute(data.workspaceId, `/objectives/${objective.id}`)}>
<a class="objective-row" href={objectiveHref(data.workspaceId, objective)}>
<div class="objective-main">
<div class="objective-title-row">
<strong class="objective-title">{objective.title}</strong>
@@ -29,7 +30,7 @@
<div class="objective-meta" aria-label="Objective metadata">
<span>Updated {objective.updated_at ? formatDate(objective.updated_at) : 'unknown'}</span>
<span>{objective.linked_tickets?.length ? `${objective.linked_tickets.length} linked ticket(s)` : 'No linked tickets'}</span>
<code>{objective.id}</code>
<code>{objective.human_key}</code>
</div>
</a>
{/each}
@@ -1,5 +1,6 @@
<script lang="ts">
import { formatDate, workspaceRoute } from '$lib/workspace/api/http';
import { formatDate } from '$lib/workspace/api/http';
import { objectiveHref, ticketHref } from '$lib/workspace/resource-links';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
@@ -14,7 +15,7 @@
{#if data.objectives}
<div class="objective-list compact">
{#each data.objectives.items as objective (objective.id)}
<a class="objective-row" class:active={objective.id === data.objectiveId} href={workspaceRoute(data.workspaceId, `/objectives/${objective.id}`)}>
<a class="objective-row" class:active={objective.id === data.objectiveId} href={objectiveHref(data.workspaceId, objective)}>
<div class="objective-main">
<div class="objective-title-row">
<strong class="objective-title">{objective.title}</strong>
@@ -24,7 +25,7 @@
</div>
<div class="objective-meta" aria-label="Objective metadata">
<span>Updated {objective.updated_at ? formatDate(objective.updated_at) : 'unknown'}</span>
<code>{objective.id}</code>
<code>{objective.human_key}</code>
</div>
</a>
{/each}
@@ -60,7 +61,15 @@
</div>
<div>
<dt>Linked tickets</dt>
<dd>{data.objective.linked_tickets.length ? data.objective.linked_tickets.join(', ') : 'none'}</dd>
<dd>
{#if data.objective.linked_ticket_summaries.length}
{#each data.objective.linked_ticket_summaries as ticket, index}
{#if index}, {/if}<a href={ticketHref(data.workspaceId, ticket)}>{ticket.human_key}</a>
{/each}
{:else}
none
{/if}
</dd>
</div>
</dl>
<pre class="objective-body">{data.objective.body}</pre>
@@ -1,4 +1,9 @@
import { redirect } from "@sveltejs/kit";
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import {
canonicalResourceReference,
resourceHumanKey,
} from "$lib/workspace/resource-links";
import type {
ObjectiveDetail,
ObjectiveListResponse,
@@ -7,7 +12,7 @@ import type { PageLoad } from "./$types";
export const load: PageLoad = async ({ fetch, params }) => {
const apiPath = (path: string) => workspaceApiPath(params.workspaceId, path);
const objectiveId = params.objectiveId;
const objectiveId = resourceHumanKey(params.objectiveId);
const [objectives, objective] = await Promise.all([
loadJson<ObjectiveListResponse>(fetch, apiPath("/objectives")),
loadJson<ObjectiveDetail>(
@@ -16,6 +21,19 @@ export const load: PageLoad = async ({ fetch, params }) => {
),
]);
if (objective.data) {
const canonical = canonicalResourceReference(
objective.data.human_key,
objective.data.title,
);
if (params.objectiveId !== canonical) {
redirect(
308,
`/w/${encodeURIComponent(params.workspaceId)}/objectives/${encodeURIComponent(canonical)}`,
);
}
}
return {
workspaceId: params.workspaceId,
objectiveId,
@@ -7,6 +7,7 @@
TicketListResponse,
TicketSummary,
} from "$lib/generated/ticket-api";
import { ticketHref } from "$lib/workspace/resource-links";
import {
ticketLanes,
type WorkspaceOrchestratorStatus,
@@ -176,9 +177,9 @@
{#each lane.tickets as ticket (ticket.id)}
<a
class="ticket-card"
href={`/w/${encodeURIComponent(data.workspaceId)}/tickets/${encodeURIComponent(ticket.id)}`}
href={ticketHref(data.workspaceId, ticket)}
>
<span class="ticket-card-id">{ticket.id}</span>
<span class="ticket-card-id">{ticket.human_key}</span>
<strong>{ticket.title}</strong>
<div class="ticket-card-meta">
<span>{ticket.state} · {ticket.priority}</span>
@@ -320,8 +320,8 @@
{#if ticket.relations.blockers.length > 0}
<div class="ticket-blocker-list">
{#each ticket.relations.blockers as blocker}
<a href={`/w/${encodeURIComponent(data.workspaceId)}/tickets/${encodeURIComponent(blocker.blocking_ticket)}`}>
<strong>Blocked by {blocker.blocking_ticket}</strong>
<a href={`/w/${encodeURIComponent(data.workspaceId)}/tickets/${encodeURIComponent(blocker.blocking_human_key ?? blocker.blocking_ticket)}`}>
<strong>Blocked by {blocker.blocking_human_key ?? blocker.blocking_ticket}</strong>
<span>{relationLabel(blocker.relation_kind)} · {blocker.blocking_state}</span>
</a>
{/each}
@@ -329,16 +329,16 @@
{/if}
<div class="ticket-relations-list">
{#each ticket.relations.outgoing as relation}
<a href={`/w/${encodeURIComponent(data.workspaceId)}/tickets/${encodeURIComponent(relation.target)}`}>
<a href={`/w/${encodeURIComponent(data.workspaceId)}/tickets/${encodeURIComponent(relation.target_human_key ?? relation.target)}`}>
<span>{relationLabel(relation.kind)}</span>
<strong>{relation.target}</strong>
<strong>{relation.target_human_key ?? relation.target}</strong>
{#if relation.note}<small>{relation.note}</small>{/if}
</a>
{/each}
{#each ticket.relations.incoming as relation}
<a href={`/w/${encodeURIComponent(data.workspaceId)}/tickets/${encodeURIComponent(relation.source_ticket)}`}>
<a href={`/w/${encodeURIComponent(data.workspaceId)}/tickets/${encodeURIComponent(relation.source_human_key ?? relation.source_ticket)}`}>
<span>{relationLabel(relation.inverse_kind)}</span>
<strong>{relation.source_ticket}</strong>
<strong>{relation.source_human_key ?? relation.source_ticket}</strong>
{#if relation.note}<small>{relation.note}</small>{/if}
</a>
{/each}
@@ -1,4 +1,9 @@
import { redirect } from "@sveltejs/kit";
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import {
canonicalResourceReference,
resourceHumanKey,
} from "$lib/workspace/resource-links";
import type { WorkspaceOrchestratorStatus } from "$lib/workspace/tickets/ticket-panel";
import type { RepositoryListResponse, TicketDetail } from "$lib/workspace/sidebar/types";
import type { PageLoad } from "./$types";
@@ -15,12 +20,19 @@ async function loadOptionalJson<T>(fetcher: typeof fetch, path: string): Promise
}
export const load = (async ({ fetch, params }) => {
const ticketPath = workspaceApiPath(params.workspaceId, `/tickets/${encodeURIComponent(params.ticketId)}`);
const reference = resourceHumanKey(params.ticketId);
const ticketPath = workspaceApiPath(params.workspaceId, `/tickets/${encodeURIComponent(reference)}`);
const [ticket, repositories, orchestrator, mergeRequest] = await Promise.all([
loadJson<TicketDetail>(fetch, ticketPath),
loadJson<RepositoryListResponse>(fetch, workspaceApiPath(params.workspaceId, "/repositories")),
loadJson<WorkspaceOrchestratorStatus>(fetch, workspaceApiPath(params.workspaceId, "/orchestrator")),
loadOptionalJson<Record<string, unknown>>(fetch, `${ticketPath}/merge-request`),
]);
return { workspaceId: params.workspaceId, ticketId: params.ticketId, ticket, repositories, orchestrator, mergeRequest };
if (ticket.data) {
const canonical = canonicalResourceReference(ticket.data.human_key, ticket.data.title);
if (params.ticketId !== canonical) {
redirect(308, `/w/${encodeURIComponent(params.workspaceId)}/tickets/${encodeURIComponent(canonical)}`);
}
}
return { workspaceId: params.workspaceId, ticketId: ticket.data?.id ?? reference, ticket, repositories, orchestrator, mergeRequest };
}) satisfies PageLoad;
@@ -1,7 +1,7 @@
<script lang="ts">
import { pushWorkspaceAlert } from '$lib/workspace/alerts/store';
import { workspaceApiPath } from '$lib/workspace/api/http';
import { workerConsoleHref } from '$lib/workspace/console/model';
import { workerHref } from '$lib/workspace/resource-links';
import { formatCurrentWorkdirRevision } from '$lib/workspace/settings/workdir-revision';
import { canOpenWorkerConsole } from '$lib/workspace/sidebar/workers';
import type { CleanupWorkerCandidate, RuntimeCleanupExecutionResponse, RuntimeCleanupPlanResponse, Worker } from '$lib/workspace/sidebar/types';
@@ -195,11 +195,11 @@
<tr>
<td>
{#if canOpenWorkerConsole(worker)}
<a class="worker-title-link" href={workerConsoleHref(worker, data.workspaceId)}><strong>{workerDisplayName}</strong></a>
<a class="worker-title-link" href={workerHref(data.workspaceId, worker)}><strong>{workerDisplayName}</strong></a>
{:else}
<strong>{workerDisplayName}</strong>
{/if}
<small>worker <code>{worker.worker_id}</code></small>
<small>worker <code>{worker.human_key ?? worker.worker_id}</code></small>
</td>
<td><code>{worker.runtime_id}</code></td>
<td>{workerProfile(worker)}</td>
@@ -0,0 +1,32 @@
<script lang="ts">
import { workspaceRoute } from '$lib/workspace/api/http';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
</script>
<svelte:head><title>{data.worker?.human_key ?? 'Worker'} · Yoi</title></svelte:head>
<section class="workspace-page-shell">
{#if data.workerError}
<p class="error-message">{data.workerError}</p>
{:else if data.worker}
<header class="workspace-page-header">
<div>
<p class="eyebrow">{data.worker.human_key}</p>
<h1>{data.worker.display_name}</h1>
</div>
<a
class="button-primary"
href={workspaceRoute(
data.workspaceId,
`/runtimes/${data.worker.runtime_id}/workers/${data.worker.worker_id}/console`,
)}
>Open console</a>
</header>
<dl class="resource-meta">
<dt>Status</dt><dd>{data.worker.state}</dd>
<dt>Profile</dt><dd>{data.worker.profile}</dd>
<dt>Internal ID</dt><dd><code>{data.worker.worker_id}</code></dd>
</dl>
{/if}
</section>
@@ -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<Worker>(
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;