Hermes 284313f908
Some checks are pending
Bidi Control Character Guard / bidi-control-guard (push) Waiting to run
Circular Dependency Check / Check for new circular dependencies (push) Waiting to run
Citus Migration Smoke / Combined migrations on single-node Citus (push) Waiting to run
E2E Fresh Install Tests / fresh-install-e2e (push) Waiting to run
ext-v2 guardrails / Run ext-v2 guard and ESLint (push) Waiting to run
Integration Tests / Check for relevant changes (push) Waiting to run
Integration Tests / ${{ (github.event_name == 'schedule' || github.event.inputs.suite == 'full') && 'Full integration suite' || 'Tier-1 integration subset' }} (push) Blocked by required conditions
Mobile checks / Mobile lint + typecheck (push) Waiting to run
Mobile checks / Mobile unit tests (push) Waiting to run
Mobile checks / Mobile dependency audit (report) (push) Waiting to run
Mobile checks / Mobile reproducibility checks (push) Waiting to run
Secrets guard (env backups) / Ensure no tracked env backup files (push) Waiting to run
Temporal Readiness / fast-readiness (push) Waiting to run
Temporal Readiness / docker-parity (push) Waiting to run
TypeScript Type Check / Nx affected typecheck (push) Waiting to run
Unit Tests / Skipped-test budget (push) Waiting to run
Unit Tests / Nx affected unit tests (push) Waiting to run
Unit Tests / Server unit coverage (informational) (push) Waiting to run
Validate Tenant Management Schema / Check for relevant changes (push) Waiting to run
Validate Tenant Management Schema / Validate Tenant Management Schema (push) Blocked by required conditions
EE Workflows Build Guard / ee-workflows-build-guard (push) Waiting to run
Initial import of AlgaPSA codebase from PSA server
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz

Source: /opt/alga-psa on psa.joliet.tech
2026-06-22 16:12:17 -05:00

325 lines
11 KiB
TypeScript

