feat(server): configure workspace service port allocation (#2165)

* starr

* fix(server): honor service port allocator contracts

* fix(server): pass workspace context to port scripts

* fix(server): cancel released service port plans

* test(server): support Windows port allocator fixtures

* docs(worktrees): clarify shell-less portScript execution
This commit is contained in:
Matt Cowger
2026-07-20 11:50:01 -07:00
committed by GitHub
parent 2ead7e7719
commit 9292f58896
14 changed files with 738 additions and 15 deletions

View File

@@ -33,6 +33,28 @@ describe("paseo config schema", () => {
});
});
it("parses service port allocation", () => {
expect(
PaseoConfigSchema.parse({
worktree: {
servicePorts: { range: "3000-4000", portScript: "/usr/bin/portmake" },
},
}),
).toEqual({
worktree: {
setup: [],
teardown: [],
servicePorts: { range: "3000-4000", portScript: "/usr/bin/portmake" },
},
});
});
it("rejects invalid service port ranges", () => {
expect(() =>
PaseoConfigRawSchema.parse({ worktree: { servicePorts: { range: "4000-3000" } } }),
).toThrow("Expected an inclusive TCP port range");
});
it("normalizes partial worktree lifecycle config without dropping present commands", () => {
expect(
PaseoConfigSchema.parse({

View File

@@ -1,5 +1,26 @@
import { z } from "zod";
const TCP_PORT_RANGE_PATTERN = /^(\d{1,5})-(\d{1,5})$/;
export const PaseoServicePortAllocationSchema = z
.object({
range: z.string().trim().regex(TCP_PORT_RANGE_PATTERN).optional(),
portScript: z.string().trim().min(1).optional(),
})
.strict()
.refine(
(value) => value.range !== undefined || value.portScript !== undefined,
"Expected range or portScript",
)
.refine((value) => {
if (!value.range) return true;
const match = TCP_PORT_RANGE_PATTERN.exec(value.range);
if (!match) return false;
const start = Number(match[1]);
const end = Number(match[2]);
return start >= 1 && end <= 65_535 && start <= end;
}, "Expected an inclusive TCP port range from 1-65535");
export function normalizeLifecycleCommands(commands: unknown): string[] {
if (typeof commands === "string") {
return commands.trim().length > 0 ? [commands] : [];
@@ -27,6 +48,7 @@ export const PaseoWorktreeConfigRawSchema = z
setup: PaseoLifecycleCommandRawSchema.optional(),
teardown: PaseoLifecycleCommandRawSchema.optional(),
terminals: z.unknown().optional(),
servicePorts: PaseoServicePortAllocationSchema.optional(),
})
.passthrough();
@@ -92,6 +114,7 @@ export const ProjectConfigRpcErrorSchema = z.discriminatedUnion("code", [
export type PaseoScriptEntryRaw = z.infer<typeof PaseoScriptEntryRawSchema>;
export type PaseoMetadataGenerationEntry = z.infer<typeof PaseoMetadataGenerationEntrySchema>;
export type PaseoMetadataGeneration = z.infer<typeof PaseoMetadataGenerationSchema>;
export type PaseoServicePortAllocation = z.infer<typeof PaseoServicePortAllocationSchema>;
export type PaseoConfigRaw = z.infer<typeof PaseoConfigRawSchema>;
export type PaseoConfig = z.infer<typeof PaseoConfigSchema>;
export type PaseoConfigRevision = z.infer<typeof PaseoConfigRevisionSchema>;