feat(profiles): add typed live step config profiles

Profiles store script + default config per owner. Workflow steps reference
them with profile:, overlay keys win, and the UI marks overrides. Profile
name is immutable after create so YAML refs stay stable.

Co-authored-by: Nasyarobby Putra <nasyarobby@gmail.com>
This commit is contained in:
Cursor Agent
2026-08-21 06:12:49 +00:00
co-authored by nsrb
parent 210fdb2244
commit e84432a492
25 changed files with 1512 additions and 123 deletions
+82
View File
@@ -0,0 +1,82 @@
import * as fsStore from "../../fs-store.js";
import {
assertProfileName,
deleteProfile,
getProfileById,
getProfilePlain,
listProfileUsages,
listProfiles,
upsertProfile,
} from "../../profiles-store.js";
/**
* @param {import("fastify").FastifyInstance} fastify
*/
export default async function profilesPlugin(fastify) {
fastify.get("/profiles", async (req, reply) => {
const q = /** @type {{ owner?: string }} */ (req.query ?? {});
try {
const owner = q.owner ? fsStore.assertOwner(q.owner) : undefined;
const profiles = await listProfiles({ owner });
const withUsage = profiles.map((profile) => ({
...profile,
usageCount: listProfileUsages(profile.owner, profile.name).length,
}));
return { profiles: withUsage };
} catch (err) {
return reply.code(err.statusCode ?? 500).send({ error: err.message });
}
});
fastify.get("/profiles/:id/usage", async (req, reply) => {
const { id } = /** @type {{ id: string }} */ (req.params);
const existing = await getProfileById(id);
if (!existing) {
return reply.code(404).send({ error: "profile not found" });
}
return { usages: listProfileUsages(existing.owner, existing.name) };
});
fastify.put("/profiles", async (req, reply) => {
const body = /** @type {{
owner?: string,
name?: string,
script?: unknown,
config?: unknown,
description?: unknown,
}} */ (req.body ?? {});
try {
fsStore.assertOwner(String(body.owner ?? ""));
assertProfileName(String(body.name ?? ""));
const profile = await upsertProfile({
owner: String(body.owner),
name: String(body.name),
script: body.script,
config: body.config,
description: body.description,
});
return reply.send({ profile });
} catch (err) {
return reply.code(err.statusCode ?? 400).send({ error: err.message });
}
});
fastify.delete("/profiles/:id", async (req, reply) => {
const { id } = /** @type {{ id: string }} */ (req.params);
const q = /** @type {{ force?: string }} */ (req.query ?? {});
const existing = await getProfileById(id);
if (!existing) {
return reply.code(404).send({ error: "profile not found" });
}
const usages = listProfileUsages(existing.owner, existing.name);
const force = q.force === "1" || q.force === "true";
if (usages.length > 0 && !force) {
return reply.code(409).send({
error: "profile is used by workflows",
usages,
});
}
await deleteProfile(id);
return { ok: true, forced: force && usages.length > 0, usages };
});
}
+63 -3
View File
@@ -26,6 +26,8 @@ import {
collectWorkflowWarnings,
parseWorkflowDocument,
} from "../../workflow-validate-warnings.js";
import { getProfilePlain } from "../../profiles-store.js";
import { resolveScriptRef } from "../../plugin-store.js";
import {
recordRevision,
listRevisions,
@@ -80,7 +82,9 @@ function scriptNames(workflow) {
for (const raw of workflow.scripts ?? []) {
try {
const parsed = parseScriptStep(raw);
names.push(parsed.kind === "set" ? "set" : parsed.script);
if (parsed.kind === "set") names.push("set");
else if (parsed.profile) names.push(`profile:${parsed.profile}`);
else names.push(parsed.script);
} catch {
names.push(null);
}
@@ -88,6 +92,59 @@ function scriptNames(workflow) {
return names;
}
/**
* @param {unknown} parsed
* @param {string} owner
*/
async function collectProfileWarnings(parsed, owner) {
/** @type {Array<{ code: string, message: string, path?: string }>} */
const warnings = [];
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed) || !owner) {
return warnings;
}
for (const [i, raw] of (parsed.scripts ?? []).entries()) {
if (raw == null || typeof raw !== "object" || Array.isArray(raw)) continue;
const profileName = raw.profile;
if (typeof profileName !== "string" || !profileName) continue;
const pathKey = `scripts[${i}]`;
let profile;
try {
profile = await getProfilePlain(owner, profileName);
} catch {
warnings.push({
code: "unknown_profile",
message: `Profile "${profileName}" is not a valid name`,
path: pathKey,
});
continue;
}
if (!profile) {
warnings.push({
code: "unknown_profile",
message: `Profile "${profileName}" not found`,
path: pathKey,
});
continue;
}
if (typeof raw.script === "string" && raw.script && raw.script !== profile.script) {
warnings.push({
code: "profile_script_mismatch",
message: `Step script "${raw.script}" does not match profile "${profileName}" (${profile.script})`,
path: pathKey,
});
}
const resolved = resolveScriptRef(profile.script);
if (resolved.error) {
warnings.push({
code: "unknown_script",
message: resolved.error,
path: `${pathKey}.profile`,
});
}
}
return warnings;
}
/**
* @param {unknown} parsed
*/
@@ -109,8 +166,11 @@ async function validateStrictWorkflow(parsed) {
* }} opts
*/
async function saveWorkflowContent(opts) {
const { warnings, parsed, parseError } = collectWorkflowWarnings(opts.content);
const saveAnyway = Boolean(opts.saveAnyway);
const { warnings, parsed, parseError } = collectWorkflowWarnings(opts.content);
if (parsed) {
warnings.push(...(await collectProfileWarnings(parsed, opts.owner)));
}
const saveAnyway = Boolean(opts.saveAnyway);
if (!saveAnyway) {
if (parseError) {