Merge remote-tracking branch 'origin/develop' into work/T-689-web-subject-create
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:318ce25c5d41452715ccb31e81798bfe2b894796a1f885afb37c83cdb7525098)
|
||||
// Input contract: 0.1.0 (sha256:f9f0f8918dbcce386e94d5368fa938f02565ca98f9f67f07537bd88ed8c49870)
|
||||
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/companion-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -34,7 +34,13 @@ export type WorkerStateSnapshot = {
|
||||
/**
|
||||
* Highest lifecycle command id observed by this controller instance.
|
||||
*/
|
||||
last_command_id: number, state: WorkerState, };
|
||||
last_command_id: number,
|
||||
/**
|
||||
* Most recent Submit request whose controller Run has returned and whose
|
||||
* post-Run state transition has been published. This is a teardown fence,
|
||||
* not a successful-result claim; cancelled and failed Runs may also finish.
|
||||
*/
|
||||
last_finished_submission_request_id?: string | null, state: WorkerState, };
|
||||
|
||||
export type TurnResult = "finished" | "paused";
|
||||
|
||||
@@ -129,12 +135,12 @@ export type SessionMessageRole = "user" | "assistant";
|
||||
|
||||
export type SessionContentPart = { "kind": "text", text: string, } | { "kind": "refusal", refusal: string, };
|
||||
|
||||
export type SessionToolAttachment = { media_type: string,
|
||||
export type SessionToolAttachment = {
|
||||
/**
|
||||
* Base64-encoded durable attachment body. Public snapshots preserve the
|
||||
* committed multimodal value instead of replacing it with placeholder text.
|
||||
* Stable, bounded identity derived from the owning Session entry, attachment
|
||||
* position, and immutable image bytes. It is a locator, not a bearer secret.
|
||||
*/
|
||||
data_base64: string, };
|
||||
attachment_id: string, media_type: string, byte_len: number, };
|
||||
|
||||
export type RunYieldReason = "compaction";
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:318ce25c5d41452715ccb31e81798bfe2b894796a1f885afb37c83cdb7525098)
|
||||
// Input contract: 0.1.0 (sha256:f9f0f8918dbcce386e94d5368fa938f02565ca98f9f67f07537bd88ed8c49870)
|
||||
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/repository-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
|
||||
@@ -69,7 +69,7 @@ export type WorkerRunState = "running" | "pausing" | "paused" | "cancelling";
|
||||
|
||||
export type WorkerState = { kind: "idle"; } | { kind: "busy"; state: WorkerBusyState; };
|
||||
|
||||
export type WorkerStateSnapshot = { last_command_id: number; state: WorkerState; };
|
||||
export type WorkerStateSnapshot = { last_command_id: number; last_finished_submission_request_id?: string | null; state: WorkerState; };
|
||||
|
||||
export type WorkerSummary = { availability: SubscriptionWorkerAvailability; diagnostics?: Array<Diagnostic>; display_name?: string; host_id: string; implementation: WorkerImplementationSummary; label: string; last_seen_at?: string | null; pinned?: boolean; profile?: string | null; resource_key: string; retention_state?: string; runtime_id: string; singleton_key?: string | null; state: string; tags?: Array<string>; workdir_attachments?: Array<WorkerWorkdirAttachmentSummary>; worker_id: string; worker_state?: WorkerStateSnapshot | null; workspace: WorkerWorkspaceSummary; };
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-runtime-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:318ce25c5d41452715ccb31e81798bfe2b894796a1f885afb37c83cdb7525098)
|
||||
// Input contract: 0.1.0 (sha256:f9f0f8918dbcce386e94d5368fa938f02565ca98f9f67f07537bd88ed8c49870)
|
||||
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/runtime-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
|
||||
@@ -159,7 +159,7 @@ export type WorkerRunState = "running" | "pausing" | "paused" | "cancelling";
|
||||
|
||||
export type WorkerState = { kind: "idle"; } | { kind: "busy"; state: WorkerBusyState; };
|
||||
|
||||
export type WorkerStateSnapshot = { last_command_id: number; state: WorkerState; };
|
||||
export type WorkerStateSnapshot = { last_command_id: number; last_finished_submission_request_id?: string | null; state: WorkerState; };
|
||||
|
||||
export type WorkerSummary = { availability: SubscriptionWorkerAvailability; diagnostics?: Array<Diagnostic>; display_name?: string; host_id: string; implementation: WorkerImplementationSummary; label: string; last_seen_at?: string | null; pinned?: boolean; profile?: string | null; resource_key: string; retention_state?: string; runtime_id: string; singleton_key?: string | null; state: string; tags?: Array<string>; workdir_attachments?: Array<WorkerWorkdirAttachmentSummary>; worker_id: string; worker_state?: WorkerStateSnapshot | null; workspace: WorkerWorkspaceSummary; };
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:318ce25c5d41452715ccb31e81798bfe2b894796a1f885afb37c83cdb7525098)
|
||||
// Input contract: 0.1.0 (sha256:f9f0f8918dbcce386e94d5368fa938f02565ca98f9f67f07537bd88ed8c49870)
|
||||
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/ticket-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-workdir-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:318ce25c5d41452715ccb31e81798bfe2b894796a1f885afb37c83cdb7525098)
|
||||
// Input contract: 0.1.0 (sha256:f9f0f8918dbcce386e94d5368fa938f02565ca98f9f67f07537bd88ed8c49870)
|
||||
// Options: workdir-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/workdir-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_workdir_api_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:318ce25c5d41452715ccb31e81798bfe2b894796a1f885afb37c83cdb7525098)
|
||||
// Input contract: 0.1.0 (sha256:f9f0f8918dbcce386e94d5368fa938f02565ca98f9f67f07537bd88ed8c49870)
|
||||
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/worker-launch-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
|
||||
@@ -11,7 +11,7 @@ export type BrowserWorkerWorkingDirectorySelection = { alias: string; relative_c
|
||||
|
||||
export type BrowserWorkspaceOrchestratorResponse = { diagnostics: Array<Diagnostic>; disposition: string; online: boolean; worker?: WorkerLaunchWorkerSummary | null; workspace_id: string; };
|
||||
|
||||
export type CreateWorkspaceWorkerRequest = { control_operation_id?: string | null; display_name: string; initial_submit?: Array<Segment>; profile?: string | null; runtime_id: string; singleton_key?: string | null; ticket_assignment?: CreateWorkspaceWorkerTicketAssignmentRequest | null; workdir_attachments?: Array<BrowserWorkerWorkingDirectorySelection>; };
|
||||
export type CreateWorkspaceWorkerRequest = { control_operation_id?: string | null; display_name: string; feature_connections: WorkspaceWorkerFeatureConnectionsRequest; initial_submit?: Array<Segment>; profile?: string | null; runtime_id: string; singleton_key?: string | null; ticket_assignment?: CreateWorkspaceWorkerTicketAssignmentRequest | null; workdir_attachments?: Array<BrowserWorkerWorkingDirectorySelection>; };
|
||||
|
||||
export type CreateWorkspaceWorkerTicketAssignmentRequest = { operation_id: string; ticket_id: string; };
|
||||
|
||||
@@ -37,6 +37,8 @@ export type RuntimeWorkingDirectorySummary = { cleanliness?: string | null; clea
|
||||
|
||||
export type Segment = { content: string; kind: "text"; } | { chars: number; content: string; id: number; kind: "paste"; lines: number; } | { artifact: PasteArtifactRef; kind: "paste_artifact"; } | { file: UploadedFileRef; kind: "uploaded_file"; } | { kind: "file_ref"; path: string; } | { kind: "flow"; selector: string; } | { kind: "unknown"; };
|
||||
|
||||
export type SubjektivWorkerConnectionRequest = { subject_id: string; };
|
||||
|
||||
export type SubscriptionWorkerAvailability = "observed" | "unavailable";
|
||||
|
||||
export type UploadedFileAvailability = "available" | "unavailable" | "integrity_failed";
|
||||
@@ -47,9 +49,11 @@ export type WorkerBusyState = { kind: "run"; state: WorkerRunState; } | { kind:
|
||||
|
||||
export type WorkerImplementationSummary = { display_hint: string; kind: string; };
|
||||
|
||||
export type WorkerLaunchFeatureConnectionOptions = { subjektiv: boolean; };
|
||||
|
||||
export type WorkerLaunchOptionsResponse = { default_profile?: string | null; diagnostics: Array<Diagnostic>; profiles: Array<WorkerLaunchProfileCandidate>; repositories: Array<WorkingDirectoryRepositoryOption>; runtimes: Array<WorkerLaunchRuntimeOption>; working_directories: Array<WorkingDirectorySummary>; workspace_id: string; };
|
||||
|
||||
export type WorkerLaunchProfileCandidate = { description: string; id: string; label: string; };
|
||||
export type WorkerLaunchProfileCandidate = { description: string; feature_connections: WorkerLaunchFeatureConnectionOptions; id: string; label: string; };
|
||||
|
||||
export type WorkerLaunchRuntimeOption = { built_in: boolean; diagnostics: Array<Diagnostic>; display_name: string; runtime_id: string; status: string; worker_creation_available: boolean; working_directory_required: boolean; };
|
||||
|
||||
@@ -63,7 +67,7 @@ export type WorkerRunState = "running" | "pausing" | "paused" | "cancelling";
|
||||
|
||||
export type WorkerState = { kind: "idle"; } | { kind: "busy"; state: WorkerBusyState; };
|
||||
|
||||
export type WorkerStateSnapshot = { last_command_id: number; state: WorkerState; };
|
||||
export type WorkerStateSnapshot = { last_command_id: number; last_finished_submission_request_id?: string | null; state: WorkerState; };
|
||||
|
||||
export type WorkerSummary = { availability: SubscriptionWorkerAvailability; diagnostics?: Array<Diagnostic>; display_name?: string; host_id: string; implementation: WorkerImplementationSummary; label: string; last_seen_at?: string | null; pinned?: boolean; profile?: string | null; resource_key: string; retention_state?: string; runtime_id: string; singleton_key?: string | null; state: string; tags?: Array<string>; workdir_attachments?: Array<WorkerWorkdirAttachmentSummary>; worker_id: string; worker_state?: WorkerStateSnapshot | null; workspace: WorkerWorkspaceSummary; };
|
||||
|
||||
@@ -84,3 +88,5 @@ export type WorkingDirectorySource = { kind: "repository"; repository_key: strin
|
||||
export type WorkingDirectoryStatusKind = "active" | "cleanup_pending" | "corrupted" | "not_found" | "unknown";
|
||||
|
||||
export type WorkingDirectorySummary = { cleanliness?: string | null; cleanup_target?: WorkingDirectoryCleanupTarget | null; creation_ref?: string | null; creation_selector?: string | null; creation_tree?: string | null; current_ref?: string | null; current_selector?: string | null; current_tree?: string | null; display_name?: string | null; materializer_kind: WorkingDirectoryMaterializerKind; observed_at_epoch_seconds?: number | null; occupied_by?: WorkingDirectoryOccupancy | null; source: WorkingDirectorySource; status: WorkingDirectoryStatusKind; working_directory_id: string; };
|
||||
|
||||
export type WorkspaceWorkerFeatureConnectionsRequest = { subjektiv?: SubjektivWorkerConnectionRequest | null; };
|
||||
|
||||
@@ -166,7 +166,12 @@ Deno.test("Worker launch options parser accepts the generated wire shape", () =>
|
||||
diagnostics: [],
|
||||
}],
|
||||
default_profile: null,
|
||||
profiles: [{ id: "builtin:coder", label: "Coder", description: "Code" }],
|
||||
profiles: [{
|
||||
id: "builtin:coder",
|
||||
label: "Coder",
|
||||
description: "Code",
|
||||
feature_connections: { subjektiv: true },
|
||||
}],
|
||||
repositories: [{ repository_key: "main" }],
|
||||
working_directories: [],
|
||||
diagnostics: [],
|
||||
@@ -235,10 +240,27 @@ Deno.test("Worker create request parser requires the complete shared request", (
|
||||
working_directory_id: "workdir-a",
|
||||
relative_cwd: null,
|
||||
}],
|
||||
feature_connections: {},
|
||||
control_operation_id: null,
|
||||
};
|
||||
|
||||
assertEquals(parseCreateWorkspaceWorkerRequest(request), request);
|
||||
assertEquals(
|
||||
parseCreateWorkspaceWorkerRequest({
|
||||
...request,
|
||||
feature_connections: { subjektiv: { subject_id: "subject-42" } },
|
||||
}).feature_connections,
|
||||
{ subjektiv: { subject_id: "subject-42" } },
|
||||
);
|
||||
assertThrows(
|
||||
() =>
|
||||
parseCreateWorkspaceWorkerRequest({
|
||||
...request,
|
||||
resolved_subjektiv_attached: true,
|
||||
}),
|
||||
Error,
|
||||
"unknown field resolved_subjektiv_attached",
|
||||
);
|
||||
assertThrows(
|
||||
() =>
|
||||
parseCreateWorkspaceWorkerRequest({
|
||||
|
||||
@@ -21,6 +21,7 @@ import type {
|
||||
WorkerWorkdirAttachmentSummary,
|
||||
WorkerWorkspaceSummary,
|
||||
WorkingDirectoryRepositoryOption,
|
||||
WorkspaceWorkerFeatureConnectionsRequest,
|
||||
} from "#lib/generated/worker-launch-api.ts";
|
||||
import type { Segment } from "#lib/generated/protocol.ts";
|
||||
import {
|
||||
@@ -148,16 +149,26 @@ function runtimeOption(
|
||||
};
|
||||
}
|
||||
|
||||
function featureConnectionOptions(value: unknown, label: string) {
|
||||
const item = record(value, label);
|
||||
exact(item, ["subjektiv"], label);
|
||||
return { subjektiv: boolean(item.subjektiv, `${label}.subjektiv`) };
|
||||
}
|
||||
|
||||
function profileCandidate(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): WorkerLaunchProfileCandidate {
|
||||
const item = record(value, label);
|
||||
exact(item, ["id", "label", "description"], label);
|
||||
exact(item, ["id", "label", "description", "feature_connections"], label);
|
||||
return {
|
||||
id: string(item.id, `${label}.id`),
|
||||
label: string(item.label, `${label}.label`),
|
||||
description: string(item.description, `${label}.description`),
|
||||
feature_connections: featureConnectionOptions(
|
||||
item.feature_connections,
|
||||
`${label}.feature_connections`,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -700,6 +711,20 @@ function ticketAssignment(
|
||||
};
|
||||
}
|
||||
|
||||
function featureConnectionsRequest(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): WorkspaceWorkerFeatureConnectionsRequest {
|
||||
const item = record(value, label);
|
||||
exact(item, ["subjektiv"], label);
|
||||
const subjektiv = optional(item.subjektiv, `${label}.subjektiv`, (value, itemLabel) => {
|
||||
const connection = record(value, itemLabel);
|
||||
exact(connection, ["subject_id"], itemLabel);
|
||||
return { subject_id: string(connection.subject_id, `${itemLabel}.subject_id`) };
|
||||
});
|
||||
return subjektiv === undefined ? {} : { subjektiv };
|
||||
}
|
||||
|
||||
function unsignedInteger(value: unknown, label: string): number {
|
||||
const parsed = number(value, label);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 0) {
|
||||
@@ -853,6 +878,7 @@ export function parseCreateWorkspaceWorkerRequest(
|
||||
"ticket_assignment",
|
||||
"initial_submit",
|
||||
"workdir_attachments",
|
||||
"feature_connections",
|
||||
"control_operation_id",
|
||||
],
|
||||
"Worker create request",
|
||||
@@ -870,6 +896,10 @@ export function parseCreateWorkspaceWorkerRequest(
|
||||
"workdir_attachments",
|
||||
workingDirectorySelection,
|
||||
),
|
||||
feature_connections: featureConnectionsRequest(
|
||||
item.feature_connections,
|
||||
"feature_connections",
|
||||
),
|
||||
control_operation_id: nullableString(
|
||||
item.control_operation_id,
|
||||
"control_operation_id",
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
<script lang="ts">
|
||||
import type { SessionToolAttachment } from "#lib/generated/protocol.ts";
|
||||
import {
|
||||
ConsoleAttachmentFetchError,
|
||||
loadConsoleAttachment,
|
||||
} from "./attachment-loader.ts";
|
||||
|
||||
type Props = {
|
||||
attachment: SessionToolAttachment;
|
||||
url: string | null;
|
||||
};
|
||||
|
||||
let { attachment, url }: Props = $props();
|
||||
let root: HTMLElement | null = $state(null);
|
||||
let objectUrl = $state<string | null>(null);
|
||||
let disposed = false;
|
||||
let status = $state<"idle" | "loading" | "ready" | "missing" | "expired" | "unauthorized" | "network" | "failed">("idle");
|
||||
|
||||
async function load(): Promise<void> {
|
||||
if (!url || status === "loading" || status === "ready") return;
|
||||
status = "loading";
|
||||
try {
|
||||
const blob = await loadConsoleAttachment(url);
|
||||
if (disposed) return;
|
||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
status = "ready";
|
||||
} catch (error) {
|
||||
if (disposed) return;
|
||||
status = error instanceof ConsoleAttachmentFetchError ? error.kind : "failed";
|
||||
}
|
||||
}
|
||||
|
||||
function statusText(): string {
|
||||
switch (status) {
|
||||
case "idle": return url ? "Image waiting to load" : "Image Session is unavailable";
|
||||
case "loading": return "Loading image…";
|
||||
case "missing": return "Image is missing";
|
||||
case "expired": return "Image retention has expired";
|
||||
case "unauthorized": return "Image access was denied";
|
||||
case "network": return "Image network request failed";
|
||||
case "failed": return "Image could not be loaded";
|
||||
default: return "";
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!root || !url || status !== "idle") return;
|
||||
if (typeof IntersectionObserver === "undefined") {
|
||||
void load();
|
||||
return;
|
||||
}
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
if (entries.some((entry) => entry.isIntersecting)) {
|
||||
observer.disconnect();
|
||||
void load();
|
||||
}
|
||||
}, { rootMargin: "160px" });
|
||||
observer.observe(root);
|
||||
return () => observer.disconnect();
|
||||
});
|
||||
|
||||
$effect(() => () => {
|
||||
disposed = true;
|
||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="console-attachment" bind:this={root} data-attachment-id={attachment.attachment_id}>
|
||||
{#if status === "ready" && objectUrl}
|
||||
<img src={objectUrl} alt="Tool output attachment" />
|
||||
{:else}
|
||||
<span>{statusText()}</span>
|
||||
{#if url && ["missing", "expired", "unauthorized", "network", "failed"].includes(status)}
|
||||
<button type="button" onclick={() => void load()}>Retry</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.console-attachment {
|
||||
margin-top: var(--space-2);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-compact);
|
||||
}
|
||||
img {
|
||||
display: block;
|
||||
max-width: min(100%, 52rem);
|
||||
max-height: 42rem;
|
||||
object-fit: contain;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
button {
|
||||
margin-left: var(--space-2);
|
||||
}
|
||||
</style>
|
||||
@@ -1,13 +1,16 @@
|
||||
<script lang="ts">
|
||||
import AnsiText from '#lib/workspace/console/AnsiText.svelte';
|
||||
import ConsoleAttachmentImage from '#lib/workspace/console/ConsoleAttachmentImage.svelte';
|
||||
import RichMarkdown from '#lib/workspace/console/RichMarkdown.svelte';
|
||||
import type { SessionToolAttachment } from '#lib/generated/protocol.ts';
|
||||
import type { ConsoleLine } from '#lib/workspace/console/model.ts';
|
||||
|
||||
type Props = {
|
||||
item: ConsoleLine;
|
||||
attachmentUrl?: (attachment: SessionToolAttachment) => string | null;
|
||||
};
|
||||
|
||||
let { item }: Props = $props();
|
||||
let { item, attachmentUrl = () => null }: Props = $props();
|
||||
let detailOpen = $state(false);
|
||||
let nowMs = $state(Date.now());
|
||||
|
||||
@@ -140,6 +143,13 @@
|
||||
{:else}
|
||||
<p class="console-plain-text">{item.body || '—'}</p>
|
||||
{/if}
|
||||
{#if item.attachments?.length}
|
||||
<div class="console-attachments">
|
||||
{#each item.attachments as attachment (attachment.attachment_id)}
|
||||
<ConsoleAttachmentImage {attachment} url={attachmentUrl(attachment)} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if item.diff}
|
||||
<div class="console-diff" role="group" aria-label="Edit diff">
|
||||
{#each item.diff as diffLine}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import {
|
||||
clearConsoleAttachmentCache,
|
||||
ConsoleAttachmentFetchError,
|
||||
loadConsoleAttachment,
|
||||
} from "./attachment-loader.ts";
|
||||
|
||||
declare const Deno: {
|
||||
test(name: string, fn: () => void | Promise<void>): void;
|
||||
};
|
||||
|
||||
function assertEquals(actual: unknown, expected: unknown): void {
|
||||
if (actual !== expected) {
|
||||
throw new Error(`expected ${String(expected)}, got ${String(actual)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertStrictEquals(actual: unknown, expected: unknown): void {
|
||||
if (actual !== expected) {
|
||||
throw new Error("expected values to be strictly identical");
|
||||
}
|
||||
}
|
||||
|
||||
Deno.test("console attachment loads are lazy-callable and deduplicate in-flight and successful fetches", async () => {
|
||||
clearConsoleAttachmentCache();
|
||||
let calls = 0;
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((resolve) => release = resolve);
|
||||
const fetcher = (async () => {
|
||||
calls += 1;
|
||||
await gate;
|
||||
return new Response(new Uint8Array([1, 2, 3]), {
|
||||
headers: { "content-type": "image/png" },
|
||||
});
|
||||
}) as typeof fetch;
|
||||
|
||||
assertEquals(calls, 0);
|
||||
const first = loadConsoleAttachment("/attachment/a", fetcher);
|
||||
const second = loadConsoleAttachment("/attachment/a", fetcher);
|
||||
assertStrictEquals(first, second);
|
||||
assertEquals(calls, 1);
|
||||
release();
|
||||
const blob = await first;
|
||||
assertEquals(blob.size, 3);
|
||||
assertStrictEquals(
|
||||
await loadConsoleAttachment("/attachment/a", fetcher),
|
||||
blob,
|
||||
);
|
||||
assertEquals(calls, 1);
|
||||
});
|
||||
|
||||
Deno.test("console attachment failures stay local and classify retention and authorization", async () => {
|
||||
clearConsoleAttachmentCache();
|
||||
for (
|
||||
const [status, kind] of [[404, "missing"], [410, "expired"], [
|
||||
403,
|
||||
"unauthorized",
|
||||
]] as const
|
||||
) {
|
||||
try {
|
||||
await loadConsoleAttachment(
|
||||
`/attachment/${status}`,
|
||||
(async () => new Response(null, { status })) as typeof fetch,
|
||||
);
|
||||
throw new Error("expected failure");
|
||||
} catch (error) {
|
||||
assertEquals(
|
||||
error instanceof ConsoleAttachmentFetchError && error.kind,
|
||||
kind,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
export type ConsoleAttachmentFetchErrorKind =
|
||||
| "missing"
|
||||
| "expired"
|
||||
| "unauthorized"
|
||||
| "network"
|
||||
| "failed";
|
||||
|
||||
export class ConsoleAttachmentFetchError extends Error {
|
||||
constructor(
|
||||
public readonly kind: ConsoleAttachmentFetchErrorKind,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ConsoleAttachmentFetchError";
|
||||
}
|
||||
}
|
||||
|
||||
const successful = new Map<string, Blob>();
|
||||
const inFlight = new Map<string, Promise<Blob>>();
|
||||
|
||||
export function loadConsoleAttachment(
|
||||
url: string,
|
||||
fetcher: typeof fetch = fetch,
|
||||
): Promise<Blob> {
|
||||
const cached = successful.get(url);
|
||||
if (cached) return Promise.resolve(cached);
|
||||
const pending = inFlight.get(url);
|
||||
if (pending) return pending;
|
||||
|
||||
const request = (async () => {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetcher(url, { credentials: "same-origin" });
|
||||
} catch (error) {
|
||||
throw new ConsoleAttachmentFetchError(
|
||||
"network",
|
||||
error instanceof Error ? error.message : "Network request failed",
|
||||
);
|
||||
}
|
||||
if (!response.ok) {
|
||||
const kind: ConsoleAttachmentFetchErrorKind = response.status === 404
|
||||
? "missing"
|
||||
: response.status === 410
|
||||
? "expired"
|
||||
: response.status === 401 || response.status === 403
|
||||
? "unauthorized"
|
||||
: "failed";
|
||||
throw new ConsoleAttachmentFetchError(kind, `HTTP ${response.status}`);
|
||||
}
|
||||
const blob = await response.blob();
|
||||
successful.set(url, blob);
|
||||
return blob;
|
||||
})().finally(() => inFlight.delete(url));
|
||||
inFlight.set(url, request);
|
||||
return request;
|
||||
}
|
||||
|
||||
export function clearConsoleAttachmentCache(): void {
|
||||
successful.clear();
|
||||
inFlight.clear();
|
||||
}
|
||||
@@ -3741,6 +3741,11 @@ Deno.test("committed tool identities reconcile one live call block", () => {
|
||||
summary: "read",
|
||||
content: "contents",
|
||||
is_error: false,
|
||||
attachments: [{
|
||||
attachment_id: "attachment-1",
|
||||
media_type: "image/png",
|
||||
byte_len: 5_735_340,
|
||||
}],
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -3753,6 +3758,15 @@ Deno.test("committed tool identities reconcile one live call block", () => {
|
||||
);
|
||||
assertEquals(lines[0].entryId, "tool-result-entry");
|
||||
assertEquals(lines[0].toolCall?.state, "done");
|
||||
assertEquals(lines[0].attachments, [{
|
||||
attachment_id: "attachment-1",
|
||||
media_type: "image/png",
|
||||
byte_len: 5_735_340,
|
||||
}]);
|
||||
assertEquals(
|
||||
projectConsoleLines(lines, "overview")[0].attachments,
|
||||
lines[0].attachments,
|
||||
);
|
||||
});
|
||||
|
||||
function readHistory(ids: string[], summary: string): ConsoleLine[] {
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
ReasoningConfig,
|
||||
Segment,
|
||||
SessionSnapshotEntry,
|
||||
SessionToolAttachment,
|
||||
WorkerState,
|
||||
WorkerStateSnapshot,
|
||||
WorkerStatus,
|
||||
@@ -63,6 +64,7 @@ type ToolCallView = {
|
||||
isError?: boolean;
|
||||
cwd?: string | null;
|
||||
command?: CommandSnapshot;
|
||||
attachments?: SessionToolAttachment[];
|
||||
};
|
||||
|
||||
export type ConsoleDiffLine = {
|
||||
@@ -99,6 +101,7 @@ export type ConsoleLine = {
|
||||
streaming?: boolean;
|
||||
error?: boolean;
|
||||
toolCall?: ToolCallView;
|
||||
attachments?: SessionToolAttachment[];
|
||||
/** Number of calls represented by a lower-level aggregate line. */
|
||||
toolCallCount?: number;
|
||||
/** Original Read rows, retained so history can reconcile before aggregation. */
|
||||
@@ -593,7 +596,12 @@ export function projectOverviewLines(lines: ConsoleLine[]): ConsoleLine[] {
|
||||
if (line.systemItemKind === "task_reminder") continue;
|
||||
if (isOverviewThinkingLine(line)) continue;
|
||||
if (line.kind === "tool" && line.toolCall) {
|
||||
toolGroup.push(line);
|
||||
if (line.attachments?.length) {
|
||||
flushTools();
|
||||
overview.push(line);
|
||||
} else {
|
||||
toolGroup.push(line);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
flushTools();
|
||||
@@ -1634,7 +1642,7 @@ function attachToolResult(
|
||||
projection: ConsoleProjection,
|
||||
eventId: string,
|
||||
id: string,
|
||||
result: Pick<ToolCallView, "summary" | "output" | "isError">,
|
||||
result: Pick<ToolCallView, "summary" | "output" | "isError" | "attachments">,
|
||||
): void {
|
||||
const index = findToolCallLineIndex(projection, id);
|
||||
if (index < 0) {
|
||||
@@ -1711,6 +1719,7 @@ function refreshedToolLine(item: ConsoleLine): ConsoleLine {
|
||||
toolStatus: toolCallStatus(toolCall),
|
||||
detail: toolCallDetail(toolCall),
|
||||
diff: toolCall.name === "Edit" ? editDiff(toolCall) : undefined,
|
||||
attachments: toolCall.attachments,
|
||||
streaming: !["done", "error"].includes(toolCall.state) && !commandTerminal,
|
||||
error: toolCall.state === "error" || commandError,
|
||||
};
|
||||
@@ -1816,6 +1825,7 @@ function readAggregateLine(group: ConsoleLine[]): ConsoleLine {
|
||||
const hasError = calls.some((call) => call.state === "error");
|
||||
const paths = calls.map(readPath);
|
||||
const visiblePaths = inProgress ? paths.slice(-3) : paths;
|
||||
const attachments = group.flatMap((line) => line.attachments ?? []);
|
||||
const body = compactLines([
|
||||
visiblePaths.map((path) => ` ${path}`).join("\n"),
|
||||
inProgress && paths.length > visiblePaths.length
|
||||
@@ -1835,8 +1845,10 @@ function readAggregateLine(group: ConsoleLine[]): ConsoleLine {
|
||||
source: "event",
|
||||
streaming: inProgress,
|
||||
error: hasError,
|
||||
attachments,
|
||||
toolCall: {
|
||||
...calls[0]!,
|
||||
attachments,
|
||||
state: hasError ? "error" : inProgress ? "running" : "done",
|
||||
isError: hasError,
|
||||
},
|
||||
@@ -2361,6 +2373,7 @@ function applySessionEntry(
|
||||
summary: value["summary"],
|
||||
content: value["content"],
|
||||
is_error: value["is_error"],
|
||||
attachments: value["attachments"],
|
||||
});
|
||||
break;
|
||||
case "system_item": {
|
||||
@@ -2509,6 +2522,10 @@ function applyLoggedItem(
|
||||
summary: stringField(item, "summary") ?? "",
|
||||
output: stringField(item, "content"),
|
||||
isError: item["is_error"] === true,
|
||||
attachments: arrayField(
|
||||
item,
|
||||
"attachments",
|
||||
) as SessionToolAttachment[],
|
||||
},
|
||||
);
|
||||
break;
|
||||
|
||||
@@ -64,7 +64,12 @@ Deno.test("defaultWorkerLaunchForm preserves an available Ticket role profile",
|
||||
...options,
|
||||
profiles: [
|
||||
...options.profiles,
|
||||
{ id: "builtin:reviewer", label: "Reviewer", description: "review" },
|
||||
{
|
||||
id: "builtin:reviewer",
|
||||
label: "Reviewer",
|
||||
description: "review",
|
||||
feature_connections: { subjektiv: true },
|
||||
},
|
||||
],
|
||||
};
|
||||
const form = defaultWorkerLaunchForm(
|
||||
@@ -195,10 +200,27 @@ Deno.test("buildCreateWorkspaceWorkerRequest emits multiple aliased attachments"
|
||||
relative_cwd: null,
|
||||
},
|
||||
],
|
||||
feature_connections: {},
|
||||
control_operation_id: null,
|
||||
});
|
||||
});
|
||||
|
||||
Deno.test("buildCreateWorkspaceWorkerRequest keeps subject attachment explicit and separate from Profile", () => {
|
||||
const unattached = buildCreateWorkspaceWorkerRequest(emptyForm({
|
||||
profile: "builtin:companion",
|
||||
}));
|
||||
assertEquals(unattached.feature_connections, {});
|
||||
|
||||
const attached = buildCreateWorkspaceWorkerRequest(emptyForm({
|
||||
profile: "builtin:companion",
|
||||
subjektiv_subject_id: " subject-42 ",
|
||||
}));
|
||||
assertEquals(attached.feature_connections, {
|
||||
subjektiv: { subject_id: "subject-42" },
|
||||
});
|
||||
assertEquals(attached.profile, "builtin:companion");
|
||||
});
|
||||
|
||||
Deno.test("buildCreateWorkspaceWorkerRequest validates attachment aliases and selections", () => {
|
||||
assertEquals(
|
||||
workerLaunchAttachmentError([attachment("invalid/alias", "wd-1-repo")]),
|
||||
@@ -268,6 +290,7 @@ Deno.test("buildCreateWorkspaceWorkerRequest emits an empty attachment list for
|
||||
ticket_assignment: null,
|
||||
initial_submit: [{ kind: "text", content: "chat" }],
|
||||
workdir_attachments: [],
|
||||
feature_connections: {},
|
||||
control_operation_id: null,
|
||||
});
|
||||
});
|
||||
@@ -280,6 +303,7 @@ function emptyForm(
|
||||
runtime_id: "",
|
||||
display_name: "",
|
||||
profile: "",
|
||||
subjektiv_subject_id: "",
|
||||
initial_text: "",
|
||||
workdir_attachments: [],
|
||||
working_directory_repository_key: "",
|
||||
@@ -340,8 +364,18 @@ const options: WorkerLaunchOptionsResponse = {
|
||||
],
|
||||
default_profile: "builtin:coder",
|
||||
profiles: [
|
||||
{ id: "builtin:companion", label: "Companion", description: "chat" },
|
||||
{ id: "builtin:coder", label: "Coder", description: "code" },
|
||||
{
|
||||
id: "builtin:companion",
|
||||
label: "Companion",
|
||||
description: "chat",
|
||||
feature_connections: { subjektiv: true },
|
||||
},
|
||||
{
|
||||
id: "builtin:coder",
|
||||
label: "Coder",
|
||||
description: "code",
|
||||
feature_connections: { subjektiv: true },
|
||||
},
|
||||
],
|
||||
repositories: [
|
||||
{ repository_key: "repo", default_selector: "HEAD" },
|
||||
|
||||
@@ -16,6 +16,7 @@ export type WorkerLaunchFormState = {
|
||||
runtime_id: string;
|
||||
display_name: string;
|
||||
profile: string;
|
||||
subjektiv_subject_id: string;
|
||||
initial_text: string;
|
||||
workdir_attachments: WorkerLaunchAttachmentFormState[];
|
||||
working_directory_repository_key: string;
|
||||
@@ -103,6 +104,7 @@ export function defaultWorkerLaunchForm(
|
||||
options?.profiles.some((candidate) => candidate.id === current.profile)
|
||||
? current.profile
|
||||
: preferredProfile?.id || "",
|
||||
subjektiv_subject_id: current.subjektiv_subject_id,
|
||||
initial_text: current.initial_text,
|
||||
workdir_attachments: workdirAttachments,
|
||||
working_directory_repository_key:
|
||||
@@ -179,6 +181,9 @@ export function buildCreateWorkspaceWorkerRequest(
|
||||
? [{ kind: "text", content: form.initial_text }]
|
||||
: [],
|
||||
workdir_attachments: validatedAttachments(form.workdir_attachments),
|
||||
feature_connections: form.subjektiv_subject_id.trim()
|
||||
? { subjektiv: { subject_id: form.subjektiv_subject_id.trim() } }
|
||||
: {},
|
||||
control_operation_id: null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -184,6 +184,68 @@
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.worker-feature-connection {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
min-width: 0;
|
||||
border-left: 2px solid var(--accent);
|
||||
padding-left: var(--space-3);
|
||||
}
|
||||
.worker-feature-connection-heading {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.worker-feature-connection-heading > div {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
.worker-feature-connection-heading h3,
|
||||
.worker-feature-connection-heading p,
|
||||
.worker-feature-connection-selection,
|
||||
.worker-feature-connection-state,
|
||||
.worker-feature-connection-note {
|
||||
margin: 0;
|
||||
}
|
||||
.worker-feature-connection-heading h3 {
|
||||
color: var(--text-strong);
|
||||
font-size: var(--font-size-body);
|
||||
}
|
||||
.worker-feature-connection-heading p,
|
||||
.worker-feature-connection-note,
|
||||
.worker-feature-connection-state {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-compact);
|
||||
}
|
||||
.worker-feature-connection-heading > span {
|
||||
flex: 0 0 auto;
|
||||
color: var(--accent);
|
||||
font-size: var(--font-size-compact);
|
||||
font-weight: 700;
|
||||
}
|
||||
.worker-feature-connection-selection {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
min-width: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-compact);
|
||||
}
|
||||
.worker-feature-connection-selection code {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.worker-feature-connection-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.worker-feature-connection-state.is-error {
|
||||
color: var(--danger);
|
||||
}
|
||||
.worker-subject-load-more {
|
||||
justify-self: start;
|
||||
}
|
||||
.worker-new-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -213,6 +275,14 @@
|
||||
.worker-attachment-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.worker-feature-connection-heading {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.worker-feature-connection-heading > span {
|
||||
align-self: start;
|
||||
}
|
||||
.worker-attachment-remove {
|
||||
justify-self: start;
|
||||
margin-bottom: 0;
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
RewindTarget,
|
||||
Segment,
|
||||
SessionHistoryPage,
|
||||
SessionToolAttachment,
|
||||
} from "#lib/generated/protocol.ts";
|
||||
import {
|
||||
MAX_FILES_PER_SUBMISSION,
|
||||
@@ -219,6 +220,17 @@
|
||||
const selectedHistory = $derived(
|
||||
historyByView[selectedHistoryKey] ?? emptyConsoleHistoryState(),
|
||||
);
|
||||
const selectedAttachmentSessionId = $derived(
|
||||
selectedWorkerView.sessionId ?? selectedHistory.sessionId,
|
||||
);
|
||||
function attachmentUrl(attachment: SessionToolAttachment): string | null {
|
||||
if (!runtimeId || !workerId || !selectedAttachmentSessionId) return null;
|
||||
return workerApiPath(
|
||||
`runtimes/${encodeURIComponent(runtimeId)}/workers/${encodeURIComponent(workerId)}` +
|
||||
`/sessions/${encodeURIComponent(selectedAttachmentSessionId)}` +
|
||||
`/attachments/${encodeURIComponent(attachment.attachment_id)}`,
|
||||
);
|
||||
}
|
||||
const committedHistoryLines = $derived(
|
||||
projectSessionHistoryEntries(
|
||||
historyEntries(selectedHistory),
|
||||
@@ -1755,7 +1767,7 @@
|
||||
{#if (consoleDisplayState.kind === "ready" || consoleDisplayState.kind === "stale") && lines.length > 0}
|
||||
<ol class="console-log">
|
||||
{#each lines as item (item.id)}
|
||||
<ConsoleLineItem {item} />
|
||||
<ConsoleLineItem {item} {attachmentUrl} />
|
||||
{/each}
|
||||
</ol>
|
||||
{/if}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
parseWorkerApiError,
|
||||
parseWorkerLaunchOptionsResponse,
|
||||
} from '#lib/workspace/api/workers.ts';
|
||||
import { parseSubjektivSubjectListResponse } from '#lib/workspace/memory/api.ts';
|
||||
import { formatCurrentWorkdirRevision } from '#lib/workspace/settings/workdir-revision.ts';
|
||||
import { formatExternalGrantPermissionLevel } from '#lib/workspace/settings/workdir-permissions.ts';
|
||||
import {
|
||||
@@ -24,6 +25,7 @@
|
||||
WorkerLaunchOptionsResponse,
|
||||
WorkingDirectorySummary,
|
||||
} from '#lib/workspace/sidebar/types.ts';
|
||||
import type { SubjektivSubjectResponse } from '#lib/generated/memory-api.ts';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
type DisplayError = {
|
||||
@@ -69,6 +71,14 @@
|
||||
: 'builtin:coder'
|
||||
: '',
|
||||
);
|
||||
let subjektivSubjectId = $state('');
|
||||
let subjects = $state<SubjektivSubjectResponse[]>([]);
|
||||
let subjectsLoading = $state(false);
|
||||
let subjectsLoaded = $state(false);
|
||||
let subjectsError = $state<string | null>(null);
|
||||
let subjectsNextCursor = $state<string | null>(null);
|
||||
let subjectsHaveMore = $state(false);
|
||||
let previousProfile = $state('');
|
||||
let initialText = $state(ticketContext?.initialInput ?? '');
|
||||
let workdirAttachments = $state<WorkerLaunchAttachmentFormState[]>([]);
|
||||
let workingDirectoryDisplayName = $state('');
|
||||
@@ -80,6 +90,10 @@
|
||||
);
|
||||
let isNewWorkingDirectorySelected = $derived(newWorkingDirectoryAttachmentIndex >= 0);
|
||||
let selectedRuntime = $derived(options?.runtimes.find((runtime) => runtime.runtime_id === runtimeId));
|
||||
let selectedProfile = $derived(options?.profiles.find((candidate) => candidate.id === profile));
|
||||
let subjektivConnectionAvailable = $derived(selectedProfile?.feature_connections.subjektiv === true);
|
||||
let activeSubjects = $derived(subjects.filter((subject) => subject.state === 'active'));
|
||||
let selectedSubject = $derived(activeSubjects.find((subject) => subject.id === subjektivSubjectId));
|
||||
let selectedRuntimeAllowsNoWorkdir = $derived(selectedRuntime?.working_directory_required === false);
|
||||
let availableWorkingDirectories = $derived(
|
||||
selectedRuntimeAllowsNoWorkdir
|
||||
@@ -130,6 +144,16 @@
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (profile !== previousProfile) {
|
||||
subjektivSubjectId = '';
|
||||
previousProfile = profile;
|
||||
}
|
||||
if (subjektivConnectionAvailable && !subjectsLoaded && !subjectsLoading) {
|
||||
void loadSubjects(false);
|
||||
}
|
||||
});
|
||||
|
||||
function nextAttachmentAlias(): string {
|
||||
const aliases = new Set(workdirAttachments.map((attachment) => attachment.alias.trim()));
|
||||
if (!aliases.has('workdir')) return 'workdir';
|
||||
@@ -178,6 +202,7 @@
|
||||
runtime_id: runtimeId,
|
||||
display_name: displayName,
|
||||
profile,
|
||||
subjektiv_subject_id: subjektivSubjectId,
|
||||
initial_text: initialText,
|
||||
workdir_attachments: workdirAttachments,
|
||||
working_directory_repository_key: workingDirectoryRepositoryKey,
|
||||
@@ -186,6 +211,7 @@
|
||||
runtimeId = form.runtime_id;
|
||||
displayName = form.display_name;
|
||||
profile = form.profile;
|
||||
subjektivSubjectId = form.subjektiv_subject_id;
|
||||
const runtimeRequiresWorkdir = payload.runtimes.find((runtime) =>
|
||||
runtime.runtime_id === form.runtime_id
|
||||
)?.working_directory_required !== false;
|
||||
@@ -215,6 +241,45 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSubjects(append: boolean) {
|
||||
if (!workspaceId || subjectsLoading) return;
|
||||
const cursor = append ? subjectsNextCursor : null;
|
||||
if (append && !cursor) return;
|
||||
subjectsLoading = true;
|
||||
subjectsLoaded = true;
|
||||
subjectsError = null;
|
||||
try {
|
||||
const query = new URLSearchParams({ limit: '100' });
|
||||
if (cursor) query.set('cursor', cursor);
|
||||
const response = await fetch(workerApiPath(`/subjektiv/subjects?${query}`));
|
||||
if (!response.ok) {
|
||||
throw new Error(`subject list failed (${response.status})`);
|
||||
}
|
||||
const payload = parseSubjektivSubjectListResponse(
|
||||
await readBoundedJson(response, 8 * 1024 * 1024),
|
||||
);
|
||||
if (append) {
|
||||
const byId = new Map(subjects.map((subject) => [subject.id, subject]));
|
||||
for (const subject of payload.items) byId.set(subject.id, subject);
|
||||
subjects = [...byId.values()];
|
||||
} else {
|
||||
subjects = payload.items;
|
||||
}
|
||||
subjectsNextCursor = payload.next_cursor ?? null;
|
||||
subjectsHaveMore = payload.has_more;
|
||||
if (
|
||||
subjektivSubjectId &&
|
||||
!subjects.some((subject) => subject.id === subjektivSubjectId && subject.state === 'active')
|
||||
) {
|
||||
subjektivSubjectId = '';
|
||||
}
|
||||
} catch (err) {
|
||||
subjectsError = err instanceof Error ? err.message : 'subject list failed';
|
||||
} finally {
|
||||
subjectsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function createWorkingDirectory() {
|
||||
const attachmentIndex = newWorkingDirectoryAttachmentIndex;
|
||||
if (attachmentIndex < 0) {
|
||||
@@ -304,6 +369,7 @@
|
||||
runtime_id: runtimeId,
|
||||
display_name: displayName,
|
||||
profile,
|
||||
subjektiv_subject_id: subjektivSubjectId,
|
||||
initial_text: initialText,
|
||||
workdir_attachments: workdirAttachments,
|
||||
working_directory_repository_key: workingDirectoryRepositoryKey,
|
||||
@@ -511,6 +577,55 @@
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#if subjektivConnectionAvailable}
|
||||
<div class="worker-feature-connection" data-worker-feature-connection="subjektiv">
|
||||
<div class="worker-feature-connection-heading">
|
||||
<div>
|
||||
<h3>Subject Memory</h3>
|
||||
<p>Optionally connect this Worker to one existing active subject.</p>
|
||||
</div>
|
||||
<span>Optional</span>
|
||||
</div>
|
||||
<label>
|
||||
<span>Subject</span>
|
||||
<select bind:value={subjektivSubjectId} aria-label="Subject Memory connection">
|
||||
<option value="">Do not connect a subject</option>
|
||||
{#each activeSubjects as subject (subject.id)}
|
||||
<option value={subject.id}>{subject.role} · {subject.id}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
{#if selectedSubject}
|
||||
<p class="worker-feature-connection-selection">
|
||||
<strong>{selectedSubject.role}</strong>
|
||||
<code title={selectedSubject.id}>{selectedSubject.id}</code>
|
||||
{#if selectedSubject.current_worker}
|
||||
<span>Current Worker: {selectedSubject.current_worker.display_name}</span>
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
{#if subjectsLoading && subjects.length === 0}
|
||||
<p class="worker-feature-connection-state" role="status">Loading authorized subjects…</p>
|
||||
{:else if subjectsError}
|
||||
<div class="worker-feature-connection-state is-error" role="alert">
|
||||
<span>{subjectsError}</span>
|
||||
<button type="button" class="secondary-button" onclick={() => void loadSubjects(false)}>Retry</button>
|
||||
</div>
|
||||
{:else if subjectsLoaded && activeSubjects.length === 0}
|
||||
<p class="worker-feature-connection-state" role="status">No active subjects are available.</p>
|
||||
{/if}
|
||||
{#if subjectsHaveMore}
|
||||
<button
|
||||
type="button"
|
||||
class="secondary-button worker-subject-load-more"
|
||||
disabled={subjectsLoading}
|
||||
onclick={() => void loadSubjects(true)}
|
||||
>{subjectsLoading ? 'Loading…' : 'Load more subjects'}</button>
|
||||
{/if}
|
||||
<p class="worker-feature-connection-note">Profile enablement does not connect a subject by itself.</p>
|
||||
</div>
|
||||
{/if}
|
||||
<label>
|
||||
<span>Initial text</span>
|
||||
<textarea bind:value={initialText} rows="7" placeholder="Optional first instruction"></textarea>
|
||||
|
||||
Reference in New Issue
Block a user