@dsh-workbench/shared 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/blocker-table.d.ts +9 -0
- package/dist/api/blocker-table.js +130 -0
- package/dist/api/blocker.d.ts +30 -0
- package/dist/api/blocker.js +1 -0
- package/dist/api/context-ref.d.ts +10 -0
- package/dist/api/context-ref.js +40 -0
- package/dist/api/dsh-client.d.ts +170 -0
- package/dist/api/dsh-client.js +1 -0
- package/dist/api/dsh.d.ts +153 -0
- package/dist/api/dsh.js +1 -0
- package/dist/api/facts.d.ts +27 -0
- package/dist/api/facts.js +146 -0
- package/dist/api/host-write.d.ts +12 -0
- package/dist/api/host-write.js +17 -0
- package/dist/api/long-task.d.ts +18 -0
- package/dist/api/long-task.js +22 -0
- package/dist/api/unit-board.d.ts +14 -0
- package/dist/api/unit-board.js +29 -0
- package/dist/api/unit.d.ts +54 -0
- package/dist/api/unit.js +1 -0
- package/dist/api/workbench-client.d.ts +19 -0
- package/dist/api/workbench-client.js +33 -0
- package/dist/api/workbench-contract.d.ts +32 -0
- package/dist/api/workbench-contract.js +1 -0
- package/dist/components/ApprovalDetail.d.ts +27 -0
- package/dist/components/ApprovalDetail.js +33 -0
- package/dist/components/CardHead.d.ts +16 -0
- package/dist/components/CardHead.js +14 -0
- package/dist/components/ChoiceGroup.d.ts +14 -0
- package/dist/components/ChoiceGroup.js +7 -0
- package/dist/components/LongTaskRow.d.ts +16 -0
- package/dist/components/LongTaskRow.js +28 -0
- package/dist/components/MockStrip.d.ts +14 -0
- package/dist/components/MockStrip.js +7 -0
- package/dist/components/ResourceStates.d.ts +18 -0
- package/dist/components/ResourceStates.js +17 -0
- package/dist/components/SaveBar.d.ts +12 -0
- package/dist/components/SaveBar.js +6 -0
- package/dist/components/StateDot.d.ts +5 -0
- package/dist/components/StateDot.js +10 -0
- package/dist/components/board-module.d.ts +67 -0
- package/dist/components/board-module.js +1 -0
- package/dist/components/icons.d.ts +18 -0
- package/dist/components/icons.js +81 -0
- package/dist/config/constants.d.ts +27 -0
- package/dist/config/constants.js +27 -0
- package/dist/config/storage.d.ts +4 -0
- package/dist/config/storage.js +27 -0
- package/dist/host/__tests__/fakes.d.ts +41 -0
- package/dist/host/__tests__/fakes.js +76 -0
- package/dist/host/__tests__/preflight-fixtures.d.ts +19 -0
- package/dist/host/__tests__/preflight-fixtures.js +47 -0
- package/dist/host/cli-error.d.ts +8 -0
- package/dist/host/cli-error.js +70 -0
- package/dist/host/context-inject.d.ts +24 -0
- package/dist/host/context-inject.js +74 -0
- package/dist/host/data-root.d.ts +3 -0
- package/dist/host/data-root.js +12 -0
- package/dist/host/events.d.ts +9 -0
- package/dist/host/events.js +70 -0
- package/dist/host/facts.d.ts +24 -0
- package/dist/host/facts.js +44 -0
- package/dist/host/git.d.ts +55 -0
- package/dist/host/git.js +64 -0
- package/dist/host/http.d.ts +36 -0
- package/dist/host/http.js +242 -0
- package/dist/host/long-task.d.ts +58 -0
- package/dist/host/long-task.js +148 -0
- package/dist/host/mount-units.d.ts +52 -0
- package/dist/host/mount-units.js +186 -0
- package/dist/host/opencli-runtime.d.ts +22 -0
- package/dist/host/opencli-runtime.js +46 -0
- package/dist/host/opencli.d.ts +37 -0
- package/dist/host/opencli.js +152 -0
- package/dist/host/plugin-source.d.ts +31 -0
- package/dist/host/plugin-source.js +81 -0
- package/dist/host/preflight.d.ts +58 -0
- package/dist/host/preflight.js +233 -0
- package/dist/host/probe-cache.d.ts +6 -0
- package/dist/host/probe-cache.js +19 -0
- package/dist/host/probe-libs.d.ts +45 -0
- package/dist/host/probe-libs.js +61 -0
- package/dist/host/probe-units.d.ts +17 -0
- package/dist/host/probe-units.js +102 -0
- package/dist/host/probe-verdict.d.ts +7 -0
- package/dist/host/probe-verdict.js +143 -0
- package/dist/host/provision.d.ts +14 -0
- package/dist/host/provision.js +12 -0
- package/dist/host/shell.d.ts +14 -0
- package/dist/host/shell.js +23 -0
- package/dist/host/tool-approval.d.ts +29 -0
- package/dist/host/tool-approval.js +190 -0
- package/dist/host/tool-kit.d.ts +19 -0
- package/dist/host/tool-kit.js +37 -0
- package/dist/host/tool-scope.d.ts +32 -0
- package/dist/host/tool-scope.js +84 -0
- package/dist/host/unit-face.d.ts +66 -0
- package/dist/host/unit-face.js +1 -0
- package/dist/host/unit-libs.d.ts +37 -0
- package/dist/host/unit-libs.js +1 -0
- package/dist/host/unit-readiness.d.ts +7 -0
- package/dist/host/unit-readiness.js +5 -0
- package/dist/host/unit-registry.d.ts +9 -0
- package/dist/host/unit-registry.js +33 -0
- package/dist/host/value-handoff.d.ts +16 -0
- package/dist/host/value-handoff.js +21 -0
- package/dist/host/version-range.d.ts +2 -0
- package/dist/host/version-range.js +73 -0
- package/dist/host/workspace-store.d.ts +19 -0
- package/dist/host/workspace-store.js +133 -0
- package/dist/host/write-result.d.ts +1 -0
- package/dist/host/write-result.js +18 -0
- package/dist/host.d.ts +19 -0
- package/dist/host.js +19 -0
- package/dist/i18n/en.d.ts +2 -0
- package/dist/i18n/en.js +162 -0
- package/dist/i18n/index.d.ts +172 -0
- package/dist/i18n/index.js +3 -0
- package/dist/i18n/merge.d.ts +6 -0
- package/dist/i18n/merge.js +40 -0
- package/dist/i18n/zh.d.ts +163 -0
- package/dist/i18n/zh.js +162 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.js +33 -0
- package/dist/lib/blocker-action.d.ts +13 -0
- package/dist/lib/blocker-action.js +12 -0
- package/dist/lib/busy.d.ts +5 -0
- package/dist/lib/busy.js +7 -0
- package/dist/lib/react.d.ts +10 -0
- package/dist/lib/react.js +9 -0
- package/dist/lib/text.d.ts +4 -0
- package/dist/lib/text.js +48 -0
- package/dist/lib/ui.d.ts +11 -0
- package/dist/lib/ui.js +37 -0
- package/package.json +44 -0
- package/src/api/__tests__/blocker-table.test.ts +253 -0
- package/src/api/__tests__/facts.test.ts +274 -0
- package/src/api/__tests__/host-write.test.ts +55 -0
- package/src/api/__tests__/long-task.test.ts +52 -0
- package/src/api/__tests__/unit-board.test.ts +76 -0
- package/src/api/blocker-table.ts +155 -0
- package/src/api/blocker.ts +25 -0
- package/src/api/context-ref.ts +47 -0
- package/src/api/dsh-client.ts +176 -0
- package/src/api/dsh.ts +156 -0
- package/src/api/facts.ts +166 -0
- package/src/api/host-write.ts +27 -0
- package/src/api/long-task.ts +38 -0
- package/src/api/unit-board.ts +44 -0
- package/src/api/unit.ts +59 -0
- package/src/api/workbench-client.test.ts +92 -0
- package/src/api/workbench-client.ts +59 -0
- package/src/api/workbench-contract.ts +40 -0
- package/src/components/ApprovalDetail.tsx +57 -0
- package/src/components/CardHead.tsx +74 -0
- package/src/components/ChoiceGroup.tsx +38 -0
- package/src/components/LongTaskRow.tsx +65 -0
- package/src/components/MockStrip.tsx +40 -0
- package/src/components/ResourceStates.tsx +44 -0
- package/src/components/SaveBar.tsx +31 -0
- package/src/components/StateDot.tsx +31 -0
- package/src/components/board-module.ts +73 -0
- package/src/components/icons.tsx +232 -0
- package/src/config/constants.test.ts +41 -0
- package/src/config/constants.ts +52 -0
- package/src/config/storage.test.ts +82 -0
- package/src/config/storage.ts +25 -0
- package/src/host/__tests__/fakes.ts +116 -0
- package/src/host/__tests__/opencli-runtime.test.ts +46 -0
- package/src/host/cli-error.ts +77 -0
- package/src/host/data-root.ts +13 -0
- package/src/host/facts.ts +69 -0
- package/src/host/git.ts +99 -0
- package/src/host/http.ts +311 -0
- package/src/host/long-task.ts +235 -0
- package/src/host/opencli-runtime.ts +66 -0
- package/src/host/opencli.ts +210 -0
- package/src/host/plugin-source.ts +145 -0
- package/src/host/provision.ts +34 -0
- package/src/host/shell.ts +42 -0
- package/src/host/tool-approval.ts +243 -0
- package/src/host/tool-kit.ts +56 -0
- package/src/host/unit-face.ts +65 -0
- package/src/host/unit-libs.ts +41 -0
- package/src/host/value-handoff.ts +38 -0
- package/src/host/workspace-store.ts +161 -0
- package/src/host/write-result.ts +16 -0
- package/src/host.ts +19 -0
- package/src/i18n/en.ts +170 -0
- package/src/i18n/index.test.ts +15 -0
- package/src/i18n/index.ts +12 -0
- package/src/i18n/merge.test.ts +60 -0
- package/src/i18n/merge.ts +54 -0
- package/src/i18n/zh.test.ts +40 -0
- package/src/i18n/zh.ts +170 -0
- package/src/index.ts +33 -0
- package/src/lib/blocker-action.test.ts +40 -0
- package/src/lib/blocker-action.ts +28 -0
- package/src/lib/busy.test.ts +27 -0
- package/src/lib/busy.ts +11 -0
- package/src/lib/react.test.ts +26 -0
- package/src/lib/react.ts +16 -0
- package/src/lib/text.test.ts +92 -0
- package/src/lib/text.ts +46 -0
- package/src/lib/ui.test.ts +93 -0
- package/src/lib/ui.ts +38 -0
package/src/api/facts.ts
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
export interface FactFrame {
|
|
2
|
+
readonly epoch?: string;
|
|
3
|
+
readonly rev: number;
|
|
4
|
+
readonly facts: readonly string[];
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export const FACT_SCOPE_SUFFIX = '*';
|
|
8
|
+
|
|
9
|
+
export function unitFactScope(unit: string): string {
|
|
10
|
+
return `${unit}${FACT_SCOPE_SUFFIX}`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function isFactScope(name: string): boolean {
|
|
14
|
+
return name.length > 1 && name.endsWith(FACT_SCOPE_SUFFIX);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function scopePrefixOf(name: string): string {
|
|
18
|
+
return isFactScope(name) ? name.slice(0, -FACT_SCOPE_SUFFIX.length) : name;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface FactTransport {
|
|
22
|
+
start(handlers: { readonly onFrame: (frame: FactFrame) => void; readonly cursor: () => number }): () => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface FactChannel {
|
|
26
|
+
subscribe(fact: string, listener: () => void): () => void;
|
|
27
|
+
revisionOf(fact: string): number;
|
|
28
|
+
refresh(fact?: string): void;
|
|
29
|
+
applyFrame(frame: FactFrame): void;
|
|
30
|
+
subscriberCount(): number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface FactEntry {
|
|
34
|
+
rev: number;
|
|
35
|
+
readonly listeners: Set<() => void>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function createFactChannel(options: { readonly transport: FactTransport }): FactChannel {
|
|
39
|
+
const entries = new Map<string, FactEntry>();
|
|
40
|
+
let epoch: string | null = null;
|
|
41
|
+
let stopTransport: (() => void) | null = null;
|
|
42
|
+
let hostRev = 0;
|
|
43
|
+
let generation = 0;
|
|
44
|
+
|
|
45
|
+
const entryOf = (fact: string): FactEntry => {
|
|
46
|
+
const existing = entries.get(fact);
|
|
47
|
+
if (existing !== undefined) return existing;
|
|
48
|
+
const created: FactEntry = { rev: 0, listeners: new Set() };
|
|
49
|
+
entries.set(fact, created);
|
|
50
|
+
return created;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const targetsOf = (fact: string): FactEntry[] => {
|
|
54
|
+
if (!isFactScope(fact)) {
|
|
55
|
+
const entry = entries.get(fact);
|
|
56
|
+
return entry === undefined ? [] : [entry];
|
|
57
|
+
}
|
|
58
|
+
const prefix = scopePrefixOf(fact);
|
|
59
|
+
const matched: FactEntry[] = [];
|
|
60
|
+
for (const [name, entry] of entries) {
|
|
61
|
+
if (name === prefix || name.startsWith(`${prefix}/`)) matched.push(entry);
|
|
62
|
+
}
|
|
63
|
+
return matched;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const mark = (fact: string, rev: number): void => {
|
|
67
|
+
if (!isFactScope(fact)) {
|
|
68
|
+
entryOf(fact).rev = rev;
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
for (const entry of targetsOf(fact)) entry.rev = rev;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const notify = (facts: Iterable<string>): void => {
|
|
75
|
+
for (const fact of facts) {
|
|
76
|
+
for (const entry of targetsOf(fact)) {
|
|
77
|
+
for (const listener of [...entry.listeners]) listener();
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const ensureTransport = (): void => {
|
|
83
|
+
if (stopTransport !== null) return;
|
|
84
|
+
stopTransport = options.transport.start({
|
|
85
|
+
onFrame: (frame) => channel.applyFrame(frame),
|
|
86
|
+
cursor: () => hostRev,
|
|
87
|
+
});
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const releaseTransport = (): void => {
|
|
91
|
+
if (stopTransport === null) return;
|
|
92
|
+
stopTransport();
|
|
93
|
+
stopTransport = null;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const channel: FactChannel = {
|
|
97
|
+
revisionOf: (fact) => entries.get(fact)?.rev ?? 0,
|
|
98
|
+
subscribe(fact, listener) {
|
|
99
|
+
const entry = entryOf(fact);
|
|
100
|
+
entry.listeners.add(listener);
|
|
101
|
+
ensureTransport();
|
|
102
|
+
return () => {
|
|
103
|
+
entry.listeners.delete(listener);
|
|
104
|
+
if (channel.subscriberCount() === 0) releaseTransport();
|
|
105
|
+
};
|
|
106
|
+
},
|
|
107
|
+
subscriberCount() {
|
|
108
|
+
let count = 0;
|
|
109
|
+
for (const entry of entries.values()) count += entry.listeners.size;
|
|
110
|
+
return count;
|
|
111
|
+
},
|
|
112
|
+
refresh(fact) {
|
|
113
|
+
generation += 1;
|
|
114
|
+
if (fact === undefined) {
|
|
115
|
+
for (const entry of entries.values()) entry.rev = generation;
|
|
116
|
+
notify(entries.keys());
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
mark(fact, generation);
|
|
120
|
+
notify([fact]);
|
|
121
|
+
},
|
|
122
|
+
applyFrame(frame) {
|
|
123
|
+
const nextEpoch = frame.epoch ?? null;
|
|
124
|
+
const first = epoch === null;
|
|
125
|
+
const epochChanged = epoch !== null && nextEpoch !== null && epoch !== nextEpoch;
|
|
126
|
+
if (nextEpoch !== null) epoch = nextEpoch;
|
|
127
|
+
hostRev = epochChanged ? frame.rev : Math.max(hostRev, frame.rev);
|
|
128
|
+
if (first) {
|
|
129
|
+
generation += 1;
|
|
130
|
+
for (const fact of frame.facts) mark(fact, generation);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (epochChanged) {
|
|
134
|
+
generation += 1;
|
|
135
|
+
for (const entry of entries.values()) entry.rev = generation;
|
|
136
|
+
notify(entries.keys());
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (frame.facts.length === 0) return;
|
|
140
|
+
generation += 1;
|
|
141
|
+
for (const fact of frame.facts) mark(fact, generation);
|
|
142
|
+
notify(frame.facts);
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
return channel;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function parseFactFrame(data: unknown): FactFrame | null {
|
|
150
|
+
if (typeof data !== 'string') return null;
|
|
151
|
+
try {
|
|
152
|
+
const parsed = JSON.parse(data) as { epoch?: unknown; rev?: unknown; facts?: unknown };
|
|
153
|
+
if (typeof parsed.rev !== 'number') return null;
|
|
154
|
+
return {
|
|
155
|
+
...(typeof parsed.epoch === 'string' ? { epoch: parsed.epoch } : {}),
|
|
156
|
+
rev: parsed.rev,
|
|
157
|
+
facts: Array.isArray(parsed.facts) ? parsed.facts.filter((f): f is string => typeof f === 'string') : [],
|
|
158
|
+
};
|
|
159
|
+
} catch {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function factOfRoute(route: string): string {
|
|
165
|
+
return route;
|
|
166
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { ApiResponse } from './workbench-client';
|
|
2
|
+
|
|
3
|
+
export type WriteChannel = 'prefs' | 'actions' | 'provision';
|
|
4
|
+
|
|
5
|
+
export function writeRoute(channel: WriteChannel, resource: string): string {
|
|
6
|
+
if (channel === 'provision') return 'provision';
|
|
7
|
+
return `${channel}/${resource}`;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type WriteOutcome<T> =
|
|
11
|
+
| { readonly ok: true; readonly value: T }
|
|
12
|
+
| { readonly ok: false; readonly message: string; readonly detail?: string };
|
|
13
|
+
|
|
14
|
+
export function writeOutcomeOf<T>(response: ApiResponse<T> | null, networkText: string): WriteOutcome<T> {
|
|
15
|
+
if (response === null) return { ok: false, message: networkText };
|
|
16
|
+
if (!response.ok) {
|
|
17
|
+
const box = response as {
|
|
18
|
+
readonly error?: { readonly message?: unknown; readonly detail?: unknown } | undefined;
|
|
19
|
+
readonly text?: unknown;
|
|
20
|
+
};
|
|
21
|
+
const own = typeof box.error?.message === 'string' && box.error.message !== '' ? box.error.message : undefined;
|
|
22
|
+
const fallback = typeof box.text === 'string' && box.text !== '' ? box.text : networkText;
|
|
23
|
+
const detail = typeof box.error?.detail === 'string' ? box.error.detail : undefined;
|
|
24
|
+
return { ok: false, message: own ?? fallback, ...(detail === undefined ? {} : { detail }) };
|
|
25
|
+
}
|
|
26
|
+
return { ok: true, value: response };
|
|
27
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export type LongTaskStatus = 'running' | 'needs-login' | 'ok' | 'failed';
|
|
2
|
+
|
|
3
|
+
export interface LongTaskStep {
|
|
4
|
+
readonly kind: 'start' | 'succeed' | 'fail' | 'info' | 'warn';
|
|
5
|
+
readonly text: string;
|
|
6
|
+
readonly loginUrl?: string | undefined;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function runsRouteOf(unit: string): string {
|
|
10
|
+
return `${unit}/runs`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface LongTasksPayload {
|
|
14
|
+
readonly runs: readonly Readonly<Record<string, unknown>>[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const LONG_TASK_POLL_MS = 1500;
|
|
18
|
+
|
|
19
|
+
export const LONG_TASK_IDLE_MS = 10_000;
|
|
20
|
+
|
|
21
|
+
export function longTaskPollMs(live: boolean, override?: number | undefined): number {
|
|
22
|
+
if (override !== undefined) return override;
|
|
23
|
+
return live ? LONG_TASK_POLL_MS : LONG_TASK_IDLE_MS;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function longTaskRunsOf<T>(state: {
|
|
27
|
+
readonly phase?: string | undefined;
|
|
28
|
+
readonly value?: LongTasksPayload | undefined;
|
|
29
|
+
}): readonly T[] {
|
|
30
|
+
return (state.value?.runs ?? []) as readonly T[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function longTaskDot(status: LongTaskStatus): 'ongoing' | 'done' | 'error' | 'idle' {
|
|
34
|
+
if (status === 'ok') return 'done';
|
|
35
|
+
if (status === 'failed') return 'error';
|
|
36
|
+
if (status === 'running' || status === 'needs-login') return 'ongoing';
|
|
37
|
+
return 'idle';
|
|
38
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { ModuleBlocker } from './blocker';
|
|
2
|
+
import type { CapabilityPayload } from './workbench-contract';
|
|
3
|
+
import type { ApiError } from './workbench-client';
|
|
4
|
+
|
|
5
|
+
export type GateMode =
|
|
6
|
+
| 'capability'
|
|
7
|
+
| 'none';
|
|
8
|
+
|
|
9
|
+
export interface UnitGate {
|
|
10
|
+
readonly gated: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function gateOf(capability: CapabilityPayload | null, mode: GateMode): UnitGate {
|
|
14
|
+
if (mode === 'none') return { gated: true };
|
|
15
|
+
if (capability === null) return { gated: true };
|
|
16
|
+
return { gated: capability.status === 'ok' || capability.status === 'unchecked' };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface UnitBlockers {
|
|
20
|
+
readonly blocker?: ModuleBlocker;
|
|
21
|
+
readonly notice?: ModuleBlocker;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function splitBlocker(candidates: readonly (ModuleBlocker | undefined)[]): UnitBlockers {
|
|
25
|
+
let blocker: ModuleBlocker | undefined;
|
|
26
|
+
let notice: ModuleBlocker | undefined;
|
|
27
|
+
for (const candidate of candidates) {
|
|
28
|
+
if (candidate === undefined) continue;
|
|
29
|
+
if (candidate.severity === 'block') {
|
|
30
|
+
blocker ??= candidate;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
notice ??= candidate;
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
...(blocker === undefined ? {} : { blocker }),
|
|
37
|
+
...(blocker !== undefined || notice === undefined ? {} : { notice }),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function probeKeyOf(error: ApiError | undefined): string | null {
|
|
42
|
+
if (error === undefined) return null;
|
|
43
|
+
return error.code === '' ? 'unknown' : error.code;
|
|
44
|
+
}
|
package/src/api/unit.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
export interface UnitCard {
|
|
2
|
+
readonly titleKey: string;
|
|
3
|
+
readonly order: number;
|
|
4
|
+
readonly enabledByDefault: boolean;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface UnitTool {
|
|
8
|
+
readonly name: string;
|
|
9
|
+
readonly writes: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface UnitHostNeeds {
|
|
13
|
+
readonly inject: readonly string[];
|
|
14
|
+
readonly optional?: readonly string[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type PluginSource =
|
|
18
|
+
| { readonly kind: 'git'; readonly url: string }
|
|
19
|
+
| { readonly kind: 'local'; readonly env: string }
|
|
20
|
+
| {
|
|
21
|
+
readonly kind: 'npm';
|
|
22
|
+
readonly package: string;
|
|
23
|
+
readonly registryEnv: string;
|
|
24
|
+
readonly registryDefault?: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export interface UnitPluginNeed {
|
|
28
|
+
readonly name: string;
|
|
29
|
+
readonly commands?: readonly string[];
|
|
30
|
+
readonly installSource?: PluginSource;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface UnitLibNeed {
|
|
34
|
+
readonly name: string;
|
|
35
|
+
readonly version?: string | undefined;
|
|
36
|
+
readonly registryEnv: string;
|
|
37
|
+
readonly registryDefault: string;
|
|
38
|
+
readonly purpose: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface UnitEnvNeeds {
|
|
42
|
+
readonly command?: string;
|
|
43
|
+
readonly plugins?: readonly UnitPluginNeed[];
|
|
44
|
+
readonly libs?: readonly UnitLibNeed[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface PluginRelease {
|
|
48
|
+
readonly installed: string;
|
|
49
|
+
readonly latest?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface UnitManifest {
|
|
53
|
+
readonly id: string;
|
|
54
|
+
readonly kind: 'board' | 'ui';
|
|
55
|
+
readonly card: UnitCard | null;
|
|
56
|
+
readonly tools: readonly UnitTool[];
|
|
57
|
+
readonly host: UnitHostNeeds;
|
|
58
|
+
readonly env?: UnitEnvNeeds;
|
|
59
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { API_PREFIX } from '../config/constants.js';
|
|
3
|
+
import { apiGet } from './workbench-client';
|
|
4
|
+
|
|
5
|
+
interface FetchRecord {
|
|
6
|
+
url: string;
|
|
7
|
+
method: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function stubFetch(reply: { status?: number; body: unknown }) {
|
|
11
|
+
const records: FetchRecord[] = [];
|
|
12
|
+
const fetchMock = vi.fn(async (url: string, init?: { method?: string }) => {
|
|
13
|
+
records.push({ url, method: init?.method ?? 'GET' });
|
|
14
|
+
return {
|
|
15
|
+
ok: (reply.status ?? 200) < 400,
|
|
16
|
+
status: reply.status ?? 200,
|
|
17
|
+
json: async () => reply.body,
|
|
18
|
+
};
|
|
19
|
+
});
|
|
20
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
21
|
+
return { records, fetchMock };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
vi.unstubAllGlobals();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe('apiGet', () => {
|
|
29
|
+
it('用 GET 打 API_PREFIX 下的路由,并带上 accept 头', async () => {
|
|
30
|
+
const { records, fetchMock } = stubFetch({ body: { ok: true } });
|
|
31
|
+
await apiGet('health');
|
|
32
|
+
|
|
33
|
+
expect(records[0]?.url).toBe(`${API_PREFIX}/health`);
|
|
34
|
+
expect(records[0]?.method).toBe('GET');
|
|
35
|
+
expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({
|
|
36
|
+
method: 'GET',
|
|
37
|
+
headers: { accept: 'application/json' },
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('查询参数进 URL;undefined 的项不出现', async () => {
|
|
42
|
+
const { records } = stubFetch({ body: { ok: true } });
|
|
43
|
+
await apiGet('issues', { workspaceId: 'w1', status: 'all', refresh: undefined, n: 0 });
|
|
44
|
+
|
|
45
|
+
const [pathname, query] = (records[0]?.url ?? '').split('?');
|
|
46
|
+
const params = new URLSearchParams(query ?? '');
|
|
47
|
+
expect(pathname).toBe(`${API_PREFIX}/issues`);
|
|
48
|
+
expect(params.get('workspaceId')).toBe('w1');
|
|
49
|
+
expect(params.get('status')).toBe('all');
|
|
50
|
+
expect(params.get('n')).toBe('0');
|
|
51
|
+
expect(params.has('refresh')).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('没有参数时不带问号', async () => {
|
|
55
|
+
const { records } = stubFetch({ body: { ok: true } });
|
|
56
|
+
await apiGet('project');
|
|
57
|
+
expect(records[0]?.url).toBe(`${API_PREFIX}/project`);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('宿主的 {ok:true} 信封原样返回', async () => {
|
|
61
|
+
stubFetch({ body: { ok: true, issues: [{ code: 1 }] } });
|
|
62
|
+
const result = await apiGet<{ issues: Array<{ code: number }> }>('issues');
|
|
63
|
+
expect(result.ok).toBe(true);
|
|
64
|
+
expect(result.ok && result.issues[0]?.code).toBe(1);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('宿主的 {ok:false} 信封原样返回,不抛异常', async () => {
|
|
68
|
+
stubFetch({
|
|
69
|
+
body: { ok: false, error: { code: 'coding-not-logged-in', message: '登录态失效' } },
|
|
70
|
+
});
|
|
71
|
+
const result = await apiGet('issues');
|
|
72
|
+
expect(result.ok).toBe(false);
|
|
73
|
+
expect(result.ok === false && result.error.code).toBe('coding-not-logged-in');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('HTTP 非 2xx 也走结构化错误(不读 body)', async () => {
|
|
77
|
+
stubFetch({ status: 500, body: {} });
|
|
78
|
+
const result = await apiGet('issues');
|
|
79
|
+
expect(result.ok).toBe(false);
|
|
80
|
+
expect(result.ok === false && result.error).toMatchObject({ code: 'http' });
|
|
81
|
+
expect(result.ok === false && result.error.message).toContain('500');
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('apiGet 不带任何"刷新鲜度"的开关', () => {
|
|
86
|
+
it('请求就是请求:没有 refresh 之类的旁路参数(宿主侧也没有缓存可绕)', async () => {
|
|
87
|
+
const { records } = stubFetch({ body: { ok: true } });
|
|
88
|
+
await apiGet('issues', { status: 'all' }, { signal: undefined });
|
|
89
|
+
|
|
90
|
+
expect(records[0]?.url).not.toContain('refresh');
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { API_PREFIX } from '../config/constants.js';
|
|
2
|
+
|
|
3
|
+
export interface ApiError {
|
|
4
|
+
readonly code: string;
|
|
5
|
+
readonly message: string;
|
|
6
|
+
readonly detail?: string;
|
|
7
|
+
readonly hint?: string;
|
|
8
|
+
readonly helpUrl?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type ApiResponse<T> = ({ readonly ok: true } & T) | { readonly ok: false; readonly error: ApiError };
|
|
12
|
+
|
|
13
|
+
export type Query = Record<string, string | number | boolean | undefined>;
|
|
14
|
+
|
|
15
|
+
export interface ApiGetOptions {
|
|
16
|
+
readonly signal?: AbortSignal | undefined;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function apiGet<T>(
|
|
20
|
+
route: string,
|
|
21
|
+
query: Query = {},
|
|
22
|
+
options: ApiGetOptions = {},
|
|
23
|
+
): Promise<ApiResponse<T>> {
|
|
24
|
+
const { signal } = options;
|
|
25
|
+
const search = new URLSearchParams();
|
|
26
|
+
for (const [key, value] of Object.entries(query)) {
|
|
27
|
+
if (value !== undefined) search.set(key, String(value));
|
|
28
|
+
}
|
|
29
|
+
const suffix = search.toString();
|
|
30
|
+
|
|
31
|
+
const response = await fetch(`${API_PREFIX}/${route}${suffix ? `?${suffix}` : ''}`, {
|
|
32
|
+
method: 'GET',
|
|
33
|
+
headers: { accept: 'application/json' },
|
|
34
|
+
...(signal === undefined ? {} : { signal }),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
if (!response.ok) {
|
|
38
|
+
return {
|
|
39
|
+
ok: false,
|
|
40
|
+
error: { code: 'http', message: `宿主返回 HTTP ${response.status}` },
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return (await response.json()) as ApiResponse<T>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function apiPost<T>(route: string, body: Record<string, unknown>): Promise<ApiResponse<T>> {
|
|
48
|
+
const response = await fetch(`${API_PREFIX}/${route}`, {
|
|
49
|
+
method: 'POST',
|
|
50
|
+
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
51
|
+
body: JSON.stringify(body),
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
if (!response.ok) {
|
|
55
|
+
return { ok: false, error: { code: 'http', message: `宿主返回 HTTP ${response.status}` } };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return (await response.json()) as ApiResponse<T>;
|
|
59
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { PluginRelease } from '../api/unit.js';
|
|
2
|
+
|
|
3
|
+
export type ProvisionAction =
|
|
4
|
+
| 'install'
|
|
5
|
+
| 'update'
|
|
6
|
+
| 'uninstall'
|
|
7
|
+
| 'update-dependency';
|
|
8
|
+
|
|
9
|
+
export interface PluginStatePayload {
|
|
10
|
+
readonly name: string;
|
|
11
|
+
readonly status: 'ok' | 'outdated' | 'missing';
|
|
12
|
+
readonly missing: readonly string[];
|
|
13
|
+
readonly install?: 'ready' | 'not-declared' | 'env-missing' | 'malformed';
|
|
14
|
+
readonly command?: string;
|
|
15
|
+
readonly release?: PluginRelease;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface CapabilityPayload {
|
|
19
|
+
readonly unit: string;
|
|
20
|
+
readonly status: 'ok' | 'missing-commands' | 'missing-plugin' | 'unchecked';
|
|
21
|
+
readonly plugin?: string;
|
|
22
|
+
readonly required: readonly string[];
|
|
23
|
+
readonly missing: readonly string[];
|
|
24
|
+
readonly detail: string;
|
|
25
|
+
readonly command?: string;
|
|
26
|
+
readonly fix?: 'install' | 'update';
|
|
27
|
+
readonly plugins?: readonly PluginStatePayload[];
|
|
28
|
+
readonly released?: boolean;
|
|
29
|
+
readonly install?: 'ready' | 'not-declared' | 'env-missing' | 'malformed';
|
|
30
|
+
readonly libs?: readonly LibStatePayload[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface LibStatePayload {
|
|
34
|
+
readonly name: string;
|
|
35
|
+
readonly purpose: string;
|
|
36
|
+
readonly status: 'ok' | 'outdated' | 'missing' | 'unsatisfied';
|
|
37
|
+
readonly installed?: string;
|
|
38
|
+
readonly latest?: string;
|
|
39
|
+
readonly command?: string;
|
|
40
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { Translate } from '../api/dsh-client.js';
|
|
2
|
+
import type { ApprovalInput, UnitClientFace } from '../components/board-module.js';
|
|
3
|
+
import { h } from '../lib/react.js';
|
|
4
|
+
|
|
5
|
+
interface ToolCallRoot {
|
|
6
|
+
readonly callId?: string;
|
|
7
|
+
readonly name?: string;
|
|
8
|
+
readonly phase?: string;
|
|
9
|
+
readonly argsRaw?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface ChatSnapshot {
|
|
13
|
+
readonly nodes?: { values(): Iterable<{ readonly kind?: string; readonly data?: { readonly root?: ToolCallRoot } }> };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
type UseChat = <T>(selector: (snapshot: ChatSnapshot) => T) => T | undefined;
|
|
17
|
+
|
|
18
|
+
export interface ApprovalDetailProps {
|
|
19
|
+
readonly callId?: string;
|
|
20
|
+
readonly useChat?: UseChat;
|
|
21
|
+
readonly units?: readonly UnitClientFace[];
|
|
22
|
+
readonly t?: Translate;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function callOf(snapshot: ChatSnapshot, callId: string | undefined): { name?: string; argsRaw: string } | undefined {
|
|
26
|
+
if (callId === undefined) return undefined;
|
|
27
|
+
for (const node of snapshot.nodes?.values() ?? []) {
|
|
28
|
+
const root = node.kind === 'tool-call' ? node.data?.root : undefined;
|
|
29
|
+
if (root?.callId !== callId || root.phase !== 'start') continue;
|
|
30
|
+
return { ...(root.name === undefined ? {} : { name: root.name }), argsRaw: root.argsRaw ?? '' };
|
|
31
|
+
}
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function parseArgs(raw: string): Readonly<Record<string, unknown>> {
|
|
36
|
+
try {
|
|
37
|
+
const parsed: unknown = JSON.parse(raw);
|
|
38
|
+
return typeof parsed === 'object' && parsed !== null ? (parsed as Record<string, unknown>) : {};
|
|
39
|
+
} catch {
|
|
40
|
+
return {};
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function ApprovalDetail(props: ApprovalDetailProps) {
|
|
45
|
+
const useChat = props.useChat ?? ((): undefined => undefined);
|
|
46
|
+
const call = useChat((snapshot) => callOf(snapshot, props.callId));
|
|
47
|
+
const t = props.t ?? ((key: string): string => key);
|
|
48
|
+
|
|
49
|
+
const args = parseArgs(call?.argsRaw ?? '');
|
|
50
|
+
const input: ApprovalInput = { tool: call?.name ?? '', args };
|
|
51
|
+
const command = typeof args.command === 'string' ? args.command : '';
|
|
52
|
+
|
|
53
|
+
const owner = (props.units ?? []).find((face) => face.approval?.tools.includes(input.tool) === true);
|
|
54
|
+
if (owner?.approval !== undefined) return <owner.approval.render input={input} t={t} />;
|
|
55
|
+
|
|
56
|
+
return command === '' ? null : <code className="wb-approvalCommand">{command}</code>;
|
|
57
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { ReactNode } from '../lib/react.js';
|
|
2
|
+
import type { Translate } from '../api/dsh-client.js';
|
|
3
|
+
import { IconRefresh, IconRightUp, IconSettings } from '../components/icons.js';
|
|
4
|
+
import { h } from '../lib/react.js';
|
|
5
|
+
|
|
6
|
+
export interface CardHeadProps {
|
|
7
|
+
readonly t: Translate;
|
|
8
|
+
readonly title: string;
|
|
9
|
+
readonly meta?: ReactNode | undefined;
|
|
10
|
+
readonly refreshing: boolean;
|
|
11
|
+
readonly onRefresh: () => void;
|
|
12
|
+
readonly gear?: string | undefined;
|
|
13
|
+
readonly gearOpen?: boolean | undefined;
|
|
14
|
+
readonly onToggleGear?: (() => void) | undefined;
|
|
15
|
+
readonly platformHref?: string | undefined;
|
|
16
|
+
readonly platform?: string | undefined;
|
|
17
|
+
readonly platformTitle?: string | undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function CardHead({
|
|
21
|
+
t,
|
|
22
|
+
title,
|
|
23
|
+
meta,
|
|
24
|
+
refreshing,
|
|
25
|
+
onRefresh,
|
|
26
|
+
gear,
|
|
27
|
+
gearOpen,
|
|
28
|
+
onToggleGear,
|
|
29
|
+
platformHref,
|
|
30
|
+
platform,
|
|
31
|
+
platformTitle,
|
|
32
|
+
}: CardHeadProps) {
|
|
33
|
+
const refreshLabel = refreshing ? t('rightbar.refreshing') : t('rightbar.refresh');
|
|
34
|
+
return (
|
|
35
|
+
<header className="wb-cardHead">
|
|
36
|
+
<h2 className="wb-cardTitle">{title}</h2>
|
|
37
|
+
{meta === undefined ? null : <span className="wb-cardMeta">{meta}</span>}
|
|
38
|
+
{platformHref === undefined || platform === undefined ? null : (
|
|
39
|
+
<a
|
|
40
|
+
className="wb-iconButton wb-cardOpen"
|
|
41
|
+
href={platformHref}
|
|
42
|
+
target="_blank"
|
|
43
|
+
rel="noreferrer noopener"
|
|
44
|
+
title={platformTitle ?? platform}
|
|
45
|
+
aria-label={platform}
|
|
46
|
+
>
|
|
47
|
+
<IconRightUp size={14} />
|
|
48
|
+
</a>
|
|
49
|
+
)}
|
|
50
|
+
<button
|
|
51
|
+
type="button"
|
|
52
|
+
className={`wb-iconButton wb-cardRefresh${refreshing ? ' wb-spinning' : ''}`}
|
|
53
|
+
aria-label={refreshLabel}
|
|
54
|
+
title={refreshLabel}
|
|
55
|
+
disabled={refreshing}
|
|
56
|
+
onClick={onRefresh}
|
|
57
|
+
>
|
|
58
|
+
<IconRefresh size={14} />
|
|
59
|
+
</button>
|
|
60
|
+
{gear === undefined || onToggleGear === undefined ? null : (
|
|
61
|
+
<button
|
|
62
|
+
type="button"
|
|
63
|
+
className={`wb-iconButton wb-cardGear${gearOpen === true ? ' wb-iconButtonActive' : ''}`}
|
|
64
|
+
aria-label={gear}
|
|
65
|
+
aria-expanded={gearOpen === true}
|
|
66
|
+
title={gear}
|
|
67
|
+
onClick={onToggleGear}
|
|
68
|
+
>
|
|
69
|
+
<IconSettings size={15} />
|
|
70
|
+
</button>
|
|
71
|
+
)}
|
|
72
|
+
</header>
|
|
73
|
+
);
|
|
74
|
+
}
|