import { z } from 'zod';
import WorkflowEntityLinkModel, { type WorkflowEntityRef } from '../../../persistence/workflowEntityLinkModel';
import { withWorkflowJsonSchemaMetadata, type WorkflowJsonSchemaMetadata } from '../../jsonSchemaMetadata';
import { getActionRegistryV2 } from '../../registries/actionRegistry';
import {
actionProvidedKey,
requirePermission,
throwActionError,
withTenantTransaction,
writeRunAudit,
} from './shared';
const MAX_LABEL_LENGTH = 256;
const workflowPermission = {
read: { resource: 'workflow', action: 'read' },
manage: { resource: 'workflow', action: 'manage' },
} as const;
const softEnumText = (
description: string,
hint: string,
softEnum: NonNullable<NonNullable<WorkflowJsonSchemaMetadata['x-workflow-editor']>['softEnum']>
): z.ZodString =>
withWorkflowJsonSchemaMetadata(z.string().trim().min(1).max(MAX_LABEL_LENGTH), description, {
'x-workflow-editor': {
kind: 'custom',
inline: { mode: 'input' },
allowsDynamicReference: true,
fixedValueHint: hint,
softEnum,
},
});
const namespaceSchema = softEnumText('Collection that groups related links together, like a folder (e.g. project-task-mirror).', 'Collection', {
component: 'soft-enum-combobox',
suggestionKind: 'workflow-data-store-namespace',
suggestionActionIds: ['links.list_namespaces', 'store.list_namespaces'],
allowCustomValue: true,
});
const entityTypeSchema = softEnumText('What kind of record this is (e.g. project_task, ticket, contact).', 'Record type', {
component: 'soft-enum-combobox',
suggestionKind: 'workflow-entity-type',
namespaceField: 'namespace',
allowCustomValue: true,
});
const relationSchema = softEnumText('How the two records are related (e.g. mirrors, maps_to).', 'Relationship', {
component: 'soft-enum-combobox',
suggestionKind: 'workflow-link-relation',
namespaceField: 'namespace',
allowCustomValue: true,
});
const entityIdSchema = withWorkflowJsonSchemaMetadata(z.string().trim().min(1).max(MAX_LABEL_LENGTH), 'The record id — usually mapped from an earlier step or the trigger payload.', {
'x-workflow-editor': {
kind: 'text',
inline: { mode: 'input' },
allowsDynamicReference: true,
fixedValueHint: 'Record id',
},
});
const idempotencyKeySchema = z.string().trim().min(1).max(MAX_LABEL_LENGTH)
.describe('(Advanced) Optional. Prevents duplicate writes if the step retries; leave blank and the workflow fills it in automatically.')
.optional();
const cursorSchema = z.union([z.number().int().nonnegative(), z.string().trim().min(1)]).optional();
const entityRefSchema = z.object({
type: entityTypeSchema,
id: entityIdSchema,
});
const fromRefSchema = entityRefSchema.describe('The record this link starts from.');
const toRefSchema = entityRefSchema.describe('The record this link points to.');
const linkItemOutputSchema = z.object({
link_id: z.string().uuid(),
namespace: z.string(),
from: z.object({ type: z.string(), id: z.string() }),
to: z.object({ type: z.string(), id: z.string() }),
relation: z.string(),
attributes: z.record(z.unknown()),
created_at: z.string().datetime(),
updated_at: z.string().datetime(),
});
const upsertInputSchema = z.object({
namespace: namespaceSchema,
from: fromRefSchema,
to: toRefSchema,
relation: relationSchema.default('related'),
attributes: z.record(z.unknown()).default({}).describe('Optional extra details to store on the link (advanced).'),
idempotency_key: idempotencyKeySchema,
});
const upsertOutputSchema = z.object({
link_id: z.string().uuid(),
created: z.boolean(),
});
const lookupInputSchema = z.object({
namespace: namespaceSchema,
from: fromRefSchema,
direction: z.enum(['forward', 'reverse', 'either']).default('forward')
.describe('Which way to follow the link: forward (from → to), reverse (to → from), or either.'),
relation: relationSchema.optional(),
to_type: entityTypeSchema.optional(),
limit: z.number().int().positive().max(200).default(100),
});
const lookupOutputSchema = z.object({
matches: z.array(z.object({
link_id: z.string().uuid(),
type: z.string(),
id: z.string(),
relation: z.string(),
attributes: z.record(z.unknown()),
})),
});
const deleteInputSchema = z.object({
namespace: namespaceSchema,
from: fromRefSchema.optional(),
to: toRefSchema.optional(),
relation: relationSchema.optional(),
idempotency_key: idempotencyKeySchema,
}).refine((input) => Boolean(input.from || input.to), {
message: 'from or to is required',
path: ['from'],
});
const deleteOutputSchema = z.object({
deleted_count: z.number(),
});
const listInputSchema = z.object({
namespace: namespaceSchema,
left_type: entityTypeSchema.optional(),
right_type: entityTypeSchema.optional(),
relation: relationSchema.optional(),
limit: z.number().int().positive().max(200).default(100),
cursor: cursorSchema,
});
const listOutputSchema = z.object({
items: z.array(linkItemOutputSchema),
next_cursor: z.number().nullable(),
});
const listNamespacesInputSchema = z.object({});
const listNamespacesOutputSchema = z.object({
namespaces: z.array(z.object({
namespace: z.string(),
link_count: z.number(),
})),
});
const toLinkItem = (item: Awaited<ReturnType<typeof WorkflowEntityLinkModel.list>>['items'][number]) => ({
link_id: item.link_id,
namespace: item.namespace,
from: { type: item.left_type, id: item.left_id },
to: { type: item.right_type, id: item.right_id },
relation: item.relation,
attributes: item.attributes ?? {},
created_at: new Date(item.created_at).toISOString(),
updated_at: new Date(item.updated_at).toISOString(),
});
const toEntityRef = (value: z.infer<typeof entityRefSchema>): WorkflowEntityRef => ({
type: String(value.type),
id: String(value.id),
});
export function registerEntityLinkActions(): void {
const registry = getActionRegistryV2();
registry.register({
id: 'links.upsert',
version: 1,
inputSchema: upsertInputSchema,
outputSchema: upsertOutputSchema,
sideEffectful: true,
idempotency: { mode: 'actionProvided', key: actionProvidedKey },
ui: {
label: 'Upsert Entity Link',
category: 'Data Store',
description: 'Create or update a persisted link between two workflow entities.',
},
handler: async (input, ctx) => withTenantTransaction(ctx, async (tx) => {
await requirePermission(ctx, tx, workflowPermission.manage);
const result = await WorkflowEntityLinkModel.upsert(tx.trx, tx.tenantId, {
namespace: input.namespace,
left: toEntityRef(input.from),
right: toEntityRef(input.to),
relation: input.relation,
attributes: input.attributes,
created_by_run_id: ctx.runId,
});
await writeRunAudit(ctx, tx, {
operation: 'links.upsert',
changedData: {
namespace: input.namespace,
from: toEntityRef(input.from),
to: toEntityRef(input.to),
relation: input.relation,
link_id: result.record.link_id,
},
details: { action_id: 'links.upsert', action_version: 1, namespace: input.namespace, link_id: result.record.link_id },
});
return { link_id: result.record.link_id, created: result.created };
}),
});
registry.register({
id: 'links.lookup',
version: 1,
inputSchema: lookupInputSchema,
outputSchema: lookupOutputSchema,
sideEffectful: false,
idempotency: { mode: 'engineProvided' },
ui: {
label: 'Lookup Entity Links',
category: 'Data Store',
description: 'Find persisted entity links by source entity and direction.',
},
handler: async (input, ctx) => withTenantTransaction(ctx, async (tx) => {
await requirePermission(ctx, tx, workflowPermission.read);
return WorkflowEntityLinkModel.lookup(tx.trx, tx.tenantId, {
namespace: input.namespace,
from: toEntityRef(input.from),
direction: input.direction,
relation: input.relation,
right_type: input.to_type,
limit: input.limit,
});
}),
});
registry.register({
id: 'links.delete',
version: 1,
inputSchema: deleteInputSchema,
outputSchema: deleteOutputSchema,
sideEffectful: true,
idempotency: { mode: 'actionProvided', key: actionProvidedKey },
ui: {
label: 'Delete Entity Links',
category: 'Data Store',
description: 'Delete persisted entity links by side and optional relation.',
},
handler: async (input, ctx) => withTenantTransaction(ctx, async (tx) => {
await requirePermission(ctx, tx, workflowPermission.manage);
let deletedCount = 0;
try {
deletedCount = await WorkflowEntityLinkModel.delete(tx.trx, tx.tenantId, {
namespace: input.namespace,
left: input.from ? toEntityRef(input.from) : undefined,
right: input.to ? toEntityRef(input.to) : undefined,
relation: input.relation,
});
} catch (error) {
if (error instanceof Error && error.message === 'WORKFLOW_ENTITY_LINK_DELETE_REQUIRES_LEFT_OR_RIGHT') {
throwActionError(ctx, {
category: 'ValidationError',
code: 'VALIDATION_ERROR',
message: 'links.delete requires a from or to record',
});
}
throw error;
}
await writeRunAudit(ctx, tx, {
operation: 'links.delete',
changedData: { namespace: input.namespace, from: input.from ?? null, to: input.to ?? null, relation: input.relation ?? null, deleted_count: deletedCount },
details: { action_id: 'links.delete', action_version: 1, namespace: input.namespace },
});
return { deleted_count: deletedCount };
}),
});
registry.register({
id: 'links.list',
version: 1,
inputSchema: listInputSchema,
outputSchema: listOutputSchema,
sideEffectful: false,
idempotency: { mode: 'engineProvided' },
ui: {
label: 'List Entity Links',
category: 'Data Store',
description: 'List persisted entity links in a namespace.',
},
handler: async (input, ctx) => withTenantTransaction(ctx, async (tx) => {
await requirePermission(ctx, tx, workflowPermission.read);
const result = await WorkflowEntityLinkModel.list(tx.trx, tx.tenantId, input.namespace, {
left_type: input.left_type,
right_type: input.right_type,
relation: input.relation,
limit: input.limit,
cursor: input.cursor,
});
return {
items: result.items.map(toLinkItem),
next_cursor: result.next_cursor,
};
}),
});
registry.register({
id: 'links.list_namespaces',
version: 1,
inputSchema: listNamespacesInputSchema,
outputSchema: listNamespacesOutputSchema,
sideEffectful: false,
idempotency: { mode: 'engineProvided' },
ui: {
label: 'List Link Namespaces',
category: 'Data Store',
description: 'List workflow entity-link namespaces used by this tenant.',
},
handler: async (_input, ctx) => withTenantTransaction(ctx, async (tx) => {
await requirePermission(ctx, tx, workflowPermission.read);
const namespaces = await WorkflowEntityLinkModel.listNamespaces(tx.trx, tx.tenantId);
return { namespaces };
}),
});
}