@tangle-network/blueprint-ui 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.
Files changed (55) hide show
  1. package/README.md +160 -0
  2. package/package.json +82 -0
  3. package/src/blueprints/registry.ts +107 -0
  4. package/src/components/forms/BlueprintJobForm.tsx +88 -0
  5. package/src/components/forms/FormField.tsx +206 -0
  6. package/src/components/forms/FormSummary.tsx +47 -0
  7. package/src/components/forms/JobExecutionDialog.tsx +203 -0
  8. package/src/components/layout/AppDocument.tsx +48 -0
  9. package/src/components/layout/AppFooter.tsx +38 -0
  10. package/src/components/layout/AppToaster.tsx +33 -0
  11. package/src/components/layout/ChainSwitcher.tsx +71 -0
  12. package/src/components/layout/ThemeToggle.tsx +18 -0
  13. package/src/components/layout/Web3Shell.tsx +28 -0
  14. package/src/components/motion/AnimatedPage.tsx +36 -0
  15. package/src/components/shared/Identicon.tsx +22 -0
  16. package/src/components/shared/TangleLogo.tsx +49 -0
  17. package/src/components/ui/badge.tsx +37 -0
  18. package/src/components/ui/button.tsx +61 -0
  19. package/src/components/ui/card.tsx +34 -0
  20. package/src/components/ui/dialog.tsx +59 -0
  21. package/src/components/ui/input.tsx +24 -0
  22. package/src/components/ui/select.tsx +80 -0
  23. package/src/components/ui/separator.tsx +25 -0
  24. package/src/components/ui/skeleton.tsx +13 -0
  25. package/src/components/ui/table.tsx +49 -0
  26. package/src/components/ui/tabs.tsx +39 -0
  27. package/src/components/ui/textarea.tsx +23 -0
  28. package/src/components/ui/toggle.tsx +35 -0
  29. package/src/components.ts +51 -0
  30. package/src/contracts/abi.ts +259 -0
  31. package/src/contracts/chains.ts +100 -0
  32. package/src/contracts/generic-encoder.ts +69 -0
  33. package/src/contracts/publicClient.ts +55 -0
  34. package/src/env.d.ts +14 -0
  35. package/src/hooks/useAuthenticatedFetch.ts +57 -0
  36. package/src/hooks/useJobForm.ts +78 -0
  37. package/src/hooks/useJobPrice.ts +283 -0
  38. package/src/hooks/useOperators.ts +141 -0
  39. package/src/hooks/useProvisionProgress.ts +125 -0
  40. package/src/hooks/useQuotes.ts +261 -0
  41. package/src/hooks/useServiceValidation.ts +113 -0
  42. package/src/hooks/useSessionAuth.ts +103 -0
  43. package/src/hooks/useSubmitJob.ts +115 -0
  44. package/src/hooks/useThemeValue.ts +6 -0
  45. package/src/index.ts +79 -0
  46. package/src/preset.ts +61 -0
  47. package/src/stores/infra.ts +43 -0
  48. package/src/stores/persistedAtom.ts +67 -0
  49. package/src/stores/session.ts +64 -0
  50. package/src/stores/theme.ts +28 -0
  51. package/src/stores/txHistory.ts +47 -0
  52. package/src/utils/resolveOperatorRpc.ts +20 -0
  53. package/src/utils/web3.ts +21 -0
  54. package/src/utils.ts +6 -0
  55. package/tsconfig.json +21 -0
