feat: adopt Workspace resource keys
This commit is contained in:
@@ -135,6 +135,11 @@ export type SubscriptionWorker = { worker_id: SubscriptionWorkerId,
|
||||
* Runtime producers leave this unset because the connection identifies the Runtime.
|
||||
*/
|
||||
runtime_id?: string | null,
|
||||
/**
|
||||
* Workspace-scoped canonical resource key. Runtime producers leave this unset;
|
||||
* Workspace-facing projections must populate it before publishing the Worker.
|
||||
*/
|
||||
resource_key?: string | null,
|
||||
/**
|
||||
* Producer-owned monotonic revision for this Worker subject.
|
||||
*/
|
||||
|
||||
@@ -5,7 +5,7 @@ export type InvalidProjectRecord = { label: string; reason: string };
|
||||
|
||||
export type TicketSummary = {
|
||||
id: string;
|
||||
human_key: string;
|
||||
resource_key: string;
|
||||
title: string;
|
||||
state: string;
|
||||
priority: string;
|
||||
@@ -54,7 +54,7 @@ export type TicketEventDetail = {
|
||||
|
||||
export type ObjectiveLinkSummary = {
|
||||
id: string;
|
||||
human_key: string;
|
||||
resource_key: string;
|
||||
title: string;
|
||||
state: string;
|
||||
};
|
||||
@@ -72,7 +72,7 @@ export type TicketAssignmentSummary = {
|
||||
assignment_id: string;
|
||||
runtime_id: string;
|
||||
worker_id: string;
|
||||
worker_human_key?: string | null;
|
||||
worker_resource_key?: string | null;
|
||||
};
|
||||
|
||||
export type TicketMergeRequestSummary = {
|
||||
@@ -122,7 +122,7 @@ export type TicketQueryRequest = {
|
||||
|
||||
export type TicketQueryItem = {
|
||||
id: string;
|
||||
human_key: string;
|
||||
resource_key: string;
|
||||
title: string;
|
||||
state: string;
|
||||
readiness: string | null;
|
||||
@@ -158,7 +158,7 @@ export type TicketRelation = {
|
||||
ticket_id: string;
|
||||
kind: string;
|
||||
target: string;
|
||||
target_human_key?: string | null;
|
||||
target_resource_key?: string | null;
|
||||
note: string | null;
|
||||
author: string;
|
||||
at: string;
|
||||
@@ -166,7 +166,7 @@ export type TicketRelation = {
|
||||
|
||||
export type DerivedTicketRelation = {
|
||||
source_ticket: string;
|
||||
source_human_key?: string | null;
|
||||
source_resource_key?: string | null;
|
||||
inverse_kind: string;
|
||||
forward_kind: string;
|
||||
note: string | null;
|
||||
@@ -176,7 +176,7 @@ export type DerivedTicketRelation = {
|
||||
|
||||
export type TicketRelationBlocker = {
|
||||
blocking_ticket: string;
|
||||
blocking_human_key?: string | null;
|
||||
blocking_resource_key?: string | null;
|
||||
reason_kind: string;
|
||||
relation_kind: string;
|
||||
note: string | null;
|
||||
@@ -198,7 +198,7 @@ export type TicketRelationView = {
|
||||
|
||||
export type TicketDetail = {
|
||||
id: string;
|
||||
human_key: string;
|
||||
resource_key: string;
|
||||
title: string;
|
||||
state: string;
|
||||
readiness: string | null;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const HUMAN_KEY_PATTERN = /^(T|O|W)-(\d+)/;
|
||||
const RESOURCE_KEY_PATTERN = /^(T|O|W)-(\d+)/;
|
||||
|
||||
export function resourceHumanKey(reference: string): string {
|
||||
const match = HUMAN_KEY_PATTERN.exec(reference);
|
||||
export function resourceKey(reference: string): string {
|
||||
const match = RESOURCE_KEY_PATTERN.exec(reference);
|
||||
return match ? `${match[1]}-${match[2]}` : reference;
|
||||
}
|
||||
|
||||
@@ -18,32 +18,30 @@ export function slugifyResourceTitle(title: string): string {
|
||||
}
|
||||
|
||||
export function canonicalResourceReference(
|
||||
humanKey: string,
|
||||
resourceKey: string,
|
||||
title: string,
|
||||
): string {
|
||||
return `${humanKey}-${slugifyResourceTitle(title)}`;
|
||||
return `${resourceKey}-${slugifyResourceTitle(title)}`;
|
||||
}
|
||||
|
||||
export function ticketHref(
|
||||
workspaceId: string,
|
||||
ticket: { human_key: string; title: string },
|
||||
ticket: { resource_key: string; title: string },
|
||||
): string {
|
||||
return `/w/${encodeURIComponent(workspaceId)}/tickets/${encodeURIComponent(canonicalResourceReference(ticket.human_key, ticket.title))}`;
|
||||
return `/w/${encodeURIComponent(workspaceId)}/tickets/${encodeURIComponent(canonicalResourceReference(ticket.resource_key, ticket.title))}`;
|
||||
}
|
||||
|
||||
export function objectiveHref(
|
||||
workspaceId: string,
|
||||
objective: { human_key: string; title: string },
|
||||
objective: { resource_key: string; title: string },
|
||||
): string {
|
||||
return `/w/${encodeURIComponent(workspaceId)}/objectives/${encodeURIComponent(canonicalResourceReference(objective.human_key, objective.title))}`;
|
||||
return `/w/${encodeURIComponent(workspaceId)}/objectives/${encodeURIComponent(canonicalResourceReference(objective.resource_key, objective.title))}`;
|
||||
}
|
||||
|
||||
export function workerHref(
|
||||
workspaceId: string,
|
||||
worker: { human_key?: string; display_name: string; worker_id: string },
|
||||
worker: { resource_key: string; display_name: string },
|
||||
): string {
|
||||
const reference = worker.human_key
|
||||
? canonicalResourceReference(worker.human_key, worker.display_name)
|
||||
: worker.worker_id;
|
||||
const reference = canonicalResourceReference(worker.resource_key, worker.display_name);
|
||||
return `/w/${encodeURIComponent(workspaceId)}/workers/${encodeURIComponent(reference)}`;
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ export type WorkerCapabilities = {
|
||||
export type Worker = {
|
||||
runtime_id: string;
|
||||
worker_id: string;
|
||||
human_key?: string;
|
||||
resource_key: string;
|
||||
host_id: string;
|
||||
display_name: string;
|
||||
label: string;
|
||||
@@ -404,7 +404,7 @@ export type {
|
||||
|
||||
export type ObjectiveSummary = {
|
||||
id: string;
|
||||
human_key: string;
|
||||
resource_key: string;
|
||||
title: string;
|
||||
state: string;
|
||||
updated_at?: string | null;
|
||||
@@ -415,14 +415,14 @@ export type ObjectiveSummary = {
|
||||
|
||||
export type ObjectiveLinkedTicketSummary = {
|
||||
id: string;
|
||||
human_key: string;
|
||||
resource_key: string;
|
||||
title: string;
|
||||
state: string;
|
||||
};
|
||||
|
||||
export type ObjectiveDetail = {
|
||||
id: string;
|
||||
human_key: string;
|
||||
resource_key: string;
|
||||
title: string;
|
||||
state: string;
|
||||
created_at?: string | null;
|
||||
|
||||
@@ -74,10 +74,12 @@ export function workspaceWorkersStore(workspaceId: string): Readable<WorkspaceWo
|
||||
|
||||
function projectWorker(worker: SubscriptionWorker): SidebarWorker {
|
||||
if (!worker.runtime_id) throw new Error('Workspace Worker projection is missing runtime_id');
|
||||
if (!worker.resource_key) throw new Error('Workspace Worker projection is missing resource_key');
|
||||
const displayName = worker.display_name ?? `Worker ${worker.worker_id}`;
|
||||
return {
|
||||
runtime_id: worker.runtime_id,
|
||||
worker_id: worker.worker_id,
|
||||
resource_key: worker.resource_key,
|
||||
host_id: worker.runtime_id,
|
||||
display_name: displayName,
|
||||
label: displayName,
|
||||
|
||||
@@ -19,6 +19,7 @@ function worker(overrides: Partial<Worker>): Worker {
|
||||
return {
|
||||
runtime_id: "arc",
|
||||
worker_id: "1",
|
||||
resource_key: "W-1",
|
||||
host_id: "host",
|
||||
display_name: "Worker 1",
|
||||
label: "Worker 1",
|
||||
|
||||
@@ -53,7 +53,7 @@ export type TicketLaneDefinition = (typeof LANE_DEFINITIONS)[number];
|
||||
export type TicketLaneId = TicketLaneDefinition["id"];
|
||||
export type TicketCardSummary = Pick<
|
||||
TicketSummary,
|
||||
"id" | "human_key" | "title" | "state" | "priority" | "updated_at"
|
||||
"id" | "resource_key" | "title" | "state" | "priority" | "updated_at"
|
||||
>;
|
||||
|
||||
const STATE_SORT_ORDER = new Map<string, number>([
|
||||
|
||||
@@ -30,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.human_key}</code>
|
||||
<code>{objective.resource_key}</code>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
|
||||
@@ -25,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.human_key}</code>
|
||||
<code>{objective.resource_key}</code>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
@@ -64,7 +64,7 @@
|
||||
<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>
|
||||
{#if index}, {/if}<a href={ticketHref(data.workspaceId, ticket)}>{ticket.resource_key}</a>
|
||||
{/each}
|
||||
{:else}
|
||||
none
|
||||
|
||||
@@ -2,7 +2,7 @@ import { redirect } from "@sveltejs/kit";
|
||||
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
|
||||
import {
|
||||
canonicalResourceReference,
|
||||
resourceHumanKey,
|
||||
resourceKey,
|
||||
} from "$lib/workspace/resource-links";
|
||||
import type {
|
||||
ObjectiveDetail,
|
||||
@@ -12,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 = resourceHumanKey(params.objectiveId);
|
||||
const objectiveId = resourceKey(params.objectiveId);
|
||||
const [objectives, objective] = await Promise.all([
|
||||
loadJson<ObjectiveListResponse>(fetch, apiPath("/objectives")),
|
||||
loadJson<ObjectiveDetail>(
|
||||
@@ -23,7 +23,7 @@ export const load: PageLoad = async ({ fetch, params }) => {
|
||||
|
||||
if (objective.data) {
|
||||
const canonical = canonicalResourceReference(
|
||||
objective.data.human_key,
|
||||
objective.data.resource_key,
|
||||
objective.data.title,
|
||||
);
|
||||
if (params.objectiveId !== canonical) {
|
||||
|
||||
@@ -179,7 +179,7 @@
|
||||
class="ticket-card"
|
||||
href={ticketHref(data.workspaceId, ticket)}
|
||||
>
|
||||
<span class="ticket-card-id">{ticket.human_key}</span>
|
||||
<span class="ticket-card-id">{ticket.resource_key}</span>
|
||||
<strong>{ticket.title}</strong>
|
||||
<div class="ticket-card-meta">
|
||||
<span>{ticket.state} · {ticket.priority}</span>
|
||||
|
||||
@@ -320,27 +320,42 @@
|
||||
{#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_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>
|
||||
{#if blocker.blocking_resource_key}
|
||||
<a href={`/w/${encodeURIComponent(data.workspaceId)}/tickets/${encodeURIComponent(blocker.blocking_resource_key)}`}>
|
||||
<strong>Blocked by {blocker.blocking_resource_key}</strong>
|
||||
<span>{relationLabel(blocker.relation_kind)} · {blocker.blocking_state}</span>
|
||||
</a>
|
||||
{:else}
|
||||
<div>
|
||||
<strong>Blocked by resource key unavailable</strong>
|
||||
<span>{relationLabel(blocker.relation_kind)} · {blocker.blocking_state}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="ticket-relations-list">
|
||||
{#each ticket.relations.outgoing as relation}
|
||||
<a href={`/w/${encodeURIComponent(data.workspaceId)}/tickets/${encodeURIComponent(relation.target_human_key ?? relation.target)}`}>
|
||||
<span>{relationLabel(relation.kind)}</span>
|
||||
<strong>{relation.target_human_key ?? relation.target}</strong>
|
||||
{#if relation.note}<small>{relation.note}</small>{/if}
|
||||
</a>
|
||||
{#if relation.target_resource_key}
|
||||
<a href={`/w/${encodeURIComponent(data.workspaceId)}/tickets/${encodeURIComponent(relation.target_resource_key)}`}>
|
||||
<span>{relationLabel(relation.kind)}</span>
|
||||
<strong>{relation.target_resource_key}</strong>
|
||||
{#if relation.note}<small>{relation.note}</small>{/if}
|
||||
</a>
|
||||
{:else}
|
||||
<span><strong>resource key unavailable</strong></span>
|
||||
{/if}
|
||||
{/each}
|
||||
{#each ticket.relations.incoming as relation}
|
||||
<a href={`/w/${encodeURIComponent(data.workspaceId)}/tickets/${encodeURIComponent(relation.source_human_key ?? relation.source_ticket)}`}>
|
||||
<span>{relationLabel(relation.inverse_kind)}</span>
|
||||
<strong>{relation.source_human_key ?? relation.source_ticket}</strong>
|
||||
{#if relation.note}<small>{relation.note}</small>{/if}
|
||||
</a>
|
||||
{#if relation.source_resource_key}
|
||||
<a href={`/w/${encodeURIComponent(data.workspaceId)}/tickets/${encodeURIComponent(relation.source_resource_key)}`}>
|
||||
<span>{relationLabel(relation.inverse_kind)}</span>
|
||||
<strong>{relation.source_resource_key}</strong>
|
||||
{#if relation.note}<small>{relation.note}</small>{/if}
|
||||
</a>
|
||||
{:else}
|
||||
<span><strong>resource key unavailable</strong></span>
|
||||
{/if}
|
||||
{/each}
|
||||
{#if ticket.relations.outgoing.length === 0 && ticket.relations.incoming.length === 0}
|
||||
<p class="workspace-empty-copy">No Ticket relations.</p>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { redirect } from "@sveltejs/kit";
|
||||
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
|
||||
import {
|
||||
canonicalResourceReference,
|
||||
resourceHumanKey,
|
||||
resourceKey,
|
||||
} from "$lib/workspace/resource-links";
|
||||
import type { WorkspaceOrchestratorStatus } from "$lib/workspace/tickets/ticket-panel";
|
||||
import type { RepositoryListResponse, TicketDetail } from "$lib/workspace/sidebar/types";
|
||||
@@ -20,7 +20,7 @@ async function loadOptionalJson<T>(fetcher: typeof fetch, path: string): Promise
|
||||
}
|
||||
|
||||
export const load = (async ({ fetch, params }) => {
|
||||
const reference = resourceHumanKey(params.ticketId);
|
||||
const reference = resourceKey(params.ticketId);
|
||||
const ticketPath = workspaceApiPath(params.workspaceId, `/tickets/${encodeURIComponent(reference)}`);
|
||||
const [ticket, repositories, orchestrator, mergeRequest] = await Promise.all([
|
||||
loadJson<TicketDetail>(fetch, ticketPath),
|
||||
@@ -29,7 +29,7 @@ export const load = (async ({ fetch, params }) => {
|
||||
loadOptionalJson<Record<string, unknown>>(fetch, `${ticketPath}/merge-request`),
|
||||
]);
|
||||
if (ticket.data) {
|
||||
const canonical = canonicalResourceReference(ticket.data.human_key, ticket.data.title);
|
||||
const canonical = canonicalResourceReference(ticket.data.resource_key, ticket.data.title);
|
||||
if (params.ticketId !== canonical) {
|
||||
redirect(308, `/w/${encodeURIComponent(params.workspaceId)}/tickets/${encodeURIComponent(canonical)}`);
|
||||
}
|
||||
|
||||
@@ -194,12 +194,12 @@
|
||||
{@const workerDisplayName = worker.display_name || worker.label}
|
||||
<tr>
|
||||
<td>
|
||||
{#if canOpenWorkerConsole(worker)}
|
||||
<a class="worker-title-link" href={workerHref(data.workspaceId, worker)}><strong>{workerDisplayName}</strong></a>
|
||||
{#if canOpenWorkerConsole(worker) && worker.resource_key}
|
||||
<a class="worker-title-link" href={workerHref(data.workspaceId, { ...worker, resource_key: worker.resource_key })}><strong>{workerDisplayName}</strong></a>
|
||||
{:else}
|
||||
<strong>{workerDisplayName}</strong>
|
||||
{/if}
|
||||
<small>worker <code>{worker.human_key ?? worker.worker_id}</code></small>
|
||||
<small>worker <code>{worker.resource_key}</code></small>
|
||||
</td>
|
||||
<td><code>{worker.runtime_id}</code></td>
|
||||
<td>{workerProfile(worker)}</td>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
let { data }: { data: PageData } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{data.worker?.human_key ?? 'Worker'} · Yoi</title></svelte:head>
|
||||
<svelte:head><title>{data.worker?.resource_key ?? 'Worker'} · Yoi</title></svelte:head>
|
||||
|
||||
<section class="workspace-page-shell">
|
||||
{#if data.workerError}
|
||||
@@ -12,7 +12,7 @@
|
||||
{:else if data.worker}
|
||||
<header class="workspace-page-header">
|
||||
<div>
|
||||
<p class="eyebrow">{data.worker.human_key}</p>
|
||||
<p class="eyebrow">{data.worker.resource_key}</p>
|
||||
<h1>{data.worker.display_name}</h1>
|
||||
</div>
|
||||
<a
|
||||
|
||||
@@ -2,13 +2,13 @@ import { redirect } from "@sveltejs/kit";
|
||||
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
|
||||
import {
|
||||
canonicalResourceReference,
|
||||
resourceHumanKey,
|
||||
resourceKey,
|
||||
} 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 reference = resourceKey(params.workerRef);
|
||||
const result = await loadJson<Worker>(
|
||||
fetch,
|
||||
workspaceApiPath(
|
||||
@@ -16,9 +16,9 @@ export const load = (async ({ fetch, params }) => {
|
||||
`/workers/${encodeURIComponent(reference)}`,
|
||||
),
|
||||
);
|
||||
if (result.data?.human_key) {
|
||||
if (result.data?.resource_key) {
|
||||
const canonical = canonicalResourceReference(
|
||||
result.data.human_key,
|
||||
result.data.resource_key,
|
||||
result.data.display_name,
|
||||
);
|
||||
if (params.workerRef !== canonical) {
|
||||
|
||||
Reference in New Issue
Block a user