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:
@@ -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 };
|
||||
});
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user