@@ -0,0 +1,259 @@
1
+ /**
2
+ * Tangle core precompile ABIs — shared across all blueprints.
3
+ * Blueprint-specific ABIs (agentSandboxBlueprintAbi, tradingBlueprintAbi, etc.)
4
+ * stay in each consuming app.
5
+ */
6
+
7
+ export const tangleJobsAbi = [
8
+ {
9
+ type: 'function',
10
+ name: 'submitJob',
11
+ inputs: [
12
+ { name: 'serviceId', type: 'uint64' },
13
+ { name: 'job', type: 'uint8' },
14
+ { name: 'args', type: 'bytes' },
15
+ ],
16
+ outputs: [{ name: 'callId', type: 'uint64' }],
17
+ stateMutability: 'payable',
18
+ },
19
+ {
20
+ type: 'event',
21
+ name: 'JobCalled',
22
+ inputs: [
23
+ { name: 'serviceId', type: 'uint64', indexed: true },
24
+ { name: 'job', type: 'uint8', indexed: true },
25
+ { name: 'callId', type: 'uint64', indexed: true },
26
+ { name: 'caller', type: 'address', indexed: false },
27
+ { name: 'args', type: 'bytes', indexed: false },
28
+ ],
29
+ },
30
+ {
31
+ type: 'event',
32
+ name: 'JobResultReceived',
33
+ inputs: [
34
+ { name: 'serviceId', type: 'uint64', indexed: true },
35
+ { name: 'job', type: 'uint8', indexed: true },
36
+ { name: 'callId', type: 'uint64', indexed: true },
37
+ { name: 'operator', type: 'address', indexed: false },
38
+ { name: 'outputs', type: 'bytes', indexed: false },
39
+ ],
40
+ },
41
+ {
42
+ type: 'event',
43
+ name: 'JobSubmitted',
44
+ inputs: [
45
+ { name: 'serviceId', type: 'uint64', indexed: true },
46
+ { name: 'callId', type: 'uint64', indexed: true },
47
+ { name: 'jobIndex', type: 'uint8', indexed: false },
48
+ { name: 'caller', type: 'address', indexed: false },
49
+ { name: 'inputs', type: 'bytes', indexed: false },
50
+ ],
51
+ },
52
+ {
53
+ type: 'event',
54
+ name: 'JobCompleted',
55
+ inputs: [
56
+ { name: 'serviceId', type: 'uint64', indexed: true },
57
+ { name: 'callId', type: 'uint64', indexed: true },
58
+ ],
59
+ },
60
+ {
61
+ type: 'event',
62
+ name: 'JobResultSubmitted',
63
+ inputs: [
64
+ { name: 'serviceId', type: 'uint64', indexed: true },
65
+ { name: 'callId', type: 'uint64', indexed: true },
66
+ { name: 'operator', type: 'address', indexed: true },
67
+ { name: 'output', type: 'bytes', indexed: false },
68
+ ],
69
+ },
70
+ ] as const;
71
+
72
+ export const tangleServicesAbi = [
73
+ {
74
+ type: 'function',
75
+ name: 'requestService',
76
+ inputs: [
77
+ { name: 'blueprintId', type: 'uint64' },
78
+ { name: 'operators', type: 'address[]' },
79
+ { name: 'config', type: 'bytes' },
80
+ { name: 'permittedCallers', type: 'address[]' },
81
+ { name: 'ttl', type: 'uint64' },
82
+ { name: 'paymentToken', type: 'address' },
83
+ { name: 'paymentAmount', type: 'uint256' },
84
+ ],
85
+ outputs: [{ name: 'requestId', type: 'uint64' }],
86
+ stateMutability: 'payable',
87
+ },
88
+ {
89
+ type: 'function',
90
+ name: 'createServiceFromQuotes',
91
+ inputs: [
92
+ { name: 'blueprintId', type: 'uint64' },
93
+ {
94
+ name: 'quotes',
95
+ type: 'tuple[]',
96
+ components: [
97
+ {
98
+ name: 'details',
99
+ type: 'tuple',
100
+ components: [
101
+ { name: 'blueprintId', type: 'uint64' },
102
+ { name: 'ttlBlocks', type: 'uint64' },
103
+ { name: 'totalCost', type: 'uint256' },
104
+ { name: 'timestamp', type: 'uint64' },
105
+ { name: 'expiry', type: 'uint64' },
106
+ {
107
+ name: 'securityCommitments',
108
+ type: 'tuple[]',
109
+ components: [
110
+ {
111
+ name: 'asset',
112
+ type: 'tuple',
113
+ components: [
114
+ { name: 'kind', type: 'uint8' },
115
+ { name: 'token', type: 'address' },
116
+ ],
117
+ },
118
+ { name: 'exposureBps', type: 'uint16' },
119
+ ],
120
+ },
121
+ ],
122
+ },
123
+ { name: 'signature', type: 'bytes' },
124
+ { name: 'operator', type: 'address' },
125
+ ],
126
+ },
127
+ { name: 'config', type: 'bytes' },
128
+ { name: 'permittedCallers', type: 'address[]' },
129
+ { name: 'ttl', type: 'uint64' },
130
+ ],
131
+ outputs: [{ name: 'serviceId', type: 'uint64' }],
132
+ stateMutability: 'payable',
133
+ },
134
+ {
135
+ type: 'function',
136
+ name: 'getService',
137
+ inputs: [{ name: 'serviceId', type: 'uint64' }],
138
+ outputs: [
139
+ {
140
+ name: '',
141
+ type: 'tuple',
142
+ components: [
143
+ { name: 'blueprintId', type: 'uint64' },
144
+ { name: 'owner', type: 'address' },
145
+ { name: 'createdAt', type: 'uint64' },
146
+ { name: 'ttl', type: 'uint64' },
147
+ { name: 'terminatedAt', type: 'uint64' },
148
+ { name: 'lastPaymentAt', type: 'uint64' },
149
+ { name: 'operatorCount', type: 'uint32' },
150
+ { name: 'minOperators', type: 'uint32' },
151
+ { name: 'maxOperators', type: 'uint32' },
152
+ { name: 'membership', type: 'uint8' },
153
+ { name: 'pricing', type: 'uint8' },
154
+ { name: 'status', type: 'uint8' },
155
+ ],
156
+ },
157
+ ],
158
+ stateMutability: 'view',
159
+ },
160
+ {
161
+ type: 'function',
162
+ name: 'isServiceActive',
163
+ inputs: [{ name: 'serviceId', type: 'uint64' }],
164
+ outputs: [{ name: '', type: 'bool' }],
165
+ stateMutability: 'view',
166
+ },
167
+ {
168
+ type: 'function',
169
+ name: 'getServiceOperators',
170
+ inputs: [{ name: 'serviceId', type: 'uint64' }],
171
+ outputs: [{ name: '', type: 'address[]' }],
172
+ stateMutability: 'view',
173
+ },
174
+ {
175
+ type: 'function',
176
+ name: 'isPermittedCaller',
177
+ inputs: [
178
+ { name: 'serviceId', type: 'uint64' },
179
+ { name: 'caller', type: 'address' },
180
+ ],
181
+ outputs: [{ name: '', type: 'bool' }],
182
+ stateMutability: 'view',
183
+ },
184
+ {
185
+ type: 'event',
186
+ name: 'ServiceRequested',
187
+ inputs: [
188
+ { name: 'requester', type: 'address', indexed: true },
189
+ { name: 'requestId', type: 'uint64', indexed: true },
190
+ { name: 'blueprintId', type: 'uint64', indexed: true },
191
+ ],
192
+ },
193
+ {
194
+ type: 'event',
195
+ name: 'ServiceActivated',
196
+ inputs: [
197
+ { name: 'serviceId', type: 'uint64', indexed: true },
198
+ { name: 'requestId', type: 'uint64', indexed: true },
199
+ { name: 'blueprintId', type: 'uint64', indexed: true },
200
+ ],
201
+ },
202
+ ] as const;
203
+
204
+ export const tangleOperatorsAbi = [
205
+ {
206
+ type: 'function',
207
+ name: 'blueprintOperatorCount',
208
+ inputs: [{ name: 'blueprintId', type: 'uint64' }],
209
+ outputs: [{ name: '', type: 'uint256' }],
210
+ stateMutability: 'view',
211
+ },
212
+ {
213
+ type: 'function',
214
+ name: 'isOperatorRegistered',
215
+ inputs: [
216
+ { name: 'blueprintId', type: 'uint64' },
217
+ { name: 'operator', type: 'address' },
218
+ ],
219
+ outputs: [{ name: '', type: 'bool' }],
220
+ stateMutability: 'view',
221
+ },
222
+ {
223
+ type: 'function',
224
+ name: 'getOperatorPreferences',
225
+ inputs: [
226
+ { name: 'blueprintId', type: 'uint64' },
227
+ { name: 'operator', type: 'address' },
228
+ ],
229
+ outputs: [
230
+ {
231
+ name: 'preferences',
232
+ type: 'tuple',
233
+ components: [
234
+ { name: 'ecdsaPublicKey', type: 'bytes' },
235
+ { name: 'rpcAddress', type: 'string' },
236
+ ],
237
+ },
238
+ ],
239
+ stateMutability: 'view',
240
+ },
241
+ {
242
+ type: 'event',
243
+ name: 'OperatorRegistered',
244
+ inputs: [
245
+ { name: 'blueprintId', type: 'uint64', indexed: true },
246
+ { name: 'operator', type: 'address', indexed: true },
247
+ { name: 'ecdsaPublicKey', type: 'bytes', indexed: false },
248
+ { name: 'rpcAddress', type: 'string', indexed: false },
249
+ ],
250
+ },
251
+ {
252
+ type: 'event',
253
+ name: 'OperatorUnregistered',
254
+ inputs: [
255
+ { name: 'blueprintId', type: 'uint64', indexed: true },
256
+ { name: 'operator', type: 'address', indexed: true },
257
+ ],
258
+ },
259
+ ] as const;
@@ -0,0 +1,100 @@
1
+ import { defineChain } from 'viem';
2
+ import { mainnet } from 'viem/chains';
3
+ import type { Address, Chain } from 'viem';
4
+
5
+ /**
6
+ * Resolve RPC URL for the current environment.
7
+ * Handles local dev (hostname swap), Vite dev proxy, and remote access.
8
+ */
9
+ export function resolveRpcUrl(envUrl?: string): string {
10
+ const configured = envUrl ?? import.meta.env.VITE_RPC_URL ?? 'http://localhost:8545';
11
+ if (typeof window === 'undefined') return configured;
12
+ try {
13
+ const rpc = new URL(configured);
14
+ const isLocalRpc = rpc.hostname === '127.0.0.1' || rpc.hostname === 'localhost';
15
+ const pageHost = window.location.hostname;
16
+ const isLocalPage = pageHost === '127.0.0.1' || pageHost === 'localhost';
17
+ // Dev-mode proxy for LAN access to local RPC
18
+ if (isLocalRpc && !isLocalPage && import.meta.env.DEV) {
19
+ return `${window.location.origin}/rpc-proxy`;
20
+ }
21
+ // Non-dev LAN access: swap hostname
22
+ if (isLocalRpc && !isLocalPage) {
23
+ rpc.hostname = pageHost;
24
+ return rpc.toString().replace(/\/$/, '');
25
+ }
26
+ } catch {
27
+ // malformed
28
+ }
29
+ return configured;
30
+ }
31
+
32
+ export const rpcUrl = resolveRpcUrl();
33
+
34
+ export const tangleLocal = defineChain({
35
+ id: Number(import.meta.env.VITE_CHAIN_ID ?? 31337),
36
+ name: 'Tangle Local',
37
+ nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
38
+ rpcUrls: { default: { http: [rpcUrl] } },
39
+ blockExplorers: { default: { name: 'Explorer', url: '' } },
40
+ contracts: { multicall3: { address: '0xcA11bde05977b3631167028862bE2a173976CA11' } },
41
+ });
42
+
43
+ export const tangleTestnet = defineChain({
44
+ id: 3799,
45
+ name: 'Tangle Testnet',
46
+ nativeCurrency: { name: 'Tangle', symbol: 'tTNT', decimals: 18 },
47
+ rpcUrls: {
48
+ default: {
49
+ http: ['https://testnet-rpc.tangle.tools'],
50
+ webSocket: ['wss://testnet-rpc.tangle.tools'],
51
+ },
52
+ },
53
+ blockExplorers: { default: { name: 'Tangle Explorer', url: 'https://testnet-explorer.tangle.tools' } },
54
+ contracts: { multicall3: { address: '0xcA11bde05977b3631167028862bE2a173976CA11' } },
55
+ });
56
+
57
+ export const tangleMainnet = defineChain({
58
+ id: 5845,
59
+ name: 'Tangle',
60
+ nativeCurrency: { name: 'Tangle', symbol: 'TNT', decimals: 18 },
61
+ rpcUrls: {
62
+ default: {
63
+ http: ['https://rpc.tangle.tools'],
64
+ webSocket: ['wss://rpc.tangle.tools'],
65
+ },
66
+ },
67
+ blockExplorers: { default: { name: 'Tangle Explorer', url: 'https://explorer.tangle.tools' } },
68
+ contracts: { multicall3: { address: '0xcA11bde05977b3631167028862bE2a173976CA11' } },
69
+ });
70
+
71
+ /** Minimum required contract addresses for Tangle core hooks. */
72
+ export interface CoreAddresses {
73
+ jobs: Address;
74
+ services: Address;
75
+ [key: string]: Address;
76
+ }
77
+
78
+ export interface NetworkConfig<T extends CoreAddresses = CoreAddresses> {
79
+ chain: Chain;
80
+ rpcUrl: string;
81
+ label: string;
82
+ shortLabel: string;
83
+ addresses: T;
84
+ }
85
+
86
+ let _networks: Record<number, NetworkConfig<any>> = {};
87
+
88
+ /** Register networks with app-specific address shapes at startup. */
89
+ export function configureNetworks<T extends CoreAddresses>(
90
+ nets: Record<number, NetworkConfig<T>>,
91
+ ) {
92
+ _networks = nets;
93
+ }
94
+
95
+ export function getNetworks<T extends CoreAddresses = CoreAddresses>(): Record<number, NetworkConfig<T>> {
96
+ return _networks as Record<number, NetworkConfig<T>>;
97
+ }
98
+
99
+ export { mainnet };
100
+ export const allTangleChains = [tangleLocal, tangleTestnet, tangleMainnet] as const;
@@ -0,0 +1,69 @@
1
+ import { encodeAbiParameters } from 'viem';
2
+ import type { JobDefinition } from '../blueprints/registry';
3
+
4
+ /**
5
+ * Generic ABI encoder for any blueprint job.
6
+ *
7
+ * Builds the ordered parameter array from:
8
+ * 1. contextParams (e.g. sidecar_url, sandbox_id) — prepended first
9
+ * 2. fields with abiType set — appended in definition order
10
+ *
11
+ * Delegates to job.customEncoder when present (for nested-struct jobs like batch_create).
12
+ */
13
+ export function encodeJobArgs(
14
+ job: JobDefinition,
15
+ formValues: Record<string, unknown>,
16
+ context?: Record<string, unknown>,
17
+ ): `0x${string}` {
18
+ if (job.customEncoder) {
19
+ return job.customEncoder(formValues, context);
20
+ }
21
+
22
+ const abiDefs: { name: string; type: string }[] = [];
23
+ const values: unknown[] = [];
24
+
25
+ // Context params first (sidecar_url, sandbox_id, etc.)
26
+ if (job.contextParams) {
27
+ for (const cp of job.contextParams) {
28
+ abiDefs.push({ name: cp.abiName, type: cp.abiType });
29
+ values.push(coerceValue(context?.[cp.abiName], cp.abiType));
30
+ }
31
+ }
32
+
33
+ // Form fields with ABI metadata
34
+ for (const field of job.fields) {
35
+ if (!field.abiType) continue;
36
+ abiDefs.push({ name: field.abiParam ?? field.name, type: field.abiType });
37
+ values.push(coerceValue(formValues[field.name], field.abiType));
38
+ }
39
+
40
+ return encodeAbiParameters(abiDefs, values);
41
+ }
42
+
43
+ function coerceValue(value: unknown, abiType: string): unknown {
44
+ switch (abiType) {
45
+ case 'bool':
46
+ return Boolean(value);
47
+ case 'uint8':
48
+ case 'uint16':
49
+ case 'uint32':
50
+ return Number(value) || 0;
51
+ case 'uint64':
52
+ case 'uint128':
53
+ case 'uint256':
54
+ return BigInt(Number(value) || 0);
55
+ case 'string':
56
+ return String(value ?? '');
57
+ case 'string[]':
58
+ if (Array.isArray(value)) return value.map(String);
59
+ return String(value ?? '').split('\n').filter(Boolean);
60
+ case 'address[]':
61
+ if (Array.isArray(value)) return value;
62
+ return String(value ?? '')
63
+ .split('\n')
64
+ .map((s) => s.trim())
65
+ .filter((s) => /^0x[a-fA-F0-9]{40}$/.test(s));
66
+ default:
67
+ return value;
68
+ }
69
+ }
@@ -0,0 +1,55 @@
1
+ import { createPublicClient, http } from 'viem';
2
+ import type { PublicClient } from 'viem';
3
+ import { atom } from 'nanostores';
4
+ import { getNetworks, tangleLocal, type CoreAddresses } from './chains';
5
+ import { persistedAtom } from '../stores/persistedAtom';
6
+
7
+ const defaultChainId = Number(import.meta.env.VITE_CHAIN_ID ?? tangleLocal.id);
8
+
9
+ export const selectedChainIdStore = persistedAtom<number>({
10
+ key: 'bp_selected_chain',
11
+ initial: defaultChainId,
12
+ });
13
+
14
+ const clientCache = new Map<number, PublicClient>();
15
+
16
+ function getOrCreateClient(chainId: number): PublicClient {
17
+ const cached = clientCache.get(chainId);
18
+ if (cached) return cached;
19
+ const networks = getNetworks();
20
+ const net = networks[chainId];
21
+ if (!net) {
22
+ const fallback = networks[defaultChainId];
23
+ if (!fallback) {
24
+ return createPublicClient({ chain: tangleLocal, transport: http() });
25
+ }
26
+ return createPublicClient({ chain: fallback.chain, transport: http(fallback.rpcUrl) });
27
+ }
28
+ const client = createPublicClient({ chain: net.chain, transport: http(net.rpcUrl) });
29
+ clientCache.set(chainId, client);
30
+ return client;
31
+ }
32
+
33
+ export const publicClientStore = atom<PublicClient>(getOrCreateClient(selectedChainIdStore.get()));
34
+
35
+ selectedChainIdStore.subscribe((chainId: number) => {
36
+ publicClientStore.set(getOrCreateClient(chainId));
37
+ });
38
+
39
+ export function getPublicClient(): PublicClient {
40
+ return publicClientStore.get();
41
+ }
42
+
43
+ export const publicClient = new Proxy({} as PublicClient, {
44
+ get(_target, prop) {
45
+ const client = getOrCreateClient(selectedChainIdStore.get());
46
+ const value = (client as any)[prop];
47
+ return typeof value === 'function' ? value.bind(client) : value;
48
+ },
49
+ });
50
+
51
+ export function getAddresses<T extends CoreAddresses = CoreAddresses>(): T {
52
+ const networks = getNetworks<T>();
53
+ const net = networks[selectedChainIdStore.get()];
54
+ return net?.addresses ?? networks[defaultChainId]?.addresses ?? {} as T;
55
+ }
package/src/env.d.ts ADDED
@@ -0,0 +1,14 @@
1
+ /** Vite env types — consumer provides the actual env values at build time. */
2
+ interface ImportMetaEnv {
3
+ readonly VITE_RPC_URL?: string;
4
+ readonly VITE_CHAIN_ID?: string;
5
+ readonly VITE_BLUEPRINT_ID?: string;
6
+ readonly VITE_SERVICE_ID?: string;
7
+ readonly VITE_SERVICE_IDS?: string;
8
+ readonly DEV?: boolean;
9
+ [key: string]: unknown;
10
+ }
11
+
12
+ interface ImportMeta {
13
+ readonly env: ImportMetaEnv;
14
+ }
@@ -0,0 +1,57 @@
1
+ import { useCallback } from 'react';
2
+ import { getSession } from '../stores/session';
3
+ import { useSessionAuth } from './useSessionAuth';
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Hook
7
+ // ---------------------------------------------------------------------------
8
+
9
+ interface UseAuthenticatedFetchOptions {
10
+ sandboxId: string;
11
+ apiUrl?: string;
12
+ }
13
+
14
+ /**
15
+ * Returns a `fetch` wrapper that injects the Bearer session token.
16
+ * If the token is expired or a 401 is received, triggers re-authentication.
17
+ */
18
+ export function useAuthenticatedFetch({ sandboxId, apiUrl }: UseAuthenticatedFetchOptions) {
19
+ const { authenticate } = useSessionAuth({ sandboxId, apiUrl });
20
+
21
+ const authFetch = useCallback(
22
+ async (url: string, init?: RequestInit): Promise<Response> => {
23
+ let session = getSession(sandboxId);
24
+
25
+ // If no session, authenticate first
26
+ if (!session) {
27
+ session = await authenticate();
28
+ if (!session) {
29
+ throw new Error('Authentication required');
30
+ }
31
+ }
32
+
33
+ // Make request with token
34
+ const headers = new Headers(init?.headers);
35
+ headers.set('Authorization', `Bearer ${session.token}`);
36
+
37
+ const res = await fetch(url, { ...init, headers });
38
+
39
+ // If 401, try re-authenticating once
40
+ if (res.status === 401) {
41
+ session = await authenticate();
42
+ if (!session) {
43
+ throw new Error('Re-authentication failed');
44
+ }
45
+
46
+ const retryHeaders = new Headers(init?.headers);
47
+ retryHeaders.set('Authorization', `Bearer ${session.token}`);
48
+ return fetch(url, { ...init, headers: retryHeaders });
49
+ }
50
+
51
+ return res;
52
+ },
53
+ [sandboxId, authenticate],
54
+ );
55
+
56
+ return { authFetch };
57
+ }
@@ -0,0 +1,78 @@
1
+ import { useState, useCallback, useEffect, useMemo } from 'react';
2
+ import type { JobDefinition } from '../blueprints/registry';
3
+
4
+ export interface JobFormState {
5
+ values: Record<string, unknown>;
6
+ errors: Record<string, string>;
7
+ onChange: (name: string, value: unknown) => void;
8
+ validate: () => boolean;
9
+ reset: () => void;
10
+ }
11
+
12
+ function buildDefaults(job: JobDefinition): Record<string, unknown> {
13
+ const init: Record<string, unknown> = {};
14
+ for (const f of job.fields) {
15
+ if (f.internal) continue;
16
+ if (f.defaultValue !== undefined) {
17
+ init[f.name] = f.defaultValue;
18
+ } else if (f.type === 'boolean') {
19
+ init[f.name] = false;
20
+ } else if (f.type === 'number') {
21
+ init[f.name] = f.min ?? 0;
22
+ } else {
23
+ init[f.name] = '';
24
+ }
25
+ }
26
+ return init;
27
+ }
28
+
29
+ export function useJobForm(job: JobDefinition | null): JobFormState {
30
+ const defaults = useMemo(() => (job ? buildDefaults(job) : {}), [job]);
31
+ const [values, setValues] = useState<Record<string, unknown>>(defaults);
32
+ const [errors, setErrors] = useState<Record<string, string>>({});
33
+
34
+ // Sync form values when job changes (e.g. blueprint selection or async load)
35
+ useEffect(() => {
36
+ setValues(defaults);
37
+ setErrors({});
38
+ }, [defaults]);
39
+
40
+ const onChange = useCallback((name: string, value: unknown) => {
41
+ setValues((prev) => ({ ...prev, [name]: value }));
42
+ setErrors((prev) => {
43
+ if (!prev[name]) return prev;
44
+ const next = { ...prev };
45
+ delete next[name];
46
+ return next;
47
+ });
48
+ }, []);
49
+
50
+ const validate = useCallback((): boolean => {
51
+ if (!job) return false;
52
+ const errs: Record<string, string> = {};
53
+ for (const f of job.fields) {
54
+ if (f.internal) continue;
55
+ const v = values[f.name];
56
+ if (f.required && (v === undefined || v === null || v === '')) {
57
+ errs[f.name] = `${f.label} is required`;
58
+ continue;
59
+ }
60
+ if (f.type === 'number' && typeof v === 'number') {
61
+ if (f.min != null && v < f.min) {
62
+ errs[f.name] = `${f.label} must be at least ${f.min}`;
63
+ } else if (f.max != null && v > f.max) {
64
+ errs[f.name] = `${f.label} must be at most ${f.max}`;
65
+ }
66
+ }
67
+ }
68
+ setErrors(errs);
69
+ return Object.keys(errs).length === 0;
70
+ }, [job, values]);
71
+
72
+ const reset = useCallback(() => {
73
+ setValues(defaults);
74
+ setErrors({});
75
+ }, [defaults]);
76
+
77
+ return { values, errors, onChange, validate, reset };
78
+ }