@flame0510/project-aether 1.4.1 → 1.4.2

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.
@@ -770,6 +770,14 @@ function currentPortRange(ports: string): string {
770
770
  return min === max ? `${min}` : `${min}-${max}`;
771
771
  }
772
772
 
773
+ /** The agent's own published host ports, from the `3700:3000, 3701:3001` string. */
774
+ function ownHostPorts(ports: string): number[] {
775
+ return (ports || '')
776
+ .split(',')
777
+ .map((p) => parseInt(p.trim().split(':')[0] ?? '', 10))
778
+ .filter((n) => Number.isInteger(n));
779
+ }
780
+
773
781
  function EditAgentModal({ agent, onClose, onSaved, onStarted }: {
774
782
  agent: Agent;
775
783
  onClose: () => void;
@@ -780,20 +788,37 @@ function EditAgentModal({ agent, onClose, onSaved, onStarted }: {
780
788
  }) {
781
789
  const initialName = agent.displayName || agent.name;
782
790
  const initialPorts = currentPortRange(agent.ports);
791
+ const ownPorts = useMemo(() => ownHostPorts(agent.ports), [agent.ports]);
783
792
  const [displayName, setDisplayName] = useState(initialName);
784
793
  const [portRange, setPortRange] = useState(initialPorts);
785
794
  const [saving, setSaving] = useState(false);
786
795
  const [error, setError] = useState('');
796
+ // The ports other containers hold, fetched when the panel opens: the same set the
797
+ // create form gets as a page prop, from the same lookup (`GET /api/agents/ports`).
798
+ const [usedPorts, setUsedPorts] = useState<Set<number> | null>(null);
799
+
800
+ useEffect(() => {
801
+ const controller = new AbortController();
802
+ fetch('/api/agents/ports', { signal: controller.signal })
803
+ .then((res) => (res.ok ? res.json() : null))
804
+ .then((data: { ports?: number[] } | null) => {
805
+ if (data && Array.isArray(data.ports)) setUsedPorts(new Set(data.ports));
806
+ })
807
+ .catch((e: unknown) => { if ((e as Error)?.name !== 'AbortError') setUsedPorts(null); });
808
+ return () => controller.abort();
809
+ }, []);
787
810
 
788
- // Client-side shape checks (the same messages as the API). Whether a port is already
789
- // published by another agent is checked by the server, which knows every container
790
- // and excludes this agent's own block.
811
+ // The same rules and messages as agent creation (lib/agent-ports.ts). This agent's own
812
+ // block is excluded, so keeping or shifting it is not a conflict with itself; every
813
+ // other container counts, running or stopped.
791
814
  const nameError = displayName.trim() ? '' : 'Display name must not be empty';
792
815
  const portError = useMemo(() => {
793
816
  if (!portRange.trim()) return '';
794
- const r = validatePortInput(portRange);
817
+ const others = new Set(usedPorts ?? []);
818
+ for (const p of ownPorts) others.delete(p);
819
+ const r = validatePortInput(portRange, others);
795
820
  return r.valid ? '' : r.error;
796
- }, [portRange]);
821
+ }, [portRange, usedPorts, ownPorts]);
797
822
 
798
823
  const nameChanged = displayName.trim() !== initialName;
799
824
  const portsChanged = portRange.trim() !== initialPorts;
@@ -882,7 +907,7 @@ function EditAgentModal({ agent, onClose, onSaved, onStarted }: {
882
907
  {error && <div style={{ fontSize: 11, color: '#ef4444' }}>{error}</div>}
883
908
  <div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
884
909
  <Button variant="secondary" size="sm" onClick={onClose} disabled={saving}>Cancel</Button>
885
- <Button variant="primary" size="sm" onClick={handleSave} disabled={saving}>
910
+ <Button variant="primary" size="sm" onClick={handleSave} disabled={saving || !!nameError || !!portError}>
886
911
  {saving ? 'Saving...' : 'Save & Recreate'}
887
912
  </Button>
888
913
  </div>
@@ -60,6 +60,20 @@ export default function CreatePageClient({ templates, models, usedNames, usedPor
60
60
  const [selectedTemplate, setSelectedTemplate] = useState<string>('');
61
61
  const [agentName, setAgentName] = useState('');
62
62
  const [portRange, setPortRange] = useState<string>('');
63
+ // The server render seeds this; the same endpoint the Edit panel uses refreshes it, so
64
+ // both flows validate against the same numbers (every container, running or stopped).
65
+ const [usedPorts, setUsedPorts] = useState<number[]>(initialUsedPorts);
66
+
67
+ useEffect(() => {
68
+ const controller = new AbortController();
69
+ fetch('/api/agents/ports', { signal: controller.signal })
70
+ .then((res) => (res.ok ? res.json() : null))
71
+ .then((data: { ports?: number[] } | null) => {
72
+ if (data && Array.isArray(data.ports)) setUsedPorts(data.ports);
73
+ })
74
+ .catch(() => { /* the server still validates on submit */ });
75
+ return () => controller.abort();
76
+ }, []);
63
77
  const [selectedModel, setSelectedModel] = useState<string>(
64
78
  models.length > 0 ? models[0].id : ''
65
79
  );
@@ -73,11 +87,11 @@ export default function CreatePageClient({ templates, models, usedNames, usedPor
73
87
  setSelectedTemplate(id);
74
88
  setAgentName('');
75
89
  // Auto-assign port block: find first free 10-port block
76
- if (initialUsedPorts.length === 0) {
90
+ if (usedPorts.length === 0) {
77
91
  setPortRange('3700-3709');
78
92
  } else {
79
- // Simple client-side block finder
80
- const block = initialUsedPorts.length ? findAvailablePortBlock(new Set(initialUsedPorts)) : null;
93
+ // Same block finder as the server: the first free 10-port block.
94
+ const block = findAvailablePortBlock(new Set(usedPorts));
81
95
  setPortRange(block ? `${block}-${block + 9}` : '');
82
96
  }
83
97
  };
@@ -91,9 +105,9 @@ export default function CreatePageClient({ templates, models, usedNames, usedPor
91
105
 
92
106
  const portError = useMemo(() => {
93
107
  if (!portRange.trim()) return '';
94
- const res = validatePortInput(portRange, new Set(initialUsedPorts));
108
+ const res = validatePortInput(portRange, new Set(usedPorts));
95
109
  return res.valid ? '' : res.error;
96
- }, [portRange, initialUsedPorts]);
110
+ }, [portRange, usedPorts]);
97
111
 
98
112
  const canProceed = (): boolean => {
99
113
  if (step === 'template') return selectedTemplate !== '';
@@ -0,0 +1,19 @@
1
+ import { NextResponse } from 'next/server';
2
+ import { requireAuthJWT } from '@/lib/rev4a-auth';
3
+ import { getUsedHostPorts } from '@/lib/agent-ports-server';
4
+
5
+ export const dynamic = 'force-dynamic';
6
+
7
+ /**
8
+ * GET /api/agents/ports
9
+ *
10
+ * The host ports held by any container, running or stopped — the set the port validators
11
+ * in lib/agent-ports.ts take. The create form receives it as a page prop; the Edit panel
12
+ * fetches it when it opens, so both flows validate a range against the same numbers and
13
+ * the same rules, and give the same message.
14
+ */
15
+ export async function GET(request: Request): Promise<NextResponse> {
16
+ const denied = await requireAuthJWT(request); if (denied) return denied as any;
17
+ const ports = await getUsedHostPorts();
18
+ return NextResponse.json({ ports: [...ports].sort((a, b) => a - b) });
19
+ }
@@ -189,6 +189,10 @@ The central container, running the Next.js dashboard + orchestration API.
189
189
  `recreateAgentContainer()` refuses such a clash **before** removing anything, naming the
190
190
  container that holds each port, so the agent stays up and the operator changes the range
191
191
  from the Edit panel; a failed `docker run` no longer leaves a container behind.
192
+ The two forms validate a range **as it is typed** against that same set — the create
193
+ wizard through its page prop plus `GET /api/agents/ports`, the Edit panel through the
194
+ endpoint when it opens — so both show the same message and disable their action while
195
+ the range is refused, instead of letting the server refuse it on submit.
192
196
  - **Agent edits** are `lib/agent-edit.ts`, with their state in the `agent_edits` table
193
197
  (`lib/agent-edit-state.ts`): the display name (`AGENT_NAME`) and/or the port range are
194
198
  applied by rebuilding the container on the image it already runs — **no backup**, the
@@ -48,7 +48,7 @@ All shared UI primitives live in `app/components/ui/` and are exported from `app
48
48
  | `ImageDownloadBanner` | `app/agents/ImageDownloadBanner.tsx` | Agent image banner on the Agents page. Polls `/api/agents/image-status` every 2 s; offers **Download Image** when no supported version is downloaded, **Download <version>** when the registry publishes a newer one, and shows the download in progress and its completion. Downloading changes no agent. |
49
49
  | `BrowserAccessSection` / `OpenControlUiButton` | `app/agents/BrowserAccessSection.tsx` | Browser access to one agent's Control UI, in its detail panel: requests waiting for approval (Approve / Reject) and approved browsers (Rename / Revoke), refreshed every 5 s while mounted. A successful approve, reject, rename or revoke updates the list at once, since the refresh behind it runs the OpenClaw CLI and takes seconds; a read started before the mutation is discarded. On agents that require approval, "Invite link" fetches `/api/agents/[id]/invite-link` and shows the link in a read-only field with Copy, which uses the Clipboard API in a secure context and the field's selection over plain HTTP, plus a warning when the link uses localhost. `OpenControlUiButton` opens `/api/agents/[id]/open-control-ui` in a new tab inside the click; that route redirects to a one-time link that pairs the browser with no approval, or to the plain token link when none can be issued. Used on the agent cards and in the panel. |
50
50
  | `ChannelManager` / `ChannelSection` | `app/agents/ChannelManager.tsx`, `ChannelSection` inline in `app/agents/PageClient.tsx` | Telegram, in the agent detail panel (`ChannelSection` is the card that opens the modal; the modal title is the agent's display name). Reads `GET /channels`; lists pending pairing requests with **Approve** (`POST /channels/pairing`) and approved senders with **Revoke** after a confirm (`DELETE /channels/pairing?senderId=`). Pending comes from `openclaw pairing list`, approved from OpenClaw's pairing store (`lib/channelManager.ts`). Polls pairings every 5 s while open; one keyed busy state per action. |
51
- | `EditAgentModal` / `EditBanner` | `app/agents/PageClient.tsx` | Rename/ports editing. The modal has **Display Name** and **Host Port** (same input and validation messages as the create wizard, from `lib/agent-ports.ts`), sends `PATCH /api/agents/[id]` and closes on `202`; `EditBanner` (top of the agent detail) polls `GET /api/agents/[id]` every 2 s while an edit is `rebuilding`, resumes on mount, and refetches the agent once it finishes, so a reload or a navigation shows the running edit instead of allowing a second (the server answers 409). No backup is taken: the volume is untouched. |
51
+ | `EditAgentModal` / `EditBanner` | `app/agents/PageClient.tsx` | Rename/ports editing. The modal has **Display Name** and **Host Port** (same input and validation messages as the create wizard, from `lib/agent-ports.ts`), sends `PATCH /api/agents/[id]` and closes on `202`; `EditBanner` (top of the agent detail) polls `GET /api/agents/[id]` every 2 s while an edit is `rebuilding`, resumes on mount, and refetches the agent once it finishes, so a reload or a navigation shows the running edit instead of allowing a second (the server answers 409). No backup is taken: the volume is untouched. The port range is validated **as it is typed**: the modal fetches `GET /api/agents/ports` when it opens (the same set the create form uses, every container, running or stopped) and excludes the agent's own block, so an occupied range shows the API's message inline and the Save button stays disabled — the create wizard does the same with the same endpoint. |
52
52
  | `ModelSection` | `app/agents/ModelSection.tsx` | Primary model and fallbacks for one agent, in its detail panel. Explicit save, no restart. The model is a property of the agent, not of the gateway. Tags models the catalogue marks `deprecated`. |
53
53
  | `ModelsProvider` / `useModels` | `app/lib/models-context.tsx` | The client's single model list, from `/api/models`. Whatever changes what is offered calls `refresh()`: the Gateway page after a toggle, a key save or removal, or a sync, and the first-run wizard after saving keys. Everything else only reads, PulseChat included. |
54
54
  | `WizardPageClient` + step components | `app/wizard/PageClient.tsx` | Multi-step first-run wizard plus its frame shell, with mobile-first CSS. |
@@ -704,6 +704,27 @@ List running agent containers (Docker containers with `AGENT_ID` label) with ful
704
704
 
705
705
  ---
706
706
 
707
+ ### `GET /api/agents/ports`
708
+ The host ports held by any container, running or stopped: the set the validators in
709
+ `lib/agent-ports.ts` take, sorted ascending. Backed by the shared lookup
710
+ (`getUsedHostPorts()`, `lib/agent-ports-server.ts`), which reads every container's
711
+ `HostConfig.PortBindings` — a stopped container keeps its block while `docker ps -a` and
712
+ the API's container list report nothing for it.
713
+
714
+ **Auth:** `rev4a_token` cookie or `Authorization: Bearer <REV4A_TOKEN>`
715
+
716
+ **Response:**
717
+ ```json
718
+ { "ports": [3700, 3701, 3710, 3711, 3741, 5555] }
719
+ ```
720
+
721
+ The Edit panel fetches it when it opens and the create form refreshes it on mount (the
722
+ page also receives it as a prop), so both validate a range as the operator types, against
723
+ the same numbers and with the same messages, and both disable their action while the range
724
+ is refused.
725
+
726
+ ---
727
+
707
728
  ### `GET /api/agents/models-summary`
708
729
  One row per agent container, running or not: the model it runs and whether that
709
730
  model is still in good standing. Feeds the model chip on the agent cards. Running
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flame0510/project-aether",
3
- "version": "1.4.1",
3
+ "version": "1.4.2",
4
4
  "description": "Rev4a — Revolution for Agents. OpenClaw agent fleet orchestrator.",
5
5
  "keywords": [
6
6
  "openclaw",