@spencerboucher/switchyard 0.3.0 → 0.4.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/README.md +2 -2
- package/dist/switchyard.mjs +1243 -742
- package/package.json +1 -1
- package/web-dist/assets/{AutomationView-Bt_v8JDx.js → AutomationView-BhEfjAtI.js} +2 -2
- package/web-dist/assets/{AutomationView-Bt_v8JDx.js.map → AutomationView-BhEfjAtI.js.map} +1 -1
- package/web-dist/assets/{InteractionCard-BRI9KgrO.js → InteractionCard-DxEZUNrD.js} +2 -2
- package/web-dist/assets/{InteractionCard-BRI9KgrO.js.map → InteractionCard-DxEZUNrD.js.map} +1 -1
- package/web-dist/assets/Menu-C4GUY_ym.js +2 -0
- package/web-dist/assets/Menu-C4GUY_ym.js.map +1 -0
- package/web-dist/assets/{Overlay-B9h5oAe6.js → Overlay-C-GqmwUn.js} +2 -2
- package/web-dist/assets/{Overlay-B9h5oAe6.js.map → Overlay-C-GqmwUn.js.map} +1 -1
- package/web-dist/assets/{attention-VkuGuoSf.js → attention-Dzqdk9Gz.js} +2 -2
- package/web-dist/assets/{attention-VkuGuoSf.js.map → attention-Dzqdk9Gz.js.map} +1 -1
- package/web-dist/assets/{automations-BY88rXFv.js → automations-DzTTwxBl.js} +2 -2
- package/web-dist/assets/{automations-BY88rXFv.js.map → automations-DzTTwxBl.js.map} +1 -1
- package/web-dist/assets/{board-OzxtnPgh.js → board-C5mG_8rg.js} +2 -2
- package/web-dist/assets/{board-OzxtnPgh.js.map → board-C5mG_8rg.js.map} +1 -1
- package/web-dist/assets/code-9shyh1Gw.js +8 -0
- package/web-dist/assets/code-9shyh1Gw.js.map +1 -0
- package/web-dist/assets/{describe-CRXLn3O6.js → describe-BbCZVvJP.js} +2 -2
- package/web-dist/assets/{describe-CRXLn3O6.js.map → describe-BbCZVvJP.js.map} +1 -1
- package/web-dist/assets/dist-PaQekTP8.js +56 -0
- package/web-dist/assets/dist-PaQekTP8.js.map +1 -0
- package/web-dist/assets/{enroll-C9-fBIKO.js → enroll-DWJANJtw.js} +2 -2
- package/web-dist/assets/{enroll-C9-fBIKO.js.map → enroll-DWJANJtw.js.map} +1 -1
- package/web-dist/assets/index-ByiuDoG0.css +2 -0
- package/web-dist/assets/{index-B11DJFHW.js → index-afOJ8NRR.js} +3 -3
- package/web-dist/assets/{index-B11DJFHW.js.map → index-afOJ8NRR.js.map} +1 -1
- package/web-dist/assets/{more-BUvq82hI.js → more-B6qxC6M-.js} +2 -2
- package/web-dist/assets/{more-BUvq82hI.js.map → more-B6qxC6M-.js.map} +1 -1
- package/web-dist/assets/{now-DR2wt1DG.js → now-BOWxgbt3.js} +2 -2
- package/web-dist/assets/{now-DR2wt1DG.js.map → now-BOWxgbt3.js.map} +1 -1
- package/web-dist/assets/settings-BpkmIv_4.js +8 -0
- package/web-dist/assets/settings-BpkmIv_4.js.map +1 -0
- package/web-dist/assets/{send-CFwekPNi.js → sliders-horizontal-S3tmwxsP.js} +2 -2
- package/web-dist/assets/sliders-horizontal-S3tmwxsP.js.map +1 -0
- package/web-dist/assets/{useNow-ChB9z-N6.js → useNow-D9Zxr5Jb.js} +2 -2
- package/web-dist/assets/{useNow-ChB9z-N6.js.map → useNow-D9Zxr5Jb.js.map} +1 -1
- package/web-dist/index.html +2 -2
- package/web-dist/sw.js +1 -1
- package/web-dist/sw.js.map +1 -1
- package/web-dist/assets/Menu-Cqil5qVF.js +0 -2
- package/web-dist/assets/Menu-Cqil5qVF.js.map +0 -1
- package/web-dist/assets/code-BR4PKt8F.js +0 -8
- package/web-dist/assets/code-BR4PKt8F.js.map +0 -1
- package/web-dist/assets/dist-C4YmDHPG.js +0 -56
- package/web-dist/assets/dist-C4YmDHPG.js.map +0 -1
- package/web-dist/assets/index-DGGOgcLZ.css +0 -2
- package/web-dist/assets/send-CFwekPNi.js.map +0 -1
- package/web-dist/assets/settings-C4tDpSCU.js +0 -7
- package/web-dist/assets/settings-C4tDpSCU.js.map +0 -1
package/dist/switchyard.mjs
CHANGED
|
@@ -295,6 +295,19 @@ var RuntimeCapabilities = z3.object({
|
|
|
295
295
|
costAccounting: z3.boolean(),
|
|
296
296
|
unsupported: z3.array(z3.object({ capability: z3.string(), reason: z3.string() }))
|
|
297
297
|
});
|
|
298
|
+
var ProjectScan = z3.object({
|
|
299
|
+
state: z3.enum(["running", "done", "failed"]),
|
|
300
|
+
startedAt: Iso,
|
|
301
|
+
finishedAt: Iso.nullable(),
|
|
302
|
+
/** Repositories found across all folders. */
|
|
303
|
+
repositories: z3.number().int(),
|
|
304
|
+
/** New projects created by this scan. */
|
|
305
|
+
projectsCreated: z3.number().int(),
|
|
306
|
+
/** New checkouts (including the ones of new projects). */
|
|
307
|
+
checkoutsAdded: z3.number().int(),
|
|
308
|
+
/** "<folder>: <message>" per folder that failed; for state "failed", the reason last. */
|
|
309
|
+
errors: z3.array(z3.string())
|
|
310
|
+
});
|
|
298
311
|
var Host = z3.object({
|
|
299
312
|
id: Id,
|
|
300
313
|
name: z3.string(),
|
|
@@ -306,7 +319,10 @@ var Host = z3.object({
|
|
|
306
319
|
connectivity: Connectivity,
|
|
307
320
|
lastSeenAt: Iso.nullable(),
|
|
308
321
|
revokedAt: Iso.nullable(),
|
|
309
|
-
createdAt: Iso
|
|
322
|
+
createdAt: Iso,
|
|
323
|
+
/** Folders on the host whose git repositories become projects (absolute or ~/...). */
|
|
324
|
+
projectFolders: z3.array(z3.string()).optional(),
|
|
325
|
+
projectScan: ProjectScan.nullable().optional()
|
|
310
326
|
});
|
|
311
327
|
var WorktreeDir = z3.string().min(1).refine((d) => !d.startsWith("/") && !d.includes("\\"), "must be a relative path").refine((d) => !d.split("/").includes(".."), "must stay inside the repository").refine((d) => d.split("/").every((s) => s.length > 0 && s !== "."), "must be a plain relative path");
|
|
312
328
|
var Project = z3.object({
|
|
@@ -817,6 +833,23 @@ var UpdateProjectRequest = z4.object({
|
|
|
817
833
|
worktreeDir: WorktreeDir.optional()
|
|
818
834
|
});
|
|
819
835
|
var AddCheckoutRequest = z4.object({ hostId: z4.string(), path: z4.string().min(1) });
|
|
836
|
+
function isProjectFolderPath(s) {
|
|
837
|
+
if (s.includes("\0"))
|
|
838
|
+
return false;
|
|
839
|
+
return s === "~" || s.startsWith("~/") || s.startsWith("/");
|
|
840
|
+
}
|
|
841
|
+
var ProjectFolder = z4.string().trim().min(1).max(1024).refine(isProjectFolderPath, "must be an absolute path or start with ~/");
|
|
842
|
+
var UpdateHostRequest = z4.object({
|
|
843
|
+
projectFolders: z4.array(ProjectFolder).max(20).optional()
|
|
844
|
+
});
|
|
845
|
+
var ProjectScanResult = ProjectScan.extend({
|
|
846
|
+
added: z4.array(z4.object({
|
|
847
|
+
projectId: z4.string(),
|
|
848
|
+
name: z4.string(),
|
|
849
|
+
path: z4.string(),
|
|
850
|
+
newProject: z4.boolean()
|
|
851
|
+
}))
|
|
852
|
+
});
|
|
820
853
|
var ProfileInput = z4.object({
|
|
821
854
|
name: z4.string().min(1),
|
|
822
855
|
provider: z4.string().nullable().default(null),
|
|
@@ -1208,6 +1241,7 @@ var HostCommand = z7.discriminatedUnion("type", [
|
|
|
1208
1241
|
z7.object({ type: z7.literal("session.readNative"), sessionFile: z7.string() }),
|
|
1209
1242
|
z7.object({ type: z7.literal("session.snapshot"), sessionId: z7.string() }),
|
|
1210
1243
|
z7.object({ type: z7.literal("checkout.inspect"), path: z7.string() }),
|
|
1244
|
+
z7.object({ type: z7.literal("projects.scan"), folder: z7.string().min(1) }),
|
|
1211
1245
|
z7.object({
|
|
1212
1246
|
type: z7.literal("workspace.discover"),
|
|
1213
1247
|
checkoutPath: z7.string(),
|
|
@@ -1297,6 +1331,12 @@ var CheckoutInfo = z7.object({
|
|
|
1297
1331
|
repoIdentity: z7.string(),
|
|
1298
1332
|
defaultBranch: z7.string()
|
|
1299
1333
|
});
|
|
1334
|
+
var ScannedRepository = CheckoutInfo.extend({ name: z7.string() });
|
|
1335
|
+
var ProjectFolderScan = z7.object({
|
|
1336
|
+
/** The folder, ~ expanded and realpath'd. */
|
|
1337
|
+
folder: z7.string(),
|
|
1338
|
+
repositories: z7.array(ScannedRepository)
|
|
1339
|
+
});
|
|
1300
1340
|
var DiffFile = z7.object({
|
|
1301
1341
|
path: z7.string(),
|
|
1302
1342
|
oldPath: z7.string().nullable(),
|
|
@@ -5125,176 +5165,675 @@ function occurrenceLabel(o) {
|
|
|
5125
5165
|
}
|
|
5126
5166
|
}
|
|
5127
5167
|
|
|
5128
|
-
// packages/coordinator/dist/
|
|
5129
|
-
import webpush from "web-push";
|
|
5130
|
-
var webPushSender = async (subscription, payload, vapid) => {
|
|
5131
|
-
await webpush.sendNotification(subscription, JSON.stringify(payload), {
|
|
5132
|
-
vapidDetails: {
|
|
5133
|
-
subject: vapid.subject,
|
|
5134
|
-
publicKey: vapid.publicKey,
|
|
5135
|
-
privateKey: vapid.privateKey
|
|
5136
|
-
},
|
|
5137
|
-
TTL: 24 * 3600,
|
|
5138
|
-
urgency: payload.type === "show" ? "high" : "normal"
|
|
5139
|
-
});
|
|
5140
|
-
};
|
|
5141
|
-
var VAPID_KEY = "vapid";
|
|
5142
|
-
var SETTINGS_KEY = "notificationSettings";
|
|
5143
|
-
var DEFAULT_NOTIFICATION_SETTINGS = {
|
|
5144
|
-
quietHours: null,
|
|
5145
|
-
privatePreviews: false,
|
|
5146
|
-
preferredDeviceId: null
|
|
5147
|
-
};
|
|
5148
|
-
var PushService = class {
|
|
5149
|
-
ctx;
|
|
5150
|
-
sender;
|
|
5151
|
-
constructor(ctx, sender = webPushSender) {
|
|
5152
|
-
this.ctx = ctx;
|
|
5153
|
-
this.sender = sender;
|
|
5154
|
-
}
|
|
5155
|
-
/** VAPID keys, generated once and stored in `settings`. */
|
|
5156
|
-
vapid() {
|
|
5157
|
-
const existing = this.ctx.store.getSetting(VAPID_KEY, null);
|
|
5158
|
-
if (existing)
|
|
5159
|
-
return existing;
|
|
5160
|
-
const keys = webpush.generateVAPIDKeys();
|
|
5161
|
-
this.ctx.store.setSetting(VAPID_KEY, keys);
|
|
5162
|
-
return keys;
|
|
5163
|
-
}
|
|
5164
|
-
subject() {
|
|
5165
|
-
const url = this.ctx.config.publicUrl;
|
|
5166
|
-
return url.startsWith("https://") ? url : "mailto:switchyard@localhost";
|
|
5167
|
-
}
|
|
5168
|
-
settings() {
|
|
5169
|
-
return {
|
|
5170
|
-
...DEFAULT_NOTIFICATION_SETTINGS,
|
|
5171
|
-
...this.ctx.store.getSetting(SETTINGS_KEY, {})
|
|
5172
|
-
};
|
|
5173
|
-
}
|
|
5174
|
-
updateSettings(patch) {
|
|
5175
|
-
const next = NotificationSettings.parse({ ...this.settings(), ...patch });
|
|
5176
|
-
if (next.quietHours) {
|
|
5177
|
-
try {
|
|
5178
|
-
new Intl.DateTimeFormat("en-US", { timeZone: next.quietHours.timezone });
|
|
5179
|
-
} catch {
|
|
5180
|
-
throw new SwitchyardError("invalid_request", `Unknown timezone "${next.quietHours.timezone}"`);
|
|
5181
|
-
}
|
|
5182
|
-
}
|
|
5183
|
-
if (next.preferredDeviceId)
|
|
5184
|
-
this.ctx.store.devices.require(next.preferredDeviceId);
|
|
5185
|
-
this.ctx.store.setSetting(SETTINGS_KEY, next);
|
|
5186
|
-
return next;
|
|
5187
|
-
}
|
|
5188
|
-
subscriptions(deviceId) {
|
|
5189
|
-
return this.ctx.store.listPushSubscriptions(deviceId);
|
|
5190
|
-
}
|
|
5191
|
-
/** Store a browser subscription for a device and record its permission state. */
|
|
5192
|
-
subscribe(deviceId, sub, permission) {
|
|
5193
|
-
const { store } = this.ctx;
|
|
5194
|
-
return store.tx(() => {
|
|
5195
|
-
if (permission === "granted") {
|
|
5196
|
-
store.upsertPushSubscription({
|
|
5197
|
-
deviceId,
|
|
5198
|
-
endpoint: sub.endpoint,
|
|
5199
|
-
subscription: { endpoint: sub.endpoint, keys: sub.keys },
|
|
5200
|
-
createdAt: store.nowIso()
|
|
5201
|
-
});
|
|
5202
|
-
} else {
|
|
5203
|
-
store.deletePushSubscription(sub.endpoint);
|
|
5204
|
-
}
|
|
5205
|
-
return this.syncDevice(deviceId, permission);
|
|
5206
|
-
});
|
|
5207
|
-
}
|
|
5208
|
-
unsubscribe(deviceId, endpoint) {
|
|
5209
|
-
const { store } = this.ctx;
|
|
5210
|
-
return store.tx(() => {
|
|
5211
|
-
if (endpoint) {
|
|
5212
|
-
const own = store.listPushSubscriptions(deviceId).some((s) => s.endpoint === endpoint);
|
|
5213
|
-
if (own)
|
|
5214
|
-
store.deletePushSubscription(endpoint);
|
|
5215
|
-
} else {
|
|
5216
|
-
store.deletePushSubscriptionsForDevice(deviceId);
|
|
5217
|
-
}
|
|
5218
|
-
return this.syncDevice(deviceId);
|
|
5219
|
-
});
|
|
5220
|
-
}
|
|
5221
|
-
/** Mirror subscription presence into `device.push`. */
|
|
5222
|
-
syncDevice(deviceId, permission) {
|
|
5223
|
-
const { store } = this.ctx;
|
|
5224
|
-
const d = store.devices.require(deviceId);
|
|
5225
|
-
const push = {
|
|
5226
|
-
subscribed: store.listPushSubscriptions(deviceId).length > 0,
|
|
5227
|
-
permission: permission ?? d.push.permission
|
|
5228
|
-
};
|
|
5229
|
-
if (JSON.stringify(push) === JSON.stringify(d.push))
|
|
5230
|
-
return d;
|
|
5231
|
-
return store.putDevice({ ...d, push });
|
|
5232
|
-
}
|
|
5233
|
-
/** Send to every subscription of a device. 404/410 deletes the subscription. */
|
|
5234
|
-
async sendToDevice(deviceId, payload) {
|
|
5235
|
-
const subs = this.ctx.store.listPushSubscriptions(deviceId);
|
|
5236
|
-
const result = { sent: 0, removed: 0, failed: 0 };
|
|
5237
|
-
if (subs.length === 0)
|
|
5238
|
-
return result;
|
|
5239
|
-
const vapid = { ...this.vapid(), subject: this.subject() };
|
|
5240
|
-
for (const s of subs) {
|
|
5241
|
-
try {
|
|
5242
|
-
await this.sender(s.subscription, payload, vapid);
|
|
5243
|
-
result.sent++;
|
|
5244
|
-
} catch (err) {
|
|
5245
|
-
const status = err.statusCode;
|
|
5246
|
-
if (status === 404 || status === 410) {
|
|
5247
|
-
this.ctx.store.tx(() => {
|
|
5248
|
-
this.ctx.store.deletePushSubscription(s.endpoint);
|
|
5249
|
-
if (this.ctx.store.devices.get(deviceId))
|
|
5250
|
-
this.syncDevice(deviceId);
|
|
5251
|
-
});
|
|
5252
|
-
result.removed++;
|
|
5253
|
-
} else {
|
|
5254
|
-
result.failed++;
|
|
5255
|
-
this.ctx.log.warn({ err, deviceId, status }, "push delivery failed");
|
|
5256
|
-
}
|
|
5257
|
-
}
|
|
5258
|
-
}
|
|
5259
|
-
return result;
|
|
5260
|
-
}
|
|
5261
|
-
};
|
|
5262
|
-
|
|
5263
|
-
// packages/coordinator/dist/routes/automations.js
|
|
5264
|
-
import { createHash as createHash2 } from "node:crypto";
|
|
5168
|
+
// packages/coordinator/dist/workspaces.js
|
|
5265
5169
|
import { z as z8 } from "zod";
|
|
5266
|
-
var
|
|
5267
|
-
|
|
5268
|
-
|
|
5269
|
-
|
|
5270
|
-
|
|
5271
|
-
|
|
5272
|
-
|
|
5273
|
-
|
|
5274
|
-
|
|
5275
|
-
|
|
5276
|
-
|
|
5277
|
-
|
|
5278
|
-
|
|
5279
|
-
|
|
5280
|
-
|
|
5170
|
+
var DEFAULT_WORKTREE_DIR = ".worktrees";
|
|
5171
|
+
function worktreeDirOf(project) {
|
|
5172
|
+
return project?.worktreeDir || DEFAULT_WORKTREE_DIR;
|
|
5173
|
+
}
|
|
5174
|
+
function predictedWorktreePath(checkout, project, branch) {
|
|
5175
|
+
return `${checkout.path}/${worktreeDirOf(project)}/${branchSlug(branch)}`;
|
|
5176
|
+
}
|
|
5177
|
+
var ZERO_GIT = { dirty: false, changedFiles: 0, ahead: 0, behind: 0, unmerged: false };
|
|
5178
|
+
var NO_SETUP = { state: "none", log: "" };
|
|
5179
|
+
var SetupState2 = z8.object({
|
|
5180
|
+
state: z8.enum(["none", "running", "succeeded", "failed"]),
|
|
5181
|
+
log: z8.string()
|
|
5182
|
+
});
|
|
5183
|
+
function stripRef(branch) {
|
|
5184
|
+
return branch?.startsWith("refs/heads/") ? branch.slice("refs/heads/".length) : branch;
|
|
5185
|
+
}
|
|
5186
|
+
async function inspectCheckout(ctx, hostId, path) {
|
|
5187
|
+
const r = await ctx.hostHub.request(hostId, { type: "checkout.inspect", path });
|
|
5188
|
+
return CheckoutInfo.parse(r);
|
|
5189
|
+
}
|
|
5190
|
+
function recordCreatedWorkspace(ctx, project, checkout, info) {
|
|
5191
|
+
return ctx.store.tx(() => {
|
|
5192
|
+
const existing = ctx.store.workspaceByPath(checkout.hostId, info.path);
|
|
5193
|
+
const buffered = ctx.hostHub.takeBufferedWorkspace(checkout.hostId, info.path);
|
|
5194
|
+
const git = GitState.safeParse(buffered?.git ?? info.git);
|
|
5195
|
+
const setup = SetupState2.safeParse(buffered?.setup ?? info.setup);
|
|
5196
|
+
const ws = {
|
|
5197
|
+
id: existing?.id ?? newId("wsp"),
|
|
5198
|
+
checkoutId: checkout.id,
|
|
5199
|
+
projectId: project.id,
|
|
5200
|
+
hostId: checkout.hostId,
|
|
5201
|
+
path: info.path,
|
|
5202
|
+
branch: info.branch,
|
|
5203
|
+
baseBranch: info.baseBranch,
|
|
5204
|
+
provenance: "managed",
|
|
5205
|
+
git: git.success ? git.data : existing?.git ?? ZERO_GIT,
|
|
5206
|
+
setup: setup.success ? setup.data : existing?.setup ?? NO_SETUP,
|
|
5207
|
+
archivedAt: null,
|
|
5208
|
+
updatedAt: ctx.store.nowIso()
|
|
5209
|
+
};
|
|
5210
|
+
return ctx.store.putWorkspace(ws);
|
|
5281
5211
|
});
|
|
5282
|
-
|
|
5283
|
-
|
|
5284
|
-
|
|
5212
|
+
}
|
|
5213
|
+
async function refreshCheckoutWorkspaces(ctx, checkout) {
|
|
5214
|
+
const raw = await ctx.hostHub.request(checkout.hostId, {
|
|
5215
|
+
type: "workspace.discover",
|
|
5216
|
+
checkoutPath: checkout.path,
|
|
5217
|
+
worktreeDir: worktreeDirOf(ctx.store.projects.get(checkout.projectId))
|
|
5285
5218
|
});
|
|
5286
|
-
|
|
5287
|
-
|
|
5288
|
-
const
|
|
5289
|
-
|
|
5290
|
-
|
|
5219
|
+
const list = DiscoveredWorktree.array().parse(Array.isArray(raw) ? raw : raw?.worktrees ?? []);
|
|
5220
|
+
return ctx.store.tx(() => {
|
|
5221
|
+
const now = ctx.store.nowIso();
|
|
5222
|
+
const seen = /* @__PURE__ */ new Set();
|
|
5223
|
+
const out = [];
|
|
5224
|
+
for (const wt of list) {
|
|
5225
|
+
if (wt.bare || wt.prunable)
|
|
5226
|
+
continue;
|
|
5227
|
+
seen.add(wt.path);
|
|
5228
|
+
const existing = ctx.store.workspaceByPath(checkout.hostId, wt.path);
|
|
5229
|
+
const next = {
|
|
5230
|
+
id: existing?.id ?? newId("wsp"),
|
|
5231
|
+
checkoutId: checkout.id,
|
|
5232
|
+
projectId: checkout.projectId,
|
|
5233
|
+
hostId: checkout.hostId,
|
|
5234
|
+
path: wt.path,
|
|
5235
|
+
branch: stripRef(wt.branch),
|
|
5236
|
+
baseBranch: existing?.baseBranch ?? (wt.provenance === "main" ? null : checkout.defaultBranch),
|
|
5237
|
+
provenance: existing?.provenance === "managed" ? "managed" : wt.provenance,
|
|
5238
|
+
git: wt.git ?? existing?.git ?? ZERO_GIT,
|
|
5239
|
+
setup: existing?.setup ?? NO_SETUP,
|
|
5240
|
+
archivedAt: null,
|
|
5241
|
+
updatedAt: existing?.updatedAt ?? now
|
|
5242
|
+
};
|
|
5243
|
+
const changed = !existing || JSON.stringify({ ...existing, updatedAt: "" }) !== JSON.stringify({ ...next, updatedAt: "" });
|
|
5244
|
+
out.push(changed ? ctx.store.putWorkspace({ ...next, updatedAt: now }) : existing);
|
|
5291
5245
|
}
|
|
5292
|
-
|
|
5293
|
-
|
|
5246
|
+
for (const ws of ctx.store.workspaces.list("checkout_id = ? AND archived_at IS NULL", [
|
|
5247
|
+
checkout.id
|
|
5248
|
+
])) {
|
|
5249
|
+
if (!seen.has(ws.path))
|
|
5250
|
+
ctx.store.putWorkspace({ ...ws, archivedAt: now, updatedAt: now });
|
|
5251
|
+
}
|
|
5252
|
+
return out;
|
|
5294
5253
|
});
|
|
5295
|
-
|
|
5296
|
-
|
|
5297
|
-
|
|
5254
|
+
}
|
|
5255
|
+
async function refreshWorkspaceStatus(ctx, ws) {
|
|
5256
|
+
const raw = await ctx.hostHub.request(ws.hostId, {
|
|
5257
|
+
type: "workspace.status",
|
|
5258
|
+
path: ws.path,
|
|
5259
|
+
base: ws.baseBranch
|
|
5260
|
+
});
|
|
5261
|
+
const git = GitState.parse(raw?.git ?? raw);
|
|
5262
|
+
return ctx.store.tx(() => {
|
|
5263
|
+
const cur = ctx.store.workspaces.require(ws.id);
|
|
5264
|
+
if (JSON.stringify(cur.git) === JSON.stringify(git))
|
|
5265
|
+
return cur;
|
|
5266
|
+
return ctx.store.putWorkspace({ ...cur, git, updatedAt: ctx.store.nowIso() });
|
|
5267
|
+
});
|
|
5268
|
+
}
|
|
5269
|
+
function baseFor(ctx, ws) {
|
|
5270
|
+
return ws.baseBranch ?? ctx.store.checkouts.get(ws.checkoutId)?.defaultBranch ?? null;
|
|
5271
|
+
}
|
|
5272
|
+
|
|
5273
|
+
// packages/coordinator/dist/sessions/service.js
|
|
5274
|
+
var LIVE_RUNTIMES2 = ["starting", "idle", "running", "waiting_input"];
|
|
5275
|
+
function requireUsableHost(ctx, hostId) {
|
|
5276
|
+
const host = ctx.store.hosts.require(hostId);
|
|
5277
|
+
if (host.revokedAt)
|
|
5278
|
+
throw new SwitchyardError("forbidden", `Host ${host.name} was revoked`);
|
|
5279
|
+
if (!ctx.hostHub.isConnected(hostId)) {
|
|
5280
|
+
throw new SwitchyardError("host_unreachable", `Host ${host.name} is offline`, { hostId });
|
|
5281
|
+
}
|
|
5282
|
+
if (!host.piCompatible) {
|
|
5283
|
+
throw new SwitchyardError("pi_incompatible", `Pi ${host.piVersion ?? "(not found)"} on ${host.name} is outside the tested range`, { hostId });
|
|
5284
|
+
}
|
|
5285
|
+
}
|
|
5286
|
+
function requireConnected(ctx, hostId) {
|
|
5287
|
+
const host = ctx.store.hosts.require(hostId);
|
|
5288
|
+
if (!ctx.hostHub.isConnected(hostId)) {
|
|
5289
|
+
throw new SwitchyardError("host_unreachable", `Host ${host.name} is offline`, { hostId });
|
|
5290
|
+
}
|
|
5291
|
+
}
|
|
5292
|
+
function resolveProfile(ctx, profileId, project) {
|
|
5293
|
+
const id = profileId ?? project?.defaultProfileId ?? null;
|
|
5294
|
+
if (id)
|
|
5295
|
+
return ctx.store.profiles.require(id);
|
|
5296
|
+
const first = ctx.store.profiles.list(void 0, [], "created_at")[0];
|
|
5297
|
+
if (!first)
|
|
5298
|
+
throw new SwitchyardError("invalid_request", "No Pi profile configured");
|
|
5299
|
+
return first;
|
|
5300
|
+
}
|
|
5301
|
+
function titleFrom(prompt, fallback = "New session") {
|
|
5302
|
+
const line = prompt.trim().split("\n")[0]?.trim() ?? "";
|
|
5303
|
+
if (!line)
|
|
5304
|
+
return fallback;
|
|
5305
|
+
return line.length > 80 ? `${line.slice(0, 77)}...` : line;
|
|
5306
|
+
}
|
|
5307
|
+
function appendNotice(ctx, session, level, text) {
|
|
5308
|
+
return ctx.store.tx(() => {
|
|
5309
|
+
const item = {
|
|
5310
|
+
id: newId("itm"),
|
|
5311
|
+
sessionId: session.id,
|
|
5312
|
+
order: ctx.store.maxItemOrder(session.id) + 1,
|
|
5313
|
+
at: ctx.store.nowIso(),
|
|
5314
|
+
kind: "notice",
|
|
5315
|
+
data: { level, text }
|
|
5316
|
+
};
|
|
5317
|
+
ctx.store.upsertItem(item);
|
|
5318
|
+
ctx.store.appendEvent("transcript.item", { sessionId: session.id, hostId: session.hostId, projectId: session.projectId ?? void 0 }, item);
|
|
5319
|
+
return item;
|
|
5320
|
+
});
|
|
5321
|
+
}
|
|
5322
|
+
function failSession(ctx, sessionId, message) {
|
|
5323
|
+
ctx.store.tx(() => {
|
|
5324
|
+
const s = ctx.store.sessions.get(sessionId);
|
|
5325
|
+
if (!s)
|
|
5326
|
+
return;
|
|
5327
|
+
ctx.store.putSession({
|
|
5328
|
+
...s,
|
|
5329
|
+
runtime: "failed",
|
|
5330
|
+
runtimeDetail: message,
|
|
5331
|
+
lastActivityAt: ctx.store.nowIso()
|
|
5332
|
+
});
|
|
5333
|
+
appendNotice(ctx, s, "error", message);
|
|
5334
|
+
});
|
|
5335
|
+
}
|
|
5336
|
+
async function startOnHost(ctx, session, opts) {
|
|
5337
|
+
const cmd = {
|
|
5338
|
+
type: "session.start",
|
|
5339
|
+
sessionId: session.id,
|
|
5340
|
+
title: session.title,
|
|
5341
|
+
workspacePath: session.cwd,
|
|
5342
|
+
projectId: session.projectId,
|
|
5343
|
+
profile: session.profileSnapshot,
|
|
5344
|
+
nativeHandle: opts.nativeHandle,
|
|
5345
|
+
initialPrompt: opts.initialPrompt,
|
|
5346
|
+
dispatchKey: opts.dispatchKey ?? null,
|
|
5347
|
+
controlRevision: opts.controlRevision ?? 1,
|
|
5348
|
+
controlMode: opts.controlMode ?? "human",
|
|
5349
|
+
origin: opts.origin ?? "human",
|
|
5350
|
+
readOnly: session.readOnly
|
|
5351
|
+
};
|
|
5352
|
+
const result = await ctx.hostHub.request(session.hostId, cmd);
|
|
5353
|
+
return ctx.store.tx(() => {
|
|
5354
|
+
const s = ctx.store.sessions.require(session.id);
|
|
5355
|
+
const handle = result?.nativeHandle ?? null;
|
|
5356
|
+
if (handle && JSON.stringify(handle) !== JSON.stringify(s.nativeHandle)) {
|
|
5357
|
+
return ctx.store.putSession({ ...s, nativeHandle: handle });
|
|
5358
|
+
}
|
|
5359
|
+
return s;
|
|
5360
|
+
});
|
|
5361
|
+
}
|
|
5362
|
+
async function createWorkspace(ctx, project, checkout, branch, base) {
|
|
5363
|
+
requireConnected(ctx, checkout.hostId);
|
|
5364
|
+
const baseBranch = base ?? checkout.defaultBranch;
|
|
5365
|
+
const result = await ctx.hostHub.request(checkout.hostId, {
|
|
5366
|
+
type: "workspace.create",
|
|
5367
|
+
checkoutPath: checkout.path,
|
|
5368
|
+
branch,
|
|
5369
|
+
base: baseBranch,
|
|
5370
|
+
setupCommand: project.setupCommand,
|
|
5371
|
+
worktreeDir: worktreeDirOf(project)
|
|
5372
|
+
});
|
|
5373
|
+
const predicted = predictedWorktreePath(checkout, project, branch);
|
|
5374
|
+
return recordCreatedWorkspace(ctx, project, checkout, {
|
|
5375
|
+
path: typeof result?.path === "string" ? result.path : predicted,
|
|
5376
|
+
branch: typeof result?.branch === "string" ? result.branch : branch,
|
|
5377
|
+
baseBranch,
|
|
5378
|
+
git: result?.git,
|
|
5379
|
+
setup: result?.setup
|
|
5380
|
+
});
|
|
5381
|
+
}
|
|
5382
|
+
function createSession(ctx, input) {
|
|
5383
|
+
const project = ctx.store.projects.require(input.projectId);
|
|
5384
|
+
const profile = resolveProfile(ctx, input.profileId, project);
|
|
5385
|
+
let hostId;
|
|
5386
|
+
let workspace = null;
|
|
5387
|
+
let checkout = null;
|
|
5388
|
+
if ("existingId" in input.workspace) {
|
|
5389
|
+
workspace = ctx.store.workspaces.require(input.workspace.existingId);
|
|
5390
|
+
if (workspace.projectId !== project.id) {
|
|
5391
|
+
throw new SwitchyardError("invalid_request", "Workspace belongs to another project");
|
|
5392
|
+
}
|
|
5393
|
+
if (workspace.archivedAt)
|
|
5394
|
+
throw new SwitchyardError("conflict", "Workspace is archived");
|
|
5395
|
+
hostId = workspace.hostId;
|
|
5396
|
+
} else {
|
|
5397
|
+
checkout = ctx.store.checkouts.require(input.workspace.checkoutId);
|
|
5398
|
+
if (checkout.projectId !== project.id) {
|
|
5399
|
+
throw new SwitchyardError("invalid_request", "Checkout belongs to another project");
|
|
5400
|
+
}
|
|
5401
|
+
hostId = checkout.hostId;
|
|
5402
|
+
}
|
|
5403
|
+
requireUsableHost(ctx, hostId);
|
|
5404
|
+
const now = ctx.store.nowIso();
|
|
5405
|
+
const host = ctx.store.hosts.require(hostId);
|
|
5406
|
+
const session = {
|
|
5407
|
+
id: newId("ses"),
|
|
5408
|
+
title: input.title?.trim() || titleFrom(input.prompt),
|
|
5409
|
+
subtitle: null,
|
|
5410
|
+
hostId,
|
|
5411
|
+
workspaceId: workspace?.id ?? null,
|
|
5412
|
+
projectId: project.id,
|
|
5413
|
+
cwd: workspace?.path ?? predictedWorktreePath(checkout, project, input.workspace.newBranch),
|
|
5414
|
+
nativeHandle: null,
|
|
5415
|
+
profileSnapshot: profile,
|
|
5416
|
+
runtime: "starting",
|
|
5417
|
+
runtimeDetail: null,
|
|
5418
|
+
connectivity: host.connectivity,
|
|
5419
|
+
runId: null,
|
|
5420
|
+
taskId: null,
|
|
5421
|
+
provenance: "created",
|
|
5422
|
+
readOnly: false,
|
|
5423
|
+
lastActivityAt: now,
|
|
5424
|
+
archivedAt: null,
|
|
5425
|
+
queue: { steering: [], followUp: [] },
|
|
5426
|
+
stats: null,
|
|
5427
|
+
lastAssistantText: null,
|
|
5428
|
+
createdAt: now
|
|
5429
|
+
};
|
|
5430
|
+
ctx.store.putSession(session);
|
|
5431
|
+
const choice = input.workspace;
|
|
5432
|
+
const started = (async () => {
|
|
5433
|
+
try {
|
|
5434
|
+
let s = session;
|
|
5435
|
+
if (!("existingId" in choice)) {
|
|
5436
|
+
const ws = await createWorkspace(ctx, project, checkout, choice.newBranch, choice.base);
|
|
5437
|
+
s = ctx.store.tx(() => {
|
|
5438
|
+
const cur = ctx.store.sessions.require(session.id);
|
|
5439
|
+
return ctx.store.putSession({ ...cur, workspaceId: ws.id, cwd: ws.path });
|
|
5440
|
+
});
|
|
5441
|
+
}
|
|
5442
|
+
await startOnHost(ctx, s, {
|
|
5443
|
+
initialPrompt: input.prompt.trim() ? input.prompt : null,
|
|
5444
|
+
nativeHandle: null
|
|
5445
|
+
});
|
|
5446
|
+
} catch (err) {
|
|
5447
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
5448
|
+
ctx.log.warn({ err, sessionId: session.id }, "session start failed");
|
|
5449
|
+
failSession(ctx, session.id, `Could not start the session: ${message}`);
|
|
5450
|
+
}
|
|
5451
|
+
})();
|
|
5452
|
+
return { session, started };
|
|
5453
|
+
}
|
|
5454
|
+
|
|
5455
|
+
// packages/coordinator/dist/project-folders.js
|
|
5456
|
+
var PROJECT_SCAN_INTERVAL_MS = 10 * 6e4;
|
|
5457
|
+
var PROJECT_SCAN_MIN_AGE_ON_CONNECT_MS = 6e4;
|
|
5458
|
+
var PROJECT_SCAN_TIMEOUT_MS = 12e4;
|
|
5459
|
+
function normalizeProjectFolders(list) {
|
|
5460
|
+
const out = [];
|
|
5461
|
+
for (const raw of list) {
|
|
5462
|
+
let f = raw.trim();
|
|
5463
|
+
while (f.length > 1 && f.endsWith("/"))
|
|
5464
|
+
f = f.slice(0, -1);
|
|
5465
|
+
if (f && !out.includes(f))
|
|
5466
|
+
out.push(f);
|
|
5467
|
+
}
|
|
5468
|
+
return out;
|
|
5469
|
+
}
|
|
5470
|
+
function uniqueProjectName(existing, base, hostName) {
|
|
5471
|
+
const taken = new Set(existing.map((n) => n.toLowerCase()));
|
|
5472
|
+
if (!taken.has(base.toLowerCase()))
|
|
5473
|
+
return base;
|
|
5474
|
+
const withHost = `${base} (${hostName})`;
|
|
5475
|
+
if (!taken.has(withHost.toLowerCase()))
|
|
5476
|
+
return withHost;
|
|
5477
|
+
for (let i = 2; ; i++) {
|
|
5478
|
+
const candidate = `${withHost} ${i}`;
|
|
5479
|
+
if (!taken.has(candidate.toLowerCase()))
|
|
5480
|
+
return candidate;
|
|
5481
|
+
}
|
|
5482
|
+
}
|
|
5483
|
+
function newProject(input) {
|
|
5484
|
+
return {
|
|
5485
|
+
id: newId("prj"),
|
|
5486
|
+
name: input.name,
|
|
5487
|
+
repoIdentity: input.repoIdentity,
|
|
5488
|
+
defaultProfileId: input.defaultProfileId ?? null,
|
|
5489
|
+
defaultHostId: input.hostId,
|
|
5490
|
+
maxConcurrentRuns: input.maxConcurrentRuns ?? 1,
|
|
5491
|
+
automationEnabled: true,
|
|
5492
|
+
setupCommand: input.setupCommand ?? null,
|
|
5493
|
+
checks: input.checks ?? [],
|
|
5494
|
+
workspaceSharing: "single_writer",
|
|
5495
|
+
worktreeDir: input.worktreeDir ?? DEFAULT_WORKTREE_DIR,
|
|
5496
|
+
createdAt: input.now,
|
|
5497
|
+
updatedAt: input.now
|
|
5498
|
+
};
|
|
5499
|
+
}
|
|
5500
|
+
function setScan(ctx, hostId, scan) {
|
|
5501
|
+
ctx.store.tx(() => {
|
|
5502
|
+
const h = ctx.store.hosts.require(hostId);
|
|
5503
|
+
ctx.store.putHost({ ...h, projectScan: scan });
|
|
5504
|
+
});
|
|
5505
|
+
}
|
|
5506
|
+
function isTooOld(err) {
|
|
5507
|
+
return err instanceof SwitchyardError && err.code === "invalid_request" && err.message.startsWith("Invalid command");
|
|
5508
|
+
}
|
|
5509
|
+
async function scanHostProjectFolders(ctx, hostId) {
|
|
5510
|
+
const host = ctx.store.hosts.require(hostId);
|
|
5511
|
+
const folders = host.projectFolders ?? [];
|
|
5512
|
+
if (folders.length === 0) {
|
|
5513
|
+
throw new SwitchyardError("invalid_request", `${host.name} has no project folders. Add one first.`);
|
|
5514
|
+
}
|
|
5515
|
+
requireConnected(ctx, hostId);
|
|
5516
|
+
const scan = {
|
|
5517
|
+
state: "running",
|
|
5518
|
+
startedAt: ctx.store.nowIso(),
|
|
5519
|
+
finishedAt: null,
|
|
5520
|
+
repositories: 0,
|
|
5521
|
+
projectsCreated: 0,
|
|
5522
|
+
checkoutsAdded: 0,
|
|
5523
|
+
errors: []
|
|
5524
|
+
};
|
|
5525
|
+
const added = [];
|
|
5526
|
+
setScan(ctx, hostId, scan);
|
|
5527
|
+
try {
|
|
5528
|
+
const newCheckouts = [];
|
|
5529
|
+
for (const folder of folders) {
|
|
5530
|
+
let result;
|
|
5531
|
+
try {
|
|
5532
|
+
const raw = await ctx.hostHub.request(hostId, { type: "projects.scan", folder }, { timeoutMs: PROJECT_SCAN_TIMEOUT_MS });
|
|
5533
|
+
result = ProjectFolderScan.parse(raw);
|
|
5534
|
+
} catch (err) {
|
|
5535
|
+
if (isTooOld(err)) {
|
|
5536
|
+
throw new SwitchyardError("unsupported_capability", `${host.name} runs Switchyard ${host.agentVersion}, which cannot scan project folders. Update its host agent.`);
|
|
5537
|
+
}
|
|
5538
|
+
if (err instanceof SwitchyardError && err.code === "host_unreachable")
|
|
5539
|
+
throw err;
|
|
5540
|
+
scan.errors.push(`${folder}: ${err instanceof Error ? err.message : String(err)}`);
|
|
5541
|
+
continue;
|
|
5542
|
+
}
|
|
5543
|
+
scan.repositories += result.repositories.length;
|
|
5544
|
+
for (const repo of result.repositories) {
|
|
5545
|
+
const r = ctx.store.tx(() => {
|
|
5546
|
+
const known = ctx.store.checkouts.list("host_id = ? AND path = ?", [
|
|
5547
|
+
hostId,
|
|
5548
|
+
repo.root
|
|
5549
|
+
])[0];
|
|
5550
|
+
if (known)
|
|
5551
|
+
return null;
|
|
5552
|
+
const now = ctx.store.nowIso();
|
|
5553
|
+
let project = ctx.store.projects.list("repo_identity = ?", [repo.repoIdentity])[0];
|
|
5554
|
+
let created = false;
|
|
5555
|
+
if (!project) {
|
|
5556
|
+
const names = ctx.store.projects.list().map((p) => p.name);
|
|
5557
|
+
project = ctx.store.putProject(newProject({
|
|
5558
|
+
name: uniqueProjectName(names, repo.name, host.name),
|
|
5559
|
+
repoIdentity: repo.repoIdentity,
|
|
5560
|
+
hostId,
|
|
5561
|
+
now
|
|
5562
|
+
}));
|
|
5563
|
+
created = true;
|
|
5564
|
+
}
|
|
5565
|
+
const checkout = ctx.store.putCheckout({
|
|
5566
|
+
id: newId("chk"),
|
|
5567
|
+
projectId: project.id,
|
|
5568
|
+
hostId,
|
|
5569
|
+
path: repo.root,
|
|
5570
|
+
commonDir: repo.commonDir,
|
|
5571
|
+
defaultBranch: repo.defaultBranch,
|
|
5572
|
+
createdAt: now
|
|
5573
|
+
});
|
|
5574
|
+
return { project, checkout, created };
|
|
5575
|
+
});
|
|
5576
|
+
if (!r)
|
|
5577
|
+
continue;
|
|
5578
|
+
scan.checkoutsAdded++;
|
|
5579
|
+
if (r.created)
|
|
5580
|
+
scan.projectsCreated++;
|
|
5581
|
+
newCheckouts.push(r.checkout);
|
|
5582
|
+
added.push({
|
|
5583
|
+
projectId: r.project.id,
|
|
5584
|
+
name: r.project.name,
|
|
5585
|
+
path: repo.root,
|
|
5586
|
+
newProject: r.created
|
|
5587
|
+
});
|
|
5588
|
+
}
|
|
5589
|
+
}
|
|
5590
|
+
for (const checkout of newCheckouts) {
|
|
5591
|
+
try {
|
|
5592
|
+
await refreshCheckoutWorkspaces(ctx, checkout);
|
|
5593
|
+
} catch (err) {
|
|
5594
|
+
ctx.log.warn({ err, checkoutId: checkout.id }, "workspace discovery failed");
|
|
5595
|
+
}
|
|
5596
|
+
}
|
|
5597
|
+
const done = { ...scan, state: "done", finishedAt: ctx.store.nowIso() };
|
|
5598
|
+
setScan(ctx, hostId, done);
|
|
5599
|
+
return { ...done, added };
|
|
5600
|
+
} catch (err) {
|
|
5601
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
5602
|
+
setScan(ctx, hostId, {
|
|
5603
|
+
...scan,
|
|
5604
|
+
state: "failed",
|
|
5605
|
+
finishedAt: ctx.store.nowIso(),
|
|
5606
|
+
errors: [...scan.errors, message]
|
|
5607
|
+
});
|
|
5608
|
+
throw err;
|
|
5609
|
+
}
|
|
5610
|
+
}
|
|
5611
|
+
var ProjectFolderService = class {
|
|
5612
|
+
ctx;
|
|
5613
|
+
opts;
|
|
5614
|
+
inflight = /* @__PURE__ */ new Map();
|
|
5615
|
+
timer = null;
|
|
5616
|
+
onConnected = ({ hostId }) => {
|
|
5617
|
+
const host = this.ctx.store.hosts.get(hostId);
|
|
5618
|
+
if (!host || host.revokedAt || !(host.projectFolders ?? []).length)
|
|
5619
|
+
return;
|
|
5620
|
+
const last = host.projectScan?.startedAt ? Date.parse(host.projectScan.startedAt) : null;
|
|
5621
|
+
const now = Date.parse(this.ctx.store.nowIso());
|
|
5622
|
+
if (last !== null && now - last < PROJECT_SCAN_MIN_AGE_ON_CONNECT_MS)
|
|
5623
|
+
return;
|
|
5624
|
+
this.background(hostId);
|
|
5625
|
+
};
|
|
5626
|
+
constructor(ctx, opts) {
|
|
5627
|
+
this.ctx = ctx;
|
|
5628
|
+
this.opts = opts;
|
|
5629
|
+
}
|
|
5630
|
+
/** Scan a host now; joins a scan already running for it. */
|
|
5631
|
+
scan(hostId) {
|
|
5632
|
+
const running = this.inflight.get(hostId);
|
|
5633
|
+
if (running)
|
|
5634
|
+
return running;
|
|
5635
|
+
const p = scanHostProjectFolders(this.ctx, hostId).finally(() => this.inflight.delete(hostId));
|
|
5636
|
+
this.inflight.set(hostId, p);
|
|
5637
|
+
return p;
|
|
5638
|
+
}
|
|
5639
|
+
/** Scan without waiting; failures are logged (and recorded on the host). */
|
|
5640
|
+
background(hostId) {
|
|
5641
|
+
this.scan(hostId).catch((err) => this.ctx.log.warn({ err, hostId }, "project folder scan failed"));
|
|
5642
|
+
}
|
|
5643
|
+
start() {
|
|
5644
|
+
this.ctx.hub.on("host.connected", this.onConnected);
|
|
5645
|
+
if (this.opts.intervalMs > 0) {
|
|
5646
|
+
this.timer = setInterval(() => this.tick(), this.opts.intervalMs);
|
|
5647
|
+
this.timer.unref();
|
|
5648
|
+
}
|
|
5649
|
+
}
|
|
5650
|
+
stop() {
|
|
5651
|
+
if (this.timer)
|
|
5652
|
+
clearInterval(this.timer);
|
|
5653
|
+
this.timer = null;
|
|
5654
|
+
this.ctx.hub.off("host.connected", this.onConnected);
|
|
5655
|
+
}
|
|
5656
|
+
tick() {
|
|
5657
|
+
for (const h of this.ctx.store.hosts.list()) {
|
|
5658
|
+
if (h.revokedAt || !(h.projectFolders ?? []).length)
|
|
5659
|
+
continue;
|
|
5660
|
+
if (!this.ctx.hostHub.isConnected(h.id))
|
|
5661
|
+
continue;
|
|
5662
|
+
this.background(h.id);
|
|
5663
|
+
}
|
|
5664
|
+
}
|
|
5665
|
+
};
|
|
5666
|
+
|
|
5667
|
+
// packages/coordinator/dist/push.js
|
|
5668
|
+
import webpush from "web-push";
|
|
5669
|
+
var webPushSender = async (subscription, payload, vapid) => {
|
|
5670
|
+
await webpush.sendNotification(subscription, JSON.stringify(payload), {
|
|
5671
|
+
vapidDetails: {
|
|
5672
|
+
subject: vapid.subject,
|
|
5673
|
+
publicKey: vapid.publicKey,
|
|
5674
|
+
privateKey: vapid.privateKey
|
|
5675
|
+
},
|
|
5676
|
+
TTL: 24 * 3600,
|
|
5677
|
+
urgency: payload.type === "show" ? "high" : "normal"
|
|
5678
|
+
});
|
|
5679
|
+
};
|
|
5680
|
+
var VAPID_KEY = "vapid";
|
|
5681
|
+
var SETTINGS_KEY = "notificationSettings";
|
|
5682
|
+
var DEFAULT_NOTIFICATION_SETTINGS = {
|
|
5683
|
+
quietHours: null,
|
|
5684
|
+
privatePreviews: false,
|
|
5685
|
+
preferredDeviceId: null
|
|
5686
|
+
};
|
|
5687
|
+
var PushService = class {
|
|
5688
|
+
ctx;
|
|
5689
|
+
sender;
|
|
5690
|
+
constructor(ctx, sender = webPushSender) {
|
|
5691
|
+
this.ctx = ctx;
|
|
5692
|
+
this.sender = sender;
|
|
5693
|
+
}
|
|
5694
|
+
/** VAPID keys, generated once and stored in `settings`. */
|
|
5695
|
+
vapid() {
|
|
5696
|
+
const existing = this.ctx.store.getSetting(VAPID_KEY, null);
|
|
5697
|
+
if (existing)
|
|
5698
|
+
return existing;
|
|
5699
|
+
const keys = webpush.generateVAPIDKeys();
|
|
5700
|
+
this.ctx.store.setSetting(VAPID_KEY, keys);
|
|
5701
|
+
return keys;
|
|
5702
|
+
}
|
|
5703
|
+
subject() {
|
|
5704
|
+
const url = this.ctx.config.publicUrl;
|
|
5705
|
+
return url.startsWith("https://") ? url : "mailto:switchyard@localhost";
|
|
5706
|
+
}
|
|
5707
|
+
settings() {
|
|
5708
|
+
return {
|
|
5709
|
+
...DEFAULT_NOTIFICATION_SETTINGS,
|
|
5710
|
+
...this.ctx.store.getSetting(SETTINGS_KEY, {})
|
|
5711
|
+
};
|
|
5712
|
+
}
|
|
5713
|
+
updateSettings(patch) {
|
|
5714
|
+
const next = NotificationSettings.parse({ ...this.settings(), ...patch });
|
|
5715
|
+
if (next.quietHours) {
|
|
5716
|
+
try {
|
|
5717
|
+
new Intl.DateTimeFormat("en-US", { timeZone: next.quietHours.timezone });
|
|
5718
|
+
} catch {
|
|
5719
|
+
throw new SwitchyardError("invalid_request", `Unknown timezone "${next.quietHours.timezone}"`);
|
|
5720
|
+
}
|
|
5721
|
+
}
|
|
5722
|
+
if (next.preferredDeviceId)
|
|
5723
|
+
this.ctx.store.devices.require(next.preferredDeviceId);
|
|
5724
|
+
this.ctx.store.setSetting(SETTINGS_KEY, next);
|
|
5725
|
+
return next;
|
|
5726
|
+
}
|
|
5727
|
+
subscriptions(deviceId) {
|
|
5728
|
+
return this.ctx.store.listPushSubscriptions(deviceId);
|
|
5729
|
+
}
|
|
5730
|
+
/** Store a browser subscription for a device and record its permission state. */
|
|
5731
|
+
subscribe(deviceId, sub, permission) {
|
|
5732
|
+
const { store } = this.ctx;
|
|
5733
|
+
return store.tx(() => {
|
|
5734
|
+
if (permission === "granted") {
|
|
5735
|
+
store.upsertPushSubscription({
|
|
5736
|
+
deviceId,
|
|
5737
|
+
endpoint: sub.endpoint,
|
|
5738
|
+
subscription: { endpoint: sub.endpoint, keys: sub.keys },
|
|
5739
|
+
createdAt: store.nowIso()
|
|
5740
|
+
});
|
|
5741
|
+
} else {
|
|
5742
|
+
store.deletePushSubscription(sub.endpoint);
|
|
5743
|
+
}
|
|
5744
|
+
return this.syncDevice(deviceId, permission);
|
|
5745
|
+
});
|
|
5746
|
+
}
|
|
5747
|
+
unsubscribe(deviceId, endpoint) {
|
|
5748
|
+
const { store } = this.ctx;
|
|
5749
|
+
return store.tx(() => {
|
|
5750
|
+
if (endpoint) {
|
|
5751
|
+
const own = store.listPushSubscriptions(deviceId).some((s) => s.endpoint === endpoint);
|
|
5752
|
+
if (own)
|
|
5753
|
+
store.deletePushSubscription(endpoint);
|
|
5754
|
+
} else {
|
|
5755
|
+
store.deletePushSubscriptionsForDevice(deviceId);
|
|
5756
|
+
}
|
|
5757
|
+
return this.syncDevice(deviceId);
|
|
5758
|
+
});
|
|
5759
|
+
}
|
|
5760
|
+
/** Mirror subscription presence into `device.push`. */
|
|
5761
|
+
syncDevice(deviceId, permission) {
|
|
5762
|
+
const { store } = this.ctx;
|
|
5763
|
+
const d = store.devices.require(deviceId);
|
|
5764
|
+
const push = {
|
|
5765
|
+
subscribed: store.listPushSubscriptions(deviceId).length > 0,
|
|
5766
|
+
permission: permission ?? d.push.permission
|
|
5767
|
+
};
|
|
5768
|
+
if (JSON.stringify(push) === JSON.stringify(d.push))
|
|
5769
|
+
return d;
|
|
5770
|
+
return store.putDevice({ ...d, push });
|
|
5771
|
+
}
|
|
5772
|
+
/** Send to every subscription of a device. 404/410 deletes the subscription. */
|
|
5773
|
+
async sendToDevice(deviceId, payload) {
|
|
5774
|
+
const subs = this.ctx.store.listPushSubscriptions(deviceId);
|
|
5775
|
+
const result = { sent: 0, removed: 0, failed: 0 };
|
|
5776
|
+
if (subs.length === 0)
|
|
5777
|
+
return result;
|
|
5778
|
+
const vapid = { ...this.vapid(), subject: this.subject() };
|
|
5779
|
+
for (const s of subs) {
|
|
5780
|
+
try {
|
|
5781
|
+
await this.sender(s.subscription, payload, vapid);
|
|
5782
|
+
result.sent++;
|
|
5783
|
+
} catch (err) {
|
|
5784
|
+
const status = err.statusCode;
|
|
5785
|
+
if (status === 404 || status === 410) {
|
|
5786
|
+
this.ctx.store.tx(() => {
|
|
5787
|
+
this.ctx.store.deletePushSubscription(s.endpoint);
|
|
5788
|
+
if (this.ctx.store.devices.get(deviceId))
|
|
5789
|
+
this.syncDevice(deviceId);
|
|
5790
|
+
});
|
|
5791
|
+
result.removed++;
|
|
5792
|
+
} else {
|
|
5793
|
+
result.failed++;
|
|
5794
|
+
this.ctx.log.warn({ err, deviceId, status }, "push delivery failed");
|
|
5795
|
+
}
|
|
5796
|
+
}
|
|
5797
|
+
}
|
|
5798
|
+
return result;
|
|
5799
|
+
}
|
|
5800
|
+
};
|
|
5801
|
+
|
|
5802
|
+
// packages/coordinator/dist/routes/automations.js
|
|
5803
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
5804
|
+
import { z as z9 } from "zod";
|
|
5805
|
+
var ListQuery = z9.object({ projectId: z9.string().optional() });
|
|
5806
|
+
var HistoryQuery = z9.object({ limit: z9.coerce.number().int().min(1).max(1e3).default(100) });
|
|
5807
|
+
var WEBHOOK_BODY_LIMIT = 256 * 1024;
|
|
5808
|
+
function registerAutomationRoutes(app, ctx) {
|
|
5809
|
+
const { store } = ctx;
|
|
5810
|
+
const svc = () => requireService(ctx, "automations");
|
|
5811
|
+
const idOf = (req) => req.params.id;
|
|
5812
|
+
const view = (id) => {
|
|
5813
|
+
const a = store.automations.require(id);
|
|
5814
|
+
return { ...a, summary: svc().describe(a) };
|
|
5815
|
+
};
|
|
5816
|
+
app.get("/api/automations", async (req) => {
|
|
5817
|
+
const q = parse(ListQuery, req.query, "query");
|
|
5818
|
+
const list = q.projectId ? store.automations.list("project_id = ?", [q.projectId], "created_at") : store.automations.list(void 0, [], "created_at");
|
|
5819
|
+
return list.map((a) => ({ ...a, summary: svc().describe(a) }));
|
|
5820
|
+
});
|
|
5821
|
+
app.post("/api/automations", async (req, reply) => {
|
|
5822
|
+
const a = svc().create(validated(req.body));
|
|
5823
|
+
return reply.code(201).send(view(a.id));
|
|
5824
|
+
});
|
|
5825
|
+
app.get("/api/automations/:id", async (req) => view(idOf(req)));
|
|
5826
|
+
app.patch("/api/automations/:id", async (req) => {
|
|
5827
|
+
const body = req.body ?? {};
|
|
5828
|
+
if (typeof body !== "object" || Array.isArray(body)) {
|
|
5829
|
+
throw new SwitchyardError("invalid_request", "Expected an object");
|
|
5830
|
+
}
|
|
5831
|
+
svc().update(idOf(req), body);
|
|
5832
|
+
return view(idOf(req));
|
|
5833
|
+
});
|
|
5834
|
+
app.delete("/api/automations/:id", async (req) => {
|
|
5835
|
+
svc().delete(idOf(req));
|
|
5836
|
+
return { ok: true };
|
|
5298
5837
|
});
|
|
5299
5838
|
app.get("/api/automations/:id/preview", async (req) => svc().preview(store.automations.require(idOf(req))));
|
|
5300
5839
|
app.post("/api/automations/preview", async (req) => {
|
|
@@ -5343,455 +5882,168 @@ function registerAutomationRoutes(app, ctx) {
|
|
|
5343
5882
|
if (!a.enabled)
|
|
5344
5883
|
throw new SwitchyardError("conflict", "This automation is disabled");
|
|
5345
5884
|
const idem = headerValue(req.headers["idempotency-key"]) ?? headerValue(req.headers["x-github-delivery"]);
|
|
5346
|
-
const payload = req.body ?? {};
|
|
5347
|
-
const key = idem ? `hook:${idem}` : derivedWebhookKey(payload, store.now());
|
|
5348
|
-
const match = filtersMatch(a.filters, payload);
|
|
5349
|
-
const r = svc().fire(a.id, match.ok ? { key, scheduledFor: null, payload } : { key, scheduledFor: null, record: { state: "filtered", reason: match.reason } });
|
|
5350
|
-
return reply.code(202).send({
|
|
5351
|
-
occurrenceId: r.occurrence.id,
|
|
5352
|
-
state: r.occurrence.state,
|
|
5353
|
-
...r.duplicate ? { duplicate: true } : {}
|
|
5354
|
-
});
|
|
5355
|
-
});
|
|
5356
|
-
}
|
|
5357
|
-
var WEBHOOK_DEDUPE_WINDOW_MS = 10 * 6e4;
|
|
5358
|
-
function derivedWebhookKey(payload, now) {
|
|
5359
|
-
const bucket = Math.floor(now.getTime() / WEBHOOK_DEDUPE_WINDOW_MS);
|
|
5360
|
-
const digest = createHash2("sha256").update(JSON.stringify(payload)).digest("hex");
|
|
5361
|
-
return `hook:body:${digest}:${bucket}`;
|
|
5362
|
-
}
|
|
5363
|
-
function headerValue(v) {
|
|
5364
|
-
const s = Array.isArray(v) ? v[0] : v;
|
|
5365
|
-
return s?.trim() ? s.trim().slice(0, 200) : null;
|
|
5366
|
-
}
|
|
5367
|
-
function validated(body) {
|
|
5368
|
-
return parse(AutomationInput, body);
|
|
5369
|
-
}
|
|
5370
|
-
|
|
5371
|
-
// packages/coordinator/dist/routes/push.js
|
|
5372
|
-
import { z as z9 } from "zod";
|
|
5373
|
-
var DeleteSubscription = z9.object({ endpoint: z9.string().optional() });
|
|
5374
|
-
function registerPushRoutes(app, ctx) {
|
|
5375
|
-
const push = () => requireService(ctx, "push");
|
|
5376
|
-
app.get("/api/push/vapid-public-key", async () => ({ publicKey: push().vapid().publicKey }));
|
|
5377
|
-
app.post("/api/push/subscriptions", async (req, reply) => {
|
|
5378
|
-
const body = parse(PushSubscriptionRequest, req.body);
|
|
5379
|
-
const device = deviceOf(req);
|
|
5380
|
-
const updated = push().subscribe(device.id, { endpoint: body.endpoint, keys: body.keys }, body.permission);
|
|
5381
|
-
return reply.code(201).send(updated);
|
|
5382
|
-
});
|
|
5383
|
-
app.delete("/api/push/subscriptions", async (req) => {
|
|
5384
|
-
const body = parse(DeleteSubscription, req.body ?? {});
|
|
5385
|
-
return push().unsubscribe(deviceOf(req).id, body.endpoint ?? null);
|
|
5386
|
-
});
|
|
5387
|
-
app.post("/api/push/test", async (req) => {
|
|
5388
|
-
const device = deviceOf(req);
|
|
5389
|
-
if (push().subscriptions(device.id).length === 0) {
|
|
5390
|
-
throw new SwitchyardError("conflict", "This device has no push subscription");
|
|
5391
|
-
}
|
|
5392
|
-
const r = await push().sendToDevice(device.id, {
|
|
5393
|
-
type: "show",
|
|
5394
|
-
title: "Switchyard",
|
|
5395
|
-
body: "Test notification from Switchyard",
|
|
5396
|
-
tag: "switchyard-test",
|
|
5397
|
-
url: "/settings",
|
|
5398
|
-
renotify: true
|
|
5399
|
-
});
|
|
5400
|
-
return { ok: r.sent > 0, ...r };
|
|
5401
|
-
});
|
|
5402
|
-
app.get("/api/me/notification-settings", async () => push().settings());
|
|
5403
|
-
app.patch("/api/me/notification-settings", async (req) => {
|
|
5404
|
-
const body = parse(NotificationSettings.partial(), req.body);
|
|
5405
|
-
return push().updateSettings(body);
|
|
5406
|
-
});
|
|
5407
|
-
}
|
|
5408
|
-
|
|
5409
|
-
// packages/coordinator/dist/routes/tasks.js
|
|
5410
|
-
import { z as z11 } from "zod";
|
|
5411
|
-
|
|
5412
|
-
// packages/coordinator/dist/sessions/control.js
|
|
5413
|
-
function requireRun(ctx, session) {
|
|
5414
|
-
const run = ctx.store.runForSession(session);
|
|
5415
|
-
if (!run) {
|
|
5416
|
-
throw new SwitchyardError("invalid_request", "This session is not controlled by an automation run");
|
|
5417
|
-
}
|
|
5418
|
-
return run;
|
|
5419
|
-
}
|
|
5420
|
-
async function setControl(ctx, sessionId, mode) {
|
|
5421
|
-
const { session, run } = ctx.store.tx(() => {
|
|
5422
|
-
const session2 = ctx.store.sessions.require(sessionId);
|
|
5423
|
-
const run2 = requireRun(ctx, session2);
|
|
5424
|
-
const now = ctx.store.nowIso();
|
|
5425
|
-
const updated = ctx.store.putRun({ ...run2, controlMode: mode, controlRevision: run2.controlRevision + 1, updatedAt: now }, session2.projectId ?? void 0);
|
|
5426
|
-
return { session: session2, run: updated };
|
|
5427
|
-
});
|
|
5428
|
-
ctx.hub.emit("control.changed", { run, session, mode });
|
|
5429
|
-
if (mode === "human")
|
|
5430
|
-
getService(ctx, "dispatcher")?.onTakeover(run.id);
|
|
5431
|
-
if (ctx.hostHub.isConnected(session.hostId)) {
|
|
5432
|
-
try {
|
|
5433
|
-
await ctx.hostHub.request(session.hostId, {
|
|
5434
|
-
type: "session.setControl",
|
|
5435
|
-
sessionId: session.id,
|
|
5436
|
-
controlRevision: run.controlRevision,
|
|
5437
|
-
mode
|
|
5438
|
-
});
|
|
5439
|
-
} catch (err) {
|
|
5440
|
-
ctx.log.warn({ err, sessionId }, "session.setControl failed");
|
|
5441
|
-
}
|
|
5442
|
-
}
|
|
5443
|
-
return { session, run };
|
|
5444
|
-
}
|
|
5445
|
-
function takeover(ctx, sessionId) {
|
|
5446
|
-
return setControl(ctx, sessionId, "human");
|
|
5447
|
-
}
|
|
5448
|
-
async function takeoverRun(ctx, runId) {
|
|
5449
|
-
const run = ctx.store.runs.require(runId);
|
|
5450
|
-
if (run.sessionId && ctx.store.sessions.get(run.sessionId)?.runId === run.id) {
|
|
5451
|
-
return (await takeover(ctx, run.sessionId)).run;
|
|
5452
|
-
}
|
|
5453
|
-
const updated = ctx.store.tx(() => {
|
|
5454
|
-
const cur = ctx.store.runs.require(runId);
|
|
5455
|
-
const task = ctx.store.tasks.get(cur.taskId);
|
|
5456
|
-
return ctx.store.putRun({
|
|
5457
|
-
...cur,
|
|
5458
|
-
controlMode: "human",
|
|
5459
|
-
controlRevision: cur.controlRevision + 1,
|
|
5460
|
-
updatedAt: ctx.store.nowIso()
|
|
5461
|
-
}, task?.projectId);
|
|
5462
|
-
});
|
|
5463
|
-
getService(ctx, "dispatcher")?.onTakeover(updated.id);
|
|
5464
|
-
return updated;
|
|
5465
|
-
}
|
|
5466
|
-
async function returnToAutomation(ctx, sessionId) {
|
|
5467
|
-
const result = await setControl(ctx, sessionId, "automation");
|
|
5468
|
-
const session = ctx.store.sessions.require(sessionId);
|
|
5469
|
-
if (session.runtime === "idle") {
|
|
5470
|
-
const runs = getService(ctx, "runs");
|
|
5471
|
-
void runs?.onSessionSettled({
|
|
5472
|
-
session,
|
|
5473
|
-
aborted: false,
|
|
5474
|
-
lastAssistantText: session.lastAssistantText
|
|
5475
|
-
}).catch((err) => ctx.log.error({ err, sessionId }, "re-evaluation failed"));
|
|
5476
|
-
}
|
|
5477
|
-
getService(ctx, "dispatcher")?.schedule();
|
|
5478
|
-
return result;
|
|
5479
|
-
}
|
|
5480
|
-
async function stopSession(ctx, sessionId) {
|
|
5481
|
-
const session = ctx.store.sessions.require(sessionId);
|
|
5482
|
-
const pre = ctx.store.runForSession(session);
|
|
5483
|
-
if (pre && (pre.state === "dispatching" || pre.state === "uncertain")) {
|
|
5484
|
-
throw new SwitchyardError("conflict", "The launch is not confirmed yet; wait for the host or pause automation instead");
|
|
5485
|
-
}
|
|
5486
|
-
await ctx.hostHub.request(session.hostId, {
|
|
5487
|
-
type: "session.abort",
|
|
5488
|
-
sessionId,
|
|
5489
|
-
clearQueue: true
|
|
5490
|
-
});
|
|
5491
|
-
const run = ctx.store.runForSession(session);
|
|
5492
|
-
if (run && !["succeeded", "failed", "canceled", "interrupted"].includes(run.state)) {
|
|
5493
|
-
ctx.store.tx(() => {
|
|
5494
|
-
const now = ctx.store.nowIso();
|
|
5495
|
-
ctx.store.putRun({ ...run, state: "interrupted", updatedAt: now }, session.projectId ?? void 0);
|
|
5496
|
-
const task = ctx.store.tasks.get(run.taskId);
|
|
5497
|
-
if (task && task.currentRunId === run.id) {
|
|
5498
|
-
ctx.store.putTask({
|
|
5499
|
-
...task,
|
|
5500
|
-
blocked: { reason: "Stopped by user" },
|
|
5501
|
-
updatedAt: now,
|
|
5502
|
-
lastActivityAt: now
|
|
5503
|
-
});
|
|
5504
|
-
}
|
|
5505
|
-
});
|
|
5506
|
-
}
|
|
5507
|
-
return ctx.store.sessions.require(sessionId);
|
|
5508
|
-
}
|
|
5509
|
-
|
|
5510
|
-
// packages/coordinator/dist/workspaces.js
|
|
5511
|
-
import { z as z10 } from "zod";
|
|
5512
|
-
var DEFAULT_WORKTREE_DIR = ".worktrees";
|
|
5513
|
-
function worktreeDirOf(project) {
|
|
5514
|
-
return project?.worktreeDir || DEFAULT_WORKTREE_DIR;
|
|
5515
|
-
}
|
|
5516
|
-
function predictedWorktreePath(checkout, project, branch) {
|
|
5517
|
-
return `${checkout.path}/${worktreeDirOf(project)}/${branchSlug(branch)}`;
|
|
5518
|
-
}
|
|
5519
|
-
var ZERO_GIT = { dirty: false, changedFiles: 0, ahead: 0, behind: 0, unmerged: false };
|
|
5520
|
-
var NO_SETUP = { state: "none", log: "" };
|
|
5521
|
-
var SetupState2 = z10.object({
|
|
5522
|
-
state: z10.enum(["none", "running", "succeeded", "failed"]),
|
|
5523
|
-
log: z10.string()
|
|
5524
|
-
});
|
|
5525
|
-
function stripRef(branch) {
|
|
5526
|
-
return branch?.startsWith("refs/heads/") ? branch.slice("refs/heads/".length) : branch;
|
|
5527
|
-
}
|
|
5528
|
-
async function inspectCheckout(ctx, hostId, path) {
|
|
5529
|
-
const r = await ctx.hostHub.request(hostId, { type: "checkout.inspect", path });
|
|
5530
|
-
return CheckoutInfo.parse(r);
|
|
5531
|
-
}
|
|
5532
|
-
function recordCreatedWorkspace(ctx, project, checkout, info) {
|
|
5533
|
-
return ctx.store.tx(() => {
|
|
5534
|
-
const existing = ctx.store.workspaceByPath(checkout.hostId, info.path);
|
|
5535
|
-
const buffered = ctx.hostHub.takeBufferedWorkspace(checkout.hostId, info.path);
|
|
5536
|
-
const git = GitState.safeParse(buffered?.git ?? info.git);
|
|
5537
|
-
const setup = SetupState2.safeParse(buffered?.setup ?? info.setup);
|
|
5538
|
-
const ws = {
|
|
5539
|
-
id: existing?.id ?? newId("wsp"),
|
|
5540
|
-
checkoutId: checkout.id,
|
|
5541
|
-
projectId: project.id,
|
|
5542
|
-
hostId: checkout.hostId,
|
|
5543
|
-
path: info.path,
|
|
5544
|
-
branch: info.branch,
|
|
5545
|
-
baseBranch: info.baseBranch,
|
|
5546
|
-
provenance: "managed",
|
|
5547
|
-
git: git.success ? git.data : existing?.git ?? ZERO_GIT,
|
|
5548
|
-
setup: setup.success ? setup.data : existing?.setup ?? NO_SETUP,
|
|
5549
|
-
archivedAt: null,
|
|
5550
|
-
updatedAt: ctx.store.nowIso()
|
|
5551
|
-
};
|
|
5552
|
-
return ctx.store.putWorkspace(ws);
|
|
5553
|
-
});
|
|
5554
|
-
}
|
|
5555
|
-
async function refreshCheckoutWorkspaces(ctx, checkout) {
|
|
5556
|
-
const raw = await ctx.hostHub.request(checkout.hostId, {
|
|
5557
|
-
type: "workspace.discover",
|
|
5558
|
-
checkoutPath: checkout.path,
|
|
5559
|
-
worktreeDir: worktreeDirOf(ctx.store.projects.get(checkout.projectId))
|
|
5560
|
-
});
|
|
5561
|
-
const list = DiscoveredWorktree.array().parse(Array.isArray(raw) ? raw : raw?.worktrees ?? []);
|
|
5562
|
-
return ctx.store.tx(() => {
|
|
5563
|
-
const now = ctx.store.nowIso();
|
|
5564
|
-
const seen = /* @__PURE__ */ new Set();
|
|
5565
|
-
const out = [];
|
|
5566
|
-
for (const wt of list) {
|
|
5567
|
-
if (wt.bare || wt.prunable)
|
|
5568
|
-
continue;
|
|
5569
|
-
seen.add(wt.path);
|
|
5570
|
-
const existing = ctx.store.workspaceByPath(checkout.hostId, wt.path);
|
|
5571
|
-
const next = {
|
|
5572
|
-
id: existing?.id ?? newId("wsp"),
|
|
5573
|
-
checkoutId: checkout.id,
|
|
5574
|
-
projectId: checkout.projectId,
|
|
5575
|
-
hostId: checkout.hostId,
|
|
5576
|
-
path: wt.path,
|
|
5577
|
-
branch: stripRef(wt.branch),
|
|
5578
|
-
baseBranch: existing?.baseBranch ?? (wt.provenance === "main" ? null : checkout.defaultBranch),
|
|
5579
|
-
provenance: existing?.provenance === "managed" ? "managed" : wt.provenance,
|
|
5580
|
-
git: wt.git ?? existing?.git ?? ZERO_GIT,
|
|
5581
|
-
setup: existing?.setup ?? NO_SETUP,
|
|
5582
|
-
archivedAt: null,
|
|
5583
|
-
updatedAt: existing?.updatedAt ?? now
|
|
5584
|
-
};
|
|
5585
|
-
const changed = !existing || JSON.stringify({ ...existing, updatedAt: "" }) !== JSON.stringify({ ...next, updatedAt: "" });
|
|
5586
|
-
out.push(changed ? ctx.store.putWorkspace({ ...next, updatedAt: now }) : existing);
|
|
5587
|
-
}
|
|
5588
|
-
for (const ws of ctx.store.workspaces.list("checkout_id = ? AND archived_at IS NULL", [
|
|
5589
|
-
checkout.id
|
|
5590
|
-
])) {
|
|
5591
|
-
if (!seen.has(ws.path))
|
|
5592
|
-
ctx.store.putWorkspace({ ...ws, archivedAt: now, updatedAt: now });
|
|
5593
|
-
}
|
|
5594
|
-
return out;
|
|
5885
|
+
const payload = req.body ?? {};
|
|
5886
|
+
const key = idem ? `hook:${idem}` : derivedWebhookKey(payload, store.now());
|
|
5887
|
+
const match = filtersMatch(a.filters, payload);
|
|
5888
|
+
const r = svc().fire(a.id, match.ok ? { key, scheduledFor: null, payload } : { key, scheduledFor: null, record: { state: "filtered", reason: match.reason } });
|
|
5889
|
+
return reply.code(202).send({
|
|
5890
|
+
occurrenceId: r.occurrence.id,
|
|
5891
|
+
state: r.occurrence.state,
|
|
5892
|
+
...r.duplicate ? { duplicate: true } : {}
|
|
5893
|
+
});
|
|
5595
5894
|
});
|
|
5596
5895
|
}
|
|
5597
|
-
|
|
5598
|
-
|
|
5599
|
-
|
|
5600
|
-
|
|
5601
|
-
|
|
5602
|
-
});
|
|
5603
|
-
const git = GitState.parse(raw?.git ?? raw);
|
|
5604
|
-
return ctx.store.tx(() => {
|
|
5605
|
-
const cur = ctx.store.workspaces.require(ws.id);
|
|
5606
|
-
if (JSON.stringify(cur.git) === JSON.stringify(git))
|
|
5607
|
-
return cur;
|
|
5608
|
-
return ctx.store.putWorkspace({ ...cur, git, updatedAt: ctx.store.nowIso() });
|
|
5609
|
-
});
|
|
5896
|
+
var WEBHOOK_DEDUPE_WINDOW_MS = 10 * 6e4;
|
|
5897
|
+
function derivedWebhookKey(payload, now) {
|
|
5898
|
+
const bucket = Math.floor(now.getTime() / WEBHOOK_DEDUPE_WINDOW_MS);
|
|
5899
|
+
const digest = createHash2("sha256").update(JSON.stringify(payload)).digest("hex");
|
|
5900
|
+
return `hook:body:${digest}:${bucket}`;
|
|
5610
5901
|
}
|
|
5611
|
-
function
|
|
5612
|
-
|
|
5902
|
+
function headerValue(v) {
|
|
5903
|
+
const s = Array.isArray(v) ? v[0] : v;
|
|
5904
|
+
return s?.trim() ? s.trim().slice(0, 200) : null;
|
|
5905
|
+
}
|
|
5906
|
+
function validated(body) {
|
|
5907
|
+
return parse(AutomationInput, body);
|
|
5613
5908
|
}
|
|
5614
5909
|
|
|
5615
|
-
// packages/coordinator/dist/
|
|
5616
|
-
|
|
5617
|
-
|
|
5618
|
-
|
|
5619
|
-
|
|
5620
|
-
|
|
5621
|
-
|
|
5622
|
-
|
|
5623
|
-
|
|
5624
|
-
|
|
5625
|
-
|
|
5626
|
-
}
|
|
5910
|
+
// packages/coordinator/dist/routes/push.js
|
|
5911
|
+
import { z as z10 } from "zod";
|
|
5912
|
+
var DeleteSubscription = z10.object({ endpoint: z10.string().optional() });
|
|
5913
|
+
function registerPushRoutes(app, ctx) {
|
|
5914
|
+
const push = () => requireService(ctx, "push");
|
|
5915
|
+
app.get("/api/push/vapid-public-key", async () => ({ publicKey: push().vapid().publicKey }));
|
|
5916
|
+
app.post("/api/push/subscriptions", async (req, reply) => {
|
|
5917
|
+
const body = parse(PushSubscriptionRequest, req.body);
|
|
5918
|
+
const device = deviceOf(req);
|
|
5919
|
+
const updated = push().subscribe(device.id, { endpoint: body.endpoint, keys: body.keys }, body.permission);
|
|
5920
|
+
return reply.code(201).send(updated);
|
|
5921
|
+
});
|
|
5922
|
+
app.delete("/api/push/subscriptions", async (req) => {
|
|
5923
|
+
const body = parse(DeleteSubscription, req.body ?? {});
|
|
5924
|
+
return push().unsubscribe(deviceOf(req).id, body.endpoint ?? null);
|
|
5925
|
+
});
|
|
5926
|
+
app.post("/api/push/test", async (req) => {
|
|
5927
|
+
const device = deviceOf(req);
|
|
5928
|
+
if (push().subscriptions(device.id).length === 0) {
|
|
5929
|
+
throw new SwitchyardError("conflict", "This device has no push subscription");
|
|
5930
|
+
}
|
|
5931
|
+
const r = await push().sendToDevice(device.id, {
|
|
5932
|
+
type: "show",
|
|
5933
|
+
title: "Switchyard",
|
|
5934
|
+
body: "Test notification from Switchyard",
|
|
5935
|
+
tag: "switchyard-test",
|
|
5936
|
+
url: "/settings",
|
|
5937
|
+
renotify: true
|
|
5938
|
+
});
|
|
5939
|
+
return { ok: r.sent > 0, ...r };
|
|
5940
|
+
});
|
|
5941
|
+
app.get("/api/me/notification-settings", async () => push().settings());
|
|
5942
|
+
app.patch("/api/me/notification-settings", async (req) => {
|
|
5943
|
+
const body = parse(NotificationSettings.partial(), req.body);
|
|
5944
|
+
return push().updateSettings(body);
|
|
5945
|
+
});
|
|
5627
5946
|
}
|
|
5628
|
-
|
|
5629
|
-
|
|
5630
|
-
|
|
5631
|
-
|
|
5947
|
+
|
|
5948
|
+
// packages/coordinator/dist/routes/tasks.js
|
|
5949
|
+
import { z as z11 } from "zod";
|
|
5950
|
+
|
|
5951
|
+
// packages/coordinator/dist/sessions/control.js
|
|
5952
|
+
function requireRun(ctx, session) {
|
|
5953
|
+
const run = ctx.store.runForSession(session);
|
|
5954
|
+
if (!run) {
|
|
5955
|
+
throw new SwitchyardError("invalid_request", "This session is not controlled by an automation run");
|
|
5632
5956
|
}
|
|
5957
|
+
return run;
|
|
5633
5958
|
}
|
|
5634
|
-
function
|
|
5635
|
-
const
|
|
5636
|
-
|
|
5637
|
-
|
|
5638
|
-
|
|
5639
|
-
|
|
5640
|
-
|
|
5641
|
-
return first;
|
|
5642
|
-
}
|
|
5643
|
-
function titleFrom(prompt, fallback = "New session") {
|
|
5644
|
-
const line = prompt.trim().split("\n")[0]?.trim() ?? "";
|
|
5645
|
-
if (!line)
|
|
5646
|
-
return fallback;
|
|
5647
|
-
return line.length > 80 ? `${line.slice(0, 77)}...` : line;
|
|
5648
|
-
}
|
|
5649
|
-
function appendNotice(ctx, session, level, text) {
|
|
5650
|
-
return ctx.store.tx(() => {
|
|
5651
|
-
const item = {
|
|
5652
|
-
id: newId("itm"),
|
|
5653
|
-
sessionId: session.id,
|
|
5654
|
-
order: ctx.store.maxItemOrder(session.id) + 1,
|
|
5655
|
-
at: ctx.store.nowIso(),
|
|
5656
|
-
kind: "notice",
|
|
5657
|
-
data: { level, text }
|
|
5658
|
-
};
|
|
5659
|
-
ctx.store.upsertItem(item);
|
|
5660
|
-
ctx.store.appendEvent("transcript.item", { sessionId: session.id, hostId: session.hostId, projectId: session.projectId ?? void 0 }, item);
|
|
5661
|
-
return item;
|
|
5959
|
+
async function setControl(ctx, sessionId, mode) {
|
|
5960
|
+
const { session, run } = ctx.store.tx(() => {
|
|
5961
|
+
const session2 = ctx.store.sessions.require(sessionId);
|
|
5962
|
+
const run2 = requireRun(ctx, session2);
|
|
5963
|
+
const now = ctx.store.nowIso();
|
|
5964
|
+
const updated = ctx.store.putRun({ ...run2, controlMode: mode, controlRevision: run2.controlRevision + 1, updatedAt: now }, session2.projectId ?? void 0);
|
|
5965
|
+
return { session: session2, run: updated };
|
|
5662
5966
|
});
|
|
5967
|
+
ctx.hub.emit("control.changed", { run, session, mode });
|
|
5968
|
+
if (mode === "human")
|
|
5969
|
+
getService(ctx, "dispatcher")?.onTakeover(run.id);
|
|
5970
|
+
if (ctx.hostHub.isConnected(session.hostId)) {
|
|
5971
|
+
try {
|
|
5972
|
+
await ctx.hostHub.request(session.hostId, {
|
|
5973
|
+
type: "session.setControl",
|
|
5974
|
+
sessionId: session.id,
|
|
5975
|
+
controlRevision: run.controlRevision,
|
|
5976
|
+
mode
|
|
5977
|
+
});
|
|
5978
|
+
} catch (err) {
|
|
5979
|
+
ctx.log.warn({ err, sessionId }, "session.setControl failed");
|
|
5980
|
+
}
|
|
5981
|
+
}
|
|
5982
|
+
return { session, run };
|
|
5663
5983
|
}
|
|
5664
|
-
function
|
|
5665
|
-
ctx
|
|
5666
|
-
const s = ctx.store.sessions.get(sessionId);
|
|
5667
|
-
if (!s)
|
|
5668
|
-
return;
|
|
5669
|
-
ctx.store.putSession({
|
|
5670
|
-
...s,
|
|
5671
|
-
runtime: "failed",
|
|
5672
|
-
runtimeDetail: message,
|
|
5673
|
-
lastActivityAt: ctx.store.nowIso()
|
|
5674
|
-
});
|
|
5675
|
-
appendNotice(ctx, s, "error", message);
|
|
5676
|
-
});
|
|
5984
|
+
function takeover(ctx, sessionId) {
|
|
5985
|
+
return setControl(ctx, sessionId, "human");
|
|
5677
5986
|
}
|
|
5678
|
-
async function
|
|
5679
|
-
const
|
|
5680
|
-
|
|
5681
|
-
|
|
5682
|
-
|
|
5683
|
-
|
|
5684
|
-
|
|
5685
|
-
|
|
5686
|
-
|
|
5687
|
-
|
|
5688
|
-
|
|
5689
|
-
|
|
5690
|
-
|
|
5691
|
-
|
|
5692
|
-
readOnly: session.readOnly
|
|
5693
|
-
};
|
|
5694
|
-
const result = await ctx.hostHub.request(session.hostId, cmd);
|
|
5695
|
-
return ctx.store.tx(() => {
|
|
5696
|
-
const s = ctx.store.sessions.require(session.id);
|
|
5697
|
-
const handle = result?.nativeHandle ?? null;
|
|
5698
|
-
if (handle && JSON.stringify(handle) !== JSON.stringify(s.nativeHandle)) {
|
|
5699
|
-
return ctx.store.putSession({ ...s, nativeHandle: handle });
|
|
5700
|
-
}
|
|
5701
|
-
return s;
|
|
5987
|
+
async function takeoverRun(ctx, runId) {
|
|
5988
|
+
const run = ctx.store.runs.require(runId);
|
|
5989
|
+
if (run.sessionId && ctx.store.sessions.get(run.sessionId)?.runId === run.id) {
|
|
5990
|
+
return (await takeover(ctx, run.sessionId)).run;
|
|
5991
|
+
}
|
|
5992
|
+
const updated = ctx.store.tx(() => {
|
|
5993
|
+
const cur = ctx.store.runs.require(runId);
|
|
5994
|
+
const task = ctx.store.tasks.get(cur.taskId);
|
|
5995
|
+
return ctx.store.putRun({
|
|
5996
|
+
...cur,
|
|
5997
|
+
controlMode: "human",
|
|
5998
|
+
controlRevision: cur.controlRevision + 1,
|
|
5999
|
+
updatedAt: ctx.store.nowIso()
|
|
6000
|
+
}, task?.projectId);
|
|
5702
6001
|
});
|
|
6002
|
+
getService(ctx, "dispatcher")?.onTakeover(updated.id);
|
|
6003
|
+
return updated;
|
|
5703
6004
|
}
|
|
5704
|
-
async function
|
|
5705
|
-
|
|
5706
|
-
const
|
|
5707
|
-
|
|
5708
|
-
|
|
5709
|
-
|
|
5710
|
-
|
|
5711
|
-
|
|
5712
|
-
|
|
5713
|
-
|
|
5714
|
-
}
|
|
5715
|
-
|
|
5716
|
-
return
|
|
5717
|
-
path: typeof result?.path === "string" ? result.path : predicted,
|
|
5718
|
-
branch: typeof result?.branch === "string" ? result.branch : branch,
|
|
5719
|
-
baseBranch,
|
|
5720
|
-
git: result?.git,
|
|
5721
|
-
setup: result?.setup
|
|
5722
|
-
});
|
|
6005
|
+
async function returnToAutomation(ctx, sessionId) {
|
|
6006
|
+
const result = await setControl(ctx, sessionId, "automation");
|
|
6007
|
+
const session = ctx.store.sessions.require(sessionId);
|
|
6008
|
+
if (session.runtime === "idle") {
|
|
6009
|
+
const runs = getService(ctx, "runs");
|
|
6010
|
+
void runs?.onSessionSettled({
|
|
6011
|
+
session,
|
|
6012
|
+
aborted: false,
|
|
6013
|
+
lastAssistantText: session.lastAssistantText
|
|
6014
|
+
}).catch((err) => ctx.log.error({ err, sessionId }, "re-evaluation failed"));
|
|
6015
|
+
}
|
|
6016
|
+
getService(ctx, "dispatcher")?.schedule();
|
|
6017
|
+
return result;
|
|
5723
6018
|
}
|
|
5724
|
-
function
|
|
5725
|
-
const
|
|
5726
|
-
const
|
|
5727
|
-
|
|
5728
|
-
|
|
5729
|
-
let checkout = null;
|
|
5730
|
-
if ("existingId" in input.workspace) {
|
|
5731
|
-
workspace = ctx.store.workspaces.require(input.workspace.existingId);
|
|
5732
|
-
if (workspace.projectId !== project.id) {
|
|
5733
|
-
throw new SwitchyardError("invalid_request", "Workspace belongs to another project");
|
|
5734
|
-
}
|
|
5735
|
-
if (workspace.archivedAt)
|
|
5736
|
-
throw new SwitchyardError("conflict", "Workspace is archived");
|
|
5737
|
-
hostId = workspace.hostId;
|
|
5738
|
-
} else {
|
|
5739
|
-
checkout = ctx.store.checkouts.require(input.workspace.checkoutId);
|
|
5740
|
-
if (checkout.projectId !== project.id) {
|
|
5741
|
-
throw new SwitchyardError("invalid_request", "Checkout belongs to another project");
|
|
5742
|
-
}
|
|
5743
|
-
hostId = checkout.hostId;
|
|
6019
|
+
async function stopSession(ctx, sessionId) {
|
|
6020
|
+
const session = ctx.store.sessions.require(sessionId);
|
|
6021
|
+
const pre = ctx.store.runForSession(session);
|
|
6022
|
+
if (pre && (pre.state === "dispatching" || pre.state === "uncertain")) {
|
|
6023
|
+
throw new SwitchyardError("conflict", "The launch is not confirmed yet; wait for the host or pause automation instead");
|
|
5744
6024
|
}
|
|
5745
|
-
|
|
5746
|
-
|
|
5747
|
-
|
|
5748
|
-
|
|
5749
|
-
|
|
5750
|
-
|
|
5751
|
-
|
|
5752
|
-
|
|
5753
|
-
|
|
5754
|
-
|
|
5755
|
-
|
|
5756
|
-
|
|
5757
|
-
|
|
5758
|
-
|
|
5759
|
-
|
|
5760
|
-
|
|
5761
|
-
|
|
5762
|
-
taskId: null,
|
|
5763
|
-
provenance: "created",
|
|
5764
|
-
readOnly: false,
|
|
5765
|
-
lastActivityAt: now,
|
|
5766
|
-
archivedAt: null,
|
|
5767
|
-
queue: { steering: [], followUp: [] },
|
|
5768
|
-
stats: null,
|
|
5769
|
-
lastAssistantText: null,
|
|
5770
|
-
createdAt: now
|
|
5771
|
-
};
|
|
5772
|
-
ctx.store.putSession(session);
|
|
5773
|
-
const choice = input.workspace;
|
|
5774
|
-
const started = (async () => {
|
|
5775
|
-
try {
|
|
5776
|
-
let s = session;
|
|
5777
|
-
if (!("existingId" in choice)) {
|
|
5778
|
-
const ws = await createWorkspace(ctx, project, checkout, choice.newBranch, choice.base);
|
|
5779
|
-
s = ctx.store.tx(() => {
|
|
5780
|
-
const cur = ctx.store.sessions.require(session.id);
|
|
5781
|
-
return ctx.store.putSession({ ...cur, workspaceId: ws.id, cwd: ws.path });
|
|
6025
|
+
await ctx.hostHub.request(session.hostId, {
|
|
6026
|
+
type: "session.abort",
|
|
6027
|
+
sessionId,
|
|
6028
|
+
clearQueue: true
|
|
6029
|
+
});
|
|
6030
|
+
const run = ctx.store.runForSession(session);
|
|
6031
|
+
if (run && !["succeeded", "failed", "canceled", "interrupted"].includes(run.state)) {
|
|
6032
|
+
ctx.store.tx(() => {
|
|
6033
|
+
const now = ctx.store.nowIso();
|
|
6034
|
+
ctx.store.putRun({ ...run, state: "interrupted", updatedAt: now }, session.projectId ?? void 0);
|
|
6035
|
+
const task = ctx.store.tasks.get(run.taskId);
|
|
6036
|
+
if (task && task.currentRunId === run.id) {
|
|
6037
|
+
ctx.store.putTask({
|
|
6038
|
+
...task,
|
|
6039
|
+
blocked: { reason: "Stopped by user" },
|
|
6040
|
+
updatedAt: now,
|
|
6041
|
+
lastActivityAt: now
|
|
5782
6042
|
});
|
|
5783
6043
|
}
|
|
5784
|
-
|
|
5785
|
-
|
|
5786
|
-
|
|
5787
|
-
});
|
|
5788
|
-
} catch (err) {
|
|
5789
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
5790
|
-
ctx.log.warn({ err, sessionId: session.id }, "session start failed");
|
|
5791
|
-
failSession(ctx, session.id, `Could not start the session: ${message}`);
|
|
5792
|
-
}
|
|
5793
|
-
})();
|
|
5794
|
-
return { session, started };
|
|
6044
|
+
});
|
|
6045
|
+
}
|
|
6046
|
+
return ctx.store.sessions.require(sessionId);
|
|
5795
6047
|
}
|
|
5796
6048
|
|
|
5797
6049
|
// packages/coordinator/dist/routes/tasks.js
|
|
@@ -7553,13 +7805,17 @@ function installBuiltins(app, ctx, opts = {}) {
|
|
|
7553
7805
|
const notifications = new NotificationRouter(ctx, push, {
|
|
7554
7806
|
desktopRecentMs: opts.desktopRecentMs ?? 5 * 6e4
|
|
7555
7807
|
});
|
|
7808
|
+
const projectFolders = new ProjectFolderService(ctx, {
|
|
7809
|
+
intervalMs: opts.projectScanIntervalMs ?? PROJECT_SCAN_INTERVAL_MS
|
|
7810
|
+
});
|
|
7556
7811
|
const services = {
|
|
7557
7812
|
dispatcher,
|
|
7558
7813
|
runs,
|
|
7559
7814
|
automations,
|
|
7560
7815
|
scheduler,
|
|
7561
7816
|
push,
|
|
7562
|
-
notifications
|
|
7817
|
+
notifications,
|
|
7818
|
+
projectFolders
|
|
7563
7819
|
};
|
|
7564
7820
|
for (const [name, svc] of Object.entries(services))
|
|
7565
7821
|
ctx.services.set(name, svc);
|
|
@@ -7575,8 +7831,10 @@ function installBuiltins(app, ctx, opts = {}) {
|
|
|
7575
7831
|
notifications.start();
|
|
7576
7832
|
dispatcher.start();
|
|
7577
7833
|
scheduler.start();
|
|
7834
|
+
projectFolders.start();
|
|
7578
7835
|
},
|
|
7579
7836
|
stop() {
|
|
7837
|
+
projectFolders.stop();
|
|
7580
7838
|
scheduler.stop();
|
|
7581
7839
|
dispatcher.stop();
|
|
7582
7840
|
notifications.stop();
|
|
@@ -8286,12 +8544,12 @@ var HostHub = class {
|
|
|
8286
8544
|
const commandId = opts.commandId ?? newId("cmd");
|
|
8287
8545
|
const existing = this.pending.get(commandId);
|
|
8288
8546
|
if (existing) {
|
|
8289
|
-
return new Promise((
|
|
8547
|
+
return new Promise((resolve13, reject) => {
|
|
8290
8548
|
const prevResolve = existing.resolve;
|
|
8291
8549
|
const prevReject = existing.reject;
|
|
8292
8550
|
existing.resolve = (r) => {
|
|
8293
8551
|
prevResolve(r);
|
|
8294
|
-
|
|
8552
|
+
resolve13(r);
|
|
8295
8553
|
};
|
|
8296
8554
|
existing.reject = (e) => {
|
|
8297
8555
|
prevReject(e);
|
|
@@ -8305,7 +8563,7 @@ var HostHub = class {
|
|
|
8305
8563
|
command: parsed
|
|
8306
8564
|
});
|
|
8307
8565
|
const timeoutMs = opts.timeoutMs ?? this.opts.commandTimeoutMs;
|
|
8308
|
-
return new Promise((
|
|
8566
|
+
return new Promise((resolve13, reject) => {
|
|
8309
8567
|
const timer = setTimeout(() => {
|
|
8310
8568
|
this.pending.delete(commandId);
|
|
8311
8569
|
reject(new SwitchyardError("timeout", `Host did not answer ${parsed.type} in time`, {
|
|
@@ -8314,7 +8572,7 @@ var HostHub = class {
|
|
|
8314
8572
|
}));
|
|
8315
8573
|
}, timeoutMs);
|
|
8316
8574
|
timer.unref();
|
|
8317
|
-
this.pending.set(commandId, { hostId, frame, resolve:
|
|
8575
|
+
this.pending.set(commandId, { hostId, frame, resolve: resolve13, reject, timer });
|
|
8318
8576
|
conn.socket.send(frame);
|
|
8319
8577
|
});
|
|
8320
8578
|
}
|
|
@@ -9719,6 +9977,31 @@ function registerHostRoutes(app, ctx) {
|
|
|
9719
9977
|
const host = ctx.store.hosts.require(id);
|
|
9720
9978
|
return { ...host, adapters: ctx.store.adaptersByHost()[id] ?? [] };
|
|
9721
9979
|
});
|
|
9980
|
+
app.patch("/api/hosts/:id", async (req) => {
|
|
9981
|
+
const { id } = req.params;
|
|
9982
|
+
const body = parse(UpdateHostRequest, req.body);
|
|
9983
|
+
let scan = false;
|
|
9984
|
+
const host = ctx.store.tx(() => {
|
|
9985
|
+
const h = ctx.store.hosts.require(id);
|
|
9986
|
+
if (h.revokedAt)
|
|
9987
|
+
throw new SwitchyardError("forbidden", "This host was revoked");
|
|
9988
|
+
const next = { ...h };
|
|
9989
|
+
if (body.projectFolders !== void 0) {
|
|
9990
|
+
const folders = normalizeProjectFolders(body.projectFolders);
|
|
9991
|
+
scan = folders.length > 0 && JSON.stringify(folders) !== JSON.stringify(h.projectFolders ?? []);
|
|
9992
|
+
next.projectFolders = folders;
|
|
9993
|
+
}
|
|
9994
|
+
return ctx.store.putHost(next);
|
|
9995
|
+
});
|
|
9996
|
+
if (scan && ctx.hostHub.isConnected(id)) {
|
|
9997
|
+
requireService(ctx, "projectFolders").background(id);
|
|
9998
|
+
}
|
|
9999
|
+
return host;
|
|
10000
|
+
});
|
|
10001
|
+
app.post("/api/hosts/:id/scan-projects", async (req) => {
|
|
10002
|
+
const { id } = req.params;
|
|
10003
|
+
return requireService(ctx, "projectFolders").scan(id);
|
|
10004
|
+
});
|
|
9722
10005
|
const revoke = async (req) => {
|
|
9723
10006
|
const { id } = req.params;
|
|
9724
10007
|
const host = ctx.store.tx(() => {
|
|
@@ -9928,21 +10211,17 @@ function registerProjectRoutes(app, ctx) {
|
|
|
9928
10211
|
if (dupe) {
|
|
9929
10212
|
throw new SwitchyardError("conflict", `Project ${dupe.name} already tracks ${info.repoIdentity}; add a checkout to it instead`, { projectId: dupe.id });
|
|
9930
10213
|
}
|
|
9931
|
-
const project2 = {
|
|
9932
|
-
id: newId("prj"),
|
|
10214
|
+
const project2 = newProject({
|
|
9933
10215
|
name: body.name,
|
|
9934
10216
|
repoIdentity: info.repoIdentity,
|
|
9935
|
-
|
|
9936
|
-
|
|
9937
|
-
|
|
9938
|
-
|
|
9939
|
-
|
|
9940
|
-
|
|
9941
|
-
|
|
9942
|
-
|
|
9943
|
-
createdAt: now,
|
|
9944
|
-
updatedAt: now
|
|
9945
|
-
};
|
|
10217
|
+
hostId: body.hostId,
|
|
10218
|
+
now,
|
|
10219
|
+
defaultProfileId: body.defaultProfileId,
|
|
10220
|
+
setupCommand: body.setupCommand,
|
|
10221
|
+
checks: body.checks,
|
|
10222
|
+
maxConcurrentRuns: body.maxConcurrentRuns,
|
|
10223
|
+
worktreeDir: body.worktreeDir
|
|
10224
|
+
});
|
|
9946
10225
|
const checkout2 = {
|
|
9947
10226
|
id: newId("chk"),
|
|
9948
10227
|
projectId: project2.id,
|
|
@@ -12563,7 +12842,7 @@ function tailBytes(text, maxBytes) {
|
|
|
12563
12842
|
return buf.subarray(start).toString("utf8");
|
|
12564
12843
|
}
|
|
12565
12844
|
function spawnShell(cwd, cmd, opts) {
|
|
12566
|
-
return new Promise((
|
|
12845
|
+
return new Promise((resolve13) => {
|
|
12567
12846
|
let settled = false;
|
|
12568
12847
|
let timedOut = false;
|
|
12569
12848
|
let exit = null;
|
|
@@ -12581,7 +12860,7 @@ function spawnShell(cwd, cmd, opts) {
|
|
|
12581
12860
|
clearTimeout(timeout);
|
|
12582
12861
|
clearTimeout(killTimer);
|
|
12583
12862
|
clearTimeout(closeTimer);
|
|
12584
|
-
|
|
12863
|
+
resolve13(outcome);
|
|
12585
12864
|
};
|
|
12586
12865
|
const timeout = setTimeout(() => {
|
|
12587
12866
|
timedOut = true;
|
|
@@ -13306,6 +13585,81 @@ exit code ${outcome.signal ?? "null"}` : `exit code ${outcome.code ?? outcome.si
|
|
|
13306
13585
|
return final;
|
|
13307
13586
|
}
|
|
13308
13587
|
|
|
13588
|
+
// packages/host-agent/dist/project-folders.js
|
|
13589
|
+
import { lstat as lstat3, readdir as readdir3, realpath as realpath3, stat as stat3 } from "node:fs/promises";
|
|
13590
|
+
import { homedir as homedir3 } from "node:os";
|
|
13591
|
+
import { isAbsolute as isAbsolute3, join as join11 } from "node:path";
|
|
13592
|
+
function expandHome2(folder, home = homedir3()) {
|
|
13593
|
+
if (folder === "~")
|
|
13594
|
+
return home;
|
|
13595
|
+
if (folder.startsWith("~/"))
|
|
13596
|
+
return join11(home, folder.slice(2));
|
|
13597
|
+
return folder;
|
|
13598
|
+
}
|
|
13599
|
+
async function mapLimit(items, limit, fn) {
|
|
13600
|
+
const out = new Array(items.length);
|
|
13601
|
+
let next = 0;
|
|
13602
|
+
const worker = async () => {
|
|
13603
|
+
while (next < items.length) {
|
|
13604
|
+
const i = next++;
|
|
13605
|
+
out[i] = await fn(items[i]);
|
|
13606
|
+
}
|
|
13607
|
+
};
|
|
13608
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
|
13609
|
+
return out;
|
|
13610
|
+
}
|
|
13611
|
+
async function isDirectory(path) {
|
|
13612
|
+
try {
|
|
13613
|
+
return (await stat3(path)).isDirectory();
|
|
13614
|
+
} catch {
|
|
13615
|
+
return false;
|
|
13616
|
+
}
|
|
13617
|
+
}
|
|
13618
|
+
async function scanProjectFolder(folder, opts = {}) {
|
|
13619
|
+
const p = expandHome2(folder.trim(), opts.home);
|
|
13620
|
+
if (!isAbsolute3(p)) {
|
|
13621
|
+
throw new SwitchyardError("invalid_request", "Project folders must be absolute or start with ~/");
|
|
13622
|
+
}
|
|
13623
|
+
let st;
|
|
13624
|
+
try {
|
|
13625
|
+
st = await stat3(p);
|
|
13626
|
+
} catch {
|
|
13627
|
+
throw new SwitchyardError("not_found", `${folder} does not exist on this host`);
|
|
13628
|
+
}
|
|
13629
|
+
if (!st.isDirectory()) {
|
|
13630
|
+
throw new SwitchyardError("invalid_request", `${folder} is not a directory`);
|
|
13631
|
+
}
|
|
13632
|
+
const root = await realpath3(p);
|
|
13633
|
+
const entries = await readdir3(root, { withFileTypes: true });
|
|
13634
|
+
const candidates = [];
|
|
13635
|
+
for (const e of entries) {
|
|
13636
|
+
if (e.name.startsWith("."))
|
|
13637
|
+
continue;
|
|
13638
|
+
if (e.isDirectory() || e.isSymbolicLink() && await isDirectory(join11(root, e.name))) {
|
|
13639
|
+
candidates.push(e.name);
|
|
13640
|
+
}
|
|
13641
|
+
}
|
|
13642
|
+
const found = await mapLimit(candidates, opts.concurrency ?? 8, async (name) => {
|
|
13643
|
+
const dir = join11(root, name);
|
|
13644
|
+
try {
|
|
13645
|
+
await lstat3(join11(dir, ".git"));
|
|
13646
|
+
} catch {
|
|
13647
|
+
return null;
|
|
13648
|
+
}
|
|
13649
|
+
const info = await inspectCheckout2(dir).catch(() => null);
|
|
13650
|
+
if (!info)
|
|
13651
|
+
return null;
|
|
13652
|
+
if (info.root !== await realpath3(dir).catch(() => ""))
|
|
13653
|
+
return null;
|
|
13654
|
+
const ownGitDir = await realpath3(join11(info.root, ".git")).catch(() => "");
|
|
13655
|
+
if (info.commonDir !== ownGitDir)
|
|
13656
|
+
return null;
|
|
13657
|
+
return { ...info, name };
|
|
13658
|
+
});
|
|
13659
|
+
const repositories = found.filter((r) => r !== null).sort((a, b) => a.name.localeCompare(b.name));
|
|
13660
|
+
return { folder: root, repositories };
|
|
13661
|
+
}
|
|
13662
|
+
|
|
13309
13663
|
// packages/host-agent/dist/commands.js
|
|
13310
13664
|
function toErrorBody(err) {
|
|
13311
13665
|
if (err instanceof SwitchyardError)
|
|
@@ -13478,6 +13832,8 @@ var CommandRouter = class {
|
|
|
13478
13832
|
return sup.snapshot(c.sessionId);
|
|
13479
13833
|
case "checkout.inspect":
|
|
13480
13834
|
return inspectCheckout2(c.path);
|
|
13835
|
+
case "projects.scan":
|
|
13836
|
+
return scanProjectFolder(c.folder);
|
|
13481
13837
|
case "workspace.discover":
|
|
13482
13838
|
return {
|
|
13483
13839
|
worktrees: await discoverWorktrees(c.checkoutPath, { worktreeDir: c.worktreeDir })
|
|
@@ -13511,8 +13867,8 @@ var CommandRouter = class {
|
|
|
13511
13867
|
|
|
13512
13868
|
// packages/host-agent/dist/config.js
|
|
13513
13869
|
import { chmodSync as chmodSync3, existsSync as existsSync9, mkdirSync as mkdirSync5, readFileSync as readFileSync5, renameSync as renameSync3, writeFileSync as writeFileSync4 } from "node:fs";
|
|
13514
|
-
import { homedir as
|
|
13515
|
-
import { dirname as dirname5, join as
|
|
13870
|
+
import { homedir as homedir4, hostname as hostname2 } from "node:os";
|
|
13871
|
+
import { dirname as dirname5, join as join12 } from "node:path";
|
|
13516
13872
|
import { z as z20 } from "zod";
|
|
13517
13873
|
var HostConfigSchema = z20.object({
|
|
13518
13874
|
coordinatorUrl: z20.string().min(1),
|
|
@@ -13520,28 +13876,28 @@ var HostConfigSchema = z20.object({
|
|
|
13520
13876
|
credential: z20.string().min(1),
|
|
13521
13877
|
name: z20.string().min(1)
|
|
13522
13878
|
});
|
|
13523
|
-
function defaultConfigPath(env = process.env, home =
|
|
13879
|
+
function defaultConfigPath(env = process.env, home = homedir4()) {
|
|
13524
13880
|
const fromEnv = env.SWITCHYARD_HOST_CONFIG?.trim();
|
|
13525
13881
|
if (fromEnv)
|
|
13526
13882
|
return fromEnv;
|
|
13527
13883
|
const xdg = env.XDG_CONFIG_HOME?.trim();
|
|
13528
|
-
return
|
|
13884
|
+
return join12(xdg || join12(home, ".config"), "switchyard", "host.json");
|
|
13529
13885
|
}
|
|
13530
|
-
function defaultStateDir(env = process.env, platform = process.platform, home =
|
|
13886
|
+
function defaultStateDir(env = process.env, platform = process.platform, home = homedir4()) {
|
|
13531
13887
|
const fromEnv = env.SWITCHYARD_STATE_DIR?.trim();
|
|
13532
13888
|
if (fromEnv)
|
|
13533
13889
|
return fromEnv;
|
|
13534
13890
|
if (platform === "darwin")
|
|
13535
|
-
return
|
|
13891
|
+
return join12(home, "Library", "Application Support", "switchyard");
|
|
13536
13892
|
const xdg = env.XDG_STATE_HOME?.trim();
|
|
13537
|
-
return
|
|
13893
|
+
return join12(xdg || join12(home, ".local", "state"), "switchyard");
|
|
13538
13894
|
}
|
|
13539
13895
|
function statePaths(stateDir) {
|
|
13540
13896
|
const p = {
|
|
13541
13897
|
root: stateDir,
|
|
13542
|
-
db:
|
|
13543
|
-
runtime:
|
|
13544
|
-
logs:
|
|
13898
|
+
db: join12(stateDir, "host.db"),
|
|
13899
|
+
runtime: join12(stateDir, "runtime"),
|
|
13900
|
+
logs: join12(stateDir, "logs")
|
|
13545
13901
|
};
|
|
13546
13902
|
for (const d of [p.root, p.runtime, p.logs])
|
|
13547
13903
|
mkdirSync5(d, { recursive: true, mode: 448 });
|
|
@@ -14126,16 +14482,16 @@ var CoordinatorLink = class {
|
|
|
14126
14482
|
this.ready = false;
|
|
14127
14483
|
if (!ws)
|
|
14128
14484
|
return;
|
|
14129
|
-
await new Promise((
|
|
14485
|
+
await new Promise((resolve13) => {
|
|
14130
14486
|
if (ws.readyState === WebSocket2.CLOSED)
|
|
14131
|
-
return
|
|
14487
|
+
return resolve13();
|
|
14132
14488
|
const timer = setTimeout(() => {
|
|
14133
14489
|
ws.terminate();
|
|
14134
|
-
|
|
14490
|
+
resolve13();
|
|
14135
14491
|
}, 2e3);
|
|
14136
14492
|
ws.once("close", () => {
|
|
14137
14493
|
clearTimeout(timer);
|
|
14138
|
-
|
|
14494
|
+
resolve13();
|
|
14139
14495
|
});
|
|
14140
14496
|
try {
|
|
14141
14497
|
ws.close(1e3, "host agent stopping");
|
|
@@ -14455,7 +14811,7 @@ function isSupportedVersion(version) {
|
|
|
14455
14811
|
async function detectPi(piBin = "pi", env = {}) {
|
|
14456
14812
|
let out;
|
|
14457
14813
|
try {
|
|
14458
|
-
out = await new Promise((
|
|
14814
|
+
out = await new Promise((resolve13, reject) => {
|
|
14459
14815
|
execFile2(piBin, ["--version"], {
|
|
14460
14816
|
timeout: 3e4,
|
|
14461
14817
|
env: {
|
|
@@ -14465,7 +14821,7 @@ async function detectPi(piBin = "pi", env = {}) {
|
|
|
14465
14821
|
PI_TELEMETRY: "0",
|
|
14466
14822
|
...env
|
|
14467
14823
|
}
|
|
14468
|
-
}, (err, stdout, stderr) => err ? reject(err) :
|
|
14824
|
+
}, (err, stdout, stderr) => err ? reject(err) : resolve13(`${stdout}
|
|
14469
14825
|
${stderr}`));
|
|
14470
14826
|
});
|
|
14471
14827
|
} catch (err) {
|
|
@@ -14492,9 +14848,9 @@ ${stderr}`));
|
|
|
14492
14848
|
|
|
14493
14849
|
// packages/pi-runtime/dist/importable.js
|
|
14494
14850
|
import { accessSync, constants, readFileSync as readFileSync6, realpathSync } from "node:fs";
|
|
14495
|
-
import { open, readdir as
|
|
14496
|
-
import { homedir as
|
|
14497
|
-
import { delimiter, dirname as dirname7, join as
|
|
14851
|
+
import { open, readdir as readdir4, readFile as readFile6, stat as stat4 } from "node:fs/promises";
|
|
14852
|
+
import { homedir as homedir5 } from "node:os";
|
|
14853
|
+
import { delimiter, dirname as dirname7, join as join13, resolve as resolve10 } from "node:path";
|
|
14498
14854
|
import { pathToFileURL as pathToFileURL2 } from "node:url";
|
|
14499
14855
|
|
|
14500
14856
|
// packages/pi-runtime/dist/transcript.js
|
|
@@ -14664,8 +15020,8 @@ ${output}`.trimEnd() }, at);
|
|
|
14664
15020
|
// packages/pi-runtime/dist/importable.js
|
|
14665
15021
|
var INACTIVE_AFTER_MS = 12e4;
|
|
14666
15022
|
function defaultSessionsRoot() {
|
|
14667
|
-
const agentDir = process.env.PI_CODING_AGENT_DIR ??
|
|
14668
|
-
return
|
|
15023
|
+
const agentDir = process.env.PI_CODING_AGENT_DIR ?? join13(homedir5(), ".pi", "agent");
|
|
15024
|
+
return join13(agentDir, "sessions");
|
|
14669
15025
|
}
|
|
14670
15026
|
async function readFirstLine(file) {
|
|
14671
15027
|
const fh = await open(file, "r");
|
|
@@ -14713,12 +15069,12 @@ function readSessionNameSync(file) {
|
|
|
14713
15069
|
async function findJsonl(dir, depth, out) {
|
|
14714
15070
|
let entries;
|
|
14715
15071
|
try {
|
|
14716
|
-
entries = await
|
|
15072
|
+
entries = await readdir4(dir, { withFileTypes: true });
|
|
14717
15073
|
} catch {
|
|
14718
15074
|
return;
|
|
14719
15075
|
}
|
|
14720
15076
|
for (const entry of entries) {
|
|
14721
|
-
const full =
|
|
15077
|
+
const full = join13(dir, entry.name);
|
|
14722
15078
|
if (entry.isDirectory() && depth > 0)
|
|
14723
15079
|
await findJsonl(full, depth - 1, out);
|
|
14724
15080
|
else if (entry.isFile() && entry.name.endsWith(".jsonl"))
|
|
@@ -14737,7 +15093,7 @@ async function listImportable(sessionsRoot = defaultSessionsRoot(), now = Date.n
|
|
|
14737
15093
|
const header = JSON.parse(first);
|
|
14738
15094
|
if (header?.type !== "session" || typeof header.id !== "string")
|
|
14739
15095
|
continue;
|
|
14740
|
-
const st = await
|
|
15096
|
+
const st = await stat4(file);
|
|
14741
15097
|
const content = await readFile6(file, "utf8");
|
|
14742
15098
|
result.push({
|
|
14743
15099
|
sessionFile: file,
|
|
@@ -14761,7 +15117,7 @@ function findOnPath(bin) {
|
|
|
14761
15117
|
for (const dir of (process.env.PATH ?? "").split(delimiter)) {
|
|
14762
15118
|
if (!dir)
|
|
14763
15119
|
continue;
|
|
14764
|
-
const candidate =
|
|
15120
|
+
const candidate = join13(dir, bin);
|
|
14765
15121
|
try {
|
|
14766
15122
|
accessSync(candidate, constants.X_OK);
|
|
14767
15123
|
return candidate;
|
|
@@ -14794,14 +15150,14 @@ function resolvePiPackageRoot(piBin = "pi") {
|
|
|
14794
15150
|
let dir = dirname7(start);
|
|
14795
15151
|
for (let i = 0; i < 8; i++) {
|
|
14796
15152
|
try {
|
|
14797
|
-
const pkg = JSON.parse(readFileSync6(
|
|
15153
|
+
const pkg = JSON.parse(readFileSync6(join13(dir, "package.json"), "utf8"));
|
|
14798
15154
|
if (pkg?.name === "@earendil-works/pi-coding-agent")
|
|
14799
15155
|
return dir;
|
|
14800
15156
|
} catch {
|
|
14801
15157
|
}
|
|
14802
|
-
const nested =
|
|
15158
|
+
const nested = join13(dir, "lib", "node_modules", "@earendil-works", "pi-coding-agent");
|
|
14803
15159
|
try {
|
|
14804
|
-
const pkg = JSON.parse(readFileSync6(
|
|
15160
|
+
const pkg = JSON.parse(readFileSync6(join13(nested, "package.json"), "utf8"));
|
|
14805
15161
|
if (pkg?.name === "@earendil-works/pi-coding-agent")
|
|
14806
15162
|
return nested;
|
|
14807
15163
|
} catch {
|
|
@@ -14821,7 +15177,7 @@ function loadPiEntryParser(piBin = "pi") {
|
|
|
14821
15177
|
if (!root)
|
|
14822
15178
|
return null;
|
|
14823
15179
|
try {
|
|
14824
|
-
const mod = await import(pathToFileURL2(
|
|
15180
|
+
const mod = await import(pathToFileURL2(join13(root, "dist", "index.js")).href);
|
|
14825
15181
|
return typeof mod.parseSessionEntries === "function" ? mod.parseSessionEntries : null;
|
|
14826
15182
|
} catch {
|
|
14827
15183
|
return null;
|
|
@@ -14978,7 +15334,7 @@ function serializeJsonl(value) {
|
|
|
14978
15334
|
`;
|
|
14979
15335
|
}
|
|
14980
15336
|
function writeJsonl(stream, value) {
|
|
14981
|
-
return new Promise((
|
|
15337
|
+
return new Promise((resolve13, reject) => {
|
|
14982
15338
|
if (stream.destroyed || stream.writableEnded) {
|
|
14983
15339
|
reject(new Error("stream is closed"));
|
|
14984
15340
|
return;
|
|
@@ -14994,7 +15350,7 @@ function writeJsonl(stream, value) {
|
|
|
14994
15350
|
return;
|
|
14995
15351
|
}
|
|
14996
15352
|
if (ok) {
|
|
14997
|
-
|
|
15353
|
+
resolve13();
|
|
14998
15354
|
return;
|
|
14999
15355
|
}
|
|
15000
15356
|
const cleanup = () => {
|
|
@@ -15004,7 +15360,7 @@ function writeJsonl(stream, value) {
|
|
|
15004
15360
|
};
|
|
15005
15361
|
const onDrain = () => {
|
|
15006
15362
|
cleanup();
|
|
15007
|
-
|
|
15363
|
+
resolve13();
|
|
15008
15364
|
};
|
|
15009
15365
|
const onClose = () => {
|
|
15010
15366
|
cleanup();
|
|
@@ -15023,7 +15379,7 @@ import { EventEmitter as EventEmitter3 } from "node:events";
|
|
|
15023
15379
|
import { chmodSync as chmodSync4, existsSync as existsSync10, mkdirSync as mkdirSync7, rmSync as rmSync2 } from "node:fs";
|
|
15024
15380
|
import { createServer } from "node:net";
|
|
15025
15381
|
import { tmpdir as tmpdir2 } from "node:os";
|
|
15026
|
-
import { join as
|
|
15382
|
+
import { join as join14 } from "node:path";
|
|
15027
15383
|
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
15028
15384
|
function bridgeExtensionPath() {
|
|
15029
15385
|
const sibling = fileURLToPath2(new URL("./bridge/switchyard-bridge.js", import.meta.url));
|
|
@@ -15043,11 +15399,11 @@ var MAX_SOCKET_PATH = 100;
|
|
|
15043
15399
|
function bridgeSocketPath(runtimeDir) {
|
|
15044
15400
|
const name = `bridge-${randomUUID().slice(0, 8)}.sock`;
|
|
15045
15401
|
let dir = runtimeDir;
|
|
15046
|
-
if (
|
|
15047
|
-
dir =
|
|
15402
|
+
if (join14(dir, name).length > MAX_SOCKET_PATH) {
|
|
15403
|
+
dir = join14("/tmp", `switchyard-${process.getuid?.() ?? "u"}`);
|
|
15048
15404
|
}
|
|
15049
15405
|
mkdirSync7(dir, { recursive: true, mode: 448 });
|
|
15050
|
-
return
|
|
15406
|
+
return join14(dir, name);
|
|
15051
15407
|
}
|
|
15052
15408
|
var PiRpcProcess = class _PiRpcProcess extends EventEmitter3 {
|
|
15053
15409
|
child;
|
|
@@ -15067,10 +15423,10 @@ var PiRpcProcess = class _PiRpcProcess extends EventEmitter3 {
|
|
|
15067
15423
|
this.child = child;
|
|
15068
15424
|
this.server = server;
|
|
15069
15425
|
this.bridgeSocket = socketPath;
|
|
15070
|
-
this.exitPromise = new Promise((
|
|
15426
|
+
this.exitPromise = new Promise((resolve13) => {
|
|
15071
15427
|
child.once("exit", (code, signal) => {
|
|
15072
15428
|
this.exitInfo = { code, signal, requested: this.closeRequested };
|
|
15073
|
-
|
|
15429
|
+
resolve13(this.exitInfo);
|
|
15074
15430
|
});
|
|
15075
15431
|
});
|
|
15076
15432
|
JsonlReader.attach(child.stdout, (record) => this.onRecord(record), (err, line) => this.emit("protocol_error", err, line));
|
|
@@ -15100,18 +15456,18 @@ var PiRpcProcess = class _PiRpcProcess extends EventEmitter3 {
|
|
|
15100
15456
|
let proc = null;
|
|
15101
15457
|
const early = [];
|
|
15102
15458
|
if (useBridge) {
|
|
15103
|
-
socketPath = bridgeSocketPath(opts.runtimeDir ??
|
|
15459
|
+
socketPath = bridgeSocketPath(opts.runtimeDir ?? join14(tmpdir2(), "switchyard"));
|
|
15104
15460
|
server = createServer((socket) => {
|
|
15105
15461
|
if (proc)
|
|
15106
15462
|
proc.attachBridgeSocket(socket);
|
|
15107
15463
|
else
|
|
15108
15464
|
early.push(socket);
|
|
15109
15465
|
});
|
|
15110
|
-
await new Promise((
|
|
15466
|
+
await new Promise((resolve13, reject) => {
|
|
15111
15467
|
server.once("error", reject);
|
|
15112
15468
|
server.listen(socketPath, () => {
|
|
15113
15469
|
server.off("error", reject);
|
|
15114
|
-
|
|
15470
|
+
resolve13();
|
|
15115
15471
|
});
|
|
15116
15472
|
});
|
|
15117
15473
|
try {
|
|
@@ -15137,10 +15493,10 @@ var PiRpcProcess = class _PiRpcProcess extends EventEmitter3 {
|
|
|
15137
15493
|
stdio: ["pipe", "pipe", "pipe"]
|
|
15138
15494
|
});
|
|
15139
15495
|
try {
|
|
15140
|
-
await new Promise((
|
|
15496
|
+
await new Promise((resolve13, reject) => {
|
|
15141
15497
|
child.once("spawn", () => {
|
|
15142
15498
|
child.off("error", reject);
|
|
15143
|
-
|
|
15499
|
+
resolve13();
|
|
15144
15500
|
});
|
|
15145
15501
|
child.once("error", reject);
|
|
15146
15502
|
});
|
|
@@ -15177,12 +15533,12 @@ var PiRpcProcess = class _PiRpcProcess extends EventEmitter3 {
|
|
|
15177
15533
|
return Promise.reject(new PiRpcError(cmd.type, "Pi is not running"));
|
|
15178
15534
|
}
|
|
15179
15535
|
const id = `sy-${++this.nextId}`;
|
|
15180
|
-
return new Promise((
|
|
15536
|
+
return new Promise((resolve13, reject) => {
|
|
15181
15537
|
const timer = setTimeout(() => {
|
|
15182
15538
|
this.pending.delete(id);
|
|
15183
15539
|
reject(new PiRpcError(cmd.type, `Pi did not answer ${cmd.type} within ${timeoutMs} ms`));
|
|
15184
15540
|
}, timeoutMs);
|
|
15185
|
-
this.pending.set(id, { command: cmd.type, resolve:
|
|
15541
|
+
this.pending.set(id, { command: cmd.type, resolve: resolve13, reject, timer });
|
|
15186
15542
|
writeJsonl(this.child.stdin, { ...cmd, id }).catch((err) => {
|
|
15187
15543
|
const p = this.pending.get(id);
|
|
15188
15544
|
if (!p)
|
|
@@ -15211,7 +15567,7 @@ var PiRpcProcess = class _PiRpcProcess extends EventEmitter3 {
|
|
|
15211
15567
|
}
|
|
15212
15568
|
const graceMs = opts.graceMs ?? 1e4;
|
|
15213
15569
|
const termMs = opts.termMs ?? 3e3;
|
|
15214
|
-
const timeout = (ms) => new Promise((
|
|
15570
|
+
const timeout = (ms) => new Promise((resolve13) => setTimeout(() => resolve13(null), ms).unref());
|
|
15215
15571
|
let info = await Promise.race([this.exitPromise, timeout(graceMs)]);
|
|
15216
15572
|
if (info)
|
|
15217
15573
|
return info;
|
|
@@ -15989,7 +16345,7 @@ ${output}`.trimEnd() }));
|
|
|
15989
16345
|
|
|
15990
16346
|
// packages/host-agent/dist/supervisor.js
|
|
15991
16347
|
import { existsSync as existsSync12, realpathSync as realpathSync2, statSync } from "node:fs";
|
|
15992
|
-
import { join as
|
|
16348
|
+
import { join as join15, sep as sep3 } from "node:path";
|
|
15993
16349
|
var RESTART_NOTICE = "Host agent restarted; resume to continue";
|
|
15994
16350
|
var QUEUED_ANSWER_SUMMARY = "Answered from your earlier form";
|
|
15995
16351
|
var PI_CLOSED_SUMMARY = "Pi closed this question (timeout or abort)";
|
|
@@ -16447,7 +16803,7 @@ var SessionSupervisor = class {
|
|
|
16447
16803
|
}
|
|
16448
16804
|
async importable() {
|
|
16449
16805
|
const env = { ...process.env, ...this.opts.env ?? {} };
|
|
16450
|
-
const root =
|
|
16806
|
+
const root = join15(piAgentDir(env), "sessions");
|
|
16451
16807
|
const list = await listImportable(root);
|
|
16452
16808
|
const liveFiles = new Set([...this.live.values()].flatMap((l) => l.rec.nativeHandle ? [realpathOr2(l.rec.nativeHandle.sessionFile)] : []));
|
|
16453
16809
|
return {
|
|
@@ -17280,7 +17636,7 @@ function defaultDoctorDeps(ctx) {
|
|
|
17280
17636
|
return {
|
|
17281
17637
|
nodeVersion: process.versions.node,
|
|
17282
17638
|
detectPi: () => detectPi(ctx.env.SWITCHYARD_PI_BIN ?? "pi"),
|
|
17283
|
-
gitVersion: () => new Promise((
|
|
17639
|
+
gitVersion: () => new Promise((resolve13) => execFile3("git", ["--version"], (err, stdout) => resolve13(err ? null : stdout.trim()))),
|
|
17284
17640
|
hostConfigPaths: [defaultConfigPath(ctx.env), standaloneHostPaths(dataDir).configPath],
|
|
17285
17641
|
readFile: (p) => existsSync13(p) ? readFileSync8(p, "utf8") : null
|
|
17286
17642
|
};
|
|
@@ -17436,14 +17792,14 @@ function printDoctor(ctx, results) {
|
|
|
17436
17792
|
// packages/cli/dist/commands/host.js
|
|
17437
17793
|
import { existsSync as existsSync15, mkdirSync as mkdirSync9 } from "node:fs";
|
|
17438
17794
|
import { hostname as hostname3 } from "node:os";
|
|
17439
|
-
import { dirname as dirname9, join as
|
|
17795
|
+
import { dirname as dirname9, join as join17, resolve as resolve12 } from "node:path";
|
|
17440
17796
|
import { DatabaseSync as DatabaseSync4 } from "node:sqlite";
|
|
17441
17797
|
|
|
17442
17798
|
// packages/cli/dist/commands/service.js
|
|
17443
17799
|
import { execFile as execFile4 } from "node:child_process";
|
|
17444
17800
|
import { existsSync as existsSync14, mkdirSync as mkdirSync8, readFileSync as readFileSync9, writeFileSync as writeFileSync5 } from "node:fs";
|
|
17445
|
-
import { homedir as
|
|
17446
|
-
import { dirname as dirname8, join as
|
|
17801
|
+
import { homedir as homedir6 } from "node:os";
|
|
17802
|
+
import { dirname as dirname8, join as join16, resolve as resolve11 } from "node:path";
|
|
17447
17803
|
var LAUNCHD_LABEL = "com.switchyard.host";
|
|
17448
17804
|
var SYSTEMD_UNIT = "switchyard-host.service";
|
|
17449
17805
|
var TEMPLATES = {
|
|
@@ -17453,7 +17809,7 @@ var TEMPLATES = {
|
|
|
17453
17809
|
<plist version="1.0">
|
|
17454
17810
|
<dict>
|
|
17455
17811
|
<key>Label</key>
|
|
17456
|
-
<string>
|
|
17812
|
+
<string>{{LABEL}}</string>
|
|
17457
17813
|
<key>ProgramArguments</key>
|
|
17458
17814
|
<!-- Through zsh (every Mac has /bin/zsh) so the agent and the Pi sessions it starts get the
|
|
17459
17815
|
environment ~/.zshenv sets for a terminal, like provider settings; PATH below is the base. -->
|
|
@@ -17467,6 +17823,8 @@ var TEMPLATES = {
|
|
|
17467
17823
|
<string>run</string>
|
|
17468
17824
|
<string>--config</string>
|
|
17469
17825
|
<string>{{CONFIG}}</string>
|
|
17826
|
+
<string>--state-dir</string>
|
|
17827
|
+
<string>{{STATE_DIR}}</string>
|
|
17470
17828
|
</array>
|
|
17471
17829
|
<key>EnvironmentVariables</key>
|
|
17472
17830
|
<dict>
|
|
@@ -17501,7 +17859,7 @@ Wants=network-online.target
|
|
|
17501
17859
|
|
|
17502
17860
|
[Service]
|
|
17503
17861
|
Type=simple
|
|
17504
|
-
ExecStart="{{NODE}}" "{{CLI}}" host run --config "{{CONFIG}}"
|
|
17862
|
+
ExecStart="{{NODE}}" "{{CLI}}" host run --config "{{CONFIG}}" --state-dir "{{STATE_DIR}}"
|
|
17505
17863
|
Environment="PATH={{PATH}}"
|
|
17506
17864
|
WorkingDirectory={{HOME}}
|
|
17507
17865
|
Restart=on-failure
|
|
@@ -17514,8 +17872,8 @@ WantedBy=default.target
|
|
|
17514
17872
|
`
|
|
17515
17873
|
};
|
|
17516
17874
|
var TEMPLATE_FILES = {
|
|
17517
|
-
launchd:
|
|
17518
|
-
systemd:
|
|
17875
|
+
launchd: join16("launchd", `${LAUNCHD_LABEL}.plist`),
|
|
17876
|
+
systemd: join16("systemd", SYSTEMD_UNIT)
|
|
17519
17877
|
};
|
|
17520
17878
|
function xmlEscape(s) {
|
|
17521
17879
|
return s.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
|
|
@@ -17527,12 +17885,12 @@ function systemdEscape(s, exec = true) {
|
|
|
17527
17885
|
return exec ? out.replaceAll("$", "$$$$") : out;
|
|
17528
17886
|
}
|
|
17529
17887
|
function loadTemplate(kind, dir = deployDir()) {
|
|
17530
|
-
const p =
|
|
17888
|
+
const p = join16(dir, TEMPLATE_FILES[kind]);
|
|
17531
17889
|
return existsSync14(p) ? readFileSync9(p, "utf8") : TEMPLATES[kind];
|
|
17532
17890
|
}
|
|
17533
17891
|
function renderService(kind, vars, template = TEMPLATES[kind]) {
|
|
17534
17892
|
for (const [k, v] of Object.entries(vars)) {
|
|
17535
|
-
if (k !== "path" && !v.startsWith("/")) {
|
|
17893
|
+
if (k !== "path" && k !== "label" && !v.startsWith("/")) {
|
|
17536
17894
|
throw new CliError(`Service file needs an absolute ${k} path, got "${v}"`);
|
|
17537
17895
|
}
|
|
17538
17896
|
}
|
|
@@ -17540,7 +17898,7 @@ function renderService(kind, vars, template = TEMPLATES[kind]) {
|
|
|
17540
17898
|
if (kind === "systemd" && /\s/.test(vars.home)) {
|
|
17541
17899
|
throw new CliError("The home directory path contains whitespace; edit the unit by hand");
|
|
17542
17900
|
}
|
|
17543
|
-
return template.replaceAll("{{NODE}}", esc(vars.node)).replaceAll("{{CLI}}", esc(vars.cli)).replaceAll("{{CONFIG}}", esc(vars.config)).replaceAll("{{HOME}}", kind === "launchd" ? esc(vars.home) : vars.home).replaceAll("{{PATH}}", kind === "launchd" ? esc(vars.path) : systemdEscape(vars.path, false)).replaceAll("{{LOG_DIR}}", esc(vars.logDir));
|
|
17901
|
+
return template.replaceAll("{{NODE}}", esc(vars.node)).replaceAll("{{CLI}}", esc(vars.cli)).replaceAll("{{LABEL}}", esc(vars.label)).replaceAll("{{CONFIG}}", esc(vars.config)).replaceAll("{{STATE_DIR}}", esc(vars.stateDir)).replaceAll("{{HOME}}", kind === "launchd" ? esc(vars.home) : vars.home).replaceAll("{{PATH}}", kind === "launchd" ? esc(vars.path) : systemdEscape(vars.path, false)).replaceAll("{{LOG_DIR}}", esc(vars.logDir));
|
|
17544
17902
|
}
|
|
17545
17903
|
function serviceKindFor(platform) {
|
|
17546
17904
|
if (platform === "darwin")
|
|
@@ -17549,17 +17907,27 @@ function serviceKindFor(platform) {
|
|
|
17549
17907
|
return "systemd";
|
|
17550
17908
|
throw new CliError(`Service install is supported on macOS and Linux, not ${platform}`);
|
|
17551
17909
|
}
|
|
17552
|
-
function
|
|
17553
|
-
|
|
17910
|
+
function serviceNames(instance) {
|
|
17911
|
+
if (instance === void 0)
|
|
17912
|
+
return { label: LAUNCHD_LABEL, unit: SYSTEMD_UNIT };
|
|
17913
|
+
if (!/^[a-z0-9][a-z0-9-]{0,31}$/.test(instance)) {
|
|
17914
|
+
throw new CliError("Instance names use lowercase letters, digits and dashes (at most 32)");
|
|
17915
|
+
}
|
|
17916
|
+
return { label: `${LAUNCHD_LABEL}.${instance}`, unit: `switchyard-host-${instance}.service` };
|
|
17917
|
+
}
|
|
17918
|
+
function serviceFilePath(kind, home = homedir6(), instance) {
|
|
17919
|
+
const { label, unit } = serviceNames(instance);
|
|
17920
|
+
return kind === "launchd" ? join16(home, "Library", "LaunchAgents", `${label}.plist`) : join16(home, ".config", "systemd", "user", unit);
|
|
17554
17921
|
}
|
|
17555
|
-
function loadCommands(kind, file, uid) {
|
|
17922
|
+
function loadCommands(kind, file, uid, instance) {
|
|
17923
|
+
const { label, unit } = serviceNames(instance);
|
|
17556
17924
|
return kind === "launchd" ? [
|
|
17557
|
-
["launchctl", "bootout", `gui/${uid}/${
|
|
17925
|
+
["launchctl", "bootout", `gui/${uid}/${label}`],
|
|
17558
17926
|
["launchctl", "bootstrap", `gui/${uid}`, file]
|
|
17559
17927
|
] : [
|
|
17560
17928
|
["systemctl", "--user", "daemon-reload"],
|
|
17561
|
-
["systemctl", "--user", "enable", "--now",
|
|
17562
|
-
["systemctl", "--user", "restart",
|
|
17929
|
+
["systemctl", "--user", "enable", "--now", unit],
|
|
17930
|
+
["systemctl", "--user", "restart", unit]
|
|
17563
17931
|
];
|
|
17564
17932
|
}
|
|
17565
17933
|
function stableNodePath(execPath, exists2 = existsSync14) {
|
|
@@ -17574,27 +17942,34 @@ function stableNodePath(execPath, exists2 = existsSync14) {
|
|
|
17574
17942
|
function defaultInstallDeps() {
|
|
17575
17943
|
return {
|
|
17576
17944
|
platform: process.platform,
|
|
17577
|
-
home:
|
|
17945
|
+
home: homedir6(),
|
|
17578
17946
|
uid: process.getuid?.() ?? 0,
|
|
17579
|
-
run: (cmd) => new Promise((
|
|
17947
|
+
run: (cmd) => new Promise((resolve13) => execFile4(cmd[0], cmd.slice(1), (err, stdout, stderr) => resolve13({ ok: !err, output: `${stdout}${stderr}`.trim() })))
|
|
17580
17948
|
};
|
|
17581
17949
|
}
|
|
17582
17950
|
async function installService(ctx, flags, deps = defaultInstallDeps()) {
|
|
17583
17951
|
const kind = serviceKindFor(deps.platform);
|
|
17952
|
+
const names = serviceNames(flags.instance);
|
|
17953
|
+
if (flags.instance !== void 0 && !(flags.config && flags.stateDir)) {
|
|
17954
|
+
throw new CliError("--instance needs --config and --state-dir, so the second host agent keeps its own enrollment and state");
|
|
17955
|
+
}
|
|
17584
17956
|
const config = flags.config ?? defaultConfigPath(ctx.env, deps.home);
|
|
17585
17957
|
if (!existsSync14(config) && !flags.print) {
|
|
17586
17958
|
throw new CliError(`No host config at ${config}. Enroll first: switchyard host enroll --coordinator <url> --token <t>`);
|
|
17587
17959
|
}
|
|
17588
|
-
const
|
|
17960
|
+
const stateDir = flags.stateDir ? resolve11(flags.stateDir) : defaultStateDir(ctx.env, deps.platform, deps.home);
|
|
17961
|
+
const logDir = join16(stateDir, "logs");
|
|
17589
17962
|
const content = renderService(kind, {
|
|
17590
17963
|
node: stableNodePath(process.execPath),
|
|
17591
17964
|
cli: cliEntry(),
|
|
17592
17965
|
config,
|
|
17966
|
+
stateDir,
|
|
17967
|
+
label: names.label,
|
|
17593
17968
|
home: deps.home,
|
|
17594
17969
|
path: ctx.env.PATH ?? "/usr/local/bin:/usr/bin:/bin",
|
|
17595
17970
|
logDir
|
|
17596
17971
|
}, loadTemplate(kind));
|
|
17597
|
-
const file = serviceFilePath(kind, deps.home);
|
|
17972
|
+
const file = serviceFilePath(kind, deps.home, flags.instance);
|
|
17598
17973
|
if (flags.print) {
|
|
17599
17974
|
ctx.out(content.trimEnd());
|
|
17600
17975
|
return { file, content };
|
|
@@ -17605,18 +17980,22 @@ async function installService(ctx, flags, deps = defaultInstallDeps()) {
|
|
|
17605
17980
|
ctx.out(`Wrote ${file}`);
|
|
17606
17981
|
if (flags.load === false) {
|
|
17607
17982
|
ctx.out("Not loaded (--no-load). Load it with:");
|
|
17608
|
-
for (const c of loadCommands(kind, file, deps.uid).slice(1))
|
|
17983
|
+
for (const c of loadCommands(kind, file, deps.uid, flags.instance).slice(1))
|
|
17609
17984
|
ctx.out(` ${c.join(" ")}`);
|
|
17610
17985
|
return { file, content };
|
|
17611
17986
|
}
|
|
17612
|
-
for (const cmd of loadCommands(kind, file, deps.uid)) {
|
|
17987
|
+
for (const cmd of loadCommands(kind, file, deps.uid, flags.instance)) {
|
|
17613
17988
|
ctx.out(`$ ${cmd.join(" ")}`);
|
|
17614
|
-
|
|
17989
|
+
let r = await deps.run(cmd);
|
|
17990
|
+
for (let i = 0; !r.ok && cmd[1] === "bootstrap" && i < 10; i++) {
|
|
17991
|
+
await new Promise((resolve13) => setTimeout(resolve13, deps.retryDelayMs ?? 500));
|
|
17992
|
+
r = await deps.run(cmd);
|
|
17993
|
+
}
|
|
17615
17994
|
if (!r.ok && cmd[1] !== "bootout") {
|
|
17616
17995
|
throw new CliError(`${cmd.join(" ")} failed${r.output ? `: ${r.output}` : ""}`);
|
|
17617
17996
|
}
|
|
17618
17997
|
}
|
|
17619
|
-
ctx.out(`The host agent runs as ${kind === "launchd" ?
|
|
17998
|
+
ctx.out(`The host agent runs as ${kind === "launchd" ? names.label : names.unit}; logs: ${logDir}`);
|
|
17620
17999
|
return { file, content };
|
|
17621
18000
|
}
|
|
17622
18001
|
|
|
@@ -17654,10 +18033,10 @@ async function hostRun(ctx, flags) {
|
|
|
17654
18033
|
}
|
|
17655
18034
|
function hostBackup(ctx, out, flags) {
|
|
17656
18035
|
const stateDir = flags.stateDir ?? defaultStateDir(ctx.env);
|
|
17657
|
-
const src =
|
|
18036
|
+
const src = join17(stateDir, "host.db");
|
|
17658
18037
|
if (!existsSync15(src))
|
|
17659
18038
|
throw new CliError(`No host database at ${src}`);
|
|
17660
|
-
const dest =
|
|
18039
|
+
const dest = resolve12(out);
|
|
17661
18040
|
if (existsSync15(dest))
|
|
17662
18041
|
throw new CliError(`${dest} already exists`);
|
|
17663
18042
|
mkdirSync9(dirname9(dest), { recursive: true });
|
|
@@ -17731,6 +18110,161 @@ async function hostStatus(ctx, flags) {
|
|
|
17731
18110
|
ctx.out(`Service: ${svc.installed ? `installed (${svc.file})` : "not installed"}`);
|
|
17732
18111
|
}
|
|
17733
18112
|
|
|
18113
|
+
// packages/cli/dist/context.js
|
|
18114
|
+
function defaultCtx() {
|
|
18115
|
+
return {
|
|
18116
|
+
out: (l) => process.stdout.write(`${l}
|
|
18117
|
+
`),
|
|
18118
|
+
err: (l) => process.stderr.write(`${l}
|
|
18119
|
+
`),
|
|
18120
|
+
env: process.env,
|
|
18121
|
+
fetch: (input, init2) => fetch(input, init2)
|
|
18122
|
+
};
|
|
18123
|
+
}
|
|
18124
|
+
function connect(ctx, flags) {
|
|
18125
|
+
const conn = resolveConnection(flags, ctx.env, ctx.paths);
|
|
18126
|
+
return {
|
|
18127
|
+
conn,
|
|
18128
|
+
api: new ApiClient({ coordinatorUrl: conn.url, credential: conn.credential, fetch: ctx.fetch })
|
|
18129
|
+
};
|
|
18130
|
+
}
|
|
18131
|
+
function table2(headers, rows) {
|
|
18132
|
+
const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => (r[i] ?? "").length)));
|
|
18133
|
+
const fmt = (cells) => cells.map((c, i) => i === cells.length - 1 ? c : c.padEnd(widths[i] ?? 0)).join(" ").trimEnd();
|
|
18134
|
+
return [fmt(headers), ...rows.map(fmt)];
|
|
18135
|
+
}
|
|
18136
|
+
function printList(ctx, json, data, headers, rows, empty) {
|
|
18137
|
+
if (json) {
|
|
18138
|
+
ctx.out(JSON.stringify(data, null, 2));
|
|
18139
|
+
return;
|
|
18140
|
+
}
|
|
18141
|
+
if (rows.length === 0) {
|
|
18142
|
+
ctx.out(empty);
|
|
18143
|
+
return;
|
|
18144
|
+
}
|
|
18145
|
+
for (const l of table2(headers, rows))
|
|
18146
|
+
ctx.out(l);
|
|
18147
|
+
}
|
|
18148
|
+
function ago(iso, now = Date.now()) {
|
|
18149
|
+
if (!iso)
|
|
18150
|
+
return "-";
|
|
18151
|
+
const s = Math.max(0, Math.round((now - Date.parse(iso)) / 1e3));
|
|
18152
|
+
if (s < 60)
|
|
18153
|
+
return `${s}s ago`;
|
|
18154
|
+
if (s < 3600)
|
|
18155
|
+
return `${Math.round(s / 60)}m ago`;
|
|
18156
|
+
if (s < 86400)
|
|
18157
|
+
return `${Math.round(s / 3600)}h ago`;
|
|
18158
|
+
return `${Math.round(s / 86400)}d ago`;
|
|
18159
|
+
}
|
|
18160
|
+
|
|
18161
|
+
// packages/cli/dist/commands/hosts.js
|
|
18162
|
+
async function resolveHost(api, ref) {
|
|
18163
|
+
const hosts = (await api.get("/api/hosts")).filter((h) => !h.revokedAt);
|
|
18164
|
+
const exact = hosts.find((h) => h.id === ref);
|
|
18165
|
+
if (exact)
|
|
18166
|
+
return exact;
|
|
18167
|
+
const named = hosts.filter((h) => h.name.toLowerCase() === ref.toLowerCase());
|
|
18168
|
+
if (named.length === 1)
|
|
18169
|
+
return named[0];
|
|
18170
|
+
if (named.length > 1)
|
|
18171
|
+
throw new CliError(`Several hosts are named "${ref}"; use the id`);
|
|
18172
|
+
const known = hosts.map((h) => `${h.name} (${h.id})`).join(", ") || "none";
|
|
18173
|
+
throw new CliError(`Unknown host "${ref}". Hosts: ${known}`);
|
|
18174
|
+
}
|
|
18175
|
+
function scanLine(scan, now = Date.now()) {
|
|
18176
|
+
if (!scan)
|
|
18177
|
+
return ["Not scanned yet."];
|
|
18178
|
+
if (scan.state === "running")
|
|
18179
|
+
return [`Scanning since ${ago(scan.startedAt, now)}.`];
|
|
18180
|
+
if (scan.state === "failed") {
|
|
18181
|
+
return [
|
|
18182
|
+
`Last scan failed ${ago(scan.finishedAt, now)}: ${scan.errors.at(-1) ?? "unknown error"}`
|
|
18183
|
+
];
|
|
18184
|
+
}
|
|
18185
|
+
return [
|
|
18186
|
+
`Last scanned ${ago(scan.finishedAt, now)}: ${scan.repositories} repositories, ${scan.checkoutsAdded} new checkouts, ${scan.projectsCreated} new projects.`,
|
|
18187
|
+
...scan.errors.map((e) => `! ${e}`)
|
|
18188
|
+
];
|
|
18189
|
+
}
|
|
18190
|
+
async function hostsList(ctx, flags) {
|
|
18191
|
+
const { api } = connect(ctx, flags);
|
|
18192
|
+
const hosts = (await api.get("/api/hosts")).filter((h) => !h.revokedAt);
|
|
18193
|
+
printList(ctx, flags.json, hosts, ["ID", "NAME", "STATUS", "AGENT", "PROJECT FOLDERS"], hosts.map((h) => [
|
|
18194
|
+
h.id,
|
|
18195
|
+
h.name,
|
|
18196
|
+
h.connectivity,
|
|
18197
|
+
h.agentVersion,
|
|
18198
|
+
(h.projectFolders ?? []).join(", ") || "-"
|
|
18199
|
+
]), "No hosts.");
|
|
18200
|
+
}
|
|
18201
|
+
async function hostsFolders(ctx, ref, flags) {
|
|
18202
|
+
const { api } = connect(ctx, flags);
|
|
18203
|
+
const changing = !!(flags.set || flags.add || flags.remove || flags.clear);
|
|
18204
|
+
if (flags.set && (flags.add || flags.remove || flags.clear) || flags.clear && (flags.add || flags.remove)) {
|
|
18205
|
+
throw new CliError("Use --set or --clear on its own, or --add/--remove");
|
|
18206
|
+
}
|
|
18207
|
+
const host = await resolveHost(api, ref);
|
|
18208
|
+
const current = host.projectFolders ?? [];
|
|
18209
|
+
if (!changing) {
|
|
18210
|
+
if (flags.json)
|
|
18211
|
+
ctx.out(JSON.stringify(host, null, 2));
|
|
18212
|
+
else {
|
|
18213
|
+
if (current.length === 0)
|
|
18214
|
+
ctx.out("No project folders.");
|
|
18215
|
+
for (const f of current)
|
|
18216
|
+
ctx.out(f);
|
|
18217
|
+
for (const l of scanLine(host.projectScan))
|
|
18218
|
+
ctx.out(l);
|
|
18219
|
+
}
|
|
18220
|
+
return host;
|
|
18221
|
+
}
|
|
18222
|
+
let next;
|
|
18223
|
+
if (flags.set)
|
|
18224
|
+
next = [...flags.set];
|
|
18225
|
+
else if (flags.clear)
|
|
18226
|
+
next = [];
|
|
18227
|
+
else {
|
|
18228
|
+
next = [...current];
|
|
18229
|
+
for (const f of flags.remove ?? []) {
|
|
18230
|
+
if (!next.includes(f))
|
|
18231
|
+
throw new CliError(`${f} is not a project folder of ${host.name}`);
|
|
18232
|
+
next = next.filter((x) => x !== f);
|
|
18233
|
+
}
|
|
18234
|
+
for (const f of flags.add ?? [])
|
|
18235
|
+
if (!next.includes(f))
|
|
18236
|
+
next.push(f);
|
|
18237
|
+
}
|
|
18238
|
+
const updated = await api.request("PATCH", `/api/hosts/${encodeURIComponent(host.id)}`, {
|
|
18239
|
+
projectFolders: next
|
|
18240
|
+
});
|
|
18241
|
+
if (flags.json) {
|
|
18242
|
+
ctx.out(JSON.stringify(updated, null, 2));
|
|
18243
|
+
return updated;
|
|
18244
|
+
}
|
|
18245
|
+
const folders = updated.projectFolders ?? [];
|
|
18246
|
+
ctx.out(`Project folders on ${updated.name}: ${folders.join(", ") || "none"}`);
|
|
18247
|
+
if (folders.length > 0 && updated.connectivity === "connected") {
|
|
18248
|
+
ctx.out(`Scanning in the background; \`switchyard hosts scan ${updated.name}\` waits for the result.`);
|
|
18249
|
+
}
|
|
18250
|
+
return updated;
|
|
18251
|
+
}
|
|
18252
|
+
async function hostsScan(ctx, ref, flags) {
|
|
18253
|
+
const { api } = connect(ctx, flags);
|
|
18254
|
+
const host = await resolveHost(api, ref);
|
|
18255
|
+
const r = await api.post(`/api/hosts/${encodeURIComponent(host.id)}/scan-projects`);
|
|
18256
|
+
if (flags.json) {
|
|
18257
|
+
ctx.out(JSON.stringify(r, null, 2));
|
|
18258
|
+
return r;
|
|
18259
|
+
}
|
|
18260
|
+
ctx.out(`Scanned ${host.name}: ${r.repositories} repositories, ${r.checkoutsAdded} new checkouts, ${r.projectsCreated} new projects`);
|
|
18261
|
+
for (const a of r.added)
|
|
18262
|
+
ctx.out(`+ ${a.name} ${a.path}${a.newProject ? " (new project)" : ""}`);
|
|
18263
|
+
for (const e of r.errors)
|
|
18264
|
+
ctx.out(`! ${e}`);
|
|
18265
|
+
return r;
|
|
18266
|
+
}
|
|
18267
|
+
|
|
17734
18268
|
// packages/cli/dist/commands/up.js
|
|
17735
18269
|
import { spawn as spawn3 } from "node:child_process";
|
|
17736
18270
|
import { existsSync as existsSync16 } from "node:fs";
|
|
@@ -17861,54 +18395,6 @@ async function up(ctx, flags) {
|
|
|
17861
18395
|
await s.stop();
|
|
17862
18396
|
}
|
|
17863
18397
|
|
|
17864
|
-
// packages/cli/dist/context.js
|
|
17865
|
-
function defaultCtx() {
|
|
17866
|
-
return {
|
|
17867
|
-
out: (l) => process.stdout.write(`${l}
|
|
17868
|
-
`),
|
|
17869
|
-
err: (l) => process.stderr.write(`${l}
|
|
17870
|
-
`),
|
|
17871
|
-
env: process.env,
|
|
17872
|
-
fetch: (input, init2) => fetch(input, init2)
|
|
17873
|
-
};
|
|
17874
|
-
}
|
|
17875
|
-
function connect(ctx, flags) {
|
|
17876
|
-
const conn = resolveConnection(flags, ctx.env, ctx.paths);
|
|
17877
|
-
return {
|
|
17878
|
-
conn,
|
|
17879
|
-
api: new ApiClient({ coordinatorUrl: conn.url, credential: conn.credential, fetch: ctx.fetch })
|
|
17880
|
-
};
|
|
17881
|
-
}
|
|
17882
|
-
function table2(headers, rows) {
|
|
17883
|
-
const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => (r[i] ?? "").length)));
|
|
17884
|
-
const fmt = (cells) => cells.map((c, i) => i === cells.length - 1 ? c : c.padEnd(widths[i] ?? 0)).join(" ").trimEnd();
|
|
17885
|
-
return [fmt(headers), ...rows.map(fmt)];
|
|
17886
|
-
}
|
|
17887
|
-
function printList(ctx, json, data, headers, rows, empty) {
|
|
17888
|
-
if (json) {
|
|
17889
|
-
ctx.out(JSON.stringify(data, null, 2));
|
|
17890
|
-
return;
|
|
17891
|
-
}
|
|
17892
|
-
if (rows.length === 0) {
|
|
17893
|
-
ctx.out(empty);
|
|
17894
|
-
return;
|
|
17895
|
-
}
|
|
17896
|
-
for (const l of table2(headers, rows))
|
|
17897
|
-
ctx.out(l);
|
|
17898
|
-
}
|
|
17899
|
-
function ago(iso, now = Date.now()) {
|
|
17900
|
-
if (!iso)
|
|
17901
|
-
return "-";
|
|
17902
|
-
const s = Math.max(0, Math.round((now - Date.parse(iso)) / 1e3));
|
|
17903
|
-
if (s < 60)
|
|
17904
|
-
return `${s}s ago`;
|
|
17905
|
-
if (s < 3600)
|
|
17906
|
-
return `${Math.round(s / 60)}m ago`;
|
|
17907
|
-
if (s < 86400)
|
|
17908
|
-
return `${Math.round(s / 3600)}h ago`;
|
|
17909
|
-
return `${Math.round(s / 86400)}d ago`;
|
|
17910
|
-
}
|
|
17911
|
-
|
|
17912
18398
|
// packages/cli/dist/commands/work.js
|
|
17913
18399
|
function byId2(list) {
|
|
17914
18400
|
return new Map(list.map((x) => [x.id, x]));
|
|
@@ -18111,7 +18597,7 @@ function buildProgram(ctx = defaultCtx()) {
|
|
|
18111
18597
|
const host = program.command("host").description("Host agent on this machine");
|
|
18112
18598
|
host.command("enroll").description("Enroll this machine as a host (writes ~/.config/switchyard/host.json)").requiredOption("--coordinator <url>", "coordinator URL").option("--token <token>", "host enrollment token (switchyard pair --host)").option("--secret <secret>", "shared host enrollment secret (SWITCHYARD_HOST_ENROLL_SECRET)").option("--name <name>", "host name (default: hostname)").option("--config <path>", "host.json path").action((o) => hostEnroll(ctx, o));
|
|
18113
18599
|
host.command("run").description("Run the host agent in the foreground (waits for host.json when not enrolled)").option("--config <path>", "host.json path (env SWITCHYARD_HOST_CONFIG)").option("--state-dir <dir>", "state dir (env SWITCHYARD_STATE_DIR)").action((o) => hostRun(ctx, o));
|
|
18114
|
-
host.command("install-service").description("Install the host agent as a launchd agent (macOS) or systemd user unit (Linux)").option("--config <path>", "host.json path").option("--print", "print the service file without writing it").option("--no-load", "write the file but do not load it").action(async (o) => {
|
|
18600
|
+
host.command("install-service").description("Install the host agent as a launchd agent (macOS) or systemd user unit (Linux)").option("--config <path>", "host.json path").option("--state-dir <dir>", "state dir (default: the platform default)").option("--instance <name>", "run a second host agent beside the default one, e.g. dev; needs --config and --state-dir").option("--print", "print the service file without writing it").option("--no-load", "write the file but do not load it").action(async (o) => {
|
|
18115
18601
|
await installService(ctx, o);
|
|
18116
18602
|
});
|
|
18117
18603
|
host.command("backup <out>").description("Consistent backup of the host database").option("--state-dir <dir>", "state dir (env SWITCHYARD_STATE_DIR)").action((out, o) => hostBackup(ctx, out, o));
|
|
@@ -18131,6 +18617,21 @@ function buildProgram(ctx = defaultCtx()) {
|
|
|
18131
18617
|
if (code !== 0)
|
|
18132
18618
|
throw new CliError("doctor found failures", code);
|
|
18133
18619
|
});
|
|
18620
|
+
const hosts = program.command("hosts").description("Hosts and their project folders");
|
|
18621
|
+
connectionOptions(hosts.command("list").description("List hosts")).option("--json", "JSON output").action((o) => hostsList(ctx, { ...connFlags(o), json: o.json }));
|
|
18622
|
+
connectionOptions(hosts.command("folders <host>").description("Show or change a host's project folders (absolute or ~/... on the host; quote ~ so your shell does not expand it)")).option("--set <folder...>", "replace the folders").option("--add <folder...>", "add folders").option("--remove <folder...>", "remove folders").option("--clear", "remove every folder").option("--json", "JSON output").action(async (ref, o) => {
|
|
18623
|
+
await hostsFolders(ctx, ref, {
|
|
18624
|
+
...connFlags(o),
|
|
18625
|
+
set: o.set,
|
|
18626
|
+
add: o.add,
|
|
18627
|
+
remove: o.remove,
|
|
18628
|
+
clear: o.clear,
|
|
18629
|
+
json: o.json
|
|
18630
|
+
});
|
|
18631
|
+
});
|
|
18632
|
+
connectionOptions(hosts.command("scan <host>").description("Scan a host's project folders now and add new repositories as projects")).option("--json", "JSON output").action(async (ref, o) => {
|
|
18633
|
+
await hostsScan(ctx, ref, { ...connFlags(o), json: o.json });
|
|
18634
|
+
});
|
|
18134
18635
|
const sessions = program.command("sessions").description("Sessions");
|
|
18135
18636
|
connectionOptions(sessions.command("list").description("List sessions")).option("--project <project>", "project id or name").option("--json", "JSON output").action((o) => sessionsList(ctx, { ...connFlags(o), json: o.json, project: o.project }));
|
|
18136
18637
|
connectionOptions(sessions.command("import <sessionFile>").description("Import an inactive Pi session file from a host")).option("--host <hostId>", "host holding the file (default: the only host)").option("--project <project>", "project id or name").option("--json", "JSON output").action(async (file, o) => {
|